首页 / 资讯中心 / 文章详情

豆瓣图书Top250爬虫实战:requests+lxml+SQLite数据采集与清洗

豆瓣图书Top250爬虫实战:requests+lxml+SQLite数据采集与清洗 ★ FEATURED ARTICLE
豆瓣图书这个数据源我前前后后折腾过好几次。最早是想把自己近几年的阅读记录做个可视化清单手动整理了几十本之后实在顶不住就动了写爬虫的念头。后来陆陆续续帮朋友抓过某类目的图书列表、做过评分对比的小工具也踩了不少坑。这个项目算是爬虫入门里非常有代表性的一个网站结构规整、数据字段丰富、有一定的反爬策略但又不至于让人劝退。如果你想系统地练手网络请求、页面解析、数据清洗这一整套流程豆瓣图书是个很合适的练手目标。这篇内容我会从整体思路讲起然后拆解请求策略、页面解析、数据存储最后是实际运行中常见的报错和排查方法。整个过程按照我当时实操的顺序来写有些地方会比较啰嗦但都是实测过的东西照着做基本能跑通。1. 项目整体设计先搞清楚要爬什么再动手写代码拿到这个需求第一件事不是打开编辑器敲代码而是先想清楚几个问题爬哪些页面、需要哪些字段、数据量大概多大、存到哪里、多久跑一次。这些想不明白代码写一半就会卡住。1.1 目标数据与页面结构分析豆瓣图书的数据分布在几个不同类型的页面上。最常用的是图书列表页和图书详情页。列表页的URL规则非常规整比如图书Top250的地址是https://book.douban.com/top250每页25本书通过?start25、?start50这样的参数翻页。详情页则走另一个模式https://book.douban.com/subject/xxxxx/后面那串数字是每本书的唯一编号。我当时给自己定的需求很简单抓取豆瓣图书Top250的书名、作者、出版社、出版年份、评分、评价人数、简介外加每本书的详情页URL。这个规模就是250条数据属于小数据量完全不需要分布式爬虫或者并发框架单线程加适量延时就够了。建议你把目标字段先列成一张表后面写解析代码和建数据库表都会轻松很多。字段名来源页面说明书名列表页/详情页书的主标题作者详情页可能有多个作者需要清洗出版社详情页中文出版社名称出版年份详情页只取年份数字评分列表页float类型保留一位小数评价人数详情页int类型注意单位简介详情页可能很长注意换行处理详情页URL列表页用作去重主键另一个出发点是爬虫与反爬的对抗思路。豆瓣这个网站比较有意思它的反爬策略不是那种“一步到位”的强制验证码而是通过请求频率、请求头特征、Cookie有效性来做渐进式限制。初学者很容易遇到的现象就是前几页抓得好好的翻到第三页突然返回418或者请求被重定向到登录页。这其实不是你的代码逻辑错了而是你的请求“看起来不像正常人”。所以做爬虫本质上是在模拟一个真实用户的浏览行为而不是单纯地发送HTTP请求。1.2 技术选型为什么是requests lxml SQLite爬虫的技术栈选择很多Scrapy、Selenium、Playwright都能用但对豆瓣图书这个项目来说我的建议是轻装上阵。我用的是requests发请求、lxml做解析、SQLite做存储。这三样东西都足够成熟而且各自的定位非常清晰requests处理HTTP层面的事情lxml负责从HTML里提取数据SQLite存放最后的结果。相比之下Scrapy对于这种250条数据的小项目来说有点过重配置文件的成本大于收益Selenium适合应对大量JavaScript动态渲染的页面但豆瓣图书的列表页和详情页都是服务端直接输出的HTML用不上浏览器渲染反而白白增加资源和时间开销。当然这篇文章给的代码并不是唯一答案。如果你看到某个页面用requests拿不到数据可以换成httpx或者加一层会话保持机制解析部分也可以改用BeautifulSoup看个人习惯。我个人的经验是lxml的XPath表达能力更强尤其适合豆瓣这种class属性使用得比较规律的网站一次写对选择器后面基本不用改。1.3 合规边界个人学习用途的使用尺度爬虫写久了会越来越在意一个词——边界。豆瓣图书的数据是公开可见的但公开可见不等于可以随便拿。我在整个项目里始终遵守几个原则只抓公开页面、不登录抓取非公开内容、请求频率控制在合理范围、不把抓下来的数据用于商业用途。具体来说列表页和详情页都是未登录用户可以正常访问的公开信息这部分属于正常的数据获取范畴。需要注意的不是“能不能抓”而是“怎么抓”如果每秒发几十个请求那不管目标网站有没有明确禁止爬虫都容易给对方服务器造成压力也不排除触发对方的安全机制这不只是技术问题更是使用分寸问题。我的做法是每次请求之间至少间隔3到5秒250条数据跑下来十几分钟对服务器来说感知不强自己也稳定省心。合规红线我放在最前面说原因很简单爬虫写没写好是技术问题用没用对是原则问题。个人学习演示、小规模数据分析和非商业用途都是合理的使用场景大规模抓取、影响网站正常服务哪怕技术再厉害也不应该做。另一个建议是数据抓下来之后不要随意公开发布原始数据尤其是把整库数据打包分享出去这种行为很可能带来不必要的麻烦。自己分析、自己看、自己学习这个维度内是没问题的。如果确实需要分享成果优先分享分析结论和图表而不是把原始数据一打包就往外发。2. 请求策略如何从豆瓣稳定拿到页面这部分是整个爬虫最容易出问题的地方。解析代码写得再漂亮请求发不出去或者被拦截后面都是白搭。我把它拆成URL规律、请求头、请求时长控制三个小节来讲。2.1 URL规律与分页参数的设计豆瓣图书Top250的URL结构是这样的https://book.douban.com/top250?start0每页25本start25是第二页start50是第三页一直加到start225就是最后一页。也就是说分页爬取的核心逻辑就是循环生成不同start值的URL然后把每个页面的HTML交给解析模块去处理。这个逻辑用一段很简单的Python代码就能实现base_url https://book.douban.com/top250 for page in range(10): start page * 25 url f{base_url}?start{start} print(f正在抓取{url})需要注意一个细节start参数是偏移量而不是页码。如果你想抓某一类别的图书列表URL规则可能会变成https://book.douban.com/tag/小说?start0typeT同样还是偏移量的逻辑。看清URL参数的含义再动手能省很多调试时间。还有一种情况是URL里的中文参数。比如按标签搜索时标签名是中文的直接拼进URL会乱码需要用urllib.parse.quote做编码。我当时第一次抓某个分类列表时就栽在这个上面页面返回200但内容不对排查了半天才发现是URL编码的问题。from urllib.parse import quote tag 小说 url fhttps://book.douban.com/tag/{quote(tag)}?start0typeT2.2 请求头伪装User-Agent与Cookie的作用豆瓣的反爬机制里最基本的检查就是User-Agent。如果你的请求头里没有User-Agent或者写的是python-requests这类默认标识服务器很容易直接拒绝——当然这里不是鼓励绕过对方的安全策略只是在正常获取公开信息时保持常规浏览器请求的特征是合理的做法。通常你可以从自己常用的浏览器里复制一段User-Agent比如Chrome或者Edge的完整UA字符串。我用的是Chrome的UA实测下来稳定性很高。需要注意User-Agent不是一次性设完就高枕无忧的对于小数据量爬取来说保持一个固定的UA就够了如果长期高频抓取建议准备几个常用的UA轮换。再一个重点是Cookie。豆瓣有一个名为bid的Cookie是未登录状态下也会种在浏览器里的一个随机字符串。第一次访问时会下发后面所有请求都带着它。这个bid值在requests会话里会自动处理但如果你看到页面返回内容异常或者请求被重定向到安全验证页面多半是和Cookie丢失有关。解决办法是通过requests.Session来维持会话先访问一次首页让它种下Cookie再开始正式抓取。import requests session requests.Session() 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 } # 先访问首页让服务器种下基础Cookie session.get(https://book.douban.com, headersheaders, timeout10)2.3 请求限速为什么必须sleep怎么设置很多人写爬虫的时候不太在意延时觉得等几秒钟太浪费时间。但实际跑过几轮之后你会明白有延时和无延时的成功率差别极其明显。无延时连续请求的情况下豆瓣的服务器会在某个临界点直接返回418状态码——这是一个比较有辨识度的反爬信号表示服务器识别出自动化流量并主动拒绝。我的经验是每次请求之间至少time.sleep(3)温和一点就5秒。250条数据总共需要抓列表页10次、详情页250次按3秒延时算大概十几分钟跑完完全在可接受范围内。如果你希望更快可以在详情页部分用线程池并发但并发上限建议控制在3个以内同时把延时调整为2秒整体耗时能压缩到几分钟但对服务端的压力也随之增大建议按需选择看清楚目标网站的承载情况再决定。import time import requests for page in range(10): start page * 25 url fhttps://book.douban.com/top250?start{start} resp session.get(url, headersheaders, timeout10) print(resp.status_code, url) time.sleep(3)3. 数据解析从HTML里精准提取图书字段页面拿下来之后接下来就是解析。我不推荐用正则表达式硬扣原因很简单HTML结构一变正则是第一个挂的。用XPath或者CSS选择器写的是“路径”和“结构”容错性会好很多。3.1 解析思路XPath选择器怎么定位元素豆瓣图书列表页里每本书被包裹在一个li classsubject-item节点里。所有需要提取的字段都藏在这个节点内部因此解析思路非常清晰先定位所有包含图书信息的节点再分别提取每个节点下的书名、评分、评价人数等字段。XPath的核心逻辑就是沿着HTML的层级一路找下去。比如我想提取一本书的标题可以先定位到li.subject-item然后在它下面找h2 a里的文本。写XPath的时候有一个小技巧就是先用相对路径不要一上来就写整条绝对路径。绝对路径对页面结构调整非常敏感稍微改一层标签就失效相对路径只针对当前父节点做查找环境影响小得多。我实际用的XPath定义大概是这样的from lxml import html tree html.fromstring(resp.text) items tree.xpath(//li[classsubject-item]) for item in items: title item.xpath(.//h2/a/text())[0].strip() rating item.xpath(.//span[classrating_nums]/text())[0].strip() detail_url item.xpath(.//h2/a/href)[0].strip()需要留一个心眼rating_nums这个class在列表页里是存在的但在某些特殊条目下可能不显示评分。所以稳妥的写法是先判断列表是否为空不要直接取下标0不然很容易IndexError。rating_list item.xpath(.//span[classrating_nums]/text()) rating rating_list[0].strip() if rating_list else 暂无评分3.2 详情页解析作者、出版社、出版年份、简介一个都不能少列表页能拿到的信息就那几样书名、评分、链接是最基本的。书名和作者这类更详细的字段在详情页里。详情页结构里作者信息位于#info节点下的span元素出版社、出版年、定价等信息则以pl类别的标签和a标签的形式出现。我当时的目标字段有出版社和出版年份这两个字段的提取比作者要稍微麻烦一点点出版社通常包在a标签里出版年份则是纯文本需要按规律找。一个比较取巧的做法是拿详情页#info节点的纯文本内容然后按正则去切简单粗暴但效果稳定。示例代码如下import re info_node detail_tree.xpath(//div[idinfo])[0] info_text info_node.text_content() # 用正则提取出版社 publisher_match re.search(r出版社[:]\s*([^\n]), info_text) publisher publisher_match.group(1).strip() if publisher_match else # 用正则提取出版年份 year_match re.search(r出版年[:]\s*([0-9]{4}), info_text) pub_year year_match.group(1) if year_match else 这个思路的原理是详情页#info节点里的信息都是直排文本用中文冒号或者英文冒号分隔字段名和字段值。正则匹配虽然看着不高级但在这种情况下反而比逐层定位更简洁可靠。简介部分的定位也有规律通常位于div#link-report节点下包在div.intro里。需要注意的坑是简介文本可能包含多余换行、空格和引用的语录块清洗时可以用join和strip处理。intro_nodes detail_tree.xpath(//div[idlink-report]//div[classintro]/p/text()) intro_text \n.join([p.strip() for p in intro_nodes if p.strip()])3.3 分页采集与异常兜底不能因为一个页面失败就重来整个爬取过程是循环式列表页翻页每个列表页里提取详情页URL再进入详情页抓详细数据。这里面最大的风险是某个详情页请求超时或者被反爬拦截如果代码不做异常处理整个程序就会中断。我的做法是给每个详情页的请求包一层try-except失败后记录日志并跳过最后汇总的时候再单独重试失败的URL。这样整个采集过程是“尽可能多地把数据拿下来”而不是“一个失败就全部重来”。日志记录我建议至少包括时间、书名、URL、状态码这几项方便排查。failed_urls [] def fetch_detail(url): try: resp session.get(url, headersheaders, timeout10) if resp.status_code 200: return html.fromstring(resp.text) else: failed_urls.append(url) return None except requests.RequestException as e: print(f请求异常{url}错误{e}) failed_urls.append(url) return None4. 数据存储与清洗去重、增量更新与库表设计数据解析出来之后不是直接塞进文件就完事了。如果只跑一次那存个CSV甚至可以了但如果要考虑后续增量更新、按条件查询SQLite是更合适的选择。4.1 存储方案选型CSV、JSON、SQLite选哪个CSV的优点是最小、直观Excel可以直接打开缺点是字段里有换行、逗号的时候会各种出错而且不适合大量数据查询。JSON能保留嵌套结构比如一本书有多个作者、多个标签JSON可以直接存数组缺点是查询效率低每次读都是全量加载。SQLite是单文件数据库不需要单独起服务Python自带的sqlite3就能操作。它的查询能力和数据管理能力比CSV/JSON强太多了。对于250条图书数据来说SQLite虽然在“大材小用”的边缘但胜在后续扩展性——比如你哪天想抓几万条数据一样能扛住。4.2 库表设计主键、索引与字段约束库表结构设计这块一个简单的原则是“一个字段一个功能能拆开的尽量拆开”。我当时建的表是这样的CREATE TABLE books ( id INTEGER PRIMARY KEY AUTOINCREMENT, book_id TEXT UNIQUE, title TEXT NOT NULL, author TEXT, publisher TEXT, pub_year TEXT, rating REAL, rating_people INTEGER, intro TEXT, detail_url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );几个关键设计的理由book_id是豆瓣图书详情页URL里那串数字每本书唯一。设置UNIQUE约束可以直接用它做去重看到同一个book_id再次写入时抛异常或者走更新逻辑。rating用REAL类型因为豆瓣评分是小数rating_people用INTEGER评价人数一定是整数。created_at加默认时间戳用来记录首次入库时间后续增量更新做对比会很方便。4.3 幂等写入同样的数据不会重复插入爬虫不是跑一次就结束的过两天你可能想更新一下评分数据。这时候如果直接INSERT主键冲突会报错。推荐的做法是使用SQLite的INSERT OR REPLACE或者先查后插。INSERT OR REPLACE的逻辑是如果唯一键冲突就删掉旧记录插入新记录。对于图书数据来说这个行为是合理的——因为一本书的评分、简介随时间可能变化旧数据被覆盖是正常的。但要注意REPLACE会导致id自增字段变化如果你对id有强引用关系需要谨慎使用。我个人的做法是这样def save_book(conn, book): sql INSERT OR REPLACE INTO books (book_id, title, author, publisher, pub_year, rating, rating_people, intro, detail_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) conn.execute(sql, ( book[book_id], book[title], book[author], book[publisher], book[pub_year], book[rating], book[rating_people], book[intro], book[detail_url], ))5. 常见问题排查实录我的踩坑经历整理这一节是我觉得对新手最有用的部分。很多问题不看现场报错根本想不到我把实际跑项目时遇到的典型问题整理成一张速查表。现象可能原因解决方案请求返回418请求被反爬拦截通常是没有合理设置请求头或频率过高检查User-Agent增加请求间隔必要时先访问首页建立会话返回200但页面内容为空Cookie丢失或者页面结构发生变化用Session维持Cookie访问一次首页再开始抓取解析时IndexError某个图书条目缺少评分类字段判断列表是否为空再取下标详情页作者信息抽不到详情页结构因图书类型而异用text_content()提取全文再正则匹配而不是硬依赖层级定位存入数据库后重复数据一堆没有设置唯一键或者主键冲突处理建表时给book_id加UNIQUE写入用INSERT OR REPLACEURL编码问题导致页面不对参数里的中文没有编码用urllib.parse.quote处理5.1 418状态码和403状态码怎么区分这两个状态码我都遇到过。简单说403是服务器理解你的请求但拒绝执行通常表示没有权限或者被策略阻止418在反爬语境下则是“识别出你是自动化工具”的典型信号。处理思路本质上是同一套降低频率、优化请求头、模拟真实浏览行为。如果这两种状态码频繁出现第一步永远是检查自己的抓取行为是不是太粗暴了而不是绞尽脑汁去“破解”对方的限制。5.2 页面结构变动如何应对网站改版是爬虫最头疼的事情之一没有人能保证解析代码永久有效。豆瓣图书的页面结构相对稳定但class的微小调整也不是没发生过。我的经验是给解析逻辑留一个容错层每个字段都先判断是否取到值再入库。这样即使某个字段解析不到数据也不会整体丢失只是该字段显示为空后续再看日志补抓就行。5.3 数据清洗的细节比较隐蔽的脏数据最后说几个肉眼不一定看得到的数据坑。第一个是评分字段带上换行和空格不清洗的话入库后查询很麻烦第二个是作者字段可能有多个人物中间用斜杠分隔要不要拆开取决于你的分析需求第三个是简介文本里的引号和特殊符号正常清洗方式是用文本去空白、去换行但不要过度清洗保存原貌有助于后续做更细致的文本分析。清洗代码我通常放在解析完成后、入库前这一段相当于给数据一个统一的“预处理闸口”。这一层写得干净后面做分析或者展示会省很多事。我个人在实际操作中的体会是爬虫项目最花时间的往往不是爬的过程而是数据清洗和异常处理。你写解析代码可能一个下午就搞定但排查各种边界情况、调整字段格式、处理请求失败重试可能会花掉周末两天。这些都是正常现象不用觉得自己基础差。豆瓣图书这个项目做完你对requests的会话管理、XPath的页面解析、SQLite的增删改查都会有一个完整的认知后面再遇到其他网站思路是相通的。
阅读完成 · 觉得有帮助?
咨询建站