
1. 项目概述从“能爬”到“会爬”的思维转变上次我们聊了Python爬虫最基础的requests和BeautifulSoup组合拳算是把门给推开了。但很多朋友照着代码跑通第一个例子后往往会遇到一堆新问题网站稍微一复杂就爬不动了数据老是缺胳膊少腿跑一会儿IP就被封了或者面对动态加载的内容一脸茫然。这太正常了因为爬虫从来不是把请求发出去、把HTML接回来就完事的简单活儿。它更像是一场你和目标网站服务器之间的“礼貌博弈”——你需要在遵守规则Robots协议、法律法规的前提下尽可能高效、稳定地拿到数据同时还要避免把对方“惹毛”。所以这个“入门2”我们不讲太多新库重点在于思维的升级和实战技巧的深化。我会带你从“写一个能跑的脚本”进化到“设计一个健壮的爬虫”。我们会深入三个核心痛点如何应对复杂的反爬机制、如何高效地解析不规则结构、如何管理爬取状态和处理异常。掌握了这些你才能算真正“入门”才能去挑战更复杂的真实网站而不是永远停留在教程里的静态示例页。2. 核心需求解析爬虫工程师的日常烦恼当你开始爬取真实网站尤其是稍微有点规模的商业站点时你会发现理想和现实的差距。以下是你几乎必定会遇到的几个核心需求也是本篇要解决的重点2.1 需求一应对基础反爬实现可持续爬取大部分网站都有基本的防护。你可能会遇到1请求频率稍高就被封IP2需要登录后才能查看内容3返回的数据是乱码或者根本不是HTML。如果你的爬虫只会用requests.get()那基本寸步难行。我们需要让爬虫“看起来”更像一个真实的浏览器用户。2.2 需求二精准定位与提取复杂数据现实中的网页HTML结构可能非常混乱标签嵌套深、没有规律性的class或id或者你需要的数据分散在多个标签甚至多个请求里。用简单的find()和select()就像用勺子挖矿效率低下。我们需要更精准的“挖掘工具”和策略。2.3 需求三让爬虫稳定、自律且易于管理爬虫不能一跑起来就不管不顾。它需要1处理网络波动、页面结构变化等异常不能动不动就崩溃2遵守爬取礼仪控制访问速度避免对目标服务器造成压力3能够断点续爬在中断后能从上次的位置继续而不是重头再来。这涉及到错误处理、速度控制和状态持久化。3. 反爬虫策略入门与基础应对方案网站管理员为了防止数据被过度抓取会设置各种反爬虫机制。作为爬虫方我们的原则是“友好访问”在技术对抗的同时尽量模拟人类行为。3.1 伪装请求头User-Agent这是最基础也最重要的一步。服务器通过HTTP请求头中的User-Agent字段来判断客户端类型。使用requests的默认UA一眼就会被认出是爬虫。import requests # 不好的做法使用默认UA # response requests.get(http://example.com) # 好的做法使用常见的浏览器UA headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 } response requests.get(http://example.com, headersheaders)注意最好准备一个UA列表每次请求随机选取一个这样可以进一步降低被识别的风险。可以从网上搜索“最新浏览器User-Agent大全”来获取。3.2 处理Cookie与简单会话Session有些网站需要登录或者通过Cookie来跟踪会话状态。requests.Session()对象可以帮我们自动管理Cookie像浏览器一样保持登录状态。import requests # 创建一个会话对象 session requests.Session() # 首先模拟登录假设是表单提交 login_data { username: your_username, password: your_password } # 这里需要找到真实的登录接口URL和参数名 login_url http://example.com/login session.post(login_url, datalogin_data) # 登录后使用同一个session去访问需要权限的页面会自动携带Cookie profile_page session.get(http://example.com/myprofile)3.3 设置请求间隔与超时控制疯狂连续请求是爬虫被封的最主要原因之一。使用time.sleep()在请求间插入随机延时是非常有效的“礼貌”行为。同时设置超时参数可以避免因某个请求卡住而长时间等待。import time import random def polite_request(url, headers): 一个带有随机延迟和超时设置的请求函数 # 随机延迟1-3秒模拟人类阅读间隔 time.sleep(random.uniform(1, 3)) try: response requests.get(url, headersheaders, timeout10) # 10秒超时 response.raise_for_status() # 如果状态码不是200抛出HTTPError异常 return response except requests.exceptions.Timeout: print(f请求超时: {url}) return None except requests.exceptions.HTTPError as e: print(fHTTP错误: {e}) return None except requests.exceptions.RequestException as e: print(f请求异常: {e}) return None3.4 解析常见的数据返回格式不是所有接口都返回HTML。越来越多的网站使用JSON格式通过API传输数据这反而对我们更友好。import json # 假设某个接口返回JSON api_url http://example.com/api/data response requests.get(api_url, headersheaders) if response.status_code 200: # 直接解析为Python字典/列表 data_dict response.json() # 接下来就可以像操作字典一样提取数据了 for item in data_dict[items]: print(item[title], item[url]) else: print(请求失败)实操心得遇到页面内容显示不全或数据为空的情况第一时间不要怀疑自己的解析代码。打开浏览器的“开发者工具”F12切换到“Network”网络选项卡刷新页面观察有哪些XHRAjax或Fetch请求。你要的数据很可能藏在某个JSON接口里直接爬这个接口比解析HTML简单得多。4. 进阶数据提取XPath与正则表达式当BeautifulSoup的find和select显得力不从心时你需要更强大的武器。4.1 使用XPath进行精准定位XPath是一种在XML和HTML文档中查找信息的语言。它比CSS选择器更强大可以通过层级关系、属性、文本内容等多种方式进行定位。Python中可以用lxml库来配合使用XPath。from lxml import etree html html body div idcontent ul classlist li项目1/li li项目2 span classhighlight特别推荐/span/li li项目3/li /ul p价格strong100/strong元/p /div /body /html # 将HTML文本解析为Element对象 tree etree.HTML(html) # 示例1获取所有li标签的文本 items tree.xpath(//ul[classlist]/li/text()) print(items) # 输出[项目1, 项目2 , 项目3] 注意“项目2”后面的空格和缺失的“特别推荐” # 示例2获取所有li标签下的所有文本包括子标签 items_full tree.xpath(//ul[classlist]/li//text()) print(items_full) # 输出[项目1, 项目2 , 特别推荐, 项目3] # 示例3获取特定位置的元素第二个li second_li tree.xpath(//ul[classlist]/li[2])[0] print(etree.tostring(second_li, encodingunicode)) # 输出整个标签 print(second_li.xpath(string(.))) # 输出标签内所有文本拼接项目2 特别推荐 # 示例4获取价格数字100 price tree.xpath(//p[contains(text(), 价格)]/strong/text())[0] print(price) # 输出100XPath语法小抄//从根节点开始查找所有后代节点。/查找子节点。[属性名‘值’]按属性筛选。[n]选择第n个元素从1开始计数。text()获取节点的文本内容。contains(属性或函数, ‘字符串’)属性或文本包含特定字符串。4.2 使用正则表达式处理非结构化文本当数据嵌入在一大段混乱的文本中没有清晰的标签边界时正则表达式是最后的杀手锏。比如从一段JavaScript代码或服务器返回的混乱字符串中提取URL、邮箱、特定格式的数字等。import re text 联系我们电话 138-0013-8000邮箱 supportexample.com官网 https://www.example.com/product/12345。 # 提取手机号简单示例国内手机号规则更复杂 phone_pattern r\d{3}-\d{4}-\d{4} phones re.findall(phone_pattern, text) print(phones) # 输出[138-0013-8000] # 提取邮箱 email_pattern r[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,} emails re.findall(email_pattern, text) print(emails) # 输出[supportexample.com] # 提取产品ID假设URL格式固定 url_pattern rhttps://www\.example\.com/product/(\d) match re.search(url_pattern, text) if match: product_id match.group(1) # group(1)获取第一个括号内匹配的内容 print(product_id) # 输出12345注意事项正则表达式是一把双刃剑功能强大但难以维护。如果网页结构稍有变化写死的正则可能就失效了。原则是能用HTML解析器BeautifulSoup, lxml解决的问题绝不用正则只有在解析器无能为力的纯文本环境中才考虑使用正则并且要尽量使模式宽松、有容错性。5. 构建健壮爬虫错误处理、日志与状态管理一个玩具脚本和一个生产可用的爬虫之间差的就是健壮性和可维护性。5.1 全面的异常处理网络请求充满不确定性必须对可能发生的异常进行捕获和处理让爬虫遇到问题能记录、能跳过而不是直接崩溃。import requests from requests.exceptions import RequestException import logging # 配置日志记录运行情况 logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, filenamespider.log) def robust_request(url, sessionNone, max_retries3): 一个健壮的请求函数包含重试机制 for attempt in range(max_retries): try: if session: resp session.get(url, timeout15, headersheaders) else: resp requests.get(url, timeout15, headersheaders) resp.raise_for_status() # 检查HTTP状态码 # 检查编码避免乱码 if resp.encoding is None or resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding or utf-8 return resp except RequestException as e: logging.warning(f第{attempt1}次请求失败 [{url}]: {e}) if attempt max_retries - 1: logging.error(f请求最终失败 [{url}]跳过。) return None time.sleep(2 ** attempt) # 指数退避等待时间越来越长5.2 使用日志记录替代print在脚本里到处用print输出信息在开发时方便但不利于后期维护和问题排查。使用Python内置的logging模块可以方便地控制输出级别DEBUG, INFO, WARNING, ERROR并输出到文件。import logging # 在脚本开头配置一次即可 logging.basicConfig(levellogging.INFO, format%(asctime)s - %(name)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(spider_run.log), # 输出到文件 logging.StreamHandler() # 同时输出到控制台 ]) # 在代码中使用 logging.info(f开始爬取页面: {url}) try: # ... 爬取逻辑 logging.debug(f解析到数据: {data}) # debug信息通常只在排查问题时打开 except Exception as e: logging.error(f爬取{url}时发生错误: {e}, exc_infoTrue) # exc_infoTrue会打印异常堆栈5.3 简单的状态持久化断点续爬爬取大量页面时脚本可能因网络、程序错误或主动中断而停止。重新开始时我们肯定不希望从头爬起。一个简单的方法是将已成功爬取的URL记录到一个文件中。import os import json STATE_FILE crawler_state.json def load_state(): 加载爬取状态 if os.path.exists(STATE_FILE): with open(STATE_FILE, r, encodingutf-8) as f: return json.load(f) return {completed_urls: [], todo_urls: []} def save_state(state): 保存爬取状态 with open(STATE_FILE, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2) # 在爬虫主逻辑中的应用示例 def main_crawler(start_urls): state load_state() all_urls_to_crawl start_urls # 初始URL列表 # 合并状态文件中未完成的URL并去除已完成的 all_urls_to_crawl.extend(state.get(todo_urls, [])) completed_urls_set set(state.get(completed_urls, [])) urls_to_crawl [url for url in all_urls_to_crawl if url not in completed_urls_set] for url in urls_to_crawl: logging.info(f正在处理: {url}) try: # ... 执行爬取和解析逻辑 ... # 假设处理成功 state[completed_urls].append(url) if url in state.get(todo_urls, []): state[todo_urls].remove(url) # 每处理完一个URL或每N个就保存一次状态 save_state(state) time.sleep(random.uniform(1, 2)) except Exception as e: logging.error(f处理{url}失败将其加回待办列表: {e}) if url not in state.get(todo_urls, []): state[todo_urls].append(url) save_state(state)6. 实战案例爬取一个简单的新闻列表页让我们用一个相对完整的例子把上面的知识点串起来。假设我们要爬取一个新闻网站列表页的标题、链接和发布时间。6.1 目标分析假设目标页面结构如下简化版div classnews-list div classnews-item h3a href/news/123.html这是第一条新闻标题/a/h3 span classpub-date2023-10-27/span /div div classnews-item h3a href/news/124.html这是第二条新闻标题/a/h3 span classpub-date2023-10-26/span /div !-- ... 更多新闻项 ... -- div classpagination a href?page11/a a href?page2 classactive2/a a href?page33/a a href?page2下一页/a /div /div6.2 爬虫代码实现import requests import time import random import logging from lxml import etree from urllib.parse import urljoin # 配置 BASE_URL http://www.example-news-site.com/news HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) def fetch_page(url): 获取页面包含基础错误处理 try: resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() # 处理编码问题 if resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding or utf-8 return resp.text except requests.exceptions.RequestException as e: logging.error(f请求失败 {url}: {e}) return None def parse_news_list(html): 解析新闻列表页 if not html: return [], None tree etree.HTML(html) news_items [] # 使用XPath定位所有新闻项 item_nodes tree.xpath(//div[classnews-list]/div[classnews-item]) for node in item_nodes: try: # 提取标题和链接注意相对路径转绝对路径 title_node node.xpath(.//h3/a)[0] title title_node.xpath(string(.)).strip() relative_link title_node.get(href) link urljoin(BASE_URL, relative_link) # 关键拼接完整URL # 提取发布日期 pub_date node.xpath(.//span[classpub-date]/text())[0].strip() news_items.append({ title: title, link: link, pub_date: pub_date }) except IndexError as e: logging.warning(f解析单个新闻项时遇到意外结构跳过。错误: {e}) continue # 跳过这项继续下一个 # 查找下一页链接如果存在 next_page_node tree.xpath(//div[classpagination]/a[contains(text(), 下一页)]/href) next_page_url urljoin(BASE_URL, next_page_node[0]) if next_page_node else None return news_items, next_page_url def main(): current_page 1 current_url f{BASE_URL}?page{current_page} all_news [] while current_url: logging.info(f开始爬取第{current_page}页: {current_url}) # 1. 获取页面 html fetch_page(current_url) if not html: logging.error(f第{current_page}页获取失败终止。) break # 2. 解析页面 news_items, next_page_url parse_news_list(html) all_news.extend(news_items) logging.info(f第{current_page}页解析到{len(news_items)}条新闻。) # 3. 准备下一次请求 current_url next_page_url current_page 1 # 4. 礼貌延迟并加入随机性 if current_url: sleep_time random.uniform(2, 5) # 每页间隔2-5秒 logging.info(f等待{sleep_time:.2f}秒后继续下一页...) time.sleep(sleep_time) else: logging.info(已到达最后一页爬取结束。) # 输出结果 logging.info(f爬取结束共获取{len(all_news)}条新闻。) for idx, news in enumerate(all_news[:5], 1): # 只打印前5条作为示例 print(f{idx}. [{news[pub_date]}] {news[title]} - {news[link]}) # 在实际应用中这里可以将all_news保存为JSON或CSV文件 # import json # with open(news.json, w, encodingutf-8) as f: # json.dump(all_news, f, ensure_asciiFalse, indent2) if __name__ __main__: main()6.3 案例代码要点解析模块化函数将获取页面、解析页面、主逻辑分开代码更清晰易于测试和修改。健壮性fetch_page函数包含了超时和状态码检查parse_news_list函数在解析单个新闻项时用了try...except即使某个项结构异常也不会导致整个解析崩溃。URL处理使用urllib.parse.urljoin将相对链接转换为绝对链接这是处理网页链接的最佳实践能避免很多路径错误。分页处理通过解析“下一页”的链接来实现自动翻页这是一种通用的分页策略。另一种常见策略是根据页码规律拼接URL如?page2。模拟人类在翻页间加入了随机延迟这是对目标网站最基本的尊重。7. 常见问题与排查技巧实录即使按照最佳实践来写爬虫过程中还是会遇到各种稀奇古怪的问题。下面是我踩过的一些坑和解决办法。7.1 问题返回的HTML是乱码或者中文显示为乱码。可能原因1服务器返回的HTTP头中Content-Type没有指定编码或者指定了错误的编码如ISO-8859-1。解决方案让requests自动推测编码resp.apparent_encoding并手动设置。resp requests.get(url) if resp.encoding ISO-8859-1: resp.encoding resp.apparent_encoding or utf-8 # 优先使用推测编码否则用utf-8 text resp.text可能原因2网页本身是gbk或gb2312编码。解决方案直接指定编码。resp.encoding gbk # 或者更通用先尝试utf-8不行再尝试gbk try: resp.encoding utf-8 _ resp.text # 触发解码 except UnicodeDecodeError: resp.encoding gbk7.2 问题用浏览器能看到数据但爬虫获取的HTML里没有。可能原因1数据是JavaScript动态加载的Ajax。这是目前最普遍的情况。排查步骤右键查看网页源代码View Page Source搜索你要的数据关键词。如果搜不到说明是动态加载的。打开浏览器开发者工具F12进入“Network”网络选项卡。刷新页面在“XHR”或“Fetch”类型的请求中寻找包含数据的请求通常看Preview或Response标签页。找到这个请求的URL、请求方法GET/POST、请求头特别是Cookie、Referer、X-Requested-With等和请求参数。用requests或session去模拟这个请求直接获取JSON等结构化数据。可能原因2网站需要特定的Cookie或请求头。解决方案在开发者工具的Network选项卡里找到那个返回数据的请求右键选择“Copy as cURL”或类似选项然后将其粘贴到在线工具如https://curlconverter.com/python/中转换成Python的requests代码。这是最快捷的方法。7.3 问题爬了一会儿就收到403 Forbidden或429 Too Many Requests错误。原因触发了网站的反爬策略。IP被限制或请求频率过高。解决方案降低频率大幅增加请求间隔并在间隔中加入随机时间如time.sleep(random.uniform(3, 10))。完善请求头确保User-Agent是真实的浏览器字符串并补充Referer来源页、Accept-Language等常见头信息。使用代理IP当单IP被彻底封禁时需要使用代理IP池来轮换请求。这是进阶内容需要购买或搭建代理服务。模拟登录对于需要登录的页面必须使用Session保持会话。7.4 问题XPath或CSS选择器写对了但就是提取不到数据。可能原因1网页中有iframe框架你要的数据在框架页里。解决方案在开发者工具的“Elements”里确认你要找的元素是否在iframe标签内。如果是你需要先获取iframe的src属性然后单独请求那个URL。可能原因2HTML结构在服务器返回后被本地JavaScript修改了。解决方案尝试使用resp.content原始字节流而不是resp.text来构造解析树确保你解析的是最原始的HTML。tree etree.HTML(resp.content) # 使用content可能原因3你的选择器路径太“脆”网页结构微调就失效。解决方案编写更健壮的选择器。避免使用绝对路径和过于依赖位置索引如div[3]。多使用属性选择器和相对路径。# 脆弱的选择器 items tree.xpath(/html/body/div[2]/div[3]/ul/li) # 更健壮的选择器假设列表有class items tree.xpath(//ul[classnews-list]/li) # 或者通过包含特定文本来定位 items tree.xpath(//div[contains(class, item) and .//h3])7.5 问题数据保存到文件如CSV、JSON时中文显示为Unicode码\uXXXX。原因Python的json.dump()默认使用ensure_asciiTrue会将非ASCII字符转义。解决方案设置ensure_asciiFalse。import json with open(data.json, w, encodingutf-8) as f: json.dump(your_data, f, ensure_asciiFalse, indent2) # 注意indent美化输出对于CSV文件在打开文件时指定编码utf-8-sig带BOM的UTF-8可以让Excel正确打开中文。import csv with open(data.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[title, link]) writer.writeheader() writer.writerows(your_list_of_dicts)爬虫开发是一个不断“发现问题-分析问题-解决问题”的过程。最重要的技能不是记住所有库的API而是学会使用浏览器开发者工具进行调试和分析以及拥有将复杂问题分解并逐个击破的耐心。从简单的静态页开始逐步增加复杂度你的爬虫技能就会在解决一个又一个的实际问题中扎实地成长起来。