---
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（🇨🇳）**，直接以中文开始
- 标题也需要翻译，保留原文标题 + 中文翻译标题
- 列表项、引用块等结构元素同样逐条翻译
- **保留原文中的图片引用**（`![](images/xxx.png)`），图片引用放在对应段落的上方或下方，确保图文对应关系不丢失

```markdown
# Original English Title
# 中文翻译标题

Original first paragraph text...

第一段的中文翻译...

![](images/img_01_xxx.png)

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/xxx)` 本地引用？（仅检查目录不够！如果 images/ 有图片但 Markdown 中无引用，说明抓取阶段出了问题——正文提取失败导致图片引用丢失，需要重新抓取）
> 3. 如果 Markdown 中只有外链图片（`![](https://...)`）而无本地引用，说明图片没有被下载到本地。`entry_import_content` 导入外链图片后，乐享中如果外链 CDN 有防盗链/过期，图片将不可见。此时应先下载图片到本地，再用 `md_to_page.py` 导入。
> 
> **判断结论**：
> - ✅ images/ 有图片 + Markdown 有 `![](images/xxx)` → **图文文章**，走图文路径
> - ⚠️ 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 内容
    按 ![xxx](images/yyy) 引用位置拆分为交替的 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. 提取纯文字版（去掉 `![](images/...)` 引用 + 去掉得到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 上传后在乐享中会丢失所有图片信息，用户看到的只是含 `![](images/xxx)` 引用的纯文本，毫无可读性。

- **无图片（纯文本文章）** → 使用 `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. **图片位置要正确**：先按原文中 `![](images/xxx.jpg)` 的位置拆分 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
   # 去除图片引用 ![](images/...)
   # 去除得到 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 中 `![](images/xxx)` 的位置，找到对应文字段落的 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 时，本地 `![](images/xxx)` 引用不会自动上传图片 | `entry_import_content` 只处理文字，**不会上传本地图片**。有本地图片必须走 `md_to_page.py`（自动处理图文）或降级方案 A（**交替导入**文字和图片，严禁先全文后补图）。步骤 4 开头的「图片判断 Checklist」是必查项 |
| 图片全部堆积在文档末尾 | 先一次性导入全部文字，事后用 `index=-1` 补图 | **必须交替导入**：按 `![](images/xxx)` 位置拆分 Markdown 为 segments，先导入一段文字 → 上传图片（index=-1 追加到末尾，此时末尾就是正确位置）→ 导入下一段文字 → 上传下一张图片... 详见降级方案 A 的执行流程 |
