#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 上传/更新《28套私域商业模式》全部28章到小程序 功能: - Markdown → 语义化 HTML(保留 h2/h3/blockquote/ul/ol/li/table/img) - 图片自动上传到 OSS,替换本地路径 - 预处理:去 # 标题行、去 ---、扁平化嵌套列表 - 后处理:去
、合并空行、修孤立 bullet - 写入 DB 后需要通过 admin API 清 Redis 缓存(脚本不含此步,见下方) 用法: python3 scripts/optimize_28_models.py --dry-run # 预览(不上传/不写库) python3 scripts/optimize_28_models.py # 执行上传+写库 清缓存(上传完后手动执行): 通过 admin API PUT /api/db/book 触发 cache.InvalidateChapterContent() 参考本对话上下文中的缓存失效脚本 小程序前端要求: contentParser.js 需支持 heading/quote/listItem/table/image 段落类型 read.wxml/read.wxss 需有对应渲染组件与样式 """ 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: 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"" return MD_IMAGE_RE.sub(_replace, md_text) def preprocess_markdown(md_text: str) -> str: """预处理 Markdown 源文本,解决嵌套列表产生的孤立 bullet 问题""" lines = md_text.splitlines() # 去掉第一行如果是 # 标题 if lines and lines[0].lstrip().startswith("# "): lines = lines[1:] result = [] for line in lines: # 把 "---" 分隔线去掉(标题已经能分隔段落) if re.match(r"^\s*---+\s*$", line): continue result.append(line) # 合并嵌套列表:把 " - xxx" 子项合并为同级 "- xxx" flat = [] for line in result: # 检测 2 空格或 4 空格缩进的子列表项 m = re.match(r"^(\s{2,4})[-*]\s+(.*)", line) if m: flat.append(f"- {m.group(2)}") else: flat.append(line) return "\n".join(flat).strip() def md_to_html(md_text: str) -> str: """Markdown → 干净的 HTML""" cleaned = preprocess_markdown(md_text) html = markdown.markdown(cleaned, extensions=["tables", "fenced_code"]) html = clean_html(html) return html.strip() def clean_html(html: str) -> str: """清理 HTML:去
、合并空标签、修孤立 bullet""" # 去掉所有
content
\n([\s\S]*?)
\s*\s*
", "", html, flags=re.IGNORECASE) # 把内的 "- xxx" 列表项拆出来为
{before}
"] if before else [] parts.append("(.*?)\n((?:\s*-\s+.+\n?)+)
", fix_inline_list, html, flags=re.IGNORECASE, ) # 压缩 block 标签之间的空白:\n\n]*>", "\n\n", text, flags=_re.IGNORECASE) text = _re.sub(r"
]*>", "", text, flags=_re.IGNORECASE) text = _re.sub(r"
", "\n", text, flags=_re.IGNORECASE) text = _re.sub(r"