app.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  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. if len(text) > max_chars:
  72. return text[:max_chars] + "..."
  73. return text
  74. def extract_thumbnail(html_body: str) -> str:
  75. """从 HTML 中提取第一张图片的 URL"""
  76. soup = BeautifulSoup(html_body, "html.parser")
  77. img = soup.find("img")
  78. if img and img.get("src"):
  79. return img["src"]
  80. return ""
  81. def render_markdown(md_content: str) -> str:
  82. """将 Markdown 渲染为 HTML"""
  83. md = markdown.Markdown(
  84. extensions=[
  85. "extra",
  86. "codehilite",
  87. "tables",
  88. "fenced_code",
  89. ]
  90. )
  91. return md.convert(md_content)
  92. def generate_static_page(post_id: str, title: str, html_body: str):
  93. """生成独立的静态 HTML 文件"""
  94. rendered = render_template(
  95. "post_template.html",
  96. title=title,
  97. content=html_body,
  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. return render_template("index.html", posts=posts)
  110. @app.route("/upload", methods=["GET", "POST"])
  111. def upload():
  112. """上传文章"""
  113. if request.method == "GET":
  114. return render_template("upload.html")
  115. # POST 处理
  116. title = request.form.get("title", "").strip()
  117. if not title:
  118. return "标题不能为空", 400
  119. markdown_file = request.files.get("markdown_file")
  120. if not markdown_file or markdown_file.filename == "":
  121. return "请选择 Markdown 文件", 400
  122. if not allowed_file(markdown_file.filename):
  123. return "不支持的文件类型,请上传 .md 文件", 400
  124. # 生成唯一 ID
  125. post_id = str(int(time.time() * 1000))
  126. # 创建目录
  127. upload_dir = config.UPLOAD_FOLDER / post_id
  128. upload_dir.mkdir(parents=True, exist_ok=True)
  129. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  130. posts_data_dir.mkdir(parents=True, exist_ok=True)
  131. # 保存 Markdown 文件
  132. md_path = posts_data_dir / "content.md"
  133. markdown_file.save(str(md_path))
  134. # 处理图片上传
  135. images = request.files.getlist("images")
  136. for img in images:
  137. if img and img.filename:
  138. filename = secure_filename(img.filename)
  139. if filename:
  140. img.save(str(upload_dir / filename))
  141. # 读取 Markdown 内容
  142. with open(md_path, "r", encoding="utf-8") as f:
  143. md_content = f.read()
  144. # 修正图片路径
  145. fixed_md = fix_image_paths(md_content, post_id)
  146. # 渲染 HTML
  147. html_body = render_markdown(fixed_md)
  148. # 提取摘要
  149. summary = extract_summary(html_body)
  150. # 提取缩略图
  151. thumbnail = extract_thumbnail(html_body)
  152. # 生成静态页面
  153. generate_static_page(post_id, title, html_body)
  154. # 更新索引
  155. index = load_index()
  156. index.append(
  157. {
  158. "id": post_id,
  159. "title": title,
  160. "date": datetime.now(timezone.utc).isoformat(),
  161. "summary": summary,
  162. "thumbnail": thumbnail,
  163. }
  164. )
  165. # 按日期倒序排序
  166. index.sort(key=lambda p: p.get("date", ""), reverse=True)
  167. save_index(index)
  168. return redirect(url_for("index"))
  169. @app.route("/post/<post_id>")
  170. def view_post(post_id: str):
  171. """查看文章详情"""
  172. # 安全验证:只允许字母数字和下划线
  173. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  174. abort(404)
  175. try:
  176. return send_from_directory(
  177. config.GENERATED_FOLDER,
  178. f"{post_id}.html",
  179. )
  180. except FileNotFoundError:
  181. abort(404)
  182. @app.route("/admin")
  183. def admin():
  184. """管理页面"""
  185. posts = load_index()
  186. return render_template("admin.html", posts=posts)
  187. @app.route("/admin/delete/<post_id>", methods=["POST"])
  188. def delete_post(post_id: str):
  189. """删除文章"""
  190. # 安全验证
  191. if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
  192. abort(404)
  193. # 删除生成的静态文件
  194. generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
  195. if generated_file.exists():
  196. generated_file.unlink()
  197. # 删除 posts_data 目录
  198. posts_data_dir = config.POSTS_DATA_FOLDER / post_id
  199. if posts_data_dir.exists():
  200. shutil.rmtree(posts_data_dir)
  201. # 删除上传的图片目录
  202. upload_dir = config.UPLOAD_FOLDER / post_id
  203. if upload_dir.exists():
  204. shutil.rmtree(upload_dir)
  205. # 从索引中移除
  206. index = load_index()
  207. index = [p for p in index if p["id"] != post_id]
  208. save_index(index)
  209. return redirect(url_for("admin"))
  210. @app.errorhandler(404)
  211. def not_found(e):
  212. return render_template("404.html"), 404
  213. if __name__ == "__main__":
  214. app.run(host="0.0.0.0", port=5000, debug=True)