| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- 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()
- 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)
- # 为每篇文章获取内容
- for post in posts:
- post_id = post.get("id", "")
- content = ""
- # 优先从原始 Markdown 文件渲染内容(即使 generated 文件不存在也能显示)
- md_path = config.POSTS_DATA_FOLDER / post_id / "content.md"
- if md_path.exists():
- try:
- with open(md_path, "r", encoding="utf-8") as f:
- md_content = f.read()
- fixed_md = fix_image_paths(md_content, post_id)
- html_body = render_markdown(fixed_md)
- content = html_body
- except Exception:
- content = ""
- else:
- # 回退到生成的静态 HTML 文件
- generated_path = config.GENERATED_FOLDER / f"{post_id}.html"
- if generated_path.exists():
- try:
- 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:
- content = card_summary.decode_contents()
- except Exception:
- content = ""
- post["content"] = 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/<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)
|