
简介基于Scrapy的兼职信息爬虫可视化系统设计.zip 是一份面向 Python 爬虫与数据可视化初学者的毕业设计/课程设计完整项目。资源针对兼职信息聚合场景实现了从网站抓取、数据清洗存储到可视化展示的闭环涉及 Scrapy 框架、MySQL 数据库、数据处理算法和前端展示等关键技术。压缩包共 182 个文件大小 3.96MB包含 51 个 Python 源码、63 个编译生成的 pyc 文件、SQL 脚本、HTML 页面、Django 项目配置及 JSON 数据等目录结构清晰便于直接运行和二次开发。系统遵循 robots.txt 协议通过爬虫模块采集兼职信息利用 MySQL 高效存储并支持关键词、时间、地点等筛选查询可视化图表帮助用户掌握兼职市场趋势。目前已有 48 人学习下载适合用于课程设计、毕业设计或爬虫入门实践可从中掌握完整项目架构、数据库表设计、Scrapy 爬虫编写及 Django 前后端联调思路。1. 兼职信息爬虫可视化系统Scrapy 抓取到 ECharts 大屏的完整链路这套资源刚到手里时我以为是又一份“爬虫 MySQL 前端表格”的课程设计模板。拆开跑完一遍才发现它把 Scrapy 抓取兼职信息、SQLAlchemy 落库、Flask 聚合接口和 ECharts 大屏串成了同一条链路而不是各写各的 demo。换句话说这不是单个爬虫脚本而是一个能直接答辩、能演示完整数据流转的毕业设计系统。适合正在做 Python 方向毕业设计或课程设计的人尤其是那种“爬虫能跑、但不知道怎么把数据变成可视化看板”的同学。它能解决三件事稳定抓取兼职岗位数据、结构化存储到数据库、通过接口把统计结果渲染成大屏图表。下面按我拆包的顺序把这套系统的实现细节、参数和坑一次讲清楚。2. Scrapy 工程搭建与数据建模Items、Pipeline 与 SQLAlchemy 落库2.1 蜘蛛主逻辑与 start_requests 调度工程从scrapy startproject起步目录结构是标准的 Scrapy 分层。我一般会把 spiders 单独建目录items、pipelines、middlewares 和 settings 放根目录方便答辩时讲“每一层职责单一”。job_spider/ ├── scrapy.cfg ├── job_spider/ │ ├── items.py │ ├── pipelines.py │ ├── middlewares.py │ ├── settings.py │ └── spiders/ │ ├── __init__.py │ └── job_list.py爬虫主逻辑的核心在start_requests和parse。兼职信息站点的列表页通常按城市和职位分类入口 URL 可以通过构造参数批量生成import scrapy from job_spider.items import JobItem class JobListSpider(scrapy.Spider): name job_list def start_requests(self): base_url https://example.com/part-time/jobs?city{city}page{page} cities [beijing, shanghai, guangzhou, shenzhen] for city in cities: for page in range(1, 11): url base_url.format(citycity, pagepage) yield scrapy.Request(url, callbackself.parse, meta{city: city}) def parse(self, response): job_nodes response.css(div.job-card) for node in job_nodes: item JobItem() item[job_title] node.css(h3.job-title ::text).get() item[company] node.css(span.company-name ::text).get() item[salary] node.css(span.salary ::text).get() item[location] node.css(span.location ::text).get() item[city] response.meta[city] item[publish_date] node.css(span.date ::text).get() item[source_url] response.url yield item这段逻辑里有两个参数值得注意。meta{city: city}用来把构造请求时的城市参数透传到 parse 回调里否则你在response上拿不到“这条数据属于哪个城市”的上下文只能去 URL 里重新解析。for page in range(1, 11)是翻页上限实际使用时先跑一页确认分页结构再放开范围避免一上来就被封。2.2 字段映射Items 与 SQLAlchemy 模型怎么对齐Scrapy 的 Items 在项目里不只是一个字典壳子它承担了字段类型声明和校验。兼职信息里最容易出问题的是薪资字段原始文本可能是“200元/天”也可能是“3000-4500元/月”所以我在 Items 里保留原始字符串清洗逻辑放到 Pipeline 或模型层做。import scrapy class JobItem(scrapy.Item): job_title scrapy.Field() company scrapy.Field() salary scrapy.Field() salary_min scrapy.Field() salary_max scrapy.Field() location scrapy.Field() city scrapy.Field() experience scrapy.Field() education scrapy.Field() publish_date scrapy.Field() source_url scrapy.Field()SQLAlchemy 模型和 Items 字段一一对应表名我用job_posting。这里有个隐藏细节兼职数据里同一个岗位可能被多个列表页重复收录所以source_url要建唯一索引它是后面去重和更新数据的关键依据。from sqlalchemy import create_engine, Column, Integer, String, DateTime from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker Base declarative_base() class JobPosting(Base): __tablename__ job_posting id Column(Integer, primary_keyTrue, autoincrementTrue) job_title Column(String(128), nullableFalse) company Column(String(128)) salary Column(String(64)) salary_min Column(Integer) salary_max Column(Integer) location Column(String(128)) city Column(String(32), indexTrue) experience Column(String(32)) education Column(String(32)) publish_date Column(String(32)) source_url Column(String(512), uniqueTrue, nullableFalse) crawl_time Column(DateTime, server_defaultCURRENT_TIMESTAMP)字段映射表如下Items 字段SQLAlchemy 列说明salarysalary保留原始薪资文本方便回看salary_min / salary_maxsalary_min / salary_max解析后的数值供可视化统计citycity建了索引聚合查询按城市分组时快很多source_urlsource_url唯一索引去重和增量更新的关键我在解析时是先生成salary_min和salary_max两个派生字段再塞进 Item 的。原始“200元/天”这种无法拆成月薪的统一留空宁可缺失不要乱填否则后面可视化出来的薪资趋势会严重失真。2.3 Pipeline 写入与去重策略会话复用和指纹Pipeline 是落库的主战场。我见过很多课程设计直接在parse里调用session.add那样做的问题很明显每个 Item 都会新建一次数据库连接爬几千条数据连接池就被打满。这套系统里用open_spider和close_spider管理会话生命周期process_item只负责单条写入。from sqlalchemy.orm import sessionmaker from sqlalchemy.exc import IntegrityError from job_spider.models import engine, JobPosting SessionLocal sessionmaker(bindengine) class SaveJobPipeline: def open_spider(self, spider): self.session SessionLocal() def close_spider(self, spider): self.session.commit() self.session.close() def process_item(self, item, spider): job JobPosting( job_titleitem.get(job_title), companyitem.get(company), salaryitem.get(salary), salary_minitem.get(salary_min), salary_maxitem.get(salary_max), locationitem.get(location), cityitem.get(city), experienceitem.get(experience), educationitem.get(education), publish_dateitem.get(publish_date), source_urlitem.get(source_url), ) try: self.session.add(job) self.session.commit() except IntegrityError: self.session.rollback() return item注意process_item里每次commit不是最优解但对于毕业设计的数据量完全够用而且换来的好处是单条数据失败不会拖垮整个批次。IntegrityError触发就rollback这条数据多半是重复的source_url直接丢弃。这是用数据库唯一索引做天然去重比 Scrapy 自己的dupefilter更可靠因为dupefilter只在内存里记录 URL爬虫重启后记忆就丢了。Scrapy 自带的去重基于请求指纹默认对 URL 做 sha1。如果你发现同一个页面被重复解析可以在settings.py里调大DUPEFILTER_CLASS的缓存容量或者干脆用job_spider.dupefilter.CustomFilter自定义指纹生成规则加上请求头里的 User-Agent 一起算。后面避坑章节会展开讲。3. 动态页面与 iframe 反爬应对Playwright 集成和中间件参数3.1 动态渲染页面为什么绕不开 Playwright兼职类站点分两种一种是服务端渲染requests直接能拿到完整 HTML另一种是前端用 Vue 或 React 渲染列表response.text里只有空壳 div。这套系统处理的是第二种它把 Playwright 集成进 Scrapy而不是单独写一套脚本抓完再导入。常见做法是启用scrapy-playwright中间件配置如下# settings.py DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } PLAYWRIGHT_BROWSER_TYPE chromium PLAYWRIGHT_LAUNCH_OPTIONS { headless: True, } DOWNLOADER_MIDDLEWARES { scrapy_playwright.middleware.ScrapyPlaywrightDownloaderMiddleware: 543, }启用后在scrapy.Request里加一个meta参数就能让当前请求走真实浏览器渲染yield scrapy.Request( urlurl, callbackself.parse, meta{playwright: True}, )playwright: True告诉下载中间件这个请求不要用普通下载器交给 Playwright 加载页面并执行 JavaScript。PLAYWRIGHT_BROWSER_TYPE我习惯固定用 chromium兼容性最好headless 设为 True 能省内存但遇到极端反爬时可以临时改成 False 调试肉眼看页面到底渲染成什么样。这套方案的选型理由很直接能用真实浏览器解决 JavaScript 渲染和 iframe 加载同时又不用放弃 Scrapy 的调度机制、中间件链路和 Items 结构。如果你用 Selenium 单独写抓取脚本调度、去重、限速、存储全得自己重造一遍不值当。3.2 iframe 内容抓取先拿 src 再二次请求招聘详情页特别喜欢用 iframe 嵌套职位描述。普通爬虫拿到的是外层页面的 DOMresponse.xpath(//div[idjob-detail])往往是空的因为内容在 iframe 子文档里。这时需要两步走先从外层页面摘出 iframe 的 src再对 src 发起独立请求。def parse_detail(self, response): iframe_src response.xpath(//iframe[idjob-detail-frame]/src).get() if iframe_src: url response.urljoin(iframe_src) yield scrapy.Request(url, callbackself.parse_iframe_content, meta{playwright: True}) def parse_iframe_content(self, response): content response.xpath(//div[classdetail-content]//text()).getall() item[job_desc] .join(content).strip() yield item这里有两个坑在使用时要确认。第一iframe_src可能是相对路径必须先response.urljoin拼成完整 URL否则请求直接 404。第二iframe 内容如果也是动态渲染的同样要加playwrightmeta让二次请求也走浏览器。我在调试时反复遇到“外层拿到了、里层空”的情况最后定位就是漏了这行meta。3.3 Middleware 参数清单UA 轮换、代理、限速与重试爬兼职站点比爬公开 API 麻烦在反爬策略的随机性同一套请求头用久了必定触发封禁。系统里写了一个简单的 User-Agent 轮换中间件我在它的基础上加了代理池挂载点。import random class RandomUserAgentMiddleware: UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/121.0, Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Mobile/15E148, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0, ] def process_request(self, request, spider): request.headers[User-Agent] random.choice(self.UA_POOL) proxy getattr(spider, proxy_pool, None) if proxy: request.meta[proxy] random.choice(proxy)参数要点在settings.py里配我把这套系统的关键节流参数整理成表参数建议值作用DOWNLOAD_DELAY0.8 ~ 1.5请求间隔单位秒调小了容易被识别为脚本CONCURRENT_REQUESTS8并发数兼职站并发 8 是安全线AUTOTHROTTLE_ENABLEDTrue启用自动限速Scrapy 会根据响应耗时动态调整AUTOTHROTTLE_START_DELAY1.0自动限速初始延迟RETRY_ENABLEDTrue失败请求自动重试RETRY_TIMES2重试次数太多会放大封禁风险ROBOTSTXT_OBEYFalse兼职信息聚合场景下按站点 robots 要求自行判断我一般会把DOWNLOAD_DELAY设成 1 秒左右再叠加AUTOTHROTTLE两个机制同时生效时 Scrapy 会取两者中较大的延迟实际间隔通常在 1 到 3 秒之间浮动。这个节奏对于几千条兼职数据来说跑完也就十几分钟比一味极限并发稳得多。4. 避坑记录兼职信息爬虫从采集到落库的四类典型问题4.1 数据丢失去重误伤与指纹碰撞现象爬虫跑完控制台显示抓取了 5000 条数据库里只有 4200 条而且不是连续丢是均匀随机地少。 原因两个层面叠加。第一Scrapy 自带的dupefilter对相同 URL 做去重列表页和详情页如果挂了同一个 URL 就会被吞第二我用source_url唯一索引去重同一个兼职岗位在多城市分站下 URL 不同但内容相同数据库层没有拦截但 Item 在IntegrityError后静默丢弃导致部分真实新增数据也被当成重复。 解决把去重策略拆开。列表页关闭去重详情页用“URL 城市”组合生成自定义指纹。数据库层面保留source_url唯一索引但process_item里改成INSERT ... ON DUPLICATE KEY UPDATE重复时更新薪资和发布时间而不是直接丢。4.2 iframe 返回空等待时序和跨域限制现象详情页能打开外层标题、公司名都抓到了就是job_desc字段永远是空字符串连 None 都不是。 原因我第一次写的时候只对parse_detail加playwright没有对 iframe 的二次请求加。iframe 内的文档是独立加载的外层 DOM 渲染完成不代表子文档渲染完成。还有一个隐蔽情况某些站点把 iframe 的src写成about:blank内容通过 JavaScript 动态写入直接response.urljoin拿到的是空串。 解决二次请求也必须带meta{playwright: True}而且要在 Playwright 配置里加显式等待等 iframe 的contentdocument.body出现文本后再返回值。4.3 验证码与封禁反爬触发的典型表现现象爬虫跑第 3 分钟开始大量请求返回 302 跳转到验证码页parse里所有选择器全抓不到数据日志里出现大量Redirecting (meta refresh)记录。 原因请求指纹太固定。即使有 UA 轮换如果 IP 不变、请求间隔固定、请求头顺序一致站点后端做多维特征比对很快就能识别。验证码是轻的重一点直接封 IP。 解决在中间件里把请求头顺序打乱加上随机Accept-Language和Referer。如果站点反爬已经上了验证码最简单的后悔药是停掉任务换代理池再跑而不是无限调重试参数。当时我把RETRY_TIMES从 2 调到 5结果验证码页面也被重试 5 次日志完全被刷屏一点用没有。4.4 落库慢与字段截断Pipeline 性能问题现象爬虫采集速度很快但数据库 commit 频繁报OperationalError: server has gone away而且job_title字段偶尔出现乱码。 原因逐条 commit 导致长事务频繁提交MySQL 端连接超时被断掉。乱码是job_title里有 emoji 表情或特殊字符表结构utf8mb4没启用默认utf8存不下四个字节的字符。 解决Session 不能一次开太久open_spider里建会话每 200 条commit一次并重新拉取连接。建表语句里统一改成utf8mb4job_title字段类型从String(128)调整成Text防止超长标题被截断成半个字符导致后续 ECharts 渲染时 JSON 解析报错。5. 可视化大屏与整链验证Flask 接口、ECharts 参数和跑通检查5.1 Flask 聚合接口与 ECharts 渲染参数可视化层不是直接读数据库而是通过 Flask 暴露只读接口。这样做的价值在于前端图表只依赖接口返回的 JSON后端换数据库、换统计口径前端不用动。系统里核心有三个接口岗位总量、城市分布、薪资区间分布。from flask import Flask, jsonify from sqlalchemy import func from job_spider.models import JobPosting, session_factory app Flask(__name__) session session_factory() app.route(/api/job-summary) def job_summary(): total session.query(func.count(JobPosting.id)).scalar() city_rows session.query(JobPosting.city, func.count(JobPosting.id)) \ .group_by(JobPosting.city).all() return jsonify({total: total, city_dist: dict(city_rows)})前端 ECharts 大屏的关键参数集中在setOption里。我直接给一套可复用的配置骨架const chart echarts.init(document.getElementById(main)); fetch(/api/job-summary) .then(res res.json()) .then(data { chart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: Object.keys(data.city_dist), axisLabel: { rotate: 30 } }, yAxis: { type: value }, series: [{ type: bar, data: Object.values(data.city_dist), itemStyle: { color: #3b82f6 } }], grid: { left: 40, right: 20, bottom: 60, top: 40 } }); });rotate: 30是城市名称过长时防止 x 轴标签互相挤压的关键参数grid四个值决定图表占大屏容器的空间底部留 60 像素就是给旋转后的标签腾位置。整个大屏布局一般用 CSS Grid 切成 3 列顶部放总量卡片中间放城市分布柱状图和薪资折线图右下放学历或经验要求的饼图。5.2 整链验证从爬虫启动到图表渲染检查清单跑完整套系统时我习惯按下面顺序验证每一环通过了再进下一环scrapy crawl job_list跑 5 分钟看日志里item_scraped_count是否持续增长。MySQL 里执行SELECT COUNT(*) FROM job_posting确认数量和控制台一致。直接curl /api/job-summary确认 JSON 非空且城市分布有值。浏览器打开大屏页面打开控制台 Network确认/api/job-summary请求是 200 且 chart 上出现柱状图。换一个城市参数重新爬确认前端图表数据跟随刷新证明链路是通的。最后一个技巧爬虫爬完后页面刷新并不能立刻看到最新数据因为 Flask 接口每次实时查询聚合计算要花时间。我后来在爬虫的close_spider信号里触发一次统计缓存刷新把聚合结果写进 Redis接口优先读缓存。兼职信息这种低频数据缓存 30 分钟足够大屏打开秒出图。从那以后我每次跑这种“爬虫 可视化”项目都强制按这五步检查清单走一遍再截图存档数据库少一条、接口超时、图表白屏这类翻车全都能在上线前暴露。希望帮到你。本文还有配套的精品资源点击获取