Scrapy爬取校园集市:动态渲染与增量抓取实战
2026/9/5 11:48:50 网站建设 项目流程

简介:本资源是一套基于Python Scrapy框架开发的校园集市数据采集系统源码,面向Python初学者、爬虫实践者及高校信息分析相关课程学习者,旨在解决校园社区平台结构化数据自动化获取难题,支撑后续信息整理、热度趋势建模与情感分析等研究场景。压缩包共48个文件,含24个.py脚本(覆盖爬虫主逻辑、中间件、Pipeline、数据库连接、热词计算、BERT/Word2Vec相关性分析等核心模块)、11个.txt说明文档(含环境配置、使用指引与注意事项)、2个.sql数据库建表脚本、以及cfg、gitignore、LICENSE等工程必需文件,整体仅120KB,轻量易部署。已有51人下载学习,资源目录结构规范,按spiders、pipelines、utils、config等分层组织,内含完整可运行流程(从start.py启动到mysql_struct.sql建库),并提供多版本依赖文件(requirements.txt/.zbak)与备份机制,便于调试对比与环境复现。

1. 项目概述:为什么校园集市数据值得用Scrapy专门爬取?

“赞噢校园集市”这个名称一听就带着鲜明的场景特征——它不是泛泛而谈的电商大平台,而是聚焦高校学生群体、主打二手教材、闲置数码、考研资料、校园周边服务的垂直型本地化交易社区。我去年帮三所高校的创业社团做过数据支持,发现这类平台有四个典型特点:首页轮播图频繁更新但结构稳定;商品列表页采用分页+懒加载混合模式;详情页嵌套多个iframe展示卖家信用、物流状态、评价聚合;最关键的是,用户行为数据(如收藏数、浏览量、发布时间)几乎全部通过AJAX异步加载,且接口路径藏在JS变量里,不抓包根本找不到真实请求地址。这恰恰是Scrapy最擅长啃的硬骨头——它不像Requests+BeautifulSoup那样需要手动拼接URL、管理Session、解析JS上下文,而是通过Spider中间件+Downloader Middleware+Item Pipeline三层解耦架构,把“识别页面结构→提取URL规则→构造请求→解析响应→清洗存储”整个链条模块化。比如我们遇到的iframe问题,Scrapy本身不执行JS,但配合Splash或Playwright中间件就能自然接管渲染流程,比硬写Selenium脚本少掉80%的维护成本。再比如反爬策略,校园集市这类平台通常不会上Cloudflare人机验证,但会校验Referer、User-Agent、Cookie过期时间,Scrapy的ROTATING_PROXYRANDOM_USER_AGENT扩展包能自动轮换,连IP池都不用自己搭。所以这个项目标题里的“基于Python Scrapy框架”,不是为了凑技术名词,而是因为Scrapy在校园场景下具备不可替代的工程优势:它能把一个需要人工点开20个页面才能确认的“教材价格波动规律”,压缩成一条命令scrapy crawl textbook_trend -a start_date=2024-03-01就跑完全量数据。如果你正在做校园消费行为分析、二手书定价模型、或者想给学生开发比价插件,这套源码就是现成的弹药库。

2. 整体架构设计与核心思路拆解

2.1 为什么放弃Requests+BeautifulSoup而选择Scrapy?

很多人看到“爬虫”第一反应就是Requests,但我在实际项目中踩过太多坑。去年给某高校图书馆做的旧书回收系统,初期用Requests写了500行脚本,结果遇到三个致命问题:第一,当网站把商品列表从/list?page=1改成/api/v2/items?offset=0&limit=20时,所有正则匹配全部失效;第二,每次新增一个字段(比如加个“是否支持面交”标签),都要手动改XPath并测试20个页面;第三,当需要同时抓取商品页、卖家主页、评价详情页时,回调函数嵌套四层,调试起来像在迷宫里找出口。Scrapy的解决方案是把“变”和“不变”彻底分离:Spider只负责定义“去哪里抓”,Item定义“抓什么”,Pipeline负责“怎么存”。比如针对赞噢校园集市,我们定义ZanOhItem类时,字段名直接对应数据库表结构:

class ZanOhItem(scrapy.Item): item_id = scrapy.Field() # 商品唯一ID(用于去重) title = scrapy.Field() # 标题(清洗掉【急售】【学长转卖】等前缀) price = scrapy.Field() # 价格(字符串转float,处理“面议”“私聊”等异常值) original_price = scrapy.Field() # 原价(用于计算折扣率) seller_id = scrapy.Field() # 卖家ID(关联用户画像) view_count = scrapy.Field() # 浏览量(需从JS变量中提取) collect_count = scrapy.Field() # 收藏数(同上) publish_time = scrapy.Field() # 发布时间(转换为标准datetime格式) campus = scrapy.Field() # 所属校区(从URL路径或页面meta标签提取)

这样做的好处是,当网站把“浏览量”字段从<span class="views">128</span>改成<div>rules = ( Rule(LinkExtractor(allow=r'/list/campus/\d+/page/\d+'), follow=True), Rule(LinkExtractor(allow=r'/item/\d+'), callback='parse_item'), )

Scrapy会自动遍历所有符合规则的链接,根本不用手写循环翻页逻辑。这种设计思想本质上是把爬虫从“过程式编程”升级为“声明式编程”,让开发者专注业务逻辑而非底层调度。

2.2 动态内容处理:Scrapy + Playwright的协同机制

赞噢校园集市的详情页里,浏览量、收藏数、卖家信用分这些关键数据都藏在iframe里,而iframe的src是动态生成的。比如主页面有个<iframe src="/widget/stats?id=123456"></iframe>,但这个id参数其实是JS运行时从window.__INITIAL_STATE__对象里读出来的。Requests无法执行JS,Selenium又太重,这时候Playwright就成为最优解。我们的方案不是用Playwright替代Scrapy,而是让它作为Scrapy的“眼睛”——在Downloader Middleware里拦截请求,对需要渲染的URL调用Playwright获取HTML,再把结果交给Scrapy的标准解析流程。具体实现分三步:

  1. 识别需要渲染的页面:在Spider的start_requests方法里,给目标URL打上playwright=True标记;
  2. Middleware接管请求:自定义PlaywrightMiddleware,检测到playwright=True就启动Playwright实例,等待#stats-container元素出现后再截图;
  3. 无缝传递响应:把Playwright返回的HTML字符串包装成HtmlResponse对象,Scrapy后续流程完全无感。

这样做的好处是,90%的静态页面走Scrapy原生流程(毫秒级响应),只有10%的动态页面才启动Playwright(耗时约1.2秒),整体效率比全程用Playwright高3倍。实测下来,抓取1000个商品详情页,纯Scrapy方案要12分钟,而混合方案只要4分30秒——因为Playwright只在必要时才启动,且可以复用浏览器上下文。

2.3 数据去重与增量抓取:Redis作为中央调度器

校园集市的数据更新频率很高,教材类商品可能一天内价格变动3次,但全量抓取既浪费带宽又增加服务器压力。我们的解决方案是用Redis做两件事:一是URL指纹去重,二是增量时间戳记录。Scrapy-Redis扩展包已经封装了RFPDupeFilter类,但默认只对URL去重,而赞噢校园集市存在“同一商品不同校区显示不同价格”的情况,所以我们要把campus_id也纳入指纹计算。修改settings.py

DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter' SCHEDULER = "scrapy_redis.scheduler.Scheduler" SCHEDULER_PERSIST = True REDIS_URL = 'redis://localhost:6379' # 自定义指纹生成逻辑 def request_fingerprint(request): from scrapy.utils.request import request_fingerprint # 基础指纹 + 校区参数 base_fp = request_fingerprint(request) campus = request.meta.get('campus_id', 'all') return f"{base_fp}_{campus}"

更关键的是增量抓取。我们在Redis里维护一个last_crawl_time键,每次抓取前先读取这个时间戳,然后只请求publish_time > last_crawl_time的商品。Spider启动时自动更新这个键:

def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.redis_client = redis.Redis.from_url(settings.REDIS_URL) self.last_time = self.redis_client.get('last_crawl_time') or b'2024-01-01' self.redis_client.set('last_crawl_time', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))

这样既能保证数据新鲜度,又避免重复抓取历史数据。实测表明,在日均新增2000条商品的校园集市上,增量抓取使带宽消耗降低76%,CPU占用下降42%。

3. 核心细节解析与实操要点

3.1 Spider编写:从页面结构到代码映射的完整链路

以抓取“教材类商品”为例,我们先人工分析页面结构。打开https://zanoh.edu.cn/list/campus/101/page/1(假设101是北大校区ID),发现商品列表是标准的<div class="item-card">容器,每个卡片包含标题、价格、发布时间。但注意两个陷阱:第一,价格显示为“¥35.00”或“面议”,需要统一处理;第二,发布时间是“2小时前”“昨天”这类相对时间,必须转换为绝对时间戳。Scrapy的CSS选择器能精准定位:

def parse_list(self, response): for card in response.css('div.item-card'): item = ZanOhItem() item['item_id'] = card.css('::attr(data-id)').get() item['title'] = card.css('h3.title::text').get().strip() # 价格清洗:移除¥符号,处理“面议” price_text = card.css('span.price::text').get() if price_text and '面议' not in price_text: item['price'] = float(re.sub(r'[^\d.]', '', price_text)) else: item['price'] = None # 时间转换:将“2小时前”转为datetime time_text = card.css('span.time::text').get() item['publish_time'] = self.parse_relative_time(time_text) # 构造详情页URL(注意:这里用绝对URL避免相对路径错误) detail_url = response.urljoin(card.css('a::attr(href)').get()) yield scrapy.Request( url=detail_url, callback=self.parse_item, meta={'item': item} # 把基础字段传给详情页解析 )

这里的关键技巧是response.urljoin()——很多新手直接拼接字符串,结果遇到/item/123这种相对路径就404。而urljoin会自动补全协议和域名,鲁棒性极强。另一个重点是meta参数,它像快递单号一样把初步提取的数据带到下一个回调函数,避免在详情页重新解析标题和价格,节省30%的CPU时间。

3.2 动态字段提取:从JS变量中“抠”出隐藏数据

详情页的浏览量和收藏数藏在<script>标签里,类似这样:

<script> window.__INITIAL_STATE__ = { "stats": { "viewCount": 156, "collectCount": 23, "sellerCredit": 98.7 } }; </script>

Scrapy原生不解析JS,但我们用正则表达式精准捕获:

def parse_item(self, response): item = response.meta['item'] # 提取JS变量中的统计信息 script_content = response.css('script:contains("__INITIAL_STATE__")::text').get() if script_content: # 匹配JSON片段 match = re.search(r'window\.__INITIAL_STATE__\s*=\s*({.*?});', script_content, re.DOTALL) if match: try: stats_data = json.loads(match.group(1)) item['view_count'] = stats_data.get('stats', {}).get('viewCount', 0) item['collect_count'] = stats_data.get('stats', {}).get('collectCount', 0) item['seller_credit'] = stats_data.get('stats', {}).get('sellerCredit', 0) except json.JSONDecodeError: self.logger.warning(f"Failed to parse stats JSON for {response.url}") # 其他静态字段继续用CSS提取 item['original_price'] = response.css('span.original-price::text').get() item['campus'] = response.css('meta[name="campus"]::attr(content)').get() yield item

这个方案比用Playwright渲染整个页面快10倍,因为正则匹配毫秒级完成。但要注意re.DOTALL标志,否则多行JS代码里的换行符会让正则失败。另外,try-except必不可少——线上环境总有JS格式意外变化,不能让单个页面错误导致整个爬虫崩溃。

3.3 Item Pipeline数据清洗:不只是存数据库

Pipeline是Scrapy的“质检车间”,我们在这里做三件事:类型转换、业务校验、数据增强。以价格字段为例,原始数据可能是字符串“¥35.00”或数字35,Pipeline统一转为float:

class DataCleaningPipeline: def process_item(self, item, spider): # 价格标准化 if item.get('price') and isinstance(item['price'], str): item['price'] = float(re.sub(r'[^\d.]', '', item['price'])) # 时间标准化 if item.get('publish_time') and isinstance(item['publish_time'], str): # 处理ISO格式和中文格式 if '年' in item['publish_time']: item['publish_time'] = datetime.strptime(item['publish_time'], '%Y年%m月%d日 %H:%M') else: item['publish_time'] = datetime.fromisoformat(item['publish_time'].replace('Z', '+00:00')) # 业务校验:教材价格不能低于1元或高于500元 if item.get('price') and not (1 <= item['price'] <= 500): spider.logger.warning(f"Invalid price {item['price']} for {item.get('title')}") item['price'] = None # 数据增强:计算折扣率(如果有原价) if item.get('price') and item.get('original_price'): try: item['discount_rate'] = round((item['original_price'] - item['price']) / item['original_price'], 2) except (ZeroDivisionError, TypeError): item['discount_rate'] = 0 return item

这里有个重要经验:Pipeline必须返回item,否则数据流中断。很多新手忘记return item,结果数据消失得莫名其妙。另外,spider.logger.warningprint()更专业,日志会自动带上爬虫名称和时间戳,方便排查问题。

3.4 存储方案选型:MySQL vs MongoDB的实战权衡

校园集市数据有强关系特征:商品属于某个校区,卖家有多个商品,评价关联商品和用户。最初我们用MongoDB,觉得JSON存储灵活,但很快遇到三个问题:第一,查“北大校区所有价格低于30元的教材”需要聚合管道,响应慢;第二,卖家信用分要实时计算,MongoDB的原子操作不如MySQL可靠;第三,导出数据给BI工具时,MongoDB的BSON格式兼容性差。最终切换到MySQL,并设计了三张核心表:

-- 商品主表 CREATE TABLE zanoh_items ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id VARCHAR(32) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, price DECIMAL(10,2), original_price DECIMAL(10,2), view_count INT DEFAULT 0, collect_count INT DEFAULT 0, publish_time DATETIME NOT NULL, campus VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 卖家表(独立存储,避免商品表冗余) CREATE TABLE zanoh_sellers ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seller_id VARCHAR(32) UNIQUE NOT NULL, credit_score DECIMAL(5,2) DEFAULT 0, join_date DATE ); -- 关联表(商品-卖家) CREATE TABLE item_seller_link ( item_id VARCHAR(32) NOT NULL, seller_id VARCHAR(32) NOT NULL, FOREIGN KEY (item_id) REFERENCES zanoh_items(item_id), FOREIGN KEY (seller_id) REFERENCES zanoh_sellers(seller_id) );

Scrapy的SQLAlchemyPipeline能自动映射Item字段到表结构,但要注意item_id作为业务主键,必须在MySQL里设为UNIQUE,否则重复插入会报错。我们还在Pipeline里加了ON DUPLICATE KEY UPDATE逻辑:

def process_item(self, item, spider): stmt = insert(ZanOhItemModel).values(**dict(item)) stmt = stmt.on_conflict_do_update( index_elements=['item_id'], set_=dict( title=item['title'], price=item['price'], view_count=item['view_count'], updated_at=datetime.now() ) ) self.session.execute(stmt) self.session.commit() return item

这样即使网络抖动导致重复提交,数据也不会错乱。实测表明,MySQL方案使复杂查询速度提升5倍,且支持标准SQL导出,教研团队直接用Excel连接就能分析。

4. 实操过程与核心环节实现

4.1 环境搭建:避开Python版本和依赖冲突的深坑

Scrapy官方推荐Python 3.8+,但校园集市的JS渲染依赖Playwright,而Playwright 1.32+要求Python 3.9+。我们实测发现,用Python 3.11.5是最稳妥的选择——它兼容所有主流Scrapy扩展,且Playwright的Chromium二进制包下载成功率100%。安装步骤必须严格按顺序:

# 1. 创建隔离环境(绝对不要用系统Python) python -m venv scrapy_env source scrapy_env/bin/activate # Linux/Mac # scrapy_env\Scripts\activate # Windows # 2. 升级pip(避免旧版pip安装wheel失败) pip install --upgrade pip # 3. 安装核心依赖(注意scrapy-playwright必须在scrapy之前) pip install scrapy-playwright pip install scrapy redis playwright # 4. 下载Playwright浏览器(关键!很多新手卡在这步) playwright install chromium # 5. 验证安装(运行这个命令应该输出Scrapy版本) scrapy version

最大的坑在于playwright install。如果网络不稳定,它会卡在下载Chromium,此时不要Ctrl+C,而是用playwright install-deps先装系统依赖,再重试。Linux用户特别注意:Ubuntu需要sudo apt-get install libnss3 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxdmcp1 libxext6 libxfixes3 libxi6 libxkbcommon0 libxml2 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libasound2 libatk-bridge2.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libpci3 libpulse0 libxcomposite1 libxcursor1 libxdamage1 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 libgbm1 libxshmfence1 libegl1 libgles2,否则Playwright启动失败。

4.2 Spider配置:settings.py里的12个关键参数

settings.py是Scrapy的“控制中枢”,我们根据校园集市特性调整了12个核心参数:

# 1. 并发控制(校园集市服务器扛不住高并发) CONCURRENT_REQUESTS = 8 CONCURRENT_REQUESTS_PER_DOMAIN = 4 # 2. 请求延迟(模拟真人操作) DOWNLOAD_DELAY = 1.5 # 每次请求间隔1.5秒 RANDOMIZE_DOWNLOAD_DELAY = True # 在0.5~2倍间随机 # 3. 用户代理池(避免被封) USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' # 启用随机UA扩展 DOWNLOADER_MIDDLEWARES = { 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None, 'scrapy_user_agents.middlewares.RandomUserAgentMiddleware': 400, } # 4. 反爬应对(Referer必须匹配) DEFAULT_REQUEST_HEADERS = { 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', 'Referer': 'https://zanoh.edu.cn/', # 强制Referer } # 5. Cookie持久化(维持登录态) COOKIES_ENABLED = True COOKIES_DEBUG = True # 开发时开启,查看Cookie变化 # 6. 重试机制(校园集市偶尔502) RETRY_TIMES = 3 RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429] # 7. 下载超时(避免卡死) DOWNLOAD_TIMEOUT = 30 # 8. 编码处理(中文网页常见GBK) FEED_EXPORT_ENCODING = 'utf-8' # 如果页面是GBK,加这个中间件 DOWNLOADER_MIDDLEWARES.update({ 'myproject.middlewares.GbkEncodingMiddleware': 300, }) # 9. 日志级别(生产环境用WARNING,开发用DEBUG) LOG_LEVEL = 'INFO' LOG_FILE = 'scrapy.log' # 10. 爬取深度限制(避免爬到无关页面) DEPTH_LIMIT = 3 # 11. 自动限速(根据响应时间动态调整) AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 1 AUTOTHROTTLE_MAX_DELAY = 3 # 12. Redis配置(增量抓取必需) REDIS_URL = 'redis://localhost:6379/0'

其中AUTOTHROTTLE_ENABLED是神器——它会监控服务器响应时间,自动调整并发数。比如当响应时间从200ms升到800ms,Scrapy会把CONCURRENT_REQUESTS从8降到4,避免触发反爬。我们实测发现,开启后爬虫稳定性提升90%,基本告别“Connection refused”错误。

4.3 运行与调试:从本地测试到分布式部署

本地开发阶段,我们用scrapy crawl zanoh_spider -a campus_id=101 -s LOG_FILE=debug.log启动,参数-a传递校区ID,-s覆盖settings参数。关键调试技巧:

  • 检查请求URL:在Spider的start_requests里加self.logger.info(f"Start crawling campus {self.campus_id}")
  • 验证Selector:用scrapy shell 'https://zanoh.edu.cn/list/campus/101/page/1'进入交互模式,直接测试response.css('div.item-card')
  • 查看Pipeline流程:在Pipeline的process_item里加self.logger.debug(f"Processed item: {item}")

当本地验证通过,就部署到服务器。我们用Scrapyd做分布式管理:

# 1. 安装Scrapyd pip install scrapyd scrapyd-client # 2. 配置scrapyd.conf [scrapyd] eggs_dir = eggs dbs_dir = dbs logs_dir = logs items_dir = items jobs_to_keep = 5 max_proc = 0 max_proc_per_cpu = 4 finished_to_keep = 100 # 3. 打包项目 scrapyd-client build # 4. 部署到服务器(假设服务器IP是192.168.1.100) scrapyd-client deploy -p zanoh_spider 192.168.1.100:6800

部署后,通过curl http://192.168.1.100:6800/schedule.json -d project=zanoh_spider -d spider=zanoh_spider -d campus_id=101触发远程爬取。Scrapyd会返回任务ID,用curl "http://192.168.1.100:6800/logs/zanoh_spider/zanoh_spider/任务ID.log"实时查看日志。这个方案比手动SSH登录服务器执行命令高效10倍,且支持多校区并行爬取——只需发10个curl请求,Scrapyd自动分配到不同CPU核心。

4.4 数据质量保障:建立三层校验体系

爬取的数据必须可信,我们构建了三层校验:

第一层:Spider内实时校验
parse_item里加入业务规则检查:

if not item.get('title') or len(item['title']) < 5: self.logger.error(f"Invalid title for {response.url}") return # 直接丢弃脏数据

第二层:Pipeline数据清洗校验
如前所述,价格范围、时间格式、空值处理都在Pipeline完成。

第三层:Post-Crawl数据审计
爬取完成后,运行独立审计脚本:

# audit.py import pandas as pd from sqlalchemy import create_engine engine = create_engine('mysql+pymysql://user:pass@localhost/zanoh') df = pd.read_sql("SELECT * FROM zanoh_items WHERE created_at > DATE_SUB(NOW(), INTERVAL 1 DAY)", engine) # 检查异常值 print("价格异常商品:", df[(df['price'] < 1) | (df['price'] > 500)].shape[0]) print("时间异常商品:", df[pd.to_datetime(df['publish_time'], errors='coerce').isna()].shape[0]) print("重复商品ID:", df['item_id'].duplicated().sum()) # 生成质量报告 with open('audit_report.txt', 'w') as f: f.write(f"总记录数: {len(df)}\n") f.write(f"价格异常: {df[(df['price'] < 1) | (df['price'] > 500)].shape[0]}\n") f.write(f"时间异常: {df[pd.to_datetime(df['publish_time'], errors='coerce').isna()].shape[0]}\n")

这个脚本每天凌晨自动运行,邮件发送报告。上线三个月,数据准确率保持在99.7%以上,教研团队反馈“比人工采集还准”。

5. 常见问题与排查技巧实录

5.1 403 Forbidden:Referer和User-Agent的组合拳

校园集市的反爬第一道关就是403。我们发现,单纯换User-Agent没用,必须同时伪造Referer。错误做法是只在DEFAULT_REQUEST_HEADERS里设Referer,但Scrapy的start_requests会忽略这个设置。正确方案是:

def start_requests(self): urls = [f'https://zanoh.edu.cn/list/campus/{self.campus_id}/page/1'] for url in urls: yield scrapy.Request( url=url, headers={ 'Referer': 'https://zanoh.edu.cn/', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' }, callback=self.parse_list )

更彻底的方案是写一个Middleware,自动为所有请求添加Referer:

class RefererMiddleware: def process_request(self, request, spider): if 'zanoh.edu.cn' in request.url: request.headers['Referer'] = 'https://zanoh.edu.cn/'

启用这个Middleware后,403错误率从35%降到0.2%。关键是Referer必须是目标域名的根路径,不能是https://zanoh.edu.cn/list/这种子路径,否则服务器校验失败。

5.2 动态URL失效:从JS中提取真实API地址

有些页面的“查看更多”按钮是JS生成的,点击后XHR请求/api/more-items?offset=20&limit=20。Scrapy无法监听点击事件,但我们能从页面JS里“挖”出API地址:

def parse_list(self, response): # 查找JS文件链接 js_urls = response.css('script[src*="bundle"]::attr(src)').getall() for js_url in js_urls: full_js_url = response.urljoin(js_url) # 下载JS文件并搜索API模式 js_response = requests.get(full_js_url) api_match = re.search(r'fetch\("(/api/more-items\?.*?)"', js_response.text) if api_match: api_url = response.urljoin(api_match.group(1)) yield scrapy.Request(url=api_url, callback=self.parse_api_items)

这个技巧让我们绕过前端渲染,直击数据源头。实测API响应速度比HTML页面快4倍,且数据结构更干净。

5.3 Redis连接拒绝:权限与端口的隐形陷阱

ConnectionRefusedError: [Errno 111] Connection refused是Redis最常见的错误。排查顺序必须是:

  1. 确认Redis服务运行systemctl status redis-server(Linux)或redis-server --version(Mac)
  2. 检查端口是否监听netstat -tuln | grep 6379,如果没输出说明Redis没启动
  3. 验证防火墙sudo ufw status,确保6379端口开放
  4. 测试连接redis-cli -h 127.0.0.1 -p 6379 ping,返回PONG才算通
  5. 检查密码:如果Redis设置了密码,REDIS_URL必须是redis://:password@localhost:6379/0

我们曾遇到一次诡异问题:本地测试正常,部署到服务器就报错。最后发现是云服务器安全组没放行6379端口,而错误提示和连接超时一模一样。所以永远先ping再查日志。

5.4 Playwright渲染空白:Chromium沙箱权限问题

Linux服务器上Playwright常报TimeoutError: Timeout 30000ms exceeded,实际是Chromium沙箱权限不足。解决方案是启动时加参数:

# 在settings.py里 PLAYWRIGHT_LAUNCH_OPTIONS = { "headless": True, "args": [ "--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage" ] }

--no-sandbox是关键,它关闭Chromium沙箱,虽然安全性略降,但在爬虫场景可接受。加上后渲染成功率从45%升到99.8%。

5.5 数据丢失:Pipeline未返回item的静默故障

这是最隐蔽的Bug。当Pipeline里写了逻辑但忘记return item,Scrapy会认为数据处理失败,直接丢弃。症状是:日志显示Scraped from <200 https://...>,但数据库里没数据。排查方法:

  • 在Pipeline开头加self.logger.debug("Pipeline started")
  • 在结尾加self.logger.debug("Pipeline finished")
  • 如果只看到开头日志,说明卡在中间某行

我们强制规定:所有Pipeline必须以return item结尾,哪怕只是pass。团队代码审查时第一条就是检查这个。

提示:Scrapy的LOG_LEVEL = 'DEBUG'能暴露所有内部流程,但会产生海量日志。建议只在问题时段临时开启,用grep "pipeline" scrapy.log快速定位。

注意:不要在Pipeline里做耗时操作(如调用外部API),否则会阻塞整个爬虫队列。必须异步处理的逻辑,应移到单独的服务里,Pipeline只做轻量清洗。

6. 源码结构与可复用模块设计

6.1 项目目录树:为什么这样组织?

zanoh_spider/ ├── scrapy.cfg ├── zanoh_spider/ │ ├── __init__.py │ ├── items.py # Item定义(业务字段) │ ├── middlewares.py # 自定义中间件(Playwright、Referer) │ ├── pipelines.py # 数据清洗与存储 │ ├── spiders/ │ │ ├── __init__.py │ │ └── zanoh_spider.py # 主Spider │ ├── settings.py # 全局配置 │ └── utils/ │ ├── __init__.py │ ├── time_parser.py # 时间解析工具 │ └── price_cleaner.py # 价格清洗工具 └── requirements.txt

这种结构的核心理念是“关注点分离”。items.py只管数据结构,pipelines.py只管数据流转,spiders/只管页面逻辑。当需要支持新站点(比如“知源二手书”),只需新建spiders/zhixuan_spider.py,复用items.pypipelines.py,开发时间缩短70%。

6.2 可复用工具模块详解

utils/time_parser.py是我们提炼的精华:

from datetime import datetime, timedelta import re def parse_relative_time(text): """解析“2小时前”“昨天”“3天前”等相对时间""" if not text: return datetime.now() # 匹配“X小时前” hour_match = re.search(r'(\d+)小时前', text) if hour_match: hours = int(hour_match.group(1)) return datetime.now() - timedelta(hours=hours) # 匹配“昨天” if '昨天' in text: return datetime.now() - timedelta(days=1) # 匹配“X天 <p> <a href="https://download.csdn.net/download/2501_91537435/92481862" 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>

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

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

立即咨询