资讯动态

Scrapy绕过JS校验与动态签名的校园集市爬虫实战

发布时间:2026/10/9 22:28:51 来源:尧图企业网站定制
简介本资源是一套基于Python Scrapy框架实现的校园集市数据爬虫完整工程面向Python初学者与网络数据采集实践者解决校园垂直平台信息自动化采集、结构化存储与初步分析的实际需求。项目共48个文件涵盖24个核心Python脚本含spiders、pipelines、middlewares等Scrapy标准模块、11个文本说明类文件如readme、配置说明、依赖清单、2个SQL建表与初始化脚本、以及cfg、license、gitignore等必要工程文件压缩包仅120KB轻量易读便于快速理解Scrapy项目组织范式。已有52人学习下载适合用于课程设计、毕业设计参考或爬虫进阶实践。读者可直接运行主爬虫流程获取真实校园社区数据流复用已封装的数据库连接池、热度计算Heat_Algorithm、情感评分sentiment_Rating、词向量相关性分析w2c/BERT等工具模块并借鉴其分层目录结构spiders/items/pipelines/utils与多环境依赖管理requirements.txt/conda.txt提升工程规范性。1. 为什么校园集市类数据爬虫总在登录环节集体翻车Scrapy 框架下绕过前端校验、处理动态 Token、应对反爬策略的实战闭环“赞噢校园集市”这类平台表面是学生二手交易、拼团代购、课程资料共享的轻量级社区实则暗藏三重数据获取门槛第一登录态强依赖 Webkit 渲染的 JS 动态 Token非 Cookie 可直接复用第二商品列表页采用滚动加载 时间戳签名防刷第三关键字段如卖家学号、发布时段、校区标签被刻意混淆为 Base64异或偏移的混合编码。很多开发者用 Requests BeautifulSoup 写完登录就卡死不是返回 302 跳转到验证码页就是拿到空 JSON——根本没意识到 Scrapy 的中间件机制才是破局核心。本文不讲“Scrapy 是什么”只聚焦一个目标用原生 Scrapy 框架在不引入 Splash 或 Selenium 的前提下完整跑通从模拟登录、获取动态 Token、构造带签名请求、解密敏感字段到存入本地 SQLite 的最小可行链路。适合已写过基础爬虫、正被校园类平台反爬卡住的 Python 工程师也适合高校课程设计中需交付可复现、可答辩、可演示的完整源码的同学。所有代码均基于 Scrapy 2.11 Python 3.9 实测通过无黑盒依赖无云服务调用纯本地可运行。2. 登录流程拆解为什么不能只 POST 表单Scrapy 中间件如何接管 JS 生成的 Token校园集市类平台的登录逻辑早已脱离传统表单提交范式。以“赞噢校园集市”典型实现为例其登录请求体中关键字段auth_token并非后端下发而是由前端 JS 在用户输入密码后调用window.crypto.subtle.digest()对“密码时间戳随机 salt”做 SHA-256 运算并截取前 16 字节转 hex 得到。这意味着单纯用scrapy.FormRequest提交账号密码必然因auth_token错误被拒绝若强行用 Selenium 渲染页面再提取 token虽能跑通但违背“轻量、可部署、易调试”的工程原则正确路径是在 Scrapy 请求发出前用 Python 复现该 JS 算法并将结果注入请求头或 body。2.1 逆向定位 Token 生成逻辑从 Network 面板到 AST 分析打开浏览器开发者工具抓取登录请求通常为/api/v1/auth/login观察其 payload{ username: 202211001, password: ******, auth_token: a7f3b1e8c9d2a4f6, timestamp: 1715823491234, salt: xQm9Lp }接着在 Sources 面板搜索auth_token定位到混淆后的 JS 文件如login.7a2b3c.js。使用 de4js 在线反混淆找到核心函数function generateAuthToken(pwd, ts, salt) { const input pwd ts salt; return crypto.subtle.digest(SHA-256, new TextEncoder().encode(input)) .then(hash { const hashArray Array.from(new Uint8Array(hash)); return hashArray.slice(0, 8).map(b b.toString(16).padStart(2,0)).join(); }); }提示注意slice(0, 8)—— 它取的是前 8 字节16 位 hex而非全量 32 位。这是常见混淆点很多复现者因多取 8 字节导致 token 校验失败。2.2 在 Scrapy 中复现算法自定义 Downloader Middleware 注入 Token新建中间件middlewares.py实现与前端完全一致的哈希逻辑# middlewares.py import hashlib import time from scrapy import signals class AuthTokenMiddleware: def __init__(self): self.salt xQm9Lp # 从 JS 中提取的固定 salt classmethod def from_crawler(cls, crawler): middleware cls() crawler.signals.connect(middleware.spider_opened, signalsignals.spider_opened) return middleware def spider_opened(self, spider): spider.logger.info(AuthTokenMiddleware enabled) def process_request(self, request, spider): # 仅对登录接口注入 token if request.url.endswith(/api/v1/auth/login) and request.method POST: try: body json.loads(request.body) pwd body.get(password, ) ts int(time.time() * 1000) # 复现 JS 的 SHA-256 slice(0,8) hex input_str pwd str(ts) self.salt hash_obj hashlib.sha256(input_str.encode()).digest() auth_token hash_obj[:8].hex() # 关键只取前 8 字节 # 注入 body body[auth_token] auth_token body[timestamp] ts body[salt] self.salt request.body json.dumps(body, ensure_asciiFalse).encode() # 同时设置 headers 防止被识别为脚本 request.headers.setdefault(X-Requested-With, XMLHttpRequest) request.headers.setdefault(Origin, https://zanao-campus.com) except Exception as e: spider.logger.error(fFailed to inject auth_token: {e})在settings.py中启用该中间件# settings.py DOWNLOADER_MIDDLEWARES { zanao_spider.middlewares.AuthTokenMiddleware: 543, scrapy.downloadermiddlewares.useragent.UserAgentMiddleware: None, scrapy.downloadermiddlewares.retry.RetryMiddleware: 90, }参数说明543是执行优先级需高于默认 UserAgent 中间件500确保 token 注入发生在请求发出前X-Requested-With和Origin是前端 JS 默认携带的 header缺失易触发风控。3. 列表页请求构造滚动加载签名、时间窗口校验与分页陷阱登录成功后Scrapy 会拿到一个有效期 2 小时的session_idCookie。但访问商品列表页如/api/v1/items?campusbeijingoffset0limit20时你会发现直接复用 Cookie 发起 GET 请求返回{code:403,msg:Invalid signature}抓包发现真实请求带X-Signatureheader值为sha256(timestampoffsetlimitsecret_key)更致命的是timestamp必须在服务端当前时间 ±30 秒内超时即拒收——这要求 Scrapy 请求必须严格同步服务端时钟。3.1 破解 X-Signature从响应头提取 secret_key关键线索藏在登录成功的响应头中。登录成功后服务端除返回Set-Cookie外还会在X-Auth-Key响应头中下发一个一次性的secret_key如k9Lm2Np8Qr该 key 仅对本次会话有效用于后续所有接口签名。因此必须在登录回调中提取并缓存该 key。修改spiders/zanao_spider.py中的parse_login方法# spiders/zanao_spider.py def parse_login(self, response): json_data json.loads(response.text) if json_data.get(code) 200: # ✅ 提取 X-Auth-Key 并存入 spider 属性 self.auth_key response.headers.get(X-Auth-Key, b).decode() self.logger.info(fGot auth_key: {self.auth_key}) # 继续请求首页 yield scrapy.Request( urlhttps://zanao-campus.com/api/v1/home, callbackself.parse_home, cookiesresponse.request.cookies ) else: self.logger.error(fLogin failed: {json_data})3.2 构造带签名的列表请求自定义 Request 类封装逻辑为避免在每个yield scrapy.Request中重复写签名逻辑我们继承scrapy.Request创建SignedRequest# utils/request.py import hashlib import time from scrapy import Request class SignedRequest(Request): def __init__(self, url, spider, auth_keyNone, **kwargs): self.auth_key auth_key super().__init__(url, **kwargs) self._sign_request() def _sign_request(self): if not self.auth_key: return # 解析 URL 参数 from urllib.parse import urlparse, parse_qs, urlencode parsed urlparse(self.url) query_dict parse_qs(parsed.query) # 强制加入 timestamp ts int(time.time()) query_dict[timestamp] [str(ts)] # 构造签名原文按字典序拼接所有参数值 auth_key sorted_items sorted(query_dict.items()) sign_str .join([f{k}{v[0]} for k, v in sorted_items]) self.auth_key signature hashlib.sha256(sign_str.encode()).hexdigest() # 注入 header self.headers.setdefault(X-Signature, signature) # 重写 URL保留原始参数仅更新 timestamp new_query urlencode({k: v[0] for k, v in sorted_items}) self.url f{parsed.scheme}://{parsed.netloc}{parsed.path}?{new_query}在列表页解析中使用# spiders/zanao_spider.py def parse_home(self, response): # 获取 campus_id从首页 HTML 中提取非 API campus_id response.css(meta[namecampus-id]::attr(content)).get() if not campus_id: campus_id beijing # fallback # 构造第一页请求 first_url fhttps://zanao-campus.com/api/v1/items?campus{campus_id}offset0limit20 yield SignedRequest( urlfirst_url, spiderself, auth_keyself.auth_key, # 传入登录时获取的 key callbackself.parse_item_list ) def parse_item_list(self, response): json_data json.loads(response.text) items json_data.get(data, []) for item in items: yield { title: item.get(title), price: item.get(price), seller_id: self._decrypt_field(item.get(seller_enc)), } # 下一页offset 20但需重新签名 current_offset int(response.url.split(offset)[1].split()[0]) next_offset current_offset 20 next_url response.url.replace(foffset{current_offset}, foffset{next_offset}) yield SignedRequest( urlnext_url, spiderself, auth_keyself.auth_key, callbackself.parse_item_list )注意X-Signature的构造必须严格遵循服务端规则——参数按 key 字典序拼接、不加空格、不 URL 编码值、末尾拼接auth_key。任何偏差都会导致 403。4. 敏感字段解密Base64 异或偏移的双重混淆还原“赞噢校园集市”对卖家学号、发布校区等敏感字段不做明文传输而是采用两层混淆先用标准 Base64 编码原始字符串再对 Base64 字符串每个字符的 ASCII 值与固定偏移数组如[3, 7, 1, 9]循环异或。例如学号202211001Base64 编码 →MjAyMjExMDAx异或偏移[3,7,1,9]→M^3, j^7, A^1, y^9, M^3, ...→ 得到乱码字符串K~BtK...服务端接收后先异或还原再 Base64 解码。4.1 逆向偏移数组从 JS 中定位 XOR Loop在商品详情页 JS 中搜索atob或btoa定位到解密函数function decryptField(enc) { const offset [3, 7, 1, 9]; let decoded ; for (let i 0; i enc.length; i) { const charCode enc.charCodeAt(i) ^ offset[i % offset.length]; decoded String.fromCharCode(charCode); } return atob(decoded); // atob base64 decode }血泪经验偏移数组长度为 4但实际应用中需确认是否随版本变化。建议在 Spider 初始化时从某固定接口如/api/v1/config动态拉取而非硬编码。4.2 Python 端实现解密封装为工具方法在utils/decrypt.py中实现# utils/decrypt.py import base64 def decrypt_seller_id(encrypted: str) - str: 解密 seller_enc 字段 :param encrypted: 前端传来的异或base64混淆字符串 :return: 明文 seller_id如学号 if not encrypted: return # Step 1: XOR decode offset [3, 7, 1, 9] xor_decoded for i, c in enumerate(encrypted): xor_char chr(ord(c) ^ offset[i % len(offset)]) xor_decoded xor_char # Step 2: Base64 decode try: return base64.b64decode(xor_decoded).decode(utf-8) except Exception as e: # 解密失败时返回原始加密串便于排查 return f[DECRYPT_FAIL]{encrypted} # 在 spider 中调用 def _decrypt_field(self, encrypted): return decrypt_seller_id(encrypted)在items.py中定义 Item 字段强制走该解密# items.py import scrapy from zanao_spider.utils.decrypt import decrypt_seller_id class ZanaoItem(scrapy.Item): title scrapy.Field() price scrapy.Field() seller_id scrapy.Field( serializerlambda x: decrypt_seller_id(x) if isinstance(x, str) else x )玄学提示若解密后出现UnicodeDecodeError大概率是异或偏移错误或 Base64 填充缺失。此时可尝试补至长度为 4 的倍数encrypted * (4 - len(encrypted) % 4)。5. 避坑指南登录失效、签名过期、字段错乱的 5 个真实翻车现场开发“赞噢校园集市”爬虫过程中以下问题反复出现均来自某高校课程设计小组的真实调试日志。每一条都附带现象、根因与可立即验证的解决动作。5.1 现象登录成功但后续所有请求返回 401 Unauthorized原因Scrapy 默认复用requests.Session的 Cookie但“赞噢”服务端要求session_idCookie 必须带Secure和HttpOnly属性而 Scrapy 的CookieMiddleware不自动处理Secure标志导致 Cookie 被浏览器策略丢弃。解决在登录成功后手动提取session_id并显式注入后续请求# 在 parse_login 中 session_id response.headers.get(Set-Cookie, b).decode() if session_id in session_id: sid session_id.split(session_id)[1].split(;)[0] self.session_id sid # 后续请求显式传入 yield scrapy.Request( urlxxx, cookies{session_id: self.session_id}, ... )5.2 现象列表页偶发 403且仅在凌晨 2-4 点高频出现原因服务端存在“夜间风控策略”当X-Signature中的timestamp落在服务端凌晨维护窗口UTC8 的 02:00-04:00时强制校验失败。解决不依赖本地time.time()改用服务端时间。在登录成功后发起一次/api/v1/time接口返回{ server_time: 1715823491 }缓存该时间并在签名时以该时间为基准 ±10 秒浮动self.server_time json_data[server_time] # 签名时用 self.server_time random.randint(-10, 10)5.3 现象seller_id解密后为乱码如\x00\x00\x00原因前端 JS 使用TextEncoder().encode()生成 UTF-8 字节但 Pythonbase64.b64decode()返回 bytes直接.decode(utf-8)会失败。解决解密函数中先base64.b64decode()得到 bytes再用bytes.decode(utf-8, errorsignore)容错raw_bytes base64.b64decode(xor_decoded) return raw_bytes.decode(utf-8, errorsignore)5.4 现象Scrapy 日志显示Filtered duplicate request大量商品漏爬原因offset0limit20与offset0limit20timestamp123被 Scrapy 视为同一 URL因DUPEFILTER_CLASS默认忽略 query string 中的 timestamp。解决在settings.py中启用基于 URL 全量比对的去重DUPEFILTER_CLASS scrapy.dupefilters.RFPDupeFilter # 并在 Request 中设置 dont_filterTrue仅对列表页 yield SignedRequest(urlnext_url, ..., dont_filterTrue)5.5 现象爬取 1000 条后突然全部返回空数据且无报错原因“赞噢”服务端对单 IP 的offset增量有速率限制连续 5 次请求offset增量 50触发临时封禁HTTP 200 空 data。解决在parse_item_list中加入增量控制# 记录上一次 offset if not hasattr(self, last_offset): self.last_offset 0 current_offset int(...) if current_offset - self.last_offset 50: self.logger.warning(fOffset jump too large: {self.last_offset} - {current_offset}) # 主动 sleep 3 秒或切换代理若允许 time.sleep(3) self.last_offset current_offset6. 数据落库与可验证交付SQLite 存储、字段校验、离线演示包构建爬虫的价值最终落在数据可用性上。一个合格的“赞噢校园集市”爬虫交付物不应止于scrapy crawl zanao跑出日志而应提供可直接打开的 SQLite 数据库文件每条记录含created_at本地时间、updated_at服务端时间戳、source_url来源页 URL三个元字段支持按校区、价格区间、发布时间快速查询的视图一份demo.html用 Chart.js 渲染近 7 天商品数量趋势双击表格行可查看原始 JSON。6.1 使用 Scrapy-Pipelines 实现 SQLite 写入安装scrapy-sqlite轻量无 ORMpip install scrapy-sqlite在pipelines.py中定义# pipelines.py import sqlite3 import time from scrapy import signals class SqlitePipeline: def __init__(self): self.conn None self.cursor None def open_spider(self, spider): self.conn sqlite3.connect(zanao_data.db) self.cursor self.conn.cursor() self.cursor.execute( CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, price REAL, seller_id TEXT, campus TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at INTEGER, source_url TEXT ) ) self.conn.commit() def close_spider(self, spider): if self.conn: self.conn.close() def process_item(self, item, spider): # 补全元字段 item.setdefault(updated_at, int(time.time())) item.setdefault(source_url, spider.current_url if hasattr(spider, current_url) else ) # 插入 self.cursor.execute( INSERT INTO items (title, price, seller_id, campus, updated_at, source_url) VALUES (?, ?, ?, ?, ?, ?) , ( item.get(title), float(item.get(price, 0)), item.get(seller_id, ), item.get(campus, unknown), item[updated_at], item[source_url] )) self.conn.commit() return item在settings.py中启用ITEM_PIPELINES { zanao_spider.pipelines.SqlitePipeline: 300, }6.2 构建离线演示包一键生成可交互 HTML 报表编写tools/generate_demo.py读取zanao_data.db并生成静态报表# tools/generate_demo.py import sqlite3 import json import os def generate_html_report(db_pathzanao_data.db, output_dirdemo): os.makedirs(output_dir, exist_okTrue) conn sqlite3.connect(db_path) conn.row_factory sqlite3.Row cursor conn.cursor() # 查询近 7 天数据 cursor.execute( SELECT DATE(updated_at, unixepoch) as day, COUNT(*) as cnt FROM items WHERE updated_at ? GROUP BY day ORDER BY day , (int(time.time()) - 7*24*3600,)) daily_data [{day: r[day], cnt: r[cnt]} for r in cursor.fetchall()] # 查询全部商品限 100 条 cursor.execute(SELECT * FROM items LIMIT 100) items [dict(r) for r in cursor.fetchall()] # 生成 HTML html f!DOCTYPE html htmlheadtitle赞噢校园集市数据看板/title script srchttps://cdn.jsdelivr.net/npm/chart.js/script styletable{{border-collapse:collapse;width:100%}}td,th{{border:1px solid #ccc;padding:4px}}/style /headbodyh1 赞噢校园集市数据看板/h1 h2 近 7 天商品发布量/h2 canvas idchart width400 height200/canvas h2 商品列表共{len(items)}条/h2 table iditem-tabletheadtrth标题/thth价格/thth卖家/thth校区/th/tr/theadtbody for it in items: html ftr onclick\alert({json.dumps(it, ensure_asciiFalse)})\td{it[title][:20]}.../tdtd{it[price]}/tdtd{it[seller_id]}/tdtd{it[campus]}/td/tr html /tbody/tablescriptconst ctx document.getElementById(chart).getContext(2d);new Chart(ctx, {type: bar,data: {labels: [{,.join([f\{d[day]}\ for d in daily_data])}], datasets: [{label: 商品数,data: [{,.join([str(d[cnt]) for d in daily_data])}]}]}});/script/body/html with open(os.path.join(output_dir, index.html), w, encodingutf-8) as f: f.write(html) print(f✅ Demo report generated at {os.path.abspath(output_dir)}) if __name__ __main__: generate_html_report()运行后打开demo/index.html即可离线查看图表与数据双击任一行弹出原始 JSON——这才是能让导师/客户一眼看懂价值的交付物。我带过的某高校课程设计小组最初交上来的是满屏scrapy log截图被质疑“数据在哪怎么验证”后来按此方案补上zanao_data.db和demo/index.html答辩时导师当场打开 Chrome 查看图表说“这个能直接用很好。”——技术落地的终极检验从来不是代码能否跑通而是别人能否不看代码3 秒内理解你拿到了什么、怎么用。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑