内容
# 工具列表
## SofaScore ChatGPT 应用 ⚽🏆
一款 **ChatGPT 应用**(OpenAI 应用 SDK / MCP-UI),通过丰富的交互式小部件回答足球问题 —— 以 **2026 年 FIFA 世界杯**(48 支球队,由美国、加拿大和墨西哥主办)为中心,同时处理一般性足球问题。
它是一个 MCP 服务器(Streamable HTTP),其工具在 ChatGPT 中通过 `openai/outputTemplate` + `text/html+skybridge` 模式渲染 React 小部件。数据来自 SofaScore 的公共 JSON API。

<p>
<img src="docs/match-detail.png" width="32%" alt="比赛详情" />
<img src="docs/knockout-bracket.png" width="32%" alt="淘汰赛对阵图" />
<img src="docs/standings-light.png" width="32%" alt="积分榜(浅色主题)" />
</p>
## 功能
| 工具 | 回答内容 | 小部件 |
| --- | --- | --- |
| `world_cup` | 关于世界杯的任何问题 —— 分组、淘汰赛对阵图、实时/即将到来的比赛。视图:`overview` · `groups` · `knockout` · `matches`。 | 带有淘汰赛对阵图的世界杯中心选项卡 |
| `list_matches` | “今天有哪些足球比赛?”,实时比分、某日期的比赛、某项比赛的比赛。 | 按锦标赛分组的比分板,实时脉动 |
| `get_match` | 一场比赛的完整详情:比分、进球/红黄牌时间轴、球队统计数据、比赛势头。 | 带有势头图和统计条的比赛页面 |
| `get_standings` | 某项比赛的联赛表格(按名称或 SofaScore ID)。 | 积分榜表格(多组意识) |
| `search_football` | 按名称查找球队/球员/比赛/教练。 | 结果列表,可钻取到其他工具 |
小部件具有主题感知能力(浅色/深色跟随 `window.openai.theme`),使用 SofaScore 徽标/旗帜图像,并带有首字母备用方案,且交互式 —— 比赛行和搜索结果通过 `window.openai.callTool` 调用其他工具。
## 架构
```
src/
server.ts Express + StreamableHTTPServerTransport(POST /mcp,/health)
mcp.ts McpServer 工厂:服务器指令 + 资源 + 工具
resources.ts 注册每个内置小部件为 ui:// text/html+skybridge 资源
sofascore/ 数据层:SofaScore 客户端(浏览器标头,TTL 缓存,可选代理,服务器端图像获取)+ ESPN 备用
源,均位于 FootballApi 接口(provider.ts,fallback.ts,espn.ts)后面;原始→结构化内容映射器,世界杯逻辑
api/index.ts Vercel 无服务器入口(重新导出 Express 应用)
shared/shapes.ts 服务器 + 小部件共享的结构化内容类型
tools/ list_matches · get_match · get_standings · world_cup · search_football
widgets/ React 源代码(每个小部件构建为一个自包含的 HTML)
shared/ window.openai 桥 + 钩子,主题,可重用组件
scripts/ build-widgets · 烟雾测试(线路)· 截图(视觉)
test/fixtures/ 用于离线运行的演示 SofaScore 响应
```
每个小部件都由 `vite` + `vite-plugin-singlefile` 构建为一个单独的 `dist/widgets/<name>/index.html`(内联 JS + CSS),作为 skybridge 资源原样提供 —— 无需单独的资产主机。
### 图片(为什么通过此服务器路由)
球队/锦标赛/球员徽标无法直接从 `api.sofascore.com` 加载到 ChatGPT 应用沙盒中:iframe 的 CSP(`img-src`)阻止主机,SofaScore 自己对无引用者跨源请求返回 403。因此服务器公开了一个 **图像代理**,小部件指向它:
```
GET /img/team/:id → https://api.sofascore.com/api/v1/team/:id/image
GET /img/tournament/:id → …/unique-tournament/:id/image
GET /img/player/:id → …/player/:id/image
GET /img/flag/:alpha2 → …/img/flags/:alpha2.png
```
只有这些允许列出的形状被代理。`resources.ts` 将 `window.__SOFA_BASE__`(来自 `PUBLIC_BASE_URL`)注入每个小部件的 `<head>`,`widgets/shared/img.ts` 从中构建 `${__SOFA_BASE__}/img/...` URL。如果 `PUBLIC_BASE_URL` 未设置(例如本地 MCP 检查器),小部件将回退到直接从 SofaScore 加载图像。
## 运行
```bash
npm install
npm run dev # 构建小部件,然后在 :3000 启动服务器(POST /mcp)
```
将 MCP 客户端指向 `http://localhost:3000/mcp`,或运行 `SOFA_MOCK=1` 以提供捆绑的固定装置而不是实时 API:
```bash
SOFA_MOCK=1 npm run dev
```
### 环境变量
| 变量 | 目的 |
| ----------------- | --------------------------------------------------------------------------------------------------------- |
| `PORT` | HTTP 端口(默认为 `3000`)。 |
| `SOFA_MOCK` | `1` 提供捆绑的固定装置而不是实时 API —— 无需网络。 |
| `PUBLIC_BASE_URL` | 此服务器可到达的公共源(例如 `https://sofa.example.com`)。对于图像代理在 ChatGPT 沙盒中加载徽标所需。如果在 Vercel 上部署,则默认为部署 URL。否则 → 小部件直接加载图像。 |
| `SOFA_PROXY` | **所有** SofaScore 获取(数据 + 图像)的出站 HTTP(S) 转发代理。指向住宅/未阻塞代理以绕过 Varnish/IP 阻止。回退到 `HTTPS_PROXY`。 |
| `ESPN_ONLY` | `1` 完全跳过 SofaScore 并从 ESPN 备用源提供所有内容。在 SofaScore 始终被阻止的主机上很有用(避免每次请求的探测 + 冷却)。 |
```bash
# 实时运行,背后有代理,图像通过此源代理:
PUBLIC_BASE_URL=https://sofa.example.com \
SOFA_PROXY=http://user:pass@proxy-host:port \
npm run dev
```
### 添加到 ChatGPT(开发者模式)
1. 通过 HTTPS 公开服务器(例如 `ngrok http 3000`)。
2. 在 ChatGPT → **设置 → 连接器 → 高级 → 开发者模式** 中,添加一个指向 `https://<your-tunnel>/mcp` 的新连接器。
3. 询问:*“给我看世界杯小组积分榜”* 或 *“现在有哪些足球比赛正在进行?”* —— 匹配的小部件内联渲染。
### 部署到 Vercel
仓库已准备好 Vercel:`api/index.ts` 将 Express 应用重新导出为单个无服务器函数,`vercel.json` 将每个路径重写为它。
```bash
vercel deploy # 首次运行链接项目;生产环境:`vercel --prod`
```
- **构建** — `vercel.json` 的 `buildCommand` 运行 `npm run build`,将 5 个小部件 HTML 文件捆绑到 `dist/widgets/`;`includeFiles` 将它们与函数一起发送(资源加载器还从 `process.cwd()` 解析它们)。
- **基准 URL** — `PUBLIC_BASE_URL` 默认为部署的 `VERCEL_URL`,因此图像代理在无需额外配置的情况下工作。显式设置它以使用自定义域。
- **数据** — ESPN 备用意味着立即有实时数据可用。要保留 SofaScore 主要源,请在 Vercel 项目的环境变量中添加 `SOFA_PROXY`;要删除它,请设置 `ESPN_ONLY=1`。
- **连接器 URL** — 将 ChatGPT 指向 `https://<deployment>/mcp`。
函数的 `maxDuration` 为 30 秒,以覆盖 ESPN 的多联赛扩展;`/` 上的登录页面由 `public/` 静态提供。
## 验证
所有验证运行离线(无需实时网络):
```bash
npm run typecheck # 服务器 + 小部件类型检查
npm run build:widgets # 捆绑 5 个自包含的小部件 HTML 文件
npm run verify # 构建 + 线路测试 + Playwright 截图
```
- **`scripts/smoke.ts`** 使用固定装置启动服务器,连接进程内 MCP 客户端,并断言每个工具的 `openai/outputTemplate` 解析为注册的 `ui://` 资源,且 `structuredContent` 非空。
- **`scripts/screenshots.ts`** 渲染每个构建的小部件与真实服务器输出(Chromium,`window.openai` 设置与主机完全相同)到 `.artifacts/shots/`。
## 数据和环境的说明
主要源是实时 SofaScore API(`api.sofascore.com`),使用类似浏览器的标头。其边缘(Varnish)返回 **403 到数据中心 IP 和标记的 TLS 指纹,无论标头如何** —— 因此云主机、CI 运行器或此沙盒通常无法访问它。这是一个出口 IP 阻止,而不是缺少标头。
**因此服务器自动回退到 ESPN 的公共足球 API。** 每个数据源都位于 FootballApi 接口后面;`FallbackApi` 尝试 SofaScore 首先(尊重 `SOFA_PROXY`)并且在任何 403 / 超时 / 空结果时透明地从 ESPN 提供相同请求 —— 映射到完全相同的形状,因此工具和小部件永远不知道区别。 ESPN 可从阻止的网络访问,涵盖 **FIFA 世界杯 + 欧洲五大联赛**,并且不需要 API 密钥。 结果:**实时数据流出无需额外配置**。球队徽标来自 `a.espncdn.com` 通过相同的图像代理。
控制源的方式:
1. **默认** — SofaScore → ESPN 备用。直接可用。
2. **`ESPN_ONLY=1`** — 完全跳过 SofaScore(在始终被阻止的主机上最佳)。
3. **`SOFA_PROXY=<url>`** — 给 SofaScore 一个住宅/未阻塞出口,以便它保持主要(见下文)。
4. **`SOFA_MOCK=1`** — 捆绑的固定装置,完全离线(开发/测试/演示)。
世界杯赛季动态解析(硬编码)。
### 获取代理端点
`SOFA_PROXY` 采用标准 `http(s)://[user:pass@]host:port` URL。你需要一个 SofaScore 未阻止的 IP —— 实际上是一个 **住宅** 或 **移动** 代理(数据中心代理通常也被阻止)。选项:
- **托管住宅代理提供商** — Bright Data、Oxylabs、Smartproxy、IPRoyal、Soax 等。注册,创建端点,他们给你一个 `http://user:pass@gateway:port` URL。将它粘贴到 `SOFA_PROXY`。最便宜的开始;按 GB 计费(徽标图像很小,JSON 很小 — 使用量很低)。
- **你自己的家庭/住宅 IP** — 在家运行服务器,或通过一个(例如运行 [`ngrok`](https://ngrok.com) / [`tailscale`](https://tailscale.com) + 转发代理像 [`tinyproxy`](https://tinyproxy.github.io/) 或 [`squid`](http://www.squid-cache.org/) 的机器隧道)。如果你已经有硬件,则免费。
- **具有住宅友好 ASP 的廉价 VPS** — 一些 VPS/边缘提供商发放 SofaScore 未阻止的 IP 范围;尝试从盒子上的 `curl` 开始。
**在连接前测试任何候选者:**
```bash
curl -x "http://user:pass@proxy-host:port" \
-H "User-Agent: Mozilla/5.0" -H "Referer: https://www.sofascore.com/" \
-o /dev/null -w "%{http_code}\n" \
https://api.sofascore.com/api/v1/sport/football/events/live
```
`200` → 将该 URL 设置为 `SOFA_PROXY`。`403` → 该 IP 也被阻止;尝试另一个。
不与 SofaScore 关联;使用他们的公共端点用于信息用途。