ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java爬虫实战:完整抓取CSDN个人博客文章的全流程方案

Java爬虫实战:完整抓取CSDN个人博客文章的全流程方案 简介一份面向有一定Java基础、希望实战网络爬虫的开发者用于自动化抓取CSDN个人博客文章并保存至本地解决手动复制备份效率低、数据分析取数难等问题。项目借助Jsoup等库实现HTTP请求与HTML解析针对CSDN博客的URL结构构造博主ID链接并遍历分页同时通过延时、代理等方式降低反爬风险。压缩包共十一个文件其中七个Java源文件为主辅以XML与Properties配置、Markdown说明文档等整体大小仅十九KB代码量不大但链路完整。当前已有207人学习/下载。通过源码可清晰看到从网络请求、页面解析到数据落盘如Markdown/文本或数据库的完整实现并能了解如何构造多页抓取、处理异常及应对反爬说明文档对运行环境和执行步骤亦有交代既适合入门爬虫也可直接用于CSDN文章备份。1. Java 爬虫实战一次把 CSDN 个人博客文章完整抓下来的完整方案做技术写作的人大概率都遇到过这种事自己辛辛苦苦写了几百篇博客想换平台或者做离线备份结果发现手动复制粘贴根本不现实。CSDN 的文章列表是分页加载的正文里还混着代码块、图片和推荐位广告单纯用正则去抠 HTML 很容易翻车。这篇笔记要分享的就是一套基于 Java 的爬虫实现目标很明确——输入一个 CSDN 用户名自动遍历他的全部文章列表逐篇抓取标题、发布时间、正文内容和代码块最后落成结构化的 HTML 文件方便本地检索或二次发布。适合有 Java 基础、想用 HttpClient 加 Jsoup 组合拳解决真实抓取需求的开发者也适合第一次接触爬虫、想理解请求—解析—存储完整链路的新手照着跑通一遍。2. 爬虫落地前的三个关键决策Robots 协议、分页规则与 User-Agent2.1 先用 Robots 协议和法律边界把项目定调写爬虫第一件事不是写代码是确认目标站点允不允许抓。CSDN 的 robots.txt 地址在https://www.csdn.net/robots.txt用浏览器直接访问就能看到它对爬虫的约束User-agent: *下面明确写了Disallow: /api/等路径。个人博客的文章列表页和详情页通常不在禁止列表里但因为页面是动态渲染的直接抓 HTML 拿不到列表数据真正有效的是调它的内部 API。这里要划一条操作红线只抓公开的个人文章数据、控制请求频率、不碰用户隐私数据和评论区信息抓下来的数据用于个人备份和学习不搞二次售卖和批量采集。明确了这几条后面写代码才不会有心理负担。2.2 摸清 CSDN 的个人博客分页机制列表页是 HTML数据接口是 JSONCSDN 个人博客的列表页 URL 长这样https://blog.csdn.net/{username}。用 Chrome 的开发者工具切到 Network 面板刷新页面后过滤 XHR 请求能看到一个名为/articles的接口完整地址是https://blog.csdn.net/community/home-api/v1/get-business-list。这个接口用 GET 请求就能访问关键参数有四个page控制页码、size控制每页条数、businessType固定为blog、username是目标用户 ID。返回的是 JSON里边的data.list就是当前页的文章摘要包含每个文章的articleId、title、createdAt等核心字段。这里有个容易踩的坑直接请求这个接口会报 400因为它在 Header 里要求带Referer。Referer 必须指向https://blog.csdn.net/{username}否则服务端会当成非法请求丢掉。另外size参数最大只接受 20传 100 进去虽然接口不报错但实际只回 20 条数据所以分页循环时别忘了这个限制。2.3 模拟浏览器请求头Cookie 和 User-Agent 的取舍CSDN 对请求头做了一定的反爬校验实测下来最简单的配置是带上完整的浏览器请求头核心就三样User-Agent、Referer和Cookie。User-Agent 用 Chrome 的就行比如Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36。Cookie 方面未登录状态也能抓公开文章但部分接口会对高频访问做频率限制稳妥的做法是先手动登录一次 CSDN从开发者工具里复制完整的 Cookie 字符串拼到请求头里。注意 Cookie 会过期代码里最好把 Cookie 单独抽成配置项过期时只改配置不用动代码逻辑。请求频率控制在每两秒一个请求CSDN 对突发请求的封禁阈值不算高宁慢勿快。3. 核心代码实现从文章列表接口到正文解析逐行拆解3.1 第一步用 HttpClient 请求列表接口并解析 JSON这里用 Apache HttpClient 4.5 版本示范Maven 坐标是org.apache.httpcomponents:httpclient:4.5.13。先写一个通用方法专门负责带请求头发 GET 请求并返回响应体字符串。import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import java.io.IOException; public class CsdnCrawler { private static final String USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36; private static final String REFERER https://blog.csdn.net/{username}; private static final String COOKIE 替换为登录后的Cookie; private String getJson(String url, String referer) throws IOException { CloseableHttpClient client HttpClients.createDefault(); HttpGet request new HttpGet(url); request.setHeader(User-Agent, USER_AGENT); request.setHeader(Referer, referer); request.setHeader(Cookie, COOKIE); request.setHeader(Accept, application/json, text/plain, */*); CloseableHttpResponse response client.execute(request); try { return EntityUtils.toString(response.getEntity(), UTF-8); } finally { client.close(); } } }这段代码的逻辑很简单每次请求都新建一个 HttpClient 实例把三样关键请求头全部带上然后返回 UTF-8 编码的响应体。注意{username}是占位符实际使用时要替换成目标用户的真实 ID。Accept头最好也加上CSDN 的接口对Accept的缺失偶尔会返回非 JSON 格式的响应。接下来用 fastjson 解析列表接口的 JSON 结构import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; public class ArticleListParser { public static void main(String[] args) throws IOException { CsdnCrawler crawler new CsdnCrawler(); String username 你的CSDN用户名; int page 1; int size 20; String apiUrl https://blog.csdn.net/community/home-api/v1/get-business-list?page page size size businessTypeblogusername username; String json crawler.getJson(apiUrl, https://blog.csdn.net/ username); JSONObject root JSON.parseObject(json); JSONObject data root.getJSONObject(data); JSONArray list data.getJSONArray(list); for (int i 0; i list.size(); i) { JSONObject item list.getJSONObject(i); String articleId item.getString(articleId); String title item.getString(title); String createdAt item.getString(createdAt); System.out.println(文章ID: articleId | 标题: title | 发布时间: createdAt); } } }这里的核心参数含义要弄清楚page从 1 开始计数返回空数组表示已经翻到最后一页这是循环终止的条件size上面说过最大 20固定传 20 减少请求次数businessType是接口的硬性参数友好一点的说法是业务类型标识但这个参数漏传会直接报错。createdAt字段是时间戳格式单位是毫秒直接拿来做离线备份的文件名很合适。3.2 第二步循环翻页拿全量文章列表上面的代码只抓一页要抓全量必须写循环。两个细节要注意一是判断list是否为空为空就 break二是控制请求间隔每页之间线程睡两秒。完整逻辑如下public class ArticleListLoop { public static void main(String[] args) throws IOException, InterruptedException { CsdnCrawler crawler new CsdnCrawler(); String username 你的CSDN用户名; int page 1; int totalCount 0; while (true) { String apiUrl https://blog.csdn.net/community/home-api/v1/get-business-list?page page size20businessTypeblogusername username; String json crawler.getJson(apiUrl, https://blog.csdn.net/ username); JSONObject root JSON.parseObject(json); JSONArray list root.getJSONObject(data).getJSONArray(list); if (list null || list.isEmpty()) { break; } for (int i 0; i list.size(); i) { JSONObject item list.getJSONObject(i); String articleId item.getString(articleId); String title item.getString(title); System.out.println(第 totalCount 篇: title (ID: articleId )); totalCount; } page; Thread.sleep(2000); // 控制请求频率防止触发反爬 } System.out.println(共抓取文章总数: totalCount); } }需要单独解释的是Thread.sleep(2000)这行。之前我试过完全不睡眠的写法跑到第 40 篇左右开始频繁触发 CSDN 的风控机制接口开始返回空的data字段页面里显示的也是验证码页。从实际经验看两秒是一个比较平衡的间隔抓 200 篇文章也就多花几分钟的事没必要为了省时间把自己 IP 搭进去。另外这段代码里root.getJSONObject(data)如果为 null 会抛空指针实战中要在 parse 之后先做一层判空这里简化处理了生产代码里建议用JSONObject data root.getJSONObject(data); if (data null) break;。3.3 第三步进入文章详情页用 Jsoup 精准提取正文拿到articleId列表后详情页的 URL 结构是https://blog.csdn.net/{username}/article/details/{articleId}。这个页面是纯服务端渲染的 HTMLJsoup 可以直接解析。核心提取逻辑围绕div#content_views展开这个节点就是博客正文的容器里边包含段落、代码块、图片和引用块。import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import java.io.IOException; public class ArticleDetailParser { private static final String USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36; public static String parseDetail(String username, String articleId) throws IOException { String url https://blog.csdn.net/ username /article/details/ articleId; Document doc Jsoup.connect(url) .userAgent(USER_AGENT) .referrer(https://blog.csdn.net/ username) .timeout(10000) .get(); // 文章标题 String title doc.select(h1.title-article).text(); // 发布时间 String time doc.select(span.time).first().attr(title); // 正文容器所有正文内容都在这个 div 里 Element contentDiv doc.selectFirst(div#content_views); // 提取所有图片地址 Elements images contentDiv.select(img); for (Element img : images) { String src img.attr(src); System.out.println(图片: src); } // 提取所有代码块 Elements codeBlocks contentDiv.select(pre code); for (Element code : codeBlocks) { String snippet code.text(); System.out.println(代码块: snippet); } // 输出纯文本版本 String text contentDiv.text(); System.out.println(正文纯文本: text); return contentDiv.html(); } }这段代码有几个值得注意的参数细节。h1.title-article是 CSDN 详情页文章标题的固定 CSS 类名不同时期页面模板偶尔会调整如果text()返回空检查一下页面上标题节点是否还在这个 class 里。span.time的title属性才是标准的时间格式直接.text()拿到的是发布于某年月日这种人性化文案做归档不友好。Jsoup.connect().timeout(10000)里的超时时间设的是 10 秒CSDN 详情页偶尔响应很慢超时设置太短容易误判失败。3.4 第四步把抓取结果落盘按文章 ID 保存 HTML 和 Markdown抓下来的正文需要持久化。最简单的方案是按文章 ID 建目录每篇文章保存两个文件一个原始 HTML 用于以后浏览器直接打开看样式一个 Markdown 纯文本用于内容复用。HTML 转 Markdown 可以用flexmark库Maven 坐标是com.vladsch.flexmark:flexmark-all:0.64.8但更可控的做法是自己用 Jsoup 对代码块、段落、标题做规则化处理输出标准的 Markdown 语法。import java.io.File; import java.io.FileWriter; import java.io.IOException; public class ArticleSaver { public static void save(String username, String articleId, String htmlContent) throws IOException { String dirPath output/ username / articleId; File dir new File(dirPath); if (!dir.exists()) { dir.mkdirs(); } // 保存原始 HTML try (FileWriter htmlWriter new FileWriter(new File(dirPath /article.html))) { htmlWriter.write(htmlContent); } // 转成 Markdown 后保存 String markdown htmlToMarkdown(htmlContent); try (FileWriter mdWriter new FileWriter(new File(dirPath /article.md))) { mdWriter.write(markdown); } } private static String htmlToMarkdown(String html) { Document doc Jsoup.parse(html); StringBuilder sb new StringBuilder(); Elements blocks doc.body().children(); for (Element block : blocks) { if (block.tagName().equals(pre)) { sb.append(java\n).append(block.text()).append(\n\n\n); } else if (block.tagName().equals(p)) { sb.append(block.text()).append(\n\n); } else if (block.tagName().startsWith(h)) { int level Integer.parseInt(block.tagName().substring(1)); sb.append(#.repeat(level)).append( ).append(block.text()).append(\n\n); } else { sb.append(block.text()).append(\n\n); } } return sb.toString(); } }这里只做了一层非常朴素的转换够用但不完美遇到嵌套的 div、列表和引用块会丢结构遇到图片标签会丢失图片地址。实战里我建议在这个基础上对img标签单独加一条规则把src转成![图片描述](图片地址)的格式这样备份的 Markdown 才具备真正的可用性。写文件时用了 try-with-resources 写法确保 FileWriter 用完即关避免频繁打开文件句柄导致的内存泄漏。4. 完整流程串联从用户名到全站文章备份一次性跑通4.1 主流程设计循环调接口 → 逐篇抓详情 → 落盘三步走把前面几个类串起来主流程就三件事第一件事是循环调列表接口拿到所有文章 ID第二件事是逐篇进详情页抓正文和元数据第三件事是把结果写到本地目录。下面是主程序完整示意public class CsdnBackupMain { public static void main(String[] args) throws Exception { String username 你的CSDN用户名; CsdnCrawler crawler new CsdnCrawler(); int page 1; int totalSaved 0; while (true) { String apiUrl https://blog.csdn.net/community/home-api/v1/get-business-list?page page size20businessTypeblogusername username; String json crawler.getJson(apiUrl, https://blog.csdn.net/ username); JSONObject root JSON.parseObject(json); JSONArray list root.getJSONObject(data).getJSONArray(list); if (list null || list.isEmpty()) { break; } for (int i 0; i list.size(); i) { JSONObject item list.getJSONObject(i); String articleId item.getString(articleId); String title item.getString(title); // 抓详情页 String detailHtml ArticleDetailParser.parseDetail(username, articleId); // 保存到本地 ArticleSaver.save(username, articleId, detailHtml); totalSaved; System.out.println(已保存: title); Thread.sleep(2000); } page; } System.out.println(全部完成共保存 totalSaved 篇文章); } }主流程里我刻意把两个sleep都保留下来列表接口之间睡 2 秒详情页之间也睡 2 秒。分开睡的原因在于详情页的请求频率更高因为每篇文章都要发一次新请求如果这里不限制触发风控的概率会更大。核心循环的逻辑缺点是没有断点续传功能中途挂了就得重新跑一遍后面我在 6.2 节会讲怎么用文件记录已抓取的文章 ID 来解决这个痛点。4.2 运行前需要处理的依赖清单和常见异常对照运行这个项目之前先把 Maven 依赖补全。除了前面用到的httpclient、fastjson、jsoup日志方面建议引入slf4j-api方便排错时定位请求失败的环节。以下是我实际项目里用的依赖版本都是稳定版依赖版本用途org.apache.httpcomponents:httpclient4.5.13发送 HTTP 请求管理请求头和连接com.alibaba:fastjson1.2.83解析列表接口的 JSON 响应org.jsoup:jsoup1.17.2解析详情页 HTML定位正文节点org.slf4j:slf4j-api1.7.36打印日志替换 System.out.println跑起来之后最常见的异常有三种。第一种是org.jsoup.HttpStatusException: HTTP error fetching URL状态码 403这几乎可以确定是请求头不全重点检查Referer和Cookie有没有正确拼接第二种是java.net.SocketTimeoutException: Read timed out原因是详情页响应太慢把timeout从 10000 加长到 15000 就行第三种是JSONException: syntax error多半是接口返回了{code:403,...}的鉴权错误而代码还按正常 JSON 结构去取data老实用root.getString(code)先做一次状态判断就完事。4.3 验证抓取结果怎么确认数据完整性而不是自欺欺人跑完之后别急着收工验证环节必须做。我常用的验证方式是写一个快速校验脚本对比列表接口返回的文章总数和本地实际保存的文件目录数量两者必须一致。具体方法是通过列表接口把total_count字段打出来这个字段在响应 JSON 里比较深路径是data.total然后在本地output/{username}目录下数一下子目录数量用 Java 自带的Files.list就能数清楚。另外一个常见问题有些文章设置了粉丝可见或 VIP 可见列表接口能拿到文章 ID但详情页抓不到正文contentDiv为 null。这种文章处理逻辑上是跳过但要在日志里单独记录articleId最后汇总时单独生成一份失败清单知道哪些文章没抓到它的原因可能是权限限制而不是代码 bug。5. 避坑手册CSDN 爬虫实战中我踩过的五个硬坑5.1 列表接口返回空数据的诡异问题Referer 漏了导致全军覆没现象接口单独用浏览器访问一切正常代码跑起来却拿不到任何文章列表data.list永远是 null。原因CSDN 的前端接口做了 Referer 校验浏览器访问时自动带了https://blog.csdn.net/作为 Referer而 HttpGet 默认带的 Referer 是http://localhost或空值服务端直接拒绝响应。解决在请求头里显式设置request.setHeader(Referer, https://blog.csdn.net/ username)而且 URL 里如果是{username}必须先替换成真实值再拼接使用模板字符串拼出来的地址中有花括号是常见的手误。5.2 Cookie 过期引发的字段全部为 0 的陷阱现象代码昨天还跑得好好的今天再运行列表接口返回{code:200,data:{list:[]}}文章总数变成 0。原因CSDN 的未登录访问会有匿名 Token浏览器里拿到的 Cookie 是登录态凭证Cookie 中的uuid_tt_dd和dc_session_id两个字段失效后接口只返回空数据不报 HTTP 错误码。解决把 Cookie 抽成独立的配置类每次运行前先用浏览器打开 CSDN 手动刷新一次登录态然后把document.cookie的值完整复制进配置。想省事的做法是写一个定时任务每天早上自动用 Chrome DevTools Protocol 重新登录并更新 Cookie 文件但这套方案依赖本地浏览器环境工作上不是必须的话不用上。5.3 代码块内容丢失text() 方法吞掉了缩进和换行现象正文里pre code是完整的代码片段但用code.text()提取后所有换行和缩进全部变成空格挤在一行。原因Jsoup 的text()方法返回的是元素内所有可见文本的拼接不保留任何空白字符。代码块恰恰是最需要保留换行和缩进的场景。解决改用code.html()配合 HTML 实体解码拿原始内容或者用 Java 的String工具手动恢复换行。更省事的是对pre节点整体取html()然后做\n的保留处理。我的实际做法是直接抓pre节点里面会有一层code把pre.outerHtml()存到数据库里渲染时再用前端代码高亮这样最稳妥。5.4 图片懒加载导致图片全部无法下载现象保存的 HTML 里能看到 img 标签但所有src都是一张占位图没有真实图片地址。原因CSDN 详情页的图片用的是懒加载策略真实地址在>import java.nio.file.Files; import java.nio.file.Paths; import java.util.HashSet; import java.util.Set; public class ResumeHelper { private static final String DONE_FILE output/done.txt; public static SetString loadDoneIds() throws IOException { SetString ids new HashSet(); if (Files.exists(Paths.get(DONE_FILE))) { for (String line : Files.readAllLines(Paths.get(DONE_FILE), java.nio.charset.StandardCharsets.UTF_8)) { ids.add(line.trim()); } } return ids; } public static void markDone(String articleId) throws IOException { Files.write(Paths.get(DONE_FILE), (articleId System.lineSeparator()).getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); } }这段代码里StandardOpenOption.APPEND是关键参数确保多次写入是追加而不是覆盖。loadDoneIds每次启动时重建集合内存开销对几千篇文章来说可以忽略不计。注意done.txt和文章保存目录放一起备份时整体打包状态文件也不会丢。6.2 增量抓取策略定期更新只拉新增文章如果目标用户经常发新文章每次全量抓取其实很浪费。增量方案很简单用data.total做第一层判断——本地已有文章数等于线上总数就直接退出如果线上总数多只抓比本地最大页码多出来的那几页。这里的边界条件是新增文章可能不止一页所以仍然要做分页循环但循环起始页可以直接跳到本地文章总数 / 20 1把请求次数压到最少。用 SQLite 做存储也是一个选项一篇文章一行记录字段有article_id、title、created_at、html_content、markdown_content、updated_at。SQLite 单文件存储对归档场景更友好不需要起服务Java 里的sqlite-jdbc驱动直接连接即可。我自己主力方案是文件系统目录加分页索引原因是文件系统对图片和资源的存放更直观SQLite 的 BLOB 存储对大段 HTML 的读写性能反而没有文件系统好。6.3 验证频率控制的产出抓取完成后的自动化检查脚本最后一步值得做的是自动化校验写一个独立的小脚本遍历抓取目录检查每个article.html是否包含content_views这个标志节点、文件大小是否小于 1KB、代码块是否缺失了pre标签。这三项能过滤掉 95% 以上的抓取异常省去人工核对的时间。find output -name article.html -size -1k | wc -l上面这条 shell 命令能快速统计出小于 1KB 的 HTML 文件数量文件太小说明正文没抓到大概率是权限受限的文章。更精细的检查可以用 Java 写先解析 HTML 再统计pre标签数量与数据库记录比对但日常使用find已经够用。从开始用这套方案做 CSDN 备份到现在我已经把三四个账号的全部文章做了本地归档最近一次跑的 200 多篇只用了不到 15 分钟中途零失败。从那以后我每次换电脑都会先跑一遍这个爬虫把云上文章拉下来权当给写作记录上一道保险。代码里每个请求头、每个 sleep 参数都是一次次翻车后调出来的你照着跑大概率能一遍过但真遇到问题也别慌优先查 Referer 和 Cookie 这两个最容易出幺蛾子的地方希望帮到你。本文还有配套的精品资源点击获取
返回列表