ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

豆瓣电影详情页爬虫实战:requests+BeautifulSoup精准提取元数据

豆瓣电影详情页爬虫实战:requests+BeautifulSoup精准提取元数据 前阵子要做个小型的电影观察分析缺一批影片的片名、导演、主演、评分、上映日期、片长、剧情简介这类元数据。我一开始想的是手动复制结果整理了十几部就受不了了——豆瓣详情页的信息密度高一条影片条目要来回滚动好几次才能把需要的东西抄完太浪费时间。后来干脆写了个 Python 爬虫把那段时间要用的详情页元数据一次性抓下来并按 CSV 落盘。这篇文章就是我那套写法的完整复盘从页面结构分析到请求伪装从字段精准提取到 CSV 导出方案再到跑批时实际踩过的几个坑全部梳理出来。适合刚学完 requests 和 BeautifulSoup、想拿真实网站练手的同学也适合已经能从列表页抓到数据、但一进详情页就各种缺字段的老哥。1. 先摸清目标豆瓣详情页的元数据到底藏在哪写爬虫最忌讳一上来就写代码先把页面“解剖”清楚后面的提取逻辑基本是水到渠成的事。我这次的目标是详情页元数据所以第一步要搞明白详情页和列表页到底有什么区别哪些数据在这个页面上能拿到它们各自以什么形式存在。1.1 从列表页到详情页URL 结构是采集的第一张地图豆瓣电影的数据组织方式很简单。列表页比如某个榜单解决的是“有哪些影片”的问题它返回几十部影片的条目但每个条目里只有片名、评分、评价人数、一句话概述这类精简信息。导演、编剧、主演、片长、剧情简介这种“深水区”数据列表页根本不给你完整展示往往要到详情页才有。详情页的 URL 长这样https://movie.douban.com/subject/xxxx/这里的数字就是每部影片在豆瓣里的唯一编号不同影片对应不同 ID。所以一条完整的采集链路是先从列表页拿到一批影片标题和链接再从链接中提取出这个数字 ID最后用 ID 拼接详情页 URL逐个抓取元数据。这一步虽然简单但值得注意不要试图用正则从网页里扣a标签来拼 URL太容易拿到广告位或随机推荐链接。我习惯用 BeautifulSoup 按 CSS 选择器去过滤比如只选择标题列表里的a标签并且用正则校验链接中必须包含/subject/和末尾的数字串。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才能保证循环抓详情页时不会突然打到无关页面。这个正则看着简单但在跑批量任务时能帮你过滤掉很多脏数据。1.2 详情页里值得抓的三块内容打开任意一个豆瓣详情页按 F12 简单瞄一眼结构会发现元数据主要分布在三个区域我习惯把它们叫做“标题区”“属性区”“脚本区”。数据块包含字段在页面中的位置推荐解析方式标题区片名、年份、导演、编剧、主演h1标题、div idinfo信息栏BeautifulSoup 选择器评分区评分、评价人数、好于百分比strong propertyv:average、评价人数标签属性选择器脚本区剧情简介、上传日期、类型标签script typeapplication/ldjsonast 或 json 解析为什么要把“脚本区”单独拿出来说因为豆瓣详情页里有一段结构化的 JSON-LD 数据里面直接写了name、description、datePublished这种近乎齐全的元数据字段。很多教程只盯 HTML 节点看绕了半天才把剧情简介抠出来其实这段 JSON 里已经有了。省事又不容易出错。我还是会从 HTML 节点里抓一部分字段因为某些展示在页面上的数据比如“好于 90% 剧情片”这种横向对比脚本里没有只能老老实实解析节点。两个来源配合使用才能做到“精准采集”。2. 请求这关怎么过依赖选型、请求头伪装与频率控制很多新手爬虫被挡在第一步不是解析代码写得不好而是请求一多就被网站识别了。豆瓣的反爬不算狠但也绝不需要你用毫无伪装的裸请求去挑战它的耐心。这个环节我踩过一次有次赶数据用短时间密集请求连抓几百个详情页结果中途连续收到好几条“检测到有异常请求”的提示页直接把整个任务打断。2.1 依赖安装与解析器选型别什么都上 Scrapy先说说工具选型。这个任务用 requests BeautifulSoup4 lxml 就够了没必要上 Scrapy。Scrapy 确实强大但对于“批量抓一批详情页并导出 CSV”这种小体量任务它的学习曲线和调试成本是多余的。你拆成一个循环、一个解析函数几十分钟就能写完出了问题也容易定位。requests 负责发请求、接收 HTMLBeautifulSoup4 负责结构化解析lxml 作为背后解析器速度比 Python 标准库的 html.parser 快不少三件套是这类轻量爬虫的黄金组合。pip install requests beautifulsoup4 lxml有一点要注意BeautifulSoup 的lxml解析器需要单独装不能因为 bs4 装好了就想当然地跳过。如果不装代码虽然能跑但解析性能会明显下降跑批量的时候一段一段卡顿体验很差。2.2 UA 伪装与请求头这几行头信息能保命豆瓣详情页对请求头里的User-Agent和Accept-Language比较敏感。一个来自明显过时浏览器的 UA或者一个连语言偏好都没声明的请求很容易触发风控标记。我习惯用一句像样的浏览器 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很重要豆瓣详情页会根据这个字段决定某些页面内容是中文还是其他处理过的语言变体。少了它偶尔会碰上拿到的字段是英文的情况解析逻辑没问题但数据就是不对劲。我自己就把这个头加上了实测稳定很多。请求头只是第一道伪装真正的安全牌是请求频率。我习惯每抓完一个详情页随机睡个2~4秒让请求间隔看起来像真人浏览。import random import time time.sleep(random.uniform(2, 4))别小看这几秒。爬虫数据量小的时候无所谓一旦抓几百个页面这种随机间隔比固定 3 秒更能模拟真人的节奏也降低被连续标记的风险。2.3 单线程顺序抓取为什么我劝你别上并发刚接触爬虫的时候我也有一个执念觉得并发越多越快越好。后来被教训过几次才明白对这种小体量详情页采集场景来说单线程顺序抓取反而是最稳妥的方案。并发一旦上到 5 个、10 个线程请求频率曲线会变得很陡。豆瓣风控的标记不是看你总计抓了多少页面而是看你在一个时间窗口内的请求密度。10 个线程同时跑每秒钟可能有 5 个请求从同一个出口打过去哪怕加了延迟上限速度还是容易翻车。再说了一个详情页的请求加解析也就半秒左右加上随机延迟抓 500 部影片也就一二十分钟的事。这个耗时完全可以接受没必要为了省那几分钟去赌账号 IP 的安全。3. 精准提取核心代码拆解我在代码里尽量把“解析页面的逻辑”收敛到一个独立函数里这样页面结构一变只需要改这一个函数其他逻辑不用动。这种做法在爬虫工程化里非常重要毕竟网站改版完全不打招呼拆得越干净后续维护成本越低。3.1 一个函数搞定单个详情页标准化输出字典下面的函数是我在实际项目里整理出来的精简版输入 HTML 文本输出一个字段干净的字典。每一步我都加了空值保护确保拿不到数据的字段不会让程序崩掉。import ast import json import re import requests from bs4 import BeautifulSoup def parse_detail_html(html: str) - dict: soup BeautifulSoup(html, lxml) item {} # 标题区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 # 评分区 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 # 属性区导演、编剧、主演 item[director] extract_info_field(soup, 导演) 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 里优先取 item[summary] extract_description(soup) return itemextract_info_field是我专门写的通用提取函数思路很直观在div#info里逐个找标签带导演、编剧、主演字样的span然后收集它后面直到br为止的所有兄弟节点文本用/连接。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 这里有个细节容易忽略找到标签后它的兄弟节点会包含a标签以及一些没有结构意义的空白节点。但豆瓣属性区里导演名和其他字段是嵌在a标签里的所以直接遍历兄弟节点遇到br就停拿到所有a文本基本等于拿到了完整字段值。这个方法比一次性选div#info下所有a再按位置索引要稳。3.2 剧情简介走 ldjsonast.literal_eval 比 json.loads 更稳剧情简介是详情页里很长的一段文本直接抓节点当然能抓到但更省事的做法是读取页面上那段application/ldjson脚本。这段脚本里已经写好了description字段和页面展示的剧情简介内容一致。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 # 退路直接用页面节点 summary soup.select_one(span[propertyv:summary]) if summary: return summary.get_text(stripTrue) return 这里我故意用了ast.literal_eval而不是json.loads。很多人写爬虫看到 JSON 字段下意识用json.loads但豆瓣这个脚本里偶尔会出现单引号、None这类 Python 字面量的写法严格来说不是合法的 JSON。json.loads会直接抛异常而ast.literal_eval能容忍这些写法处理起来更顺手。如果数据本身就是标准 JSONast.literal_eval也能正常解析所以这个小选择总体来说更稳健。另外要说的是从 HTML 节点提取剧情简介时务必留意stripTrue否则很容易把换行和空格粘进摘要里。字段里带一堆\n后续做数据分析还得二次清洗纯属自找麻烦。3.3 空值处理别让一个坏页面毁掉整批任务实际跑批量的时候一定会碰到某些影片页面缺少某个字段的情况。比如个别老片没有片长、没有评分或者年份信息异常。如果代码一遇到None就抛异常整个循环都会中断前面抓的数据全部白费。我的习惯是所有字段都做了None兜底取不到就把空字符串放进去。表面上看 CSV 里有一列是空的但这总比程序崩掉、任务中断要好。后期的数据处理可以统一过滤空值根本不耽误事。def safe_get(node): return node.get_text(stripTrue) if node else 再加上主循环里对单页解析做异常捕获一个页面解析出错时只记录日志跳过它继续抓下一个。这个“容错优先”的思路是爬虫从“能跑”到“能长时间稳定跑”的关键一步。4. CSV 导出的三个细节乱码、重复表头、字段顺序抓到的元数据最终要落盘到 CSV 才方便后续用办公软件打开、筛选、分析。CSV 导出看着简单但有几个细节不处理好出来的文件会很难用。4.1 追加写入还是每次重写我推荐追加模式如果你是一次性抓完一批再导出那每次都重写文件没问题。但我是边抓边写希望抓完 50 个页面后文件里就有 50 行数据中途跑挂了也不至于所有数据全部丢失。这种情况下追加写入最合适。追加写入的核心是用modea打开文件而不是默认的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判断文件是否已存在是为了避免表头重复。文件第一次创建时写表头之后就只追加数据行。4.2 为什么必须是 utf-8-sigExcel 乱码的根源很多人导出 CSV 后用 Excel 一打开中文全变成乱码问题基本出在编码上。普通utf-8编码的 CSVExcel 默认会用本地的ANSI解码中文字符自然对不上号。解决办法是写入时用utf-8-sig编码它会在文件开头写入一个 BOM 标记Excel 识别到之后就自动按 UTF-8 解码。我在第一次写爬虫脚本时就是忘了这一步导出的 CSV 用记事本打开正常、用 Excel 打开一片乱码当时还以为是代码写错了排查了半天才发现是编码问题。4.3 字段顺序定义清楚CSV 的第一行就是你的数据字典csv.DictWriter的fieldnames列表顺序就是输出 CSV 里每一列的顺序。我习惯把主标识类字段放前面标题、年份再放人员类导演、编剧、主演然后是评分类评分、评价人数最后是长文本剧情简介。这个顺序对后续人工审查和机器读取都比较友好。如果字段顺序不稳定比如这次是“标题 年份 导演”下次是“导演 标题 年份”CSV 打开后表头乱七八糟不说别人接手你的数据还得重新对齐。这一行 fieldnames最好在写代码时就固定下来一劳永逸。5. 实测里绕不开的坑风控提示、页面结构升级、脏数据过滤前面说的方法已经够跑通一个基础版本但真正让脚本“稳”起来的是处理意外情况的能力。这里把我踩过的几个坑集中讲一下。5.1 风控提示怎么识别与应对豆瓣风控上线得比较早当你请求频率过高时返回的不是拦截页面就是一个看似正常的页面里加了一句“检测到有异常请求从你的 IP 发出”。这种情况下HTML 内容解析时不会报错但你会发现rating、votes这些字段全部消失数据开始大量出现空值。排查这个问题最直接的办法是打印响应状态码和页面标题。如果状态码是 418 或者页面标题变成了“页面不存在”基本可以断定是触发了风控这时候继续硬着头皮跑没有意义。我的处理策略也很简单降低频率、按指数退避重新请求。具体来说第一次遇到异常时 sleep 30 秒重试一次如果还是异常就 sleep 60 秒再试连续三次不行就跳过当前页面并记录日志。注意重试之间要累积延时不要固定间隔地反复重试。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这套重试逻辑非常适合中小体量的爬虫既不会在风控面前死磕也不会因为一两次波动就丢掉数据。5.2 页面结构升级选择器失效时的兜底方案豆瓣改版过好几次某些节点的 class 或属性会调整。我的解析代码之所以能快速适应靠的是两个原则第一优先用稳定属性选择器。比如propertyv:average、propertyv:votes、propertyv:runtime这些属性是豆瓣对外输出的结构化标记比classrating_self clearfix这种视觉样式类稳定得多。样式类说改就改结构化属性通常不会随便变动。第二解析函数统一收口。无论页面怎么变解析逻辑全部放在parse_detail_html一个函数里。网站改版后只需要修改这个函数的内部选择器其他部分完全不用动。我把这种设计理解成“爬虫的依赖倒置”页面结构与采集逻辑隔离变化只出现在一层。5.3 数据清洗过滤掉无效条目和重复条目批量跑完一批数据后别急着直接用。CSV 里可能有些字段缺失严重的行比如连片名都没有的空行也可能因为断点重爬部分条目重复写入。我常用的清洗逻辑很简单用 pandas 读进来先按title去重再过滤掉标题为空的记录最后把评分、年份这类字段的类型转好。排序时按评分降序、评价人数降序方便后续分析。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)这一步清理能从粗糙的原始数据里快速拎出可用部分。很多爬虫教程到“导出 CSV”就结束了但我实际用下来的体会是导出的 CSV 才是数据工作的起点清洗和你抓取本身同样重要。6. 爬虫合理的边界频率、数据量与用途要有分寸最后我想认真聊聊分寸感。写爬虫的都知道“能不能”和“该不该”是两回事。豆瓣详情页的数据本质上是由网友和平台共同维护的公开内容不以登录状态为前提就可以浏览采集它对个人学习、数据分析来说有合理需求。但这不代表可以无限度地抓。我给自己定的边界有三条单次采集控制在可解释的范围内。我的项目需求是几百部影片的元数据这个规模用单线程慢慢跑完全合理。但如果你要做全站几百万条数据的镜像采集那就已经完全超出个人学习范畴了该考虑寻求官方数据接口或授权渠道。控制请求频率。个人用途正常浏览式的频率可以理解但这不意味着可以用高并发去挑战平台防守。我在前面反复提到随机延迟这不仅是技术层面的防封策略也是态度问题。数据只用于个人分析不二次转卖、不公开完整数据集。别人的劳动成果值得手下留情这也是所有技术人生存的基本盘。这套边界在我的所有爬虫项目里都是默认配置。技术本身是中性工具关键是使用方式和用途要经得起推敲。最后再分享一个实操小技巧批量任务结束后把抓取到的 CSV 文件名后缀加上当天的日期比如douban_movies_20240615.csv。这样每次重新抓取、清洗都不会把旧数据覆盖掉如果需要对比不同时间的数据变化也有据可查。我后来很多分析结果能顺利回溯靠的就是这个小习惯。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表