# 非遗新闻采集 - 详细实现计划(todoDetail) > 配套 todo.md(权威规范)。本文档为逐步实施明细,实施中若与 todo.md 冲突以 todo.md 为准,并反向同步。 ## 前置结论(实测确认) - 列表接口:`total=16458, pages=9`(pageSize=2000),按 publishTime 降序返回;字段与 todo.md 一致。 - 列表接口会**偶发 500**(本机实测 p7/p9),`search` 必须重试 2 次。 - 详情页 `https://huacheng.gz-cmc.com/pages/{yyyy}/{MM}/{dd}/{id}.html`,用列表接口返回的 `url` 直取,不要拼 `0000/00/00/`(会 302)。 - `div.container` 直接子 div(实测顺序):header-guide-box / article-title / not-exist-media-leader / article-video-fixed / article-content / read-like-number / article-copyright / media-list / article-launch-app-box / article-comment / related-content / related-channel / article-operate。只保留 3 个,其余移除。 - 部分详情页是分享页(无 `.article-content`),解析为全 NULL,不报错。 ## 记者/编辑解析(实测变体清单) 匹配子句(严格按 spec,顺序无关,正则全局匹配): | 子句 | 前缀 | 目标列 | |---|---|---| | `文、图/…记者:X` | `文、图` | text + image | | `文/…记者:X` | `文` | text | | `图/…记者:X` | `图` | image | | `通讯员:X` | - | correspondents | | `新花城编辑:X` / `编辑:X` | - | editor | 实现要点: 1. 名称段捕获,遇到下列任一标记即停止(lookahead):`文、图/`、`文/`、`图/`、`视频/`、`通讯员`、`(广州日报)?新花城编辑`、`编辑:`、`实习生`、行尾。 2. 名称段按 `、` `,` `,` 空白 拆分;分词后**丢弃含 `:` 或 `/` 的 token**(实测 `实习生:邹文婧`、`图片由受访者提供`、`视频/…记者:…` 均由此清除)。 3. 人名去除尾部 `(…)` 括注(实测 `王理润(除署名外)` → `王理润`)。 4. editor 取 editor 子句捕获段的**第一个合法 token**(其后跟 `浏览量:` 等杂项会被 token 过滤吞掉)。 5. `视频/…记者`、`图、视频/…提供` 等非 spec 写法一律忽略。 实测样例(须全部正确): - `文、图/广州日报新花城记者:曾焕阳 通讯员:潘永光、陈诺广州日报新花城编辑:石忠情` → 文字[曾焕阳] 图片[曾焕阳] 通讯[潘永光,陈诺] 编辑[石忠情] - `文/广州日报新花城记者:孙嘉晖 通讯员:袁智斌 图/广州日报新花城记者:杨泽彬 通讯员:黄晋文 视频/广州日报新花城记者:杨泽彬 广州日报新花城编辑:杜娟` → 文字[孙嘉晖] 图片[杨泽彬] 通讯[袁智斌,黄晋文] 编辑[杜娟] - `文、图/广州日报新花城记者:轩慧 广州日报新花城编辑:童丹` → 文字[轩慧] 图片[轩慧] 编辑[童丹] - `文/广州日报新花城记者:陈家源、庄小龙、张忠安、倪明、刘幸、曾繁莹、李波 通讯员:潮宣、王理润(除署名外) 广州日报新花城编辑:赵小满` → 文字7人 通讯[潮宣,王理润] 编辑[赵小满] - `文/广州日报新花城记者:廖靖文 实习生:邹文婧 图片由受访者提供 广州日报新花城编辑:时秀芙` → 文字[廖靖文] 编辑[时秀芙] - `文/广州日报新花城记者:黄子宁 通讯员:徐韬 图、视频/北滘宣办提供 广州日报新花城编辑:何波` → 文字[黄子宁] 通讯[徐韬] 编辑[何波] ## 实施步骤 ### Step 0 - 依赖 pom.xml 增加(版本号照 todo.md): - `com.baomidou:mybatis-plus:3.5.9` - `com.zaxxer:HikariCP:6.3.3` - `org.postgresql:postgresql:42.7.4` - `org.jsoup:jsoup:1.16.1` - `com.fasterxml.jackson.core:jackson-databind:2.18.3` - `org.junit.jupiter:junit-jupiter:5.14.0`(test scope) - surefire `maven-surefire-plugin:3.5.6`(含 junit-platform 集成) 验证:`mvn -q compile` ### Step 1 - 配置 - 根目录新建 `collection.properties`(gitignore 已含,不提交),内容照 todo.md,含数据库口令。 - `config/Config`: - 读取优先级:工作目录文件 → classpath 资源。 - 类型化 getter:`dbUrl/dbUser/dbPassword/dbSchema`、`keywords()`(逗号拆 List)、`publishStart()/publishEnd()`(`yyyy-MM-dd HH:mm:ss` → LocalDateTime)、`pageSize()/sleepMs()/timeoutMs()/referer()`。 - 不得打印/记录密码。 ### Step 2 - DTO 与实体 - `dto/ChannelAllContentsResponse`:`status`(Integer) `msg`(String) `total`(Integer) `pages`(Integer) `list`(List),jackson 直配 camelCase。 - `dto/NewsItem`:`id`(String) `contentType`(Integer) `contentId`(String) `siteId`(String) `data`(NewsItemData)。 - `dto/NewsItemData`:`id/title/url/channelId/channelName/userName`(String)、`publishTime`(String,手动解析)。 - `entity/News`:`@TableName("news")`,字段与表列一一对应(snake_case 由 `mapUnderscoreToCamelCase` 映射),`@TableId(type=IdType.INPUT)`(id 来自接口)。 ### Step 3 - 数据库层 - `db/Db`: 1. 裸 `DriverManager` 连接(url 不带 currentSchema)执行 `CREATE SCHEMA IF NOT EXISTS new_collection` 与 `CREATE TABLE IF NOT EXISTS new_collection.news (...)`(DDL 见 todo.md,id varchar(64) PK)。 2. 建 HikariCP 池:url 带 `?currentSchema=new_collection`,timeout 从配置取。 3. 暴露 `getDataSource()` 与 `init(Config)` 静态引导。 - `db/Mybatis`: - `MybatisPlusSqlSessionFactoryBuilder` 构建 `SqlSessionFactory`;`Configuration.setMapUnderscoreToCamelCase(true)`;注册 `NewsMapper`。 - 提供 `getMapper()`、`commit()`、`rollback()`、`close()` 便捷方法。 ### Step 4 - 客户端 - `client/GzCmcClient`(单例,复用 `HttpClient`): - `search(String keyword, int pageNum, int pageSize)`:URL 编码 keyword,GET + 头 `referer`;失败/非 200/响应 `status!=200` 时重试 2 次(间隔 sleep);jackson 反序列化返回 `ChannelAllContentsResponse`。 - `fetchDetail(String url)`:GET + `referer`,返回 body 字符串;重试 2 次。 ### Step 5 - 清洗与解析 - `cleaner/DetailCleaner`(静态方法): - `clean(String html)`: 1. `doc.select("meta, script").remove()` 2. `doc.select("#hidden-box").remove()` 3. `div.container` 直接子 div 仅保留 `.article-title`/`.not-exist-media-leader`/`.article-content`,其余 `remove()` 4. 返回 `doc.html()` - `articleContentText(Document)`:取 `div.article-content` 纯文本(无则返回空串)。 - `parsePersons(String text)`:按上文「解析变体清单」实现,返回 `PersonInfo(editor, textReporters, imageReporters, correspondents)`(record,List 无则空)。 ### Step 6 - 服务层 - `service/ListCollectService.run()`: 1. 对每个 keyword:`pageNum=1` 循环。 2. `search` 取本页;list 为空或 `pageNum > pages` 退出。 3. 每条 → News 实体;`publishTime` 用 `yyyy-MM-dd HH:mm:ss` 解析;**窗口过滤** `[publishStart, publishEnd]` 含端点;`content=null`。 4. 本页 id 集合 `selectBatchIds` 查出已存在 id → 只 insert 缺失行(逐条 insert,简单可靠)。 5. 打印每页/每关键词统计。 - `service/DetailCollectService.run()`: 1. `QueryWrapper` 查 `content IS NULL` 按 `publish_time ASC`。 2. 逐条:`fetchDetail(url)` → `clean` → `articleContentText` → `parsePersons` → `updateById`(content/editor/textReporters/imageReporters/correspondents/content_fetched_at=now)。 3. 每条后 sleep `detail.sleep.ms`(300);重试失败仍 NULL 留待下轮。 4. 打印进度(每 100 条)与失败数。 ### Step 7 - 测试入口 - `ListCollectTest.collectList()`:init Config+Db+Mybatis+GzCmcClient → `ListCollectService.run()` → 打印插入统计。 - `DetailCollectTest.collectDetail()`:同上初始化 → `DetailCollectService.run()`。 ### Step 8 - 验证 1. `mvn -q compile` 2. `mvn test -Dtest=ListCollectTest` 3. psql:`PGPASSWORD=MIMA2004 psql -h 127.0.0.1 -U yangyi -d yangyi -c "select count(*), min(publish_time), max(publish_time) from new_collection.news"` 4. `mvn test -Dtest=DetailCollectTest`(约 50~60 分钟,300ms 间隔) 5. 抽查 `content` 清洗结果与 text_reporters/image_reporters/correspondents/editor。 6. 重跑两阶段,确认 id 不重复(幂等)。 ## 预估 - 阶段一:9 页 × 2000,秒级到分钟级。 - 阶段二:窗口内约 1 万条 × 300ms ≈ 50~60 分钟。