數(shù)據(jù)加載爬蟲實戰(zhàn):從抓包定位JSON接口到CSV落盤)
最近在做一個小工具想把公開的疫情辟謠信息歸集起來做關鍵詞檢索方便身邊一些長輩快速核對消息真?zhèn)巍D繕隧撁娲蜷_后條目列表清清楚楚標題、來源、發(fā)布時間、摘要全都有我當時心想這還不簡單直接requests.get()拿回 HTML 解析就行。結果源碼一下來嗅到了一個熟悉的問題——整個頁面里連一條辟謠標題都搜不到HTML 就像個空殼。這種“頁面能看到、源碼里找不到”的情況就是典型的動態(tài)數(shù)據(jù)加載。頁面上的內(nèi)容并不是服務器直接寫在 HTML 里的而是瀏覽器加載完框架頁后額外發(fā)起請求從一個接口拿到 JSON 數(shù)據(jù)再動態(tài)渲染出來的。而這篇文章要解決的正是圍繞這類頁面的爬蟲實戰(zhàn)問題怎么定位那個數(shù)據(jù)接口、怎么構造請求、怎么解析接口返回的“{}類型”也就是 JSON 對象數(shù)據(jù)以及最后怎么把結果落成可用的表格。內(nèi)容偏實戰(zhàn)適合已經(jīng)會用requests和正則、但被動態(tài)加載卡住的朋友。1. 目標拆解為什么頁面有數(shù)據(jù)源碼里卻什么都搜不到1.1 前后端分離數(shù)據(jù)不在 HTML 里的根本原因早期很多網(wǎng)站是服務端渲染用戶在瀏覽器里看到的 HTML 是服務器拼好之后一次性返回的爬蟲用requests拿回來就能直接提取內(nèi)容。但現(xiàn)在很多信息發(fā)布類頁面采用的是前后端分離架構后端只負責提供數(shù)據(jù)接口前端代碼通過 JavaScript 在瀏覽器里異步請求接口再把數(shù)據(jù)渲染到頁面上。疫情辟謠專區(qū)這類頁面在技術上通常就是一個列表頁 一個 JSON 接口的組合。后端返回的 HTML 只包含頁面骨架、樣式引用和腳本文件辟謠條目的真實內(nèi)容全部藏在接口響應里。所以如果我們只盯著 HTML 源碼當然什么都搜不到。理解這一點之后爬蟲思路就要從“解析 HTML”切換到“找接口、請求接口、解析 JSON”。1.2 三種常見的動態(tài)加載形式動態(tài)加載在實戰(zhàn)中常表現(xiàn)為三種形式。第一種是 XHR / Fetch 異步請求頁面加載后自動向后端要數(shù)據(jù)這是最常見的形式。第二種是“加載更多”或“下一頁”按鈕觸發(fā)的分頁請求點擊按鈕后瀏覽器發(fā)起新請求并追加渲染數(shù)據(jù)辟謠專區(qū)大多屬于這種。第三種是滾動懶加載頁面滾動到底部才觸發(fā)請求常見于資訊類信息流。無論是哪種形式它們共同的特點是數(shù)據(jù)通過 HTTP 接口返回響應體幾乎都是 JSON 格式。所以我們做爬蟲的關鍵動作就變成了兩條——先找到那個接口再解析接口返回的 JSON 數(shù)據(jù)。這也是標題里“動態(tài)數(shù)據(jù)加載解析{}類型”最核心的含義。1.3 “{}類型”到底是什么標題里的{}指的就是 JSON 對象。JSON 的完整名稱是 JavaScript Object Notation它的對象結構用花括號包裹內(nèi)部是一組組鍵值對比如{ code: 200, message: success, data: { list: [ { id: 1001, title: 示例辟謠標題, publish_time: 2023-01-01 10:00:00 } ], total: 1 } }JSON 對象在 Python 里用json模塊解析之后會變成字典dict所以不少開發(fā)者習慣調侃說“解析{}類型”說的其實就是解析 JSON 對象并從中取數(shù)據(jù)的過程。理解了這一點后面所有代碼就都圍繞著一個核心問題展開怎么安全、準確地把data.list里的每一條辟謠信息取出來。2. 瀏覽器抓包鎖定 JSON 接口的完整操作路徑2.1 Network 面板從“加載更多”按鈕順藤摸瓜我用的是 Chrome 的開發(fā)者工具這套方法在 Edge、Firefox 里也通用。操作路徑很直觀打開目標頁面按 F12 進入開發(fā)者工具切到Network面板。在篩選欄勾選Fetch/XHR這樣面板里只會顯示異步請求過濾掉圖片、CSS、字體等靜態(tài)資源。點擊頁面上的“加載更多”或翻頁按鈕觀察面板里新增的請求。逐個點開新增請求重點看Preview或Response標簽頁凡是預覽里能看到辟謠條目數(shù)據(jù)的就是我們要找的接口。實際操作時我一般會先把頁面滾動到最底部然后點擊“加載更多”按鈕讓面板同時出現(xiàn)兩到三個新請求再去甄別哪一個才是真實數(shù)據(jù)源。有的站點會同時發(fā)送埋點統(tǒng)計請求、用戶行為請求、數(shù)據(jù)請求它們的響應體一眼就能分辨出來——數(shù)據(jù)請求的 Preview 里一定能看到與頁面上對應的列表內(nèi)容。2.2 對照頁面字段確認接口返回的 JSON 結構這一步是整個抓包過程里最需要耐心的環(huán)節(jié)。找到候選接口后先在 Preview 里展開 JSON然后和頁面上顯示的辟謠條目逐項對照頁面上的標題對應 JSON 里的哪個字段來源對應哪個字段發(fā)布時間又對應哪個字段。我建議用文字記錄下字段映射關系。比如頁面上“發(fā)布時間”在接口里叫publish_time頁面上“摘要”在接口里叫summary。別小看這個對照過程后面寫解析代碼時全靠這份映射。字段名字的大小寫、下劃線分隔還是駝峰命名都會直接影響代碼取值。另外要留意有些接口會把列表數(shù)據(jù)包在兩層或三層嵌套里比如data.list或result.records外層還有code、message等狀態(tài)字段。把這些層次關系也記下來后面取值時思路就非常清晰。2.3 把接口要素記錄成一張請求清單抓包不是光看一眼就完事我習慣把接口的關鍵信息整理成一張表后續(xù)寫代碼時直接照著抄。下面是一份示例地址和參數(shù)我都做了虛構替換真實項目里換成你自己抓包得到的值即可項值請求方式GET請求地址https://example.com/api/covid/rumor/list請求參數(shù)page1page_size10請求頭User-Agent: Mozilla/5.0 ... Chrome/120.0Referer: https://example.com/rumorX-Requested-With: XMLHttpRequest響應格式JSON外層含code、message、data這份清單里請求地址和參數(shù)決定了能不能拿到數(shù)據(jù)請求頭則決定了服務器怎么看待你的請求。接下來就進入請求構造環(huán)節(jié)。3. 請求頭與參數(shù)構造讓服務器把你當成正常訪客3.1 必備請求頭與各自的作用很多新手寫爬蟲只寫一個User-Agent就發(fā)請求遇到接口返回 403 或空數(shù)據(jù)時完全摸不著頭腦。實際上動態(tài)數(shù)據(jù)接口通常需要完整的請求頭才能正常返回。我用到的核心請求頭有三個User-Agent標識瀏覽器類型和版本。服務器靠它判斷訪問者是不是真實瀏覽器最省事的辦法就是直接復制自己瀏覽器里的 UA。Referer告訴服務器“我是從哪個頁面跳轉過來的”。很多后端接口會校驗 Referer 是否來自自身站點不帶它容易觸發(fā)防盜鏈邏輯。X-Requested-With值為XMLHttpRequest時表示當前請求是一個 Ajax 請求部分后端會據(jù)此返回 JSON 而不是 HTML 頁面。這三個字段組合起來基本能讓服務器把腳本請求當成普通訪客。構造請求的代碼長這樣import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://example.com/rumor, X-Requested-With: XMLHttpRequest, Accept: application/json, text/plain, */* } API_URL https://example.com/api/covid/rumor/list params { page: 1, page_size: 10 } resp requests.get(API_URL, headersHEADERS, paramsparams, timeout10) print(resp.status_code) print(resp.text[:500])注意代碼里我給requests.get加了timeout10這是個好習慣能防止請求卡住導致程序假死。設置幾秒超時連接失敗時快速報錯比干等著強太多。3.2 參數(shù)里page與page_size的意義動態(tài)加載列表頁普遍采用分頁參數(shù)控制數(shù)據(jù)批次。page表示頁碼從 1 開始遞增page_size表示每頁返回多少條通常由頁面寫死比如 10 或 20。我們點擊“加載更多”時瀏覽器實際上就是發(fā)了一次page2或page3的請求。個別站點不用page而是用offset偏移量比如第二頁offset10、第三頁offset20。這兩種分頁方式的邏輯都能理解關鍵是要在抓包時對比兩個相鄰請求的參數(shù)差異看清楚page變了還是offset變了。這一步不做扎實后面的翻頁循環(huán)就會寫錯。3.3 用 Session 保持會話避免 Cookie 失效有些接口雖然第一次請求能直接返回數(shù)據(jù)但多翻幾頁之后突然返回空內(nèi)容或者狀態(tài)碼變成 401這大概率是登錄態(tài)缺失或 Cookie 失效。遇到這種情況我建議改用requests.Session()它會自動保存服務端返回的 Cookie并在后續(xù)請求中自動帶上。session requests.Session() resp session.get(API_URL, headersHEADERS, paramsparams, timeout10)Session 在爬蟲里的作用就像是瀏覽器保持登錄狀態(tài)一樣只要第一次請求成功后面翻頁請求都會攜帶同樣的身份標識。如果接口做了強鑒權比如需要登錄后才能訪問那還得先從瀏覽器里復制一份 Cookie 放到請求頭里或者先用 Session 模擬一次登錄流程。辟謠專區(qū)這類公開信息頁面通常不用登錄但保留這個思路遇到帶鑒權的頁面時不至于卡住。4. 解析 {} 類型響應從 JSON 對象里安全取值4.1resp.json()與json.loads()的選擇拿到接口響應文本后解析 JSON 的路徑有兩條一條是直接用resp.json()這是requests庫內(nèi)置的方法另一條是先取resp.text再調用json.loads()。兩者本質沒有區(qū)別resp.json()底層調用的就是json.loads()。我習慣直接用resp.json()省一行代碼。但要注意如果接口返回的Content-Type不是標準 JSON或者返回內(nèi)容里夾雜了額外字符resp.json()偶爾會解析失敗。更穩(wěn)妥的寫法是先打印resp.text[:500]確認響應體格式再決定用哪種方式解析。另外有些站點會返回 JSONP 格式比如callback({...})這種不能直接解析需要先把外層函數(shù)調用去掉或者用正則把括號里的 JSON 片段提取出來。4.2 剝洋蔥式取值.get()在手健壯性我有解析 JSON 時最容易出的問題不是語法錯誤而是字段取值時對結構不熟悉導致的報錯。比如直接寫resp.json()[data][list][0][title]如果某個中間字段缺失或者值為null程序立刻拋TypeError或KeyError。我的習慣是“剝洋蔥式”取值先判斷最外層狀態(tài)再逐層向下取。代碼模板如下data resp.json() if data.get(code) ! 200: print(接口返回異常, data.get(message)) return items (data.get(data) or {}).get(list) or [] for item in items: title item.get(title, ) publish_time item.get(publish_time, ) source item.get(source, )這里的關鍵是使用.get()方法而不是直接[]取下標。.get()在字段不存在時返回默認值對于內(nèi)容型接口非常適合因為不同條目可能缺不同字段缺了頂多顯示空字符串程序不會崩。(data.get(data) or {})這種寫法則防止了data字段為None時繼續(xù)往下取值的風險。4.3 翻頁循環(huán)什么時候該停知道了page和page_size之后翻頁循環(huán)的框架很簡單從第 1 頁開始每次頁碼加 1持續(xù)請求直到?jīng)]有數(shù)據(jù)為止。有的接口會返回總頁數(shù)total_pages那可以直接按已知頁數(shù)循環(huán)如果接口不返回就用“當前頁數(shù)據(jù)為空則停止”的判斷條件。我傾向于用后一種因為它的適應性更強即使接口改動導致總頁數(shù)計算邏輯變化也不會影響程序正常退出。一個典型的翻頁解析片段page 1 all_items [] while True: params {page: page, page_size: 10} resp session.get(API_URL, headersHEADERS, paramsparams, timeout10) data resp.json() items (data.get(data) or {}).get(list) or [] if not items: break all_items.extend(items) page 1注意跳出循環(huán)的判斷我用的是if not items它同時覆蓋了兩種情況接口返回空列表或者接口返回異常導致列表為None。4.4 JSON 里的null到 Python 變成None這是一個特別容易被新手忽略的細節(jié)。JSON 中的空值寫作null解析到 Python 后就變成了None。如果你把某個字段直接拼進字符串里結果可能是來源None這種半截數(shù)據(jù)看起來很奇怪。處理辦法很簡單凡是可能為空的字段統(tǒng)一用.get(key, )給出默認值再做一次空值判斷source item.get(source) or item.get(source)返回None時or 會把它替換成空字符串這樣后續(xù)拼接、寫入都不容易出錯。這個小習慣在內(nèi)容型接口上特別管用。5. 數(shù)據(jù)落盤與去重把接口內(nèi)容變成可復用的 CSV 表格5.1 CSV 字段設計與 UTF-8-sig 編碼接口數(shù)據(jù)解析出來后下一步就是存儲。如果只是想快速查看結果打印到控制臺就夠了但如果要長期積累數(shù)據(jù)做分析CSV 是最輕量好用的落盤方式。設計 CSV 的字段時要結合業(yè)務場景。辟謠信息通常關注唯一 ID、標題、來源、發(fā)布時間、摘要、原文鏈接。字段不要貪多夠用即可后續(xù)處理起來也更方便。寫入時有一個坑必須提醒用 Excel 打開 CSV 時中文容易亂碼。解決方案是寫文件時指定encodingutf-8-sig這個編碼會在文件開頭加一個 BOM 標記Excel 能正確識別。如果只用默認的utf-8Excel 大概率會把中文顯示成亂碼。寫入代碼import csv import os OUTPUT_FILE rumor_list.csv file_exists os.path.isfile(OUTPUT_FILE) with open(OUTPUT_FILE, a, newline, encodingutf-8-sig) as f: writer csv.writer(f) if not file_exists: writer.writerow([id, title, source, publish_time, summary]) for item in items: writer.writerow([ item.get(id, ), item.get(title, ), item.get(source, ), item.get(publish_time, ), item.get(summary, ) ])newline是為了防止寫入時出現(xiàn)空行這也是 Python 寫 CSV 的標準配置。5.2 去重靠 ID 靠標題都行翻頁抓取時重復數(shù)據(jù)幾乎是必然出現(xiàn)的。有些接口的列表里會混入置頂條目不同頁重復出現(xiàn)有些接口在數(shù)據(jù)更新時會把舊數(shù)據(jù)又重新返回一遍。去重邏輯必須在爬蟲里內(nèi)置。最簡單的去重方案是維護一個seen集合把已處理條目的唯一標識加進去。如果接口提供 ID 字段就用 ID 去重如果沒有 ID就用標題去重。實際操作中標題重復的情況其實很少用標題做鍵基本可靠。seen set() for item in items: key item.get(id) or item.get(title) if key not in seen: seen.add(key) # 繼續(xù)處理并寫入這一步花不了幾行代碼但能讓最終數(shù)據(jù)質量提升一個檔次。5.3 斷點續(xù)爬與中途落盤如果目標頁面的數(shù)據(jù)量很大比如幾千條一次性全抓完再寫入會有風險中途網(wǎng)絡抖動一次前面所有進度全丟。我的做法是每抓完一頁就落盤一次邊抓邊存這樣即使程序中斷已經(jīng)寫進去的數(shù)據(jù)也不會丟失。更進一步還可以把當前進度保存下來。我習慣在程序里維護一個last_page變量每成功抓完一頁就把它更新到一個本地記錄文件里。下次啟動時讀取這個記錄從停下的頁碼繼續(xù)。對于需要持續(xù)更新的信息頁面這種做法非常實用——第一次抓歷史全量后續(xù)每天跑增量只需要從上次結束的頁碼繼續(xù)即可。當然增量場景更優(yōu)雅的做法是通過發(fā)布時間過濾只抓取最近一天的新增條目。這個視接口是否支持按時間過濾而定如果接口不支持斷點續(xù)爬就是最可靠的兜底方案。6. 實測避雷翻頁參數(shù)、簽名、限流和字段改版的坑6.1 “加載更多”和“下一頁”參數(shù)經(jīng)常不是同一套邏輯我在這個項目里踩到的第一個坑就是翻頁邏輯。頁面上有一個“加載更多”按鈕點擊后抓包發(fā)現(xiàn)請求參數(shù)確實從page1變成了page2看起來很正常。但當我準備寫循環(huán)時又把頁面底部的“下一頁”按鈕點了一下發(fā)現(xiàn)它發(fā)起的請求參數(shù)是offset10。也就是說同一個列表頁面內(nèi)部有兩種翻頁入口兩套參數(shù)模型。接口可能同時支持兩種模式也可能只支持其中一種。我當時如果只看“加載更多”就開寫之后換到真實場景大概率會錯位。務必多試幾個入口對比參數(shù)差異再確定循環(huán)里用哪套參數(shù)。6.2 帶簽名的接口請求過期問題另一個坑是簽名參數(shù)。目標接口的請求 URL 里帶了一個_sign字段一開始我把瀏覽器里復制出來的完整 URL 直接拿來請求居然能返回數(shù)據(jù)就天真地以為這個簽名是固定的。結果過了一個多小時再跑突然全部返回空內(nèi)容狀態(tài)碼倒是 200。后來檢查發(fā)現(xiàn)_sign是根據(jù)當前時間戳加一段固定密鑰生成的有效期很短。瀏覽器的請求之所以能成功是因為我復制 URL 時它剛生成。解決辦法是看前端 JavaScript 里的簽名邏輯用 Python 重新實現(xiàn)一遍。這里不展開加密算法的具體內(nèi)容只強調一個排查思路當請求頭、參數(shù)都對但請求結果不穩(wěn)定時優(yōu)先懷疑簽名是否過期。6.3 限流與封禁的應對策略爬蟲和服務器之間也是講“禮儀”的。請求頻率太快哪怕目標頁面是公開信息也可能觸發(fā)限流。常見的表現(xiàn)是請求幾次之后突然返回 403或者毫無規(guī)律地返回 429Too Many Requests。我的實測經(jīng)驗是抓這種公開信息類列表頁單線程加隨機延時完全夠用。每次翻頁之間用time.sleep(random.uniform(1, 2))把請求節(jié)奏控制在 1 到 3 秒一次基本不會觸發(fā)限流。千萬不要一上來就開多線程并發(fā)大多數(shù)封禁案例都源于腳本里沒有任何間隔。如果已經(jīng)觸發(fā)了限流正確做法是降低頻率或者暫停一段時間再繼續(xù)。重試時也要講究策略連續(xù)失敗三次就停一下別用暴力重試繼續(xù)給服務器制造壓力。6.4 字段改版接口悄悄改名你都不知道內(nèi)容平臺的接口字段并不是一成不變的。跑了一段時間之后我發(fā)現(xiàn)抓下來的數(shù)據(jù)突然全是空標題排查了半天才發(fā)現(xiàn)接口里原來的publish_time字段改叫create_time了。因為代碼里一直用.get(publish_time, )字段缺失時靜默返回空串程序不報錯很容易讓人誤以為接口正常。這提醒我建立一個數(shù)據(jù)體檢習慣比如每次跑完后抽查最新一條數(shù)據(jù)的發(fā)布時間如果距離當前日期太遠或者關鍵字段為空率異常升高那就很有可能是接口字段變了。另外解析代碼里對關鍵字段要少用靜默默認值可以用日志記錄字段缺失情況方便盡早發(fā)現(xiàn)問題。6.5 貼一份可以直接改的完整腳本把前面所有內(nèi)容整合起來給出一份相對完整的腳本框架。地址、請求頭等請按自己抓包得到的信息替換import csv import os import random import time import requests API_URL https://example.com/api/covid/rumor/list OUTPUT_FILE rumor_list.csv PAGE_SIZE 10 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://example.com/rumor, X-Requested-With: XMLHttpRequest, Accept: application/json, text/plain, */* } def fetch_page(session, page): params {page: page, page_size: PAGE_SIZE} for attempt in range(3): try: resp session.get(API_URL, headersHEADERS, paramsparams, timeout10) if resp.status_code 200: return resp.json() except requests.RequestException as e: print(f第{page}頁第{attempt 1}次請求失敗{e}) time.sleep(2) return None def parse_items(data): if not data or data.get(code) ! 200: return [] return (data.get(data) or {}).get(list) or [] def save_items(items): file_exists os.path.isfile(OUTPUT_FILE) with open(OUTPUT_FILE, a, newline, encodingutf-8-sig) as f: writer csv.writer(f) if not file_exists: writer.writerow([id, title, source, publish_time, summary]) for item in items: writer.writerow([ item.get(id, ), item.get(title, ), item.get(source, ), item.get(publish_time, ), item.get(summary, ) ]) def main(): session requests.Session() page 1 seen set() while True: data fetch_page(session, page) items parse_items(data) if not items: print(f第{page}頁無數(shù)據(jù)停止抓取) break new_items [] for item in items: key item.get(id) or item.get(title) if key not in seen: seen.add(key) new_items.append(item) if new_items: save_items(new_items) print(f第{page}頁完成新增 {len(new_items)} 條) page 1 time.sleep(random.uniform(1, 2)) if __name__ __main__: main()這個腳本已經(jīng)足夠支撐一個中小型信息列表頁的全量抓取需求。如果你要抓的頁面結構更復雜比如登錄后才能訪問、或者接口帶動態(tài)簽名在它的基礎上再加鑒權和簽名邏輯即可。做完這個實戰(zhàn)項目我再遇到“頁面有數(shù)據(jù)、源碼找不到”的頁面就完全不慌了。核心思路其實反反復復就是那幾步打開開發(fā)者工具抓包、找到 JSON 接口、構造帶齊請求頭的請求、用.get()安全取值、邊抓邊存。動態(tài)數(shù)據(jù)加載表面上看比靜態(tài)頁面難但只要把數(shù)據(jù)鏈路的邏輯摸透它反而比解析一堆嵌套div更直接——畢竟 JSON 結構再復雜也沒有 HTML 標簽層級復雜。最后再分享一個小技巧把抓包得到的接口地址保留在筆記里下次頁面改版時快速對比接口變化能幫你省下大量重新定位的時間。