# 政府网站信息采集管理平台 — 设计文档

> 版本：v2.0（基于 2026-04-29 实现快照）
> 替代历史文档：`政务网站信息采集系统-2.0设计文档.md`、`政务网站采集系统-设计文档.md`
> 作者：开发团队

---

## 目录

1. [系统概述](#1-系统概述)
2. [总体架构](#2-总体架构)
3. [数据模型](#3-数据模型)
4. [核心模块详解](#4-核心模块详解)
   - 4.1 [适配器层 (adapters)](#41-适配器层-adapters)
   - 4.2 [抓取层 (fetcher)](#42-抓取层-fetcher)
   - 4.3 [解析层 (parser)](#43-解析层-parser)
   - 4.4 [流水线 (pipeline)](#44-流水线-pipeline)
   - 4.5 [存储层 (storage)](#45-存储层-storage)
   - 4.6 [任务队列 (task_queue)](#46-任务队列-task_queue)
   - 4.7 [调度器 (scheduler)](#47-调度器-scheduler)
   - 4.8 [配置层 (config)](#48-配置层-config)
   - 4.9 [合规层 (compliance)](#49-合规层-compliance)
5. [API 接口](#5-api-接口)
6. [管理后台 UI](#6-管理后台-ui)
7. [部署架构](#7-部署架构)
8. [运维手册](#8-运维手册)
9. [配置参考](#9-配置参考)
10. [失效模式与排查](#10-失效模式与排查)
11. [演进路线图](#11-演进路线图)

---

## 1. 系统概述

### 1.1 平台目标

自动化采集中国政府公开网站发布的政策、通知、公告等公文信息，集中入库、结构化存储，作为下游 RAG（检索增强生成）系统的权威知识源。

覆盖目标范围：
- 中央：国务院 (gov.cn) 政策文件库 / 信息公开
- 省级：广东省政府 (gd.gov.cn) — 文件库 + 公开目录平台
- 地级：清远市 (gdqy.gov.cn) 及其党政公开
- 县区：清远市 8 个县区（清城、清新、英德、连州、阳山、连南、连山、佛冈）的政务公开 / 信息公开
- 媒体：新华网（习近平活动报道集 10 个栏目）、网易新闻（多频道）

### 1.2 核心能力

| 能力 | 实现 |
|---|---|
| 多种站点适配 | 三种采集器：yaml-driven CSS / `gkmlpt` API / `gov_cn_policy` API（含 RSA 加密） |
| 增量抓取 | 基于 `url_hash` + `(site_id, native_post_id)` 双键去重，按发布时间倒序遇重即停 |
| 全量回溯 | `force=true` 旁路 dedup early-stop，支持按 cron + 列表分页全量补抓历史 |
| 智能解析 | CSS 选择器 + GNE 兜底 + trafilatura 二级兜底 + `<title>` 兜底 + 表格/附件特殊判定 |
| 反封禁 | 同 host 串行（任务队列）+ 失败计数器冷却 + cookie 池 + 网络层错误不升级浏览器 |
| 任务调度 | apscheduler + cron + 启动期任务派发 + 5 分钟周期性 reconcile（DB 同步） |
| 可视化管理 | FastAPI + Tailwind 单页后台：站点 / 目标 / 文章 / 部门 / 任务队列 / 监控 / 统计 |
| 数据兜底 | 入库前清洗（NULL 字节 / 控制符 / 超长截断），保证脏数据不丢文章 |

### 1.3 技术栈

| 分层 | 选型 |
|---|---|
| 语言 | Python 3.11 |
| Web 框架 | FastAPI 0.111 + uvicorn |
| ORM | SQLAlchemy 2.x（`mapped_column` 风格） |
| 校验 | Pydantic v2 |
| 数据库 | MySQL 8.0（utf8mb4_unicode_ci） |
| 缓存 / Cookie 池 | Valkey（Redis 兼容） |
| 调度 | APScheduler 3.x（BlockingScheduler） |
| 抓取 | httpx + patchright (Playwright 反检测分支) |
| 解析 | parsel + lxml + cssselect + GNE + trafilatura |
| 容器化 | Docker + docker-compose |
| 前端 | 原生 ES Module + TailwindCDN + 自研 5 个页面模块 |

---

## 2. 总体架构

### 2.1 容器拓扑

```
┌────────────────────── 192.168.1.13 (生产) ─────────────────────────┐
│                                                                    │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────────┐      │
│  │ docker-api   │    │docker-       │    │ docker-valkey    │      │
│  │              │    │scheduler     │    │  (Redis 兼容)    │      │
│  │ FastAPI:8787 │    │              │    │                  │      │
│  │ - 后台 UI    │◄───│ apscheduler  │    │  cookie pool     │      │
│  │ - REST API   │    │ + reconcile  │    │  per-host KV     │      │
│  │ - task_queue │    │              │    │                  │      │
│  │ - pipeline   │    │ HTTP→ api/   │    │                  │      │
│  └──────┬───────┘    │ /run         │    └─────▲────────────┘      │
│         │            └──────┬───────┘          │                   │
│         │                   │                  │                   │
│         └─────────┬─────────┴──────────────────┘                   │
│                   │                                                │
│         ┌─────────▼──────────┐                                     │
│         │  bind-mounts:       │                                    │
│         │  ../config/         │  YAML 站点定义                      │
│         │  ../data/govcrawler │  raw_html / text / attachments     │
│         │  ../data/ms-        │  Chromium binaries                 │
│         │    playwright/      │                                    │
│         └────────────────────┘                                     │
└────────────────────────────────────────────────────────────────────┘
                       │
                       ▼ TCP 3306
              ┌────────────────────┐
              │ 192.168.1.222      │
              │ MySQL 8.0          │
              │ (govcrawler db)    │
              └────────────────────┘
                       │
                       ▼ HTTP/HTTPS
              ┌────────────────────┐
              │  目标政府网站      │
              │  (gov.cn / gd.gov  │
              │   .cn / qingxin    │
              │   .gov.cn / ...)   │
              └────────────────────┘
```

### 2.2 数据流（一次抓取的生命周期）

```
[cron tick @ 07:00:00]
   │
   ▼
[apscheduler 触发 _job(target_code)]
   │
   ├─ DB 查 enabled — 已停用？→ skip + log
   │
   ▼
[POST /admin/api/targets/<code>/run]   ← scheduler → api（HTTP）
   │
   ├─ api 重新查 enabled — 兜底再确认
   │
   ▼
[task_queue.submit(site_code, target_code)]
   │
   ├─ resolve site_code → host (e.g. gd_gkmlpt → www.gd.gov.cn)
   ├─ 入 host 级 FIFO 队列
   │   （同一 host 同时只跑一个）
   │
   ▼
[per-host worker 拉出 job → asyncio.to_thread(crawl_target)]
   │
   ▼
[crawl_target(target_code)]                            (pipeline.py)
   │
   ├─ _resolve_target — 装载 site / target / yaml col
   │
   ├─ _list_via_adapter / _list_via_yaml
   │   ├─ for page in 1..hard_max:
   │   │   ├─ host_cooldown 检查 → 若冷却中，跳过
   │   │   ├─ httpx GET 列表页
   │   │   ├─ 解析列表 (CSS / 适配器 fetch_list_page)
   │   │   ├─ aggregated += [新 url]
   │   │   └─ 若 len(aggregated) >= max_items → break
   │
   ├─ for entry in aggregated:
   │   ├─ fetch_html(url) — Tier2 httpx → (条件)Tier3 playwright
   │   ├─ parse_detail — robust CSS + GNE + trafilatura 兜底
   │   ├─ download_attachments(并发=2)
   │   ├─ status = ready / failed (按多重判定)
   │   ├─ insert_article_from_contract — 含 sanitize
   │   ├─ insert_attachments
   │   └─ stop_on_duplicate=True 时遇到 duplicate_url_hash → break
   │
   ├─ insert_crawl_log
   │
   └─ return summary { items_seen, items_new, items_failed, ... }
```

---

## 3. 数据模型

### 3.1 ER 图

```
crawl_site (站点)                 local_department (清远部门)
   id PK                            dept_id PK
   site_code (uniq)                 full_name / short_name
   site_name                        region / category
   base_url                         ▲
   cms_adapter                      │ FK
   adapter_params_json              │
   default_fetch_strategy           │
   schedule_cron                    │
   enabled                          │
   yaml_path                        │
   ▲                                │
   │ FK 1:N                         │
   │                                │
crawl_target (栏目)                  │
   id PK                            │
   site_id FK ─────────────────► crawl_site
   target_code (uniq)               │
   target_name                      │
   entry_url                        │
   channel_name / channel_path      │
   content_category /               │
     content_subcategory            │
   dept_id FK ──────────────────► local_department
   schedule_cron / interval_sec     │
   parser_override_json             │
   enabled                          │
   ▲                                │
   │ FK 1:N                         │
   │                                │
article (文章)                       │
   id PK                            │
   site_id FK                       │
   target_id FK                     │
   dept_id FK ──────────────────────┘
   url / url_hash (uniq)
   native_post_id          ┐ uniq (site_id, native_post_id)
   title (TEXT)            │
   publish_time / publish_date
   source_raw / publisher
   channel_name / channel_path
   content_category / content_subcategory
   content_text (TEXT, ≤14000)
   raw_html_path / text_path
   has_attachment
   status (ready/failed/raw)
   fetch_strategy (httpx/playwright)
   exported_to_rag_at
   ▲
   │ FK 1:N
   │
attachment (附件)
   id PK
   article_id FK
   url / file_path
   filename / mime / size_bytes
   sha256
   download_status

crawl_log (抓取日志)
   id PK
   site_id FK
   target_id FK
   article_id FK NULL
   occurred_at
   success bool
   http_status / fetch_strategy
   duration_ms
   error_msg
   url
```

### 3.2 关键约束

| 约束 | 字段 | 作用 |
|---|---|---|
| UNIQUE | `crawl_site.site_code` | 站点编号唯一 |
| UNIQUE | `crawl_target.target_code` | 目标编号唯一，建议 `<site>__<column>` 格式 |
| UNIQUE | `article.url_hash` | URL 主键级去重 |
| UNIQUE | `(article.site_id, article.native_post_id)` | CMS 原生 ID 去重（跨 url 变体） |
| FK CASCADE | `crawl_target.site_id → crawl_site.id` | 站点删除连带目标删除 |
| INDEX | `article.fetched_at`, `article.publish_time` | 列表 ORDER BY 用 |

### 3.3 字段长度限制（与 sanitize 同步）

| 列 | 类型 | sanitizer 上限 |
|---|---|---:|
| `native_post_id` | varchar(64) | 64 |
| `title` | TEXT | 4000 |
| `source_raw` | varchar(500) | 500 |
| `channel_name` | varchar(200) | 200 |
| `channel_path` | varchar(1000) | 1000 |
| `content_category` | varchar(100) | 100 |
| `content_subcategory` | varchar(100) | 100 |
| `index_no` | varchar(200) | 200 |
| `doc_no` | varchar(200) | 200 |
| `publisher` | varchar(500) | 500 |
| `topic_words` | varchar(500) | 500 |
| `open_category` | varchar(200) | 200 |
| `content_text` | TEXT | 14000（utf8mb4 4 字节×16K = 64KB）|

入库前 `_sanitize_str` 自动按上限截断 + 剔除 `\x00`、控制符 (0x00-0x1F) 与孤立 surrogate halves（U+D800-U+DFFF），写 WARN 日志保留现场。这是 MySQL 1366/1406 错误的统一兜底层。

### 3.4 迁移历史

| 版本 | 文件 | 内容 |
|---|---|---|
| 0001 | initial_schema | v1 单表布局 |
| 0002 | schema_v2_rebuild | 重构为 site/target/article/attachment 四表 |
| 0003 | add_managed_by | 区分 yaml-managed vs admin-managed 站点 |
| 0004 | add_local_dept_region | 部门表加 region/category 字段 |
| 0005 | site_crawl_plan | 站点级 crawl_plan_json 字段 |
| 0006 | add_site_schedule_cron | 站点级 cron 兜底（位于 cron 解析链第 4 优先级）|

---

## 4. 核心模块详解

### 4.1 适配器层 (adapters)

#### 4.1.1 适配器协议

`govcrawler.adapters.contract.CrawlItem` 是站点 → pipeline 间的数据契约，每个适配器只需要返回填充好的 `CrawlItem` 列表即可。

```python
@dataclass
class CrawlItem:
    site_id: str            # 临时站点 code，pipeline 会替换为 FK int
    target_id: int | None
    dept_id: int | None
    url: str
    url_hash: str           # = sha256(normalize_url(url))[:64]
    title: str
    publish_time: datetime | None
    publish_date: date | None
    native_post_id: str | None
    doc_no: str | None
    publisher: str | None
    source_raw: str | None
    channel_name: str | None
    channel_path: str | None
    content_category: str | None
    content_subcategory: str | None
    metadata_json: dict | None
    # ... + 其他可选字段
```

#### 4.1.2 三种适配器

| 适配器 ID | 适用站点 | 列表抓取方式 | 详情抓取方式 |
|---|---|---|---|
| **(yaml-driven)** | 多数政府门户 / NF-CMS / 新华网 | CSS 选择器 from yaml | CSS + GNE 兜底 |
| **gkmlpt** | 清远云"政府公开目录平台"系列 | GET athena API → JSON | CSS + GNE |
| **gov_cn_policy** | gov.cn 政策文件库 / 信息公开 | warmup + RSA 加密 POST 拉 athena | TRS-CMS CSS |

#### 4.1.3 yaml 适配器（默认）

**列表选择器**（可在 yaml `list_selector` 中定义）：
```yaml
list_selector:
  row: "ul.list > li"            # 每条记录的 wrapper
  href: "a::attr(href)"          # 链接
  title: "a::text"               # 标题
  date: "span.date::text"        # 发布时间（可选，pipeline 会兜底从详情页解析）
```

**详情选择器**（`detail`）：
```yaml
detail:
  title: "h1::text, title::text"
  publish_time: "meta[name='PubDate']::attr(content)"
  source: "meta[name='ContentSource']::attr(content)"
  content: "div.content, div.TRS_Editor"     # 多 CSS 用逗号分隔，按顺序匹配
  attachment_css: |
    div.content a[href$='.pdf'],
    div.content a[href$='.docx']
```

**分页**（`pagination`）支持三种类型：
- `none` — 单页
- `page_param` — `?page=N`（query string）
- `path_pattern` — `index_{page}.html`（路径模板）

**目标合并**：
- `default_column` — 同一 yaml 内未列出的 column_id 自动套用此模板
- `alias_of` — 子栏目继承指定主栏目的所有选择器

#### 4.1.4 gkmlpt 适配器（清远云）

特点：
- 列表是公开 GET API，参数走 query string（`area_code`, `dept_path`, `column_id`, `page`）
- 返回标准 JSON `{result: {data: {list: [...]}}}`
- 不需要登录态、不需要 cookie 池
- 支持 `hard_max_pages` 上限（默认 50，避免无限翻页）

参数源：`crawl_site.adapter_params_json`：
```json
{
  "page_size": 20,
  "hard_max_pages": 50,
  "list_api_path_tpl": "/gkmlpt/api/...",
  "sid": "..."
}
```

#### 4.1.5 gov_cn_policy 适配器（中央政府网）

最复杂的适配器。两个 profile 由 `adapter_params_json.policy_profile` 路由：

**`xxgk` profile**（信息公开 /zhengce/xxgk）：
1. **Warmup GET**：拿动态 `code` 字段 → `https://sousuoht.www.gov.cn/athena/forward/<TOKEN_A>`
2. **RSA 加密 appkey**：JSEncrypt 风格 PKCS1-v1.5，公钥写死在适配器里
3. **POST 列表**：
   ```
   POST https://sousuoht.www.gov.cn/athena/forward/<TOKEN_B>
   Header: athenaappkey: <RSA(static_key)>
           athenaappname: <static>
   Body: { code, thirdPartyCode, thirdPartyTableId, pageNo, pageSize, sorts, ... }
   ```
4. 解析 `result.data.list` 为 `CrawlItem` 列表

**`zcwjk` profile**（政策文件库）：直接 GET search-gov/data 接口，无需 RSA。

**详情页**（两个 profile 共用）：TRS-CMS 静态渲染，正文在 `div.pages_content` / `#UCAP-CONTENT`，meta 标签齐全。但是 parsel/cssselect 在 gov.cn 双层 `<table>` 嵌套下 descendant-or-self 轴失效（具体 bug 见 §4.3.3），需 robust_css 兜底。

### 4.2 抓取层 (fetcher)

#### 4.2.1 三层 fallback 链

```
fetcher/chain.py: fetch_html(url)
   │
   ├─ host_cooldown 检查 → 在冷却中？立即返回 fast-fail
   │
   ├─ Tier 2: fetch_html_http (httpx + cookie pool)
   │     ├─ 成功 (status==200, html >= 500 chars, !is_challenge)
   │     │     → 记账 + return
   │     └─ 失败：
   │           ├─ 网络层错误 (ReadError / ConnectError / ...)
   │           │     → 不升级，直接记账失败 + return
   │           └─ 应用层错误 (403/412/429/5xx / is_challenge / 内容过短)
   │                 → 升级 Tier 3
   │
   └─ Tier 3: fetch_html_browser (patchright Chromium)
         ├─ 自动绕通防 (ctct shield)
         ├─ 写回 cookie 池供下次 Tier 2 复用
         └─ 记账 + return
```

#### 4.2.2 Host 级冷却（防 WAF 雪崩）

`fetcher/chain.py` 维护进程级 `_host_fails` + `_host_cooldown_until`：

```
连续 3 次网络层错误（ReadError / TCP RST / ConnectError）
   ↓
该 host 进入 5 分钟冷却
   ↓
冷却期内所有 fetch_html(url) 立即返回 fast-fail
   ↓
冷却到期或一次成功 fetch → 计数器清零
```

实测：`gd.gov.cn` 之前 18 小时累计 1831 次连接重置，新机制下首次 3 个错误后即停止冲击，等 WAF 自然冷却。

#### 4.2.3 Cookie 池（Valkey 后端）

`govcrawler/cookies/store.py`：
- 键：lower(host)
- 值：`{name: value}` cookie dict
- TTL：默认 24h
- Tier 3 playwright 成功一次后写入；Tier 2 httpx 第一时间读取 → 大多数受保护站点能走 Tier 2 廉价路径

失效时机：Tier 2 命中 cookie 后仍失败 → `invalidate(host)`，下次 Tier 3 重新种 cookie。

#### 4.2.4 节流（HostThrottle）

`fetcher/throttle.py`：每 host 最低请求间隔（默认 5s，可被 `crawl_target.interval_sec` 覆盖）。Pipeline 内 attachments 下载和列表分页都走 throttle.wait()。

### 4.3 解析层 (parser)

#### 4.3.1 列表解析

`parser/list_parser.py`：parsel CSS 选择器抽取 `(url, title, date)` 三元组，对相对 URL `urljoin(base, href)` 转绝对。

#### 4.3.2 详情解析

`parser/detail_parser.py: parse_detail(html, base_url, selectors)` 五步法：

1. **CSS 抽取**（含 robust_css 兜底）：title / publish_time / source / content。
2. **`<title>` 兜底**：当 domain 选择器没匹到 title，从 `<title>` 取，剥掉 "_新华网" / "_中国政府网" 等站名后缀。
3. **GNE 兜底**：当 content_html < 100 字符 AND title 缺失时触发 `gne_extract` (中文 generic 提取库)。
4. **trafilatura 兜底**：GNE < 50 字符再降级。
5. **html_to_text + img count + 附件抽取**：从 content_html 内抽 `<a href>` 附件链接。

返回 `DetailFields` dataclass：`title / publish_time_raw / source / content_html / content_text / attachment_urls / used_fallback / fallback_engine / inline_image_count`。

#### 4.3.3 robust_css — 应对 libxml2 axis bug

**Bug**：parsel 默认用 cssselect 把 CSS 翻译成 `descendant-or-self::div[has-class('foo')]` xpath。在 gov.cn 这类 TRS-CMS 站点，正文嵌套在双层 `<table>` 内，`descendant-or-self` 轴在某些条件下递归失败 — 直接 xpath `//div[...]` 能找到 65 个 div，但同样的轴只找到 9 个。

**Fix**：`_robust_css(sel, css)`：
```python
out = sel.css(css)
if out: return out
# Fallback: per-comma 子选择器单独翻译并加 // 前缀
for sub in css.split(','):
    xp = HTMLTranslator().css_to_xpath(sub.strip(), prefix='//')
    found = sel.xpath(xp)
    if found: return found
```

代价：每次失败多一次 xpath 评估，对成功路径无影响。

#### 4.3.4 文本清洗

`parser/cleaner.py: html_to_text(html)`：
- 剔除 `<script>`, `<style>` 子树
- 块级标签换行
- `&nbsp;` → 空格 / `&emsp;` → 空格
- `\xa0` 保留（不 strip — 影响表格 spacing）
- 去重连续空行

### 4.4 流水线 (pipeline)

#### 4.4.1 入口

```python
crawl_target(
    target_code: str,
    *,
    max_items: int | None = None,        # 列表最大累计 URL 数
    stop_on_duplicate: bool = True,      # 是否启用 dedup early-stop
    throttle: HostThrottle | None = None,
) -> dict
```

#### 4.4.2 主循环（伪代码）

```python
1. resolve_target(target_code)
   └─ snap site_code, target_id, cms_adapter, respect_robots, interval_sec
2. cron 链: target.interval_sec → adapter.DEFAULT_INTERVAL_SEC → HostThrottle 默认
3. 列表抓取
   if cms_adapter:
       list_url, entries, list_fr = _list_via_adapter(rt, interval_sec, max_items)
   else:
       list_url, entries, list_fr = _list_via_yaml(rt, interval_sec, max_items)
4. 公共合规检查
   - is_public_path(list_url)
   - robots.txt allowed?
5. 主循环 fetch_and_store
   for i, entry in enumerate(entries):
       if max_items and i >= max_items: break
       result = fetch_and_store(entry)
       if result.reason == "duplicate_url_hash" and stop_on_duplicate:
           break  # 历史边界
       if result.reason == "duplicate_other_target":
           skipped_count += 1
           continue  # 跨 target 重复，但同 target 内可能还有新货
       ...
6. update target.last_crawled_at, last_article_time
7. write crawl_log
8. return summary
```

#### 4.4.3 fetch_and_store（单篇）

1. **fetch_html** → FetchResult
2. **parse_detail** → DetailFields
3. **下载附件**（并发=2，单附件超时 60s，受 throttle 控制）
4. **判定 status**：
   ```python
   status = "ready" if any([
       len(content_text) >= 50,
       downloaded_attachments >= 1,
       len(attachment_urls) >= 1,        # 即使下载失败，URL 存在也算真实文章
       inline_image_count >= 1,           # 纯图片公示
       len(content_html) >= 500,          # 表格类公示（如 救助名单）
   ]) else "failed"
   ```
5. **持久化**：
   - 写 `raw_html` 到磁盘
   - 写 `text` 到磁盘
   - `insert_article_from_contract(item)` — 自动 sanitize
   - `insert_attachments(...)`
6. **写 crawl_log**

#### 4.4.4 去重策略

| 层 | 检查 | 行为 |
|---|---|---|
| L1 | `Article.url_hash` UNIQUE | 同 URL 已抓 → `duplicate_url_hash` reason |
| L2 | `(site_id, native_post_id)` UNIQUE | 同 CMS post 不同 URL 变体 → `duplicate_other_target` |
| L3 | `seen_urls` set 内存 | 同次抓取列表内多页重复 → 跳过不入库 |

stop_on_duplicate=True 时，L1 命中触发 break；L2/L3 只跳过当前条目，继续看后续。

### 4.5 存储层 (storage)

#### 4.5.1 文件布局

```
${DATA_DIR}/
   raw_html/
      <site_code>/
         <target_code>/
            YYYY/MM/<filename>.html        # 原始 HTML 副本
   text/
      <site_code>/
         <target_code>/
            YYYY/MM/<filename>.txt         # html_to_text 结果
   attachments/
      <site_code>/
         <target_code>/
            <article_id>/
               <safe_filename>.pdf         # 附件二进制
```

`DATA_DIR` 默认 `/data/govcrawler`（容器内），通过 docker bind-mount 挂到 host 的 `data/govcrawler`，重启不丢。

#### 4.5.2 路径安全

`storage/paths.py: to_os_path(data_dir, rel_path)` — Path-traversal 防御：所有 rel_path 必须 `.resolve().relative_to(data_dir.resolve())`，否则拒绝。

#### 4.5.3 sanitize（入库防御）

`storage/repo.py: _sanitize_item_inplace(item)`：
- 剔除 `\x00`、控制符（0x00-0x08, 0x0B-0x0C, 0x0E-0x1F）和孤立 surrogate halves
- 按 `_VARCHAR_LIMITS` 截断
- 任一改动 → log.warning 记录字段名 + 改动量

修复了 1366/1406 错误：之前每个 site 累积约 10 次/天 因脏 HTML 数据导致整篇 INSERT 失败，新机制下文章被截断后入库 + 警告。

#### 4.5.4 附件下载

`storage/attachments.py: download_attachment(url, ...)`：
- httpx 流式下载，超时 60s
- 自动注入 cookie 池
- 文件名清洗：`safe_filename` 处理中文、特殊字符
- SHA-256 去重
- mime 类型由 `Content-Type` header + 后缀 fallback

### 4.6 任务队列 (task_queue)

#### 4.6.1 设计

`api/task_queue.py: TaskQueue`：
- **进程内** asyncio.Queue（不持久化，重启丢失）
- **per-host 串行**（不是 per-site！）
  - `_host_for_site(site_code)` 缓存：site_code → urlparse(base_url).netloc.lower()
  - `gd_gkmlpt` + `gd_wjk` 都解析为 `www.gd.gov.cn` → 共用一队列
- 每个 host 独立的 `_worker_loop` 协程
- 任务历史保留 200 条（`HISTORY_KEEP`）

#### 4.6.2 JobInfo 状态机

```
queued → running → done
   │           ↓
   │       failed
   │           ↓
   └─────► cancelled
```

`stop_requested` flag：管理员点"取消"，pipeline 当前 sync 函数仍跑完，但状态改为 cancelled。

#### 4.6.3 并发不变量

> **同一 host，同一时刻，至多一个 crawl_target 在跑**

所有 crawl 入口都过此队列：
- 手动 UI 单击 → `/api/targets/<code>/run` → submit
- 批量运行 → `/api/targets/bulk-run` → 多个 submit
- cron 定时 → scheduler `_job` HTTP POST → submit

Scheduler 移除了 inline fallback 路径（v1.1）— 之前 api 不可达时 inline 跑会绕开队列，现在 api 不可达就 skip 等下个 tick。

### 4.7 调度器 (scheduler)

#### 4.7.1 启动流程

```python
run_forever():
  sched = build_target_scheduler(_job)  # 同 reconcile_jobs(), 启动期一次
  sched.add_job(_reconcile_tick, interval=300s, id='admin.reconcile_jobs')
  sched.start()
```

#### 4.7.2 cron 优先级解析

按以下顺序选第一个非空：
1. `crawl_target.schedule_cron`
2. yaml 中匹配 `column.schedule`
3. yaml 中 `default_column.schedule`
4. `crawl_site.schedule_cron`
5. `DEFAULT_SCHEDULE = "0 2 * * *"`

后端 `/api/jobs/scheduled` 用同一 resolver — 操作员看到的"下次执行时间"和实际触发完全一致。

#### 4.7.3 Spread 默认窗口

cron == 默认 `0 2 * * *` 时，按 `sha256(site_code/target_code)[:4] % 240 分钟` 在 01:00-05:00 窗口内打散，避免 4000+ targets 同时打 02:00 整点引起惊群。

#### 4.7.4 Reconcile（运行期 DB 同步）

每 300s（默认）跑一次 `reconcile_jobs(sched, _job)`：
- `desired = _compute_desired_jobs()` — 从 DB 读所有"启用 site + 启用 target"组合
- `current = sched.get_jobs()` 中带 `target.` 前缀的
- diff：
  - desired 有 / current 无 → `add_job`
  - current 有 / desired 无 → `remove_job`
  - cron 表达式不同 → `reschedule_job`

效果：操作员在 UI 上启停 / 改 cron / 新建目标，**最多 5 分钟生效，无需重启容器**。

可调：`SCHEDULER_RECONCILE_SEC` 环境变量。

#### 4.7.5 双重 enabled 防线

```
cron 触发 → _job 入口
   ├─ 防线 1: DB 查 enabled — 已停用？立即 skip + log
   └─ HTTP POST api /run
        └─ 防线 2: api 重新查 enabled — 已停用？返回 409
            └─ scheduler 拿到 409 后 return（不再 fallback 到 inline）
```

防线 1 让"刚启停 + 还没 reconcile"的窗口期也能 instant 生效。防线 2 是双保险。Inline fallback 已移除（曾因绕过 enabled 检查 + 同站点并行 触发 4974 进程泄漏）。

### 4.8 配置层 (config)

#### 4.8.1 yaml-driven 站点配置

文件：`config/sites/<site_code>.yaml`

```yaml
site_id: xinhua_xjp
site_name: 新华网·习近平报道集
base_url: https://www.news.cn
default_strategy: httpx
concurrency: 1
interval_sec: 5
enabled: true
respect_robots: true

default_column:
  list_selector: { row, href, title, date }
  pagination: { type: none | page_param | path_pattern, ... }
  detail: { title, publish_time, source, content, attachment_css }

columns:
  - column_id: zxbd
    name: 最新报道
    list_url: https://www.news.cn/.../zxbd.html
    list_selector: ...
    pagination: ...
    detail: ...
    schedule: "0 7 * * *"
    enabled: true
  - column_id: hyhd
    list_url: ...
    alias_of: zxbd        # 继承 zxbd 全部选择器
    schedule: "0 7 * * *"
    enabled: true
```

加载流程：
- `config/loader.py: load_site(path)` — Pydantic v2 `SiteConfig`
- `config/registry.py: _registry()` 进程级缓存，所有 yaml 一次性 load
- 修改 yaml 后需重启 api 容器（`docker compose restart api`）才生效

#### 4.8.2 DB-managed 站点

`crawl_site` 行直接通过后台 UI / SQL 创建：
- `cms_adapter` 字段决定走哪个适配器（None = yaml）
- `adapter_params_json` 存 adapter 私有参数（gkmlpt 的 list_api_path_tpl / sid 等）
- `yaml_path` 字段：当存在时 pipeline 也会去 yaml 读 detail 选择器（混合模式）

### 4.9 合规层 (compliance)

#### 4.9.1 robots.txt 检查

`compliance/robots.py: is_allowed(url, ua)`：
- LRU cache（避免重复拉取 robots.txt）
- 拉取失败默认 allow（怕误拦合规站）
- 站点级 `respect_robots=false` 可绕过（应当合规审核后启用）

#### 4.9.2 公开路径校验

`compliance/paths.py: is_public_path(url)`：
- 白名单 host 后缀（`.gov.cn`, `.com.cn` 等）
- 拒绝 `/admin/`, `/internal/` 等明显非公开路径

---

## 5. API 接口

所有 admin endpoint 前缀 `/admin`，需 HTTP Basic Auth（`ADMIN_USER` / `ADMIN_PASSWORD`）。

### 5.1 站点管理

| Method | Path | 说明 |
|---|---|---|
| GET | `/admin/api/sites` | 列出所有站点 + 嵌套 targets |
| POST | `/admin/api/sites` | 新建站点 |
| PUT | `/admin/api/sites/<code>` | 更新站点 |
| POST | `/admin/api/sites/<code>/toggle` | 启用/停用 |
| DELETE | `/admin/api/sites/<code>` | 删除（级联） |

### 5.2 采集目标管理

| Method | Path | 说明 |
|---|---|---|
| GET | `/admin/api/targets/lookup` | 按 site / dept / 关键词查找 |
| POST | `/admin/api/targets` | 新建 |
| PUT | `/admin/api/targets/<code>` | 更新 |
| POST | `/admin/api/targets/<code>/toggle` | 启停 |
| DELETE | `/admin/api/targets/<code>` | 删除 |
| **POST** | `/admin/api/targets/<code>/run` | 入队抓取（强制 enabled 检查 + 队列序列化）|
| **POST** | `/admin/api/targets/bulk-run` | 批量入队 |
| POST | `/admin/api/targets/discover-html` | 自动发现栏目（BFS）|
| POST | `/admin/api/targets/bulk-create-yaml` | 基于 yaml default_column 批量创建 |
| POST | `/admin/api/targets/bulk-create` | 批量手工创建 |
| GET | `/admin/api/targets/<code>/parser` | 查看解析覆盖配置 |
| PUT | `/admin/api/targets/<code>/parser` | 更新覆盖 |

### 5.3 文章管理（带分页）

| Method | Path | 说明 |
|---|---|---|
| GET | `/admin/api/articles/search` | **分页**搜索（limit ≤ 200，返回 total）|
| GET | `/admin/api/articles/search/ids` | 仅返回 ID（给"勾选全部搜索结果"用）|
| GET | `/admin/api/articles/<id>` | 单篇详情 |
| DELETE | `/admin/api/articles/<id>` | 删除单篇（含文件清理）|
| POST | `/admin/api/articles/bulk-delete` | 批量删除 |

`search` 响应格式：
```json
{
  "count": 20,
  "total": 5127,
  "limit": 20,
  "offset": 0,
  "items": [...]
}
```

### 5.4 部门管理

| Method | Path | 说明 |
|---|---|---|
| GET | `/admin/api/local-departments` | 列出（支持 region/category 过滤）|
| POST | `/admin/api/local-departments` | 新建 |
| PUT | `/admin/api/local-departments/<id>` | 更新 |
| DELETE | `/admin/api/local-departments/<id>` | 删除 |

### 5.5 任务队列 / 调度

| Method | Path | 说明 |
|---|---|---|
| GET | `/admin/api/jobs/queue` | 当前队列摘要（host 级）|
| GET | `/admin/api/jobs/list` | 全量 job 列表（含历史）|
| POST | `/admin/api/jobs/<id>/cancel` | 取消 |
| GET | `/admin/api/jobs/scheduled` | 即将触发的 cron 任务 |

### 5.6 监控 / 统计

| Method | Path | 说明 |
|---|---|---|
| GET | `/admin/api/logs/recent` | 近 N 小时 crawl_log |
| GET | `/admin/api/stats/overview` | 仪表盘概览 |
| GET | `/admin/api/stats/by-site` | 按站点聚合 |
| GET | `/admin/api/stats/by-dept` | 按部门聚合 |
| GET | `/admin/api/alerts` | 当前告警 |

### 5.7 适配器 / 注册表

| Method | Path | 说明 |
|---|---|---|
| GET | `/admin/api/adapters` | 列出所有可用适配器 + 默认选择器 |
| GET | `/admin/api/registry` | 主栏目元数据 |

---

## 6. 管理后台 UI

单页应用 (`/admin/`)，无打包构建步骤。文件：

```
api/static/
   admin.html              # 唯一入口
   js/
      core.js              # esc / fmt / pill / api / toast / card / renderPager / DEPT_REGIONS
      boot.js              # DOMContentLoaded 启动 + 顶栏全局搜索
      nav.js               # 左侧菜单 + 路由 (location.hash)
      pages/
         dashboard.js
         sites.js
         targets.js
         articles.js
         departments.js
         jobs.js            # 任务队列 + 已计划
         logs.js            # 运行监控
         stats.js           # 统计分析
```

### 6.1 全局状态约定

每个页面用全大写 `XXX_STATE` 单例，`PAGES.<key>` 注册渲染函数（async）。跨页面跳转用 `pendingXxx` 字段传递期望过滤条件，目标页 `PAGES.xxx` 在同一渲染周期消费，避免初始 fetch 与后续 search 的竞态。

### 6.2 关键交互规则

- **顶栏全局搜索**：回车 → 写 `ARTICLES_STATE.pendingQ` → 跳 `articles` 页面 → 直接以这个关键词 search。
- **采集目标管理**：行级"运行 / 全量"按钮直接 submit 任务；批量栏复选多个 target → 批量入队 + force 选项。
- **文章列表**：每次搜索/翻页都重新请求后端（真分页），勾选跨页保留；"勾选全部搜索结果"调 `/search/ids` lazy 加载所有匹配 ID。
- **统计分析**："看文章"按钮跳 articles 页时通过 `pendingSite/pendingTarget/pendingDept` 三联状态完成 hand-off。
- **部门下拉**：input + datalist 自动联想（输入"财政"会过滤到"财政局"）。

### 6.3 渲染骨架

- TailwindCDN 提供 utility class，按 shadcn/ui 风格构造常用 `card()`, `pill()`, `btn-outline`。
- 分页器 `renderPager({ container, total, page, pageSize, onPage })` — 后端真分页时 onPage 重新发请求；前端切片场景仍兼容（各页面自行选择）。

---

## 7. 部署架构

### 7.1 docker-compose 服务

| 服务 | 镜像 | 端口 | 卷挂载 |
|---|---|---|---|
| `api` | 自建 (Dockerfile) | 8787:8787 | `../config:/app/config:ro`, `../data/govcrawler:/data/govcrawler`, `../data/ms-playwright:/root/.cache/ms-playwright` |
| `scheduler` | 自建 | — | 同 api |
| `valkey` | redis-alike | 6379 internal | 持久卷 `valkey-data` |

共用 build：
- `SKIP_CHROMIUM=1` build arg — 不在 build 时下载 chromium，由 host bind-mount 提供
- 系统包：`libxfixes3 libxshmfence1` — chromium-headless-shell 运行时依赖

环境变量（关键）：
- `DATABASE_URL`：MySQL DSN
- `VALKEY_URL`：`redis://valkey:6379/0`
- `SCHEDULER_API_URL`：`http://api:8787`
- `SCHEDULER_RECONCILE_SEC`：300（默认）
- `ADMIN_USER` / `ADMIN_PASSWORD`：HTTP Basic Auth 凭据
- `DATA_DIR`：`/data/govcrawler`

### 7.2 部署步骤

```bash
# 1. 拉代码
ssh root@192.168.1.13
cd /home/app/GovCrawler
git pull   # 或 rsync -az ... 同步本地修改

# 2. 重建并启动（仅改 .py / 静态资源）
cd docker
docker compose -f docker-compose.prod.yml up -d --build api scheduler

# 3. 仅改 yaml 配置 — 不需要 build
docker compose -f docker-compose.prod.yml restart api

# 4. 数据库迁移
docker exec docker-api-1 alembic upgrade head
```

### 7.3 备份

| 项 | 频率 | 命令 |
|---|---|---|
| MySQL 数据库 | 每日 | `mysqldump -h 192.168.1.222 -u govcrawler -p govcrawler > backup_$(date +%F).sql` |
| 文件存储 | 每周 | `rsync -az /home/app/GovCrawler/data/govcrawler backup-host:/...` |
| yaml 配置 | git 同步 | `cd /home/app/GovCrawler && git status` |

---

## 8. 运维手册

### 8.1 日常监控

| 命令 | 检查项 |
|---|---|
| `docker ps` | 三个容器都 Up |
| `docker exec docker-scheduler-1 ls /proc \| grep -E '^[0-9]+$' \| wc -l` | < 10 个进程；> 50 即异常 |
| `docker logs --tail 100 docker-scheduler-1 \| grep reconcile` | 每 5 分钟一次 reconcile done 日志 |
| `docker logs --tail 200 docker-api-1 \| grep ERROR` | 错误数 |

### 8.2 调速

- WAF 敏感站点：DB 改 `crawl_target.interval_sec` 到 60+；reconcile 5 分钟内生效。
- gd.gov.cn 全 host 抓取：`gd_gkmlpt` + `gd_wjk` 共用同一 host 队列，无需手动错峰。

#### 8.2.1 采集间隔配置优先级

管理员调整采集间隔时，需要区分“运行时配置”和“源配置”：

1. 运行时最高优先级：`crawl_target.interval_sec`、`crawl_target.interval_jitter_sec`

   采集任务实际执行时，会先读取数据库中当前采集对象的 `crawl_target.interval_sec` 和 `crawl_target.interval_jitter_sec`。后台页面修改采集对象间隔，或运维直接更新数据库，都会影响实际运行。

2. 源配置：`config/sites_v2/*.yaml` 中每个 column 的 `interval_sec`、`interval_jitter_sec`

   YAML 不直接决定已经在跑的任务，但执行配置同步时会写入数据库并覆盖对应 `crawl_target` 字段。因此长期配置调整必须同时落到 YAML；否则后续同步可能把数据库中的人工调整覆盖回旧值。

3. 适配器默认值：`adapter.DEFAULT_INTERVAL_SEC`

   只有当 `crawl_target.interval_sec` 为 `NULL`，且站点配置了 `cms_adapter` 时，系统才会读取适配器里的默认间隔。例如 `gkmlpt` 适配器提供自己的默认值，用于没有单独配置间隔的新目标。

4. 最后兜底：`HostThrottle` 默认间隔

   如果数据库目标间隔为空，适配器也没有默认值，才使用 `HostThrottle` 的默认间隔。该值只是保护性兜底，不建议作为日常调速入口。

5. 附件下载节流

   附件下载也参考当前采集对象的间隔和 jitter，但会被系统参数 `attachment_throttle_cap_s` 截断，避免附件下载因为目标间隔过大而等待过久。

调整建议：

- 临时止血：先改数据库 `crawl_target.interval_sec`，让生产尽快生效。
- 长期生效：同步修改对应 YAML，提交 git，并通过生产部署流程更新服务器代码。
- 批量调整：只更新目标值等于旧间隔的记录，避免覆盖已经单独调过速的采集对象。

### 8.3 日志查找模板

```bash
# 看某个 target 最近错误
docker exec docker-api-1 python -c "
from govcrawler.db import get_sessionmaker
from govcrawler.models import CrawlLog, CrawlTarget
from sqlalchemy import desc
S=get_sessionmaker()
with S() as s:
    t=s.query(CrawlTarget).filter_by(target_code='xxx').first()
    for L in s.query(CrawlLog).filter_by(target_id=t.id, success=False).order_by(desc(CrawlLog.id)).limit(5):
        print(L.occurred_at, L.error_msg[:200])
"
```

---

## 9. 配置参考

### 9.1 yaml 完整字段

```yaml
site_id: <slug>                  # 必填，与 crawl_site.site_code 对应
site_name: <中文名>
base_url: https://...            # 用于 host 推导
default_strategy: httpx          # httpx | playwright | drission
concurrency: 1                   # 当前未实际使用（task_queue 强制 1）
interval_sec: 5                  # 默认 host throttle，可被 target 覆盖
enabled: true
respect_robots: true

default_column:                  # 可选，作为未在 columns 列出的 column_id 的回退
  list_selector: { ... }
  pagination: { ... }
  detail: { ... }

columns:
  - column_id: <slug>
    name: <中文名>
    category: <分类>
    list_url: https://...
    alias_of: <other column_id>  # 互斥于 list_selector/detail/pagination
    list_selector: { row, href, title, date }
    pagination:
      type: none | page_param | path_pattern
      param: page                # for page_param
      pattern: index_{page}.html # for path_pattern
      start: 2                   # 可选（默认 2）
      max_pages: 30              # 必填（不超过 hard_max_pages）
    detail: { title, publish_time, source, content, attachment_css }
    schedule: "0 7 * * *"        # cron 优先级第 2/3
    enabled: true
```

### 9.2 adapter_params_json（gkmlpt / gov_cn_policy）

```json
// gkmlpt
{
  "page_size": 20,
  "hard_max_pages": 50,
  "list_api_path_tpl": "/gkmlpt/api/...",
  "sid": "..."
}

// gov_cn_policy
{
  "page_size": 20,
  "hard_max_pages": 5,
  "policy_profile": "xxgk"   // xxgk | zcwjk
}
```

### 9.3 列长度上限速查

见 §3.3。修改时同步：
- `models.py` 的列定义
- `storage/repo.py` 的 `_VARCHAR_LIMITS`
- alembic 新增 migration

---

## 10. 失效模式与排查

| 现象 | 根因 | 处理 |
|---|---|---|
| `ConnectionResetError` 大量出现 | 目标 WAF 限流 | 调高 `interval_sec`；Host cooldown 机制 5 分钟内自动止血 |
| `ctct_challenge_unresolved` | 通防保护启用 | 确认 chromium binary 挂载存在；检查 cookie 池 |
| `content_text_too_short` 但页面有内容 | 选择器没匹中 | 检查页面结构；可能需 `parser_override_json` 或 yaml 改 detail.content |
| `1366 / 1406` MySQL 错误 | 旧版本 — 现已被 sanitize 拦截，截断后入库 + WARN | 看 api 日志中 `sanitize:` warning，根据字段名调整选择器 |
| 启用了 target 但 cron 没触发 | reconcile 还没跑 / DB 状态没读到 | 等 5 分钟；查 `docker logs docker-scheduler-1 \| grep reconcile`；必要时 `docker compose restart scheduler` |
| 任务卡住超 10 分钟 | pipeline 卡在某 HTTP / playwright 调用 | 看 task_queue 是否还在 running；重启 api 容器 |
| scheduler 进程数飙升 | （历史 v1.0 bug，已修）inline fallback 绕过队列 + 进程泄漏 | restart scheduler 即恢复 |
| 列表页只显示前 5000 条 | （历史 v1.0 bug，已修）前端假分页 | 现已真分页，limit ≤ 200，`total` 字段返回总数 |
| 同站点并行抓取 | （历史 v1.0 bug，已修）site 而非 host 级队列 | 现按 host (netloc) 串行，gd_gkmlpt + gd_wjk 共用一队列 |

---

## 11. 演进路线图

### 11.1 已完成（v2.0 - 2026-04-29）

- ✅ 三种适配器框架
- ✅ Tier 2 / Tier 3 fallback 抓取链 + Host cooldown
- ✅ 真分页（前端 + 后端）
- ✅ Per-host 任务序列化
- ✅ Reconcile 自动同步 DB 状态
- ✅ 入库前 sanitize 防御
- ✅ Robust CSS 兜底（应对 libxml2 axis bug）

### 11.2 短期（v2.1）

- [ ] 站点级 / 全局级失败率告警 webhook（钉钉 / 飞书）
- [ ] 附件去重（基于 sha256 跨文章共享）
- [ ] 列表分页支持 cursor-based（应对超大量级文章场景）
- [ ] discover-html 自动同步到 yaml（目前只入库不写文件）

### 11.3 中期（v2.2 - v3.0）

- [ ] RAG 推送：定时把 status=ready 的文章发往下游（mcp / API）
- [ ] 文章正文 OCR：扫描件 PDF 附件提取文本
- [ ] 多语言（粤语 / 少数民族语言）来源支持
- [ ] 全文搜索引擎（OpenSearch / ZincSearch）替代 MySQL LIKE %q%
- [ ] 多租户 / 多区域隔离

---

## 附录 A：术语表

| 术语 | 含义 |
|---|---|
| 站点 (site) | 一个被采集的网站，对应 `crawl_site` 行。 |
| 采集目标 (target) | 站点下的一个具体栏目，对应 `crawl_target` 行。 |
| 适配器 (adapter) | 决定如何抓列表 + 怎么解析的代码模块。 |
| 流水线 (pipeline) | `crawl_target()` 主函数 + 配套子流程。 |
| dedup early-stop | 列表按时间倒序；遇到已抓 URL 立即停止当次抓取（增量模式）。 |
| 全量抓取 (force) | `stop_on_duplicate=False`；列表全部走完，配合 dedup 自然跳过已存在的。 |
| robust_css | 当 parsel CSS 失败时用 `//` 前缀 xpath 兜底。 |
| sanitize | 入库前清洗字段（剔除非法字符 + 截断）。 |
| reconcile | scheduler 周期性同步 DB 与内存任务表。 |
| host cooldown | 同 host 累计 N 次网络失败后封锁 M 秒。 |

---

## 附录 B：参考文件索引

| 主题 | 文件 |
|---|---|
| 数据契约 | `govcrawler/adapters/contract.py` |
| ORM 模型 | `govcrawler/models.py` |
| 抓取链 | `govcrawler/fetcher/chain.py`, `http_client.py`, `browser.py` |
| 解析 | `govcrawler/parser/detail_parser.py`, `cleaner.py`, `extractor.py` |
| 流水线 | `govcrawler/pipeline.py` |
| 入库 | `govcrawler/storage/repo.py` |
| 任务队列 | `govcrawler/api/task_queue.py` |
| 调度 | `govcrawler/scheduler.py` |
| API 路由 | `govcrawler/api/admin/*.py` |
| 前端 | `govcrawler/api/static/js/**` |
| yaml 配置 | `config/sites/*.yaml` |
| 部署 | `docker/docker-compose.prod.yml`, `Dockerfile` |
| 迁移 | `alembic/versions/*.py` |

---

**— 设计文档 v2.0 —**
