
简介这是一套面向Python爬虫初学者与校园数据分析爱好者的Scrapy实战项目源码聚焦校园集市网页数据的结构化采集与初步分析解决高校场景下二手交易、闲置信息发布等非结构化数据获取难题。资源共48个文件包含24个Python源文件含spiders、pipelines、utils等模块、11个文本类说明与配置文件如readme.txt、db_config.py、2个SQL建表与初始化脚本、1个cfg配置及1个LICENSE协议文件整体压缩包仅1.33MB轻量易部署。已有77人学习下载项目目录结构规范完整覆盖Scrapy标准组件——从爬虫启动、页面解析、情感评分、热度计算到MySQL持久化全流程并附带停用词库、BERT与Word2Vec相关性计算等进阶分析模块可直接运行调试或作为课程设计/毕业设计基础框架。基于Python Scrapy框架的校园集市数据爬取设计源码说起来之前有个朋友找我帮个忙说他们学校有个校园二手集市平台里面每天都有大量学生发布二手教材、宿舍神器、自行车、考研资料之类的东西。他想做一个数据分析的小课题想统计一下校园里什么品类最热门、什么时间段发布量最大、二手教材的平均流转周期有多长。想法挺好但问题是怎么把数据拿下来——人工去翻网页显然不现实随手写个requests脚本呢又发现页面是动态渲染的部分关键内容还在iframe里。折腾了一圈之后我干脆用Scrapy给他搭了一套完整的校园集市爬虫方案。这篇文章就把这个项目的完整设计思路、源码结构、动态页面的处理方式、还有落库和增量更新的方案一起拆开讲清楚。如果你也想爬校园集市、同城二手、校内交易这类站点做分析或者单纯想学Scrapy怎么处理动态页面和复杂渲染场景这篇文章应该能给你省下不少踩坑的时间。1. 校园集市类站点的特征与爬取需求拆解1.1 目标站点到底长什么样先说清楚校园集市这类平台和普通电商网站的区别。普通电商站的商品列表通常在服务端渲染好直接返回HTML爬虫拿到就能解析。但校园集市这类平台尤其是近几年的新站点普遍采用前后端分离架构页面数据由JavaScript动态加载部分核心内容甚至被塞进了嵌套的iframe里。我在实际抓取中发现这类站点的典型页面结构大致是这样的商品列表页路由是 /market/list但列表数据并不是直接写在HTML里的而是页面加载后通过AJAX请求 /api/goods/list 拉取JSON数据再动态渲染成DOM。商品详情页路由是 /item/12345详情页里有一部分内容是服务端渲染的比如商品标题、价格但更详细的描述、卖家联系方式、图片列表可能在一个嵌入式iframe里iframe的src指向另一个独立域名的页面。登录态部分校园集市要求登录后才能查看联系方式和完整信息这给爬虫带来额外麻烦。这类站点的核心特征就是静态壳子 动态数据 局部iframe嵌套。如果直接拿requests去请求HTML拿到的基本就是一堆空壳标签什么有效信息都提取不到。1.2 爬虫要采集的核心数据维度在设计爬虫之前先要明确目标字段。我根据朋友的分析需求把采集维度拆成了三个层次第一层是商品基本信息包括商品ID、标题、描述、价格、原价、图片列表、发布时间、更新时间、所在校区、商品分类、成色全新/几乎全新/轻微使用痕迹等。第二层是卖家与交互信息包括卖家昵称、卖家信用等级、是否实名认证、浏览量、收藏数、留言数。这里要特别说明涉及个人身份的信息需要谨慎对待我在项目里只采集了昵称和脱敏后的ID不采集手机号、微信号这类个人联系方式合规底线必须守住。第三层是衍生统计信息这部分不是直接爬的而是通过前两层数据在本地二次计算出来的比如某分类下的商品总数、价格区间分布、发布时段热度分布、二手教材的平均流转时间。采集字段要在正式写爬虫前就定为明确的Item结构否则爬到一半发现字段不够用再回头改Spider和Pipeline就非常难受了。1.3 合规边界先讲清楚爬虫这东西技术上是通的但合规问题必须摆在前面。我自己做的原则是这样的第一只采集公开可见的信息不绕过登录验证去抓需要账号权限才能看的数据。如果目标站点的信息需要登录后才能看到那就停在这里不要强行突破。第二遵守目标站点的robots.txt声明虽然Scrapy默认遵守但很多人会关掉我建议不要关至少对robots明确禁止的路径不要碰。第三控制请求频率设置合理的下载延迟不给目标站点造成压力。校园集市这类站点服务器资源通常有限高频请求不仅不道德还很容易触发反爬策略导致封IP。第四采集的数据仅用于学术研究或非商业用途不做倒卖、不做用户画像、不涉及个人隐私的深度挖掘。我在给朋友的项目里所有采集的数据都是脱敏后做统计分析用的这个定位要清晰。2. Scrapy项目结构与数据模型设计2.1 为什么选Scrapy而不是requests朋友一开始问我能不能用requests写我直接告诉他能但不建议。原因很实际纯粹的requests脚本在应对这种规模的爬虫任务时会遇到几个硬伤。第一个是并发控制。自己写线程池或者协程池要处理线程安全问题、请求失败重试、超时处理工作量不小且容易出bug。Scrapy内部基于Twisted异步框架自带完善的并发调度机制只需要改一个CONCURRENT_REQUESTS参数就能控制并发度省心太多了。第二个是数据管道。爬下来的数据要做清洗、去重、存储Scrapy的Item Pipeline机制天然支持分阶段处理每一步都是独立的类改起来互不干扰。第三个是中间件机制。处理UA伪装、Cookie管理、代理切换、重试策略全部可以做成独立的Downloader Middleware模块化程度高后期维护也方便。当然requests不是不能用我自己早期写爬虫也是requests起家的。但项目一旦上升到“需要持续运行、定期更新、数据量大、页面结构复杂”这个级别Scrapy的工程化优势会体现得特别明显。这次的校园集市项目后续还加了增量更新和定时调度用Scrapy底子打得好扩展起来非常顺。2.2 完整项目目录结构整个项目的目录结构如下我尽量保持Scrapy原生结构不做过度的自定义封装方便其他人拿到源码后能快速上手campus_market/ ├── scrapy.cfg # 项目配置文件 ├── requirements.txt # 依赖清单 └── campus_market/ ├── __init__.py ├── items.py # 数据模型定义 ├── middlewares.py # 中间件UA伪装、重试、代理 ├── pipelines.py # 数据清洗与存储管道 ├── settings.py # 项目全局配置 ├── spiders/ │ ├── __init__.py │ ├── market_list.py # 列表页爬虫 │ └── market_detail.py # 详情页爬虫 ├── utils/ │ ├── __init__.py │ ├── parser.py # 公共解析函数 │ └── sql_helper.py # 数据库操作辅助 └── run.py # 启动与调度入口2.3 Item数据模型的字段定义Item是Scrapy里定义“爬什么数据”的核心模型相当于数据字典。我在items.py里把字段分为三类来定义。第一类是商品基础字段import scrapy class MarketItem(scrapy.Item): # 商品基础信息 goods_id scrapy.Field() # 商品ID唯一标识 title scrapy.Field() # 商品标题 desc scrapy.Field() # 商品详细描述 price scrapy.Field() # 当前价格单位元 original_price scrapy.Field()# 原价可能为空 category scrapy.Field() # 分类名称 campus scrapy.Field() # 所属校区 condition scrapy.Field() # 成色描述 image_urls scrapy.Field() # 图片地址列表 publish_time scrapy.Field() # 发布时间 update_time scrapy.Field() # 最后更新时间 detail_url scrapy.Field() # 商品详情页URL第二类是互动与统计字段# 互动与统计信息 seller_nick scrapy.Field() # 卖家昵称 seller_level scrapy.Field() # 卖家信用等级 view_count scrapy.Field() # 浏览量 like_count scrapy.Field() # 收藏数 comment_count scrapy.Field() # 留言数第三类字段是抓取状态标记# 抓取状态字段 crawl_time scrapy.Field() # 本次抓取时间 md5_key scrapy.Field() # 去重键由goods_id生成我特别加了一个md5_key字段这是用来做全链路去重的。因为Scrapy自带的重复过滤是基于Request的URL去重的但同一个商品可能通过不同入口的URL访问所以必须在Item层面再做一次业务的唯一键去重。3. 核心难题动态Iframe页面的抓取方案3.1 校园集市里的Iframe到底藏了什么这个项目里最折腾人的就是动态iframe。朋友一开始用requests直接抓详情页发现HTML里能看到商品标题和价格但卖家的描述信息、成色说明、部分图片全都在一个iframe子页面里。而这个iframe的src是JavaScript动态生成的通过分析请求发现它是这样一段逻辑var itemId getUrlParam(id); var iframeSrc /embed/detail?item_id itemId scene generateSceneToken(itemId); $(#detailFrame).attr(src, iframeSrc);也就是说不执行这段JavaScript你根本不知道iframe的地址是什么。就算你强行从页面源码里拼一个iframe地址出来里面的scene参数也是动态token直接请求会返回403。解决这个问题的思路有三个方向我分别说下取舍。第一条路是用Selenium简单直接但重启动浏览器实例要浪费几百毫秒并发上不去跑几百个页面就慢得让人崩溃。第二条路是用Scrapy SplashSplash是个轻量级的JS渲染服务配合Scrapy还算顺手。但问题是Splash环境要单独维护一个Docker容器多一层部署复杂度对于一些简单的动态参数生成场景有点杀鸡用牛刀。第三条路是我最终选定的方案用Playwright做动态请求拦截在渲染页面时直接捕获浏览器发出的真实iframe接口请求然后把接口请求的URL和参数交给Scrapy去请求。这一套组合拳下来既拿到了iframe的真实地址又避免了让Scrapy直接去驱动浏览器性能和稳定性都有保证。3.2 具体思路用Playwright拦截真实请求说下这套方案的完整链路。先用Playwright打开详情页在页面加载过程中监听网络请求当看到符合 /embed/detail 的请求时把完整的URL包括动态token记录下来然后立刻关闭浏览器实例。这个URL就是iframe页面的真实地址Scrapy再拿这个地址去请求就能拿到完整的HTML内容。核心代码是这样写的# utils/dynamic_fetcher.py import asyncio from playwright.async_api import async_playwright async def fetch_iframe_url(page_url): iframe_real_url None async with async_playwright() as p: browser await p.chromium.launch( headlessTrue, args[--no-sandbox, --disable-blink-featuresAutomationControlled] ) context await browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, viewport{width: 1280, height: 800} ) page await context.new_page() # 监听网络请求捕获iframe的真实URL async def on_request(request): nonlocal iframe_real_url if /embed/detail in request.url: iframe_real_url request.url page.on(request, on_request) try: await page.goto(page_url, timeout15000, wait_untildomcontentloaded) await page.wait_for_timeout(1500) # 等待动态请求发出 except Exception: pass finally: await browser.close() return iframe_real_url这段代码的关键点在于wait_for_timeout(1500)这个时间不是随便拍的是反复测试出来的。页面加载完后iframe请求通常会在500毫秒到2秒之间发出等1.5秒基本能捕到。如果你要爬的站点响应比较慢可以把这个时间适当调高到3秒左右但不要设太长否则单位时间能处理的页面数量会明显下降。3.3 与Scrapy的整合流程Playwright负责“动态发现URL”拿到iframe请求地址后剩下的事情就交给Scrapy来处理。我在Spider里设计了这样一个流程# spiders/market_detail.py import scrapy from utils.dynamic_fetcher import fetch_iframe_url class MarketDetailSpider(scrapy.Spider): name market_detail def start_requests(self): # 从上一次列表页爬取结果中读取需要进入详情的商品链接 for url in self.get_pending_detail_urls(): yield scrapy.Request(url, callbackself.parse_detail) async def parse_detail(self, response): # 第一步从静态HTML中直接提取基础字段 goods_id response.url.split(/)[-1] title response.css(h1.goods-title::text).get() price response.css(span.price::text).get() # 第二步动态发现iframe真实地址 iframe_url await fetch_iframe_url(response.url) if iframe_url: # 让Scrapy去请求iframe页面解析详细描述 yield scrapy.Request(iframe_url, callbackself.parse_iframe, meta{goods_id: goods_id}) # 第三步组装Item基础字段 item MarketItem() item[goods_id] goods_id item[title] title item[price] price # ... 其他基础字段 yield item def parse_iframe(self, response): # 解析iframe页面的详细描述、图片、卖家信息等 desc response.css(div.detail-content::text).get() image_urls response.css(img.detail-img::attr(src)).getall() seller_nick response.css(span.seller-name::text).get() # 把iframe解析到的字段补充到Item里 # 这里通过meta机制把goods_id传过来做Item字段的合并 # 具体合并逻辑在Pipeline里统一处理 yield { goods_id: response.meta[goods_id], desc: desc, image_urls: image_urls, seller_nick: seller_nick }3.4 为什么不直接用Selenium或Splash这里说下我对比过的几种动态页面解决方案的优缺点给同样被iframe折磨的人一个参考。Selenium最大的问题是慢每个页面都要起一个真正的浏览器实例就为了捕捉一个iframe连接这个开销太浪费了。而且Selenium在headless模式下的稳定性也需要调优经常出现莫名其妙的元素定位失败。Splash相对轻一点但问题在于它只能执行页面里的JavaScript却没法像真实浏览器那样触发一些复杂的异步加载逻辑。有些站点的iframe地址是根据鼠标事件或滚动事件动态生成的Splash这种纯渲染引擎对这类场景支持不好。Playwright的好处在于它有完整的事件监听机制可以精确捕捉页面加载过程中发出的每一个网络请求哪怕这个请求是在页面渲染完成后500毫秒才发出的。它本质上是“监听者”而不是“渲染者”定位更精准。而且Playwright在headless模式下性能和资源占用都控制得很好实测下来单页面的额外开销大概几百毫秒比Selenium轻太多了。4. 中间件与反爬策略UA伪装、代理与限速调优4.1 User-Agent池与Cookie管理校园集市这类站点虽然反爬强度没有大厂那么高但基础的UA检测还是有的。如果所有请求都用一个默认UA跑不了几百个页面就会被识别为爬虫。我在middlewares.py里实现了一个随机UA中间件从几个常用浏览器UA里随机选一个挂到每个请求上# middlewares.py import random from scrapy import signals class RandomUserAgentMiddleware: def __init__(self): self.user_agents [ 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/16.6 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0 ] classmethod def from_crawler(cls, crawler): return cls() def process_request(self, request, spider): request.headers[User-Agent] random.choice(self.user_agents) return None利用Python自带的random.choice做随机UA选择虽然简单但对付基础的UA校验完全够用。要点是让UA的选择真正随机不要让同一个UA在一段时间内反复出现否则照样会被统计出来。Cookie管理这块校园集市的列表页和商品基础字段通常不需要登录就能看但如果你要采集的数据必须登录后才可见那就涉及登录态维护的问题。我的建议是能避开登录页面的就避开从公开渠道拿数据。如果实在绕不开可以使用Scrapy的CookieJar中间件手动把登录后的Cookie传给Spider。这里再强调一次只使用自己的账号、只采集自己权限范围内可见的数据不要尝试绕过权限控制。4.2 自动重试与请求失败处理爬虫跑时间长了必然遇到超时、连接重置、被限流等各种问题。Scrapy自带的RetryMiddleware提供了基础的重试能力但默认参数在很多场景下不够用。我根据校园集市站点的实际响应情况在settings.py里做了这样的配置# settings.py 关键代码 DOWNLOAD_TIMEOUT 15 DOWNLOAD_DELAY 1.5 CONCURRENT_REQUESTS 8 CONCURRENT_REQUESTS_PER_DOMAIN 4 RETRY_ENABLED True RETRY_TIMES 3 RETRY_HTTP_CODES [403, 408, 429, 500, 502, 503, 504]下载超时设为15秒是因为校园集市这类站点在高峰期的响应速度并不稳定时间太短容易误伤正常请求时间太长会让整个爬虫的吞吐量明显下降。下载延迟设为1.5秒配合每域名4个并发请求这个节奏对校园站点来说比较温和。特别注意我把403加进了重试的HTTP状态码里。不做这个处理之前偶尔会被站点反爬机制返回403Request直接就进错误日志了数据缺失率非常高。其实很多403只是临时的风控策略过几秒再请求就正常了。加上403重试后数据的完整率提升了不少。4.3 限速策略的调优思路Scrapy提供了AutoThrottle扩展它能根据服务器响应时间动态调整请求速率。我的建议是初期直接打开AutoThrottle默认配置让它在跑的过程中自行适配站点的承载能力。# settings.py AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 2.0 AUTOTHROTTLE_MAX_DELAY 15.0 AUTOTHROTTLE_TARGET_CONCURRENCY 4.0但在实际跑了几小时后我发现AutoThrottle在这个场景下保守了一点吞吐量上不去。后来我把策略调整为使用普通限速而不是AutoThrottle并手动设置DOWNLOAD_DELAY和CONCURRENT_REQUESTS_PER_DOMAIN。原因很简单校园集市站点的响应速度相对稳定不像大型电商那样有明显的动态波动在这个场景下固定限速更可控。你要是在爬那种负载波动很大的站点AutoThrottle会更合适。选择哪种策略关键是看目标站点的响应稳定性。4.4 IP代理不要轻易碰碰了就要用对关于IP代理很多人一上来就想挂代理池。我的观点是能不用就不用。校园集市这类站点的反爬重点是UA、频率和行为轨迹只要限速合理单IP完全扛得住。挂代理反而容易出问题——代理池质量参差不齐响应慢不说还经常被目标站点判定为高风险IP。如果你确实需要代理我建议买付费的高质量代理池不要用免费的来路不明的代理。在Scrapy里代理的配置方式很简单通过meta传给请求就行yield scrapy.Request( url, callbackself.parse, meta{ proxy: http://your_proxy_address:port } )但这里有个坑要提醒别忘了在代理失效时自动切换。我自己的处理方式是在中间件里监听异常当代理请求返回403或504时自动从代理池里换一个IP重新请求。否则一个坏代理能让爬虫卡死在一个页面上反复重试浪费大量时间。关于代理这块我自己在这个项目里实际没有用。原因很简单限速和UA伪装已经足够应对了没必要引入额外的复杂度和成本。5. Pipeline数据清洗与MySQL落库设计5.1 数据清洗的典型问题爬到数据后绝不能直接塞进数据库。校园集市这类平台的数据质量我见过不少问题这里举几个典型的例子。价格字段可能是带货币符号的字符串比如“¥18”或者“18.00元”直接存数据库会导致类型转换失败。发布时间可能是各种相对时间比如“3分钟前”“昨天 14:30”“2023-12-01 12:30:00”需要统一转成标准datetime格式。描述文本里混杂着HTML标签、不可见字符、重复空格需要做清理。同一个商品可能在多个列表页里被重复抓到同一个ID对应多条记录必须去重。所以Pipeline的第一步就是做标准化清洗。我写了一个独立的清洗类来处理这些脏数据核心逻辑如下# pipelines.py import re from datetime import datetime, timedelta class DataCleanPipeline: def process_item(self, item, spider): # 价格清洗去掉货币符号和多余空格转成float if item.get(price): item[price] float(re.sub(r[^\d.], , str(item[price]))) # 发布时间清洗处理x分钟前这类相对时间 raw_time item.get(publish_time) if raw_time: now datetime.now() if 分钟前 in raw_time: minutes int(re.search(r\d, raw_time).group()) item[publish_time] now - timedelta(minutesminutes) elif 小时前 in raw_time: hours int(re.search(r\d, raw_time).group()) item[publish_time] now - timedelta(hourshours) else: # 尝试标准格式解析失败则保留原值 try: item[publish_time] datetime.strptime(raw_time, %Y-%m-%d %H:%M:%S) except ValueError: item[publish_time] now # 描述文本清洗去HTML标签和多余空白 if item.get(desc): desc re.sub(r[^], , item[desc]) desc re.sub(r\s, , desc).strip() item[desc] desc return item5.2 去重Pipeline的设计细节去重这里有一个很容易踩的坑。Scrapy自带的RFPDupeFilter只能过滤掉URL完全相同的重复Request对于同一个商品通过不同入口URL访问的情况它是管不了的。比如 /item/12345 和 /item/12345?fromrecommendURL不同但其实是同一个商品。所以我在Pipeline里做了应用层去重基于商品ID生成MD5键在写入数据库之前先查一次这个键是否已经存在# pipelines.py import hashlib from utils.sql_helper import get_connection class DuplicateFilterPipeline: def process_item(self, item, spider): if not item.get(goods_id): return item # 构建业务唯一键 md5_key hashlib.md5(str(item[goods_id]).encode()).hexdigest() item[md5_key] md5_key conn get_connection() cursor conn.cursor() try: cursor.execute(SELECT id FROM goods WHERE md5_key%s, (md5_key,)) exists cursor.fetchone() if exists: # 已存在则跳过这里用DropItem通知Scrapy丢弃该Item raise scrapy.exceptions.DropItem(fDuplicate item: {item[goods_id]}) finally: cursor.close() conn.close() return item一个小细节这里的去重查询必须在同一个数据库连接里完成不要用连接池里的不同连接否则在高并发时可能出现两个连接同时查询到不存在然后同时插入导致重复数据的边界问题。我这个方法的思路比较直接就是用这个检查的粒度足够高在项目里够用。如果你对极端稳定性有要求可以在数据库的goods_id字段上加唯一索引作为兜底。5.3 MySQL建表与批量写入数据落库我用的是MySQLInnoDB引擎UTF-8mb4字符集。建表语句如下CREATE TABLE IF NOT EXISTS goods ( id INT AUTO_INCREMENT PRIMARY KEY, goods_id VARCHAR(64) NOT NULL COMMENT 商品唯一ID, title VARCHAR(255) NOT NULL COMMENT 标题, desc TEXT COMMENT 描述, price DECIMAL(10,2) COMMENT 当前价格, original_price DECIMAL(10,2) COMMENT 原价, category VARCHAR(64) COMMENT 分类, campus VARCHAR(64) COMMENT 校区, condition VARCHAR(64) COMMENT 成色, image_urls TEXT COMMENT 图片地址逗号分隔, publish_time DATETIME COMMENT 发布时间, update_time DATETIME COMMENT 更新时间, detail_url VARCHAR(512) COMMENT 详情页URL, seller_nick VARCHAR(128) COMMENT 卖家昵称, seller_level VARCHAR(32) COMMENT 卖家等级, view_count INT DEFAULT 0 COMMENT 浏览量, like_count INT DEFAULT 0 COMMENT 收藏数, comment_count INT DEFAULT 0 COMMENT 评论数, crawl_time DATETIME COMMENT 抓取时间, md5_key VARCHAR(32) NOT NULL COMMENT 业务去重键, UNIQUE KEY uk_goods_id (goods_id), KEY idx_category (category), KEY idx_publish_time (publish_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT校园集市商品表;写入这块千万要注意不要一条一条地insert性能太差了。我在Pipeline里做了批量缓存攒够一定条数后一次性插入# pipelines.py class MySQLStorePipeline: def __init__(self): self.items_buffer [] self.batch_size 50 def process_item(self, item, spider): if not item.get(goods_id): return item self.items_buffer.append(dict(item)) if len(self.items_buffer) self.batch_size: self._bulk_insert(self.items_buffer) self.items_buffer [] return item def close_spider(self, spider): if self.items_buffer: self._bulk_insert(self.items_buffer) self.items_buffer [] def _bulk_insert(self, items): conn get_connection() cursor conn.cursor() try: sql INSERT INTO goods (goods_id, title, desc, price, original_price, category, campus, condition, image_urls, publish_time, update_time, detail_url, seller_nick, seller_level, view_count, like_count, comment_count, crawl_time, md5_key) VALUES (%(goods_id)s, %(title)s, %(desc)s, %(price)s, %(original_price)s, %(category)s, %(campus)s, %(condition)s, %(image_urls)s, %(publish_time)s, %(update_time)s, %(detail_url)s, %(seller_nick)s, %(seller_level)s, %(view_count)s, %(like_count)s, %(comment_count)s, %(crawl_time)s, %(md5_key)s) cursor.executemany(sql, items) conn.commit() except Exception as e: conn.rollback() self.logger.error(f批量写入失败: {e}) finally: cursor.close() conn.close()批量插入的性能收益非常明显。我之前一条条插入跑1000条记录要五分钟改成批量50条一次后几秒钟就完成了。而且executemany的方式参数化处理天然避免了SQL注入风险。这里有一个细节要说明如果同一个商品在增量抓取中再次出现并且数据有变化比如价格变了、浏览量变了单纯的INSERT会直接报唯一键冲突。我建议加上ON DUPLICATE KEY UPDATE实现“存在就更新、不存在就插入”的upsert语义这样增量更新时数据才能保持一致。INSERT INTO goods (...) VALUES (...) ON DUPLICATE KEY UPDATE titleVALUES(title), priceVALUES(price), view_countVALUES(view_count), update_timeVALUES(update_time), crawl_timeVALUES(crawl_time)6. 列表页爬取、增量更新与定时调度6.1 列表页翻页与详情页入口列表页爬虫相对简单但里面有个小坑要说一下。校园集市列表页的翻页方式有些是传统的 ?page2有些是“点击加载更多”按钮触发的。这又是一个动态加载的场景。我的处理方式是先解析第一页HTML从页面底部的分页组件里读取总页数然后直接构造 ?pageN 的URL去请求。如果分页信息不在HTML里那就用Playwright模拟点击“加载更多”每点击一次记录新加载的商品链接直到没有更多内容为止。列表页解析的核心代码# spiders/market_list.py import scrapy from items import MarketItem class MarketListSpider(scrapy.Spider): name market_list def start_requests(self): base_url https://example-campus-market.com/market/list yield scrapy.Request(base_url ?page1, callbackself.parse_list) def parse_list(self, response): # 解析商品卡片列表 goods_cards response.css(div.goods-card) for card in goods_cards: detail_url card.css(a.goods-link::attr(href)).get() if detail_url: yield response.follow(detail_url, callbackMarketDetailSpider.parse_detail) # 翻页逻辑 current_page response.meta.get(page, 1) total_pages_text response.css(span.total-pages::text).get() if total_pages_text: total_pages int(total_pages_text) if current_page total_pages: next_page current_page 1 yield scrapy.Request( f/market/list?page{next_page}, callbackself.parse_list, meta{page: next_page} )这里我没有把列表页的每一页商品都记录到数据库而是直接从列表页提取详情页URL把具体数据解析交给详情页爬虫。这样职责更清晰列表页只负责“发现”详情页负责“采集”。6.2 增量更新策略拿到新增与变动爬虫不能只跑一次就完事校园集市的数据每天都在更新。增量更新的核心思路是先抓列表页找到当天新发布的商品然后对比数据库里已有的goods_id只对新增商品请求详情页对于已存在的商品可以定期重新请求详情页更新价格、浏览量、库存状态等动态字段。这里有一个省流量的技巧如果列表页已经展示了价格和浏览量那么更新这些基础字段根本不需要重新请求详情页直接从列表页的卡片信息里提取就行。只有标题变化、描述变化这类需要详情页数据的场景才值得额外消耗一次请求。我把增量更新逻辑写在了run.py里用一张单独的表来记录每个商品的最近抓取时间CREATE TABLE IF NOT EXISTS crawl_state ( id INT AUTO_INCREMENT PRIMARY KEY, goods_id VARCHAR(64) NOT NULL, last_crawl_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待抓取 1已抓取, UNIQUE KEY uk_goods_id (goods_id) );调度逻辑就是每天凌晨跑列表页爬虫发现新商品后把goods_id插入crawl_state表然后详情页爬虫从crawl_state表里读取所有状态为待抓取的商品逐个请求详情页。跑完一批更新一次状态。6.3 基于APScheduler的定时调度Scrapy本身是不带调度器的它只是一个爬虫框架需要外部工具来触发定时运行。我给朋友推荐的是APScheduler它是个Python原生的任务调度库轻量、可靠、不依赖外部服务。在run.py里的调度配置# run.py from apscheduler.schedulers.blocking import BlockingScheduler from scrapy.crawler import CrawlerProcess from scrapy.utils.project import get_project_settings from spiders.market_list import MarketListSpider from spiders.market_detail import MarketDetailSpider def run_list_spider(): process CrawlerProcess(get_project_settings()) process.crawl(MarketListSpider) process.start() def run_detail_spider(): process CrawlerProcess(get_project_settings()) process.crawl(MarketDetailSpider) process.start() if __name__ __main__: scheduler BlockingScheduler(timezoneAsia/Shanghai) # 每天早上8点跑列表页晚上10点跑详情页 scheduler.add_job(run_list_spider, cron, hour8, minute0) scheduler.add_job(run_detail_spider, cron, hour22, minute0) scheduler.start()时间安排也是有讲究的。早上8点是学生发布二手信息的高峰时段这个时间点跑列表页能抓到最多的新增商品晚上10点宿舍熄灯前后服务器压力小适合跑详情页做深度采集。如果你不想额外装APScheduler也可以用系统自带的crontab。但APScheduler的好处是整个调度逻辑都写在Python项目里部署和迁移时不用单独配系统定时任务对不熟悉Linux的同事更友好。6.4 断点续爬爬虫跑的过程难免中断可能是网络问题、服务器重启、数据库连接断开。如果中断后要从头开始爬前面积累的进度就全浪费了。Scrapy自带了一个jobs目录的机制可以保存爬虫的状态。只需要在启动命令里指定JOBDIRscrapy crawl market_detail -s JOBDIRcrawls/market_detail这样Scrapy会把已经处理的Request指纹和队列状态存在crawls/market_detail目录下下次用同一个JOBDIR启动时会从上次中断的地方继续。实际上在很多场景下特别是在增量抓取模式下由于我们已经在数据库里用md5_key做了去重即使爬虫重启后有部分URL被重新请求Pipeline也会自动把重复的Item丢掉所以整体影响很小。7. 运行监控、日志分析与常见问题排查7.1 用抓取统计数据判断爬虫健康度爬虫跑起来后你不能什么都不管就等结果。Scrapy的日志里其实自带一套统计指标每次关闭爬虫时都会打印类似这样的内容scraped_count: 1560, item_scraped_count: 1320, downloader/response_status_count/200: 1780, downloader/response_status_count/403: 25, duplicate_filter/dropped: 240, log_count/ERROR: 6这些指标是判断爬虫是否健康的第一手依据。我常用的判断标准是这样的item_scraped_count 和 downloader/response_status_count/200 的比例如果远低于1说明大量请求没有产出Item可能是页面结构变了解析规则失效。download/response_status_count/403 如果持续增加说明触发了反爬需要降低并发或调整UA策略。log_count/ERROR 如果持续增长要翻日志看具体异常类型是网络超时、解析错误还是数据库异常。我专门给这个项目写了一个简单的统计日志脚本每次跑完后把统计数据追加到一个本地文件里方便观察趋势变化。环比看数据比单看某一次运行更有价值。7.2 常见问题排查链路这里讲几个我实际遇到过的坑。第一个坑是Scrapy默认的User-Agent太明显了不设置的话所有请求都带一个scrapy/2.x的UA一抓一个准。如果没有配置UA中间件爬虫大概率在一两百个页面之后就开始碰到403。第二个坑是iframe页面解析后Item合并的竞态问题。因为一个详情页的基础字段是在parse_detail里yield的而iframe的补充字段是在parse_iframe里yield的这两个yield出来的Item在Pipeline里是两条独立的记录如果处理不当会导致同一个商品被插入两条数据。我的解决方案是在Pipeline里对同一个goods_id的Item做合并后到的字段追加到已有记录上最终以md5_key为键合并成一条完整记录再落库。具体做法是在Pipeline里维护一个有状态的字典根据goods_id把数据合并后再写库。第三个坑是Playwright在Linux服务器上运行需要安装系统依赖。直接跑会报浏览器启动失败。解决办法是执行 playwright install chromium并且在启动前用 playwright install-deps 安装运行chrome需要的系统库。这个步骤很容易漏掉一旦漏了爬虫在本地跑得好好的一到服务器就各种打不开浏览器。第四个坑是数据库连接池的问题。Scrapy的请求是异步并发的Pipeline里的数据库操作也可能并发执行。如果每个操作都新建连接数据库连接很容易爆掉。我的解决办法是写了一个简单的连接管理工具利用threading.local保证每个线程只有一条连接并且在用完即关的基础之上用连接池做兜底。核心原则就是数据库连接一定要复用不能拿一次请求新建一次连接。7.3 爬虫挂了怎么快速恢复在实际运维中爬虫挂掉是常态而不是意外。我的恢复流程很固定第一步查看日志中最后的报错内容判断是网络层问题、解析层问题还是存储层问题。第二步如果是网络层问题等几分钟后重新运行列表页爬虫利用断点续爬机制恢复。第三步如果是解析层问题大概率是目标站点改版了。打开浏览器手动访问一个详情页看页面结构变成了什么样然后更新Spider里的CSS选择器或XPath。这里有个经验是不要打补丁式地修而是要把解析代码封装成独立函数后期的维护成本会低很多。第四步如果是存储层问题检查MySQL连接状态确认表结构没变化后重跑一次即可。因为Pipeline里做了去重重复执行不会产生脏数据。8. 项目运行效果与性能数据跑了一段时间后我统计了一下这个爬虫的实际效果。在没有使用代理的情况下单IP、并发4个请求、下载延迟1.5秒每小时大约能抓取800到1000个详情页每天增量更新约200到300条新商品信息。抓下来的数据准确率方面在页面结构没有变化的前提下解析成功率在95%以上剩余的5%主要来自某些商品页面特殊布局或者数据缺失导致的字段为空。对于校园集市这种中小型站点来说这个吞吐量已经足够用了。如果目标是爬一个百万级商品的大型平台这个方案还需要在分布式方向上升级可以考虑使用Scrapy-Redis做分布式协调把多个爬虫节点串联起来。不过对于校园集市这个项目角度来说单机方案更合适部署成本低、稳定性也容易保证。在资源占用方面这个项目的内存占用稳定在200MB以内CPU占用在高峰期也只有30%左右。这是因为Playwright的浏览器实例是即用即关的并不会长期驻留避免了对资源的持续消耗。9. 个人经验总结这个项目做完之后我自己最大的体会是爬虫项目的难点从来不是“把数据抓下来”而是“长期稳定地把数据抓下来”。目标站点改一次页面结构你的所有解析逻辑可能就要跟着调整反爬策略升级一次你之前积累的调优经验可能就废了一半。所以写爬虫代码时一定要把解析逻辑、数据清洗逻辑、存储逻辑分开让它们的耦合度降到最低。这样即使页面改版你只需要改一个解析函数其他部分完全不用动。另外Scrapy这个框架本身确实值得深入学习。很多人对Scrapy的印象停留在“能写个spider跑一下”但其实它的Pipeline、Middleware、Extension机制都非常强大用好了能解决很多看似复杂的问题。就拿这次动态iframe的处理来说如果不会用Middleware去拦截请求就得换一条很麻烦的路。Scrapy真正难的地方在于理解它的异步机制和请求生命周期理解了这两个点之后你能玩出来的花样会多很多。如果你要用这个方案去爬其他类似站点我建议先把目标站点的页面结构摸清楚再动手写代码。爬虫这东西七分靠分析三分靠写码。分析做足了写代码只是水到渠成的事。最后再说一句一定记得在项目里加限速和合规控制这一行做得越久越明白控制好自己的爬虫节奏才是走远路的基础。本文还有配套的精品资源点击获取