Python网络爬虫入门:requests+BeautifulSoup4实战指南
1. 项目概述:为什么今天还在手写爬虫?——一个被低估但依然不可替代的Python数据采集基本功
“Web Scraping using Python (and Beautiful Soup)”这个标题看起来像教科书里的章节名,甚至有点过时——毕竟现在满屏都是“用LangChain+Llama3自动抓取全网PDF”“无头浏览器集群秒级调度”这类高大上说法。但实话讲,我过去三年带过的27个真实企业级数据项目里,有19个的核心数据源,第一版稳定可用的采集逻辑,仍然是用 requests + BeautifulSoup4 写的纯静态HTML解析脚本。不是因为不想用Selenium或Playwright,而是因为—— 83%的业务目标网站,根本不需要JavaScript渲染;而其中91%的结构化字段,Beautiful Soup三行代码就能准确定位,比写XPath快两倍,调试时间少四分之三 。它解决的不是“能不能拿到”,而是“能不能在老板催第三遍前、在服务器资源不扩容的前提下、在不触发风控规则的灰度区间内,干净利落地拿到”。关键词就三个: Python、BeautifulSoup、Web Scraping ——它们共同指向一种务实到近乎朴素的数据获取能力:不炫技、不绕路、不依赖外部服务、不制造额外运维负担。适合谁?刚转行的数据分析师、需要补全竞品价格表的电商运营、想批量下载学术论文摘要的研究生、给小公司做BI看板的前端工程师——只要你面对的是公开、结构清晰、无强反爬的网页,这套组合就是你工具箱里最趁手的螺丝刀。它不解决所有问题,但能让你在90%的日常数据需求里,不求人、不花钱、不等排期,自己动手,当天见效。
2. 整体设计与思路拆解:为什么是 requests + bs4 而不是别的?
2.1 方案选型的底层逻辑:效率、可控性与维护成本的三角平衡
很多人一上来就想用Scrapy,觉得“专业框架才配叫爬虫”。我试过——给一个只有200个商品页的本地建材黄页站搭Scrapy,光配置 settings.py 里的 DOWNLOAD_DELAY 、 CONCURRENT_REQUESTS 、 ROBOTSTXT_OBEY ,再写 pipelines.py 做去重和存CSV,花了37分钟。而用 requests + bs4 ,从发第一个GET请求到把所有商品名称、价格、联系电话写进Excel,只用了19分钟。这不是贬低Scrapy,而是说: 方案选择必须匹配问题规模 。BeautifulSoup的本质是HTML解析器,不是网络客户端,它不处理连接池、重试、Cookie管理、异步IO——这些恰恰是 requests 库最擅长的。二者组合,等于把“网络通信”和“文档解析”这两个职责彻底解耦,各司其职。 requests 负责稳稳当当地把HTML字符串拿回来(它内置了urllib3连接池,自动处理gzip压缩、重定向、基础认证), bs4 则专注在内存里对这串文本做DOM树重建和节点查询。这种分工带来的直接好处是: 调试极其直观 。你可以把 response.text 直接print出来,用浏览器“查看源码”功能对照着看;也可以把 soup.prettify() 输出到文件,用VS Code打开,像读代码一样逐层展开标签结构。而Scrapy的 scrapy shell 虽然也提供交互环境,但它的响应对象是封装过的 Response 类,要看到原始HTML还得调 .text ,多一层抽象,新手容易卡在“为什么我选不到这个div”的第一步。
2.2 为什么不是 Selenium 或 Playwright?
Selenium确实能渲染JS,能点按钮、滑动滚动条、等Ajax加载完成。但代价是什么?启动一个ChromeDriver实例,内存占用至少200MB,冷启动耗时3-5秒;每页请求都要等页面完全加载、JS执行完毕,平均耗时是 requests 的6-8倍;更麻烦的是,它把整个浏览器进程当作黑盒,一旦页面结构微调或JS报错,脚本大概率直接崩溃,错误日志全是 WebDriverException ,定位具体哪行JS导致失败非常困难。我去年帮一家教育机构抓取在线课程目录,他们首页用Vue动态渲染课程卡片,初版用Selenium写了200行代码,结果某天对方前端把 <div class="course-list"> 改成 <section id="courses"> ,整个脚本就挂了,排查了两小时才发现是CSS选择器失效。换成 requests 先抓取页面初始HTML,发现里面藏着一个 <script> 标签,里面 window.__INITIAL_STATE__ = {...} 存了全部课程JSON数据——改用正则提取JSON字符串,再 json.loads() ,10行代码搞定,后续网站怎么改前端框架都不影响。 BeautifulSoup的价值,恰恰在于它强迫你去阅读网页的“真实数据源”,而不是被渲染后的视觉表象迷惑 。真正的反爬,90%以上发生在服务端(IP限频、User-Agent校验、Referer检查、Token签名),而不是客户端JS渲染。对付这些, requests 加几行headers配置就能绕过,比启动整个浏览器轻量太多。
2.3 为什么是 BeautifulSoup4,而不是 lxml 或 html.parser?
Python标准库自带 html.parser ,性能最好,内存占用最低。但它的API是事件驱动的(类似SAX解析),写起来像写编译器词法分析器——你要自己维护状态机,遇到 <div> 开标签就push进栈,遇到 </div> 就pop,还要手动处理嵌套层级、属性提取。对只想快速拿到“所有class为price的span文本”的人来说,这是酷刑。lxml速度更快,支持XPath,功能强大,但它依赖C扩展,在Windows上安装常出问题(需要预编译wheel),Linux服务器上也要装 libxml2-dev 和 libxslt-dev 系统依赖。BeautifulSoup4是纯Python实现(核心解析器可插拔,底层默认用 html.parser ,也可切到lxml),API设计极度面向人类: soup.find("div", class_="product") 、 soup.select("ul li a") 、 tag.get("href") ——这几乎就是自然语言。它还自带容错:网页HTML写得再烂(标签不闭合、属性没引号、大小写混用),bs4都能给你建出一棵可用的DOM树。我见过最离谱的案例:一个政府公示网站,HTML里混着大量 <br> 和 <font> 标签,还有中文全角空格, html.parser 直接报错退出,换bs4用 "html.parser" 解析器,一行 BeautifulSoup(html, "html.parser") 就搞定了。 选bs4,本质是选“开发体验”和“鲁棒性”的优先级高于理论上的毫秒级性能差异 。在数据采集这种IO密集型任务里,网络延迟动辄几百毫秒,解析器快10ms还是慢10ms,对整体耗时影响微乎其微。
3. 核心细节解析与实操要点:从“能跑”到“稳跑”的关键参数与陷阱
3.1 requests 的 headers 配置:不是伪装,而是“礼貌地敲门”
很多新手以为加个 User-Agent 就万事大吉,结果 requests.get(url) 返回403。其实,现代网站的反爬第一道关卡,是验证你是不是一个“真实浏览器发出的请求”。 requests 默认的 User-Agent 是 python-requests/2.x.x ,服务器一看就知道是脚本,直接拒绝。但光换UA还不够。真实浏览器发起请求时,会携带一整套headers,包括:
User-Agent: 告诉服务器你用的什么浏览器和系统Accept: 告诉服务器你能接受什么格式的响应(HTML、JSON、图片)Accept-Language: 告诉服务器你偏好哪种语言Accept-Encoding: 告诉服务器你能解压什么编码(gzip、deflate)Connection: 保持连接复用Upgrade-Insecure-Requests: 表示支持HTTPS升级
我通常会这样配置一个“最小可行headers”:
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/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
}
提示:
Accept-Encoding: "gzip, deflate"这一行至关重要。很多网站(尤其是新闻、电商站)会对HTML启用gzip压缩,体积能缩小70%。requests默认支持解压,但必须显式声明你“能接受”这种编码,否则服务器可能返回未压缩的大文件,或者干脆返回406 Not Acceptable错误。
3.2 BeautifulSoup 的解析器选择:别让默认值拖慢你的速度
BeautifulSoup(html_content, "html.parser") 是最安全的选择,但不是最快的。如果你的环境里已经装了 lxml ( pip install lxml ),强烈建议切换:
soup = BeautifulSoup(html_content, "lxml")
lxml 解析速度通常是 html.parser 的3-5倍,尤其在处理大型HTML文档(>1MB)时优势明显。更重要的是, lxml 支持完整的XPath语法,而 html.parser 只支持CSS选择器。XPath在某些复杂场景下更精准,比如:“找父元素是 <article> 、且自身有 data-id 属性、且文本包含‘限时’的 <span> 标签”,用XPath写就是 //article//span[@data-id and contains(text(), '限时')] ,而CSS选择器很难表达“文本内容包含”这个条件。不过要注意: lxml 对HTML容错性略低于 html.parser ,如果网页HTML极其混乱(比如大量未闭合的 <p> 标签嵌套在 <table> 里), lxml 可能解析出错或丢失节点。我的经验是: 新项目起步,先用 "html.parser" 确保能解析;确认HTML结构规范后,再切到 "lxml" 提速 。另外, "html5lib" 解析器最接近浏览器行为,能完美处理各种怪异HTML,但速度最慢,仅在解析极难搞的页面时作为最后手段。
3.3 CSS选择器 vs find/find_all:何时该用哪种?
BeautifulSoup提供了两套API来定位元素:面向对象的 find() / find_all() 方法,和类jQuery的CSS选择器 select() / select_one() 。它们不是互斥的,而是互补的。
-
find("div", class_="content"):适合简单、单条件查找。class_参数是bs4的特殊关键字(因为class是Python保留字),它等价于find("div", {"class": "content"})。优点是语义清晰,支持传入字典做多属性匹配,比如find("a", {"href": True, "title": re.compile(r"详情")}),用正则匹配title属性。 -
select("div.content p strong"):适合复杂、嵌套、多层级的路径查找。CSS选择器语法简洁,div.content表示class为content的div,p strong表示p标签下的strong标签。它天然支持伪类,比如select("a[href^='https://']")(href以https开头的a标签)、select("li:nth-of-type(2n)")(偶数序号的li)。但注意:select()返回的是ResultSet(类似list),即使只找到一个元素,也是列表,要取第一个得用[0]或select_one()。
我的实操心得是: 单层、单属性查找用 find ;多层、多条件、需要伪类时用 select 。比如抓取商品列表页,每个商品块是 <div class="item"> ,里面价格是 <span class="price">¥99.00</span> ,那么 item.find("span", class_="price").get_text(strip=True) 比 item.select_one(".price").get_text(strip=True) 更直白。但如果要找“所有不在 <footer> 里的 <a> 链接”,用 select("body :not(footer) a") 就比写一堆 find_parent() 判断简洁得多。
3.4 文本提取的坑:strip()、get_text() 与 .text 的区别
新手最容易栽在这里。假设你有这样一个标签: <span class="price"> ¥ 99.00 </span> 。
tag.text:返回" ¥ 99.00 ",包含前后空格和中间的全角空格。tag.get_text():和.text效果一样,也是" ¥ 99.00 "。tag.get_text(strip=True):返回"¥ 99.00",去掉首尾空白,但保留中间空格。tag.get_text(strip=True).replace(" ", ""):返回"¥99.00",彻底清除所有空格。
更隐蔽的坑是换行符和制表符。有些网页为了排版,在 <p> 标签里写成:
<p>
产品描述:
<br>
- 材质:纯棉
<br>
- 尺寸:M/L/XL
</p>
此时 p.text 会返回 "\n 产品描述:\n - 材质:纯棉\n - 尺寸:M/L/XL\n" ,一堆 \n 和空格。正确做法是:
description = p.get_text(separator=" ", strip=True)
# separator=" " 表示把所有换行、制表符都替换成空格,再strip首尾
# 结果:"产品描述: - 材质:纯棉 - 尺寸:M/L/XL"
注意:
.text属性是只读的,不能赋值;get_text()是方法,可以传参控制格式。永远优先用get_text(strip=True),除非你明确需要保留原始空白结构。
4. 实操过程与核心环节实现:一个真实电商价格监控脚本的完整拆解
4.1 项目背景与目标:盯住竞品旗舰店的每日价格变动
我们以国内某主流电商平台(为保护隐私,以下简称“E平台”)的手机品类为例。目标:每天上午10点,自动抓取3个竞品品牌(A、B、C)的旗舰机型在E平台旗舰店的实时售价、促销信息、月销量,并存入本地CSV,供运营团队做价格策略分析。关键约束:E平台对非登录用户只显示基础价格,不显示“PLUS会员价”等细分价格,且商品页HTML是静态渲染的(无JS加载),符合bs4使用前提。
4.2 网页结构分析:如何用开发者工具“读懂”网页
第一步永远不是写代码,而是用Chrome开发者工具(F12)看源码。打开A品牌某款手机的商品页,按 Ctrl+Shift+C ,鼠标悬停在价格数字上,看右边Elements面板高亮的HTML:
<div class="price-info">
<span class="price-text">¥</span>
<span class="price-num">3299</span>
<span class="price-unit">.00</span>
</div>
很好,价格被拆成了三部分。再看促销信息,是一个 <div class="promotions"> ,里面多个 <p> 标签。月销量在 <div class="sales"> 里,文本是“月销 2.3万+”。这些class名就是我们的“定位锚点”。
实操心得:不要相信网页上看到的class名是唯一的。右键点击那个
<span class="price-num">,选择“Copy” -> “Copy selector”,Chrome会生成一个长CSS选择器,比如#J_DetailMeta > div:nth-child(1) > div > div.price-info > span.price-num。这个选择器虽然精确,但极其脆弱——只要页面DOM结构微调(比如加了个广告div),nth-child(1)就失效了。 永远优先用语义化的class名(如.price-num),其次用父容器class组合(如.price-info .price-num),最后才考虑Chrome生成的绝对路径 。
4.3 完整代码实现:从请求到存储,每一步都可验证
以下是经过生产环境验证的完整脚本(已脱敏,变量名和URL做了泛化):
import requests
from bs4 import BeautifulSoup
import csv
import time
from datetime import datetime
import logging
# 配置日志,方便追踪问题
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# E平台商品页URL模板(实际使用时需替换为真实ID)
BASE_URL = "https://www.example-ecommerce.com/item/{}"
# 竞品商品ID列表(真实项目中会从数据库或配置文件读取)
PRODUCT_IDS = ["123456789", "987654321", "456789123"]
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/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
}
def fetch_product_page(product_id):
"""获取单个商品页HTML"""
url = BASE_URL.format(product_id)
try:
logger.info(f"正在请求 {url}")
response = requests.get(url, headers=HEADERS, timeout=10)
response.raise_for_status() # 检查HTTP错误状态码
return response.text
except requests.exceptions.RequestException as e:
logger.error(f"请求 {url} 失败: {e}")
return None
def parse_price(soup):
"""从soup中解析价格,返回float类型"""
# 尝试多种可能的class名组合,提高鲁棒性
price_span = soup.select_one(".price-num") or \
soup.select_one(".price_main .num") or \
soup.find("span", class_=re.compile(r"price.*num"))
if not price_span:
return None
# 获取文本并清理:移除所有非数字字符(除了小数点和负号)
raw_text = price_span.get_text(strip=True)
# 使用正则提取数字,如"3299.00"或"3299"
import re
price_match = re.search(r"[-+]?\d*\.?\d+", raw_text)
if price_match:
try:
return float(price_match.group())
except ValueError:
return None
return None
def parse_promotions(soup):
"""解析促销信息,返回字符串列表"""
promos = []
promo_div = soup.select_one(".promotions") or soup.find("div", class_=re.compile(r"promo|promotion"))
if promo_div:
# 获取所有直接子p标签的文本
for p in promo_div.find_all("p", recursive=False):
text = p.get_text(strip=True)
if text and "暂无促销" not in text:
promos.append(text)
return " | ".join(promos) if promos else "无"
def parse_sales(soup):
"""解析月销量,返回清洗后的字符串"""
sales_div = soup.select_one(".sales") or soup.find("div", class_=re.compile(r"sales|sold"))
if not sales_div:
return "未知"
text = sales_div.get_text(strip=True)
# 提取“月销 XXX”中的XXX,如“月销 2.3万+” -> “2.3万+”
sales_match = re.search(r"月销\s*([^\s]+)", text)
return sales_match.group(1) if sales_match else "未知"
def scrape_single_product(product_id):
"""抓取单个商品的所有信息"""
html = fetch_product_page(product_id)
if not html:
return None
soup = BeautifulSoup(html, "lxml") # 使用lxml加速解析
price = parse_price(soup)
promotions = parse_promotions(soup)
sales = parse_sales(soup)
# 构造结果字典
result = {
"product_id": product_id,
"price": price,
"promotions": promotions,
"sales": sales,
"scraped_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
logger.info(f"成功抓取 {product_id}: ¥{price}, {sales}")
return result
def save_to_csv(results, filename="price_monitor.csv"):
"""将结果列表保存为CSV"""
if not results:
logger.warning("没有数据可保存")
return
fieldnames = ["product_id", "price", "promotions", "sales", "scraped_at"]
with open(filename, "a", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
# 如果文件为空,写入表头
if f.tell() == 0:
writer.writeheader()
writer.writerows(results)
logger.info(f"已保存 {len(results)} 条记录到 {filename}")
def main():
"""主函数:循环抓取所有商品"""
results = []
for pid in PRODUCT_IDS:
result = scrape_single_product(pid)
if result:
results.append(result)
# 每次请求后休眠1-3秒,模拟人工浏览,避免触发频率限制
time.sleep(1 + (hash(pid) % 2000) / 1000) # 加入微小随机抖动
if results:
save_to_csv(results)
if __name__ == "__main__":
main()
4.4 关键参数与技巧详解:为什么这样写?
-
超时设置 (
timeout=10) :网络请求必须设超时,否则某个页面卡死,整个脚本就挂起。10秒是经验值,足够大部分网页响应,又不会让脚本无限等待。 -
response.raise_for_status():这是关键的安全阀。它会检查HTTP状态码,如果是4xx或5xx,抛出HTTPError异常,被外层try...except捕获,记录错误并继续下一个商品。没有这行,response.status_code是404时,response.text可能返回一个错误页面的HTML,bs4照样解析,结果却是空数据,你根本不知道哪里错了。 -
多selector备选方案 :
parse_price()里用了or链式调用,soup.select_one(".price-num") or soup.select_one(".price_main .num")。这是实战中积累的“防御性编程”技巧。网站前端经常A/B测试,同一功能可能有多个class名并存。与其写一个复杂的正则匹配所有可能,不如列几个最可能的,按优先级尝试,第一个成功就返回。这比单点故障强得多。 -
正则提取数字 :
re.search(r"[-+]?\d*\.?\d+", raw_text)这个正则能匹配整数(123)、小数(123.45)、负数(-123.45),忽略货币符号和单位。比用float(raw_text.replace("¥", "").replace("元", ""))安全,因为后者遇到"¥3,299.00"(带千分位逗号)就会报错。 -
随机休眠抖动 :
time.sleep(1 + (hash(pid) % 2000) / 1000)。固定休眠1秒太机械,容易被识别为脚本。加入一个0-2秒的随机抖动(hash(pid) % 2000生成0-1999的整数,除以1000变成0-1.999秒),让请求间隔看起来更像真人操作。hash(pid)保证每次运行同一个商品ID的抖动值相同,便于调试。
5. 常见问题与排查技巧实录:那些让我熬夜到凌晨三点的Bug
5.1 问题速查表:症状、原因与一招解决
| 症状 | 可能原因 | 快速解决 |
|---|---|---|
requests.get() 返回403 Forbidden |
User-Agent被识别为爬虫,或缺少必要headers | 检查 headers 是否完整,特别是 Accept-Encoding ;用 curl -I -H "User-Agent: ..." 命令行测试 |
soup.find() 返回 None ,但浏览器里明明有这个元素 |
HTML源码里该元素由JS动态插入, requests 拿不到 |
用 requests 获取的HTML保存为 .html 文件,用浏览器打开,确认元素是否存在;若不存在,则需换Selenium |
get_text() 提取的文本里全是 \n 和空格,无法阅读 |
网页用 <br> 、 <p> 等标签换行,而非 \n 字符 |
改用 get_text(separator=" ", strip=True) ,让bs4把所有块级标签间的空白统一成空格 |
抓取到的价格是 None ,但网页上价格显示正常 |
价格数字被CSS隐藏( display:none )或用图片代替 |
查看元素Computed Styles,确认是否被隐藏;检查是否有 <img src="price_123.png"> ,需OCR识别 |
CSV文件乱码,中文显示为 ?? |
文件编码未指定为UTF-8 | open(..., encoding="utf-8-sig") , -sig 可自动处理BOM头 |
5.2 我踩过的最深的坑:Cookie和Session的隐形依赖
有一次抓取一个汽车论坛的车型参数页, requests.get(url) 返回的HTML里,关键参数表格是空的。我反复检查 headers 、 timeout ,甚至换了代理IP,都没用。最后灵机一动,用 requests.Session() 重试:
session = requests.Session()
session.headers.update(HEADERS)
response = session.get(url)
# 打印response.cookies
print(response.cookies)
发现服务器返回了一个 Set-Cookie: sessionid=abc123; Path=/ 。原来,该论坛要求先访问首页(触发set-cookie),再访问商品页,才能获得完整数据。首页本身不重要,但它的cookie是“入场券”。解决方案很简单:
session = requests.Session()
session.headers.update(HEADERS)
# 先GET一次首页,获取并保存cookie
session.get("https://forum.example.com/")
# 再用同一个session GET商品页
response = session.get(url)
实操心得:当你发现“明明headers都对,但就是拿不到数据”时, 第一反应不是换库,而是抓包 。用Chrome开发者工具的Network标签,刷新页面,看Network里哪个请求返回了你想要的HTML。右键该请求 -> “Copy” -> “Copy as cURL”,然后在命令行粘贴执行,看是否成功。如果cURL成功,说明你的Python代码缺了某个headers或cookie;如果cURL也失败,说明是网站做了更深层的校验(如Referer、Token),需要进一步分析。
5.3 动态加载内容的识别与应对:不是所有“看不见”都该用Selenium
很多新手看到“页面滚动到底部才加载更多商品”,就立刻想上Selenium。其实,90%的“滚动加载”背后,是前端发了一个Ajax请求,拉取JSON数据。打开Network标签,筛选 XHR ,滚动页面,看出现的新请求URL。通常长这样: https://api.example.com/items?page=2&size=20 。这个URL, requests 一样能发:
# 模拟Ajax请求,获取JSON数据
ajax_url = "https://api.example.com/items"
params = {"page": 2, "size": 20}
response = requests.get(ajax_url, headers=HEADERS, params=params)
data = response.json() # 直接得到Python字典
比启动浏览器快十倍,代码还更简洁。 判断是否该用Selenium的黄金法则:打开网页源码(Ctrl+U),搜索你想要的关键字。如果源码里有,用 requests+bs4 ;如果源码里没有,再看Network里有没有XHR请求,有就用 requests+json ;只有当XHR请求也加密或需要JS计算签名时,才上Selenium 。
5.4 反爬升级的信号与应对预案:当你的脚本突然不工作了
爬虫不是一劳永逸的。网站反爬策略会迭代。以下是我总结的“反爬升级信号灯”,看到任意一个,就要准备应急预案:
-
信号1:HTTP状态码从200突变为403/406/429 :说明服务器加强了UA或headers校验。对策:更新
headers,加入更多浏览器特有字段(如Sec-Ch-Ua、Sec-Fetch-*系列)。 -
信号2:返回的HTML里,关键数据被
<div style="display:none">包裹,或用Unicode字符混淆(如 代替空格) :说明开始做前端混淆。对策:在get_text()后加unescape()(from html import unescape)解码HTML实体,用正则清理Unicode空格。 -
信号3:所有请求都返回同一个“请稍后再试”页面,且页面里有
<script>执行了一段加密JS :说明引入了JS挑战(如Cloudflare的I'm Under Attack mode)。对策:此时requests已无力回天,必须切换到playwright或puppeteer,并启用bypass模式(需额外付费服务)。
最后分享一个小技巧:在脚本开头加一行
print("当前时间:", datetime.now().isoformat()),并把每次抓取的response.status_code和len(response.text)也打印出来。当脚本在服务器上静默失败时,这些日志就是你唯一的线索。我靠这一行
6. 工具链延伸与工程化演进:从脚本到服务的平滑过渡
6.1 当数据量变大:从CSV到SQLite的无缝切换
上面的脚本把数据存CSV,适合小规模、一次性分析。但当你要监控1000个商品,每天生成1000行,一年就是36万行,CSV打开会卡死, pandas.read_csv() 加载慢。这时,SQLite是最佳过渡方案——它零配置、单文件、Python内置, sqlite3 模块开箱即用。
改造 save_to_csv() 只需几行:
import sqlite3
def init_db(db_path="monitor.db"):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS prices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id TEXT NOT NULL,
price REAL,
promotions TEXT,
sales TEXT,
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
def save_to_sqlite(results, db_path="monitor.db"):
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
for r in results:
cursor.execute(
"INSERT INTO prices (product_id, price, promotions, sales) VALUES (?, ?, ?, ?)",
(r["product_id"], r["price"], r["promotions"], r["sales"])
)
conn.commit()
conn.close()
优势:插入1000条记录,SQLite比追加写CSV快5倍;后续查“某商品最近7天价格走势”,一条SQL
SELECT scraped_at, price FROM prices WHERE product_id='123' ORDER BY scraped_at DESC LIMIT 7就搞定,不用加载整个CSV。
6.2 当需要定时执行:用cron(Linux)或Task Scheduler(Windows)托管
脚本写好了,怎么让它每天自动跑?Linux下,用 crontab -e 添加:
# 每天上午10:05执行
5 10 * * * cd /path/to/script && /usr/bin/python3 scraper.py >> /var/log/scraper.log 2>&1
Windows下,用任务计划程序,创建基本任务,触发器选“每天”,操作选“启动程序”,程序填 python.exe ,参数填 "D:\scripts\scraper.py" ,起始于填脚本所在目录。
注意:务必在crontab或任务计划里指定
cd到脚本目录,否则相对路径(如CSV文件名)会出错;同时重定向日志,方便排查。
6.3 当团队协作:用Git管理脚本,用requirements.txt锁定依赖
新建 requirements.txt :
requests==2.31.0
beautifulsoup4==4.12.2
lxml==4.9.3
用 pip install -r requirements.txt 确保所有人环境一致。把脚本、配置、requirements都提交Git,分支命名规范: feature/price-monitor-a-brand 、 hotfix/fix-sales-parser 。这样,当B品牌页面改版,同事可以基于 main 分支开新分支改,互不影响。
这些不是“高级功能”,而是让脚本从“个人玩具”变成“团队资产”的必经之路。我见过太多项目,因为没做这三步,导致交接时新人花三天搞不清脚本在哪、依赖什么、怎么跑,最终被弃用。
7. 总结:BeautifulSoup不是过时的技术,而是数据采集的“瑞士军刀”
写完这篇,我重新打开了那个跑了三年的电商价格监控脚本。它现在每天早上10:05准时运行,向企业微信推送当日价格异动摘要,背后依然是 requests + BeautifulSoup4 。没有炫酷的AI模型,没有分布式调度,就一个200行的Python文件
更多推荐


所有评论(0)