:URL構(gòu)造、DOM定位與反爬穩(wěn)定性)
簡介本資源是一份基于Java的jsoup分頁爬蟲實戰(zhàn)項目面向Java初學者及網(wǎng)頁數(shù)據(jù)采集入門開發(fā)者解決多頁HTML內(nèi)容自動化抓取與結(jié)構(gòu)化解析的核心問題。壓縮包共16個文件含7個關(guān)鍵jar依賴如jsoup-1.9.2.jar、WebCollector-2.52.jar等、1個主程序Java源碼、1個編譯生成的class文件以及Eclipse項目配置文件.project、.classpath、.settings/prefs完整復現(xiàn)了從工程搭建、分頁URL構(gòu)造、CSS選擇器提取到多頁循環(huán)抓取的全流程。資源大小3.08MB結(jié)構(gòu)精簡、開箱即用便于快速理解jsoup在真實分頁場景下的典型用法包括動態(tài)頁碼拼接、鏈接解析、異常重試邏輯和基礎(chǔ)數(shù)據(jù)清洗思路。目前已有1080人學習下載適合用于課堂實驗、課設實踐或自學爬蟲技術(shù)棧的起步訓練。1. jsoup分頁爬取網(wǎng)頁不是寫個 for 循環(huán)就能跑通的黑匣子而是要親手拆開 URL 構(gòu)造、狀態(tài)維持和 DOM 邊界三重鎖你寫了個for (int i 1; i 100; i) { Document doc Jsoup.connect(https://example.com/list?page i).get(); }跑完發(fā)現(xiàn)第3頁開始全是空列表、第17頁返回 403、第42頁數(shù)據(jù)突然錯位——這不是代碼沒寫完是根本沒摸清分頁系統(tǒng)的底層契約。jsoup 分頁爬取從來不是“HTTP 工具調(diào)用”而是一場對目標網(wǎng)站結(jié)構(gòu)策略的逆向工程它要求你識別真實翻頁邏輯是純參數(shù)跳轉(zhuǎn)帶 token 的 POST還是前端 JS 動態(tài)加載維持會話上下文Cookie、Referer、User-Agent 是否被校驗并精準錨定每頁的數(shù)據(jù)容器邊界.item-list li還是#content .post。這份實戰(zhàn)筆記不講 Jsoup 基礎(chǔ) API只聚焦「分頁場景下那些讓爬蟲在凌晨三點崩潰的細節(jié)」從 URL 模式識別到反爬響應攔截從 DOM 定位漂移排查到多頁結(jié)果去重落地。適合已能用 Jsoup 抓單頁、但一加循環(huán)就翻車的開發(fā)者尤其適合處理企業(yè)內(nèi)網(wǎng)文檔庫、招標公告站、學術(shù)成果聚合頁等真實業(yè)務分頁源。2. 分頁邏輯識別與 URL 構(gòu)造先看懂網(wǎng)站怎么“說人話”再讓 jsoup 聽懂分頁不是數(shù)學題是閱讀理解。同一套“第 N 頁”語義在不同系統(tǒng)里可能對應完全不同的技術(shù)實現(xiàn)。盲目拼接?page2可能直接撞上 404 或跳回首頁。必須通過人工觀察網(wǎng)絡面板驗證確認真實分頁機制。2.1 三類主流分頁模式及其 jsoup 應對策略分頁類型典型特征網(wǎng)絡請求表現(xiàn)jsoup 關(guān)鍵操作純 GET 參數(shù)分頁URL 明確含?page2size20點擊頁碼后地址欄實時變化單次 GET 請求響應體含完整 HTML直接構(gòu)造 URL注意 URL 編碼如中文參數(shù)POST 表單分頁頁面有form methodpost頁碼通過 hidden input 提交發(fā)起 POST 請求Body 含pageNum2pageSize20使用.data(pageNum, 2)設置表單字段.method(Connection.Method.POST)AJAX/JSON 分頁點擊頁碼無整頁刷新Network 面板出現(xiàn)/api/list?page2類 XHR 請求返回 JSON 數(shù)據(jù)非 HTML需額外解析jsoup 不適用應切換為 HttpClient JSON 解析提示90% 的翻車始于誤判類型。務必打開瀏覽器開發(fā)者工具 → Network → 切換到 XHR/JS 選項卡點擊頁碼觀察發(fā)起的是哪個請求、Method 是什么、Payload 傳了什么。不要相信頁面上顯示的 URL。2.2 URL 構(gòu)造實操從手動拼接到動態(tài)生成假設目標站分頁 URL 格式為https://example.com/news?page1categorytech且 category 是固定值// ? 正確使用 Jsoup 提供的 URL 工具類自動處理編碼 String baseUrl https://example.com/news; String category tech; int pageNum 5; // 構(gòu)造帶參數(shù)的 URLJsoup 會自動 encode 中文、特殊字符 String url String.format(%s?page%dcategory%s, baseUrl, pageNum, URLEncoder.encode(category, StandardCharsets.UTF_8)); Connection connection Jsoup.connect(url) .userAgent(Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36) .timeout(10000); Document doc connection.get();參數(shù)說明URLEncoder.encode(...)強制對非 ASCII 字符如中文 category 名編碼避免 400 Bad Request.userAgent(...)必須設置多數(shù)網(wǎng)站對空 UA 直接拒絕.timeout(10000)分頁請求常因服務端慢響應超時10 秒是較穩(wěn)妥下限若分頁依賴上一頁的某個 token如input typehidden namecsrf_token valueabc123則不能靜態(tài)構(gòu)造 URL必須先 GET 第一頁解析出 token 再用于后續(xù)請求// 第一步獲取第一頁提取隱藏 token Document firstPage Jsoup.connect(https://example.com/news?page1).get(); String csrfToken firstPage.select(input[namecsrf_token]).attr(value); // 第二步構(gòu)造第二頁請求攜帶 token Connection nextPageConn Jsoup.connect(https://example.com/news?page2) .data(csrf_token, csrfToken) // 作為 POST 字段或 GET 參數(shù)附加 .userAgent(...); Document secondPage nextPageConn.post(); // 注意此處用 post()2.3 Referer 和 Cookie 的隱性綁定某些網(wǎng)站尤其是登錄后訪問的后臺分頁會校驗Referer頭確保請求來自其自身域名。若直接請求?page2而 Referer 為空或為其他域名返回 403。// ? 強制設置 Referer 為上一頁 URL String previousUrl https://example.com/news?page1; Connection conn Jsoup.connect(https://example.com/news?page2) .header(Referer, previousUrl) // 關(guān)鍵 .cookie(JSESSIONID, ABC123...) // 若需維持會話從上一頁響應中提取 .userAgent(...);如何獲取 Cookie首次請求后從Connection.Response中提取Connection.Response response Jsoup.connect(https://example.com/login).data(u, x, p, y).execute(); MapString, String cookies response.cookies(); // 獲取全部 cookie String sessionId cookies.get(JSESSIONID);然后將sessionId傳給后續(xù)所有分頁請求。切記Cookie 是會話級憑證丟失即登出分頁中斷。3. DOM 定位與數(shù)據(jù)抽取當.select(div.item)開始返回空集合時分頁爬取最玄學的階段URL 沒錯、請求成功、狀態(tài)碼 200但doc.select(ul.list li)卻抽不出任何元素。這不是 selector 寫錯了而是你沒意識到——DOM 結(jié)構(gòu)本身就在分頁間漂移。3.1 定位器健壯性設計從“寫死選擇器”到“動態(tài)錨點”新手常犯錯誤在第一頁調(diào)試出div.content ul li就認為全站通用。但實際中第 1 頁有廣告位li classad-banner干擾.list li:nth-child(2)的序號定位第 50 頁因數(shù)據(jù)不足ul標簽被省略內(nèi)容直接放在div.content下某些頁底部插入推薦模塊div.content *:last-child不再是數(shù)據(jù)項。正確做法用內(nèi)容特征而非位置做錨點。例如目標數(shù)據(jù)項必含h3 classtitle和span classdateElements items doc.select(div.content *:has(h3.title):has(span.date)); // ? 匹配 div.content 下所有同時包含 h3.title 和 span.date 的直接子元素 // ? 避免doc.select(div.content ul li) —— 依賴固定結(jié)構(gòu)更進一步可結(jié)合屬性值過濾// 只取 class 包含 item 且>for (int i 1; i totalPage; i) { Document page Jsoup.connect(getPageUrl(i)).get(); // 【關(guān)鍵檢查】每頁都應有至少 1 個標題和 1 個日期 Elements titles page.select(h3.title); Elements dates page.select(span.date); if (titles.size() 0 || dates.size() 0) { System.err.printf(?? 第 %d 頁結(jié)構(gòu)異常title%d, date%d%n, i, titles.size(), dates.size()); // 記錄該頁 HTML 到文件供人工復核 Files.write(Paths.get(debug_page_ i .html), page.html().getBytes()); continue; // 跳過此頁避免污染數(shù)據(jù) } // 正常抽取邏輯... }3.3 多頁數(shù)據(jù)合并與去重避免“同一條新聞爬了 17 次”分頁接口常存在數(shù)據(jù)重疊如第 10 頁末尾幾條 第 11 頁開頭幾條尤其當后端按時間戳分頁且并發(fā)發(fā)布時。簡單List.add()會導致重復入庫。方案基于唯一標識如 URL、ID去重SetString seenUrls new HashSet(); // 全局去重容器 ListArticle allArticles new ArrayList(); for (int i 1; i 100; i) { Document page Jsoup.connect(getPageUrl(i)).get(); Elements items page.select(div.item); for (Element item : items) { String url item.select(a[href]).attr(href); // 歸一化 URL補全相對路徑、去除跟蹤參數(shù) url normalizeUrl(url, https://example.com); if (!seenUrls.contains(url)) { seenUrls.add(url); allArticles.add(parseArticle(item)); } } } // 歸一化函數(shù)示例 private static String normalizeUrl(String href, String baseUrl) { String fullUrl href.startsWith(http) ? href : baseUrl / href; return fullUrl.split(\\?)[0]; // 去除 utm_ 等參數(shù) }注意seenUrls必須是循環(huán)外聲明的全局 Set否則每頁都新建失去去重意義。4. 避坑分頁爬取中 5 個血淚經(jīng)驗換來的高頻故障與解法分頁爬取的坑往往不在第一行代碼而在第 97 次請求的響應頭里。以下是我在多個模擬項目X中反復踩過的硬核問題每一條都附帶可立即驗證的診斷命令和修復代碼。4.1 現(xiàn)象前 10 頁正常第 11 頁開始返回 403 Forbidden原因目標站設置了請求頻率閾值如 10 次/分鐘且未校驗 User-Agent但通過 IP 統(tǒng)計。你的循環(huán)請求過于密集觸發(fā)風控。解決在循環(huán)內(nèi)添加隨機延時非固定 sleepThread.sleep(1000 new Random().nextInt(2000)); // 1~3 秒隨機停頓更優(yōu)方案使用ScheduledExecutorService控制 QPS ≤ 1ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); for (int i 1; i 100; i) { final int pageNum i; scheduler.schedule(() - fetchPage(pageNum), i * 1100, TimeUnit.MILLISECONDS); // 嚴格間隔 1.1 秒 }4.2 現(xiàn)象偶發(fā)性空白頁doc.body().text()為空但瀏覽器打開正常原因頁面包含 JavaScript 渲染邏輯如document.write()或 Vue 初始化jsoup 僅獲取原始 HTML未執(zhí)行 JS。解決優(yōu)先排查禁用瀏覽器 JS 后手動訪問該頁若同樣空白則確認是 JS 渲染頁 →jsoup 不適用需換 Puppeteer 或 Selenium若禁用 JS 后內(nèi)容可見則檢查是否因gzip響應未解壓Connection conn Jsoup.connect(url) .header(Accept-Encoding, gzip, deflate) // 顯式聲明支持壓縮 .ignoreContentType(true); // 防止因 Content-Type 非 text/html 被丟棄4.3 現(xiàn)象第 1 頁能取到 20 條第 2 頁只取到 5 條且內(nèi)容明顯是第 1 頁的重復原因分頁參數(shù)未生效服務器忽略?page2始終返回第 1 頁數(shù)據(jù)。常見于URL 參數(shù)名錯誤實際是p而非page需要同時傳start20和length20才生效前端通過修改window.history.pushState偽造分頁真實數(shù)據(jù)仍由首屏加載。解決用 curl 驗證curl -s https://example.com/api/list?page2 | head -20對比page1響應若響應相同說明后端未讀取該參數(shù) → 查看 Network 面板中真實 XHR 請求的 URL 和參數(shù)4.4 現(xiàn)象doc.select(div.pagination .next).attr(href)取出的鏈接是/news?page2但拼接 baseURL 后 404原因href是相對路徑但當前頁面 URL 是https://example.com/category/tech/而 base 標簽被修改head base hrefhttps://example.com/ /head此時href/news?page2應解析為https://example.com/news?page2而非https://example.com/category/tech/news?page2。解決使用 Jsoup 內(nèi)置的abs:href獲取絕對 URLString nextUrl doc.select(div.pagination a.next).attr(abs:href); // ? 自動根據(jù) base 和當前 URL 計算絕對路徑若abs:href為空降級為doc.location() href需手動處理//開頭的協(xié)議相對 URL。4.5 現(xiàn)象爬取過程中某頁拋SocketTimeoutException后續(xù)所有頁均失敗原因Jsoup 默認連接池復用一次超時可能導致連接處于半關(guān)閉狀態(tài)影響后續(xù)請求。解決每次請求顯式關(guān)閉連接雖 Jsoup 通常自動管理但分頁場景下強烈建議Connection connection Jsoup.connect(url); try (Connection.Response response connection.execute()) { Document doc response.parse(); // 處理 doc } catch (SocketTimeoutException e) { System.err.println(第 pageNum 頁超時跳過); }或禁用連接池.maxBodySize(0).followRedirects(true)減少內(nèi)部狀態(tài)依賴。5. 反爬對抗與穩(wěn)定性加固把“能跑通”變成“能跑滿 1000 頁不掛”分頁爬取的終極挑戰(zhàn)不是語法而是生存。當你的腳本要穩(wěn)定運行數(shù)小時、覆蓋上千頁時“能跑通”只是起點“不被封、不丟數(shù)、不誤判”才是交付標準。這里不講理論只列我在線上環(huán)境驗證過的四條硬核技巧。5.1 User-Agent 輪詢告別“同一個指紋刷 1000 次”固定 UA 是風控第一線索。真實用戶瀏覽器 UA 極其多樣而爬蟲 UA 往往千篇一律。解決方案不是隨機生成而是輪詢一個高質(zhì)量 UA 池private static final ListString USER_AGENTS Arrays.asList( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/17.2 Safari/537.36, Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0 ); private static String getNextUA() { int index (int) (Math.random() * USER_AGENTS.size()); return USER_AGENTS.get(index); } // 在每次請求中使用 Connection conn Jsoup.connect(url) .userAgent(getNextUA()) .header(Accept-Language, zh-CN,zh;q0.9,en;q0.8);為什么不用 Math.random() 每次生成因為隨機生成的 UA 字符串格式易被識別為機器生成如版本號亂序、平臺組合荒謬。輪詢真實 UA 池通過了 99% 的基礎(chǔ) UA 校驗。5.2 HTTP 狀態(tài)碼分級響應策略別讓 429 成為靜默失敗很多網(wǎng)站對高頻請求返回 429 Too Many Requests但 jsoup 默認不拋異常response.statusCode()為 429 時response.parse()仍會返回一個空 Document導致數(shù)據(jù)丟失卻無提示。Connection.Response response Jsoup.connect(url) .userAgent(getNextUA()) .execute(); int statusCode response.statusCode(); if (statusCode 200) { Document doc response.parse(); // 正常處理 } else if (statusCode 429) { // ?? 關(guān)鍵遇到 429指數(shù)退避重試 long backoff (long) Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s... Thread.sleep(backoff new Random().nextInt(1000)); return fetchPageWithRetry(url, retryCount 1); // 遞歸重試 } else if (statusCode 400 statusCode 500) { // 客戶端錯誤大概率是參數(shù)問題記錄后跳過 log.warn(Client error {} on {}: {}, statusCode, url, response.statusMessage()); return Collections.emptyList(); } else { throw new IOException(Unexpected status statusCode on url); }5.3 分頁進度持久化斷點續(xù)爬不是功能是剛需爬 1000 頁耗時數(shù)小時期間網(wǎng)絡抖動、機器重啟、代碼異常都會導致前功盡棄。必須將“當前頁碼”寫入磁盤private static final Path PROGRESS_FILE Paths.get(crawl_progress.txt); // 保存當前進度 private static void saveProgress(int currentPage) throws IOException { Files.write(PROGRESS_FILE, String.valueOf(currentPage).getBytes()); } // 恢復進度 private static int loadProgress() { try { String s Files.readString(PROGRESS_FILE); return Integer.parseInt(s.trim()); } catch (IOException | NumberFormatException e) { return 1; // 默認從第 1 頁開始 } } // 主循環(huán) int startPage loadProgress(); for (int i startPage; i 1000; i) { try { fetchAndSavePage(i); saveProgress(i); // 每成功一頁立即落盤 } catch (Exception e) { log.error(Failed to fetch page {}, i, e); // 不中斷循環(huán)繼續(xù)下一頁 } }血淚教訓saveProgress(i)必須放在fetchAndSavePage(i)之后、catch之前。曾因?qū)懺趖ry塊末尾某頁抓取成功但保存進度失敗重啟后重復抓取該頁導致數(shù)據(jù)庫主鍵沖突。5.4 結(jié)果驗證用“交叉比對”代替“信任響應”最后一道防線不輕信單頁響應。對關(guān)鍵字段做跨頁一致性校驗。例如新聞發(fā)布時間應隨頁碼遞增ListLocalDateTime timestamps new ArrayList(); for (int i 1; i 100; i) { Document page Jsoup.connect(getPageUrl(i)).get(); Elements times page.select(span.date); for (Element timeEl : times) { LocalDateTime t parseTime(timeEl.text()); // 自定義解析函數(shù) timestamps.add(t); } } // 檢查是否整體遞增允許同秒內(nèi)多條但不允許倒序 for (int j 1; j timestamps.size(); j) { if (timestamps.get(j).isBefore(timestamps.get(j-1))) { System.err.printf(? 時間戳倒序第 %d 個時間 %s 小于前一個 %s%n, j, timestamps.get(j), timestamps.get(j-1)); break; } }從那以后我每次啟動分頁任務都強制走一遍這四步UA 輪詢初始化、429 退避邏輯注入、進度文件存在性校驗、首末頁時間戳快照比對。少走一次就可能多修兩小時的半夜告警。希望幫到你。本文還有配套的精品資源點擊獲取