|
@@ -0,0 +1,275 @@
|
|
|
|
|
+import os
|
|
|
|
|
+import re
|
|
|
|
|
+import json
|
|
|
|
|
+import time
|
|
|
|
|
+import uuid
|
|
|
|
|
+import shutil
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+from flask import (
|
|
|
|
|
+ Flask,
|
|
|
|
|
+ render_template,
|
|
|
|
|
+ request,
|
|
|
|
|
+ redirect,
|
|
|
|
|
+ url_for,
|
|
|
|
|
+ send_from_directory,
|
|
|
|
|
+ abort,
|
|
|
|
|
+ jsonify,
|
|
|
|
|
+)
|
|
|
|
|
+from werkzeug.utils import secure_filename
|
|
|
|
|
+import markdown
|
|
|
|
|
+from bs4 import BeautifulSoup
|
|
|
|
|
+
|
|
|
|
|
+import config
|
|
|
|
|
+
|
|
|
|
|
+app = Flask(__name__)
|
|
|
|
|
+app.config.from_object(config)
|
|
|
|
|
+
|
|
|
|
|
+# 确保必要的目录存在
|
|
|
|
|
+for dir_path in [
|
|
|
|
|
+ config.UPLOAD_FOLDER,
|
|
|
|
|
+ config.GENERATED_FOLDER,
|
|
|
|
|
+ config.POSTS_DATA_FOLDER,
|
|
|
|
|
+ config.INDEX_FILE.parent,
|
|
|
|
|
+]:
|
|
|
|
|
+ dir_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+# 如果索引文件不存在,创建空列表
|
|
|
|
|
+if not config.INDEX_FILE.exists():
|
|
|
|
|
+ with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
|
|
|
|
|
+ json.dump([], f, ensure_ascii=False, indent=2)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def allowed_file(filename: str) -> bool:
|
|
|
|
|
+ """检查文件扩展名是否允许"""
|
|
|
|
|
+ return (
|
|
|
|
|
+ "." in filename
|
|
|
|
|
+ and filename.rsplit(".", 1)[1].lower() in config.ALLOWED_EXTENSIONS
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def load_index() -> list:
|
|
|
|
|
+ """加载文章索引"""
|
|
|
|
|
+ with open(config.INDEX_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
+ return json.load(f)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def save_index(index: list):
|
|
|
|
|
+ """保存文章索引"""
|
|
|
|
|
+ with open(config.INDEX_FILE, "w", encoding="utf-8") as f:
|
|
|
|
|
+ json.dump(index, f, ensure_ascii=False, indent=2)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fix_image_paths(md_content: str, post_id: str) -> str:
|
|
|
|
|
+ """将 Markdown 中的本地图片路径替换为可访问的 URL"""
|
|
|
|
|
+ pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
|
|
|
|
|
+
|
|
|
|
|
+ def repl(match):
|
|
|
|
|
+ alt, src = match.groups()
|
|
|
|
|
+ # 仅当 src 不含 '/' 或 'http' 且扩展名为图片时视为本地文件
|
|
|
|
|
+ if (
|
|
|
|
|
+ not src.startswith(("http://", "https://", "/", "#"))
|
|
|
|
|
+ and "." in src
|
|
|
|
|
+ ):
|
|
|
|
|
+ new_src = f"/static/uploads/posts/{post_id}/{src}"
|
|
|
|
|
+ return f''
|
|
|
|
|
+ return match.group(0)
|
|
|
|
|
+
|
|
|
|
|
+ return re.sub(pattern, repl, md_content)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def extract_summary(html_body: str, max_chars: int = 200) -> str:
|
|
|
|
|
+ """从 HTML 中提取纯文本摘要"""
|
|
|
|
|
+ soup = BeautifulSoup(html_body, "html.parser")
|
|
|
|
|
+ text = soup.get_text()
|
|
|
|
|
+ # 去除多余空白
|
|
|
|
|
+ text = re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
+ if len(text) > max_chars:
|
|
|
|
|
+ return text[:max_chars] + "..."
|
|
|
|
|
+ return text
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def extract_thumbnail(html_body: str) -> str:
|
|
|
|
|
+ """从 HTML 中提取第一张图片的 URL"""
|
|
|
|
|
+ soup = BeautifulSoup(html_body, "html.parser")
|
|
|
|
|
+ img = soup.find("img")
|
|
|
|
|
+ if img and img.get("src"):
|
|
|
|
|
+ return img["src"]
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def render_markdown(md_content: str) -> str:
|
|
|
|
|
+ """将 Markdown 渲染为 HTML"""
|
|
|
|
|
+ md = markdown.Markdown(
|
|
|
|
|
+ extensions=[
|
|
|
|
|
+ "extra",
|
|
|
|
|
+ "codehilite",
|
|
|
|
|
+ "tables",
|
|
|
|
|
+ "fenced_code",
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ return md.convert(md_content)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def generate_static_page(post_id: str, title: str, html_body: str):
|
|
|
|
|
+ """生成独立的静态 HTML 文件"""
|
|
|
|
|
+ rendered = render_template(
|
|
|
|
|
+ "post_template.html",
|
|
|
|
|
+ title=title,
|
|
|
|
|
+ content=html_body,
|
|
|
|
|
+ )
|
|
|
|
|
+ output_path = config.GENERATED_FOLDER / f"{post_id}.html"
|
|
|
|
|
+ with open(output_path, "w", encoding="utf-8") as f:
|
|
|
|
|
+ f.write(rendered)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ==================== 路由 ====================
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.route("/")
|
|
|
|
|
+def index():
|
|
|
|
|
+ """首页:瀑布流文章列表"""
|
|
|
|
|
+ posts = load_index()
|
|
|
|
|
+ # 按日期倒序排列
|
|
|
|
|
+ posts.sort(key=lambda p: p.get("date", ""), reverse=True)
|
|
|
|
|
+ return render_template("index.html", posts=posts)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.route("/upload", methods=["GET", "POST"])
|
|
|
|
|
+def upload():
|
|
|
|
|
+ """上传文章"""
|
|
|
|
|
+ if request.method == "GET":
|
|
|
|
|
+ return render_template("upload.html")
|
|
|
|
|
+
|
|
|
|
|
+ # POST 处理
|
|
|
|
|
+ title = request.form.get("title", "").strip()
|
|
|
|
|
+ if not title:
|
|
|
|
|
+ return "标题不能为空", 400
|
|
|
|
|
+
|
|
|
|
|
+ markdown_file = request.files.get("markdown_file")
|
|
|
|
|
+ if not markdown_file or markdown_file.filename == "":
|
|
|
|
|
+ return "请选择 Markdown 文件", 400
|
|
|
|
|
+
|
|
|
|
|
+ if not allowed_file(markdown_file.filename):
|
|
|
|
|
+ return "不支持的文件类型,请上传 .md 文件", 400
|
|
|
|
|
+
|
|
|
|
|
+ # 生成唯一 ID
|
|
|
|
|
+ post_id = str(int(time.time() * 1000))
|
|
|
|
|
+
|
|
|
|
|
+ # 创建目录
|
|
|
|
|
+ upload_dir = config.UPLOAD_FOLDER / post_id
|
|
|
|
|
+ upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+ posts_data_dir = config.POSTS_DATA_FOLDER / post_id
|
|
|
|
|
+ posts_data_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+
|
|
|
|
|
+ # 保存 Markdown 文件
|
|
|
|
|
+ md_path = posts_data_dir / "content.md"
|
|
|
|
|
+ markdown_file.save(str(md_path))
|
|
|
|
|
+
|
|
|
|
|
+ # 处理图片上传
|
|
|
|
|
+ images = request.files.getlist("images")
|
|
|
|
|
+ for img in images:
|
|
|
|
|
+ if img and img.filename:
|
|
|
|
|
+ filename = secure_filename(img.filename)
|
|
|
|
|
+ if filename:
|
|
|
|
|
+ img.save(str(upload_dir / filename))
|
|
|
|
|
+
|
|
|
|
|
+ # 读取 Markdown 内容
|
|
|
|
|
+ with open(md_path, "r", encoding="utf-8") as f:
|
|
|
|
|
+ md_content = f.read()
|
|
|
|
|
+
|
|
|
|
|
+ # 修正图片路径
|
|
|
|
|
+ fixed_md = fix_image_paths(md_content, post_id)
|
|
|
|
|
+
|
|
|
|
|
+ # 渲染 HTML
|
|
|
|
|
+ html_body = render_markdown(fixed_md)
|
|
|
|
|
+
|
|
|
|
|
+ # 提取摘要
|
|
|
|
|
+ summary = extract_summary(html_body)
|
|
|
|
|
+
|
|
|
|
|
+ # 提取缩略图
|
|
|
|
|
+ thumbnail = extract_thumbnail(html_body)
|
|
|
|
|
+
|
|
|
|
|
+ # 生成静态页面
|
|
|
|
|
+ generate_static_page(post_id, title, html_body)
|
|
|
|
|
+
|
|
|
|
|
+ # 更新索引
|
|
|
|
|
+ index = load_index()
|
|
|
|
|
+ index.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": post_id,
|
|
|
|
|
+ "title": title,
|
|
|
|
|
+ "date": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
+ "summary": summary,
|
|
|
|
|
+ "thumbnail": thumbnail,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ # 按日期倒序排序
|
|
|
|
|
+ index.sort(key=lambda p: p.get("date", ""), reverse=True)
|
|
|
|
|
+ save_index(index)
|
|
|
|
|
+
|
|
|
|
|
+ return redirect(url_for("index"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.route("/post/<post_id>")
|
|
|
|
|
+def view_post(post_id: str):
|
|
|
|
|
+ """查看文章详情"""
|
|
|
|
|
+ # 安全验证:只允许字母数字和下划线
|
|
|
|
|
+ if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
|
|
|
|
|
+ abort(404)
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ return send_from_directory(
|
|
|
|
|
+ config.GENERATED_FOLDER,
|
|
|
|
|
+ f"{post_id}.html",
|
|
|
|
|
+ )
|
|
|
|
|
+ except FileNotFoundError:
|
|
|
|
|
+ abort(404)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.route("/admin")
|
|
|
|
|
+def admin():
|
|
|
|
|
+ """管理页面"""
|
|
|
|
|
+ posts = load_index()
|
|
|
|
|
+ return render_template("admin.html", posts=posts)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.route("/admin/delete/<post_id>", methods=["POST"])
|
|
|
|
|
+def delete_post(post_id: str):
|
|
|
|
|
+ """删除文章"""
|
|
|
|
|
+ # 安全验证
|
|
|
|
|
+ if not re.match(r"^[a-zA-Z0-9_]+$", post_id):
|
|
|
|
|
+ abort(404)
|
|
|
|
|
+
|
|
|
|
|
+ # 删除生成的静态文件
|
|
|
|
|
+ generated_file = config.GENERATED_FOLDER / f"{post_id}.html"
|
|
|
|
|
+ if generated_file.exists():
|
|
|
|
|
+ generated_file.unlink()
|
|
|
|
|
+
|
|
|
|
|
+ # 删除 posts_data 目录
|
|
|
|
|
+ posts_data_dir = config.POSTS_DATA_FOLDER / post_id
|
|
|
|
|
+ if posts_data_dir.exists():
|
|
|
|
|
+ shutil.rmtree(posts_data_dir)
|
|
|
|
|
+
|
|
|
|
|
+ # 删除上传的图片目录
|
|
|
|
|
+ upload_dir = config.UPLOAD_FOLDER / post_id
|
|
|
|
|
+ if upload_dir.exists():
|
|
|
|
|
+ shutil.rmtree(upload_dir)
|
|
|
|
|
+
|
|
|
|
|
+ # 从索引中移除
|
|
|
|
|
+ index = load_index()
|
|
|
|
|
+ index = [p for p in index if p["id"] != post_id]
|
|
|
|
|
+ save_index(index)
|
|
|
|
|
+
|
|
|
|
|
+ return redirect(url_for("admin"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.errorhandler(404)
|
|
|
|
|
+def not_found(e):
|
|
|
|
|
+ return render_template("404.html"), 404
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ app.run(host="0.0.0.0", port=5000, debug=True)
|