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、思否、博客园、掘金等)抓取技术文章列表,用于:
- 内容聚合:统一汇聚到内部知识库 / 大数据平台。
- 运营分析:作为后续 AI 训练 / 智能问答 / 标签画像的语料源。
- 监测舆情:追踪大厂技术风向、热门标签。
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 | ✅ 显式设置Origin 与 Referer,模拟来自列表页 |
| 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 动态渲染,直接
GETHTML 拿不到数据;前端会触发这个接口。
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)
│
└─────────► 回到循环顶部
关键点说明:
IsContinueHold:为引用类型,可被extracted()内部修改,达到「任一层级都能终止」的目的,避免使用全局静态变量。pageNo++自增时机 :在调用extracted的同时自增,采用「先使用、后递增」风格。Thread.sleep(1000):礼貌等待,降低单 IP 请求频率;⚠️ 若需更激进的并行,应改为配置项。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 步处理:
-
标题 :候选
title / articleTitle / name。 -
链接 :候选
url / link / articleUrl / href / shortUrl;若顶层无,进入ext子对象查找;若仍无,根据articleId拼出:https://cloud.tencent.com/developer/article/{articleId} -
日期 :候选
publishTime / publishDate / createTime / ctime / date;- 优先按字符串解析(
DBUtil.parseDate); - 失败回退按数字时间戳解析(秒/毫秒自动识别);
- 都失败则跳过该条(可能是「专栏」类无日期)。
- 优先按字符串解析(
-
截止日期判断 :
publishDate <= stopDay时停爬。 -
入库 :
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 健壮性
- ⚠️
Thread.sleep不响应中断语义外的中断信号,建议配合isContinueHold双重判断。 - ⚠️
extracted中catch (Exception)仅记日志,可能「循环空转」。建议加连续失败计数,达到阈值再退出。 - ⚠️ JSON 拼接可换
JSONObject.toJSONString(...),杜绝特殊字符问题。
11.2 可观测性
- 🟡 缺少「已采集条数」「运行耗时」「当前最小日期」等指标,不便于监控。
- 🟡
pageNo==1打印 200 字符响应体,对排查够用,但建议接入MDC写入 traceId。
11.3 反爬与可用性
- ⚠️ 没有重试机制(指数退避);网络抖动即丢一页。
- ⚠️ 没有代理池配置,大规模采集易被封 IP。建议抽象
HttpClient注入点。 - ⚠️ UA 是常量,长时间使用同一指纹,建议定期从
user-agents池随机取。
11.4 工程化
- 🟡 站点相关常量(URL/UA/Header)硬编码在类内,可提取
TXSiteProperties配置类,便于多环境。 - 🟡 缺少单元测试(接口打桩),
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;
}
}
}