#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 重新上传《28套私域商业模式》:Markdown → HTML(含表格/图片/粗体/标题) 1. 遍历 3 篇目录,读取 .md 文件 2. 把 Markdown 转为 HTML(保留表格、图片、引用、列表、粗体等) 3. 上传本地图片到 OSS,替换为线上 URL 4. 更新 chapters 表的 content 字段 用法: python3 scripts/reupload_28_models_html.py --dry-run # 预览 python3 scripts/reupload_28_models_html.py # 执行 """ from __future__ import annotations import importlib.util import os import re import sys from pathlib import Path import markdown import requests ROOT = Path(__file__).resolve().parent.parent BOOK_DIR = Path("/Users/karuo/Documents/个人/2、我写的书/《28套私域商业模式》") API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com") PARTS = [ ("01_起盘篇|10套私域基础模式", "part-28-basic", "chapter-28-basic"), ("02_增长篇|10套私域进阶模式", "part-28-growth", "chapter-28-growth"), ("03_精英篇|8套私域高阶模式", "part-28-elite", "chapter-28-elite"), ] MODEL_NUM_RE = re.compile(r"模式(\d+)") IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)") def load_db(): mig = ROOT / "scripts" / "migrate_2026_sections.py" spec = importlib.util.spec_from_file_location("_mig_db", mig) mod = importlib.util.module_from_spec(spec) assert spec.loader is not None spec.loader.exec_module(mod) return mod.DB_CONFIG def guess_mime(path: Path) -> str: return { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", }.get(path.suffix.lower(), "application/octet-stream") def upload_image(local: Path) -> str: url = f"{API_BASE.rstrip('/')}/api/upload" mime = guess_mime(local) with local.open("rb") as f: r = requests.post(url, files={"file": (local.name, f, mime)}, data={"folder": "book-images"}, timeout=120) r.raise_for_status() j = r.json() if not j.get("success"): raise RuntimeError(j.get("error") or j.get("message") or str(j)) out = j.get("url") or (j.get("data") or {}).get("url") if not out: raise RuntimeError("响应无 url: " + str(j)[:300]) return str(out) def process_images(md_text: str, md_path: Path, upload_cache: dict, dry_run: bool) -> str: """替换 md 中的本地图片引用为 OSS URL""" def _replace(m): alt, ref = m.group(1), m.group(2).strip() if ref.startswith(("http://", "https://")): return m.group(0) local = (md_path.parent / ref).resolve() if not local.is_file() or local.suffix.lower() not in IMAGE_EXT: print(f" ⚠ 图片不存在: {ref}") return m.group(0) key = str(local) if key not in upload_cache: if dry_run: upload_cache[key] = f"https://dry-run/{local.name}" print(f" [dry] 图片: {local.name}") else: print(f" 上传图片: {local.name} …", end=" ", flush=True) try: upload_cache[key] = upload_image(local) print("✓", upload_cache[key]) except Exception as e: print(f"✗ {e}") upload_cache[key] = ref return f"![{alt}]({upload_cache[key]})" return MD_IMAGE_RE.sub(_replace, md_text) def md_to_html(md_text: str) -> str: """Markdown → HTML,保留表格/粗体/标题/列表/引用/分隔线/图片""" lines = md_text.splitlines() if lines and lines[0].lstrip().startswith("# "): lines = lines[1:] cleaned = "\n".join(lines).strip() html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) html = re.sub(r"\n{3,}", "\n\n", html) return html.strip() def extract_model_num(filename: str) -> int: m = MODEL_NUM_RE.search(filename) return int(m.group(1)) if m else 999 def collect_md_files(part_dir: Path) -> list[tuple[int, str, Path]]: """返回 [(model_num, section_title, path)]""" result = [] for f in part_dir.iterdir(): if not f.name.endswith(".md") or f.name.startswith("."): continue num = extract_model_num(f.name) title = f.stem result.append((num, title, f)) result.sort(key=lambda x: x[0]) return result def main(): try: import pymysql except ImportError: sys.exit("需要: pip install pymysql") dry_run = "--dry-run" in sys.argv cfg = load_db() conn = pymysql.connect(**cfg, autocommit=False) cur = conn.cursor() upload_cache: dict[str, str] = {} total_ok = 0 total_err = 0 for dir_name, part_id, chapter_id in PARTS: part_dir = BOOK_DIR / dir_name if not part_dir.exists(): print(f"⚠ 目录不存在: {part_dir}") continue print(f"\n{'='*60}") print(f"{dir_name}") print(f"{'='*60}") files = collect_md_files(part_dir) for model_num, title, path in files: ip_id = f"m28.{model_num:02d}" print(f"\n 📄 {ip_id} | {title}") try: raw = path.read_text(encoding="utf-8") except Exception as e: print(f" ✗ 读取失败: {e}") total_err += 1 continue processed = process_images(raw, path, upload_cache, dry_run) html_content = md_to_html(processed) word_count = len(re.sub(r"<[^>]+>", "", html_content).replace("\n", "")) if dry_run: print(f" [dry] HTML 长度={len(html_content)}, 字数={word_count}") preview = html_content[:200].replace("\n", " ") print(f" [dry] 预览: {preview}...") total_ok += 1 continue try: cur.execute( "UPDATE chapters SET content=%s, word_count=%s, updated_at=NOW(3) WHERE id=%s", (html_content, word_count, ip_id), ) if cur.rowcount == 0: print(f" ⚠ 未找到 id={ip_id},跳过") else: conn.commit() print(f" ✓ 更新成功 (HTML {len(html_content)} 字符, {word_count} 字)") total_ok += 1 except Exception as e: conn.rollback() print(f" ✗ 更新失败: {e}") total_err += 1 conn.close() print(f"\n{'='*60}") print(f"完成:成功 {total_ok} | 失败 {total_err}") if dry_run: print("(dry-run 模式,未实际写库/上传图片)") else: print(f"图片上传缓存: {len(upload_cache)} 张") if __name__ == "__main__": main()