通用文章抓取与归档工具。抓取任意 URL(免费/付费/登录墙)的文章全文,转换为结构化 Markdown,并可选转存到乐享知识库。支持 Substack、Medium、知识星球等付费平台的登录态管理。支持 YouTube 视频下载(yt-dlp)、播客音频下载(小宇宙FM等)、音频转录(Whisper)、翻译(中...
---
name: fetch-archive-to-lexiang
description: 通用文章抓取与归档工具。抓取任意 URL(免费/付费/登录墙)的文章全文,转换为结构化 Markdown,并可选转存到乐享知识库。支持 Substack、Medium、知识星球等付费平台的登录态管理。支持 YouTube 视频下载(yt-dlp)、播客音频下载(小宇宙FM等)、音频转录(Whisper)、翻译(中英对照格式),并将音视频和文字稿上传乐享知识库(文字稿使用在线文档格式,支持按块编辑)。支持 PDF 文件/链接:自动提取文本+精确裁剪图形,非中文内容默认翻译为中英对照后转存乐享。支持微博帖子抓取(CDP 模式绕过登录墙)。关键词触发:抓取文章、获取全文、付费文章、转存知识库、乐享、保存原文、fetch article、归档、YouTube、视频转录、字幕提取、视频下载、播客、podcast、小宇宙、xiaoyuzhou、PDF、论文、arxiv、微博、weibo。
---
# 抓取链接内容 & 转存知识库
> **🎬 视频/音频上传到乐享**:必须用 `scripts/upload_video_via_openapi.py`(走 OpenAPI `/cgi-bin/v1/kb/files/upload-params`)。**不要**用 MCP 的 `file_apply_upload` 或 `docs/cos-param`——它们产出 `entry_type=file` 的条目,不触发 VOD 转码,视频无法播放。详见下方「YouTube 视频处理 → Step 2:上传到乐享知识库」章节。凭证存放于 `~/.lexiang/openapi.json`(不进 git)。
## 概述
将文章 URL(免费/付费/登录墙)抓取为结构化 Markdown,并自动转存到乐享知识库,实现素材归档和可追溯。
### 最终产出物
1. `<项目子目录>/<原文标题>.md` — 完整文章 Markdown(含图片引用)
2. `<项目子目录>/<原文标题>_meta.json` — 结构化元信息(原文链接、作者、发布时间、抓取时间等)
3. `<项目子目录>/images/` — 所有文章配图
4. 乐享知识库中的文档副本(按天维度归档)
### 乐享文档链接格式(⚠️ 必须遵守)
转存完成后,**必须**按以下格式输出可点击访问的链接:
```
https://lexiangla.com/pages/{entry_id}?company_from=e6c565d6d16811efac17768586f8a025
```
- `entry_id`:`import_content` 或 `entry_create_entry` 返回的 `entry.id`
- `company_from`:固定值 `e6c565d6d16811efac17768586f8a025`(凡哥的企业 ID,不可省略,省略后链接无法访问)
- **禁止**使用 `mcp.lexiang-app.com/pages/...` 格式——这是 MCP 内部调试链接,用户无法直接访问
### 文件命名规则(重要)
- **必须使用原文标题命名**,不要用 `article.md` 等通用名称
- 文件名格式:`<原文标题>.md`、`<原文标题>_meta.json`
- 示例:`How Notion uses Custom Agents.md`、`How Notion uses Custom Agents_meta.json`
- 如果标题中包含文件名不合法字符(`/`、`\`、`:`等),替换为 `-`
- 乐享知识库转存时也使用原文标题作为文档标题
## 工作流程
### Step 1:素材收集
#### 抓取方式决策树
根据 URL 类型选择抓取方式(按优先级排列):
1. **claude.com / anthropic.com 博客**(`claude.com/blog/*`、`anthropic.com/research/*`、`anthropic.com/news/*`)→ 直接用 `fetch_article.py`(已内置 Webflow SPA 支持,自动检测 `.u-rich-text-blog` / `.w-richtext` 容器并移除内嵌 `<style>` 标签)。用法与其他站点一致:
```bash
python3 scripts/fetch_article.py fetch "<URL>" --output-dir <项目子目录>
```
2. **微信公众号文章**(`mp.weixin.qq.com`)→ **优先使用乐享 MCP `file_create_hyperlink`**(一步到位,后端自动抓取图文+OCR),详见下方「微信公众号文章处理」章节。降级方案:`fetch_article.py`
3. **YouTube 视频** → 使用 `yt_download_transcribe.py`(yt-dlp 下载 + Whisper 转录 + AI 翻译),详见下方「YouTube 视频处理」章节
4. **播客音频**(小宇宙 `xiaoyuzhoufm.com`、Apple Podcasts 等)→ yt-dlp 下载音频 + Whisper 转录,详见下方「播客音频处理」章节
5. **PDF 文件或 PDF 直链**(如 arXiv PDF、乐享知识库中已存储的 PDF、本地 PDF 路径)→ 详见下方「PDF 处理」章节。**不要**用 `web_fetch` 或 `fetch_article.py` 处理 PDF
6. **微博**(`weibo.com`)→ **必须用 `fetch_article.py --cdp`**(微博强制登录,WebFetch/Playwright 均被拦截),详见下方「微博帖子抓取」章节
7. **付费/登录墙文章** → 用 `fetch_article.py`(Cookie 注入或 CDP 模式)
8. **免费图文文章**(正文含图片/截图/图表)→ **必须**用 `fetch_article.py`(`web_fetch` 只能返回文本,无法提取和下载页面中的图片)
9. **免费纯文字文章**(正文无配图)→ 可用 `web_fetch`,内容不完整时切换 `fetch_article.py`
10. **SPA 动态渲染网站**(`fetch_article.py` 抓取正文为空或极少)→ **Playwright 直接生成 PDF**,详见下方「SPA 网站 Playwright 直接出 PDF」章节
11. **批量抓取帮助中心/文档站**(如 readme.io、GitBook、Guru 等)→ Playwright 直接生成 PDF,详见下方「SPA 网站 Playwright 直接出 PDF」章节
12. **文字观点** → 直接整理
13. **图片素材** → 分析图片内容
> **⚠️ 关键原则**:`web_fetch` 工具**只能返回文本内容,无法提取和下载页面中的图片**。任何包含图片、截图、图表的文章,都**必须**使用 `fetch_article.py` 抓取,否则图片信息会完全丢失。当不确定文章是否含图时,**默认用 `fetch_article.py`**。
>
> **⚠️ SPA 降级原则**:如果 `fetch_article.py` 抓取后正文内容极少(< 200 字符),说明该网站是 SPA 动态渲染,通用内容提取器无法工作。此时应切换到 **Playwright 直接生成 PDF** 方案。
>
> **⚠️ 图片处理必须贯穿全流程**:抓取阶段产出的 `images/` 目录中的图片,在上传到乐享时**不可遗漏**。无论走哪条路径(`md_to_page.py` / MCP connector 分块导入 / `entry_import_content`),只要本地有图片文件,就必须上传到乐享文档中。具体图片上传流程见下方「步骤 4」的降级方案 A。
#### 付费/登录墙文章获取
适用于**所有需要登录态才能查看全文的网站**(Substack 付费订阅、Medium 会员、知识星球、财新网、The Information 等),使用 `fetch_article.py` 脚本:
```bash
# Cookie 注入模式(默认,适用于大部分站点)
python scripts/fetch_article.py fetch <URL> --output-dir <项目子目录>
# CDP 模式(适用于 Cloudflare 保护站点、需要 Google 账号登录的站点)
python scripts/fetch_article.py fetch <URL> --output-dir <项目子目录> --cdp
```
**两种浏览器模式**:
| 模式 | 参数 | 原理 | 适用场景 |
|------|------|------|----------|
| Cookie 注入 | (默认) | 从 Chrome Cookie DB 提取 cookies → 注入 Playwright 浏览器 | Medium 等大部分站点 |
| **CDP** | `--cdp` | 通过 Chrome DevTools Protocol 连接用户真实 Chrome(port 9222),复用完整登录态 | **Substack(自动启用)**、OpenAI、Cloudflare 保护站点、LinkedIn、Google 系网站等 |
> **自动升级到 CDP 模式的场景**:
> 1. **Substack 站点**(所有 `*.substack.com` 及已知自定义域名):自动使用 CDP 模式,并在抓取前**校验登录态**。未登录时会暂停提示用户在 Chrome 中登录,验证通过后才继续抓取。
> 2. **Cloudflare 保护站点**(如 openai.com):自动切换 CDP 模式,等待 JS challenge 通过。
> 3. 手动指定 `--cdp` 参数。
**CDP 模式前置条件**:确保 Chrome 浏览器已开启 CDP 远程调试端口:
```bash
# 方式1(推荐):直接用带 CDP 的方式启动 Chrome
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 &
# 方式2:如果 Chrome 已在运行,需要先关闭再以 CDP 模式重启
# 脚本会自动尝试此操作,但可能需要用户手动确认
```
> **⚠️ CDP 独立 profile 的已知限制**:
>
> 脚本会使用独立的 CDP profile 目录(`~/.fetch_article/chrome_cdp_profile`),虽然会自动复制 Cookies 文件,但**以下登录态信息不会被同步**:
> - `localStorage`(Substack 等 SPA 站点的会话 token)
> - `Service Worker` 缓存
> - `sessionStorage`
>
> **实际影响**:对于 Substack 等依赖 localStorage 的站点,仅靠 Cookies 复制可能无法完全还原登录态。脚本已通过 **Substack 登录态缓存**机制(`~/.substack/storage_state.json`)弥补此限制——首次登录后会保存完整的 Playwright storage state(含 Cookies + localStorage),后续抓取直接复用。
>
> **最佳实践**:
> 1. **首次使用 Substack 前**,先运行 `python scripts/fetch_article.py login` 完成登录并缓存
> 2. 如果 CDP 模式下登录态校验失败,脚本会自动暂停并引导用户在弹出的 Chrome 窗口中登录
> 3. 登录成功后会自动刷新缓存,后续抓取无需重复登录
**工作原理**:
1. 自动从 Chrome 浏览器的 Cookie 数据库提取目标域名的登录 cookies
2. 将 cookies 注入 Playwright 浏览器上下文
3. 加载页面,自动检测并等待 Cloudflare challenge 通过(如有)
4. 滚动加载懒加载内容、下载所有图片
5. **自动格式转换**:检测下载图片的真实格式(WebP/SVG 伪装成 .png/.jpg 很常见),自动转为真正的 PNG 以确保 PDF 生成和文档嵌入兼容
6. 将正文转换为 Markdown(`article.md`),图片保存到 `images/` 子目录
7. 内容提取时自动选择**最长的内容容器**(避免只抓到免费预览区域)
**标题提取增强**(多策略回退):
1. CSS 选择器优先级:`h1.post-title` > `article h1` > `[class*="title"] h1` > `h1`
2. 回退到 `<meta property="og:title">` → `<meta name="title">` → `document.title`
3. 自动清理标题中的网站后缀(如 `" - Cursor"`、`" | Substack"`)
4. 正文中与已提取标题相同的第一个 `<h1>` 会被自动去重,避免 MD 中标题重复
**作者提取增强**:
- CSS 选择器 + `meta[name="author"]` + `[rel="author"]` + `meta[property="article:author"]` 多策略回退
### PDF 文件/链接处理(2026-05-12 实战验证 ✅)
**触发条件**:用户提供的是 PDF 直链(如 `arxiv.org/pdf/xxx`、COS/CDN 直链)、乐享知识库中已存储的 PDF 条目链接,或本地 PDF 文件路径。
**核心原则**:
- PDF 的文字和图片必须分两条路处理,不能合并为一步
- 非中文 PDF(如英文论文)**默认翻译为中英对照格式**后再转存,不可跳过
- 最终产出为乐享**在线文档(page 类型)**,支持后续编辑
#### Step A:获取 PDF 文件
**情况1:乐享知识库中已有 PDF 条目**(如 `lexiangla.com/pages/xxx`)
```python
# 1. 从 URL 提取 entry_id
entry = mcp.entry_describe_entry(entry_id="<entry_id>")
file_id = entry.target_id # PDF 的文件ID
# 2. 获取下载链接(有效期 3600s)
result = mcp.file_download_file(file_id=file_id, expire_seconds=3600)
download_url = result.url
# 3. 下载 PDF
curl -L -o paper.pdf "<download_url>"
```
> ⚠️ 注意:`entry_describe_ai_parse_content` 对大型 PDF 会超过 80K 字符限制而报错,**不要用它获取 PDF 内容**,改用下载方式。
**情况2:arXiv 等直链 PDF**
```bash
curl -L -o paper.pdf "https://arxiv.org/pdf/2605.05538"
```
#### Step B:提取文字(pymupdf)
```python
import fitz
doc = fitz.open('paper.pdf')
text = ''
for page in doc:
text += page.get_text()
with open('paper.txt', 'w') as f:
f.write(text)
print(f"Pages: {doc.page_count}, chars: {len(text)}")
```
`get_text()` 只提取纯文字——矢量图(流程图/柱状图)和表格结构会丢失,这是预期行为,图形通过 Step C 单独处理。
#### Step C:定位并精确裁剪图形
PDF 中的图形分两类,提取方式不同:
| 类型 | 判断方法 | 提取方法 |
|------|---------|---------|
| **光栅图**(嵌入的 PNG/JPEG) | `page.get_images()` 有结果 | `page.get_image_rects(xref)` 获取坐标 → `clip=Rect` 裁剪 |
| **矢量图**(流程图/柱状图,PDF 绘图命令) | `page.get_images()` 无结果,但 `page.get_drawings()` 有数据 | `page.get_drawings()` 获取边界 → `clip=Rect` 截图 |
**定位图形坐标**(先找出各 Figure 所在页面):
```python
doc = fitz.open('paper.pdf')
for page_idx in range(doc.page_count):
page = doc[page_idx]
# 找 Figure caption 位置(确定图形所在页)
blocks = page.get_text("blocks")
for b in blocks:
if 'Figure' in b[4]:
print(f"Page {page_idx+1}: [{b[0]:.0f},{b[1]:.0f},{b[2]:.0f},{b[3]:.0f}] {b[4][:60]}")
# 光栅图坐标
for img in page.get_images(full=True):
rects = page.get_image_rects(img[0])
print(f" Raster img: {rects}")
# 矢量图分布(按左栏/右栏区分,双栏论文左栏 x<295,右栏 x>295)
drawings = page.get_drawings()
if drawings:
xs0 = [d['rect'].x0 for d in drawings]
xs1 = [d['rect'].x1 for d in drawings]
ys0 = [d['rect'].y0 for d in drawings]
ys1 = [d['rect'].y1 for d in drawings]
print(f" Vector drawings bbox: ({min(xs0):.0f},{min(ys0):.0f},{max(xs1):.0f},{max(ys1):.0f})")
```
**精确裁剪图形区域**(3x 高清,仅裁剪图形本身+caption,不含论文正文):
```python
mat = fitz.Matrix(3.0, 3.0) # 3x 放大,确保清晰
# 根据上面分析的坐标,裁剪每个 Figure
# 双栏论文参考坐标:左栏 x: 58-290,右栏 x: 295-535
# y 坐标从 caption 文字位置往上定图形起点
clip = fitz.Rect(x0, y0, x1, y1) # 精确到图形边界,含 caption
pix = page.get_pixmap(matrix=mat, clip=clip)
pix.save(f'Figure{n}.png')
```
**⚠️ 关键注意事项**:
- **绝对不能用 `page.get_pixmap()` 不传 clip**——会截整页(含论文正文),不是图形本身
- `get_image_rects()` 只对光栅图有效;矢量图只能通过 `get_drawings()` 的 bbox + caption 坐标推断边界
- 验证截图效果:`Read` 工具可预览 PNG,检查是否干净(只含图形+caption)
- 如果矢量图跨越双栏(`x0 < 295`),说明是通栏图,裁剪区域应包含完整宽度
#### Step D:语言检测与翻译
读取 `paper.txt` 前 500 字符,统计中文字符占比:
- 中文字符比例 ≥ 30% → 中文内容,**跳过翻译**
- 中文字符比例 < 30% → 非中文(如英文论文),**必须翻译为中英对照格式**
**中英对照翻译格式**(每段先英文原文,紧跟中文翻译,保留所有结构):
```markdown
## Introduction / 引言
Standard RAG pipelines follow a static retrieve-then-generate paradigm...
标准 RAG 流水线遵循静态的"先检索再生成"范式...
### Tables(表格保留完整数据,加中文表头)
### Figures(图形位置用文字描述,图片在 Step E 中插入)
```
翻译方式(按优先级):
1. `translate_gemini.py`(`GEMINI_API_KEY` 可用时)
2. `translate_article.py`(`OPENAI_API_KEY` 可用时)
3. AI 助手在对话中直接逐段翻译
#### Step E:导入乐享在线文档 + 插入图片
**1. 创建在线文档(import_content)**
```python
result = mcp.entry_import_content(
name="论文标题 中英对照翻译",
content=open('paper_translated.md').read(), # 中英对照版
content_type="markdown",
parent_id="<日期目录ID>",
space_id="<SPACE_ID>"
)
page_entry_id = result.entry.id
page_root_block_id = result.entry.target_id # page 根 block,用于 move/insert
```
**2. 上传各 Figure 图片并插入正确位置**
每张图的完整流程(三步):
```bash
# Step 1: 申请上传凭证
session_id, upload_url = mcp.block_apply_block_attachment_upload(
entry_id=page_entry_id,
name="FigureN.png",
size=str(file_size),
mime_type="image/png"
)
# Step 2: 上传图片(必须包含 Content-Length 和 Content-Type)
curl -X PUT "<upload_url>" \
-H "Content-Type: image/png" \
-H "Content-Length: <size>" \
--data-binary @FigureN.png
# Step 3: 在文档正确位置插入 image block
# - 先用 block_list_block_children 获取 block 列表,找到对应章节段落的 index
# - caption 格式:英文原文 / 中文翻译(放在一行,用 / 分隔)
mcp.block_create_block_descendant(
entry_id=page_entry_id,
parent_block_id=page_root_block_id,
index="<正确位置>",
descendant=[{
"block_type": "image",
"image": {
"session_id": session_id,
"caption": "Figure N: English caption / 图N:中文翻译",
"align": "center"
}
}]
)
```
> **⚠️ 重要**:
> - **caption 英中放在一行**:用 ` / ` 分隔,不要额外插入独立段落作为图注,否则显示时会有多余换行
> - **图片位置要精确**:先获取 block 列表找到对应章节 block 的 index,不能全部 append 到末尾
> - **`update_block` 不支持修改 image block 的 caption**:如果 caption 写错了,只能 delete + recreate。重建时可用已有的 `file_id`(通过 `block_describe_block` 查询),**不需要重新上传文件**
> - **每次插入图片后 index 会偏移**:如需在不同位置插入多张图,要按顺序从前往后插,并累计计算 index
**3. 图片位置确定方法**
先获取文档当前全部 block:
```python
blocks = mcp.block_list_block_children(entry_id=page_entry_id, with_descendants=False)
```
找到对应章节的最后一个 p/h block 的 index,图片插入该 index+1 处。
**已验证的工作流(arXiv PDF 2605.05538 实战,2026-05-12)**:
- Figure 1/2 为光栅图(嵌入 PNG),用 `get_image_rects()` 定位
- Figure 3/4 为矢量图(柱状图),用 `get_drawings()` bbox + caption 坐标确定裁剪区域
- Figure 5 为光栅图(截图),用 `get_image_rects()` 定位
- 所有图片 3x 渲染后 60-90KB,质量良好
### 微信公众号文章(mp.weixin.qq.com)专项优化
脚本对微信公众号文章有专门的检测和处理策略:
1. **自动检测**:识别 `mp.weixin.qq.com` 域名,自动启用微信模式
2. **无需登录**:微信公众号文章是公开可读的,跳过登录检测和 Cookie 注入流程
3. **专用内容选择器**:使用 `#js_content` / `.rich_media_content` 精准定位正文区域(而非通用选择器可能匹配到页面其他内容)
4. **标题提取**:`#activity-name` > `h1.rich_media_title` > 通用 h1 > meta 标签回退
5. **作者提取**:`#js_name`(公众号名称)> `.rich_media_meta_nickname` > 通用选择器回退
6. **日期提取**:`#publish_time` > 通用 time/date 选择器回退
7. **图片懒加载增强**:
- 微信图片使用 `data-src` + IntersectionObserver 懒加载
- 滚动速度放慢(300px 步长、200ms 间隔)以确保触发所有 IntersectionObserver
- 强制将未触发的 `data-src` 复制到 `src`(兜底策略)
- 图片下载时优先使用 `data-src` 的高清原图 URL
8. **图片格式识别**:微信图片 URL 格式特殊(`mmbiz.qpic.cn/...?wx_fmt=png`),从 `wx_fmt` 查询参数推断文件扩展名
9. **Referer 防盗链**:通过 Playwright 页面上下文的 `page.request.get()` 下载图片,自动携带正确的 Referer 头
**Substack 站点(如 www.lennysnewsletter.com)专项优化**:
脚本对 Substack 托管的站点(`*.substack.com`、`lennysnewsletter.com` 等)有专门的登录检测和**登录态缓存**机制:
1. **登录态缓存**:登录成功后自动保存 Playwright `storage_state` 到 `~/.substack/storage_state.json`,后续抓取直接复用,**无需重复登录和邮箱验证**
2. **优先级**:缓存 `storage_state` > Chrome cookies > 引导登录
3. **自动检测登录状态**:加载页面后检查右上角是否有用户头像(已登录)还是 "Sign in" 按钮(未登录)
4. **已登录** → 直接抓取全文,并刷新缓存延长有效期
5. **缓存过期** → 自动清理旧缓存,进入引导登录流程
6. **未登录** → 打开可见浏览器窗口引导登录,用户在终端输入 `y` 确认后二次验证,通过后自动缓存
**独立登录命令**(推荐首次使用时先执行):
```bash
python scripts/fetch_article.py login
```
此命令单独完成 Substack 登录并缓存,不需要指定文章 URL。后续所有 Substack 文章抓取都会自动复用此登录态。
**非 Substack 站点的登录确认机制**:
- 无 Chrome cookies 时自动切换到非无头模式,打开可见浏览器窗口
- 终端提示用户完成登录操作后**按回车键**继续
- 收到确认信号后重新加载页面并检测付费墙状态
**付费墙检测**:脚本同时检测以下信号:
- DOM 元素:`[data-testid="paywall"]`、`.paywall`
- 文本关键词:`This post is for paid subscribers`、`Subscribe to read`、`Upgrade to paid` 等
- 注意:不同网站的付费墙 DOM 结构和关键词不同,如遇新网站抓取不完整,需检查页面实际的付费墙标识并更新检测逻辑
**判断内容是否完整的方法**:
- 先用 `web_fetch` 尝试获取,如果明显被截断(内容不完整、出现付费提示),则切换到 `fetch_article.py`
- 抓取完成后**必须**告知用户查看 `article.md` 确认内容完整性
- 关注文章末尾是否有作者署名/总结段落作为完整性标志
- 如果用户反馈内容不完整,检查:(1) 登录账号是否有付费权限 (2) 页面是否有懒加载内容未触发 (3) 内容选择器是否匹配到了免费预览区而非全文区
**产出物**:
- `<项目子目录>/<原文标题>.md` — 完整文章 Markdown(含图片引用)
- `<项目子目录>/<原文标题>_meta.json` — 结构化元信息(原文链接、作者、发布时间、抓取时间等)
- `<项目子目录>/images/` — 所有文章配图
`<原文标题>_meta.json` 格式:
```json
{
"url": "原文链接",
"title": "文章标题",
"subtitle": "副标题",
"author": "作者",
"date": "发布时间",
"content_length": 12345,
"image_count": 5,
"images": ["images/img_01_xxx.png", ...],
"fetched_at": "2026-02-25T10:30:00"
}
```
#### X.com / Twitter 帖子抓取(必须用 CDP 模式)
**X.com 是登录墙网站的典型代表**,`web_fetch` 和普通 Cookie 注入模式都无法抓取,**必须使用 CDP 模式**:
```bash
# CDP 模式(必须)
python scripts/fetch_article.py fetch "https://x.com/<username>/status/<id>" --output-dir <项目子目录> --cdp
```
**CDP 模式工作原理**:
1. 通过 Chrome DevTools Protocol (port 9222) 连接用户真实 Chrome 浏览器
2. 复用浏览器中已登录的 X 账号会话
3. 绕过自动化浏览器检测(X 会检测并阻止 Playwright/Selenium)
**CDP 模式前置条件**:
```bash
# 启动 Chrome 并开启 CDP 端口
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 &
# 验证
curl -s http://localhost:9222/json/version
```
**X.com 抓取的特殊处理**:
1. 帖子内容会转换为 Markdown 格式
2. 图片(帖子中的媒体)会下载到 `images/` 目录
3. 帖子中的链接会转换为 Markdown 链接格式
4. 转发数、点赞数等元信息会保留
**产出物**:
- `<项目子目录>/<原文标题>.md` — 帖子 Markdown
- `<项目子目录>/<原文标题>_meta.json` — 元信息
- `<项目子目录>/images/` — 帖子中的媒体图片
#### 微博帖子抓取(必须用 CDP 模式)
**微博是强制登录墙网站**,所有端口(PC 端 `weibo.com`、移动端 `m.weibo.cn`、API `m.weibo.cn/statuses/show`)均需要登录态,`web_fetch` 和普通 Playwright 都会被重定向到 `Sina Visitor System` 登录页。**必须使用 CDP 模式**。
**抓取命令**:
```bash
# CDP 模式(必须)— 连接本地已登录 Chrome
python scripts/fetch_article.py fetch "https://weibo.com/<uid>/<mid>" --output-dir <项目子目录> --cdp
```
**完整流程**:
1. **确保 Chrome 已开启 CDP 端口**(port 9222)且已登录微博
2. **运行 `fetch_article.py --cdp`**:脚本会连接真实 Chrome,复用微博登录态
3. **CDP 连接失败时的自动降级**:脚本会回退到 Cookie 注入模式(从 Chrome Cookie DB 提取 cookies),但微博 Cookie 注入通常也能工作
4. **抓取后整理内容**:微博原始 HTML 结构较乱,抓取结果中可能包含导航、按钮等噪音文本,需要手动清理或用 AI 整理为结构化 Markdown
5. **转存乐享**:使用 `entry_import_content` 创建页面(非 `file_create_hyperlink`,后者仅支持微信公众号链接)
**微博抓取的特殊注意事项**:
| 问题 | 说明 |
|------|------|
| `web_fetch` 失败 | 微博强制登录,WebFetch 会被重定向到 `passport.weibo.com/visitor/visitor` |
| Playwright 失败 | 微博检测 HeadlessChrome UA,即使用 `--browser=chrome` 也会被拦截 |
| CDP 前置条件 | Chrome 必须已开启 `--remote-debugging-port=9222` 且已登录微博 |
| 内容整理 | 微博页面标题通常是「微博正文 - 微博」,转存时应提取作者名和关键主题作为标题 |
| 图片处理 | 微博图片使用 `sinaimg.cn` CDN,`fetch_article.py` 可以下载,但部分图片可能需要 Referer |
**产出物**:
- `<项目子目录>/article.md` — 微博内容 Markdown(注意:微博标题通常是通用的,需手动重命名)
- `<项目子目录>/article_meta.json` — 元信息
- `<项目子目录>/images/` — 微博中的图片(如有)
**转存乐享时的标题建议**:
微博原始标题是「微博正文 - 微博」,转存时应改为有意义的标题,格式建议:`<作者>:<主题关键词>`,例如:
- `唐杰THU:最近的一些想法(AI 技术趋势)`
- `李飞飞:关于 Spatial AI 的思考`
#### 英文文章翻译为中英对照
对于英文文章(如 X 帖子、英文博客等),可以使用 OpenAI API 翻译为中英对照格式:
**翻译脚本** (`scripts/translate_article.py`):
```bash
python scripts/translate_article.py <原文.md> <输出.md> --model gpt-4o-mini
```
**翻译格式**:
```markdown
## 英文标题
[英文原文段落]
[中文翻译]
## 第二节英文标题
[英文原文...]
[中文翻译...]
```
**翻译工作流**:
1. 先用 `fetch_article.py` 抓取原文
2. 用 `translate_article.py` 翻译为中英对照
3. 将翻译后的 Markdown 上传到乐享知识库
**依赖**:
- `OPENAI_API_KEY` 环境变量
#### 使用 `web_fetch` 获取的免费文章
对于通过 `web_fetch` 获取到完整内容的免费文章,**同样需要保存原文**:
1. **保存原文全文**:将 `web_fetch` 返回的内容直接保存为 Markdown,**不做总结、不做摘要、不做改写**,保持原文的完整结构和措辞
2. 文件名使用原文标题:`<项目子目录>/<原文标题>.md`
3. 手动构建 `<原文标题>_meta.json`,包含 URL、标题、作者、日期等元信息
4. 如果文章包含图片,尽量下载保存到 `<项目子目录>/images/`
> **关键区分**:`web_fetch` 工具可能会返回总结/摘要版本而非原文全文。如果返回的内容明显是总结(缺少原始段落、引用、细节),需要在 `web_fetch` 调用时明确要求"返回完整原始全文内容,不要总结或缩写"。保存到本地的**必须是原文全文**,而不是经过 AI 总结的摘要。
#### YouTube 视频处理(yt-dlp + Whisper + 翻译 + 乐享)
**当用户提供 YouTube 视频链接时**,使用 `yt_download_transcribe.py` 脚本完成完整的下载-转录-翻译-归档工作流。
**⚠️ 重要**:**不要**使用 `web_fetch`(无法获取视频内容),**不要**使用 NotebookLM(已替换为本地 Whisper 方案,速度更快、无外部依赖)。
**工作流概述**:
1. **yt-dlp 下载视频** → 本地 `.mp4` 文件
2. **ffmpeg 提取音频** → WAV 格式(16kHz 单声道)
3. **Whisper 转录** → 带时间戳的文字稿
4. **AI 翻译**(如果是英文)→ 中英对照格式的 Markdown
5. **上传乐享知识库**:
- 文字稿:**以在线文档(page)格式上传**,支持后续按块维度编辑更新
- 视频文件:以文件(file)格式上传
6. **清理**:上传成功后删除本地视频文件
**Step 1:下载 + 转录 + 翻译**
```bash
cd <项目子目录>
# 完整流程(下载 + 转录 + 翻译)
python3 scripts/yt_download_transcribe.py "<YouTube URL>" \
--output-dir . \
--whisper-model base
# 常用参数:
# --whisper-model tiny|base|small|medium|large 转录模型(越大越准但越慢)
# --skip-download 跳过下载(用于重新转录已下载的视频)
# --skip-translate 跳过翻译步骤
# --keep-audio 保留提取的音频文件
```
**产出物**:
- `<视频标题>.mp4` — 下载的视频文件
- `<视频标题>.md` — 文字稿 Markdown(英文视频为中英对照格式)
- `<视频标题>_meta.json` — 视频元信息
**文字稿格式**(英文视频,中英对照):
```markdown
# 视频标题
**频道**: xxx
**发布日期**: 2026-03-10
**时长**: 15:30
**原始链接**: https://www.youtube.com/watch?v=xxx
**转录语言**: en
---
## 文字稿(中英对照)
> 以下内容采用「英文原文 + 中文翻译」对照排列。
**[00:00]**
This is the original English text from the video...
这是视频中的中文翻译文本...
**[01:23]**
Next paragraph of English text...
下一段中文翻译...
```
**Whisper 模型选择建议**:
| 模型 | 速度 | 精度 | 适用场景 |
|------|------|------|---------|
| `tiny` | 最快 | 较低 | 快速预览、非关键内容 |
| `base` | 快 | 中等 | **默认推荐**,适合大部分场景 |
| `small` | 中等 | 较高 | 口音较重、背景噪音较多 |
| `medium` | 慢 | 高 | 重要内容、需要高精度 |
| `large` | 最慢 | 最高 | 专业内容、学术演讲 |
**Step 2:上传到乐享知识库**
> 通过 lexiang MCP 工具完成上传,流程与 Step 2(普通文章转存乐享)一致。**前提是 lexiang MCP 已连接**(参见 Step 2 的「乐享 MCP 工具的调用方式」章节)。
**文字稿上传**(在线文档 page 类型):
1. 获取知识库根节点 → 检查/创建日期目录(同上述步骤 1-3)
2. 调用 `entry_import_content`(参数:`space_id`, `parent_id=<日期目录ID>`, `name="<视频标题>"`, `content=<文字稿Markdown内容>`, `content_type="markdown"`)
- ⚠️ 此接口不支持 `after` 参数,新建文档会追加到目录末尾,**无法控制位置**
3. 在线文档支持后续在乐享中按块维度编辑更新(如修正翻译)
**视频文件上传**(🚨 推荐使用 OpenAPI 路径,MCP 的 `file_apply_upload` 产生不可播放的 file 条目):
```bash
# ✅ 推荐:通过 OpenAPI 上传,产生 entry_type=video,乐享会 VOD 转码,真能播放
python3 scripts/upload_video_via_openapi.py "<视频路径>.mp4" \
--space-id <space_id> \
--parent-entry-id <父节点 entry_id> \
--media-type video
```
需要在 `~/.lexiang/openapi.json` 配置 AppKey/AppSecret/StaffID(**不入 git**)。
OpenAPI 正确流程(脚本已封装):
1. `POST /cgi-bin/v1/kb/files/upload-params`(body: `{"name":"xxx.mp4","media_type":"video"}`)→ 获取 VOD 上传签名 + state
2. `PUT <bucket>.cos.<region>.myqcloud.com/<key>` → 上传到 VOD COS
3. `POST /cgi-bin/v1/kb/entries?space_id=xxx&state=xxx`(body: `entry_type=video, name=xxx.mp4`)→ 创建可播放视频节点
**🚨 关键踩坑(2026-05-03 实战总结)**:
- ❌ 不要用 MCP 的 `file_apply_upload`——产物是 `entry_type=file + extension=video`,不触发 VOD 转码,**视频无法播放**
- ❌ 不要用 `/cgi-bin/v1/docs/cos-param` 签名接口——它只支持 `attachment/file`,签发的 state 不能用于创建 entry_type=video
- ✅ **必须用 `/cgi-bin/v1/kb/files/upload-params`**——支持 `media_type=video/audio/file`,签发的 state 可用于 `kb/entries`
- ✅ `name` 参数**必须带文件后缀**(`.mp4` 等),否则报"name 需指定文件后缀"
- ✅ `kb/entries` 接口用 **`x-staff-id`**(小写带连字符),不是 `StaffID`
**备用:MCP 三步流程(仅适用于非视频文件,如 PDF)**:
1. `file_apply_upload`(参数:`parent_entry_id=<日期目录ID>`, `name="<文件名>.pdf"`, `size=<文件字节数>`, `mime_type="application/pdf"`, `upload_type="PRE_SIGNED_URL"`)
2. `curl -X PUT "<upload_url>" -H "Content-Type: application/pdf" --data-binary "@<文件路径>"`
3. `file_commit_upload`(参数:`session_id=<上一步返回的session_id>`)
**上传成功后**:自动删除本地视频文件(`rm -f <视频文件路径>`),节省磁盘空间。
**依赖**:
- `yt-dlp`(**推荐 `brew install yt-dlp`**,不要用 `pip3 install`)— YouTube 视频下载。必须用 brew 安装以获取最新版本,pip 版本受限于系统 Python 版本(如 Python 3.9 无法安装 nightly 版),而 brew 版自带独立 Python 环境
- `openai-whisper`(`pip3 install openai-whisper`)— 音频转录
- `ffmpeg`(`brew install ffmpeg`)— 音频提取
- `openai`(`pip3 install openai`)— 翻译(需要 `OPENAI_API_KEY` 环境变量)。**如果没有 API Key,可以跳过翻译步骤,由 AI 助手直接在对话中翻译后更新文档**
#### 播客音频处理(yt-dlp + Whisper + 乐享)
**当用户提供播客链接时**(小宇宙FM `xiaoyuzhoufm.com`、Apple Podcasts 等),使用 yt-dlp 下载音频 + Whisper 转录的方式处理。
**⚠️ 重要**:yt-dlp 的 generic extractor 可以从播客页面中自动提取音频 URL(m4a/mp3),**不需要** cookies,也**不需要**专门的播客 extractor。
**工作流概述**:
1. **yt-dlp 下载音频** → 本地 `.m4a` 或 `.mp3` 文件(播客没有视频,直接是音频)
2. **ffmpeg 提取/转换音频** → WAV 格式(16kHz 单声道,Whisper 推荐)
3. **Whisper 转录** → 带时间戳的文字稿
4. **繁简转换**(如需要)→ Whisper base 模型对中文会输出繁体,需用 `opencc` 转为简体
5. **上传乐享知识库**(通过 lexiang MCP 工具):
- 文字稿:`entry_import_content` 创建为在线文档(page)格式
- 音频文件:`file_apply_upload` → `curl PUT` → `file_commit_upload` 三步上传
**Step 1:下载音频**
```bash
cd <项目子目录>
# yt-dlp 直接下载播客音频(不需要 cookies)
yt-dlp --no-playlist -o "%(title)s.%(ext)s" "<播客链接>"
```
> **小宇宙链接格式**:`https://www.xiaoyuzhoufm.com/episode/<episode_id>`
> yt-dlp 会通过 generic extractor 自动从页面中提取 `media.xyzcdn.net` 的音频直链。
**Step 2:提取 WAV + Whisper 转录**
```bash
# 提取 WAV(16kHz 单声道)
ffmpeg -i "<音频文件>.m4a" -vn -acodec pcm_s16le -ar 16000 -ac 1 -y "<音频文件>.wav"
# Whisper 转录(中文播客指定 language=zh)
python3 -c "
import whisper, json, time
model = whisper.load_model('base')
result = model.transcribe('<音频文件>.wav', language='zh', verbose=False)
with open('whisper_segments.json', 'w', encoding='utf-8') as f:
json.dump(result['segments'], f, ensure_ascii=False, indent=2)
print(f'Done: {len(result[\"segments\"])} segments')
"
```
**Step 3:合并段落 + 繁简转换 + 生成 Markdown**
使用与 YouTube 视频相同的段落合并逻辑(max_gap=1.5s, max_duration=30s,遇句末标点+gap>0.8s 断开)。
**关键**:Whisper base 模型对中文普通话倾向输出繁体字,必须用 `opencc` 进行繁简转换:
```bash
pip3 install opencc-python-reimplemented
```
```python
import opencc
converter = opencc.OpenCC("t2s")
simplified_text = converter.convert(traditional_text)
```
**文字稿 Markdown 格式**(中文播客):
```markdown
# 播客标题
> 播客:节目名 | 平台:小宇宙FM
> 嘉宾:xxx | 主播:xxx
> 发布日期:YYYY-MM-DD | 时长:xx分xx秒
> 原始链接:https://www.xiaoyuzhoufm.com/episode/xxx
> 转录工具:Whisper base + OpenCC 繁简转换
---
## Part 1:章节标题
**[00:00]** 第一段转录文本,由多个 Whisper segment 合并而成...
**[01:23]** 第二段转录文本...
## Part 2:章节标题
**[15:30]** 第三段转录文本...
```
**文字稿整理规范(🚨 必须遵守,避免格式混乱)**:
> **核心原则**:Whisper 输出的 segments 是细碎的短句(通常每条1-5秒),必须**先合并为自然段落**,再插入章节标题和时间戳。直接按 segment 粒度插入标题会导致同一个标题在每个短句前重复出现。
**段落合并策略**:
> **🚨 关键 bug 修复(2026-05-08)**:Whisper base 对中文输出几乎没有句号等标点,因此"句末标点断开"条件基本不会触发。**唯一有效的断开条件是 duration 和 gap**。必须确保 duration 计算正确:`duration = 当前 segment 的 end - 段落起始 cur_start`。
1. 相邻 segment 间隔 > 1.0s → **强制断开**
2. 累计时长 > 15s(`seg.end - cur_start > 15`)→ **强制断开**
3. 遇到句号、问号等句末标点 + gap > 0.5s → 断开为新段落
4. 合并后的段落开头标注时间戳 `**[MM:SS]**`
**⚠️ 参数选择依据**:
- `max_duration=15s` 而非 30s/60s:因为中文 Whisper 没有标点输出,只能靠 duration 强制切割。15s 约 200 字/段,阅读体验较好
- `max_gap=1.0s`:对话中的自然停顿通常 > 1s
- 目标:48 分钟播客应产出 150-200 段(平均 90-100 字/段)
**合并代码参考**:
```python
paragraphs = []
cur_text = ""
cur_start = 0
cur_end = 0
for seg in segments:
start, end, text = seg["start"], seg["end"], seg["text"].strip()
if not text:
continue
if cur_text:
gap = start - cur_end
duration = end - cur_start # ⚠️ 必须用 end 而非 cur_end
if duration > 15 or gap > 1.0:
paragraphs.append({"start": cur_start, "end": cur_end, "text": cur_text.strip()})
cur_text, cur_start, cur_end = text, start, end
else:
cur_text += text
cur_end = end
else:
cur_text, cur_start, cur_end = text, start, end
if cur_text:
paragraphs.append({"start": cur_start, "end": cur_end, "text": cur_text.strip()})
```
**章节标题插入策略(🚨 关键,避免重复)**:
1. 从播客简介/shownotes 中提取章节时间线
2. 将章节时间点转换为秒数,建立映射
3. **每个标题只插入一次**:用 `inserted_headers = set()` 跟踪已插入的标题
4. 在段落合并**完成后**,根据段落起始时间匹配最近的章节标题
5. 匹配条件:`段落起始时间 >= 章节时间点` 且 `该标题尚未插入`
**常见错误(必须避免)**:
- ❌ 在每个 Whisper segment 级别插入章节标题 → 同一标题重复几十次
- ❌ 用宽松时间容差匹配(如 `abs(start - ts) < 5`)→ 多个 segment 命中同一标题
- ❌ 不跟踪已插入状态 → 标题被重复插入
- ✅ 先合并 segments 为段落,再在段落级别插入标题,每个标题只插入一次
**Step 4:上传到乐享知识库**
与 YouTube 视频处理相同的流程(通过 lexiang MCP 工具完成,**前提是 MCP 已连接**):
1. 获取知识库根节点 → 检查/创建日期目录
2. 文字稿使用 `entry_import_content_to_entry` 创建为**在线文档(page 类型)**,**不要**直接上传 .md 文件(排版会乱,用户无法正常阅读)
3. 音频文件**必须**使用 `upload_video_via_openapi.py --media-type audio`(走 OpenAPI VOD 路径),**不要**用 MCP 的 `file_apply_upload`(产生 entry_type=file,无法在线播放)
**文字稿在线文档导入方法(🚨 分块导入,避免内容丢失)**:
> **核心问题**:播客文字稿通常 15-25K chars,无法在单次 MCP 工具调用中传入全部内容。**必须分块导入**。
**分块导入流程**:
1. 先用 `entry_create_entry`(`entry_type="page"`)创建空白 page,获取 `entry_id`
2. 将 markdown 内容按行分块,每块 ≤ 4000 chars(确保没有超长单行)
3. **第一块**:`entry_import_content_to_entry`(`entry_id=<page_id>`, `force_write=true`, `content=<第一块>`)
4. **后续块**:`entry_import_content_to_entry`(`entry_id=<page_id>`, `force_write=false`, `content=<后续块>`)— 追加到末尾
5. 验证:调用 `entry_describe_ai_parse_content` 确认内容完整(检查最后一个时间戳是否接近播客总时长)
**⚠️ 关键注意事项**:
- 每块内容必须是完整的 markdown 结构(不要在标题或段落中间切断)
- 如果文字稿中有单行超过 4000 chars 的情况(说明合并策略有 bug),需要回到 Step 3 修复合并逻辑
- 48 分钟播客(~200 段 × ~100 字/段 = ~20K chars)通常需要分 5-6 块导入
```bash
# ✅ 正确:通过 OpenAPI 上传音频(产生 entry_type=audio,触发 VOD 转码可播放)
python3 scripts/upload_video_via_openapi.py "<音频文件>.m4a" \
--space-id <space_id> \
--parent-entry-id <日期目录 entry_id> \
--media-type audio
```
> **🚨 关键踩坑(2026-05-08 实战验证)**:
> - ❌ `file_apply_upload` + curl PUT + `file_commit_upload` → 产出 `entry_type=file`,音频**无法播放**
> - ✅ `upload_video_via_openapi.py --media-type audio` → 产出 `entry_type=audio`,乐享自动 VOD 转码,**可在线播放**
**播客 vs YouTube 的关键区别**:
| 维度 | YouTube 视频 | 播客音频 |
|------|-------------|---------|
| 文件格式 | `.mp4`(视频) | `.m4a`/`.mp3`(纯音频) |
| 文件大小 | 较大(HLS 720p ~500MB) | 较小(~60MB/小时) |
| 下载方式 | 需要 HLS 格式避免 403 | 直接下载,无反爬 |
| cookies | 通常需要 | 不需要 |
| Whisper 语言 | 通常是英文(需翻译) | 通常是中文(需繁简转换) |
| 上传 MIME | `video/mp4` | `audio/mp4` 或 `audio/mpeg` |
**依赖**(额外):
- `opencc-python-reimplemented`(`pip3 install opencc-python-reimplemented`)— 繁体转简体(Whisper base 模型中文输出为繁体时需要)
#### 结构化分析
输出结构化分析:
```
【文章主题】一句话概括
【核心论点】3-5 个关键观点
【关键数据】文章中的重要数据/图表
【利益相关】作者/机构的立场与潜在倾向(如有)
【原文出处】完整标题 + URL
```
规划图表:第 1 张为总览图,第 2-N 张各聚焦 1 个核心论点。向用户确认图表数量和主题划分。
### Step 2:原文保存到乐享知识库
**在进入信息图生成流程之前,先将原文完整保存到乐享知识库**,确保素材归档和可追溯。
#### 配置文件与初始化
本 skill 的目标知识库等信息通过配置文件管理,**不在 SKILL.md 中硬编码**。
配置文件路径:**`config.json`**(位于 skill 根目录,即与本 SKILL.md 同级)
##### 对话式配置初始化(首次使用时自动触发)
当 `config.json` 中 `_initialized` 为 `false` 或 `space_id` 为空时,**在执行任何乐享操作前**,必须先通过对话引导用户完成配置。
**核心设计**:用户只需要粘贴一个乐享知识库链接,Agent 自动完成所有配置。
**链接格式**:`https://<domain>/spaces/<space_id>?company_from=<company_from>`
- 示例:`https://lexiangla.com/spaces/b6013f6492894a29abbd89d5f2e636c6?company_from=e6c565d6d16811efac17768586f8a025`
- 从链接中可解析出三个关键信息:**域名**(`lexiangla.com`)、**space_id**、**company_from**
---
**流程如下**:
**第一步:检测 MCP 连接**
1. 尝试调用任意一个 lexiang MCP 工具(如 `whoami`)检测 MCP 是否已连接
2. 如果调用成功 → MCP 已连接,进入第二步
3. 如果调用失败(MCP 未连接)→ 引导用户完成 MCP 鉴权:
```
⚠️ 乐享 MCP 尚未连接。请先完成鉴权配置:
1. 访问 https://lexiangla.com/mcp 登录后获取 COMPANY_FROM 和 LEXIANG_TOKEN
2. 按照你使用的 Agent 配置 MCP 连接:
- CodeBuddy:在 MCP 管理面板中添加 lexiang server
- OpenClaw:运行 claw install https://github.com/tencent-lexiang/lexiang-mcp-skill
- 其他 Agent:在 MCP 配置文件中添加 lexiang server
3. 完成后告诉我,我会继续配置流程。
```
**不要继续后续步骤**,等待用户完成 MCP 连接后重试。
**第二步:请求用户提供知识库链接**
1. 向用户发送引导消息:
```
🔧 首次使用,需要配置目标知识库。
请粘贴你想用来归档文章的乐享知识库链接,格式如:
https://lexiangla.com/spaces/xxxxx?company_from=yyyyy
💡 获取方式:在乐享中打开目标知识库,复制浏览器地址栏中的链接即可。
```
2. **等待用户输入**,不要自行猜测或列举知识库
**第三步:解析链接并验证**
1. 从用户提供的链接中用正则解析出三个字段:
- **domain**:链接的域名部分(如 `lexiangla.com`),用于生成后续访问链接
- **space_id**:`/spaces/` 后面的路径段(如 `b6013f6492894a29abbd89d5f2e636c6`)
- **company_from**:`company_from=` 参数值(如 `e6c565d6d16811efac17768586f8a025`)
2. 如果链接格式不正确(缺少 `space_id` 或 `company_from`)→ 提示用户重新粘贴正确的链接
3. 调用 `space_describe_space`(参数:`space_id=<解析出的 space_id>`)验证知识库是否存在
4. 如果验证失败 → 提示用户检查链接是否正确或是否有该知识库的访问权限
**第四步:写入配置并确认**
1. 将解析和验证得到的信息写入 `config.json`:
- `lexiang.target_space.space_id` = 解析出的 space_id
- `lexiang.target_space.space_name` = 从 `space_describe_space` 返回值获取的知识库名称
- `lexiang.target_space.company_from` = 解析出的 company_from
- `lexiang.access_domain.domain` = 解析出的域名
- `lexiang.access_domain.page_url_template` = `https://<domain>/pages/{entry_id}?company_from={company_from}`
- `lexiang.access_domain.space_url_template` = `https://<domain>/spaces/{space_id}?company_from={company_from}`
- `_initialized` = `true`
2. 向用户确认配置结果:
```
✅ 配置完成!
📚 目标知识库:<知识库名称>
🔗 访问链接:https://<domain>/spaces/<space_id>?company_from=<company_from>
后续抓取的文章将自动归档到此知识库。如需更换,告诉我「重新配置知识库」即可。
```
##### 重新配置
当用户说「重新配置知识库」、「切换知识库」、「更换目标知识库」等类似意图时:
1. 将 `config.json` 中 `_initialized` 设为 `false`
2. 重新执行上述对话式初始化流程(从第一步开始)
##### 用户输入容错
用户可能不会粘贴完美的链接,需要处理以下情况:
| 用户输入 | 处理方式 |
|---------|---------|
| 完整链接 `https://lexiangla.com/spaces/xxx?company_from=yyy` | 直接解析 ✅ |
| 不带 company_from 的链接 `https://lexiangla.com/spaces/xxx` | 提示:「链接中缺少 company_from 参数。请在乐享中重新复制完整链接(地址栏中通常会包含 ?company_from=xxx),或者访问 https://lexiangla.com/mcp 获取你的 COMPANY_FROM 值告诉我。」|
| 纯 space_id `b6013f6492894a29abbd89d5f2e636c6` | 提示:「请提供完整的知识库链接(包含 company_from 参数),我需要从链接中同时获取知识库 ID 和企业标识。」|
| 页面链接 `https://lexiangla.com/pages/xxx` | 提示:「这是一个页面链接,请提供知识库链接(格式:https://lexiangla.com/spaces/xxx?company_from=yyy)。你可以在乐享中进入目标知识库首页,复制地址栏链接。」|
| 返回的文档链接打不开/无权限 | 链接中缺少 `company_from` 参数。页面链接必须带 `?company_from=xxx`,格式:`https://lexiangla.com/pages/<entry_id>?company_from=<company_from>` |
##### 配置结构
```json
{
"_initialized": false,
"lexiang": {
"target_space": {
"space_id": "",
"space_name": "",
"company_from": ""
},
"access_domain": {
"domain": "lexiangla.com",
"page_url_template": "https://lexiangla.com/pages/{entry_id}?company_from={company_from}",
"space_url_template": "https://lexiangla.com/spaces/{space_id}?company_from={company_from}"
}
}
}
```
> **`access_domain` 会从用户粘贴的链接中自动提取域名**,无需手动配置。适配自定义域名的乐享部署。
后续文档中所有 `<SPACE_ID>`、`<COMPANY_FROM>`、`<ACCESS_DOMAIN>` 等占位符,均指从 `config.json` 中读取的实际值。
#### 乐享 MCP 工具的调用方式(重要 — 多 Agent 适配)
本 skill 需要服务多个 Agent 产品(OpenClaw、CodeBuddy、Claude Desktop 等)。不同 Agent 连接乐享 MCP 的方式不同,但**暴露的工具名称和参数完全一致**(都是 lexiang MCP server 提供的标准工具)。
> **核心原则**:本 skill 只描述「调用哪个工具 + 传什么参数」,**不规定具体的 MCP 调用语法**。每个 Agent 按自己的方式调用即可。
**各 Agent 产品的 MCP 连接方式**:
| Agent 产品 | lexiang MCP 连接方式 | 工具调用方式 |
|-----------|---------------------|-------------|
| **CodeBuddy** | 在 `~/.codebuddy/mcp.json` 中配置 lexiang server,通过 IDE 的 MCP 管理面板启用连接 | 直接调用 `space_describe_space`、`file_apply_upload` 等 lexiang MCP 工具 |
| **OpenClaw** | `claw install https://github.com/tencent-lexiang/lexiang-mcp-skill`,加载 skill 时自动连接 MCP | 同上,通过 skill 暴露的 MCP 工具调用 |
| **Claude Desktop / 其他 MCP 兼容 Agent** | 在 Agent 的 MCP 配置文件中添加 lexiang server URL | 同上 |
**MCP 连接检测与降级**:
在执行乐享操作前,**必须先检测 lexiang MCP 是否已连接**:
1. 读取 `config.json`,检查 `_initialized` 和 `lexiang.target_space.space_id`
2. 如果未初始化 → 先触发对话式配置初始化(参见上方「对话式配置初始化」),初始化流程中会自动完成 MCP 连接检测
3. 如果已初始化,尝试调用 `space_describe_space`(参数:`space_id=<config 中的 space_id>`)验证 MCP 连接
4. 如果调用成功 → MCP 已连接,继续后续流程
5. 如果调用失败(MCP 未连接)→ **提示用户检查 MCP 连接**,给出对应 Agent 的操作指引:
- CodeBuddy:「请在 MCP 管理面板中确认 lexiang server 已启用并显示为已连接状态」
- OpenClaw:「请确认已安装 lexiang skill(`claw install https://github.com/tencent-lexiang/lexiang-mcp-skill`)」
- 其他 Agent:「请确认 MCP 配置中已添加 lexiang server」
> **⚠️ 禁止降级为 curl 调用 REST API**:即使 MCP 未连接,也**不要**自行编写 curl 调用乐享 REST API,因为:(1) 认证信息硬编码在 curl 中不安全;(2) 不同 Agent 的执行环境差异大,curl 方式不通用;(3) REST API 的 URL 格式和鉴权方式可能变化。应该引导用户修复 MCP 连接。
**认证配置**(首次使用时需要):
1. 访问 [https://lexiangla.com/mcp](https://lexiangla.com/mcp) 登录后获取 **`LEXIANG_TOKEN`**(访问令牌,格式:`lxmcp_xxx`)
> `COMPANY_FROM` 无需手动获取 — 会从用户粘贴的知识库链接中自动解析
2. 配置方式(二选一):
- **环境变量**(推荐):`export LEXIANG_TOKEN="lxmcp_xxx"`
- **直接修改 MCP 配置**:将 MCP server URL 中的 `${LEXIANG_TOKEN}` 占位符替换为实际值
3. 详细配置步骤参见:[lexiang-mcp-skill setup.md](https://github.com/tencent-lexiang/lexiang-mcp-skill/blob/main/setup.md)
#### 目标知识库
从 `config.json` 的 `lexiang.target_space` 中读取:
- **知识库名称**:`config.lexiang.target_space.space_name`
- **知识库访问链接**:按 `config.lexiang.access_domain.space_url_template` 格式拼接
- **Space ID**:`config.lexiang.target_space.space_id`
> **⚠️ 访问链接域名**:用户可访问的乐享前端域名从 `config.lexiang.access_domain.domain` 读取(默认为 `lexiangla.com`),**不是** `mcp.lexiang-app.com`(后者是 MCP API 服务端域名,浏览器无法直接访问)。所有展示给用户的链接必须按 `config.lexiang.access_domain.page_url_template` 格式生成。
#### 目录组织方式
按**天维度**组织目录:
```
知识库根目录/
2026-02-25/
文章标题A (图文文章,在线文档 page 类型,图片内嵌)
文章标题B (纯文本文章,在线文档 page 类型)
2026-02-26/
文章标题C (在线文档 page 类型)
```
> **⚠️ 默认格式**:所有文章(无论是否含图片)**统一使用在线文档(page)格式上传**。在线文档支持在乐享中直接编辑、划词评论、全文检索,体验远优于 PDF。PDF 仅作为降级方案(`md_to_page.py` 失败时)或用户明确要求时使用。
#### 操作流程
> **⚠️ 严格按步骤顺序执行,不得跳步!** 必须完成步骤 0→1→2→3→4 的完整流程。尤其是**步骤 2(创建日期目录)不可跳过**——文档必须上传到当天日期命名的文件夹中,而不是直接上传到知识库根目录。如果跳过步骤 2 直接用 `root_entry_id` 作为上传目标,文档将错误地出现在根目录下。
通过 lexiang MCP 工具,按以下步骤完成转存:
**步骤 0:读取配置(含初始化检测)**
- 读取 skill 目录下的 `config.json` 文件
- 检查 `_initialized` 是否为 `true` 且 `lexiang.target_space.space_id` 非空
- 如果**未初始化**(`_initialized` 为 `false` 或 `space_id` 为空)→ **触发对话式配置初始化流程**(参见上方「对话式配置初始化」),完成后再继续
- 提取 `lexiang.target_space.space_id`、`lexiang.access_domain.page_url_template` 等配置项
**步骤 1:获取知识库根节点**
- 调用 `space_describe_space`(参数:`space_id=<config 中的 SPACE_ID>`)
- 从返回结果中提取 `root_entry_id`
**步骤 2:检查/创建当天日期目录(🚨 必须先查再建,禁止直接创建)**
> **🚨 这是本 skill 最常见的错误!** 2026-05-11 实战中,Agent 未查询直接创建了同名目录。每次执行到此步骤,**必须**严格按照下方决策树执行,绝对禁止跳过查询直接调用创建工具。
---
**🚨 执行前必读:两种常见错误**
| # | 错误做法 ❌ | 正确做法 ✅ |
|---|---|---|
| 1 | 直接调用 `mcp__lexiang__entry_create_entry` 创建文件夹 | 先调用 `mcp__lexiang__entry_list_children` 查询根目录 |
| 2 | 只匹配 `name=="2026-05-11"`,不检查 `entry_type` | 必须同时匹配 `name=="2026-05-11"` **且** `entry_type=="folder"` |
---
**决策树(必须逐条执行,不可跳步):**
```
步骤 2a:查询根目录
工具:mcp__lexiang__entry_list_children
参数:{"parent_id": "<root_entry_id>"}
遍历返回的 entries[] 数组:
查找是否有 entry_type=="folder" 且 name=="当天日期" 的条目
例如今天 2026-05-11 → 查找 name=="2026-05-11" 且 entry_type=="folder"
如果找到 → 记录其 id → 【跳到步骤 3,不创建】
如果没找到 → 【继续到步骤 2b】
步骤 2b:确认不存在后,才能创建
调用:mcp__lexiang__entry_create_entry
参数:{"entry_type": "folder", "parent_entry_id": "<root_entry_id>", "name": "当天日期"}
🚨 创建后必须置顶(否则新目录会出现在末尾):
1. 先调用 entry_list_children 获取父目录当前第一个条目的 entry_id
2. 调用 entry_move_entry,使用 **before** 参数传入第一个条目的 entry_id,将新目录移到它之前(即置顶)
参数:{"entry_id": "<新建的entry_id>", "parent_id": "<root_entry_id>", "before": "<当前第一个条目的entry_id>"}
⚠️ 注意事项:
- after="" 实测是移到末尾(非置顶),API 文档描述有误,禁止使用
- 必须用 before=<第一个条目ID> 才能真正置顶
- 这一步不可省略!创建目录后如果不执行 move,新目录会默认出现在最底部
```
> **📌 关于分页的说明**:日期目录按创建时间倒序排列,当天的目录如果存在一定在第一页,无需处理分页。但如果你在处理非日期目录的场景(如查找某个不确定的条目),应注意 `next_page_token` 的存在。
---
**❌ 错误示例(禁止这样做):**
```
# 错误:直接创建,不查询
→ 调用 mcp__lexiang__entry_create_entry,参数 name="2026-05-11"
→ 结果:知识库中出现多个同名 "2026-05-11" 文件夹
```
**✅ 正确示例(必须这样做):**
```
# 正确:先查询第一页,找到就用,找不到才创建
→ 调用 mcp__lexiang__entry_list_children,参数 {"parent_id": "<root_entry_id>"}
→ 遍历 entries,检查是否有 name=="2026-05-11" 且 entry_type=="folder"
→ 找到 → 记录 id,跳过创建,直接进入步骤 3
→ 没找到 → 调用 mcp__lexiang__entry_create_entry 创建
```
**步骤 3:去重检查**
- 调用 `entry_list_children`(参数:`parent_id=<日期目录ID>`)查询该日期目录下已有的条目
- 按「名称 + 类型」检查是否已存在同名文档,如果已存在则跳过上传并告知用户
**步骤 3.5:非中文文章翻译(🚨 强制检查,不可跳过)**
> **⚠️ 重要**:无论文章是通过 `fetch_article.py`、`web_fetch`、PDF 下载还是其他方式获取,在上传到乐享之前**都必须经过语言检测和翻译步骤**。这是一个**强制检查点**,不存在任何可以跳过的"简化路径"。
>
> **PDF 特别说明**:PDF 文件(包括英文学术论文)同样适用此规则。非中文 PDF 必须先翻译为中英对照格式后再转存乐享,详见上方「PDF 文件/链接处理」章节的 Step D。
>
> **常见遗漏场景**:
> 1. ❌ 用 `web_fetch` 抓取后直接转 PDF 上传 → 英文原文未翻译
> 2. ❌ 觉得文章"看起来不长"就跳过翻译 → 知识库中留下纯英文文档
> 3. ❌ 翻译脚本不可用就放弃翻译 → 应该由 Agent 直接在对话中翻译
> 4. ❌ PDF 是论文就不翻译 → 英文论文同样必须翻译
>
> **正确做法**:每篇文章上传前,**必须先执行语言检测**,非中文则翻译后再上传。
在上传到乐享之前,**必须检测原文语言**。如果原文不是中文,则需要先翻译为**中英对照格式**后再归档。
**语言检测规则**:
- 读取 `<原文标题>.md` 的前 500 个字符,统计中文字符(Unicode 范围 `\u4e00-\u9fff`)占比
- 中文字符占比 **≥ 30%** → 判定为中文文章,**跳过翻译**,直接进入步骤 4
- 中文字符占比 **< 30%** → 判定为非中文文章,**执行翻译**
**翻译排版格式(中英对照)**:
- 按段落逐段翻译,每段原文紧跟对应中文翻译
- **段落之间不加分隔线 `---`**,仅通过空行分隔
- **中文翻译段落开头不加国旗 emoji(🇨🇳)**,直接以中文开始
- 标题也需要翻译,保留原文标题 + 中文翻译标题
- 列表项、引用块等结构元素同样逐条翻译
- **保留原文中的图片引用**(``),图片引用放在对应段落的上方或下方,确保图文对应关系不丢失
```markdown
# Original English Title
# 中文翻译标题
Original first paragraph text...
第一段的中文翻译...

Original second paragraph text...
第二段的中文翻译...
```
**翻译方式(按优先级)**:
1. **translate_article.py 脚本**(如果 `OPENAI_API_KEY` 可用):
```bash
python3 scripts/translate_article.py "<原文标题>.md" "<原文标题>_translated.md" --model gpt-4o-mini
```
2. **AI 助手直接翻译**(如果无 API Key):由 Agent 在对话中逐段翻译全文,生成 `<原文标题>_translated.md`
**翻译完成后**:
- 本地保存两个文件:`<原文标题>.md`(原文)和 `<原文标题>_translated.md`(中英对照版)
- **归档到乐享知识库的必须是翻译后的中英对照版本**(`_translated.md`),确保知识库中的内容对中文读者友好
- 乐享文档标题使用:`<原文标题中文翻译>(<原文标题>)`,如:`AI 原型精通阶梯(The AI Prototyping Mastery Ladder)`
**步骤 3.7:评价信息处理(可选)**
如果在转存前用户提供了对文章的评价(例如:"这篇文章好在:1)... 2)..."),需要在上传时自动添加评价信息:
1. **检测评价信息**:在对话中识别用户是否提供了评价内容(关键词:好在、评价、优点、建议等)
2. **保存评价内容**:将评价信息保存到临时文件(如 `/tmp/evaluation.txt`)
3. **传入脚本参数**:调用 `md_to_page.py` 时,添加 `--evaluation-file /tmp/evaluation.txt` 参数
4. **脚本自动处理**:`md_to_page.py` 会自动在文档顶部插入评价信息(格式为 blockquote,乐享可能自动转换为 callout 组件)
**对于非在线文档格式(如视频)**:
- 由于 lexiang MCP 工具中**没有创建评论的 API**(只有查询评论的 `comment_list_comments` 和 `comment_describe_comment`),暂时无法自动添加评论到视频文件
- **建议**:转存完成后,手动在乐享中添加评论
**示例:用户提供评价后的处理流程**
```bash
# 1. 将用户评价保存到临时文件
cat > /tmp/evaluation.txt << 'EOF'
这篇文章好在:
1)把智能体Agent做了分类,每个分类定义了对应是适用场景;
2)列举了详实的案例说明;
3)通过构建的复杂度、技术架构、实现时长、运行成本、衡量成功等几个维度来系统化地综合判断Agent落地的优先级
4)未来关于Agent选型上,能够提供系统性的参考建议
EOF
# 2. 调用 md_to_page.py,传入评价文件
python3 scripts/md_to_page.py "<原文标题>_translated.md" \
--parent-id <日期目录ID> --name "<文档标题>" \
--evaluation-file /tmp/evaluation.txt \
--token "$LEXIANG_TOKEN" --company-from "$COMPANY_FROM"
```
**评价信息格式说明**:
- 脚本会将评价信息格式化为 blockquote(以 `>` 开头的 Markdown 格式)
- 在乐享在线文档中,blockquote 可能被自动渲染为 callout 组件(带有左侧竖线或背景色)
- 如果需要真正的 callout 组件(特殊 block 类型),需要通过 `block_create_block_descendant` API 创建,但需要先了解 callout 的 block 结构
**步骤 3.8:页面内嵌视频检测与链接附加(⚠️ 不可跳过)**
在生成 PDF 或上传之前,**必须检测页面中是否包含嵌入视频**。嵌入视频(如 Wistia、YouTube、Vimeo、Loom 等 iframe 嵌入)在转为 PDF 时会完全丢失,因此需要将视频链接以文本形式附加到文档末尾,确保知识库读者能找到并观看原始视频。
**检测范围**(按优先级扫描):
1. `<iframe>` 嵌入 — 匹配 `src` 中包含 `youtube`、`youtu.be`、`vimeo`、`loom`、`wistia`、`vidyard`、`player` 的 iframe
2. `<video>` 标签 — 提取 `src` 或内部 `<source>` 的 `src`
3. `<a>` 链接 — 匹配 `href` 指向 `youtube.com/watch`、`youtu.be/`、`vimeo.com/`、`loom.com/share` 等视频平台
4. 平台特定容器 — 如 readme.io 的 `rdmd-embed` 组件、`[class*="video"]` 容器等
**视频链接还原规则**:
- Wistia embed(`fast.wistia.net/embed/iframe/<id>`)→ 附加可观看链接 `https://fast.wistia.net/embed/iframe/<id>`
- YouTube embed(`youtube.com/embed/<id>`)→ 还原为 `https://www.youtube.com/watch?v=<id>`
- Vimeo embed(`player.vimeo.com/video/<id>`)→ 还原为 `https://vimeo.com/<id>`
- Loom embed(`loom.com/embed/<id>`)→ 还原为 `https://www.loom.com/share/<id>`
- 其他视频 URL → 原样保留
**附加格式**:在 Markdown 文档末尾(PDF 生成前)追加一个独立章节:
```markdown
---
## 📹 页面内嵌视频
本页面包含以下嵌入视频,PDF 中无法播放,请通过链接观看:
1. [视频] https://fast.wistia.net/embed/iframe/xxxxx
2. [视频] https://www.youtube.com/watch?v=yyyyy
```
如果使用 Playwright 直接生成 PDF(非 `fetch_article.py` 抓取),应在 `page.pdf()` 之前通过 `page.evaluate()` 在页面底部注入视频链接信息块。
**步骤 4:上传到乐享(统一使用在线文档格式)**
> **🚨 核心原则:所有文章默认使用在线文档(page)格式上传,不再默认转 PDF。**
> 在线文档的优势:支持编辑、划词评论、全文检索、移动端阅读体验好。
> PDF 仅在以下情况使用:(1) `md_to_page.py` 和 `entry_import_content` 都失败时的最终降级;(2) 用户明确要求 PDF 格式。
检查 `<原文标题>.md` 文件同目录下是否存在 `images/` 目录且包含图片文件:
> **🚨 图片判断 Checklist(必须逐条检查)**:
> 1. `images/` 目录是否存在且有图片?
> 2. Markdown 内容中是否有 `` 本地引用?(仅检查目录不够!如果 images/ 有图片但 Markdown 中无引用,说明抓取阶段出了问题——正文提取失败导致图片引用丢失,需要重新抓取)
> 3. 如果 Markdown 中只有外链图片(``)而无本地引用,说明图片没有被下载到本地。`entry_import_content` 导入外链图片后,乐享中如果外链 CDN 有防盗链/过期,图片将不可见。此时应先下载图片到本地,再用 `md_to_page.py` 导入。
>
> **判断结论**:
> - ✅ images/ 有图片 + Markdown 有 `` → **图文文章**,走图文路径
> - ⚠️ images/ 有图片 + Markdown 无本地引用 → **抓取异常**,需重新抓取或手动修复 Markdown
> - ⚠️ images/ 无图片 + Markdown 有外链图片 → **外链图片**,建议下载后转本地引用
> - ✅ images/ 无图片 + Markdown 无图片引用 → **纯文本文章**,走纯文本路径
- **有图片(图文文章)** → 使用 `scripts/md_to_page.py` 将 Markdown 图文导入为在线文档(图片内嵌到正文对应位置):
```bash
python3 scripts/md_to_page.py "<原文标题>.md" \
--parent-id <日期目录ID> --name "<原文标题>" \
--token "$LEXIANG_TOKEN" --company-from "$COMPANY_FROM"
```
脚本会自动:按图片位置拆分 markdown → 分段导入文字(直传原始 markdown,不做 base64 编码)→ 逐张上传图片到 COS → 在正确位置插入 image block。
**降级方案 A(脚本无 token 时 — 通过 MCP connector 交替导入图文)**:
当 `md_to_page.py` 因缺少 LEXIANG_TOKEN 无法运行时(如 mcp.json 中无 lexiang 配置,只有 connector 模式),改用以下流程。
> **🚨 核心原则:交替导入,严禁先全文后补图!**
>
> 必须按「文字段→图片→文字段→图片→...」的顺序交替导入,这样每张图片自然落在正确位置。
> 绝对不能先把全部文字一次性导入,事后再补图——这会导致所有图片堆积在文档末尾,破坏图文混排。
**执行流程(照此逐步执行,不可跳步)**:
```
步骤 A1:准备——按图片位置拆分 Markdown
读取 article.md 内容
按  引用位置拆分为交替的 segments 数组:
[("text", "第一段文字..."), ("image", "img_01.png"), ("text", "第二段文字..."), ("image", "img_02.png"), ...]
如果文字段超过 15000 字符,按段落 \n\n 二次拆分为多个 ≤15000 的子块
步骤 A2:创建空白 page
entry_create_entry(entry_type="page", parent_entry_id=<日期目录ID>, name="<原文标题>")
记录 entry_id
步骤 A3:交替导入(按 segments 数组顺序逐个处理)
is_first = true
for each segment in segments:
if segment.type == "text":
entry_import_content_to_entry(entry_id, content=segment.text, force_write=is_first)
is_first = false
elif segment.type == "image":
img_path = images/<segment.filename>
if 文件不存在 or 文件 < 1KB → 跳过
// 三步上传图片
1. block_apply_block_attachment_upload(entry_id, name, size, mime_type)→ session_id + upload_url
2. curl -X PUT "<upload_url>" --data-binary @<img_path> → 确认 HTTP 200
3. block_create_block_descendant(entry_id, index="-1", descendant=[{block_type: "image", image: {session_id}}])
// index="-1" 在这里是正确的!因为是交替执行,当前末尾就是刚导入的文字段之后
```
> **⚠️ 为什么 index=-1 在交替导入中是正确的?**
> 因为文字和图片严格交替执行:先导入文字(追加到末尾),再在末尾插入图片,图片自然位于刚导入的文字之后。
> **只有「先全文导入完毕→事后补图」的场景下,index=-1 才会导致图片堆积在末尾。**
>
> **⚠️ 小图片(<50KB 的 icon/分隔线/SVG 装饰图)可跳过**,只上传有信息量的关键图片(图表/截图/概念图)。
> **⚠️ 得到 APP 文章特殊情况**:得到文章通常有 80-100+ 张图片,其中大部分是公式渲染图(3-10KB),真正有信息量的数据图表约 5-10 张(>50KB)。对得到文章,**必须**上传 >50KB 的关键图片(概念图、流程图、案例配图等),不需要逐张上传所有小图。
> **⚠️ 得到文章完整转存流程(2026-05-09 实战验证)**:
> 1. `fetch_article.py --cdp` 抓取全文 → 本地 article.md + images/
> 2. 提取纯文字版(去掉 `` 引用 + 去掉得到APP UI噪声如"展开"、"分享"、点赞数、用户留言等)
> 3. 创建 page → 分块导入纯文字(每块 ≤4000 chars)
> 4. 筛选 >50KB 的关键图片(用 `find images/ -size +50k`)
> 5. 排除 SVG 格式的 UI 图标(查看文件头是否为 `<?xml`)
> 6. 逐张上传关键图片并插入文档对应位置
**降级方案 B(最终降级)**:如果以上都失败 → 调用 `scripts/md_to_pdf.py` 转为 PDF,再通过三步上传流程上传:
1. `file_apply_upload`(参数:`parent_entry_id=<日期目录ID>`, `name="<原文标题>.pdf"`, `size=<文件字节数>`, `mime_type="application/pdf"`, `upload_type="PRE_SIGNED_URL"`)
2. 使用 `curl -X PUT` 将 PDF 文件上传到返回的 `upload_url`
3. `file_commit_upload`(参数:`session_id=<上一步返回的session_id>`)
> **🚨 绝对禁止**:不要用 `file_apply_upload` 直接上传 .md 文件!.md 上传后在乐享中会丢失所有图片信息,用户看到的只是含 `` 引用的纯文本,毫无可读性。
- **无图片(纯文本文章)** → 使用 `entry_import_content` 创建为**在线文档(page 类型)**:
- 参数:`space_id=<config 中的 SPACE_ID>`, `parent_id=<日期目录ID>`, `name="<原文标题>"`, `content=<Markdown文件内容>`, `content_type="markdown"`
- 在线文档支持在乐享中直接编辑
- **通过 `web_fetch` 抓取的文章(无本地图片文件)** → 直接使用 `entry_import_content` 创建在线文档,Markdown 内容中的外链图片在乐享中可能无法显示,但文字内容完整可编辑、可检索。
**步骤 5:输出结果**
- 按 `config.json` 中的 `lexiang.access_domain.page_url_template` 格式拼接文档链接,告知用户
- 示例:`https://lexiangla.com/pages/<entry_id>?company_from=<company_from>`(域名和 company_from 从配置读取,**不要**硬编码)
- **⚠️ 链接必须包含 `company_from` 参数**,否则用户打开页面会跳转到登录页或显示无权限
#### 注意事项
- **配置初始化是前置条件**:首次使用时会自动通过对话引导完成知识库配置,无需手动编辑文件
- **MCP 连接是前置条件**:必须先确认 lexiang MCP 已连接才能执行操作。不同 Agent 的连接方式不同,参见上方「乐享 MCP 工具的调用方式」
- **访问链接域名**:展示给用户的链接一律按 `config.json` 中 `page_url_template` 格式生成(含 `company_from` 参数),**不要**使用 `mcp.lexiang-app.com`,**不要**省略 `company_from`
- **上传前自动去重**:按「文档名称 + 文档类型」在目标日期目录下查重,避免重复上传
- **默认使用在线文档(page)格式**:所有文章(含图文)统一以在线文档格式上传,支持编辑、检索、评论。PDF 仅作为最终降级方案
- 纯文本文章直接用 `entry_import_content`,图文文章优先用 `md_to_page.py`(图片内嵌),降级用 `entry_import_content`(图片不内嵌但文字完整)
- PDF 转换依赖 `pymupdf` 库(`pip3 install pymupdf`),仅在前两种方式都失败时使用
- 如果同一天多次处理不同文章,它们会归入同一个日期目录下
- 使用 `_mcp_fields` 参数可以减少返回数据量,如 `_mcp_fields=["id", "root_entry_id", "name"]`
## 脚本文件
| 文件 | 用途 |
|------|------|
| `scripts/fetch_article.py` | 通用文章抓取脚本(Chrome cookies + Playwright)。支持付费墙/登录墙、微信公众号、得到、**Webflow SPA(claude.com/anthropic.com)**等多种站点,自动识别内容容器,输出 Markdown + 图片 + 元信息 JSON。Webflow 支持为内置自动检测,无需额外参数 |
| `scripts/md_to_pdf.py` | Markdown 转 PDF 脚本(使用 pymupdf,嵌入本地图片,正确渲染中文,支持标题回退和拆行标题修复) |
| `scripts/md_to_page.py` | **【推荐】** Markdown 图文导入乐享在线文档脚本。按图片位置将 markdown 拆分为 text/image 交替段落,分段导入到乐享 page(文字用 entry_import_content_to_entry 直传原始 markdown,图片用 block_apply_block_attachment_upload + curl PUT + block_create_block_descendant 三步上传)。⚠️ 脚本通过 HTTP JSON-RPC 直连乐享 MCP API,content 字段**不需要 base64 编码**(直传原始 markdown 字符串)。支持任意长度文章,图片内嵌到正文对应位置,生成可编辑、可划词评论的在线文档。用法:`python3 scripts/md_to_page.py <md_file> --entry-id <ID> --token <TOKEN> --company-from <CF>` 或 `--parent-id <PID> --name "标题"` 创建新页面 |
| `scripts/yt_download_transcribe.py` | YouTube 视频下载 + Whisper 转录 + AI 翻译脚本(yt-dlp 下载、ffmpeg 提取音频、Whisper 转录、OpenAI 翻译为中英对照 Markdown)。也可用于播客音频转录(跳过视频下载步骤) |
| `scripts/translate_gemini.py` | 使用 Gemini API 将英文 Markdown 翻译为中英对照格式。按 ~4K 字符分段翻译,每段间隔 2 秒避免限频。模型:`gemini-2.5-flash`。需要 `GEMINI_API_KEY` 环境变量。用法:`python3 scripts/translate_gemini.py`(翻译后生成 `_translated.md` 文件) |
> **注意**:乐享知识库操作不再通过独立脚本(`save_to_lexiang.sh`/`upload_yt_to_lexiang.sh`)完成,而是由大模型通过 **lexiang MCP 工具**直接执行。不同 Agent 产品(OpenClaw、CodeBuddy、Claude Desktop 等)各自管理 MCP 连接,但调用的工具名称和参数完全一致。
## 经验总结
### 在线文档图文导入(md_to_page.py)
**核心方案**:Python 脚本通过 HTTP JSON-RPC 直连乐享 MCP API,按图片位置将 markdown 拆分为 text/image 交替段落,逐段导入。
**为什么不走 IDE 的 MCP 工具调用**:
- IDE MCP 工具调用有参数长度限制,45K 字符的 markdown base64 编码后 62K,无法一次性传递
- Python 脚本直连 HTTP JSON-RPC 没有此限制,按 ~15K 字符分段传输即可
**关键踩坑(⚠️ 重要)**:
1. **不要做 base64 编码**:通过 HTTP JSON-RPC 直连时,`entry_import_content_to_entry` 的 content 字段直传原始 markdown 字符串。如果做了 base64 编码,乐享会把 base64 字符串当成纯文本存储,页面显示为乱码。只有通过 IDE MCP 协议调用时才需要 base64
2. **图片需逐张插入**:`block_create_block_descendant` 一次传多张图片的 block 会失败,必须一张一张来
3. **文字分段追加**:第一段用 `force_write=true` 覆盖,后续段用 `force_write=false` 追加到末尾
4. **图片位置要正确**:先按原文中 `` 的位置拆分 markdown,确保文字和图片按原文顺序交替插入
5. **乐享文档名称**:要与文章原标题一致,创建时通过 `--name` 指定,或创建后用 `entry_rename_entry` 修改
**翻译注意事项**:
- **所有英文文章默认必须翻译为中英对照格式再归档**,不可跳过
- 翻译脚本 `translate_gemini.py` 使用 Gemini API(模型:`gemini-2.5-flash`),按 ~4K 字符分段翻译
- Gemini API `gemini-2.0-flash` 已下线,务必使用 `gemini-2.5-flash` 或更新的模型
- 翻译完成后用 `md_to_page.py --entry-id <ID>` 覆盖更新在线文档
- 如果没有 Gemini API Key 也没有 OpenAI API Key,由 AI 助手在对话中翻译后写入文件
**自测清单**(发布前必须完成):
- [ ] 通过 `entry_describe_ai_parse_content` 验证文字内容可读(非 base64 乱码)
- [ ] 通过 `block_list_block_children` 验证图片 block 存在且有 file_id
- [ ] 验证文档名称与文章标题一致
- [ ] 验证中英对照格式(英文在前,中文翻译紧跟其后)
### YouTube 视频下载与转录
**核心方案**:yt-dlp 下载 → ffmpeg 提取音频 → Whisper 本地转录 → OpenAI API 翻译
**为什么不用 NotebookLM / summarize.sh**:
1. NotebookLM 需要 Google 账号且有额度限制,部分视频可能因版权限制无法提取
2. summarize.sh 依赖外部 API(Apify/YouTube 字幕 API),部分视频无字幕时无法工作
3. Whisper 本地转录**不依赖字幕**,直接从音频波形识别语音,覆盖率 100%
**yt-dlp 版本与安装(关键!)**:
- **必须使用 `brew install yt-dlp`** 安装,不要用 `pip3 install yt-dlp`
- 原因:pip 版本受限于系统 Python 版本(macOS 自带 Python 3.9),无法安装 yt-dlp 的 nightly 版本(需要 Python 3.10+)。而 YouTube 频繁更新反爬策略,旧版 yt-dlp 会遇到 HTTP 403 Forbidden 错误
- brew 安装的 yt-dlp 自带独立 Python 环境,始终能获取最新版本
- 脚本中调用方式:直接用 `yt-dlp` 命令,**不要**用 `python3 -m yt_dlp`
**YouTube DASH 格式 403 错误(重要!)**:
- YouTube 正在强制使用 SABR(Streaming ABR)流媒体协议,传统 DASH 分片下载(`bestvideo+bestaudio`)会触发 HTTP 403 Forbidden
- **解决方案**:优先使用 HLS(m3u8)格式下载,不会被 SABR 拦截
- 脚本中的格式选择顺序:`95-1/94-1/93-1/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best`
- `95-1`: 720p HLS(推荐,画质和文件大小的最佳平衡)
- `94-1`: 480p HLS
- `93-1`: 360p HLS
- 后面是传统 DASH 格式作为回退
- HLS 格式下载的视频文件会比 DASH 大一些(720p HLS 约 500-600MB vs DASH 约 200-300MB)
- **注意**:`--extractor-args "youtube:player_client=android"` 不支持 cookies,不是可靠的 403 解决方案
**Whisper 转录最佳实践**:
- 音频预处理:16kHz 采样率、单声道 WAV(`ffmpeg -ar 16000 -ac 1`),减少文件大小且是 Whisper 推荐格式
- 段落合并策略:相邻 segment 间隔 <2s 且总时长 <60s 则合并,句号/问号结尾时倾向断开
- 模型选择:默认用 `base`(速度和精度的最佳平衡),重要内容用 `small` 或 `medium`
**翻译策略**:
- 使用 OpenAI `gpt-4o-mini`,分批翻译(每批 10 段),避免 token 超限
- 翻译 prompt 要求"自然流畅的中文表达,专业术语保留英文并附中文注释"
- 中英对照格式:每段先展示英文原文,紧跟中文翻译,段间用空行分隔(不加分隔线和国旗 emoji)
- **如果没有 OPENAI_API_KEY**:脚本会跳过翻译步骤,输出纯英文文字稿。此时可以由 AI 助手在对话中直接翻译全文,然后用 `md_to_page.py --entry-id` 更新乐享文档
**上传乐享的关键决策**:
- 文字稿使用 **在线文档(page)格式**而非文件上传,原因:支持在乐享中按块维度编辑更新,可以逐段修正翻译或补充注释
- 视频使用 **文件(file)格式**上传,因为视频不需要在线编辑
- 上传成功后自动删除本地视频文件,避免占用磁盘空间
**视频上传到乐享的正确方式(重要!)**:
- 通过 lexiang MCP 工具完成,使用三步上传流程:
1. `file_apply_upload`:申请上传凭证(传入 `parent_entry_id`=日期目录 ID、`upload_type`=PRE_SIGNED_URL、`mime_type`=video/mp4、`size`=文件字节数)
2. `curl -X PUT` 上传文件到返回的 `upload_url`(预签名 URL,直传 COS)
3. `file_commit_upload`:确认上传完成(传入 `session_id`)
- 518MB 视频的 PUT 上传约需 30-60 秒
### 播客音频转录
**核心方案**:yt-dlp(generic extractor)下载音频 → ffmpeg 转 WAV → Whisper 转录 → opencc 繁简转换
**yt-dlp 对小宇宙的支持**:
- yt-dlp 没有小宇宙专用 extractor,但 **generic extractor 完全够用**
- 小宇宙页面中嵌入了 `<audio>` 标签,音频直链在 `media.xyzcdn.net`
- 下载不需要 cookies,直接用 `yt-dlp --no-playlist -o "%(title)s.%(ext)s" <URL>` 即可
- 下载速度约 7MB/s,63 分钟播客(59MB)仅需 8 秒
**Whisper 中文转录的繁体问题(重要!)**:
- Whisper base 模型对中文普通话**倾向输出繁体字**(如「歡迎」→ 应为「欢迎」)
- 这是 Whisper 的已知行为,因为训练数据中繁体中文比重较大
- **解决方案**:转录后用 `opencc-python-reimplemented` 的 `t2s`(Traditional to Simplified)模式批量转换
- 安装:`pip3 install opencc-python-reimplemented`
- 用法:`opencc.OpenCC("t2s").convert(text)`
**中文播客 vs 英文 YouTube 的流程差异**:
- 中文播客**不需要翻译**,但**需要繁简转换**
- 播客音频是直接的 m4a/mp3 文件,**不需要从视频中提取音频**(但仍需 ffmpeg 转为 WAV 格式给 Whisper)
- Whisper 转录时**指定 `language='zh'`** 可以提高中文识别准确率
- 上传乐享时 MIME 类型用 `audio/mp4`(m4a)或 `audio/mpeg`(mp3),不是 `video/mp4`
**转录性能参考**:
- 63 分钟中文播客 → Whisper base 模型在 CPU 上转录耗时约 115 秒
- 产出 2496 个 segments,合并后 65 个段落
### 微信公众号图文抓取
**核心问题**:`web_fetch` 工具无法获取微信公众号文章的图片(懒加载 + 防盗链),**必须**使用 `fetch_article.py`。
**技术原理**:
1. **懒加载机制**:微信图片的真实 URL 存放在 `data-src` 而非 `src`,依赖 `IntersectionObserver` 在元素进入视口时才加载。Playwright 无头浏览器通过 `window.scrollBy(0, 300)` 配合 `asyncio.sleep(0.2)` 模拟慢速滚动,逐步触发所有图片的懒加载观察器
2. **兜底策略**:滚动完成后,通过 `page.evaluate()` 遍历所有 `img[data-src]`,将未被触发的 `data-src` 强制复制到 `src`
3. **高清图优先**:提取图片 URL 时优先使用 `data-src`(高清原图),而非 `src`(可能是低分辨率占位图)
4. **格式识别**:微信图片 URL 无常规扩展名(如 `mmbiz.qpic.cn/...?wx_fmt=png`),需解析 `wx_fmt` 查询参数推断文件格式
5. **防盗链绕过**:通过 Playwright 页面上下文的 `page.request.get()` 下载图片,自动携带正确的 Referer 头
6. **专用选择器**:微信文章有固定 DOM 结构(`#js_content`、`#activity-name`、`#js_name`、`#publish_time`),使用专用选择器比通用选择器更精准可靠
**关键决策**:
- 微信文章是公开可读的,跳过登录检测和 Cookie 注入流程
- 滚动参数(300px 步长、200ms 间隔)经实测可平衡速度与懒加载触发成功率
- Markdown 转换时 `imageMap` 同时匹配 `src` 和 `data-src`,确保无论 HTML 中引用哪个属性都能正确替换
**验证标准**:抓取完成后检查 `article_meta.json` 中的 `image_count` 字段,与原文图片数量比对,确认无遗漏。
### 新平台适配思路
适配新平台时,需依次识别和处理以下 4 个维度:
1. **懒加载机制** — 图片是否用 `data-src`、`data-lazy` 等延迟加载?需要怎样的滚动策略触发?
2. **专用 DOM 结构** — 正文、标题、作者、日期的选择器是什么?
3. **图片 URL 格式** — 扩展名是否在路径中?是否需要从查询参数推断?
4. **防盗链策略** — 是否需要正确的 Referer?是否有其他鉴权机制?
### 微信公众号文章处理(mp.weixin.qq.com)
**首选方案:乐享 MCP `file_create_hyperlink`(2026-05-09 验证 ✅)**
乐享后端原生支持微信公众号文章的抓取与解析,**一步到位**,无需本地抓取和手动上传图片。
```
mcp__lexiang__file_create_hyperlink(
url = "https://mp.weixin.qq.com/s/...",
parent_entry_id = "<目标目录 entry_id>",
name = "<文章标题>" // 可选,不传会自动从微信提取
)
```
**返回值**:
- `finished: true` — 后端抓取完成
- `entry.id` — 新创建的知识条目 ID
- `entry_type: "flink"` — 外部链接类型
- `extension: "wechat"` — 自动识别微信来源
**后端自动完成的事情**:
1. 抓取微信文章全文(正文 + 图片)
2. 图片保存到乐享 COS(`/assets/xxx` 格式)
3. OCR 识别图片中的文字(用于全文检索和 AI 解析)
4. 自动提取标题、作者、发布时间等元信息
**优势**:
- 一步完成,省去 fetch_article.py + 分块导入 + 逐张上传图片的复杂流程
- Token 消耗从 ~50K 降到 <1K
- 图片质量由乐享后端保证,无需本地下载和上传
- 支持乐享的全文检索和 AI 解析(RAG)
**如需附加用户评价/评论**:
- 创建 hyperlink 后,可用 `entry_import_content_to_entry`(force_write=false)追加评价内容
- 或用 `block_create_block_descendant` 在文档末尾插入评价 block
**降级方案(当 `file_create_hyperlink` 失败时)**:
- 如果返回 `finished: false` 或错误码,改用 `fetch_article.py` 本地抓取 + 降级方案 A 导入
- 某些被限制的微信文章(如已删除、需付费等)可能无法通过此接口抓取
**注意事项**:
- 产出的 entry_type 是 `flink`(外部链接),而非 `page`(在线文档)
- flink 类型在乐享中以原始文章格式展示,支持全文检索和 AI 解析
- 如果用户明确要求以「在线文档/page」格式存储(需要后续编辑),才使用 fetch_article.py 降级方案
### 得到 APP 文章抓取(dedao.cn)
**核心问题**:得到 APP(`www.dedao.cn`)的文章内容是**付费内容 + SPA 动态渲染**,`web_fetch` 和 `fetch_article.py` 的通用提取逻辑都无法直接获取正文。
**技术原因**:
1. **SPA 架构**:得到网页版是 React SPA,文章正文通过 JS 异步渲染,`web_fetch` 只能拿到空白壳页面
2. **付费墙**:文章属于付费专栏内容,必须有已登录且已订阅的账号才能查看全文
3. **DOM 结构特殊**:正文容器使用 `.iget-articles` 类名,不在 `fetch_article.py` 的默认选择器列表(`article`、`.post-content` 等)中。通用 `article` 选择器只匹配到极少内容(~167 字符),而真正的正文在 `.iget-articles` 中有 6000+ 字符
4. **内容区混杂**:正文容器中混入了标题重复、音频时长、"划重点"、用户评论等非正文内容,需要清理
**抓取方案**:使用 **CDP 模式**连接已登录得到的 Chrome 浏览器:
```bash
# 前提:用户已在 Chrome 中登录得到 APP 且有文章阅读权限
python scripts/fetch_article.py fetch "https://www.dedao.cn/course/article?id=<ID>" --output-dir <目录> --cdp
```
**已知限制**:
- `fetch_article.py` 的通用内容提取逻辑对得到 DOM 结构匹配不佳,**抓取结果可能不完整**
- 正确做法是通过 Playwright CDP 连接后,**手动指定 `.iget-articles` 选择器**提取正文:
```python
# 通过 CDP 连接后,用专用选择器提取得到文章正文
content_el = await page.query_selector('.iget-articles')
if content_el:
text = await content_el.inner_text() # 完整正文
```
**内容清理要点**:
- 去掉正文开头的标题重复、日期、音频时长等元信息(通常在 `凡哥杂谈,你好` 或类似开场白之前)
- 去掉正文末尾的"划重点"、"添加到笔记"、"首次发布"、"用户留言"等非正文内容
- 如果是多篇系列文章(如上/下篇),合并时用 `## 上篇` / `## 下篇` 分隔
- 作者信息需要手动确认(通用提取器可能抓错)
**得到文章转存乐享完整流程(2026-05-09 实战验证 ✅)**:
> 以下流程已在实际操作中验证通过,确保图文完整转存。
1. **抓取**:`python scripts/fetch_article.py fetch "<URL>" --output-dir articles/dedao_<ID短码> --cdp`
- 产出:`article.md` + `images/` 目录(通常 80-100+ 张图,大部分是小于 10KB 的公式/icon 图)
2. **提取纯文字版**(去除图片引用和得到 UI 噪声):
```bash
# 去除图片引用 
# 去除得到 APP 特有 UI 噪声:
# - "展开"/"收起" 按钮文字
# - 点赞数、评论数、分享按钮(如 "25"、"8"、"218"、"分享")
# - "关注" 按钮
# - 用户昵称 + 日期行(如 "Christy\n05-05")
# - "划重点" / "添加到笔记" / "写笔记划线删除划线复制" 等功能按钮
# - "首次发布: ..." 行
# - "我的留言" / "用户留言" / "全部 精选 筛选" 等区域标记
# 保留正文 + 注释引用
```
3. **创建在线文档 + 分块导入文字**:
- `entry_create_entry`(entry_type="page", parent_entry_id=日期目录, name="<文章标题>(来源描述)")
- 将纯文字版分块(≤4000 chars/块),第一块 force_write=true,后续 force_write=false 追加
- 验证导入结果(spot check 关键段落)
4. **筛选并上传关键图片**:
```bash
# 找出 >50KB 的关键图片
find images/ -size +50k -type f | sort
# 排除 SVG/UI 图标(检查文件头)
file images/img_04_*.png # 如果是 SVG XML 则跳过
# 查看图片内容(确认哪些有信息价值)
# 典型有价值的:概念图、流程图、人物照片、数据图表
# 典型无价值的:SVG 格式的得到 APP logo/icon
```
5. **逐张上传图片到文档对应位置**(每张图3步):
```
① block_apply_block_attachment_upload(entry_id, name, size, mime_type) → session_id + upload_url
② curl -X PUT "<upload_url>" -H "Content-Type: <mime>" -H "Content-Length: <size>" --data-binary @<file>
③ block_create_block_descendant(entry_id, parent_block_id=page_block_id, index=<位置>, descendant=[{block_type:"image", image:{session_id, caption, align:"center"}}])
```
**图片位置确定**:
- 先用 `block_list_block_children`(entry_id, with_descendants=false)获取所有一级 block
- 根据原文 article.md 中 `` 的位置,找到对应文字段落的 block_id
- 用 index 参数插入(注意:每插入一张图,后面的 block index 都会 +1)
- 如果精确位置难以确定,也可以用 index=-1 追加到末尾(所有图集中放在文末也可接受)
**适用场景**:得到 APP 专栏文章(`www.dedao.cn/course/article?id=xxx`)
**TODO**:考虑在 `fetch_article.py` 中增加得到专用检测和选择器(类似微信公众号的 `_is_wechat_article` 机制),自动使用 `.iget-articles` 提取正文。
### SPA 网站 Playwright 直接出 PDF(正文隔离方案)
**适用场景**:
- `fetch_article.py` 抓取后正文为空或极少(< 200 字符),说明网站是 SPA 动态渲染,通用 Markdown 提取器无法工作
- 批量抓取帮助中心/文档站(如 Guru help.getguru.com、readme.io 托管站、GitBook 等)
- 已知案例:`vcsmemo.com`(Nuxt.js SPA)、`help.getguru.com`(readme.io)
**核心方案**:用 Playwright 无头浏览器直接访问页面 → 等待 SPA 渲染完成 → 隔离正文区域 → `page.pdf()` 生成 PDF。
**关键步骤**:
#### 1. 加载与等待
```javascript
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
await page.waitForTimeout(5000); // SPA 需要额外等待 JS 渲染
```
#### 2. 滚动触发懒加载图片
```javascript
await page.evaluate(async () => {
const delay = (ms) => new Promise(r => setTimeout(r, ms));
for (let i = 0; i < document.body.scrollHeight; i += 300) {
window.scrollBy(0, 300);
await delay(200);
}
window.scrollTo(0, 0);
});
await page.waitForTimeout(3000);
```
#### 3. 正文隔离(⚠️ 最关键的一步)
**问题**:直接 `page.pdf()` 会把整个页面打进 PDF,包括导航栏、侧边栏、相关推荐、页脚等非正文内容。**必须在生成 PDF 前隔离正文区域**。
**正文隔离策略(三步法)**:
**Step A:定位正文容器** — 找到包含文章核心段落的最小公共祖先节点
```javascript
// 用文章中的关键句子定位正文 <p> 标签
const articleParagraphs = [];
document.querySelectorAll("p").forEach(p => {
if (p.textContent.includes("文章中的某段独特文字")) {
articleParagraphs.push(p);
}
});
// 计算所有正文段落的最小公共祖先
let commonAncestor = articleParagraphs[0];
for (let i = 1; i < articleParagraphs.length; i++) {
// ... 向上遍历 DOM 树找公共祖先
}
```
**Step B:替换 body** — 将整个 `document.body` 的内容替换为正文容器的克隆
```javascript
const articleContent = commonAncestor.cloneNode(true);
document.body.innerHTML = "";
document.body.appendChild(articleContent);
```
**Step C:清理残余** — 从正文容器内部移除混入的非正文元素
```javascript
// 移除正文容器内可能混入的非内容元素
articleContent.querySelectorAll(
'[class*="related"], [class*="sidebar"], [class*="comment"], ' +
'[class*="share"], [class*="subscribe"], nav, header, footer'
).forEach(el => el.remove());
// 按文本内容移除(如"相关文章"、"登录"等中文导航项)
articleContent.querySelectorAll("*").forEach(el => {
const t = el.textContent.trim();
if (t === "相关文章" || t === "登录" || t.startsWith("Signal, not noise")) {
const wrapper = el.closest("section, div, aside");
wrapper ? wrapper.remove() : el.remove();
}
});
```
#### 4. 样式优化
```javascript
articleContent.style.maxWidth = "750px";
articleContent.style.margin = "0 auto";
articleContent.style.padding = "30px 20px";
articleContent.style.fontSize = "15px";
articleContent.style.lineHeight = "1.8";
articleContent.querySelectorAll("img").forEach(img => {
img.style.maxWidth = "100%";
img.style.height = "auto";
});
```
#### 5. 生成 PDF
```javascript
await page.pdf({
path: outputPath,
format: "A4",
printBackground: true,
margin: { top: "15mm", bottom: "15mm", left: "15mm", right: "15mm" },
});
```
**常见需要移除的非正文元素**:
| 元素类型 | 典型选择器/文本 | 说明 |
|---------|---------------|------|
| 左侧导航 | `nav`, `[class*="sidebar"]`, 包含"首页/快讯/登录"等文本 | 网站主导航 |
| 右侧推荐 | `[class*="related"]`, 包含"相关文章"文本 | 相关文章推荐 |
| 顶部搜索 | `[class*="search"]`, `header` | 搜索栏和网站 header |
| 底部页脚 | `footer`, `[class*="footer"]` | 版权信息等 |
| 作者卡片 | `[class*="author-card"]`, 包含头像+简介的独立区块 | 如果在正文外部 |
| 订阅入口 | `[class*="subscribe"]`, `[class*="newsletter"]` | CTA 按钮 |
**调试技巧**:
- 在 `page.pdf()` 之前先 `page.screenshot({ path: "debug.png", fullPage: true })` 截图确认隔离效果
- 如果首次隔离不干净,根据截图调整选择器,迭代优化
**已验证的 SPA 网站**:
| 网站 | 框架 | 正文定位方式 |
|------|------|-------------|
| `vcsmemo.com` | Nuxt.js | 通过文章段落文本找公共祖先,class `left` 内的 `section` |
| `help.getguru.com` | readme.io | 移除 `.rm-Sidebar` + `nav` + `header` + `footer` |
| `dedao.cn` | React SPA | CDP 模式 + `.iget-articles` 专用选择器 |
### Python 兼容性
脚本使用 `from __future__ import annotations` 以兼容 Python 3.9(`str | None` 联合类型语法在 3.9 中不可用)。
## 常见问题
| 问题 | 原因 | 修复方法 |
|------|------|----------|
| YouTube 视频下载 HTTP 403 Forbidden | yt-dlp 版本过旧 + YouTube 强制 SABR 流媒体协议,传统 DASH 分片下载被拦截 | ① `brew install yt-dlp` 升级到最新版(不要用 pip);② 脚本已配置优先使用 HLS(m3u8) 格式(`95-1/94-1/93-1`),自动回退 |
| `pip3 install --upgrade yt-dlp` 无法安装最新版 | macOS 自带 Python 3.9,yt-dlp nightly 版需要 Python 3.10+ | 改用 `brew install yt-dlp`,brew 版自带独立 Python 环境 |
| 脚本中 `python3 -m yt_dlp` 调用失败 | pip 安装的旧版 yt-dlp 与 brew 安装的新版不一致 | 脚本已修改为直接调用 `yt-dlp` 命令(brew 安装的版本) |
| 视频上传乐享报"不支持的文件格式" | 旧版 COS API(`/kb/files/upload-params`)不识别视频格式 | 通过 lexiang MCP 工具使用三步上传流程:`file_apply_upload` → `curl PUT` → `file_commit_upload` |
| Whisper 转录速度极慢 | 模型太大或音频太长 | 换用 `tiny` 或 `base` 模型;对于长视频(>1h),考虑用 `--whisper-model tiny` 先快速预览 |
| 翻译结果为空 | 未设置 `OPENAI_API_KEY` 环境变量 | `export OPENAI_API_KEY=sk-xxx`;或使用 `--skip-translate` 跳过翻译,由 AI 助手在对话中直接翻译全文后用 `md_to_page.py --entry-id` 更新乐享文档 |
| 中英对照格式段落错位 | AI 翻译返回的段落数与原文不匹配 | 脚本已有容错处理(缺少翻译的段落会跳过),可手动补充翻译 |
| 视频上传乐享超时 | 视频文件过大(>500MB)| 使用 MCP 的 `file_apply_upload` 预签名 URL 方式上传,518MB 文件约 30-60 秒即可完成 |
| Whisper 中文转录输出繁体字 | Whisper base 模型对中文普通话倾向输出繁体 | 用 `opencc-python-reimplemented` 的 `t2s` 模式进行繁简转换:`opencc.OpenCC("t2s").convert(text)` |
| 小宇宙播客下载提示 generic extractor | yt-dlp 没有小宇宙专用 extractor | 正常现象,generic extractor 能自动从页面提取音频直链(`media.xyzcdn.net`),下载完全正常 |
| 微信文章图片丢失 | `web_fetch` 无法触发懒加载和绕过防盗链 | **首选**:使用 `file_create_hyperlink` 直接导入(乐享后端自动处理图文)。**降级**:使用 `fetch_article.py`(脚本自动检测微信域名并启用专用处理策略) |
| 乐享知识库操作失败 | MCP 连接异常或 Token 过期 | ① 确认当前 Agent 的 lexiang MCP 已连接(CodeBuddy 检查 MCP 面板、OpenClaw 检查 skill 安装状态);② Token 过期时访问 https://lexiangla.com/mcp 获取新 Token 并更新 MCP 配置 |
| 文件上传到了知识库根目录而非日期目录 | 跳过了步骤 2(创建日期目录)和步骤 3(去重检查),直接以 `root_entry_id` 作为 `parent_entry_id` 上传 | 严格按照步骤 1→2→3→4 顺序执行,步骤 2 中先 `entry_list_children` 检查日期目录是否存在,不存在则创建 |
| 展示给用户的乐享链接无法访问 | 使用了 MCP API 域名 `mcp.lexiang-app.com` 或缺少 `company_from` 参数 | 所有展示给用户的链接必须按 `config.json` 中 `page_url_template` 格式生成:`https://lexiangla.com/pages/<entry_id>?company_from=<company_from>`。**company_from 不可省略**,否则用户无法访问 |
| PDF 中缺少标题 | `fetch_article.py` 的 `processNode` 将正文 `<h1>` 转为 `# 标题`,与手动拼接的元信息头标题重复;某些网站(如 Lenny's Newsletter)标题在 `articleEl` 外部导致 MD 文件第一行 `# ` 为空 | 已修复:(1) `processNode` 中自动去重正文中与已提取 title 相同的第一个 h1 (2) 标题提取增加 `og:title`、`meta[name="title"]`、`document.title` 多策略回退 (3) `md_to_pdf.py` 增加标题回退——当 MD 中无有效 h1 时从 `article_meta.json` 补充 |
| PDF 中缺少子标题 | 某些网站的 HTML 结构导致 `### # 从 Tab 到 Agents` 被拆为两行:`### #` 和 `从 Tab 到 Agents`,`parse_markdown` 将 `#` 视为无效标题丢弃 | 已修复:`parse_markdown` 增加拆行标题检测——当标题文字为 `#` 或空时,检查下一行是否为实际标题文字并合并 |
| md_to_page.py 导入后文字显示为 base64 乱码 | 脚本通过 HTTP JSON-RPC 直连乐享 MCP API 时,对 content 做了多余的 base64 编码。乐享 MCP 的 base64 要求仅针对 IDE 侧 MCP 协议 | 已修复:去掉 `import_content` 函数中的 `base64.b64encode()`,直传原始 markdown。⚠️ 通过 HTTP JSON-RPC 直连时**永远不要做 base64 编码** |
| md_to_page.py 批量插入图片 block 失败 | `block_create_block_descendant` 一次传多张图片的 descendant 数组会超时或报错 | 改为逐张插入,每次只传一个 image block 的 descendant + children |
| Gemini API 调用报 404 模型不存在 | `gemini-2.0-flash` 模型已下线 | 使用 `gemini-2.5-flash` 替代。可通过 `curl "https://generativelanguage.googleapis.com/v1beta/models?key=$GEMINI_API_KEY"` 查看当前可用模型 |
| 英文文章未翻译就归档 | 跳过了步骤 3.5 的语言检测和翻译 | **所有英文文章必须翻译为中英对照后再归档**,这是强制步骤不可跳过。使用 `translate_gemini.py`(Gemini API)或 `translate_article.py`(OpenAI API)翻译,翻译完用 `md_to_page.py --entry-id` 覆盖更新 |
| `translate_gemini.py` 报错 FileNotFoundError | 脚本硬编码了源文件路径,不读取命令行参数 | 已修复:改用 `sys.argv[1]` 读取输入文件,`sys.argv[2]` 读取输出文件,默认输出 `_translated.md` |
| `md_to_page.py` 执行报错 IndentationError | 添加 `--evaluation`/`--evaluation-file` 参数时缩进不一致 | 已修复:参数定义须与上方 `--base-url` 对齐;Python 严禁混用 tab 和空格 |
| `fetch_article.py` 下载的图片在 `md_to_page.py` 中提示 NOT FOUND | 下载保存的文件名与写入 Markdown 的引用不一致(如 `img_06_1c1cfc4c.gif` vs `img_06_1c1cfc42.gif`)| `fetch_article.py` 的 `process_images` 函数中,保存到 `images/` 的文件名与替换 Markdown `src` 时的文件名必须完全一致;建议统一使用 `hash[:8]` + 原始扩展名,并在替换后打印映射表方便排查 |
| 乐享 MCP 更新 token 后工具仍报 "not found" | `mcp.json` 配置已更新,但 MCP 服务未重新加载 | **必须重启 WorkBuddy**(或禁用再重新启用 MCP 服务),新的 token 才能生效 |
| `md_to_page.py` 新增评价信息功能 | 需要在文档顶部插入用户评价(callout 组件)| 已添加 `--evaluation`(短文本)和 `--evaluation-file`(文件路径)两个参数;评价内容会以 blockquote 格式插入文档顶部,乐享会自动渲染为 callout 组件 |
| 播客文字稿章节标题重复出现几十次 | 在 Whisper segment 级别(1-5秒粒度)插入章节标题,且用宽松时间容差匹配 `abs(start - ts) < 5`,导致多个 segment 都命中同一标题 | **必须先合并 segments 为段落(gap<2s, duration<60s),再在段落级别插入标题**。用 `inserted_headers = set()` 跟踪已插入标题,每个标题只插入一次 |
| 日期目录重复创建 | 直接调用 `entry_create_entry` 而不先查询目录是否已存在 | **必须先用 `entry_list_children` 查询根目录**,匹配到同名 folder 则复用其 ID,不存在才创建。已在步骤 2 中加强约束 |
| 得到文章转存后无图片 | 只导入了纯文字,未执行图片上传步骤 | 得到文章**必须**在文字导入后,逐张上传 >50KB 的关键图片(概念图/流程图/配图),流程:`block_apply_block_attachment_upload` → `curl PUT` → `block_create_block_descendant`(image block)。详见"得到 APP 文章抓取"章节 |
| 图片上传后显示不出来 | `block_create_block_descendant` 的 image block 未正确传入 session_id | image block 的 `session_id` 必须来自同一个 `block_apply_block_attachment_upload` 返回值,且 curl PUT 必须返回 HTTP 200 才表示文件上传成功 |
| `fetch_article.py` 抓取 Webflow SPA 站点(如 claude.com)正文为空 | 旧版通用选择器列表中无 Webflow 容器,且内嵌 `<style>` 标签污染提取 | 已修复:`fetch_article.py` 内置 `_is_webflow_blog()` 检测,自动使用 `.u-rich-text-blog` / `.w-richtext` 选择器,并在提取前移除内嵌 `<style>` 标签。无需使用独立脚本 |
| `md_to_page.py` 无法匹配某些图片引用 | 旧版 img_pattern 只匹配 `img_XX_HASH.ext` 格式(fetch_article.py),不匹配其他命名格式 | 已修复:img_pattern 改为通用 `!\[[^\]]*\]\(images/([^)]+)\)`,兼容所有图片命名格式 |
| 新建目录排到末尾而非顶部 | `md_to_page.py` 和手动调用 `entry_move_entry` 时使用 `after=<第一个条目ID>`(排第二)或 `after=""`(排末尾) | 已修复:统一使用 `before=<第一个条目ID>` 实现真正置顶。`after=""` 的 API 文档描述不准确,实测是排末尾 |
| images/ 有图片但乐享文档无图片 | `entry_import_content` 导入 Markdown 时,本地 `` 引用不会自动上传图片 | `entry_import_content` 只处理文字,**不会上传本地图片**。有本地图片必须走 `md_to_page.py`(自动处理图文)或降级方案 A(**交替导入**文字和图片,严禁先全文后补图)。步骤 4 开头的「图片判断 Checklist」是必查项 |
| 图片全部堆积在文档末尾 | 先一次性导入全部文字,事后用 `index=-1` 补图 | **必须交替导入**:按 `` 位置拆分 Markdown 为 segments,先导入一段文字 → 上传图片(index=-1 追加到末尾,此时末尾就是正确位置)→ 导入下一段文字 → 上传下一张图片... 详见降级方案 A 的执行流程 |
don't have the plugin yet? install it then click "run inline in claude" again.