Java实现腾讯云开发者社区列表爬虫

TXListJsoup 腾讯云开发者社区列表爬虫 --- 详细解析文档

文档版本:v1.0

适用源码:com.ruoyi.crawler.part1crawler.jishu.crawler4tx.TXListJsoup

适用工程:若依(RuoYi)系多站点爬虫子系统(part1crawler.jishu.crawler4tx)

编写目的:从「是什么、为什么、怎么做」三个层次,对该类做完整剖析,作为后续维护、扩展的参考手册。

仅供学习交流


一、文档导读与术语约定

1.1 读者对象

  • 后端开发:需要在本工程内新增 / 修改爬虫模块的同学。
  • 运维:负责采集任务调度、数据库落库、IP 风控的同事。
  • 代码审查(Code Review):需要快速理解业务背景与实现细节。

1.2 术语约定

术语 含义
站点(Site) 被采集的目标网站,本类对应「腾讯云开发者社区」
列表页(List Page) 文章列表的聚合页(分页),用于发现新文章链接
详情页(Detail Page) 单篇文章正文页,本类做采集
翻页(Pagination) 通过页码 / 游标获取下一批列表数据
终止条件(Stop Condition) 遇到截止日期 / 重复文章时停止翻页的标志
接口(JSON API) 站点前端通过 XHR 调用的后端 REST 接口
UA(User-Agent) HTTP 请求头中的浏览器标识
stopDay 采集截止日(yyyy-MM-dd),早于或等于该日的文章不再入库
allowDuplicate 是否允许重复入库,true 表示始终写入,false 表示遇到重复立即抛 StopException

1.3 标注约定

  • ✅ 表示「确定」,基于源码可观察到的事实。
  • 🟡 表示「推断」,基于类注释、通用爬虫惯例推断。
  • ⚠️ 表示「注意事项」,涉及风控、可维护性、潜在风险点。

二、项目背景与定位

2.1 业务背景

工程 ruoyi-crawler 隶属于若依(RuoYi)开源框架下的爬虫子系统 ,目标是从各大技术站点(腾讯云、阿里云、CSDN、思否、博客园、掘金等)抓取技术文章列表,用于:

  1. 内容聚合:统一汇聚到内部知识库 / 大数据平台。
  2. 运营分析:作为后续 AI 训练 / 智能问答 / 标签画像的语料源。
  3. 监测舆情:追踪大厂技术风向、热门标签。

2.2 类在架构中的位置

复制代码
com.ruoyi.crawler
└── part1crawler
    └── jishu
        └── crawler4tx                ← 腾讯云站点专用包
            └── TXListJsoup           ✅ 本文档主体(列表爬虫,轻量版)

TXListJsoup 是「轻量化重写」的列表爬虫,旨在以「调 JSON 接口」的方式替代传统 HTML 解析的列表爬虫。

2.3 为什么选 Jsoup

维度 Jsoup
启动速度 纯 HTTP,毫秒级
内存占用 几 MB
反爬 需自行加 Header(本类已加 UA/Origin/Referer)
适用场景 走 XHR / 列表接口
维护成本 仅依赖org.jsoup
2.3.2 与常见爬虫框架对比
维度 Jsoup HttpClient / OkHttp Selenium / Playwright WebMagic / Scrapy
定位 HTML 解析器 + 简易 HTTP 通用 HTTP 客户端 真实浏览器自动化 整站爬取框架
启动速度 毫秒级 毫秒级 秒级(需启浏览器) 秒级
内存占用 几 MB 几 MB 数百 MB 数十~百 MB
JS 渲染 ❌ 不支持 ❌ 不支持 ✅ 完整支持 ✅(配合 Selenium)
并发模型 单线程(可手写线程池) 自行实现 多 Tab 异步 内置多线程 / 分布式
反爬能力 弱,需手动加 Header 弱,需手动加 Header 真实浏览器指纹,强 中(可对接代理池)
学习曲线
适用场景 走 XHR 的列表/详情页 任意 HTTP 调用 强 JS 加密、登录态、验证码 大型整站、分布式抓取
依赖体积 极小 大(浏览器二进制)
本类是否采用 ✅ 已采用 🟡 部分场景可替换 ❌ 过度重量 ❌ 过度重量

结论:本场景(腾讯云列表 XHR 接口)只需「拿 JSON 字符串」,用 Jsoup 即可一步到位;若后续站点加入强 JS 渲染(如登录态、加密参数),再考虑切换到 Selenium 或 WebMagic。

腾讯云列表页虽是 JS 动态加载,但前端实际调用的是 POST /developer/api/home/article-list,因此完全可以用 Jsoup 调接口,这是该类存在的核心动机。


三、目标站点与接口剖析

3.1 站点概览

  • 站点名称:腾讯云开发者社区
  • 主域 :https://cloud.tencent.com
  • 列表页 URL :https://cloud.tencent.com/developer/column?from=19154
  • 页面形态:典型 SPA(单页应用),「加载更多」按钮触发 XHR 请求。

3.2 列表接口(关键!)

字段
URL https://cloud.tencent.com/developer/api/home/article-list
Method POST
Content-Type application/json
排序策略 sortType=timeline 对应「最新优先」(按发布时间倒序)
3.2.1 请求体(Request Body)
json 复制代码
{
  "classifyId": 0,
  "type": "recommend",
  "pagesize": 20,
  "page": 1,
  "sortType": "timeline"
}
  • classifyId=0:全部分类。
  • type=recommend:推荐流(也可传 latest)。
  • pagesize=20:每页 20 条(代码中 PAGE_SIZE)。
  • page:从 1 开始的页码。
  • sortType=timeline:倒序时间线。
3.2.2 响应体(Response Body)

⚠️ 由于代码中没有硬编码字段路径,而是通过 extractList / pickString 做兼容提取,实际结构以运行日志输出的「接口响应结构」为准。常见结构:

json 复制代码
{
  "code": 0,
  "msg": "success",
  "data": {
    "list": [
      {
        "articleId": 123456,
        "title": "文章标题",
        "url": "/developer/article/123456",
        "createTime": 1723456789
      }
    ]
  }
}

3.3 反爬策略与对策

风控点 应对措施
UA 校验 ✅ 使用真实浏览器 UA(Chrome 138 + QQBrowser 复合标识)
Referer / Origin ✅ 显式设置OriginReferer,模拟来自列表页
Cookie / 登录 🟡 当前不携带,匿名可访问
请求频率 ✅ 每页间隔 1000ms(Thread.sleep(1000))
翻页上限 🟡 当list 为空时自动停止(isContinueHold.isContinue=false)
风控降级 ⚠️ 若返回 HTML/验证码,当前仅记 warn 并退出,未实现重试 / 代理

四、类结构总览

text 复制代码
TXListJsoup (final class, 全 static)
│
├── 常量区
│    ├── SITE_NAME = "腾讯云"
│    ├── API_URL   = ".../article-list"
│    ├── PAGE_SIZE = 20
│    ├── UA        = "...Chrome 138 / QQBrowser..."
│    └── SORT_TYPE = "timeline"
│
├── 公开方法
│    └── crawler(String stopDay, boolean allowDuplicate)
│
└── 私有方法
     ├── extracted(pageNo, isContinueHold, stopDay, allowDuplicate)
     ├── extractList(JSONObject root)              // 兼容多种 JSON 列表 key
     ├── pickString(JSONObject obj, String... keys)// 兼容多种字段名
     ├── parseDateFlexible(String dateStr)         // 字符串日期解析
     └── parseTimestamp(long ts)                   // 时间戳 → LocalDate

4.1 设计模式

  • 🟡 模板方法 :crawler() 是入口,extracted() 是单页逻辑;IsContinueHold 作为外部可变状态容器贯穿翻页循环,作为「继续抓取」标记位。
  • 策略兼容 :extractList / pickString 通过枚举候选字段,适配接口字段调整。
  • 职责分离 :HTTP、解析、日期、持久化(交给 DBUtil)互相独立。

五、依赖与协作类

⚠️ 因源码不在本机仓库,以下依赖仅按 import 与类注释推断,实际签名以工程内为准。

5.1 同包及公共包

推断职责
com.ruoyi.crawler.part1crawler.common.DBUtil 数据库工具,提供insertArticle(SpiderArticle, boolean)parseDate(String)
com.ruoyi.crawler.part1crawler.common.IsContinueHold 跨方法传递「是否继续」的布尔容器,避免全局变量
com.ruoyi.crawler.part1crawler.common.SpiderArticle 文章 POJO,字段:siteName / title / link / publishDate
com.ruoyi.crawler.part1crawler.common.StopException 重复时抛出,crawler() 捕获并结束翻页

5.2 第三方

版本建议 作用
org.jsoup:jsoup 1.17+ HTTP 客户端 + HTML 解析
com.alibaba.fastjson2:fastjson2 2.x JSON 解析(本类用其JSONObject)
org.projectlombok:lombok 1.18+ @Slf4j 日志门面
org.slf4j:slf4j-api 1.7+ 日志接口,运行期绑定 Logback

六、字段与常量详解

java 复制代码
private static final String SITE_NAME = "腾讯云";
  • 作用:作为入库的「站点来源」标识,后续可按站点做去重 / 统计。
  • 约定:全工程中文站点名,英文站点会使用拼音或英文。
java 复制代码
private static final String API_URL =
        "https://cloud.tencent.com/developer/api/home/article-list";
  • 为什么用 API :列表页 JS 动态渲染,直接 GET HTML 拿不到数据;前端会触发这个接口。
java 复制代码
private static final int PAGE_SIZE = 20;
  • 与接口 pagesize 保持一致;⚠️ 过大易触发限流,过小增加请求次数。
java 复制代码
private static final String UA = "Mozilla/5.0 ... Chrome/138.0.0.0 ... QQBrowser/21.6.7003.400";
  • 设计:用 QQ 浏览器 UA,匹配站点访客画像(腾讯系产品,自身访客多 QQ 浏览器),降低被识别为脚本的概率。
java 复制代码
private static final String SORT_TYPE = "timeline";
  • 为什么必须 timeline:若传错,采集到的会是「热门」「编辑推荐」等乱序,终止日期判断失效。

七、核心流程详解

7.1 入口方法 crawler(String stopDay, boolean allowDuplicate)

java 复制代码
public static void crawler(String stopDay, boolean allowDuplicate) {
    IsContinueHold isContinueHold = new IsContinueHold();
    int pageNo = 1;
    while (true) {
        if (!isContinueHold.isContinue) break;
        log.info("按页将文章信息入库--页数:{}", pageNo);
        try {
            extracted(pageNo++, isContinueHold, stopDay, allowDuplicate);
        } catch (StopException e) {
            log.info("遇到重复,抓取结束");
            break;
        }
        try { Thread.sleep(1000); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
    }
    log.info("文章入库完成!");
}

执行流程图:

复制代码
┌─────────────────────┐
│  crawler(stopDay,   │
│   allowDuplicate)   │
└──────────┬──────────┘
           │
           ▼
   ┌───────────────┐     否
   │ isContinue?   ├────────► break
   └──────┬────────┘
          │ 是
          ▼
   ┌────────────────────┐
   │ extracted(pageNo)  │ ── StopException ──► break
   └──────────┬─────────┘
              │
              ▼
      Thread.sleep(1000)
              │
              └─────────► 回到循环顶部

关键点说明:

  1. IsContinueHold :为引用类型,可被 extracted() 内部修改,达到「任一层级都能终止」的目的,避免使用全局静态变量。
  2. pageNo++ 自增时机 :在调用 extracted 的同时自增,采用「先使用、后递增」风格。
  3. Thread.sleep(1000):礼貌等待,降低单 IP 请求频率;⚠️ 若需更激进的并行,应改为配置项。
  4. StopException :重复文章时由 DBUtil 抛出,本层仅捕获并退出循环,符合「异常即终止信号」的语义。

7.2 单页抓取 extracted(...)

整体可划分为 5 个阶段:

阶段 A:构造请求体
java 复制代码
String requestBody = "{\"classifyId\":0,\"type\":\"recommend\",\"pagesize\":"
        + PAGE_SIZE + ",\"page\":" + pageNo + ",\"sortType\":\"" + SORT_TYPE + "\"}";
  • 手工拼接 JSON,🟡 可改为 JSONObject.toJSONString(map) 提升可读性。
  • ⚠️ 未做 JSON 转义 :若 SORT_TYPE 中含 " 会被破坏;当前为常量,暂无风险。
阶段 B:发起 HTTP POST
java 复制代码
String responseJson = Jsoup.connect(API_URL)
        .userAgent(UA)
        .timeout(15000)
        .header("Accept", "application/json, text/plain, */*")
        .header("Content-Type", "application/json")
        .header("Origin", "https://cloud.tencent.com")
        .header("Referer", "https://cloud.tencent.com/developer/column?from=19154")
        .ignoreContentType(true)
        .requestBody(requestBody)
        .post()
        .body()
        .text();

逐项说明:

调用 作用
connect(API_URL) 创建连接,实际是Connection 对象的工厂
userAgent(UA) 模拟浏览器
timeout(15000) 15 秒超时,避免长时间阻塞
header("Accept", ...) 接受 JSON,降低被拒绝概率
header("Content-Type", "application/json") 必须,接口按 JSON 解析 Body
header("Origin", ...) 跨域必需
header("Referer", ...) 防盗链识别
ignoreContentType(true) ⚠️ 必须,否则 Jsoup 会因application/json 不是 HTML 抛错
requestBody(...) POST 体
.post() 触发请求
.body().text() 拿到响应字符串

💡 Jsoup 默认只接受 text/html,ignoreContentType(true) 是「把它当成通用 HTTP Client」的关键开关。

阶段 C:解析 JSON 与列表提取
java 复制代码
com.alibaba.fastjson2.JSONObject root = com.alibaba.fastjson2.JSON.parseObject(responseJson);
if (root == null) { ... isContinueHold.isContinue = false; return; }
  • 若响应不是合法 JSON(如被风控返回 HTML 登录页),parseObject 返回 null,此处主动停爬。
  • 第一次请求打印响应结构:便于线上排查字段变化,运维 / 开发可对比日志确认接口升级。
  • 排序探测 :首次请求打印前 3 条 createTime,确认是否按 timeline 倒序,防止站点改版后排序变更导致截止日失效。
阶段 D:遍历文章并入库

对每条记录做 5 步处理:

  1. 标题 :候选 title / articleTitle / name

  2. 链接 :候选 url / link / articleUrl / href / shortUrl;若顶层无,进入 ext 子对象查找;若仍无,根据 articleId 拼出:

    复制代码
    https://cloud.tencent.com/developer/article/{articleId}
  3. 日期 :候选 publishTime / publishDate / createTime / ctime / date;

    • 优先按字符串解析(DBUtil.parseDate);
    • 失败回退按数字时间戳解析(秒/毫秒自动识别);
    • 都失败则跳过该条(可能是「专栏」类无日期)。
  4. 截止日期判断 :publishDate <= stopDay 时停爬。

  5. 入库 :DBUtil.insertArticle(spiderArticle, allowDuplicate)

    • allowDuplicate=false 时,若主键 / 唯一索引冲突,DBUtil 应抛 StopException
    • allowDuplicate=true 时,绕过去重,常用于「补采」。
阶段 E:异常处理
  • StopException 透传,留给 crawler() 统一收口。
  • 其它异常仅记日志,不中断翻页;⚠️ 这可能导致「某页全失败但任务继续」,建议改为「连续 N 次失败再退出」。

7.3 私有工具方法

7.3.1 extractList(JSONObject root)

按候选 key 顺序 list → data → articles → records → rows 查找数组;

data 是对象则递归调用一次,覆盖 {data:{list:[...]}} 结构。

  • ✅ 极强的接口演进兼容能力。
7.3.2 pickString(JSONObject obj, String... keys)

遍历候选 key,首个非 null / 非空 / 非字面 "null"toString() 即返回。

  • 解决后端字段大小写、空值、字符串 "null" 多种坑。
7.3.3 parseDateFlexible(String dateStr)
java 复制代码
if (dateStr.matches("\\d+"))  // 全数字 → 时间戳
    return parseTimestamp(Long.parseLong(dateStr));
return DBUtil.parseDate(dateStr);
  • 字符串日期解析走 DBUtil.parseDate(🟡 内部可能支持 yyyy-MM-dd HH:mm:ss / yyyy-MM-dd / ISO 等多种 pattern)。
7.3.4 parseTimestamp(long ts)
java 复制代码
long ms = ts > 100_000_000_000L ? ts : ts * 1000L;
  • 判断逻辑:> 10¹¹(约 2001 年后的毫秒时间戳)视为毫秒,否则视为秒。
  • 时区 :Asia/Shanghai,腾讯业务在国内,需特别注意东八区。

八、终止条件与去重策略

8.1 三种停止信号

信号 来源 触发条件
isContinueHold.isContinue = false 当前类显式赋值 截止日到达 / 响应非 JSON / 列表为空
StopException DBUtil.insertArticle allowDuplicate=false 且命中唯一索引
InterruptedException Thread.sleep 主线程中断(调度器停止)

8.2 时间停止 vs 重复停止 的差异

  • 按时间停止 :从大到小倒序遍历,第一条 <= stopDay 即停,不会重复
  • 按重复停止 :增量采集时,数据库已有的最后一条会触发唯一约束 → 立即停,保证幂等
  • ⚠️ 两者结合:首次全量靠时间,后续增量靠重复。

九、运行时序图(从调用到落库)

复制代码
┌──────────┐        ┌────────────┐       ┌──────────┐       ┌──────────┐
│ Scheduler│        │ TXListJsoup│       │ Jsoup API│       │ Tencent  │
└────┬─────┘        └─────┬──────┘       └────┬─────┘       └────┬─────┘
     │  cron trigger       │                   │                   │
     │────────────────────►│                   │                   │
     │                     │ POST /article-list│                   │
     │                     │──────────────────►│                   │
     │                     │                   │ ──── HTTPS ──────►│
     │                     │                   │◄──── JSON 200 ────│
     │                     │                   │                   │
     │                     │ parse JSON        │                   │
     │                     │ for each item     │                   │
     │                     │ DBUtil.insertArticle                  │
     │                     │───────────────┐   │                   │
     │                     │               ▼   │                   │
     │                     │         ┌─────────┴──────┐            │
     │                     │         │   DB (MySQL)   │            │
     │                     │         └────────────────┘            │
     │                     │ next page? (isContinue)               │
     │                     │ sleep 1s                             │
     │                     │────────────────────► ...             │
     │                     │                                   │
     │                     │ StopException / 日期到 / 列表空      │
     │◄────────────────────│                                   │
     │   done              │                                   │

十一、可改进点与风险清单

这一节是 Code Review 重点,涵盖 10 个潜在改进项,可按优先级分批处理。

11.1 健壮性

  1. ⚠️ Thread.sleep 不响应中断语义外的中断信号,建议配合 isContinueHold 双重判断。
  2. ⚠️ extractedcatch (Exception) 仅记日志,可能「循环空转」。建议加连续失败计数,达到阈值再退出。
  3. ⚠️ JSON 拼接可换 JSONObject.toJSONString(...),杜绝特殊字符问题。

11.2 可观测性

  1. 🟡 缺少「已采集条数」「运行耗时」「当前最小日期」等指标,不便于监控。
  2. 🟡 pageNo==1 打印 200 字符响应体,对排查够用,但建议接入 MDC 写入 traceId。

11.3 反爬与可用性

  1. ⚠️ 没有重试机制(指数退避);网络抖动即丢一页。
  2. ⚠️ 没有代理池配置,大规模采集易被封 IP。建议抽象 HttpClient 注入点。
  3. ⚠️ UA 是常量,长时间使用同一指纹,建议定期从 user-agents 池随机取。

11.4 工程化

  1. 🟡 站点相关常量(URL/UA/Header)硬编码在类内,可提取 TXSiteProperties 配置类,便于多环境。
  2. 🟡 缺少单元测试(接口打桩),extractList / parseTimestamp 都是纯函数,极易写测试。

十二、扩展建议

12.1 配置化

java 复制代码
@Data
@Component
@ConfigurationProperties(prefix = "crawler.tx")
public class TXSiteProperties {
    private String apiUrl;
    private int pageSize = 20;
    private String sortType = "timeline";
    private long sleepMillis = 1000;
    private List<String> userAgents;
}

配合 application.yml:

yaml 复制代码
crawler:
  tx:
    api-url: https://cloud.tencent.com/developer/api/home/article-list
    page-size: 20
    sort-type: timeline
    sleep-millis: 1000

12.2 通用 HttpClient 抽象

java 复制代码
public interface ArticleListClient {
    String fetchPage(int pageNo) throws IOException;
}

TXListJsoup 只关心 JSON → POJO,具体 HTTP 实现可替换为 OkHttp / HttpClient5 / 代理。

12.3 单元测试示例(JUnit5 + Mockito)

java 复制代码
class TXListJsoupTest {
    @Test
    void parseTimestamp_seconds() {
        // 2024-01-01 00:00:00 UTC+8 → 1704038400 秒
        LocalDate d = invokeStatic("parseTimestamp", 1704038400L);
        assertEquals(LocalDate.of(2024, 1, 1), d);
    }
}

十三、调用入口与调度建议(推断)

🟡 基于工程命名(part1crawler.jishu.crawler4tx)推断存在调度入口,如:

  • JobController 暴露 HTTP 接口;
  • xxl-job / quartz 定时任务调用 TXListJsoup.crawler("2024-01-01", false)

建议参数:

  • 全量:crawler("1970-01-01", true)
  • 增量:crawler("1970-01-01", false)(靠重复停)

十四、关键代码索引(行号速查)

行号对应「当前读取到的 TXListJsoup.java」。

主题 行号
类声明 / 包名 1-2
类注释(背景、URL、入参) 14-28
常量声明 33-53
crawler 入口 61-89
extracted 单页逻辑 94-228
请求体构造 98-102
Jsoup POST 108-120
JSON 解析 122-136
排序探测 146-157
字段提取 / 入库 159-220
extractList 兼容 233-249
pickString 兼容 253-265
parseDateFlexible 270-283
parseTimestamp 289-299

十五、FAQ

Q1:为什么不直接 GET HTML 然后用 Jsoup 解析 DOM?

A:列表数据由前端 JS 异步加载,HTML 中只有空容器,所以选择「抓包找出真实接口 + Jsoup 调接口」。

Q2:pageNo 从 1 开始还是 0?

A:从 1 开始,腾讯云接口 page=0 会返回空,这是常见分页误区。

Q3:为什么 sortType 必须用 timeline?

A:站点默认按 createTime desc 排序,如果站点改版不传或传错,采集到的不是倒序,「截止日期」判断会失效,可能漏采或重采。

Q4:allowDuplicate 该传什么?

A:首次全量补齐 → true;日常增量 → false

Q5:遇到「响应是 HTML 而不是 JSON」怎么办?

A:站点可能触发风控返回登录页或验证码。当前实现会打 warn 并退出;建议在 catch 里重试 N 次或切换代理。

Q6:为什么把日期先转字符串再比较?

A:LocalDate.compareTo(stopDay) 看似更优雅,但 stopDay 是字符串(可能由调度方传入),保持字符串比较避免解析开销与时区误差。


十六、总结

TXListJsoup 是一个 职责清晰、风格统一、扩展友好 的轻量级列表爬虫:

  • 清晰:常量集中、方法单一、易读;
  • 统一 :骨架由 IsContinueHold + StopException + DBUtil 组成,工程内一致;
  • 友好:JSON / 字段多重兼容,降低站点改版带来的维护成本;
  • 轻量:零浏览器依赖、毫秒级启动、单实例资源占用 < 50MB;
  • 可控 :显式的 stopDay + 重复异常终止,采集策略明确。

后续可围绕 配置化、监控指标、重试/代理、单元测试 四个方向继续演进,使之成为多站点采集框架中的标杆实现。

代码

java 复制代码
package com.ruoyi.crawler.part1crawler.jishu.crawler4tx;

import com.ruoyi.crawler.part1crawler.common.DBUtil;
import com.ruoyi.crawler.part1crawler.common.IsContinueHold;
import com.ruoyi.crawler.part1crawler.common.SpiderArticle;
import com.ruoyi.crawler.part1crawler.common.StopException;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;

/**
 * 腾讯云开发者社区文章列表爬虫(轻量级 Jsoup 实现)
 *
 * <p>完全参考 AliList 的实现风格,使用 Jsoup 调内部 API 翻页,
 * 不依赖 Selenium / WebDriver,启动快、资源占用低。</p>
 *
 * <p>腾讯云列表是 JS 动态加载(「点击加载更多」按钮),
 * 前端实际调用的是 {@code POST /developer/api/home/article-list} 接口,
 * 入参为 JSON:{@code {"pageNumber":N,"pageSize":100,"type":"latest",
 * "orderBy":"createTime","order":"desc"}},
 * 其中 type=latest + orderBy=createTime 对应「最新优先」排序。</p>
 *
 * <p>数据结构:title / link / publishDate
 * 参考原 TXList.chgDbArticlesAnd2DB()</p>
 */
@Slf4j
public class TXListJsoup {

    /** 站点名称,写入数据库用 */
    private static final String SITE_NAME = "腾讯云";

    /**
     * 列表接口地址
     * 抓包实测:POST https://cloud.tencent.com/developer/api/home/article-list
     * Body:{"classifyId":0,"type":"recommend","pagesize":20,"page":N,"sortType":"timeline"}
     * sortType=timeline 对应「最新优先」排序
     */
    private static final String API_URL =
            "https://cloud.tencent.com/developer/api/home/article-list";

    /** 每页大小 */
    private static final int PAGE_SIZE = 20;

    /** 通用浏览器 UA(用浏览器真实 UA,降低风控) */
    private static final String UA =
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
            "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 QQBrowser/21.6.7003.400";

    /** 排序方式:timeline = 最新优先(按发布时间倒序) */
    private static final String SORT_TYPE = "timeline";

    /**
     * 入口方法
     *
     * @param stopDay        截止日期,采集到此日期(含)就停止
     * @param allowDuplicate 是否允许重复入库;true 时即使重复也继续,false 时遇重复抛 StopException
     */
    public static void crawler(String stopDay, boolean allowDuplicate) {
        IsContinueHold isContinueHold = new IsContinueHold();
        int pageNo = 1;

        while (true) {
            // 提前返回:终止条件判断
            if (!isContinueHold.isContinue) {
                break;
            }

            log.info("按页将文章信息入库--页数:{}", pageNo);
            try {
                extracted(pageNo++, isContinueHold, stopDay, allowDuplicate);
            } catch (StopException e) {
                log.info("遇到重复,抓取结束");
                break;
            }

            // 礼貌等待,避免请求过快
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }

        log.info("文章入库完成!");
    }

    /**
     * 抓取单页(走 JSON 接口翻页)
     */
    private static void extracted(int pageNo, IsContinueHold isContinueHold,
                                  String stopDay, boolean allowDuplicate) {
        // 排序策略:对应页面 tab 「最新优先」
        // 抓包实测字段:sortType=timeline
        String requestBody = "{\"classifyId\":0" +
                ",\"type\":\"recommend\"" +
                ",\"pagesize\":" + PAGE_SIZE +
                ",\"page\":" + pageNo +
                ",\"sortType\":\"" + SORT_TYPE + "\"}";

        try {
            log.info("正在爬取 {}, page={}, body={}", API_URL, pageNo, requestBody);

            // 直接 POST 到真实接口
            String responseJson = Jsoup.connect(API_URL)
                    .userAgent(UA)
                    .timeout(15000)
                    .header("Accept", "application/json, text/plain, */*")
                    .header("Content-Type", "application/json")
                    .header("Origin", "https://cloud.tencent.com")
                    .header("Referer",
                            "https://cloud.tencent.com/developer/column?from=19154")
                    .ignoreContentType(true)
                    .requestBody(requestBody)
                    .post()
                    .body()
                    .text();

            // 2. 解析 JSON(用 fastjson2,已有依赖)
            com.alibaba.fastjson2.JSONObject root =
                    com.alibaba.fastjson2.JSON.parseObject(responseJson);
            if (root == null) {
                log.warn("响应不是 JSON,page={}, body={}", pageNo,
                        responseJson.length() > 200 ? responseJson.substring(0, 200) + "..." : responseJson);
                isContinueHold.isContinue = false;
                return;
            }

            // 第一次请求时打印一次响应结构,便于排查字段名
            if (pageNo == 1) {
                log.info("接口响应结构(供排查): {}", responseJson.length() > 500
                        ? responseJson.substring(0, 500) + "..." : responseJson);
            }

            // 3. 兼容多种字段名:list / data / articles / records
            List<com.alibaba.fastjson2.JSONObject> list = extractList(root);
            if (list == null || list.isEmpty()) {
                log.info("page={} 已无数据,停止翻页", pageNo);
                isContinueHold.isContinue = false;
                return;
            }

            // 第一次请求时探测排序:打印前 3 条的 createTime,确认是否降序
            if (pageNo == 1) {
                log.info("=== 排序探测:前 3 条 createTime ===");
                for (int i = 0; i < Math.min(3, list.size()); i++) {
                    com.alibaba.fastjson2.JSONObject it = list.get(i);
                    log.info("[{}] articleId={}, title={}, createTime={}",
                            i + 1,
                            it.get("articleId"),
                            it.get("title"),
                            it.get("createTime"));
                }
            }

            // 4. 遍历文章,提取标题/链接/日期
            for (com.alibaba.fastjson2.JSONObject item : list) {
                String title = pickString(item, "title", "articleTitle", "name");

                // 链接:可能直接在顶层,也可能嵌套在 ext.url / articleUrl 等
                String link = pickString(item, "url", "link", "articleUrl", "href", "shortUrl");
                if (link == null || link.isEmpty()) {
                    // 尝试从 ext 子对象取
                    Object ext = item.get("ext");
                    if (ext instanceof com.alibaba.fastjson2.JSONObject) {
                        link = pickString((com.alibaba.fastjson2.JSONObject) ext,
                                "url", "link", "articleUrl", "href", "shortUrl");
                    }
                }
                // 兜底:用 articleId 拼接详情页 URL
                if (link == null || link.isEmpty()) {
                    Object articleId = item.get("articleId");
                    if (articleId != null) {
                        link = "https://cloud.tencent.com/developer/article/" + articleId;
                    }
                }

                // 日期:优先用 createTime(10位秒级时间戳),兼容字符串字段
                String dateStr = pickString(item, "publishTime", "publishDate",
                        "createTime", "ctime", "date");
                LocalDate publishDate = parseDateFlexible(dateStr);
                if (publishDate == null) {
                    // 时间戳直接读
                    Object ts = item.get("createTime");
                    if (ts == null) ts = item.get("publishTime");
                    if (ts == null) ts = item.get("updateTime");
                    if (ts instanceof Number) {
                        publishDate = parseTimestamp(((Number) ts).longValue());
                    }
                }

                // 标题为空就跳过
                if (title == null || title.isEmpty()) {
                    continue;
                }
                // 链接为空也跳过
                if (link == null || link.isEmpty()) {
                    continue;
                }
                // 日期为空也跳过(一般是专栏之类)
                if (publishDate == null) {
                    log.warn("日期解析失败,跳过:title={}", title);
                    continue;
                }

                // 截止日期判断:遇到 stopDay 就停
                String dateForCompare = publishDate.toString();
                if (dateForCompare.compareTo(stopDay) <= 0) {
                    log.info("达到截止日期 stopDay={},停止:{}", stopDay, dateForCompare);
                    isContinueHold.isContinue = false;
                    return;
                }

                SpiderArticle spiderArticle = new SpiderArticle(SITE_NAME, title, link, publishDate);
                log.info("文章信息:{}", spiderArticle);
                DBUtil.insertArticle(spiderArticle, allowDuplicate);
            }

        } catch (StopException e) {
            // 重复文章,透传给上层
            throw e;
        } catch (Exception e) {
            log.error("爬取失败:page={}, msg={}", pageNo, e.getMessage(), e);
        }
    }

    /**
     * 从响应 JSON 里提取文章列表,兼容多种字段名
     */
    @SuppressWarnings("unchecked")
    private static List<com.alibaba.fastjson2.JSONObject> extractList(
            com.alibaba.fastjson2.JSONObject root) {
        // 优先级:list → data → articles → records → rows
        for (String key : new String[]{"list", "data", "articles", "records", "rows"}) {
            Object v = root.get(key);
            if (v instanceof List) {
                return (List<com.alibaba.fastjson2.JSONObject>) v;
            }
        }
        // data 嵌套的情况:{"data":{"list":[...]}}
        Object data = root.get("data");
        if (data instanceof com.alibaba.fastjson2.JSONObject) {
            return extractList((com.alibaba.fastjson2.JSONObject) data);
        }
        return null;
    }

    /**
     * 从 JSON 对象里按候选 key 取字符串值
     */
    private static String pickString(com.alibaba.fastjson2.JSONObject obj, String... keys) {
        for (String key : keys) {
            Object v = obj.get(key);
            if (v != null) {
                String s = v.toString();
                if (!s.isEmpty() && !"null".equalsIgnoreCase(s)) {
                    return s;
                }
            }
        }
        return null;
    }

    /**
     * 灵活解析日期:先按字符串走 DBUtil.parseDate,
     * 失败则尝试当作时间戳处理
     */
    private static LocalDate parseDateFlexible(String dateStr) {
        if (dateStr == null || dateStr.isEmpty()) {
            return null;
        }
        // 数字字符串也当作时间戳
        if (dateStr.matches("\\d+")) {
            try {
                return parseTimestamp(Long.parseLong(dateStr));
            } catch (Exception ignored) {
            }
        }
        return DBUtil.parseDate(dateStr);
    }

    /**
     * 时间戳(秒/毫秒)转 LocalDate
     *
     * <p>自动识别:> 10^11 视为毫秒,否则视为秒</p>
     */
    private static LocalDate parseTimestamp(long ts) {
        try {
            long ms = ts > 100_000_000_000L ? ts : ts * 1000L;
            java.time.Instant instant = java.time.Instant.ofEpochMilli(ms);
            return instant.atZone(java.time.ZoneId.of("Asia/Shanghai")).toLocalDate();
        } catch (Exception e) {
            log.warn("时间戳解析失败:{}", ts);
            return null;
        }
    }
}
相关推荐
智码看视界1 小时前
Day49-AI微服务化-将大模型能力封装为标准微服务
java·微服务·ai·架构·大模型·sse流式输出·ai中台
Nebula_g1 小时前
JavaSE基础语法:特殊类(特殊情景下的设计模式)
java·开发语言·设计模式
2401_894915531 小时前
Geo 优化源码部署避坑指南:解决访问异常、定位失效、收录卡顿问题
java·服务器·后端·缓存·开源
Dovis(誓平步青云)1 小时前
《 固井工程软件 Cemsol 的数据管理与国产化适配实践》
android·java·开发语言·人工智能
大模型码小白1 小时前
AI安全前沿:AI大模型安全防护的前沿技术
java·网络·人工智能·python·深度学习·学习·安全
evans在进步1 小时前
LeetCode 34:在排序数组中查找元素的首尾位置——Java 两次二分查找详解
java·python·leetcode
@卓越俊逸_角立杰出@2 小时前
java实现Agent+ReAct demo(Spring Boot + Spring AI Alibaba ReAct Agent)
java·spring·react.js
看浪的路人2 小时前
第6讲:SQL 解析器
java·开发语言·数据库
weixin_440784112 小时前
【OkHttp实现原理】
android·java·okhttp