
1. 从“能跑”到“能活”爬虫工程师的日常困境干了这么多年爬虫我越来越觉得写一个能跑起来的爬虫大概只占整个工作量的20%。剩下的80%都是在和各种各样的“失败”作斗争。你可能刚写完一个脚本测试时数据刷刷地来感觉良好但一旦放到服务器上定时跑或者加大请求量各种幺蛾子就来了页面结构变了、IP被封了、请求被重定向到一个验证页面、返回的数据是乱码或者干脆就是空……这些才是爬虫工程师的日常。很多人学爬虫都是从requests.get()和BeautifulSoup开始的教程里展示的永远是那个完美的、结构清晰的静态页面。但现实是你面对的是一个动态的、充满防御的、随时可能变化的网络环境。你的爬虫不是在真空中运行而是在和网站的运维、风控系统进行一场持续的、静默的“对话”。这场对话的失败就体现在我们常说的“爬取失败”上。今天我们不谈高深的分布式架构或机器学习反破解就扎扎实实地聊聊那些最常见、最烦人、也最消耗时间的爬取失败问题。我会结合我这些年踩过的坑把这些问题归类并给出经过实战检验的解决方案和排查思路。无论你是刚入门的新手还是已经写过不少爬虫的开发者希望这些经验能帮你少走些弯路让你的爬虫不仅“能跑”更能“活得久”。2. 网络层失败连接都建不起来谈何爬取网络层的问题是最底层的通常表现为根本连不上目标服务器或者连接极不稳定。这是所有问题的第一步如果这一步都过不去后面的解析、存储都无从谈起。2.1 连接超时与拒绝连接这是最经典的错误之一。你的代码可能报requests.exceptions.ConnectTimeout,requests.exceptions.ConnectionError或者更底层的socket.timeout。原因深度剖析目标服务器过载或宕机这是最直接的原因。对于小型或个人站点尤其常见。本地网络问题你的代理配置错误、防火墙阻拦、DNS解析失败。对方服务器的反爬机制一些网站会对短时间内来自同一IP的过多连接请求直接拒绝Connection Reset这是一种比较粗暴但有效的防御。请求频率过高即使服务器没挂你的脚本如果以毫秒级间隔疯狂请求也极易触发操作系统的TCP连接限制或服务器的连接数限制。实战排查与解决首先别急着改代码用手动测试来缩小范围。# 使用curl测试基本连通性 curl -I https://target-website.com # 如果慢或超时加个超时参数和详细输出 curl -v --connect-timeout 10 https://target-website.com如果curl也失败那基本是网络环境或目标服务器的问题。如果curl成功而你的Python脚本失败问题就在脚本环境。在代码层面永远不要使用默认的超时设置。requests的默认超时是None意味着它会一直等下去这在生产环境是灾难。import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry # 方案一为单个请求设置超时连接超时和读取超时 try: # (连接超时 读取超时) response requests.get(https://example.com, timeout(3.05, 27)) except requests.exceptions.Timeout: # 处理超时逻辑比如记录日志、重试 print(请求超时) # 方案二配置会话Session级别的重试策略更推荐 session requests.Session() retry_strategy Retry( total3, # 总重试次数 backoff_factor1, # 重试等待时间增长因子 (0.5, 1, 2, 4, ...秒) status_forcelist[429, 500, 502, 503, 504], # 遇到这些状态码也重试 allowed_methods[GET, POST] # 只对GET/POST方法重试 ) adapter HTTPAdapter(max_retriesretry_strategy) session.mount(http://, adapter) session.mount(https://, adapter) # 现在使用这个session发起请求会自动处理重试 response session.get(https://example.com, timeout5)经验之谈timeout参数的值需要根据目标网站响应速度和你的网络状况调整。对于国内网站(3, 10)可能就够了对于某些海外慢速站点可能需要(10, 30)。backoff_factor是体现“礼貌”的关键它让重试间隔逐渐变长避免在服务器恢复期继续施压。2.2 SSL证书验证错误错误信息常为requests.exceptions.SSLError。这在爬取一些老旧网站或配置不当的网站时常见。原因深度剖析网站证书已过期或自签名很多内部系统或测试环境使用自签名证书。证书链不完整服务器配置问题。本地系统时间错误如果系统时间偏差太大证书有效期校验会失败。中间人攻击检测如果你使用了某些抓包工具如Fiddler, Charles并安装了其根证书但脚本未正确配置也会报错。解决方案最粗暴的方法是verifyFalse但这会完全禁用SSL验证存在安全风险且会收到警告。# 不推荐但有时的确最快 response requests.get(https://example.com, verifyFalse) # 会警告InsecureRequestWarning更稳妥的做法是指定一个自定义的CA证书包或者忽略特定错误。import ssl import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # 全局禁用警告 # 或者创建一个自定义的未验证的上下文适用于自签名证书 custom_ssl_context ssl.create_default_context() custom_ssl_context.check_hostname False custom_ssl_context.verify_mode ssl.CERT_NONE # 将这个上下文传递给requests (需要搭配适配器) from requests.adapters import HTTPAdapter from urllib3.poolmanager import PoolManager class InsecureSSLAdapter(HTTPAdapter): def init_poolmanager(self, *args, **kwargs): kwargs[ssl_context] custom_ssl_context return super().init_poolmanager(*args, **kwargs) session requests.Session() session.mount(https://, InsecureSSLAdapter()) response session.get(https://internal-site.com)注意verifyFalse或自定义不验证的上下文仅限用于你完全信任的、非敏感数据的爬取场景比如公司内网测试环境。爬取公开的、重要的商业网站尽量不要使用以免潜在的数据篡改风险。3. 应用层失败服务器说“不”当网络连接建立成功服务器也响应了但返回的不是你想要的数据而是各种HTTP状态码或奇怪的页面内容这就进入了应用层失败的范畴。3.1 4xx 客户端错误403 Forbidden这是反爬的“明星”状态码。意味着服务器理解请求但拒绝执行。常见原因缺乏必要的请求头如User-Agent,Referer,Cookie、IP被封、请求频率过高触发了风控。排查先用浏览器正常访问目标页面打开开发者工具F12的Network标签查看一个成功请求所携带的所有Headers并逐一在你的爬虫中模拟。重点是User-Agent伪装成浏览器、Referer表明来源页面、Cookie维持会话。对于登录后才能访问的页面Cookie是必须的。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, Referer: https://www.google.com/, Accept-Language: zh-CN,zh;q0.9,en;q0.8, # 其他在浏览器中观察到的Headers } session.headers.update(headers) # 更新到session404 Not Found页面不存在。可能是你的URL拼写错误或者目标页面已被移除。更狡猾的情况是网站根据你的请求参数或Headers动态返回404来反爬。解决方案仔细核对URL特别是那些带有查询参数?后面的URL。对于动态URL尝试在浏览器中访问一次复制完整的URL。429 Too Many Requests这是明确告诉你“请求太快了”的状态码。是网站速率限制Rate Limiting的友好提示。解决方案必须降低请求频率。加入随机延迟。import time import random def request_with_delay(url, session): # 随机延迟1-3秒模拟人类操作 time.sleep(random.uniform(1, 3)) return session.get(url) # 或者更精细地根据服务器返回的响应头来决定等待时间 # 有些API会在响应头中返回 Retry-After: 60 response session.get(url) if response.status_code 429: retry_after int(response.headers.get(Retry-After, 60)) print(f被限速等待 {retry_after} 秒) time.sleep(retry_after) # 然后重试3.2 5xx 服务器错误500 Internal Server Error服务器内部错误。这通常不是你的错是网站后端出了问题。你的脚本需要做的是优雅地处理并重试。结合前面提到的重试机制 (Retry)对500状态码进行重试。502 Bad Gateway / 503 Service Unavailable / 504 Gateway Timeout这些通常意味着服务器的负载均衡器、后端服务或网关出现了问题。处理方式和500类似加入重试和更长的等待。一个关键技巧区分“真错误”和“假错误”。有些网站即使正常返回数据状态码也是200但数据体里可能是一句“访问过于频繁”的HTML或JSON。因此解析响应内容前先检查状态码再检查内容里是否包含错误关键词。response session.get(url) if response.status_code 200: # 初步成功但需要检查内容 if 访问频率过高 in response.text or Verification in response.text: print(触发反爬返回了验证页面) # 触发反爬处理逻辑如更换IP、等待更长时间 else: # 真正成功开始解析 parse_data(response.text) else: # 处理非200状态码 handle_http_error(response.status_code)4. 内容解析失败拿到了“盒子”里面却是空的这是爬虫开发中最令人沮丧的情况之一请求成功了状态码200你也拿到了返回的HTML或JSON字符串但当你兴冲冲地开始用XPath、CSS选择器或正则表达式去提取数据时却发现什么都抓不到或者抓到的全是乱码。4.1 动态渲染内容JavaScript加载现代网站大量使用JavaScript尤其是Vue, React, Angular等框架在客户端动态渲染内容。你用requests获取到的初始HTML只是一个空壳或加载骨架真实数据是通过后续的AJAX/Fetch请求获取并填充的。如何判断浏览器中查看页面源代码CtrlU。如果源代码里没有你想要的数据比如商品列表、评论内容但浏览器渲染的页面里有那基本就是JS动态加载的。在浏览器开发者工具的Network标签中过滤XHR或Fetch请求刷新页面观察哪些请求返回了JSON格式的数据这些通常就是数据接口。解决方案直接调用数据接口首选找到那个返回结构化数据通常是JSON的AJAX请求URL。直接模拟这个请求往往比渲染整个页面高效得多。你需要分析这个请求的Headers可能包含认证Token、签名、参数可能有时戳、加密参数。工具如curl命令复制为Python requests代码片段非常有用。使用无头浏览器当接口参数过于复杂、加密难以破解时使用Selenium、Playwright或Puppeteer来模拟真实浏览器操作。from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options webdriver.ChromeOptions() options.add_argument(--headless) # 无头模式不显示浏览器窗口 options.add_argument(--disable-gpu) driver webdriver.Chrome(optionsoptions) try: driver.get(https://dynamic-website.com) # 等待某个特定元素加载出来代表页面渲染完成 element WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, .product-list)) ) # 此时再获取页面源码 html driver.page_source # ... 用BeautifulSoup或lxml解析html finally: driver.quit()注意无头浏览器资源消耗大、速度慢只应作为最后手段。优先分析接口。4.2 页面结构变更你的爬虫昨天还好好的今天突然就解析不到数据了。十有八九是网站前端更新了HTML结构变了你的XPath或CSS选择器失效了。防御性编程使用更健壮的选择器避免使用绝对路径如/html/body/div[3]/div[2]/span和依赖固定索引的选择器。尽量使用具有唯一性的ID、Class或属性。坏的//div[3]/div/span[2]好的//div[classproduct-price]/span或//*[idprice]多层解析与异常处理不要假设一次解析就能成功。设计解析逻辑时准备备选方案。from lxml import etree import logging def parse_price(html): tree etree.HTML(html) price None # 方案1尝试主选择器 try: price_elem tree.xpath(//span[data-testidproduct-price]/text()) if price_elem: price price_elem[0].strip() return price except Exception as e: logging.debug(f主选择器解析失败: {e}) # 方案2备用选择器 try: price_elem tree.xpath(//div[contains(class, price-box)]//text()) if price_elem: # 可能需要更复杂的文本清洗 price .join(price_elem).strip() return price except Exception as e: logging.debug(f备用选择器解析失败: {e}) # 方案3正则表达式兜底如果价格格式固定如 129.00 import re price_match re.search(r[¥\$]?\s*(\d\.?\d*), html) if price_match: return price_match.group(1) logging.warning(所有价格解析方案均失败) return None建立监控与告警对于重要的生产爬虫设置监控点。如果连续多次解析失败或数据量为零触发告警邮件、钉钉、Slack让你能第一时间知道并修复。4.3 编码与乱码问题你拿到一段response.text打印出来全是ä½ å¥½这样的乱码。原因HTTP响应头中的Content-Type声明的编码如charsetiso-8859-1与实际内容的编码如utf-8不一致或者requests自动猜测编码错误。解决方案不要完全依赖response.text它使用requests推测的编码。直接使用response.content获取字节流然后用正确的编码解码。response requests.get(url) # 方法1优先使用响应头中声明的编码 encoding response.encoding if charset in response.headers.get(content-type, ).lower(): # 从content-type提取 pass # 方法2使用chardet库自动检测较慢但准 import chardet detected_encoding chardet.detect(response.content)[encoding] # 方法3如果知道固定编码直接指定 html response.content.decode(utf-8) # 或 gbk, gb2312 # 对于中文网站utf-8和gbk是两种最常见的编码可以尝试 try: html response.content.decode(utf-8) except UnicodeDecodeError: html response.content.decode(gbk)5. 反爬虫对抗升级从简单屏蔽到智能验证现在的网站反爬手段越来越高明简单的修改User-Agent和加延迟已经不够用了。5.1 请求头指纹与浏览器指纹网站会检查你的请求头是否完整、是否像来自一个真实的浏览器。缺失Accept、Accept-Language、Accept-Encoding、Connection等头或者它们的值过于简单都可能被识别为爬虫。解决方案完整地复制一个真实浏览器如Chrome的请求头。使用session.headers.update()一次性设置好。注意Cookie要动态管理如果涉及登录。更高级的反爬会检测浏览器指纹如navigator.userAgent,navigator.platform, 屏幕分辨率、时区、WebGL渲染器等。这在无头浏览器环境下容易被识别。Selenium或Playwright可以通过加载特定插件、设置WebDriver属性来模拟更真实的指纹但道高一尺魔高一丈。5.2 IP封锁与代理池的使用这是最有效的反爬手段之一。一旦你的服务器IP被识别为爬虫并封禁所有来自这个IP的请求都会失败返回403、429或直接跳转到验证码。解决方案使用代理IP池。免费代理质量极不稳定速度慢存活时间短仅适合测试或极低频率的爬取。付费代理服务提供稳定、高速、高匿名的代理IP通常按流量或时间计费。它们提供API接口让你可以动态获取和更换IP。import requests # 假设你使用了一个付费代理服务其API格式为http://api.proxy-service.com/get?keyYOUR_KEY def get_proxy_from_service(): resp requests.get(http://api.proxy-service.com/get?keyYOUR_KEY) # 返回格式可能是 {proxy: 1.2.3.4:8080} return resp.json().get(proxy) proxy get_proxy_from_service() proxies { http: fhttp://{proxy}, https: fhttp://{proxy}, # 注意很多代理HTTP和HTTPS用的是同一个端口和协议 } try: # 使用代理发起请求 response requests.get(https://target.com, proxiesproxies, timeout10) print(f使用代理 {proxy} 成功) except Exception as e: print(f代理 {proxy} 失败: {e}) # 标记该代理失效并从池中移除或重新获取自建代理池对于大规模、长期的爬虫项目自建代理池是更可控、成本可能更低的选择。通过爬取免费代理网站、购买拨号VPS每次拨号换IP或云服务商提供的弹性IP可动态绑定来构建。关键策略代理IP的质量检测与调度。不能拿到代理就用必须有一个检测环节用代理去访问一个稳定的测试网站如http://httpbin.org/ip检查是否连通、匿名度如何、速度如何。然后根据质量评分来调度使用。5.3 验证码CAPTCHA验证码是区分人类和机器的终极关卡之一。常见的有图片点选、滑块、文字识别、算术题等。应对策略按成本和技术难度排序规避通过降低请求频率、维护会话Cookie、使用高质量代理IP尽量减少触发验证码的概率。人工打码对于低频、重要的任务当出现验证码时程序暂停将验证码图片保存下来弹出给人工识别输入后再继续。这可以通过集成打码平台API实现半自动化。第三方打码平台如超级鹰、图鉴等提供API接口你上传图片它返回识别结果。需要付费识别率因验证码类型而异。自动化识别简单图形验证码使用pytesseractOCR库或dlib机器学习进行识别但面对干扰线、扭曲字体时效果很差。滑块验证码使用图像处理库如OpenCV计算滑块缺口位置然后通过Selenium模拟拖动。难点在于模拟人类的拖动轨迹先加速后减速。点选验证码难度极高通常需要目标检测模型如YOLO来识别图中指定物体位置。重要提醒自动化破解验证码可能违反网站的服务条款甚至涉及法律风险。务必评估目标网站的Robots协议和法律条款在合法合规的范围内进行爬取。对于商业级、大规模的爬取与数据提供方协商获取官方API永远是第一选择。6. 数据存储与后续处理中的“失败”爬取成功、解析成功并不代表万事大吉。数据存储环节同样会“失败”。6.1 数据库写入失败重复数据爬虫多次运行可能插入重复记录。需要在数据库表设计时为数据建立唯一约束如URL的MD5哈希、商品ID等或使用INSERT ... ON DUPLICATE KEY UPDATE(MySQL) 、upsert操作。连接超时或断开长时间运行的爬虫数据库连接可能超时。需要使用连接池并在每次操作后检查连接是否存活必要时重连。数据格式不符解析出来的数据长度超过字段定义、类型错误字符串试图写入整型字段。在入库前必须进行严格的数据清洗和类型转换并做好异常捕获。import pymysql from datetime import datetime def clean_and_insert(data_dict, db_connection): cursor db_connection.cursor() try: # 数据清洗 price data_dict.get(price, 0).replace(¥, ).replace(,, ).strip() try: price_float float(price) except ValueError: price_float 0.0 title data_dict.get(title, )[:255] # 防止超长 # 处理可能为空的日期字段 date_str data_dict.get(date) if date_str: try: date_obj datetime.strptime(date_str, %Y-%m-%d) except ValueError: date_obj None else: date_obj None sql INSERT INTO products (title, price, date) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE priceVALUES(price), dateVALUES(date) cursor.execute(sql, (title, price_float, date_obj)) db_connection.commit() except pymysql.Error as e: db_connection.rollback() print(f数据库写入失败: {e}) # 记录失败的数据以便后续排查或重试 log_failed_data(data_dict) finally: cursor.close()6.2 文件写入问题并发写入冲突如果爬虫是多线程/多进程的同时写同一个文件会导致数据错乱或损坏。解决方案使用线程锁threading.Lock或者更常见的每个线程/进程写入独立的文件最后再合并。对于日志文件可以使用logging库它是线程安全的。磁盘空间不足爬虫运行一段时间后可能写满磁盘。需要在代码中监控磁盘空间或者在存储逻辑中加入归档和清理机制。7. 调试与日志你的“黑匣子”当爬虫失败时如果没有详细的日志你就像在黑暗中摸索。一个健壮的爬虫必须有完善的日志系统。日志记录什么INFO级别爬虫开始、结束、成功爬取一个页面的URL。DEBUG级别请求的详细参数、响应的状态码和部分内容可截取前500字符、解析出的关键数据样本。WARNING级别遇到非致命问题如解析字段缺失、遇到验证码、代理IP速度慢。ERROR级别请求异常超时、连接错误、解析完全失败、数据库写入失败。如何配置日志import logging import sys from logging.handlers import RotatingFileHandler def setup_logger(name): logger logging.getLogger(name) logger.setLevel(logging.DEBUG) # 设置最低日志级别 # 控制台处理器 console_handler logging.StreamHandler(sys.stdout) console_handler.setLevel(logging.INFO) # 控制台只显示INFO及以上 console_format logging.Formatter(%(asctime)s - %(name)s - %(levelname)s - %(message)s) console_handler.setFormatter(console_format) # 文件处理器滚动日志防止单个文件过大 file_handler RotatingFileHandler(crawler.log, maxBytes10*1024*1024, backupCount5) file_handler.setLevel(logging.DEBUG) # 文件记录所有DEBUG及以上日志 file_format logging.Formatter(%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s) file_handler.setFormatter(file_format) logger.addHandler(console_handler) logger.addHandler(file_handler) return logger # 使用 logger setup_logger(my_crawler) logger.info(f开始爬取: {url}) try: response session.get(url, timeout5) response.raise_for_status() except requests.exceptions.RequestException as e: logger.error(f请求失败 {url}: {e}, exc_infoTrue) # exc_infoTrue 会记录异常堆栈 return logger.debug(f响应头: {response.headers})当爬虫出问题时第一件事就是查看日志文件从ERROR和WARNING信息开始顺着时间线回溯往往能快速定位问题根源。把日志当作你爬虫的“飞行记录仪”它在关键时刻能救你的命。