diff --git a/.planning/phases/08-crawler/08-RESEARCH.md b/.planning/phases/08-crawler/08-RESEARCH.md new file mode 100644 index 0000000..8d27aa4 --- /dev/null +++ b/.planning/phases/08-crawler/08-RESEARCH.md @@ -0,0 +1,390 @@ +# Phase 8: 内容爬取实现 - Research + +**Researched:** 2026-03-17 +**Domain:** Playwright API Interception, Zhihu Crawler, Anti-Scraping +**Confidence:** HIGH + +## Summary + +本阶段实现知乎内容爬取的核心功能,使用 Playwright 拦截 API 请求的方式获取结构化数据。关键技术点是复用浏览器 Cookie,自动处理 x-zse-96 签名,无需逆向工程。 + +**Primary recommendation:** 使用 Playwright `page.route()` 拦截知乎 API 请求,直接获取 JSON 响应。热门评论通过 `/api/v4/comment_v5/` 端点获取,代理通过环境变量 `HTTP_PROXY`/`HTTPS_PROXY` 配置并在 `browser.new_context()` 中应用。 + + +## User Constraints (from CONTEXT.md) + +### Implementation Decisions + +#### 爬取方式选择 +- **实现方式**: 使用 Playwright 拦截 API 请求 +- **原理**: 浏览器访问页面时拦截其发出的 API 请求(如 /api/v4/answers/xxx),直接获取 JSON 响应 +- **优势**: 复用浏览器 Cookie,自动处理 x-zse-96 签名,无需逆向 +- **支持内容类型**: 问答 (answer)、专栏 (zhuanlan) +- **不支持**: 知乎视频 — 推迟到后续版本 + +#### 评论爬取范围 +- **爬取范围**: 只爬取热门评论 +- **嵌套展开**: 不展开回复的回复,只爬第一层评论 +- **可选开关**: 评论爬取是可选功能,通过参数控制是否启用 + +#### 代理配置方式 +- **配置方式**: 通过环境变量配置(HTTP_PROXY/HTTPS_PROXY) +- **配置文件**: 添加到 ~/.kb_env,与现有配置方式一致 +- **使用策略**: 强制使用代理 — 不配置代理则拒绝爬取,防止 IP 被封 + +#### 反爬策略 +- **请求频率**: 随机延迟,每个请求间隔 1-3 秒 +- **重试策略**: 有限重试,失败后重试 3 次,间隔递增 +- **错误处理**: 重试全部失败后抛出异常,由上层处理 + +### Claude's Discretion +- Playwright 拦截的具体实现方式(route/intercept) +- 热门评论的具体数量限制 +- 重试间隔的具体递增策略 +- 错误提示信息的具体文案 + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| CONT-01 | 爬取知乎问答 - 问题标题、问题描述、回答内容、元数据 | Playwright route 拦截 `/api/v4/answers/{id}` 获取结构化 JSON | +| CONT-02 | 爬取知乎专栏/文章 - 标题、正文、元数据 | Playwright route 拦截 zhuanlan API 或页面 DOM 解析 | +| CONT-03 | 爬取内容下的评论(可选开关) | `/api/v4/comment_v5/{type}/{id}/comments?order=hot` 热门评论 API | +| CONT-04 | 提取作者信息、发布时间、点赞数等元数据 | API 响应中包含完整元数据字段 | +| NET-01 | 支持代理 IP 配置 | 环境变量 `HTTP_PROXY`/`HTTPS_PROXY` + `browser.new_context(proxy=...)` | +| NET-02 | 请求频率控制,避免触发反爬 | `time.sleep(random.uniform(1, 3))` 随机延迟 | +| NET-03 | 自动获取请求签名(x-zse-96),无需手动逆向 | Playwright 浏览器自动处理签名 | + + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| playwright | 1.40+ | 浏览器自动化 | 项目已使用,API 拦截功能完善 | +| pydantic | 2.x | 数据模型验证 | 项目标准,用于响应数据模型 | +| tenacity | 8.x | 重试机制 | 项目已使用,支持可配置超时 | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| httpx | 0.25+ | HTTP 客户端 | 如需直接 API 调用(非拦截模式) | +| python-dotenv | 1.0+ | 环境变量加载 | 代理配置读取 | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Playwright route | requests + 逆向签名 | 需要持续维护签名算法,Playwright 方式更稳定 | +| 直接调用 API | Selenium + DOM 解析 | DOM 结构变化频繁,API 响应更稳定 | + +**Installation:** +```bash +# 已安装,无需额外安装 +playwright install chromium # 如需更新浏览器 +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +kb_workflow/ +├── crawler/ # 新建爬虫模块 +│ ├── __init__.py +│ ├── zhihu_crawler.py # 主爬虫类 +│ ├── api_interceptor.py # API 拦截逻辑 +│ ├── models.py # 响应数据模型 +│ └── config.py # 爬虫配置(代理、延迟等) +├── auth/ +│ └── zhihu_login.py # 现有 - 复用 Cookie 管理 +└── metadata/ + └── zhihu_extractor.py # 现有 - 复用 PlaywrightBrowser +``` + +### Pattern 1: Playwright Route Interception +**What:** 拦截浏览器发出的 API 请求,直接获取 JSON 响应 +**When to use:** 获取结构化数据,避免 DOM 解析 +**Example:** +```python +# Source: https://playwright.dev/python/docs/network +from playwright.sync_api import sync_playwright + +def intercept_api(route): + """拦截知乎 API 请求""" + if "/api/v4/" in route.request.url: + # 继续请求以获取响应 + response = route.fetch() + json_data = response.json() + # 存储拦截到的数据 + route.fulfill(response=response) + else: + route.continue_() + +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.route("**/api/v4/**", intercept_api) + page.goto("https://www.zhihu.com/question/123/answer/456") +``` + +### Pattern 2: 代理配置 +**What:** 通过环境变量配置代理,在 Playwright 中应用 +**When to use:** 所有爬取请求,防止 IP 被封 +**Example:** +```python +# Source: https://playwright.dev/python/docs/network +import os +from playwright.sync_api import sync_playwright + +def get_proxy_config(): + """从环境变量获取代理配置""" + proxy_server = os.environ.get("HTTP_PROXY") or os.environ.get("HTTPS_PROXY") + if proxy_server: + # 解析代理 URL + # http://user:pass@host:port 或 http://host:port + return {"server": proxy_server} + return None + +# 在 new_context 中应用代理 +browser = chromium.launch() +context = browser.new_context(proxy=get_proxy_config()) +``` + +### Pattern 3: 随机延迟与重试 +**What:** 请求间随机延迟,失败时有限重试 +**When to use:** 所有网络请求 +**Example:** +```python +import random +import time +from tenacity import Retrying, stop_after_attempt, wait_exponential + +def crawl_with_retry(crawl_func, url): + """带重试的爬取""" + for attempt in Retrying( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=2, max=10), + reraise=True + ): + with attempt: + # 随机延迟 + time.sleep(random.uniform(1, 3)) + return crawl_func(url) +``` + +### Anti-Patterns to Avoid +- **硬编码延迟时间:** 使用固定延迟容易被检测,应使用随机值 +- **忽略代理检查:** 不检查代理配置直接爬取可能导致 IP 被封 +- **无限重试:** 重试次数应有限制,避免无限循环 + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| 请求重试 | 自定义重试循环 | tenacity.Retrying | 支持指数退避、超时、回调 | +| API 签名 | 逆向 x-zse-96 算法 | Playwright 浏览器自动处理 | 签名算法频繁更新,逆向成本高 | +| Cookie 管理 | 手动解析存储 | 现有 ZhihuLogin + PlaywrightBrowser | 已有成熟实现 | +| 代理解析 | 自定义 URL 解析 | Playwright proxy 参数 | 支持认证、SOCKS5 | + +**Key insight:** Playwright 拦截模式的核心优势是复用浏览器的完整上下文(Cookie、签名、指纹),无需维护逆向代码。 + +## Common Pitfalls + +### Pitfall 1: Service Worker 干扰 +**What goes wrong:** 使用 `page.route()` 时某些请求未被捕获 +**Why it happens:** Service Worker 拦截了网络请求 +**How to avoid:** 在 `new_context()` 中设置 `service_workers="block"` +**Warning signs:** API 拦截处理器未被调用 + +```python +context = browser.new_context(service_workers="block") +``` + +### Pitfall 2: 代理未生效 +**What goes wrong:** 配置了环境变量但请求仍使用直连 +**Why it happens:** Playwright 默认不读取环境变量 +**How to avoid:** 显式读取环境变量并传入 `new_context(proxy=...)` +**Warning signs:** IP 被封、请求频率限制 + +### Pitfall 3: 评论 API 分页处理 +**What goes wrong:** 只获取了第一页评论 +**Why it happens:** 评论 API 使用 cursor 分页 +**How to avoid:** 使用 `offset` 参数处理分页,但根据决策只爬热门评论(单页) +**Warning signs:** 评论数量固定为 20 条 + +### Pitfall 4: Cookie 过期未检测 +**What goes wrong:** 爬取时返回登录页面 +**Why it happens:** Cookie 已过期但未提前验证 +**How to avoid:** 爬取前调用 `/api/v4/me` 验证 Cookie 有效性 +**Warning signs:** 响应包含"登录"关键词 + +## Code Examples + +Verified patterns from official sources: + +### API 拦截存储响应 +```python +# Source: https://playwright.dev/python/docs/network +class ZhihuApiInterceptor: + """知乎 API 拦截器""" + + def __init__(self): + self.captured_data = {} + + def handle_route(self, route): + """处理拦截的请求""" + url = route.request.url + + # 拦截答案 API + if "/api/v4/answers/" in url: + response = route.fetch() + data = response.json() + self.captured_data["answer"] = data + route.fulfill(response=response) + return + + # 拦截评论 API + if "/comment_v5/" in url: + response = route.fetch() + data = response.json() + self.captured_data["comments"] = data + route.fulfill(response=response) + return + + route.continue_() + + def setup(self, page): + """设置路由拦截""" + page.route("**/api/v4/**", self.handle_route) +``` + +### 代理配置读取 +```python +# Source: https://playwright.dev/python/docs/network +import os +from urllib.parse import urlparse + +def parse_proxy_env(): + """解析代理环境变量""" + proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") + if not proxy_url: + return None + + parsed = urlparse(proxy_url) + config = {"server": f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"} + + if parsed.username and parsed.password: + config["username"] = parsed.username + config["password"] = parsed.password + + return config +``` + +### 热门评论 API 调用 +```python +# Source: https://docs.tikhub.io/262614090e0 +# 知乎评论 V5 API 参数说明 +COMMENT_API_TEMPLATE = ( + "https://www.zhihu.com/api/v4/comment_v5/" + "{content_type}/{content_id}/comments" + "?order=hot&limit=20" +) + +# content_type: "answers" (回答) 或 "articles" (文章) +# order: "hot" (热门) 或 "ts" (最新) +# limit: 每页数量,默认 20 +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| requests + 逆向签名 | Playwright 拦截 | 2024+ | 无需维护签名算法 | +| DOM 选择器解析 | API 响应 JSON | 2024+ | 数据结构更稳定 | +| 固定延迟 | 随机延迟 | 最佳实践 | 降低被检测风险 | + +**Deprecated/outdated:** +- 逆向 x-zse-96 签名算法: 知乎频繁更新签名,维护成本高 +- 基于 DOM 的内容提取: 页面结构变化导致解析失败 + +## Open Questions + +1. **热门评论数量限制** + - What we know: API 默认返回 20 条,支持分页 + - What's unclear: 决策说"只爬热门评论",是否限制为 20 条 + - Recommendation: 默认 20 条,通过参数可配置 + +2. **专栏文章的 API 端点** + - What we know: 专栏页面有对应的 API 调用 + - What's unclear: 具体的 API 路径格式 + - Recommendation: 实现时通过 route 拦截自动发现 + +3. **重试间隔递增策略** + - What we know: 使用 tenacity 支持指数退避 + - What's unclear: 具体的 min/max 值 + - Recommendation: min=2s, max=10s, multiplier=1 + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | pytest 7.x | +| Config file | pytest.ini | +| Quick run command | `pytest tests/test_crawler/ -x -v` | +| Full suite command | `pytest tests/ -v` | + +### Phase Requirements -> Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CONT-01 | 爬取问答内容 | integration | `pytest tests/test_crawler/test_answer_crawler.py -x` | Wave 0 | +| CONT-02 | 爬取专栏文章 | integration | `pytest tests/test_crawler/test_article_crawler.py -x` | Wave 0 | +| CONT-03 | 爬取热门评论 | integration | `pytest tests/test_crawler/test_comment_crawler.py -x` | Wave 0 | +| CONT-04 | 提取元数据 | unit | `pytest tests/test_crawler/test_metadata_extraction.py -x` | Wave 0 | +| NET-01 | 代理配置 | unit | `pytest tests/test_crawler/test_proxy_config.py -x` | Wave 0 | +| NET-02 | 请求频率控制 | unit | `pytest tests/test_crawler/test_rate_limiter.py -x` | Wave 0 | +| NET-03 | 自动签名处理 | integration | `pytest tests/test_crawler/test_api_intercept.py -x` | Wave 0 | + +### Sampling Rate +- **Per task commit:** `pytest tests/test_crawler/ -x --tb=short` +- **Per wave merge:** `pytest tests/ -v` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `tests/test_crawler/__init__.py` - 测试模块初始化 +- [ ] `tests/test_crawler/conftest.py` - 共享 fixtures (mock browser, mock API responses) +- [ ] `tests/test_crawler/test_answer_crawler.py` - 问答爬取测试 +- [ ] `tests/test_crawler/test_article_crawler.py` - 专栏爬取测试 +- [ ] `tests/test_crawler/test_comment_crawler.py` - 评论爬取测试 +- [ ] `tests/test_crawler/test_metadata_extraction.py` - 元数据提取测试 +- [ ] `tests/test_crawler/test_proxy_config.py` - 代理配置测试 +- [ ] `tests/test_crawler/test_rate_limiter.py` - 频率控制测试 +- [ ] `tests/test_crawler/test_api_intercept.py` - API 拦截测试 + +## Sources + +### Primary (HIGH confidence) +- [Playwright Python Network Docs](https://playwright.dev/python/docs/network) - Route interception, proxy configuration +- [TikHub API Docs - Zhihu Comment V5](https://docs.tikhub.io/262614090e0) - 评论 API 参数说明 +- Project existing code: `kb_workflow/auth/zhihu_login.py`, `kb_workflow/metadata/zhihu_extractor.py` + +### Secondary (MEDIUM confidence) +- [MediaCrawler GitHub](https://github.com/NanmiCoder/MediaCrawler) - 知乎爬虫实现参考 +- [Playwright Proxy Configuration](https://rg9.dev/posts/playwright-http-proxy/) - 多种代理配置方式 + +### Tertiary (LOW confidence) +- [How to Intercept API Calls in Playwright](https://roundproxies.com/blog/intercept-network-playwright/) - 教程参考 + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - 项目已有 Playwright 和 tenacity 使用经验 +- Architecture: HIGH - Playwright 官方文档详细,模式清晰 +- Pitfalls: MEDIUM - 部分问题需要实际运行验证 + +**Research date:** 2026-03-17 +**Valid until:** 2026-04-17 (Playwright API 稳定,知乎 API 可能变化)