docs(07): research phase domain - login auth system
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
# Phase 7: 登录认证体系 - Research
|
||||
|
||||
**Researched:** 2026-03-17
|
||||
**Domain:** Playwright 浏览器自动化、知乎登录认证、Cookie 管理
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
本阶段实现知乎登录认证体系,支持二维码扫码和手动 Cookie 两种认证方式。核心技术基于 Playwright 浏览器自动化,参考 MediaCrawler 项目的成熟实现模式。
|
||||
|
||||
关键实现点:
|
||||
1. **二维码登录流程**:访问登录页 → Canvas 截图获取二维码 → 保存图片 → 轮询检测 `z_c0` Cookie → 超时退出
|
||||
2. **Cookie 验证**:通过 `GET /api/v4/me` 接口检测登录状态
|
||||
3. **Cookie 持久化**:存储到 `~/.kb_env` 文件,格式为 `ZHIHU_COOKIE="name1=value1; name2=value2"`
|
||||
|
||||
**Primary recommendation:** 扩展现有 `PlaywrightBrowser` 类添加二维码登录能力,复用 `KBSettings` 的 `zhihu_cookie` 字段,新增 `login` CLI 子命令。
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### 二维码显示方式
|
||||
- **显示方式**: 保存为图片文件,作为标准 step 输出(让 AI 代理能够获取并展示)
|
||||
- **存储位置**: 中间目录(与现有中间文件存放位置一致)
|
||||
- **等待超时**: 60 秒
|
||||
- **超时行为**: 轮询检测,超时退出(不自动重试)
|
||||
|
||||
#### Cookie 存储策略
|
||||
- **存储位置**: `~/.kb_env` 文件(与现有配置文件一致)
|
||||
- **输入格式**: 支持 JSON 导入(Chrome Cookie 导出插件格式),内部存储为字符串
|
||||
- **存储格式**: 原始 Cookie 字符串,例如 `ZHIHU_COOKIE="name1=value1; name2=value2"`
|
||||
- **加密**: 不加密,用户需自行保护 `~/.kb_env` 文件权限
|
||||
|
||||
#### 过期检测机制
|
||||
- **检测时机**: 每次使用前检测 Cookie 有效性
|
||||
- **检测方法**: 请求知乎用户信息接口,检查返回状态
|
||||
- **过期处理**: 自动触发扫码重新登录
|
||||
|
||||
#### 命令行交互流程
|
||||
- **登录命令**: `python3 kb-workflow/kb_workflow.py login`
|
||||
- **默认模式**: 扫码模式(弹出二维码让用户扫码)
|
||||
- **手动设置**: `--import-cookie cookies.json` 从文件导入 JSON 格式 Cookie
|
||||
- **状态查看**: `login --status` 检查当前 Cookie 是否有效
|
||||
|
||||
### Claude's Discretion
|
||||
- 二维码图片的具体文件名格式
|
||||
- 用户信息接口的具体 URL 和返回格式
|
||||
- JSON Cookie 解析的具体实现
|
||||
- 错误提示信息的具体文案
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None - discussion stayed within phase scope
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| AUTH-01 | 用户可通过二维码扫码登录知乎 | Playwright Canvas 截图 + `z_c0` Cookie 轮询检测 |
|
||||
| AUTH-02 | 用户可手动设置 Cookie 进行认证 | JSON 解析 + `_add_cookies()` 方法复用 |
|
||||
| AUTH-03 | Cookie 自动持久化到本地文件,下次自动复用 | `KBSettings.zhihu_cookie` + `~/.kb_env` 写入 |
|
||||
| AUTH-04 | Cookie 过期时自动检测并提示重新登录 | `/api/v4/me` 接口验证 + 状态码检测 |
|
||||
</phase_requirements>
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| playwright | 已安装 | 浏览器自动化 | 现有项目已使用,支持 Cookie、Canvas 操作 |
|
||||
| pydantic | v2 | Schema 验证 | 现有项目使用,类型安全 |
|
||||
| pydantic-settings | 已安装 | 配置管理 | 现有 KBSettings 使用 |
|
||||
| tenacity | 需安装 | 重试/轮询逻辑 | 登录状态轮询的标准库 |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| rich | 可选 | 终端输出美化 | CLI 交互提示 |
|
||||
| httpx | 可选 | HTTP 请求 | Cookie 验证接口调用(或复用 Playwright) |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| playwright | selenium | Playwright 更现代,项目已使用 |
|
||||
| playwright | requests + 手动逆向 | 免维护,无需逆向签名算法 |
|
||||
| tenacity | 自实现轮询 | tenacity 提供声明式重试,更可靠 |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
pip install tenacity
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
kb_workflow/
|
||||
├── auth/ # 新增:认证模块
|
||||
│ ├── __init__.py
|
||||
│ ├── zhihu_login.py # 知乎登录核心逻辑
|
||||
│ └── cookie_manager.py # Cookie 持久化管理
|
||||
├── metadata/
|
||||
│ └── zhihu_extractor.py # 现有:PlaywrightBrowser 类(可扩展)
|
||||
├── pipeline/
|
||||
│ └── config.py # 现有:KBSettings(已有 zhihu_cookie)
|
||||
└── kb_workflow.py # 现有:CLI 入口(添加 login 子命令)
|
||||
```
|
||||
|
||||
### Pattern 1: 二维码登录流程
|
||||
|
||||
**What:** 使用 Playwright 访问知乎登录页,从 Canvas 元素截图获取二维码,轮询检测登录状态
|
||||
|
||||
**When to use:** 默认登录模式
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# Source: MediaCrawler media_platform/zhihu/login.py
|
||||
import asyncio
|
||||
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||
|
||||
class ZhihuLogin:
|
||||
LOGIN_URL = "https://www.zhihu.com/signin"
|
||||
CANVAS_SELECTOR = "canvas.SignIn-qrcode"
|
||||
|
||||
@retry(stop=stop_after_attempt(60), wait=wait_fixed(1))
|
||||
async def check_login_state(self) -> bool:
|
||||
"""轮询检测登录状态 - 检查 z_c0 Cookie"""
|
||||
cookies = await self.context.cookies()
|
||||
for cookie in cookies:
|
||||
if cookie["name"] == "z_c0":
|
||||
return True
|
||||
return False
|
||||
|
||||
async def login_by_qrcode(self):
|
||||
"""二维码登录主流程"""
|
||||
# 1. 访问登录页
|
||||
await self.page.goto(self.LOGIN_URL)
|
||||
|
||||
# 2. 从 Canvas 截图获取二维码
|
||||
canvas = await self.page.wait_for_selector(self.CANVAS_SELECTOR)
|
||||
screenshot = await canvas.screenshot()
|
||||
qr_base64 = base64.b64encode(screenshot).decode()
|
||||
|
||||
# 3. 保存二维码图片
|
||||
qrcode_path = Path(f"~/work/intermediate/zhihu_qrcode_{timestamp}.png")
|
||||
qrcode_path.write_bytes(screenshot)
|
||||
|
||||
# 4. 轮询等待扫码(60秒超时)
|
||||
try:
|
||||
logged_in = await self.check_login_state()
|
||||
if logged_in:
|
||||
# 5. 提取并保存 Cookie
|
||||
cookies = await self.context.cookies()
|
||||
cookie_str = self._format_cookies(cookies)
|
||||
return cookie_str
|
||||
except RetryError:
|
||||
raise TimeoutError("扫码超时,请重试")
|
||||
```
|
||||
|
||||
### Pattern 2: Cookie 验证
|
||||
|
||||
**What:** 通过知乎 API 检测 Cookie 是否有效
|
||||
|
||||
**When to use:** 每次使用 Cookie 前、`login --status` 命令
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# Source: 知乎 API 实测
|
||||
VALIDATION_URL = "https://www.zhihu.com/api/v4/me"
|
||||
|
||||
async def validate_cookie(self, cookie_str: str) -> dict:
|
||||
"""验证 Cookie 有效性"""
|
||||
# 方式1:使用 Playwright context(推荐,与登录流程一致)
|
||||
self.browser.start(cookie=cookie_str)
|
||||
response = await self.page.request.get(self.VALIDATION_URL)
|
||||
|
||||
if response.status == 200:
|
||||
user_info = await response.json()
|
||||
return {
|
||||
"valid": True,
|
||||
"username": user_info.get("name", ""),
|
||||
"user_id": user_info.get("id", "")
|
||||
}
|
||||
elif response.status == 401:
|
||||
return {"valid": False, "reason": "Cookie 已过期"}
|
||||
else:
|
||||
return {"valid": False, "reason": f"验证失败: {response.status}"}
|
||||
```
|
||||
|
||||
### Pattern 3: JSON Cookie 导入
|
||||
|
||||
**What:** 解析 Chrome Cookie Editor 导出的 JSON 格式
|
||||
|
||||
**When to use:** `--import-cookie` 参数
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# Chrome Cookie Editor JSON 格式:
|
||||
# [{"name": "z_c0", "value": "xxx", "domain": ".zhihu.com"}, ...]
|
||||
|
||||
def parse_json_cookies(json_path: str) -> str:
|
||||
"""从 JSON 文件解析 Cookie 为字符串格式"""
|
||||
import json
|
||||
|
||||
with open(json_path, 'r') as f:
|
||||
cookies = json.load(f)
|
||||
|
||||
# 过滤知乎域名,转换为 name=value 格式
|
||||
zhihu_cookies = [
|
||||
f"{c['name']}={c['value']}"
|
||||
for c in cookies
|
||||
if 'zhihu.com' in c.get('domain', '')
|
||||
]
|
||||
|
||||
return '; '.join(zhihu_cookies)
|
||||
```
|
||||
|
||||
### Pattern 4: Cookie 持久化
|
||||
|
||||
**What:** 将 Cookie 写入 `~/.kb_env` 文件
|
||||
|
||||
**When to use:** 登录成功后、导入 Cookie 后
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# 复用现有 KBSettings 的 zhihu_cookie 字段
|
||||
# 存储格式: ZHIHU_COOKIE="name1=value1; name2=value2"
|
||||
|
||||
def save_cookie_to_env(cookie_str: str):
|
||||
"""保存 Cookie 到 ~/.kb_env"""
|
||||
env_path = Path.home() / ".kb_env"
|
||||
|
||||
# 读取现有内容
|
||||
lines = []
|
||||
if env_path.exists():
|
||||
lines = env_path.read_text().splitlines()
|
||||
|
||||
# 更新或添加 ZHIHU_COOKIE 行
|
||||
cookie_line = f'ZHIHU_COOKIE="{cookie_str}"'
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("ZHIHU_COOKIE="):
|
||||
lines[i] = cookie_line
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
lines.append(cookie_line)
|
||||
|
||||
# 写回文件
|
||||
env_path.write_text('\n'.join(lines) + '\n')
|
||||
|
||||
# 清除配置缓存
|
||||
get_settings.cache_clear()
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **手动逆向 x-zse-96 签名**: 维护成本高,算法经常变化。使用二维码登录完全避免此问题。
|
||||
- **在终端直接显示二维码 ASCII**: 兼容性差,AI 代理无法读取。保存为图片文件是更可靠的方案。
|
||||
- **硬编码超时逻辑**: 使用 tenacity 的 `@retry` 装饰器更清晰、可测试。
|
||||
- **忽略 Cookie 域名过滤**: 导入时必须过滤 `.zhihu.com` 域名,否则会导入无关 Cookie。
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| 轮询重试逻辑 | while loop + sleep | tenacity @retry | 声明式、可配置、有异常处理 |
|
||||
| Cookie 字符串解析 | 手动 split(';') | 现有 `_add_cookies()` 方法 | 已处理边界情况 |
|
||||
| 配置文件读写 | 手动文件操作 | pydantic-settings | 自动类型转换、环境变量支持 |
|
||||
| Canvas 截图 | 手动像素提取 | Playwright element.screenshot() | 内置优化,支持多种格式 |
|
||||
|
||||
**Key insight:** 现有代码库已有 Playwright 封装和配置管理,优先复用而非新建。
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Canvas 选择器变化
|
||||
**What goes wrong:** 知乎更新前端后,Canvas 选择器 `canvas.SignIn-qrcode` 失效
|
||||
**Why it happens:** 前端类名可能随版本变化
|
||||
**How to avoid:**
|
||||
1. 使用多个备选选择器
|
||||
2. 添加选择器存在性检测
|
||||
3. 失败时给出明确错误提示
|
||||
|
||||
**Warning signs:** `wait_for_selector` 超时
|
||||
|
||||
```python
|
||||
CANVAS_SELECTORS = [
|
||||
"canvas.SignIn-qrcode",
|
||||
"canvas[class*='qrcode']",
|
||||
"canvas" # 最宽松的备选
|
||||
]
|
||||
|
||||
async def find_qrcode_canvas(self):
|
||||
for selector in self.CANVAS_SELECTORS:
|
||||
try:
|
||||
return await self.page.wait_for_selector(selector, timeout=5000)
|
||||
except TimeoutError:
|
||||
continue
|
||||
raise RuntimeError("无法找到二维码 Canvas 元素")
|
||||
```
|
||||
|
||||
### Pitfall 2: z_c0 Cookie 延迟出现
|
||||
**What goes wrong:** 用户扫码后,`z_c0` Cookie 不是立即出现
|
||||
**Why it happens:** 服务端验证需要时间
|
||||
**How to avoid:** 使用 tenacity 的 1 秒间隔轮询,60 次重试(60 秒总超时)
|
||||
|
||||
**Warning signs:** 扫码成功但检测失败
|
||||
|
||||
### Pitfall 3: Cookie 格式不一致
|
||||
**What goes wrong:** 不同来源的 Cookie 格式不同(字符串 vs JSON vs 字典)
|
||||
**Why it happens:** Chrome 插件、手动复制、Playwright 导出格式各异
|
||||
**How to avoid:**
|
||||
1. 统一内部格式为字符串 `name1=value1; name2=value2`
|
||||
2. 在边界层做格式转换
|
||||
3. 提供格式自动检测
|
||||
|
||||
**Warning signs:** Cookie 设置后仍显示未登录
|
||||
|
||||
### Pitfall 4: ~/.kb_env 文件权限
|
||||
**What goes wrong:** Cookie 明文存储可能被其他用户读取
|
||||
**Why it happens:** 文件创建时使用默认 umask
|
||||
**How to avoid:**
|
||||
1. 创建文件时设置 600 权限
|
||||
2. 提示用户检查权限
|
||||
|
||||
```python
|
||||
env_path = Path.home() / ".kb_env"
|
||||
env_path.touch(mode=0o600, exist_ok=True)
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### CLI 子命令扩展
|
||||
|
||||
```python
|
||||
# Source: 现有 kb_workflow.py 模式
|
||||
# 在 main() 函数中添加 login 子命令
|
||||
|
||||
def add_login_subparser(subparsers):
|
||||
"""添加 login 子命令"""
|
||||
login_parser = subparsers.add_parser('login', help='知乎登录认证')
|
||||
login_parser.add_argument('--import-cookie', '-i', type=str,
|
||||
help='从 JSON 文件导入 Cookie')
|
||||
login_parser.add_argument('--status', action='store_true',
|
||||
help='检查当前 Cookie 状态')
|
||||
return login_parser
|
||||
|
||||
# 在 main() 中:
|
||||
if args.command == 'login':
|
||||
if args.status:
|
||||
result = check_cookie_status()
|
||||
elif args.import_cookie:
|
||||
result = import_cookie_from_file(args.import_cookie)
|
||||
else:
|
||||
result = login_by_qrcode()
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
```
|
||||
|
||||
### 扩展 PlaywrightBrowser
|
||||
|
||||
```python
|
||||
# Source: 现有 zhihu_extractor.py PlaywrightBrowser 类
|
||||
# 添加二维码登录相关方法
|
||||
|
||||
class PlaywrightBrowser:
|
||||
# 现有方法保持不变...
|
||||
|
||||
async def get_qrcode_screenshot(self) -> bytes:
|
||||
"""获取登录二维码截图"""
|
||||
canvas = await self.page.wait_for_selector("canvas.SignIn-qrcode")
|
||||
return await canvas.screenshot()
|
||||
|
||||
async def check_login_cookie(self) -> bool:
|
||||
"""检测是否已登录(检查 z_c0 Cookie)"""
|
||||
cookies = await self.context.cookies()
|
||||
return any(c["name"] == "z_c0" for c in cookies)
|
||||
|
||||
async def get_all_cookies_str(self) -> str:
|
||||
"""获取所有 Cookie 为字符串格式"""
|
||||
cookies = await self.context.cookies()
|
||||
return '; '.join(f"{c['name']}={c['value']}" for c in cookies)
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| 逆向 x-zse-96 签名 | 二维码扫码登录 | 2024+ | 免维护,无需逆向 |
|
||||
| 终端 ASCII 二维码 | 图片文件保存 | 本项目决定 | AI 代理可读取 |
|
||||
| 手动 Cookie 管理 | 自动持久化 | 本项目决定 | 用户体验提升 |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- 纯 API 请求(无浏览器):需要维护签名算法,知乎经常更新
|
||||
- 第三方打码服务:付费且不稳定,二维码登录免费可靠
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **二维码图片文件名格式**
|
||||
- What we know: 存储在中间目录,包含时间戳
|
||||
- What's unclear: 是否需要包含随机数避免冲突
|
||||
- Recommendation: 使用 `zhihu_qrcode_{timestamp}.png` 格式,同一秒内多次登录概率低
|
||||
|
||||
2. **验证接口的精确返回格式**
|
||||
- What we know: `/api/v4/me` 返回用户信息
|
||||
- What's unclear: 具体字段名称(name vs username)
|
||||
- Recommendation: 实现时先打印完整响应,根据实际返回调整字段名
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | pytest |
|
||||
| Config file | pytest.ini |
|
||||
| Quick run command | `pytest tests/test_auth/ -x -v` |
|
||||
| Full suite command | `pytest tests/ -v` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| AUTH-01 | 二维码扫码登录 | unit + integration | `pytest tests/test_auth/test_qrcode_login.py -x` | Wave 0 |
|
||||
| AUTH-02 | 手动设置 Cookie | unit | `pytest tests/test_auth/test_cookie_import.py -x` | Wave 0 |
|
||||
| AUTH-03 | Cookie 持久化 | unit | `pytest tests/test_auth/test_cookie_persistence.py -x` | Wave 0 |
|
||||
| AUTH-04 | Cookie 过期检测 | unit | `pytest tests/test_auth/test_cookie_validation.py -x` | Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `pytest tests/test_auth/ -x`
|
||||
- **Per wave merge:** `pytest tests/ -v`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `tests/test_auth/__init__.py` — 测试模块初始化
|
||||
- [ ] `tests/test_auth/test_qrcode_login.py` — AUTH-01 二维码登录测试
|
||||
- [ ] `tests/test_auth/test_cookie_import.py` — AUTH-02 Cookie 导入测试
|
||||
- [ ] `tests/test_auth/test_cookie_persistence.py` — AUTH-03 Cookie 持久化测试
|
||||
- [ ] `tests/test_auth/test_cookie_validation.py` — AUTH-04 Cookie 验证测试
|
||||
- [ ] `tests/test_auth/conftest.py` — 共享 fixtures (mock browser, mock env file)
|
||||
|
||||
**测试策略:**
|
||||
- 使用 `unittest.mock` 模拟 Playwright browser 和 context
|
||||
- 使用 `tmp_path` fixture 创建临时 `~/.kb_env` 文件
|
||||
- Cookie 验证测试使用 mock HTTP 响应
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- **MediaCrawler 源码** - `media_platform/zhihu/login.py`, `tools/crawler_util.py`
|
||||
- 二维码登录完整实现
|
||||
- Cookie 格式转换函数
|
||||
- **现有项目代码** - `kb_workflow/metadata/zhihu_extractor.py`
|
||||
- PlaywrightBrowser 类实现
|
||||
- Cookie 设置方法 `_add_cookies()`
|
||||
- **现有项目代码** - `kb_workflow/pipeline/config.py`
|
||||
- KBSettings 配置类
|
||||
- zhihu_cookie 字段定义
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- 知乎 API 实测 - `/api/v4/me` 端点行为
|
||||
- Chrome Cookie Editor JSON 格式规范
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- 网络博客关于知乎登录流程的描述(需实现时验证)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - 现有项目已使用 Playwright 和 pydantic-settings
|
||||
- Architecture: HIGH - MediaCrawler 提供了成熟的参考实现
|
||||
- Pitfalls: MEDIUM - 部分边界情况需要在实现时验证
|
||||
|
||||
**Research date:** 2026-03-17
|
||||
**Valid until:** 2026-04-17 (知乎 API 可能变化,选择器可能更新)
|
||||
Reference in New Issue
Block a user