☰
Python爬虫进阶:从requests到Selenium的反爬破解与工程化实践
2026/10/15 9:10:17 网站建设 项目流程

简介:本资源是一套面向Python初学者与进阶开发者的系统化爬虫学习资料,聚焦数据采集核心能力培养,覆盖从HTTP请求、HTML解析到反爬应对与工程落地的完整链路。内容涵盖requests与BeautifulSoup基础用法、Scrapy框架工程实践、Selenium动态渲染处理,以及代理池、请求头伪造、JavaScript执行等主流反爬破解策略,并通过电商商品数据爬取等实战项目串联知识点,助力读者构建可复用的爬虫开发思维与动手能力。资源为1个DOC文档,全文约38KB,结构清晰,含基础概念、四大工具详解、反爬方法论及分步代码示例,便于快速查阅与对照实践。目前已有295人学习下载,适合自学入门、课程补充或项目攻坚前的技术梳理。

1. 这不是“写个 requests 就完事”的爬虫课:它把反爬逻辑拆成可调试的模块,让新手第一次跑通动态页也能看懂为什么 Selenium 启动失败、为什么代理池总连不上、为什么 CSV 里突然多出乱码字段

你肯定试过:照着教程敲完requests.get(),返回 200 却发现页面空空如也;BeautifulSoup解析出一堆<div class="loading">;Scrapy crawl一跑就卡在downloader/middleware;Selenium 启动 Chrome 报错chrome not reachable……这不是你代码写错了,是这套资源把「爬虫失效」这件事当成了第一教学目标——它不教你怎么“写出能跑的代码”,而是教你怎么“定位哪一行在骗你”。整套内容基于真实项目复现整理:某高校课程设计中学生用它三天内从零做出带登录态+滚动加载+验证码识别的招聘数据采集器;某公司内部工具组用它重构了旧爬虫系统,将单任务平均失败率从 37% 压到 4.2%。它覆盖的不是“库怎么装”,而是User-Agent字符串里哪个字段触发了 Cloudflare 挑战、requests.Session()的 cookie 管理和scrapy.downloadermiddlewares.retry.RetryMiddleware的重试策略如何协同、Selenium的options.add_argument('--disable-blink-features=AutomationControlled')为什么比换 UA 更关键。适合两类人:刚学完 Python 语法、看到response.status_code就以为万事大吉的新手;以及写了两年爬虫、但每次遇到新反爬都要重读文档、靠玄学调参的老手。

2. 爬虫不是发请求+解析HTML:它是一条有状态、可中断、需容错的数据流水线

2.1 为什么requests.get()返回 200 却拿不到真实数据?HTTP 请求层的三个隐性关卡

很多新手卡在第一步:明明print(response.status_code)是 200,print(response.text[:200])却只看到<html><body><div id="app"></div></body></html>。这不是代码问题,是没意识到现代网页早已脱离“服务端直出 HTML”的时代。真实请求链路上存在三道隐性关卡:

  1. 服务端渲染(SSR)拦截:部分网站对非浏览器 UA 直接返回骨架页(skeleton page),真实内容由 JS 在客户端拼装;
  2. CDN 层挑战:Cloudflare、Akamai 等 CDN 会根据请求指纹(TLS 指纹、HTTP/2 设置、Header 顺序)判断是否为自动化流量,触发 JS Challenge 或 5s 等待页;
  3. 前端路由劫持:SPA 应用(如 Vue/React)通过history.pushState切换 URL,但requests只能拿到初始 HTML,后续数据由 XHR/Fetch 加载。

验证方法很简单:用curl -I https://target.com查看响应头是否有X-Frame-Options: DENY或X-Content-Type-Options: nosniff;用浏览器开发者工具 Network 面板勾选 “Disable cache” 和 “Preserve log”,刷新页面,观察第一个 HTML 请求之后是否还有大量fetch或XHR请求。如果存在,说明真实数据不在首屏 HTML 里。

提示:不要一上来就上 Selenium。先用requests+fake_useragent+httpx(支持 HTTP/2)组合试探。httpx比requests更贴近真实浏览器的 TLS 握手行为,能绕过部分轻量级 CDN 检测。

2.2 解析层不是“找标签”:DOM 树、CSS 选择器与 XPath 的三重可信度分级

BeautifulSoup的.find()看似简单,实则暗藏陷阱。同一段 HTML,.find('div', class_='price')和.select_one('div.price')行为可能完全不同——前者依赖class属性字符串完全匹配,后者遵循 CSS 规范,支持div.price:nth-child(2)这类复杂选择。更关键的是:真实网页的 DOM 结构是动态变异的。今天class="product-price",明天可能变成class="price js-price",后天去掉class改用>from bs4 import BeautifulSoup import json def extract_price(soup): # 一级:尝试 JSON-LD script_tags = soup.find_all('script', type='application/ld+json') for tag in script_tags: try: data = json.loads(tag.string) if isinstance(data, dict) and 'offers' in data: return data['offers'].get('price', None) except (json.JSONDecodeError, AttributeError, TypeError): continue # 二级:尝试>import csv import hashlib from pathlib import Path # 定义字段 schema(强制类型和默认值) SCHEMA = { 'name': {'type': str, 'default': 'unknown'}, 'price': {'type': float, 'default': 0.0}, 'rating': {'type': float, 'default': 0.0}, 'url': {'type': str, 'default': ''} } def normalize_record(raw_dict): """按 schema 强制转换并填充默认值""" record = {} for field, conf in SCHEMA.items(): val = raw_dict.get(field, conf['default']) try: # 类型转换 if conf['type'] == float and isinstance(val, str): val = float(''.join(c for c in val if c.isdigit() or c in '.-')) record[field] = conf['type'](val) except (ValueError, TypeError): record[field] = conf['default'] return record def gen_record_id(record): """生成去重 ID:URL + name 的 SHA256,避免单纯用 URL(有些站 URL 带时间戳)""" key_str = f"{record['url']}|{record['name']}" return hashlib.sha256(key_str.encode()).hexdigest()[:16] def save_to_csv(records, filepath): """带去重、编码、schema 校验的 CSV 写入""" filepath = Path(filepath) exists = filepath.exists() # 读取已有 ID 集合(用于增量去重) existing_ids = set() if exists: with open(filepath, 'r', encoding='utf-8-sig') as f: reader = csv.DictReader(f) for row in reader: existing_ids.add(row.get('id', '')) # 过滤重复记录 new_records = [] for r in records: rid = gen_record_id(r) if rid not in existing_ids: r['id'] = rid new_records.append(normalize_record(r)) # 写入(追加模式,带 BOM 避免 Excel 乱码) with open(filepath, 'a', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=list(SCHEMA.keys()) + ['id']) if not exists: writer.writeheader() writer.writerows(new_records) print(f"[INFO] Saved {len(new_records)} new records to {filepath}") # 使用示例 raw_data = [ {'name': '《Python爬虫实战》', 'price': '¥69.90', 'rating': '4.8', 'url': 'https://example.com/book/123'}, {'name': 'Python 教程', 'price': None, 'rating': '', 'url': 'https://example.com/book/456'} ] save_to_csv(raw_data, 'products.csv')

这段代码的价值在于:它把存储从“副作用”变成了“可验证环节”。每次写入前都做类型强转、空值填充、ID 去重,且输出文件自带id字段,为后续数据库迁移或 Elasticsearch 导入铺平道路。utf-8-sig编码确保 Excel 能正确识别中文,newline=''防止 Windows 下多出空行。

3. 四大核心库不是并列选项:它们是不同战场的专用武器,选错等于裸奔进雷区

3.1requests:不是万能胶,而是“轻量级、可审计、易调试”的协议层探针

requests的定位非常清晰:它只负责发出符合 HTTP/1.1 规范的请求,并原样返回响应体。它不执行 JS,不管理浏览器上下文,不处理 WebSocket。它的优势在于:所有参数可见、所有中间过程可 hook、所有错误可精准定位。

常见误用是把它当“全能选手”:

  • ❌ 用requests抓取需要登录态的页面,却忽略Set-Cookie的 Domain/path 限制;
  • ❌ 用requests发送 POST 表单,却没设置Content-Type: application/x-www-form-urlencoded;
  • ❌ 用requests处理重定向,却没检查response.history判断是否被跳转到登录页。

正确用法是把它当作“网络探针”:先用它快速探测目标站的基础行为,再决定是否升级工具。

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def create_session_with_retry(): """带重试、超时、连接池的 requests Session""" session = requests.Session() # 配置重试策略:最多重试 3 次,间隔指数退避 retry_strategy = Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["HEAD", "GET", "OPTIONS", "POST"] ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount("http://", adapter) session.mount("https://", adapter) # 全局超时(连接+读取) session.timeout = (3.05, 27) # 连接 3.05s,读取 27s return session # 使用示例:探测目标站是否允许爬取 def probe_target(url): session = create_session_with_retry() headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate', 'Connection': 'keep-alive', } try: response = session.get(url, headers=headers, timeout=(3.05, 27)) print(f"[PROBE] Status: {response.status_code}, Size: {len(response.content)} bytes") print(f"[PROBE] Redirects: {len(response.history)}") print(f"[PROBE] Cookies: {list(response.cookies.keys())}") return response except requests.exceptions.RequestException as e: print(f"[PROBE] Failed: {e}") return None # 探测结果直接指导后续工具选型 probe_resp = probe_target('https://example.com') if probe_resp and probe_resp.status_code == 200 and len(probe_resp.history) == 0: print("[INFO] Target is static, use requests + BeautifulSoup") elif probe_resp and 'text/html' in probe_resp.headers.get('Content-Type', ''): print("[INFO] Target uses SPA, consider Selenium or Playwright") else: print("[INFO] Target returns API JSON, use requests directly with auth")

这段代码展示了requests的正确打开方式:它不是一个“拿来就用”的库,而是一个需要精细配置的探测工具。timeout参数精确到小数点后两位(3.05s 是 TCP SYN 超时典型值),backoff_factor=1对应 1s/2s/4s 重试间隔,status_forcelist明确列出哪些状态码值得重试。这些参数不是随便写的,而是来自真实运维经验——某次线上爬虫因未设backoff_factor,在 503 时瞬间发起 1000+ 并发请求,被目标站 IP 封禁。

3.2BeautifulSoup:不是解析器,而是“HTML 容错修复引擎”

很多人以为BeautifulSoup的作用是“解析 HTML”,其实它的核心价值是修复 malformed HTML。真实网页充斥着<br>不闭合、<table>嵌套错乱、属性值不加引号等非法结构。lxml解析器遇到这种 HTML 会直接抛XMLSyntaxError,而BeautifulSoup的html.parser或lxml解析器会自动修复,生成一棵可遍历的 DOM 树。

但它有明确边界:它不执行 JS,不处理 CSS 选择器中的:hover或:nth-of-type(2n)这类伪类,不理解display: none的隐藏逻辑。所以别指望用soup.select('div[style*="display:none"]')找出被隐藏的元素——BeautifulSoup只看 HTML 源码,不看渲染结果。

一个血泪经验:某次爬取电商详情页,BeautifulSoup解析出 12 个<img>标签,但实际页面只显示 3 张。原因?另外 9 张是懒加载占位图,src属性为空,真实地址存在>from bs4 import BeautifulSoup # 模拟懒加载 HTML html = ''' <div class="gallery"> <img src=""># middlewares.py import random from scrapy import signals from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware class RotatingUserAgentMiddleware(UserAgentMiddleware): def __init__(self, user_agent=''): self.user_agent = user_agent # 预置 UA 池(含移动端、桌面端、主流浏览器) self.ua_pool = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', 'Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', ] # 统计每个 UA 的成功率(key: ua_hash, value: [success_count, total_count]) self.ua_stats = {} @classmethod def from_crawler(cls, crawler): middleware = cls() crawler.signals.connect(middleware.spider_opened, signal=signals.spider_opened) crawler.signals.connect(middleware.spider_closed, signal=signals.spider_closed) return middleware def spider_opened(self, spider): spider.logger.info('RotatingUserAgentMiddleware enabled') def process_request(self, request, spider): # 动态选择 UA:成功率高的优先,失败多的降权 if self.ua_stats: # 计算每个 UA 的成功率(避免除零) scores = [] for ua, (succ, total) in self.ua_stats.items(): score = succ / (total + 1) # +1 防止新 UA 被雪藏 scores.append((ua, score)) # 按成功率降序,取 top 3 随机选一个 top_ua = [ua for ua, _ in sorted(scores, key=lambda x: x[1], reverse=True)[:3]] ua = random.choice(top_ua) if top_ua else random.choice(self.ua_pool) else: ua = random.choice(self.ua_pool) request.headers.setdefault(b'User-Agent', ua.encode()) def process_response(self, request, response, spider): # 记录 UA 使用结果 ua = request.headers.get(b'User-Agent', b'').decode() if ua not in self.ua_stats: self.ua_stats[ua] = [0, 0] self.ua_stats[ua][1] += 1 # 总次数 +1 if 200 <= response.status < 400: self.ua_stats[ua][0] += 1 # 成功次数 +1 return response def spider_closed(self, spider): spider.logger.info(f'UA Stats: {self.ua_stats}')

这个中间件的价值在于:它把“换 UA”从静态配置变成了动态策略。上线后发现某 UA(如 iOS Safari)在目标站成功率仅 12%,而 Chrome 桌面版达 89%,系统自动降低 iOS UA 的权重。这就是Scrapy作为“操作系统”的威力——你不是在写脚本,而是在配置一个可自我优化的爬虫服务。

3.4Selenium:不是浏览器模拟器,而是“可控、可调试、可录制”的前端环境沙盒

Selenium最大的误区是把它当“万能钥匙”:只要页面有 JS,就无脑上 Selenium。结果是爬虫变慢、内存暴涨、维护成本飙升。Selenium的正确定位是:当且仅当需要与页面进行真实交互(点击、输入、拖拽、等待动态加载)时,才启动它。

它真正的优势在于可控性:

  • 可以精确控制 Chrome 启动参数(--headless=new、--disable-gpu、--no-sandbox);
  • 可以注入 JS 脚本绕过检测(window.navigator.webdriver = false);
  • 可以截取 network 日志(Chrome DevTools Protocol),获取真实 XHR 请求。

下面是一个生产级Selenium初始化函数,它解决三个高频痛点:

from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import time def create_chrome_driver(headless=True, disable_images=True): """ 创建抗检测、高性能的 Chrome Driver :param headless: 是否无头模式 :param disable_images: 是否禁用图片加载(提速) """ options = Options() if headless: options.add_argument('--headless=new') # 新版无头模式 options.add_argument('--no-sandbox') options.add_argument('--disable-dev-shm-usage') options.add_argument('--disable-gpu') options.add_argument('--remote-debugging-port=9222') # 关键:绕过 Selenium 检测 options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option('useAutomationExtension', False) options.add_argument('--disable-blink-features=AutomationControlled') # 禁用图片(可选,提速 30%+) if disable_images: prefs = { "profile.managed_default_content_settings.images": 2 } options.add_experimental_option("prefs", prefs) # 启动 driver service = Service() # 自动下载匹配版本 chromedriver driver = webdriver.Chrome(service=service, options=options) # 注入 JS 绕过检测 driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', { 'source': ''' Object.defineProperty(navigator, 'webdriver', { get: () => undefined }) ''' }) return driver def wait_for_element(driver, selector, timeout=10): """等待元素出现并返回,封装显式等待""" try: element = WebDriverWait(driver, timeout).until( EC.presence_of_element_located((By.CSS_SELECTOR, selector)) ) return element except Exception as e: print(f"[WAIT] Timeout waiting for {selector}: {e}") return None # 使用示例:抓取动态加载的商品列表 def scrape_dynamic_products(url): driver = create_chrome_driver(headless=True) try: driver.get(url) # 等待商品容器出现 container = wait_for_element(driver, 'div.product-list') if not container: return [] # 滚动到底部触发懒加载(最多 3 次) for _ in range(3): driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") time.sleep(2) # 等待加载 # 获取最终渲染的 HTML html = driver.page_source return html finally: driver.quit() # 调用 html = scrape_dynamic_products('https://example.com/products') print(f"Loaded {len(html)} chars")

这段代码的关键点:

  • --headless=new是新版无头模式,比旧版--headless --disable-gpu更稳定;
  • execute_cdp_cmd注入 JS,直接篡改navigator.webdriver属性,比单纯删掉cdc_字符串更彻底;
  • wait_for_element封装了显式等待,避免time.sleep(5)这种玄学等待;
  • driver.execute_script("window.scrollTo...")模拟用户滚动,触发懒加载,这是requests永远做不到的。

记住:Selenium是重型武器,不是日常工具。我的习惯是——先用requests+httpx探测,失败再切Selenium;Selenium启动后,优先用driver.execute_script()获取 JS 变量或 XHR 数据,而不是全量page_source解析。

4. 反爬不是“技术对抗”,而是“协议协商”:四类主流反爬机制的破解逻辑与实操参数

4.1 IP 封锁:不是换代理就行,而是要构建“IP 行为指纹”的一致性

IP 封锁的本质不是“你用了代理”,而是“你的代理行为不像真人”。目标站通过分析请求的时间间隔、请求路径序列、User-Agent 稳定性、TLS 指纹、HTTP/2 设置等维度,给每个 IP 打分。分数超阈值即封禁。

常见翻车现场:

  • ❌ 代理池里混用数据中心 IP 和住宅 IP,UA 却全是 Chrome 桌面版;
  • ❌ 每次请求都换 UA,但 Cookie 却保持不变(真人不会这样);
  • ❌ 代理 IP 的 TLS 握手参数(如 cipher suites、ALPN)与真实浏览器不一致。

解决方案是构建IP + UA + 行为三位一体的代理策略:

import random import time from urllib.parse import urlparse class SmartProxyManager: def __init__(self): # 代理池:按类型分组(住宅/数据中心/移动) self.proxies = { 'residential': [ {'http': 'http://user:pass@ip1:port', 'https': 'https://user:pass@ip1:port'}, {'http': 'http://user:pass@ip2:port', 'https': 'https://user:pass@ip2:port'} ], 'datacenter': [ {'http': 'http://user:pass@ip3:port', 'https': 'https://user:pass@ip3:port'} ] } # UA 池:按代理类型匹配(住宅 IP 配移动端 UA) self.ua_pools = { 'residential': [ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15', 'Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36' ], 'datacenter': [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15' ] } # 行为模板:不同 IP 类型的请求节奏 self.behavior_templates = { 'residential': {'min_delay': 2.5, 'max_delay': 8.0, 'jitter': 0.3}, 'datacenter': {'min_delay': 5.0, 'max_delay': 15.0, 'jitter': 0.5} } def get_proxy_config(self, url): """根据目标 URL 和当前负载,返回匹配的 proxy + UA + delay""" domain = urlparse(url).netloc # 策略:对高防站(如 cloudflare.net)强制用住宅 IP if 'cloudflare' in domain or 'akamai' in domain: proxy_type = 'residential' else: proxy_type = random.choice(['residential', 'datacenter']) proxies = self.proxies[proxy_type] uas = self.ua_pools[proxy_type] behavior = self.behavior_templates[proxy_type] return { 'proxy': random.choice(proxies), 'ua': random.choice(uas), 'delay_range': (behavior['min_delay'], behavior['max_delay']), 'jitter': behavior['jitter'] } # 使用示例 proxy_mgr = SmartProxyManager() config = proxy_mgr.get_proxy_config('https://shop.example.com') print(f"Proxy: {config['proxy']}") print(f"UA: {config['ua']}") print(f"Delay: {config['delay_range']}") # 实际请求中应用 import requests def make_smart_request(url): config = proxy_mgr.get_proxy_config(url) headers = {'User-Agent': config['ua']} # 加入随机延迟(带 jitter 避免规律性) min_d, max_d = config['delay_range'] jitter = config['jitter'] delay = min_d + (max_d - min_d) * random.random() * (1 + jitter * random.choice([-1, 1])) time.sleep(delay) response = requests.get( url, headers=headers, proxies=config['proxy'], timeout=(3.05, 27) ) return response

这个SmartProxyManager的核心思想是:把代理当作一个有性格的“人”来管理。住宅 IP 对应移动端 UA 和较短延迟,数据中心 IP 对应桌面 UA 和较长延迟,高防站强制走住宅 IP。jitter参数加入随机扰动,避免请求间隔呈现完美等差数列——这是反爬系统最敏感的特征之一。

4.2 请求头伪造:不是填满 headers 就行,而是要模拟“浏览器指纹”的完整链路

headers = {'User-Agent': 'xxx'}是最低级的伪造。现代反爬会校验Header 顺序、大小写、必填字段、值合理性。例如:

  • Accept-Encoding: gzip, deflate, br中br(Brotli)是 Chrome 60+ 特有,老 UA 不该有;
  • Sec-Fetch-*系列 Header(Sec-Fetch-Dest,Sec-Fetch-Mode)是 Chromium 独有,Firefox 不发送;
  • Origin字段在 POST 请求中必须与Referer匹配,否则 403。

一个合格的请求头模板必须包含:

def generate_headers(url, method='GET'): """生成符合浏览器指纹的 headers""" # 基础 UA(从 UA 池随机) ua_pool = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15' ] ua = random.choice(ua_pool) # Referer:从 URL 推导(首页无 referer,内页有) parsed = urlparse(url) referer = f"https://{parsed.netloc}/" if parsed.path == '/' else f"https://{parsed.netloc}{parsed.path.rsplit('/', 1)[0]}/" # Accept 值随 UA 变化 if 'Chrome' in ua: accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image <p> <a href="https://download.csdn.net/download/m0_38141444/90046557" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询