app.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import os
  2. import re
  3. import json
  4. import time
  5. import uuid
  6. import shutil
  7. from datetime import datetime, timezone
  8. from pathlib import Path
  9. from flask import (
  10. Flask,
  11. render_template,
  12. request,
  13. redirect,
  14. url_for,
  15. send_from_directory,
  16. abort,
  17. jsonify,
  18. )
  19. from werkzeug.utils import secure_filename
  20. import markdown
  21. from bs4 import BeautifulSoup
  22. import config
  23. app = Flask(__name__)
  24. app.config.from_object(config)
  25. # 确保必要的目录存在
  26. for dir_path in [
  27. config.UPLOAD_FOLDER,
  28. config.GENERATED_FOLDER,
  29. config.POSTS_DATA_FOLDER,
  30. config.INDEX_FILE.parent,
  31. ]:
  32. dir_path.mkdir(parents=True, exist_ok=True)
  33. # 如果索引文件不存在,创建空列表
  34. if not config.INDEX_FILE.exists():
  35. with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
  36. json.dump([], f, ensure_ascii=False, indent=2)
  37. def allowed_file(filename: str) -> bool:
  38. """检查文件扩展名是否允许"""
  39. return (
  40. "." in filename
  41. and filename.rsplit(".", 1)[1].lower() in config.ALLOWED_EXTENSIONS
  42. )
  43. def load_index() -> list:
  44. """加载文章索引"""
  45. with open(config.INDEX_FILE, "r", encoding="utf-8") as f:
  46. return json.load(f)
  47. def save_index(index: list):
  48. """保存文章索引"""
  49. with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
  50. json.dump(index, f, ensure_ascii=False, indent=2)
  51. def fix_image_paths(md_content: str, post_id: str) -> str:
  52. """将 Markdown 中的本地图片路径替换为可访问的 URL"""
  53. pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
  54. def repl(match):
  55. alt, src = match.groups()
  56. # 仅当 src 不含 '/' 或 'http' 且扩展名为图片时视为本地文件
  57. if (
  58. not src.startswith(("http://", "https://", "/", "#"))
  59. and "." in src
  60. ):
  61. new_src = f"/static/uploads/posts/{post_id}/{src}"
  62. return f'![{alt}]({new_src})'
  63. return match.group(0)
  64. return re.sub(pattern, repl, md_content)
  65. def extract_summary(html_body: str, max_chars: int = 200) -> str:
  66. """从 HTML 中提取纯文本摘要(返回完整文本)"""
  67. soup = BeautifulSoup(html_body, "html.parser")
  68. text = soup.get_text()
  69. # 去除多余空白
  70. text = re.sub(r"\s+", " ", text).strip()
  71. return text
  72. def extract_thumbnail(html_body: str) -> str:
  73. """从 HTML 中提取第一张图片的 URL"""
  74. soup = BeautifulSoup(html_body, "html.parser")
  75. img = soup.find("img")
  76. if img and img.get("src"):
  77. return img["src"]
  78. return ""
  79. def render_markdown(md_content: str) -> str:
  80. """将 Markdown 渲染为 HTML"""
  81. md = markdown.Markdown(
  82. extensions=[
  83. "extra",
  84. "codehilite",
  85. "tables",
  86. "fenced_code",
  87. ]
  88. )
  89. return md.convert(md_content)
  90. def generate_static_page(post_id: str, title: str, html_body: str):
  91. """生成独立的静态 HTML 文件"""
  92. rendered = render_template(
  93. "post_template.html",
  94. title=title,
  95. content=html_body,
  96. )
  97. output_path = config.GENERATED_FOLDER / f"{post_id}.html"
  98. with open(output_path, "w", encoding="utf-8") as f:
  99. f.write(rendered)
  100. # ==================== 路由 ====================
  101. @app.route("/")
  102. def index():
  103. """首页:瀑布流文章列表(展示完整博文)"""
  104. posts = load_index()
  105. # 按日期倒序排列
  106. posts.sort(key=lambda p: p.get("date", ""), reverse=True)
  107. # 为每篇文章读取生成的静态 HTML 文件内容
  108. for post in posts:
  109. post_id = post.get("id", "")
  110. generated_path = config.GENERATED_FOLDER / f"{post_id}.html"
  111. if generated_path.exists():
  112. with open(generated_path, "r", encoding="utf-8") as f:
  113. # 提取 <article class="post-article"> 内的内容
  114. html_content = f.read()
  115. # 简单提取 body 内的内容(实际项目中可用 BeautifulSoup)
  116. start = html_content.find('<article class="post-article">')
  117. end = html_content.find("</article>")
  118. if start != -1 and end != -1:
  119. post["content"] = html_content[start + len('<article class="post-article">'):end]
  120. else:
  121. post["content"] = ""
  122. else:
  123. post["content"] = ""
  124. return render_template("index.html", posts=posts)
  125. @app.route("/upload", methods=["GET", "POST"])
  126. def upload():
  127. """上传文章"""
  128. if request.method == "GET":
  129. return render_template("upload.html")
  130. # POST 处理
  131. title = request.form.get("title", "").strip()
  132. if not title:
  133. return "标题不能为空", 400
  134. markdown_file = request.files.get("markdown_file")
  135. if not markdown_file or markdown_file.filename == "":
  136. return "请选择 Markdown 文件", 400
  137. if not allowed_file(markdown_file.filename):
  138. return "不支持的文件类型,请上传 .md 文件", 400
  139. # 生成唯一 ID
  140. post_id = str(int(time.time() * 1000))
  141. # 创建目录
  142. upload_dir = config.UPLOAD_FOLDER / post_id
  143. upload_dir.mkdir(parents=True, exist_ok=True)
  144. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  145. posts_data_dir.mkdir(parents=True, exist_ok=True)
  146. # 保存 Markdown 文件
  147. md_path = posts_data_dir / "content.md"
  148. markdown_file.save(str(md_path))
  149. # 处理图片上传
  150. images = request.files.getlist("images")
  151. for img in images:
  152. if img and img.filename:
  153. filename = secure_filename(img.filename)
  154. if filename:
  155. img.save(str(upload_dir / filename))
  156. # 读取 Markdown 内容
  157. with open(md_path, "r", encoding="utf-8") as f:
  158. md_content = f.read()
  159. # 修正图片路径
  160. fixed_md = fix_image_paths(md_content, post_id)
  161. # 渲染 HTML
  162. html_body = render_markdown(fixed_md)
  163. # 提取摘要
  164. summary = extract_summary(html_body)
  165. # 提取缩略图
  166. thumbnail = extract_thumbnail(html_body)
  167. # 生成静态页面
  168. generate_static_page(post_id, title, html_body)
  169. # 更新索引
  170. index = load_index()
  171. index.append(
  172. {
  173. "id": post_id,
  174. "title": title,
  175. "date": datetime.now(timezone.utc).isoformat(),
  176. "summary": summary,
  177. "thumbnail": thumbnail,
  178. }
  179. )
  180. # 按日期倒序排序
  181. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  182. save_index(index)
  183. return redirect(url_for("index"))
  184. @app.route("/post/<post_id>")
  185. def view_post(post_id: str):
  186. """查看文章详情"""
  187. # 安全验证:只允许字母数字和下划线
  188. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  189. abort(404)
  190. try:
  191. return send_from_directory(
  192. config.GENERATED_FOLDER,
  193. f"{post_id}.html",
  194. )
  195. except FileNotFoundError:
  196. abort(404)
  197. @app.route("/admin")
  198. def admin():
  199. """管理页面"""
  200. posts = load_index()
  201. return render_template("admin.html", posts=posts)
  202. @app.route("/admin/delete/<post_id>", methods=["POST"])
  203. def delete_post(post_id: str):
  204. """删除文章"""
  205. # 安全验证
  206. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  207. abort(404)
  208. # 删除生成的静态文件
  209. generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
  210. if generated_file.exists():
  211. generated_file.unlink()
  212. # 删除 posts_data 目录
  213. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  214. if posts_data_dir.exists():
  215. shutil.rmtree(posts_data_dir)
  216. # 删除上传的图片目录
  217. upload_dir = config.UPLOAD_FOLDER / post_id
  218. if upload_dir.exists():
  219. shutil.rmtree(upload_dir)
  220. # 从索引中移除
  221. index = load_index()
  222. index = [p for p in index if p["id"] != post_id]
  223. save_index(index)
  224. return redirect(url_for("admin"))
  225. @app.errorhandler(404)
  226. def not_found(e):
  227. return render_template("404.html"), 404
  228. if __name__ == "__main__":
  229. app.run(host="0.0.0.0", port=5000, debug=True)