
Qiaomu Paper Interpreter
- 18 installs
- 12 repo stars
- Updated June 1, 2026
- joeseesun/qiaomu-paper-interpreter
Helps with ai & agent building tasks.
About
qiaomu-paper-interpreter is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- qiaomu-paper-interpreter
- AI & Agent Building
- AI-coding skill
Qiaomu Paper Interpreter by the numbers
- 18 all-time installs (skills.sh)
- Ranked #10,710 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joeseesun/qiaomu-paper-interpreter --skill qiaomu-paper-interpreterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 18 |
|---|---|
| repo stars | ★ 12 |
| Last updated | June 1, 2026 |
| Repository | joeseesun/qiaomu-paper-interpreter ↗ |
What it does
Helps with ai & agent building tasks.
Files
乔木论文解读
概述
将学术论文自动转化为乔木风格的深度解读文章。全自动执行,无需用户中途确认。
核心特点:
- 对话式语言,像和朋友聊天
- 关键术语用引用块(>)解释,每出现新术语立刻加
- 生活化类比帮助理解,每个核心方法后必须紧跟一个类比
- 真实论文图表嵌入文章(从 LaTeX 源码精确提取)
- AI 生成纸雕水彩封面 + 《纽约客》风格配图(需配置图片生成服务)
- 写作风格规范已内嵌,无需外部依赖
---
配置(首次使用必读)
配置方式:.env 文件
在 skill 目录下创建 .env 文件(已加入 .gitignore,不会被发布):
# ~/.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 或多项目场景。
配置读取逻辑(每次执行时运行)
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
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:
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 继续。适用于崩溃重试场景。
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 元数据
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 的图(多页定性对比图,不适合放博客);其余全部转换。
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/` 目录里没有对应文件。
**检测代码**:
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 截图流程**:
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
论文图表清单
可用图表(建议全部引用)
| 编号 | 文件 | 大小 | 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. 写作前准备(必须完整执行)
**第一步:读取完整论文内容 + 当前日期**
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 做图文对齐"这样的具体陈述
- 知识截止日期内未发生的事不要猜测,只写确实知道的
**第二步:自动判断是否为里程碑论文(决定字数下限)**
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 渲染器会把所有行合并成一段:
论文原文:{完整英文标题}
>
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 格式)**:
!图2:系统架构 ✅ 正确
![[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. 写作后自检(必须执行,不合格必须修改后才能保存)
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 音乐驱动舞蹈 级联专家`):
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字中文,具象场景隐喻抽象概念,不写风格指令):
{ "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 } }
python "{IMAGE_GENERATOR_SCRIPT}" \ "{paper_dir}/illustrations/visual_config.json" --workers 3
**完成后**:更新 Todo C 为 completed,D 为 in_progress。
---
## 步骤D:保存 + 打开
### D1. 复制到阅读目录
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 非空)
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**。
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}" 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)
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 截图流程,或手动执行:
下载 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)需要强制转换
调低分辨率: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`
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Virtual environments
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.log
# Test outputs (不提交测试生成的图片)
test_output/
*.png
*.jpg
*.jpeg
# 敏感信息(本地配置,不提交)
.env
*.env
.env.*
*_config_local.json
# 个人论文数据(本地工作目录,不提交)
papers/
*.pdf
乔木论文解读 - 架构文档
核心设计原则
关注点分离:
- Claude 负责内容理解和视觉策略生成
- Python 脚本负责图片生成和文档更新
- 共享库提供统一的 API 接口
单一真相源:
- 所有图片生成逻辑集中在
~/.claude/skills/shared-lib/image_api.py - 避免功能重复,确保一致性
目录结构
~/.claude/skills/qiaomu-paper-interpreter/
├── SKILL.md # Skill 定义和使用指南
├── ARCHITECTURE.md # 本文件 - 架构说明
├── README.md # 功能说明和快速开始
├── scripts/
│ ├── generate_illustrations_v2.py # ✅ 主力脚本:并发生成配图
│ ├── generate_illustrations.py # 单线程版本(保留用于调试)
│ ├── finalize_markdown.py # Markdown 最终处理
│ ├── extract_pdf_metadata.py # PDF 元数据提取
│ ├── load_env.py # 环境变量加载器
│ │
│ ├── image_api.py.deprecated # ❌ 已废弃:使用 shared-lib 替代
│ └── image_api.py.deprecated.txt # 废弃说明
│
└── references/
├── workflow.md # 工作流程详解
└── style-guide.md # 视觉风格指南依赖关系
外部服务
本地 Docker 容器
├── 镜像: ghcr.io/zhizinan1997/jimeng-free-api-all:latest
├── 端口: 8000
└── 环境变量: JIMENG_SESSION_ID
↓
即梦官方 API
├── 认证: Bearer Token (session_id)
├── 模型: jimeng-image-4.5 (付费) / jimeng-image-4.1 (免费)
└── 限额: 66积分/天 (免费版)代码依赖
scripts/generate_illustrations_v2.py
↓
导入优先级设置
sys.path.insert(0, '~/.claude/skills/shared-lib')
↓
~/.claude/skills/shared-lib/image_api.py
├── ImageGenerator.generate_newyorker_style()
│ ├── 构建统一提示词模板
│ ├── 调用 _generate_jimeng()
│ └── Fallback: _generate_gemini()
│
├── _generate_jimeng()
│ └── POST http://localhost:8000/v1/images/generations
│
└── save_image()
└── 下载并保存 PNG核心工作流
1. 配图生成流程
用户触发 Skill
↓
Claude 分析文章内容
↓
生成 visual_config.json
├── h2_title: "章节标题"
├── visual_description: "具体视觉描述"
└── caption: "底部标题"
↓
调用 generate_illustrations_v2.py
├── 封面生成 (关键词 → 视觉策略)
├── 章节配图并发生成 (3线程池)
│ ├── ImageGenerator.generate_newyorker_style()
│ ├── 重试机制 (最多3次)
│ └── 文件命名: {paper_slug}-{idx:02d}.png
│
└── 自动插入 Markdown
├── 线程安全 (markdown_lock)
├── 防重复检查
└── Obsidian 兼容路径处理
↓
输出: 带配图的完整文章2. 图片生成技术细节
提示词架构 (已优化,零文字干扰):
prompt = f"""纽约客杂志插图风格:钢笔线条速写,黑白为主,朱红色点缀,简约留白。
{visual_strategy} # ← 唯一动态内容
16:9横幅构图,手绘松弛质感{caption_instruction}"""关键优化点:
- ✅ 只包含抽象的
visual_strategy参数 - ✅ 移除所有结构化标记 (
主题:,核心观点:) - ✅ 移除所有具体内容变量 (避免渲染成文字)
- ✅ 自然语言描述比例 ("横向宽幅构图16:9")
- ✅ 明确禁止文字 ("画面中不要任何文字")
环境配置
必需环境变量
# .env 文件或 shell 配置
export JIMENG_SESSION_ID="your_session_id_here" # 必需
export JIMENG_API_URL="http://localhost:8000" # 可选,默认值获取 Session ID
1. 访问 https://jimeng.jianying.com/ 2. 开发者工具 (F12) → Application → Cookies 3. 复制 sessionid 值
启动 Docker 服务
# 检查服务状态
docker ps | grep jimeng-free-api
# 如未运行,启动服务
docker run -it -d \
--init \
--name jimeng-free-api \
-p 8000:8000 \
-e TZ=Asia/Shanghai \
ghcr.io/zhizinan1997/jimeng-free-api-all:latest性能优化
并发策略
- 线程池大小: 3 (平衡速度与限流)
- 理论加速比: 3× (实测接近)
- 单张耗时: 10-30秒 (jimeng-image-4.1)
示例:
- 12张图串行: 348秒 (约6分钟)
- 12张图并发: 116秒 (约2分钟)
容错机制
1. 重试策略: 每个 API 调用最多重试 3 次 2. Fallback: jimeng 失败自动切换到 gemini 3. 线程安全: markdown_lock 保护文件写入 4. 防重复: 检查现有图片引用,避免重复插入
测试验证
快速测试
cd /tmp && python3 << 'EOF'
import sys
from pathlib import Path
sys.path.insert(0, str(Path.home() / '.claude' / 'skills' / 'shared-lib'))
from image_api import ImageGenerator
gen = ImageGenerator(provider='jimeng', jimeng_model='jimeng-image-4.1')
url, provider = gen.generate_newyorker_style(
visual_strategy="一只橘色短毛猫坐在窗台上,背景是城市夜景",
aspect_ratio='16:9'
)
gen.save_image(url, "/tmp/test.png")
print(f"✅ 测试成功: {provider}, /tmp/test.png")
EOF预期输出
✅ 测试成功: jimeng, /tmp/test.png验证图片:
file /tmp/test.png
# 输出: PNG image data, 2560 x 1440, 8-bit/color RGB故障排查
问题 1: API 连接失败
症状: Connection refused 或 timeout
解决:
# 检查 Docker 状态
docker ps | grep jimeng-free-api
# 重启服务
docker restart jimeng-free-api
# 查看日志
docker logs jimeng-free-api问题 2: 认证失败
症状: 401 Unauthorized
解决:
# 检查环境变量
echo $JIMENG_SESSION_ID
# Session ID 过期,重新获取
# 1. 访问 https://jimeng.jianying.com/
# 2. 开发者工具获取新 sessionid
# 3. 更新环境变量
export JIMENG_SESSION_ID="new_session_id"问题 3: 积分不足
症状: 提示积分用尽
解决:
- 等待第二天 0 点自动刷新 (66积分)
- 或使用付费模型
jimeng-image-4.5
问题 4: 图片有文字干扰
症状: 生成图片包含不需要的文字
诊断:
# 检查 visual_strategy 是否包含具体内容
# ❌ 错误: "主题:残差学习,核心观点:解决退化问题"
# ✅ 正确: "用隐喻手法表现深度学习中的残差连接"解决: 使用抽象的视觉描述,避免具体术语
版本历史
v2.0.0 (Current)
- ✅ 迁移到 shared-lib 统一接口
- ✅ 废弃本地 image_api.py
- ✅ 优化提示词模板 (零文字干扰)
- ✅ 添加架构文档
v1.0.0
- 初始实现
- 本地 image_api.py (已废弃)
- 存在文字干扰问题
相关资源
- 共享库:
~/.claude/skills/shared-lib/image_api.py - 即梦官网: https://jimeng.jianying.com/
- Docker 镜像: ghcr.io/zhizinan1997/jimeng-free-api-all
- 相关 Skills:
- article-illustrator (文章配图)
- jimeng-image-generator (单图生成)
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Fixed
1. Table截图只包含标题不包含表格数据
- [Critical] Table截图只包含标题不包含表格数据
- 问题:
extract_all_figures.py对Table的截图区域计算错误 - 根因:代码假设Table标题在表格上方,实际PDF中Table标题通常在表格下方
- Bug代码:
# 旧逻辑(错误)
y0 = max(0, inst.y0 - 20) # 只往上偏移20pt
y1 = min(page_height, inst.y1 + 400) # 往下400pt找表格- 表现:截图只包含"Table X: ..."标题文字和下方说明,表格主体在上方被漏掉
- 修复:
# 新逻辑(正确)
y0 = max(0, inst.y0 - 400) # 往上400pt包含表格主体
y1 = min(page_height, inst.y1 + 100) # 往下100pt包含说明文字- 验证:T5论文的Table 1/2/3现在都包含完整表格数据
- 影响:
scripts/extract_all_figures.py:102-106 - Linus品味:注释说"标题在上方"但代码按"标题在下方"写,注释和实现不一致是Bug的温床
2. 重复提取图表导致文件覆盖
- [Critical] 跨页图表重复提取,后者覆盖前者
- 问题:同一个图表被提取多次,后面的覆盖前面的,导致文件名与内容不匹配
- 表现:
- T5论文提取33次,实际只有22个独立图表
table3.png实际内容是Table 5(被第22页的Table 5覆盖)- 11个图表重复提取:Figure 1/3/4/6, Table 2/3/4/8/11/12/14
- 根因1 - 无法区分定义和引用:
# 旧正则(错误)- 匹配所有"Table X:"和"Table X."
r'(Table)\s+(\d+)\s*[:\.]'- 问题:既匹配定义("Table 3: Examples...")也匹配引用("shown in Table 3.")
- 结果:同一页多次出现同一个Table编号,全部被提取
- 根因2 - 去重只在单页内有效:
# 旧逻辑(错误)- 每页重新初始化seen字典
for page_num, page in enumerate(doc, 1):
seen = {} # 页面级去重,跨页失效- 问题:跨页图表在第一页提取一次,第二页又提取一次
- 结果:文件被覆盖,
table3.png变成最后一次保存的内容 - 修复方案:
# 1. 只匹配定义(冒号),忽略引用(句号)
patterns = [
r'(Figure)\s+(\d+)\s*:', # 只匹配"Figure X:"
r'(Table)\s+(\d+)\s*:', # 只匹配"Table X:"
]
# 2. 全局去重,跨页面维护
global_seen = {} # 移到循环外
for page_num, page in enumerate(doc, 1):
# 页面内去重
page_seen = {...}
# 全局去重检查
for key in page_seen:
if key not in global_seen:
global_seen[key] = page_num
# 只提取首次出现的- 效果对比:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 提取次数 | 33 | 22 |
| 重复次数 | 11 | 0 |
| Figure 1 | 2次(第3/9页) | 1次(第3页) |
| Table 3 | 2次(第21/22页,被覆盖) | 1次(第21页,正确) |
| table3.png内容 | Table 5(错误) | Table 3(正确) |
- 影响文件:
scripts/extract_all_figures.py:39-83 - Linus品味:
- 去重是消除特殊情况的经典案例
- 全局状态(global_seen)比局部状态(seen per page)更简单
- 冒号vs句号的区分让模式匹配更精确,减少if/else分支
---
[2.0.0] - 2026-01-02
Changed - 架构升级
- [Breaking] PDF处理引擎替换:从 pdfplumber 迁移到 Markitdown
- 原因:pdfplumber 是低级字节流解析,输出混乱(空格丢失、公式错乱、表格无法识别)
- 新方案:Markitdown 理解文档结构,输出标准Markdown(格式完整、表格清晰、公式保留)
- 文件格式:
extracted_text.txt→extracted_text.md - 架构哲学:消除"格式后处理"这个特殊情况,直接获得结构化数据
- 性能对比:
- pdfplumber: 51,630字符,空格丢失,需大量后处理
- Markitdown: 57,968字符,保留完整格式,开箱即用
- Linus品味原则:不要为每种特殊情况写if/else,选择让特殊情况消失的设计
Added
- Markitdown 支持:
- ✅ 完整空格和标点保留
- ✅ 数学公式格式完整(
x𝑙+1 = x𝑙 + F (x𝑙, W𝑙)) - ✅ 表格自动转换为Markdown表格(管道符分隔)
- ✅ 图表描述清晰可读
- ✅ 文档结构语义化(标题、引用、列表)
- ✅ 可选OCR支持(需要tesseract)
Fixed
- 文本提取质量问题:
- 空格丢失:
ZhendaXie→Zhenda Xie - 公式格式:
=x𝑙→= x𝑙 - 表格混乱:原始文本流 → 结构化Markdown表格
Documentation
- 更新所有文档中的
extracted_text.txt→extracted_text.md - 更新 SKILL.md 步骤1:详细说明Markitdown架构优势
- 更新 TROUBLESHOOTING.md:Markitdown内置OCR支持
Migration Guide
无需用户操作,skill自动使用新方式。旧的 extracted_text.txt 仍可读但不再生成。
[1.2.2] - 2025-12-31
Fixed
- [Critical] Obsidian 图片索引冲突:文件名全局唯一化
- 问题:不同论文都使用
illustration_1.png等通用名称,Obsidian 索引冲突导致图片预览/链接混乱 - 解决:文件名包含论文标识符
{paper_slug}-{idx:02d}.png - 示例:
Deep_Residual_Learning_2015-01.png,BERT_2018-01.png - 优势:文件名自描述、全局唯一、不依赖目录隔离
- 影响:修复
generate_illustrations_v2.pyLine 143-146
Changed
- 文件命名策略:从
illustration_{idx}.png改为{paper_slug}-{idx:02d}.png - 路径生成逻辑:保持相对路径不变,只改文件名
[1.2.1] - 2025-12-23
Fixed
- [Critical] 图片路径错误:修复finalize后图片路径失效的问题
- 问题:生成配图时使用相对路径
images/illustrations/,文章移动到根目录后路径错误 - 解决:生成时直接使用完整相对路径
papers/{paper_id}/images/illustrations/ - 影响:防止不同论文的配图互相干扰
- 图片路径验证:finalize_markdown.py新增图片路径自动验证
- 在保存最终文章时,自动检查所有图片路径是否存在
- 显示无效路径的详细列表,便于排查问题
Documentation
- 新增
STRUCTURE_OPTIMIZATION_PLAN.md- 文件组织结构优化方案 - 诊断当前问题:路径混乱、命名不规范、中间产物混杂
- 提出优化方案:分离式结构(source/workspace/assets)
- 制定实施步骤:P0立即修复,P1短期优化,P2长期完善
[1.2.0] - 2025-12-23
Performance
- ⚡ 并发生成配图:3线程并发,性能提升3倍(12张图从6分钟降至2分钟)
- 新增:
generate_illustrations_parallel.py并发版本脚本 - 线程安全:使用锁保护markdown文件读写
- 智能调度:ThreadPoolExecutor实现任务并发
- 可配置:
--workers N参数控制并发数
Fixed
- [Critical] image_api.py返回值bug:修复provider模式下返回tuple类型不匹配
- 问题:
generate_newyorker_style()在jimeng/gemini模式返回str,auto模式返回tuple - 解决:统一返回
(image_url, provider_used)tuple - 防重复插入配图:增强检测逻辑避免并发时重复插入
- 检查相同图片路径
- 检查是否已有其他illustration图片
- 线程安全的文件读写
Improved
- 友好的错误提示:区分超时、服务不可用等不同错误类型
- 更好的进度显示:实时显示每张图的生成状态和耗时
- 性能统计:显示总耗时、平均耗时、加速比等指标
Documentation
- 更新
SKILL.md配图生成章节,增加性能优化说明 - 记录API性能:即梦约29秒/张,Gemini当前不可用(503)
[1.1.0] - 2025-12-23
Fixed
- [Critical] 配图生成prompt修复:使用中文描述风格特征,避免AI将英文指令误读为要画的文字
- 修复:
shared-lib/image_api.py - 问题:英文"The New Yorker"、"#E34334"等被画成文字内容
- 解决:改用"钢笔墨水速写"、"朱红色点缀"等中文描述
Added
- 16:9横幅比例支持:更适合文章配图的宽屏比例
- 底部中文标题:《纽约客》经典元素,一句话点题
- visual_description编写指南:详细的配图设计规范和检查清单
- 配图改进对比文档:展示优化前后的具体差异
Changed
generate_newyorker_style()增加caption参数支持底部标题- 默认aspect_ratio从4:3改为16:9
- visual_config.json格式更新,增加
caption和core_point字段
Documentation
- 新增
visual_description_guide.md- 配图设计编写指南 - 新增
README.md- 项目概述和快速开始 - 新增
CHANGELOG.md- 版本变更记录 - 更新
SKILL.md- 添加"最近更新"章节
[1.0.0] - 2025-12-22
Added
- 初始版本
- 智能PDF管理:自动提取元数据,生成有意义的文件名
- 论文图表自动提取:批量提取所有Figure和Table
- 乔木风格文章生成:对话式语言,术语解释,生活化类比
- 《纽约客》风格配图生成:配置驱动workflow
- 最终化处理:提取H1标题作为文件名
Technical
scripts/extract_pdf_metadata.py- PDF元数据提取scripts/extract_all_figures.py- 批量图表提取scripts/generate_illustrations_v2.py- 配图生成scripts/finalize_markdown.py- 最终化处理shared-lib/image_api.py- 统一图片生成API
[Unreleased]: https://github.com/yourusername/qiaomu-paper-interpreter/compare/v1.1.0...HEAD [1.1.0]: https://github.com/yourusername/qiaomu-paper-interpreter/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/yourusername/qiaomu-paper-interpreter/releases/tag/v1.0.0
使用示例
完整工作流示例
示例1:解读Transformer论文
用户输入:
解读 https://arxiv.org/pdf/1706.03762执行流程:
0. 智能PDF管理:
✓ 下载PDF(临时)
✓ 提取标题:"Attention Is All You Need"
✓ 提取年份:2017 (从URL)
✓ 生成paper_id:Transformer_2017
✓ 创建目录:papers/Transformer_2017/
✓ 重命名PDF:papers/Transformer_2017/Transformer_2017.pdf
✓ 保存元数据:papers/Transformer_2017/metadata.json
1. 提取文本 → papers/Transformer_2017/extracted_text.md(Markitdown格式化)
2. 自动生成完整解读(包含:
- 故事化引入
- 15+个术语解释(引用块)
- 5处生活化类比
- 3个数据对比表格
- 4处配图标注
- 方法论思考)
3. 保存到工作目录 → papers/Transformer_2017/Transformer论文_解读.md
4. 执行图表提取(带前缀 Transformer_2017):
✓ 提取 Figure 1 -> papers/Transformer_2017/images/Transformer_2017_figure1.png
✓ 提取 Figure 2 -> papers/Transformer_2017/images/Transformer_2017_figure2.png
✓ 提取 Table 1 -> papers/Transformer_2017/images/Transformer_2017_table1.png
✓ 提取 Table 2 -> papers/Transformer_2017/images/Transformer_2017_table2.png
5. 生成《纽约客》风格配图(带前缀 Transformer_2017):
[1/10] 正在为「旧世界的问题」生成配图...
✓ 图片已保存: papers/Transformer_2017/images/illustrations/illustration_1.png
✓ 已插入到markdown
[2/10] 正在为「注意力的魔法」生成配图...
✓ 图片已保存: papers/Transformer_2017/images/illustrations/illustration_2.png
✓ 已插入到markdown
... (共10张配图)
✅ 完成!成功生成 10/10 张配图
6. 提取H1标题并保存最终文件:
✓ 提取H1标题:"注意力即一切:AI架构的范式革命"
✓ 删除文章中的H1行
✓ 保存到根目录 → ./注意力即一切:AI架构的范式革命.md
7. 报告完成
总耗时:约4-6分钟(含配图生成)最终文件结构:
./
├── 注意力即一切:AI架构的范式革命.md ← 最终文件(用H1标题命名,无H1)
└── papers/ ← 统一管理所有论文
└── Transformer_2017/ ← 论文标识_年份
├── Transformer_2017.pdf ← 原始PDF(已重命名)
├── metadata.json ← 元数据(标题、作者、年份等)
├── extracted_text.md ← 提取的完整文本(Markdown格式)
├── Transformer论文_解读.md ← 工作副本(保留H1标题)
└── images/
├── Transformer_2017_figure1.png ← 论文原图(带前缀)
├── Transformer_2017_figure2.png
├── Transformer_2017_table1.png
├── Transformer_2017_table2.png
└── illustrations/ ← 《纽约客》风格配图
├── illustration_1.png
├── illustration_2.png
├── illustration_3.png
└── ... (共10张)生成的文章特点:
- 字数:约9000字
- 术语解释:15+处引用块
- 生活化类比:5+处
- 数据表格:3个
- 论文原图:5张(Figure)+ 4张(Table)
- 纽约客配图:10张
---
示例2:解读T5论文
用户输入:
用乔木风格解读这篇paper: https://arxiv.org/pdf/1910.10683执行流程:
0. 智能PDF管理:
✓ 下载PDF
✓ 提取标题:"Exploring the Limits of Transfer Learning..."
✓ 生成paper_id:T5_2019
✓ 创建目录:papers/T5_2019/
1-6. [执行完整workflow...]
7. 最终输出:
📄 AI模型的统一范式:T5的突破.md
📁 papers/T5_2019/ (完整档案)---
示例3:快速触发方式
方式1:直接提供URL
https://arxiv.org/pdf/1706.03762方式2:明确指令
解读这篇论文 https://arxiv.org/pdf/1706.03762方式3:使用skill命令
/qiaomu-paper-interpreter https://arxiv.org/pdf/1706.03762---
输出示例
元数据文件(metadata.json)
{
"paper_id": "Transformer_2017",
"title": "Attention Is All You Need",
"year": "2017",
"authors": [
"Ashish Vaswani",
"Noam Shazeer",
"Niki Parmar",
"Jakob Uszkoreit",
"Llion Jones",
"Aidan N. Gomez",
"Lukasz Kaiser",
"Illia Polosukhin"
],
"source_url": "https://arxiv.org/pdf/1706.03762",
"extracted_at": "2025-12-23T17:05:00",
"original_filename": "1706.03762.pdf"
}图表列表(figure_list.md)
## Figure 1 (第3页)

## Figure 2 (第4页)

## Table 1 (第6页)

## Table 2 (第8页)
配图配置(visual_config.json)
{
"article_title": "Transformer论文_解读",
"sections": [
{
"h2_title": "旧世界的问题",
"visual_description": "工厂流水线场景,8个工人站成一排,只有第一个工人在忙碌工作,其余7人无所事事地等待,用黑白线条绘制,朱红色标注第一个工人",
"caption": "七个GPU在等待",
"core_point": "RNN必须串行处理,8个GPU只能排队等待"
},
{
"h2_title": "注意力的魔法",
"visual_description": "图书馆场景,中央有一个目录检索台,多条虚线连接到不同书架上亮起的书本,展示Query-Key-Value匹配机制",
"caption": "查询、匹配、获取",
"core_point": "Query-Key-Value机制:查询匹配,直接定位相关信息"
}
]
}---
常见使用场景
场景1:学习新论文
需求:快速理解一篇复杂的学术论文 操作:提供PDF URL 输出:通俗易懂的解读文章 + 完整档案
场景2:准备分享内容
需求:把论文改写成公众号文章 操作:指定"用乔木风格解读" 输出:带配图的完整文章,可直接发布
场景3:建立论文库
需求:系统化管理多篇论文 操作:依次解读多篇论文 输出:
papers/
├── Transformer_2017/
├── BERT_2018/
├── GPT3_2020/
└── T5_2019/每篇论文都有完整的元数据、原图、解读
场景4:深度研究
需求:详细分析论文的实验数据 操作:解读后,查看images/目录的所有图表 输出:所有Figure和Table的高清截图
---
性能指标
| 指标 | 数值 |
|---|---|
| 平均执行时间 | 4-6分钟 |
| 文章字数 | 8000-10000字 |
| 术语解释 | 15+处 |
| 生活化类比 | 5+处 |
| 论文图表提取 | 自动识别全部 |
| 纽约客配图 | 每个H2标题1张 |
| PDF文本提取准确率 | >95% |
| 图表提取成功率 | >90% |
---
优势总结
✅ 全自动:无需手动干预,一键生成 ✅ 高质量:专业内容 + 通俗语言 ✅ 完整归档:PDF + 图表 + 元数据 + 解读 ✅ 可复用:所有资料规范化保存 ✅ 可发布:带配图,适合公众号 ✅ 可扩展:轻松建立论文知识库
📁 文件位置规范
核心原则
所有过程文件统一在工作目录,最终文章复制到阅读目录
---
目录结构
工作目录:20-29 学习/25 论文库/21.01 papers/{paper_id}/
所有过程文件都在这里,完整保留论文档案:
20-29 学习/25 论文库/21.01 papers/LLM_Agents_2023/
├── source/
│ ├── LLM_Agents_2023.pdf # 原始PDF
│ ├── metadata.json # 元数据
│ └── extracted_text.md # 提取文本
│
├── images/ # 原文图表
│ ├── LLM_Agents_2023_figure1.png
│ ├── LLM_Agents_2023_figure2.png
│ └── figure_list.md
│
├── illustrations/ # AI生成配图
│ ├── 三个魔法组件.png
│ ├── 规划_会拆解任务的大脑.png
│ └── ...
│
├── work/ # 临时文件
│ └── visual_config.json
│
└── 让AI像人一样思考_解读.md # 最终文章(工作副本)阅读目录:20-29 学习/25 论文库/21.02 论文解读/
最终文章复制到这里,方便阅读:
20-29 学习/25 论文库/21.02 论文解读/
├── 让AI像人一样思考_解读.md
├── 深度学习的本质_解读.md
└── ...---
为什么这样设计?
✅ 工作目录保留完整档案
- 原始PDF + 图表 + 配图全在一起
- 方便后续引用、修改、备份
✅ 阅读目录简洁清晰
- 只有最终文章,无杂乱文件
- 统一位置,方便浏览和搜索
✅ 双份保留,互不干扰
- 工作目录:完整资料,供深度使用
- 阅读目录:纯净文章,供快速阅读
---
脚本修改要点
所有涉及路径的脚本需要修改:
1. extract_pdf_metadata.py
BASE_DIR = "20-29 学习/25 论文库/21.01 papers" # 固定2. generate_illustrations_v2.py
# 输出到论文目录下的 illustrations/
output_dir = f"{paper_dir}/illustrations"3. finalize_markdown.py
# 最终文章复制到两个位置
work_copy = f"{paper_dir}/{title}_解读.md"
reading_copy = f"20-29 学习/25 论文库/21.02 论文解读/{title}_解读.md"4. visual_config.json
# 生成在 work/ 子目录
config_path = f"{paper_dir}/work/visual_config.json"---
迁移清单
- [x] 创建
20-29 学习/25 论文库/21.02 论文解读/目录 - [x] 移动现有文章到新位置
- [x] 清理根目录临时文件
- [ ] 修改脚本路径配置
- [ ] 更新 SKILL.md 说明
- [ ] 测试完整流程
---
详见:FILE_MANAGEMENT.md 完整设计文档
Git 管理指南
仓库结构
~/.claude/skills/
├── qiaomu-paper-interpreter/ # 论文解读skill(独立git仓库)
│ ├── .git/
│ ├── README.md
│ ├── CHANGELOG.md
│ ├── VERSION
│ └── ...
│
└── shared-lib/ # 共享库(独立git仓库)
├── .git/
├── README.md
├── image_api.py
└── ...快速命令
查看状态
# 论文解读skill
cd ~/.claude/skills/qiaomu-paper-interpreter
git status
git log --oneline
# 共享库
cd ~/.claude/skills/shared-lib
git status
git log --oneline提交更改
# 论文解读skill
cd ~/.claude/skills/qiaomu-paper-interpreter
git add .
git commit -m "✨ 描述你的更改"
# 共享库
cd ~/.claude/skills/shared-lib
git add .
git commit -m "✨ 描述你的更改"查看变更
# 查看未提交的更改
git diff
# 查看某个文件的历史
git log -p scripts/generate_illustrations_v2.py
# 查看某次提交的详情
git show ec0960f版本回退(谨慎)
# 撤销最后一次提交(保留更改)
git reset --soft HEAD~1
# 撤销最后一次提交(丢弃更改,危险!)
git reset --hard HEAD~1
# 查看某个历史版本
git checkout ec0960f
# 返回最新版本
git checkout main提交规范
使用 Conventional Commits 格式:
✨ feat:新功能🐛 fix:修复bug📝 docs:文档更新♻️ refactor:代码重构🎨 style:代码格式⚡️ perf:性能优化✅ test:测试相关🔧 chore:构建/工具相关
示例:
git commit -m "✨ feat: 添加16:9横幅比例支持"
git commit -m "🐛 fix: 修复英文prompt被误读问题"
git commit -m "📝 docs: 更新配图设计指南"版本发布流程
1. 更新版本号
echo "1.2.0" > VERSION2. 更新CHANGELOG.md
- 添加新版本的变更记录
- 移动Unreleased到新版本
3. 提交并打tag
git add VERSION CHANGELOG.md
git commit -m "🔖 Release v1.2.0"
git tag -a v1.2.0 -m "Release v1.2.0"4. 查看所有tag
git tag
git show v1.2.0当前版本
qiaomu-paper-interpreter: v1.1.0
- ✅ 配图生成prompt使用中文描述
- ✅ 支持16:9横幅比例
- ✅ 支持底部中文标题
shared-lib: v1.1.0
- ✅ image_api.py 修复后的图片生成API
- ✅ 自动fallback机制(即梦→Gemini)
常见问题
Q: 如何查看某个文件的修改历史?
git log --follow -p -- scripts/generate_illustrations_v2.pyQ: 如何比较两个版本的差异?
git diff ec0960f ae7f6a3Q: 如何恢复删除的文件?
# 如果还未提交
git checkout -- 文件名
# 如果已提交,从历史恢复
git checkout 历史提交ID -- 文件名Q: 如何创建分支试验新功能?
# 创建并切换到新分支
git checkout -b feature/new-style
# 试验完成后合并回main
git checkout main
git merge feature/new-style
# 删除分支
git branch -d feature/new-style备份建议
虽然本地有git版本控制,但建议定期:
1. 推送到远程仓库(如GitHub)
git remote add origin https://github.com/yourname/qiaomu-paper-interpreter.git
git push -u origin main2. 或者手动备份
cd ~/.claude/skills
tar -czf skills-backup-$(date +%Y%m%d).tar.gz qiaomu-paper-interpreter shared-lib协作工作流
如果多人协作:
# 拉取最新代码
git pull origin main
# 创建功能分支
git checkout -b feature/your-feature
# 开发完成后推送
git push origin feature/your-feature
# 创建Pull Request合并到mainMIT License
Copyright (c) 2026 joeseesun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
乔木论文解读Skill优化总结 v1.2.0
🎯 优化目标
基于实践发现的性能瓶颈和稳定性问题,对配图生成流程进行系统性优化。
📊 性能提升
生成速度对比
| 指标 | 优化前(串行) | 优化后(并发) | 提升 |
|---|---|---|---|
| 12张图总耗时 | 348秒(约6分钟) | 116秒(约2分钟) | 3倍 |
| 单张平均 | 29秒 | 29秒(API时间) | - |
| 并发数 | 1 | 3(可配置) | - |
| 理论加速比 | 1x | 3x | 3倍 |
API性能基准
- 即梦API:单张约29秒 ✅ 稳定可用
- Gemini API:503错误 ❌ 当前不可用
🔧 关键修复
1. [Critical] image_api.py返回值bug
问题:
# 错误:jimeng/gemini模式返回str,auto模式返回tuple
if self.provider == 'jimeng':
return self._generate_jimeng(...) # 返回str
elif self.provider == 'gemini':
return self._generate_gemini(...) # 返回str
else: # auto
url = self._generate_jimeng(...)
return url, 'jimeng' # 返回tuple解决:
# 统一返回tuple
if self.provider == 'jimeng':
url = self._generate_jimeng(...)
return url, 'jimeng' # ✅ 统一返回tuple
elif self.provider == 'gemini':
url = self._generate_gemini(...)
return url, 'gemini' # ✅ 统一返回tuple影响:修复后所有模式都返回(image_url, provider_used),避免类型错误。
2. 并发竞态条件
问题:多线程同时读写markdown文件导致文件损坏(UnicodeDecodeError)
解决:
import threading
# 全局锁保护markdown文件的读写
markdown_lock = threading.Lock()
def insert_image_into_markdown(markdown_path, h2_title, image_path):
with markdown_lock: # 🔒 线程安全
# 读写操作3. 重复插入配图
问题:并发时同一配图可能被插入多次
解决:
def insert_image_into_markdown(markdown_path, h2_title, image_path):
with markdown_lock:
# 严格检查
if next_line.startswith('![') and image_path in next_line:
return False # 已存在相同图片
if 'illustration_' in next_line:
return False # 已有其他配图⚡ 性能优化
并发架构
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=3) as executor:
# 提交所有任务
futures = [executor.submit(generate_single_image, task) for task in tasks]
# 收集结果(按完成顺序)
for future in as_completed(futures):
result = future.result()特性:
- 3线程并发(可通过
--workers N调整) - 实时进度显示
- 自动负载均衡
- 失败不影响其他任务
智能错误处理
try:
# 生成图片
except Exception as e:
# 友好的错误提示
if 'timeout' in str(e).lower():
result['message'] = "❌ 超时: API响应时间过长"
elif '503' in str(e):
result['message'] = "❌ API服务不可用"📝 文档更新
SKILL.md
- ✅ 更新配图生成章节,增加性能说明
- ✅ 添加
--workers参数说明 - ✅ 记录API性能基准
CHANGELOG.md
- ✅ 新增v1.2.0版本记录
- ✅ 详细记录性能优化和bug修复
VERSION
- ✅ 更新到1.2.0
🧪 测试验证
创建了完整的自动化测试脚本 test_skill_workflow.sh:
✅ 所有测试通过!
📊 优化总结:
- 并发生成:3线程并发,性能提升3倍
- 线程安全:文件读写加锁
- 防重复:智能检测已插入的配图
- Bug修复:image_api.py返回值统一📦 文件清单
新增文件:
scripts/generate_illustrations_parallel.py- 并发版本(保留)OPTIMIZATION_SUMMARY.md- 本优化总结
修改文件:
scripts/generate_illustrations_v2.py- 替换为并发版本shared-lib/image_api.py- 修复返回值bugSKILL.md- 更新配图章节CHANGELOG.md- 新增v1.2.0VERSION- 1.1.0 → 1.2.0
备份文件:
scripts/generate_illustrations_v2_backup_20251223.py- 旧版本备份
🚀 使用示例
1. 创建配置模板
python ~/.claude/skills/qiaomu-paper-interpreter/scripts/generate_illustrations_v2.py \
--create-template "文章.md"2. 填写配置
编辑 visual_config.json,为每个章节设计视觉场景。
3. 并发生成配图
python ~/.claude/skills/qiaomu-paper-interpreter/scripts/generate_illustrations_v2.py \
"文章.md" --workers 3性能:12张图约2分钟(vs 原来6分钟)
💡 最佳实践
1. 并发数建议:3-4线程,避免API限流 2. 网络环境:确保即梦API可访问 3. 错误处理:失败的图片可单独重新生成(--no-skip) 4. 防重复:已插入的配图会自动跳过
📈 未来优化方向
- [ ] 支持更多图片生成API(Stable Diffusion等)
- [ ] 增加图片质量评估和自动重试
- [ ] 支持批量处理多篇文章
- [ ] 增加配图预览和交互式选择
---
优化日期:2025-12-23 版本:v1.2.0 贡献者:Claude Sonnet 4.5
{
"paper_id": "Your_Group_Relative_2026",
"title": "Your Group-Relative Advantage Is Biased",
"year": "2026",
"authors": [
"Fengkai Yang; Zherui Chen; Xiaohan Wang; Xiaodong Lu; Jiajun Chai; Guojun Yin; Wei Lin; Shuai Ma; Fuzhen Zhuang; Deqing Wang; Yaodong Yang; Jianxin Li; Yikun Ban"
],
"source_url": "https://arxiv.org/pdf/2601.08521",
"extracted_at": "2026-01-21T21:33:59.036105",
"original_filename": "paper_temp.pdf"
}路径问题紧急修复总结 v1.2.1
问题复现
用户在解读LSTM论文后发现:配图路径错误,可能引用到其他论文的配图。
根本原因
1. generate_illustrations_v2.py 生成相对路径 images/illustrations/illustration_1.png 2. 在工作目录 papers/LSTM_1997/ 时,相对路径正确指向该目录下的配图 3. finalize_markdown.py 将文章移动到根目录后,相对路径变成了根目录下的 images/illustrations/ 4. 如果根目录有 images/illustrations/(如Adam论文的配图),LSTM文章就会错误引用Adam的配图
影响范围
- ✅ LSTM论文已手动修复路径
- ⚠️ 未来所有论文都会遇到同样问题(已修复)
---
修复方案(v1.2.1)
修复1:生成完整相对路径
文件:scripts/generate_illustrations_v2.py
改动:
# 🔧 修复前:
image_rel_path = f"{output_dir}/{image_filename}"
# 结果:images/illustrations/illustration_1.png(相对于markdown所在目录)
# ✅ 修复后:
paper_dir = markdown_path.parent
paper_id = paper_dir.name
image_rel_path = f"papers/{paper_id}/{output_dir}/{image_filename}"
# 结果:papers/LSTM_1997/images/illustrations/illustration_1.png(从根目录开始)效果:
- 生成的图片路径从根目录开始
- 文章移动到根目录后,路径仍然有效
- 不同论文的配图完全隔离
---
修复2:图片路径验证
文件:scripts/finalize_markdown.py
新增功能:
def validate_image_paths(content, output_dir="."):
"""验证markdown中的所有图片路径是否存在"""
# 提取所有图片路径
# 检查每个路径是否存在
# 返回无效路径列表效果:
- finalize时自动验证所有图片路径
- 发现无效路径时显示详细列表
- 提前发现问题,避免发布错误的文章
示例输出:
⚠️ 图片路径验证:
✅ 有效: 10 张
❌ 无效: 2 张
- images/missing_figure.png
- papers/Wrong_2024/illustration_1.png---
验证测试
测试1:路径生成逻辑
✅ 新逻辑生成的路径: papers/TestPaper/images/illustrations/illustration_1.png
markdown位置: papers/TestPaper/test.md
paper_id: TestPaper
匹配:✅测试2:实际生成流程
运行完整的论文解读流程:
- ✅ PDF下载和提取
- ✅ 图表提取
- ✅ 文章生成
- ✅ 配图生成(路径正确)
- ✅ 文章finalize(路径验证通过)
---
长期优化方案
详见 STRUCTURE_OPTIMIZATION_PLAN.md,包括:
P0(已完成)✅
- [x] 修复图片路径生成逻辑
- [x] 添加路径验证功能
P1(短期)
- [ ] 实现标准化目录结构(source/workspace/assets)
- [ ] 优化paper_id生成(从URL式改为可读式)
- [ ] 添加自动化结构检查脚本
P2(长期)
- [ ] 迁移现有论文到新结构
- [ ] 支持配置文件自定义结构
- [ ] 完善文档和使用示例
---
使用建议
对于用户
现在开始解读新论文,路径问题已完全修复,无需担心配图混乱。
检查旧文章:
# 检查文章的图片路径是否正确
cd "/Users/joe/乔木新知识库/03.项目/AI不插电"
python3 << 'EOF'
import re
from pathlib import Path
md_file = "你的文章.md"
with open(md_file, 'r') as f:
content = f.read()
paths = re.findall(r'!\[.*?\]\(([^)]+)\)', content)
for path in paths:
if not path.startswith('http'):
full_path = Path(path)
if not full_path.exists():
print(f"❌ 无效路径: {path}")
EOF对于开发者
关键文件:
generate_illustrations_v2.py- 配图生成(已修复)finalize_markdown.py- 文章最终化(已增强)STRUCTURE_OPTIMIZATION_PLAN.md- 长期优化方案
测试新功能:
cd ~/.claude/skills/qiaomu-paper-interpreter
bash /tmp/test_new_path_logic.sh---
总结
✅ 立即修复:图片路径从相对改为完整相对路径 ✅ 防护机制:finalize时自动验证图片路径 ✅ 向后兼容:旧文章可手动修复,新文章自动正确 ✅ 版本更新:v1.2.0 → v1.2.1
未来方向:实施标准化目录结构,彻底解决组织混乱问题。
---
修复日期:2025-12-23 版本:v1.2.1 影响:所有未来的论文解读 状态:✅ 已部署,已测试,已验证
qiaomu-paper-interpreter
将 arXiv 论文自动转化为乔木风格中文解读文章的 Claude Code Skill。
全程自动,无需中途确认。
功能
- arXiv 原生支持:输入论文链接或 ID,自动获取 LaTeX 源码提取图表(比 PDF 边界框检测更准确)
- 乔木风格写作:8000-10000 字深度解读,对话式语言 + 术语引用块 + 生活化类比
- 双层配图:纸雕水彩封面 + 《纽约客》风格章节插图(黑白线条 + 朱红色点缀)
- 论文原图提取:从 LaTeX 源码精准提取所有 Figure / Table
- 智能文件管理:工作目录保留完整档案,阅读目录存放纯净文章
- 即梦 API 集成:支持免费模型(4.1)和付费模型(4.5)
快速开始
在 Claude Code 中直接说:
解读论文 https://arxiv.org/abs/1706.03762读paper 2501.00001帮我理解这篇paper https://arxiv.org/abs/2312.00001,乔木风格输出
工作目录(25 论文库/21.01 papers/{paper_id}/):
Transformer_2017/
├── Transformer_2017.pdf
├── metadata.json
├── extracted_text.md
├── images/ # 论文原图(Figure / Table)
├── illustrations/ # AI 生成配图
│ ├── cover.png # 纸雕水彩封面
│ └── 01-*.png # 纽约客风格章节插图
└── 注意力即一切:AI架构的范式革命_解读.md阅读目录(25 论文库/21.02 论文解读/):
注意力即一切:AI架构的范式革命.md # 纯净版,直接阅读配置
复制 .env.example 为 .env 并按需修改:
cp .env.example .env| 变量 | 说明 | 示例 |
|---|---|---|
PAPER_OUTPUT_DIR | 论文工作目录 | ~/Papers/papers |
PAPER_READING_DIR | 解读文章目录 | ~/Papers/reading |
OBSIDIAN_VAULT | Obsidian Vault 名称,完成后自动打开 | MyVault |
IMAGE_PROVIDER | 配图方案:skip / jimeng | jimeng |
依赖
pip install pdfplumber pymupdf requests图片生成需要安装 qiaomu-image-generator skill 并配置即梦 API。
安装
已安装 Claude Code CLI:
claude skill install https://github.com/joeseesun/qiaomu-paper-interpreter未全局安装 claude 时,可以用 npx 临时运行 Claude Code CLI(需要 Node.js 18+):
npx -y @anthropic-ai/claude-code skill install https://github.com/joeseesun/qiaomu-paper-interpreter目录结构
qiaomu-paper-interpreter/
├── SKILL.md # Skill 定义与完整工作流
├── scripts/
│ ├── extract_figures.py # LaTeX 源码图表提取(主力)
│ ├── extract_all_figures.py # PDF 图表提取(备用)
│ ├── extract_pdf_metadata.py # PDF 元数据提取
│ ├── generate_illustrations_v2.py # 配图生成
│ ├── finalize_markdown.py # 文章最终化处理
│ └── load_env.py # 环境变量加载
├── references/
│ ├── style-guide.md # 乔木写作风格指南
│ └── workflow.md # 工作流参考
├── visual_description_guide.md # 配图描述编写指南
├── .env.example # 配置模板
└── .gitignore📱 关注作者
如果这个项目对你有帮助,欢迎关注我获取更多技术分享:
- X (Twitter): @vista8
- 微信公众号「向阳乔木推荐看」:
<p align="center"> <img src="https://github.com/joeseesun/terminal-boost/raw/main/assets/wechat-qr.jpg" alt="向阳乔木推荐看公众号二维码" width="300"> </p>
License
MIT
乔木论文重写风格参考 v2
语言特质
- 口语化、对话感强,像和读者面对面聊天
- 善用生活化类比解释复杂概念(如"就像"、"可以想象成"、"类似于")
- 在专业性和可读性之间自然平衡
- 用引用块(>)解释专业术语,语言通俗易懂
表达习惯
- 短段落,多留白,视觉舒适
- 重要观点用加粗突出
- 频繁用设问和"你"来制造互动感
- 不要用"不是...而是..."这种AI感表达
- 术语第一次出现时用引用块解释,后续直接使用
- 多用"就像""比如""试想一下"等引导性词汇
- 绝对不要用破折号
- 一定要用中文标点符号,例如逗号,,冒号:等
术语解释风格
- 用 > 引用块标注术语解释
- 格式:术语(英文缩写):通俗解释。类比说明。
- 示例:
> **Diffusion Transformer(DiT)**:一种图像生成技术。可以想象成"从噪点逐步还原成图像"的过程,就像拼图游戏,从一堆乱七八糟的碎片慢慢拼出完整画面。- 解释要做到:
- 一句话说清本质
- 用生活化类比
- 点明为什么重要/有什么用
内容层次
- 不满足于表面解释,会延伸到更深的思考
- 善于在不同领域间建立联系(技术→生活→认知)
- 既讲"是什么",也讲"为什么重要"
- 对复杂技术会拆解成多个层次讲解
- 用"核心思想""关键是""精妙之处"等词引出要点
结构组织
- 用数字列表展示并列关系
- 用层级标题(####)组织复杂内容
- 重要数据用表格呈现
- 技术细节用"几个有意思的观察/例子"引入
- 每个大段落后有小结或过渡
配图标注
- 用【配图建议:第X页,Figure/Table X - 简短描述】格式
- 在合适位置插入配图提示
- 配图描述要说明"展示了什么"而非"是什么图"
风格调性
- 真诚、不装、承认复杂性
- 专业但不掉书袋,数据和案例支撑观点
- 有洞察力,能给读者"原来如此"的感觉
- 对巧妙设计表达赞赏("很聪明""精妙""有意思")
- 结尾有思考和启发,不只是总结
避免事项
- 不用"首先""其次""最后"等僵硬过渡
- 不用"值得注意的是""需要指出的是"等学术腔
- 不堆砌术语而不解释
- 不用过于正式的书面语
- 避免"该""此""其"等文言词汇
技术论文改写要点
1. 开篇:用故事或对比引入,不直接讲技术 2. 术语处理:首次出现必解释,用引用块标注 3. 数据呈现:关键数据加粗,用对比突出优势 4. 技术细节:分层讲解,从"是什么"到"为什么"到"怎么做" 5. 配图规划:在关键论述处标注配图位置 6. 结尾升华:从技术延伸到方法论和思考
论文解读工作流程
完整流程
1. 读取PDF论文
- 使用PDF读取工具获取论文全文
- 快速浏览论文结构:标题、摘要、章节、图表
2. 第一次生成解读
按照style-guide.md中的乔木风格,生成初稿:
- 用对话式开篇引入论文主题
- 对所有专业术语用引用块(>)解释
- 用生活化类比阐述核心概念
- 标注需要插入图表的位置(使用【配图建议:第X页,Figure/Table X - 描述】格式)
- 确保内容层次分明、有深度思考
3. 自我审查
生成初稿后,必须进行自我审查,检查:
- 是否遗漏了论文的重要贡献点?
- 是否有关键的实验结果未提及?
- 是否有重要的技术细节被忽略?
- 是否有值得深入讨论的洞察未展开?
- 配图标注是否完整(所有重要图表都标注了)?
4. 再次生成完整版
基于自我审查的发现,生成完整版本:
- 补充遗漏的重点内容
- 深化重要概念的讨论
- 确保所有术语都有恰当解释
- 完善配图标注
5. 插入图表
- 根据配图标注,使用browser工具打开PDF
- 截图相应的Figure和Table
- 在markdown中的配图标注位置插入实际图片
6. 保存文件
- 将最终内容写入markdown文件
- 文件名格式:论文标题_解读.md
- 保存在用户当前工作目录
#!/usr/bin/env python3
"""
自动从PDF中提取所有Figure和Table - v2.0 全面优化版
改进点:
1. 通用分隔符模式:支持 : | . — - 等各种学术论文格式
2. 更智能的引用过滤:基于上下文判断是标题还是引用
3. 双向搜索:Figure 标题可能在图片上方或下方
4. 调试模式:--debug 参数输出详细匹配信息
5. 更好的边界检测:处理矢量图和复杂布局
"""
import fitz # PyMuPDF
import re
from pathlib import Path
import sys
import argparse
# ============ 配置常量 ============
# 通用分隔符模式(覆盖主流学术论文格式)
# 支持: Figure 1: / Figure 1. / Figure 1 | / Figure 1 — / Figure 1 - / Figure 1 Description
SEPARATORS = r'[::\.\|—\-]'
# 引用动词(用于过滤正文中的引用,如 "Figure 1 shows...")
REFERENCE_VERBS = {
'shows', 'show', 'showing', 'shown',
'illustrates', 'illustrate', 'illustrating', 'illustrated',
'demonstrates', 'demonstrate', 'demonstrating', 'demonstrated',
'presents', 'present', 'presenting', 'presented',
'depicts', 'depict', 'depicting', 'depicted',
'displays', 'display', 'displaying', 'displayed',
'summarizes', 'summarize', 'summarizing', 'summarized',
'compares', 'compare', 'comparing', 'compared',
'lists', 'list', 'listing', 'listed',
'reports', 'report', 'reporting', 'reported',
'provides', 'provide', 'providing', 'provided',
'contains', 'contain', 'containing', 'contained',
'includes', 'include', 'including', 'included',
'describes', 'describe', 'describing', 'described',
'outlines', 'outline', 'outlining', 'outlined',
}
# 引用介词(用于过滤正文中的引用,如 "as shown in Figure 1")
REFERENCE_PREPOSITIONS = {'in', 'of', 'from', 'see', 'cf', 'refer', 'to'}
def is_likely_caption(text_before: str, text_after: str, match_text: str) -> bool:
"""
判断匹配到的 Figure/Table 是标题还是引用
标题特征:
- 在行首或段落开头
- 后面跟着分隔符或描述性名词
- 不是 "as shown in Figure 1" 这样的句式
引用特征:
- 前面有介词(in, of, from, see)
- 后面跟着动词(shows, illustrates)
- 在句子中间
"""
# 检查前文:如果前面紧跟介词,很可能是引用
before_words = text_before.strip().split()[-3:] if text_before.strip() else []
for word in before_words:
if word.lower().rstrip('.,;:') in REFERENCE_PREPOSITIONS:
return False
# 检查后文:如果后面紧跟动词,很可能是引用
after_words = text_after.strip().split()[:2] if text_after.strip() else []
for word in after_words:
if word.lower().rstrip('.,;:') in REFERENCE_VERBS:
return False
# 检查是否在行首(标题的典型位置)
# 前文为空,或者前文以换行结束
if not text_before.strip() or text_before.rstrip().endswith('\n'):
return True
# 检查后面是否有分隔符(标题的典型格式)
if text_after and re.match(r'\s*' + SEPARATORS, text_after):
return True
# 检查后面是否跟着大写字母开头的描述(如 "Figure 1 The Architecture")
if text_after and re.match(r'\s+[A-Z][a-z]', text_after):
# 但要排除动词
first_word = text_after.strip().split()[0] if text_after.strip() else ''
if first_word.lower() not in REFERENCE_VERBS:
return True
return False
def find_figure_captions(page, page_num: int, debug: bool = False) -> list:
"""
在页面中查找所有 Figure/Table 标题
返回: [(item_type, item_num, title_rect, full_title_text), ...]
"""
text = page.get_text()
found_items = []
# 统一的匹配模式:Figure/Fig./Table + 数字
# 捕获组1: 类型(Figure/Fig./Table)
# 捕获组2: 编号
pattern = r'(Figure|Fig\.?|TABLE|Table)\s+(\d+)'
for match in re.finditer(pattern, text, re.IGNORECASE):
item_type = match.group(1).lower()
item_num = match.group(2)
position = match.start()
# 标准化类型名
if item_type.startswith('fig'):
item_type = 'figure'
elif item_type == 'table':
item_type = 'table'
# 获取上下文
context_before = text[max(0, position-50):position]
context_after = text[match.end():match.end()+100]
# 判断是标题还是引用
if not is_likely_caption(context_before, context_after, match.group(0)):
if debug:
print(f" [跳过引用] {match.group(0)} (上文: ...{context_before[-20:]})")
continue
# 提取完整标题(包括分隔符和描述)
# 尝试匹配到行尾或句号
title_pattern = re.escape(match.group(0)) + r'[^\.]*\.?'
title_match = re.search(title_pattern, text[position:position+300])
if title_match:
full_title = title_match.group(0).strip()
else:
full_title = match.group(0)
# 在页面上定位标题
search_text = match.group(0)
text_instances = page.search_for(search_text)
if not text_instances:
if debug:
print(f" [找不到位置] {search_text}")
continue
# 使用第一个匹配(通常是页面上最靠上的)
title_rect = text_instances[0]
if debug:
print(f" [找到标题] {item_type.capitalize()} {item_num} @ y={title_rect.y0:.0f}")
found_items.append({
'type': item_type,
'number': item_num,
'rect': title_rect,
'full_title': full_title,
'page_num': page_num
})
return found_items
def analyze_figure_boundaries(page, title_rect, item_type: str, debug: bool = False):
"""
分析Figure/Table的精确边界
改进策略:
1. 双向搜索:图片可能在标题上方或下方
2. 更大的搜索范围
3. 更好的矢量图检测
"""
page_width = page.rect.width
page_height = page.rect.height
# 获取页面所有元素
text_blocks = page.get_text("dict")["blocks"]
images = page.get_images(full=True)
drawings = page.get_drawings()
# 初始边界从标题开始
y0, y1 = title_rect.y0, title_rect.y1
x0, x1 = title_rect.x0, title_rect.x1
if item_type == 'figure':
# === 搜索图片内容(上方和下方都找)===
image_found = False
best_image_rect = None
# 1. 尝试找位图图片
for img_info in images:
try:
img_rect = page.get_image_bbox(img_info)
# 图片在标题上方(常见情况)
if img_rect.y1 < title_rect.y0 and (title_rect.y0 - img_rect.y1) < 100:
if not best_image_rect or img_rect.y0 < best_image_rect.y0:
best_image_rect = img_rect
image_found = True
# 图片在标题下方(某些论文格式)
elif img_rect.y0 > title_rect.y1 and (img_rect.y0 - title_rect.y1) < 50:
if not best_image_rect or img_rect.y1 > best_image_rect.y1:
best_image_rect = img_rect
image_found = True
except:
continue
if best_image_rect:
y0 = min(y0, best_image_rect.y0)
y1 = max(y1, best_image_rect.y1)
x0 = min(x0, best_image_rect.x0)
x1 = max(x1, best_image_rect.x1)
# 2. 如果没找到位图,尝试找矢量绘图
if not image_found and drawings:
# 收集标题上方300pt范围内的所有绘图(扩大搜索范围)
drawing_rects = []
for drawing in drawings:
draw_rect = drawing.get('rect')
if draw_rect:
# 上方
if draw_rect.y1 < title_rect.y0 and (title_rect.y0 - draw_rect.y0) < 400:
drawing_rects.append(draw_rect)
# 下方
elif draw_rect.y0 > title_rect.y1 and (draw_rect.y0 - title_rect.y1) < 100:
drawing_rects.append(draw_rect)
if drawing_rects:
y0 = min(y0, min(r.y0 for r in drawing_rects))
y1 = max(y1, max(r.y1 for r in drawing_rects))
x0 = min(x0, min(r.x0 for r in drawing_rects))
x1 = max(x1, max(r.x1 for r in drawing_rects))
image_found = True
# 3. 如果还没找到,扩大搜索范围到标题上方400pt
if not image_found:
# 可能是纯文本构成的图表或者复杂布局
for block in text_blocks:
if block.get('type') == 0: # 文本块
block_rect = fitz.Rect(block['bbox'])
# 在标题上方,但不太远
if block_rect.y1 < title_rect.y0 and (title_rect.y0 - block_rect.y0) < 400:
y0 = min(y0, block_rect.y0)
# === 向下找说明文字 ===
caption_end = title_rect.y1
for block in text_blocks:
if block.get('type') == 0:
block_rect = fitz.Rect(block['bbox'])
# 紧接着标题下方的文本
if block_rect.y0 >= title_rect.y1 and (block_rect.y0 - title_rect.y1) < 50:
# 说明文字通常较短
text_content = ""
for line in block.get('lines', []):
for span in line.get('spans', []):
text_content += span.get('text', '')
if len(text_content.strip()) < 500: # 说明文字不会太长
caption_end = max(caption_end, block_rect.y1)
x0 = min(x0, block_rect.x0)
x1 = max(x1, block_rect.x1)
y1 = max(y1, caption_end)
else: # table
# Table 的标题通常在表格上方
# 向下找表格主体
table_found = False
# 1. 查找标题下方的表格线
table_drawings = []
for drawing in drawings:
draw_rect = drawing.get('rect')
if draw_rect and draw_rect.y0 >= title_rect.y1:
if (draw_rect.y0 - title_rect.y1) < 500:
width = draw_rect.x1 - draw_rect.x0
height = draw_rect.y1 - draw_rect.y0
# 表格线特征:水平细线
if height < 5 or width > 100:
table_drawings.append(draw_rect)
if table_drawings:
y1 = max(r.y1 for r in table_drawings)
x0 = min(x0, min(r.x0 for r in table_drawings))
x1 = max(x1, max(r.x1 for r in table_drawings))
table_found = True
# 2. 如果没找到绘图,查找密集的文本块
if not table_found:
table_blocks = []
last_y = title_rect.y1
for block in text_blocks:
if block.get('type') == 0:
block_rect = fitz.Rect(block['bbox'])
if block_rect.y0 >= title_rect.y1 and (block_rect.y0 - title_rect.y1) < 500:
gap = block_rect.y0 - last_y
if len(table_blocks) > 0 and gap > 50:
break
table_blocks.append(block_rect)
last_y = block_rect.y1
if len(table_blocks) >= 2:
y1 = max(r.y1 for r in table_blocks)
x0 = min(x0, min(r.x0 for r in table_blocks))
x1 = max(x1, max(r.x1 for r in table_blocks))
# 添加边距
margin = 15
x0 = max(0, x0 - margin)
x1 = min(page_width, x1 + margin)
y0 = max(0, y0 - margin)
y1 = min(page_height, y1 + margin)
# 确保最小尺寸(避免只截取标题)
min_height = 100
if y1 - y0 < min_height:
# 如果太小,向上扩展
y0 = max(0, title_rect.y0 - 300)
return fitz.Rect(x0, y0, x1, y1)
def scan_and_extract_figures(pdf_path, output_dir="images", prefix="", debug=False):
"""
两阶段提取:
1. 扫描阶段:找到所有Figure/Table标题
2. 提取阶段:分析边界并截取
"""
pdf_path = Path(pdf_path)
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
if not pdf_path.exists():
print(f"❌ PDF文件不存在: {pdf_path}")
return []
doc = fitz.open(str(pdf_path))
print(f"📄 正在扫描PDF: {pdf_path.name}")
print(f"📄 总页数: {len(doc)}")
print(f"📁 输出目录: {output_dir}")
if debug:
print(f"🔧 调试模式: 开启")
print()
# ============ 阶段1: 扫描并标记 ============
print("🔍 阶段1: 扫描所有图表标题...")
all_items = []
global_seen = {}
for page_num, page in enumerate(doc, 1):
if debug:
print(f"\n === 第 {page_num} 页 ===")
items = find_figure_captions(page, page_num, debug=debug)
# 全局去重(同一个 Figure/Table 只处理一次)
for item in items:
key = (item['type'], item['number'])
if key not in global_seen:
global_seen[key] = page_num
item['page'] = page
all_items.append(item)
if not debug:
print(f" [第{page_num}页] {item['type'].capitalize()} {item['number']}")
print(f"\n✅ 阶段1完成: 找到 {len(all_items)} 个图表\n")
if not all_items:
print("⚠️ 未找到任何图表。可能的原因:")
print(" 1. PDF中没有标准格式的Figure/Table标题")
print(" 2. 使用 --debug 参数查看详细匹配信息")
doc.close()
return []
# ============ 阶段2: 分析边界并截取 ============
print("✂️ 阶段2: 分析边界并截取...")
extracted = []
for item in all_items:
item_type = item['type']
item_num = item['number']
page_num = item['page_num']
page = item['page']
title_rect = item['rect']
print(f" [第{page_num}页] {item_type.capitalize()} {item_num} - 分析边界...", end='')
# 分析精确边界
precise_rect = analyze_figure_boundaries(page, title_rect, item_type, debug=debug)
width = precise_rect.x1 - precise_rect.x0
height = precise_rect.y1 - precise_rect.y0
print(f" ({int(width)}x{int(height)}) ", end='')
# 截取图片
pix = page.get_pixmap(clip=precise_rect, matrix=fitz.Matrix(2, 2)) # 2x分辨率
# 生成文件名
if prefix:
filename = f"{prefix}_{item_type}{item_num}.png"
else:
filename = f"{item_type}{item_num}.png"
output_path = output_dir / filename
pix.save(str(output_path))
print(f"✅ 已保存")
extracted.append({
'type': item_type,
'number': item_num,
'page': page_num,
'filename': filename,
'path': str(output_path)
})
doc.close()
print(f"\n{'='*60}")
print(f"✨ 完成!成功提取 {len(extracted)} 个图表")
print(f"📁 保存位置: {output_dir.absolute()}")
return extracted
def generate_markdown_references(extracted, output_file="figure_list.md"):
"""生成markdown引用列表"""
with open(output_file, 'w', encoding='utf-8') as f:
f.write("# 提取的图表列表\n\n")
f.write("复制下面的引用到你的文章中:\n\n")
for item in extracted:
item_type = item['type'].capitalize()
item_num = item['number']
filename = item['filename']
page = item['page']
f.write(f"## {item_type} {item_num} (第{page}页)\n\n")
f.write(f"```markdown\n")
f.write(f"\n")
f.write(f"```\n\n")
print(f"📝 引用列表已保存: {output_file}")
def main():
parser = argparse.ArgumentParser(
description='从PDF中自动提取Figure和Table',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python extract_all_figures.py paper.pdf
python extract_all_figures.py paper.pdf images paper_prefix
python extract_all_figures.py paper.pdf --debug # 调试模式
"""
)
parser.add_argument('pdf', help='PDF文件路径')
parser.add_argument('output_dir', nargs='?', default='images', help='输出目录 (默认: images)')
parser.add_argument('prefix', nargs='?', default='', help='文件名前缀')
parser.add_argument('--debug', '-d', action='store_true', help='开启调试模式,输出详细匹配信息')
args = parser.parse_args()
extracted = scan_and_extract_figures(
args.pdf,
args.output_dir,
args.prefix,
debug=args.debug
)
if extracted:
list_file = Path(args.output_dir) / "figure_list.md"
generate_markdown_references(extracted, list_file)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
从PDF中提取指定的Figure和Table,并保存为图片
"""
import re
import os
from pathlib import Path
def extract_figure_annotations(markdown_path):
"""
从markdown文件中提取所有配图标注
返回: [(页码, 类型, 编号, 描述, 原始标注文本), ...]
例如: [(3, 'Figure', '1', '展示T5的text-to-text统一框架', '**【配图建议:...】**')]
"""
with open(markdown_path, 'r', encoding='utf-8') as f:
content = f.read()
pattern = r'\*\*【配图建议:第(\d+)页,(Figure|Table)\s*([0-9]+|[IVX]+)\s*-\s*([^】]+)】\*\*'
matches = re.finditer(pattern, content)
annotations = []
for match in matches:
page_num = int(match.group(1))
fig_type = match.group(2)
fig_num = match.group(3)
description = match.group(4).strip()
original_text = match.group(0)
annotations.append((page_num, fig_type, fig_num, description, original_text))
return annotations
def extract_figures_from_pdf_pymupdf(pdf_path, annotations, output_dir="images", prefix=""):
"""
使用PyMuPDF从PDF中提取指定的图表
参数:
pdf_path: PDF文件路径
annotations: 配图标注列表
output_dir: 输出目录
prefix: 图片文件名前缀(如"T5")
返回: {原始标注: 图片路径} 的字典
"""
try:
import fitz # PyMuPDF
except ImportError:
print("需要安装PyMuPDF: pip install pymupdf")
return {}
# 创建输出目录
Path(output_dir).mkdir(exist_ok=True)
doc = fitz.open(pdf_path)
screenshot_map = {}
for page_num, fig_type, fig_num, description, original_text in annotations:
# 页码从0开始
page_idx = page_num - 1
if page_idx >= len(doc):
print(f"警告:页码 {page_num} 超出范围")
continue
page = doc[page_idx]
# 搜索图表标题
search_terms = [
f"{fig_type} {fig_num}",
f"{fig_type} {fig_num}:",
f"{fig_type} {fig_num}.",
]
found = False
for term in search_terms:
text_instances = page.search_for(term)
if text_instances:
# 找到第一个匹配
inst = text_instances[0]
# 扩展边界框以包含整个图表
# Figure的caption在图片下方,Table的caption在表格上方
x0 = page.rect.width * 0.05 # 左边距5%
x1 = page.rect.width * 0.95 # 右边距5%
if fig_type == "Figure":
# Figure: caption在图片下方,所以向上找图片
# inst.y0是标题顶部,inst.y1是标题底部
y0 = max(0, inst.y0 - 500) # 标题上方500pt(图片区域)
y1 = inst.y1 + 20 # 包含标题,下方留20pt
else: # Table
# Table: 表格数据在caption上方!
# 先搜索caption上方是否有"Model"、"Method"等表格列标题关键词
# 如果找不到就使用默认范围
table_keywords = ["Model", "Method", "Task", "Dataset"]
table_top = inst.y0 - 300 # 默认向上300pt
for keyword in table_keywords:
keyword_instances = page.search_for(keyword)
for kw_inst in keyword_instances:
# 找caption上方100-500pt范围内的关键词
if (inst.y0 - 500) < kw_inst.y0 < inst.y0:
table_top = min(table_top, kw_inst.y0 - 20)
break
y0 = max(0, table_top) # 表格顶部
y1 = inst.y1 + 20 # 包含caption,下方留20pt
clip_rect = fitz.Rect(x0, y0, x1, y1)
# 截图
pix = page.get_pixmap(clip=clip_rect, matrix=fitz.Matrix(2, 2)) # 2x分辨率
# 保存(带前缀避免冲突)
if prefix:
img_filename = f"{prefix}_{fig_type.lower()}{fig_num}.png"
else:
img_filename = f"{fig_type.lower()}{fig_num}.png"
img_path = os.path.join(output_dir, img_filename)
pix.save(img_path)
screenshot_map[original_text] = img_path
print(f"✓ 提取 {fig_type} {fig_num} -> {img_path}")
found = True
break
if not found:
print(f"✗ 未找到 {fig_type} {fig_num} 在第 {page_num} 页")
doc.close()
return screenshot_map
def extract_figures_from_pdf_pdfplumber(pdf_path, annotations, output_dir="images", prefix=""):
"""
使用pdfplumber从PDF中提取图表(备选方案)
参数:
pdf_path: PDF文件路径
annotations: 配图标注列表
output_dir: 输出目录
prefix: 图片文件名前缀(如"T5")
"""
try:
import pdfplumber
from PIL import Image
except ImportError:
print("需要安装: pip install pdfplumber pillow")
return {}
Path(output_dir).mkdir(exist_ok=True)
screenshot_map = {}
with pdfplumber.open(pdf_path) as pdf:
for page_num, fig_type, fig_num, description, original_text in annotations:
page_idx = page_num - 1
if page_idx >= len(pdf.pages):
continue
page = pdf.pages[page_idx]
# 获取页面图片
img = page.to_image(resolution=150)
# 搜索文本位置
words = page.extract_words()
search_text = f"{fig_type} {fig_num}"
for word in words:
if search_text in word['text']:
# 找到了,截取区域
x0 = page.width * 0.05
x1 = page.width * 0.95
if fig_type == "Figure":
# Figure: caption在图片下方,向上找
y0 = max(0, word['top'] - 500)
y1 = min(word['bottom'] + 20, page.height)
else: # Table
# Table: 表格在caption上方,向上找
# 搜索caption上方的表格列标题
table_top = word['top'] - 300 # 默认值
table_keywords = ["Model", "Method", "Task", "Dataset"]
for kw in table_keywords:
for w in words:
if kw in w['text'] and (word['top'] - 500) < w['top'] < word['top']:
table_top = min(table_top, w['top'] - 20)
break
y0 = max(0, table_top)
y1 = min(word['bottom'] + 20, page.height)
# 裁剪
cropped = img.original.crop((x0, y0, x1, y1))
# 保存(带前缀避免冲突)
if prefix:
img_filename = f"{prefix}_{fig_type.lower()}{fig_num}.png"
else:
img_filename = f"{fig_type.lower()}{fig_num}.png"
img_path = os.path.join(output_dir, img_filename)
cropped.save(img_path)
screenshot_map[original_text] = img_path
print(f"✓ 提取 {fig_type} {fig_num} -> {img_path}")
break
return screenshot_map
def replace_annotations_with_images(markdown_path, screenshot_map, output_path=None):
"""
将markdown中的配图标注替换为实际图片
参数:
markdown_path: 原始markdown文件
screenshot_map: {原始标注: 图片路径} 字典
output_path: 输出文件路径(如果为None,则覆盖原文件)
"""
with open(markdown_path, 'r', encoding='utf-8') as f:
content = f.read()
for annotation, img_path in screenshot_map.items():
# 提取描述
desc_match = re.search(r'-\s*([^】]+)', annotation)
description = desc_match.group(1).strip() if desc_match else ""
# 替换为markdown图片语法
replacement = f"\n\n*{description}*"
content = content.replace(annotation, replacement)
# 保存
if output_path is None:
output_path = markdown_path
with open(output_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"\n✅ 已更新 {output_path}")
print(f" 替换了 {len(screenshot_map)} 处配图标注")
def main():
"""主函数:从命令行调用"""
import sys
if len(sys.argv) < 3:
print("用法: python extract_figures.py <PDF文件> <Markdown文件> [输出目录] [图片前缀]")
print("示例: python extract_figures.py paper.pdf T5论文_解读.md images T5")
print(" 如果不指定前缀,会从markdown文件名自动提取")
sys.exit(1)
pdf_path = sys.argv[1]
markdown_path = sys.argv[2]
output_dir = sys.argv[3] if len(sys.argv) > 3 else "images"
# 图片前缀:优先使用命令行参数,其次从文件名提取
if len(sys.argv) > 4:
prefix = sys.argv[4]
else:
# 从markdown文件名提取前缀
# 例如:"T5论文_解读.md" -> "T5"
# 或 "BERT_Pretraining_解读.md" -> "BERT_Pretraining"
md_basename = os.path.basename(markdown_path)
md_name = os.path.splitext(md_basename)[0]
# 提取第一个下划线或"论文"、"解读"之前的部分
for sep in ['_解读', '_论文', '论文', '解读', '_']:
if sep in md_name:
prefix = md_name.split(sep)[0]
break
else:
prefix = md_name[:20] # 如果没有分隔符,取前20个字符
# 清理前缀中的特殊字符
prefix = re.sub(r'[^\w\-]', '_', prefix)
# 检查文件是否存在
if not os.path.exists(pdf_path):
print(f"错误:PDF文件不存在: {pdf_path}")
sys.exit(1)
if not os.path.exists(markdown_path):
print(f"错误:Markdown文件不存在: {markdown_path}")
sys.exit(1)
# 提取标注
print("正在解析markdown中的配图标注...")
annotations = extract_figure_annotations(markdown_path)
print(f"找到 {len(annotations)} 处配图标注")
print(f"图片前缀: {prefix}\n")
if not annotations:
print("没有找到任何配图标注,退出")
sys.exit(0)
# 提取图表
print("正在从PDF中提取图表...")
# 优先使用PyMuPDF,失败则使用pdfplumber
screenshot_map = extract_figures_from_pdf_pymupdf(pdf_path, annotations, output_dir, prefix)
if not screenshot_map:
print("\nPyMuPDF提取失败,尝试使用pdfplumber...")
screenshot_map = extract_figures_from_pdf_pdfplumber(pdf_path, annotations, output_dir, prefix)
if not screenshot_map:
print("\n所有提取方法都失败了")
sys.exit(1)
# 更新markdown
print("\n正在更新markdown文件...")
replace_annotations_with_images(markdown_path, screenshot_map)
print("\n✨ 完成!")
if __name__ == "__main__":
main()
此脚本已被弃用
================
**原因**:
- 文件命名格式错误:使用 `illustration_{idx}.png`,不包含论文名称
- 会导致不同论文的配图文件名冲突,Obsidian 索引混乱
**替代方案**:
使用 `generate_illustrations_v2.py`,它具有:
- ✅ 全局唯一文件名:`{paper_slug}-{idx:02d}.png`
- ✅ 并发生成(ThreadPoolExecutor)
- ✅ 完整功能
- ✅ 正确的统一存储路径
**迁移命令**:
```bash
python ~/.claude/skills/qiaomu-paper-interpreter/scripts/generate_illustrations_v2.py \
论文.md \
visual_config.json \
--output-dir ~/乔木新知识库/07.附件/papers/{paper_name}_{date}
```
此脚本已被弃用
================
**原因**:
- 文件命名格式错误:使用 `illustration_{idx}.png` 或 `{prefix}_illustration_{idx}.png`
- 不包含论文名称,会导致文件名冲突和 Obsidian 索引混乱
- 单线程执行,效率低
**替代方案**:
使用 `generate_illustrations_v2.py`,它具有:
- ✅ 全局唯一文件名:`{paper_slug}-{idx:02d}.png`
- ✅ 并发生成(ThreadPoolExecutor,3-4x 加速)
- ✅ 完整功能
- ✅ 正确的统一存储路径
**迁移命令**:
```bash
python ~/.claude/skills/qiaomu-paper-interpreter/scripts/generate_illustrations_v2.py \
论文.md \
visual_config.json \
--output-dir ~/乔木新知识库/07.附件/papers/{paper_name}_{date}
```
# DEPRECATED: This file has been moved to ~/.claude/skills/shared-lib/image_api.py
# All scripts now use the shared library. Do not import from this file.
#!/usr/bin/env python3
"""
加载 .env 文件中的环境变量
支持从多个位置查找 .env 文件
"""
import os
from pathlib import Path
def load_env_file(env_path=None):
"""
加载 .env 文件到环境变量
参数:
env_path: .env 文件路径(可选)
"""
if env_path and Path(env_path).exists():
env_file = Path(env_path)
else:
# 多个候选位置
candidates = [
# 1. 当前工作目录
Path.cwd() / '.env',
# 2. 用户主目录下的 vault 根目录(常见位置)
Path.home() / '乔木新知识库' / '.env',
# 3. 从当前脚本向上查找
Path(__file__).resolve().parent / '.env',
]
# 向上查找(最多10层)
current = Path(__file__).resolve().parent
for _ in range(10):
candidates.append(current / '.env')
current = current.parent
# 找到第一个存在的 .env 文件
env_file = None
for candidate in candidates:
if candidate.exists():
env_file = candidate
break
if env_file is None:
return False
# 读取并加载环境变量
with open(env_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# 跳过注释和空行
if not line or line.startswith('#'):
continue
# 解析 KEY=VALUE
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
# 设置环境变量(如果还未设置)
if key and not os.environ.get(key):
os.environ[key] = value
return True
# 自动加载
load_env_file()
1.2.1