---
name: qiaomu-paper-interpreter
description: Transform academic papers into conversational Chinese articles in Qiaomu's style. Use when user provides arXiv URL/ID with keywords "解读论文", "论文解读", "理解paper", "读paper", or "乔木风格". Runs fully automatically.
allowed-tools: Bash, Read, Write, Edit, Glob, Grep, WebFetch, TodoWrite
---
# 乔木论文解读
## 概述
将学术论文自动转化为乔木风格的深度解读文章。**全自动执行**,无需用户中途确认。
**核心特点**:
- 对话式语言,像和朋友聊天
- 关键术语用引用块(>)解释,每出现新术语立刻加
- 生活化类比帮助理解,每个核心方法后必须紧跟一个类比
- 真实论文图表嵌入文章(从 LaTeX 源码精确提取)
- AI 生成纸雕水彩封面 + 《纽约客》风格配图(需配置图片生成服务)
- 写作风格规范已内嵌,无需外部依赖
---
## 配置(首次使用必读)
### 配置方式:.env 文件
在 skill 目录下创建 `.env` 文件(已加入 `.gitignore`,不会被发布):
```bash
# ~/.claude/skills/qiaomu-paper-interpreter/.env
PAPER_OUTPUT_DIR=~/Papers/papers
PAPER_READING_DIR=~/Papers/reading
OBSIDIAN_VAULT=
IMAGE_PROVIDER=skip
IMAGE_GENERATOR_SCRIPT=
```
**参考模板**:skill 目录下的 `.env.example` 包含所有可用变量及说明。
**变量说明**:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PAPER_OUTPUT_DIR` | `~/Papers/papers` | 论文工作目录根路径 |
| `PAPER_READING_DIR` | `~/Papers/reading` | 最终文章存放目录 |
| `OBSIDIAN_VAULT` | 空 | Obsidian vault 名称,空则跳过自动打开 |
| `IMAGE_PROVIDER` | `skip` | `skip`(跳过配图)/ `jimeng` / `openai` |
| `IMAGE_GENERATOR_SCRIPT` | 空 | 图片生成脚本路径,空则用内置默认 |
系统环境变量优先级高于 `.env` 文件,适合 CI/CD 或多项目场景。
### 配置读取逻辑(每次执行时运行)
```python
import os
from pathlib import Path
SKILL_DIR = Path("~/.agents/skills/qiaomu-paper-interpreter").expanduser()
# 1. 读取 .env 文件(如果存在)
env_file = SKILL_DIR / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
# 系统环境变量优先,.env 不覆盖已设置的值
os.environ.setdefault(k.strip(), v.strip())
# 2. 读取最终配置(含默认值兜底)
OUTPUT_DIR = Path(os.environ.get("PAPER_OUTPUT_DIR", "~/Papers/papers")).expanduser()
READING_DIR = Path(os.environ.get("PAPER_READING_DIR", "~/Papers/reading")).expanduser()
OBSIDIAN_VAULT = os.environ.get("OBSIDIAN_VAULT", "")
IMAGE_PROVIDER = os.environ.get("IMAGE_PROVIDER", "skip") # skip | jimeng | openai
IMAGE_GENERATOR_SCRIPT = os.environ.get("IMAGE_GENERATOR_SCRIPT", "")
# 图片生成脚本默认路径
if not IMAGE_GENERATOR_SCRIPT and IMAGE_PROVIDER != "skip":
IMAGE_GENERATOR_SCRIPT = str(
Path("~/.agents/skills/qiaomu-image-generator/scripts/generate.py").expanduser()
)
```
---
## 执行流程(4步)
**顺序**:步骤A → 步骤B → 步骤C → 步骤D
**执行原则**:全程自动,用 TodoWrite 显示进度,静默修复质量问题。
### 初始化 Todo
```python
TodoWrite([
{"content": "A. 提取论文内容 + 并发转换图片", "status": "in_progress"},
{"content": "B. 生成乔木风格解读文章", "status": "pending"},
{"content": "C. 生成 AI 配图(封面 + 纽约客)", "status": "pending"},
{"content": "D. 保存发布", "status": "pending"},
])
```
---
## 步骤A:提取论文内容 + 并发转换图片
**目标**:一次完成 LaTeX 提取、元数据解析、图片并发转换、图表清单生成。
### A1. 确定 arxiv_id
支持输入格式:
| 输入 | 处理方式 |
|------|---------|
| `https://arxiv.org/abs/2605.03269` | 直接提取 ID |
| `https://arxiv.org/pdf/2605.03269` | 直接提取 ID(和 abs 等价) |
| `https://arxiv.org/pdf/2605.03269v2` | 提取 ID,保留版本号 |
| `https://huggingface.co/papers/2605.03269` | 先 WebFetch 页面找 arXiv 链接,再提取 ID |
| `2605.03269` | 直接作为 ID 使用 |
**内部流程**:extract_tex.py 拿到 ID 后,从 `https://arxiv.org/e-print/{id}` 下载 LaTeX 源码 tar.gz,自动解压,找 main.tex,提取结构化内容和图片。
**如果论文没有 LaTeX 源码(PDF-only)**:
`e-print` 返回 PDF 而不是 tar.gz,extract_tex.py 会返回 `has_source: false`。此时自动切换到 markitdown fallback:
```bash
markitdown "https://arxiv.org/pdf/{arxiv_id}" -o "{paper_dir}/extracted_text.md"
```
fallback 情况下无法提取真实图表,figure_list.md 标注"LaTeX 源码不可用",文章中用文字描述代替图片引用。
### A2. 并发启动:extract_tex.py + arXiv API(含断点续跑)
**断点续跑**:如果 `extract_result.json` 已存在且有效,直接跳过下载,从 A3 继续。适用于崩溃重试场景。
```python
import subprocess, threading, urllib.request, re, time, json
from pathlib import Path
# ── 断点续跑检测 ──
# 检查是否有已完成的 paper_id 目录(paper_id 此时尚未确定,用 arxiv_id 查找)
existing = list(OUTPUT_DIR.glob(f"*{arxiv_id.replace('.', '_')}*"))
_resume_dir = next((d for d in existing
if (d / "extract_result.json").exists()
and (d / "extract_result.json").stat().st_size > 100), None)
if _resume_dir:
paper_dir = _resume_dir
result_file = paper_dir / "extract_result.json"
print(f"⚡ 断点续跑:跳过下载,使用 {paper_dir.name}")
arxiv_meta = {} # 仍需 API 获取日期,下面会并发请求
_skip_extract = True
else:
paper_dir = OUTPUT_DIR / f"tmp_{int(time.time())}"
paper_dir.mkdir(parents=True, exist_ok=True)
result_file = paper_dir / "extract_result.json"
_skip_extract = False
# ── 线程1:并发请求 arXiv API(⚠️ 发布日期严禁猜测,只用此处返回值)──
arxiv_meta = {}
def fetch_arxiv_meta():
try:
xml = urllib.request.urlopen(
f"http://export.arxiv.org/api/query?id_list={arxiv_id}", timeout=10
).read().decode()
m = re.search(r'(.*?)', xml)
arxiv_meta["published_date"] = m.group(1)[:10] if m else ""
# ⚠️ 必须从 内提取,xml 第一个 是 feed 标题,不是论文标题
entries = re.findall(r'(.*?)', xml, re.DOTALL)
entry = entries[0] if entries else ""
t = re.search(r'(.*?)', entry, re.DOTALL)
arxiv_meta["title"] = t.group(1).strip().replace('\n', ' ') if t else ""
arxiv_meta["authors"] = re.findall(r'(.*?)', entry)
except Exception:
arxiv_meta["published_date"] = ""
meta_thread = threading.Thread(target=fetch_arxiv_meta, daemon=True)
meta_thread.start()
# ── 线程2(主线程):运行 extract_tex.py,120s 超时 ──
if not _skip_extract:
try:
proc = subprocess.run(
["python3",
str(Path("~/.agents/skills/qiaomu-paper-interpreter/scripts/extract_tex.py").expanduser()),
arxiv_url_or_id, "--json", "--output-dir", str(paper_dir / "latex_source")],
capture_output=True, text=True, timeout=120
)
result_file.write_text(proc.stdout, encoding="utf-8")
except subprocess.TimeoutExpired:
print("⚠️ extract_tex.py 超时(>120s),切换 markitdown fallback")
proc = None
meta_thread.join(timeout=5)
```
> `extract_tex.py` 已内置于本 skill 的 `scripts/` 目录,无需外部依赖。
### A3. 解析 JSON,失败检测 + 合并 arXiv 元数据
```python
import os
# ── 失败检测:空输出或无 JSON → 切 markitdown fallback ──
raw = result_file.read_text(encoding="utf-8") if result_file.exists() else ""
json_start = raw.find('{')
if json_start == -1 or len(raw.strip()) < 50:
print("⚠️ extract_tex.py 输出无效,切换 markitdown fallback")
import subprocess as _sp
_sp.run(["markitdown", f"https://arxiv.org/pdf/{arxiv_id}",
"-o", str(paper_dir / "extracted_text.md")], check=False)
data = {}
figures = []
else:
try:
data = json.loads(raw[json_start:])
except json.JSONDecodeError:
print("⚠️ JSON 解析失败,切换 markitdown fallback")
import subprocess as _sp
_sp.run(["markitdown", f"https://arxiv.org/pdf/{arxiv_id}",
"-o", str(paper_dir / "extracted_text.md")], check=False)
data = {}
figures = []
title = data.get("title", "") or arxiv_meta.get("title", "")
authors = data.get("authors", []) or arxiv_meta.get("authors", [])
arxiv_id = data.get("arxiv_id", arxiv_id)
markdown = data.get("markdown", "")
figures = data.get("media", {}).get("figures", figures if 'figures' in dir() else [])
published_date = arxiv_meta.get("published_date", "")
```
# 生成 paper_id(代码化,避免每次结果不一致导致重名)
def make_paper_id(title: str, pub_date: str) -> str:
year = pub_date[:4] if pub_date else "0000"
# 提取全大写缩写词(长度 2-6),如 BERT、MACE、VL
abbrevs = re.findall(r'\b[A-Z]{2,6}\b', title)
if abbrevs:
return f"{'_'.join(abbrevs[:2])}_{year}"
# 否则取前 3 个英文关键词(跳过冠词介词)
skip = {'a','an','the','of','for','on','in','to','and','or','with','via'}
words = [w for w in re.findall(r'[a-zA-Z]+', title) if w.lower() not in skip]
slug = "_".join(w.capitalize() for w in words[:3])
return f"{slug}_{year}"
paper_id = make_paper_id(title, published_date)
# 重命名临时目录(如已存在则加后缀避免冲突)
new_dir = OUTPUT_DIR / paper_id
if new_dir.exists():
new_dir = OUTPUT_DIR / f"{paper_id}_2"
os.rename(paper_dir, new_dir)
paper_dir = new_dir
# 保存文本和元数据
(paper_dir / "extracted_text.md").write_text(markdown, encoding="utf-8")
(paper_dir / "metadata.json").write_text(
json.dumps({
"paper_id": paper_id, "title": title, "authors": authors,
"arxiv_id": arxiv_id,
"arxiv_url": f"https://arxiv.org/abs/{arxiv_id}",
"published_date": published_date,
}, ensure_ascii=False, indent=2),
encoding="utf-8"
)
```
### A4. 并发转换图片
**过滤规则**:跳过 >20MB 的图(多页定性对比图,不适合放博客);其余全部转换。
```python
import concurrent.futures, subprocess, shutil
(paper_dir / "images").mkdir(exist_ok=True)
def convert_figure(fig):
idx = fig["index"]
raw_src = fig["local_files"][0] if fig.get("local_files") else None
if not raw_src:
return None
# local_files 可能是相对路径或绝对路径,逐级尝试
latex_dir = paper_dir / "latex_source"
candidates = [
Path(raw_src), # 原始路径(绝对)
latex_dir / raw_src, # 相对于 latex_source/
latex_dir / Path(raw_src).name, # 只取文件名,在 latex_source/ 下找
] + list(latex_dir.glob(f"**/{Path(raw_src).name}")) # 递归搜索
src = next((p for p in candidates if p.exists()), None)
if not src:
return None
caption = fig.get("caption", "")
words = re.findall(r'[a-zA-Z]+', caption)[:3]
slug = "_".join(w.lower() for w in words) or "fig"
dst = paper_dir / "images" / f"figure{idx}_{slug}.png"
ext = Path(src).suffix.lower()
size_mb = Path(src).stat().st_size / 1024 / 1024
if ext == ".pdf":
if size_mb > 20:
return {"index": idx, "skipped": True,
"reason": f"超大图({size_mb:.0f}MB)", "caption": caption}
r = subprocess.run(
["pdftoppm", "-r", "150", "-png", "-singlefile", src, str(dst)[:-4]],
capture_output=True
)
if r.returncode != 0 or not dst.exists():
subprocess.run(["convert", "-density", "150", f"{src}[0]", str(dst)])
elif ext in (".eps", ".ps"):
subprocess.run(["convert", "-density", "150", src, str(dst)])
elif ext in (".png", ".jpg", ".jpeg", ".gif"):
shutil.copy2(src, dst)
if not dst.exists():
return {"index": idx, "skipped": True, "reason": "转换失败", "caption": caption}
return {"index": idx, "file": str(dst),
"filename": dst.name, "caption": caption,
"size_kb": dst.stat().st_size // 1024}
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(convert_figure, figures))
converted = [r for r in results if r and not r.get("skipped")]
skipped = [r for r in results if r and r.get("skipped")]
```
### A4.5. TikZ 图补全(PDF 截图方案)
**背景**:arXiv 论文中有些图是用 LaTeX TikZ 代码直接绘制的,不是 `\includegraphics` 引用的文件。`extract_tex.py` 只提取 `\includegraphics` 图,TikZ 图不会出现在 `figures` 列表中,但它们往往是最重要的架构图和流程图。
**触发条件**:`extracted_text.md` 中出现 `**Figure N**:` 引用(说明正文里提到了这张图),但 `images/` 目录里没有对应文件。
**检测代码**:
```python
import re
from pathlib import Path
# 从 extracted_text.md 找所有 Figure 引用编号
mentioned_figs = set(re.findall(r'\*\*Figure (\d+)\*\*', paper_text))
# 已提取的图编号(来自 A4 converted 列表)
extracted_ids = set(str(r["index"]) for r in converted)
missing_ids = mentioned_figs - extracted_ids
if missing_ids:
print(f"⚠️ 检测到 {len(missing_ids)} 张 TikZ 图(Figure {sorted(missing_ids)}),启动 PDF 截图补全")
```
**PDF 截图流程**:
```python
import subprocess
from pathlib import Path
if missing_ids:
pdf_path = "/tmp/arxiv_paper.pdf"
# 1. 下载 arXiv PDF
subprocess.run(
["curl", "-sL", f"https://arxiv.org/pdf/{arxiv_id}", "-o", pdf_path],
check=True
)
# 2. 渲染所有页面为 PNG(150 DPI)
subprocess.run(
["pdftoppm", "-r", "150", "-png", pdf_path, "/tmp/arxiv_page"],
check=True
)
page_files = sorted(Path("/tmp").glob("arxiv_page-*.png"))
print(f"PDF 共 {len(page_files)} 页")
# 3. 对每张缺失的图,找对应页面并裁剪
from PIL import Image
for fig_id in sorted(missing_ids, key=int):
# 启发式:Figure N 通常在 PDF 第 N+1 到 N+3 页附近
# 实际操作:先看 page N+1,不对再往后翻
target_idx = min(int(fig_id), len(page_files) - 1)
src_page = page_files[target_idx]
img = Image.open(src_page)
w, h = img.size
# 截取上半页(图通常在页面上方,caption 在图下方)
# crop 参数可能需要根据实际情况调整(0.4 ~ 0.55 之间)
cropped = img.crop((0, 0, w, int(h * 0.5)))
dst = paper_dir / "images" / f"fig{fig_id}_tikz.png"
cropped.save(dst)
# 提取该图的 caption(从 extracted_text.md 找)
cap_match = re.search(
rf'\*\*Figure {fig_id}\*\*[:\.]?\s*(.{{0,200}})',
paper_text
)
caption = cap_match.group(1).strip() if cap_match else ""
converted.append({
"index": int(fig_id),
"file": str(dst),
"filename": dst.name,
"caption": caption,
"size_kb": dst.stat().st_size // 1024,
"source": "pdf_screenshot"
})
print(f"✅ Figure {fig_id}: PDF 第 {target_idx+1} 页截图 → {dst.name}")
# 清理临时文件
for f in Path("/tmp").glob("arxiv_page-*.png"):
f.unlink(missing_ok=True)
Path(pdf_path).unlink(missing_ok=True)
```
**注意事项**:
- `pdftoppm` 来自 `poppler-utils`(macOS 用 `brew install poppler`);不可用时用 `convert -density 150 paper.pdf[{page_idx}] output.png`(ImageMagick)
- `Pillow` 需已安装:`pip install pillow`
- 截图默认取上半页,论文图通常在页面上方;如果截出来有问题,调整 `0.5` 比例(例如改 `0.4` 或 `0.6`)
- TikZ 截图会包含 figure caption 文字,这是正常的,帮助读者理解图意
- **不要用 AI 生成图替代 TikZ 图**:架构图、流程图必须用论文原图,它们是作者精心设计的,随意替换会误导读者
### A5. 生成 figure_list.md
```markdown
# 论文图表清单
## 可用图表(建议全部引用)
| 编号 | 文件 | 大小 | Caption 摘要 | 建议章节 |
|------|------|------|-------------|---------|
| Figure 1 | figure1_teaser.png | 2.1MB | 整体效果展示 | 开头引入 |
| Figure 2 | figure2_overview.png | 746KB | 系统架构图 | 方法解读 |
| ...
## 跳过的图
| 编号 | 原因 | Caption 摘要 |
|------|------|-------------|
| Figure 3 | 超大图(84MB) | 定性对比图 |
```
**完成后**:更新 Todo A 为 completed,B 为 in_progress。
---
## 步骤B:生成乔木风格解读文章
### B0. 写作前准备(必须完整执行)
**第一步:读取完整论文内容 + 当前日期**
```python
import datetime
# 读取 A 步骤提取的完整正文(不能只靠记忆,必须重新读取)
paper_text = (paper_dir / "extracted_text.md").read_text(encoding="utf-8")
figure_list = (paper_dir / "figure_list.md").read_text(encoding="utf-8")
metadata = json.loads((paper_dir / "metadata.json").read_text(encoding="utf-8"))
# 读取当前日期,用于计算论文发布至今的时间跨度
today = datetime.date.today() # e.g. 2026-05-12
pub_date = metadata.get("published_date", "")
years_since = (today.year - int(pub_date[:4])) if pub_date else 0
print(f"论文发布于 {pub_date},距今 {years_since} 年({today})")
```
**当前日期的用途**:
- 在"写在后面"中具体说出"这篇论文发表于 X 年,距今 Y 年",不用模糊说"几年前"
- 主动调用训练知识,找出这篇论文**发布后出现的重要后继工作**:哪些论文直接引用并发展了它的思路?哪些产品落地了它的方法?
- 如果论文发布时间超过 2 年,必须在"写在后面"或相关章节自然提到后续影响(不是列表,而是融入正文),例如"2 年后 Stable Diffusion 用的正是 CLIP 做图文对齐"这样的具体陈述
- 知识截止日期内未发生的事不要猜测,只写确实知道的
**第二步:自动判断是否为里程碑论文(决定字数下限)**
```python
LANDMARK_KEYWORDS = {
# NLP / LLM
"transformer", "attention is all you need",
"bert", "gpt", "gpt-2", "gpt-3", "gpt-4",
"rlhf", "reinforcement learning from human feedback",
"instruct", "instructgpt",
"chain-of-thought", "chain of thought",
"in-context learning", "scaling laws",
"codex", "alphacode", "word2vec", "seq2seq",
# 多模态 / 生成
"clip", "dall-e", "imagen", "stable diffusion",
"diffusion", "ddpm", "gan", "generative adversarial",
"vae", "variational autoencoder",
# CV 骨干网络
"resnet", "vit", "vision transformer",
"alexnet", "vgg", "googlenet", "inception",
"densenet", "efficientnet", "mobilenet",
"batch normalization",
# 视频理解
"two-stream", "two stream", "i3d", "slowfast",
"optical flow", "action recognition",
# 检测 / 分割
"faster rcnn", "yolo", "mask rcnn", "detr",
"feature pyramid", "fpn",
# 优化 / 训练技巧
"lora", "dropout", "adam optimizer",
"knowledge distillation",
# 强化学习
"dqn", "alphago", "ppo", "proximal policy",
}
title_lower = metadata.get("title", "").lower()
is_landmark = any(kw in title_lower for kw in LANDMARK_KEYWORDS)
min_words = 8000 if is_landmark else 5000
print(f"{'⭐ 里程碑论文' if is_landmark else '普通论文'},字数下限:{min_words} 字")
```
**第三步:阅读写作规范**(见下方 B0 写作风格)
---
### B0. 乔木写作风格(内嵌,无需读取外部文件)
#### 语言特质
- 口语化、对话感强,像和读者面对面聊天
- 用"你"直接称呼读者
- **生活化类比触发规则**:每讲完一个核心方法/设计决策的技术解释后,**必须**紧跟一个类比段落。全文 ≥ 3 处,分散在不同章节。
- **类比质量标准**:类比必须同时做到两点:① 用一个具体的日常场景(不是抽象描述),② 包含"如果不这样做会怎样"的反事实,让类比揭示的是这个设计决策的代价和收益,而不只是装饰性的比喻。
- **禁止的类比**:太宽泛的比喻("就像用地图导航")、无法推出设计必要性的比喻、和技术原理对不上的类比,这些不算数,必须重写。
- 在专业性和可读性之间自然平衡
#### 表达习惯
- 短段落,多留白,视觉舒适
- 重要观点用 **加粗**,加粗句必须单独成段,不和其他句子同处一段
- 加粗句之前和之后都留空行
- **引用块触发规则**:每当文章中出现一个新的专有名词、技术术语、缩写时,**立刻**在其后加引用块解释,不要等写完再补。格式:`> **术语**:一句话解释`。全文累计 ≥ 10 处
- **引用块间距规则**:两个引用块之间必须至少隔一个正文段落。如果连续出现了多个新术语,把解释合并到同一个引用块里(用换行分隔),不要连续放多个独立引用块
- 冒号后接长内容,冒号后另起段落
- 三条以上并列经验,用列表
#### 内容层次
- 不满足于表面解释,延伸到更深的思考
- 善于在不同领域间建立联系(技术→生活→认知)
- 既讲"是什么",也讲"为什么重要"
- 每段通过「增量信息测试」:这段提供了前面没有的新信息吗?
#### 风格调性
- 真诚、不装、承认自己的困惑
- 专业但不掉书袋,数据和案例支撑观点
- 批判性反思融入正文流,不辟独立章节
#### 让文章"生动有趣"的具体技法(每篇至少用 3 种)
1. **开头用悬念或反直觉事实**:不是"本文提出了X方法",而是"2014 年,有一件让所有人困惑的事……"或"你可能不知道,深度学习曾经有整整两年打不过一个叫做'密集轨迹'的老方法"。
- **使用前提**:这个"反直觉"必须是真的,读者读完之后会说"啊确实,我之前没想到"。如果你说的其实是领域常识,或者打了"反直觉"的标签但内容很平,给读者带来的是被欺骗感。**宁可不写反直觉,也不要凑一个假的**。
- **判断方法**:写完这个反直觉事实后,问自己:一个对该领域有基本了解的读者,在读到这句话之前,他的默认预期是什么?如果这句话真的和他的预期相反,才算数。
2. **写研究者的困境和直觉**:不只说方法,要写"为什么他们会想到这个"。如果能推测研究者当时的思路(有据可查),大胆写出来
3. **反事实推理**:在讲完一个设计决策后,追问"如果不这样做会怎样"。例如"如果只用空间流,会发生什么?实验数据告诉我们:准确率从 88% 掉到 72.6%,整整少了 15 个点"
4. **具体化数字场景**:把抽象数字变成可感受的场景。"20 个百分点的差距,意味着什么?意味着每 5 个动作里,深度学习就要比手工方法多认错 1 个"
5. **图片叙事**:不只是插图,要描述图里能看到什么、这说明了什么。"看这张第一层卷积核的图,你会发现它们大多是方向性的滤波器……这不是巧合,这是网络自己学出来的,它发现光流场里最重要的信息就是方向"
6. **人物和机构背景**(仅限有公开信息的内容):Simonyan 和 Zisserman 是同一个 VGG 组的,他们几个月后发表了 VGGNet——两篇论文是同期的工作。这个细节本身就是故事
7. **历史节点感**:点明这篇论文发表的时刻在历史上的意义。"这是深度学习在视频动作识别上第一次赢过手工特征——在 2014 年,这句话的分量不亚于 AlexNet 横空出世"
---
### B1. 硬性禁止清单(写完必须逐条自检)
| 禁止项 | 上限 | 替换方式 |
|--------|------|---------|
| 破折号(——) | **0 个** | 逗号、句号、冒号 |
| 总之 / 综上所述 / 综上 | **0 个** | 直接写结论 |
| 让我们 / 让我们来拆解 | **0 个** | 直接陈述 |
| 关键在于 / 关键来了 | **0 个** | 直接说关键点 |
| 想象一个世界 | **0 个** | — |
| 不是X而是Y | 最多 1 次 | — |
| 值得注意的是 / 重要的是 / 有趣的是 | **0 个** | 删掉,直接说 |
| delve / landscape / tapestry / robust / leverage | **0 个** | 用简单词 |
| 结尾写"总结" / 总结一下 | **0 个** | 画面或问题收尾 |
| 每个列表项都粗体开头 | **禁止** | 粗体只用于真正重点 |
| 连续的引用块(相邻无正文段落) | **禁止** | 合并到同一个引用块,或中间插入一句正文过渡 |
| 碎片式短句独立成段制造假强调 | **禁止** | 合并为一句 |
| 预告式渲染:"最震撼的部分"/"一针见血" | **禁止** | 删掉,直接呈现内容 |
| "不只是X,更是Y" 排比式拔高 | **禁止** | 直接说那个"Y"是什么 |
| "不只是一个工程方案,更是一种思维方式" 类套话 | **禁止** | 没有信息量,删掉 |
### B2. 文章结构
文章由两部分组成:**正文**(主体解读)+ **写在后面**(意义总结)。
#### 正文结构
```
1. 开头场景引入(不直接说"这篇论文...",用具体场景)
2. 核心问题(这件事难在哪?)
3. 方法解读(每个核心贡献一个 H2 节)
- 是什么 → 为什么这么设计 → 生活化类比(必须有)
- 新术语出现立刻加引用块
- 插入对应论文原图
4. 数据表格(核心实验结果,加粗最佳值)
```
**字数要求**:
- 普通方法论论文:≥ 5000 汉字
- 里程碑论文:≥ 8000 汉字
- **由 B0 第二步的 `is_landmark` 自动判断**,覆盖关键词见上方列表
- **写完必须自检**:`grep -oP '[\x{4e00}-\x{9fff}]' article.md | wc -l`,低于 `min_words` 则继续扩充,不得以"文章完成"为由停笔
**每个 H2 节必须包含的四要素(缺一不可)**:
1. **技术解释**:这个设计是什么,为什么这样设计(不只是"是什么")
2. **生活化类比**:用日常场景说明这个技术决策,每节 ≥ 1 个,全文 ≥ 3 个
3. **论文原图**:插入对应 figure,并用 1-2 句话解释图里能看到什么
4. **意义追问**:这个设计决策解决了什么根本矛盾?如果不这样做会怎样?
**历史现场要求**(论文发表 > 2 年时强制)**:
- 在"核心问题"节或第一个 H2 节中,必须描述该论文发表时领域的具体状态:当时最强的方法是什么、准确率是多少、为什么大家认为这是极限
- 用具体数字说话,不说"当时方法不好",要说"当时最好的方法只有 X%,而手工特征达到 Y%,差了 Z 个百分点"
- 至少提及 1 个这篇论文发表前的代表性工作作为对照基准
**影响链要求**(写在后面)**:
- 必须追溯 ≥ 2 篇直接建立在本论文基础上的后续工作,用具体年份和改进点说明
- 格式参考:"3 年后,X 团队的 Y 论文把这个思路扩展到……,准确率提升到……"
- 禁止只说"影响了后来的研究",必须说出具体是哪篇论文、做了什么改变
#### 写在后面(必须包含,放文章末尾正文之前)
这一节的核心标准只有一个:**必须给读者带来正文里没有的新信息增量**,或者一个真实的感悟、启发、乐趣。不是正文的复述,不是宏观意义的拔高,不是鸡汤。
**可以写的内容**(选其中有货的,不必全写):
- 读到这篇论文时,有什么具体的想法被触发了?(必须是具体的,不是"很有启发")
- 这个方法打破了什么你原来以为是常识的东西?(如果有的话)
- 论文里有什么细节,值得单独拿出来说一说?(比如某个反直觉的实验结果)
- 这个思路让你联想到什么完全不同领域的东西?(只有联想是真实的才写)
- 这篇论文还没解决的问题是什么?值得追问吗?
**格式规范**:
- 用 `## 写在后面` 作为节标题
- 150-300 字,短而有料,不要为了凑字数而展开
- 不强制用"我",但语气要是真实的,不是论文腔
- 结尾可以是一个开放性问题,但必须是你真正想问的,不是套话式的"未来值得期待"
- 禁止破折号、禁止"总之"、禁止"不只是X更是Y"这类排比
**禁止的写法举例**:
- "这篇论文不只是一个工程方案,更是一种思维方式" ← 套话,没有信息量
- "Lighthouse 的贡献对领域影响深远" ← 废话,读者已经知道了
- "未来的研究方向值得关注" ← 永远可以套用,等于没说
- 把正文已经说过的结论再说一遍 ← 没有新增量,直接删掉
#### 结尾升华(紧接"写在后面"之后)
用一个让读者脑子停不下来的画面、故事或问题作为最后一段,不归纳、不总结。
**完整顺序**:
```
论文信息引用块(开头)→ 正文主体 → ## 写在后面 → 结尾升华段落
```
#### 论文信息引用块(**仅开头放一次**,固定格式)
- **开头**:紧跟在 H1 标题之后,正文第一段之前。让读者一眼看到论文出处。
- **末尾不再重复**:结尾是画面或问题收尾,不加信息块。
所有字段均来自 `metadata.json`,**禁止凭记忆填写**。
⚠️ **换行规则**:每行之间必须加空的 `>` 行,否则 Markdown 渲染器会把所有行合并成一段:
```markdown
> **论文原文**:{完整英文标题}
>
> **arXiv**:https://arxiv.org/abs/{arxiv_id}
>
> **发布日期**:{published_date}
>
> **作者**:{前三位作者} et al.({机构})
```
注意:
- 不要在发布日期后加任何括号注释(如"来自 arXiv API,非推测"),直接写日期即可
- 如果 `published_date` 为空,写 `未能获取` 即可,不要猜测
### B3. 图片引用策略
**原则:所有已成功转换的图,都应在文章中找到对应位置引用。**
1. 读取 `figure_list.md`,了解所有可用图及其建议章节
2. 写每个章节时,主动匹配并插入对应图片
3. 不要集中堆放,每张图紧跟在最相关的段落之后
**引用格式(必须用标准 Markdown,禁止用 Obsidian wiki 格式)**:
```markdown
 ✅ 正确
```
```markdown
![[figure2_overview.png]] ❌ 禁止
```
> **为什么禁止 wiki 格式**:Obsidian 渲染 `![[xxx]]` 没问题,但发布到博客时这种格式既不会被图片上传逻辑识别,也无法被 markdown 渲染器解析,结果就是博客上一堆裂图。文章在 Obsidian 里也能正常显示标准 Markdown 格式(路径相对于文章所在目录),所以**永远用标准格式**。即便文章只准备本地阅读,也按标准格式写,避免日后想发布时再返工。
**图片-章节映射参考**:
- teaser / result 展示图 → 开头引入或结尾
- overview / architecture 图(含 TikZ 截图的 fig1_tikz.png 类型)→ 方法总览节,紧跟在介绍整体架构的段落之后
- 模块细节图(含 TikZ 截图的 fig2_tikz.png 类型)→ 对应具体方法节,讲到该模块时插入
- comparison / ablation 图 → 实验数据节
- user study / visualization → 数据分析节
**TikZ 截图图片的引用说明**:文章正文中正常引用,图名用图的实际内容命名而不是 `tikz`(例如 ``)。读者看到的是正常论文图,无需知道截图来源。
### B4. 写作后自检(必须执行,不合格必须修改后才能保存)
```python
import re
text = open(article_path, encoding="utf-8").read()
chinese_count = len(re.findall(r'[\u4e00-\u9fff]', text))
checks = [
(len(re.findall(r'——', text)) == 0, f"破折号: {len(re.findall(r'——', text))} 个(必须为0)"),
(len(re.findall(r'总之|综上|让我们|关键在于|值得注意', text)) == 0,
"禁用词检查(必须为0)"),
(len(re.findall(r'> \*\*', text)) >= 10, f"术语引用块: {len(re.findall(r'> \*\*', text))} 个(≥10)"),
(len(re.findall(r'!\[', text)) >= 3, f"图片引用: {len(re.findall(r'!\[', text))} 张(≥3)"),
('## 写在后面' in text, "写在后面节(必须有)"),
(text.count('> **论文原文**') >= 1, "论文信息引用块(开头一次)"),
(chinese_count >= min_words, f"汉字数: {chinese_count}(要求 ≥ {min_words})"),
]
all_pass = True
for ok, msg in checks:
status = "✅" if ok else "❌"
print(f"{status} {msg}")
if not ok:
all_pass = False
if not all_pass:
print("\n⚠️ 自检未通过,继续补充修改,不得保存!")
# 字数不足时:继续写,展开已有章节,或新增"背景"/"技术细节"/"消融实验"节
else:
print(f"\n✅ 自检通过,汉字数 {chinese_count},保存文章")
```
**保存到** `{paper_dir}/{中文标题}_解读.md`
**完成后**:更新 Todo B 为 completed,C 为 in_progress。
---
## 步骤C:生成 AI 配图
**前提**:`IMAGE_PROVIDER != "skip"`,否则跳过本步骤,直接进入步骤D。
### C1. 封面图
提炼 1-3 个核心关键词(如 `MACE 音乐驱动舞蹈 级联专家`):
```bash
mkdir -p "{paper_dir}/illustrations"
# 生成封面配置
cat > "{paper_dir}/illustrations/visual_config_cover.json" << EOF
{
"task_id": "cover_{paper_id}",
"cover": {
"enabled": true,
"filename": "cover.png",
"style": "paper-watercolor-cover",
"aspect_ratio": "16:9",
"description": "{关键词1} {关键词2} {关键词3}"
},
"illustrations": [],
"defaults": {
"style": "paper-watercolor-cover",
"provider": "{IMAGE_PROVIDER}",
"retry_count": 2
}
}
EOF
python "{IMAGE_GENERATOR_SCRIPT}" \
"{paper_dir}/illustrations/visual_config_cover.json" --workers 1
# 插入文章开头
python3 -c "
content = open('{article_path}').read()
open('{article_path}', 'w').write('\n\n' + content)
"
```
### C2. 纽约客配图(3线程并发)
为每个主要 H2 节设计 visual_description(50-80字中文,具象场景隐喻抽象概念,不写风格指令):
```json
{
"task_id": "illustrations_{paper_id}",
"cover": { "enabled": false },
"illustrations": [
{
"id": "01",
"h2_title": "对应H2标题",
"visual_description": "50-80字具象场景描述",
"filename": "01-slug.png"
}
],
"defaults": {
"style": "newyorker",
"provider": "{IMAGE_PROVIDER}",
"retry_count": 2
}
}
```
```bash
python "{IMAGE_GENERATOR_SCRIPT}" \
"{paper_dir}/illustrations/visual_config.json" --workers 3
```
**完成后**:更新 Todo C 为 completed,D 为 in_progress。
---
## 步骤D:保存 + 打开
### D1. 复制到阅读目录
```python
import shutil
from pathlib import Path
article_path = paper_dir / f"{chinese_title}_解读.md"
images_dir = paper_dir / "images"
dest_dir = READING_DIR
dest_images = dest_dir / f"{paper_id}_images"
dest_dir.mkdir(parents=True, exist_ok=True)
dest_images.mkdir(parents=True, exist_ok=True)
# 复制文章,更新图片路径为新的相对路径
content = article_path.read_text(encoding="utf-8")
content = content.replace("images/", f"{paper_id}_images/")
(dest_dir / article_path.name).write_text(content, encoding="utf-8")
# 复制图片
if images_dir.exists():
for img in images_dir.glob("*.png"):
shutil.copy2(img, dest_images / img.name)
print(f"已复制到: {dest_dir / article_path.name}")
```
### D2. 在 Obsidian 中打开(仅当 OBSIDIAN_VAULT 非空)
```python
import os, urllib.parse
from pathlib import Path
vault = os.environ.get("OBSIDIAN_VAULT", "")
if vault:
reading_dir = Path(os.environ.get("PAPER_READING_DIR", "~/Papers/reading")).expanduser()
dest_file = reading_dir / article_path.name
# 计算相对于 vault 根目录的路径(Obsidian URI 需要 vault 内相对路径)
vault_root = None
for parent in dest_file.parents:
if parent.name == vault:
vault_root = parent
break
if vault_root:
rel = dest_file.relative_to(vault_root)
encoded = urllib.parse.quote(str(rel).replace(".md", ""), safe="/")
else:
# fallback: 只用文件名(去掉 .md)
encoded = urllib.parse.quote(article_path.stem, safe="")
uri = f"obsidian://open?vault={urllib.parse.quote(vault)}&file={encoded}"
os.system(f'open "{uri}"')
```
### D3. 发布到博客
**触发条件**:用户输入包含"发布"/"博客"/"blog"/"post"时,本步骤必须自动执行,不询问用户。
上传本地图片并替换路径,然后发布(status 固定为 draft):
> ⚠️ **API 调用统一走 curl(subprocess)**。博客 API 会用 User-Agent 拦截 Python 默认 `urllib`(返回 403 Forbidden),`requests` 库带的 UA 也不稳定。下面所有 HTTP 请求都用 `curl` 子进程,**不要换成 urllib/requests**。
```python
import json, re, subprocess
from pathlib import Path
TOKEN = open(Path("~/.claude/skills/qiaomu-blog-publish/config.json").expanduser()).read()
TOKEN = json.loads(TOKEN)["token"]
content = article_path.read_text(encoding="utf-8")
title_match = re.search(r'^# (.+)$', content, re.MULTILINE)
title = title_match.group(1).strip()
body = content[title_match.end():].lstrip('\n')
# 🔴 关键修复:Obsidian wiki 格式 (`![[xxx.png]]`) 必须先转成标准 Markdown,
# 否则下面的 `images/` 正则匹配不到 → 不会上传图片 → 发布出来全是裂图。
# 文章写作时本应用标准 Markdown,但若漏写则在此兜底。
def _wiki_to_md(m):
name = m.group(1).strip()
# 兼容 ![[folder/name.png]],只取 basename
base = Path(name).name
if (article_path.parent / "images" / base).exists():
return f""
return m.group(0) # 找不到对应文件就保留原样,让人工 review
body = re.sub(
r'!\[\[([^\]]+\.(?:png|jpg|jpeg|gif|webp|svg))\]\]',
_wiki_to_md, body, flags=re.IGNORECASE
)
# 上传单张图片,带 retry(最多 2 次)
def upload_image(abs_path):
# timeout=60 防止大图超时(870KB 图曾在 30s 内超时)
for _ in range(2):
r = subprocess.run(["curl", "-s", "-X", "POST",
"https://blog.qiaomu.ai/api/uploads",
"-H", f"Authorization: Bearer {TOKEN}",
"-F", f"file=@{abs_path}"], capture_output=True, text=True, timeout=60)
try:
resp = json.loads(r.stdout)
if resp.get("success") and resp.get("url"):
return resp["url"]
except Exception:
pass
return None # 两次都失败,保留原路径
# 并发上传所有图片(最多 5 线程)
import concurrent.futures
local_images = [
(m[0], m[1], article_path.parent / m[1])
for m in re.findall(r'!\[([^\]]*)\]\((images/[^)]+)\)', body)
]
local_images = [(alt, img_path, abs_path)
for alt, img_path, abs_path in local_images if abs_path.exists()]
def upload_one(item):
alt, img_path, abs_path = item
url = upload_image(abs_path)
return img_path, url
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
for img_path, url in pool.map(upload_one, local_images):
if url:
body = body.replace(f"({img_path})", f"({url})")
# 生成 SEO slug:论文英文缩写/代号 + 1-2 个领域词,不含日期
# 规则:全小写、连字符分隔、≤50字符
# 例:"two-stream-video-action-recognition", "bert-masked-language-model"
# 提取英文大写词(模型名/方法名)
_model_words = re.findall(r'\b([A-Z][A-Z0-9\-]{1,})\b', title) # e.g. BERT, CLIP, GPT
_model_slug = "-".join(w.lower() for w in _model_words[:2]) if _model_words else ""
# 补充领域关键词(取自 paper_id 中的小写词,去掉年份和泛词)
_domain_words = [w for w in paper_id.lower().replace("_", "-").split("-")
if len(w) > 3 and w not in ("paper","2014","2015","2016","2017","2018","2019","2020","2021","2022","2023","2024","2025","2026")]
_domain_slug = "-".join(_domain_words[:3])
if _model_slug:
seo_slug = f"{_model_slug}-{_domain_slug}"[:60]
else:
seo_slug = _domain_slug[:60]
seo_slug = re.sub(r'-+', '-', seo_slug).strip('-')
# 去掉正文里的 H1 标题(博客已有单独标题字段,避免重复显示)
_lines = body.split('\n')
for _i, _line in enumerate(_lines):
if _line.startswith('# '):
del _lines[_i]
while _i < len(_lines) and _lines[_i].strip() == '':
del _lines[_i]
break
body = '\n'.join(_lines)
payload = json.dumps({
"title": title, "content": body,
"slug": seo_slug,
"category": "paper", "status": "draft"
}, ensure_ascii=False)
r = subprocess.run(["curl", "-s", "-X", "POST",
"https://blog.qiaomu.ai/api/posts",
"-H", f"Authorization: Bearer {TOKEN}",
"-H", "Content-Type: application/json",
"-d", payload], capture_output=True, text=True)
resp = json.loads(r.stdout)
slug = resp.get("slug", "")
if slug:
print(f"✅ 博客草稿已创建")
print(f" Slug: {slug}")
print(f" 编辑: https://blog.qiaomu.ai/editor?slug={slug}")
print(f" 预览: https://blog.qiaomu.ai/posts/{slug}")
else:
print(f"⚠️ 发布响应异常: {r.stdout[:200]}")
```
### D4. 完成报告
```
✅ 论文解读完成!
📄 标题:{h1_title}
📝 字数:约 X 字
🖼️ 原文图表:N 张引用 / M 张转换(K 张超大跳过)
🎨 封面:已生成 / 已跳过(IMAGE_PROVIDER=skip)
🎭 配图:N 张 / 已跳过
📁 {paper_dir}/
📖 已在 Obsidian 中打开 / Obsidian 未配置,请手动打开
🌐 博客草稿:https://blog.qiaomu.ai/editor?slug={slug}(如触发了发布)
```
---
## 常见问题处理
### arXiv 来源为 HuggingFace 链接
先 WebFetch 页面,找到 `arxiv.org` 链接,再传入 `extract_tex.py`。
### LaTeX 源码不可用(论文未上传 arXiv)
```bash
markitdown -o {paper_dir}/extracted_text.md
```
此情况下无法提取真实图表,`figure_list.md` 标注"不可用",文章中用文字描述代替图片引用。
### extract_tex.py 不存在
该脚本已内置于本 skill 的 `scripts/extract_tex.py`。如文件缺失,重新克隆本 skill 即可。
直接使用 markitdown fallback 亦可。
### 论文图是 TikZ 代码,extract_tex.py 没有提取到
这是正常现象。`extract_tex.py` 只提取 `\includegraphics` 引用的文件;TikZ 图是 LaTeX 代码绘制的矢量图,没有对应的图片文件。
检测方法:在 `extracted_text.md` 里搜索 `**Figure N**:`,如果某张图有文字描述但 `images/` 目录里没有文件,就是 TikZ 图。
解决方案:运行 A4.5 的 PDF 截图流程,或手动执行:
```bash
# 下载 PDF 并渲染指定页面(如第 3 页)
curl -sL "https://arxiv.org/pdf/{arxiv_id}" -o /tmp/paper.pdf
pdftoppm -r 150 -png -f 3 -l 3 /tmp/paper.pdf /tmp/paper_page
# 用 PIL 或 ImageMagick 裁剪图区域
python3 -c "
from PIL import Image
img = Image.open('/tmp/paper_page-03.png')
w, h = img.size
img.crop((0, 0, w, int(h*0.5))).save('{paper_dir}/images/fig1_architecture.png')
"
```
页面编号从 1 开始;Figure 1 通常在第 2-4 页,Figure 2 在第 3-5 页,具体看论文结构。
### 超大图(>20MB)需要强制转换
调低分辨率:
```bash
pdftoppm -r 72 -png -singlefile src.pdf dst
```
### 图片生成失败
如果 `IMAGE_PROVIDER` 配置了但生成失败,跳过配图步骤,保存纯文章版本,在报告中说明。
---
## 质量检查清单(保存前强制)
- [ ] `grep -c '——'` = 0
- [ ] `grep -c '总之\|综上\|让我们\|不只是.*更是'` = 0
- [ ] 术语引用块 ≥ 10 处
- [ ] 生活化类比 ≥ 3 处,每个类比含反事实("如果不这样做...")
- [ ] 数据表格 ≥ 1 个(加粗最佳值)
- [ ] 图片引用数 ≈ 已转换图总数(不漏图)
- [ ] 包含 `## 写在后面` 节(150-300字,有新信息增量,无套话)
- [ ] **开头**(H1 之后)有论文信息引用块(仅此一处,末尾不重复)
- [ ] 结尾以画面/问题收尾,无"总结"段落
- [ ] 逐段增量信息测试(每段提供新信息?)
- [ ] "写在后面"自检:是否包含正文里没有的新信息?删掉套话后还剩什么?
---
## 参考文档
- **LaTeX 提取**:`scripts/extract_tex.py`(已内置,自包含)
- **图片生成**:`~/.agents/skills/qiaomu-image-generator/scripts/generate.py`(可替换,需配置 `IMAGE_GENERATOR_SCRIPT`)
- **博客发布 token**:`~/.claude/skills/qiaomu-blog-publish/config.json`(`{"token":"qm_xxx"}`)
- **写作风格**:已内联到本 skill(步骤 B0),无需外部依赖
- **风格指南**:`references/style-guide.md`
- **使用示例**:`examples.md`
- **故障排查**:`TROUBLESHOOTING.md`
- **配图设计**:`visual_description_guide.md`
- **版本历史**:`CHANGELOG.md`