:requests+BeautifulSoup精準(zhǔn)提取元數(shù)據(jù))
前陣子要做個小型的電影觀察分析缺一批影片的片名、導(dǎo)演、主演、評分、上映日期、片長、劇情簡介這類元數(shù)據(jù)。我一開始想的是手動復(fù)制結(jié)果整理了十幾部就受不了了——豆瓣詳情頁的信息密度高一條影片條目要來回滾動好幾次才能把需要的東西抄完太浪費時間。后來干脆寫了個 Python 爬蟲把那段時間要用的詳情頁元數(shù)據(jù)一次性抓下來并按 CSV 落盤。這篇文章就是我那套寫法的完整復(fù)盤從頁面結(jié)構(gòu)分析到請求偽裝從字段精準(zhǔn)提取到 CSV 導(dǎo)出方案再到跑批時實際踩過的幾個坑全部梳理出來。適合剛學(xué)完 requests 和 BeautifulSoup、想拿真實網(wǎng)站練手的同學(xué)也適合已經(jīng)能從列表頁抓到數(shù)據(jù)、但一進(jìn)詳情頁就各種缺字段的老哥。1. 先摸清目標(biāo)豆瓣詳情頁的元數(shù)據(jù)到底藏在哪寫爬蟲最忌諱一上來就寫代碼先把頁面“解剖”清楚后面的提取邏輯基本是水到渠成的事。我這次的目標(biāo)是詳情頁元數(shù)據(jù)所以第一步要搞明白詳情頁和列表頁到底有什么區(qū)別哪些數(shù)據(jù)在這個頁面上能拿到它們各自以什么形式存在。1.1 從列表頁到詳情頁URL 結(jié)構(gòu)是采集的第一張地圖豆瓣電影的數(shù)據(jù)組織方式很簡單。列表頁比如某個榜單解決的是“有哪些影片”的問題它返回幾十部影片的條目但每個條目里只有片名、評分、評價人數(shù)、一句話概述這類精簡信息。導(dǎo)演、編劇、主演、片長、劇情簡介這種“深水區(qū)”數(shù)據(jù)列表頁根本不給你完整展示往往要到詳情頁才有。詳情頁的 URL 長這樣https://movie.douban.com/subject/xxxx/這里的數(shù)字就是每部影片在豆瓣里的唯一編號不同影片對應(yīng)不同 ID。所以一條完整的采集鏈路是先從列表頁拿到一批影片標(biāo)題和鏈接再從鏈接中提取出這個數(shù)字 ID最后用 ID 拼接詳情頁 URL逐個抓取元數(shù)據(jù)。這一步雖然簡單但值得注意不要試圖用正則從網(wǎng)頁里扣a標(biāo)簽來拼 URL太容易拿到廣告位或隨機推薦鏈接。我習(xí)慣用 BeautifulSoup 按 CSS 選擇器去過濾比如只選擇標(biāo)題列表里的a標(biāo)簽并且用正則校驗鏈接中必須包含/subject/和末尾的數(shù)字串。import re from urllib.parse import urljoin def extract_subject_id(link): match re.search(r/subject/(\d)/, link) return match.group(1) if match else None只有拿到干凈的 subject_id才能保證循環(huán)抓詳情頁時不會突然打到無關(guān)頁面。這個正則看著簡單但在跑批量任務(wù)時能幫你過濾掉很多臟數(shù)據(jù)。1.2 詳情頁里值得抓的三塊內(nèi)容打開任意一個豆瓣詳情頁按 F12 簡單瞄一眼結(jié)構(gòu)會發(fā)現(xiàn)元數(shù)據(jù)主要分布在三個區(qū)域我習(xí)慣把它們叫做“標(biāo)題區(qū)”“屬性區(qū)”“腳本區(qū)”。數(shù)據(jù)塊包含字段在頁面中的位置推薦解析方式標(biāo)題區(qū)片名、年份、導(dǎo)演、編劇、主演h1標(biāo)題、div idinfo信息欄BeautifulSoup 選擇器評分區(qū)評分、評價人數(shù)、好于百分比strong propertyv:average、評價人數(shù)標(biāo)簽屬性選擇器腳本區(qū)劇情簡介、上傳日期、類型標(biāo)簽script typeapplication/ldjsonast 或 json 解析為什么要把“腳本區(qū)”單獨拿出來說因為豆瓣詳情頁里有一段結(jié)構(gòu)化的 JSON-LD 數(shù)據(jù)里面直接寫了name、description、datePublished這種近乎齊全的元數(shù)據(jù)字段。很多教程只盯 HTML 節(jié)點看繞了半天才把劇情簡介摳出來其實這段 JSON 里已經(jīng)有了。省事又不容易出錯。我還是會從 HTML 節(jié)點里抓一部分字段因為某些展示在頁面上的數(shù)據(jù)比如“好于 90% 劇情片”這種橫向?qū)Ρ饶_本里沒有只能老老實實解析節(jié)點。兩個來源配合使用才能做到“精準(zhǔn)采集”。2. 請求這關(guān)怎么過依賴選型、請求頭偽裝與頻率控制很多新手爬蟲被擋在第一步不是解析代碼寫得不好而是請求一多就被網(wǎng)站識別了。豆瓣的反爬不算狠但也絕不需要你用毫無偽裝的裸請求去挑戰(zhàn)它的耐心。這個環(huán)節(jié)我踩過一次有次趕數(shù)據(jù)用短時間密集請求連抓幾百個詳情頁結(jié)果中途連續(xù)收到好幾條“檢測到有異常請求”的提示頁直接把整個任務(wù)打斷。2.1 依賴安裝與解析器選型別什么都上 Scrapy先說說工具選型。這個任務(wù)用 requests BeautifulSoup4 lxml 就夠了沒必要上 Scrapy。Scrapy 確實強大但對于“批量抓一批詳情頁并導(dǎo)出 CSV”這種小體量任務(wù)它的學(xué)習(xí)曲線和調(diào)試成本是多余的。你拆成一個循環(huán)、一個解析函數(shù)幾十分鐘就能寫完出了問題也容易定位。requests 負(fù)責(zé)發(fā)請求、接收 HTMLBeautifulSoup4 負(fù)責(zé)結(jié)構(gòu)化解析lxml 作為背后解析器速度比 Python 標(biāo)準(zhǔn)庫的 html.parser 快不少三件套是這類輕量爬蟲的黃金組合。pip install requests beautifulsoup4 lxml有一點要注意BeautifulSoup 的lxml解析器需要單獨裝不能因為 bs4 裝好了就想當(dāng)然地跳過。如果不裝代碼雖然能跑但解析性能會明顯下降跑批量的時候一段一段卡頓體驗很差。2.2 UA 偽裝與請求頭這幾行頭信息能保命豆瓣詳情頁對請求頭里的User-Agent和Accept-Language比較敏感。一個來自明顯過時瀏覽器的 UA或者一個連語言偏好都沒聲明的請求很容易觸發(fā)風(fēng)控標(biāo)記。我習(xí)慣用一句像樣的瀏覽器 UA比如下面這種headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Referer: https://movie.douban.com/, Connection: keep-alive }其中Accept-Language里的zh-CN很重要豆瓣詳情頁會根據(jù)這個字段決定某些頁面內(nèi)容是中文還是其他處理過的語言變體。少了它偶爾會碰上拿到的字段是英文的情況解析邏輯沒問題但數(shù)據(jù)就是不對勁。我自己就把這個頭加上了實測穩(wěn)定很多。請求頭只是第一道偽裝真正的安全牌是請求頻率。我習(xí)慣每抓完一個詳情頁隨機睡個2~4秒讓請求間隔看起來像真人瀏覽。import random import time time.sleep(random.uniform(2, 4))別小看這幾秒。爬蟲數(shù)據(jù)量小的時候無所謂一旦抓幾百個頁面這種隨機間隔比固定 3 秒更能模擬真人的節(jié)奏也降低被連續(xù)標(biāo)記的風(fēng)險。2.3 單線程順序抓取為什么我勸你別上并發(fā)剛接觸爬蟲的時候我也有一個執(zhí)念覺得并發(fā)越多越快越好。后來被教訓(xùn)過幾次才明白對這種小體量詳情頁采集場景來說單線程順序抓取反而是最穩(wěn)妥的方案。并發(fā)一旦上到 5 個、10 個線程請求頻率曲線會變得很陡。豆瓣風(fēng)控的標(biāo)記不是看你總計抓了多少頁面而是看你在一個時間窗口內(nèi)的請求密度。10 個線程同時跑每秒鐘可能有 5 個請求從同一個出口打過去哪怕加了延遲上限速度還是容易翻車。再說了一個詳情頁的請求加解析也就半秒左右加上隨機延遲抓 500 部影片也就一二十分鐘的事。這個耗時完全可以接受沒必要為了省那幾分鐘去賭賬號 IP 的安全。3. 精準(zhǔn)提取核心代碼拆解我在代碼里盡量把“解析頁面的邏輯”收斂到一個獨立函數(shù)里這樣頁面結(jié)構(gòu)一變只需要改這一個函數(shù)其他邏輯不用動。這種做法在爬蟲工程化里非常重要畢竟網(wǎng)站改版完全不打招呼拆得越干凈后續(xù)維護(hù)成本越低。3.1 一個函數(shù)搞定單個詳情頁標(biāo)準(zhǔn)化輸出字典下面的函數(shù)是我在實際項目里整理出來的精簡版輸入 HTML 文本輸出一個字段干凈的字典。每一步我都加了空值保護(hù)確保拿不到數(shù)據(jù)的字段不會讓程序崩掉。import ast import json import re import requests from bs4 import BeautifulSoup def parse_detail_html(html: str) - dict: soup BeautifulSoup(html, lxml) item {} # 標(biāo)題區(qū)h1 里通常是 片名 年份 title_node soup.select_one(h1) title_text title_node.get_text(stripTrue) if title_node else # 用空格切出片名和年份注意年份可能不存在 parts title_text.split() item[title] parts[0] if parts else item[year] parts[-1] if len(parts) 1 and parts[-1].isdigit() else # 評分區(qū) rating_node soup.select_one(strong[propertyv:average]) item[rating] rating_node.get_text(stripTrue) if rating_node else votes_node soup.select_one(span[propertyv:votes]) item[votes] votes_node.get_text(stripTrue) if votes_node else # 屬性區(qū)導(dǎo)演、編劇、主演 item[director] extract_info_field(soup, 導(dǎo)演) item[screenwriter] extract_info_field(soup, 編劇) item[actors] extract_info_field(soup, 主演) # 片長 runtime_node soup.select_one(span[propertyv:runtime]) item[runtime] runtime_node.get_text(stripTrue) if runtime_node else # 劇情簡介從 ldjson 里優(yōu)先取 item[summary] extract_description(soup) return itemextract_info_field是我專門寫的通用提取函數(shù)思路很直觀在div#info里逐個找標(biāo)簽帶導(dǎo)演、編劇、主演字樣的span然后收集它后面直到br為止的所有兄弟節(jié)點文本用/連接。def extract_info_field(soup, label: str) - str: info soup.find(div, idinfo) if not info: return for span in info.find_all(span, class_pl): if label in span.get_text(stripTrue): values [] for sibling in span.find_next_siblings(): if sibling.name br: break text sibling.get_text(stripTrue) if text and text not in (更多, 更多?): values.append(text) cleaned [] for value in values: value re.sub(rnbsp;, , value) cleaned.append(value) return / .join(cleaned) return 這里有個細(xì)節(jié)容易忽略找到標(biāo)簽后它的兄弟節(jié)點會包含a標(biāo)簽以及一些沒有結(jié)構(gòu)意義的空白節(jié)點。但豆瓣屬性區(qū)里導(dǎo)演名和其他字段是嵌在a標(biāo)簽里的所以直接遍歷兄弟節(jié)點遇到br就停拿到所有a文本基本等于拿到了完整字段值。這個方法比一次性選div#info下所有a再按位置索引要穩(wěn)。3.2 劇情簡介走 ldjsonast.literal_eval 比 json.loads 更穩(wěn)劇情簡介是詳情頁里很長的一段文本直接抓節(jié)點當(dāng)然能抓到但更省事的做法是讀取頁面上那段application/ldjson腳本。這段腳本里已經(jīng)寫好了description字段和頁面展示的劇情簡介內(nèi)容一致。def extract_description(soup) - str: script soup.find(script, typeapplication/ldjson) if script and script.string: raw_text script.string.strip() try: data ast.literal_eval(raw_text) description data.get(description, ) if isinstance(description, str): return description.strip() except Exception: pass # 退路直接用頁面節(jié)點 summary soup.select_one(span[propertyv:summary]) if summary: return summary.get_text(stripTrue) return 這里我故意用了ast.literal_eval而不是json.loads。很多人寫爬蟲看到 JSON 字段下意識用json.loads但豆瓣這個腳本里偶爾會出現(xiàn)單引號、None這類 Python 字面量的寫法嚴(yán)格來說不是合法的 JSON。json.loads會直接拋異常而ast.literal_eval能容忍這些寫法處理起來更順手。如果數(shù)據(jù)本身就是標(biāo)準(zhǔn) JSONast.literal_eval也能正常解析所以這個小選擇總體來說更穩(wěn)健。另外要說的是從 HTML 節(jié)點提取劇情簡介時務(wù)必留意stripTrue否則很容易把換行和空格粘進(jìn)摘要里。字段里帶一堆\n后續(xù)做數(shù)據(jù)分析還得二次清洗純屬自找麻煩。3.3 空值處理別讓一個壞頁面毀掉整批任務(wù)實際跑批量的時候一定會碰到某些影片頁面缺少某個字段的情況。比如個別老片沒有片長、沒有評分或者年份信息異常。如果代碼一遇到None就拋異常整個循環(huán)都會中斷前面抓的數(shù)據(jù)全部白費。我的習(xí)慣是所有字段都做了None兜底取不到就把空字符串放進(jìn)去。表面上看 CSV 里有一列是空的但這總比程序崩掉、任務(wù)中斷要好。后期的數(shù)據(jù)處理可以統(tǒng)一過濾空值根本不耽誤事。def safe_get(node): return node.get_text(stripTrue) if node else 再加上主循環(huán)里對單頁解析做異常捕獲一個頁面解析出錯時只記錄日志跳過它繼續(xù)抓下一個。這個“容錯優(yōu)先”的思路是爬蟲從“能跑”到“能長時間穩(wěn)定跑”的關(guān)鍵一步。4. CSV 導(dǎo)出的三個細(xì)節(jié)亂碼、重復(fù)表頭、字段順序抓到的元數(shù)據(jù)最終要落盤到 CSV 才方便后續(xù)用辦公軟件打開、篩選、分析。CSV 導(dǎo)出看著簡單但有幾個細(xì)節(jié)不處理好出來的文件會很難用。4.1 追加寫入還是每次重寫我推薦追加模式如果你是一次性抓完一批再導(dǎo)出那每次都重寫文件沒問題。但我是邊抓邊寫希望抓完 50 個頁面后文件里就有 50 行數(shù)據(jù)中途跑掛了也不至于所有數(shù)據(jù)全部丟失。這種情況下追加寫入最合適。追加寫入的核心是用modea打開文件而不是默認(rèn)的w。同時要指定newline否則 CSV 里每兩行之間都會多出一個空行這是 Windows 下常見的坑。import csv import os fieldnames [title, year, director, screenwriter, actors, rating, votes, runtime, summary] def save_to_csv(item: dict, filename: str douban_movies.csv): file_exists os.path.isfile(filename) and os.path.getsize(filename) 0 with open(filename, modea, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesfieldnames) if not file_exists: writer.writeheader() writer.writerow(item)用os.path.isfile判斷文件是否已存在是為了避免表頭重復(fù)。文件第一次創(chuàng)建時寫表頭之后就只追加數(shù)據(jù)行。4.2 為什么必須是 utf-8-sigExcel 亂碼的根源很多人導(dǎo)出 CSV 后用 Excel 一打開中文全變成亂碼問題基本出在編碼上。普通utf-8編碼的 CSVExcel 默認(rèn)會用本地的ANSI解碼中文字符自然對不上號。解決辦法是寫入時用utf-8-sig編碼它會在文件開頭寫入一個 BOM 標(biāo)記Excel 識別到之后就自動按 UTF-8 解碼。我在第一次寫爬蟲腳本時就是忘了這一步導(dǎo)出的 CSV 用記事本打開正常、用 Excel 打開一片亂碼當(dāng)時還以為是代碼寫錯了排查了半天才發(fā)現(xiàn)是編碼問題。4.3 字段順序定義清楚CSV 的第一行就是你的數(shù)據(jù)字典csv.DictWriter的fieldnames列表順序就是輸出 CSV 里每一列的順序。我習(xí)慣把主標(biāo)識類字段放前面標(biāo)題、年份再放人員類導(dǎo)演、編劇、主演然后是評分類評分、評價人數(shù)最后是長文本劇情簡介。這個順序?qū)罄m(xù)人工審查和機器讀取都比較友好。如果字段順序不穩(wěn)定比如這次是“標(biāo)題 年份 導(dǎo)演”下次是“導(dǎo)演 標(biāo)題 年份”CSV 打開后表頭亂七八糟不說別人接手你的數(shù)據(jù)還得重新對齊。這一行 fieldnames最好在寫代碼時就固定下來一勞永逸。5. 實測里繞不開的坑風(fēng)控提示、頁面結(jié)構(gòu)升級、臟數(shù)據(jù)過濾前面說的方法已經(jīng)夠跑通一個基礎(chǔ)版本但真正讓腳本“穩(wěn)”起來的是處理意外情況的能力。這里把我踩過的幾個坑集中講一下。5.1 風(fēng)控提示怎么識別與應(yīng)對豆瓣風(fēng)控上線得比較早當(dāng)你請求頻率過高時返回的不是攔截頁面就是一個看似正常的頁面里加了一句“檢測到有異常請求從你的 IP 發(fā)出”。這種情況下HTML 內(nèi)容解析時不會報錯但你會發(fā)現(xiàn)rating、votes這些字段全部消失數(shù)據(jù)開始大量出現(xiàn)空值。排查這個問題最直接的辦法是打印響應(yīng)狀態(tài)碼和頁面標(biāo)題。如果狀態(tài)碼是 418 或者頁面標(biāo)題變成了“頁面不存在”基本可以斷定是觸發(fā)了風(fēng)控這時候繼續(xù)硬著頭皮跑沒有意義。我的處理策略也很簡單降低頻率、按指數(shù)退避重新請求。具體來說第一次遇到異常時 sleep 30 秒重試一次如果還是異常就 sleep 60 秒再試連續(xù)三次不行就跳過當(dāng)前頁面并記錄日志。注意重試之間要累積延時不要固定間隔地反復(fù)重試。def fetch_html(url, headers, retries3): for attempt in range(retries): resp requests.get(url, headersheaders, timeout10) if resp.status_code 200 and 檢測到有異常請求 not in resp.text: return resp.text time.sleep(10 * (attempt 1)) return None這套重試邏輯非常適合中小體量的爬蟲既不會在風(fēng)控面前死磕也不會因為一兩次波動就丟掉數(shù)據(jù)。5.2 頁面結(jié)構(gòu)升級選擇器失效時的兜底方案豆瓣改版過好幾次某些節(jié)點的 class 或?qū)傩詴{(diào)整。我的解析代碼之所以能快速適應(yīng)靠的是兩個原則第一優(yōu)先用穩(wěn)定屬性選擇器。比如propertyv:average、propertyv:votes、propertyv:runtime這些屬性是豆瓣對外輸出的結(jié)構(gòu)化標(biāo)記比classrating_self clearfix這種視覺樣式類穩(wěn)定得多。樣式類說改就改結(jié)構(gòu)化屬性通常不會隨便變動。第二解析函數(shù)統(tǒng)一收口。無論頁面怎么變解析邏輯全部放在parse_detail_html一個函數(shù)里。網(wǎng)站改版后只需要修改這個函數(shù)的內(nèi)部選擇器其他部分完全不用動。我把這種設(shè)計理解成“爬蟲的依賴倒置”頁面結(jié)構(gòu)與采集邏輯隔離變化只出現(xiàn)在一層。5.3 數(shù)據(jù)清洗過濾掉無效條目和重復(fù)條目批量跑完一批數(shù)據(jù)后別急著直接用。CSV 里可能有些字段缺失嚴(yán)重的行比如連片名都沒有的空行也可能因為斷點重爬部分條目重復(fù)寫入。我常用的清洗邏輯很簡單用 pandas 讀進(jìn)來先按title去重再過濾掉標(biāo)題為空的記錄最后把評分、年份這類字段的類型轉(zhuǎn)好。排序時按評分降序、評價人數(shù)降序方便后續(xù)分析。import pandas as pd df pd.read_csv(douban_movies.csv, encodingutf-8-sig) df df[df[title] ! ] df df.drop_duplicates(subset[title], keeplast) df[rating] pd.to_numeric(df[rating], errorscoerce) df[votes] pd.to_numeric(df[votes], errorscoerce) df df.sort_values([rating, votes], ascendingFalse)這一步清理能從粗糙的原始數(shù)據(jù)里快速拎出可用部分。很多爬蟲教程到“導(dǎo)出 CSV”就結(jié)束了但我實際用下來的體會是導(dǎo)出的 CSV 才是數(shù)據(jù)工作的起點清洗和你抓取本身同樣重要。6. 爬蟲合理的邊界頻率、數(shù)據(jù)量與用途要有分寸最后我想認(rèn)真聊聊分寸感。寫爬蟲的都知道“能不能”和“該不該”是兩回事。豆瓣詳情頁的數(shù)據(jù)本質(zhì)上是由網(wǎng)友和平臺共同維護(hù)的公開內(nèi)容不以登錄狀態(tài)為前提就可以瀏覽采集它對個人學(xué)習(xí)、數(shù)據(jù)分析來說有合理需求。但這不代表可以無限度地抓。我給自己定的邊界有三條單次采集控制在可解釋的范圍內(nèi)。我的項目需求是幾百部影片的元數(shù)據(jù)這個規(guī)模用單線程慢慢跑完全合理。但如果你要做全站幾百萬條數(shù)據(jù)的鏡像采集那就已經(jīng)完全超出個人學(xué)習(xí)范疇了該考慮尋求官方數(shù)據(jù)接口或授權(quán)渠道??刂普埱箢l率。個人用途正常瀏覽式的頻率可以理解但這不意味著可以用高并發(fā)去挑戰(zhàn)平臺防守。我在前面反復(fù)提到隨機延遲這不僅是技術(shù)層面的防封策略也是態(tài)度問題。數(shù)據(jù)只用于個人分析不二次轉(zhuǎn)賣、不公開完整數(shù)據(jù)集。別人的勞動成果值得手下留情這也是所有技術(shù)人生存的基本盤。這套邊界在我的所有爬蟲項目里都是默認(rèn)配置。技術(shù)本身是中性工具關(guān)鍵是使用方式和用途要經(jīng)得起推敲。最后再分享一個實操小技巧批量任務(wù)結(jié)束后把抓取到的 CSV 文件名后綴加上當(dāng)天的日期比如douban_movies_20240615.csv。這樣每次重新抓取、清洗都不會把舊數(shù)據(jù)覆蓋掉如果需要對比不同時間的數(shù)據(jù)變化也有據(jù)可查。我后來很多分析結(jié)果能順利回溯靠的就是這個小習(xí)慣。