简介:面向具备一定 Python 与网络基础的数据分析师、爬虫开发及机器学习等人员,这份代码包围绕网络数据自动抓取展开,覆盖网页文本、图片等信息的采集与清洗,可支撑数据分析、舆情监测、市场调研等场景。压缩包内共 21 个文件,以 16 个 py 脚本为主体,从数字、字符串、列表、字典等语法基础,到 requests 网络请求、BeautifulSoup 文档遍历、csv 文件读写均有示例;另含 2 个 txt 说明文本、2 个 csv 数据集及 1 张图片用于测试与效果展示,整包仅 34KB,轻量易读。脚本兼顾入门级知识点与可运行实战案例,如豆瓣读书简介抓取、Glassdoor 职位信息采集、新东方头像批量下载等,便于对照学习请求构造、页面解析与存储落盘的整体流程。目前已有 4379 人学习下载,适合希望快速建立爬虫知识框架并动手实践的读者。
1. 入手先看骨架:这份 Python 爬虫代码包的四层结构
抓取一个动态渲染的列表页,第一版能跑通是运气,能连续跑一周不翻车才是工程。这套 Python 爬虫代码包把请求、解析、反爬、存储拆成四个独立模块,下载之后你可以按目标站参数去换 UA、换选择器、换存储方式。它适合有一定 Python 基础、想直接看工程化写法的人:既要拿到能跑的代码,也要看清每一步为什么这么写。数据抓取的核心从来不是语法,而是稳定性——请求会超时、页面会改版、登录态会过期。所以本篇按请求层、解析层、动态页面、排查路径、断点续爬的顺序展开,每一层都给出可验证的检查方法。
2. 请求层与重试机制:session 复用、超时与指数退避的工程化写法
2.1 为什么请求层要单独封装
单个页面用requests.get('https://...')没什么问题,一旦数据量上来,性能会断崖式下跌:每次请求都重新建连、每次都要手动带 cookie,写到后面代码里全是重复的 header 字典。常见做法是把请求封装成一个带会话的模块,让requests.Session帮你维护连接池和 cookie。我一般会在代码包里看到两层设计:一层是连接级别的复用,另一层是失败重试。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(retries=3, backoff=0.5, timeout=(5, 15)): session = requests.Session() retry = Retry( total=retries, # 总重试次数 connect=retries, # 连接失败重试 read=retries, # 读超时重试 backoff_factor=backoff, # 退避系数 status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset(["GET", "POST"]), ) adapter = HTTPAdapter(max_retries=retry, pool_connections=20, pool_maxsize=20) session.mount("https://", adapter) session.mount("http://", adapter) session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept-Language": "zh-CN,zh;q=0.9", "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", }) return session这里最容易被忽略的是timeout=(5, 15):第一个值是连接超时,第二个值是读超时。连接超时设 5 秒,足够判断目标是否可达;读超时设 15 秒,给慢接口留余量。backoff_factor=0.5生成的退避序列是 0.5 秒、1 秒、2 秒,配合status_forcelist,遇到 429 和 503 会自动等待再重试。注意别把 404 放进去,404 是确定结果,重试多少次都一样。
2.2 重试次数与退避参数:3 次还是 5 次,区别在哪
重试次数不是越多越好。Retry(total=5)看起来稳妥,实际会放大请求压力:一个接口抖动,5 次重试全打过去,反而把服务打得更慢。我的习惯是total=3,配合退避已经能覆盖大多数网络抖动。如果目标接口偶尔返回 429,total=5也可以,但必须在退避里加随机抖动,防止多个线程在同一秒同时重试形成雪崩。
调试时有个麻烦:内置Retry不打印日志,你想确认是连接失败还是读超时时,只能在外层套循环。我一般会临时写一段带日志的循环,把失败阶段的细节暴露出来。
import time import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("spider") for url in url_list: for attempt in range(3): try: resp = session.get(url, timeout=(5, 15)) resp.raise_for_status() break except (requests.ConnectionError, requests.Timeout) as exc: logger.warning("第 %s 次失败: %s, %s", attempt + 1, url, exc) time.sleep(0.5 * (2 ** attempt)) # 指数退避这段代码和内置Retry是互补关系:循环负责暴露「第几次失败、因为什么失败」,真正上线时再换回Retry让代码更简洁。日志里如果频繁出现Read timed out,优先怀疑目标接口响应慢,而不是重试次数不够。
2.3 请求头与编码联动:UA、Accept 与乱码的隐藏关系
请求头里最不值得纠结的是User-Agent,随便用一个主流浏览器的版本就行,真正影响结果是Accept和Accept-Encoding。Accept如果漏了application/json,某些接口会回退到 HTML 格式,解析逻辑就得全部重写。Accept-Encoding最好不要手动设,让requests自己管理压缩,否则可能在解压时拿到乱码。
这里有个隐藏坑:请求层看到的乱码,往往不是解析问题,而是编码推测失败。resp.text会按响应头里的charset去解码,很多站点这个字段是错的。我在抓一个老平台时,响应头写的是ISO-8859-1,实际页面是 GBK,最后resp.text全是乱码。正确姿势是先拿到resp.content原始字节,再按候选编码逐个尝试,这个逻辑在下一章的解析层具体展开。
3. 解析层与编码:parsel 选择器、乱码修复与字段规整
3.1 解析器选型:三个常见库的取舍
代码包里选解析器时,我建议优先看三件事:语法好不好写、换框架成本高不高、性能够不够。经常被拿来做对比的三个库,差异其实很明显。
| 解析库 | 速度 | 语法风格 | 适用场景 |
|---|---|---|---|
| BeautifulSoup4 | 慢 | 对象属性 + select | 小型任务、新手学习 |
| lxml | 快 | 原生 etree + XPath | 复杂 HTML、性能敏感 |
| parsel | 中 | 与 Scrapy 同款 XPath/CSS | 列表抓取、模块复用 |
我在这份代码包里看到的是 parsel,这个选择比较务实:语法和 Scrapy 完全一致,今天用 parsel 写完的 XPath,明天放进 Scrapy 项目可以直接复用,迁移成本为零。BeautifulSoup 写起来确实直观,但速度慢,数据量过万之后差距会放大;lxml 性能最强,但它的 API 偏底层,写复杂提取逻辑时心智负担重。
3.2 列表页解析实战:XPath 与 CSS 的配合
假设目标页面的结构是ul[class=item-list]下多个li,每个li里有标题、链接和价格。用 parsel 的解析代码很接近生产环境的写法。
import parsel def parse_list(html): sel = parsel.Selector(html) rows = sel.xpath('//ul[@class="item-list"]/li') items = [] for row in rows: title = row.xpath('.//h2/a/text()').get() link = row.xpath('.//h2/a/@href').get() price = row.xpath('.//span[@class="price"]/text()').get() if not title: continue items.append({ "title": title.strip(), "link": link, "price": price.strip() if price else "", }) return items这里有两个经常踩坑的细节。第一个是row.xpath('.//h2/a/text()')里的点号.//,表示从当前节点继续往下找,丢掉点号就会从文档根节点重新找,结果往往是空列表。第二个是get()和getall()的区别:get()只取第一个匹配,getall()返回全部,价格字段只要一个值就够,所以用get()。
3.3 乱码与缺失值:先修复编码,再清洗字段
解析层最玄学的问题是乱码。如果你发现 title 变成一堆看不懂的符号,第一反应不应该是写清洗正则,而是回去检查解码方式。我习惯用一段兜底式解码:
def decode_response(resp): raw = resp.content candidates = [resp.encoding, resp.apparent_encoding, "utf-8", "gbk"] for enc in candidates: if not enc: continue try: html = raw.decode(enc) return html except UnicodeDecodeError: continue return raw.decode("utf-8", errors="ignore")这段代码的逻辑是:先信任响应头里的resp.encoding,解不了就换apparent_encoding,再解不了就试utf-8和gbk。实际用到最后两个候选的场景不多,但一旦遇到一个声明错误的站点,这段兜底就是后悔药。
字段清洗比编码简单,原则是宁缺毋滥:strip()去掉首尾空白,数字字段统一格式,标题缺失就跳过该条,别让脏数据进存储。价格字段可能没有,保留空字符串比直接抛异常更合适。把字段规整和编码修复放到解析层,存储层就不用反复处理脏数据。
4. 动态页面与登录态:selenium 选型、等待条件与登录复用
4.1 动静结合:什么时候不该硬啃 HTML
如果你在浏览器里能看到数据,但用 requests 拿到的 HTML 里什么都没有,别急着去写更复杂的选择器——大概率是页面内容由 JavaScript 动态渲染,数据根本没进首次返回的源码。这时的排查顺序应该是:先按 F12 打开开发者工具,切换到 Network 面板,找 XHR 请求,看看数据是不是从接口返回的 JSON 里来的。
很多站点的列表页其实有现成接口,只是接口带了签名参数或者需要特定的 cookie。先尝试直接请求接口,能拿到 JSON 就用 requests 批量抓,性能和稳定性都远好过浏览器渲染。只有当接口被加密、参数无法还原、或者需要真实点击操作时,才值得上 selenium。把 selenium 当成最后手段,不是第一选择。
4.2 用 undetected_chromedriver 抓动态列表
代码包里的动态页面模块用了undetected_chromedriver,它比原生 selenium 更适合抓取场景,能规避一部分自动化特征识别。基础用法并不复杂:
import undetected_chromedriver as uc from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = uc.ChromeOptions() options.user_data_dir = "./chrome_profile" driver = uc.Chrome(options=options) driver.get("https://target.example.com/list") wait = WebDriverWait(driver, 20) wait.until(EC.visibility_of_element_located((By.CSS_SELECTOR, "div.item"))) items = driver.find_elements(By.CSS_SELECTOR, "div.item") for item in items: title = item.find_element(By.CSS_SELECTOR, "h2 a").text print(title)options.user_data_dir = "./chrome_profile"是关键参数,它把浏览器的用户数据目录写到本地磁盘。第一次运行时你在浏览器里手动登录,账号状态会保存在这个目录里;下一次启动uc.Chrome(options=options),登录态自动带上,不用重新扫码或输密码。等待条件用visibility_of_element_located,表示目标元素真正可见才算完成,比presence_of_element_located更适合列表页渲染的场景。
4.3 登录态复用与自动化识别的边界
登录态复用是效率的分水岭。很多数据抓取任务卡在登录墙,requests 直接请求会跳转到登录页,如果硬要绕过登录取数,很容易把时间浪费在验证码上。我一般让 selenium 先把登录全程跑完,再把页面里的 cookie 导出给 requests 的 session 用。代码包里的做法是直接持久化整个user_data_dir,简单粗暴,但可靠。
自动化识别的边界也要心里有数:navigator.webdriver属性、窗口尺寸、鼠标轨迹都可能暴露自动化特征,undetected_chromedriver能降低风险,但不能保证永远不被检测。遇到个别站点盯着不放时,最实际的方案是把浏览器里的真实操作录下来,找到真正的数据接口,绕开渲染这层墙。selenium 是黑匣子,用它解决登录和动态渲染,但不该靠它完成全量抓取——一个页面加载两三秒,抓一万条就是几个小时的差距。
5. 爬虫避坑手册:五个高频故障的排查路径与修复代码
5.1 返回 200 但解析全部为空
现象:请求状态码是 200,日志看起来一切正常,但解析出来 items 列表是空的。
原因:目标页面可能是 JavaScript 动态渲染,requests 拿到的 HTML 只是空壳。也可能是页面内容被转义或嵌套在注释里,选择器根本匹配不上。
解决:先打印响应内容前 500 个字符,确认拿到的是不是预期 HTML。
resp = session.get(url) print(repr(resp.content[:500]))如果是空壳页面,切到 XHR 接口或上 selenium;如果内容在,再调整选择器。这一步能帮你区分是解析问题还是渲染问题,避免在错误的方向上调半天选择器。
5.2 抓几十条后全部变成登录页
现象:前几十条正常,再往下抓,所有接口返回的页面都变成了登录页。
原因:cookie 过期或者触发了站点风控。很多站点会对高频访问的 Session 做标记,一旦发现特征异常,就强制跳登录。
解决:检查响应 URL 是否发生跳转,确认登录页特征。
if "login" in resp.url: print("cookie 过期,需要重新登录")临时方案是把浏览器里的新 cookie 手动复制进 session;长期方案是保存好登录状态后用固定 user_data_dir 持续复用,让 cookie 自动刷新。
5.3 CSV 用 Excel 打开乱码
现象:抓下来的内容用文本编辑器看没问题,用 Excel 打开中文全是乱码。
原因:CSV 文件默认按 UTF-8 写入,Excel 在 Windows 上默认按 GBK 打开,两边编码不一致就乱码。
解决:写 CSV 时用utf-8-sig编码,这是 Excel 最友好的格式。
import csv with open("data.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "price"]) writer.writeheader() writer.writerows(items)utf-8-sig会在文件头部写入 BOM 标记,Excel 看到标记后会自动用 UTF-8 解码。这个坑我自己踩过好几次,后来凡是给业务方看的 CSV,一律默认utf-8-sig。
5.4 触发限流后连续失败
现象:抓取速度一开始稳定,过了一会儿突然开始连续超时或大量报错。
原因:目标站点检测到请求频率过高,对该 IP 或 Session 做了限流。
解决:把固定间隔改成随机间隔,减少请求规律性。
import time import random time.sleep(random.uniform(0.5, 1.5))固定间隔比如每 1 秒一次,反而更容易被识别;随机间隔让请求时间点分布更接近真实用户。这也是血泪经验:第一次跑全量时我没做限速,两千条数据后直接被拒,后来凡是批量任务,强制在请求层加随机延时。
5.5 日志全是重试但程序像卡死
现象:日志里不断输出重试信息,但程序长时间没有进度,看起来像死循环。
原因:读超时设置太长,配合重试后单个 URL 的耗时被放大了好几倍。比如读超时 60 秒,重试 3 次,一个坏 URL 就占 3 分钟。
解决:把读超时调短,并限制重试次数。
session = build_session(retries=2, timeout=(5, 10))如果接口本身要 20 秒才返回,可以把读超时放宽到 30,但重试次数不要超过 2。遇到个别超时 URL,直接跳过比反复等待更划算。
6. 断点续爬与最终验证:用 SQLite 让采集任务可中断
6.1 进度表结构设计
一次性把几万条抓完不太现实,中途断网、进程崩溃、改选择器重启是常态。最常见的后悔药是记录已抓取状态。SQLite 单文件、零服务、免配置,适合中小采集任务,拿它做进度表比写一个done.txt可靠得多。
CREATE TABLE IF NOT EXISTS article ( url TEXT PRIMARY KEY, title TEXT, price TEXT, crawled_at TEXT DEFAULT CURRENT_TIMESTAMP );url做主键是去重的基础。同一地址只抓一次,重启后跑INSERT OR IGNORE自动跳过已抓过的条目。
6.2 断点续爬主流程
import sqlite3 conn = sqlite3.connect("crawl.db") cursor = conn.cursor() for url in url_list: cursor.execute("SELECT 1 FROM article WHERE url = ?", (url,)) if cursor.fetchone(): continue item = fetch_item(url) if not item: continue cursor.execute( "INSERT OR IGNORE INTO article(url, title, price) VALUES (?, ?, ?)", (url, item["title"], item["price"]), ) conn.commit()先查一遍是否已存在,存在就跳过,不存在再抓。每抓一条就commit(),避免进程崩溃时丢失大批进度。如果某次选择器写错了,重启后把表清空或只删指定 url 即可,不用整个重来。
6.3 采集质量自检
抓完别急着交差。先跑三个 SQL 确认质量:总条数、URL 重复数、标题缺失数。
SELECT COUNT(*) FROM article; SELECT COUNT(*) - COUNT(DISTINCT url) FROM article; SELECT COUNT(*) FROM article WHERE title IS NULL OR title = '';从那以后我每次正式采集前,都会先抓两页样本、确认编码和字段缺失率,再放全量跑。数据抓取的工程化无非是让每一步都可验证、可恢复、可重跑,这套代码包帮你把骨架搭好了,剩下的就交给你的目标站去磨合。希望帮到你。
本文还有配套的精品资源,点击获取