# Qiaomu Paper Interpreter

> 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.

- Skill: `joeseesun/qiaomu-paper-interpreter` (Agent Skill, multi-file: 16 files)
- Install (CLI): `npx skillmds@latest add joeseesun/qiaomu-paper-interpreter`
- Raw SKILL.md: https://api.skillmd.com/api/skills/joeseesun/qiaomu-paper-interpreter/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: joeseesun (https://skillmd.com/u/joeseesun)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/joeseesun/qiaomu-paper-interpreter

---


# 乔木论文解读

## 概述

将学术论文自动转化为乔木风格的深度解读文章。**全自动执行**，无需用户中途确认。

**核心特点**：
- 对话式语言，像和朋友聊天
- 关键术语用引用块（>）解释，每出现新术语立刻加
- 生活化类比帮助理解，每个核心方法后必须紧跟一个类比
- 真实论文图表嵌入文章（从 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'<published>(.*?)</published>', xml)
        arxiv_meta["published_date"] = m.group(1)[:10] if m else ""
        # ⚠️ 必须从 <entry> 内提取，xml 第一个 <title> 是 feed 标题，不是论文标题
        entries = re.findall(r'<entry>(.*?)</entry>', xml, re.DOTALL)
        entry = entries[0] if entries else ""
        t = re.search(r'<title>(.*?)</title>', entry, re.DOTALL)
        arxiv_meta["title"] = t.group(1).strip().replace('\n', ' ') if t else ""
        arxiv_meta["authors"] = re.findall(r'<name>(.*?)</name>', 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
![图2：系统架构](images/figure2_overview.png)   ✅ 正确
```

```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`（例如 `![图1：Lighthouse Attention 架构图](images/fig1_tikz.png)`）。读者看到的是正常论文图，无需知道截图来源。

### 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('![封面](illustrations/cover.png)\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"![{Path(base).stem}](images/{base})"
    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 <pdf_url_or_path> -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`

