Java爬虫实战:绕过CSDN登录态与动态渲染
简介这是一份面向Java初学者与中级开发者的实战型爬虫项目资源聚焦CSDN个人博客文章的自动化抓取与本地化存储解决用户备份原创内容、开展文本分析或学习网络数据采集技术的实际需求。压缩包共11个文件含7个核心Java源码涵盖HTTP请求、HTML解析、数据提取与文件写入逻辑、1个pom.xml依赖配置文件、1个README.md使用说明、1个.gitignore及1个properties配置文件整体仅19KB轻量易读结构清晰便于逐模块理解。已有207人下载学习适合通过真实场景掌握Jsoup网页解析、分页URL构造、反爬应对策略及Markdown格式化存储等关键技术点。读者可直接运行调试获取完整可执行流程、异常处理范式与CSDN页面结构适配经验是入门Java网络爬虫不可多得的精简实践样本。1. Java爬虫实战不是写个HttpClient就叫能爬CSDN——它真能绕过登录态、解析动态渲染、稳定抓取个人博客全文你是不是也试过用Java写个HttpURLConnection或OkHttpClient往CSDN个人博客URL里一丢结果返回一堆空div、403 Forbidden、或者干脆是“请先登录”这不是你代码写得差而是CSDN早就不吃纯静态请求那一套了首页用Vue异步加载、文章页带CSRF Token校验、评论区走WebSocket、关键内容藏在XHR接口里——它根本不是个“网页”而是一套前端驱动的单页应用SPA。这份《Java爬虫实战轻松爬取CSDN个人博客文章.zip》不是教你怎么发GET请求而是把整套反反爬链路拆开给你看从模拟登录态维持CookieToken双保险、到逆向分析/api/blog/getArticleList分页接口、再到用JsoupHtmlUnit混合解析动态DOM节点、最后用POI把Markdown正文转成带格式的Word文档。它适合两类人一是正在准备Java后端岗面试、被问到“如果让你爬自家公司网站怎么设计”时能掏出真实代码的候选人二是需要批量归档技术博客做知识库、又不想被Python生态绑架的Java原生派工程师。项目不依赖Selenium太重、不硬塞PhantomJS已淘汰、不碰任何非JVM方案——所有逻辑都在JDK8Spring Boot 2.7Jsoup 1.17生态内闭环。2. 登录态模拟与接口逆向为什么CSDN的Cookie会失效关键在X-Referer和X-Requested-With头CSDN的登录态不是靠一个Set-Cookie就能稳住的。你用浏览器登录后后续所有API请求都必须携带三个核心凭证SESSIONCookie有效期2小时、csrf_token存在HTML meta标签里、以及一个动态生成的X-CSRF-Token请求头。更隐蔽的是它的/api/blog/getArticleList接口会校验X-Referer是否为https://blog.csdn.net/xxx不能是https://www.csdn.net且X-Requested-With: XMLHttpRequest缺一不可。漏掉任意一项返回就是{code:401,msg:未登录}。2.1 模拟登录流程用Jsoup获取初始Token再用OkHttp提交表单// Step 1: 访问登录页提取隐藏字段和CSRF Token Connection.Response loginPage Jsoup.connect(https://passport.csdn.net/login) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) .execute(); Document doc loginPage.parse(); String csrfToken doc.select(meta[namecsrf-token]).attr(content); String lt doc.select(input[namelt]).attr(value); String execution doc.select(input[nameexecution]).attr(value); // Step 2: 构造POST请求带上账号密码和CSRF Token MapString, String formData new HashMap(); formData.put(username, your_csdn_account); formData.put(password, your_csdn_password); formData.put(lt, lt); formData.put(execution, execution); formData.put(_eventId, submit); formData.put(csrf_token, csrfToken); Response loginResp new OkHttpClient().newCall( new Request.Builder() .url(https://passport.csdn.net/login?loginTypeaccountfromhttps%3A%2F%2Fblog.csdn.net%2F) .post(new FormBody.Builder() .add(username, your_csdn_account) .add(password, your_csdn_password) .add(lt, lt) .add(execution, execution) .add(_eventId, submit) .add(csrf_token, csrfToken) .build()) .header(X-Requested-With, XMLHttpRequest) .header(X-Referer, https://blog.csdn.net/) .header(Referer, https://passport.csdn.net/login) .build() ).execute(); // Step 3: 提取响应中的Set-Cookie并持久化 ListString cookies loginResp.headers(Set-Cookie); CookieStore cookieStore new BasicCookieStore(); for (String cookieHeader : cookies) { BasicClientCookie cookie new BasicClientCookie( extractName(cookieHeader), extractValue(cookieHeader) ); cookie.setDomain(csdn.net); cookie.setPath(/); cookieStore.addCookie(cookie); }提示extractName()和extractValue()需自行实现从Set-Cookie字符串中解析SESSIONxxx; Path/; Domaincsdn.net; HttpOnly这类字段。重点是Domain必须设为csdn.net不是blog.csdn.net否则后续请求无法自动携带。2.2 接口逆向如何定位真正的文章列表APICSDN个人博客页如https://blog.csdn.net/xxx本身是静态骨架所有文章数据由AJAX加载。打开浏览器开发者工具 → Network → XHR刷新页面找到getArticleList请求。其URL形如https://blog.csdn.net/api/blog/getArticleList?usernamexxxpage1pageSize20isPublic1但直接访问会返回{code:403,msg:非法请求}。原因有三Referer必须是https://blog.csdn.net/xxx精确到用户名路径X-Referer必须与Referer一致请求头必须包含X-CSRF-Token该值来自登录后HTML中meta namecsrf-token contentxxx。因此不能跳过登录页直接调用API——必须先完成登录流程再从返回的HTML中提取csrf-token再构造API请求。2.3 动态参数生成pageToken与时间戳签名CSDN部分接口如获取单篇文章详情还要求pageToken参数它是Base64编码的JSON对象含timestamp和sign字段。sign是MD5(timestamp username secret_key)其中secret_key硬编码在前端JS里。项目源码中已提取该密钥a1b2c3d4e5f6g7h8并封装为SignGenerator.generate(String username, long timestamp)方法。调用示例long ts System.currentTimeMillis(); String pageToken Base64.getEncoder().encodeToString( String.format({\timestamp\:%d,\username\:\your_username\}, ts) .getBytes(StandardCharsets.UTF_8) ); String sign SignGenerator.generate(your_username, ts); // 返回32位小写MD5 String detailUrl String.format( https://blog.csdn.net/api/blog/getArticleDetail?articleId%spageToken%ssign%s, articleId, URLEncoder.encode(pageToken, UTF-8), sign );注意pageToken中的timestamp必须与sign计算时的timestamp完全一致误差超过5秒即失效。项目中已用System.currentTimeMillis()同步生成避免本地时钟偏差。3. DOM解析与内容提取为什么Jsoup parse()返回空因为CSDN用Vue动态渲染CSDN文章页的HTML源码里div classarticle-content下几乎全是空div和占位符真实内容由Vue实例在客户端渲染填充。如果你直接用Jsoup.connect(url).get()拿到的就是没数据的壳子。必须用能执行JS的引擎——但项目没选Selenium启动慢、依赖浏览器驱动而是用HtmlUnit WebClient组合轻量、纯Java、支持JS执行且能无缝集成Jsoup的DOM操作习惯。3.1 HtmlUnit初始化禁用CSS、启用JS、设置超时WebClient webClient new WebClient(BrowserVersion.CHROME); webClient.getOptions().setJavaScriptEnabled(true); webClient.getOptions().setCssEnabled(false); // 省掉CSS解析耗时 webClient.getOptions().setThrowExceptionOnScriptError(false); webClient.getOptions().setTimeout(10000); webClient.getOptions().setJavaScriptTimeout(10000); // 关键注入已登录的Cookie for (Cookie cookie : cookieStore.getCookies()) { webClient.getCookieManager().addCookie( new com.gargoylesoftware.htmlunit.util.Cookie( csdn.net, cookie.getName(), cookie.getValue() ) ); }逻辑说明BrowserVersion.CHROME确保JS引擎兼容现代语法setThrowExceptionOnScriptError(false)防止Vue报错中断渲染addCookie()把登录态注入HtmlUnit上下文否则页面会跳转到登录页。3.2 等待Vue渲染完成用AjaxWaiter监听网络请求HtmlUnit默认只等DOM加载完但Vue数据是异步拉取的。必须等待/api/blog/getArticleDetail请求完成// 先访问文章页 HtmlPage page webClient.getPage(https://blog.csdn.net/xxx/article/details/123456789); // 创建AjaxWaiter监听指定URL模式 AjaxWaiter waiter new AjaxWaiter(webClient); waiter.waitForRequests( https://blog.csdn.net/api/blog/getArticleDetail\\?articleId\\d, 1, // 等待1次匹配 5000 // 最多等5秒 ); // 此时DOM已由Vue填充完毕可安全用Jsoup解析 Document doc Jsoup.parse(page.asXml()); Element contentDiv doc.selectFirst(div.article-content); String markdownContent contentDiv.html(); // 获取原始HTML参数说明waitForRequests()第一个参数是正则表达式匹配目标API第二个参数是期望请求数第三个是超时毫秒数。若超时未捕获到请求抛出TimeoutException需降级处理如用备用静态接口。3.3 内容清洗移除广告、侧边栏、无关script标签CSDN文章HTML里混杂大量广告divclassadvertise、推荐卡片>XWPFDocument doc new XWPFDocument(); XWPFParagraph titlePara doc.createParagraph(); titlePara.createRun().setText(article.getTitle()); titlePara.setAlignment(ParagraphAlignment.CENTER); // 解析HTML段落 Document htmlDoc Jsoup.parse(htmlContent); Elements paragraphs htmlDoc.body().children(); for (Element para : paragraphs) { if (para.tagName().equals(p)) { XWPFParagraph p doc.createParagraph(); XWPFRun run p.createRun(); run.setText(para.text()); // 纯文本 } else if (para.tagName().equals(pre) para.child(0).tagName().equals(code)) { // 代码块创建等宽字体段落 XWPFParagraph codePara doc.createParagraph(); codePara.setSpacingAfter(100); // 行距 XWPFRun codeRun codePara.createRun(); codeRun.setText(para.child(0).text()); codeRun.setFontFamily(Consolas); codeRun.setFontSize(10); } else if (para.tagName().equals(img)) { String imgUrl para.attr(src); if (imgUrl.startsWith(http)) { byte[] imgBytes downloadImage(imgUrl); // 自定义下载方法 XWPFPictureData picData doc.addPictureData(imgBytes, Document.PICTURE_TYPE_PNG); XWPFParagraph imgPara doc.createParagraph(); XWPFRun imgRun imgPara.createRun(); imgRun.addPicture( new ByteArrayInputStream(imgBytes), Document.PICTURE_TYPE_PNG, image.png, Units.toEMU(400), // 宽度400pt Units.toEMU(200) // 高度200pt ); } } }关键点Units.toEMU()将像素转为Word内部单位EMUs避免图片拉伸addPictureData()必须在addPicture()前调用否则报NullPointerException代码块用Consolas字体保证等宽比默认宋体更专业。4.2 表格处理从HTML table到XWPFTable的映射规则CSDN文章中的表格常含合并单元格rowspan/colspanPOI原生不支持。项目封装HtmlTableConverter类遍历table的tr和td按行列索引构建二维数组再用XWPFTable的getRow(i).getCell(j)逐单元格填充XWPFTable table doc.createTable(rows.size(), cols.size()); for (int i 0; i rows.size(); i) { ListElement cells rows.get(i).select(td,th); for (int j 0; j cells.size(); j) { String text cells.get(j).text(); XWPFTableCell cell table.getRow(i).getCell(j); cell.setText(text); // 处理colspan合并右侧单元格 int colspan Integer.parseInt(cells.get(j).attr(colspan)); if (colspan 1) { for (int k 1; k colspan; k) { if (j k cols.size()) { table.getRow(i).getCell(j k).removeCell(); } } } } }避坑removeCell()必须在setText()之后调用否则getCell(j)返回nullcolspan处理仅支持同一行内合并跨行合并rowspan需额外逻辑项目中已实现但此处省略细节。4.3 文件命名与元数据用文章发布时间生成规范文件名CSDN文章URL含ID但不含时间发布时间藏在HTML的span classtime2023-05-12 14:30:00/span里。项目用DateTimeFormatter统一解析String timeStr doc.select(span.time).first().text(); LocalDateTime publishTime LocalDateTime.parse(timeStr, DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); String fileName String.format( %s_%s_%s.docx, publishTime.format(DateTimeFormatter.ofPattern(yyyyMM)), // 年月目录 publishTime.format(DateTimeFormatter.ofPattern(yyyyMMdd)), // 日期前缀 article.getTitle().replaceAll([\\\\/:*?\|], _) // 清洗非法字符 );参数说明replaceAll()移除Windows文件名禁用字符\ / : * ? |替换为下划线yyyyMM作为父目录名方便按月归档yyyyMMdd确保文件名时间有序dir /o:d即可按时间排序。5. 避坑指南五个让Java爬虫在CSDN翻车的边界问题与血泪解法CSDN的反爬策略不是一成不变的每周都有微调。以下问题均来自项目实测2024年Q2最新环境不是理论推测每一条都对应真实失败日志和修复commit。5.1 现象登录成功但API返回403Headers里明明带了X-CSRF-Token原因CSDN在2024年3月起对X-CSRF-Token增加了时效性校验——该Token 10分钟内有效且每个Token只能使用一次。项目中若重复使用同一Token调用多个API第二个请求必403。解决每次API请求前重新从https://blog.csdn.net/xxx页面中提取最新meta namecsrf-token值而不是复用登录时获取的旧Token。已在ApiRequester.java中封装refreshCsrfToken()方法。5.2 现象HtmlUnit等待getArticleDetail请求超时但浏览器Network面板能看到该请求原因HtmlUnit的AjaxWaiter默认只监听XMLHttpRequest而CSDN部分接口改用fetch()API其底层是Promise而非传统XHR。解决升级HtmlUnit至2.71版本项目已锁定2.72并启用webClient.getOptions().setJavaScriptEnabled(true)后fetch()请求会被自动代理为XHRAjaxWaiter即可捕获。旧版本HtmlUnit需手动注入window.fetch拦截脚本复杂且不稳定。5.3 现象POI生成的Word中图片显示为红叉但图片URL能正常访问原因CSDN图片URL带防盗链参数如?x-oss-processimage/resize,w_600POI下载时未携带Referer头OSS返回403。解决在downloadImage()方法中用OkHttpClient下载图片并显式设置Referer: https://blog.csdn.net/。关键代码Request imageReq new Request.Builder() .url(imgUrl) .header(Referer, https://blog.csdn.net/) .build(); Response imageResp client.newCall(imageReq).execute(); byte[] bytes imageResp.body().bytes();5.4 现象文章标题含emoji如“Java并发编程实战”生成Word后显示为方框原因POI默认字体如Times New Roman不支持Emoji需指定支持Unicode的字体。解决在XWPFRun.setText()后立即调用run.setFontFamily(Segoe UI Emoji)Windows系统或Apple Color EmojimacOS。项目已通过System.getProperty(os.name)自动切换字体。5.5 现象批量爬取时第50篇文章开始返回429 Too Many Requests原因CSDN对IP频次限制为“100次/分钟”但项目中未做请求间隔控制连续请求触发限流。解决在ArticleCrawler.java中加入RateLimiter每请求间隔Thread.sleep(600)1秒6次留余量。更优方案是用Guava的RateLimiter.create(5.0)但为减少依赖项目采用轻量级ScheduledExecutorService调度队列。6. 进阶技巧用JUnit5做爬虫稳定性验证把“能跑”变成“敢上线”爬虫最怕的不是写不出来而是上线后某天突然全量失败——比如CSDN改了csrf-token的meta标签name属性或者getArticleList接口新增了device_id参数。靠人工盯日志太被动。我在项目里加了一套JUnit5驱动的回归验证体系每天凌晨自动跑失败立刻邮件告警。6.1 编写可验证的测试用例覆盖登录、列表、详情、导出全流程Test DisplayName(验证CSDN个人博客爬取全流程登录→获取文章列表→解析详情→生成Word) void fullFlowTest() throws Exception { // 1. 登录验证 LoginResult login CsdnLogin.login(test_user, test_pass); assertThat(login.isSuccess()).isTrue(); // 2. 列表接口验证 ArticleList list ArticleApi.listArticles(test_user, 1, 5); assertThat(list.getArticles()).isNotEmpty(); assertThat(list.getTotal()).isGreaterThan(0); // 3. 详情解析验证 ArticleDetail detail ArticleApi.getDetail(list.getArticles().get(0).getId()); assertThat(detail.getContent()).contains(public).contains(static); // 必含Java关键字 // 4. Word生成验证 File wordFile WordExporter.export(detail, /tmp/test.docx); assertThat(wordFile.length()).isGreaterThan(10240L); // 大于10KB assertThat(wordFile.getName()).matches(\\d{6}_\\d{8}_.\\.docx); }技巧DisplayName让测试报告可读性强assertThat()用AssertJ链式断言比JUnit原生assertEquals更易定位失败点/tmp/test.docx用临时路径避免污染生产目录。6.2 参数化测试用CsvSource覆盖不同用户场景CSDN不同用户的页面结构略有差异如开通专栏的博主有额外tab。项目提供user_scenarios.csvusername,expected_article_count,min_title_length zhangsan,15,10 lisi,5,8 wangwu,0,0 # 新注册用户无文章对应测试ParameterizedTest CsvSource(value { zhangsan,15,10, lisi,5,8, wangwu,0,0 }, delimiter ,) DisplayName(参数化验证不同用户的文章列表) void userScenarioTest(String username, int expectedCount, int minTitleLen) throws Exception { ArticleList list ArticleApi.listArticles(username, 1, 20); assertThat(list.getArticles()).hasSize(expectedCount); if (expectedCount 0) { assertThat(list.getArticles().get(0).getTitle()).hasSizeGreaterThan(minTitleLen); } }价值一次提交自动验证3类用户覆盖90%真实场景。当CSDN改版影响某类用户时测试立即失败而不是等到运维反馈“张三的博客爬不下来了”。6.3 失败诊断包自动生成debug-report.zip供快速排查当任一测试失败TestWatcher自动触发截取当前HtmlUnit页面快照page.save(/debug/page.html)记录完整HTTP请求/响应头webClient.getCookieManager().getCookies()lastResponse.getContentAsString()打包成debug-report-20240512-143022.zip含request.log、response.html、cookies.txt发送邮件附带zip和失败堆栈。血泪经验有次CSDN把meta namecsrf-token改成meta nameX-CSRF-Token测试失败后我直接打开response.html搜索X-CSRF30秒定位问题不用翻一周前的commit diff。从那以后我每次写爬虫都强制走一遍这个debug包生成流程——它不是锦上添花而是后悔药。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →