Skill: handsomestwei/zhihu-fetch-skill (version 1.1.0)

--- name: zhihu-fetcher description: "知乎收藏夹与文章内容抓取：API/Playwright 多级降级、Cookie 持久化与保活、批量正文与图片、断点续传、可选写入 Obsidian。| Zhihu collection scraping, batch article fetch, Obsidian export." version: "1.1.0" user-invocable: true argument-hint: "[可选：收藏夹 URL 或 ID、单篇链接、输出目录、Vault 路径]" allowed-tools: Read, Write, Edit, Grep, Glob, Bash, WebFetch --- # 知乎数据抓取 从知乎获取**收藏夹文章列表**与**正文 Markdown**（含图片本地化），支持写入 **Obsidian** 知识库。命令与路径约定见下文；可视化说明见仓库根目录 [`README.md`](README.md)。 --- ## 环境与约定 - **语言**：默认与用户语种一致。 - **技能根目录**：下文 `${CLAUDE_SKILL_DIR}` 表示本 skill 仓库根目录（部分宿主 UI 中写作 **`{baseDir}`**，含义相同）。脚本均在 **`scripts/`** 下。 - **工作区目录**：脚本默认将 Cookie、浏览器用户数据、默认文章输出等放在 **`OPENCLAW_WORKSPACE`** 环境变量指定的目录；未设置时为 **`~/.openclaw/workspace/`**。 - **依赖**：在 **`scripts/`** 下执行 **`pip install -r requirements.txt`**，并 **`playwright install chromium`**。 ### 登录与可选页面验证 - **`zhihu_login.py`**：打开浏览器等待登录，默认以检测到 **`z_c0`** 为成功条件即可结束（不要求额外跳转）。 - **可选二次校验**：若用户希望登录后再确认「某一内需登录页」是否可访问（如某收藏夹页、专栏后台、关注动态等），属**可选项**，不设则不执行： - **环境变量** **`ZHIHU_VERIFY_URL`**：值为完整 **`http://` 或 `https://`** URL； - **或**命令行第一个参数传入同一完整 URL：`python "${CLAUDE_SKILL_DIR}/scripts/zhihu_login.py" "https://www.zhihu.com/..."`。 - 脚本会访问该 URL，若正文仍出现知乎通用提示「请登录后查看」，则提示可能未登录完成；否则认为当前会话可访问该页。**不限定于收藏夹**，任意知乎链接均可（只要登录态相关）。 - **`zhihu_relogin.py`**：Cookie 失效、需重新登录并写回 **`zhihu_cookies.json`** 时使用（会打开浏览器）。 --- ## 触发条件 在用户使用以下任一方式时启用本技能： - 明确提及：知乎、Zhihu、专栏、收藏夹、文章抓取、批量下载、Cookie、验证码、Obsidian、知识库同步等 - 粘贴 **zhihu.com** / **zhuanlan.zhihu.com** 链接并希望获取正文或列表 - 需要 **断点续传**、**图片落盘**、**反爬 / Stealth** 相关协助 --- ## 工具与脚本路由 按任务选用能力；具体工具名以当前 Agent 环境为准。 ### 常见任务与建议方式 | 任务 | 建议方式 | |------|----------| | 获取收藏夹 JSON 列表 | **`Bash`** → `python "${CLAUDE_SKILL_DIR}/scripts/fetch_zhihu_collection.py" <收藏夹URL或ID>`；优先 API，失败降级 Playwright DOM | | 批量抓取正文与图片 | **`Bash`** → `python "${CLAUDE_SKILL_DIR}/scripts/fetch_zhihu_batch.py" <列表.json> [输出目录] [图片目录]`；默认输出目录见「路径约定」 | | 写入 Obsidian Vault | **`Bash`** → `python "${CLAUDE_SKILL_DIR}/scripts/write_to_obsidian.py" <文章目录> [Vault路径]`；Vault：命令行优先，否则环境变量 **`OBSIDIAN_VAULT`**；会先找 `<文章目录>/images`，否则兼容同级 **`zhihu_images`** | | Cookie 失效需人工登录 | **`Bash`** → `python "${CLAUDE_SKILL_DIR}/scripts/zhihu_relogin.py"`（会打开浏览器窗口） | | 首次登录辅助（可选验证页） | **`Bash`** → `zhihu_login.py`；可选 **`ZHIHU_VERIFY_URL`** 或首个参数传入完整 http(s) 链接，见「登录与可选页面验证」 | | 单篇快速验证 | **`Bash`** → `fetch_zhihu_api.py` / `fetch_zhihu_stealth.py` / `fetch_zhihu_interactive.py` / 汇总 **`fetch_zhihu.py`**（自动多策略），按场景选用 | | 读本地已抓取 Markdown、排查 `_progress.json` | **`Read`** / **`Grep`** | --- ## 脚本一览 | 脚本 | 用途 | 典型场景 | |------|------|----------| | `fetch_zhihu_collection.py` | **收藏夹列表**，智能版 | 输出 `zhihu_collection_{id}.json` | | `fetch_zhihu_batch.py` | **批量抓取**，推荐 | 大量文章、图片、`images/`、`_progress.json` | | `fetch_zhihu.py` | 自动降级抓取 | 单篇、多策略串联 | | `fetch_zhihu_api.py` | API 直连 | 快速测试 | | `fetch_zhihu_stealth.py` | Playwright 隐身 | 绕过常见自动化检测 | | `fetch_zhihu_interactive.py` | 交互式浏览器 | 登录页、验证码 | | `write_to_obsidian.py` | 写入 Obsidian | 自动检测 Vault、智能分类、`知乎收藏/` | | `zhihu_relogin.py` | 重新登录 | Cookie 不可用 | | `zhihu_login.py` | 登录辅助 | 检测 `z_c0`；可选访问 **`ZHIHU_VERIFY_URL`** / 命令行 URL 做页面级验证 | | `zhihu_login_save.py` | 登录并保存 | 按需配合 Cookie 流程 | --- ## 主流程（推荐执行顺序） 1. **安装依赖**：`scripts/requirements.txt` + Chromium。 2. **`fetch_zhihu_collection.py`** → 得到收藏夹 **JSON 列表**。 3. **`fetch_zhihu_batch.py`** → 生成 **`zhihu_articles_{collectionId}/`**（含 **`_progress.json`**、**`images/`**、编号 **`*.md`**）。 4. （可选）**`write_to_obsidian.py`** → 同步到 **`{Vault}/知乎收藏/{分类}/`**。 中断批量任务时：**重新运行同一条** `fetch_zhihu_batch.py` 命令即可续跑（已完成 URL 记录在 `_progress.json`）。 --- ## 路径与输出约定 ### 批量抓取命令格式 ```bash python fetch_zhihu_batch.py <列表文件> [输出目录] [图片目录] ``` | 参数 | 说明 | |------|------| | **列表文件** | `fetch_zhihu_collection.py` 产出的 JSON | | **输出目录** | 可选；省略时默认为 **`{workspace}/zhihu_articles_{collectionId}/`**（`collectionId` 由列表文件名推导） | | **图片目录** | 可选；省略时默认为 **`{输出目录}/images/`** | ### 目录结构示例 ``` zhihu_articles_{collectionId}/ ├── _progress.json # 断点续传 ├── images/ # 默认图片目录 │ └── ... ├── 0001_文章标题.md └── ... ``` ### 单篇文章格式要点 - YAML frontmatter：`title`、`author`、`source`、`url`、`voteup`、`images` 等 - 正文为 Markdown；图片引用指向本地 **`images/`** 下文件名（或脚本生成的相对路径） 示例结构： ```markdown --- title: "文章标题" author: "作者" source: zhihu url: "https://..." voteup: 123 images: 5 --- # 文章标题 > 作者: xxx | 原文: [知乎链接](https://...) 正文... ``` ### 持久化文件（默认 workspace） | 用途 | 路径 | |------|------| | Cookie | `{workspace}/zhihu_cookies.json` | | Playwright 用户数据 | `{workspace}/chrome_user_data/` | | 默认文章目录 | `{workspace}/zhihu_articles_{collectionId}/` | | 默认图片目录 | `{文章输出目录}/images/` | --- ## Obsidian 写入要点 - **Vault**：① **命令行第二个参数**（优先让用户直接写出 Vault 根路径）；② 未传时使用环境变量 **`OBSIDIAN_VAULT`**（单个路径）；③ 仍无时脚本按常见目录扫描，多个命中时再交互选择。 - **分类**：优先对齐已有 **`知乎收藏/`** 子目录；否则按内容关键词；无法归类则 **「未分类」**。 - **落盘**：**`{Vault}/知乎收藏/{分类}/{文章标题}.md`**；图片同步规则见 **`write_to_obsidian.py`**（目标侧常有集中 **`images`** 目录）。 --- ## 已知问题与对策 | # | 现象 / 原因 | 处理 | |---|-------------|------| | 1 | **Cookie 失效**：标题「安全验证」、`/account/unhuman` | **自动恢复**：脚本内置 3 次重试（激进保活：访问文章页+模拟阅读）；仍失败则 **`zhihu_relogin.py`** | | 2 | **收藏夹 API 分页**：带 `include` 时列表可能被截断 | **`fetch_zhihu_collection.py`** 已内置 API ↔ DOM 切换；必要时减少 `include` 或走浏览器分页 | | 3 | **反爬**：Headless 被识别 | Stealth、UA、间隔；必要时 **`fetch_zhihu_interactive.py`** | | 4 | **API 正文不完整**：`include` 只给摘要 | 批量与单篇流程中已优先 **页面 DOM** 拉全文 | | 5 | **图片下载失败** | 正文仍保留原 URL；排查网络、Referer、过期链接 | | 6 | **Windows 控制台 GBK** | 脚本已 **`sys.stdout.reconfigure(encoding='utf-8')`** | | 7 | **批量中断** | 直接再次运行 **`fetch_zhihu_batch.py`**，依赖 **`_progress.json`** | | 8 | **失败项累积** | 散发失败自动记录到 `_progress.json`（含 url/reason/title/timestamp）；连续失败 ≥5 次中断并丢弃缓存；用 **`--retry-failed`** 参数可重试 | ### Cookie 保活机制 脚本内置多层 Cookie 保活策略： 1. **主动 TTL 检测**（每篇文章）：解析 z_c0 的 `expires` 字段，剩余 < 30 分钟时自动触发激进刷新 2. **常规保活**（每 5-8 篇）：访问知乎列表页 + 模拟滚动 3. **激进保活**（每 ~20 篇）：访问实际文章页 + 模拟阅读（停留 2-5 秒 + 滚动） 4. **被动检测**：每次访问文章时检查是否被重定向到 `/account/unhuman` 或 `/signin` 5. **自动恢复**：检测到失效时，自动尝试 3 次激进保活恢复 6. **Cookie 备份**：每次保活后自动从浏览器提取最新 Cookie 保存到文件（扩展格式含 expires） 7. **安全退出**：脚本结束前保存最新 Cookie + 当前进度 ### 失败处理策略 脚本采用**两级失败处理**，区分「文章本身问题」和「环境问题」： | 场景 | 行为 | 说明 | |------|------|------| | 散发失败（中间有成功） | 记录到 `_progress.json` 的 `failed` 字段 | 视为文章本身问题（已删除/不可访问），后续跳过 | | 连续失败 ≥ 5 次 | 中断抓取，**丢弃**缓存的失败记录 | 视为环境问题（Cookie/网络），下次重试仍可跑 | **工作原理：** - 失败先缓存在内存中，不立即写入进度文件 - 下一条成功时，将缓存的失败记录批量写入进度文件（确认是文章问题） - 连续失败达到阈值（5 次）时，中断抓取，丢弃缓存（保留重试机会） **相关常量：** - `CONSECUTIVE_FAIL_THRESHOLD = 5`：连续失败阈值 - `CONSECUTIVE_FAIL_INTERRUPT = True`：是否在连续失败时中断 **重试模式：** ```bash python fetch_zhihu_batch.py <列表文件> [输出目录] [图片目录] --retry-failed ``` 此模式会清空 `failed` 列表，只重试之前记录为失败的文章。 --- ## 故障排查流程 ``` 正文全空？ → Cookie（含 z_c0）→ 是否跳转验证页 → zhihu_relogin.py 图片失败？ → URL/网络/Referer → Markdown 中仍可保留链接 批量中途停止？ → 确认 _progress.json → 原命令重跑 ``` --- ## Agent 自用工作流检查清单 ``` □ 已确认 scripts 依赖与 playwright chromium 可用；必要时提示用户设置 OPENCLAW_WORKSPACE □ 收藏夹任务：已运行 fetch_zhihu_collection.py 并得到合法 JSON，再执行 fetch_zhihu_batch.py □ 批量输出路径：知悉默认 {workspace}/zhihu_articles_* 与 images/ 子目录；第三个参数仅在自定义图片目录时需要 □ Obsidian：`write_to_obsidian.py` 的文章目录含 *.md 与 images/；Vault 优先命令行路径或 **`OBSIDIAN_VAULT`** □ 遇验证页或全文为空：优先 Cookie/重登录，而非重复盲目加大并发 □ 用户仅需单篇或调试：选用 fetch_zhihu_api / stealth / interactive / fetch_zhihu，避免不必要批量 ```

path: README.md
content: <div align="center">

# 知乎抓取.skill

> 从知乎**收藏夹列表**到**批量正文与图片**，再到 **Obsidian 自动分类入库**：API / Playwright 多级降级、Cookie 持久化与保活、断点续传。

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
[![Playwright](https://img.shields.io/badge/Playwright-Chromium-45ba4b.svg)](https://playwright.dev/)
[![AgentSkills](https://img.shields.io/badge/AgentSkills-Standard-green)](https://agentskills.io)

<br>

收藏夹里上千篇文章想**归档成 Markdown**？<br>
需要**配图本地化**、中断后能**接着抓**？<br>
希望落库到 Obsidian，并**按主题自动分类**？<br>
Cookie 经常失效，想要**持久化上下文 + 保活**？

**本 Skill 按 AgentSkills 约定编排全流程，入口见根目录 [`SKILL.md`](SKILL.md)，脚本集中在 `scripts/`。**

[功能特性](#功能特性) · [安装](#安装) · [使用](#使用) · [项目结构](#项目结构) · [运行效果](#运行效果) · [参考文档](#参考文档)

</div>

---

## 功能特性

| 能力 | 说明 |
|------|------|
| 收藏夹列表 | `fetch_zhihu_collection.py` 优先 API，失败降级 Playwright DOM；输出 JSON 列表 |
| 批量抓取 | `fetch_zhihu_batch.py`：正文 Markdown、图片默认写入 `{输出目录}/images/`、`_progress.json` 断点续传 |
| Cookie | 持久化浏览器上下文 + 定时保活；失效时用 `zhihu_relogin.py` 手动登录 |
| 单篇 / 调试 | `fetch_zhihu.py`、`fetch_zhihu_api.py`、`fetch_zhihu_stealth.py`、`fetch_zhihu_interactive.py` 等多路径 |
| Obsidian | `write_to_obsidian.py`：Vault 检测、按内容与已有「知乎收藏」结构智能分类、同步图片 |

**依赖**：见 [`scripts/requirements.txt`](scripts/requirements.txt)，并需 `playwright install chromium`。

---

## 安装

### Claude Code / Cursor

将本仓库放到宿主约定的 skills 路径（与 [`SKILL.md`](SKILL.md) 同级为 skill 根目录），重启后在规则或技能列表中确认已加载。

```bash
# 示例：克隆到项目的 skills 目录
mkdir -p .cursor/skills
git clone https://github.com/handsomestWei/zhihu-fetch-skill.git .cursor/skills/zhihu-fetch-skill
```

### 依赖

```bash
cd scripts
pip install -r requirements.txt
playwright install chromium
```

---

## 使用

在 Agent 中用自然语言描述即可，例如：知乎文章、收藏夹、批量抓取、写入 Obsidian、Cookie 失效。

典型三步（路径请按本机 `{workspace}` 调整，详见 [`SKILL.md`](SKILL.md)）：

```bash
# 1. 收藏夹 → JSON 列表
python scripts/fetch_zhihu_collection.py <收藏夹URL或ID>

# 2. 批量抓取正文与图片
python scripts/fetch_zhihu_batch.py <列表.json>

# 3. 写入 Obsidian Vault（可选 Vault 路径）
python scripts/write_to_obsidian.py <文章目录> [Vault路径]
```

Cookie 异常时：

```bash
python scripts/zhihu_relogin.py
```

---

## 项目结构

本仓库遵循 [AgentSkills](https://agentskills.io)，根目录即一个 skill：

```
zhihu-fetch-skill/
├── SKILL.md                 # 技能入口：触发条件、命令与路径约定
├── README.md                # 本说明
├── LICENSE
├── .gitignore
├── docs/                    # 文档配图（运行效果截图）
│   ├── openclaw-run.jpg
│   └── obs.jpg
└── scripts/
    ├── requirements.txt
    ├── fetch_zhihu_collection.py
    ├── fetch_zhihu_batch.py
    ├── fetch_zhihu.py
    ├── fetch_zhihu_api.py
    ├── fetch_zhihu_stealth.py
    ├── fetch_zhihu_interactive.py
    ├── write_to_obsidian.py
    ├── zhihu_login.py
    ├── zhihu_login_save.py
    └── zhihu_relogin.py
```

默认文章与图片目录等行为以 [`SKILL.md`](SKILL.md)「批量抓取详解」「文件路径」为准。

---

## 运行效果

**在 OpenClaw 对话中执行批量抓取**（工具输出中可见进度、剩余篇数、图片数量与 Cookie 保活提示）

![OpenClaw 聊天：批量抓取进度与 Cookie 保活](./docs/openclaw-run.jpg)

**写入 Obsidian 后的 Vault 结构**（「知乎收藏」下主题分类与关系图谱）

![Obsidian：知乎收藏分类与关系图谱](./docs/obs.jpg)

---

## 参考文档

- [技能入口与完整命令说明](SKILL.md)（依赖、脚本表、故障排查）
- [脚本依赖清单](scripts/requirements.txt)

---

<div align="center">

MIT License © [handsomestWei](https://github.com/handsomestWei/)

</div>


path: scripts/fetch_zhihu.py
content: #!/usr/bin/env python3
"""
知乎文章抓取 - 三级降级入口
按优先级尝试三种方式获取文章，自动降级。
用法: python fetch_zhihu.py <文章URL或ID>
"""

import sys
import re


def extract_url(url_or_id):
    """统一为完整 URL"""
    if url_or_id.startswith('http'):
        return url_or_id
    if url_or_id.isdigit():
        return f"https://zhuanlan.zhihu.com/p/{url_or_id}"
    return url_or_id


def main():
    if len(sys.argv) < 2:
        print("用法: python fetch_zhihu.py <文章URL或ID>")
        print("示例: python fetch_zhihu.py https://zhuanlan.zhihu.com/p/2015027745743189513")
        print("      python fetch_zhihu.py 2015027745743189513")
        sys.exit(1)

    url = extract_url(sys.argv[1])
    match = re.search(r'/p/(\d+)', url)
    article_id = match.group(1) if match else 'unknown'

    # 方式1: API 直连
    print("=" * 50)
    print("[1/3] 尝试 API 直连...")
    print("=" * 50)
    try:
        from fetch_zhihu_api import fetch_via_api, html_to_text, format_output
        data = fetch_via_api(article_id)
        title = data.get('title', '未知标题')
        author = data.get('author', {}).get('name', '未知作者')
        publish_time = str(data.get('created', '未知时间'))
        content_html = data.get('content', '')
        content = html_to_text(content_html) if content_html else ''

        if content:
            output = format_output(title, author, publish_time, url, content)
            print(output)
            output_file = f"zhihu_{article_id}.txt"
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(output)
            print(f"\n✅ API 直连成功，已保存到 {output_file}")
            return
    except Exception as e:
        print(f"❌ API 直连失败: {e}")

    # 方式2: Playwright 隐身模式
    print("\n" + "=" * 50)
    print("[2/3] 尝试 Playwright 隐身模式...")
    print("=" * 50)
    try:
        import asyncio
        from fetch_zhihu_stealth import fetch_zhihu_stealth, format_output as stealth_format
        result = asyncio.run(fetch_zhihu_stealth(url))
        if result and result.get('content'):
            output = stealth_format(result['title'], result['author'], url, result['content'])
            print(output)
            output_file = f"zhihu_{article_id}.txt"
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(output)
            print(f"\n✅ 隐身模式成功，已保存到 {output_file}")
            return
    except Exception as e:
        print(f"❌ 隐身模式失败: {e}")

    # 方式3: Playwright 交互模式
    print("\n" + "=" * 50)
    print("[3/3] 尝试 Playwright 交互模式（需要手动操作）...")
    print("=" * 50)
    try:
        import asyncio
        from fetch_zhihu_interactive import fetch_zhihu_interactive, format_output as interactive_format
        result = asyncio.run(fetch_zhihu_interactive(url))
        if result and result.get('content'):
            output = interactive_format(result, url)
            print(output)
            output_file = f"zhihu_{article_id}.txt"
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(output)
            print(f"\n✅ 交互模式成功，已保存到 {output_file}")
            return
    except Exception as e:
        print(f"❌ 交互模式失败: {e}")

    print("\n❌ 所有方式均失败，请检查网络或文章链接是否有效")


if __name__ == "__main__":
    main()


path: scripts/fetch_zhihu_api.py
content: #!/usr/bin/env python3
"""
知乎文章抓取 - API 直连方式
直接调用知乎 API，速度快，无需浏览器。
用法: python fetch_zhihu_api.py <文章URL或ID>
"""

import requests
import re
import sys
import json
import time


def extract_article_id(url_or_id):
    """从 URL 或直接 ID 中提取文章 ID"""
    match = re.search(r'/p/(\d+)', url_or_id)
    if match:
        return match.group(1)
    if url_or_id.isdigit():
        return url_or_id
    return url_or_id


def fetch_via_api(article_id, timeout=10):
    """通过知乎 API 获取文章内容"""
    api_url = f"https://zhuanlan.zhihu.com/api/articles/{article_id}"
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                       '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'application/json, text/plain, */*',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Referer': f'https://zhuanlan.zhihu.com/p/{article_id}',
    }

    response = requests.get(api_url, headers=headers, timeout=timeout)
    response.raise_for_status()
    return response.json()


def fetch_via_page(url, timeout=15):
    """通过页面 HTML 中的 initialData 获取文章内容"""
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                       '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
        'Sec-Ch-Ua-Mobile': '?0',
        'Sec-Ch-Ua-Platform': '"Windows"',
        'Sec-Fetch-Dest': 'document',
        'Sec-Fetch-Mode': 'navigate',
        'Sec-Fetch-Site': 'none',
        'Upgrade-Insecure-Requests': '1',
    }

    session = requests.Session()
    response = session.get(url, headers=headers, timeout=timeout)
    response.raise_for_status()

    # 从页面 script 中提取 initialData
    script_tags = re.findall(r'<script[^>]*>(.*?)</script>', response.text, re.DOTALL)
    for script in script_tags:
        if 'initialData' in script:
            start = script.find('{')
            end = script.rfind('}') + 1
            if start != -1 and end > start:
                data = json.loads(script[start:end])
                article = data.get('initialData', {}).get('data', {})
                if article:
                    return article
    return None


def html_to_text(html_content):
    """清理 HTML 标签，转为纯文本"""
    text = re.sub(r'<[^>]+>', ' ', html_content)
    text = re.sub(r'\s+', ' ', text).strip()
    return text


def format_output(title, author, publish_time, url, content):
    """统一输出格式"""
    parts = [
        f"标题: {title}",
        f"作者: {author}",
        f"发布时间: {publish_time}",
        f"原文链接: {url}",
        "",
        "=" * 50,
        "",
        content,
    ]
    return "\n".join(parts)


def main():
    if len(sys.argv) < 2:
        print("用法: python fetch_zhihu_api.py <文章URL或ID>")
        print("示例: python fetch_zhihu_api.py https://zhuanlan.zhihu.com/p/2015027745743189513")
        sys.exit(1)

    url_or_id = sys.argv[1]
    article_id = extract_article_id(url_or_id)
    full_url = f"https://zhuanlan.zhihu.com/p/{article_id}"

    print(f"正在获取文章 {article_id} ...")

    # 方式1: API 直连
    try:
        print("尝试 API 直连...")
        data = fetch_via_api(article_id)
        title = data.get('title', '未知标题')
        author = data.get('author', {}).get('name', '未知作者')
        publish_time = str(data.get('created', '未知时间'))
        content_html = data.get('content', '')
        content = html_to_text(content_html) if content_html else '无法获取内容'

        output = format_output(title, author, publish_time, full_url, content)
        print(output)

        # 保存到文件
        output_file = f"zhihu_{article_id}.txt"
        with open(output_file, 'w', encoding='utf-8') as f:
            f.write(output)
        print(f"\n已保存到 {output_file}")
        return

    except Exception as e:
        print(f"API 直连失败: {e}")

    # 方式2: 从页面提取
    try:
        print("尝试从页面提取...")
        time.sleep(2)
        article = fetch_via_page(full_url)
        if article:
            title = article.get('title', '未知标题')
            author = article.get('author', {}).get('name', '未知作者')
            content_html = article.get('content', '')
            content = html_to_text(content_html) if content_html else '无法获取内容'

            output = format_output(title, author, '未知时间', full_url, content)
            print(output)

            output_file = f"zhihu_{article_id}.txt"
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(output)
            print(f"\n已保存到 {output_file}")
            return

    except Exception as e:
        print(f"页面提取失败: {e}")

    print("所有方式均失败，请尝试 Playwright 方式")


if __name__ == "__main__":
    main()


path: scripts/fetch_zhihu_interactive.py
content: #!/usr/bin/env python3
"""
知乎文章抓取 - Playwright 交互模式
打开有界面的浏览器，遇到验证码时可手动完成验证。登录状态会持久化保存。
用法: python fetch_zhihu_interactive.py <文章URL>
"""

import asyncio
import re
import sys
import os

try:
    from playwright.async_api import async_playwright
except ImportError:
    print("请先安装 playwright: pip install playwright && playwright install chromium")
    sys.exit(1)


def get_user_data_dir():
    """获取浏览器用户数据目录（保存登录状态）"""
    workspace = os.environ.get('OPENCLAW_WORKSPACE',
                               os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace'))
    data_dir = os.path.join(workspace, 'chrome_user_data')
    os.makedirs(data_dir, exist_ok=True)
    return data_dir


def html_to_text(html_content):
    text = re.sub(r'<[^>]+>', ' ', html_content)
    text = re.sub(r'\s+', ' ', text).strip()
    return text


async def fetch_zhihu_interactive(url):
    user_data_dir = get_user_data_dir()

    async with async_playwright() as p:
        print("启动浏览器（有界面模式）...")
        print("如遇验证码，请在浏览器中手动完成验证")

        context = await p.chromium.launch_persistent_context(
            user_data_dir,
            headless=False,
            user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                       '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            viewport={'width': 1920, 'height': 1080},
            locale='zh-CN',
            timezone_id='Asia/Shanghai',
            args=[
                '--disable-blink-features=AutomationControlled',
                '--no-sandbox',
            ]
        )

        # 移除 webdriver 属性
        await context.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
        """)

        page = await context.new_page()

        try:
            print(f"正在访问: {url}")
            await page.goto(url, wait_until='domcontentloaded', timeout=60000)
            await page.wait_for_timeout(3000)

            # 检查验证码
            page_content = await page.content()
            if '验证码' in page_content or 'captcha' in page_content.lower():
                print("\n" + "=" * 60)
                print("检测到验证码！请在浏览器中手动完成验证")
                print("完成后请在这里按回车继续...")
                print("=" * 60 + "\n")
                input()
                await page.wait_for_timeout(3000)

            # 等待页面加载
            print("等待页面完全加载...")
            await page.wait_for_timeout(5000)

            # 提取内容
            article_data = await page.evaluate('''() => {
                const getData = (selectors) => {
                    for (let sel of selectors) {
                        const el = document.querySelector(sel);
                        if (el && el.innerText) return el.innerText.trim();
                    }
                    return '';
                };
                return {
                    title: getData(['h1.Post-Title', 'h1[class*="Title"]', 'article h1', 'h1']),
                    author: getData(['.AuthorInfo-name', 'a[class*="Author"]', 'span[class*="author"]']),
                    content: getData(['.Post-RichText', '.RichText', 'article', '[itemprop="articleBody"]']),
                    publishTime: (() => {
                        const el = document.querySelector('time') || document.querySelector('[datetime]');
                        return el ? (el.getAttribute('datetime') || el.innerText.trim()) : '';
                    })()
                };
            }''')

            # 如果未获取到内容，尝试获取整个页面文本
            if not article_data['content']:
                print("通过选择器未获取到内容，尝试获取全部文本...")
                all_text = await page.evaluate('() => document.body.innerText')
                article_data['content'] = all_text

            await context.close()
            return article_data

        except Exception as e:
            print(f"抓取失败: {e}")
            await context.close()
            return None


def format_output(data, url):
    return "\n".join([
        f"标题: {data.get('title', '未知标题')}",
        f"作者: {data.get('author', '未知作者')}",
        f"发布时间: {data.get('publishTime', '未知时间')}",
        f"原文链接: {url}",
        "",
        "=" * 50,
        "",
        data.get('content', ''),
    ])


async def main():
    if len(sys.argv) < 2:
        print("用法: python fetch_zhihu_interactive.py <文章URL>")
        sys.exit(1)

    url = sys.argv[1]
    print(f"正在使用交互模式获取: {url}")

    result = await fetch_zhihu_interactive(url)
    if result and result.get('content'):
        output = format_output(result, url)
        print(output)

        match = re.search(r'/p/(\d+)', url)
        article_id = match.group(1) if match else 'unknown'
        output_file = f"zhihu_{article_id}.txt"
        with open(output_file, 'w', encoding='utf-8') as f:
            f.write(output)
        print(f"\n已保存到 {output_file}")
    else:
        print("抓取失败")


if __name__ == "__main__":
    asyncio.run(main())


path: scripts/requirements.txt
content: requests>=2.28.0
beautifulsoup4>=4.12.0
playwright>=1.40.0


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)