ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

爬虫进阶:requests+selenium+SQLAlchemy从抓取到入库的完整链路

爬虫进阶:requests+selenium+SQLAlchemy从抓取到入库的完整链路 这篇是爬虫学习系列的第三篇。前两篇我们把 requests 的用法、HTML 解析的基本套路都过了一遍能拿到静态页面的数据打印到终端或者写进 CSV。但只到这一步还不够——真实世界里的爬虫任务数据量更大、目标站会有反爬、数据要反复查询和更新这些都不是打印到控制台能解决的。所以这一篇我练手的案例选了一个公开图书网站的 Top250 榜单从列表页翻页抓取到补齐详情信息最终用 SQLAlchemy 存进 SQLite完整走一遍采集入库的流程。这篇内容解决的核心问题用一句话概括就是“爬下来之后怎么办”——数据怎么建模、怎么去重、怎么在第二次运行脚本时不把同一个榜单重复存一遍。另外还会处理一个很常见的真实情况详情页一部分内容是通过异步接口渲染出来的静态请求拿不到这时候得让 selenium 上场兜底。适合已经会写简单爬虫、想往工程化方向走一步的朋友。1. 案例选型为什么第三篇盯上一个图书榜单1.1 一个适合练手的站点应该长什么样很多新手练爬虫喜欢直接挑大平台或者冷门小站点下手。大平台反爬做得好动不动 403新手很容易被劝退冷门小站点又往往结构混乱字段缺胳膊少腿练完也积累不了通用经验。我选案例目标的时候会先过一遍这几条标准页面结构规整HTML 语义化程度高能用 CSS 选择器稳定定位有列表页和详情页两级结构能覆盖不同类型的抓取模式带一点反爬但不至于要逆向加密参数适合学习阶段处理数据字段够多样文本、数字、链接、评分、评论数都有入库时能体现字段设计。按这个标准我当天选了一个公开图书榜单。它分页规律很直白URL 参数里带 start 偏移量每页 25 条正好 10 页。每条记录包含排名、书名、作者、出版年份、评分、评论人数点进详情页还能拿到出版社、ISBN 和简介。这些字段在真实采集任务里非常典型用来演示 SQLAlchemy 数据模型再合适不过。为什么不选那些需要登录才能看的站点一方面练手的重点不该放在绕登录上那是另一套复杂技术还涉及权限边界另一方面干干净净的公开数据足够验证整个流程。学习阶段把地基打牢比炫技重要得多。1.2 这一篇要过的四道关系列文章每一篇都得有明确的验收标准不然学完也不知道自己到底会了什么。第三篇我给自己定了四道关第一关把列表页稳定地抓下来正确处理 HTTP 状态码、超时和字符编码。这一步看着简单但很多人在请求阶段就折了返回空列表或者乱码半天找不出原因。第二关从 HTML 里解析出结构化字段数据必须是干净的 Python 字典而不是带着换行符和空格的原始文本。第三关用 SQLAlchemy ORM 把数据写进数据库并且同一个脚本重复运行表里不会出现重复记录。第四关遇到动态渲染的详情页片段知道什么时候该上 selenium以及怎么让它跟 requests 配合而不是全程都用浏览器模拟。四道关全部跑通这篇的价值就兑现了。写代码之前先别急着动手花十分钟把方案定下来后面能省很多返工的时间。2. 动手前先做两件事看页面结构定技术方案2.1 先确认边界robots、公开数据、请求频率爬虫的原理其实就三步发起请求、解析响应、存储结果。高级玩法都是围绕这三步做工程化优化。但开工之前得先明确边界问题。我自己的习惯是每个新目标站点先访问robots.txt看看哪些路径允许抓取。尽管 robots 协议不是技术强制但它是一个站点对所有爬虫的明确态度值得尊重。然后确认数据的性质。只处理公开页面能看到的数据不碰需要登录、需要绕过验证码才能访问的内容更不会去碰任何个人隐私字段。这不是圣母心而是现实考量练手项目一旦涉及违规采集轻则 IP 被封重则惹上法律麻烦完全没必要。包括以后接爬虫私活也一样先问清楚数据来源和用途不合规的单子给再多钱也别接。还有一个容易被忽略的点请求频率。我一开始就打算控制节奏每两次请求之间至少间隔一两秒。这样既能减少目标站压力也符合“正常访客”的行为特征。很多人迷信高并发代理池其实对学习案例来说完全用不上单线程慢速跑数据量几千条也不过是几分钟的事。2.2 requests 打底selenium 兜底SQLAlchemy 统一入库这次的技术栈不复杂。静态列表页用 requests 发请求BeautifulSoup 加 lxml 解析部分异步渲染的详情页片段用 selenium 驱动真实浏览器等 JS 执行完再取数数据层用 SQLAlchemy ORM先连 SQLite以后想换 MySQL 只改连接串。我做了个选型说明方便你对照自己的场景来判断使用场景选型理由静态列表页抓取requests BeautifulSoup lxml轻量、快、可控适合学习爬虫底层逻辑异步片段/动态渲染selenium ChromeDriver能执行 JavaScript等元素出现再提取数据存储SQLAlchemy SQLiteORM 模型清晰单机学习方便后续可切 MySQL超时重试requests 适配器 urllib3 Retry不引重型框架先跑通再演进有人会问为什么不用 Scrapy我的回答是学习阶段先用 requests 把每个环节的细节摸透你才知道框架替你做了什么。Scrapy 是很优秀的框架但你直接上手它反而不容易理解下载中间件、管道、去重这些概念到底解决什么问题。requests 这套自己搭一遍后面再看 Scrapy 文档会豁然开朗。3. 数据模型设计与存储方案3.1 Book 表怎么设计才够用这一步很多新手会跳过直接拿字典往 CSV 里塞。但一旦数据量上来没有明确的表结构后面数据清洗、查询都无从谈起。我用 SQLAlchemy 定义了一张books表字段设计如下字段类型说明idInteger主键自增rankInteger榜单排名titleString(255)书名authorString(255)作者yearInteger出版年份ratingFloat评分rating_peopleInteger评论人数publisherString(255)出版社isbnString(32)ISBN 编码introText简介created_atDateTime入库时间模型代码长这样from sqlalchemy import Column, Integer, String, Float, Text, DateTime, UniqueConstraint, func from sqlalchemy.orm import declarative_base Base declarative_base() class Book(Base): __tablename__ books __table_args__ ( UniqueConstraint(title, author, nameuniq_title_author), ) id Column(Integer, primary_keyTrue, autoincrementTrue) rank Column(Integer, nullableFalse) title Column(String(255), nullableFalse) author Column(String(255), nullableFalse) year Column(Integer) rating Column(Float) rating_people Column(Integer) publisher Column(String(255)) isbn Column(String(32)) intro Column(Text) created_at Column(DateTime, server_defaultfunc.now())这里特别注意UniqueConstraint(title, author)这是一个联合唯一约束。同一本书、同一个作者在表里只允许存在一条它是我后面做幂等去重的最后一道保险。3.2 连接 SQLite 并建表建表和连接放在一个单独脚本里很干净。用create_engine生成引擎再调create_all就能把表建出来。from sqlalchemy import create_engine engine create_engine(sqlite:///books.db, echoFalse) Base.metadata.create_all(engine)echoFalse表示不打印 SQL 日志调试的时候可以临时改成 True能看到每一条 insert 语句排查问题很方便。SQLite 适合学习阶段的原因很简单零配置、单文件、Python 内置支持跑完整个案例不用装数据库服务。等以后数据量大到需要多人并发访问把连接串换成mysqlpymysql://user:passhost/dbname就行模型代码不用动这就是用 ORM 而不是裸写 SQL 的长期价值。4. 列表页抓取与解析的核心代码4.1 请求阶段最容易翻车的地方列表页抓取我用的是一个常驻的 requests Session。为什么用 Session 而不是每次直接requests.get因为 Session 会自动复用 TCP 连接还能统一管理 headers对目标站更友好请求效率也更高。最开始跑的时候我踩过一个典型坑没有设置 User-Agent直接请求返回 403。这是服务器在拒绝裸奔的程序请求。补上浏览器头的 UA 之后状态码恢复正常。import requests from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter session requests.Session() session.headers.update({ 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-Language: zh-CN,zh;q0.9, }) retry Retry(total3, backoff_factor0.5, status_forcelist[500, 502, 503, 504]) session.mount(https://, HTTPAdapter(max_retriesretry)) session.mount(http://, HTTPAdapter(max_retriesretry))Retry的作用是当请求遇到 500、502、503、504 这类服务器临时错误时自动重试最多 3 次每次间隔按backoff_factor递增。网络请求从来不可能 100% 稳定重试机制是正规爬虫的标配。拿到响应之后第一件事不是急着解析而是检查状态码和设置正确的编码。如果直接resp.text遇到乱码很可能是字符编码没对上。我当时定位到页面是 UTF-8但响应头里的 charset 写得不一致于是手动固定了编码问题立刻解决。4.2 解析阶段CSS 选择器定位字段解析部分用 BeautifulSoup 简直顺手。这个案例里榜单页的每条记录都包在ol.grid_view li里我只需要对这个列表循环再用 CSS 选择器把每个字段抠出来。from bs4 import BeautifulSoup resp session.get(list_url, timeout10) soup BeautifulSoup(resp.text, lxml) items soup.select(ol.grid_view li) parsed [] for item in items: rank_el item.select_one(div.pic em) title_el item.select_one(div.hd a span.title) author_el item.select_one(div.bd p) if not rank_el or not title_el: continue rank int(rank_el.text.strip()) title title_el.text.strip() # author 这一行通常是 作者 / 译者 / 出版年 / 页数 / 价格 author_line author_el.text.split(/) author author_line[0].strip() year author_line[-3].strip() if len(author_line) 3 else None parsed.append({ rank: rank, title: title, author: author, year: int(year) if year and year.isdigit() else None, })注意select_one可能返回 None所以要先判空再取.text否则会抛AttributeError。这在爬虫里是高频翻车点因为不是每一条记录的所有字段都完整。用if not rank_el or not title_el: continue跳过残缺项能保证解析过程不中断。4.3 翻页循环与请求频率控制榜单的翻页逻辑很简单URL 里start参数从 0 开始每次加 25。我写一个循环把每页解析出来的数据累积到总列表里。import time import random all_books [] for page in range(10): start page * 25 list_url fhttps://example-book-site/top250?start{start} page_books parse_list_page(list_url) # 上面的解析逻辑封装成函数 all_books.extend(page_books) print(f第 {page 1} 页解析完成累计 {len(all_books)} 条) time.sleep(random.uniform(1.2, 3.0))这里我特意用了random.uniform(1.2, 3.0)而不是固定time.sleep(2)。固定频率反而容易被识别成机器行为随机间隔更像真人浏览的节奏。这个细节看起来不起眼但对反爬效果的影响非常明显。按 25 条一页计算10 页就是 250 条数据。单线程慢速抓完大概需要 30 秒左右完全在可接受范围内。5. 详情页动态加载selenium 补位实操5.1 什么时候必须上 selenium列表页抓得很顺利但进到详情页之后问题来了。我抽查了几个详情页发现出版社、简介、ISBN 这几个字段的 HTML 里竟然是空的但浏览器里能看到内容。打开开发者工具一看这些片段是页面加载后通过异步请求从接口里拿到的然后由 JavaScript 填充进 DOM静态请求拿不到最终渲染结果。这种情况有几个处理思路一是找到幕后接口直接请求 JSON最省资源但需要花时间逆向接口地址和传参二是用 selenium 驱动真实浏览器等页面执行完 JS代码通用性更强代价是慢。我这次选择用 selenium 兜底原因很简单这次只有详情页一小部分需要动态渲染量不大selenium 完全扛得住。顺带说一下selenium 这个词经常和反爬一起出现其实它有两层意思网站用各种手段反爬虫我们也可以用真实浏览器应对需要 JS 执行的页面。但要注意selenium 不要滥用全程浏览器抓取既不环保也容易触发检测。它的定位应该是“补位选手”而不是“主力前锋”。5.2 最小可用的 selenium 抓取片段selenium 这部分的代码我尽量精简核心逻辑是让 Chrome 以无头模式启动打开详情页用显式等待等关键元素出现取到文本后立刻关掉浏览器绝不让他长期驻留。from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def fetch_detail_with_selenium(detail_url): options webdriver.ChromeOptions() options.add_argument(--headlessnew) driver webdriver.Chrome(optionsoptions) try: driver.get(detail_url) intro_el WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, div.intro)) ) publisher_el driver.find_element(By.CSS_SELECTOR, p.publisher) return { publisher: publisher_el.text.strip(), isbn: driver.find_element(By.CSS_SELECTOR, span.isbn).text.strip(), intro: intro_el.text.strip(), } finally: driver.quit()这里有几个容易踩的坑。第一WebDriverWait(driver, 10)表示最多等 10 秒只要元素一出现就立即返回不会傻等满 10 秒。第二finally块里必须driver.quit()否则 Chrome 进程会在内存里越堆越多跑几十个详情页之后机器就开始卡。第三如果页面里嵌了 iframe得先switch_to.frame()再找元素不然元素定位永远抛 NoSuchElementException。5.3 静态与动态怎么搭配效率最高既然详情页只有一小部分需要动态渲染我的策略是“先分类再分治”。先在前面 10 个详情页上各用 requests 和 selenium 跑一遍对比结果凡是 requests 能拿齐的标记为静态类型拿不到关键字段的标记为动态类型然后对整个详情页列表做一次分组。静态类型的详情页继续走 requests速度快平均一页只需要几百毫秒动态类型的才交给 selenium一页可能需要两三秒。按 250 条数据假设 90% 是静态只有 25 条走 selenium整体耗时依然可控。如果反过来全程 selenium那 250 页跑下来光是浏览器启动关闭的开销就得让人等到怀疑人生。这种搭配思路本质上是在“速度”和“通用性”之间做权衡。真实项目里能走接口就走接口接口拿不到的再考虑浏览器模拟永远不要一上来就上最重型的方案。6. 入库、去重与断点续抓6.1 把解析结果安全地写进数据库所有数据解析完成之后进入最后一步写库。SQLAlchemy 的 Session 管理有几条铁律我这次全用上了用完要 close出错要 rollback事务要有始有终。from sqlalchemy.orm import sessionmaker Session sessionmaker(bindengine) session Session() try: for book_data in all_books: exists session.query(Book).filter_by( titlebook_data[title], authorbook_data[author] ).first() if not exists: session.add(Book(**book_data)) session.commit() except Exception: session.rollback() raise finally: session.close()如果过滤条件是字段齐全可以更简单直接session.add_all([Book(**d) for d in all_books])。但一旦中途重跑一次数据库里的唯一约束就会让程序在 duplicate key 上报错。为避免这种情况我用先查重再插入的方法。听起来像多做了一次数据库查询但对 250 条数据来说性能损失完全可以忽略。6.2 重复数据与断点续抓入库流程里最值得说的就是幂等设计。所谓幂等就是同一个操作执行一次和执行多次结果一致。我把抓取脚本连续跑了两次第一次插入 250 条第二次跑完一查表里总数还是 250 条没有变成 500 条。保证幂等有两条防线。第一道是业务层查重插入前用filter_by检查同 title 和 author 的记录是否已存在第二道是数据库层的UniqueConstraint兜底。就算业务代码漏了查重数据库也会把重复数据挡在外面。这两道防线缺一不可因为任何代码都有写漏逻辑的可能但数据库约束是硬性的。断点续抓的逻辑也一样。如果抓取过程中网络断了脚本重跑时已经入库的记录会被查重逻辑跳过未入库的会补进去。这个特性在真实项目里特别重要因为爬虫跑几个小时太常见了不可能每次都从零开始。7. 常见问题与排查技巧实录以下是这次案例实操过程中最常遇到的问题我整理成了一个速查表现象可能原因排查与解决请求返回 403UA 缺失或请求频率太高设置完整浏览器 UA降低请求频率中文乱码页面编码与默认解析不一致固定resp.encoding优先从响应头拿 charset字段值为空或 None元素是 JS 异步渲染或 selector 没匹配打印页面片段确认渲染方式必要时上 selenium入库报重复键错误缺唯一约束或没先查重建表时加 UniqueConstraint插入前先 filter_bysqlite 报 database is locked并发写库或事务未提交单进程顺序写及时 commitselenium 找不到元素页面没加载完或元素在 iframe 里用 WebDriverWait先 switch_to.frame 再定位程序运行一段时间后变慢浏览器 driver 没退出内存泄漏用 try/finally 包裹确保 driver.quit() 执行其中一个问题我要多讲几句乱码。很多人习惯拿到响应就resp.text但 requests 会自动猜测编码猜错就乱码。最稳妥的办法是先看响应头里的charset如果是空再试resp.apparent_encoding但这个方法不总是准。这次案例里我遇到的是响应头写 UTF-8 但内容实际是其他编码的情况最后直接根据页面 meta 标签里的声明手动指定一劳永逸。还有一个排除技巧值得分享当 CSS 选择器一直匹配不到元素时别只顾着改选择器。先把resp.text前 2000 个字符打印出来肉眼看看是不是登录页、验证码页、或者是“请开启 JS”的拦截页。方向错了再高级的选择器语法也没用。8. 这一轮练手后沉淀的几点体会整个案例跑完我最直接的感受是爬虫真正繁琐的部分不是“爬到数据”而是“数据从网页到数据库最后一公里”的处理。请求和解析只是前菜建模、去重、幂等、异常处理才是真正决定项目质量的地方。有句经验想写在这里新手写爬虫天然会想一口气把数据全部抓到再处理但更稳的做法是分阶段验证。先抓单页解析出一个完整的字段字典再抓三页确认翻页逻辑没有偏差最后全量跑数据入库后马上查总数和字段完整性。每一步都验证完再走下一步返工成本可以降低一大半。最后再分享一个小技巧。别一上来就追求并发先用单线程把全流程跑通。这次案例 250 条数据单线程也就半小时以内搞完速度根本不是瓶颈。先把全链路调稳定再去考虑在详情页阶段用线程池提速这个顺序能帮你避开九成以上的反爬和调试灾难。爬虫学习是一场长跑稳一点反而更快。
返回列表