前几天有个朋友问我为什么他用 requests 请求豆瓣页面状态码总是 418而不是预期中的 200。我第一反应是让他检查 User-Agent结果他回我一句“User-Agent 不就是一段字符串吗随便填一个不就行了”这段对话完美概括了大多数新手写爬虫脚本时最典型的误区——以为爬虫是“发个请求、拿点数据”这么简单实际上从发送请求到拿到能用的响应数据中间至少隔了请求头、状态码语义、编码推断、HTML 解析、频率控制这一整套基本功。这篇文章就以“爬虫获取豆瓣网站响应数据”这个练手项目为线索把完整链路拆开过一遍。适合刚学完 requests 和 BeautifulSoup 基础语法、但面对真实网站不知道从哪下手的读者。我会从一个 418 状态码开始逐步讲清楚如何配置请求、如何判断响应是否正常、如何从 HTML 里提取结构化字段、如何把结果安全地落盘。整个过程我只讨论公开页面的基础抓取目标放在技术学习上不涉及任何登录后内容、个人隐私数据和高频采集。1. 为什么一上来就撞上 418先把抓包调试的基本功摆出来先回答刚才那个问题418 是什么它是 HTTP 协议里一个非标准的玩笑状态码全称是 “Im a teapot”源自 1998 年愚人节的 RFC 2324。现在的实际使用场景已经变味了——不少网站把它当作“我认出了你是脚本”的信号。豆瓣对没有携带浏览器特征 User-Agent 的请求大概率会直接回 418而不是常见的 403 Forbidden。这个细节很关键。它意味着你的请求被服务器识别成了非浏览器客户端所以被拒之门外。问题不在你的代码逻辑而在请求的“外表”不够像一个正常访客。1.1 用开发者工具看一个正常请求长什么样解决这个问题之前先别急着写代码。把你自己的浏览器打开按下 F12切到 Network网络面板然后正常访问一次豆瓣页面。随便点开一个请求你会看到一串请求头信息。不要被那十几行字段吓到真正需要关注的只有几个。以我常用的浏览器为例一份典型的页面请求头长这样字段值会根据浏览器版本变化我不建议你直接复制我的Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q0.9,en;q0.8 Connection: keep-alive User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36这里最重要的就是User-Agent。它告诉服务器“我是谁”。如果你用 requests 直接发请求默认的 UA 是python-requests/x.x.x服务器一眼就能看出这是个脚本。把 UA 换成上面这种主流浏览器的常见值请求就从“一看就是机器人”变成了“看起来像正常浏览器”。这是一个 HTTP 客户端应有的基本信息不是什么破解手段任何正常访问网站的工具都会携带这类信息。1.2 三个字段把请求包装成一个“正常访客”除了 UA我一般还会带上Accept和Accept-Language。Accept告诉服务器我支持哪几种返回格式Accept-Language说明我希望拿到中文内容。这几个头加在一起能让你的请求在大多数情况下不再被简单粗暴地拦截。写进代码里就是这样import requests 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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } resp requests.get(https://book.douban.com/top250, headersheaders, timeout10) print(resp.status_code)这个基础版本跑通之后你会发现状态码从 418 变成了 200。别高兴太早这只是万里长征第一步。后面还有编码、解析、异常处理这些正经内容等着你。2. 从状态码到响应头把返回结果完整地读一遍拿到 200 之后很多教程就直接让你去解析 HTML 了。但我想先停下来讲讲怎么“读”一个响应。所谓“获取响应数据”不仅仅是指resp.text这一段 HTML还包括状态码、响应头、最终 URL、编码方式等元信息。这些信息在你后面排查问题时作用比正文还大。2.1 状态码不是 200 不代表脚本失败新手最容易出现的判断错误是一看到不是 200就认为网站“挂了”或者自己被封了。实际上每种状态码都有它的含义合理应对才是关键。状态码含义常见原因你的应对200正常-继续解析301/302重定向未登录访问某些受保护页面被引导到登录页检查resp.url是否偏离目标 URL403无权访问服务器明确拒绝检查 UA、频率、是否触碰了禁止爬取的路径404页面不存在目标 URL 拼写错误核对链接观察页面是否改版418识别为脚本UA 异常或请求频率过高先修正请求头再降低频率429请求过多单位时间内请求次数超过阈值加大 sleep 间隔至少休息几分钟举个例子我抓详情页时曾经遇到 200 状态码但解析出来的页面里全是登录框。排查了半天才发现页面先 302 跳转到了登录链接而 requests 默认会跟随重定向所以最终拿到的 200 其实是登录页的 200。这种问题只看状态码是发现不了的你得对比一下resp.url和当初请求的 URL 是否一致。2.2 编码坑为什么输出全是乱码状态码正常、页面 HTML 也拿到了但print(resp.text[:500])打出来的内容全是乱码。这是另一个高频问题。requests 库有一套推断编码的机制它会优先看响应头里的charset声明如果没有就尝试从 HTML 的meta charset标签里找再不行会启用一些默认策略。问题在于当响应头里没有明确的字符集信息时requests 可能猜错甚至退回 ISO-8859-1这是一个拉丁字符集中文用它输出自然全是问号或乱码。豆瓣页面本身是 UTF-8 编码所以最简单的处理方式是手动指定resp.encoding utf-8如果你不确定目标页面是什么编码可以用resp.apparent_encoding让 requests 使用字符集检测库去猜resp.encoding resp.apparent_encoding但apparent_encoding偶尔也会误判所以最可靠的办法还是请求之后先打印resp.encoding再打印网页源码前几百个字符确认一下到底应该用什么编码。这个习惯养成之后能帮你省掉大量“为什么乱码”的调试时间。2.3 一个真实的排查链路418 → 403 → 200我把一次完整的排查过程写出来你会更清楚这条链路该怎么走。某次我需要抓取一个列表页第一次脚本没带任何请求头得到 418。我把 UA 加上变成 403。403 意味着服务器认出了我是脚本或者路径被禁止访问。我检查了两个地方一个是我访问的路径是否在允许范围内另一个是请求头是否还有遗漏。后来我补上了Accept和Accept-Language并把请求频率控制在两秒一次状态码终于稳定在 200。这个过程看起来简单但很多人会在 418 这一步就放弃或者反反复复乱试参数。正确的排查节奏应该是先改 UA → 观察状态码变化 → 再补其他请求头 → 如果还不行才考虑是不是频率问题。每一步都只改一个变量这样你才知道到底是哪个配置起了作用。这比把十种策略同时堆上去要高效得多。3. 解析 HTML把书名、评分这些字段从标签里捞出来响应数据拿到手接下来才是真正干活的部分从 HTML 里提取你要的信息。BeautifulSoup 是 Python 生态里对新手最友好的 HTML 解析库它不追求极致的性能但 API 设计相当人性化适合做页面结构分析。在写选择器之前先用浏览器开发者工具或者直接打印soup.prettify()看一眼页面结构。很多新手直接抄网上的选择器复制到自己的脚本里发现返回空列表就开始怀疑 BeautifulSoup 有问题。实际上网站改版和页面结构差异都会导致选择器失效你需要基于当前页面的实际结构来写。3.1 用 BeautifulSoup 定位目标数据的三板斧我调试选择器的时候一般按以下顺序来这套方法基本覆盖了绝大多数页面先用soup.find(a, ...)定位单个元素测试选择器是否正确再用soup.select(a.title)用 CSS 选择器获取一批同类元素最后用item.select_one(...)/item.find_all(...)在列表项内部继续往下挖。比如你要从列表页里提取每一项的书名、评分和详情页链接可以先找到包含这一项的最外层容器再在容器内部继续查找子元素。思路大概是from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, html.parser) # 先找到所有包含书目信息的条目容器具体选择器以你抓到的页面结构为准 items soup.select(tr.item) for item in items: title_tag item.select_one(td a) title title_tag.get(title, ) if title_tag else link title_tag.get(href, ) if title_tag else score_tag item.select_one(span.rating_nums) score score_tag.get_text(stripTrue) if score_tag else print(title, score, link)这里的关键不是具体的select参数而是思路先定位容器再在容器中取字段。一个小建议写选择器时优先用 class 或 id 这类稳定的属性少用完全依赖层级关系的div div div a因为页面层级只要稍有改动这种选择器就会失效。3.2 选择器的三种写法与优先级BeautifulSoup 定位元素的方法从简单到复杂大致有三种find/find_all、select_one/select、以及直接操作soup的属性。find系列适合按单个条件定位比如find(h2)它不依赖 CSS 语法门槛最低。select系列支持 CSS 选择器一次能表达更复杂的定位规则比如select(div.info h2 a)。如果你只是想快速拿到某个标签下的数据也可以直接操作节点属性但这通常只用于已定位元素内部的二次提取。我的习惯是外层定位用select拿到元素后内部的字段提取尽量用select_one加get_text。get_text(stripTrue)这个参数特别有用它会自动去掉字符串首尾的空白和换行。做数据处理时我不太建议手动去搞正则表达式先用 BeautifulSoup 把结构化部分处理干净剩下的零散文本再交给正则这样代码维护成本会低很多。3.3 列表页到详情页的二次请求要克制列表页通常只有摘要信息比如书名、评分、评价人数。如果你想拿简介、作者介绍这类内容就得跟着详情页链接再次发请求。这涉及一个规模问题假设列表页有 50 条数据每条都要再请求一次详情页总请求数就变成了 1 50 次。如果很不巧地设置了极短的间隔甚至并发抓取对目标服务器造成的压力会成倍增加。我处理这种二次请求时会先问自己几个问题详情页的数据是不是真的必要能不能从列表页现有的字段中推断如果必须获取能不能只抓其中排名靠前的若干条而不是全量抓取这不是胆小而是爬虫工程的根本问题——你每一次额外的请求都在消耗对方的资源也在提高自己被限制的风险。控制请求总量是最基本也是最容易被忽略的礼貌。4. 请求节奏与异常重试更像一个克制的人类很多初学者把爬虫写成“一次性脚本”循环发请求、解析、存数据跑完后扔在角落。这种脚本一旦遇到网络抖动、反爬拦截或者目标页面结构变化就会直接崩溃。更现实的问题在于正式的目标网站都会有基本的风控策略表现得太像一个高压请求机被限制只是时间问题。4.1 sleep 的正确打开方式在循环里加time.sleep()是最简单也最实用的保护措施。但 sleep 多长时间没有标准答案它取决于你的抓取规模和目标网站的容忍度。我个人的经验是页面详情类请求每次间隔 1.5 到 3 秒比较保险列表页请求可以稍微密一点但最短也不要低于 0.5 秒。如果你拿不准取一个相对保守的值总没有错。import time for url in url_list: resp await fetch(url) # ... 解析与存储 time.sleep(2)有两点值得提醒第一sleep 的位置要放在每次请求完成之后、下一次请求发起之前别放在循环开头第二sleep 时间最好不要是完全固定的常数稍微加一点随机抖动比如1.5 random.uniform(0, 1)这样请求节奏更像人类操作。这不是什么高深技巧只是降低请求模式被识别出来的概率。4.2 异常重试与指数退避网络请求不可能永远成功。超时、连接拒绝、临时性 5xx 错误这些都属于环境问题重试通常就能解决。但重试不是无脑地多试几次那样反而会加重服务器负担。我一般使用指数退避第一次失败等 2 秒第二次失败等 4 秒第三次失败等 8 秒最多重试 3 次。import time import requests def fetch_with_retry(url, session, max_retries3): for attempt in range(max_retries): try: resp session.get(url, timeout10) if resp.status_code 200: return resp # 对 4xx 类错误如 403、404没有必要重试 if resp.status_code 500: return resp except requests.RequestException as e: print(f第 {attempt 1} 次请求失败: {e}) time.sleep(2 ** attempt) return None注意我把4xx和5xx分开处理了。404重试一百次也是 404403大概率也是同样的结果这些错误应该直接暴露出来而不是在循环里消耗时间。只有超时和5xx这类服务器临时性错误才值得重试。这种区分能帮你省下大量无意义的时间。4.3 断点续抓与去重如果你要抓的数据量大脚本很可能中途因为各种原因挂掉。最稳妥的办法是每处理完一条数据就立即写入磁盘而不是攒到最后一次性保存。这样脚本挂了已经完成的部分还在重跑时跳过已有数据就行。去重逻辑也很简单保存数据时同时记录一个唯一标识比如详情页 URL下一次请求前检查当前 URL 是否已在已抓取列表中。量小时可以直接用 Python 的set量大时写进 SQLite 或本地文本文件都行。很多新手忽略这一步结果脚本重跑时报错“数据重复”还得花时间写清洗逻辑。从一开始就设计好反而节省时间。5. 数据落盘CSV/JSON 里那些容易踩的编码细节数据解析完成后最后一步是保存。这个环节听着简单但实际上有两个高频坑一是 CSV 用 Excel 打开乱码二是 JSON 序列化后中文变成\uXXXX。5.1 用 utf-8-sig 解决 Excel 打开乱码Python 的 csv 模块默认写入文件时如果你用encodingutf-8生成的文件用记事本或 VS Code 打开没问题但双击用 Excel 打开时中文大概率是乱码。原因在于 Excel 默认用 ANSI 编码读取 CSV不认 UTF-8 的魔数。解决办法是写文件时使用utf-8-sig编码import csv with open(top250.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([title, score, link]) writer.writerow([百年孤独, 9.5, https://... ])utf-8-sig会在文件头部写入一个 BOM 标记Excel 看到这个标记就知道该用 UTF-8 解码了。另一个细节是newline如果你不写这个参数Windows 平台上 csv 模块写出来的文件每一行之间会多一个空行这是行尾符处理不当导致的加了newline就能解决。5.2 JSON 序列化时的 ensure_ascii 陷阱如果你更倾向于用 JSON 格式保存数据会遇到另一个问题。默认的json.dump会把所有非 ASCII 字符转成\uXXXX的转义序列输出的文件里全是\u767e\u5e74\u5b64\u5bd2这样的东西肉眼基本没法看。这个行为是 Python 的ensure_ascii参数默认值导致的。把它设为False中文就能以明文形式写入文件import json data [ {title: 百年孤独, score: 9.5} ] with open(top250.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)这里encodingutf-8是控制文件本身的编码ensure_asciiFalse是控制 JSON 序列化时是否转义中文两个参数要配合使用。设置完之后生成的 JSON 文件里面直接就是可读的中文代码里查数据时也能少一层转义的烦恼。6. 聊点实在的爬虫的边界与长期主义技术链路讲完了最后想聊几句不那么“技术”但很重要的话题。爬虫不是“能抓到数据”就万事大吉更关键的是知道什么该抓、什么不该抓、抓到之后能做什么。6.1 robots.txt 是平台给你的“访问说明书”任何网站都有一个robots.txt文件比如https://book.douban.com/robots.txt。访问之前花一分钟读一下这个文件它会告诉你哪些路径对普通爬虫开放哪些不允许抓取。这里的规则不是法律强制力但它代表了平台运营方的明确态度。不同平台差异很大有的允许搜索引擎抓全部有的把评论、用户主页等路径明确划为禁止区域。动笔之前读一下 robots.txt 这个习惯是所有靠谱从业者的基本功。有些路径虽然技术上可以请求到数据但它明确不在允许范围内在职责范围内就不应该去碰。这不是道德绑架而是降低自身风险、保证项目长期可维护的必要条件。6.2 数据的使用方式比抓取方式更重要学术机构、个人博客、公开的图书榜单这类信息本身就是为了被公众阅读而发布的写脚本批量获取这些公开页面的摘要信息用于个人学习在合理的节奏和数量控制下是可以理解的。但如果你把抓下来的数据打包分发、商用牟利或者把别人网站辛苦整理的内容直接变成自己的产品性质就完全不同了。我见过不少案例技术能力完全没问题最后栽在数据使用上。所以我的建议是练手就用公开页面的摘要信息拿到数据之后自己分析、画画图表、做做词频统计这些场景足够你消化所有技术点。当你开始考虑“我能用这些数据做什么”的时候时刻保持对原始数据来源的尊重这才是长期主义。写这个豆瓣练手项目我最后跑通的脚本非常简单带齐请求头、控制请求间隔在两秒、最多抓取几十条数据、加上异常重试和断点续存整套不到一百行代码。但让我收获最大的不是那几百行代码而是学会了怎么读状态码、怎么判断重定向、怎么从 HTML 结构里定位字段、怎么在失败时保持冷静。这些基本功放到任何接口抓取、页面解析场景里都通用。如果你也想练手我建议先从响应头和状态码开始看起把每一次返回都当成一次对话——你很快就会发现爬虫真正的功夫不在“请求”而在处理各种边界情况上。