尧图精选

Python爬虫实战:requests与BeautifulSoup优雅抓取全站名言及作者数据

🕒 发布时间:2026/10/1 4:32:13 📁 来源:尧图网络
前两天朋友问我说是想把自己喜欢的名人名言整理成一本“语录集”手动一条条复制太累问我有没有现成的工具。我说工具倒是有但与其找一个可能不好用的导出功能不如直接写个 Python 爬虫把整个网站的名人名言和作者数据一口气抓下来干净、可控、还顺手练了技术。今天就把这个“优雅地抓取全站名人名言与作者数据”的实战过程完整复盘一下从页面分析到数据落地每一个坑和每一个选择背后的原因都讲清楚。这篇内容适合刚学完 Python 基础、想真正上手 requests BeautifulSoup 练手的人也适合那些已经有爬虫经验但想梳理全站爬取思路的朋友。1. 项目拆解先想清楚“全站”到底要爬什么很多人一上来就写代码结果爬到一半发现页面结构变了、翻页逻辑不对、数据重复一大堆最后越改越乱。我的习惯是先花半小时把目标网站从头到尾点一遍把页面间的关系画清楚再动手写第一行代码。1.1 明确数据字段与页面结构以常见的名言类网站为例这类站点的结构通常非常相似首页或分类页列出名言的标题、摘要、作者名和链接。详情页包含名言完整正文、作者介绍、标签分类等信息。我这次的目标是抓取“励志”“爱情”“哲理”三个主要分类下的全部名言每一条需要保存名言正文、作者名字、作者朝代或国籍如果有、名言所属分类、名言详情页 URL。先点上几个分类页观察 URL 规律。大多数情况下类别和页码都会体现在查询参数或者路径里比如/mingyan/lizhi/1、?page2这类形式。把 URL 规律摸清楚后面写翻页循环就非常省事。1.2 反爬环境判断别急着拿代码硬刚在动手之前我用浏览器开发工具F12看了一个关键信息目标站点有没有明显的反爬措施。判断维度很简单是否校验 User-Agent几乎所有站点都会是否有登录态 / Cookie 校验页面数据是服务端渲染还是接口动态加载有没有频率限制或 WAF 拦截我快速测了一下这个站点本身是服务端渲染的 HTML 页面没有 JS 动态加载只要带上合理的请求头、控制访问频率就能爬。这种定位很重要决定了技术选型——不需要上 Selenium 或 Playwright用 requests 就够了。我这里提一句经验如果一个站点需要登录才能看内容或者核心数据是靠接口动态返回的那 requests 直爬的成本会高很多要么模拟登录要么抓包接口要么直接上浏览器自动化。先做判断再选方向才是高效的路径。1.3 技术选型为什么是 requests BeautifulSoup现在的爬虫方案很多requests、httpx、Scrapy、Selenium、Playwright 都有人用。我这次选的是最经典的requests BeautifulSoup组合理由有三个目标页面是静态 HTML不需要模拟浏览器渲染requests 完全可以胜任。BeautifulSoup 的 API 对新手友好find_all、select清晰直观调试起来很舒服。这种组合足够轻量不需要额外部署浏览器环境在服务器上也跑得动。不要一上来就上大件。工具越复杂出问题的地方越多。只要能完成需求简单方案永远是最优雅的方案。2. 环境准备与请求基础选定方案之后先把环境捋顺。我用的仍是 Python 3.10系统环境是 Windows但下面的操作在 macOS 和 Linux 上完全一致。2.1 安装依赖与初始化项目我的做法是先创建虚拟环境再安装依赖。新手最容易犯的错就是直接往全局环境装包装一堆之后版本冲突项目挪到别的机器又跑不起来。# 创建虚拟环境 python -m venv venv # 激活环境Windows venv\Scripts\activate # 激活环境macOS/Linux source venv/bin/activate # 安装依赖 pip install requests beautifulsoup4 lxml我把lxml也装上是因为 BeautifulSoup 在解析 HTML 时指定lxml解析器比默认的html.parser快不少容错性也更好。安装完之后我习惯建一个spider.py作为主入口再建一个config.py放所有可以调整的配置项请求头、延迟时间、URL 列表等。把配置和逻辑分开后续调参数的时候不用翻大段代码找起来很快。2.2 请求头伪装别让服务器一眼认出你是爬虫很多站点默认会拦截带python-requests标识的请求。虽然这是一种很基础的反爬但基础伪装还是要做。我在config.py里写了一个完整的请求头# config.py HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.example.com/ }这段代码的核心作用是把请求伪装成正常浏览器访问。User-Agent 一定要用浏览器的真实标识别用111.111.111.111这种测试 UA 去请求真实站点很容易被识别并拒绝。另外我把Accept-Language也加上了因为有些站点会根据语言头返回不同版本的内容加上这个能减少编码判断的干扰。2.3 Session 管理保持连接减少握手开销如果只是抓一两个页面直接requests.get()就够了。但我们要做全站爬取页面数量上百甚至上千每次都新建连接会导致明显的性能浪费。我选择用requests.Session()来复用底层的 TCP 连接import requests from config import HEADERS session requests.Session() session.headers.update(HEADERS)Session 会把请求头保存在会话中后续所有请求都会自动带上这些头。在连续抓取多个页面时这种方式能显著减少重复握手的时间消耗而且如果你需要维持登录态Session 也能自动保存 Cookie。实测下来同一批页面上爬取用 Session 比直接调用requests.get快大约 30%。3. 核心爬虫实现逐层解析页面数据环境就绪之后进入重头戏从列表页到详情页的完整解析链路。这一部分是整个项目的核心我会拆成四步讲清楚每一步的调试方法也会附上。3.1 列表页解析拿到名言的基础条目先看列表页的结构。典型的名言列表页会包含多个条目每个条目通常有名言摘要作者名字详情页链接我用浏览器开发者工具快速定位了 HTML 结构发现每条名言都处于一个div.quote容器里正文在span.text下作者在small.author下详情链接在a的href属性中。代码实现思路如下from bs4 import BeautifulSoup def parse_list_page(html): soup BeautifulSoup(html, lxml) items [] for quote_div in soup.select(div.quote): text_node quote_div.select_one(span.text) author_node quote_div.select_one(small.author) link_node quote_div.select_one(a) if text_node and author_node and link_node: items.append({ text: text_node.get_text(stripTrue), author: author_node.get_text(stripTrue), detail_url: link_node[href], }) return items这里有两个关键细节优先用 CSS 选择器select_one和select比套好几层find_all直观。文本用get_text(stripTrue)去空白因为网页源码里经常有换行和缩进不去的话存进数据库会出现一堆\n。写完第一步后我强烈建议先打印一下解析结果不要急着写全站逻辑。看三五行输出确认文本、作者、链接都对得上再往后推进。3.2 详情页字段提取丰富名言背后的作者信息列表页拿到的信息比较粗真正要落库的数据还是在详情页。详情页一般会多出作者介绍、名言分类标签、热度或收藏数甚至有些站会有译注。这一步的关键是定位详情页里的信息容器。我用soup.select_one(div.quote-content)拿到正文容器然后再往里找作者简介、标签。def parse_detail_page(html, base_url): soup BeautifulSoup(html, lxml) # 详情页正文 content soup.select_one(div.quote-content) text content.get_text(stripTrue) if content else # 作者信息 author_box soup.select_one(div.author-desc) author_desc author_box.get_text(stripTrue) if author_box else # 标签 tags [tag.get_text(stripTrue) for tag in soup.select(a.tag)] # 构造完整 URL from urllib.parse import urljoin full_url urljoin(base_url, detail_path) return { text: text, author_desc: author_desc, tags: |.join(tags), url: full_url, }这里用到了urljoin它的好处是当详情链接是相对路径比如/quote/12345.html时能自动拼接成完整的绝对 URL。不要自己手动拼字符串因为有些链接可能带/前缀、有些不带拼接规则不同很容易出错。3.3 翻页逻辑与全站遍历策略列表页一般都有分页常见的 URL 规律是/mingyan/lizhi_2.html或/mingyan/lizhi/?page2。判断总页数的方式有两种页面底部有“最后一页”的链接找到它的数字编号。通过总条数和每页条数计算上限。我这次的目标站是第一种所以先解析出最后一页的页码然后循环遍历def get_total_pages(soup): pagination soup.select_one(div.pagination) if not pagination: return 1 # 找到所有页码数字取最大值 page_nums [int(a.get_text()) for a in pagination.select(a) if a.get_text().isdigit()] return max(page_nums) if page_nums else 1 def crawl_category(category_url): total 0 for page in range(1, get_total_pages(first_page_soup) 1): page_url f{category_url}_{page}.html html fetch_page(page_url) items parse_list_page(html) for item in items: detail fetch_and_parse_detail(item[detail_url]) save_to_database(detail) total 1 # 控制节奏 time.sleep(random.uniform(0.5, 1.5)) return total这里有个重要的节奏控制每抓一个页面或一条详情至少停顿 0.5~1.5 秒。不是服务器承受不住而是去硬怼一个无名网站没有任何意义从容一点才能走得远。随机延迟比固定延迟更好因为固定间隔的模式更容易被服务端的访问频率检测识别。3.4 数据清洗别把脏数据带进数据库做过两次真实项目之后我越来越确定一件事爬虫的时间一半在写代码另一半在洗数据。从 HTML 里拿到的字段经常存在这些问题正文里有特殊符号中文引号被转义成实体、连续空白作者名字前后有各种不可见字符标签里混入废话词我写了一个清洗函数统一处理import re def clean_text(text): if not text: return # 将乱码的 \xa0 替换为普通空格 text text.replace(\xa0, ) # 多个空白折叠为一个 text re.sub(r\s, , text) # 替换常见全角标点为半角可按需保留 text text.replace(“, ).replace(”, ) return text.strip()清洗规则不要做太狠尤其是标点符号保存成数据时尽量保留原文风格不然以后做文本分析可能会出歧义。我的原则是只处理对存储和后续使用有实际影响的脏字符不做过度改写。4. 数据存储从 CSV 到 SQLite 的落地路径爬虫抓下来的数据最后总得有个去处。有人直接打印到终端完事但既然做的是全站数据存储方案的取舍还是要认真考虑一下。4.1 先落 CSV / JSON快速验证数据完整性我第一版代码总是先输出 CSV。原因很简单CSV 可以用 Excel 直接打开预览能非常直观地发现字段错位、缺失、乱码等问题。JSON 则适合保结构化的嵌套数据比如标签列表。写 CSV 的时候有一个老坑中文乱码。如果你不指定编码默认可能是gbk或系统编码存出来的文件打开就是乱码。正确姿势是import csv def save_to_csv(items, filenamequotes.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[text, author, tags, url]) writer.writeheader() writer.writerows(items)这里用utf-8-sig而不是utf-8是因为 Excel 打开 UTF-8 文件时会多出一个 BOM 头的问题用utf-8-sig可以避免首行乱码。这个小细节很多新手都要踩一次才知道。4.2 引入 SQLite增量去重才是全站爬虫的关键CSV 适合小规模数据但全站爬下来至少几千条CSV 的去重和查询能力就很弱了。我这次爬到一半就发现重复数据不少——有的名言在“励志”和“哲理”分类下都有入口详情页也会出现略微不同的版本。SQLite 是单机场景最合适的选择不需要额外服务Python 内置sqlite3模块开箱即用。建表和插入逻辑我这样写import sqlite3 def init_db(db_pathquotes.db): conn sqlite3.connect(db_path) conn.execute( CREATE TABLE IF NOT EXISTS quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, author TEXT, author_desc TEXT, tags TEXT, url TEXT UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() return conn def insert_quote(conn, quote): try: conn.execute( INSERT OR IGNORE INTO quotes (text, author, author_desc, tags, url) VALUES (?, ?, ?, ?, ?), (quote[text], quote[author], quote[author_desc], quote[tags], quote[url]) ) conn.commit() except sqlite3.IntegrityError: # URL 重复说明已经存在跳过 pass这里有两个点值得展开url TEXT UNIQUE是天然的去重键。同一个详情页的理论 URL 是唯一的重复爬到时直接忽略。INSERT OR IGNORE本质上是靠唯一约束干掉了重复记录比先查后插的效率高很多。这个思路对全站爬虫特别有用——因为你永远无法保证自己的翻页逻辑里没有交叉入口。4.3 字段设计面向后续使用反推存储格式我的字段表除了基本内容外特意加了created_at时间戳。为什么因为如果后续你打算做数据分析比如统计哪个作者被收录的语录最多、分类之间的数量分布有一个入库时间字段会方便做增量更新和排错。还有一点是标签字段的存储格式。我用|分隔符把多个标签拼成一个字符串而不是单独建一张关联表。虽然规范化设计可能更标准但单机小项目里简单字段带来的便利远超范式收益。要在“标准”和“实用”之间找平衡爬虫项目偏向后者。5. 反爬与稳定性优雅爬虫必须过的三道坎全站爬取不等于暴力请求真正做到“优雅”就要有策略地处理反爬机制和网络波动。这一部分我不讲那些绕开服务器防护的灰色手法纯粹从正向工程的角度聊稳定性。5.1 请求频率控制与随机延迟我在前面的代码里已经写了time.sleep(random.uniform(0.5, 1.5))这里单独拿出来讲是因为频率控制实在太重要了。爬虫请求频率过高后果不只是被封 IP更严重的是给对方服务器造成不必要的压力。一个正常的站点爬虫的访问频率最好不高于一个真实用户的行为模型——正常人看一页要几秒钟你就别做到每秒几十个请求。更细的做法是“分布式延迟”把延迟打散到不同的数值区间而不是固定一个数import random import time def polite_sleep(): # 0.8 ~ 2.5 秒随机 time.sleep(random.uniform(0.8, 2.5))实测下来这种随机延迟既保证了总进度不会太慢又让访问节奏更加自然。5.2 异常重试机制网络波动是常态网络请求失败太常见了超时、连接重置、SSL 错误都有可能出现。如果你不处理异常爬虫会在某个请求失败时直接崩溃之前的进度全部白费。我给fetch_page加了一层重试封装from time import sleep def fetch_page(url, max_retries3, timeout10): for attempt in range(1, max_retries 1): try: resp session.get(url, timeouttimeout) if resp.status_code 200: resp.encoding resp.apparent_encoding or resp.encoding return resp.text elif resp.status_code in (403, 429): # 被限流了退避更久 sleep(attempt * 5) else: sleep(2) except requests.RequestException as e: print(f[重试 {attempt}/{max_retries}] {url} 失败{e}) sleep(attempt * 2) raise RuntimeError(f连续失败{url})核心逻辑是遇到 403/429限流时重试等待时间线性拉长而不是继续用正常节奏请求。遇到普通超时用attempt * 2秒的间隔做指数退避的基础版。这里也说一下编码问题。我在响应里设置了resp.encoding resp.apparent_encoding or resp.encoding有些站点响应头没给出正确 charset导致后面解析中文全乱。用apparent_encoding自动检测可以规避绝大多数编码问题。不过检测也有失败概率如果乱码就要手动指定比如resp.encoding gbk。5.3 断点续爬中断之后不用从头再来做全站爬虫经常会遇到跑到一半手动终止、或者服务器突然拒绝的情况。如果没有断点机制重新爬会浪费几十分钟还可能因为重复插入把数据库搞乱。我的做法很简单在 SQLite 里记录已经成功爬取的详情页 URL 集合。启动时先加载已存在的 URL 到一个集合爬取前先判断如果 URL 在集合里就跳过。关键在于def load_done_urls(conn): cur conn.execute(SELECT url FROM quotes) return set(row[0] for row in cur.fetchall()) def crawl_category(category_url, done_urls): ... for item in items: if item[detail_url] in done_urls: continue detail fetch_and_parse_detail(item[detail_url]) insert_quote(conn, detail)这样就实现了天然的断点续爬中断之后重新启动已经入库的 URL 会被自动跳过进度直接续上。不用写复杂的 checkpoint 文件数据库本身就可以作为状态记录器。6. 实战避坑实录我在这类项目里踩过的坑这一部分不是文档里的理论而是实际操作里真实遇到的问题。拿出来单独成章是因为它们几乎每一个都能让新手卡壳半天。6.1 列表页和详情页的解析器不统一刚开始我只在列表页用了lxml解析器详情页解析时直接写成了soup BeautifulSoup(html)省略了解析器参数。结果某个详情页的 HTML 写法不规范时解析结果时好时坏间歇性丢数据。排查了半天发现是默认的html.parser容错能力和lxml相差太大。修法很简单所有 BeautifulSoup 实例化都必须显式指定解析器。这类问题最难排查因为不是稳定复现而是页面结构触发。6.2 Python 字典 Key 拼写错误导致字段丢失爬虫代码里经常出现多个字典互相嵌套。我有一次在构造item时把detail_url拼成了detail_urls列表页解析完全正常但详情页抓取函数拿不到detail_url抛 KeyError。这种错误用眼睛看很难发现最好在开发阶段给每个函数写一个极简的单元测试assert parse_list_page(sample_html)[0][detail_url]用断言去约束入参出参一个小技巧能省很多调试时间。6.3 全文网页编码不一致有些站点不是所有页面都是 UTF-8尤其是老旧站点的某些分类页采用的是其他编码。我的第一次全站爬取在爬到第三类时突然出现大量乱码就是因为只在前几个页面手动指定了编码。后来统一改成apparent_encoding自动检测并且打印检测到的编码信息用于人工复核。实测中apparent_encoding对绝大多数页面都能给出正确的编码只有极少数带特殊字符的页面需要人工兜底。6.4 图片链接、时间字段这些“附加题”的处理名人名言页面除了文本数据常有一些附加内容比如作者头像、名言配图。如果你要抓这些资源需要额外处理图片下载和云存储这一部分我这次没做但提一下方向requests.get(url).content可以拿二进制流存成文件后把本地路径或对象存储 URL 存入数据库。如果后续要发布成 API 或做成网页应用图片数据会比纯文本复杂一个量级需要另行设计存储方案。7. 多线程改造优雅提速的最后一公里单线程爬虫跑完整个站可能要二十分钟在某些场景下可以接受但如果你想做个常跑的全站数据更新任务多线程就是绕不开的话题。这一部分算扩展我只讲一个比较稳妥的线程池方案。Python 的concurrent.futures.ThreadPoolExecutor相比threading手写线程代码量少很多控制也清晰from concurrent.futures import ThreadPoolExecutor, as_completed def crawl_all(category_pages): done_urls load_done_urls(conn) with ThreadPoolExecutor(max_workers4) as executor: futures [] for page_url in category_pages: futures.append(executor.submit(process_page, page_url)) for future in as_completed(futures): try: future.result() except Exception as e: print(f任务执行异常{e})线程数我建议先控制在 2~4 个不要一上来就开十几线程。爬虫的速度瓶颈通常不在本地而在目标服务器的响应时间。线程太多反而容易触发限流最后整体进度反而比单线程还慢。多线程配合上面的随机延迟每个线程各睡各的访问节奏会更自然。另外多线程下写入 SQLite 需要注意锁问题。我的做法是给写入函数加一个threading.Lock()在insert_quote外面包一层锁避免多线程同时写库导致database is locked错误。import threading write_lock threading.Lock() def safe_insert(conn, quote): with write_lock: insert_quote(conn, quote)这样一个简单的锁就能解决 99% 的写入冲突问题。最后再分享一个真实感受爬虫这个技术实现“能跑”非常容易但要实现“稳定、干净、不打扰别人”的优雅版本需要打磨的地方比想象中多。我之前跑这类全站项目时总会在收尾阶段做一次完整的数据抽查随机挑 20 条记录核对原文和数据库是否一致。这个习惯强烈建议大家保留——数据质量是后续一切应用的地基地基不稳上面盖什么都白搭。把这一套流程跑通之后你得到的不仅仅是几千条名言数据更是一套可以迁移到任意同类站点的通用爬虫方法论。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →