返回博客

我如何构建 RFP Search,一个由 AI 驱动的 RFP 聚合器

2026-03-256 min read

如果您曾尝试寻找政府的数字服务RFP(Request for Proposal,建议请求),您一定知道其中的痛苦。机会分散在SAM.gov、州采购门户、Grants.gov、联合国、欧盟以及十几个其他地方。每个网站都有自己的搜索界面、自己的格式和自己的怪癖。手动检查所有这些地方很繁琐,所以我构建了RFP Search来为我完成这项工作。

它的作用

RFP Search每晚抓取11个政府和非营利性采购来源的数据,通过一个AI模型处理每个机会以提取结构化数据,并通过一个带有筛选器和交互式地图的单一可搜索界面提供所有内容。

这些来源包括SAM.gov、Grants.gov、德克萨斯州的ESBD、加州的Cal eProcure、佛罗里达州的MFMP、欧盟的TED门户、联合国全球采购平台、纽约市的Open Data、联邦公报(Federal Register)、USAspending,以及用于更广泛覆盖的Brave Search。每天早上,新的结果就准备好了。

架构

整个系统运行在Cloudflare上。三个Worker、一个D1数据库,以及零个传统服务器。我使用Turborepo和npm workspaces将其构建为一个单体仓库(monorepo)。

这三个Worker各有明确的职责。

  • rfp-web 是前端,使用React Router v7(Remix的继任者)构建。它处理搜索UI、筛选器以及带有标记点聚类的交互式Leaflet地图。
  • rfp-api 是一个Hono REST API,处理搜索查询、分页、统计数据以及地图的地理定位数据。
  • rfp-scraper 是一个计划中的Worker,运行夜间cron作业,从所有来源获取和处理RFP。

Web Worker通过Cloudflare Service Bindings与API Worker通信。没有HTTP,没有CORS,没有公共API端点。绑定直接在Cloudflare的网络内部调用API,这既更快也更安全。

抓取器(Scraper)

这是最有趣的部分。每个来源都是一个实现简单接口的插件。想添加一个新的来源?创建一个文件,实现接口,注册它,并在数据库中添加一行记录。就是这样。

抓取器分三个错开的批次运行(UTC时间9:00、9:15和9:30),以保持在Worker CPU时间限制内。每个批次处理一部分来源。

批次 1 (UTC 9:00)  SAM.gov, TED EU, USAspending
批次 2 (UTC 9:15) Texas ESBD, Cal eProcure, Brave Search, Grants.gov, Federal Register
批次 3 (UTC 9:30) Florida MFMP, UNGM, NYC Open Data

每个来源插件都知道如何获取和解析其自己的数据格式。有些调用REST API,有些抓取HTML,有些使用RSS源。插件架构将这种复杂性限制在内。如果某个来源更改了其格式,我只需要更新一个文件。

抓取器还会跟踪失败情况。如果一个来源连续失败三次,它会自动停用,这样就不会浪费周期或污染日志。

AI提取

原始的RFP列表非常混乱。有些有详细描述,有些只有标题和链接。我使用Cloudflare Workers AI和Llama 3.1 70B Instruct将所有内容规范化为一致的结构。

每个RFP都会进行一次AI调用,以提取结构化字段(截止日期、预算、地点、联系信息)、决策元数据(所需认证、合同类型、远程友好性、估计团队规模、技术栈)、类别(Web开发、CMS、云、AI、迁移等)以及一个2-3句的摘要。

每个RFP只调用一次模型,一个提示完成所有工作。这使得Workers AI成本保持在较低水平,同时仍能获得良好的提取质量。该模型在从非结构化的政府采购文本中提取结构化数据方面表现出惊人的能力。

数据库

Cloudflare D1(边缘托管的SQLite)存储所有内容。主要的rfps表有30多个字段,涵盖核心数据、AI提取的字段和决策元数据。

对于搜索,我使用的是SQLite的FTS5(全文搜索5),并使用触发器在插入、更新或删除行时自动同步搜索索引。无需手动重新索引,无需单独的搜索服务。它就是能用。

CREATE VIRTUAL TABLE rfps_fts USING fts5(
title, description, ai_summary, categories,
content=rfps, content_rowid=id
);

去重使用(source_name, external_id)上的唯一约束,因此如果同一个RFP出现在多次抓取运行中,它会被更新而不是重复。

RFP状态(开放、即将到期、已关闭)是在查询时根据截止日期字段计算得出的,而不是存储的。这意味着状态始终准确,而无需后台作业来更新过时的记录。

前端

UI使用React Router v7在Cloudflare Workers上构建,并使用Tailwind CSS v4进行样式设计。它有一个搜索栏,可以对标题、描述、摘要和类别进行全文搜索。下拉筛选器允许您按组织类型、类别、截止日期状态和估计价值进行筛选。

交互式地图使用带有标记点聚类的Leaflet。每个具有地理编码位置数据的RFP都会显示为一个图钉。地理编码本身在抓取器中使用Nominatim(OpenStreetMap的地理编码服务)进行,将位置字符串转换为经纬度对。

顶部的统计栏显示当前开放的RFP数量和活动的来源数量,因此您可以一目了然地看出数据的时效性。

我会如何做得不同

如果我重新开始,我可能会从第一天就开始添加电子邮件提醒。目前,您必须访问网站才能查看新机会。为保存的搜索设置一个简单的每日摘要会使其更有用。

我还会投入更多精力进行来源可靠性测试。政府网站会毫无预警地更改,抓取它们本质上是脆弱的。更好的监控和在来源开始返回意外数据时自动提醒,可以节省调试时间。

试一试

该平台已在rfp.davidloor.com上线。它专注于数字服务、Web开发、CMS、云和AI机会,但该架构可以支持任何类别的RFP。

保持更新

将最新文章和见解发送到您的收件箱。

Unsubscribe anytime. No spam, ever.