有些人看我写爬虫总觉得好像“手到擒来”其实哪有什么玄学。爬虫说白了就是三个字拿、解、存。你把这三件事做顺了大部分页面都能轻松上手。这篇文章不聊那些花里胡哨的框架和分布式架构就讲我日常写爬虫时最常用到的顺手方法从请求到解析从排错到落地全部都是可以直接抄作业的干活经验。这篇文章更适合谁看刚接触爬虫、想补全自己采集流程的开发者或者是工作中需要经常抓公开数据做分析的同学。如果你是那种连requests和BeautifulSoup都还没装过的纯萌新也没关系我会按一个完整示例带你把流程走一遍。只要照着敲你就能得到一份结构清晰、能复用的采集脚本。1. 先把“顺手”想明白爬虫不是炫技是解决问题很多人一提到爬虫脑子里全是分布式、高并发、IP池、逆向破解这类词。但实际上工作中遇到的爬虫需求八成以上都是“快速拿到一批公开数据整理成表格或者接口”。这时候谁用分布式啊那是给自己找罪受。1.1 到底什么样的爬虫才算“顺手”我给“顺手”定了三条标准代码结构一眼能看懂隔三个月回来自己还能改。请求失败能自动处理而不是崩一片。换一个目标页面改少量代码就能复用。满足这三点就是合格的“顺手方法”。相反如果你写了个爬虫只有自己能跑别人接手就爆炸那不管抓取效率多高都不算顺手。先说我个人踩过的一个坑。早期写爬虫我喜欢把所有的请求逻辑堆在main函数里50 行代码把请求、解析、保存全干完。当时觉得很爽后来需求一变要加个代理池还要过滤重复数据结果整个脚本改得面目全非。从那时候起我才明白爬虫代码也是代码不规划直接写后面全是债。1.2 四个核心模块取、解、洗、存我把一个爬虫拆成四个阶段取发 HTTP 请求拿到 HTML 或 JSON 文本。解从文本中抽取出目标字段。洗清洗数据去重、补缺、转换类型。存写入 Excel、CSV、JSON 或者数据库。这四个模块对应四个函数是我个人最常用的模板骨架def fetch(url): # 返回 Response 文本 def parse(html): # 返回结构化数据列表 def clean(items): # 清洗、去重、标准化 def save(items): # 写入目标存储后面所有爬虫不管抓的是电商平台还是新闻站点我都尽量把逻辑往这四个函数里塞。好处是每个环节出问题单独调试一个函数就行不用在一大坨代码里大海捞针。1.3 选工具时我的默认判断工具这东西最忌讳跟风。我记得有一阵子 Scrapy 特别火大家都说“爬虫不上 Scrapy 不算专业”。我也跟着去学了学完发现很多简单的采集场景用 Scrapy 反而费劲——中间件、Item Pipeline、Spider 类全是被框架绑定的概念加上部署和监控一套下来非常重。所以我的默认判断是分层的只需要抓几十几百个页面直接用requestsBeautifulSoup最快。需要并发请求或异步抓取优先httpx代码好写。页面是登录后才能看需要维护 Cookie 会话还是requests.Session方便管理。需要定时、增量、错误重试、日志的工程化爬虫才考虑上 Scrapy。说到底顺手的意思就是“拿最合适的工具干最合适的活”。工具只是手段解决问题才是目的。2. 核心细节解析与实操要点这部分我要把爬虫里最容易出问题的几个细节单独拎出来讲。这些问题看起来小但每一个都足以让你折腾一下午。2.1 第一关是请求不是解析很多人拿到一个页面第一反应是打开浏览器 F12 去看 HTML 结构然后就开始写解析。但我的习惯是先看请求长什么样。什么意思就是说你在浏览器里看到的页面内容可能根本不是后端返回的 HTML 自带的而是 JS 动态渲染出来的。如果你直接请求 URL 拿到的是空壳解析就是白搭。所以第一步应该做的是用requests请求目标 URL打印响应的前 500 个字符确认你需要的数据是否直接存在于 HTML 里。import requests resp requests.get(https://example.com/page, timeout10) print(resp.status_code) print(resp.text[:500])如果能看到目标数据的痕迹说明是服务端渲染直接解析即可。如果只看到一堆script标签或者idroot的空壳基本就是前端渲染页面这时候要么找数据接口要么考虑用自动化工具。请求阶段还有几个容易忽略但很重要的点超时设置。requests的timeout参数必须写不写的后果是一旦目标服务器卡住你的爬虫会一直等白白挂死。显式声明 User-Agent。很多站点对默认的python-requests有拦截换个常用浏览器的 UA 通常就稳了。会话复用。如果你需要登录或者有 Cookie 的页面用requests.Session()而不是每次get()裸用这样能保持 Cookie 状态。重试机制。网络抖动是常态简单做法是用requests.adapters.HTTPAdapter设置最大重试次数。下面是我日常用的请求函数模板非常顺手from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry session requests.Session() retry Retry( total3, backoff_factor0.5, status_forcelist[500, 502, 503, 504] ) adapter HTTPAdapter(max_retriesretry) session.mount(http://, adapter) session.mount(https://, adapter) def fetch(url, headersNone): h {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)} if headers: h.update(headers) resp session.get(url, headersh, timeout15) resp.raise_for_status() return resp.text这里raise_for_status()也很关键它会帮你在 HTTP 状态码异常时主动报错而不是拿到一堆乱码还在那瞎解析。2.2 解析页面从正则到 BS4 的选择解析 HTML 有很多办法最原始的是正则表达式。但我建议非万不得已别用正则解析 HTML。为什么HTML 是一种结构不严谨的标记语言同一个语义可以有无数种写法。标签可以不闭合属性顺序可以乱引号可以省略。正则擅长匹配“符合规则的文本”但 HTML 往往不符合规则于是你会陷入不断加条件的死循环。我的默认选择是BeautifulSoup lxml。这个组合非常快而且对不规范 HTML 的容忍度极高。from bs4 import BeautifulSoup soup BeautifulSoup(html, lxml) items soup.select(div.item .title a)select()支持 CSS 选择器这是我最喜欢的特性。写选择器的时候有个通用原则先定位到包含目标数据的最小容器再往外或往里取字段。比如下一页、列表页、详情页思路都一样。如果只想取文本不要直接用.text先用get_text(stripTrue)这样可以顺手把大量空白字符清掉。复杂的页面结构我的建议是不要试图一条选择器打完天下。拆成多个步骤一步步逼近数据代码虽然啰嗦但极其稳定container soup.select_one(div.product-list) products container.select(div.product-item) for p in products: name p.select_one(h3.name a) price p.select_one(span.price)这样写的好处是即使某个字段缺失也不会影响其他字段的解析。2.3 编码与字符集容易翻车的地方爬虫中文乱码大多数情况下是编码问题。响应头里的charset可能不正确或者页面内部用meta声明了字符集但requests没识别出来。一个比较稳妥的方法是拿到 Response 后先通过resp.apparent_encoding看看自动探测出来的编码有时候比 response 头里写的还准。resp session.get(url, timeout15) resp.encoding resp.apparent_encoding text resp.text但这里有个坑apparent_encoding有时候会误判尤其是小体量文本。所以更稳的做法是直接用BeautifulSoup解析时指定from_encodingsoup BeautifulSoup(resp.content, lxml, from_encodingutf-8)如果页面声明是gbk或gb2312就对应改成text resp.content.decode(gbk, errorsignore)这里的关键点是requests拿到的是字节流resp.content如果你直接用resp.text它内部会用默认的编码猜测规则去解码猜错就乱码。所以当你发现中文乱码时第一反应应该是重新取resp.content然后手动指定编码解码。还有一个小技巧是打印页面的 meta 标签内容从中读取真实的 charset 声明再用这个结果去解码。很多老站点就是这么干的。3. 实操过程与核心环节实现下面我拿一个非常经典的公开示例站——quotes.toscrape.com走一遍完整的爬虫流程。这个站是专门给爬虫学习者练习用的服务器压力不大数据公开适合演示。3.1 目标选择与准备首先我要做的事情不是写代码而是先看看这个站点的根路径有没有robots.txt。这是爬虫的基本礼仪也是一个顺手习惯。我想查的是结构而不是想绕过什么。curl https://quotes.toscrape.com/robots.txt正常访问该地址会看到允许抓取的声明。我实际工作中凡是遇到没写禁止抓取的公开站点也会尽量控制频率至少不会给服务器造成压力。然后我看一下首页结构这是一个名言列表页面里有名言内容、作者、标签三类信息。页脚有下一页链接。这样我们可以设计抓取逻辑用fetch获取列表页 HTML。用parse提取每一条名言的数据。找到下一页链接继续抓直到没有下一页为止。最后把数据存成 CSV。3.2 完整代码示例抓取公开的名言列表下面这段代码就是按照“取、解、洗、存”四段式来写的完整可以直接跑import csv import requests from bs4 import BeautifulSoup BASE_URL https://quotes.toscrape.com def fetch(url): resp requests.get(url, timeout15) resp.raise_for_status() resp.encoding utf-8 return resp.text def parse(html): soup BeautifulSoup(html, lxml) quotes [] for item in soup.select(div.quote): text item.select_one(span.text) author item.select_one(small.author) tags item.select_one(div.tags) tags_list [t.get_text() for t in tags.select(a.tag)] if tags else [] quotes.append({ text: text.get_text(stripTrue) if text else , author: author.get_text(stripTrue) if author else , tags: ,.join(tags_list) }) return quotes def clean(items): seen set() result [] for item in items: key (item[text], item[author]) if key in seen: continue seen.add(key) item[text] item[text].replace(\u201c, ).replace(\u201d, ) result.append(item) return result def save(items, filenamequotes.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[text, author, tags]) writer.writeheader() writer.writerows(items) def main(): all_items [] url BASE_URL /page/1/ while url: print(抓取中:, url) html fetch(url) all_items.extend(parse(html)) soup BeautifulSoup(html, lxml) next_btn soup.select_one(li.next a) url BASE_URL next_btn[href] if next_btn else None all_items clean(all_items) save(all_items) print(完成共抓取, len(all_items), 条名言) if __name__ __main__: main()这个脚本里有个细节encodingutf-8-sig。为什么不用utf-8因为utf-8-sig会在文件开头写入一个 BOM 头这样你用 Excel 打开 CSV 时不会乱码。很多人抓了数据一到 Excel 就乱码就是这里没注意。另一个细节是名言文本里带英文弯引号\u201c和\u201d我在清洗阶段把它们替换掉了。这个就是清洗的“现场表演”——永远不要指望源头数据是干净的。3.3 清洗、转换与落盘上面的代码已经把清洗和存储做进去了但我要专门强调一下这两步为什么值得做。清洗的目的是保证数据可用性。很多项目卡在最后一步就是因为拿回来的数据杂七杂八没法直接进分析流程。我说几个高频清洗动作去重同一页面抓两遍或者翻页重复抓都会有重复数据。用关键字段做去重比全字段去重更精准。去空白get_text(stripTrue)之外还要注意常见不可见字符比如\u200b零宽空格、\xa0不间断空格。类型转换价格、日期、数字这类字段解析出来往往是字符串要手动转成float或datetime再入库。缺失值处理有的页面某个字段为空别急着丢整行先补上默认值后续分析的人可能会需要这条记录。存储层面如果是小量数据CSV 足够如果数据量大或者要频繁查询建议入 SQLite。Python 标准库里的sqlite3就够用没必要为这点数据上一个重量级数据库。import sqlite3 conn sqlite3.connect(quotes.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT, author TEXT, tags TEXT ) ) cursor.executemany( INSERT INTO quotes (text, author, tags) VALUES (?, ?, ?), [(q[text], q[author], q[tags]) for q in all_items] ) conn.commit() conn.close()这个executemany执行批量插入比循环单条插入快得多。顺手真的很重要。4. 常见问题与排查技巧实录爬虫写多了你会发现大部分时间根本不是在写爬虫而是在排查各种奇怪问题。下面这些问题我几乎每个都遇到过也都有对应的排查思路。4.1 遇到 403 或 429 状态码403 是访问被拒绝。常见原因有两种一是没有带合理的User-Agent默认的python-requests很容易被拦截二是服务器做了频率限制你访问太频繁触发了防护。排查步骤用浏览器打开同一个 URL确认数据在浏览器里能正常展示。如果浏览器也打不开那说明是服务器或网络问题和你的代码无关。如果浏览器能打开对比浏览器请求头和你的请求头把User-Agent、Accept、Accept-Language尽量补全。如果加了 UA 仍然被拒大概率是频率限制。这里应该做什么不是想办法绕过而是降低请求频率增加两次请求之间的间隔这是对服务器的基本尊重也能避免给站点造成压力。429 是请求过于频繁返回头里通常会有Retry-After字段告诉你多少秒之后再试。我见过有人直接把重试逻辑做成死循环一秒请求一次结果就是账号被封、IP 被禁。正确做法是读取Retry-After按服务器要求等待。import time resp session.get(url, timeout15) if resp.status_code 429: wait_time int(resp.headers.get(Retry-After, 30)) print(f请求太频繁等待 {wait_time} 秒) time.sleep(wait_time)4.2 页面结构变了筛选器失效这个太常见了。今天写的选择器明天就没数据。原因可能是页面改版也可能是接口数据结构调整。排查时先别改代码先手动打开页面看看目标节点还在不在。如果页面结构变了把新的 HTML 保存下来重新分析选择器。我用的是最土但最有效的方法把抓回来的 HTML 用浏览器打开然后 F12 选中目标元素复制它的 CSS 路径再逐步简化。这里有个原则选择器写得越简单抗页面变化的能力越强。比如div.quote就比body div:nth-child(2) div.container div.row div.col-md-8 div:nth-child(1)稳定得多。所以写选择器时尽量用 class、id 这类有语义的标识少用伪造的层级和索引。4.3 动态渲染内容怎么处理回到前面说的页面可能是 JS 动态渲染的。这时 HTML 源码是空壳。你 F12 看到的数据是浏览器执行 JS 之后生成的直接requests是拿不到的。我自己的处理顺序是这样的在浏览器里打开页面按 F12 切到 Network 面板刷新页面看网络请求里有没有返回 JSON 数据的接口。很多前端框架都会从后端拉数据接口然后渲染到页面上。如果你能找到这个接口直接用requests请求它拿到 JSON 解析效率最高。找不到数据接口或者接口加了复杂的签名验证这时候才考虑自动化浏览器工具。对公开数据而言用自动化工具打开页面等渲染完成再获取页面源码是可行的。但要注意这种方式开销大、速度慢而且资源占用高不适合大规模采集偶尔跑一跑还行。我特别想提醒的是动态渲染页面往往会频繁触发验证码之类的人机验证。如果你频繁用自动化工具去访问会给对方服务器带来压力还可能导致正常的访问也被限制。所以这里更推荐优先找数据接口而不是一股脑上自动化工具。5. 提高实用性的几条经验最后一章我分享几个长期实践下来的经验。它们不一定能直接让你抓得更快但能让你的爬虫“活”得更久也更省心。5.1 请求频率与自律很多站点的反爬其实不是针对爬虫本身而是针对行为。正常用户不可能每秒发出几十个请求。所以控制请求频率是大规模采集的底线。我个人的习惯是每次请求之间至少间隔 0.5 到 1 秒。小批量采集可以快一点大批量就一定慢下来。这不是怂这是细水长流。你可以设一个随机间隔让请求看起来更像人工操作import random time.sleep(random.uniform(0.5, 1.5))这样的好处一是给服务器留出响应空间二是避免自己被打进黑名单。如果任务量很大我的建议是分批跑比如一次只抓 100 页休息几分钟再继续。5.2 断点续爬与增量更新一次性全量抓完的情况其实很少。大多数实际项目是需要每天定时增量更新的。如果每次都从第一页开始抓既慢又浪费带宽。思路是把“已抓取页面的最后位置”记下来。可以是文件可以是数据库表。import os def get_last_page(): if os.path.exists(cursor.txt): with open(cursor.txt, r, encodingutf-8) as f: return int(f.read().strip()) return 1 def set_last_page(page): with open(cursor.txt, w, encodingutf-8) as f: f.write(str(page))这个思路很适合列表类页面。每天定时任务从上次结束的页数开始抓抓完更新游标。增量更新的另一个关键点是“去重”建议入库时用唯一约束比如给(text, author)建唯一索引遇到重复数据直接跳过。5.3 日志与调试我见过太多人写爬虫完全不打印日志出了问题只能靠猜。说实话爬虫是最需要日志的场景因为目标站点不在你控制范围内任何异常都可能随时发生。我建议至少做三件事每抓取一个页面打印 URL 和状态码。每次解析到的数量打印出来。每个异常完整记录 traceback。import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s ) logger logging.getLogger(__name__) def fetch(url): logger.info(正在获取: %s, url) try: resp session.get(url, timeout15) logger.info(状态码: %s, resp.status_code) return resp.text except Exception as e: logger.exception(请求失败: %s, url)日志的作用不只是排错它还能让你大致估算整个任务的耗时。比如抓了 1000 个页面日志显示平均每页 1.2 秒那你就能预估全部跑完需要多久方便安排执行计划。最后分享一个我个人的小习惯。每次写完爬虫我都会单独写下这个目标站点的“需求说明书”——目标 URL 是什么、字段有哪些、数据量大概多少、多久更新一次。哪怕只是几行笔记下次需求变更时你再看这段代码就不会一脸茫然。爬虫这个领域代码变化太快但思路和习惯是可以沉淀的。你把这些顺手方法内化了再去面对任何新的采集需求都会从容很多。