Python爬虫实战:用requests和BeautifulSoup高效抓取网页标题

发布时间:2026/10/24 11:43:16
Python爬虫实战:用requests和BeautifulSoup高效抓取网页标题

简介:面向具备基本编程基础、对 Python 爬虫感兴趣的开发者,文档围绕“抓取网页文章标题”任务展开,系统演示了用 requests 发起 HTTP 请求、BeautifulSoup 解析 HTML,并通过 find_all('h2') 提取标题后保存到本地 txt 文件的完整流程。内容包含库的安装命令、fetch_titles 与 save_titles 函数的逐段讲解、运行实例与结果展示,还提醒读者遵守 robots.txt 与网站使用条款、在请求频繁时加入延时,并讨论如何根据页面结构调整选择器、做好异常处理与数据存储、灵活应对反爬策略等问题,对初学者搭建真实可扩展的爬虫脚本很有参考价值。资源为单个 docx 文档,压缩包大小约 17KB,轻量易读,便于随时查阅对照。已有 176 人学习浏览,适合跟随示例亲手实践,并在此基础上调整目标站点与选择器,逐步构建自己的抓取工具。

1. 从文章标题抓取聊起:一个能跑通的最小爬虫骨架

日常接需求时,我经常遇到“帮我把某几个栏目的文章标题整理成表格”这类活儿。手动复制当然能做,但页面一多就让人崩溃。用 Python 写个爬虫去抓标题,是最快能落地、也最容易让新手建立信心的场景。

这篇文章要拆的是一份标题抓取资源:基于 requests 发起请求,用 BeautifulSoup 解析页面里的标题标签,最后把结果写到本地文本文件。它能解决“批量获取文章列表、快速做内容盘点、给后续数据分析做前置采集”这类具体问题。适合有基础 Python 语法、想上手爬虫但还没系统学过的人,也适合需要快速抄一个简化版采集脚本的熟手。

我会把完整代码、参数含义、适配真实网页的调整方法,以及我实际踩过的几个坑都放出来。读完你能得到一个可以直接改 URL 就跑的脚本,也能知道标题不在预期标签里时该怎么兜底。

2. 最小可用版本:requests 取网页,BeautifulSoup 拆标题

2.1 为什么选 requests + BeautifulSoup,而不是别的组合

做标题抓取这种轻量采集,技术选型的原则是“够用就好”。requests 是 Python 生态里最成熟的 HTTP 客户端,API 简单,get 一下就能拿到响应文本;BeautifulSoup 则是把 HTML 解析成可遍历对象的老牌库,对新手特别友好,查找标签的写法很像是在写自然语言。

有人会问:怎么不用 urllib?我的判断是 urllib 虽然内置,但处理 Cookie、超时、请求头时要写的样板代码更多。还有人会推荐 Scrapy,可抓标题这种单页、低频的任务,上 Scrapy 有点大材小用。requests 加 BeautifulSoup 的组合,代码量最少,调试时哪里出错一眼就能看到,出了问题也容易在社区搜到答案。

这套组合的边界也要说清楚:它适合页面内容在 HTML 源码里直接可见的静态页面。如果目标网站是前端 JS 动态渲染数据,requests 拿到的 HTML 里根本没有标题,那种情况我在第四章会讲怎么绕。

2.2 最小可运行版本:完整代码与参数说明

先看一个能直接跑的脚本。它做的事很简单:请求指定 URL,找到页面里所有<h2>标签,提取文本,打印出来并保存。

import requests from bs4 import BeautifulSoup def fetch_titles(url): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } try: response = requests.get(url, headers=headers, timeout=10) except requests.RequestException as e: print(f"请求失败: {e}") return [] if response.status_code != 200: print(f"页面返回异常状态码: {response.status_code}") return [] soup = BeautifulSoup(response.text, 'html.parser') title_tags = soup.find_all('h2') return [tag.get_text(strip=True) for tag in title_tags] def save_titles(titles, filename="titles.txt"): with open(filename, "w", encoding="utf-8") as f: for title in titles: f.write(title + "\n") if __name__ == "__main__": url = "https://example.com" titles = fetch_titles(url) if titles: print(f"共抓取 {len(titles)} 个标题") save_titles(titles) print("已保存到 titles.txt") else: print("未获取到标题")

这段代码里有几个参数值得单独说。

headers里的User-Agent是标识客户端类型的字符串。很多网站会拦截不带 UA 的请求,或者直接返回 403,加了这一项能伪装成普通浏览器,降低被拒概率。timeout=10是请求超时时间,单位是秒,超过这个时间还没响应就主动放弃,避免脚本卡死。

response.status_code != 200是状态码检查。200 表示正常,其他常见状态码比如 302 是重定向、404 是地址不存在、403 是禁止访问。这一层判断虽然简单,但能让你在第一时间知道是网站拒绝了你,还是解析逻辑出了问题。

soup.find_all('h2')返回所有符合条件的标签对象;get_text(strip=True)提取标签内的纯文本,strip=True会去掉首尾空白和换行。这里有个限制:它默认只认<h2>,真实网站的标题结构未必如此,第三章细说。

2.3 从控制台输出到本地落盘:文件处理的细节

很多入门教程会在控制台打印完就结束,但实际采集场景里,结果往往要交给下游使用,所以落盘是刚需。上面代码里我用了open(filename, "w", encoding="utf-8"),其中"w"模式表示覆盖写入,每次运行都会重建文件。如果希望多次运行结果不互相覆盖,可以改成"a"追加模式。

encoding="utf-8"这一项不能省。Windows 下 Python 默认编码可能是 gbk,写中文标题时会直接 UnicodeEncodeError,导致程序中断。指定 utf-8 之后,文件内容也能被大多数编辑器和后续处理工具正常读取。

写文件时我习惯在每条标题末尾加\n,保证每个标题占一行。如果你后续想把标题导入 Excel,这个格式可以直接用 Excel 的“分列”功能处理。如果想存成 CSV,可以用 csv 模块替代直接写文件,但对纯标题场景,文本文件已经足够清晰。

3. 适配真实网页:标题不在 h2 里时怎么定位

3.1 先用浏览器开发者工具确认标题的真实结构

把示例代码往真实网站上一跑,最常见的情况就是“脚本没报错,但标题列表是空的”。原因几乎都是同一个:目标网站的标题标签根本不是<h2>。

我一般的做法是,先用浏览器打开目标页面,按 F12 打开开发者工具,点左上角的箭头图标,再点击页面上的任意一个标题,DevTools 会直接高亮对应的 HTML 元素。这一步能精确看到标题包在什么标签里、带什么 class、有没有嵌套结构。

比如有的网站标题是<h1 class="article-title">,有的是<a class="title-link">,还有一些是<h3>搭配内层<a>。拿到真实结构后,再回头改爬虫的查找语句。这个过程是整个适配工作的核心:不要猜网页结构,要看结构。

3.2 多选择器兜底:从 find_all 到 select

确定真实结构之后,可以用更精准的方式定位。下面这段代码演示了如何通过 CSS 选择器查找标题,并做多套选择器兜底。

def fetch_titles_flexible(url): headers = {"User-Agent": "Mozilla/5.0"} response = requests.get(url, headers=headers, timeout=10) soup = BeautifulSoup(response.text, "html.parser") # 按优先级尝试不同的选择器,哪个命中用哪个 selectors = [ "h2", "h3 a", "a.title-link", ".article-title" ] for selector in selectors: elements = soup.select(selector) if elements: print(f"使用选择器 {selector} 命中 {len(elements)} 个标题") return [el.get_text(strip=True) for el in elements] return []

soup.select接收 CSS 选择器,h3 a表示 “h3 内部的 a 标签”,.article-title表示class="article-title"的标签。按顺序逐个尝试,哪个先找到结果就用哪个,这种兜底策略能应对同一站点不同页面结构不完全一致的情况。

get_text(strip=True)在嵌套结构下会把子标签里的文字也拼进来。如果标题里混有分类名、日期等其他信息,你要在循环里加上过滤逻辑,比如只保留长度在 5 到 100 字之间的文本。这一步没有统一标准,完全看目标页面的实际结构。

3.3 去重、排序与乱码处理

页面标题可能出现重复,比如置顶文章在列表里出现两次。文本范围抓取时,通常需要去重。最简单的方式是转换为集合再变回列表:

def clean_titles(titles): seen = set() result = [] for title in titles: title = title.strip() if title and title not in seen: seen.add(title) result.append(title) return result

这段代码通过一个set记录已出现的标题,既去掉了空字符串,也保留了第一次出现的顺序。很多列表页的标题本身就是按时间倒序排列的,所以我不建议在做完解析后轻易重新排序,保持页面原始顺序往往更符合业务直觉。

乱码是另一个高频问题。requests拿到响应后会用response.encoding去解码,但某些网站的响应头没声明编码,或声明的编码与实际不符,就会出现满屏–之类的乱码。这时可以采用以下策略:

response.encoding = response.apparent_encoding

apparent_encoding是从响应内容里推测出的编码,准确率通常不错。但注意,用它之后要在实际项目中多做几个页面的验证,因为推测不是百分百可靠。建议在脚本里先打印response.encoding和response.apparent_encoding,做个对比,再决定用哪种方式解码。

4. 标题爬虫避坑:四个我翻过车的真实问题

4.1 访问被拒,返回 403 Forbidden

现象:运行脚本后,控制台输出页面返回异常状态码: 403,一个标题都没抓到。

原因:绝大部分情况是请求没有带 User-Agent。网站服务器认出了请求不是来自真实浏览器,直接拒绝了。另一个可能是请求频率太高,被服务器临时封禁。

解决:先加上模拟浏览器的请求头,这是最简单的止损手段。如果还不行,就把目标网站的robots.txt找出来看一眼,路径通常是域名/robots.txt,确认你要抓的路径没有被明确禁止。再不行就把请求之间的间隔拉长,加入time.sleep(2)这类延时。遇到 403 我从不死磕单次请求,优先从“伪装程度”和“请求频率”两个方向同时调整。

4.2 页面源码里根本没有标题

现象:代码不报错,状态码是 200,但soup.find_all('h2')返回空列表,打印结果为空。

原因:目标页面是前端框架动态渲染的,标题数据由 JavaScript 在浏览器端发起异步请求,然后渲染到页面上。requests 拿到的只是空壳 HTML,里面没有实际数据。

解决:优先去浏览器的开发者工具里看 Network 面板,找到返回标题数据的 XHR 或 Fetch 请求。直接请求那个数据接口,通常能拿到结构更干净的 JSON 数据,这也比解析 HTML 更稳定。如果接口加密复杂,才考虑用自动化工具,但那是更重的方案,对入门场景来说,找接口是性价比最高的路径。

4.3 中文标题乱码成谜

现象:标题内容变成新闻一类看不懂的字符。

原因:response.encoding从响应头读到的编码与页面实际使用的编码不一致。常见于没有声明 charset 的站点,或者声明了 gbk 但实际是 utf-8 的页面。

解决:用我在第三章提到的response.apparent_encoding兜底。更稳妥的做法是检查一下 HTML 里<meta charset=...>标签的实际声明,手动设置响应编码。比如页面是 GBK 编码,就写response.encoding = "gbk",再继续解析。这类问题没有万能钥匙,遇到乱码就多打印几个响应头信息,能少走弯路。

4.4 请求太频繁,账号或 IP 被限制

现象:脚本连续运行一段时间后,突然所有请求都返回 503 或验证码页面,要求输入验证码才能继续。

原因:短时间内的请求数量超过了网站设定的阈值,被识别为爬虫行为。

解决:降低请求频率,加入随机延时而不是固定延时。比如time.sleep(random.uniform(1, 3)),模拟人工操作的自然间隔。对于大量页面,可以分批跑,每批之间停一段时间。此外,采集数据也要尊重目标网站的条款,明确禁止采集的内容不要强行突破。我是把它当成长期运行脚本的基本礼仪来看的。

5. 把脚本加固成可复用小工具:重试机制、增量保存与参数调节

5.1 请求重试:应对网络抖动与临时限制

网络请求很难做到百分百成功,断网、服务器 5xx、超时都可能导致单次抓取失败。与其失败后手动重跑,不如在脚本里内置重试逻辑。

import time import random import requests from requests.adapters import HTTPAdapter def fetch_with_retry(url, headers, retries=3, timeout=10): session = requests.Session() session.mount("https://", HTTPAdapter(max_retries=retries)) try: response = session.get(url, headers=headers, timeout=timeout) response.raise_for_status() return response.text except requests.RequestException as e: print(f"第 {retries} 次重试后仍失败: {e}") return None

这里用requests.Session配合HTTPAdapter设置最大重试次数,raise_for_status()会在状态码为 4xx 或 5xx 时主动抛出异常,触发重试逻辑。注意,重试不是无脑重复,初次请求失败后,下一次请求前等待一个随机间隔,能避免在服务器过载时立刻加重负担。我会建议重试次数设为 3,超过 3 次直接把 URL 记录下来,等这一轮跑完再单独处理。

5.2 增量保存:只抓新标题,不重复落盘

当脚本需要定时运行时,全量覆盖写入会丢掉之前抓到的标题,追加写入则会产生大量重复。这时增量保存是更实用的策略。做法是先读取本地已有的标题集合,再过滤掉已经存在的部分。

def load_existing_titles(filename="titles.txt"): try: with open(filename, encoding="utf-8") as f: return set(line.strip() for line in f if line.strip()) except FileNotFoundError: return set() def incremental_save(titles, filename="titles.txt"): existing = load_existing_titles(filename) new_titles = [t for t in titles if t not in existing] with open(filename, "a", encoding="utf-8") as f: for title in new_titles: f.write(title + "\n") return len(new_titles)

load_existing_titles把文件里已有的标题读成一个集合,incremental_save先计算差值,再把新标题追加写入文件,返回值是新写入的数量。这个函数可以直接接在上一章的去重逻辑后面,防止同一标题被反复保存。用追加模式"a"而不是覆盖模式,是为了保住历史数据。

5.3 参数调节清单:超时、间隔、编码、请求头

最后整理一份我在实际使用中会反复调整的参数清单。网上很多现成代码跑不动,多数是因为这些参数没有针对目标页面做适配。

参数常见取法作用
timeout10~30 秒避免请求长时间无响应导致脚本挂起
请求间隔随机 1~3 秒降低服务器压力,防止封禁
encoding页面 meta 声明或 apparent_encoding解决中文乱码
User-Agent本机浏览器的 UA 字符串提高请求通过率
选择器浏览器 DevTools 实测得到的 CSS 选择器精确定位标题

抓标题这类任务,真正花时间的往往不是写爬虫,而是调这些参数。比如我上次做一个列表页采集,标题标签有三种情况,我先后试了五个选择器才确认了稳定的定位方式。从那以后,我每次写爬虫都强制自己先打开浏览器看结构,再决定代码怎么写。建议你也保留这个习惯,能省掉不少排查时间。希望这篇文章能帮你顺利抓到自己想要的内容。

本文还有配套的精品资源,点击获取