# 非遗新闻采集(newCollection) 针对 [广州日报新花城](https://www.gz-cmc.com/) 的非遗相关新闻采集、清洗、存储与产出工具集。 全流程分三阶段: 1. **列表采集**:按频道 × 关键词全量分页拉取列表,过滤发布窗口后去重入库。 2. **详情采集与清洗**:补抓详情页,jsoup 清洗正文,解析文字/图片记者、通讯员、编辑并回写。 3. **分析产出**:视频统计、二次筛选、目标表格(CSV)+ PDF + 正文视频打包交付。 前两个阶段以 JUnit 测试作为启动入口,**可重复运行且幂等**。 ## 功能特性 - **频道化列表采集**:配置的频道 × 关键词做双层循环(组合数 O(频道数×关键词数)),每个组合独立全量分页采集;到达服务端检索上限(offset≥10000)时优雅停止该组合,不中断整体。 - **发布窗口过滤**:仅保留 `[publish.start, publish.end]` 窗口内的记录。 - **去重入库**:以列表接口 `data.id` 为主键,插入前 `selectBatchIds` 校验,重复运行不产生重复行。 - **详情补全 + HTML 清洗**:`content IS NULL` 记录组成待补队列,逐条抓详情页并按 spec 清洗;单条失败不中断,留待下轮重补。 - **记者/编辑解析**:从正文纯文本按子句正则提取文字记者、图片记者、通讯员与编辑,支持同句多人、`、`/`,`/`,`/空格 分隔、`(…)`括注剥离、`实习生` 等杂项过滤。 - **PDF 生成**:openhtmltopdf 渲染清洗后 HTML,思源宋体注册到页面字体族名下保证中文渲染;自动修复来源页的重复属性与 ` ` 实体,规避严格 XML 解析器报错。 - **产出工具**:视频包含统计、二次筛选命中(标题 1 次 / 正文 2 次关键词)、目标表格 + PDF + 正文视频逐条下载打包。 ## 技术栈 | 组件 | 版本 | 说明 | |---|---|---| | JDK | 25 | Maven 编译/运行目标 | | Maven | — | surefire 3.5.6 | | JDK `HttpClient` | 内置 | GET + `referer`,失败重试 | | jsoup | 1.16.1 | HTML 解析与清洗 | | jackson-databind | 2.18.3 | 列表接口 JSON 反序列化 | | mybatis-plus | 3.5.9 | standalone 模式(无 Spring) | | HikariCP | 6.3.3 | 数据库连接池 | | PostgreSQL JDBC | 42.7.4 | 驱动 | | openhtmltopdf | 1.0.10 | HTML → PDF 渲染 | | logback-classic | 1.5.18 | 日志输出 | | JUnit Jupiter | 5.14.0 | 阶段入口与工具测试 | ## 目录结构 ``` ├── pom.xml ├── sql/init.sql # 数据库初始化(重跑会清空 news 表) ├── todo.md / todoDetail.md # 实现规划与详细计划(权威规范) ├── src/main/resources/ │ ├── collection.properties # 运行配置(含 DB 口令,已被 .gitignore 忽略) │ └── logback.xml # 日志:控制台 + 滚动文件 logs/app.log └── src/main/java/space/anyi/ ├── config/Config.java # 加载配置,类型化 getter ├── dto/ # 列表接口响应模型 ├── entity/News.java # news 表实体 ├── mapper/NewsMapper.java # MyBatis-Plus Mapper ├── db/Db.java # 建 schema/表 + HikariCP 连接池(单例) ├── db/Mybatis.java # standalone SqlSessionFactory + 会话封装 ├── client/GzCmcClient.java # HTTP 客户端:search / fetchDetail ├── cleaner/DetailCleaner.java # 清洗 / PDF / 记者编辑解析(静态方法) └── service/ ├── ListCollectService.java # 阶段一:列表采集 + 去重 + 入库 └── DetailCollectService.java # 阶段二:补详情 + 清洗 + 解析 + 更新 ``` ## 快速开始 ### 1. 环境准备 - JDK 25、Maven、本地 PostgreSQL(默认 `localhost:5432`)。 - 建库建 schema(应用启动也会自动执行 `CREATE SCHEMA/TABLE IF NOT EXISTS`): ```bash PGPASSWORD= psql -h localhost -U -d yangyi -c "CREATE SCHEMA IF NOT EXISTS new_collection;" ``` 或用 `sql/init.sql` 初始化(注意:会 `DROP TABLE` 重建)。 ### 2. 配置 `collection.properties` 复制仓库模板到 `src/main/resources/collection.properties` 并填入真实口令: ```properties db.url=jdbc:postgresql://localhost:5432/yangyi?currentSchema=new_collection db.user= db.password= db.schema=new_collection channel.ids=<频道id1>,<频道id2> keywords=非遗,非物质文化遗产,遗产保护,传承 publish.start=2024-01-01 00:00:00 publish.end=2025-12-31 23:59:59 list.page.size=2000 detail.sleep.ms=300 client.timeout.ms=20000 referer=https://www.gz-cmc.com/ ``` 配置项说明: | 键 | 含义 | 默认/示例 | |---|---|---| | `db.*` | JDBC 地址 / 用户 / 口令 / schema | 见上 | | `channel.ids` | 采集频道 id,逗号分隔(取自 `compare/channel` 表) | 多个 | | `keywords` | 采集关键词,逗号分隔 | 非遗… | | `publish.start/end` | 发布窗口(含端点),格式 `yyyy-MM-dd HH:mm:ss` | — | | `list.page.size` | 列表分页大小 | 2000 | | `detail.sleep.ms` | 详情请求间隔(限速),毫秒 | 300 | | `client.timeout.ms` | HTTP 超时,毫秒 | 20000 | | `referer` | 请求头 `referer`(接口必带) | `https://www.gz-cmc.com/` | ### 3. 编译验证 ```bash mvn -q compile ``` ### 4. 运行两阶段采集 ```bash # 阶段一:列表采集(频道 × 关键词全量,去重入库) mvn test -Dtest=ListCollectTest # 阶段二:详情采集 + 清洗 + 记者编辑解析(content IS NULL 队列) mvn test -Dtest=DetailCollectTest ``` 两阶段均可重复运行:阶段一只插缺失 id,阶段二只处理 `content IS NULL`。 ### 5. 数据核对 ```bash PGPASSWORD= psql -h localhost -U -d yangyi \ -c "select count(*), min(publish_time), max(publish_time) from new_collection.news" ``` ## 数据库结构(schema `new_collection`) `news` 表: | 列 | 类型 | 说明 | |---|---|---| | `id` | varchar(64) PK | 列表接口 `data.id` | | `title` | text | 标题(`data.title`,经 HTML 标签去清洗) | | `url` | text | 新闻地址 | | `publish_time` | timestamp | 发布时间(列表接口) | | `channel_id` / `channel_name` | varchar | 所属频道 | | `editor` | varchar(255) | 编辑;阶段一取 `data.userName`,阶段二详情页解析到则覆盖 | | `text_reporters` | text | 文字记者(`,` 分隔) | | `image_reporters` | text | 图片记者(`,` 分隔) | | `correspondents` | text | 通讯员(`,` 分隔) | | `content` | text | 清洗后的详情 HTML;NULL 表示待补详情 | | `content_fetched_at` | timestamp | 详情采集时间 | | `created_at` | timestamp | 入库时间(默认 now()) | ## 采集流程细节 ### 阶段一:列表采集(`ListCollectService`) 1. 对 `channel.ids` × `keywords` 每个组合独立分页调用 `getChannelAllContents`(带 `referer`),直至 `pageNum > pages`。 2. 每条映射为 `News`;时间格式 `yyyy-MM-dd HH:mm:ss`,解析失败或不在窗口内则跳过。 3. 本页 id 批量查已存在 → 仅插入缺失记录。 4. 此时 `content`/记者字段均为 NULL,留给阶段二。 ### 阶段二:详情采集 + 清洗(`DetailCollectService`) 1. 查 `content IS NULL` 记录,按 `publish_time` 升序逐条处理。 2. `fetchDetail(url)` 抓详情页 → `DetailCleaner.clean()` 清洗 → `parsePersons()` 解析 → `updateById` 回写 `content/editor/记者各列/content_fetched_at`。 3. 清洗规则(严格按 spec): - 移除 `meta`、`script` 与 `#hidden-box`; - `div.container` 直接子 div 仅保留 `.article-title`、`.not-exist-media-leader`、`.article-content`(及 `.article-description`),并移除保留区内 `video`; - 页面无 `div.container`(如分享页)时回退保留 `div.article-detail` / `div#articleContent`,解析结果可能全 NULL,不报错。 4. 单条失败仅计数,失败记录保留空 `content` 供下轮重补;每 100 条打印进度。 ### 记者/编辑解析规则(`DetailCleaner.parsePersons`) 对 `.article-content` 纯文本按子句全局正则匹配: - `文、图/…记者:X` → X 同时进文字记者 + 图片记者 - `文/…记者:X` → 文字记者;`图/…记者:X` → 图片记者 - `通讯员[::]?X` → 通讯员 - `(广州日报)?新花城编辑:X` / `编辑:X` → editor(取第一个合法人名) 名称段在遇到 `文、图/` 、`文/`、`图/`、`视频/`、`通讯员`、`编辑`、`实习生` 或行尾时终止捕获;按 `、`/`,`/`,`/空格 拆分后丢弃含 `:`或`/` 的 token(`实习生:X`、`图片由受访者提供` 等),剥离人名尾部 `(…)` 括注。 > 注意:`userName`(列表接口)与 `editor`(详情页解析)是两个独立字段,阶段二解析到编辑时才覆盖,勿混用。 ## 产出工具(`MyListTest`) | 测试方法 | 作用 | |---|---| | `cleanHtmlTest` | 单个样例 HTML 的清洗结果输出 | | `html2pdfTest` | 样例 HTML → `output/` 下 PDF | | `html2plaintextTest` | 样例 HTML 纯文本输出 | | `test()` | 取最近 100 条 `content` 非空新闻批量生成 PDF(`output/`) | | `titleCleanTest` | 清洗库中标题残留的 HTML 标签并回写 | | `videoCountTest` | 按窗口统计新闻总量与包含 `