Files
Mycontent/scripts/reupload_gps_html.py
卡若 b5f5180654 chore: 以本地工作区为准全量快照同步 GitHub
包含小程序、管理端、soul-api、脚本与静态资源等当前本地全部已跟踪与新增文件(排除 .DS_Store 与 .obsidian)。

Made-with: Cursor
2026-04-13 11:49:38 +08:00

280 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
重新上传《游戏明星GPS导航》Markdown → 高质量 HTML表格/列表/粗体/标题/引用/图片)。
与 reupload_28_models_html.py 同样使用 python-markdown 做完整转换。
用法:
python3 scripts/reupload_gps_html.py --dry-run # 预览
python3 scripts/reupload_gps_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、我写的书/《游戏明星GPS导航》")
API_BASE = os.environ.get("SOUL_API_BASE", "https://soulapi.quwanzhi.com")
IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
BOOK_STRUCTURE = [
("part-gps-0", "序、什么是游戏明星", "chapter-gps-0", "什么是游戏明星", [
(1, "敬告", "序_什么是游戏明星/敬告.md"),
(2, "誓言", "序_什么是游戏明星/誓言.md"),
(3, "一、什么是游戏明星", "序_什么是游戏明星/一、什么是游戏明星.md"),
(4, "二、为什么要做游戏明星", "序_什么是游戏明星/二、为什么要做游戏明星.md"),
(5, "三、你好,未来的游戏明星", "序_什么是游戏明星/三、方向不对,努力全费...三、你好,未来的游戏明星.md"),
(6, "四、赚回十倍学费的一个小礼物", "序_什么是游戏明星/四、赚回十倍学费的一个小礼物.md"),
(7, "五、卡若与玩值电竞的故事", "序_什么是游戏明星/五、卡若与玩值电竞的故事.md"),
]),
("part-gps-1", "第一章、打开粉丝宝库的钥匙", "chapter-gps-1", "打开粉丝宝库的钥匙", [
(1, "粉丝是如何判断你有没有吸引力的", "第1章_打开粉丝宝库的钥匙/粉丝是如何判断你有没有吸引力的.md"),
(2, "一、我是如何发现这些粉丝的秘籍的", "第1章_打开粉丝宝库的钥匙/一、我是如何发现这些粉丝的秘籍的(打开粉丝宝库的钥匙).md"),
(3, "二、目标粉丝聚集地(你的粉丝在哪里?)", "第1章_打开粉丝宝库的钥匙/二、目标粉丝聚集地(你的粉丝在哪里?).md"),
(4, "三、价值输出载体(找出你与粉丝交流的桥梁)", "第1章_打开粉丝宝库的钥匙/三、价值输出载体(找出你与粉丝交流的桥梁).md"),
(5, "四、不为人知的具体吸粉秘籍(总论)", "第1章_打开粉丝宝库的钥匙/四,不为人知的具体吸粉秘籍...(手把手带你吸粉).md"),
(6, "4.1、斗鱼直播间粉丝自动流入", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.1,斗鱼直播间粉丝自动流入【吸粉秘籍】.md"),
(7, "4.2、原创游戏视频大脑缺口弥补术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.2,原创游戏视频大脑缺口弥补术【吸粉秘籍】.md"),
(8, "4.3、批量渗透玩家微信群", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.3,批量渗透玩家微信群【吸粉秘籍】.md"),
(9, "4.4、朋友圈粉丝自我说服裂变", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.4,朋友圈粉丝自我说服裂变【吸粉秘籍】.md"),
(10, "4.5、淘宝老客户快速转移进入你的领地", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.5、淘宝老客户快速转移进入你的领地【吸粉秘籍】.md"),
(11, "4.6、人性换粉(威力巨大)", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/4.6,人性换粉(不管你在任何时间,任何场合永远有效,威力巨大).md"),
(12, "QQ空间红包引流术", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/QQ空间红包引流术.md"),
(13, "微博问答降维吸粉", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/10微博问答降维人吸粉.md"),
(14, "一个提醒", "第1章_打开粉丝宝库的钥匙/四,吸粉秘籍/一个提醒.md"),
(15, "五、在这章你学到的东西,和将要得到的东西", "第1章_打开粉丝宝库的钥匙/五、在这章你学到的东西,和将要得到的东西.md"),
]),
("part-gps-2", "第二章、粉丝互动养熟秘籍", "chapter-gps-2", "粉丝互动养熟秘籍", [
(1, "粉丝对你的信任程度,与你的收入成正比", "第2章_粉丝互动养熟秘籍/粉丝对你的信任程度,以你的收入成正比。.md"),
(2, '让粉丝疯狂追捧的\u201c聊天技巧\u201d在哪里?', '第2章_粉丝互动养熟秘籍/让粉丝疯狂追棒的\u201c聊天技巧\u201d在哪里?.md'),
(3, "一、粉丝信任升级逻辑", "第2章_粉丝互动养熟秘籍/一、粉丝信任升级逻辑.md"),
(4, "二、如何在互动中,快速制造粉丝对你的好印象", "第2章_粉丝互动养熟秘籍/二、如何在互动中,快速制造粉丝对你的好印象.md"),
(5, "三、神奇的信任升级导图", "第2章_粉丝互动养熟秘籍/三、神奇的信任升级导图.md"),
(6, "四、3种立即见效的日常互动模型", "第2章_粉丝互动养熟秘籍/四、3种立既见效的日常互动模型.md"),
(7, "五、3个神秘的互动秘诀", "第2章_粉丝互动养熟秘籍/五、3个神秘的互动秘诀.md"),
(8, "六、4个步骤过滤超级粉丝", "第2章_粉丝互动养熟秘籍/六、4个步骤过滤超级粉丝.md"),
(9, "七、日常规划", "第2章_粉丝互动养熟秘籍/七、日常规划.md"),
]),
("part-gps-3", "第三章、粉丝转化系统", "chapter-gps-3", "粉丝转化系统", [
(1, "一、转化的四个前提", "第3章_粉丝转化系统/一、转化的四个前提.md"),
(2, "二、破解转化障碍", "第3章_粉丝转化系统/二、破解转化障碍.md"),
(3, "三、行之有效的转化秘籍", "第3章_粉丝转化系统/三、行之有效的转化秘籍.md"),
(4, "3.1、钟摆楼:一分钟获得变现的机会", "第3章_粉丝转化系统/3.1 钟摆楼,如何在一分钟之内获得变现的机会.md"),
(5, "3.2、社群价值裂变", "第3章_粉丝转化系统/3.2 激起兴趣的一些具体方法,微信群价值裂变.md"),
(6, "3.3、直播间转化实战", "第3章_粉丝转化系统/3.3 直播间转化实战.md"),
(7, "如何真正地掌握这套转化系统", "第3章_粉丝转化系统/一些想说的话,如何真正地掌握这套转化系统.md"),
]),
("part-gps-4", "第四章、超级粉丝", "chapter-gps-4", "超级粉丝", [
(1, "一、粉丝未来价值的重要性", "第4章_超级粉丝/一、粉丝未来价值的重要性.md"),
(2, "二、多维度升级为超级粉丝的谋略", "第4章_超级粉丝/二、多维度升级为超级粉丝的谋略.md"),
(3, "三、立体放大未来价值", "第4章_超级粉丝/三、立体放大未来价值.md"),
(4, "四、从超级粉丝到合伙人", "第4章_超级粉丝/四、从超级粉丝到合伙人.md"),
]),
("part-gps-5", "第五章、提升影响力的几个细节", "chapter-gps-5", "提升影响力的几个细节", [
(1, "一、当你觉得Hold不住自己野心时的处理方法", "第5章_提升影响力的几个细节/一、当你觉得Hold不住自己野心时的处理方法.md"),
(2, "二、保持你的个性,创造自己的小领地", "第5章_提升影响力的几个细节/二、保持你的个性,创造自己的小领地.md"),
(3, "三、如何让粉丝支持你", "第5章_提升影响力的几个细节/三、如何让粉丝和支持你.md"),
(4, "四、提升影响力前必须注意的细节", "第5章_提升影响力的几个细节/四、提升影响力前必须注意的细节.md"),
(5, "五、潜意识认同", "第5章_提升影响力的几个细节/五、潜意识认同.md"),
(6, "细节一、头像(视觉锤)", "第5章_提升影响力的几个细节/提升影响力的具体细节/一、头像.md"),
(7, "细节二、关联性昵称", "第5章_提升影响力的几个细节/提升影响力的具体细节/二、关联性昵称.md"),
(8, "细节三、个性签名", "第5章_提升影响力的几个细节/提升影响力的具体细节/三、个性签名.md"),
(9, "细节四、定位", "第5章_提升影响力的几个细节/提升影响力的具体细节/四、定位.md"),
(10, "案例:咨询卡若", "第5章_提升影响力的几个细节/案例/咨询卡若 目前为了拒绝一些没什么问题的用户你花半小时解答他花30秒看完.md"),
]),
("part-gps-6", "第六章、建造属于自己的游戏明星领地", "chapter-gps-6", "建造属于自己的游戏明星领地", [
(1, "一、什么是老铁", "第6章_建造属于自己的游戏明星领地/一、什么是老铁.md"),
(2, "二、如何设置领地规则", "第6章_建造属于自己的游戏明星领地/二、如何设置领地规则.md"),
(3, "三、玩值电竞——你的游戏明星大本营", "第6章_建造属于自己的游戏明星领地/三、玩值电竞——你的游戏明星大本营.md"),
(4, "四、领地的商业模式设计", "第6章_建造属于自己的游戏明星领地/四、领地的商业模式设计.md"),
(5, "如何坚持下去", "第6章_建造属于自己的游戏明星领地/如何坚持下去.md"),
]),
("part-gps-appendix-a", "附录A、玩值电竞入门指南", "chapter-gps-appendix-a", "玩值电竞入门指南", [
(1, "玩值电竞入门指南", "附录_玩值电竞入门指南/玩值电竞入门指南.md"),
]),
("part-gps-appendix-b", "附录B、干货工具箱", "chapter-gps-appendix-b", "干货工具箱", [
(1, "吸粉话术模板库", "附录_干货工具箱/吸粉话术模板库.md"),
(2, "游戏明星日常运营SOP", "附录_干货工具箱/游戏明星日常运营SOP.md"),
(3, "游戏明星自检清单", "附录_干货工具箱/游戏明星自检清单.md"),
]),
]
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保持 markdown 格式)"""
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("")
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 clean_excessive_blank_lines(md_text: str) -> str:
"""压缩连续 3+ 空行为 2 空行,确保 markdown 解析正常"""
return re.sub(r"\n{3,}", "\n\n", 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()
cleaned = clean_excessive_blank_lines(cleaned)
html_out = markdown.markdown(
cleaned,
extensions=["tables", "fenced_code", "nl2br"],
)
html_out = re.sub(r"\n{3,}", "\n\n", html_out)
return html_out.strip()
def main():
try:
import pymysql
except ImportError:
sys.exit("需要: pip install pymysql markdown requests")
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 part_id, part_title, chapter_id, chapter_title, sections in BOOK_STRUCTURE:
print(f"\n{'='*60}")
print(f"{part_title}")
print(f"{'='*60}")
for seq, section_title, rel_path in sections:
part_num = part_id.replace("part-gps-", "")
section_id = f"gps-{part_num}.{seq}"
md_path = BOOK_DIR / rel_path
if not md_path.exists():
print(f" ✗ 文件不存在: {rel_path}")
total_err += 1
continue
print(f"\n 📄 {section_id} | {section_title}")
try:
raw = md_path.read_text(encoding="utf-8")
except Exception as e:
print(f" ✗ 读取失败: {e}")
total_err += 1
continue
processed = process_images(raw, md_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}")
has_table = "<table" in html_content
has_list = "<ul" in html_content or "<ol" in html_content
has_strong = "<strong" in html_content
has_img = "<img" in html_content
has_h2 = "<h2" in html_content or "<h3" in html_content
has_blockquote = "<blockquote" in html_content
tags = []
if has_table: tags.append("表格")
if has_list: tags.append("列表")
if has_strong: tags.append("粗体")
if has_img: tags.append("图片")
if has_h2: tags.append("标题")
if has_blockquote: tags.append("引用")
print(f" [dry] 富文本元素: {', '.join(tags) if tags else '纯文字'}")
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, section_id),
)
if cur.rowcount == 0:
print(f" ⚠ 未找到 id={section_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()