# gkmlpt — 清远政务云"政务公开目录平台"适配器
#
# ⚠️ 适用范围（重要）：
#   本适配器 **只对清远市 + 其下辖县区** 的政务公开子站有效（含市本级各部门
#   站、县区政府站、县区部门站）。这是清远政务云的一种 CMS 部署，不代表
#   其他地市的规律。
#   • 广东省内其他地市可能是完全不同的 CMS（TRS WCM / 博思 / 用友 / 自研门户 / …）
#   • 跨省更是一家一套，要新接一个地市/省厅，必须先跑 scripts/probe_*.py 摸
#     一遍真实 API 与 DOM，才能决定：
#       (a) 纳入本适配器（检测规则 detect 命中即可复用），或
#       (b) 新起一份 config/cms/<new_adapter>.yaml
#   • "一份适配器打天下" 是陷阱；"一个 CMS 一份适配器、适配器内共享 selectors"
#     才是真路径。
#
# Empirically verified 2026-04-23 across 3 清远县区级站点（清城发改 / 连州发改
# / 清新发改）+ 1 清远市级站点（gdqy 教育局）。前者裸 httpx 直通，后者需
# Playwright 清 ctct 挑战。See data/probe/verify_httpx.json /
# data/probe/verify_city_v2.json.
#
# Key facts locked by PoC:
#   • 列表 API 为纯 JSON，无反爬盾：
#         GET {base_url}/{dept_path}/gkmlpt/api/all/{column_id}?page={page}&sid={sid}
#     三站全部 200，httpx 裸连可读，无需 Playwright / Cookie 池。
#   • 响应 shape:
#         { "classify": {"id","name","post_count",...},
#           "articles": [ {"id","title","url","date","first_publish_time",
#                          "publisher","document_number","identifier",
#                          "attachment":[...],"classify_main","site",...}, ... ] }
#     items_path = $.articles[*]
#   • 详情 URL 形态 = {base_url}/{dept_path}/gkmlpt/content/2/{bucket}/post_{id}.html
#         bucket = floor(id / 1000)   — 三站 100% 命中
#   • 详情 DOM (三站一致):
#         title:   div.content h1.title::text
#         date:    div.content div.date-row::text  → "发布日期：YYYY-MM-DD  浏览次数：..."
#         body:    div.article-content
#     附件以 API 的 attachment[] 为准（比 DOM 扫 <a> 可靠），并回退 DOM 扫描兜底。
#   • sid 每站不同（763042/763156/763068），为站点级常量，记录在 site yaml。
#     字段在每篇 article 里也以 `site` 出现，可做 cross-check。
#   • native_post_id = articles[*].id，跨协议 (http/https) 稳定主键。
#
# 使用方式：site-level yaml 只需声明 base_url + dept_path + sid + 订阅的 column_id
# 列表；实际抓取走 runtime 组装，不再逐站定义 selectors。

adapter_id: gkmlpt
adapter_name: 清远政务公开目录平台（仅适用于清远市 + 下辖县区）
scope: qingyuan_cluster_only   # 明示范围；接入其他地市前需新跑 PoC
version: "1.0.0"
verified_at: "2026-04-23"
verified_sites:
  - qingcheng_fgw
  - lianzhou_fgw
  - qingxin_fgw

# -----------------------------------------------------------------------------
# 探测：给一个 base_url + dept_path 就能判定"是不是 gkmlpt"。
# -----------------------------------------------------------------------------
detect:
  url_signatures:
    - "/gkmlpt/index"        # 入口列表页 SPA
    - "/gkmlpt/api/all/"     # 列表 API
    - "/gkmlpt/content/"     # 详情页
  response_signatures:
    # 详情页必带这些 DOM 特征
    detail_css_must_hit:
      - "div.content h1.title"
      - "div.content div.date-row"
      - "div.article-content"
    # 列表 API 必返回这些顶层键
    api_json_must_have:
      - "classify"
      - "articles"

# -----------------------------------------------------------------------------
# 抓取策略层级（铁律，实证 2026-04-23）:
#
#   集群事实：市级 + 县区级共用同一套 gkmlpt 后端，前端 SPA 也同一套，
#   但 **ctct 反爬盾按域名粒度独立开关**：
#     • 县区级域 (qingcheng/lianzhou/qingxin/yingde…) → 盾关 → 裸 httpx 直通
#     • 市级本级域 (gdqy.gov.cn…) → 盾开 → 裸 httpx 返 412 + ctct 挑战页
#       首次访问需 Playwright 跑 JS 挑战，拿 CT_* + laravel_session cookie，
#       之后 Cookie 池 TTL 内可退回 httpx
#
#   所以策略 **不在适配器层固化**，而是：
#     1) adapter 给一个 default_strategy: httpx（大多数县区站可用）
#     2) site.yaml 可覆盖 strategy: playwright（市级、遇盾站）
#     3) resilience.escalate_to_playwright_on 作自动升级兜底 —
#        出现 412 / ctct 标记就从 httpx 升到 playwright 并回写 site.yaml
# -----------------------------------------------------------------------------
default_strategy: httpx   # 县区级默认；市级在 site.yaml 里覆写为 playwright
request:
  headers:
    User-Agent: >-
      Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
      AppleWebKit/537.36 (KHTML, like Gecko)
      Chrome/121.0.0.0 Safari/537.36
    Accept: "application/json, text/plain, */*"
    Accept-Language: "zh-CN,zh;q=0.9"
  # 部分子域的 HTTPS 证书 hostname mismatch（yingde 同系），
  # 下载附件时沿用原 scheme，不要强转 https。
  preserve_scheme: true
  # 很多 gov 站 HTTPS 证书不严格（SAN 漏 www 子域），允许 verify=False
  tls_verify: false

# -----------------------------------------------------------------------------
# 列表抓取：参数化 API URL
# -----------------------------------------------------------------------------
list:
  # 变量：{base_url} {dept_path} {column_id} {page} {sid}
  url_template: "{base_url}/{dept_path}/gkmlpt/api/all/{column_id}?page={page}&sid={sid}"
  method: GET
  response_type: json
  pagination:
    type: page_param       # page=1,2,3...
    param: page
    start: 1
    # classify.post_count / len(articles) 可推末页；runtime 在 articles 为空时停。
    stop_on_empty_items: true
    # 安全阀：单次刷新最多翻多少页（防 edge case 死循环）
    hard_max_pages: 50

  items_path: "$.articles[*]"
  classify_path: "$.classify"   # 供 column metadata 回写（name/post_count/update_time）

  # 字段映射：从 item(JSON) 提到 crawl_item 规范字段
  item_fields:
    native_post_id: "$.id"                       # int, stable primary key
    title:          "$.title"
    url:            "$.url"                      # 已是绝对 URL
    publish_time:   "$.first_publish_time"       # unix seconds, 优先用"首次发布"
    publish_time_fallback: "$.date"              # 个别老记录 first_publish_time=null
    publisher:      "$.publisher"                # 来源部门，直接入 article.source
    document_number: "$.document_number"         # 文号（常为空）
    identifier:     "$.identifier"               # 统一编号（信息公开索引号）
    abstract:       "$.abstract"
    attachment_list: "$.attachment"              # list[{name,url,size?...}]
    site_sid_echo:  "$.site"                     # 应等于 site.sid，做一致性校验

  # URL 规整：articles[*].url 可能漏 scheme 或相对路径；runtime 用 base_url 归一。
  url_normalize:
    force_absolute_with: "{base_url}"

# -----------------------------------------------------------------------------
# 详情抓取：DOM 解析
# -----------------------------------------------------------------------------
detail:
  url_template: "{base_url}/{dept_path}/gkmlpt/content/2/{bucket}/post_{native_post_id}.html"
  url_template_vars:
    bucket: "{native_post_id // 1000}"

  selectors:
    title: "div.content h1.title::text"
    # date-row 文本形如 "发布日期：2026-02-24  浏览次数：21" — runtime 用 regex
    date_row: "div.content div.date-row::text"
    date_regex: '发布日期[:：]\s*(\d{4}-\d{2}-\d{2})'
    content: "div.article-content"
    # 兜底：DOM 里抓附件，跟 API attachment[] 取并集去重
    attachment_css: >-
      div.article-content a[href$='.pdf'],
      div.article-content a[href$='.doc'],
      div.article-content a[href$='.docx'],
      div.article-content a[href$='.xls'],
      div.article-content a[href$='.xlsx'],
      div.article-content a[href$='.zip'],
      div.article-content a[href$='.rar']

  # 来源/发布部门：API 的 publisher 比 DOM 更干净，runtime 直接沿用 list 阶段的值。
  # 仅当 list 阶段 publisher 为空时才尝试从 DOM 拿。
  source_from_api: "$.publisher"

  # 正文清洗：去掉底部分享/统计等噪声块（暂无发现，留 hooks）
  content_cleaners:
    remove_css: []

# -----------------------------------------------------------------------------
# 去重 / 主键
# -----------------------------------------------------------------------------
dedupe:
  # 跨 http/https 稳定：用 native_post_id + site_id（不用 URL hash，URL 协议不稳）
  primary_key: ["site_id", "native_post_id"]
  # url_hash 仍然算（Plan 03 consumer），但仅做辅助索引
  compute_url_hash: true

# -----------------------------------------------------------------------------
# 健壮性 / 反爬预案（市级实证 2026-04-23）
#
# gdqy.gov.cn 实测特征：
#   • 裸 httpx → 412 Precondition Failed，body 含 <meta name="ctct"> + "请稍候…"
#   • Playwright 打开 ~2s 即清挑战（无需人工滑块），自动拿到一批 cookie：
#       CT_1f7ba0eb8 / CT_1g9aa1ec2 / CT_1e6tzab00 / CT_16eadf26c / CT_1rqu7ab01
#       laravel_session
#     前缀 CT_ 是 ctct 盾颁发的 session，laravel_session 是后端 session。
#   • Cookie 池以 (etld+1, CT_*) 为 key 缓存；TTL 内复用 → 再次走 httpx 即可。
# -----------------------------------------------------------------------------
resilience:
  escalate_to_playwright_on:
    - http_status_in: [403, 412, 429, 503]
    - response_body_contains:
        - '<meta name="ctct"'     # gdqy 盾挑战页的指纹
        - "请稍候"                 # 挑战页中文标题
        - "滑块验证"
        - "请完成安全验证"
        - "云盾"
  cookie_pool:
    seed_cookies_prefix: ["CT_"]   # 这些名字前缀的 cookie 跨请求复用
    seed_cookies_exact: ["laravel_session"]
    ttl_sec: 3600                  # 保守 1h；失效再触发升级
  rate_limit:
    per_host_concurrency: 1
    min_interval_sec: 2

# -----------------------------------------------------------------------------
# site.yaml 模板片段（供 UI "批量接入" 向导自动生成）
# -----------------------------------------------------------------------------
site_template: |
  site_id: {{site_id}}
  site_name: {{site_name}}
  base_url: {{base_url}}
  cms_adapter: gkmlpt
  # strategy 是站点级决策（盾按域名粒度独立开关）:
  #   - 县区级默认不填，沿用 adapter default_strategy: httpx
  #   - 市级本级域（如 gdqy.gov.cn）显式写 playwright，runtime 首访走浏览器
  #     拿 CT_* + laravel_session cookie，之后在 TTL 内自动退回 httpx
  strategy: {{strategy|default("")}}   # "" | httpx | playwright
  adapter_params:
    dept_path: {{dept_path}}     # e.g. qyqcfgw / qylzfgw / qyqxfgw / qyjyj
    sid: {{sid}}                 # e.g. 763042 / 763156 / 763068
  columns:
    # 每项只需 column_id + 栏目别名；名字与 post_count 运行时从 classify 回写
    - column_id: "{{column_id}}"
      name: "{{column_name}}"
      category: "{{category}}"
      schedule: "0 2 * * *"
      enabled: true
