소스 검색

feat:频道化采集、PDF/视频产出工具链与清洗加固

- client: 列表接口改用 channelId 并支持按频道采集
- service: 频道×关键词双层循环采集
- cleaner: 修复 openhtmltopdf 重复属性解析、div.container 缺失回退、接入 slf4j
- test: 新增 PDF 生成/标题清洗/视频统计/二次筛选/结果整理用例
- 配置脱敏: collection.properties 替换为占位符
- 新增 channel 表、logback 配置与实现文档
yangyi 5 일 전
부모
커밋
3744a70919

+ 2 - 1
.gitignore

@@ -38,4 +38,5 @@ build/
 .vscode/
 
 ### Mac OS ###
-.DS_Store
+.DS_Store
+logs

+ 18 - 13
pom.xml

@@ -48,6 +48,11 @@
             <version>5.14.0</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <version>1.5.18</version>
+        </dependency>
 
         <dependency>
             <!-- ALWAYS required, usually included transitively. -->
@@ -84,20 +89,20 @@
             <version>${openhtml.version}</version>
         </dependency>
 
-        <dependency>
-            <!-- Optional, leave out if you do not need SVG support. -->
-            <groupId>com.openhtmltopdf</groupId>
-            <artifactId>openhtmltopdf-svg-support</artifactId>
-            <version>${openhtml.version}</version>
-        </dependency>
+        <!--<dependency>-->
+        <!--    &lt;!&ndash; Optional, leave out if you do not need SVG support. &ndash;&gt;-->
+        <!--    <groupId>com.openhtmltopdf</groupId>-->
+        <!--    <artifactId>openhtmltopdf-svg-support</artifactId>-->
+        <!--    <version>${openhtml.version}</version>-->
+        <!--</dependency>-->
 
-        <dependency>
-            <!-- Optional, leave out if you do not need MathML support. -->
-            <!-- Introduced in RC-13. -->
-            <groupId>com.openhtmltopdf</groupId>
-            <artifactId>openhtmltopdf-mathml-support</artifactId>
-            <version>${openhtml.version}</version>
-        </dependency>
+        <!--<dependency>-->
+        <!--    &lt;!&ndash; Optional, leave out if you do not need MathML support. &ndash;&gt;-->
+        <!--    &lt;!&ndash; Introduced in RC-13. &ndash;&gt;-->
+        <!--    <groupId>com.openhtmltopdf</groupId>-->
+        <!--    <artifactId>openhtmltopdf-mathml-support</artifactId>-->
+        <!--    <version>${openhtml.version}</version>-->
+        <!--</dependency>-->
     </dependencies>
 
     <build>

+ 10 - 0
sql/init.sql

@@ -23,3 +23,13 @@ CREATE TABLE new_collection.news (
 );
 
 COMMENT ON TABLE new_collection.news IS '非遗新闻采集结果';
+
+DROP TABLE IF EXISTS new_collection.channel;
+
+CREATE TABLE new_collection.channel (
+    id BIGSERIAL PRIMARY KEY ,
+    channel_id   varchar(64) UNIQUE ,
+    channel_name varchar(255)
+);
+
+COMMENT ON TABLE new_collection.channel IS '新闻频道';

+ 50 - 9
src/main/java/space/anyi/cleaner/DetailCleaner.java

@@ -2,20 +2,23 @@ package space.anyi.cleaner;
 
 import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder;
 import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
+import com.openhtmltopdf.slf4j.Slf4jLogger;
+import com.openhtmltopdf.util.XRLog;
 import org.jsoup.Jsoup;
+import org.jsoup.nodes.Attribute;
+import org.jsoup.nodes.Attributes;
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 import org.jsoup.select.Elements;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.File;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -29,6 +32,11 @@ import java.util.regex.Pattern;
  * 解析:从清洗后正文纯文本按子句正则提取文字/图片记者、通讯员与编辑。</p>
  */
 public class DetailCleaner {
+    static {
+        XRLog.setLoggerImpl(new Slf4jLogger());
+    }
+
+    private final static Logger log = LoggerFactory.getLogger(DetailCleaner.class);
 
     /** 思源宋体(常规/粗体)文件路径,用于 PDF 中文字形渲染 */
     private static final String SERIF_REGULAR = "/usr/share/fonts/yangyi/SourceHanSerifCN-Regular.ttf";
@@ -80,11 +88,17 @@ public class DetailCleaner {
         Element head = result.head();
         head.insertChildren(0, style);
 
-        Element container = doc.select("div.container").first();
-        Elements keep = container.children().select("div.article-title, div.not-exist-media-leader, div.article-content");
-        keep.select("video").remove();
-        result.body().appendChildren(keep);
-
+        Elements containers = doc.select("div.container");
+        if (!containers.isEmpty()) {
+            Element container = containers.first();
+            Elements keep = container.children().select("div.article-title, div.not-exist-media-leader, div.article-content, div.article-description");
+            keep.select("video").remove();
+            result.body().appendChildren(keep);
+        }else {
+            log.warn("元素div.container不存在,document:{}",doc.toString());
+            Elements elements = doc.select("div.article-detail, div#articleContent");
+            result.body().appendChildren(elements);
+        }
         return result.html();
     }
 
@@ -121,6 +135,9 @@ public class DetailCleaner {
                 builder.useFont(bold, family, 700, BaseRendererBuilder.FontStyle.NORMAL, true);
             }
             doc.outputSettings().syntax(Document.OutputSettings.Syntax.xml);
+            // 部分来源页面存在重复属性(如 <link rel=..>.rel=..>),XML 解析器会拒绝,
+            // 需在序列化前去重,仅保留同名属性的第一次出现
+            deduplicateAttributes(doc);
             // jsoup XML 语法会把 &nbsp; 原样输出,而 openhtmltopdf 的 XML 解析器不认该实体,
             // 需还原为 Unicode 不间断空格
             builder.withHtmlContent(doc.html().replace("&nbsp;", "\u00A0"), "https://www.gz-cmc.com/");
@@ -148,6 +165,30 @@ public class DetailCleaner {
         return content == null ? "" : content.text();
     }
 
+    /**
+     * 去除文档中所有元素上的重复属性(同一属性名仅保留第一次出现的值)。
+     *
+     * <p>部分来源页面的标记含重复属性(如 <code>&lt;link rel="stylesheet" ... rel="stylesheet"&gt;</code>),
+     * jsoup 会原样保留,而 openhtmltopdf 的严格 XML 解析器拒绝重复属性,需在序列化前清理。</p>
+     *
+     * @param doc 待清理的文档
+     */
+    private static void deduplicateAttributes(Document doc) {
+        for (Element el : doc.getAllElements()) {
+            Attributes attrs = el.attributes();
+            Set<String> seen = new HashSet<>();
+            List<Attribute> dupes = new ArrayList<>();
+            for (Attribute attr : attrs) {
+                if (!seen.add(attr.getKey())) {
+                    dupes.add(attr);
+                }
+            }
+            for (Attribute dup : dupes) {
+                attrs.remove(dup.getKey());
+            }
+        }
+    }
+
     /**
      * 从正文纯文本解析记者/通讯员/编辑。
      *

+ 8 - 6
src/main/java/space/anyi/client/GzCmcClient.java

@@ -21,8 +21,8 @@ import java.time.Duration;
  */
 public class GzCmcClient {
 
-    /** 列表接口地址,{kw}/{n}/{s} 占位符分别为关键词、页码、分页大小 */
-    private static final String LIST_API = "https://www.gz-cmc.com/contentapi/api/content/getChannelAllContents?siteId=5e88c884e2ed4e7a9a8d5225c299f707&keyword={kw}&channelCode=shouye&pageNum={n}&pageSize={s}";
+    /** 列表接口地址,{cid}/{kw}/{n}/{s} 占位符分别为频道 id、关键词、页码、分页大小 */
+    private static final String LIST_API = "https://www.gz-cmc.com/contentapi/api/content/getChannelAllContents?siteId=5e88c884e2ed4e7a9a8d5225c299f707&keyword={kw}&channelId={cid}&pageNum={n}&pageSize={s}";
     /** 单次请求失败后的最大重试次数 */
     private static final int RETRY = 5;
 
@@ -51,15 +51,17 @@ public class GzCmcClient {
     /**
      * 分页搜索列表。
      *
-     * @param keyword  关键词
-     * @param pageNum  页码(从 1 开始)
-     * @param pageSize 分页大小
+     * @param keyword   关键词
+     * @param channelId 频道 id
+     * @param pageNum   页码(从 1 开始)
+     * @param pageSize  分页大小
      * @return 列表接口响应(status/msg/total/pages/list)
      * @throws IOException          重试后仍失败或响应异常
      * @throws InterruptedException 线程被中断
      */
-    public ChannelAllContentsResponse search(String keyword, int pageNum, int pageSize) throws IOException, InterruptedException {
+    public ChannelAllContentsResponse search(String keyword, String channelId, int pageNum, int pageSize) throws IOException, InterruptedException {
         String url = LIST_API
+                .replace("{cid}", channelId)
                 .replace("{kw}", URLEncoder.encode(keyword, StandardCharsets.UTF_8))
                 .replace("{n}", String.valueOf(pageNum))
                 .replace("{s}", String.valueOf(pageSize));

+ 27 - 0
src/main/java/space/anyi/config/Config.java

@@ -60,6 +60,21 @@ public class Config {
         return v.trim();
     }
 
+    /**
+     * 读取可选配置项,缺失或空白时返回默认值。
+     *
+     * @param key          配置项键名
+     * @param defaultValue 缺失时的默认值
+     * @return 去除首尾空白后的配置值,缺失时返回默认值
+     */
+    private String get(String key, String defaultValue) {
+        String v = props.getProperty(key);
+        if (v == null || v.isBlank()) {
+            return defaultValue;
+        }
+        return v.trim();
+    }
+
     /** JDBC 连接 URL */
     public String dbUrl() {
         return get("db.url");
@@ -92,6 +107,18 @@ public class Config {
                 .toList();
     }
 
+    /**
+     * 频道 id 列表(配置中逗号分隔)。
+     *
+     * @return 去空格、去空串后的频道 id 集合
+     */
+    public List<String> channelIds() {
+        return Arrays.stream(get("channel.ids").split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .toList();
+    }
+
     /** 发布时间过滤窗口起点(含) */
     public LocalDateTime publishStart() {
         return LocalDateTime.parse(get("publish.start"), TIME_FORMATTER);

+ 40 - 34
src/main/java/space/anyi/service/ListCollectService.java

@@ -41,48 +41,54 @@ public class ListCollectService {
     /**
      * 执行列表采集主流程。
      *
-     * <p>逐关键词分页请求;某页重试后仍失败(通常是服务端 offset≥10000 的检索上限)
-     * 时优雅停止该关键词采集,不中断整体运行。完成后打印各关键词与合计新增数。</p>
+     * <p>外层遍历配置的全部 channel、内层遍历全部关键词(双层循环,组合数为
+     * O(频道数 × 关键词数) = O(n²)),每个频道×关键词组合独立分页采集。某页重试后
+     * 仍失败(通常是服务端 offset≥10000 的检索上限)时优雅停止该组合的采集,
+     * 不中断整体运行。完成后打印各组合与合计新增数。</p>
      *
      * @throws Exception 网络或序列化等未预期异常
      */
     public void run() throws Exception {
         int totalInserted = 0;
-        int totalSkipped = 0;
-        for (String keyword : config.keywords()) {
-            int pageNum = 1;
-            int keywordInserted = 0;
-            while (true) {
-                ChannelAllContentsResponse resp;
-                try {
-                    resp = client.search(keyword, pageNum, config.pageSize());
-                } catch (Exception e) {
-                    System.err.printf("[列表] 关键词=%s pageNum=%d 请求失败(重试后),疑似达到服务端检索上限,停止本关键词采集: %s%n",
-                            keyword, pageNum, e.getMessage());
-                    break;
-                }
-                List<NewsItem> items = resp.getList();
-                if (items == null || items.isEmpty()) {
-                    break;
-                }
-                List<News> batch = new ArrayList<>();
-                for (NewsItem item : items) {
-                    News news = toNews(item);
-                    if (news != null) {
-                        batch.add(news);
+        for (String channelId : config.channelIds()) {
+            int channelInserted = 0;
+            for (String keyword : config.keywords()) {
+                int pageNum = 1;
+                int keywordInserted = 0;
+                while (true) {
+                    ChannelAllContentsResponse resp;
+                    try {
+                        resp = client.search(keyword, channelId, pageNum, config.pageSize());
+                    } catch (Exception e) {
+                        System.err.printf("[列表] 频道=%s 关键词=%s pageNum=%d 请求失败(重试后),疑似达到服务端检索上限,停止本组合采集: %s%n",
+                                channelId, keyword, pageNum, e.getMessage());
+                        break;
                     }
+                    List<NewsItem> items = resp.getList();
+                    if (items == null || items.isEmpty()) {
+                        break;
+                    }
+                    List<News> batch = new ArrayList<>();
+                    for (NewsItem item : items) {
+                        News news = toNews(item);
+                        if (news != null) {
+                            batch.add(news);
+                        }
+                    }
+                    keywordInserted += insertMissing(batch);
+                    int pages = resp.getPages() == null ? 1 : resp.getPages();
+                    System.out.printf("[列表] 频道=%s 关键词=%s pageNum=%d/%d 命中窗口=%d%n",
+                            channelId, keyword, pageNum, pages, batch.size());
+                    if (pageNum >= pages) {
+                        break;
+                    }
+                    pageNum++;
                 }
-                keywordInserted += insertMissing(batch);
-                int pages = resp.getPages() == null ? 1 : resp.getPages();
-                System.out.printf("[列表] 关键词=%s pageNum=%d/%d 命中窗口=%d%n",
-                        keyword, pageNum, pages, batch.size());
-                if (pageNum >= pages) {
-                    break;
-                }
-                pageNum++;
+                channelInserted += keywordInserted;
+                System.out.printf("[列表] 频道=%s 关键词=%s 新增入库=%d%n", channelId, keyword, keywordInserted);
             }
-            totalInserted += keywordInserted;
-            System.out.printf("[列表] 关键词=%s 新增入库=%d%n", keyword, keywordInserted);
+            totalInserted += channelInserted;
+            System.out.printf("[列表] 频道=%s 新增入库=%d%n", channelId, channelInserted);
         }
         System.out.printf("[列表] 全部完成, 新增入库合计=%d%n", totalInserted);
     }

+ 12 - 0
src/main/resources/collection.properties

@@ -0,0 +1,12 @@
+db.url=jdbc:postgresql://localhost:5432/yangyi?currentSchema=new_collection
+db.user=username
+db.password=password
+db.schema=new_collection
+channel.ids=7bcc861ab2c44a31abe32650dc19fe43,e1e51f8a598d42fb8acc7c9fc2ee756a,a5b9a6f2bd6949cf9b9f3b9f7b903068,e05640fd4eae41bd8530a3d6422f459f,ff3929c9decd4856839d839e42aeae05,903d342af9af43a59cf7cd9d5342be0b,49849754cc8a47fb99454b5abedc79d3
+keywords=非遗,非物质文化遗产,遗产保护,传承,保护项目,文化政策,文化多样性,文化传统
+publish.start=2024-01-01 00:00:00
+publish.end=2024-10-01 23:59:59
+list.page.size=100
+detail.sleep.ms=100
+client.timeout.ms=20000
+referer=https://www.gz-cmc.com/

+ 37 - 0
src/main/resources/logback.xml

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+
+    <!-- 控制台输出 -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- 滚动文件输出:按天滚动,保留最近 30 天 -->
+    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>logs/app.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <maxHistory>30</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!-- 应用命名空间,INFO 起输出业务日志 -->
+    <logger name="space.anyi" level="INFO"/>
+    <!-- 收敛第三方噪音日志 -->
+    <logger name="org.apache.ibatis" level="WARN"/>
+    <logger name="com.zaxxer.hikari" level="WARN"/>
+    <logger name="com.openhtmltopdf" level="WARN"/>
+
+    <root level="INFO">
+        <appender-ref ref="CONSOLE"/>
+        <appender-ref ref="FILE"/>
+    </root>
+
+</configuration>

+ 563 - 7
src/test/java/space/anyi/MyListTest.java

@@ -1,10 +1,13 @@
 package space.anyi;
 
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import org.jsoup.nodes.Element;
 import org.jsoup.select.Elements;
 import org.junit.jupiter.api.Test;
 import org.jsoup.Jsoup;
 import org.jsoup.nodes.Document;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import space.anyi.cleaner.DetailCleaner;
 import space.anyi.client.GzCmcClient;
 import space.anyi.config.Config;
@@ -16,9 +19,25 @@ import space.anyi.dto.NewsItemData;
 import space.anyi.entity.News;
 import space.anyi.mapper.NewsMapper;
 
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 /**
  * 20 条数据的完整流程测试。
@@ -27,6 +46,7 @@ import java.util.List;
  * 记者/编辑解析 -> 更新,用于快速验证全流程与可重复运行。</p>
  */
 public class MyListTest {
+    private final static Logger log = LoggerFactory.getLogger(MyListTest.class);
 
     /**
      * 执行 20 条的完整采集流程。
@@ -41,7 +61,7 @@ public class MyListTest {
         NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
         try {
             // 阶段一: 列表采集 20 条并去重入库
-            ChannelAllContentsResponse resp = client.search(config.keywords().get(0), 1, 20);
+            ChannelAllContentsResponse resp = client.search(config.keywords().get(0), config.channelIds().get(0), 1, 20);
             List<News> newsList = new ArrayList<>();
             for (NewsItem item : resp.getList()) {
                 NewsItemData data = item.getData();
@@ -50,7 +70,7 @@ public class MyListTest {
                 }
                 News news = new News();
                 news.setId(data.getId());
-                news.setTitle(data.getTitle());
+                news.setTitle(DetailCleaner.titleClean(data.getTitle()));
                 news.setUrl(data.getUrl());
                 news.setPublishTime(LocalDateTime.parse(data.getPublishTime(), Config.TIME_FORMATTER));
                 news.setChannelId(data.getChannelId());
@@ -65,7 +85,7 @@ public class MyListTest {
                     inserted++;
                 }
             }
-            System.out.printf("[20条流程] 列表采集=%d 新增入库=%d%n", newsList.size(), inserted);
+            log.info("[20条流程] 列表采集={} 新增入库={}", newsList.size(), inserted);
 
             // 阶段二: 逐条抓详情 -> 清洗 -> 解析 -> 更新
             int ok = 0;
@@ -86,14 +106,14 @@ public class MyListTest {
                     update.setContentFetchedAt(LocalDateTime.now());
                     mapper.updateById(update);
                     ok++;
-                    System.out.printf("[20条流程] %s 编辑=%s 文字记者=%s 图片记者=%s 通讯员=%s%n",
+                    log.info("[20条流程] {} 编辑={} 文字记者={} 图片记者={} 通讯员={}",
                             news.getId(), person.editor(), person.textReporters(), person.imageReporters(), person.correspondents());
                 } catch (Exception e) {
-                    System.err.printf("[20条流程] 失败 id=%s url=%s err=%s%n", news.getId(), news.getUrl(), e.getMessage());
+                    log.error("[20条流程] 失败 id={} url={} err={}", news.getId(), news.getUrl(), e.getMessage());
                 }
                 Thread.sleep(config.sleepMs());
             }
-            System.out.printf("[20条流程] 完成: 成功=%d 失败=%d%n", ok, newsList.size() - ok);
+            log.info("[20条流程] 完成: 成功={} 失败={}", ok, newsList.size() - ok);
         } finally {
             mybatis.close();
         }
@@ -436,7 +456,543 @@ public class MyListTest {
         keep.select("video").remove();
         result.body().appendChildren(keep);
 
-        System.out.println(result.html());
+        log.info("{}", result.html());
 
     }
+
+    @Test
+    public void html2pdfTest() throws Exception {
+        String html = """
+                <!doctype html>
+                <html lang="en" style="font-size: 18px;">
+                 <head>
+                  <link href="https://oss.gz-cmc.com/news-static/huacheng/2026-06-03/css/86-6b9ab229.css" rel="stylesheet">
+                  <link href="https://oss.gz-cmc.com/news-static/huacheng/2026-06-03/css/textDetail-43f87c2a.css" rel="stylesheet">
+                 </head>
+                 <body>
+                  <div class="article-title" id="article-title">
+                   畅通版权交易渠道,漫博会推动国产IP出海与海外IP引进落地
+                  </div>
+                  <div class="not-exist-media-leader">
+                   <div class="article-source-time">
+                    <div class="article-time">
+                     2026-07-24 15:43:26
+                    </div>
+                    <div class="article-source">
+                     广州日报新花城
+                    </div>
+                   </div>
+                  </div>
+                  <div class="article-content" id="article-content">
+                   <p style="text-align: justify;">7月24日,记者从第十六届中国国际影视动漫版权保护和贸易博览会新闻发布会上获悉,第十六届中国国际动漫博览会将于2026年8月6日至10日在东莞“中国潮玩之都·漫博中心”举行。据悉,本届展会现场设立版权服务工作站,提供版权登记咨询、快速登记、维权指引等一站式服务,为参展企业原创IP、新品设计提供即时版权保护。同时建立展会版权快速维权机制,严厉打击侵权盗版行为,大力营造尊重原创、保护版权的良好展会氛围。</p>
+                   <p style="text-align: justify;"><span class="insert-img-container" style="display:inline-block;line-height: 1.3em; "><img style="vertical-align: bottom;" src="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2026/07/24/640aebfbf44e4649954551fbd309dd04.jpg?x-oss-process=style/content" class="uedito-cusimg" title="微信图片_20260724110823_7734_327" alt="微信图片_20260724110823_7734_327"><br></span></p>
+                   <p style="text-align: justify;">本届展会畅通版权交易渠道,激活版权市场价值。展会进一步打造专业化版权交易对接平台,设置IP产业融合馆,配套举办AI漫剧产业对接及出海合作活动、IP品牌授权精准对接交流活动、文博IP跨界合作发布会、非遗潮玩创新合作发布会等产业活动,推动版权方与制造企业、品牌方、渠道商精准对接,促进版权成果转化落地。展会还将联动中阿合作中心、西班牙商会等数十家海外商会、协会、外事机构,组织跨境采购团,搭建中外版权合作桥梁,推动国产IP出海与海外IP引进落地,构建双向循环的版权贸易格局。</p>
+                   <p style="text-align: justify;">展会将创新版权金融服务,赋能企业健康发展。展会现场设置版权金融咨询专区,为中小微动漫潮玩企业提供融资对接服务,破解企业发展难点。同时开展版权金融政策宣讲,普及质押融资流程等知识,引导企业用好版权金融工具,将版权资产转化为发展动能。</p>
+                   <p style="text-align: justify;">文/广州日报新花城记者:莫斯其格 实习生:谭斯文</p>
+                   <p style="text-align: justify;">图/广州日报新花城记者:杨泽彬</p>
+                   <p style="text-align: justify;">广州日报新花城编辑:吴嘉丽</p>
+                  </div>
+                 </body>
+                </html>
+                """;
+
+        File outDir = new File("output");
+        Files.createDirectories(outDir.toPath());
+        File pdf = new File(outDir, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + ".pdf");
+
+        DetailCleaner.html2pdf(html, pdf.toPath());
+        log.info("[html2pdf] 生成成功: {}", pdf.getAbsolutePath());
+    }
+
+    @Test
+    public void html2plaintextTest(){
+        String html = """
+                <!doctype html>
+                <html lang="en" style="font-size: 18px;">
+                 <head>
+                  <link href="https://oss.gz-cmc.com/news-static/huacheng/2026-06-03/css/86-6b9ab229.css" rel="stylesheet">
+                  <link href="https://oss.gz-cmc.com/news-static/huacheng/2026-06-03/css/textDetail-43f87c2a.css" rel="stylesheet">
+                 </head>
+                 <body>
+                  <div class="article-title" id="article-title">
+                   畅通版权交易渠道,漫博会推动国产IP出海与海外IP引进落地
+                  </div>
+                  <div class="not-exist-media-leader">
+                   <div class="article-source-time">
+                    <div class="article-time">
+                     2026-07-24 15:43:26
+                    </div>
+                    <div class="article-source">
+                     广州日报新花城
+                    </div>
+                   </div>
+                  </div>
+                  <div class="article-content" id="article-content">
+                   <p style="text-align: justify;">7月24日,记者从第十六届中国国际影视动漫版权保护和贸易博览会新闻发布会上获悉,第十六届中国国际动漫博览会将于2026年8月6日至10日在东莞“中国潮玩之都·漫博中心”举行。据悉,本届展会现场设立版权服务工作站,提供版权登记咨询、快速登记、维权指引等一站式服务,为参展企业原创IP、新品设计提供即时版权保护。同时建立展会版权快速维权机制,严厉打击侵权盗版行为,大力营造尊重原创、保护版权的良好展会氛围。</p>
+                   <p style="text-align: justify;"><span class="insert-img-container" style="display:inline-block;line-height: 1.3em; "><img style="vertical-align: bottom;" src="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2026/07/24/640aebfbf44e4649954551fbd309dd04.jpg?x-oss-process=style/content" class="uedito-cusimg" title="微信图片_20260724110823_7734_327" alt="微信图片_20260724110823_7734_327"><br></span></p>
+                   <p style="text-align: justify;">本届展会畅通版权交易渠道,激活版权市场价值。展会进一步打造专业化版权交易对接平台,设置IP产业融合馆,配套举办AI漫剧产业对接及出海合作活动、IP品牌授权精准对接交流活动、文博IP跨界合作发布会、非遗潮玩创新合作发布会等产业活动,推动版权方与制造企业、品牌方、渠道商精准对接,促进版权成果转化落地。展会还将联动中阿合作中心、西班牙商会等数十家海外商会、协会、外事机构,组织跨境采购团,搭建中外版权合作桥梁,推动国产IP出海与海外IP引进落地,构建双向循环的版权贸易格局。</p>
+                   <p style="text-align: justify;">展会将创新版权金融服务,赋能企业健康发展。展会现场设置版权金融咨询专区,为中小微动漫潮玩企业提供融资对接服务,破解企业发展难点。同时开展版权金融政策宣讲,普及质押融资流程等知识,引导企业用好版权金融工具,将版权资产转化为发展动能。</p>
+                   <p style="text-align: justify;">文/广州日报新花城记者:莫斯其格 实习生:谭斯文</p>
+                   <p style="text-align: justify;">图/广州日报新花城记者:杨泽彬</p>
+                   <p style="text-align: justify;">广州日报新花城编辑:吴嘉丽</p>
+                  </div>
+                 </body>
+                </html>
+                """;
+        String text = Jsoup.parse(html).text();
+        log.info("{}", text);
+    }
+
+    /**
+     * 为库中已采集详情(content 非空)的新闻批量生成 PDF。
+     *
+     * <p>取发布时间最新的 100 条,逐条渲染到 output/ 目录,单条失败不中断整体。</p>
+     *
+     * @throws Exception 数据库初始化等未预期的异常
+     */
+    @Test
+    public void test() throws Exception {
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        try {
+            NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+
+            //获取一百条已采集详情的记录
+            QueryWrapper<News> wrapper = new QueryWrapper<News>()
+                    .isNotNull("content")
+                    .eq("channel_id","903d342af9af43a59cf7cd9d5342be0b")
+                    .orderByDesc("publish_time")
+                    .last("LIMIT 100");
+            List<News> pending = mapper.selectList(wrapper);
+            log.info("获取数据成功:{}条", pending.size());
+
+            File outDir = new File("output");
+            Files.createDirectories(outDir.toPath());
+            int ok = 0;
+            for (News news : pending) {
+                try {
+                    String publicTime = news.getPublishTime().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
+                    String editor = news.getEditor() == null ? "" : news.getEditor();
+                    String title = news.getTitle() == null ? "" : news.getTitle();
+                    String fileName = sanitizeFileName(String.format("%s-%s-%s-%s.pdf", publicTime, title, editor, news.getId()));
+                    File file = new File(outDir, fileName);
+                    DetailCleaner.html2pdf(news.getContent(), file.toPath());
+                    ok++;
+                } catch (Exception e) {
+                    log.error("[生成PDF] 失败 id={} err={}", news.getId(), e.getMessage());
+                }
+            }
+            log.info("[生成PDF] 完成: 成功={} 失败={}", ok, pending.size() - ok);
+        } finally {
+            mybatis.close();
+        }
+    }
+
+    /**
+     * 清理文件名中的非法字符并截断超长标题。
+     *
+     * <p>截断按 UTF-8 字节数计算(文件名上限 255 字节,中文每字占 3 字节),
+     * 并在字符边界处切断,避免截出乱码。</p>
+     *
+     * @param name 原始文件名
+     * @return 可安全落盘的文件名(最长 200 字节)
+     */
+    private static String sanitizeFileName(String name) {
+        String s = name.replaceAll("[\\\\/:*?\"<>|\\s]", "_");
+        byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
+        if (bytes.length <= 200) {
+            return s;
+        }
+        int cut = 200;
+        while (cut > 0 && (bytes[cut - 1] & 0xC0) == 0x80) {
+            cut--;
+        }
+        return new String(bytes, 0, cut, StandardCharsets.UTF_8);
+    }
+
+    @Test
+    public void titleCleanTest() throws Exception {
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        try {
+            NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+            List<News> all = mapper.selectList(new QueryWrapper<News>().like("title", "<"));
+            int updated = 0;
+            for (News news : all) {
+                String cleaned = DetailCleaner.titleClean(news.getTitle());
+                if (cleaned != null && !cleaned.equals(news.getTitle())) {
+                    News update = new News();
+                    update.setId(news.getId());
+                    update.setTitle(cleaned);
+                    mapper.updateById(update);
+                    updated++;
+                    log.info("[标题清洗] {} 原:{}  新:{}", news.getId(), news.getTitle(), cleaned);
+                }
+            }
+            log.info("[标题清洗] 完成: 总数={} 更新={}", all.size(), updated);
+        } finally {
+            mybatis.close();
+        }
+    }
+
+    /**
+     * 统计日期区间中新闻数量和去除包含视频新闻后的数量
+     * 20260913
+     */
+    @Test
+    public void videoCountTest() throws Exception {
+        //1.从数据库获取符合日期要求的新闻
+        //select * from news where (publish_time >= '2024-01-01' and publish_time < '2024-03-01') or (publish_time >= '2024-09-01' and publish_time < '2024-10-01') and content is not null;
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        try {
+            NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+            List<News> newsList = mapper.selectList(new QueryWrapper<News>()
+                    .and(w -> w.ge("publish_time", LocalDateTime.of(2024, 1, 1, 0, 0))
+                            .lt("publish_time", LocalDateTime.of(2024, 3, 1, 0, 0)))
+                    .or(w -> w.ge("publish_time", LocalDateTime.of(2024, 9, 1, 0, 0))
+                            .lt("publish_time", LocalDateTime.of(2024, 10, 1, 0, 0))
+                            .isNotNull("content")));
+            log.info("[视频统计] 获取新闻={}条", newsList.size());
+
+            //2.统计包含视频的新闻(通过URL获取新闻的html,包含 video 标签即为包含视频,使用Jsoup解析html进行判断)
+            GzCmcClient client = new GzCmcClient(config);
+            Map<String, Boolean> videoMap = new LinkedHashMap<>();
+            int videoCount = 0;
+            for (News news : newsList) {
+                boolean hasVideo = false;
+                try {
+                    String html = client.fetchDetail(news.getUrl());
+                    hasVideo = !Jsoup.parse(html).select("video").isEmpty();
+                    if (hasVideo) {
+                        videoCount++;
+                    }
+                    log.info("[视频统计] {} 视频={}", news.getId(), hasVideo);
+                } catch (Exception e) {
+                    log.error("[视频统计] 失败 id={} url={} err={}", news.getId(), news.getUrl(), e.getMessage());
+                }
+                videoMap.put(news.getId(), hasVideo);
+                Thread.sleep(config.sleepMs());
+            }
+
+            //3.输出结果(新闻总数,去掉包含视频新闻的数量)
+            log.info("[视频统计] 完成: 总数={} 包含视频={} 去除视频后={}",
+                    newsList.size(), videoCount, newsList.size() - videoCount);
+
+            //4.统计结果存储到CSV(仅明细: id,是否包含视频)
+            List<String> lines = new ArrayList<>();
+            lines.add("id,video");
+            for (Map.Entry<String, Boolean> e : videoMap.entrySet()) {
+                lines.add(e.getKey() + "," + e.getValue());
+            }
+            File csv = new File("videoCountResult.csv");
+            Files.write(csv.toPath(), lines, StandardCharsets.UTF_8);
+            log.info("[视频统计] 已写入: {} (共{}行)", csv.getAbsolutePath(), lines.size());
+        } finally {
+            mybatis.close();
+        }
+    }
+
+    /**
+     * 二次筛选
+     * - 标题出现一次关键字或正文出现一个关键词两次
+     * - 正文中包含视频
+     * 记录符合要求的id和命中原因(使用Map)
+     * 输出二次筛选符合的数量
+     * 20260913
+     */
+    @Test
+    public void secondSelectTest() throws Exception {
+        /**
+         * 1.从文件 videoCountResult.csv 中选出包含视频的新闻id(仅video=true的记录)
+         * 2.从数据库中获取新闻
+         * 3.进行筛选,正文需要使用Jsoup进行存文本解析,数据库存储的是HTML
+         * 4.输出符合的记录和数量
+         * 5.筛选结果存储到result.csv文件中
+         */
+        Set<String> titleKeys = Set.of("非遗","非物质文化遗产","非遗保护","遗产保护","传承","保护项目","文化政策","文化多样性","非遗创新","文化传统","非遗价值","非遗传承人","非遗活动");
+        Set<String> contentKeys = Set.of("非物质文化遗产","非遗保护","遗产保护","传承","保护项目","文化政策","文化多样性","非遗创新","文化传统","非遗价值","非遗传承人","非遗活动");
+
+        //1.从CSV中选出包含视频的新闻id
+        Set<String> videoIds = new HashSet<>();
+        for (String line : Files.readAllLines(Path.of("videoCountResult.csv"), StandardCharsets.UTF_8)) {
+            if (line.isBlank() || line.startsWith("id,")) {
+                continue;
+            }
+            String[] cols = line.split(",", -1);
+            if (cols.length >= 2 && "true".equals(cols[1].trim())) {
+                videoIds.add(cols[0].trim());
+            }
+        }
+        log.info("[二次筛选] CSV视频新闻={}条", videoIds.size());
+
+        //2.从数据库中获取新闻
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        try {
+            NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+            List<News> newsList = videoIds.isEmpty() ? List.of() : mapper.selectBatchIds(videoIds);
+            log.info("[二次筛选] 数据库命中={}条", newsList.size());
+
+            //3.进行标题判断和正文判断(正文存储为HTML,需先用Jsoup解析为纯文本)
+            Map<String, String> matched = new LinkedHashMap<>();
+            for (News news : newsList) {
+                String reason = matchReason(news, titleKeys, contentKeys);
+                if (reason != null) {
+                    matched.put(news.getId(), reason);
+                }
+            }
+
+            //4.输出符合的记录和数量
+            matched.forEach((id, reason) -> log.info("[二次筛选] {} 命中={}", id, reason));
+            log.info("[二次筛选] 符合: {}条", matched.size());
+
+            //5.筛选结果存储到result.csv文件中
+            List<String> lines = new ArrayList<>();
+            lines.add("id,title,reason");
+            for (News news : newsList) {
+                String reason = matched.get(news.getId());
+                if (reason != null) {
+                    lines.add(csvEscape(news.getId()) + "," + csvEscape(news.getTitle()) + "," + csvEscape(reason));
+                }
+            }
+            File csv = new File("result1.csv");
+            Files.write(csv.toPath(), lines, StandardCharsets.UTF_8);
+            log.info("[二次筛选] 已写入: {}", csv.getAbsolutePath());
+        } finally {
+            mybatis.close();
+        }
+    }
+
+    /**
+     * 判断新闻是否命中二次筛选规则:标题出现任一关键字一次,或正文出现任一关键字两次。
+     *
+     * @param news        新闻对象
+     * @param titleKeys   标题关键字集合
+     * @param contentKeys 正文关键字集合
+     * @return 命中原因;未命中返回 null
+     */
+    private static String matchReason(News news, Set<String> titleKeys, Set<String> contentKeys) {
+        String title = news.getTitle() == null ? "" : news.getTitle();
+        List<String> parts = new ArrayList<>();
+        for (String key : titleKeys) {
+            int cnt = countOccurrences(title, key);
+            if (cnt >= 1) {
+                parts.add("标题'" + key + "'x" + cnt);
+            }
+        }
+        String content = news.getContent() == null ? "" : DetailCleaner.articleContentText(Jsoup.parse(news.getContent()));
+        //篇幅控制,字数小于1500剔除
+        //if (content.length() > 1500) {
+        //    return null;
+        //}
+        for (String key : contentKeys) {
+            int cnt = countOccurrences(content, key);
+            if (cnt >= 2) {
+                parts.add("正文'" + key + "'x" + cnt);
+            }
+        }
+        return parts.isEmpty() ? null : String.join(" | ", parts);
+    }
+
+    /**
+     * 统计关键字在文本中的出现次数(非重叠)。
+     */
+    private static int countOccurrences(String text, String key) {
+        int count = 0;
+        int idx = 0;
+        while ((idx = text.indexOf(key, idx)) >= 0) {
+            count++;
+            idx += key.length();
+        }
+        return count;
+    }
+
+    /**
+     * CSV 字段转义:含逗号/引号/换行时加引号包裹,内部引号加倍。
+     */
+    private static String csvEscape(String field) {
+        if (field == null) {
+            return "";
+        }
+        String s = field.replace("\"", "\"\"");
+        return s.contains(",") || s.contains("\"") || s.contains("\n") || s.contains("\r")
+                ? "\"" + s + "\""
+                : s;
+    }
+
+    /**
+     * 整理结果新闻列表和新闻详情pdf(符合要求的新闻存储在result.csv文件中)
+     * csv格式的表格(序号,id,发布日期,标题,链接,频道名称,作者,篇幅字数)
+     * 正文通过URL获取;正文含视频时下载到 doc/序号/ 下(1.ext、2.ext...)
+     * pdf和视频都保存到 doc/序号/ 子目录, pdf命名:序号-发布时间-新闻标题-编辑.pdf
+     * 20260913
+     */
+    @Test
+    public void resultCollectTest() throws Exception {
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        try {
+            NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+
+            //1.读取 result.csv 中的 id 并查库
+            List<String> ids = new ArrayList<>();
+            for (String line : Files.readAllLines(Path.of("result1.csv"), StandardCharsets.UTF_8)) {
+                if (line.isBlank() || line.startsWith("id,")) {
+                    continue;
+                }
+                int comma = line.indexOf(',');
+                String id = comma < 0 ? line.trim() : line.substring(0, comma).trim();
+                if (id.startsWith("\"") && id.endsWith("\"") && id.length() >= 2) {
+                    id = id.substring(1, id.length() - 1).replace("\"\"", "\"");
+                }
+                ids.add(id);
+            }
+            List<News> newsList = ids.isEmpty() ? new ArrayList<News>() : new ArrayList<>(mapper.selectBatchIds(ids));
+            log.info("[结果整理] result.csv记录={}条 数据库命中={}条", ids.size(), newsList.size());
+
+            //按发布时间升序排列,保证序号稳定
+            newsList.sort(Comparator.comparing(News::getPublishTime, Comparator.nullsLast(Comparator.naturalOrder())));
+
+            File docDir = new File("doc1");
+            Files.createDirectories(docDir.toPath());
+            GzCmcClient client = new GzCmcClient(config);
+            HttpClient videoHttp = HttpClient.newHttpClient();
+
+            //2.逐条处理: 正文通过URL获取 -> 下载视频 -> 清洗 -> 生成PDF, 并组装csv行
+            List<String> lines = new ArrayList<>();
+            lines.add("序号,id,发布日期,标题,链接,频道名称,作者,篇幅字数");
+            int ok = 0;
+            int seq = 0;
+            for (News news : newsList) {
+                seq++;
+                try {
+                    String html;
+                    boolean fetched = true;
+                    try {
+                        html = client.fetchDetail(news.getUrl());
+                    } catch (Exception ef) {
+                        fetched = false;
+                        if (news.getContent() != null) {
+                            log.warn("[结果整理] URL抓取失败回退库内content id={} err={}", news.getId(), ef.getMessage());
+                            html = news.getContent();
+                        } else {
+                            log.error("[结果整理] 抓取失败且无库内content id={} url={} err={}", news.getId(), news.getUrl(), ef.getMessage());
+                            lines.add(resultCsvLine(seq, news, 0));
+                            continue;
+                        }
+                    }
+
+                    //序号子目录(时机: 需要清洗正文时创建)
+                    File seqDir = new File(docDir, String.format("%03d", seq));
+
+                    //下载正文中的视频到序号目录: 1.ext、2.ext...
+                    //视频采集行为控制
+                    //if (false) {
+                    if (fetched) {
+                        Files.createDirectories(seqDir.toPath());
+                        List<String> videoSrcs = extractVideoSrcs(html);
+                        for (int i = 0; i < videoSrcs.size(); i++) {
+                            try {
+                                String url = videoSrcs.get(i);
+                                Path target = seqDir.toPath().resolve((i + 1) + "." + extOf(url));
+                                int status = downloadVideo(videoHttp, url, target);
+                                log.info("[结果整理] 视频下载 序号={} id={} {}/{} code={} 文件={}", seq, news.getId(), i + 1, videoSrcs.size(), status, target.getFileName());
+                            } catch (Exception e) {
+                                log.error("[结果整理] 视频下载失败 序号={} id={} url={} err={}", seq, news.getId(), videoSrcs.get(i), e.getMessage());
+                            }
+                        }
+                    } else {
+                        log.warn("[结果整理] 回退库内content, 跳过视频 序号={} id={}", seq, news.getId());
+                    }
+
+                    //清洗正文并统计字数
+                    String content = fetched ? DetailCleaner.clean(html) : html;
+                    String contentText = DetailCleaner.articleContentText(Jsoup.parse(content));
+                    lines.add(resultCsvLine(seq, news, contentText.length()));
+
+                    //生成PDF到序号目录
+                    Files.createDirectories(seqDir.toPath());
+                    String publicTime = news.getPublishTime().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
+                    String editor = news.getEditor() == null ? "" : news.getEditor();
+                    String title = news.getTitle() == null ? "" : news.getTitle();
+                    String fileName = sanitizeFileName(String.format("%03d-%s-%s-%s.pdf", seq, publicTime, title, editor));
+                    DetailCleaner.html2pdf(content, new File(seqDir, fileName).toPath());
+                    ok++;
+                    log.info("[结果整理] PDF生成 序号={} id={} 文件={}", seq, news.getId(), fileName);
+                } catch (Exception e) {
+                    log.error("[结果整理] 失败 序号={} id={} err={}", seq, news.getId(), e.getMessage());
+                }
+            }
+
+            //3.目标csv存储到doc目录
+            File csv = new File(docDir, "result.csv");
+            Files.write(csv.toPath(), lines, StandardCharsets.UTF_8);
+            log.info("[结果整理] 目标csv已写入: {}", csv.getAbsolutePath());
+
+            log.info("[结果整理] 完成: 成功={} 失败={}", ok, newsList.size() - ok);
+        } finally {
+            mybatis.close();
+        }
+    }
+
+    /**
+     * 组装结果csv行: 序号,id,发布日期,标题,链接,频道名称,作者,篇幅字数
+     */
+    private static String resultCsvLine(int seq, News news, int wordCount) {
+        return seq + "," + csvEscape(news.getId()) + ","
+                + csvEscape(news.getPublishTime() == null ? "" : news.getPublishTime().toLocalDate().toString()) + ","
+                + csvEscape(news.getTitle()) + "," + csvEscape(news.getUrl()) + ","
+                + csvEscape(news.getChannelName()) + "," + csvEscape(news.getEditor()) + ","
+                + wordCount;
+    }
+
+    /**
+     * 提取正文中视频的下载地址: video[src] 或其子 source[src],按文档顺序去重。
+     */
+    private static List<String> extractVideoSrcs(String html) {
+        Document doc = Jsoup.parse(html);
+        Set<String> srcs = new LinkedHashSet<>();
+        for (Element video : doc.select("video")) {
+            String src = video.attr("src");
+            if (!src.isBlank()) {
+                srcs.add(src);
+            }
+            for (Element source : video.select("source[src]")) {
+                String s = source.attr("src");
+                if (!s.isBlank()) {
+                    srcs.add(s);
+                }
+            }
+        }
+        return new ArrayList<>(srcs);
+    }
+
+    /**
+     * 从视频URL路径中提取扩展名,取不到时默认 mp4。
+     */
+    private static String extOf(String url) {
+        try {
+            String path = URI.create(url).getPath();
+            int dot = path.lastIndexOf('.');
+            if (dot >= 0 && dot < path.length() - 1) {
+                String ext = path.substring(dot + 1);
+                if (ext.matches("[a-zA-Z0-9]{1,10}")) {
+                    return ext;
+                }
+            }
+        } catch (Exception ignored) {
+        }
+        return "mp4";
+    }
+
+    /**
+     * 参考 mTest: 用 JDK HttpClient 直接下载视频到目标文件。
+     */
+    private static int downloadVideo(HttpClient http, String url, Path target) throws IOException, InterruptedException {
+        HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
+        HttpResponse<Path> resp = http.send(request, HttpResponse.BodyHandlers.ofFile(target));
+        return resp.statusCode();
+    }
+
 }

+ 53 - 0
src/test/java/space/anyi/cleaner/DetailCleanerTest.java

@@ -0,0 +1,53 @@
+package space.anyi.cleaner;
+
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * @fileName: DetailCleanerTest
+ * @projectName: newCollection
+ * @package: space.anyi.cleaner
+ * @author: yangyi
+ * @date:13/9/2026 5:51 pm
+ * @description:
+ */
+class DetailCleanerTest {
+    private final static Logger log = LoggerFactory.getLogger(DetailCleanerTest.class);
+    @Test
+    void clean() {
+    }
+
+    @Test
+    void html2pdf() throws IOException {
+        File htmlFile = Path.of(System.getProperty("user.dir"), "temp.html").toFile();
+        log.info("htmlFile:{}", htmlFile.getAbsolutePath());
+        File pdfFile = Path.of(System.getProperty("user.dir"), "temp.pdf").toFile();
+        log.info("pdfFile:{}", pdfFile.getAbsolutePath());
+        FileReader fileReader = new FileReader(htmlFile);
+        String[] html = new String[]{""};
+        fileReader.readAllLines().forEach((line)->{html[0] += line;});
+        log.info("html:{}", html[0]);
+        DetailCleaner.html2pdf(html[0],pdfFile.toPath());
+    }
+
+    @Test
+    void titleClean() {
+    }
+
+    @Test
+    void articleContentText() {
+    }
+
+    @Test
+    void parsePersons() {
+    }
+}

+ 43 - 0
temp.html

@@ -0,0 +1,43 @@
+<!doctype html>
+<html lang="en" style="font-size: 18px;">
+<head>
+    <link href="https://oss.gz-cmc.com/news-static/huacheng/2024-02-20/css/396-0efdf98f.css" rel="stylesheet">
+    <link href="https://oss.gz-cmc.com/news-static/huacheng/2024-02-20/css/textDetail-e2f3c763.css" rel="stylesheet">
+</head>
+<body>
+<div class="article-title" id="article-title">
+    “阅见”月月见|阅见非遗,感受广式红木宫灯的魅力
+</div>
+<div class="not-exist-media-leader">
+    <div class="article-source-time">
+        <div class="article-time">
+            2024-02-27 16:24:13
+        </div>
+        <div class="article-source">
+            广州日报新花城
+        </div>
+    </div>
+</div>
+<div class="article-content" id="article-content">
+    <p style="width: fit-content; height: fit-content; margin: 0px auto; float: none; text-align: center;" class="extend-video-p upload-trans-status-2" data-status="2" data-ai-id="" video-trans-id="b88be699b16a48fc996ad0160b71e6ae" insert-type="2"><em class="extend-video-title editor-news-title" style="font-style:normal;width:fit-content;display:block;margin:auto;"></em><span class="video-flag" style="top: 101px; left: 199px;"></span><strong class="upload-trans-status-b upload-trans-status-b-2" style="top: 0px; left: 381px;"></strong></p>
+    <p style="font-size: 20px;">近日,由书香羊城组委会举办的“阅见”月月见2月专场阅读活动结合“广式红木宫灯”非遗花灯文化,在广州购书中心天河店,举办了“阅见非遗·享南粤团圆中国年”主题活动。</p>
+    <p style="font-size: 20px;"><img id="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/539ea11b2ab34e16b8e596374c08bdfc.jpg?x-oss-process=style/content" src="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/539ea11b2ab34e16b8e596374c08bdfc.jpg?x-oss-process=style/content" class="uedito-cusimg"></p>
+    <p style="font-size: 20px;">记者了解到,“阅见”月月见自去年启动以来,坚持每月定期组织开展读书分享沙龙、文艺诵读展演、名家大咖签售等系列活动,创新“阅读+”多元文化体验,丰富市民读者的精神文化生活。</p>
+    <p style="font-size: 20px;"><img id="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/756d8ee9c1d44dbfa8111eff775c807f.jpg?x-oss-process=style/content" src="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/756d8ee9c1d44dbfa8111eff775c807f.jpg?x-oss-process=style/content" class="uedito-cusimg"></p>
+    <p style=""><span style="font-size: 20px;">“广式红木宫灯”代表传承</span><span style="font-size: 20px;">人罗敏欣</span></p>
+    <p style=""><span style="font-size: 20px;">“阅见”月月见2月专场活动,从团圆·美、团圆·乐、团圆·情、团圆·趣四个维度出发,引领市民从岭南非遗中读懂广州,从体验传统岭南非遗中深入挖掘广州历史,让岭南传统文化在创新的体验中焕发全新的活力。活动特邀请到省级非物质文化遗产“广式红木宫灯”代表传承</span><span style="font-size: 20px;">人——罗敏</span>欣<span style="font-size: 20px;">老师到场,与市民读者一同,感受非</span>遗<span style="font-size: 20px;">的魅力。罗敏</span>欣<span style="font-size: 20px;">老师现场讲述“广式红木宫灯制作技艺”与“广式红木宫灯”的历史。</span></p>
+    <p style="font-size: 20px;"><img id="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/5441767506954adaa41aceac209b9921.jpg?x-oss-process=style/content" src="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/5441767506954adaa41aceac209b9921.jpg?x-oss-process=style/content" class="uedito-cusimg"></p>
+    <p style="font-size: 20px;">在历史的长河中,广式红木宫灯以其细刻的雕刻、稳重古雅的风格,端庄大气的造型,兼容并蓄的姿态,体现了岭南文化多元、务实、开放、兼容、创新的特点,体现了勇于创新的广府文化精神。广式红木宫灯在风格上吸纳了“广式硬木家具”中西合璧的特点,同时融入了广府岭南的特色元素,例如岭南建筑文化、艺术文化、民俗文化、农耕文化等。</p>
+    <p style="font-size: 20px;"><img id="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/ed61f928b52b465b925966080b1dfb6b.jpg?x-oss-process=style/content" src="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/ed61f928b52b465b925966080b1dfb6b.jpg?x-oss-process=style/content" class="uedito-cusimg"></p>
+    <p style="font-size: 20px;">在广州市白云区,说起宫灯,就不得不提起江高镇的红木宫灯。江高镇是传统红木宫灯的发源地之一。红木宫灯是难得一见的传统工艺品,如今在传承人的坚守和创新下,也走进寻常百姓家,传统文化与现代元素碰撞出新火花,集欣赏性、装饰性、历史性和实用性于一体,成为民间民俗节日的时尚礼品。</p>
+    <p style="font-size: 20px;"><img id="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/e24a7af33c7b440097f6c285f08ff506.jpg?x-oss-process=style/content" src="https://oss.gz-cmc.com/pgcr/root/huacheng/upload/news/image/2024/02/27/e24a7af33c7b440097f6c285f08ff506.jpg?x-oss-process=style/content" class="uedito-cusimg"></p>
+    <p style="font-size: 20px;">通过罗敏欣老师的精彩讲解,记者在现场了解到,“广式红木宫灯制作技艺”于2009年被评为广东省第三批省级非物质文化遗产代表性项目。红木宫灯是集绘画、木雕和玻璃工艺为一体的传统工艺品,起源于明朝。古时为宫廷专用的照明灯饰,流传至今已有六百多年。作为宫灯发源地之一的广州,广式宫灯以贡品形式为明清宫廷使用,至今故宫博物院仍完好保存有当时广州匠人制作的精美宫灯。</p>
+    <hr>
+    <p style="font-size: 20px;">文/广州日报·新花城记者:吴波</p>
+    <p style="font-size: 20px;">图/广州日报·新花城记者:吴波</p>
+    <p style="font-size: 20px;">视频/广州日报·新花城记者:吴波</p>
+    <p style="font-size: 20px;">通讯员:书香羊城</p>
+    <p style="font-size: 20px;">广州日报·新花城编辑:刘丽琴</p>
+</div>
+</body>
+</html>

+ 142 - 0
todo.md

@@ -0,0 +1,142 @@
+# 非遗新闻采集 - 实现规划
+
+数据源:[广州日报新花城](https://www.gz-cmc.com/)(需求详见仓库根目录 `非遗新闻采集.md`)
+
+## 目标(本次范围)
+
+实现到:**数据采集 -> 数据清洗 -> 存储到数据库**。PDF 导出与数据表格交付留待下阶段。
+
+## 技术栈
+
+- JDK 25、Maven
+- JDK 内置 `HttpClient`
+- `jsoup` 解析/清洗 HTML
+- `jackson` 解析 JSON
+- `mybatis-plus`(standalone 模式,不使用 Spring,`MybatisPlusSqlSessionFactoryBuilder`)
+- `HikariCP` 连接池
+- PostgreSQL 存储
+- JUnit 5 作为两个阶段的启动入口
+
+## 数据库
+
+- 库:`yangyi`,模式:`new_collection`
+- 连接:`jdbc:postgresql://localhost:5432/yangyi?currentSchema=new_collection`,用户 `yangyi` / 密码 `MIMA2004`
+- 实现时执行 `CREATE SCHEMA IF NOT EXISTS new_collection;`
+- 程序启动时 `CREATE TABLE IF NOT EXISTS new_collection.news (...)`,无需迁移工具
+- 初始化脚本见 `sql/init.sql`(重跑会清空 news 表)
+
+### 表结构 news
+
+| 列 | 类型 | 说明 |
+|---|---|---|
+| id | varchar(64) PK | 接口 data.id,唯一标识 |
+| title | text | 标题(列表接口) |
+| url | text | 新闻地址(列表接口) |
+| publish_time | timestamp | 发布时间(列表接口) |
+| channel_id | varchar(64) | 频道 id |
+| channel_name | varchar(128) | 频道名称 |
+| editor | varchar(255) | 编辑(阶段一取列表接口 data.userName;阶段二详情页解析到则覆盖) |
+| text_reporters | text | 文字记者,逗号分隔 |
+| image_reporters | text | 图片记者,逗号分隔 |
+| correspondents | text | 通讯员,逗号分隔 |
+| content | text | 清洗后的详情 html;NULL 表示待补详情 |
+| content_fetched_at | timestamp | 详情采集时间 |
+| created_at | timestamp default now() | 入库时间 |
+
+## 目录结构
+
+生产代码 `src/main/java/space/anyi/`:
+
+| 类 | 职责 |
+|---|---|
+| `entity/News` | `@TableName("news")` 实体 |
+| `mapper/NewsMapper` | `BaseMapper<News>` |
+| `dto/ChannelAllContentsResponse` | 列表接口响应(status/msg/total/pages/list) |
+| `dto/NewsItem` | list 元素(id + data) |
+| `dto/NewsItemData` | data 字段映射(id/title/url/publishTime/channelId/channelName/userName) |
+| `config/Config` | 加载 `collection.properties`,类型化 getter |
+| `db/Db` | HikariCP DataSource + 建 schema/表 |
+| `db/Mybatis` | 构建 SqlSessionFactory,统一会话获取/提交/回滚 |
+| `client/GzCmcClient` | JDK HttpClient 封装:`search` / `fetchDetail` |
+| `cleaner/DetailCleaner` | jsoup 清洗 + 记者/编辑解析 |
+| `service/ListCollectService` | 阶段一:列表采集 + 去重 + 入库 |
+| `service/DetailCollectService` | 阶段二:补详情 + 清洗 + 解析 + 更新 |
+
+测试代码 `src/test/java/space/anyi/`(启动入口,两阶段分离):
+
+| 类 | 方法 | 运行 |
+|---|---|---|
+| `ListCollectTest` | `@Test void collectList()` | `mvn test -Dtest=ListCollectTest` |
+| `DetailCollectTest` | `@Test void collectDetail()` | `mvn test -Dtest=DetailCollectTest` |
+
+依赖:`mybatis-plus:3.5.9`、`HikariCP:6.3.3`、`postgresql:42.7.4`、`jsoup:1.16.1`、`jackson-databind:2.18.3`、`junit-jupiter:5.14.0`、`maven-surefire-plugin:3.5.6`
+
+## 阶段一:列表采集(ListCollectTest)
+
+对配置中每个频道(`channel.ids` 逗号分隔)× 每个关键词(`keywords` 逗号分隔)做双层循环(组合数 O(频道数×关键词数) = O(n²)),每个组合独立全量采集;后期加词/加频道只改配置:
+
+1. `pageSize=2000` 分页请求 `https://www.gz-cmc.com/contentapi/api/content/getChannelAllContents?siteId=5e88c884e2ed4e7a9a8d5225c299f707&keyword={kw}&channelId={cid}&pageNum={n}&pageSize=2000`(channelId 取自配置,可能多个),直到 `pageNum > pages` 或 list 为空
+2. 请求头必带 `referer: https://www.gz-cmc.com/`
+3. 每条记录映射为实体,按 `data.publishTime`(格式 `yyyy-MM-dd HH:mm:ss`)过滤在 `[publish.start, publish.end]` 内
+4. 去重:`selectBatchIds` 查出本页已存在 id,仅插入缺失记录(幂等,可重复运行)
+5. 此时 `content` 为 NULL,作为阶段二待补队列
+
+## 阶段二:详情采集 + 清洗(DetailCollectTest)
+
+1. 查 `content IS NULL` 的记录,按时间排序逐条处理
+2. `fetchDetail(url)` 获取完整 html(url 形如 `https://huacheng.gz-cmc.com/pages/yyyy/MM/dd/{id}.html`)
+3. jsoup 清洗(严格按 spec):
+   - 移除 `meta`、`script` 标签
+   - 移除 `id="hidden-box"` 元素
+   - `div.container` 的直接子 div 仅保留 `.article-title`、`.not-exist-media-leader`、`.article-content`,其余移除
+   - 清洗结果存入 `content`
+4. 从保留文本解析记者/编辑(见下)
+5. 更新 `content/editor/各记者列/content_fetched_at`
+6. 请求间 sleep(默认 300ms),失败重试 2 次
+
+## 记者/编辑解析规则(DetailCleaner)
+
+对清洗后 `.article-content` 纯文本按子句匹配,顺序:
+
+- `文、图/…记者:X` -> X 同时进文字记者和图片记者
+- `文/…记者:X` -> 文字记者
+- `图/…记者:X` -> 图片记者
+- `通讯员:X` -> 通讯员
+- `新花城编辑:X` / `编辑:X` -> editor
+
+要点:
+
+- 记者、通讯员可能同一行:`图/广州日报新花城记者:曾焕阳 通讯员:潘永光、陈诺` -> 图片[曾焕阳],通讯员[潘永光, 陈诺]
+- 多人名以 `、` / `,` / `,` / 空格分隔
+- `实习生` 等非 spec 字段忽略
+- `editor` 单一字段:阶段一由列表接口 `userName` 填充,阶段二详情页解析到编辑时覆盖(已合并原 `userName` 列)
+
+## 配置 `collection.properties`
+
+```properties
+db.url=jdbc:postgresql://localhost:5432/yangyi?currentSchema=new_collection
+db.user=yangyi
+db.password=MIMA2004
+db.schema=new_collection
+channel.ids=6d99c57fe7dd46aa8d6ecf697da08268
+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/
+```
+
+## 验证
+
+1. `mvn -q compile`
+2. `mvn test -Dtest=ListCollectTest`,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"`
+3. `mvn test -Dtest=DetailCollectTest`,抽查 `content` 清洗结果、图文记者/通讯员/编辑字段
+4. 重跑验证幂等(不重复插入)
+
+## 不在本次范围
+
+- PDF 导出(命名:发布时间-新闻标题-记者.pdf)
+- 数据表格交付(标题/记者/发布时间/新闻地址)

+ 131 - 0
todoDetail.md

@@ -0,0 +1,131 @@
+# 非遗新闻采集 - 详细实现计划(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<String>)、`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<NewsItem>),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<String> 无则空)。
+
+### 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 分钟。