ARTICLE DETAIL

资讯详情

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

Python爬虫实战:requests+xpath抓取猫眼Top100入库MySQL

Python爬虫实战:requests+xpath抓取猫眼Top100入库MySQL 有人问学爬虫从哪个项目入手比较好。我通常建议第一选择别是那些上来就要处理加密参数的平台而是找一个服务端渲染、结构规整的榜单页。猫眼电影的Top100榜单正好是这个定位用requests发一个GET请求用xpath从HTML里把片名、主演、上映时间、评分抠出来最后写进MySQL。整个过程不涉及Scrapy框架也不用Selenium逻辑链路非常直接请求、解析、入库。这篇文章就把这个完整流程拆开说清楚每个环节都给可复现代码适合刚学完Python基础、想把HTTP请求、页面解析和数据库操作串起来做一遍的同学。1. 开爬前先想清楚三件事目标页面、字段设计、合规边界1.1 为什么选猫眼电影作为练手对象判断一个网站适不适合做入门爬虫主要看三点HTML里有没有数据、结构规不规整、反爬门槛高不高。猫眼电影Top100榜单在这三点上表现得非常典型。它不像很多现代网站那样用JavaScript异步渲染而是在页面源码里直接把每条电影信息包在dd标签里。也就是说requests拿到的HTML就是最终要解析的HTML不需要再走接口、不好解密。对于第一次接触爬虫的人而言这种所见即所得的页面能最大程度减少变量干扰让你专注于理解requests和xpath这两个核心工具本身。为什么不推荐用正则表达式直接抠数据正则处理文本确实高效但面对HTML这种带嵌套结构的半结构化文档写起来会非常痛苦稍微变一点换行缩进就失效。xpath则是为树状文档设计的查询语言可以按标签、属性、层级关系定位元素可读性和稳定性都远胜于一长串正则。至于BeautifulSoup的CSS选择器当然也能做但xpath是很多爬虫框架底层的定位方式学会之后去看Scrapy、看抓包工具的xpath查询、写自动化脚本都会更顺手。1.2 字段选型和存储方案实战项目不需要贪多建议控制在5个字段左右。我选的是排名、片名、主演、上映时间、评分。这五个字段覆盖了三种最常见的提取场景文本直接提取片名、带前缀的文本清洗主演、多个子节点内容拼接评分。把这三类处理练熟基本就能应对大多数静态页面的解析任务了。存储方案上很多人第一反应是存CSV图省事。但既然项目标题里写了写入数据库我们就直接用MySQL。CSV和数据库的差别用一张表就能看懂对比维度CSVMySQL重复数据控制需要自己读文件判重唯一索引自动去重并发写入容易冲突事务机制安全可控查询效率全量扫描条件索引快速定位工程化程度适合一次性分析适合持续更新和对外提供数据相比之下MySQL更像是数据服务的形态而且建表、插入、去重这些动作本身就是后端开发的基本功。练完这个项目SQL操作也顺手掌握了。1.3 合规与礼貌爬取这一步很多人会跳过但我建议放在最前面。爬虫不是技术能跑通就万事大吉先确认目标站点是否有禁止采集的声明遵守robots.txt的基本要求。本文演示用的榜单页数据属于公开可浏览的内容但仅用于个人学习和功能演示不要抓取后做商业化使用也不要高频请求、开多线程猛抓。控制请求频率、设置适当的延时既是个人学习时的基本礼貌也能大大降低触发反爬的概率。2. 环境准备依赖库安装与MySQL初始化2.1 Python虚拟环境和三个核心库建议使用Python 3.10以上版本先用虚拟环境隔离项目依赖避免污染全局环境。创建并激活虚拟环境python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # Linux/macOS然后安装三个库pip install requests lxml pymysqlrequests负责发送HTTP请求lxml提供etree.HTML方法能快速把HTML文本解析成可查询的树状结构核心就是它的.xpath()方法pymysql是Python连接MySQL的驱动纯Python实现安装简单、社区活跃比官方mysql-connector-python更轻量。这里我不推荐使用selenium因为它是模拟浏览器方案启动慢、消耗资源大对这种静态渲染页面属于降维打击而且会把请求和解析两个环节混在一起反而不利于理解HTTP爬虫的本质。2.2 建库建表字段类型和唯一索引确保本机MySQL正常运行登录后执行建库语句CREATE DATABASE maoyan_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE maoyan_demo; CREATE TABLE movie ( id INT AUTO_INCREMENT PRIMARY KEY, rank_no INT NOT NULL COMMENT 排名, movie_name VARCHAR(200) NOT NULL COMMENT 片名, actors VARCHAR(500) DEFAULT COMMENT 主演, release_time VARCHAR(100) DEFAULT COMMENT 上映时间, score DECIMAL(3,1) DEFAULT NULL COMMENT 评分, UNIQUE KEY uniq_name_time (movie_name, release_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个设计细节值得展开。字符集必须用utf8mb4因为榜单里有些片名和主演名字可能包含特殊字符utf8mb4可以完整覆盖否则插入时会报1366编码错误。评分字段用DECIMAL(3,1)既能保留一位小数也避免浮点误差。最关键的是唯一索引uniq_name_time它建在movie_name和release_time的组合上。为什么要这么做因为不同年份可能上映同名电影单纯用片名做唯一键会误杀用片名加上映时间两个维度基本能锁定一条唯一记录。有了这个索引后面入库时就可以用INSERT IGNORE实现自动去重。3. 目标页面结构摸底开发者工具与XPath定位思路3.1 打开页面找到榜单列表的DOM特征在浏览器里打开猫眼电影榜单页建议直接搜索猫眼电影实时票房再进入榜单入口。进入页面后右键点击任意一部电影的片名选择检查DevTools会自动定位到该节点。顺着Elements面板向上找会看到每部电影的完整信息都被包在一个dd标签里而这些dd的共同父级是dl classboard-wrapper。这个观察是整个爬虫的关键。与其费劲写一长串绝对路径不如先找到稳定的列表容器。页面改版时只要列表容器和条目标签没变解析逻辑就大概率还能用。我的定位思路是先确认容器再确认单条记录最后才去看字段节点。对应到xpath列表容器//dl[classboard-wrapper] 单条记录//dl[classboard-wrapper]/dd3.2 不要迷信开发者工具里的Copy XPathChrome开发者工具右键可以复制XPath但复制出来的往往是绝对路径长这样/html/body/div[2]/div[2]/div[2]/dl/dd[1]/div/div[2]/p[1]/a这种路径一旦页面结构有细微调整立刻失效。而且它没有任何语义别人拿到代码根本看不懂。正确做法是手写相对xpath以列表节点为起点用点开头表示在当前节点下继续查找方式示例稳定性绝对路径/html/body/div[2]/div[2]/dl/dd[1]//p[1]/a差层级一变就挂相对路径//dl[classboard-wrapper]/dd[1]//p[classname]/a好定位逻辑清晰相对路径的核心思想是先找靠得住的锚点再在锚点下面找目标。写xpath就像在陌生商场里找店铺先找到地标建筑再按指示牌走比记住每一步的楼梯数量靠谱得多。3.3 页面源码里的空白符和换行处理开始写解析代码前还有一个容易踩的坑浏览器Elements面板里看到的数据是渲染后的而requests拿到的页面源码里可能包含大量换行和空格。比如p classstar 主演沈腾, 马丽 /p直接用text()提取返回值会带着前后的换行和缩进所以后面解析时一定要加strip()。这也解释了为什么在写代码前先手动查看一下返回的HTML文本有助于对后面要做多少清洗工作心里有数。4. requests取数实战请求头、状态检查与重试4.1 构造一个看起来像正常人的请求头用requests请求页面最不起眼但也最容易出问题的就是请求头。如果裸奔请求服务端看到陌生的Python默认User-Agent很可能直接返回403。我通常这样构造import requests headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Referer: https://maoyan.com/, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8 }User-Agent模拟的是浏览器环境告诉服务端我是正常访客。Referer表示请求是从站点首页跳转过来的很多站点会校验这个头。为什么不需要Cookie对于榜单页这种公开数据一般无需登录即可访问所以第一版请求头没必要带Cookie等到页面返回异常时再补不迟。4.2 用状态码和页面特征双重校验拿到响应后先别急着解析先做两道检查resp requests.get(url, headersheaders, timeout10) print(状态码:, resp.status_code) print(响应长度:, len(resp.text)) if board-wrapper not in resp.text: print(页面中没有board-wrapper可能被反爬或页面结构变化)状态码200只代表服务器给了响应不代表内容是对的。很多时候反爬会返回200但页面内容是一段验证逻辑或空壳。用board-wrapper这个页面特征做关键词校验是最简单有效的办法。如果特征缺失直接停止解析先看页面是怎么回事。4.3 超时设置与简易重试机制网络请求不可能每次都很顺利所以超时和重试是必修课。requests.get如果不设timeout可能会无限等待整个脚本就卡死在那里。我习惯写一个带重试的小函数import time import random def fetch_html(url, headers, retries3, timeout10): for attempt in range(retries): try: resp requests.get(url, headersheaders, timeouttimeout) if resp.status_code 200: return resp.text print(f第{attempt 1}次请求状态码: {resp.status_code}) except requests.RequestException as e: print(f第{attempt 1}次请求异常: {e}) time.sleep(1 attempt * 2) return None这个重试的逻辑是每次失败后等待时间递增给服务端一个喘息空间而不是快速连续重试。抓取多页时每页之间也要加随机等待time.sleep(random.uniform(1, 2))随机间隔比固定间隔更像真实用户也能防止被自动化检测识别。5. XPath解析字段提取与数据清洗5.1 用lxml构建DOM树先确认条目数量requests拿到的是纯文本lxml负责把它解析成可查询的树。解析代码很简洁from lxml import etree tree etree.HTML(html_text) items tree.xpath(//dl[classboard-wrapper]/dd) print(解析到电影条数:, len(items))这一步输出的条数如果和预期不符千万别继续往下写。先确认是不是页面结构变了是不是被反爬了。宁可在这里停下来排查也不要等到入库时才发现全是空数据。5.2 单条记录里的XPath字段提取拿到items列表后遍历每一条item。注意每个xpath表达式前都要加.表示从当前item开始找否则xpath会全局搜索可能导致不同条目之间数据串位。核心提取逻辑如下for item in items: rank item.xpath(./i[classboard-index]/text()) rank rank[0].strip() if rank else name item.xpath(.//p[classname]/a/text()) name name[0].strip() if name else actors item.xpath(.//p[classstar]/text()) actors actors[0].strip().replace(主演, ).replace(主演:, ) if actors else release item.xpath(.//p[classreleasetime]/text()) release release[0].strip() if release else score_parts item.xpath(.//p[classscore]//text()) score_text .join(score_parts).strip() score float(score_text) if score_text else 0.0每个字段都做了空列表兜底。原因是xpath在找不到节点时不会抛异常而是返回空列表如果你直接取[0]就会IndexError。这是写爬虫时最常见的崩溃原因之一。5.3 为什么评分要拼接而不是直接取text榜单页里的评分不是像片名那样一个文本节点完事而是拆成了两个i标签比如i classinteger9./ii classfraction7/i浏览器里看到的是9.7。如果用.//p[classscore]/text()取出来是两个i标签的文本吗不是/text()只取当前节点的直接文本子节点而i标签的内容不会包含在p的text()里。所以要用//text()取所有后代文本节点再用join拼接才能得到完整的9.7。这一步非常能体现xpath的层级逻辑也提醒我遇到数据被标签拆分的情况时先看看DOM结构再动手。5.4 返回结果统一成列表字典为了让后续入库代码更清晰我会把每条电影信息统一封装成字典movie_list.append({ rank: rank, name: name, actors: actors, release: release, score: score })这样后面入库函数只负责接收字典列表不需要关心解析逻辑。模块边界清楚出问题时也好排查。6. 写入MySQL连接、去重与批量插入6.1 用PyMySQL建立连接数据库连接的代码要稳定有几个细节不能省import pymysql conn pymysql.connect( host127.0.0.1, port3306, userroot, password你的密码, databasemaoyan_demo, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor ) cursor conn.cursor()charsetutf8mb4必须和建库时保持一致否则中文一样会出问题。cursorclass用DictCursor查询结果会以字典形式返回调试时更直观。密码不建议硬编码在脚本里实际项目可以放到环境变量或配置文件里这里为了教程可读性先直接写。6.2 用INSERT IGNORE实现幂等写入在代码里实现去重有两种常见思路一种是先SELECT查一下存在就跳过另一种是直接利用数据库唯一索引用INSERT IGNORE。我更推荐后者原因是它把去重逻辑交给数据库代码更短也避免了并发时先查后插之间产生重复的竞态问题。sql INSERT IGNORE INTO movie (rank_no, movie_name, actors, release_time, score) VALUES (%s, %s, %s, %s, %s) data [ (m[rank], m[name], m[actors], m[release], m[score]) for m in movie_list ]组合唯一索引uniq_name_time会在插入时自动判断如果movie_name和release_time相同就忽略这次插入。INSERT IGNORE的返回rowcount是实际插入的行数被忽略的记录不会报错这样即便脚本跑了两次数据也不会重复。6.3 executemany批量插入与事务提交一条一条insert每次都要和数据库走一次网络往返效率非常低。批量插入用executemanyaffected cursor.executemany(sql, data) conn.commit() print(实际新增行数:, affected)executemany会把多组参数一次性发给MySQL执行数据量大时性能差距非常明显。事务方面commit()提交事务如果中途发生异常需要回滚try: cursor.executemany(sql, data) conn.commit() except Exception as e: conn.rollback() print(写入失败已回滚:, e) finally: cursor.close() conn.close()这里有一个我踩过的坑如果直接commit但之后连接异常关闭数据还在但如果不commit就关闭连接所有插入会丢失。所以一定要有明确的commit位置。7. 完整脚本串联主流程、翻页和控制台输出7.1 模块划分和主函数编排把前面所有零散代码整合成一个可运行的脚本需要设计清晰的主流程。我的做法是把功能拆成四个函数fetch_html管请求parse_html管解析save_to_db管入库main管调度。完整例子如下import random import time import requests import pymysql from lxml import etree BASE_URL https://maoyan.com/board/4?offset{offset} HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Referer: https://maoyan.com/ } def fetch_html(url): for attempt in range(3): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text except requests.RequestException: pass time.sleep(1 attempt * 2) return None def parse_html(html_text): tree etree.HTML(html_text) items tree.xpath(//dl[classboard-wrapper]/dd) result [] for item in items: rank item.xpath(./i[classboard-index]/text()) rank rank[0].strip() if rank else name item.xpath(.//p[classname]/a/text()) name name[0].strip() if name else actors item.xpath(.//p[classstar]/text()) actors actors[0].strip().replace(主演, ).replace(主演:, ) if actors else release item.xpath(.//p[classreleasetime]/text()) release release[0].strip() if release else score_parts item.xpath(.//p[classscore]//text()) score_text .join(score_parts).strip() score float(score_text) if score_text else 0.0 result.append((rank, name, actors, release, score)) return result def save_to_db(movie_data): conn pymysql.connect( host127.0.0.1, userroot, password你的密码, databasemaoyan_demo, charsetutf8mb4, cursorclasspymysql.cursors.DictCursor ) cursor conn.cursor() sql INSERT IGNORE INTO movie (rank_no, movie_name, actors, release_time, score) VALUES (%s, %s, %s, %s, %s) try: affected cursor.executemany(sql, movie_data) conn.commit() print(本次新增:, affected, 条) except Exception as e: conn.rollback() print(写入失败:, e) finally: cursor.close() conn.close() def main(): all_movies [] for offset in range(0, 100, 10): url BASE_URL.format(offsetoffset) print(开始抓取 offset , offset) html_text fetch_html(url) if html_text: movies parse_html(html_text) print(本页解析出:, len(movies), 条) all_movies.extend(movies) time.sleep(random.uniform(1, 2)) print(总共解析:, len(all_movies), 条) save_to_db(all_movies) if __name__ __main__: main()BASE_URL里的offset参数是关键第一页offset是0第二页是10以此类推每页10条总共10页。如果目标页面改版导致URL失效需要按实际分页参数调整。7.2 控制台运行效果脚本运行后控制台会输出类似这样的信息开始抓取 offset 0 本页解析出: 10 条 开始抓取 offset 10 本页解析出: 10 条 ... 总共解析: 100 条 本次新增: 90 条第二次运行时由于唯一索引的存在INSERT IGNORE会把已存在的90条全部忽略只新增变化的部分。这是可重复运行和只用CSV存储的最大差别。7.3 翻页抓取的频率控制很多人在翻页时会犯一个错误把time.sleep(random.uniform(1, 2))放在循环里但循环依然很快。实际上十页还好但如果是一千页这个等待时间就远远不够了。我通常会再叠加一个每10页休息5秒的逻辑进一步降低短时间内的请求密度。搞爬虫的核心不是把请求速度拉到最快而是让服务端感觉不到你的存在。8. 实测中的坑与优化建议8.1 XPath定位失败的常见原因排查写解析代码最常见的坑是页面结构变化导致xpath失效。排查时建议按下面的顺序来现象可能原因解决方案列表条数为0页面改版或反爬用curl或浏览器查看源码重新定位容器提取不到片名class属性匹配过严改用contains(class, name)数据带有大量空白HTML里有换行缩进提取后加strip()多条数据内容串位xpath没有用点开头每条item内用.//定位子节点评分拼接为空文本在子标签里用.//text()而非/text()这些排查经验不是说一遍就能背下来而是形成一种习惯出问题时先打印原始HTML、打印提取结果再逐层debug不要瞎猜。8.2 反爬特征识别与低风险应对如果页面开始返回验证码、要求登录或状态码变成特殊值说明触发风控了。这时候最安全的做法不是去研究绕过方案而是停下来降低频率。可以做的动作包括拉长请求间隔、换用更完整的请求头、用requests.Session保持连接状态以及检查请求频率是不是太高。切记不要因为页面被保护就去尝试破解验证码或绕过风控这既涉及安全边界也不是爬虫入门应该碰的内容。个人学习场景下频率低一点没有任何损失。8.3 数据库写入中的几个真实报错数据库这块的报错通常集中在三个点。第一个是连接不上pymysql.err.OperationalError: (2003, ...)基本就是MySQL服务没启动或端口不通。第二个是权限问题(1045, Access denied)检查用户名密码。第三个最隐蔽插入特殊字符时报(1366, Incorrect string value)这就是建库时字符集没设成utf8mb4。很多人只在连接参数里写了charsetutf8mb4但库本身的字符集还是latin1一样会报错。正确做法是建库、建表、连接参数三层保持一致。另外大批量数据入库时不要攒到最后一次性executemany一亿条应该每500或1000条一批commit一次。这样既避免事务日志过大也能在出错时缩小回滚范围。8.4 做完这个项目以后还能怎么扩展这个项目做完基础链路已经打通。接下来最自然的扩展方向有三个一是把抓取结果存一份历史表每天定时跑一次观察榜单变化趋势二是加一个简单的趋势分析脚本用pandas统计不同类型电影的上榜数量三是把整个流程迁到定时任务里每天固定时间抓一次。再往后如果想做更大规模的数据采集再考虑Scrapy框架。但不管是Scrapy还是其他爬虫框架底层的HTTP请求、XPath定位、数据库入库都还是这篇文章里的这套基本功。我在实际写这个项目时最大的体会是爬虫真正花时间的不是请求失败而是解析结果不达预期。xpath写错一个小点整个页面的数据可能全部为空数据库唯一索引没建好脚本跑两遍数据就乱套。requestsxpathMySQL这个组合看起来简单但它把HTTP请求、页面解析、结构化存储这三项后端必备技能完整地带你走了一遍。最后给个小建议写解析函数时先加一个调试模式只处理第一条数据并打印出来确认每个字段都正确后再放开全量解析这样能节省大量试错时间。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表