todoDetail.md 8.3 KB

非遗新闻采集 - 详细实现计划(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/dbSchemakeywords()(逗号拆 List)、publishStart()/publishEnd()yyyy-MM-dd HH:mm:ss → LocalDateTime)、pageSize()/sleepMs()/timeoutMs()/referer()
    • 不得打印/记录密码。
  • Step 2 - DTO 与实体

    • dto/ChannelAllContentsResponsestatus(Integer) msg(String) total(Integer) pages(Integer) list(List),jackson 直配 camelCase。
    • dto/NewsItemid(String) contentType(Integer) contentId(String) siteId(String) data(NewsItemData)。
    • dto/NewsItemDataid/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_collectionCREATE 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 构建 SqlSessionFactoryConfiguration.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)
        • doc.select("meta, script").remove()
        • doc.select("#hidden-box").remove()
        • div.container 直接子 div 仅保留 .article-title/.not-exist-media-leader/.article-content,其余 remove()
        • 返回 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 实体;publishTimeyyyy-MM-dd HH:mm:ss 解析;窗口过滤 [publishStart, publishEnd] 含端点;content=null
          4. 本页 id 集合 selectBatchIds 查出已存在 id → 只 insert 缺失行(逐条 insert,简单可靠)。
          5. 打印每页/每关键词统计。
        • service/DetailCollectService.run()
          1. QueryWrappercontent IS NULLpublish_time ASC
          2. 逐条:fetchDetail(url)cleanarticleContentTextparsePersonsupdateById(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 分钟。