☰
豆瓣TOP250采集实战:Python爬虫解析与数据存储全攻略
2026/10/14 1:04:46 网站建设 项目流程

你可能已经刷到过无数份“高分电影榜单”,但真正自己动手把一份TOP250榜单从网页上干净利落地采集下来,是另一回事。这个案例我反复做过很多次,它是数据采集入门阶段最值得练手的场景之一:页面是服务端渲染的静态HTML,不需要动态渲染引擎;翻页规律一眼就能看穿;字段结构也高度规整。从网页结构分析、请求头构造,到数据清洗、落库存储,一整条链路都覆盖到了,而且踩坑成本很低——即使中间出了差错,页面也不会立刻把IP封死。

我常说,数据采集的核心不是“抓到数据”,而是“稳定地、有礼貌地、可复用地抓到数据”。TOP250这个项目正好把这三个词都练习了一遍。这篇文章会把完整方案拆开讲,涉及请求头怎么写、频率怎么控制、解析怎么容错、存储怎么设计,以及我在实际采集过程中遇到过的各类反爬异常和修复过程。不论你是刚开始学Python的小白,还是已经写过几个简单爬虫想系统提升,这个案例都能直接落地。

1. 整体方案设计:先想清楚再动手

1.1 为什么选择静态HTML解析路线

动工之前,先确认一个问题:目标榜单的页面数据,是服务端一次性渲染在HTML里的,还是前端用JavaScript异步加载的?这决定了你是用requests直接拉HTML,还是需要上无头浏览器。

实测下来,TOP250的列表页确实属于前者。每个电影条目的片名、导演、评分、评价人数、引用语都完整地写在HTML源码里,无需执行任何JS就能拿到。因此,最合适的方案就是requests + BeautifulSoup。它足够轻量,请求开销小,对目标服务器的压力也远小于动不动就启动一个无头浏览器。有人觉得上无头浏览器更“稳”,其实在这个场景里纯属杀鸡用牛刀,还更容易因为浏览器特征暴露而触发风控。

另一个关键点是URL规律。榜单分了10页,每页展示25条,URL中的start参数按0、25、50、75……递增。第一页是start=0,第二页是start=25,第十页是start=225。这意味着翻页逻辑就是一个简单的偏移量循环,根本不需要解析“下一页”按钮的跳转地址。

有时候最笨的规律反而是最稳的,因为你不依赖页面元素的ID或class命名变化。哪怕前端工程师某天把“下一页”按钮的href改成了一段奇怪的JS函数,你的翻页逻辑也不会受任何影响。

1.2 TOP250页面的数据结构拆解

在写任何选择器之前,先用浏览器开发者工具(F12)看一下页面DOM结构,这一步绝对不能省。TOP250的列表结构大致是这样的:

  • 整个列表是一个<ol class="grid_view">,里面每个<li>包着一部电影;
  • 每个<li>内部有一个<div class="item">,这是核心容器;
  • 电影信息在<div class="info">里面,包含标题区<div class="hd">、详情区<div class="bd">和引用语<p class="quote">。

具体的字段映射关系大致如下:

目标字段所在节点
片名(中文名/原名).hd下的span.title
链接div.item > a的href
导演、主演.bd p的第一行文本
年份、国家、类型.bd p的第二行文本
评分span.rating_num
评价人数.star内最后一个span的文本,如“1920000人评价”
一句话引用.quote span.inq

这里有个容易忽略的细节:span.title在片名区域会出现两次,第一次是中文名,第二次是外文名,外文名前面通常带一个空格。写选择器时如果直接用select_one(".title"),拿到的只是第一个,也就是中文名;想拿外文名就需要用select_all("span.title")然后取索引1。

另一个难点在导演和主演那一行。这个<p>里面是用<br/>分隔的两行文本:第一行是“导演: xxx 主演: xxx”(主演可能有也可能没有),第二行是“1994 / 美国 / 犯罪 剧情”。直接用get_text()会把两行粘连成一长串,年份、国家、类型就全混在一起了。处理思路后面在数据清洗部分细说。

1.3 采集策略的基本原则

写代码之前,先把三条原则刻在脑子里,这比任何语法细节都重要:

  • 低频:单线程串行抓取,每两次请求之间至少休息0.5秒到1秒。TOP250一共只有10页,几十个请求而已,完全没必要并发加速。
  • 伪装:请求头要像一个真实浏览器,尤其User-Agent不能是默认的Python库标识,否则第一轮请求就可能收到403。
  • 容错:网络请求不是本地函数调用,什么情况都可能发生。超时、连接重置、被限流都得预设好重试和退让机制。

我知道有人会觉得0.5秒的间隔太保守,25页全跑完也就十几秒。但采集这件事,快不是本事,稳才是。尤其后面对站点结构不熟悉时,慢一点能换来极大的调试空间。

2. 请求环节:构造一个“正常用户”的访问

2.1 请求头不是装饰,是诚信声明

我见过不少新手写请求时只带一个User-Agent,其他全不放,结果经常莫名其妙被拒。请求头的作用是模拟浏览器的网络行为特征。服务端通过请求头判断访客身份时,看的不是某一个字段,而是一组字段的组合。

一个比较稳妥的请求头配置长这样:

headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://movie.douban.com/", "Connection": "keep-alive" }
  • User-Agent是最基础的“身份证”,直接写浏览器版本标识,注意别在中间混入“requests”“python”之类的字样;
  • Referer表示你是从哪个页面跳转过来的,主流浏览器访问榜单时,Referer一般是网站首页或者榜单本身;
  • Accept-Language在中文站点很重要,它暗示你的浏览环境偏向中文,少了它可能被识别为脚本客户端。

还有一个容易踩的坑:Cookie。直接访问榜单列表页通常不需要登录,但如果后面想采集“我收藏的电影”这类需要登录状态的页面,就得带Cookie。TOP250这个场景不用刻意构建Cookie,保持请求头和其他客户端一致就行。

这里还要说一句:代码里出现请求头,不代表你要在里面伪造大量字段。核心原则是“和浏览器保持一致”,而不是“表现得比浏览器还像浏览器”。有些反爬系统会检测异常一致的请求头组合,反而弄巧成拙。

2.2 超时、重试与请求间隔

请求处理不能是“发一次请求,爱回不回”。在工程上,任何网络请求都要预设三件事:超时时间、失败重试次数、重试间隔。

我习惯这样设置:

resp = requests.get(url, headers=headers, timeout=8)

timeout=8表示连接和读取的总超时时间为8秒。超过这个时间没有响应就抛出异常,而不是无限期地挂在那里。重试逻辑采用“退让式重试”:第一次失败后休息2秒,第二次失败后休息3秒,最多尝试3次。如果3次都失败,就说明大概率不是临时抖动,而是请求被拦了,这时候该停下来反思,而不是继续硬刚。

请求间隔上,我通常设置time.sleep(random.uniform(0.8, 1.5))。加一个随机浮动,而不是固定的1秒,是为了让请求节奏更接近人类行为。固定间隔看起来机械而规律,反而容易被风控系统识别。

2.3 被拦截时先减速,别硬碰硬

采集过程中,最常见的拦截表现是 HTTP 状态码 403 或 418。403 表示“服务器拒绝请求”,418 是另一道反爬机制特有的状态码(通常是拦截脚本客户端的“不速之客”通知)。偶发的 429 表示“请求过于频繁”。

遇到这些状态码,我建议的做法是:

  1. 先停止当前任务,不要继续翻页;
  2. 等待5到10分钟,让IP“冷静”下来;
  3. 恢复后降低请求频率,把间隔从1秒拉长到3秒以上;
  4. 检查最近一次请求的请求头是否缺失或异常。

不要试图通过换代理IP来“勇闯禁地”,也不要尝试解析验证码。从个人学习项目的角度讲,尊重目标站点的服务条款、控制频率是长期可采的前提;从技术角度讲,验证码识别涉及复杂图像处理,也不是这个入门案例该碰的东西。

3. 数据解析与清洗:从HTML到结构化记录

3.1 解析库选型与选择器

Python里常用的HTML解析库有两个方向:BeautifulSoup4 + lxml和lxml的XPath。前者可读性强,选择器语法接近CSS;后者语法紧凑,运行效率高。对这个数据量只有10页的场景,性能差距可以忽略,我更推荐BeautifulSoup4,理由是它的API对新手更友好,出错时调试也直观。

安装很简单:

pip install beautifulsoup4 lxml

注意解析器参数。创建BeautifulSoup对象时最好指定"lxml"作为解析器:

soup = BeautifulSoup(html, "lxml")

不指定解析器会有警告,默认的html.parser虽然也能解析,但处理不规范HTML时的容错略差。既然装了lxml,就顺手用上。

定位列表项我习惯用soup.select("div.item"),这是一种CSS风格选择器。写选择器时有个技巧:先选大容器,再逐层往下取字段,不要一上来就写一长串从html到具体字段的绝对路径,那样一旦层级变动就全崩。

3.2 关键字段的逐项提取

提取字段时,我习惯先处理3个最容易出问题的地方:链接去重、标题分离、详情行拆分。

链接去重很简单,因为div.item > a的href就是电影详情页地址,每部电影唯一。我后面设计断点续采时,就用这个链接作为主键判断是否已经采集过。

标题分离用到的代码大概是:

titles = item.select("span.title") cn_title = titles[0].get_text(strip=True) if titles else "" en_title = titles[1].get_text(strip=True).replace("\xa0", "") if len(titles) > 1 else ""

这里有个字符坑:外文名前面那个空格,在HTML源码里实际上是\xa0(不间断空格),直接用strip()去不掉,必须显式 replace。这个坑不亲自踩一次,还真不一定能发现。

详情行拆分的思路,是把<p>中的<br/>替换成换行符,然后再把文本按换行切分。代码参考:

meta_p = item.select_one(".bd p").find_all(text=True, recursive=False)

更简单的做法是直接复制整个<p>的HTML,把<br>换成\n:

import re p_html = str(item.select_one(".bd p")) p_html = re.sub(r"<br\s*/?>", "\n", p_html) text_block = BeautifulSoup(p_html, "lxml").get_text(strip=True) lines = text_block.split("\n")

lines[0]就是“导演: xxx 主演: xxx”,lines[1]就是“1994 / 美国 / 犯罪 剧情”。这个处理方式对<br/>、<br>都兼容,实测很稳。

取导演和主演时,直接用正则从lines[0]里拆:

director = re.search(r"导演: (.*?)(?: 主演: |$)", lines[0]) actors = re.search(r"主演: (.*)", lines[0])

要注意主演是可选的,有些老电影没有主演信息,正则写的时候要给“主演”部分加可选分支,否则re.search匹配到None再取.group(1)就会抛异常。

年份、国家、类型从lines[1]拆,标准格式是1994 / 美国 / 犯罪 剧情:

meta = lines[1].split(" / ") year = meta[0] country = meta[1] genres = meta[2]

评分和评价人数没有太多技巧:

rating = item.select_one(".rating_num").get_text(strip=True) comment_count = re.search(r"(\d+)人评价", item.select_one(".star").get_text())

引用语是可选字段,直接用select_one(".quote .inq"),取不到就填空字符串。

3.3 脏数据的清洗规则

解析完原始字段后,还需要一道清洗工序。我把常见的清洗规则总结成了四步:

  • 去除不可见空格:文本里所有\xa0统一替换成普通空格,再 strip;
  • 数字标准化:评价人数是“1906815人评价”,用正则只提取数字部分,转成int;
  • 评分转float:评分字段是字符串,比如"9.7",要float()转成数字,否则后面做数据分析时排序会出错;
  • 缺失字段兜底:引用语、主演、外文名都可能缺失。每个取字段的地方都用条件判断给默认值,不要让一个字段的缺失拖垮整条记录。

清洗有个原则:宁可在解析时多写两行判断,也不要等到存储后再发现脏数据。数据一旦进库,再清洗就麻烦得多。

4. 存储方案:数据落到本地才算采集完成

4.1 CSV、JSON还是SQLite

采集到的数据最终用三种格式之一落盘:CSV、JSON、SQLite。我的选择标准很简单:

  • 如果只是一次性看数据、做可视化,CSV最方便,Excel和pandas都能直接读;
  • 如果后续要把数据分享给其他程序,或者对接API,JSON更合适;
  • 如果数据可能反复更新、需要去重、需要按条件和排名查询,SQLite是首选。

TOP250这个场景,我建议同时输出CSV和SQLite。CSV负责“看”,SQLite负责“查”。这样既方便人工检查,也方便以后做增量采集时查重。

4.2 CSV写入的编码与格式坑

CSV的两个经典坑,一个是Excel打开中文乱码,一个是每两行之间多一个空行。

乱码问题的根源是编码不一致。Python默认写入CSV用utf-8,但Excel打开时默认按本地编码(中文系统一般是GBK)解析,所以中文全变乱码。解决办法是写文件时用utf-8-sig编码,它会自动在文件开头加一个BOM标记,Excel看到后就能正确识别。

空行问题的根源是写入时同时用了csv.writer的默认换行和open的默认换行。解决办法是open时指定newline=""。

正确写法是:

with open("top250.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["排名", "片名", "原名", "导演", "主演", "年份", "国家", "类型", "评分", "评价人数", "引用语", "链接"])

4.3 SQLite建表与去重设计

SQLite不需要独立的数据库服务,sqlite3是Python标准库,直接导入就能用。建表语句我通常这样写:

CREATE TABLE IF NOT EXISTS top250 ( id INTEGER PRIMARY KEY AUTOINCREMENT, movie_link TEXT UNIQUE, rank_no INTEGER, title TEXT, original_title TEXT, director TEXT, actors TEXT, year INTEGER, country TEXT, genres TEXT, rating REAL, comment_count INTEGER, quote TEXT );

关键在movie_link TEXT UNIQUE这一行。它保证了每条电影链接只能入库一次,重复采集同一个链接时,SQLite会抛唯一约束冲突。配合INSERT OR IGNORE语法,就可以实现“已存在的记录自动跳过”,这就是断点续采的底层保障。

写入时用参数化查询,也就是给execute传占位符?和元组参数,不要用字符串拼接SQL。这既防注入,也避免引号转义问题:

conn.execute( "INSERT OR IGNORE INTO top250 (movie_link, rank_no, title, ...) VALUES (?, ?, ?, ...)", (link, rank_no, title, ...) )

提交数据时有个实操细节:不要每插入一条就commit一次,那会拖慢速度。正确做法是攒20条左右commit一次,或者每页采集完统一提交一次。万一中间程序崩了,损失的也就最后一页的数据,重新跑一遍就能补上。

5. 翻页调度与断点续采

5.1 翻页参数与循环逻辑

TOP250的翻页逻辑是标准的start偏移量调度,跟页码的换算关系是:

start = (page - 1) * 25

第一页start=0,第二页start=25,第十页start=225。循环可以这样写:

seen_links = load_seen_links() # 从已有数据库或CSV里读入已采集链接 for page in range(1, 11): start = (page - 1) * 25 url = f"https://movie.douban.com/top250?start={start}&filter=" html = fetch_page(url) if html is None: print(f"第{page}页获取失败,跳过") continue movie_list = parse_html(html, seen_links) save_to_csv(movie_list) save_to_sqlite(movie_list) time.sleep(random.uniform(0.8, 1.5))

这里我故意把URL拼成了f-string的形式,但这不是唯一选择。也可以像requests.get(base_url, params={"start": start})这样传参,让库来处理参数拼接,避免问号位置弄错。两种方式都行,我更推荐后者,代码更干净。

5.2 单线程串行还是并发

说完翻页,再回答一个很多新手会问的问题:10页数据,能不能开线程池并发抓,一次全抓完?

能,但在这个场景里我不建议。

TOP250一共就250条数据,串行加间隔,最多30秒跑完。并发带来的收益微乎其微,却把请求特征变得特别明显:同一秒内好几个请求同时进来,间隔趋近于零,这是典型的脚本行为。对于个人学习项目,为了保护目标服务和自己的IP,单线程加合理间隔是最优解。

这套代码更大的价值在于可扩展。如果哪一天数据源从TOP250换成某个有几十万个条目的站点,你不需要推翻单线程方案,只需要在fetch_page外面套一层任务队列,把URL和已采集集合交给多线程任务池去调度。调度策略可以这样演进:第一步单线程间隔抓取;第二步线程池控制并发数为3到5;第三步多机分布式调度。Mermaid图就不画了,核心思路是从“单点可控”走向“多点协同”,而不是一上来就上分布式架构。

5.3 断点续采与增量更新设计

断点续采是工程化采集和玩具脚本的分水岭。玩具脚本的特点是:中断了就从第一页重跑;工程化代码的特点是:中断了继续跑,自动跳过已经有的数据。

实现的关键是两个数据结构:

  1. 一个全量set,里面存所有已经采集过的电影链接;
  2. 一个去重逻辑,在解析完每个条目后先判断链接是否已存在。

首次运行,set为空;运行中断后再次启动,先从SQLite里把所有movie_link查出来塞进set,再启动翻页循环。解析时遇到已在集合里的链接,直接跳过,不需要重复请求详情页。

增量更新也依赖这个设计。假设榜单更新了两部电影,你重新跑一次采集,程序会跳过之前已有的248部,只采集新出现的2部。这是movie_link UNIQUE+INSERT OR IGNORE+ 内存set三者配合的结果。

还有一个容易忽视的细节:seen_links必须在parse_html内部同步更新。解析到一个新链接后,除了加入结果列表,还要马上add进set,否则同一页里如果出现重复链接(虽然TOP250不太可能,但其他站点会遇到),就会重复采集。

6. 完整代码实现与运行效果

6.1 可直接运行的采集脚本

我把上述所有设计整合成一个完整的可运行脚本。没有拆分多文件,方便直接复制运行:

import requests from bs4 import BeautifulSoup import csv import sqlite3 import time import random import re BASE_URL = "https://movie.douban.com/top250" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://movie.douban.com/", } DB_NAME = "top250.db" CSV_NAME = "top250.csv" def get_soup(html): return BeautifulSoup(html, "lxml") def load_seen_links(): seen = set() conn = sqlite3.connect(DB_NAME) try: rows = conn.execute("SELECT movie_link FROM top250").fetchall() for row in rows: seen.add(row[0]) except sqlite3.Error: pass conn.close() return seen def fetch_page(start): params = {"start": start, "filter": ""} for attempt in range(3): try: resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=8) if resp.status_code == 200: return resp.text if resp.status_code in (403, 418): print(f"请求被拦截,状态码: {resp.status_code}") return None print(f"请求异常,状态码: {resp.status_code},重试 {attempt + 1}/3") except requests.RequestException as e: print(f"请求异常: {e},重试 {attempt + 1}/3") time.sleep(random.uniform(1.5, 3)) return None def parse_html(html, seen_links): soup = get_soup(html) items = soup.select("div.item") records = [] for item in items: link_tag = item.select_one(".hd a") if not link_tag: continue link = link_tag.get("href", "").strip() if link in seen_links: continue seen_links.add(link) titles = item.select("span.title") cn_title = titles[0].get_text(strip=True) if titles else "" en_title = "" if len(titles) > 1: en_title = titles[1].get_text(strip=True).replace("\xa0", "") meta_p = item.select_one(".bd p") if not meta_p: continue p_html = re.sub(r"<br\s*/?>", "\n", str(meta_p)) lines = BeautifulSoup(p_html, "lxml").get_text(strip=True).split("\n") director_line = lines[0] if lines else "" year_line = lines[1] if len(lines) > 1 else "" director = "" actors = "" d_match = re.search(r"导演: (.*?)(?: 主演: |$)", director_line) if d_match: director = d_match.group(1).strip() a_match = re.search(r"主演: (.*)", director_line) if a_match: actors = a_match.group(1).strip() year = "" country = "" genres = "" if year_line: meta_parts = year_line.split(" / ") year = meta_parts[0].strip() country = meta_parts[1].strip() if len(meta_parts) > 1 else "" genres = meta_parts[2].strip() if len(meta_parts) > 2 else "" rating = item.select_one(".rating_num").get_text(strip=True) if item.select_one(".rating_num") else "" star_text = item.select_one(".star").get_text() if item.select_one(".star") else "" comment_m = re.search(r"(\d+)人评价", star_text) comment_count = int(comment_m.group(1)) if comment_m else 0 quote_tag = item.select_one(".quote .inq") quote = quote_tag.get_text(strip=True) if quote_tag else "" records.append({ "link": link, "title": cn_title, "original_title": en_title, "director": director, "actors": actors, "year": year, "country": country, "genres": genres, "rating": rating, "comment_count": comment_count, "quote": quote, }) return records def save_to_csv(records): if not records: return new_file = not __import__("os").path.exists(CSV_NAME) with open(CSV_NAME, "a", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) if new_file: writer.writerow(["片名", "原名", "导演", "主演", "年份", "国家", "类型", "评分", "评价人数", "引用语", "链接"]) for r in records: writer.writerow([r["title"], r["original_title"], r["director"], r["actors"], r["year"], r["country"], r["genres"], r["rating"], r["comment_count"], r["quote"], r["link"]]) def save_to_sqlite(records): if not records: return conn = sqlite3.connect(DB_NAME) conn.execute(""" CREATE TABLE IF NOT EXISTS top250 ( id INTEGER PRIMARY KEY AUTOINCREMENT, movie_link TEXT UNIQUE, title TEXT, original_title TEXT, director TEXT, actors TEXT, year INTEGER, country TEXT, genres TEXT, rating REAL, comment_count INTEGER, quote TEXT ) """) for r in records: conn.execute( "INSERT OR IGNORE INTO top250 (movie_link, title, original_title, director, actors, year, country, genres, rating, comment_count, quote) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (r["link"], r["title"], r["original_title"], r["director"], r["actors"], r["year"], r["country"], r["genres"], r["rating"], r["comment_count"], r["quote"]) ) conn.commit() conn.close() def main(): seen_links = load_seen_links() print(f"已从数据库加载 {len(seen_links)} 条已采集数据") for page in range(1, 11): start = (page - 1) * 25 print(f"正在采集第 {page} 页,start={start}") html = fetch_page(start) if html is None: print(f"第 {page} 页获取失败,跳过") continue records = parse_html(html, seen_links) print(f"第 {page} 页解析出 {len(records)} 条新数据") save_to_csv(records) save_to_sqlite(records) time.sleep(random.uniform(0.8, 1.5)) print("采集完成") if __name__ == "__main__": main()

这个脚本把前面几章讲的所有设计都落到了代码里:请求头、退让重试、链接去重、字段容错、CSV和SQLite双写。实际运行前,先确认虚拟环境里装好了requests、beautifulsoup4、lxml。

6.2 运行结果与产出说明

第一次运行后,你会得到两个文件:

  • top250.csv:Excel可直接打开,编码为utf-8-sig;
  • top250.db:SQLite数据库,里面有top250表。

跑完一页就能在终端看到类似输出:

已从数据库加载 0 条已采集数据 正在采集第 1 页,start=0 第 1 页解析出 25 条新数据 正在采集第 2 页,start=25 第 2 页解析出 25 条新数据

如果第二次运行,第一行会变成“已从数据库加载 250 条已采集数据”,然后每页解析出0条新数据——因为全部跳过。这可能让你产生“程序是不是坏了”的错觉,其实这正是断点续采在正常工作。

7. 常见问题与排查实录

7.1 状态码异常排查

现象可能原因处理方案
403请求头缺失或User-Agent不像浏览器补全请求头,尤其注意UA和Referer
418反爬系统识别为脚本客户端停止程序,等5-10分钟,降低频率再跑
429请求过于频繁增大请求间隔,比如改成2-3秒
500服务端异常或ip被封大概率是临时问题,过一段时间再试

最典型的一次,我收到418后检查发现,User-Agent里带了个旧版库的标识,从日志看整个请求的“指纹”都很像爬虫。修掉UA后恢复正常。所以遇到状态码异常,先检查请求头,再谈其他。

7.2 解析结果一直为空

如果状态码是200,但items列表一直为空,大概率是拿到的HTML不是预期的榜单页。比如:页面可能弹出了验证码页,或者跳到了安全验证页面,这种页面自然没有div.item节点。

排查方法很简单:把html内容存成.html文件,用浏览器直接打开看看到底返回了什么。你在终端里只看得到状态码,看不到页面内容,所以“眼见为实”这一步很关键。

还有一种情况是解析器的选择器写错了。TOP250页面结构偶尔会微调,比如某个class改名。你在F12里看到的是浏览器渲染后的DOM,但requests拿到的是原始HTML,两者结构不完全一致。排查时用浏览器“查看网页源代码”而不是“开发者工具”里的标签页,才更接近requests拿到的东西。

7.3 Excel打开CSV乱码

utf-8编码的CSV文件在Excel里显示乱码,是全世界Python初学者的共同噩梦。解决方式前面讲了:写入时改用encoding="utf-8-sig"。如果文件已经生成但乱码了,可以把文件用记事本打开,另存为ANSI编码;或者直接在代码里改编码重新跑一遍。

如果你用的不是Excel而是其他表格软件或pandas,utf-8-sig也完全兼容,不影响读取。所以放心用这个编码。

7.4 重复数据和入库异常

程序跑了两次,数据库里同一个链接出现了多行,怎么办?

先检查建表语句中是否有movie_link TEXT UNIQUE。如果忘了加唯一约束,INSERT OR IGNORE是不生效的。解决方式有两种:

  • 手动清理:先把现有表删掉,重建带唯一约束的表,再全量采集一次;
  • 软去重:保留当前表,每次插入前用SELECT 1 FROM top250 WHERE movie_link=?判断是否已存在。

两种方式都行,但工程上更推荐第一种,因为唯一约束在数据库层面兜底,比在应用层判断更保险。

如果插入时报sqlite3.IntegrityError,说明你用了INSERT INTO而不是INSERT OR IGNORE,或者唯一约束本身就被破坏了。实际项目里更常见的报错反而是类型不匹配,比如年份字段传了一个字符串,但表结构定义为INTEGER。SQLite对类型其实比较宽容,但为了可读性,写代码时手动int()转换一下更稳妥。

7.5 采集过程中断后如何恢复

中断有两种:一种是代码抛异常退出,另一种是网络错误导致某几页没抓到。

对第一种,重新运行main()就行,load_seen_links()会自动跳过已采集的数据。对第二种,你会发现某页被跳过但其他页都采到了,重跑时会补上缺失的那一页,已存在的其他页都会走“跳过”逻辑。

所以整个项目的容错设计,围绕“可重入”这个词就能理解。每一条数据要么完整采集入库,要么不在库中,不存在半截状态。这是数据采集项目应该有的基本素质。

最后说点实操体会

这个项目我前前后后写过好几版,从最初只输出CSV的十几个脚本,到后来带数据库、断点续采的完整版本,体会最深的是:代码量不重要,数据流的闭环才重要。你在页面里看到一条电影记录,要思考它在HTML里怎么定位,在Python里怎么表示,在存储里怎么组织,在下次运行里怎么识别身份。这条思路一打通,很多采集需求都能套用。

如果把TOP250当作起点,接下来可以往几个方向扩展:一是换其他榜单或分类页,改成带参数的采集;二是做数据可视化,看看TOP250的评分分布、年份分布有什么规律,我跑过一次,发现评分集中在8.5到9.3之间,年份分布则明显偏向近30年,这个结论用采集到的数据画出来特别直观;三是把清洗和存储逻辑抽象成通用工具函数,以后遇到新站点直接复用骨架。

我的建议是,别急着追求“一次跑完250条”的结果,而是先在一两页上反复调试,把每页25条数据采干净,再去跑全量。这个习惯能帮你省掉大量排查问题的时间。稳定的采集代码,比跑得快的采集代码,重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询