Преглед на файлове

实现非遗新闻采集完整流程

- 阶段一:列表采集 + id 去重入库(ListCollectService,offset 超限优雅停止)
- 阶段二:详情抓取 + jsoup 清洗 + 记者/编辑解析更新(DetailCollectService)
- 解析规则:通讯员可省冒号,过滤供图/供稿/提供/摄及(…)括注
- 独立 MyBatis-Plus + HikariCP + PostgreSQL,无 Spring;启动自动建表
- 提供 4 个 JUnit 入口与完整 20 条流程测试,两阶段幂等可重跑
yangyi преди 1 месец
родител
ревизия
8e0be9f2cf

+ 1 - 0
.gitignore

@@ -3,6 +3,7 @@ target/
 !**/src/main/**/target/
 !**/src/test/**/target/
 .kotlin
+collection.properties
 
 ### IntelliJ IDEA ###
 .idea/

+ 44 - 0
pom.xml

@@ -14,4 +14,48 @@
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     </properties>
 
+    <dependencies>
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus</artifactId>
+            <version>3.5.9</version>
+        </dependency>
+        <dependency>
+            <groupId>com.zaxxer</groupId>
+            <artifactId>HikariCP</artifactId>
+            <version>6.3.3</version>
+        </dependency>
+        <dependency>
+            <groupId>org.postgresql</groupId>
+            <artifactId>postgresql</artifactId>
+            <version>42.7.4</version>
+        </dependency>
+        <dependency>
+            <groupId>org.jsoup</groupId>
+            <artifactId>jsoup</artifactId>
+            <version>1.16.1</version>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+            <version>2.18.3</version>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <version>5.14.0</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <version>3.5.6</version>
+            </plugin>
+        </plugins>
+    </build>
+
 </project>

+ 25 - 0
sql/init.sql

@@ -0,0 +1,25 @@
+-- 非遗新闻采集 - 数据库初始化脚本
+-- 库: yangyi / 模式: new_collection
+-- 注意: 重跑会清空 news 表数据。
+
+CREATE SCHEMA IF NOT EXISTS new_collection;
+
+DROP TABLE IF EXISTS new_collection.news;
+
+CREATE TABLE new_collection.news (
+    id                  varchar(64) PRIMARY KEY,
+    title               text,
+    url                 text,
+    publish_time        timestamp,
+    channel_id          varchar(64),
+    channel_name        varchar(128),
+    editor              varchar(255),
+    text_reporters      text,
+    image_reporters     text,
+    correspondents      text,
+    content             text,
+    content_fetched_at  timestamp,
+    created_at          timestamp default now()
+);
+
+COMMENT ON TABLE new_collection.news IS '非遗新闻采集结果';

+ 181 - 0
src/main/java/space/anyi/cleaner/DetailCleaner.java

@@ -0,0 +1,181 @@
+package space.anyi.cleaner;
+
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 详情页清洗与记者/编辑解析工具(静态方法)。
+ *
+ * <p>清洗:严格按需求移除 meta/script、id 为 hidden-box 的元素,并在 div.container
+ * 的直接子 div 中仅保留 .article-title、.not-exist-media-leader、.article-content。
+ * 解析:从清洗后正文纯文本按子句正则提取文字/图片记者、通讯员与编辑。</p>
+ */
+public class DetailCleaner {
+
+    /** 名称段捕获的终止标记:后续子句前缀或行尾 */
+    private static final String STOP = "(?=文、|文/|图、|图/|视频/|通讯员[::]?|(?:广州日报)?新花城编辑[::]|编辑[::]|实习生[::]|$)";
+    /** 记者子句:文、图/…记者:X 同时计入图文,文/ 只计文字,图/ 只计图片 */
+    private static final Pattern REPORTER = Pattern.compile("(文、图|文|图)/[^::]*?记者[::]\\s*(.*?)" + STOP);
+    /** 通讯员子句(冒号可省略,如 通讯员 张三) */
+    private static final Pattern CORRESPONDENT = Pattern.compile("通讯员[::]?\\s*(.*?)" + STOP);
+    /** 编辑子句(新花城编辑:X) */
+    private static final Pattern EDITOR = Pattern.compile("(?:广州日报)?新花城编辑[::]\\s*(.*?)" + STOP);
+    /** 编辑子句兜底(仅 编辑:X) */
+    private static final Pattern EDITOR_FALLBACK = Pattern.compile("编辑[::]\\s*(.*?)" + STOP);
+
+    private DetailCleaner() {
+    }
+
+    /**
+     * 清洗详情页 HTML。
+     *
+     * <ol>
+     *   <li>移除 meta、script 标签</li>
+     *   <li>移除 id 或 class 为 hidden-box 的元素</li>
+     *   <li>div.container 直接子 div 仅保留 .article-title、.not-exist-media-leader、.article-content</li>
+     * </ol>
+     *
+     * @param html 详情页原始 HTML
+     * @return 清洗后的完整 HTML
+     */
+    public static String clean(String html) {
+        Document doc = Jsoup.parse(html);
+        doc.select("meta, script").remove();
+        doc.select("#hidden-box").remove();
+        doc.select("div.hidden-box").remove();
+        for (Element container : doc.select("div.container")) {
+            Elements children = new Elements();
+            container.children().forEach(children::add);
+            for (Element child : children) {
+                boolean keep = child.is("div")
+                        && (child.hasClass("article-title")
+                        || child.hasClass("not-exist-media-leader")
+                        || child.hasClass("article-content"));
+                if (!keep) {
+                    child.remove();
+                }
+            }
+        }
+        return doc.html();
+    }
+
+    /**
+     * 取清洗后正文(.article-content)的纯文本。
+     *
+     * @param doc 清洗后的文档
+     * @return 正文纯文本;无正文元素时返回空串
+     */
+    public static String articleContentText(Document doc) {
+        Element content = doc.selectFirst("div.article-content");
+        return content == null ? "" : content.text();
+    }
+
+    /**
+     * 从正文纯文本解析记者/通讯员/编辑。
+     *
+     * @param text 清洗后 .article-content 的纯文本
+     * @return 解析结果(编辑 + 三类人员列表,无则空列表/空串)
+     */
+    public static PersonInfo parsePersons(String text) {
+        if (text == null || text.isBlank()) {
+            return new PersonInfo(null, List.of(), List.of(), List.of());
+        }
+        Set<String> textReporters = new LinkedHashSet<>();
+        Set<String> imageReporters = new LinkedHashSet<>();
+        Set<String> correspondents = new LinkedHashSet<>();
+
+        Matcher rm = REPORTER.matcher(text);
+        while (rm.find()) {
+            String prefix = rm.group(1);
+            List<String> names = splitNames(rm.group(2));
+            if (prefix.contains("图")) {
+                imageReporters.addAll(names);
+            }
+            if (prefix.contains("文")) {
+                textReporters.addAll(names);
+            }
+        }
+        Matcher cm = CORRESPONDENT.matcher(text);
+        while (cm.find()) {
+            correspondents.addAll(splitNames(cm.group(1)));
+        }
+
+        String editor = null;
+        Matcher em = EDITOR.matcher(text);
+        if (em.find()) {
+            editor = firstValidName(em.group(1));
+        } else {
+            Matcher efm = EDITOR_FALLBACK.matcher(text);
+            if (efm.find()) {
+                editor = firstValidName(efm.group(1));
+            }
+        }
+
+        return new PersonInfo(editor, List.copyOf(textReporters), List.copyOf(imageReporters), List.copyOf(correspondents));
+    }
+
+    /**
+     * 将名称段拆分为人名列表。
+     *
+     * <p>按 、/,/,/空白 分隔;丢弃含冒号或斜杠的杂项子句(如 实习生:X、图片由受访者提供、
+     * 视频/…记者),剔除 供图/供稿/提供/摄 等标注词,并剥离人名尾部的(…)括注。</p>
+     *
+     * @param segment 一个子句捕获的原始名称段
+     * @return 清洗后的人名列表
+     */
+    private static List<String> splitNames(String segment) {
+        List<String> names = new ArrayList<>();
+        for (String tok : segment.split("[、,,\\s]+")) {
+            String t = tok.trim();
+            if (t.isEmpty()) {
+                continue;
+            }
+            if (t.contains(":") || t.contains(":") || t.contains("/")) {
+                continue;
+            }
+            if (t.contains("供图") || t.contains("供稿") || t.contains("提供") || t.contains("摄")) {
+                continue;
+            }
+            int paren = t.indexOf('(');
+            if (paren > 0) {
+                t = t.substring(0, paren).trim();
+            }
+            if (!t.isEmpty()) {
+                names.add(t);
+            }
+        }
+        return names;
+    }
+
+    /**
+     * 取名称段的第一个合法人名(编辑字段使用)。
+     *
+     * @param segment 原始名称段
+     * @return 第一个人名,无则返回 null
+     */
+    private static String firstValidName(String segment) {
+        List<String> names = splitNames(segment);
+        return names.isEmpty() ? null : names.get(0);
+    }
+
+    /**
+     * 解析结果:编辑 + 文字记者/图片记者/通讯员。
+     *
+     * @param editor         编辑
+     * @param textReporters  文字记者
+     * @param imageReporters 图片记者
+     * @param correspondents 通讯员
+     */
+    public record PersonInfo(String editor, List<String> textReporters, List<String> imageReporters,
+                             List<String> correspondents) {
+    }
+}

+ 129 - 0
src/main/java/space/anyi/client/GzCmcClient.java

@@ -0,0 +1,129 @@
+package space.anyi.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import space.anyi.config.Config;
+import space.anyi.dto.ChannelAllContentsResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+/**
+ * 广州日报新花城接口客户端。
+ *
+ * <p>封装 JDK 内置 HttpClient:列表搜索(search)与详情抓取(fetchDetail)。
+ * 所有请求都携带 referer 头;接口存在偶发 500,统一重试 RETRY 次。</p>
+ */
+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}";
+    /** 单次请求失败后的最大重试次数 */
+    private static final int RETRY = 5;
+
+    /** 复用的 HttpClient 实例 */
+    private final HttpClient http;
+    /** referer 请求头值 */
+    private final String referer;
+    /** 重试间隔毫秒数 */
+    private final long sleepMs;
+    /** JSON 反序列化器 */
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * 按配置构造客户端。
+     *
+     * @param config 运行时配置(referer、超时、休眠间隔)
+     */
+    public GzCmcClient(Config config) {
+        this.referer = config.referer();
+        this.sleepMs = config.sleepMs();
+        this.http = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofMillis(config.timeoutMs()))
+                .build();
+    }
+
+    /**
+     * 分页搜索列表。
+     *
+     * @param keyword  关键词
+     * @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 {
+        String url = LIST_API
+                .replace("{kw}", URLEncoder.encode(keyword, StandardCharsets.UTF_8))
+                .replace("{n}", String.valueOf(pageNum))
+                .replace("{s}", String.valueOf(pageSize));
+        Exception last = null;
+        for (int attempt = 0; attempt <= RETRY; attempt++) {
+            try {
+                HttpResponse<String> resp = http.send(newRequest(url), HttpResponse.BodyHandlers.ofString());
+                if (resp.statusCode() == 200) {
+                    ChannelAllContentsResponse r = mapper.readValue(resp.body(), ChannelAllContentsResponse.class);
+                    if (r.getStatus() != null && r.getStatus() == 200 && r.getList() != null) {
+                        return r;
+                    }
+                    throw new IOException("列表接口业务失败 status=" + r.getStatus() + " msg=" + r.getMsg());
+                }
+                throw new IOException("列表接口 HTTP " + resp.statusCode());
+            } catch (IOException | InterruptedException e) {
+                last = e;
+                if (attempt < RETRY) {
+                    Thread.sleep(sleepMs);
+                }
+            }
+        }
+        throw new IOException("列表接口请求失败: " + url, last);
+    }
+
+    /**
+     * 抓取新闻详情页完整 HTML。
+     *
+     * @param url 详情页地址(列表接口返回的 url)
+     * @return 完整 HTML 字符串
+     * @throws IOException          重试后仍失败
+     * @throws InterruptedException 线程被中断
+     */
+    public String fetchDetail(String url) throws IOException, InterruptedException {
+        Exception last = null;
+        for (int attempt = 0; attempt <= RETRY; attempt++) {
+            try {
+                HttpResponse<String> resp = http.send(newRequest(url), HttpResponse.BodyHandlers.ofString());
+                if (resp.statusCode() == 200) {
+                    return resp.body();
+                }
+                throw new IOException("详情接口 HTTP " + resp.statusCode());
+            } catch (IOException | InterruptedException e) {
+                last = e;
+                if (attempt < RETRY) {
+                    Thread.sleep(sleepMs);
+                }
+            }
+        }
+        throw new IOException("详情接口请求失败: " + url, last);
+    }
+
+    /**
+     * 构造带 referer 与超时的 GET 请求。
+     *
+     * @param url 目标地址
+     * @return 构建好的请求
+     */
+    private HttpRequest newRequest(String url) {
+        return HttpRequest.newBuilder(URI.create(url))
+                .timeout(Duration.ofMillis(20000))
+                .header("referer", referer)
+                .header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36")
+                .GET()
+                .build();
+    }
+}

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

@@ -0,0 +1,124 @@
+package space.anyi.config;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Properties;
+
+/**
+ * 运行时配置。
+ *
+ * <p>从 classpath 根目录的 collection.properties 加载配置项,并对外提供类型化的读取方法。
+ * 配置文件含数据库口令,已被 .gitignore 忽略,切勿提交。</p>
+ */
+public class Config {
+
+    /** 列表接口发布时间字符串格式,如 2024-01-01 00:00:00 */
+    public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    /** 配置项容器 */
+    private final Properties props = new Properties();
+
+    /** 默认加载 classpath 根目录下的 collection.properties */
+    public Config() {
+        this("collection.properties");
+    }
+
+    /**
+     * 从指定 classpath 资源加载配置文件(UTF-8 编码)。
+     *
+     * @param path classpath 下的配置文件路径
+     * @throws IllegalStateException 配置文件不存在或加载失败
+     */
+    public Config(String path) {
+        try (InputStream in = Config.class.getClassLoader().getResourceAsStream(path)) {
+            if (in == null) {
+                throw new IllegalStateException("找不到配置文件: " + path);
+            }
+            props.load(new InputStreamReader(in, StandardCharsets.UTF_8));
+        } catch (IOException e) {
+            throw new IllegalStateException("加载配置文件失败: " + path, e);
+        }
+    }
+
+    /**
+     * 读取单个配置项,缺失或空白时抛出异常。
+     *
+     * @param key 配置项键名
+     * @return 去除首尾空白后的配置值
+     */
+    private String get(String key) {
+        String v = props.getProperty(key);
+        if (v == null || v.isBlank()) {
+            throw new IllegalStateException("缺少配置项: " + key);
+        }
+        return v.trim();
+    }
+
+    /** JDBC 连接 URL */
+    public String dbUrl() {
+        return get("db.url");
+    }
+
+    /** 数据库用户名 */
+    public String dbUser() {
+        return get("db.user");
+    }
+
+    /** 数据库口令 */
+    public String dbPassword() {
+        return get("db.password");
+    }
+
+    /** 数据库模式名(schema) */
+    public String dbSchema() {
+        return get("db.schema");
+    }
+
+    /**
+     * 采集关键词列表(配置中逗号分隔)。
+     *
+     * @return 去空格、去空串后的关键词集合
+     */
+    public List<String> keywords() {
+        return Arrays.stream(get("keywords").split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .toList();
+    }
+
+    /** 发布时间过滤窗口起点(含) */
+    public LocalDateTime publishStart() {
+        return LocalDateTime.parse(get("publish.start"), TIME_FORMATTER);
+    }
+
+    /** 发布时间过滤窗口终点(含) */
+    public LocalDateTime publishEnd() {
+        return LocalDateTime.parse(get("publish.end"), TIME_FORMATTER);
+    }
+
+    /** 列表分页大小 */
+    public int pageSize() {
+        return Integer.parseInt(get("list.page.size"));
+    }
+
+    /** 详情请求之间的休眠毫秒数(限速) */
+    public int sleepMs() {
+        return Integer.parseInt(get("detail.sleep.ms"));
+    }
+
+    /** HTTP 客户端超时毫秒数 */
+    public int timeoutMs() {
+        return Integer.parseInt(get("client.timeout.ms"));
+    }
+
+    /** 请求头 referer 值(接口要求必带) */
+    public String referer() {
+        return get("referer");
+    }
+}

+ 99 - 0
src/main/java/space/anyi/db/Db.java

@@ -0,0 +1,99 @@
+package space.anyi.db;
+
+import com.zaxxer.hikari.HikariConfig;
+import com.zaxxer.hikari.HikariDataSource;
+import space.anyi.config.Config;
+
+import javax.sql.DataSource;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.Statement;
+
+/**
+ * 数据库引导与连接池。
+ *
+ * <p>先通过裸 JDBC 连接创建 schema 与表(表结构变更时的初始化脚本见 sql/init.sql),
+ * 再构建 HikariCP 连接池。池为单例,重复调用直接复用已有实例。</p>
+ */
+public class Db {
+
+    /** HikariCP 连接池单例 */
+    private static HikariDataSource dataSource;
+
+    private Db() {
+    }
+
+    /**
+     * 初始化数据库:建 schema/表并创建连接池。
+     *
+     * @param config 运行时配置(连接信息、schema、超时)
+     * @return 已初始化的 HikariDataSource
+     */
+    public static synchronized HikariDataSource init(Config config) {
+        if (dataSource != null && !dataSource.isClosed()) {
+            return dataSource;
+        }
+        String baseUrl = "jdbc:postgresql://" + config.dbUrl()
+                .replace("jdbc:postgresql://", "");
+        createSchemaAndTable(baseUrl, config);
+
+        HikariConfig hikari = new HikariConfig();
+        hikari.setJdbcUrl(config.dbUrl());
+        hikari.setUsername(config.dbUser());
+        hikari.setPassword(config.dbPassword());
+        hikari.setMaximumPoolSize(10);
+        hikari.setMinimumIdle(1);
+        hikari.setConnectionTimeout(config.timeoutMs());
+        hikari.setPoolName("newCollection-pool");
+        dataSource = new HikariDataSource(hikari);
+        return dataSource;
+    }
+
+    /**
+     * 用裸连接执行建库建表 DDL。
+     *
+     * <p>连接 URL 去掉 currentSchema 参数,因为 schema 可能尚不存在;
+     * 表结构以 {@code new_collection.news} 限定名创建。</p>
+     *
+     * @param baseUrl 去掉查询参数后的 JDBC URL
+     * @param config  运行时配置
+     */
+    private static void createSchemaAndTable(String baseUrl, Config config) {
+        String url = baseUrl.contains("?") ? baseUrl.substring(0, baseUrl.indexOf("?")) : baseUrl;
+        try (Connection conn = DriverManager.getConnection(url, config.dbUser(), config.dbPassword());
+             Statement st = conn.createStatement()) {
+            st.execute("CREATE SCHEMA IF NOT EXISTS " + config.dbSchema());
+            String schema = config.dbSchema();
+            st.execute("CREATE TABLE IF NOT EXISTS " + schema + ".news ("
+                    + "id varchar(64) PRIMARY KEY,"
+                    + "title text,"
+                    + "url text,"
+                    + "publish_time timestamp,"
+                    + "channel_id varchar(64),"
+                    + "channel_name varchar(128),"
+                    + "editor varchar(255),"
+                    + "text_reporters text,"
+                    + "image_reporters text,"
+                    + "correspondents text,"
+                    + "content text,"
+                    + "content_fetched_at timestamp,"
+                    + "created_at timestamp default now()"
+                    + ")");
+        } catch (Exception e) {
+            throw new IllegalStateException("初始化 schema/表失败: " + url, e);
+        }
+    }
+
+    /**
+     * 获取已初始化的数据源。
+     *
+     * @return HikariDataSource
+     * @throws IllegalStateException 尚未调用 {@link #init(Config)}
+     */
+    public static DataSource getDataSource() {
+        if (dataSource == null) {
+            throw new IllegalStateException("Db 未初始化,请先调用 Db.init(Config)");
+        }
+        return dataSource;
+    }
+}

+ 74 - 0
src/main/java/space/anyi/db/Mybatis.java

@@ -0,0 +1,74 @@
+package space.anyi.db;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.mapping.Environment;
+import org.apache.ibatis.session.Configuration;
+import org.apache.ibatis.session.SqlSession;
+import org.apache.ibatis.session.SqlSessionFactory;
+import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
+import space.anyi.mapper.NewsMapper;
+
+import javax.sql.DataSource;
+
+/**
+ * MyBatis-Plus 独立模式封装(不依赖 Spring)。
+ *
+ * <p>用 {@link MybatisPlusSqlSessionFactoryBuilder} 构建 SqlSessionFactory,
+ * 并持有一个自动提交的长生命周期会话,供服务层直接取 Mapper 使用。
+ * 使用方在流程结束后应调用 {@link #close()} 关闭会话。</p>
+ */
+public class Mybatis {
+
+    /** SqlSessionFactory,整个应用生命周期内持有 */
+    private final SqlSessionFactory factory;
+    /** 自动提交的长生命周期会话 */
+    private final SqlSession session;
+
+    /**
+     * 基于数据源构建 SqlSessionFactory 并打开会话。
+     *
+     * @param dataSource 已初始化的数据源
+     */
+    public Mybatis(DataSource dataSource) {
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        configuration.setMapUnderscoreToCamelCase(true);
+        configuration.setEnvironment(new Environment("default", new JdbcTransactionFactory(), dataSource));
+        configuration.addMapper(NewsMapper.class);
+        factory = new MybatisSqlSessionFactoryBuilder().build((Configuration) configuration);
+        session = factory.openSession(true);
+    }
+
+    /**
+     * 获取指定 Mapper 的代理实例(绑定到内部会话)。
+     *
+     * @param mapperClass Mapper 接口类型
+     * @param <T>         继承了 BaseMapper 的接口
+     * @return Mapper 代理
+     */
+    @SuppressWarnings("unchecked")
+    public <T extends BaseMapper<?>> T getMapper(Class<T> mapperClass) {
+        return session.getMapper(mapperClass);
+    }
+
+    /** 便捷获取 NewsMapper */
+    public NewsMapper newsMapper() {
+        return session.getMapper(NewsMapper.class);
+    }
+
+    /** 提交当前事务(自动提交模式下通常无需显式调用) */
+    public void commit() {
+        session.commit();
+    }
+
+    /** 回滚当前事务 */
+    public void rollback() {
+        session.rollback();
+    }
+
+    /** 关闭内部会话,释放资源 */
+    public void close() {
+        session.close();
+    }
+}

+ 65 - 0
src/main/java/space/anyi/dto/ChannelAllContentsResponse.java

@@ -0,0 +1,65 @@
+package space.anyi.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import java.util.List;
+
+/**
+ * 列表接口响应体顶层结构(/contentapi/api/content/getChannelAllContents)。
+ *
+ * <p>只映射本项目使用的字段,其余字段忽略。</p>
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ChannelAllContentsResponse {
+
+    /** 业务状态码 */
+    private Integer status;
+    /** 状态描述 */
+    private String msg;
+    /** 命中总数 */
+    private Integer total;
+    /** 总页数(=ceil(total/pageSize),服务端可能缺省) */
+    private Integer pages;
+    /** 本页新闻列表 */
+    private List<NewsItem> list;
+
+    public Integer getStatus() {
+        return status;
+    }
+
+    public void setStatus(Integer status) {
+        this.status = status;
+    }
+
+    public String getMsg() {
+        return msg;
+    }
+
+    public void setMsg(String msg) {
+        this.msg = msg;
+    }
+
+    public Integer getTotal() {
+        return total;
+    }
+
+    public void setTotal(Integer total) {
+        this.total = total;
+    }
+
+    public Integer getPages() {
+        return pages;
+    }
+
+    public void setPages(Integer pages) {
+        this.pages = pages;
+    }
+
+    public List<NewsItem> getList() {
+        return list;
+    }
+
+    public void setList(List<NewsItem> list) {
+        this.list = list;
+    }
+}

+ 63 - 0
src/main/java/space/anyi/dto/NewsItem.java

@@ -0,0 +1,63 @@
+package space.anyi.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * 列表接口中的一个列表元素(list 数组元素)。
+ *
+ * <p>外层字段主要为路由/类型信息,真正有用的内容在 {@link #data} 中。</p>
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class NewsItem {
+
+    /** 元素 id */
+    private String id;
+    /** 内容类型(数字枚举) */
+    private Integer contentType;
+    /** 内容 id(可能与本层 id 不同) */
+    private String contentId;
+    /** 站点 id */
+    private String siteId;
+    /** 内容主体:标题、地址、发布时间、频道、发布人等 */
+    private NewsItemData data;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public Integer getContentType() {
+        return contentType;
+    }
+
+    public void setContentType(Integer contentType) {
+        this.contentType = contentType;
+    }
+
+    public String getContentId() {
+        return contentId;
+    }
+
+    public void setContentId(String contentId) {
+        this.contentId = contentId;
+    }
+
+    public String getSiteId() {
+        return siteId;
+    }
+
+    public void setSiteId(String siteId) {
+        this.siteId = siteId;
+    }
+
+    public NewsItemData getData() {
+        return data;
+    }
+
+    public void setData(NewsItemData data) {
+        this.data = data;
+    }
+}

+ 84 - 0
src/main/java/space/anyi/dto/NewsItemData.java

@@ -0,0 +1,84 @@
+package space.anyi.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * 列表元素的内容主体(NewsItem.data)。
+ *
+ * <p>注意:userName(列表接口的发布人字段)在本项目映射到 News.editor,
+ * 与详情页解析出的 editor 相互独立、可互相覆盖,勿混淆。</p>
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class NewsItemData {
+
+    /** 内容 id(即 News 主键) */
+    private String id;
+    /** 标题 */
+    private String title;
+    /** 新闻地址 */
+    private String url;
+    /** 发布时间(字符串,格式见 Config.TIME_FORMATTER) */
+    private String publishTime;
+    /** 频道 id */
+    private String channelId;
+    /** 频道名称 */
+    private String channelName;
+    /** 发布人/编辑(列表接口提供,阶段一写入 editor 字段) */
+    private String userName;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+    public String getUrl() {
+        return url;
+    }
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+
+    public String getPublishTime() {
+        return publishTime;
+    }
+
+    public void setPublishTime(String publishTime) {
+        this.publishTime = publishTime;
+    }
+
+    public String getChannelId() {
+        return channelId;
+    }
+
+    public void setChannelId(String channelId) {
+        this.channelId = channelId;
+    }
+
+    public String getChannelName() {
+        return channelName;
+    }
+
+    public void setChannelName(String channelName) {
+        this.channelName = channelName;
+    }
+
+    public String getUserName() {
+        return userName;
+    }
+
+    public void setUserName(String userName) {
+        this.userName = userName;
+    }
+}

+ 149 - 0
src/main/java/space/anyi/entity/News.java

@@ -0,0 +1,149 @@
+package space.anyi.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import java.time.LocalDateTime;
+
+/**
+ * 新闻采集实体,映射数据库表 new_collection.news。
+ *
+ * <p>主键来自列表接口 data.id(INPUT 型,不自动生成)。
+ * content 为 NULL 表示详情尚未采集,是阶段二的待补队列依据。</p>
+ */
+@TableName("new_collection.news")
+public class News {
+
+    /** 唯一标识(列表接口 data.id) */
+    @TableId(type = IdType.INPUT)
+    private String id;
+    /** 标题(列表接口) */
+    private String title;
+    /** 新闻地址(列表接口) */
+    private String url;
+    /** 发布时间(列表接口) */
+    private LocalDateTime publishTime;
+    /** 频道 id */
+    private String channelId;
+    /** 频道名称 */
+    private String channelName;
+    /** 编辑:阶段一取列表接口 data.userName,阶段二详情页解析到则覆盖 */
+    private String editor;
+    /** 文字记者,逗号分隔 */
+    private String textReporters;
+    /** 图片记者,逗号分隔 */
+    private String imageReporters;
+    /** 通讯员,逗号分隔 */
+    private String correspondents;
+    /** 清洗后的详情 html;NULL 表示待补详情 */
+    private String content;
+    /** 详情采集时间 */
+    private LocalDateTime contentFetchedAt;
+    /** 入库时间(数据库默认 now()) */
+    private LocalDateTime createdAt;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+    public String getUrl() {
+        return url;
+    }
+
+    public void setUrl(String url) {
+        this.url = url;
+    }
+
+    public LocalDateTime getPublishTime() {
+        return publishTime;
+    }
+
+    public void setPublishTime(LocalDateTime publishTime) {
+        this.publishTime = publishTime;
+    }
+
+    public String getChannelId() {
+        return channelId;
+    }
+
+    public void setChannelId(String channelId) {
+        this.channelId = channelId;
+    }
+
+    public String getChannelName() {
+        return channelName;
+    }
+
+    public void setChannelName(String channelName) {
+        this.channelName = channelName;
+    }
+
+    public String getEditor() {
+        return editor;
+    }
+
+    public void setEditor(String editor) {
+        this.editor = editor;
+    }
+
+    public String getTextReporters() {
+        return textReporters;
+    }
+
+    public void setTextReporters(String textReporters) {
+        this.textReporters = textReporters;
+    }
+
+    public String getImageReporters() {
+        return imageReporters;
+    }
+
+    public void setImageReporters(String imageReporters) {
+        this.imageReporters = imageReporters;
+    }
+
+    public String getCorrespondents() {
+        return correspondents;
+    }
+
+    public void setCorrespondents(String correspondents) {
+        this.correspondents = correspondents;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public void setContent(String content) {
+        this.content = content;
+    }
+
+    public LocalDateTime getContentFetchedAt() {
+        return contentFetchedAt;
+    }
+
+    public void setContentFetchedAt(LocalDateTime contentFetchedAt) {
+        this.contentFetchedAt = contentFetchedAt;
+    }
+
+    public LocalDateTime getCreatedAt() {
+        return createdAt;
+    }
+
+    public void setCreatedAt(LocalDateTime createdAt) {
+        this.createdAt = createdAt;
+    }
+}

+ 13 - 0
src/main/java/space/anyi/mapper/NewsMapper.java

@@ -0,0 +1,13 @@
+package space.anyi.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import space.anyi.entity.News;
+
+/**
+ * News 表的 MyBatis-Plus Mapper。
+ *
+ * <p>由 {@code Mybatis} 注册并在独立会话中代理使用;
+ * 继承 BaseMapper 即可获得增删改查与条件查询能力。</p>
+ */
+public interface NewsMapper extends BaseMapper<News> {
+}

+ 84 - 0
src/main/java/space/anyi/service/DetailCollectService.java

@@ -0,0 +1,84 @@
+package space.anyi.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import space.anyi.cleaner.DetailCleaner;
+import space.anyi.client.GzCmcClient;
+import space.anyi.config.Config;
+import space.anyi.entity.News;
+import space.anyi.mapper.NewsMapper;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 阶段二:详情采集服务。
+ *
+ * <p>查询 content 为空的记录(按发布时间升序),逐条抓取详情页并清洗,解析
+ * 记者/编辑后更新库。请求间按配置休眠限速;单条失败仅计数并保留空 content,
+ * 留待下次运行重补。可重复运行,幂等。</p>
+ */
+public class DetailCollectService {
+
+    private final GzCmcClient client;
+    private final NewsMapper mapper;
+    private final Config config;
+
+    /**
+     * @param client 接口客户端
+     * @param mapper NewsMapper
+     * @param config 运行时配置
+     */
+    public DetailCollectService(GzCmcClient client, NewsMapper mapper, Config config) {
+        this.client = client;
+        this.mapper = mapper;
+        this.config = config;
+    }
+
+    /**
+     * 执行详情采集主流程。
+     *
+     * <p>循环处理所有 content 为空的记录:抓详情 -> 清洗 -> 解析记者/编辑 -> 更新。
+     * 每 100 条打印一次进度,单条失败不中断,结束打印成功/失败数。</p>
+     *
+     * @throws Exception 网络等未预期异常
+     */
+    public void run() throws Exception {
+        QueryWrapper<News> wrapper = new QueryWrapper<>();
+        wrapper.isNull("content").orderByAsc("publish_time");
+        List<News> pending = mapper.selectList(wrapper);
+        System.out.printf("[详情] 待补详情 %d 条%n", pending.size());
+
+        int ok = 0;
+        int failed = 0;
+        for (int i = 0; i < pending.size(); i++) {
+            News news = pending.get(i);
+            try {
+                String html = client.fetchDetail(news.getUrl());
+                String content = DetailCleaner.clean(html);
+                Document doc = Jsoup.parse(content);
+                DetailCleaner.PersonInfo person = DetailCleaner.parsePersons(DetailCleaner.articleContentText(doc));
+
+                News update = new News();
+                update.setId(news.getId());
+                update.setContent(content);
+                update.setEditor(person.editor());
+                update.setTextReporters(person.textReporters().isEmpty() ? null : String.join(",", person.textReporters()));
+                update.setImageReporters(person.imageReporters().isEmpty() ? null : String.join(",", person.imageReporters()));
+                update.setCorrespondents(person.correspondents().isEmpty() ? null : String.join(",", person.correspondents()));
+                update.setContentFetchedAt(LocalDateTime.now());
+                mapper.updateById(update);
+                ok++;
+            } catch (Exception e) {
+                failed++;
+                System.err.printf("[详情] 失败 id=%s url=%s err=%s%n", news.getId(), news.getUrl(), e.getMessage());
+            }
+            if ((i + 1) % 100 == 0) {
+                System.out.printf("[详情] 进度 %d/%d 成功=%d 失败=%d%n", i + 1, pending.size(), ok, failed);
+            }
+            Thread.sleep(config.sleepMs());
+        }
+        System.out.printf("[详情] 完成: 成功=%d 失败=%d (失败条留待下次补)%n", ok, failed);
+    }
+}

+ 150 - 0
src/main/java/space/anyi/service/ListCollectService.java

@@ -0,0 +1,150 @@
+package space.anyi.service;
+
+import space.anyi.client.GzCmcClient;
+import space.anyi.config.Config;
+import space.anyi.dto.ChannelAllContentsResponse;
+import space.anyi.dto.NewsItem;
+import space.anyi.dto.NewsItemData;
+import space.anyi.entity.News;
+import space.anyi.mapper.NewsMapper;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 阶段一:列表采集服务。
+ *
+ * <p>对每个配置关键词全量分页采集列表,按发布时间窗口过滤,通过 id 去重后
+ * 仅插入缺失记录(content 置空,作为阶段二待补队列)。可重复运行,幂等。</p>
+ */
+public class ListCollectService {
+
+    private final GzCmcClient client;
+    private final NewsMapper mapper;
+    private final Config config;
+
+    /**
+     * @param client 接口客户端
+     * @param mapper NewsMapper
+     * @param config 运行时配置
+     */
+    public ListCollectService(GzCmcClient client, NewsMapper mapper, Config config) {
+        this.client = client;
+        this.mapper = mapper;
+        this.config = config;
+    }
+
+    /**
+     * 执行列表采集主流程。
+     *
+     * <p>逐关键词分页请求;某页重试后仍失败(通常是服务端 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);
+                    }
+                }
+                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++;
+            }
+            totalInserted += keywordInserted;
+            System.out.printf("[列表] 关键词=%s 新增入库=%d%n", keyword, keywordInserted);
+        }
+        System.out.printf("[列表] 全部完成, 新增入库合计=%d%n", totalInserted);
+    }
+
+    /**
+     * 将列表元素映射为实体(含窗口过滤)。
+     *
+     * @param item 列表元素
+     * @return News 实体;数据缺失、时间解析失败或不在发布窗口内时返回 null
+     */
+    private News toNews(NewsItem item) {
+        NewsItemData data = item.getData();
+        if (data == null || data.getId() == null) {
+            return null;
+        }
+        LocalDateTime publishTime;
+        try {
+            publishTime = LocalDateTime.parse(data.getPublishTime(), Config.TIME_FORMATTER);
+        } catch (Exception e) {
+            return null;
+        }
+        if (publishTime.isBefore(config.publishStart()) || publishTime.isAfter(config.publishEnd())) {
+            return null;
+        }
+        News news = new News();
+        news.setId(data.getId());
+        news.setTitle(data.getTitle());
+        news.setUrl(data.getUrl());
+        news.setPublishTime(publishTime);
+        news.setChannelId(data.getChannelId());
+        news.setChannelName(data.getChannelName());
+        news.setEditor(data.getUserName());
+        return news;
+    }
+
+    /**
+     * 批量去重并插入缺失记录。
+     *
+     * <p>先用本批 id 查询库中已存在 id,仅插入不存在的记录,保证可重复运行。</p>
+     *
+     * @param batch 本页映射出的实体(已通过窗口过滤)
+     * @return 实际新增行数
+     */
+    private int insertMissing(List<News> batch) {
+        if (batch.isEmpty()) {
+            return 0;
+        }
+        Set<String> ids = new HashSet<>();
+        for (News n : batch) {
+            ids.add(n.getId());
+        }
+        List<News> existing = mapper.selectBatchIds(ids);
+        Set<String> existingIds = new HashSet<>();
+        for (News n : existing) {
+            existingIds.add(n.getId());
+        }
+        int inserted = 0;
+        for (News n : batch) {
+            if (!existingIds.contains(n.getId())) {
+                mapper.insert(n);
+                inserted++;
+            }
+        }
+        return inserted;
+    }
+}

+ 72 - 0
src/test/java/space/anyi/DetailCleanerTest.java

@@ -0,0 +1,72 @@
+package space.anyi;
+
+import org.junit.jupiter.api.Test;
+import space.anyi.cleaner.DetailCleaner;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * DetailCleaner 解析规则的单元测试。
+ *
+ * <p>覆盖真实页面中出现的各类署名变体:文、图/记者、文/记者、图/记者、视频/记者、
+ * 通讯员冒号省略、实习生子句、供图/供稿/提供/摄 过滤、(…)括注剥离等。</p>
+ */
+class DetailCleanerTest {
+
+    /**
+     * 断言一条解析样例的四个输出。
+     *
+     * @param input  正文纯文本
+     * @param text   期望的文字记者(逗号连接)
+     * @param img    期望的图片记者列表
+     * @param corr   期望的通讯员列表
+     * @param editor 期望的编辑
+     */
+    private void assertCase(String input, String text, List<String> img, List<String> corr, String editor) {
+        DetailCleaner.PersonInfo p = DetailCleaner.parsePersons(input);
+        assertEquals(text, String.join(",", p.textReporters()), "文字记者: " + input);
+        assertEquals(img, p.imageReporters(), "图片记者: " + input);
+        assertEquals(corr, p.correspondents(), "通讯员: " + input);
+        assertEquals(editor, p.editor(), "编辑: " + input);
+    }
+
+    /** 真实页面样例集合:文/图、通讯员省略冒号、多记者、实习生/供图等变体。 */
+    @Test
+    void parseSamples() {
+        assertCase("文、图/广州日报新花城记者:曾焕阳 通讯员:潘永光、陈诺广州日报新花城编辑:石忠情",
+                "曾焕阳", List.of("曾焕阳"), List.of("潘永光", "陈诺"), "石忠情");
+
+        assertCase("文/广州日报新花城记者:孙嘉晖 通讯员:袁智斌 图/广州日报新花城记者:杨泽彬 通讯员:黄晋文 视频/广州日报新花城记者:杨泽彬 广州日报新花城编辑:杜娟",
+                "孙嘉晖", List.of("杨泽彬"), List.of("袁智斌", "黄晋文"), "杜娟");
+
+        assertCase("文、图/广州日报新花城记者:轩慧 广州日报新花城编辑:童丹",
+                "轩慧", List.of("轩慧"), List.of(), "童丹");
+
+        assertCase("文/广州日报新花城记者:陈家源、庄小龙、张忠安、倪明、刘幸、曾繁莹、李波 通讯员:潮宣、王理润(除署名外) 广州日报新花城编辑:赵小满",
+                "陈家源,庄小龙,张忠安,倪明,刘幸,曾繁莹,李波", List.of(), List.of("潮宣", "王理润"), "赵小满");
+
+        assertCase("文/广州日报新花城记者:廖靖文 实习生:邹文婧 图片由受访者提供 广州日报新花城编辑:时秀芙",
+                "廖靖文", List.of(), List.of(), "时秀芙");
+
+        assertCase("文/广州日报新花城记者:黄子宁 通讯员:徐韬 图、视频/北滘宣办提供 广州日报新花城编辑:何波",
+                "黄子宁", List.of(), List.of("徐韬"), "何波");
+
+        assertCase("文/广州日报新花城记者:何钻莹 通讯员:刘燕 图/广州日报新花城记者:苏俊杰 通讯员:刘燕 广州日报新花城编辑:曾卫康",
+                "何钻莹", List.of("苏俊杰"), List.of("刘燕"), "曾卫康");
+
+        assertCase("文/广州日报新花城记者:张素芹 通讯员 黄嘉军 图/梁家欣 广州日报新花城编辑:戴雨静",
+                "张素芹", List.of(), List.of("黄嘉军"), "戴雨静");
+
+        assertCase("文/广州日报新花城记者:耿旭静 通讯员:罗瑞娴 黄俞菁 图/广州日报新花城记者:莫伟浓 通讯员:罗仲贤 活动主办方供图 广州日报新花城编辑:时秀芙",
+                "耿旭静", List.of("莫伟浓"), List.of("罗瑞娴", "黄俞菁", "罗仲贤"), "时秀芙");
+
+        assertCase("来源:团区委、文体南沙 文字:罗瑞娴 通讯员:黄俞菁 图片:罗仲贤、活动主办方供图 视频:罗仲贤 编辑:叶雅欣",
+                "", List.of(), List.of("黄俞菁"), "叶雅欣");
+
+        DetailCleaner.PersonInfo p = DetailCleaner.parsePersons("");
+        assertEquals(null, p.editor());
+        assertEquals(List.of(), p.textReporters());
+    }
+}

+ 36 - 0
src/test/java/space/anyi/DetailCollectTest.java

@@ -0,0 +1,36 @@
+package space.anyi;
+
+import org.junit.jupiter.api.Test;
+import space.anyi.client.GzCmcClient;
+import space.anyi.config.Config;
+import space.anyi.db.Db;
+import space.anyi.db.Mybatis;
+import space.anyi.mapper.NewsMapper;
+import space.anyi.service.DetailCollectService;
+
+/**
+ * 阶段二(详情采集)的入口测试。
+ *
+ * <p>通过 {@code mvn test -Dtest=DetailCollectTest} 运行:初始化配置/连接池/MyBatis,
+ * 然后对 content 为空的记录逐条抓详情、清洗、解析并更新,可重复运行且幂等。</p>
+ */
+class DetailCollectTest {
+
+    /**
+     * 执行详情采集主流程。
+     *
+     * @throws Exception 采集过程中未预期的异常
+     */
+    @Test
+    void collectDetail() throws Exception {
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        GzCmcClient client = new GzCmcClient(config);
+        NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+        try {
+            new DetailCollectService(client, mapper, config).run();
+        } finally {
+            mybatis.close();
+        }
+    }
+}

+ 36 - 0
src/test/java/space/anyi/ListCollectTest.java

@@ -0,0 +1,36 @@
+package space.anyi;
+
+import org.junit.jupiter.api.Test;
+import space.anyi.client.GzCmcClient;
+import space.anyi.config.Config;
+import space.anyi.db.Db;
+import space.anyi.db.Mybatis;
+import space.anyi.mapper.NewsMapper;
+import space.anyi.service.ListCollectService;
+
+/**
+ * 阶段一(列表采集)的入口测试。
+ *
+ * <p>通过 {@code mvn test -Dtest=ListCollectTest} 运行:初始化配置/连接池/MyBatis,
+ * 然后执行全量列表采集(去重后仅插入缺失记录),可重复运行且幂等。</p>
+ */
+class ListCollectTest {
+
+    /**
+     * 执行列表采集主流程。
+     *
+     * @throws Exception 采集过程中未预期的异常
+     */
+    @Test
+    void collectList() throws Exception {
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        GzCmcClient client = new GzCmcClient(config);
+        NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+        try {
+            new ListCollectService(client, mapper, config).run();
+        } finally {
+            mybatis.close();
+        }
+    }
+}

+ 99 - 0
src/test/java/space/anyi/MyListTest.java

@@ -0,0 +1,99 @@
+package space.anyi;
+
+import org.junit.jupiter.api.Test;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import space.anyi.cleaner.DetailCleaner;
+import space.anyi.client.GzCmcClient;
+import space.anyi.config.Config;
+import space.anyi.db.Db;
+import space.anyi.db.Mybatis;
+import space.anyi.dto.ChannelAllContentsResponse;
+import space.anyi.dto.NewsItem;
+import space.anyi.dto.NewsItemData;
+import space.anyi.entity.News;
+import space.anyi.mapper.NewsMapper;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 20 条数据的完整流程测试。
+ *
+ * <p>覆盖两个阶段的整条链路:列表采集 -> 去重入库 -> 详情抓取 -> 清洗 ->
+ * 记者/编辑解析 -> 更新,用于快速验证全流程与可重复运行。</p>
+ */
+public class MyListTest {
+
+    /**
+     * 执行 20 条的完整采集流程。
+     *
+     * @throws Exception 采集过程中未预期的异常
+     */
+    @Test
+    public void fullCollectionFlow() throws Exception {
+        Config config = new Config();
+        Mybatis mybatis = new Mybatis(Db.init(config));
+        GzCmcClient client = new GzCmcClient(config);
+        NewsMapper mapper = mybatis.getMapper(NewsMapper.class);
+        try {
+            // 阶段一: 列表采集 20 条并去重入库
+            ChannelAllContentsResponse resp = client.search(config.keywords().get(0), 1, 20);
+            List<News> newsList = new ArrayList<>();
+            for (NewsItem item : resp.getList()) {
+                NewsItemData data = item.getData();
+                if (data == null || data.getId() == null) {
+                    continue;
+                }
+                News news = new News();
+                news.setId(data.getId());
+                news.setTitle(data.getTitle());
+                news.setUrl(data.getUrl());
+                news.setPublishTime(LocalDateTime.parse(data.getPublishTime(), Config.TIME_FORMATTER));
+                news.setChannelId(data.getChannelId());
+                news.setChannelName(data.getChannelName());
+                news.setEditor(data.getUserName());
+                newsList.add(news);
+            }
+            int inserted = 0;
+            for (News news : newsList) {
+                if (mapper.selectById(news.getId()) == null) {
+                    mapper.insert(news);
+                    inserted++;
+                }
+            }
+            System.out.printf("[20条流程] 列表采集=%d 新增入库=%d%n", newsList.size(), inserted);
+
+            // 阶段二: 逐条抓详情 -> 清洗 -> 解析 -> 更新
+            int ok = 0;
+            for (News news : newsList) {
+                try {
+                    String html = client.fetchDetail(news.getUrl());
+                    String content = DetailCleaner.clean(html);
+                    Document doc = Jsoup.parse(content);
+                    DetailCleaner.PersonInfo person = DetailCleaner.parsePersons(DetailCleaner.articleContentText(doc));
+
+                    News update = new News();
+                    update.setId(news.getId());
+                    update.setContent(content);
+                    update.setEditor(person.editor());
+                    update.setTextReporters(person.textReporters().isEmpty() ? null : String.join(",", person.textReporters()));
+                    update.setImageReporters(person.imageReporters().isEmpty() ? null : String.join(",", person.imageReporters()));
+                    update.setCorrespondents(person.correspondents().isEmpty() ? null : String.join(",", person.correspondents()));
+                    update.setContentFetchedAt(LocalDateTime.now());
+                    mapper.updateById(update);
+                    ok++;
+                    System.out.printf("[20条流程] %s 编辑=%s 文字记者=%s 图片记者=%s 通讯员=%s%n",
+                            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());
+                }
+                Thread.sleep(config.sleepMs());
+            }
+            System.out.printf("[20条流程] 完成: 成功=%d 失败=%d%n", ok, newsList.size() - ok);
+        } finally {
+            mybatis.close();
+        }
+    }
+}