據(jù)采集精進指南:從爬蟲腳本到工程化系統(tǒng))
最近幫某大宗商品研究團隊重構了煤炭數(shù)據(jù)采集流程爬取金投網(wǎng)上的煤炭價格、庫存、運價指數(shù)等內容。這個項目的難點不在“能不能爬到”而在“怎么長期穩(wěn)定地爬到”以及“爬下來的數(shù)據(jù)能不能直接用”。如果你只是臨時抓一次數(shù)據(jù)做分析那隨便寫個腳本就夠了但如果你的下游是日報、周報、月度復盤甚至是自動化的數(shù)據(jù)推送服務那這套邏輯就要重新設計。這個“精進版”項目就是想解決三件事一是把單次爬蟲變成可持續(xù)運行的采集任務二是讓采集到的臟數(shù)據(jù)在入庫前就被清洗干凈三是在數(shù)據(jù)源反爬策略調整、字段結構變化時系統(tǒng)能快速適應而不是原地崩潰。下面我把整個項目的設計思路、關鍵代碼和踩坑經(jīng)歷都拆開來講適合已經(jīng)寫過簡單爬蟲、想往工程化方向走的開發(fā)者參考。1. 精進版要解決的核心問題從單頁抓取到全鏈路采集1.1 為什么是煤炭行情數(shù)據(jù)煤炭行業(yè)的數(shù)據(jù)有一個特點公開渠道多、口徑不統(tǒng)一、更新節(jié)奏差異大。金投網(wǎng)這類行情站點把動力煤、焦煤、焦炭等品種的現(xiàn)貨報價、期貨價格、港口庫存、海運運價指數(shù)聚合到一起頁面結構清晰很適合做數(shù)據(jù)源。但這些數(shù)據(jù)的價值在于“連續(xù)觀測”斷一天數(shù)據(jù)趨勢分析就出現(xiàn)缺口。團隊需要的不是某一天的靜態(tài)快照而是每天定時、自動、完整地采集并和前一天的數(shù)據(jù)做增量對比。這決定了項目不能是一個一次性的requests腳本而是一個帶有調度、存儲、校驗、告警的小型系統(tǒng)。1.2 精進版與基礎版的差異很多人寫爬蟲時習慣把“下載頁面、解析字段、打印結果”三步寫在同一個文件里跑完就算結束?;A版能應付單次任務但一旦涉及長期運行會暴露出一堆問題沒有重試機制網(wǎng)絡抖動一次當天數(shù)據(jù)就缺失沒有去重邏輯重復抓取導致庫里出現(xiàn)大量重復記錄沒有頻率控制短時間高頻請求容易被數(shù)據(jù)源限制訪問沒有字段校驗解析到空值、錯位數(shù)據(jù)時代碼不報錯但數(shù)據(jù)已經(jīng)污染了沒有運行日志出了問題都不知道是哪個環(huán)節(jié)出的問題精進版的核心工作就是把這些“基礎版沒考慮的事”全部補上。整體架構可以拆成四層調度層、下載層、解析層、存儲層。每層只干自己那一件事層與層之間通過任務隊列解耦這樣任何一層出問題時都可以單獨替換不至于牽一發(fā)動全身。2. 數(shù)據(jù)源分析與請求策略先搞清對手再動手2.1 頁面結構與數(shù)據(jù)加載方式動手寫代碼之前先花一天時間把數(shù)據(jù)源的頁面結構摸清楚。煤炭行情頁面一般分兩類一類是服務端直接渲染的靜態(tài)表格HTML里就帶全部數(shù)據(jù)另一類是前端通過接口異步加載頁面上的數(shù)據(jù)是JavaScript跑出來的。兩種方式的采集策略完全不同。金投網(wǎng)的煤炭板塊大部分是服務端渲染一個列表頁對應一個URL參數(shù)里帶品種、日期、頁碼。這種結構的優(yōu)點是不需要模擬瀏覽器一個帶正確請求頭的requests就能拿到完整HTML缺點是一旦網(wǎng)站改版表格列的順序變了解析邏輯就要跟著改。我的建議是第一次接觸一個新數(shù)據(jù)源時先手動用瀏覽器打開頁面查看源代碼確認數(shù)據(jù)是直接寫在HTML里還是通過接口返回的。如果是接口返回的去Network面板里找真正的數(shù)據(jù)接口解析JSON比解析HTML穩(wěn)得多。這一步?jīng)Q定了后續(xù)所有代碼的寫法值得多花時間。2.2 請求頭與訪問頻率設計爬蟲被限制訪問多數(shù)時候不是因為你“爬了”而是因為你“爬得太像程序了”。默認的Python-requests UA一眼就會被識別所以請求頭至少需要偽裝成常用瀏覽器的樣子。我維護了一個UA池每次請求隨機取一個比固定UA更自然import random USER_AGENTS [ 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) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/121.0 Firefox/121.0, ] def random_headers(): return { User-Agent: random.choice(USER_AGENTS), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.gold678.com/, Connection: keep-alive, }再說頻率控制。很多新手踩過的坑是用線程池一口氣發(fā)50個請求數(shù)據(jù)源還沒反應過來就觸發(fā)了風控。精進版的下載器引入了一個信號量把并發(fā)數(shù)控制在合理范圍內同時每次請求之間加一個隨機小延遲import threading import time SEMAPHORE threading.Semaphore(5) # 最多5個并發(fā) def throttled_request(session, url, delay_range(1, 3)): with SEMAPHORE: time.sleep(random.uniform(*delay_range)) resp session.get(url, headersrandom_headers(), timeout15) resp.raise_for_status() return resp這里有個細節(jié)信號量放在請求之前而不是請求之后是為了防止并發(fā)請求同時卡在timeout等待上時新請求仍然涌入。實測下來5并發(fā)、1到3秒隨機延遲的組合對于數(shù)據(jù)量不大的行情頁來說足夠穩(wěn)定既不會觸發(fā)風控也完全能滿足每日采集的時效要求。2.3 代理策略的取舍關于代理很多人一上來就想上代理池覺得這樣才“高級”。實際做行情數(shù)據(jù)采集時代理不是必需品。因為這種公開行情頁面本身價值密度不算高數(shù)據(jù)源對單IP的請求頻率限制通常不會特別苛刻只要頻率控制得當一個干凈的住宅IP就夠了。但有兩類情況確實需要代理一是數(shù)據(jù)源對單IP的日請求次數(shù)做了硬上限比如一天只放行1000次請求你的采集任務超過這個量就必須換IP二是你采集的目標數(shù)據(jù)覆蓋多個站點主IP因為其他站點的原因被連坐限制。我現(xiàn)在這套系統(tǒng)預留了代理接口但默認走直連只有出現(xiàn)HTTP 403或429時才動態(tài)切換到備用代理。這個“按需啟用”的思路可以避免大部分時候都背著代理的額外延遲和失敗率。3. 抓取流程的分層設計調度、下載、解析、存儲3.1 任務調度層增量更新與全量補采調度層是整個系統(tǒng)的“指揮中心”負責決定“什么時間抓什么頁面”。煤炭數(shù)據(jù)的更新節(jié)奏基本是工作日每日更新個別品種甚至是一周更新一次。設計調度時不能只寫死一個cron表達式因為周末、節(jié)假日數(shù)據(jù)源可能不更新而每天需要抓的頁面數(shù)量又會隨品種數(shù)量變化。我采用一個最簡單的方案維護一份抓取任務清單每條任務記錄包含任務類型、URL模板、參數(shù)列表。調度器每天定時掃描任務清單哪些任務該在今天執(zhí)行就動態(tài)生成具體的抓取URL去下載層排隊。這樣加一個新品種只需要往任務清單里加一行記錄不用改代碼。增量更新的邏輯是默認每天抓取當日數(shù)據(jù)如果發(fā)現(xiàn)當日數(shù)據(jù)為空或者數(shù)據(jù)源返回了“暫無數(shù)據(jù)”的提示就自動回退到前一個工作日進行補采。補采的深度最多回退5個工作日超過這個深度就觸發(fā)告警讓負責人決定是否要手動核查數(shù)據(jù)源是不是改版了。3.2 下載層請求重試、超時控制下載層負責“把頁面拿到手”但它真正的難點不是請求本身而是怎么處理異常。網(wǎng)絡抖動、數(shù)據(jù)源臨時故障、偶發(fā)的429限流這些都是正?,F(xiàn)象。我的下載器對所有請求做兩層重試第一層是網(wǎng)絡異常重試比如連接超時、ReadTimeout、ConnectionError這類錯誤通常過幾秒就能恢復第二層是HTTP狀態(tài)碼重試遇到502、503、504這類服務器臨時錯誤時退避一段時間后再試。重試必須配合退避策略否則就是變相給數(shù)據(jù)源制造壓力。我用的是一種指數(shù)退避加隨機抖動的方式def fetch_with_retry(session, url, retries3): for attempt in range(retries): try: resp throttled_request(session, url) if resp.status_code 200: return resp.text elif resp.status_code in (403, 429): raise RateLimitError(fHTTP {resp.status_code}) else: raise HTTPError(fUnexpected status: {resp.status_code}) except (RateLimitError, HTTPError, requests.RequestException) as e: wait_time (2 ** attempt) random.uniform(0, 1) time.sleep(wait_time) return None這里有個容易被忽略的點重試時不要直接復用同一個Session因為有些連接已經(jīng)被數(shù)據(jù)源標記了異常狀態(tài)。我在重試邏輯里每次都會新建Session連接雖然多了一點握手開銷但成功率明顯提升。對于每日幾百個頁面的采集量這點開銷完全可以接受。3.3 解析層表格解析與字段校驗解析層是把HTML轉成結構化數(shù)據(jù)的關鍵環(huán)節(jié)。煤炭行情頁面大量使用表格而表格解析最怕的是“列錯位”。數(shù)據(jù)源偶爾會把某些列合并、隱藏或者在表頭新增一列直接按固定索引取值就會出錯。我用Python的BeautifulSoup加lxml解析器來處理同時維護一個“表頭到字段名”的映射表。解析時先確認頁面的表頭順序再按表頭映射到內部字段而不是寫死第幾列代表價格from bs4 import BeautifulSoup FIELD_MAP { 日期: date, 品種: variety, 價格: price, 漲跌: change, 漲跌幅: change_pct, 單位: unit, } def parse_price_table(html): soup BeautifulSoup(html, lxml) table soup.find(table) header_row table.find(tr) headers [th.get_text(stripTrue) for th in header_row.find_all([th, td])] rows [] for tr in table.find_all(tr)[1:]: cells [td.get_text(stripTrue) for td in tr.find_all(td)] row {} for idx, header in enumerate(headers): if header in FIELD_MAP and idx len(cells): row[FIELD_MAP[header]] cells[idx] rows.append(row) return rows解析之后不能直接入庫要先做字段校驗。我加了三個維度的檢查空值檢查核心字段日期、品種、價格為空時該行標記為異常不寫入正式表類型檢查價格字段必須是數(shù)值型遇到“暫無”“--”這類非數(shù)值內容轉換為空值并記錄日志范圍檢查煤炭價格不可能一夜之間從500漲到5000所以每個品種都配置了合理的波動范圍超出范圍的數(shù)值會被標記為可疑數(shù)據(jù)進入人工復核隊列這三個檢查的價格在于它們不是“防止爬蟲出錯”而是“防止臟數(shù)據(jù)污染下游”。下游分析師拿到的數(shù)據(jù)必須是干凈的否則一個小數(shù)點錯誤就可能讓一份研報的結論全錯。3.4 存儲層去重策略與庫表設計存儲層我選擇MySQL主要是為了后續(xù)對接BI工具和定時報表方便。表結構的設計要考慮到“同一品種同一日期只能有一條記錄”所以建表時直接加唯一索引CREATE TABLE coal_daily_price ( id INT AUTO_INCREMENT PRIMARY KEY, variety VARCHAR(32) NOT NULL, trade_date DATE NOT NULL, price DECIMAL(10,2), change_value DECIMAL(10,2), change_pct DECIMAL(5,2), unit VARCHAR(16), source VARCHAR(64), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_variety_date (variety, trade_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;入庫時用INSERT ... ON DUPLICATE KEY UPDATE而不是先查再插這樣既保證冪等性又省掉一次查詢def upsert_rows(cursor, rows): sql INSERT INTO coal_daily_price (variety, trade_date, price, change_value, change_pct, unit, source) VALUES (%(variety)s, %(trade_date)s, %(price)s, %(change_value)s, %(change_pct)s, %(unit)s, %(source)s) ON DUPLICATE KEY UPDATE price VALUES(price), change_value VALUES(change_value), change_pct VALUES(change_pct), unit VALUES(unit), source VALUES(source) for row in rows: cursor.execute(sql, row)其實這里有一個設計上的取舍值得說說為什么用唯一索引做冪等而不是先DELETE再INSERT因為先刪后插在數(shù)據(jù)源當天數(shù)據(jù)延遲更新時會導致歷史記錄被清空而我們希望保留“數(shù)據(jù)源最后一次更新”的狀態(tài)。用唯一索引加覆蓋寫的方式可以保證每天都只保留最新狀態(tài)同時磁盤上不會堆積大量重復記錄。4. 核心代碼實現(xiàn)調度、解析與入庫的關鍵片段4.1 定時調度與任務清單調度器用APScheduler實現(xiàn)每天上午9點半和下午5點各觸發(fā)一次任務掃描。為什么要兩個時間點因為煤炭現(xiàn)貨報價有的品種上午出有的品種下午才更新分兩個批次可以減少“抓不到數(shù)據(jù)”的概率。任務清單我直接放在了數(shù)據(jù)庫里這樣加任務不用改代碼SELECT task_name, url_template, params_json, schedule_type FROM crawl_task_config WHERE is_active 1 AND next_run_time NOW() OR next_run_time IS NULL每次調度觸發(fā)時系統(tǒng)掃描所有需要執(zhí)行的任務生成具體的URL列表然后交給下載層去異步抓取。抓取完成后無論成功還是失敗每條任務都會記錄下一次執(zhí)行時間同時更新成功次數(shù)和失敗次數(shù)的統(tǒng)計。如果某個任務連續(xù)失敗超過3天調度器會把它標記為“異?!睍和:罄m(xù)自動執(zhí)行并把異常信息推送到告警群。4.2 字段清洗與多表關聯(lián)解析出來的原始字段經(jīng)常不能直接用比如“價格”字段在頁面上顯示成“現(xiàn)價 780 元/噸”或者日期顯示成“01-20”而不是完整的“2025-01-20”。我寫了一個清洗模塊專門做三件事提取數(shù)值、標準化日期、統(tǒng)一單位。數(shù)值提取用正則表達式解決import re def extract_number(text): if not text: return None match re.search(r-?\d(\.\d)?, text.replace(,, )) if match: return float(match.group()) return None日期標準化要考慮兩種格式一種是帶年份的完整日期另一種是只有月和日的簡稱。對于后者需要根據(jù)當前日期推斷年份同時處理“跨年”邊界問題比如12月31日的簡稱日期到了1月1日年份應該還是上一年from datetime import datetime, timedelta def normalize_date(text, todayNone): today today or datetime.now().date() cleaned text.strip() try: return datetime.strptime(cleaned, %Y-%m-%d).date() except ValueError: pass try: m, d cleaned.split(-) candidate datetime(today.year, int(m), int(d)).date() if candidate - today timedelta(days90): candidate datetime(today.year - 1, int(m), int(d)).date() return candidate except ValueError: return None這個“跨年推斷”的邏輯其實是個很容易踩的坑。如果你不考慮跨年每年1月初你會發(fā)現(xiàn)系統(tǒng)里突然出現(xiàn)一批日期錯誤的數(shù)據(jù)而且這種錯很難被普通校驗發(fā)現(xiàn)因為日期格式本身是合法的。4.3 多線程下載與入庫性能下載和解析可以做到并行但入庫必須串行因為MySQL不支持多線程同時寫同一張表還能保證唯一索引不出沖突。我的做法是下載和解析用ThreadPoolExecutor并行處理解析結果放進一個內存隊列再單獨開一個線程從隊列里批量取數(shù)據(jù)、批量入庫。批量入庫的批次大小我調到500條一批實測比一條一條插快了近10倍。對于煤炭數(shù)據(jù)這個量級500條一批完全不會給數(shù)據(jù)庫帶來壓力。5. 踩坑實錄與排查鏈路從403到臟數(shù)據(jù)的完整復盤5.1 偶發(fā)403從User-Agent到IP限流系統(tǒng)上線第三周的某天運維反饋當天的采集任務成功率為0所有請求都返回403。我第一反應是UA被識別了于是趕緊換了新的UA池結果仍然403。接著查了代理IP的可用性也正常。最后打開數(shù)據(jù)源頁面才發(fā)現(xiàn)是數(shù)據(jù)源整體升級了防護策略對所有非瀏覽器請求都返回403連正常訪問偶爾也會要求驗證。這個問題的排查鏈路是這樣的先看HTTP狀態(tài)碼分布確認是全部失敗還是部分失敗如果是全部失敗考慮是不是IP被全局封禁排除IP因素后再看是不是UA、Cookie這些請求頭出了問題。最終解決方式是增加了Cookie的自動獲取流程也就是先訪問一次首頁拿到必要的Cookie再帶著去訪問數(shù)據(jù)頁模擬真實用戶的訪問路徑。這比單純偽造UA要有效得多。5.2 請求頻率被限制并發(fā)不是越高越好另一個印象深刻的坑是我把并發(fā)從5調到10之后原本跑得好好的任務突然開始大面積超時。查下來發(fā)現(xiàn)數(shù)據(jù)源對單IP的并發(fā)連接數(shù)做了限制超過之后新的連接會被掛起直到超時。這個問題的本質是“系統(tǒng)設計時沒有考慮目標服務器的承受能力”。數(shù)據(jù)源本身只是一個普通網(wǎng)站不是為你準備的高并發(fā)API服務你用太高的并發(fā)去訪問人家第一反應不是“服務好你”而是“這個IP有問題”。最終我把并發(fā)調回5并且在代碼里加了自適應限速每次收到429或503時自動把并發(fā)數(shù)減半等一段時間后再逐步恢復。這個機制上線后再也沒有出現(xiàn)過全鏈路超時的情況。5.3 臟數(shù)據(jù)的隱蔽風險類型正確但邏輯錯誤最坑的數(shù)據(jù)錯誤不是格式錯誤而是“看起來完全正常、但邏輯上不可能”的數(shù)據(jù)。有一次動力煤價格在庫里連續(xù)三天都是600元/噸不變而數(shù)據(jù)源頁面上每天數(shù)值都有變化。排查發(fā)現(xiàn)解析器把表格中的一列錯位了取到的是“昨日結算”而不是“今日價格”而恰好那幾天“昨日結算”的數(shù)值都是一樣的所以入庫后看起來像“數(shù)據(jù)沒更新”。這種問題最容易被忽略因為數(shù)據(jù)校驗時類型、范圍都沒問題。從那以后我加了一道專門的“環(huán)比校驗”每個品種每日的價格與前一天做對比如果變化率超過設定閾值系統(tǒng)自動把這條記錄標為“待復核”而不是直接覆蓋前一日的記錄。這個機制幫我抓住了好幾次數(shù)據(jù)解析錯位的問題。6. 上線后的運維細節(jié)與擴展思路6.1 日志、監(jiān)控與告警爬蟲系統(tǒng)的運維核心不是“跑通”而是“可觀測”。我規(guī)定所有關鍵節(jié)點必須打結構化日志包括請求開始時間、請求結束時間、狀態(tài)碼、解析行數(shù)、入庫條數(shù)、耗時。這些日志統(tǒng)一寫入日志文件同時按天切割方便檢索。監(jiān)控分兩層一層是基礎設施監(jiān)控比如任務是否在跑、進程是否存活另一層是業(yè)務監(jiān)控比如當天采集成功率、解析行數(shù)是否與前一天有明顯波動。我設置了兩個告警級別單任務失敗不告警只記日志連續(xù)失敗超過3次或成功率低于80%才會推送告警消息。這里有個經(jīng)驗告警閾值要留有余地不要一點小波動就告警否則團隊會進入“狼來了”狀態(tài)最后真的出問題時沒人響應。6.2 失敗回補機制無論系統(tǒng)多穩(wěn)定總有那么幾天數(shù)據(jù)源不配合。我把“失敗任務重跑”做成了一個獨立的操作入口只要數(shù)據(jù)源恢復正常負責人手動觸發(fā)一次回補任務系統(tǒng)就會根據(jù)任務清單里記錄的最后成功日期自動補齊所有缺失的數(shù)據(jù)?;匮a任務復用正常的下載、解析、入庫流程唯一區(qū)別是參數(shù)里的日期范圍換成多個工作日。這個設計的價值在于它把“異常處理”從被動變成了主動。不用在凌晨數(shù)據(jù)缺失時人工寫腳本臨時抓數(shù)據(jù)而是按一個固定的“補數(shù)流程”執(zhí)行。6.3 后續(xù)擴展從采集到分析爬蟲只是數(shù)據(jù)鏈路的第一公里。數(shù)據(jù)進庫之后真正給團隊創(chuàng)造價值的是分析應用。目前我在庫表基礎上做了幾個簡單的視圖品種價格趨勢、日環(huán)比排名、港口庫存與價格聯(lián)動關系。下一步計劃接入一個簡單的預測模型用歷史數(shù)據(jù)和外部事件數(shù)據(jù)做價格波動區(qū)間的參考預測。如果你也想做類似的項目我給你一個忠告不要一開始就追求大而全的分布式爬蟲系統(tǒng)先用最簡單的方式跑通數(shù)據(jù)鏈路再逐步加調度、告警、校驗這些“耐用性”功能。一個能穩(wěn)定跑三個月的簡單系統(tǒng)遠比一個看起來高級但天天需要維護的復雜系統(tǒng)有價值。最后分享一個我從這個項目里學到的東西爬蟲項目最重要的不是爬蟲本身而是數(shù)據(jù)質量和可維護性。把數(shù)據(jù)源變了怎么辦、數(shù)據(jù)錯了怎么發(fā)現(xiàn)、任務掛了怎么恢復這三件事想清楚你的爬蟲項目才算真正“精進”了。