资讯动态

Python爬虫实战:二级页面处理与并发抓取电影数据

发布时间:2026/8/18 10:11:52 来源:尧图企业网站定制
1. 项目概述从电影列表到详情页的完整数据抓取最近在整理一个本地影视库需要批量获取一些电影的基本信息、评分和简介。手动去各个网站复制粘贴显然不现实于是把目光投向了像4567电影网这样的资源站。这类网站通常结构清晰电影列表页规整详情页信息集中是练习和实战Python爬虫的绝佳目标。这个项目的核心就是写一个爬虫能够自动遍历电影列表然后逐个“点”进详情页把我们需要的信息一网打尽。听起来简单但其中涉及到的“二级页面处理”是爬虫从入门到熟练的关键一步它意味着你的爬虫不再是单页面的简单抓取而是具备了模拟用户浏览路径、串联多页面数据的能力。对于刚接触爬虫的朋友来说直接从列表页获取电影名和链接可能已经掌握但如何高效、稳定地调度这些链接进入二级页面即详情页提取更丰富的信息并且处理好可能出现的各种异常如网络超时、页面结构变动才是真正考验代码健壮性和设计思路的地方。本次实战我将使用requests库进行网页请求用re正则表达式和parsel或lxml来解析HTML重点分享在二级页面处理中URL的拼接、请求调度、数据合并以及错误重试等一套完整方案。无论你是想构建自己的电影数据集还是学习通用的多级页面爬虫框架这个案例都能提供直接的参考。2. 核心思路与架构设计2.1 目标分析与技术选型首先我们得明确爬取的目标。以4567电影网为例典型的爬取路径是从某个分类列表页比如“最新电影”开始这个列表页通常包含多部电影的条目每个条目会有电影名称和指向其详情页的链接URL。我们的目标是获取详情页里的信息如导演、主演、上映日期、评分、剧情简介等。因此爬虫的工作流可以拆解为两个主要阶段一级页面列表页抓取与解析获取所有电影详情页的URL。二级页面详情页抓取与解析遍历上一步得到的URL列表逐个请求并提取目标信息。技术选型上requests库因其简单易用、社区成熟是发起HTTP请求的不二之选。对于HTML解析正则表达式 (re) 在匹配有规律的文本如特定的标签模式时非常高效但对于复杂的、嵌套深的HTML结构它容易出错且难以维护。因此我推荐使用parsel或lxml这类基于XPath或CSS选择器的解析库它们更结构化容错性更好。本项目将结合两者用parsel处理主要的页面结构用正则表达式辅助提取一些嵌在脚本里或特定格式的文本信息。注意在开始任何爬取操作前务必检查目标网站的robots.txt文件通常在网站根目录如https://www.4567.tv/robots.txt并尊重其中的爬虫协议。同时应在请求头中设置合理的User-Agent模拟真实浏览器访问避免因请求过于频繁或特征明显而被封禁IP。2.2 二级页面处理的核心挑战与方案二级页面处理不是简单地循环请求URL它至少面临三个核心挑战URL的规范化与拼接列表页提取的链接可能是相对路径如/movie/12345.html也可能是绝对路径。我们需要一个可靠的方法将其转换为可请求的完整URL。请求调度与效率同步顺序请求大量详情页会非常慢。我们需要引入简单的并发机制如threading或asyncio来提升效率但同时必须控制并发度避免对目标服务器造成过大压力。异常处理与健壮性网络请求可能超时、返回错误状态码如404、502页面HTML结构也可能微调导致解析失败。爬虫必须能妥善处理这些异常记录失败日志并尽可能继续执行后续任务。我的设计方案是构建一个“生产者-消费者”模型。主线程作为“生产者”负责解析列表页生成详情页URL队列。然后启动多个“消费者”线程或异步任务从队列中获取URL并执行请求、解析、存储数据的工作。每个消费者线程内部都包含完善的异常处理逻辑。3. 环境准备与基础工具函数3.1 安装必要的库确保你的Python环境建议3.7以上中已安装以下库。如果未安装使用pip进行安装。pip install requests parselparsel库封装了lxml和cssselect提供了非常友好的CSS和XPath选择器接口是我们解析页面的主力。3.2 定义全局配置与工具函数在代码开头我们先定义一些全局配置和工具函数让后续代码更清晰。import requests import parsel import re import time import logging from urllib.parse import urljoin from concurrent.futures import ThreadPoolExecutor, as_completed # 基础配置 BASE_URL https://www.4567.tv # 网站基础域名用于URL拼接 LIST_PAGE_URL f{BASE_URL}/dianying/new.html # 示例列表页可根据需要修改 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 } # 请求会话可以保持一些连接参数比直接使用requests.get更高效 SESSION requests.Session() SESSION.headers.update(HEADERS) # 日志配置方便查看运行过程和错误 logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) def fetch_page(url, max_retries3, timeout10): 获取网页内容的通用函数包含重试机制。 :param url: 目标URL :param max_retries: 最大重试次数 :param timeout: 请求超时时间秒 :return: 成功则返回响应文本失败返回None for attempt in range(max_retries): try: resp SESSION.get(url, timeouttimeout) resp.raise_for_status() # 如果状态码不是200抛出HTTPError异常 # 检查编码避免乱码 resp.encoding resp.apparent_encoding or utf-8 return resp.text except requests.exceptions.RequestException as e: logger.warning(f第{attempt1}次请求失败: {url}, 错误: {e}) if attempt max_retries - 1: wait_time 2 ** attempt # 指数退避策略 logger.info(f等待{wait_time}秒后重试...) time.sleep(wait_time) else: logger.error(f请求{url}失败已达最大重试次数。) return None def save_to_csv(data_list, filenamemovies.csv): 将数据列表保存到CSV文件简单示例 import csv if not data_list: return keys data_list[0].keys() with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnameskeys) writer.writeheader() writer.writerows(data_list) logger.info(f数据已保存至 {filename})fetch_page函数是核心工具它封装了网络请求、重试和基础错误处理。使用urljoin可以智能地拼接基础URL和相对路径生成绝对路径这是处理二级页面URL的关键。4. 一级页面列表页解析与URL提取列表页是我们的起点。我们需要分析其HTML结构找到电影条目所在的区域并从中提取电影名称和详情页链接。4.1 分析页面结构使用浏览器开发者工具F12打开目标列表页查看电影条目对应的HTML代码。假设我们发现每个电影条目都包裹在一个类名为movie-item的div标签内电影标题和链接在一个a标签里。div classmovie-item a href/movie/12345.html title电影A img src... h3电影A/h3 /a span classscore8.5/span /div4.2 编写解析函数根据上面的结构我们编写解析列表页的函数。def parse_list_page(html_content): 解析列表页HTML提取所有电影的详情页URL和标题。 :param html_content: 列表页的HTML文本 :return: 返回一个列表每个元素是包含(title, detail_url)的字典 if not html_content: return [] selector parsel.Selector(html_content) movie_items selector.css(.movie-item) # 使用CSS选择器定位所有电影条目 movie_links [] for item in movie_items: # 提取详情页链接通常是a标签的href属性 link_tag item.css(a[href*/movie/]).get() # 获取第一个匹配的a标签的HTML字符串 if link_tag: # 使用parsel从标签字符串中提取属性 link_selector parsel.Selector(textlink_tag) relative_url link_selector.css(a::attr(href)).get() title link_selector.css(a::attr(title)).get() or link_selector.css(h3::text).get() if relative_url: # 将相对URL转换为绝对URL absolute_url urljoin(BASE_URL, relative_url) movie_links.append({ title: title.strip() if title else 未知标题, detail_url: absolute_url }) logger.info(f从列表页解析到 {len(movie_links)} 个电影链接。) return movie_links这里使用了parsel的CSS选择器语法::attr(href)用于提取属性::text用于提取文本。urljoin确保了无论提取到的是相对路径如/movie/123.html还是绝对路径如https://www.4567.tv/movie/123.html都能得到正确的完整URL。实操心得网页结构可能会变所以不要将选择器路径写得太死。尽量使用具有唯一性的类名或属性。如果movie-item这个类名不稳定可以尝试寻找其父级容器或者使用XPath进行更灵活的定位。例如可以使用//div[contains(class, movie)]/a[href]这样的XPath。5. 二级页面详情页信息提取拿到详情页URL后下一步就是深入每个页面提取我们关心的信息。详情页的信息通常更分散需要仔细分析。5.1 分析详情页结构打开一个电影详情页查看导演、主演、上映日期、评分、简介等信息所在的HTML标签。假设信息分布如下导演位于div classdirector导演span某某某/span/div主演位于div classactors主演span演员A, 演员B/span/div上映日期位于span classrelease-date2023-05-01/span评分可能直接是文本也可能藏在>def parse_detail_page(html_content, base_info): 解析电影详情页HTML提取详细信息。 :param html_content: 详情页HTML文本 :param base_info: 从列表页获取的基础信息如title和detail_url :return: 包含所有提取信息的字典 if not html_content: return {**base_info, error: 页面获取失败} selector parsel.Selector(html_content) movie_info {**base_info} # 1. 提取导演信息 (CSS选择器示例) director_span selector.css(div.director span::text).get() movie_info[director] director_span.strip() if director_span else 未知 # 2. 提取主演信息 actors_span selector.css(div.actors span::text).get() if actors_span: # 假设主演用逗号分隔 movie_info[actors] [actor.strip() for actor in actors_span.split(,)] else: movie_info[actors] [] # 3. 提取上映日期 release_date selector.css(span.release-date::text).get() movie_info[release_date] release_date.strip() if release_date else 未知 # 4. 提取评分 (正则表达式示例假设评分在脚本变量中) # 假设页面中有 scriptvar movieScore 8.5;/script script_text selector.xpath(//script[contains(text(), movieScore)]/text()).get() score None if script_text: # 使用正则表达式匹配数字包括小数 match re.search(rmovieScore\s*\s*([\d.]), script_text) if match: score match.group(1) movie_info[score] score or selector.css(.score::text).get(default无评分).strip() # 5. 提取剧情简介 summary_paragraphs selector.css(div.summary p::text).getall() movie_info[summary] .join([p.strip() for p in summary_paragraphs if p.strip()]) # 6. 可选提取其他信息如电影类型、片长、国家等 # 假设信息在一个dl列表里 info_items selector.css(dl.info-list dt, dl.info-list dd) info_dict {} for i in range(0, len(info_items), 2): if i1 len(info_items): key info_items[i].css(::text).get(default).strip().rstrip() value info_items[i1].css(::text).get(default).strip() if key and value: info_dict[key] value movie_info.update(info_dict) # 将字典合并到主信息中 logger.debug(f已解析电影: {movie_info.get(title)}) return movie_info这个函数展示了两种主要的解析方式对于结构规整、有明确CSS类名的信息直接用parsel的CSS选择器对于嵌入在JavaScript变量或特定格式文本中的信息则用正则表达式re来匹配。base_info参数用于传递从列表页带来的信息确保最终数据记录的完整性。注意事项正则表达式虽然强大但过度依赖它来解析HTML是脆弱的。一旦页面中无关的空格、换行符稍有变化就可能匹配失败。因此原则是能用CSS/XPath选择器稳定获取的绝不用正则只有选择器无法直接触及的文本片段如脚本内的变量才考虑使用正则表达式。编写正则时尽量使用非贪婪匹配.*?并考虑可能存在的空白字符\s*。6. 整合与并发调度实现完整爬虫流程现在我们把所有部分组合起来并引入并发机制以提高效率。6.1 单线程串联版本基础版首先我们实现一个简单清晰的单线程版本理解完整流程。def main_single_thread(): 单线程版本的主函数 all_movie_data [] # 步骤1: 获取并解析列表页 logger.info(开始抓取列表页...) list_page_html fetch_page(LIST_PAGE_URL) if not list_page_html: logger.error(列表页抓取失败程序退出。) return movie_links parse_list_page(list_page_html) if not movie_links: logger.warning(未从列表页解析到任何电影链接。) return # 步骤2: 遍历每个电影链接抓取并解析详情页 logger.info(f开始抓取 {len(movie_links)} 个详情页...) for idx, link_info in enumerate(movie_links, 1): logger.info(f正在处理第 {idx}/{len(movie_links)} 个: {link_info[title]}) detail_html fetch_page(link_info[detail_url]) if detail_html: movie_detail parse_detail_page(detail_html, link_info) all_movie_data.append(movie_detail) else: logger.error(f电影详情页抓取失败: {link_info[detail_url]}) # 可以记录失败信息以便后续重试 # 礼貌性延迟避免请求过快 time.sleep(1) # 步骤3: 保存数据 logger.info(所有页面处理完成开始保存数据。) save_to_csv(all_movie_data) logger.info(f共成功爬取 {len(all_movie_data)} 条电影数据。)这个版本逻辑直白但速度慢尤其是当电影数量较多时。time.sleep(1)是必要的礼貌延迟但进一步拖慢了速度。6.2 多线程并发版本进阶版为了提高效率我们使用ThreadPoolExecutor来并发处理详情页的请求和解析。这是二级页面爬虫的典型优化手段。def worker(link_info): 每个线程执行的任务抓取并解析一个详情页 logger.debug(f开始处理: {link_info[title]}) detail_html fetch_page(link_info[detail_url]) if detail_html: return parse_detail_page(detail_html, link_info) else: logger.error(f详情页抓取失败: {link_info[detail_url]}) return {**link_info, error: 抓取失败} def main_concurrent(max_workers5): 使用线程池并发执行的主函数 all_movie_data [] # 步骤1: 获取列表页 (这部分保持单线程因为通常只有一个列表页) logger.info(开始抓取列表页...) list_page_html fetch_page(LIST_PAGE_URL) if not list_page_html: return movie_links parse_list_page(list_page_html) if not movie_links: return logger.info(f解析到 {len(movie_links)} 个电影链接开始并发抓取详情页...) # 步骤2: 使用线程池并发处理详情页 # 使用 with 语句确保线程池正确关闭 with ThreadPoolExecutor(max_workersmax_workers) as executor: # 提交所有任务到线程池得到一组Future对象 future_to_movie {executor.submit(worker, link): link for link in movie_links} # 使用 as_completed 获取已完成的任务结果 for future in as_completed(future_to_movie): link_info future_to_movie[future] try: movie_data future.result(timeout15) # 设置单个任务超时 all_movie_data.append(movie_data) logger.info(f完成: {movie_data.get(title)}) except Exception as exc: logger.error(f处理 {link_info[title]} 时产生异常: {exc}) all_movie_data.append({**link_info, error: str(exc)}) # 步骤3: 保存数据 logger.info(并发抓取完成开始保存数据。) save_to_csv(all_movie_data) logger.info(f共处理 {len(movie_links)} 个链接成功获取 {len([d for d in all_movie_data if error not in d])} 条有效数据。)在这个版本中max_workers控制了并发线程数一般设置为5-10比较合适既能提升速度又不会对目标网站造成过大负担。ThreadPoolExecutor管理了线程的创建和回收as_completed让我们能在每个任务完成时立即处理结果而不是等所有任务都完成。实操心得并发爬虫的关键是控制速率和处理好资源共享。除了设置max_workers还可以在fetch_page函数内部或任务提交间隙加入随机延迟time.sleep(random.uniform(0.5, 1.5))让请求模式更接近人工操作。另外所有线程共享SESSION对象和写入同一个all_movie_data列表在更复杂的场景下可能需要引入线程锁 (threading.Lock) 来确保数据写入的安全但本例中由于每个线程只追加自己独立获取的数据不涉及修改共享数据所以是安全的。7. 反爬策略应对与健壮性增强真实的网站往往会有反爬虫措施。除了设置User-Agent我们还需要考虑其他情况。7.1 处理动态加载内容如果电影列表或详情信息是通过JavaScript动态加载的即直接在网页源代码里看不到数据那么requests获取的静态HTML就不包含这些数据。这时需要分析网站的网络请求找到真正的数据接口通常是返回JSON格式的API。使用浏览器的开发者工具切换到“网络”(Network)选项卡筛选XHR或Fetch请求找到包含电影数据的请求然后模拟这个请求。例如可能发现列表数据来自一个如https://www.4567.tv/api/movie/list?page1的接口。那么我们的parse_list_page函数就需要改为请求这个API并解析JSON。def parse_list_page_from_api(): 假设列表数据来自API api_url https://www.4567.tv/api/movie/list params {page: 1, size: 30} resp SESSION.get(api_url, paramsparams) if resp.status_code 200: data resp.json() movie_links [] for item in data.get(list, []): movie_links.append({ title: item.get(name), detail_url: urljoin(BASE_URL, item.get(urlPath)) }) return movie_links return []7.2 处理请求头与Cookie有些网站会检查Referer、Accept-Language等请求头或者需要登录后的Cookie。我们需要在HEADERS字典中补充这些信息。Cookie可以通过手动登录后从浏览器复制或者使用requests.Session()先模拟登录一次来获取并维持。HEADERS { User-Agent: ..., Referer: BASE_URL, # 告诉服务器请求来自哪个页面 Accept-Language: zh-CN,zh;q0.9, # Cookie: 你的cookie字符串 # 谨慎使用注意隐私和过期时间 }7.3 使用IP代理池如果请求频率过高可能会被网站封禁IP。这时需要使用代理IP。可以购买付费代理服务或者使用一些免费的代理IP但稳定性较差。在fetch_page函数中集成代理支持。PROXIES { http: http://your-proxy-ip:port, https: http://your-proxy-ip:port, } def fetch_page_with_proxy(url, max_retries3, timeout10, use_proxyFalse): for attempt in range(max_retries): try: proxies PROXIES if use_proxy else None resp SESSION.get(url, timeouttimeout, proxiesproxies) resp.raise_for_status() resp.encoding resp.apparent_encoding or utf-8 return resp.text except requests.exceptions.ProxyError as e: logger.error(f代理错误: {e}) # 可以在这里切换下一个代理IP break # 或 continue 使用下一个代理重试 except requests.exceptions.RequestException as e: # ... 其他错误处理同上8. 数据存储、优化与项目扩展8.1 数据存储的更多选择除了保存为CSV根据数据量和使用场景可以考虑其他存储方式JSON文件适合嵌套结构复杂的数据Python读写方便。SQLite数据库适合数据量大、需要查询和去重的场景。可以使用sqlite3标准库。MongoDB适合文档型数据模式灵活易于扩展。例如使用SQLite存储import sqlite3 def save_to_sqlite(data_list, db_filemovies.db): conn sqlite3.connect(db_file) cursor conn.cursor() # 创建表 cursor.execute( CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, director TEXT, actors TEXT, -- 可以用JSON字符串存储列表 release_date TEXT, score REAL, summary TEXT, detail_url TEXT UNIQUE -- 设置唯一约束避免重复 ) ) for movie in data_list: # 将列表转换为JSON字符串存储 actors_str json.dumps(movie.get(actors, []), ensure_asciiFalse) try: cursor.execute( INSERT OR IGNORE INTO movies (title, director, actors, release_date, score, summary, detail_url) VALUES (?, ?, ?, ?, ?, ?, ?) , (movie.get(title), movie.get(director), actors_str, movie.get(release_date), movie.get(score), movie.get(summary), movie.get(detail_url))) except sqlite3.IntegrityError: logger.warning(f重复数据已跳过: {movie.get(title)}) conn.commit() conn.close() logger.info(f数据已保存至SQLite数据库: {db_file})8.2 爬虫策略优化增量爬取在数据库中记录每条数据的爬取时间。下次运行时只爬取新增或更新的内容。可以通过对比URL是否已存在或者检查页面是否有“更新时间”标签来实现。分布式爬取对于海量数据单机爬取可能力不从心。可以考虑使用Scrapy框架它原生支持分布式结合Scrapy-Redis可以轻松搭建分布式爬虫集群。更优雅的解析对于极其复杂的页面可以尝试使用BeautifulSoup它的API对初学者更友好。或者使用pyquery如果你熟悉jQuery语法的话。8.3 项目扩展方向这个电影信息爬虫只是一个起点你可以在此基础上扩展很多功能多分类爬取不仅仅爬“最新电影”还可以遍历“动作片”、“喜剧片”等所有分类。分页处理列表页通常有多页需要自动识别“下一页”链接并循环抓取。图片下载从详情页提取电影海报的URL并下载到本地。数据清洗与分析对爬取到的评分、上映年份等进行简单的统计分析用pandas和matplotlib生成图表。构建简单搜索引擎将数据存入SQLite或Elasticsearch实现一个本地电影搜索工具。9. 常见问题与排查技巧实录在实际运行中你几乎一定会遇到各种问题。下面是我踩过的一些坑和解决方法。9.1 页面结构变化导致解析失败这是最常见的问题。今天还能用的CSS选择器明天可能就失效了。排查与解决日志定位首先确保你的爬虫有详细的日志记录。当解析失败时日志会告诉你是在哪一步、解析哪个字段时出了问题。手动验证立刻用浏览器打开出问题的URL查看元素确认原先的CSS路径是否还正确。网站可能进行了前端改版。调整选择器更宽松的匹配如果.movie-item失效尝试div[class*movie]或//div[contains(class, movie)]。向上查找如果目标元素本身没有稳定特征尝试定位其具有稳定特征的父级元素再向下查找。使用XPath轴XPath的轴如following-sibling::,preceding-sibling::在定位相邻元素时非常有用。备用方案为关键字段编写多个备选解析路径。例如先尝试用CSS选择器A如果得到空值再尝试用XPath B。# 示例具有备用方案的解析 def extract_title(selector): title selector.css(h1.movie-title::text).get() if not title: title selector.css(div.title-wrapper span::text).get() if not title: # 最后尝试从meta标签获取 title selector.css(meta[propertyog:title]::attr(content)).get() return title.strip() if title else 未知标题9.2 网络请求失败502、503、Timeout网络不稳定或服务器过载会导致请求失败。排查与解决检查fetch_page函数确保你已经实现了重试机制和指数退避如本项目所示。降低并发度如果频繁出现502/503错误可能是并发请求过高被服务器限制。尝试减少ThreadPoolExecutor的max_workers数量例如从10降到3。增加延迟在每次请求即使是失败的请求后增加一个随机延迟time.sleep(random.uniform(1, 3))。检查请求头确保User-Agent是有效的并且包含了Referer等可能被检查的头部。验证URL有效性在发起请求前可以使用简单的正则检查URL格式是否大致正确。但这不能替代真正的网络请求。9.3 数据编码与乱码问题网页可能使用gbk,gb2312等编码而不是utf-8。解决优先使用resp.apparent_encoding这是requests库推测的编码通常比较准确。如果仍有乱码可以手动指定编码resp.encoding gbk。可以通过查看网页HTML源码中meta charset...标签来确定编码。9.4 被网站屏蔽或弹出验证码这是反爬虫升级的信号。应对策略立即停止首先暂停爬虫避免IP被永久封禁。模拟更真实的行为随机化请求间隔。使用更完整的浏览器请求头列表。考虑使用Selenium或Playwright这类浏览器自动化工具来模拟真人操作但这会大大降低速度。使用代理IP这是应对IP封锁最直接有效的方法但需要成本。遵守robots.txt确认你的爬取行为是否被允许。即使技术上可行也应尊重网站的意愿。9.5 内存与性能问题当爬取数万甚至更多页面时可能会遇到内存不足或程序变慢的问题。优化建议流式存储不要将所有数据都保存在all_movie_data列表后再一次性写入文件。可以每爬取一定数量如100条或每隔一段时间就将数据追加到文件或数据库中。控制并发任务数量不要一次性向线程池提交数万个任务。可以分批提交例如每次提交500个URL处理完一批再提交下一批。使用生成器在解析列表页时可以使用生成器函数yield逐个返回电影链接而不是一次性返回所有链接的列表这对于海量列表页如分页有内存优势。及时关闭连接确保requests.Session在适当的时候被关闭虽然with语句通常会帮你处理。在极高频请求下注意系统 socket 资源。这个爬虫项目从简单的需求出发逐步深入到并发处理、异常应对、反爬策略等实际问题几乎涵盖了中小型爬虫项目会遇到的所有核心环节。最关键的是理解每一步背后的“为什么”并根据实际情况灵活调整策略。爬虫技术是获取数据的利器但务必合法合规使用尊重数据所有者的权益和网站的服务器压力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价