☰
招投标推荐可视化系统实战:Flask+Vue+爬虫+推荐算法
2026/9/29 18:07:10 网站建设 项目流程

做过招投标的人都懂,每天上班第一件事就是刷各个公共资源交易平台,翻公告、筛资质、记时间,一不小心漏掉一条和自己业务匹配的项目,半个月业绩就悬了。我做的这套“浙江招投标推荐可视化系统”,核心就干三件事:用爬虫把公开的招投标公告抓下来,用推荐算法把公告和企业画像做匹配,再用Vue把匹配结果做成可视化的驾驶舱。整个系统基于Flask提供接口、SQLAlchemy存数据,前端走Vue,整套东西不大,但能把“人肉刷公告”变成“系统推荐+地图展示”的工作流。这套东西适合三类人看:想给自己或团队做一个投标辅助工具的开发、刚接触Flask+Vue全栈想找个完整实战项目的学生、以及手里有公共数据想做二次加工的从业者。

F053_ZJ 浙江招投标推荐可视化系统:Vue + Flask + 推荐算法 + 爬虫实战

1. 项目全景与需求拆解

1.1 为什么需要一套独立的招投标推荐系统

招投标行业有个很现实的问题:公开数据都在那里,但分散在省、市、区各级平台,公告格式不统一,更新频率不一样,早期可能还有邮件订阅,但关键词订阅的颗粒度太粗——你订阅“智能化”,来的可能是“智能化办公设备采购”,也可能是“智能化工厂设计”,前者是货物类,后者是工程类,完全是两条业务线。靠人去分辨这个信息噪音,成本非常高。

我接到这个项目需求时,对方的原始诉求很简单:“能不能让系统每天自己看公告,然后只把和我公司相关的推给我?”这就是这套系统最早的出发点。后来加了可视化,是因为客户发现光是推荐列表还不够,他们想知道自己关注的领域每周有多少新项目、项目集中在哪个城市、哪个业主单位发布频率最高,这些维度能直接影响投标决策——比如在某市项目量明显上升时,值得提前去那边维护关系。

所以F053这套系统的核心不是“做网站”,而是“做信息漏斗”:爬虫负责把全网公开数据收进来,算法负责把噪音过滤掉,可视化负责把过滤后的结果变成可决策的信息。ZJ在项目编号里代表浙江,但整个架构换数据源就能迁到其他省份,数据层和业务层是解耦的。

1.2 系统核心能力拆解

从功能上看,这套系统可以拆成四个能力模块:

  • 数据采集:定时抓取目标网站的招标公告、中标结果、采购意向等公开页面,提取结构化字段存库。
  • 企业画像:给每家企业建立标签体系,包括经营范围关键词、历史中标领域、资质信息、常用投标地区等。
  • 智能匹配:新公告入库后,算法自动计算公告和企业画像的相似度,超过阈值的进入推荐列表,再按相关度排序。
  • 可视化看板:用ECharts展示项目趋势、地域分布、推荐命中情况;用腾讯地图展示项目位置和企业位置关系。

交互流程是:爬虫每天增量采集 → 数据清洗入库 → 推荐引擎在晚间或者公告入库时实时计算 → 前端早上一打开就是当天的推荐结果,同时能看到历史数据趋势。这套流程跑通以后,使用者的日常工作从“盯平台”变成了“看推荐”,效率提升非常明显。

1.3 技术选型:为什么要用 Vue + Flask 而不是其他组合

这套技术栈是我和客户反复讨论后定下来的。先说后端,Flask的定位是“微框架”,本身不带ORM、不带表单校验、不带后台管理,但恰恰因为轻,它在做一个中等体量的内部工具时非常舒服。招投标推荐系统不是电商平台,不需要高并发的用户体系,核心逻辑就是爬虫入库、算法计算、接口输出,Flask + SQLAlchemy这种“轻后端”完全够用。

Django也不是不能用,但Django的设计哲学是“全家桶”,它的admin后台、迁移管理、auth体系对这个小项目来说属于“用不上但占学习成本”的东西。Flask的蓝图Blueprint机制足够把项目按模块拆分,配合SQLAlchemy的模型定义,代码结构可以非常干净。

前端选Vue是考虑到这套系统要长期迭代,Vue的单文件组件结构适合多人协作,而且Vue生态里ECharts的封装非常成熟,做数据可视化比React生态更顺手。再说了,Vue的开发和调试门槛相对低,客户的IT团队后续自己维护起来也更容易。

2. 数据采集层:爬虫架构与反爬规避

2.1 数据源规划与采集字段设计

招投标数据的重点是“准”而不是“多”。浙江本地的公共资源交易信息主要分布在省公共资源交易中心和各市区级平台,有些平台有RSS,有些只能网页访问。我第一版把数据源分成三类,优先级从高到低:

  • 结构良好型:有固定列表页和详情页,URL规律明显,用Requests直接抓解析HTML就行。
  • 动态渲染型:数据通过JavaScript异步加载,直接Requests拿不到内容,这种情况我准备了Playwright渲染抓取。
  • 文件公告型:PDF或者Word附件里才是完整招标信息,网页只给了标题和摘要,这需要额外做附件下载和文本解析。

字段设计上,公告表我定义了十几个核心字段:标题、文号、采购人、代理机构、预算金额、发布日期、报名截止时间、开标时间、项目地点、项目类型(工程/货物/服务)、行业关键词、原文链接、来源平台。这里有个容易忽略的点:公告的唯一性不能靠标题判断。同一项目在不同阶段会发好几个公告,比如“采购意向”“招标公告”“更正公告”“结果公告”,标题可能一样但文号不同。我后来用“来源+文号”组合作为唯一键,才解决了重复入库的问题。

2.2 爬虫框架选型:Requests、Scrapy、Playwright 怎么配合

第一版我直接用Requests写循环抓取,代码写着很快,但问题是一个请求出错就会中断整个任务。第二版我换成了Scrapy,把每个数据源配成一个Spider,中间件里统一处理User-Agent轮换、超时重试和限速,这样每个源独立运行,一个挂了不影响其他。Scrapy的Item Pipeline逻辑很适合做清洗——原始HTML进来,Pipeline里做字段抽取、去重、格式化,最后进SQLAlchemy模型。

动态渲染型的页面我用了Playwright。这里提示一下:不要一上来就上Selenium,Selenium太重了,而且新版Selenium的API变化比较大。Playwright的API更现代,自带自动等待,还支持直接拦截响应,有些接口数据其实藏在XHR请求里,用Playwright拦截到JSON直接解析,比渲染完整页面再抓要快得多。不过这类抓取频率必须降下来,动态渲染型的网站一般都有更严格的风险控制策略,我每个页面的请求间隔设置在8到15秒随机波动,跑了几个月没有出过问题。

2.3 合规采集与异常处理

现在爬虫相关的合规要求越来越严格,我在项目里专门加了一个“合规开关”:采集前自动读取目标网站的robots.txt,声明不允许抓的路径直接跳过;默认只抓公开信息,不碰需要登录才能看到的非公开数据;采集频率控制在对方服务器的合理承受范围内。这套系统的定位是给企业做公开信息整合,不是做损害平台的数据搬运,这一点在项目交付文档里写得非常明确。

异常处理方面,我总结了三个高发问题,对应解法如下表:

问题现象原因解决办法
请求偶尔返回403频率过高触发策略请求间隔随机化,备用请求代理池轮换
列表页抓到了但详情页解析为空详情页结构变化用CSS选择器时预留多套备用规则,解析失败自动切换
增量爬虫漏数据页面分页规则不稳定每次采集后用标题+发布日期的MD5做集合比对,回补缺失项

注意:采集频率控制不是小事,如果你在高峰期集中抓取,哪怕目标网站没有明确禁止,也会给服务器造成压力。我常用的经验是“慢就是快”——把总时长拉长一倍,反而更稳定,数据完整度更高。

3. 数据持久化与清洗:SQLAlchemy 存储实战

3.1 SQLAlchemy ORM 表结构设计

数据存什么、怎么存,直接决定推荐算法能不能跑起来。我用了SQLAlchemy作为ORM,数据库先用SQLite起步,数据量超过几十万条后再无缝切换MySQL——ORM的好处就是换数据库只需要改连接串,模型代码不用动。

核心表我设计了四张:announcements(公告表)、companies(企业画像表)、rec_rules(推荐规则表)、recommendations(推荐结果缓存表)。公告表存储采集到的原始信息,企业画像表存储企业的基础信息和标签,推荐规则表用来配置不同行业的权重,推荐结果缓存表是算法跑完后生成的匹配记录,前端查询直接读这张表,不用实时跑算法。

SQLAlchemy模型定义时我踩过一个坑:JSON字段的使用。公告信息里有“行业关键词列表”这种结构,用JSON存确实方便,但SQLite对JSON的支持不如MySQL好,查询起来麻烦。后来我采用了“标签用逗号分隔存字符串,查询时用LIKE匹配”的方案。如果是在MySQL里,可以直接用JSON_CONTAINS,但是考虑到这套系统要在各种环境下本地部署,兼容性比高级查询功能更重要。

3.2 数据清洗流程:从HTML到结构化记录

爬虫抓回来的详情页HTML,第一步是提取正文。我最开始用BeautifulSoup做正文抽取,但不同网站的HTML结构差异很大,选择器经常要改。后来换成了trafilatura这个库,它对新闻公告类页面的正文抽取效果非常稳定,自动过滤掉导航栏、页脚、相关推荐这些噪音,返回干净的文本。虽然它主要面向文章正文,但处理招投标公告这类“段落型文本”时表现也相当好。

正文抽取完,下一步是清洗:去掉空白字符和特殊符号,做简繁体统一——浙江的公告偶尔会夹带繁体字,用opencc-python做转换;再按标点切分成句子列表,为后面的关键词提取做准备。清洗这一步的意义再怎么强调都不为过:推荐算法的上限取决于数据质量,而不是算法复杂度。你喂进去的是“弱电智能化改造、工程量清单、资质要求:电子与智能化工程专业承包二级”,和喂“本项目已具备招标条件,现对该项目进行公开招标”,算出来的推荐质量天差地别。

3.3 无效信息过滤与编码陷阱

无效信息过滤是个反复迭代的活。第一版我只做了简单的停用词过滤,后来发现招投标公告里有大量的“公共噪音词”,比如“招标编号”“受委托”“资金已落实”“有意向的投标人”。这些词本身有意义,但在几乎所有公告里都出现,对区分不同行业没有帮助。我维护了一个“业务停用词表”,大概一百多个词,在关键词提取前先做一轮过滤。

编码问题是爬虫新手最容易忽略的:很多政府网站的页面写的是UTF-8,但部分老站是GBK,或者页面meta声明和实际编码不一致。我用Requests的resp.apparent_encoding检测实际编码,再统一转为UTF-8存库。这个细节看起来小,但如果不处理,数据库里会出现大量乱码,最头疼的是乱码会直接影响中文分词和关键词提取——分词器看到一个错字都会影响整个句子的切分效果。

4. 推荐算法设计与实现:中文分词 + TF-IDF + 余弦相似度

4.1 为什么不用简单标签匹配,而用 TF-IDF + 余弦相似度

第一版我图省事,直接做了关键词交集匹配:公告标签和企业标签的重合词越多,相关度越高。这个方案实现很简单,但实际效果不好,原因有三个:一是标签列表太短,交集经常为空,召回率极低;二是不同词的重要程度被一视同仁,“施工”这个词在建筑类企业里几乎人人都有,但“医疗洁净工程”才是真正区分专业方向的词;三是公告不是标签集合,它是一段文本,强行抽取几个标签出来会丢失大量语义信息。

所以第二版换成了业界用烂但非常稳妥的方案:TF-IDF 向量化 + 余弦相似度。思路是:把公告正文当作一袋词,TF代表词在本文档里出现的频率,IDF代表词在整个语料库中的稀缺程度。一个词在一篇公告里出现得多、但在所有公告里很少见,它的TF-IDF权重就高,说明这个词能代表这篇公告的特色。企业画像同理,然后在同一个向量空间里计算两个向量的夹角余弦值。

这背后的直觉是:文本相似度不是看字面重合多少,而是看在“词的分布”层面是否对齐。生活化一点说,两个人都聊“手术室净化”比两个人都聊“项目”更相似,因为前者是小众词,后者是大众词。

4.2 TF-IDF 向量化完整实现代码

关键代码分几步:先用jieba做分词,再用jieba.analyse.extract_tags提取关键词权重,最后用scikit-learn的TfidfVectorizer做向量化。这里是实际跑通的核心逻辑:

import jieba import jieba.analyse import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 加载停用词 stopwords = set() with open("data/stopwords.txt", "r", encoding="utf-8") as f: for line in f: stopwords.add(line.strip()) def text_to_terms(text): """清洗 + 分词 + 去除停用词,返回空格分隔的词序列""" words = jieba.lcut(text) filtered = [w for w in words if w.strip() and w not in stopwords and len(w) > 1] return " ".join(filtered) def build_vectors(corpus_texts): """corpus_texts: 所有公告文本列表 + 企业画像文本追加到末尾""" vectorizer = TfidfVectorizer(token_pattern=r"(?u)\b\w+\b", lowercase=False) tfidf_matrix = vectorizer.fit_transform(corpus_texts) return vectorizer, tfidf_matrix # 假设 announcements 是公告对象列表,companies 是企业对象列表 ann_texts = [text_to_terms(a.cleaned_content) for a in announcements] comp_texts = [text_to_terms(c.profile_text) for c in companies] # 合并建向量空间,保证公告和企业画像在同一空间可比 all_texts = ann_texts + comp_texts vectorizer, matrix = build_vectors(all_texts) # 矩阵前 N 行是公告,后面的行是企业 ann_matrix = matrix[:len(ann_texts)] comp_matrix = matrix[len(ann_texts):] # 计算每个企业和每条公告的相似度矩阵 for i, company in enumerate(companies): sims = cosine_similarity(comp_matrix[i:i+1], ann_matrix)[0] top_indices = sims.argsort()[-5:][::-1] # 输出 top5 推荐公告

我跑真实数据时,公告正文有两三千字,企业画像也有几百字,计算相似度矩阵是稠密矩阵操作,数据量在几千条时毫秒级出结果,完全不需要上ES或者向量数据库。如果你面对的是十万级数据,那就要考虑把计算放到离线任务里,或者用Faiss做近邻检索,但F053这个场景用暴力余弦就够了。

4.3 从“相似度”到“推荐分数”:加权规则让算法落地

纯相似度算出来的结果还有一个问题:它只考虑文本相似度,没有考虑业务约束。比如一家注册在宁波的企业,和一家注册在杭州的企业,文本画像可能高度相似,但宁波企业根本不去杭州投标。所以最终推荐分数我用了加权组合:

推荐分数 = 0.6 * 文本相似度 + 0.2 * 地域匹配分 + 0.1 * 时间衰减分 + 0.1 * 规模匹配分

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

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

立即咨询