# Phase 1: 地基与 PoC 破冰 - Context

**Gathered:** 2026-04-22
**Status:** Ready for planning
**Source:** Distilled from `政务网站采集系统-设计文档.md` v2.1 + PROJECT.md + REQUIREMENTS.md

<domain>
## Phase Boundary

**What this phase delivers:**
- 端到端从 `www.gdqy.gov.cn`（实测有 ctct 盾反爬）抓到**一篇真实文章**的完整内容（标题、发布时间、来源、正文、附件）。
- 验证"**真实 Chrome 内核 + Playwright stealth 自动过 JS 挑战**"这条路径是可行且稳定的。
- 沉淀出本系统所有后续阶段都会复用的**存储骨架**：文件目录布局、PG schema、基础解析器。
- 覆盖 FETCH-02（Playwright stealth）、PARSE-01..04（列表+详情解析）、STORE-01..05（三类文件 + PG 元数据）共 10 个 REQ-ID。

**What this phase does NOT deliver（明确划给 Phase 2/3）：**
- 不做多站点配置化（一个 gdqy 的硬编码 YAML + 一个 gdqy 栏目即可）
- 不做 httpx/DrissionPage 分层（只用 Playwright stealth 走通即可）
- 不做 Cookie 池（单次启动浏览器即可）
- 不做定时调度（手动 CLI 触发）
- 不做 REST API / 监控 / 告警 / Docker Compose

</domain>

<decisions>
## Implementation Decisions (LOCKED)

### 语言与基础栈
- **LOCKED**：Python 3.11+
- **LOCKED**：Playwright（Python 版）+ `playwright-stealth` 或 `patchright`（反检测）
- **LOCKED**：PostgreSQL 16（本地 Docker 或已有实例即可）
- **LOCKED**：项目结构采用单 Python 包 `govcrawler/`，模块清晰分层（fetcher/parser/storage/models）
- **LOCKED**：依赖管理用 `uv` 或 `pip-tools`，**不引入 Poetry**（减少依赖）

### Fetcher（本阶段唯一 Tier）
- **LOCKED**：只实现 Playwright stealth 一条路径。实测 gdqy.gov.cn 的 ctct 盾会返回 HTTP 412 + `<title>请稍候…</title>` + `ctct-slider-canvas` 滑块，但真实 Chromium 加载 JS 会自动过挑战、不触发真滑块。
- **LOCKED（已根据 RESEARCH §Common Pitfalls #2 于 2026-04-22 revision 更新为两步 fallback 策略）**：抓取动作采用 **domcontentloaded-first + networkidle 回退** 的两步策略（政务站常集成 CNZZ/51.la 埋点长轮询，纯 `networkidle` 永不触发）：
  1. 主路径：`page.goto(url, wait_until="domcontentloaded", timeout=30000)`
  2. `page.wait_for_selector(detail_selector, timeout=5000)` 等正文出现（selector 列表：`div.article-content, div.content, div.TRS_Editor, body`）。
  3. 若 selector 超时未出现，回退：`page.goto(url, wait_until="networkidle", timeout=45000)` 再次 `wait_for_selector`。
  4. 若仍失败，最终回退到直接读 `page.content()`（正文可能不完整，但至少能拿到当前 DOM，交由 parser + GNE 兜底处理）。
  5. 任意一步抓到非空 HTML 后，`page.content()` 返回字符串，进入 parser 阶段。
  Rationale: 政务站 CNZZ/51.la/百度统计心跳让 networkidle 超时，`domcontentloaded` + 显式 selector 等待更稳（RESEARCH §Common Pitfalls #2）。
- **LOCKED**：UA 带身份 `GovCrawlerBot/1.0 (contact: xxx@example.com)`。
- **LOCKED**：请求间隔 ≥ 5s（即便本阶段只抓 1 篇，也要在代码里体现节流能力）。

### Parser
- **LOCKED**：列表页用 `parsel` + CSS/XPath 选择器，详情页同样。
- **LOCKED**：XPath 抽取失败或字段为空时，用 `GNE`（general news extractor）兜底抽取正文。
- **LOCKED**：正文输出同时保留：`content_html`（清洗后但仍是 HTML）+ `content_text`（纯文本，保留段落换行）。
- **LOCKED**：附件识别靠在正文 HTML 中匹配 `a[href$=".pdf"], a[href$=".doc"], a[href$=".docx"], a[href$=".xls"], a[href$=".xlsx"], a[href$=".zip"]`。

### Storage 布局（LOCKED — 三目录 + PG）
本地文件系统根目录：`./data/govcrawler/`（开发）或 `/data/govcrawler/`（部署）。三类文件分层：

```
data/govcrawler/
├── raw_html/<site>/<column>/<YYYY>/<MM>/<article_key>.html
├── articles_text/<site>/<column>/<YYYY>/<MM>/<article_key>.txt
└── attachments/<site>/<column>/<YYYY>/<MM>/<article_key>_<safe_filename>
```

`<article_key>` 取 URL 最后一段的 stem（如 `post_2136593`）或 `url_hash[:16]`。

### PG Schema（LOCKED — 本阶段创建）
用 Alembic 管理迁移。创建以下表（字段与 REQUIREMENTS.md 对齐）：

```sql
sites(id, site_id, name, base_url, config_json, enabled, created_at)
columns(id, site_id, column_id, name, category, list_url, config_json, last_crawled_at, last_article_time, enabled)
articles(
  id, site_id, column_id, category,
  url, url_hash UNIQUE, content_simhash,
  title, publish_time, source,
  content_text, raw_html_path, text_path,
  has_attachment, status, fetch_strategy,
  fetched_at, exported_to_rag_at
)
attachments(id, article_id, file_name, file_ext, size_bytes, file_path, file_hash, downloaded_at)
crawl_logs(id, site_id, column_id, article_url, strategy, http_status, duration_ms, success, error_msg, occurred_at)
```

本阶段**只需建表 + 写入一条记录**，不做复杂索引调优。

### 去重（本阶段最小版）
- `url_hash`: `hashlib.sha256(normalized_url.encode()).hexdigest()`，UNIQUE 约束保证 URL 级去重
- `content_simhash`: 本阶段实现但只记录，不做语义相似度阈值判断（那属于 Phase 2）
- `file_hash`: 附件文件 SHA256，用于 Phase 2 的附件去重（本阶段仅存字段）

### CLI 入口
- **LOCKED**：`python -m govcrawler fetch gdqy szfwj post_2136593` 能把一篇硬编码 URL 跑通。
- **LOCKED**：所有可观测信息打印到 stdout + 写入 `crawl_logs` 表。

### 反爬选择器失效演练
- **LOCKED**：加一个测试用例——"故意把主 XPath 换成 `div.nonexistent`"，验证 GNE 兜底能抽到正文。

### Claude's Discretion（Planner 自决定）
- Python 包内部文件/目录结构细节（models.py / config.py 的具体分层）
- Playwright 启动参数细节（除了上面 LOCKED 的之外）
- 浏览器 Context 复用 vs 每次新开（本阶段量小，新开也可以接受）
- 单元测试覆盖范围
- 本地开发时 PG 的连接方式（用 `docker-compose.yml` 起本地 PG 即可）
- 如何组织 `settings.py`（pydantic-settings 从 `.env` 读取）

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### 设计文档与规范
- `政务网站采集系统-设计文档.md` — **主设计文档 v2.1**，包含架构、数据模型、分层 Fetcher、Cookie 池、存储布局、合规红线、许可证清单。Phase 1 重点读 §3、§4.1（Tier 3 部分）、§4.4、§4.5、§4.8、§5.1。
- `.planning/PROJECT.md` — 项目总纲、Core Value、Key Decisions。
- `.planning/REQUIREMENTS.md` — 全量 REQ-ID；本阶段认领 10 个：FETCH-02, PARSE-01..04, STORE-01..05。
- `.planning/ROADMAP.md` — Phase 1 的 goal 与 success criteria。

### 实测证据（已在本会话验证）
- `https://www.gdqy.gov.cn/gdqy/newxxgk/fgwj/szfwj/content/post_2136593.html` — 目标文章示例，已实测：httpx 返回 412，真实 Chrome 可自动过 ctct 盾拿到正文。
- 文章标题：《清远市人民政府关于2026年第四届全国轻型飞机锦标赛航空嘉年华活动期间无人驾驶航空器安全管控的公告》
- 发布时间：2026-04-10 16:34:22
- 栏目路径：政务公开 > 法规文件 > 市政府文件

### 关键依赖的官方文档
- Playwright Python: https://playwright.dev/python/
- playwright-stealth: https://github.com/AtuboDad/playwright_stealth
- GNE (general news extractor): https://github.com/GeneralNewsExtractor/GeneralNewsExtractor
- parsel: https://parsel.readthedocs.io/
- Alembic: https://alembic.sqlalchemy.org/

</canonical_refs>

<specifics>
## Specific Ideas

- **要覆盖的 REQ-ID（10 个）**：FETCH-02, PARSE-01, PARSE-02, PARSE-03, PARSE-04, STORE-01, STORE-02, STORE-03, STORE-04, STORE-05
- **Target URL**：`https://www.gdqy.gov.cn/gdqy/newxxgk/fgwj/szfwj/content/post_2136593.html`（硬编码即可，本阶段不做栏目翻页）
- **list_url 示例**（列表页解析最低验证）：`https://www.gdqy.gov.cn/gdqy/newxxgk/fgwj/szfwj/`（能解析出多条详情 URL + 时间即可，**抓完列表后只取第一条走完详情**，验证端到端即可）
- **附件**：实测文章本身无附件。为了验证附件流程，Planner 可选一个**同栏目**另外带 PDF 附件的文章作为次测（或 mock 一个）
- **PG 连接**：本地 Docker 起一个 `postgres:16-alpine`，`docker-compose.yml` 同目录放，开发者 `docker compose up -d db` 即可。
- **配置加载**：用 `pydantic-settings` 从 `.env` 读 DB_URL / DATA_DIR / USER_AGENT
- **选择器失效验证**：专门写一个 pytest：patch XPath 到不存在的选择器 → assert GNE 仍能抽到 > 50 字正文。

## 成功标准映射（来自 ROADMAP Phase 1）
1. 从 gdqy.gov.cn 抓到一篇真实文章完整字段，无 412，自动过 ctct 盾 ✓
2. 本地同时产出 raw_html / articles_text / attachments 三类文件，目录规范 ✓
3. PG `articles` + `attachments` 表有对应行（含 url_hash / simhash / file_hash）✓
4. 删掉主 XPath 时 GNE 兜底仍能抽到正文 ✓
5. sites/columns/articles/attachments/crawl_logs schema 通过迁移脚本可重建 ✓

</specifics>

<deferred>
## Deferred Ideas

- 多站点配置化（YAML 解析、sites/columns YAML → DB 同步）→ **Phase 2**
- httpx/DrissionPage 分层降级 → **Phase 2**
- Cookie 池（Valkey） → **Phase 2**
- 增量 / SimHash 阈值去重 → **Phase 2**
- APScheduler 定时调度、错峰、节流 → **Phase 2**
- REST API / Prometheus 指标 / 飞书告警 / robots.txt 遵守 / UA 身份检查 / Docker Compose 全栈 → **Phase 3**
- 管理后台 → **v2**

</deferred>

---

*Phase: 01-poc*
*Context gathered: 2026-04-22 (distilled from design doc v2.1)*
