app.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  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, date: str, thumbnail: str):
  91. """生成独立的静态 HTML 文件"""
  92. rendered = render_template(
  93. "post_template.html",
  94. title=title,
  95. content=html_body,
  96. date=date,
  97. thumbnail=thumbnail,
  98. )
  99. output_path = config.GENERATED_FOLDER / f"{post_id}.html"
  100. with open(output_path, "w", encoding="utf-8") as f:
  101. f.write(rendered)
  102. # ==================== 路由 ====================
  103. @app.route("/")
  104. def index():
  105. """首页:瀑布流文章列表(展示完整博文)"""
  106. posts = load_index()
  107. # 按日期倒序排列
  108. posts.sort(key=lambda p: p.get("date", ""), reverse=True)
  109. # 为每篇文章获取内容
  110. for post in posts:
  111. post_id = post.get("id", "")
  112. content = ""
  113. # 优先从原始 Markdown 文件渲染内容(即使 generated 文件不存在也能显示)
  114. md_path = config.POSTS_DATA_FOLDER / post_id / "content.md"
  115. if md_path.exists():
  116. try:
  117. with open(md_path, "r", encoding="utf-8") as f:
  118. md_content = f.read()
  119. fixed_md = fix_image_paths(md_content, post_id)
  120. html_body = render_markdown(fixed_md)
  121. content = html_body
  122. except Exception:
  123. content = ""
  124. else:
  125. # 回退到生成的静态 HTML 文件
  126. generated_path = config.GENERATED_FOLDER / f"{post_id}.html"
  127. if generated_path.exists():
  128. try:
  129. with open(generated_path, "r", encoding="utf-8") as f:
  130. html_content = f.read()
  131. soup = BeautifulSoup(html_content, "html.parser")
  132. card_summary = soup.find("div", class_="card-summary")
  133. if card_summary:
  134. content = card_summary.decode_contents()
  135. except Exception:
  136. content = ""
  137. post["content"] = content
  138. return render_template("index.html", posts=posts)
  139. @app.route("/upload", methods=["GET", "POST"])
  140. def upload():
  141. """上传文章"""
  142. if request.method == "GET":
  143. return render_template("upload.html")
  144. # POST 处理
  145. title = request.form.get("title", "").strip()
  146. if not title:
  147. return "标题不能为空", 400
  148. markdown_file = request.files.get("markdown_file")
  149. if not markdown_file or markdown_file.filename == "":
  150. return "请选择 Markdown 文件", 400
  151. if not allowed_file(markdown_file.filename):
  152. return "不支持的文件类型,请上传 .md 文件", 400
  153. # 生成唯一 ID
  154. post_id = str(int(time.time() * 1000))
  155. # 创建目录
  156. upload_dir = config.UPLOAD_FOLDER / post_id
  157. upload_dir.mkdir(parents=True, exist_ok=True)
  158. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  159. posts_data_dir.mkdir(parents=True, exist_ok=True)
  160. # 保存 Markdown 文件
  161. md_path = posts_data_dir / "content.md"
  162. markdown_file.save(str(md_path))
  163. # 处理图片上传
  164. images = request.files.getlist("images")
  165. for img in images:
  166. if img and img.filename:
  167. filename = secure_filename(img.filename)
  168. if filename:
  169. img.save(str(upload_dir / filename))
  170. # 读取 Markdown 内容
  171. with open(md_path, "r", encoding="utf-8") as f:
  172. md_content = f.read()
  173. # 修正图片路径
  174. fixed_md = fix_image_paths(md_content, post_id)
  175. # 渲染 HTML
  176. html_body = render_markdown(fixed_md)
  177. # 提取摘要
  178. summary = extract_summary(html_body)
  179. # 提取缩略图
  180. thumbnail = extract_thumbnail(html_body)
  181. # 生成日期
  182. date_iso = datetime.now(timezone.utc).isoformat()
  183. # 生成静态页面
  184. generate_static_page(post_id, title, html_body, date_iso, thumbnail)
  185. # 更新索引
  186. index = load_index()
  187. index.append(
  188. {
  189. "id": post_id,
  190. "title": title,
  191. "date": date_iso,
  192. "summary": summary,
  193. "thumbnail": thumbnail,
  194. }
  195. )
  196. # 按日期倒序排序
  197. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  198. save_index(index)
  199. return redirect(url_for("index"))
  200. @app.route("/post/<post_id>")
  201. def view_post(post_id: str):
  202. """查看文章详情"""
  203. # 安全验证:只允许字母数字和下划线
  204. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  205. abort(404)
  206. try:
  207. return send_from_directory(
  208. config.GENERATED_FOLDER,
  209. f"{post_id}.html",
  210. )
  211. except FileNotFoundError:
  212. abort(404)
  213. @app.route("/admin")
  214. def admin():
  215. """管理页面"""
  216. posts = load_index()
  217. return render_template("admin.html", posts=posts)
  218. @app.route("/admin/delete/<post_id>", methods=["POST"])
  219. def delete_post(post_id: str):
  220. """删除文章"""
  221. # 安全验证
  222. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  223. abort(404)
  224. # 删除生成的静态文件
  225. generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
  226. if generated_file.exists():
  227. generated_file.unlink()
  228. # 删除 posts_data 目录
  229. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  230. if posts_data_dir.exists():
  231. shutil.rmtree(posts_data_dir)
  232. # 删除上传的图片目录
  233. upload_dir = config.UPLOAD_FOLDER / post_id
  234. if upload_dir.exists():
  235. shutil.rmtree(upload_dir)
  236. # 从索引中移除
  237. index = load_index()
  238. index = [p for p in index if p["id"] != post_id]
  239. save_index(index)
  240. return redirect(url_for("admin"))
  241. @app.errorhandler(404)
  242. def not_found(e):
  243. return render_template("404.html"), 404
  244. if __name__ == "__main__":
  245. app.run(host="0.0.0.0", port=5000, debug=True)