# 政务网站信息采集系统 2.0 设计文档
## 2.0 核心闭环 + 清远 gkmlpt 集群专项增强

> 版本：v2.2　　日期：2026-04-23
> 目标：在现有 1.0 采集能力基础上，将系统升级为**面向清远政务知识库建设**的数据归集平台。
> 核心方向：**栏目级采集目标管理 + 本地部门绑定 + 公开属性结构化保存 + 面向 RAG 的轻量文本分类**
> 规模：20–30 个政府门户网站；清远本地部门子集约 100–200 个；单机长期运行。
>
> ⚠️ **硬边界声明**
> 2.0 的**稳定基线**（schema、部门归属、公开属性、健康度、后台核心页）面向**所有接入站点**适用。
> **gkmlpt / master_column_registry / CMS 适配器层 / 批量接入向导** 等章节是**清远集群专项增强**，
> 仅在已实证的清远市及下辖县区 `gkmlpt` CMS 子站群内启用，**不代表通用政务站模式**，
> 不作为其他地市、省站、国站的通用设计前提。
>
> 文档结构：全文分两层 —
> - **2.0 核心闭环**（§1–§4 / §5.1 / §5.3–§5.7 / §6 / §7.1–§7.4 / §8.1–§8.4 / §9–§11）：通用基线，首版必须交付
> - **清远集群增强**（§5.2 site_department / §5.8 master_column_registry / §7.5 / §7.6 / §8.5）：专项能力，在基线跑通后分阶段落地
>
> **v2.2 修订说明**：在 v2.1 评审基础上，按"核心闭环 + 专项增强"两层重排，收死了 CMS 适配器的
> 适用边界、去重键优先级、YAML 职责、实施阶段顺序与成功标准的二分。具体变更见各章节首段
> 及 §12 / §13。历史关键变更：
> - 模板配置留在 YAML（不再引入 `site_parser` 表）
> - 无历史数据包袱，一刀切重建 schema，无灰度、无双写
> - 删除 `content_simhash`（RAG 入库侧统一做正文去重）
> - 删除 `classification_mode` 字段（可由 `dept_id IS NULL` 推导）
> - 新增 `expected_cadence_days` 驱动的健康度模型
> - 新增 `sample_article_url` + 保存前 dry-run 校验
> - `schedule_cron` 不再由人工填写，系统按 hash 分配

---

## 1. 背景与升级目标

### 1.1 当前系统现状

现有 1.0 系统已经具备以下基础能力：

- 多站点采集
- `httpx -> Playwright` 分层抓取
- Cookie 复用
- 原始 HTML / 正文 / 附件落盘
- PostgreSQL 元数据入库
- REST API 与后台雏形
- Cookie 池、告警 webhook、站点/栏目启停、管理后台（Phase 3 已交付）

现阶段的不足不在于"能不能抓到页面"，而在于**抓到的内容如何稳定归集为知识库可用的数据**。

### 1.2 2.0 的业务背景

2.0 的知识库服务对象是清远市政府工作人员，因此数据源分成两类：

1. **本地重点来源**
   - 清远市政府门户
   - 清远下辖区县门户
   - 各部门专属信息公开栏目
   - 这类来源的文章应尽量标记本地 `dept_id`

2. **外部参考来源**
   - 广东省政府门户
   - 中国政府网
   - 其他可采集的外部政务站点
   - 这类来源进入知识库备查，不强制绑定本地 `dept_id`
   - **按栏目级接入，不抓全站**（控制噪声）

### 1.3 2.0 的设计目标

| 维度 | 目标 |
|---|---|
| 归属 | 清远本地部门栏目抓取的文章稳定带上 `dept_id` |
| 结构化 | 保存常见信息公开属性，供 RAG 检索筛选 |
| 简洁 | 不过度设计，不引入无必要的栏目树、匹配规则平台、复杂同步机制 |
| 可维护 | 后台可直接新增采集栏目目标并绑定部门 |
| 可扩展 | 一个部门可配置多条采集目标，覆盖复杂栏目结构 |
| 面向 RAG | 尽量使用文本快照与结构化公开属性，避免大量下游还要翻译的 ID |

### 1.4 非目标

- ❌ 不做向量化 / Embedding / 切片
- ❌ 不做 OA 组织同步；本系统只导入 OA 部门子集
- ❌ 不做复杂的部门匹配规则引擎
- ❌ 不做完整的网站栏目树建模
- ❌ 不做附件抽文本 / OCR
- ❌ 不做实时抓取；允许 T+1
- ❌ 不做外部参考站点的全站抓取（只按栏目）
- ❌ 不做采集层正文去重（交给 RAG 入库层）

---

## 2. 设计原则

### 2.1 部门归属前置到采集目标配置层

2.0 不采用"文章入库时根据 `source_raw` 猜部门"的方案，而采用：

- **采集目标绑定部门**
- **文章继承采集目标的 `dept_id`**

原因：

- 本地政府门户中，一个部门通常拥有专属信息公开栏目
- 栏目归属比页面字段更稳定
- 维护成本更低
- 权责更清晰

### 2.2 一个部门可以有多条采集目标

对于复杂栏目层级，不单独抽象栏目树，而采用：

- 一个部门对应多条 `crawl_target`
- 每条 `crawl_target` 对应网站上的一个实际栏目实体
- 每条 `crawl_target` 绑定同一个 `dept_id`

这样既能覆盖"政策文件 / 通知公告 / 工作动态"等多个栏目，又不需要引入额外树结构。

### 2.3 模板留在 YAML，目标覆盖放 DB

模板配置（列表/详情/分页选择器）继续沿用 1.0 的 `config/sites/*.yaml`：

- git 管控、带注释、好 diff、好 review
- 后台对站点模板**只读展示**
- 后续阶段再做"后台直接改 YAML"（**不在 2.0 首版范围**）

**但** `crawl_target` 级的少量差异覆盖（极个别子栏目选择器不同时）仍允许落库：

- `crawl_target.parser_override_json`
- 生效逻辑：`effective_parser = site_yaml + target_override`

### 2.4 栏目信息与公开属性优先用文本快照

由于本系统主要服务 RAG，下游更适合使用可读文本，因此：

- 栏目路径保留为纯文本
- 公开属性保留为显式字段 + 原始 `metadata_json`
- 除 `dept_id` 外，不强求大量业务 ID

### 2.5 无历史数据包袱，一刀切重建

2.0 处于重构阶段，无需考虑线上历史数据迁移：

- 老表（`sites / columns / articles / attachments / crawl_logs`）直接 drop
- Alembic 起新 baseline revision
- 不做双写、不做灰度

---

## 3. 总体架构

```
                    ┌────────────────────────────────────┐
                    │            管理后台                 │
                    │ 站点管理 · 栏目目标管理 · 部门绑定  │
                    └─────────────────┬──────────────────┘
                                      │
              ┌───────────────────────┼───────────────────────┐
              ↓                       ↓                       ↓
      ┌──────────────┐       ┌──────────────┐        ┌──────────────┐
      │ 配置层        │       │ 采集层        │        │ 存储层        │
      │ Site/Target  │ ───▶  │ Fetch + Parse │ ───▶   │ PG + 文件系统 │
      │ Dept 子集     │       │ Meta 抽取     │        │              │
      └──────────────┘       └──────────────┘        └──────┬───────┘
                                                             │
                                                             ↓
                                                   ┌──────────────────┐
                                                   │  对外输出 / RAG   │
                                                   │ REST / DB / 文件  │
                                                   └──────────────────┘
```

> **架构层级定位**：**CMS 适配器层属于采集层内部的可选实现方式**，仅在已验证的网站群场景
> （当前仅 `gkmlpt` 清远集群）下启用。通用站点不经过适配器，直接用站点级 YAML 模板即可完成
> `Fetch + Parse`。所以所有站点都必须先进 adapter 是误解；核心闭环不依赖适配器层存在。

### 3.1 关键模块

| 模块 | 职责 |
|---|---|
| 站点管理 | 管理门户站点基础信息与默认抓取策略（模板 YAML 只读展示） |
| 采集目标管理 | 维护实际栏目入口 URL，并绑定 `dept_id` / 分类信息 |
| 部门子集管理 | 导入 OA 部门子集，仅作为知识归属引用表 |
| 抓取器 | 按站点策略执行 `httpx -> Playwright` 抓取 |
| 解析器 | 解析列表、正文、附件、公开属性、栏目路径 |
| 入库器 | 保存文章、公开属性、附件、日志 |
| 调度器 | 按 target 分配 cron（hash 错峰 01:00–05:00） |
| 健康度监控 | 基于 `expected_cadence_days` 判定 stale |
| 输出接口 | 面向 RAG 提供增量数据和附件访问 |

---

## 4. 核心业务对象

### 4.1 站点 `crawl_site`

代表一个门户网站，例如：

- `gdqy.gov.cn`
- `yingde.gov.cn`
- `gd.gov.cn`
- `gov.cn`

站点层负责：

- 站点身份
- 默认抓取策略
- 是否遵守 robots
- 关联的 YAML 模板文件路径

### 4.2 采集目标 `crawl_target`

2.0 中唯一的采集配置单元。

它代表网站上的一个实际栏目入口，例如：

- 清远市教育局｜政策文件
- 清远市教育局｜通知公告
- 英德市政务服务和数据管理局｜法定主动公开内容
- 广东省政府｜政策文件

一个 `crawl_target` 决定：

- 从哪个 URL 开始抓
- 这批内容是否属于本地部门
- 属于哪个 `dept_id`
- 默认内容分类是什么
- 使用哪套模板（默认继承 site YAML，必要时用 `parser_override_json`）
- 如何调度（cron 由系统分配）
- 预期更新节奏（`expected_cadence_days`）

### 4.3 本地部门子集 `local_department`

来源于 OA 部门表的一个子集镜像。

特点：

- `dept_id` 与 OA 保持一致
- 数量约 100–200 个
- 手工拷贝 / 一次性导入
- 不做同步
- **`dept_id` 一旦写入即为快照，OA 侧变更不回溯历史文章**

### 4.4 文章 `article`

文章是系统的核心结果对象。

每篇文章需要具备三类信息：

1. **归属信息**
   - `dept_id`（可空，仅本地部门栏目必填）
   - `site_id`
   - `target_id`

2. **分类信息**
   - `channel_name`
   - `channel_path`
   - `content_category`
   - `content_subcategory`

3. **公开属性**
   - 索引号
   - 文号
   - 发布机构
   - 发布日期
   - 成文日期
   - 主题词
   - 公开分类
   - 原始属性 JSON

---

## 5. 数据结构设计

### 5.1 站点表 `crawl_site`

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `site_code` | varchar(50) UNIQUE NOT NULL | 站点编码（对外标识） |
| `site_name` | varchar(200) | 站点名称 |
| `base_url` | varchar(500) | 站点根地址 |
| `site_role` | varchar(50) | `qingyuan_local / county_local / province_ref / nation_ref` |
| `cms_adapter` | varchar(50) null | **【清远专项增强字段】**所属 CMS 适配器 id，如 `gkmlpt`；**仅在站点确认属于某个已验证 CMS 集群时使用，普通站点留空并仅依赖 `yaml_path`** |
| `adapter_params_json` | json null | **【清远专项增强字段】**适配器所需的站点级参数，形如 `{"dept_path":"qyqcfgw","sid":763042}`；具体键名由 adapter yaml 的 `site_template` 约束 |
| `default_fetch_strategy` | varchar(50) | 默认抓取策略，`httpx` / `playwright`；未设时沿用 adapter 的 `default_strategy`（无 adapter 则用系统全局默认） |
| `strategy_override_reason` | varchar(200) null | 覆盖默认策略的原因快照（如 `"城市级 ctct 盾"`），便于回溯 |
| `respect_robots` | boolean | 是否遵守 robots |
| `yaml_path` | varchar(500) null | 站点模板 YAML 路径，如 `config/sites/gdqy.yaml`；**核心闭环下所有站点都走此字段**；仅在走适配器的站点上留空 |
| `enabled` | boolean | 是否启用 |
| `remark` | varchar(500) | 备注 |
| `created_at` | timestamp | 创建时间 |
| `updated_at` | timestamp | 更新时间 |

约束：

- **`cms_adapter` 与 `yaml_path` 二选一**（CHECK：恰好一个非空）
- `cms_adapter` 值必须能在 `config/cms/<value>.yaml` 找到对应适配器文件，启动时校验
- `adapter_params_json` 在走适配器时必填，未设则拒绝启用该站
- **默认路径是 `yaml_path`**：基线开发阶段（阶段 A）所有站点都走 `yaml_path`；`cms_adapter` 字段可存在但均为 null，不阻塞核心闭环上线

> **注**：v2.0 曾规划的 `site_parser` 表已从设计中移除。模板以 YAML 文件承载；
> 不走适配器的站用 `yaml_path`（这是基线的默认路径，覆盖所有通用站点）；
> 仅在已验证的 CMS 集群（当前仅清远 `gkmlpt`）下使用 `cms_adapter + adapter_params_json`
> 作为可选的跨站复用增强。

---

### YAML 职责边界（跨章节铁律，全文一次收死）

**不允许** YAML 同时承担"模板、事实源、运行期状态"这三种职责。为避免事故，明确：

| 路径 | 职责 | 读写 | 管控方式 |
|---|---|---|---|
| `config/cms/*.yaml` | **CMS 适配器静态契约**（selectors、API 模板、detect 指纹、resilience 规则） | **只读** | git 管控，PR review |
| `config/sites/*.yaml` | **站点静态模板 / 参数**（base_url、cms_adapter 指针、adapter_params、订阅栏目列表） | **只读** | git 管控，PR review |
| DB 表（`crawl_site / crawl_target / crawl_log / master_column_registry` 等） | **所有运行期状态**（cookie 池、last_crawled_at、post_count、健康度、dry-run 结果、批次状态） | 读写 | 运行时唯一真相来源 |

**由此派生的硬约束**：

- 运行期状态**一律落 DB，绝不回写 YAML**
- 后台"批量接入向导"（§8.5.2）**只能生成 YAML 草稿供人工 PR review**，不做磁盘直写，不做运行时真相
- Cookie 池、挑战升级决策、classify 同步结果 **只写 DB，不污染 YAML**
- 盾策略的自动升级历史记录在 `crawl_site.strategy_override_reason` + `crawl_log`，**不修改 site.yaml 本身**

### 5.2 站点部门映射表 `site_department`【清远专项增强】

清远 gkmlpt 集群的三层数据模型里，**站点（domain）→ CMS 部门（dept_path）→ 栏目（column_id）**
不是 1:1。一个域名（如 `gdqy.gov.cn`）下挂十几个部门子站，每个部门子站又有若干
gkmlpt 栏目。本表把 CMS 原生的 `dept_path` 和本地 OA 部门（`local_department.dept_id`）
绑到一起，作为 `crawl_target` 的归属父级。

**仅在走 CMS 适配器的站点启用**（即 `crawl_site.cms_adapter IS NOT NULL`）；通用
YAML 模板路径的站点不用这张表，栏目直接挂到 `dept_id`。

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `site_id` | bigint FK → crawl_site.id | 所属站点（域名级） |
| `dept_path` | varchar(100) NOT NULL | CMS 里部门子站的路径片段，如 `qyfgw / qyjyj / qyzjj`；出现在 `{base_url}/{dept_path}/gkmlpt/` |
| `local_dept_id` | bigint FK → local_department.dept_id null | 挂到哪个本地部门；`dept_binding=mapped` 时必填，其余场景允许空 |
| `dept_binding` | varchar(20) NOT NULL default `pending` | 本地部门绑定语义，取值见下表；**默认 `pending` 强制后台在保存 crawl_target 前确认过一次** |
| `dept_display_name` | varchar(200) null | 该部门在 CMS 里的展示名（如 `清远市发改局`），首次探测时从 classify 或站点 meta 抓，供 UI 直接显示 |
| `detect_status` | varchar(20) | `pending / ok / failed`；最近一次命中 `detect.url_signatures` 的结果 |
| `last_probed_at` | timestamp null | 最近一次 `{base_url}/{dept_path}/gkmlpt/` 探测时间 |
| `last_classify_json` | json null | 最近一次拉到的 classify 顶层数组快照，供 UI 栏目订阅弹窗直接渲染；刷新时覆盖 |
| `enabled` | boolean | 是否启用（关掉相当于批量停用该部门下全部 target） |
| `created_at` | timestamp | 创建时间 |
| `updated_at` | timestamp | 更新时间 |

`dept_binding` 取值语义（收死，不要自创第四种）：

| 值 | 场景 | `local_dept_id` | 典型例子 |
|---|---|---|---|
| `pending` | 新建 dept_path 后还没人工确认归属（默认） | null | 探测刚命中但还没人过目 |
| `mapped` | CMS 部门 = OA 里某个具体部门 | **必填** | `qyfgw` → 清远市发改局 (dept_id=37) |
| `city_level` | 市本级/虚拟归属，OA 没有对应实体部门 | null | `xxgk`（市政府信息公开）下的市政府工作报告 |
| `cross_dept` | 跨部门合出，挂到单一部门不合适 | null | 统计公报、年鉴、大事记 |
| `external_ref` | 省/国等外部参考来源，无本地部门概念 | null | `gdfgw`（省发改委）如果被纳进来做参考 |

约束：

- `UNIQUE (site_id, dept_path)` —— 同一站点下 dept_path 唯一
- 新增 target 前 `detect_status` 必须是 `ok`，否则 UI "栏目订阅"按钮不可点（防止配了个探测失败的 dept 却批量挂栏目）
- 新增 target 前 `dept_binding` 必须 **不等于 `pending`** —— 强制后台选一次绑定语义。`mapped` 要挂 `local_dept_id`；另外三种显式表达"就是没本地部门"，UI 会用 badge（市本级 / 跨部门 / 外部参考）渲染而非 ⚠️
- 删除一行 `site_department` 级联软删其下所有 `crawl_target`（`enabled=false`），不物理删，保留历史追溯

与其他表的上下游：

```
crawl_site (域名级, 9 个)
    └── site_department (dept_path 级, 每站 N 个)
            └── crawl_target (column_id 级, 每部门 N 个)
                    └── article / crawl_log
```

### 5.3 采集目标表 `crawl_target`

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `site_id` | bigint FK → crawl_site.id | 所属站点（域名级，冗余但查询高频） |
| `site_department_id` | bigint FK → site_department.id null | 【清远专项增强】所属站点部门；走适配器的站点必填，普通 YAML 站留空 |
| `target_code` | varchar(100) UNIQUE NOT NULL | 目标编码（对外标识，API/日志/告警/后台展示都用它） |
| `target_name` | varchar(200) | 目标名称 |
| `entry_url` | varchar(1000) | 栏目入口 URL |
| `sample_article_url` | varchar(1000) null | 样例文章 URL，保存配置前用于 dry-run 校验 |
| `dept_id` | bigint null | 本地部门 ID；走适配器时由 `site_department.local_dept_id` 推导写入（快照冗余）；普通 YAML 站直接手填；`site_department.dept_binding ∈ {city_level, cross_dept, external_ref}` 或外部参考目标时**允许为空** |
| `parser_override_json` | json null | 目标差异覆盖（极少使用） |
| `channel_name` | varchar(200) | 栏目名称快照 |
| `channel_path` | varchar(1000) | 栏目路径文本，如 `信息公开｜政策文件｜市政府文件` |
| `content_category` | varchar(100) | 一级分类，如 `政策文件` |
| `content_subcategory` | varchar(100) | 二级分类，如 `市政府文件` |
| `schedule_cron` | varchar(100) | 调度表达式（**系统按 hash(target_code) 分配**，人工不填） |
| `expected_cadence_days` | int | 预期更新节奏，单位天，默认 30；驱动 stale 告警 |
| `interval_sec` | int | 同站点请求间隔 |
| `enabled` | boolean | 是否启用 |
| `last_crawled_at` | timestamp null | 最近抓取时间 |
| `last_article_time` | timestamp null | 最近文章发布时间 |
| `created_at` | timestamp | 创建时间 |
| `updated_at` | timestamp | 更新时间 |

说明：

- 走适配器的站点 **`site_department_id` 必填**（CMS 结构事实，dept_path 一定存在）；`dept_id` 是快照冗余，按 `site_department.dept_binding` 的不同派生：
  - `mapped` → `dept_id = site_department.local_dept_id`
  - `city_level / cross_dept / external_ref` → `dept_id = NULL`（栏目合法存在但不挂本地部门）
  - `pending` → 拒绝保存，回到 UI 让人工先定语义
- 普通 YAML 站点 `site_department_id` 留空，`dept_id` 由后台手填（本地部门站必填；外部参考站留空）
- `channel_name / channel_path / content_category / content_subcategory` 是给文章继承使用的文本快照
- **"本地 / 外部"的判定**：走适配器的站点看 `site_department.dept_binding`（`mapped` 为本地；`city_level / cross_dept` 仍算本地但无具体部门；`external_ref` 为外部）；普通 YAML 站看 `dept_id IS NULL` + `crawl_site.site_role`。不单设 `classification_mode` 字段
- **`schedule_cron` 不允许后台手填**，由系统根据 `hash(target_code) % 01:00–05:00 窗口` 自动分配，避免全挤一个点
- 保存 target 时，若提供了 `sample_article_url`，后台必须先触发 `validator.py` dry-run，全绿才允许落库

### 5.4 本地部门子集表 `local_department`

OA 原生字段全大写；本系统落库统一 **snake_case**，类型统一 **bigint**。OA ↔ 本系统字段映射仅在 ETL 导入层保留。

| 字段 | 类型 | 说明 |
|---|---|---|
| `dept_id` | bigint PK | 部门 ID，与 OA 一致；**快照值，不回溯** |
| `dept_name` | varchar(250) | 部门名称 |
| `parent_dept_id` | bigint null | 上级部门 |
| `dept_level` | int null | 部门级别 |
| `dept_code` | varchar(250) null | 部门编码 |
| `short_name` | varchar(50) null | 简称 |
| `full_name` | varchar(500) null | 全称 |
| `state` | int | 状态 |
| `order_id` | int null | 排序号 |
| `updated_at` | timestamp null | 更新时间 |

> **快照语义**：若 OA 换系统或合并部门，本表的 `dept_id` **不追改**，历史文章保留抓取当时的部门归属。新数据按新的 target 绑定走。

### 5.5 文章表 `article`

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `site_id` | bigint FK | 来源站点 |
| `target_id` | bigint FK | 来源采集目标 |
| `dept_id` | bigint null | 本地部门 ID；直接继承自 `crawl_target.dept_id` |
| `native_post_id` | varchar(64) null | CMS 原生稳定 id（如 gkmlpt 的 `articles[*].id`）；找不到原生 id 的站点留空，退回仅靠 `url_hash` 去重 |
| `url` | text | 原文 URL |
| `url_hash` | char(64) NOT NULL | URL 去重键（兜底） |
| `title` | text | 标题 |
| `publish_time` | timestamp null | 发布时间 |
| `source_raw` | varchar(500) null | 页面原始来源/发布机构文本 |
| `channel_name` | varchar(200) null | 栏目名快照 |
| `channel_path` | varchar(1000) null | 栏目路径快照 |
| `content_category` | varchar(100) null | 一级分类 |
| `content_subcategory` | varchar(100) null | 二级分类 |
| `index_no` | varchar(200) null | 索引号 |
| `doc_no` | varchar(200) null | 文号 |
| `publisher` | varchar(500) null | 发布机构 |
| `publish_date` | date null | 发布日期 |
| `effective_date` | date null | 成文日期 |
| `topic_words` | varchar(500) null | 主题词 |
| `open_category` | varchar(200) null | 公开分类 |
| `metadata_json` | json null | 原始公开属性字典 |
| `content_text` | text null | 正文纯文本 |
| `raw_html_path` | text null | 原始 HTML 文件路径 |
| `text_path` | text null | 正文文件路径 |
| `has_attachment` | boolean | 是否有附件 |
| `status` | varchar(20) | `raw / ready / failed` |
| `fetch_strategy` | varchar(50) null | 抓取策略 |
| `fetched_at` | timestamp | 抓取时间 |
| `exported_to_rag_at` | timestamp null | RAG 已消费时间（拉取即标记；ack 回写延后到对接时再议） |
| `created_at` | timestamp | 创建时间 |
| `updated_at` | timestamp | 更新时间 |

索引与约束（**去重规则，一次收死**）：

`native_post_id` 在不同站点有无 CMS 原生稳定 id 取决于上游实现，所以去重规则按下列三条执行，不再写"两套都唯一但又看情况"：

1. **当 `native_post_id` 可得时**（走适配器且 CMS 暴露稳定 id，例如 gkmlpt）：
   - **主去重键：`UNIQUE (site_id, native_post_id)`**
   - 跨 `http/https` 协议切换、CMS 升级改详情 URL 格式都不会断裂
2. **当 `native_post_id` 不可得时**（通用站点 / 普通 YAML 模板路径）：
   - **主去重键：`url_hash`**
3. **`url_hash` 始终保留**，NOT NULL 每篇都算，职责按场景切换：
   - 无 `native_post_id` 站点：作为**主去重键**
   - 有 `native_post_id` 站点：作为**辅助索引与兜底字段**（便于 RAG 侧按 URL 反查、历史回溯）

实现细节：

- `UNIQUE (site_id, native_post_id)` 在 PG 下 `native_post_id` 为空的行自动不参与约束；MySQL 需写函数索引 `(site_id, COALESCE(native_post_id,''))` 加显式哨兵
- `UNIQUE (url_hash)` 作为数据库级兜底约束对所有行施加
- 两个键都存在时 pipeline 以 `(site_id, native_post_id)` 胜出；如果遇到"同一 native_post_id 在多次抓取中 url 不同"的情况（CMS 改 URL 格式），保留 native_post_id 那条、url_hash 更新为新值

> **不保留 `content_simhash`**：采集层不承担正文去重职责。跨栏目转发/多栏目
> 同文的去重由 RAG 入库侧用自己的 hash 策略统一处理。URL 去重键 `url_hash`
> 和 CMS 原生键 `native_post_id` 保留。

### 5.6 附件表 `attachment`

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `article_id` | bigint FK | 所属文章 |
| `file_name` | text | 文件名 |
| `file_ext` | varchar(20) | 扩展名 |
| `size_bytes` | bigint | 文件大小 |
| `file_path` | text | 存储路径 |
| `file_hash` | char(64) | 文件哈希 |
| `downloaded_at` | timestamp | 下载时间 |

### 5.7 抓取日志表 `crawl_log`

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `site_id` | bigint FK | 站点 |
| `target_id` | bigint FK | 采集目标 |
| `article_url` | text null | 详情 URL |
| `strategy` | varchar(50) | `httpx / playwright` |
| `http_status` | int null | HTTP 状态码 |
| `duration_ms` | int null | 耗时 |
| `success` | boolean | 是否成功 |
| `error_msg` | text null | 错误信息 |
| `occurred_at` | timestamp | 发生时间 |

### 5.8 全局栏目目录表 `master_column_registry`【清远专项增强】

只读派生表：由适配器刷列表时持续回写，用于 §7.6 / §8.5.3 的跨站主题检索。
不是事实表 —— 栏目下线仅标 `active=false`，历史 crawl_target/article 不受影响。

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | bigint PK | 主键 |
| `adapter_id` | varchar(50) NOT NULL | 所属 CMS 适配器，如 `gkmlpt` |
| `site_id` | bigint FK → crawl_site.id | 所属站点 |
| `column_id` | varchar(64) NOT NULL | 站内栏目 id（如 `2849`）；类型用 varchar 防 CMS 用字母 id |
| `column_name` | varchar(500) | 栏目中文名（如 `规章文件`） —— 去尾随空格后入库 |
| `column_path` | varchar(1000) null | 栏目层级路径（如 `信息公开｜政策文件｜规章文件`） |
| `admin_level` | varchar(20) | 行政层级：`city / county / township` —— 由 site_role 派生 |
| `topic_tags` | json | 归一主题标签数组（来自 `config/topic_synonyms.yaml`） |
| `post_count` | int | 最近一次探测到的文章数 |
| `last_seen_at` | timestamp | 最近一次在列表 API 观察到该栏目的时间 |
| `subscribed_target_id` | bigint FK null | 若已建 crawl_target，指向它；否则 null |
| `active` | boolean default true | 栏目是否仍在 CMS 中可见（下线即 false，不删行） |
| `created_at` | timestamp | 创建时间 |
| `updated_at` | timestamp | 更新时间 |

索引与约束：

- `UNIQUE (adapter_id, site_id, column_id)` —— 一个栏目同一站点只一行
- `INDEX (topic_tags)` GIN (PG) / 虚列拆表 (MySQL) —— 支持按 tag 跨站检索
- `INDEX (admin_level, subscribed_target_id)` —— 支撑"未订阅的县区级栏目"这类筛选

写入时机：

- 适配器每次成功拉列表第一页，用 `classify.name / post_count` upsert 一行
- `scripts/discover_columns.py` 定期扫站点首页补齐（用于无 JSON API 的 CMS）
- `topic_tags` 由 `config/topic_synonyms.yaml` 在写入时解析，改同义词表后批量重算

---

## 6. 公开属性设计

### 6.1 为什么要保存公开属性

很多政府信息公开页面除了正文，还会有固定属性区块，例如：

- 索引号
- 分类
- 发布机构
- 成文日期
- 名称
- 文号
- 发布日期
- 主题词

这些字段对 RAG 很有价值，常用于：

- 过滤
- 排序
- 约束检索范围
- 引用定位

### 6.2 保存策略

2.0 采用两层保存：

1. **显式字段（冻结 7 个，不再扩张）**
   - `index_no`
   - `doc_no`
   - `publisher`
   - `publish_date`
   - `effective_date`
   - `topic_words`
   - `open_category`

2. **原始属性字典保存到 `metadata_json.raw`**

> **Schema 收紧规则**：政务站公开属性区块存在 15+ 种变体（"体裁"、"效力状态"、"废止日期"、"有效期"…）。除非走正式 schema review，否则**一律进 `metadata_json.raw`**，不允许为个别站点打补丁式新增显式列。

---

## 7. 采集与入库流程

### 7.1 标准流程

```
1. 调度器按 crawl_target.schedule_cron 选择一个 target
2. 读取该 target 所属站点的 YAML 模板
3. 合并 target.parser_override_json（如有）
4. 抓取 target.entry_url 列表页
5. 解析列表与分页
6. 遍历文章详情 URL
7. 抓取详情页
8. 解析：
   - 标题
   - 发布时间
   - 来源文本
   - 正文
   - 附件
   - 栏目文本
   - 公开属性
9. 文章继承 target 的：
   - dept_id
   - channel_name
   - channel_path
   - content_category
   - content_subcategory
10. 保存原始 HTML、正文、附件、文章记录、抓取日志
11. 更新 target.last_crawled_at / last_article_time
12. RAG 侧按增量拉取
```

### 7.2 部门归属规则

2.0 不做复杂部门匹配规则，引入唯一规则：

`article.dept_id = crawl_target.dept_id`

适用原因：

- 本地信息公开栏目通常归属明确
- 一个栏目只对应一个部门
- 一个部门可有多条采集目标

### 7.3 栏目分类规则

文章的以下文本字段默认继承自采集目标：

- `channel_name`
- `channel_path`
- `content_category`
- `content_subcategory`

这样无需额外做栏目匹配，也能给 RAG 提供稳定初步分类。

### 7.4 外部参考站点的范围控制

外部参考站点（省府 / 中国政府网）**严禁全站抓取**：

- 只起与本地栏目同粒度的 `crawl_target`
- 每个 target 对应一个具体栏目入口
- `dept_id` 留空，由 `site_role` 标记来源

### 7.5 CMS 适配器层（跨站复用）

#### 7.5.1 为什么需要这一层

实际运营中遇到的集群型站点（2026-04-23 PoC 实证，**仅清远集群范围内**）：

> 清远市和下辖县区（清城 / 连州 / 清新 / 英德 / …）以及清远市本级各部门（教育局 / 发改委 / …）是 **同一套后端 CMS 集中部署**，前端皮肤可换、域名不同、栏目编号独立，但列表 API、详情 DOM、字段格式一致。清远政务云把它称为 "**政务公开目录平台**"（路径标识 `gkmlpt`），覆盖清远集群的几十个子站。

> ⚠️ **关键边界：这只是清远的一个特例，不是政务站的通用规律。**
> - 广东省内其他地市（广州 / 深圳 / 东莞 / 佛山 / …）很可能用完全不同的 CMS（TRS WCM / 博思 / 用友 / 自研门户 / …）。
> - 跨省更是一家一套。
> - 每进入一个新地市或新省厅，**必须先跑 `scripts/probe_*.py` 摸一遍真实 API 与 DOM**，再决定复用已有适配器还是新起一份 `config/cms/<new_adapter>.yaml`。
> - "一份适配器打天下" 是陷阱；"一个 CMS 一份适配器、适配器内共享 selectors" 才是真路径。

如果仍按 1.0 的思路 "**一站一份 YAML**"，即使只在清远集群内也会出现：

- 接入 30 个县区站 → 复制粘贴 30 份相同 selectors，其中一处改 CMS 升级，要改 30 份
- selectors 对，但每站 `sid`、`dept_path` 不同，容易粘错
- 市级 + 县级盾开关不同（见 7.5.4），策略字段散落在各 YAML 里

2.0 引入 CMS 适配器层，把 **同一 CMS 下跨站不变的解析契约** 抽出来：

```
config/
├── cms/
│   ├── gkmlpt.yaml          # 清远政务云，覆盖清远集群
│   ├── trs_wcm.yaml         # (未来) TRS 内容管理，若某地市确认使用
│   └── <其他 CMS>.yaml      # (未来) 每发现一种新 CMS 起一份
└── sites/
    ├── qingcheng.yaml       # cms_adapter: gkmlpt ; dept_path + sid
    ├── lianzhou.yaml        # 同上
    ├── qingxin.yaml
    ├── gdqy_jyj.yaml        # cms_adapter: gkmlpt ; strategy: playwright
    └── <其他站点>.yaml      # cms_adapter 指向它所属的 CMS 适配器
```

#### 7.5.2 适配器契约结构

`config/cms/<adapter_id>.yaml` 包含以下节（详见 `config/cms/gkmlpt.yaml`）：

| 节 | 职责 |
|---|---|
| `detect` | URL / 响应指纹。用于自动识别"这个新站属不属于本 CMS" |
| `default_strategy` | 默认抓取策略（httpx / playwright），可被站点级覆盖 |
| `request` | UA、Accept、TLS 宽容策略、保留原 scheme 等 |
| `list` | 列表 API 模板 + 分页 + items_path + 字段映射（JSONPath） |
| `detail` | 详情 URL 模板 + selectors + 正文清洗规则 |
| `dedupe` | 主键组合（`site_id + native_post_id`），URL hash 仅辅助 |
| `resilience` | 反爬升级条件、Cookie 池 seed 规则、频控 |
| `site_template` | 站点 YAML 骨架，供"批量接入"向导自动生成 |

关键设计：**同一 CMS 的所有站点共享 selectors 与字段映射；差异项 (`base_url / dept_path / sid / strategy / columns`) 下放到 `sites/*.yaml`**。

#### 7.5.3 站点 YAML 极简化

gkmlpt 集群的三层模型（§5.2）要求 site.yaml 同时表达 **site → dept → column**
三层。县区级站点（一个域名往往只挂一个部门子站，如清城区发改局）仍然简单：

```yaml
site_id: qingcheng_fgw
site_name: 清远市清城区发展和改革局
base_url: http://www.qingcheng.gov.cn
cms_adapter: gkmlpt
adapter_params:
  sid: 763042                # 站点级常量
depts:
  - dept_path: qyqcfgw
    dept_binding: mapped     # mapped / city_level / cross_dept / external_ref / pending
    local_dept_id: 37        # dept_binding=mapped 时必填；其他四种删除该行
    dept_display_name: 清远市清城区发展和改革局
    columns:
      - column_id: "2849"
        name: 规章文件
        category: 法规文件
        schedule: "0 2 * * *"
        enabled: true
```

市级站（如 `gdqy.gov.cn`）一个域名下挂十几个部门子站，yaml 的 `depts[]` 就是一个
长列表；每个 dept 下自己的 columns 独立管理：

```yaml
site_id: gdqy
site_name: 清远市政府门户
base_url: https://www.gdqy.gov.cn
cms_adapter: gkmlpt
strategy: playwright         # 市级盾开，站点级覆写
adapter_params:
  sid: 763042
depts:
  - dept_path: qyfgw
    dept_binding: mapped
    local_dept_id: 37
    columns: [ { column_id: "14001", name: 政策文件, ... } ]
  - dept_path: qyjyj
    dept_binding: mapped
    local_dept_id: 41
    columns: [ ... ]
  - dept_path: xxgk
    dept_binding: city_level     # 市政府信息公开：市本级虚拟归属
    # local_dept_id 省略
    columns: [ { column_id: "90001", name: 市政府工作报告, ... } ]
  - dept_path: tjgb
    dept_binding: cross_dept     # 统计公报：多部门合出
    columns: [ ... ]
```

写法铁律：

- **`dept_binding` 必填**。yaml 里出现 `pending` 会被启动时 lint 拒绝 —— `pending` 只在运行期临时态存在（刚探测命中、人工还没过目），不能落盘成配置。
- **`local_dept_id` 与 `dept_binding=mapped` 严格绑定**：`mapped` 时必填；其他四种填了会报错（防止"既说是市本级又挂了部门"这种自相矛盾配置）。
- 列表 URL / 详情 URL / selectors / 分页 / 字段映射 **全部来自适配器**，yaml 里看不到，也不该看到。
- **DB 与 YAML 的关系**（§5.1 的 YAML 职责边界在这里落地）：yaml 是 git 管控的模板；启动时把 `depts[]` / `columns[]` **同步生成 / 更新 `site_department` / `crawl_target` 两张 DB 表**；运行期状态（`detect_status / last_probed_at / last_classify_json / enabled 切换` 等）**只写 DB，不回写 yaml**。

#### 7.5.4 策略分层（市县盾开关不同的铁律）

2026-04-23 实测关键结论：

| 层级 | 域名举例 | 裸 httpx 访问入口 | 裸 httpx 访问 api/all | 需要 Playwright |
|---|---|---|---|---|
| 县区级 | `qingcheng.gov.cn / lianzhou / qingxin` | 200 | **200 + JSON** | 不需要 |
| 市级 | `gdqy.gov.cn` | **412 + `<meta name="ctct">`** | 412 | **需要，~2s 清挑战** |

同一套 gkmlpt CMS 后端，**反爬盾 (ctct) 按域名粒度独立开关**。所以策略必须是 **站点级决策**，不能固化在适配器：

- 适配器给默认值：`default_strategy: httpx`
- 县区站 yaml 不写 `strategy` → 沿用默认
- 市级站 yaml 显式 `strategy: playwright` → runtime 首访走浏览器
- 额外兜底：`resilience.escalate_to_playwright_on` 在出现 `412` / `<meta name="ctct"` / `请稍候` / `滑块验证` / `云盾` 等指纹时自动升级，并把决策回写 site yaml（避免每次都触发挑战）

市级挑战过关后 Playwright 会自动拿到这批 cookie：
`CT_1f7ba0eb8 / CT_1g9aa1ec2 / CT_1e6tzab00 / CT_16eadf26c / CT_1rqu7ab01 / laravel_session`

Cookie 池按 `(etld+1, cookie_name)` 缓存；TTL 内复用，盾"清一次管一小时"，之后可退回 httpx 节省预算。Seed 规则写在适配器的 `resilience.cookie_pool`：

```yaml
cookie_pool:
  seed_cookies_prefix: ["CT_"]
  seed_cookies_exact: ["laravel_session"]
  ttl_sec: 3600
```

**所以"Cookie 池以 CMS 为单位共享 seed 规则，以域名为单位共享 cookie 实例"** — gdqy 清挑战拿的 cookie 不会泄漏给 qingcheng（域名不同），但"CT\_ 值得缓存"这条规则一次写在适配器里，整个网站群受益。

#### 7.5.5 主键与跨协议去重

列表 API 返回的 `articles[*].id` 是 CMS 级稳定整数（PoC 中 2109918 / 2116964 / 2108053），作为 **`native_post_id`** 入库。主键改为：

```
primary_key = (site_id, native_post_id)
```

URL hash 仍算一份，但仅做辅助索引。这样：

- `http://` 和 `https://` 版本的同一文章不再因 URL 协议漂移重复入库
- CMS 升级改详情 URL 格式（例：`/content/2/{bucket}/` → `/article/`）不会造成历史文章断裂

#### 7.5.6 适配器生命周期

```
1. 发现新集群：人工或半自动跑 scripts/probe_*.py（patchright 录 XHR）
2. 抽契约：把 API 模板 / selectors / 字段 / 盾指纹沉淀成 config/cms/<id>.yaml
3. 首站实证：3 个真实站点跑 verify_*.py，ALL GREEN 才签版本
4. 批量接入：UI 向导按 site_template 生成 N 份 site yaml
5. 运行期自愈：resilience 规则自动升级策略 / 刷新 cookie
6. CMS 升级：改一份适配器 yaml → 整个网站群同步
```

已落地 / 待探明的适配器：

| adapter_id | 覆盖范围 | 状态 |
|---|---|---|
| `gkmlpt` | **仅清远集群**（清远市本级各部门 + 下辖县区政府/部门站） | v1.0.0 已实证落地（县区 httpx / 市级 playwright） |
| _其他地市_ | 广州 / 深圳 / 东莞 / 佛山 / 湛江 / 省厅 / 跨省…… | **未探明**。接入任一新地市前先跑 probe 脚本，证实 CMS 形态后再决定：<br>① 检测命中现有适配器 → 复用；<br>② 不命中 → 新起一份 `config/cms/<id>.yaml` |

> 结论：**适配器数量 ≈ 所见 CMS 种类数**。不要提前画"TRS 常用"之类的饼 —
> 真到某地市时跑 PoC 看 XHR + DOM，才是唯一可信输入。

#### 7.5.7 规范化输出契约（RAG 入库稳定点，不可让步）

**铁律**：不管走哪个适配器、哪种抓取策略（httpx / playwright / Cookie 池 / 未来的任何新招），
落到数据库的 `article` / `attachment` / `crawl_log` 行 **必须严格符合 §5 的 schema**，
字段语义、单位、主键组合完全一致。这是 RAG 导出接口的稳定点。

换言之，适配器的职责边界是：

```
[原始站点响应  ——适配器—→  规范化 Article/Attachment/CrawlLog 对象]
                             ↓
                       （下游统一）
                             ↓
                  pipeline 落库 / RAG 导出 / 运营查询
```

适配器内部可以五花八门（JSON API / HTML 解析 / 浏览器模拟 / 第三方数据源回灌…），
但 **输出必须是同一 Pydantic model**。具体契约（引用 §5）：

- `Article.site_id` / `Article.target_id` / `Article.dept_id`（继承自 crawl_target）
- `Article.native_post_id`：CMS 原生稳定 id（本适配器取 `articles[*].id`；其他 CMS
  适配器须自行找到等价物，**找不到时留 null**，靠 `url_hash` 去重，**不得伪造**）
- `Article.url` / `Article.url_hash`：归一化 URL（见 `url_norm.py`），`url_hash` NOT NULL
- `Article.title` / `Article.publish_time`（UTC）/ `Article.source_raw` / `Article.publisher`
- `Article.content_text` / `Article.raw_html_path` / `Article.text_path`
- `Article.channel_name` / `Article.channel_path` / `Article.content_category` /
  `Article.content_subcategory`（继承自 crawl_target）
- `Article.index_no` / `Article.doc_no` / `Article.publish_date` / `Article.effective_date` /
  `Article.topic_words` / `Article.open_category` / `Article.metadata_json`（公开属性，见 §6）
- `Article.has_attachment` / `Article.status` ∈ `{raw, ready, failed}` — RAG 只读 `ready`
- `Article.fetch_strategy`：实际使用的 tier（httpx / playwright）
- `Attachment`：`file_hash`（sha256）+ `file_path` + `file_name` + `file_ext` + `size_bytes`
- `CrawlLog`：`strategy` + `http_status` + `success` + `duration_ms` + `error_msg`

> 采集层 **不产出 `content_simhash`**（与 §5.4 保持一致）；正文去重是 RAG 侧职责。

**Code review 时强制**：任何新适配器的 PR 必须过 `tests/test_adapter_contract.py` —
针对该适配器的 ≥3 个真实站点样本，断言输出对象通过 Pydantic 校验、必填字段非空、
URL 已归一、`publish_time` 在合理范围、`status` 取值合法、`fetch_strategy` 与
CrawlLog 一致。契约不通过不合并。

**不做兼容层，但契约是共用基线**：清远分支聚焦 gkmlpt，不为其他 CMS 预留 hook；
但 Article 这一层的字段、单位、状态机**从今天就按最终形态落**，将来新加适配器
只是多一个适配器 yaml + 一个解析函数，DB schema 和 RAG 接口不会再动。

### 7.6 全局栏目目录（跨站主题检索）

#### 7.6.1 场景

运营同事往往按 **主题** 而非 **站点** 想问题：

> "规划计划"栏目，清远市所有区县的发改局都要订；  
> "招聘信息"栏目，教育口 + 人社口的每个下辖机构都要订；  
> 某部委突然要求"今年市县的规章文件全部归集"，需要一键搜到所有备选栏目。

1.0 的 crawl_target 是站点级配置，主题视角要在 DB 里跨站 join、去重，体验差。

#### 7.6.2 `master_column_registry`（跨站只读视图）

引入一个 **派生自适配器拉取结果** 的只读视图表：

| 字段 | 含义 |
|---|---|
| `id` | PK |
| `adapter_id` | 如 `gkmlpt` |
| `site_id` | 所属站点 |
| `column_id` | 站内栏目 id（如 `2849`） |
| `column_name` | 栏目中文名（如 `规章文件`） |
| `category` | 行政层级（`市级` / `县区级` / `乡镇级`） |
| `post_count` | 最近一次探测到的文章数 |
| `last_seen_at` | 最近确认栏目存在的时间 |
| `subscribed` | 是否已建 crawl_target |
| `topic_tags` | 归一主题标签（见 7.6.3） |

数据来源：

- gkmlpt 适配器有 `/api/all/` 会回 `classify.name` 与 `post_count`，每次刷新列表就更新一次 registry
- 没有 JSON API 的 CMS，靠 `scripts/discover_columns.py` 定期扫站点首页发现栏目

#### 7.6.3 主题归一

栏目中文名在不同站点会写成 `"规划计划"` / `"规划计划 "`（尾随空格） / `"规划与计划"` / `"发展规划"`，搜索不易。2.0 引入一张小型同义词表：

```yaml
# config/topic_synonyms.yaml
规划计划:
  - 规划计划
  - 规划与计划
  - 发展规划
  - 五年规划
规章文件:
  - 规章文件
  - 规章制度
  - 规范性文件
招聘信息:
  - 招聘公告
  - 招考信息
  - 人事招聘
```

registry 写入时给每行打 `topic_tags`（数组），UI 按 tag 做跨站检索。

#### 7.6.4 UI：全局栏目目录页

后台新增一个页面（详见 §8.5）：

- 顶部筛选：`行政层级`（市/县区/乡镇）× `主题 tag` × `已订阅 / 未订阅`
- 结果表：每行一个 `(site, column)` 组合，列显示站点、栏目名、post_count、最后更新、已订/未订、操作
- 批量勾选 → 一键建 crawl_target（默认 schedule / category 继承自模板，可行前统一改）

这把"给 20 个县区发改局批量接入规划计划栏目"从一小时降到一分钟。

#### 7.6.5 与 crawl_target 的关系

- `master_column_registry`：只读的"栏目地图"，反映 CMS 现实
- `crawl_target`：我们选择订阅并实际抓取的栏目，一行 registry 可能对应 0 或 1 条 target
- 建 target 时从 registry 一键生成，但 registry 行删掉不影响历史 target（target 是事实入库记录）

---

## 8. 后台管理规划

### 8.1 站点管理

功能：

- 新增 / 编辑站点基础信息（编码、名称、base_url、role、respect_robots）
- 关联 `yaml_path`（**只读**展示解析后的 list/detail/pagination 选择器）
- 启停站点
- 模板修改仍走 YAML 文件 + git + reload（2.0 首版不支持后台直接编辑 YAML）

### 8.2 采集目标管理（2.0 核心页面）

每条 `crawl_target` 可配置：

- 所属站点
- `target_code`（对外唯一标识）
- `target_name`
- `entry_url`
- `sample_article_url`
- 是否属于本地部门（等价于"是否填 `dept_id`"）
- 若属于本地部门，则绑定 `dept_id`
- `channel_name / channel_path / content_category / content_subcategory`
- 调度节奏选项（每日 / 每周 / 每月）+ `expected_cadence_days`，系统自动算 cron
- `interval_sec`
- `parser_override_json`（选填，默认空）
- 启停状态

**保存前必须 dry-run**：

- 若填了 `sample_article_url`，后台在保存前调用 `validator.py` 对 entry_url（列表）+ sample（详情）做一次干跑
- 选择器命中、标题非空、content_text ≥ 50 字符 → 绿灯才入库
- 未填样例 URL 时给出警告但允许保存

### 8.3 本地部门管理

功能：

- 一次性导入 OA 部门子集（CSV / SQL dump）
- 查看部门树
- 搜索部门
- **部门详情页**：展示该部门名下所有 `crawl_target` + 每条的 `last_crawled_at / last_article_time / 健康度`
  - 这是运营发现"某部门栏目挂了"的主入口
  - 按 `expected_cadence_days` 标红 stale target

### 8.4 数据与运行监控

功能：

- 查看文章列表
- 按站点 / 采集目标 / 部门 / 分类 / 栏目路径筛选
- 查看抓取日志
- 手动重抓
- 查看哪些 target 按其 `expected_cadence_days` 判定为 stale
- 查看哪些文章未导出到 RAG

### 8.5 CMS 适配器与全局栏目目录（网站群入口）

对应 §7.5 / §7.6 的两页：

**8.5.1 CMS 适配器列表页**

- 表格：`adapter_id / adapter_name / 版本 / 覆盖站点数 / 已订阅栏目数 / 最近验证时间 / 状态`
- 详情抽屉：展开显示 detect 指纹、字段映射、resilience 规则；YAML 以只读代码块呈现
- 操作：`跑探测脚本` / `从该适配器批量接入站点`（唤起 8.5.2 向导）

**8.5.2 从适配器批量接入向导**

日常单站接入走 §8.1 手动路径（新增站点 → 站点部门映射页挂 dept_path → 栏目订阅）。
本向导仅用于**批量扫描一个域名下的全部 dept_path**（市级门户如 `gdqy.gov.cn` 下
有 40+ 部门子站），或一次性接入一批县区站。四步：

1. **粘站点**：粘 base_url 列表（每行一条）。系统跑 `detect.url_signatures`
   / `api_json_must_have` 校验，命中某适配器则标记；未命中的站需人工决定走通用
   YAML 还是新起一份适配器。
2. **扫 dept_path**：对命中适配器的站，遍历 `{base_url}/{dept_path}/gkmlpt/`
   探测所有可用的 dept_path（市级门户常有 40+），每个 dept 拉 classify 填入
   `dept_display_name` + 预估 column 数。
3. **定绑定**（关键新步骤）：每行 dept_path 必须选 `dept_binding`。UI 给出四个
   选项 + 第五个临时 `pending`：
   - `mapped` → 下拉选 `local_dept_id`（带搜索）
   - `city_level / cross_dept / external_ref` → 直接 badge 标记，不挂部门
   - `pending` → 本向导**拒绝继续**；`pending` 是运行期态不能出 yaml
   
   UI 提供"按名称智能建议"：如 dept_display_name 是 "清远市发改局" → 自动建议
   `mapped` + local_dept_id=37；"市政府信息公开" 这类关键词 → 建议 `city_level`。
4. **预览落盘**：生成 N 份 site.yaml（每站一份，`depts[]` 展开），在 diff 视图
   确认后写入 `config/sites/` 并触发 reload，DB 里同步生成 `site_department` 行。
   栏目订阅不在本向导内完成 —— 落盘后运营去"站点部门映射 → 栏目订阅"逐部门勾选。

**8.5.3 全局栏目目录页**

- 顶部筛选区：`行政层级` × `主题 tag` × `已订阅 / 未订阅` × `站点搜索` × `适配器 id`
- 结果表（100% 宽度，非抽屉）：
  - 列：`站点 / 栏目名 / topic_tags / post_count / last_seen_at / 已订阅标记 / 操作`
  - 行内操作：`预览文章`（弹出框拉 classify 最新 1 页）/ `一键订阅`
- 批量选中 → 底部出现工具条：`批量订阅（统一 schedule + category）` / `批量退订`
- 左侧栏：主题 tag 云；点击 tag 快速过滤

**8.5.4 与既有 §8 页面的关系**

- §8.1 站点管理：新增"批量接入"按钮跳到 8.5.2
- §8.2 采集目标管理：行内多一列"来源"，若 target 由 registry 批量生成则记录其 `master_column_registry.id`，方便 CMS 升级时回溯
- §8.3 本地部门管理：不受影响，部门仍独立维护

---

## 9. 健康度与告警

### 9.1 Stale 判定规则

不用固定 "24h 无新增" 阈值（会误报周更/月更栏目），改为基于节奏：

```
target stale  ⟺  now() - target.last_article_time  >  3 × target.expected_cadence_days
```

`expected_cadence_days` 默认值建议：

| 栏目类型 | 默认节奏 |
|---|---|
| 政策文件 / 通知公告 | 7 |
| 工作动态 / 新闻 | 3 |
| 部门简介 / 规划 | 90 |
| 外部参考（省府/国务院） | 3 |

### 9.2 告警通道

复用 1.0 已有的 Feishu / 企微 webhook（`govcrawler/alerting/`）：

- R1：成功率 < 阈值
- R2：challenge block 率 > 阈值
- R3：target stale（按 9.1 规则）

---

## 10. RAG 使用模式

### 10.1 RAG 最关心的字段

RAG 最常用的不是各种内部 ID，而是：

- `title`
- `dept_id`
- `channel_path`
- `content_category`
- `content_subcategory`
- `publisher`
- `index_no`
- `doc_no`
- `publish_date`
- `effective_date`
- `topic_words`
- `content_text`

### 10.2 推荐检索方式

1. **按本地部门过滤**
   - `dept_id = ?`

2. **按栏目路径过滤**
   - `channel_path LIKE '%政策文件%'`

3. **按公开属性过滤**
   - `publisher = ?`
   - `topic_words LIKE ?`
   - `doc_no = ?`

4. **按外部参考来源过滤**
   - `dept_id IS NULL`
   - `crawl_site.site_role IN ('province_ref', 'nation_ref')`

### 10.3 导出状态

- 采集层只写 `exported_to_rag_at`（RAG 拉取时标记）
- **RAG 侧的 embedding ack 回写机制不在 2.0 范围**，对接 RAG 时再决定是否加 `exported_state` 状态机

### 10.4 为什么优先文本字段

因为下游主要是 RAG，而不是传统业务系统，所以：

- 文本字段更直观
- 无需下游再查字典表翻译
- 历史数据能保留抓取当时的快照语义

---

## 11. 与 1.0 的差异

### 11.1 1.0 特点

1.0 偏"通用采集器"：

- 配置键是 `(site_id, column_id)` 字符串复合
- 文章结构相对简单（无 dept、无公开属性、category 单列）
- 无部门归属建模
- 正文 simhash 在采集层做

### 11.2 2.0 升级重点

1. `crawl_target` 成为唯一配置单元，内置 `dept_id`
2. `article` 保存栏目文本快照 + 7 个公开属性显式字段 + `metadata_json`
3. 引入 `local_department` 子集表
4. 后台支持栏目绑定部门、保存前 dry-run
5. 调度 cron 系统分配，不再人工填
6. 基于 `expected_cadence_days` 的健康度模型
7. 删除采集层正文 simhash（职责下沉 RAG 入库）

### 11.3 迁移策略

**无历史数据，一刀切**：

- 老表 drop
- Alembic 新 baseline
- 无双写、无灰度
- 现有 `config/sites/*.yaml` 保留，在 2.0 中依然是模板唯一来源

---

## 12. 实施步骤

### 阶段 A：Schema 重建 + Target 打通（1 周）

内容：

- Alembic 新 baseline：drop 老 5 表 + create 新 6 表（`crawl_site / crawl_target / local_department / article / attachment / crawl_log`）
- 重写 `govcrawler/models.py`
- 重写 pipeline 内的 site/column 查询路径为 target 查询路径
- 导入 `local_department` 初始子集（100–200 行 CSV）
- 人工创建 5–10 个 target 跑通（gdqy、yingde 现有栏目迁过来）

交付标准：

- 可在后台配置 target 并绑定 `dept_id`
- 新抓文章带 `dept_id / channel_path / 公开属性`
- 所有老测试重写通过

### 阶段 B：后台升级（1 周）

内容：

- 站点管理页（只读模板展示）
- 采集目标管理页 + 保存前 dry-run
- 本地部门管理页（部门详情展示名下 target 健康度）
- 监控页按 `expected_cadence_days` 标 stale

交付标准：

- 非开发人员可通过后台新增栏目 target
- 可绑定部门与分类文本
- 可按部门与栏目路径查看文章
- Stale 告警接入现有 webhook

### 阶段 C：运行细化（持续）

内容：

- 完善分页抓取
- 优化增量停止逻辑
- 丰富公开属性抽取（扩 `metadata_json.raw` 覆盖面，**不扩显式列**）
- 接入剩余站点

---

## 13. 成功标准

2.0 完成后，应达到以下效果：

1. 清远本地部门栏目抓取的文章稳定带有 `dept_id`
2. 同一部门可配置多个栏目目标
3. 外部参考栏目可不绑定 `dept_id`，但正常进入知识库
4. 文章保留栏目路径文本快照，便于 RAG 初步分类
5. 文章保留 7 个公开属性显式字段 + 原始属性 JSON
6. 后台可直接新增栏目目标、绑定部门，并在保存前 dry-run 校验选择器
7. 调度自动错峰，无需人工填 cron
8. Stale 告警按栏目节奏自适应判定，不误报周更/月更栏目
9. 不引入无必要的树结构、匹配规则平台、OA 同步机制、采集层正文去重

---

## 14. 一句话总结

2.0 的本质不是把爬虫做得更复杂，而是把抓到的数据变成**更适合知识库使用的归集结果**：

- 部门归属前置到栏目配置
- 栏目分类保留文本快照
- 公开属性结构化保存（显式 7 列冻结 + metadata_json 兜底）
- 模板留 YAML，配置上后台，调度系统分，健康度看节奏
- 重点服务清远本地知识库，同时兼容外部参考来源（仅栏目级）
