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'![{alt}]({new_src})' 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() 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, date: str, thumbnail: str): """生成独立的静态 HTML 文件""" rendered = render_template( "post_template.html", title=title, content=html_body, date=date, thumbnail=thumbnail, ) 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) # 为每篇文章读取生成的静态 HTML 文件内容 for post in posts: post_id = post.get("id", "") generated_path = config.GENERATED_FOLDER / f"{post_id}.html" if generated_path.exists(): with open(generated_path, "r", encoding="utf-8") as f: html_content = f.read() soup = BeautifulSoup(html_content, "html.parser") card_summary = soup.find("div", class_="card-summary") if card_summary: post["content"] = card_summary.decode_contents() else: post["content"] = "" else: post["content"] = "" 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) # 生成日期 date_iso = datetime.now(timezone.utc).isoformat() # 生成静态页面 generate_static_page(post_id, title, html_body, date_iso, thumbnail) # 更新索引 index = load_index() index.append( { "id": post_id, "title": title, "date": date_iso, "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/") 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/", 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)