简介:基于Python的网络爬虫设计与实现论文,面向爬虫初学者、在校学生以及需要课程设计或毕业设计参考的开发者。内容以股票信息抓取为案例,从初始链接到数据存储完整展示爬虫设计全过程,涵盖系统结构、爬行流程、页面采集、页面解析、连接处理、数据存储等模块;利用Requests、BeautifulSoup和Scrapy等工具进行多线程URL处理与定向抓取,最终将股票数据存入PostgreSQL数据库并做可视化展示。资源仅含1个PDF文档,大小约1.2MB,为期刊正式论文,包含系统功能模块划分、软硬件环境要求、技术经济操作可行性分析、关键代码示例以及开发环境配置建议,便于读者借鉴环境配置与框架选型,也可作为毕业设计或技术报告的写作模板。目前已有483人浏览学习,适合想系统掌握爬虫架构、快速构建爬虫项目或撰写相关技术文档的学习者。
1. 基于Python的网络爬虫:先弄清楚它在真实项目里解决什么
基于Python的网络爬虫,本质上是在做一件反直觉的事:把浏览器里的一次点击,变成程序里的上万次循环。一个爬虫项目的核心价值不在于“能访问多少个网址”,而在于它能不能持续、稳定、可维护地把分散在网页里的数据变成结构化记录。我在实际项目里见过太多跑两天就报废的爬虫脚本,也见过只用了不到两百行代码就能稳定运行几个月的采集模块。两者的差距不在框架,而在设计:请求层是否健壮、解析层是否可靠、数据落盘是否可续。这篇文章就是围绕这三个层次展开的,同时会把最容易让人翻车的边界条件单独拎出来讲。
如果你是需要定期采集公开数据做分析的从业者,或者在做课程设计、毕业设计时被“设计一个网络爬虫”这个题目卡住,又或者想从单脚本走向规模化采集,这篇笔记都能对应上你的进度。这里不堆架构图,只讲动手能复现的东西。
2. 请求层设计:从urllib到requests,把“拿到页面”这一步做稳
2.1 urllib与requests:为什么多数落地项目选requests
先说一个常见的选择题:标准库里已经有urllib,为什么还要装requests?我的答案是,urllib适合没有第三方依赖的环境,但日常落地项目里requests提供的API设计更符合人类直觉。requests把连接池、Cookie持久化、编码猜测、重定向这些细节封装好了,这让开发者把精力省下来放到解析和存储上,而不是沉在HTTP协议细节里。
从工程角度看,urllib还有一个让人难受的点:它对响应内容的编码处理比较粗糙,经常需要手动从响应头或页面meta里找charset,否则中文内容容易乱码。而requests会结合响应头做一次编码判断,大部分情况下直接访问.text就能拿到正确字符串。当然,编解码也有它自己的坑,这一点我会在避坑章节专门展开。
如果项目里不限制第三方库,我几乎总是先装requests。即使以后要迁移到异步框架,requests写出来的逻辑也能很自然地平移过去。urllib则保留在“必须零依赖”的场合,比如某些受限的CI环境或离线部署机器上。简单说,requests是默认选项,urllib是兜底方案。
2.2 最小请求模块:超时、重试、会话一次配齐
一个能持续运行的爬虫,请求层至少要回答四个问题:超时多久算失败、失败后要不要重试、重试几次、要不要复用TCP连接。我见过太多爬虫死于“没有设置超时”,目标站一卡,整个脚本就挂在那里不动。下面这段代码是我常用的最小请求模块,它把会话、请求头和连接池一次性配置好。
import requests from requests.adapters import HTTPAdapter session = requests.Session() session.headers.update({ "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,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", }) adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10, max_retries=3) session.mount("https://", adapter) session.mount("http://", adapter) def get_page(url, timeout=(5, 10)): resp = session.get(url, timeout=timeout) resp.raise_for_status() return resp.text这段代码里有几个参数值得拆开说。timeout=(5, 10)传入的是一个元组,前一个值是“连接超时”,后一个是“读取超时”。连接超时解决的是目标服务器不响应TCP握手的问题,读取超时解决的是数据传了一半卡住的问题。如果只传一个数字,requests会把它同时用在两个阶段,但连接和读取的耗时特征完全不同,拆开设置更合理。
HTTPAdapter的pool_connections控制的是不同主机之间的连接池数量,pool_maxsize控制的是同一主机最多复用多少连接。对单机爬虫来说,这两个值不需要调得很大,10就够用。max_retries=3的作用是在请求遇到连接错误时自动重试3次,但它不会对HTTP 404或500做重试,这是一个容易误解的点。如果需要按状态码做重试,得在业务代码里自行判断。
2.3 会话保持与Cookie:登录态爬取的正确姿势
很多目标网站的数据需要登录后才能看到,这就引出第二个请求层问题:怎么维持登录态。requests.Session的机制决定了它会自动保存服务端下发的Cookie,并在后续请求中自动携带。这意味着只要登录一次,后续请求都带着会话身份,不需要每次手动塞Cookie头。
登录过程通常分为两步:先请求登录页拿到表单里的令牌,再用POST提交账号密码。很多站点会在登录表单里埋一个csrf_token,这个值通常藏在HTML里,先用正则取出来。下面的例子演示了带登录态的采集流程。
import re login_url = "https://example.com/login" resp = session.get(login_url, timeout=(5, 10)) token = re.search(r'name="csrf_token" value="([^"]+)"', resp.text).group(1) session.post(login_url, data={ "username": "your_account", "password": "your_password", "csrf_token": token, }, timeout=(5, 10)) data_url = "https://example.com/orders?page=1" page = session.get(data_url, timeout=(5, 10)) if "请登录" in page.text or "login" in page.url: print("登录态已失效") else: print("登录态保持成功")这里的关键是判断登录态是否失效的逻辑。不要在拿到200状态码后就默认数据有效,很多站点在Cookie失效后返回的仍然是200页面,只不过内容变成了登录页。一个实用的做法是,在登录成功的响应里记住某个标识字符串,之后每次请求检查页面文本里是否还存在这个标识。如果不存在,就说明会话已经断了,需要重新登录。
另外,requests默认会跟随重定向,allow_redirects默认是True。在采集过程中如果发现resp.url和请求的URL不一致,通常意味着服务端做了跳转,这时要留意跳转后的页面是否还是目标业务页面。我一般会在调试阶段把resp.url打印出来,这比只看状态码更能定位问题。
3. 数据解析层:正则、XPath、CSS选择器怎么分工
3.1 正则只做末端字段提取,别拿它解析整个页面
拿到HTML之后,下一步是提取数据。很多人第一反应是用正则硬啃,因为正则看起来最直接,但实际上它只适合做末端字段的提取,不适合做整页结构的解析。HTML是嵌套结构,正则擅长的是“在一个字符串里按模式找片段”,它记不住层级关系,面对不规范的标签很容易写出又长又脆的匹配式。
常见的做法是,用正则去处理那些“寄生”在脚本或JSON字符串里的数据。比如页面里有一段var listData = [...],整块是JSON格式,直接用正则提取listData等号后面的内容,再用json.loads解析。这种场景正则很合适,因为目标明确、格式统一。
import json import re text = "<script>var listData = [{\"title\": \"Python工程师\", \"salary\": \"20K\"}];</script>" match = re.search(r"var listData = (\[.*?\]);", text) if match: data = json.loads(match.group(1)) for item in data: print(item["title"], item["salary"])这段代码的思路是:先用正则把JSON片段从脚本里剥出来,再用JSON解析器做后续处理。注意.*?用了非贪婪匹配,防止截断到第一个];就停下来。正则在这里扮演的是“定位器”角色,而不是“解析器”角色,这是它与XPath的最本质区别。
3.2 XPath与lxml:从混乱HTML里批量抽数据的核心写法
如果目标页面是规范的HTML列表结构,XPath是抽数据的主力工具。lxml的etree.HTML会自动补全缺失的标签,这个容错处理尤其适合真实网页里大量存在的“标签没闭合”“属性值带引号”等情况。
from lxml import etree html = """ <ul class="job-list"> <li class="item">from parsel import Selector selector = Selector(text=html) titles = selector.css("li.item a::text").getall() hrefs = selector.css("li.item a::attr(href)").getall() for title, href in zip(titles, hrefs): print(title, href)::text和::attr(href)是parsel对CSS选择器做的扩展,标准CSS没有这种语法。用getall()拿全部匹配结果,用get()只拿第一个,且在没有匹配时返回None而不是抛异常。相比lxml的[0]写法,parsel在处理“某些条目可能缺少某个字段”时更从容,不用每次判断列表是否为空。如果你的项目后续要上Scrapy,解析代码几乎可以原封不动搬过去。就这一点而言,parsel是我推荐的解析层首选。
4. 爬虫工程化:限速、重试与增量断点,把脚本变成系统
4.1 请求头与UA:在源头减少被拒的概率
爬虫写完之后,最常见的失败不是解析出错,而是请求被目标站点拒绝。很多站点反爬的第一道关卡就是请求头校验,它不需要多复杂的机器学习模型,只要检测到请求头里的浏览器标识缺失或异常,就直接返回403。这就是为什么请求头设计要“完整得像一个真实浏览器”,而不只是塞一个User-Agent。
一个最小完整的请求头至少要包含User-Agent、Accept、Accept-Language。Referer在某些场景下也很关键,比如访问列表页时带上上一级页面的URL。还有一点很多人会忽略:Accept-Encoding不要随意设置,如果设置了gzip,就要正确处理压缩后的响应,否则页面内容会变成乱码字节。requests默认会帮我们处理这部分,手动覆盖时要谨慎。
如果需要在多个请求间轮换身份标识,常见做法是准备一个UA池,每次请求随机取一个再更新到Session里。但要注意,UA池轮换不等于伪装成功,它只能应对最基础的检查。更关键的是请求频率,这一点下面单独讲。
4.2 限速与指数退避重试:给目标站点留余地
很多爬虫翻车的直接原因是请求频率太快。单线程跑没问题,一旦并发提上去,几分钟内就被封IP。设计爬虫的时候,限速不应该是事后补救,而应该是实现之初就写进采集循环里的基础约束。一个基本的限速策略是每次请求之间sleep一个随机时间,让节奏不规律且平均频率可控。
import random import time def fetch_one(session, url): time.sleep(random.uniform(0.5, 1.2)) try: resp = session.get(url, timeout=(5, 10)) except requests.RequestException: return None if resp.status_code != 200: return None return resp.textrandom.uniform(0.5, 1.2)的含义是每次请求前随机等待0.5到1.2秒,这样平均每秒大约发起1个请求。这个速率对大多数信息公开站点都是能接受的。比起固定的sleep(1),随机延迟的好处是节奏更像人工浏览,也比固定频率更难被规则检测命中。
关于重试,我的习惯是只在“网络异常”和“5xx状态码”时重试,4xx几乎不重试,因为4xx通常意味着客户端的问题,重试多少次都没用。如果要用指数退避,推荐手写一段轻量逻辑,规则就是每次重试等待时间翻倍再叠加随机抖动。重试上限一般设置在3到5次,超过上限就放弃当前请求,把URL记录下来,供后续补采。
4.3 增量去重与数据落盘:重启不丢进度
一个采集任务往往不是跑一次就结束,而是要按天或按小时持续执行。如果脚本中途崩了,或者需要停掉重启,能不能从断点继续跑,就是工程化程度的分水岭。最朴素也最有效的方案是:数据库里已存在的记录标识,启动时加载到内存集合,采集前先查一次集合。
import sqlite3 conn = sqlite3.connect("jobs.db") conn.execute("CREATE TABLE IF NOT EXISTS job (id TEXT PRIMARY KEY, title TEXT, url TEXT)") visited = set(row[0] for row in conn.execute("SELECT id FROM job")) def collect_page(page_id, html): title = extract_title(html) # 假设已有解析函数 url = f"https://example.com/list/{page_id}" conn.execute( "INSERT OR IGNORE INTO job (id, title, url) VALUES (?, ?, ?)", (str(page_id), title, url) ) conn.commit() visited.add(str(page_id)) for page_id in range(1, 100): if str(page_id) in visited: continue html = fetch_one(session, f"https://example.com/list/{page_id}") if html is None: continue collect_page(page_id, html)这段代码有三个设计点值得说。第一,visited集合启动时从数据库里恢复,而不是从空集合开始,这样就实现了重启续跑。第二,INSERT OR IGNORE利用主键唯一约束天然防重,即使两个逻辑环节之间出了并发问题,数据库层面也能兜底。第三,collect_page里先解析再提交,提交成功后立刻加入visited,保证“处理成功”和“标记已访问”保持同步。
SQLite对单机爬虫足够用了,多线程场景下写入会触发锁冲突,这时可以给写操作加一个简单的线程锁,或者退一步用CSV加追加写。但无论如何,不要把去重只放在内存里,否则一次重启就是一场血泪教训。
5. 避坑:五个让爬虫翻车的现场,每条都有解法
5.1 页面内容全是乱码:编码探测不是靠猜
现象:请求返回的HTML打开后全是``或者问号,中文字符全部乱掉。原因:requests在解析响应时优先使用HTTP响应头里的charset,而这个值和页面实际编码可能不一致。很多站点的响应头不声明编码,或者声明为ISO-8859-1,但页面实际是UTF-8。解决:先打印resp.encoding和resp.apparent_encoding,如果两者不一致,手动指定正确的编码值再取.text。
resp = session.get(url, timeout=(5, 10)) if resp.encoding != resp.apparent_encoding: resp.encoding = resp.apparent_encoding html = resp.text这样处理比每次都从<meta charset>里正则匹配快得多。注意,如果页面是GBK编码,这种方式也能正确识别,但前提是页面的字节流里有足够的编码特征。如果实在拿不准,就直接用resp.content先拿到原始字节,再用bytes.decode("gbk", errors="ignore")逐种编码尝试。
5.2 页面明明有数据,解析结果却为空
现象:在浏览器里打开目标页面,数据清清楚楚,但爬虫解析出来的列表是空的。原因大概率不是XPath写错,而是这些数据是动态加载的,根本不在初始HTML里。解决:打开浏览器的开发者工具,切到Network面板,刷新页面,找到返回JSON数据的XHR请求,直接采集这个接口而不是采集HTML页面。接口返回的JSON用resp.json()一行解析,比在HTML里找数据省事得多。
判断动态加载还有一个快速方法:在浏览器里右键查看网页源代码,搜索你在页面上看到的关键词,如果搜不到,说明数据是通过脚本后来填充的。这种情况下花时间调XPath是浪费,直接转接口采集才是正路。
5.3 请求被403/418拦截
现象:刚开始还能正常采集,过了几十条请求后突然大量返回403或418状态码。原因:服务端检测到了异常请求特征,可能是单一IP请求过快,也可能是请求头指纹与真实浏览器差异过大。418的出现通常意味着服务端明确识别出了“这不是浏览器”。解决:先把请求头补完整,UA、Accept、Accept-Language、Referer缺一不可;然后进一步降低请求频率;如果还是被拦,考虑为采集任务增加代理源,并把代理与请求频率的配合做灰度验证。
这一步里最玄学的部分在TLS指纹识别。有些站点不看你带了什么头,而是直接探测客户端TLS握手特征,普通的requests库在这种检查下基本伪装不了。这是requests本身的限制,不是靠改参数能解决的。遇到这种情况,常见的出路是换用能模拟浏览器完整协议指纹的客户端库,同时把采集频率压到最低。但归根结底,是否值得为了某个目标站投入这种成本,要在项目启动前就评估清楚。
5.4 多线程一开,IP就被封
现象:单线程采集一切正常,把代码改成ThreadPoolExecutor之后,十分钟内所有请求都被拒绝。原因非常简单直接:请求频率翻了几倍,超过了目标站容忍阈值。解决:在引入多线程之前,先想清楚单位时间内的总请求数是否可控。
一个可行的做法是把限速器抽成全局唯一实例,所有线程共用一个令牌或睡眠锁,让总请求速率维持在线程数不变时的水平。多数人在这里犯的错误是只在线程函数里写了sleep,但线程数量翻倍意味着实际每秒请求数翻倍。先用单线程把限速逻辑调整到位,再逐步增加线程数,每次加一倍并观察封禁情况,这是最稳妥的路径。
5.5 登录态突然失效,采集数据中断
现象:采集任务跑了几十分钟,突然返回的数据都变成“请先登录”或登录页HTML。原因:会话Cookie过期,服务端session超时,或者目标站在检测到异常频率时主动失效了当前会话。解决:在采集主循环里加入登录态校验逻辑,检测到失效时先停止采集,走重新登录流程,成功后从断点续采。
重新登录的逻辑要写在请求模块里,而不是写在业务解析逻辑里。也就是说,get_page内部发现页面跳转到登录页时,自动完成一次登录再重试原请求。这样采集主循环完全感知不到登录态变化,也不需要为“会话失效”写额外状态机。这套逻辑看着不起眼,却是让爬虫长稳运行的关键设计。
6. 进阶:上分布式之前,先把去重、队列和采集边界想清楚
6.1 从单机到分布式:任务队列与URL去重的抽象
当你手里的目标站点从一两个变成几十个,单机脚本的逻辑开始爆炸时,才需要考虑分布式。但分布式爬虫最容易犯的错是过早引入重型组件。我的建议是,上分布式之前先把三个抽象做对:任务队列、URL去重、数据分层。把“下一个要采集的URL”从函数参数变成一条消息,把内存里的visited集合换成可共享的存储,这一步做好了,分布式只是换个消息队列的事。
URL去重在分布式场景下的标准做法是使用Redis的Set,这里给一段最简实现。
import redis r = redis.Redis(host="localhost", port=6379, db=0) def is_visited(url): return bool(r.sismember("visited_urls", url)) def mark_visited(url): r.sadd("visited_urls", url)当URL数量过亿,Set的内存占用会变得可观,这时才值得引入BloomFilter。但很多项目其实到不了这个量级,提前上布隆过滤器只会增加不必要的心智负担。真实的分布式难点从来不是“谁去重”或者“谁拿任务”,而是数据怎么汇总、任务怎么平衡、失败怎么重试。我的经验是,先把这三件事在单机单进程里用清晰接口抽象出来,迁移到分布式时替换实现就好。
还有采集边界的问题。爬虫能做不等于什么都该采集,工程上要遵守目标站点的访问协议与频率限制,只采集公开数据,并考虑目标服务器的承载压力。这不是套话,而是延续一个采集项目的基本前提。我曾经为了赶数据进度,把一个简单页面的并发开到50,结果不仅IP被封了一周,还给对方服务器添了不必要的麻烦。那之后我养成了一个习惯:先写限速器,再写采集逻辑。数据采集是长跑,不是一锤子买卖。
希望这些踩坑经验能帮你在做爬虫设计时少走弯路,也欢迎把你自己的翻车现场拿来对照,很多问题的解法其实早就有迹可循。
本文还有配套的精品资源,点击获取