Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8b7140b
docs(book): 《当时明月——中国诗人列传》全书 24 篇初稿入库(清账前的基线)
oratis Aug 28, 2026
c741830
tools(wechat): 三本书共用的公众号流水线入库,并让它认得引诗块
oratis Aug 28, 2026
c4afe98
docs(book): 清账轮中途落盘(01–07 等章已交稿)
oratis Aug 28, 2026
41d2cb9
docs(book): 清账轮再落盘(03/08/11/19 等章交稿)
oratis Aug 28, 2026
a5009a6
tools(book): style_audit 查章末编年表里的清账过程词
oratis Aug 28, 2026
69f897e
docs(book): 清账轮落盘(03/10/12/序终章交稿,05 编年表过程词清掉)
oratis Aug 28, 2026
4769f6d
docs(book): 修订轮去模板(已交稿的章先改)
oratis Aug 28, 2026
92fa700
docs(book): 出处入口表大改——本轮实测的六十多个入口、补零规则表、以及「检索不能用来证否」
oratis Aug 28, 2026
e774a14
fix(tools): debt_report 只读欠账条目的第一行,把 41 条报成了「没写明原因」
oratis Aug 28, 2026
02ca0ec
docs(book): 清账轮落盘(09/14/17/18/20 交稿,第一二卷细纲回改)
oratis Aug 28, 2026
3c664d9
docs(book): 细纲回改——README 待办里挂了很久的「正文为准,细纲未回改」
oratis Aug 28, 2026
f962fdf
docs(book): 去模板补完(13/14/17/18),只剩第 16 章一句待它交稿
oratis Aug 28, 2026
97c2344
docs(book): 清账第二轮的做法写进说明——专打「纸本不可及」,主武器是搜韵 API
oratis Aug 28, 2026
e413eb3
docs(book): 第 21 章交稿(纳兰)——四库提要被当成四库定论、雅克萨抵达与受降并成一天,两处已改
oratis Aug 28, 2026
563601b
docs(book): 第 16 章交稿;出处入口补一条推翻多项旧判断的——四部丛刊本要用 action=render
oratis Aug 28, 2026
0a991a4
docs(book): 第一轮清账全部交稿(22 章龚自珍收尾,清 25 条、改正文 7 处)
oratis Aug 28, 2026
485e3d7
docs(book): HANDOFF 改写——这一轮的实况、七条检索纪律、三条全书级模板、下一步
oratis Aug 28, 2026
45cf730
tools(wechat): mp_publish 先查封面齐不齐,别让二十四篇挨个抛 FileNotFoundError
oratis Aug 28, 2026
23c86e3
docs(book): 参考资料补上「各章系年的默认依据」——分章那一节此前只有第 6 章
oratis Aug 28, 2026
7a86b48
tools(book): refresh_refs.py——系年依据表也不要手写
oratis Aug 28, 2026
0aba6cb
docs(book): HANDOFF 记下一次失败的尝试——规范 §17 量不准,没做成检查项
oratis Aug 28, 2026
56dac4b
docs(book): 终章第四节的月亮名单补进龚自珍,19 → 20 人
oratis Aug 28, 2026
bbf4a67
docs(book): 清账第二轮陆续交稿——搜韵翻掉一批「纸本不可及」
oratis Aug 28, 2026
65e223f
docs(book): 出处入口写清搜韵给的是哪一种东西——笺注原文可据以打勾,它自己的编年库不行
oratis Aug 28, 2026
dcba426
docs(book): 第二轮 12/13/02 交稿——裴延翰序上一轮只截了半句,三件事全在里面
oratis Aug 28, 2026
319bcd0
docs(book): 第 20 章「二百五十一个人」改成「两百多个人」——那个数不出自任何古书
oratis Aug 28, 2026
eaf2522
docs(book): 第二轮 18/15 交稿——《建炎以来系年要录》四库本二百卷俱在,辛弃疾归宋有了最早的官方记载
oratis Aug 28, 2026
b8e74c5
docs(book): 清账两轮收工——「没写明为什么没清」93 → 1
oratis Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@ docs/marketing/.build/
星号之下-全书.html
星号之下-试读本.html
星号之下-试读本.pdf
当时明月-全书.html
当时明月-试读本.html
当时明月-试读本.pdf
548 changes: 548 additions & 0 deletions book/tools/build_wechat.py

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions book/tools/covers_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 covers/ 里的封面拼成一页 HTML,连排期一起,用来一眼看完整本书的封面。

python3 tools/covers_page.py <产出目录>

图片以 data URI 内嵌,所以这一页可以单独发给别人看。缺图的位置留白并标出来。
只依赖标准库。
"""
import os, sys, json, base64, html

def main():
outdir = (sys.argv[1] if len(sys.argv) > 1 else ".").rstrip("/")
index = json.load(open(f"{outdir}/meta/index.json", encoding="utf-8"))
cards, missing = [], 0
for m in index:
path = f"{outdir}/{m['cover']}"
if os.path.exists(path) and os.path.getsize(path) > 10000:
b64 = base64.b64encode(open(path, "rb").read()).decode()
img = (f'<img src="data:image/jpeg;base64,{b64}" '
f'style="width:100%;display:block;border-radius:4px;">')
else:
missing += 1
img = ('<div style="width:100%;aspect-ratio:2960/1260;background:#f2f2f2;'
'border-radius:4px;display:flex;align-items:center;'
'justify-content:center;color:#b0b0b0;font-size:13px;">封面尚未生成</div>')
cards.append(
f'<figure style="margin:0 0 34px;">{img}'
f'<figcaption style="margin-top:8px;font-size:13px;color:#6b6b6b;'
f'display:flex;justify-content:space-between;gap:12px;">'
f'<span>{html.escape(m["mp_title"])}</span>'
f'<span style="color:#a0a0a0;">{m["publish_date"]}</span>'
f'</figcaption></figure>')
title = index[0]["mp_title"].split(" ")[0]
page = (f'<!doctype html><meta charset="utf-8"><title>{html.escape(title)} · 封面总览</title>'
f'<body style="margin:0;padding:40px 20px;background:#fff;'
f'font-family:-apple-system,\'PingFang SC\',sans-serif;">'
f'<div style="max-width:880px;margin:0 auto;">'
f'<h1 style="font-size:20px;font-weight:600;color:#1a1a1a;margin:0 0 28px;">'
f'{html.escape(title)} · 封面总览({len(index)} 篇)</h1>'
f'{"".join(cards)}</div></body>')
dst = f"{outdir}/封面总览.html"
open(dst, "w", encoding="utf-8").write(page)
print(f"{len(index)} 篇 → {dst}" + (f"({missing} 张封面还没生成)" if missing else ""))

if __name__ == "__main__":
main()
186 changes: 186 additions & 0 deletions book/tools/gen_covers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""用 Seedream 给一本书的每篇公众号文章生成封面图。

用法:
ARK_API_KEY=xxx python3 tools/gen_covers.py <产出目录> [只做这几篇的序号...]

画面描述从 <产出目录>/meta/cover_prompts.json 读,格式:
{"style": "全系列共用的调子", "subjects": {"1": "第 1 篇画什么", ...}}
所以每本书各带各的提示词,代码不用改。没有这个文件就退回本文件里
内置的《要有光》那 33 条(历史遗留,新书请写 JSON)。

微信封面按 2.35:1 裁切,所以直接出 2960x1260。Seedream 要求像素数 ≥ 3 686 400。
生成链接 24 小时过期,出图即下载。
"""
import os, sys, json, time, urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor

API = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
KEY = os.environ.get("ARK_API_KEY", "")
MODEL = "doubao-seedream-5-0-260128"
SIZE = "2960x1260"

# 全系列共用的视觉语言:暗底、一束强光、电影感、无文字
STYLE = (
"电影级宽幅海报,2.35:1 横构图,深蓝近黑的暗调背景,"
"一束强烈的光从画面深处穿出打在主体上,体积光与丁达尔效应,"
"极致的明暗对比,光线追踪,OC 渲染,浅景深,轻微胶片颗粒与暗角,"
"史诗感,肃穆,克制的暖金色高光点缀,"
"画面中不出现任何文字、字母、数字或水印"
)

# (序号, 主体描述)
SUBJECTS = [
(1, "宇宙尽头,一切恒星熄灭后的绝对黑暗中,悬浮着一台巨大的、由无数光点构成的超级计算机残影,"
"虚空里刚刚亮起第一个微小的光点,正在向外爆开成一道创世之光"),
(2, "三千年前中国周代的宫殿内,一具被拆开的木质人偶横陈在地毯上,"
"革、木、胶、漆做的内脏与筋骨散落一旁,齿轮与丝线裸露,"
"一位古代帝王俯身端详,殿内烛火昏黄"),
(3, "十七世纪欧洲,一具解剖学人体模型的胸腔被打开,里面不是器官而是精密的钟表机芯,"
"黄铜齿轮与发条在缓缓转动,旁边散落着羽毛笔与手稿,实验室窗外是冷灰的天光"),
(4, "十七世纪的黄铜机械计算器特写,密布的数字轮与棘爪咬合,"
"旁边摊开一本写满符号的羊皮手稿,烛光斜照,金属反射出温暖的光泽,工艺极其精密"),
(5, "维多利亚时代巨大的黄铜差分机与分析机,齿轮塔林立直上,"
"一长串雅卡尔提花机的穿孔卡片如瀑布般垂落,"
"一位身着长裙的女性背影站在机器前,仰头凝视"),
(6, "一条无限长的纸带穿过黑暗,纸带上是被刻下的方格与符号,"
"一个金属读写头悬在其上,纸带的远端消失在虚无里,"
"背景是崩塌的数学公理体系,几何图形正在解体"),
(7, "极度放大的单个神经元,树突如闪电般伸展,"
"轴突末端却接着一枚冰冷的金属逻辑门电路,生物与电路在中段融合,"
"一道电脉冲正沿轴突奔驰,全或无的瞬间"),
(8, "1940 年代的实验室里,一只三轮的机械龟正朝一盏灯缓慢爬行,"
"透明外壳下露出真空管与继电器,地面上是它反复试探留下的粉笔轨迹,"
"一圈闭合的反馈箭头虚影环绕着它"),
(9, "两间隔开的房间,中间一道厚重的帘幕,"
"帘幕一侧坐着一个人影,另一侧是一台运转中的电传打字机,"
"一张纸条正从帘幕下方递过来,无法分辨哪一边才是人"),
(10, "1956 年夏天,新英格兰学院一间空荡的会议室,"
"长桌上散落着咖啡杯、烟灰缸与写满公式的稿纸,黑板上是半擦掉的图,"
"午后阳光斜穿过高窗,房间里没有人,一个词刚刚在这里诞生"),
(11, "1950 年代末的巨大机柜,正面是密密麻麻上千个电位器旋钮与纵横交错的接线,"
"一位研究者背影正在手动调节其中一个,指示灯零星闪烁,机房昏暗"),
(12, "一个玻璃罩下的微缩积木世界,几块彩色几何积木整齐堆叠,光洁完美,"
"玻璃罩之外是一片混乱失焦的真实街道,两个世界之间只隔着一层薄玻璃"),
(13, "一间幽深的档案室,从地面到天花板全是抽屉与卡片柜,"
"无数张写着规则的卡片悬浮在空中排成长链,"
"链条的末端正在断裂,卡片开始如雪片般坠落"),
(14, "一张多层网络结构在黑暗中发光,一道明亮的红色误差信号正从最顶层逆流而下,"
"沿着每一条连接向后倒灌,所经之处连接的粗细在改变,逆行的光河"),
(15, "一片起伏的三维能量曲面在黑暗中悬浮,如同液态金属的丘陵与深谷,"
"一颗发光的小球正沿着斜坡滚落,即将停在某个最低的谷底,"
"谷底浮现出一张模糊的人脸轮廓"),
(16, "一只猫的侧影与它眼睛后方展开的视觉皮层剖面,"
"皮层里排列着一格格对边缘与朝向敏感的细胞,每一格里是一小段亮起的线条,"
"这些格子逐渐演化成一层层整齐的滤波器矩阵"),
(17, "一条时间轴向画面深处延伸,轴上串着一串相同的网络单元,"
"越靠近深处的单元发出的光越暗淡,直至完全熄灭,"
"近处几个单元上出现了金属闸门,门开着,一束记忆之光被放行"),
(18, "一个高维空间里,两团数据点被一道笔直的发光超平面干净地劈开,"
"间隔清晰,几何感极强;远处的背景是一盘国际象棋的棋局残影"),
(19, "一只猫在木制迷箱里踩下机关,箱门弹开的瞬间,"
"从它脑中迸出一道多巴胺般的金色奖赏信号,向外扩散成涟漪,"
"背景是一张不断被强化加粗的行为路径网"),
(20, "2012 年的地下室,两块显卡在机箱里烧得通红,风扇高速旋转,"
"机箱外的黑暗中悬浮着上百万张缩略图组成的巨墙,"
"三个人的剪影站在墙前,墙上某处正在被点亮"),
(21, "一张围棋棋盘悬在黑暗中,棋盘上一枚棋子落在无人预料的位置,"
"从这一手向外炸开成一片深不见底的网络结构,层层堆叠向上,看不到顶"),
(22, "漆黑中一个长长的句子由一串暗淡的光点组成,"
"一束探照灯般的强光只照亮了其中的两三个光点,其余仍在黑暗里,"
"被照亮的点之间拉出发光的连线"),
(23, "一个由纯粹的连线构成的对称建筑结构悬浮在虚空中,"
"所有的循环与回路都已被拆除,只剩下纵横交错、彼此凝视的直连光束,"
"结构轻盈、对称、近乎神殿"),
(24, "一条道路在黑暗中分岔成两条,两条路上各自流淌着由文字组成的光河,"
"一条向左,一条向右,路口立着一块空白的石碑,"
"远处的地平线上是整个互联网的文本汪洋"),
(25, "一条平滑的对数曲线在深空中无限向上延伸,看不到拐点,"
"曲线由无数发光的参数点堆积而成,曲线下方是层层叠叠的数据中心机柜的暗影"),
(26, "同一把巨大的金色钥匙悬在中央,下方并排三把不同的锁:"
"一张图像、一段代码、一条折叠的蛋白质长链,"
"蛋白质正在自行盘绕成精妙的三维结构,发出微光"),
(27, "一只人手从画面上方伸入,正轻轻扶正一个由光构成的、有些歪斜的发光体,"
"光体在被校准的瞬间变得温顺明亮,人手与光之间保持着微妙的距离"),
(28, "一条平滑上升的曲线在某一点突然垂直跃起,冲出画面;"
"旁边悬着一把刻度粗糙的旧尺子,尺子上的刻度正在扭曲变形,"
"两者对峙,无法判断是曲线在跳还是尺子在错"),
(29, "一个正在被剖开的黑色立方体,切面里不是电路而是无数半透明的特征叠影,"
"彼此重叠、互相穿透,其中一个特征被单独抽出并调到最亮,整个立方体随之扭曲"),
(30, "一间幽暗的房间里,一盏灯下摊开一张写满推演步骤的长卷,"
"卷轴从桌面一直垂到地面又延伸到画面之外,"
"每一步都比上一步更亮,时间在这里被拉得很长"),
(31, "一只由光构成的机械手伸出屏幕之外,正握住一件真实世界的工具,"
"屏幕的边框在接触点上碎裂开来,光与实体交错,"
"背后是一整面墙的应用图标与终端窗口"),
(32, "英国乡间一座维多利亚式庄园的夜景,窗内透出温暖的灯光,"
"庄园上空悬着两枚发光的诺贝尔奖章虚影,"
"草坪上残留着二战时期解密机房的幽灵般轮廓,历史在此闭环"),
(33, "绝对黑暗的宇宙中心,一个点正在亮起,"
"光从这一点向四面八方奔涌而出,照亮了尚未成形的星云与尘埃,"
"画面绝大部分仍是黑的,但那道光已不可阻挡,创世的第一瞬"),
]


def load_prompts(outdir):
"""优先读产出目录里的 cover_prompts.json,没有就用内置的《要有光》那套。"""
p = f"{outdir}/meta/cover_prompts.json"
if os.path.exists(p):
d = json.load(open(p, encoding="utf-8"))
return d["style"], [(int(k), v) for k, v in sorted(
d["subjects"].items(), key=lambda kv: int(kv[0]))]
return STYLE, SUBJECTS


def gen(seq, subject, outdir, slug, style):
dst = f"{outdir}/covers/{slug}.jpg"
if os.path.exists(dst) and os.path.getsize(dst) > 10000:
return seq, "已存在", dst
payload = json.dumps({
"model": MODEL,
"prompt": f"{subject}。{style}",
"sequential_image_generation": "disabled",
"response_format": "url",
"size": SIZE,
"stream": False,
"watermark": False,
}).encode()
req = urllib.request.Request(API, data=payload, headers={
"Content-Type": "application/json", "Authorization": f"Bearer {KEY}"})
for attempt in range(4):
try:
with urllib.request.urlopen(req, timeout=180) as r:
data = json.load(r)
url = data["data"][0]["url"]
urllib.request.urlretrieve(url, dst)
return seq, "OK", dst
except Exception as e:
if attempt == 3:
return seq, f"失败 {e}", ""
time.sleep(4 * (attempt + 1))


def main():
if len(sys.argv) < 2:
raise SystemExit("用法:gen_covers.py <产出目录> [序号...]")
outdir = sys.argv[1].rstrip("/")
only = {int(x) for x in sys.argv[2:]} if len(sys.argv) > 2 else None
os.makedirs(f"{outdir}/covers", exist_ok=True)
index = json.load(open(f"{outdir}/meta/index.json", encoding="utf-8"))
slugs = {m["seq"]: m["cover"].split("/")[-1][:-4] for m in index}

style, subjects = load_prompts(outdir)
jobs = [(s, t) for s, t in subjects if only is None or s in only]
missing = [s for s, _ in jobs if s not in slugs]
if missing:
raise SystemExit(f"提示词里有 index.json 没有的序号:{missing}")
with ThreadPoolExecutor(max_workers=4) as ex:
for seq, status, path in ex.map(
lambda j: gen(j[0], j[1], outdir, slugs[j[0]], style), jobs):
size = os.path.getsize(path) if path and os.path.exists(path) else 0
print(f"{seq:>2} {status:<8} {size/1024:>7.0f} KB {os.path.basename(path)}")


if __name__ == "__main__":
main()
Loading
Loading