chore: 清理
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,161 +0,0 @@
|
||||
# API 测试指南
|
||||
|
||||
## GET /api/events
|
||||
|
||||
获取所有事件:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/events
|
||||
```
|
||||
|
||||
筛选特定年份:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api/events?year=2024"
|
||||
```
|
||||
|
||||
限制返回数量:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api/events?limit=10"
|
||||
```
|
||||
|
||||
分页:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api/events?offset=10&limit=10"
|
||||
```
|
||||
|
||||
## POST /api/events
|
||||
|
||||
创建单个事件:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-d '[
|
||||
{
|
||||
"title": "事件标题",
|
||||
"eventDate": "2023-03-14T00:00:00Z",
|
||||
"description": "事件描述(10-500字)",
|
||||
"imageUrl": "https://example.com/image.jpg"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
批量创建事件:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-d '[
|
||||
{ "title": "事件1", "eventDate": "2023-01-01T00:00:00Z", "description": "描述1", "imageUrl": "https://example.com/1.jpg" },
|
||||
{ "title": "事件2", "eventDate": "2023-02-01T00:00:00Z", "description": "描述2", "imageUrl": "https://example.com/2.jpg" }
|
||||
]'
|
||||
```
|
||||
|
||||
包含可选字段:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/events \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: YOUR_API_KEY" \
|
||||
-d '[
|
||||
{
|
||||
"title": "GPT-4 Release",
|
||||
"titleEn": "GPT-4 发布",
|
||||
"eventDate": "2023-03-14T00:00:00Z",
|
||||
"description": "OpenAI launches multimodal LLM",
|
||||
"descriptionEn": "OpenAI 发布多模态大语言模型",
|
||||
"imageUrl": "https://example.com/gpt4.jpg",
|
||||
"sourceUrl": "https://openai.com/blog/gpt-4"
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
## 验证规则
|
||||
|
||||
### 输入验证 (AIEventInputSchema)
|
||||
|
||||
- `title`: 1-200 字符(必填)
|
||||
- `titleEn`: 最多 200 字符(可选)
|
||||
- `eventDate`: ISO 8601 datetime 格式(必填)
|
||||
- `description`: 10-500 字符(必填)
|
||||
- `descriptionEn`: 最多 500 字符(可选)
|
||||
- `imageUrl`: 有效 URL(必填)
|
||||
- `sourceUrl`: 有效 URL(可选)
|
||||
|
||||
### 查询参数验证 (AIEventQuerySchema)
|
||||
|
||||
- `year`: 4位数字年份(可选)
|
||||
- `limit`: 正整数(可选,默认 100)
|
||||
- `offset`: 非负整数(可选,默认 0)
|
||||
|
||||
## 认证
|
||||
|
||||
所有 POST 请求必须在请求头中包含 API Key:
|
||||
|
||||
```
|
||||
X-API-Key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
## 响应示例
|
||||
|
||||
### 成功响应 (POST)
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 2,
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 成功响应 (GET)
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "cmkwn6q0300004jjz4lobtkpf",
|
||||
"title": "Transformer论文",
|
||||
"titleEn": null,
|
||||
"eventDate": "2017-06-12T00:00:00.000Z",
|
||||
"description": "Google团队发表Transformer架构",
|
||||
"descriptionEn": null,
|
||||
"imageUrl": "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800",
|
||||
"sourceUrl": null,
|
||||
"createdAt": "2026-01-27T13:38:39.268Z",
|
||||
"updatedAt": "2026-01-27T13:38:39.268Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Validation failed",
|
||||
"details": [
|
||||
{
|
||||
"code": "too_small",
|
||||
"path": ["0", "description"],
|
||||
"message": "String must contain at least 10 character(s)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 测试记录
|
||||
|
||||
### 2025-01-27 测试结果
|
||||
|
||||
- ✅ GET /api/events - 返回空列表
|
||||
- ✅ POST /api/events - 单个事件创建成功
|
||||
- ✅ GET /api/events - 验证事件已创建(1个事件)
|
||||
- ✅ POST /api/events - 批量创建成功(2个事件)
|
||||
- ✅ GET /api/events?year=2018 - 年份筛选成功(返回2个事件)
|
||||
|
||||
所有基础功能测试通过!
|
||||
@@ -1,548 +0,0 @@
|
||||
# 项目发现工作流 (Project Discovery Workflow)
|
||||
|
||||
本文档详细说明了 AI 项目自动发现和收录的完整工作流程。
|
||||
|
||||
## 📋 工作流概览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 项目发现完整流程 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
1. n8n 自动化平台
|
||||
│
|
||||
├─ 定期抓取 GitHub/Twitter/HackerNews 等平台
|
||||
├─ 筛选符合条件的项目链接
|
||||
│
|
||||
▼
|
||||
2. 调用去重检查 API (推荐)
|
||||
POST /api/discovery/check-duplicates
|
||||
│
|
||||
├─ 检查 URL 是否已存在任务
|
||||
├─ 检查 URL 对应项目是否已收录
|
||||
├─ 过滤出需要创建的 URL
|
||||
│
|
||||
▼
|
||||
3. 调用创建任务 API
|
||||
POST /api/discovery/tasks
|
||||
│
|
||||
├─ 存入 ProjectDiscoveryTask 表
|
||||
├─ 状态: PENDING
|
||||
│
|
||||
▼
|
||||
4. 手动触发本地命令 (定期执行)
|
||||
/discover-projects
|
||||
│
|
||||
├─ 通过 curl 获取待处理任务
|
||||
├─ 调用 Content Explorer Agent
|
||||
│ ├─ 使用 agent-browser 探索项目
|
||||
│ ├─ 提取项目信息 (README, 代码结构等)
|
||||
│ ├─ 生成结构化 JSON 数据
|
||||
│ └─ 应用内容质量标准
|
||||
│
|
||||
├─ 调用 API Submitter Agent
|
||||
│ ├─ 批量标记任务为 IN_PROGRESS
|
||||
│ ├─ 提交探索数据到完成 API
|
||||
│ └─ 自动重试失败的提交
|
||||
│
|
||||
▼
|
||||
5. 完成任务并入库
|
||||
POST /api/discovery/tasks/{id}/complete
|
||||
│
|
||||
├─ 验证数据格式 (Zod Schema)
|
||||
├─ 多级去重检测 (GitHub/Website URL)
|
||||
├─ 创建或更新 Project
|
||||
├─ 创建 Tag 和关联关系
|
||||
├─ 创建 ExternalLink
|
||||
│
|
||||
├─ 更新任务状态: COMPLETED/FAILED
|
||||
│
|
||||
▼
|
||||
6. 数据已入库,可在前台展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 详细步骤说明
|
||||
|
||||
### 步骤 1: n8n 自动收集项目链接
|
||||
|
||||
**平台**: n8n 自动化平台 (独立部署)
|
||||
|
||||
**工作内容**:
|
||||
- 定期抓取 GitHub Trending、Twitter、HackerNews 等平台
|
||||
- 根据关键词筛选 AI 相关项目
|
||||
- 提取项目的基本信息 (名称、链接、简介等)
|
||||
|
||||
**输出数据格式**:
|
||||
```json
|
||||
{
|
||||
"sourceUrl": "https://github.com/langchain-ai/langchain",
|
||||
"sourceType": "github_trending" // 或 "twitter", "hackernews" 等
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2: 调用创建任务 API
|
||||
|
||||
**API 端点**: `POST /api/discovery/tasks`
|
||||
|
||||
**调用示例**:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/discovery/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: YOUR_API_KEY" \
|
||||
-d '{
|
||||
"apiKey": "YOUR_API_KEY",
|
||||
"tasks": [
|
||||
{
|
||||
"sourceUrl": "https://github.com/langchain-ai/langchain",
|
||||
"sourceType": "github_trending"
|
||||
},
|
||||
{
|
||||
"sourceUrl": "https://github.com/openai/openai-quickstart-python",
|
||||
"sourceType": "github_trending"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**数据库变更**:
|
||||
- 在 `ProjectDiscoveryTask` 表中插入新记录
|
||||
- `status` = `PENDING`
|
||||
- `sourceUrl` 和 `sourceType` 来自 n8n
|
||||
- `createdAt` = 当前时间
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2.5: 调用去重检查 API(推荐)
|
||||
|
||||
**API 端点**: `POST /api/discovery/check-duplicates`
|
||||
|
||||
**调用示例**:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/discovery/check-duplicates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"apiKey": "YOUR_API_KEY",
|
||||
"urls": [
|
||||
"https://github.com/langchain-ai/langchain",
|
||||
"https://github.com/openai/openai-quickstart-python"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"results": [
|
||||
{
|
||||
"url": "https://github.com/langchain-ai/langchain",
|
||||
"shouldCreate": false,
|
||||
"reason": "Task already exists with status PENDING",
|
||||
"existingTask": {
|
||||
"id": "cmxxxxx",
|
||||
"status": "PENDING",
|
||||
"sourceUrl": "https://github.com/langchain-ai/langchain",
|
||||
"createdAt": "2025-01-18T10:00:00Z",
|
||||
"projectId": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/openai/openai-quickstart-python",
|
||||
"shouldCreate": true,
|
||||
"reason": "No existing task or project found"
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"total": 2,
|
||||
"shouldCreate": 1,
|
||||
"duplicate": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**去重逻辑**(按优先级):
|
||||
1. **优先级 1**: 检查是否有 PENDING/IN_PROGRESS 的相同 URL 任务
|
||||
- 如果存在 → `shouldCreate: false`
|
||||
- 原因:任务已在处理中,避免重复探索
|
||||
|
||||
2. **优先级 2**: 检查是否有 COMPLETED/FAILED 的相同 URL 任务
|
||||
- 如果存在 → `shouldCreate: false`
|
||||
- 原因:任务已探索过,无需重复
|
||||
|
||||
3. **优先级 3**: 检查 URL 对应的项目是否已存在(通过 ExternalLink)
|
||||
- 如果存在 → `shouldCreate: false`
|
||||
- 原因:项目已通过其他来源收录
|
||||
|
||||
4. **默认**: 允许创建新任务
|
||||
- `shouldCreate: true`
|
||||
|
||||
**n8n 集成建议**:
|
||||
```javascript
|
||||
// n8n Workflow 示例
|
||||
const checkResponse = await fetch('https://your-domain.com/api/discovery/check-duplicates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
urls: collectedUrls // 从上一步收集的 URL 列表
|
||||
})
|
||||
})
|
||||
|
||||
const { results, stats } = await checkResponse.json()
|
||||
|
||||
// 过滤出应该创建任务的 URL
|
||||
const urlsToCreate = results
|
||||
.filter(r => r.shouldCreate)
|
||||
.map(r => r.url)
|
||||
|
||||
// 只为不重复的 URL 创建任务
|
||||
if (urlsToCreate.length > 0) {
|
||||
await fetch('https://your-domain.com/api/discovery/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
tasks: urlsToCreate.map(url => ({
|
||||
sourceUrl: url,
|
||||
sourceType: 'github_trending'
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`创建 ${urlsToCreate.length} 个新任务,跳过 ${stats.duplicate} 个重复任务`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3: 手动触发本地命令
|
||||
|
||||
**执行环境**: 本地开发环境 (Local Development)
|
||||
|
||||
**执行命令**:
|
||||
```bash
|
||||
# 处理默认 10 个任务 (每批 3 个)
|
||||
/discover-projects
|
||||
|
||||
# 处理指定数量的任务
|
||||
/discover-projects 20
|
||||
|
||||
# 自定义批次大小
|
||||
/discover-projects 9 --batch=2
|
||||
|
||||
# 处理所有待处理任务
|
||||
/discover-projects all --batch=5
|
||||
```
|
||||
|
||||
**命令执行流程**:
|
||||
1. 通过 curl 调用 `GET /api/discovery/tasks?status=PENDING&limit=N`
|
||||
2. 获取待处理的任务列表
|
||||
3. 分批处理 (默认每批 3 个任务)
|
||||
4. 为每个任务启动 **Content Explorer Agent**
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3.1: Content Explorer Agent (项目探索)
|
||||
|
||||
**Agent 定义**: `.claude/agents/content-explorer-agent.md`
|
||||
|
||||
**核心能力**:
|
||||
- 使用 `agent-browser` 子任务并行探索 GitHub 项目
|
||||
- 访问项目主页、README、代码结构
|
||||
- 提取项目元数据 (名称、描述、标签、链接等)
|
||||
- 生成符合 `ProjectInputSchema` 的 JSON 数据
|
||||
|
||||
**内容质量标准** (参考: `.claude/schemas/project-content-template.md`):
|
||||
- ✅ **描述**: 客观说明功能、突出价值、避免营销术语、10-500字
|
||||
- ✅ **内容**: 从 README 提取并重新组织、符合中文表达习惯
|
||||
- ✅ **链接**: 必须包含 GITHUB 链接、所有链接可访问
|
||||
- ✅ **标签**: 1-10 个标签、按技术/应用/状态分类
|
||||
- ✅ **动态数据**: Star/Fork 等动态数据不写入内容,使用 GitHub Badge
|
||||
|
||||
**输出数据格式**:
|
||||
```json
|
||||
{
|
||||
"name": "LangChain",
|
||||
"nameEn": "LangChain",
|
||||
"description": "开发由 LLM 驱动的应用程序的框架,提供文档加载、文本分割、向量存储等核心组件",
|
||||
"descriptionEn": "Framework for developing applications powered by language models",
|
||||
"content": "## 核心功能\n\n- 文档加载: 支持 PDF、TXT、网页等多种格式\n- 文本分割: 智能分割长文本\n...",
|
||||
"tags": [
|
||||
{ "name": "LLM", "nameEn": "Large Language Model" },
|
||||
{ "name": "框架", "nameEn": "Framework" }
|
||||
],
|
||||
"links": [
|
||||
{ "type": "GITHUB", "url": "https://github.com/langchain-ai/langchain", "title": "GitHub 仓库" },
|
||||
{ "type": "WEBSITE", "url": "https://python.langchain.com", "title": "官方文档" }
|
||||
],
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3.2: API Submitter Agent (数据提交)
|
||||
|
||||
**Agent 定义**: `.claude/agents/api-submitter-agent.md`
|
||||
|
||||
**核心能力**:
|
||||
- 批量标记任务为 `IN_PROGRESS`
|
||||
- 调用 `POST /api/discovery/tasks/{id}/complete` 提交数据
|
||||
- 自动重试失败的提交 (指数退避,最多 3 次)
|
||||
- 处理部分成功/失败情况
|
||||
|
||||
**提交流程**:
|
||||
```
|
||||
1. PATCH /api/discovery/tasks/{id} → status=IN_PROGRESS
|
||||
↓
|
||||
2. POST /api/discovery/tasks/{id}/complete → 提交探索数据
|
||||
↓
|
||||
3. 检查响应
|
||||
├─ 成功 → 标记任务完成
|
||||
├─ 失败 → 重试 (最多 3 次)
|
||||
└─ 最终失败 → 记录错误信息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 4: 完成任务并入库
|
||||
|
||||
**API 端点**: `POST /api/discovery/tasks/{id}/complete`
|
||||
|
||||
**处理逻辑** (参考: `src/app/api/discovery/tasks/[id]/complete/route.ts`):
|
||||
|
||||
1. **验证数据格式**: 使用 `ProjectInputSchema` 验证
|
||||
2. **多级去重检测**:
|
||||
- 优先级 1: GitHub URL 精确匹配
|
||||
- 优先级 2: Website URL 精确匹配
|
||||
- 优先级 3: slug 匹配
|
||||
3. **创建或更新项目**:
|
||||
- 如果存在重复项目 → 更新所有字段、标签、链接
|
||||
- 如果不存在 → 创建新项目
|
||||
4. **更新任务状态**:
|
||||
- 成功 → `COMPLETED`
|
||||
- 失败 → `FAILED` (记录错误信息)
|
||||
|
||||
**数据库变更**:
|
||||
- `Project` 表: 创建或更新记录
|
||||
- `Tag` 表: Upsert 标签
|
||||
- `ProjectTag` 表: 创建关联关系
|
||||
- `ExternalLink` 表: 创建或更新链接
|
||||
- `ProjectDiscoveryTask` 表: 更新 `status`、`projectId`、`completedAt`
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 数据模型关系
|
||||
|
||||
```
|
||||
ProjectDiscoveryTask (任务表)
|
||||
├─ id: String (主键)
|
||||
├─ status: TaskStatus (PENDING/IN_PROGRESS/COMPLETED/FAILED)
|
||||
├─ sourceUrl: String (n8n 提供的原始 URL)
|
||||
├─ sourceType: String (github_trending/twitter/hackernews)
|
||||
├─ explorationData: Json (Agent 探索结果)
|
||||
├─ explorationSummary: String (探索摘要)
|
||||
├─ projectId: String (关联到 Project.id)
|
||||
├─ errorMessage: String (失败原因)
|
||||
└─ createdAt/startedAt/completedAt: DateTime
|
||||
|
||||
Project (项目表) ← 通过 projectId 关联
|
||||
├─ id, name, nameEn, slug
|
||||
├─ description, descriptionEn
|
||||
├─ content, contentEn
|
||||
├─ status (ACTIVE/ARCHIVED)
|
||||
└─ 关联: tags, links, discoveryTasks
|
||||
|
||||
Tag (标签表)
|
||||
└─ 通过 ProjectTag 多对多关联
|
||||
|
||||
ExternalLink (外部链接表)
|
||||
└─ 通过 projectId 一对多关联
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 环境配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
```env
|
||||
# .env.local (本地开发)
|
||||
WEBHOOK_API_KEY=sk_live_your_secure_api_key_min_32_chars # 用于调用完成 API
|
||||
|
||||
# 生产环境 (Vercel Dashboard 配置)
|
||||
DATABASE_URL=postgres://...
|
||||
WEBHOOK_API_KEY=sk_live_your_secure_api_key_min_32_chars
|
||||
```
|
||||
|
||||
### 依赖服务
|
||||
|
||||
1. **n8n 平台**:
|
||||
- 独立部署 (自托管或云服务)
|
||||
- 配置定时工作流 (Workflow)
|
||||
- 存储 `WEBHOOK_API_KEY` 用于 API 调用
|
||||
|
||||
2. **本地开发环境**:
|
||||
- Node.js 18+
|
||||
- pnpm 包管理器
|
||||
- Claude Code CLI (支持斜杠命令)
|
||||
|
||||
3. **生产环境**:
|
||||
- Vercel (Next.js 部署)
|
||||
- Neon PostgreSQL (数据库)
|
||||
|
||||
---
|
||||
|
||||
## 📊 执行监控
|
||||
|
||||
### 查看待处理任务
|
||||
|
||||
```bash
|
||||
# 查询待处理任务数量
|
||||
curl https://your-domain.com/api/discovery/tasks?status=PENDING&limit=100
|
||||
|
||||
# 查询进行中的任务
|
||||
curl https://your-domain.com/api/discovery/tasks?status=IN_PROGRESS
|
||||
|
||||
# 查询失败的任务 (需要重试)
|
||||
curl https://your-domain.com/api/discovery/tasks?status=FAILED
|
||||
```
|
||||
|
||||
### 重试失败任务
|
||||
|
||||
```bash
|
||||
# 手动重试失败的任务
|
||||
/discover-projects 10 --status=FAILED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 质量保障
|
||||
|
||||
### 内容质量标准 (详细参考: `.claude/schemas/project-content-template.md`)
|
||||
|
||||
1. **描述要求**:
|
||||
- 清晰说明项目的核心功能
|
||||
- 突出项目的独特价值
|
||||
- 避免使用营销术语 ("最好"、"第一"、"革命性" 等)
|
||||
- 字数控制在 10-500 字
|
||||
|
||||
2. **内容要求**:
|
||||
- 从项目 README 提取并重新组织
|
||||
- 避免机械翻译,符合中文表达习惯
|
||||
- 支持 Markdown 格式
|
||||
- 动态数据 (Star/Fork) 不写入内容
|
||||
|
||||
3. **链接要求**:
|
||||
- 必须包含 GITHUB 链接
|
||||
- 所有链接必须可访问
|
||||
- 链接类型必须正确 (WEBSITE/GITHUB/HUGGINGFACE/PAPER)
|
||||
|
||||
4. **标签要求**:
|
||||
- 1-10 个标签
|
||||
- 按技术栈/应用领域/开发状态分类
|
||||
- 中英文对应
|
||||
|
||||
### 数据验证
|
||||
|
||||
所有提交的数据必须通过 `ProjectInputSchema` 验证 (详见 `src/lib/validations.ts`):
|
||||
|
||||
```typescript
|
||||
ProjectInputSchema {
|
||||
name: string (1-200字符, 必填)
|
||||
description: string (10-500字符, 必填)
|
||||
tags: Tag[] (1-10个, 必填)
|
||||
links: ExternalLink[] (1-10个, 必填)
|
||||
nameEn?: string (1-200字符)
|
||||
descriptionEn?: string
|
||||
content?: string (Markdown, 最多10000字符)
|
||||
contentEn?: string
|
||||
status?: "ACTIVE" | "ARCHIVED"
|
||||
source?: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 第一次使用
|
||||
|
||||
1. **配置 n8n 工作流**:
|
||||
- 创建新的 n8n Workflow
|
||||
- 配置定时触发器 (如每天凌晨 2 点)
|
||||
- 添加 HTTP Request 节点调用 `POST /api/discovery/tasks`
|
||||
|
||||
2. **本地执行命令**:
|
||||
```bash
|
||||
# 启动开发服务器 (如果未运行)
|
||||
pnpm dev
|
||||
|
||||
# 执行项目发现命令
|
||||
/discover-projects
|
||||
```
|
||||
|
||||
3. **查看结果**:
|
||||
- 访问 https://your-domain.com 查看新收录的项目
|
||||
- 检查数据库确认数据正确性
|
||||
|
||||
### 日常维护
|
||||
|
||||
1. **定期执行命令** (建议每天 1-2 次):
|
||||
```bash
|
||||
/discover-projects 20
|
||||
```
|
||||
|
||||
2. **监控失败任务**:
|
||||
- 检查 `status=FAILED` 的任务
|
||||
- 分析错误原因 (网络问题、数据格式等)
|
||||
- 必要时手动重试
|
||||
|
||||
3. **优化内容质量**:
|
||||
- 随机抽查已收录项目的描述和内容
|
||||
- 调整 Agent 的提示词以提升质量
|
||||
|
||||
---
|
||||
|
||||
## 🔍 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **任务长时间处于 IN_PROGRESS 状态**:
|
||||
- 可能原因: Agent 探索超时、网络问题
|
||||
- 解决方案: 手动更新任务状态为 PENDING 后重试
|
||||
|
||||
2. **大量任务失败**:
|
||||
- 检查 `errorMessage` 字段
|
||||
- 常见原因: 数据格式不正确、URL 无法访问、API Key 错误
|
||||
|
||||
3. **重复项目被创建**:
|
||||
- 检查去重逻辑是否正常工作
|
||||
- 确认 `ExternalLink` 表的索引 `idx_link_type_url` 存在
|
||||
|
||||
4. **n8n 无法调用 API**:
|
||||
- 检查 `WEBHOOK_API_KEY` 是否正确配置
|
||||
- 确认 API 端点可访问
|
||||
- 查看 n8n 执行日志
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **API 参考**: `docs/api-reference.md`
|
||||
- **数据模型**: `prisma/schema.prisma`
|
||||
- **验证规则**: `src/lib/validations.ts`
|
||||
- **Agent 定义**: `.claude/agents/content-explorer-agent.md`
|
||||
- **Agent 定义**: `.claude/agents/api-submitter-agent.md`
|
||||
- **内容质量标准**: `.claude/schemas/project-content-template.md`
|
||||
- **项目主文档**: `CLAUDE.md`
|
||||
|
||||
---
|
||||
|
||||
## 📝 更新日志
|
||||
|
||||
- **2025-01-18**: 创建文档,记录完整的项目发现工作流
|
||||
@@ -1,106 +0,0 @@
|
||||
# Timeline E2E 测试指南
|
||||
|
||||
## 使用 chrome-devtools-mcp 测试
|
||||
|
||||
### 1. 启动测试环境
|
||||
|
||||
```bash
|
||||
# 确保开发服务器运行
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 2. 页面加载测试
|
||||
|
||||
使用 `new_page` 或 `navigate_page`:
|
||||
```
|
||||
URL: http://localhost:3000/timeline
|
||||
预期: 页面成功加载,中文路径重定向到 /zh/timeline
|
||||
```
|
||||
|
||||
使用 `take_snapshot`:
|
||||
- 验证页面结构正确,包含 header 和 timeline sections
|
||||
- 验证导航菜单包含"AI 时间轴"链接
|
||||
|
||||
### 3. 数据验证
|
||||
|
||||
使用 `evaluate_script`:
|
||||
```javascript
|
||||
() => {
|
||||
const yearSections = document.querySelectorAll('section');
|
||||
const eventCards = document.querySelectorAll('.stack-card');
|
||||
|
||||
return {
|
||||
yearCount: yearSections.length,
|
||||
eventCount: eventCards.length,
|
||||
hasHeader: document.querySelector('h1') !== null
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
预期结果:
|
||||
```json
|
||||
{
|
||||
"yearCount": 2,
|
||||
"eventCount": 3,
|
||||
"hasHeader": true
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 无控制台错误
|
||||
|
||||
使用 `list_console_messages` with `types: ["error", "warn"]`:
|
||||
- Expected: 空数组或仅资源预加载警告(可忽略)
|
||||
|
||||
### 5. 响应式测试
|
||||
|
||||
使用 `resize_page`:
|
||||
- 桌面: 1920x1080
|
||||
- 移动: 375x667 (iPhone SE)
|
||||
|
||||
验证: 布局在不同尺寸下正常显示,移动端显示汉堡菜单
|
||||
|
||||
### 6. 截图对比
|
||||
|
||||
使用 `take_screenshot`:
|
||||
- 保存路径: `tests/screenshots/timeline-page.png`
|
||||
- 手动对比与设计原型
|
||||
- 验证视觉风格符合 Neo-brutalism 设计
|
||||
|
||||
## 测试记录
|
||||
|
||||
### 2025-01-27 测试结果
|
||||
|
||||
- ✅ 页面加载成功(自动重定向到 /zh/timeline)
|
||||
- ✅ 数据渲染正确(2年3事件:2018年2个,2017年1个)
|
||||
- ✅ 年份降序排列(2018 → 2017)
|
||||
- ✅ 导航菜单"AI 时间轴"链接正常
|
||||
- ✅ 无控制台错误(仅1个资源预加载警告)
|
||||
- ✅ 移动端响应式布局正常
|
||||
- ✅ 桌面端截图已保存
|
||||
|
||||
### 测试数据
|
||||
|
||||
**2018年事件(2个):**
|
||||
1. BERT发布 - Google发布BERT预训练模型 (2018/10/11)
|
||||
2. GPT-1发布 - OpenAI发布第一代GPT模型 (2018/6/11)
|
||||
|
||||
**2017年事件(1个):**
|
||||
1. Transformer论文 - Google团队发表Transformer架构 (2017/6/12)
|
||||
|
||||
### 测试环境
|
||||
|
||||
- Node.js: v22
|
||||
- Next.js: 15.1.11
|
||||
- 浏览器: Chrome (chrome-devtools-mcp)
|
||||
- 测试时间: 2025-01-27 21:40
|
||||
|
||||
### 已知问题
|
||||
|
||||
无
|
||||
|
||||
### 后续优化
|
||||
|
||||
- 添加更多历史事件数据
|
||||
- 实现搜索和筛选功能
|
||||
- 添加事件详情页面
|
||||
- 优化移动端卡片间距
|
||||
@@ -1,145 +0,0 @@
|
||||
# n8n Tag Janitor Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
Daily automated workflow to clean up duplicate/similar tags using AI semantic analysis.
|
||||
|
||||
## Workflow Structure
|
||||
|
||||
[Cron] → [HTTP GET /api/tags] → [Code: Preprocess] → [AI: Generate Plan] → [Code: Validate JSON] → [HTTP POST /api/tags/maintenance] → [Notification]
|
||||
|
||||
## Node Configuration
|
||||
|
||||
### 1. Schedule Trigger (Cron)
|
||||
|
||||
- Trigger: Daily at 03:00 UTC
|
||||
- Timezone: UTC
|
||||
|
||||
### 2. HTTP Request - Fetch Tags
|
||||
|
||||
- Method: GET
|
||||
- URL: `{{$env.SITE_BASE_URL}}/api/tags`
|
||||
- Response: JSON with `.tags` array
|
||||
|
||||
### 3. Code Node - Preprocess
|
||||
|
||||
Purpose: Format tags for LLM input, handle chunking if >200 tags
|
||||
|
||||
```javascript
|
||||
const tags = $input.first().json.tags;
|
||||
const formatted = tags.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
nameEn: t.nameEn || "",
|
||||
projectCount: t._count.projects,
|
||||
}));
|
||||
// Sort by projectCount desc for prioritization
|
||||
formatted.sort((a, b) => b.projectCount - a.projectCount);
|
||||
return [{ json: { tags: formatted, total: formatted.length } }];
|
||||
```
|
||||
|
||||
### 4. AI Node - Generate Merge Plan
|
||||
|
||||
Model: GPT-4o / Claude 3.5 Sonnet
|
||||
Temperature: 0.1 (deterministic)
|
||||
|
||||
Prompt Template:
|
||||
|
||||
```
|
||||
You are a tag management expert. Analyze these tags and identify:
|
||||
1. Semantic duplicates that should be merged (e.g., "机器学习" and "ML" → keep "机器学习" with nameEn "Machine Learning")
|
||||
2. Tags missing English names that need nameEn补全
|
||||
|
||||
Tags (JSON):
|
||||
{{$json.tags}}
|
||||
|
||||
Output STRICT JSON (no markdown, no explanation):
|
||||
{
|
||||
"merges": [
|
||||
{
|
||||
"target": { "name": "保留的标签名", "nameEn": "Canonical English Name" },
|
||||
"sourceTagIds": ["id1", "id2"]
|
||||
}
|
||||
],
|
||||
"updates": [
|
||||
{ "tagId": "id", "nameEn": "English Name" }
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Keep the tag with higher projectCount as target
|
||||
- For merges, target can be { "id": "existing_id" } if keeping existing tag, or { "name": "...", "nameEn": "..." } to create new
|
||||
- Only include tags that NEED action (empty arrays if nothing to do)
|
||||
- nameEn should be proper English, not pinyin
|
||||
- Common tech terms: 机器学习=Machine Learning, 深度学习=Deep Learning, 自然语言处理=NLP
|
||||
```
|
||||
|
||||
### 5. Code Node - Validate & Parse
|
||||
|
||||
```javascript
|
||||
const response = $input.first().json;
|
||||
let plan;
|
||||
try {
|
||||
plan = typeof response === "string" ? JSON.parse(response) : response;
|
||||
} catch (e) {
|
||||
throw new Error("Invalid JSON from AI: " + e.message);
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
if (!Array.isArray(plan.merges)) plan.merges = [];
|
||||
if (!Array.isArray(plan.updates)) plan.updates = [];
|
||||
|
||||
// Self-merge check: target.id cannot be in sourceTagIds
|
||||
for (const merge of plan.merges) {
|
||||
if (merge.target.id && merge.sourceTagIds.includes(merge.target.id)) {
|
||||
throw new Error("Self-merge detected: " + merge.target.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [{ json: plan }];
|
||||
```
|
||||
|
||||
### 6. HTTP Request - Execute Maintenance
|
||||
|
||||
- Method: POST
|
||||
- URL: `{{$env.SITE_BASE_URL}}/api/tags/maintenance`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "{{$env.WEBHOOK_API_KEY}}",
|
||||
"updates": {{$json.updates}},
|
||||
"merges": {{$json.merges}}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Notification (Slack/Email/Webhook)
|
||||
|
||||
Send summary:
|
||||
|
||||
- Tags merged: X
|
||||
- Tags deleted: Y
|
||||
- Names updated: Z
|
||||
- Errors: [list]
|
||||
|
||||
## Environment Variables Required
|
||||
|
||||
- `SITE_BASE_URL`: https://your-site.com
|
||||
- `WEBHOOK_API_KEY`: API key for authentication
|
||||
|
||||
## Chunking Strategy (for >200 tags)
|
||||
|
||||
1. Split tags into batches of 100
|
||||
2. Process each batch sequentially
|
||||
3. Aggregate results before final notification
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Retry HTTP requests 3x with exponential backoff
|
||||
- On AI parse failure: skip and alert
|
||||
- On maintenance failure: log error, continue with notification
|
||||
|
||||
## Testing
|
||||
|
||||
1. Dry run: Comment out HTTP POST node, check AI output only
|
||||
2. Real run: Enable all nodes, monitor /api/tags count before/after
|
||||
@@ -1,121 +0,0 @@
|
||||
# Frontier Signals Workflow
|
||||
|
||||
## Goal
|
||||
|
||||
Aggregate frontier discussions from multiple platforms, keep only **AI Agent-related** signals via AI filtering, and ingest into `POST /api/webhook/signals`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Workflow name: `前沿信号聚合(多源+AI Agent过滤)`
|
||||
- Workflow ID: `bAxNZKGq2ApUUiw9`
|
||||
- Status: `active`
|
||||
- Trigger: every 4 hours (`Schedule Trigger`)
|
||||
- Activated at: `2026-02-23`
|
||||
|
||||
## Source Research (Endpoints + Extracted Elements)
|
||||
|
||||
| Source | Endpoint | Node Type | Extracted Elements |
|
||||
| --- | --- | --- | --- |
|
||||
| Hacker News | `https://hacker-news.firebaseio.com/v0/topstories.json` + `.../item/{id}.json` | HTTP Request | `title`, `url`, `text`, `score`, `descendants`, `time` |
|
||||
| GitHub | `https://api.github.com/search/issues` | HTTP Request | `title`, `html_url`, `body`, `comments`, `reactions`, `labels`, `updated_at` |
|
||||
| arXiv | `https://export.arxiv.org/api/query?...` | RSS Read | `title`, `link`, `content/contentSnippet`, `pubDate/isoDate`, `categories` |
|
||||
| Reddit | `https://www.reddit.com/r/LocalLLaMA/new.json?limit=40` | HTTP Request | `title`, `permalink`, `selftext/url`, `ups`, `num_comments`, `created_utc` |
|
||||
| Product Hunt | `https://www.producthunt.com/feed` | RSS Read | `title`, `link`, `content/contentSnippet`, `published/updated` |
|
||||
| Hugging Face | `https://huggingface.co/blog/feed.xml` | RSS Read | `title`, `link`, `content/contentSnippet`, `published/updated` |
|
||||
|
||||
## Why These Nodes
|
||||
|
||||
- `Schedule Trigger`: periodic ingestion
|
||||
- `HTTP Request` / `RSS Read`: source fetching with stable machine-readable endpoints
|
||||
- `Code`: per-source normalization and schema-safe cleanup
|
||||
- `Merge (append)`: multi-source union
|
||||
- `Remove Duplicates`: `source + sourceUrl` dedupe before/after AI
|
||||
- `Limit`: cap candidate volume before AI
|
||||
- `LLM Chain + Structured Output Parser`: relevance filtering + structured sections
|
||||
- `If`: keep only `shouldKeep=true`
|
||||
- `HTTP Request` (POST): write to `/api/webhook/signals`
|
||||
|
||||
## Target Contract Mapping
|
||||
|
||||
The workflow emits payload compatible with `SignalWebhookPayloadSchema`:
|
||||
|
||||
- `apiKey`: manually configured in node `构建 Webhook Payload`
|
||||
- `signals[]`:
|
||||
- `source` -> one of:
|
||||
- `hacker_news`
|
||||
- `github`
|
||||
- `arxiv`
|
||||
- `hugging_face`
|
||||
- `reddit`
|
||||
- `product_hunt`
|
||||
- `sourceUrl`, `title`, `summary`, `topic`, `tags`, `sections`, `engagement`, `hotScore`, `isHot`, `publishedAt`, `isActive`
|
||||
|
||||
Sections are constrained to:
|
||||
|
||||
- style: `focus | debate | evidence | action | risk`
|
||||
- max 6 sections, max 6 items per section
|
||||
|
||||
## AI Filtering Policy
|
||||
|
||||
The AI node does **not** score ideas by novelty/reliability/feasibility.
|
||||
It only decides whether a signal is about AI Agent topics and then structures content for reading efficiency.
|
||||
|
||||
- Keep (`shouldKeep=true`) if discussion is materially agent-related
|
||||
- Drop (`shouldKeep=false`) if clearly unrelated to AI Agent
|
||||
- Drop low-discussion or low-value question items (`shouldKeep=false`)
|
||||
- If information is insufficient, default to drop
|
||||
|
||||
## High-Heat Gate(AI 前硬过滤)
|
||||
|
||||
`筛掉占位候选` 节点会在 AI 前做硬性筛选,减少低价值输入:
|
||||
|
||||
- source-level minimum engagement:
|
||||
- `hacker_news >= 35`
|
||||
- `github >= 8`
|
||||
- `reddit >= 25`
|
||||
- source-level freshness window:
|
||||
- `hacker_news <= 72h`
|
||||
- `github <= 168h`
|
||||
- `reddit/arxiv/hugging_face/product_hunt <= 120h`
|
||||
- low-value question filtering:
|
||||
- 求助型单问题 + 短摘要 + 低互动,直接丢弃
|
||||
- per-source candidate cap(AI 前限流):
|
||||
- `hacker_news 6`, `github 6`, `reddit 6`, `arxiv 4`, `hugging_face 4`, `product_hunt 4`
|
||||
- strict keyword gate for low-discussion sources:
|
||||
- `arxiv/hugging_face/product_hunt` 必须命中 agent 相关关键词(如 `agent`, `agentic`, `multi-agent`, `tool calling`, `mcp`, `智能体`)才进入 AI
|
||||
|
||||
## HOT 判定(流程内)
|
||||
|
||||
`清洗并映射入库字段` Code 节点会基于**互动量 + 时间新鲜度**计算:
|
||||
|
||||
- `hotScore`:0-100
|
||||
- `isHot`:布尔值,用于页面 HOT 标签与排序优先
|
||||
|
||||
该判定只衡量讨论热度,不评价观点对错或质量。
|
||||
|
||||
## DB 升级(新增 HOT 字段)
|
||||
|
||||
由于当前仓库默认忽略 `prisma/migrations/*_*` 目录,建议在数据库手动执行一次:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "signals"
|
||||
ADD COLUMN IF NOT EXISTS "hotScore" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS "isHot" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_signal_hot_score" ON "signals"("hotScore");
|
||||
CREATE INDEX IF NOT EXISTS "idx_signal_active_hot_sort" ON "signals"("isActive", "isHot", "hotScore", "publishedAt");
|
||||
```
|
||||
|
||||
## Manual Configuration (No Env)
|
||||
|
||||
This workflow is intentionally configured without `$env` usage.
|
||||
|
||||
- Node `构建 Webhook Payload`:
|
||||
- set `apiKey` to your real webhook key (replace placeholder string)
|
||||
- Node `发送到 Signals Webhook`:
|
||||
- set the target URL to your actual API base (current default is `https://agentpark.fun/api/webhook/signals`)
|
||||
|
||||
## Notes
|
||||
|
||||
- Reddit source uses JSON API with explicit `User-Agent` headers to reduce 403 blocking risk.
|
||||
- `n8n_test_workflow` cannot trigger schedule-only workflows via API; runtime verification should be done by waiting for scheduled execution or manual run in n8n UI.
|
||||
@@ -1,177 +0,0 @@
|
||||
# n8n 历史数据初始化 Workflow
|
||||
|
||||
## 概述
|
||||
|
||||
此 workflow 用于一次性收集和初始化 2017-2025 年的 AI 重大事件数据。
|
||||
|
||||
## Workflow 结构
|
||||
|
||||
### Node 1: Cron 触发器(手动触发)
|
||||
|
||||
- 节点类型: `Manual Trigger`
|
||||
- 用途: 开发测试时手动运行
|
||||
|
||||
### Node 2: 设置年份列表
|
||||
|
||||
- 节点类型: `Code`
|
||||
- 用途: 定义要处理的年份列表
|
||||
|
||||
```javascript
|
||||
// 返回年份数组
|
||||
return [
|
||||
{ year: 2017 },
|
||||
{ year: 2018 },
|
||||
{ year: 2019 },
|
||||
{ year: 2020 },
|
||||
{ year: 2021 },
|
||||
{ year: 2022 },
|
||||
{ year: 2023 },
|
||||
{ year: 2024 },
|
||||
{ year: 2025 },
|
||||
];
|
||||
```
|
||||
|
||||
### Node 3: 搜索 Agent(循环每年)
|
||||
|
||||
- 节点类型: `Loop Over Items`
|
||||
- 用途: 遍历每个年份
|
||||
|
||||
### Node 4: Web Search - Agent 1
|
||||
|
||||
- 节点类型: `HTTP Request`
|
||||
- 方法: POST
|
||||
- URL: `http://localhost:3000/api/web-search` (或 MCP 端点)
|
||||
- Headers:
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"search_query": "AI breakthrough {{ $json.year }} LLM release transformer model",
|
||||
"search_recency_filter": "noLimit",
|
||||
"content_size": "high"
|
||||
}
|
||||
```
|
||||
|
||||
### Node 5: 筛选 Agent - Agent 2
|
||||
|
||||
- 节点类型: `Code`
|
||||
- 用途: 根据权威来源筛选
|
||||
|
||||
```javascript
|
||||
const trustedDomains = [
|
||||
'arxiv.org',
|
||||
'openai.com',
|
||||
'anthropic.com',
|
||||
'google.ai',
|
||||
'meta.ai',
|
||||
'deepmind.com',
|
||||
'research.google',
|
||||
];
|
||||
|
||||
const items = $input.all();
|
||||
|
||||
const filtered = items.filter(item => {
|
||||
const url = item.json.url || '';
|
||||
return trustedDomains.some(domain => url.includes(domain));
|
||||
});
|
||||
|
||||
return filtered;
|
||||
```
|
||||
|
||||
### Node 6: 格式化 Agent - Agent 3
|
||||
|
||||
- 节点类型: `Code`
|
||||
- 用途: 转换为 API 格式
|
||||
|
||||
```javascript
|
||||
const items = $input.all();
|
||||
|
||||
const formatted = items.map(item => {
|
||||
const publishedDate = item.json.published_date || new Date().toISOString();
|
||||
|
||||
return {
|
||||
json: {
|
||||
title: item.json.title || 'Untitled',
|
||||
eventDate: new Date(publishedDate).toISOString(),
|
||||
description: (item.json.description || item.json.snippet || '').substring(0, 500),
|
||||
imageUrl: item.json.image_url || 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800',
|
||||
sourceUrl: item.json.url,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return formatted;
|
||||
```
|
||||
|
||||
### Node 7: 提交到 API
|
||||
|
||||
- 节点类型: `HTTP Request`
|
||||
- 方法: POST
|
||||
- URL: `http://localhost:3000/api/events`
|
||||
- Headers:
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "={{ $env.WEBHOOK_API_KEY }}"
|
||||
}
|
||||
```
|
||||
- Body: `={{ $json }}` (发送整个数组)
|
||||
|
||||
### Node 8: 错误处理
|
||||
|
||||
- 节点类型: `IF`
|
||||
- 条件: 检查上一个节点的 status code
|
||||
- On True: 记录成功
|
||||
- On False: 发送错误邮件
|
||||
|
||||
## 环境变量
|
||||
|
||||
在 n8n 中设置:
|
||||
- `WEBHOOK_API_KEY`: 你的 API 密钥(从 .env.local 获取)
|
||||
- `API_ENDPOINT`: `http://localhost:3000/api/events` (开发) 或生产 URL
|
||||
|
||||
## 测试步骤
|
||||
|
||||
1. 在 n8n UI 中创建此 workflow
|
||||
2. 手动触发运行
|
||||
3. 检查数据库: `pnpm prisma studio`
|
||||
4. 验证事件已正确创建
|
||||
|
||||
## 数据质量标准
|
||||
|
||||
### 标题要求
|
||||
- 清晰描述事件
|
||||
- 1-200 字符
|
||||
- 避免营销术语
|
||||
|
||||
### 描述要求
|
||||
- 客观描述功能和价值
|
||||
- 10-500 字符
|
||||
- 突出技术亮点
|
||||
|
||||
### 日期要求
|
||||
- ISO 8601 格式
|
||||
- 准确的发布日期
|
||||
|
||||
### 链接要求
|
||||
- 必须包含 sourceUrl(权威来源)
|
||||
- 链接可访问
|
||||
- 优先 arxiv.org、openai.com 等
|
||||
|
||||
## 权威来源列表
|
||||
|
||||
- 学术论文: arxiv.org
|
||||
- 官方博客: openai.com, anthropic.com, google.ai, meta.ai
|
||||
- 研究机构: deepmind.com, research.google
|
||||
- 新闻媒体: techcrunch.com, theverge.com (需人工审核)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **去重**: workflow 会自动跳过重复的事件(基于 sourceUrl)
|
||||
2. **图片**: 如果没有图片,使用默认占位图
|
||||
3. **错误处理**: 失败的事件会被记录,不会中断整个流程
|
||||
4. **数据验证**: API 会验证所有字段,不符合标准的数据会被拒绝
|
||||
@@ -1,135 +0,0 @@
|
||||
# n8n 增量更新 Workflow
|
||||
|
||||
## 概述
|
||||
|
||||
此 workflow 每周一自动运行,收集最近 7 天的新 AI 事件。
|
||||
|
||||
## Workflow 结构
|
||||
|
||||
### Node 1: Cron 触发器
|
||||
|
||||
- 节点类型: `Cron`
|
||||
- 表达式: `0 9 * * 1` (每周一早上 9:00)
|
||||
- 时区: Asia/Shanghai
|
||||
|
||||
### Node 2: Web Search - Agent 1
|
||||
|
||||
- 节点类型: `HTTP Request`
|
||||
- URL: `http://localhost:3000/api/web-search` (或 MCP 端点)
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"search_query": "AI news LLM release model launch this week",
|
||||
"search_recency_filter": "oneWeek"
|
||||
}
|
||||
```
|
||||
|
||||
### Node 3: 筛选 Agent - Agent 2
|
||||
|
||||
- 节点类型: `Code`
|
||||
- 用途: 筛选 + 去重(查询数据库避免重复)
|
||||
|
||||
```javascript
|
||||
const trustedDomains = [
|
||||
'arxiv.org',
|
||||
'openai.com',
|
||||
'anthropic.com',
|
||||
'google.ai',
|
||||
'meta.ai',
|
||||
'deepmind.com',
|
||||
];
|
||||
|
||||
// 过滤权威来源
|
||||
const items = $input.all();
|
||||
const filtered = items.filter(item => {
|
||||
const url = item.json.url || '';
|
||||
return trustedDomains.some(domain => url.includes(domain));
|
||||
});
|
||||
|
||||
// TODO: 添加数据库查询去重
|
||||
// 这里可以调用 GET /api/events 检查 sourceUrl 是否已存在
|
||||
|
||||
return filtered;
|
||||
```
|
||||
|
||||
### Node 4: 格式化 Agent - Agent 3
|
||||
|
||||
- 节点类型: `Code`
|
||||
- 代码: 同历史 workflow
|
||||
|
||||
### Node 5: 提交到 API
|
||||
|
||||
- 节点类型: `HTTP Request`
|
||||
- 配置: 同历史 workflow
|
||||
|
||||
### Node 6: 发送通知邮件
|
||||
|
||||
- 节点类型: `Send Email`
|
||||
- 条件: 仅在创建新事件时发送
|
||||
- 内容:
|
||||
```
|
||||
主题: AI Timeline - 新事件已添加
|
||||
|
||||
本次更新添加了 {{ $json.created }} 个新事件。
|
||||
|
||||
查看: https://your-domain.com/timeline
|
||||
```
|
||||
|
||||
### Node 7: 错误处理
|
||||
|
||||
- 节点类型: `Error Trigger`
|
||||
- 动作: 发送错误邮件到管理员
|
||||
|
||||
## 测试
|
||||
|
||||
1. 修改 Cron 为手动触发进行测试
|
||||
2. 验证只有新事件被添加
|
||||
3. 检查邮件通知是否正常发送
|
||||
4. 确认错误处理工作正常
|
||||
|
||||
## 数据质量保证
|
||||
|
||||
### 自动筛选规则
|
||||
|
||||
1. **来源可信**: 仅来自权威域名
|
||||
2. **时效性**: 仅最近 7 天的内容
|
||||
3. **去重**: 基于 sourceUrl 自动去重
|
||||
|
||||
### 人工审核流程
|
||||
|
||||
建议在自动导入后进行人工审核:
|
||||
1. 检查标题是否准确
|
||||
2. 验证描述是否客观
|
||||
3. 确认图片是否合适
|
||||
4. 测试链接是否可访问
|
||||
|
||||
## 邮件通知配置
|
||||
|
||||
### 成功通知
|
||||
|
||||
当有新事件添加时发送:
|
||||
- 收件人: 内容团队
|
||||
- 主题: "AI Timeline - {{ count }} 个新事件已添加"
|
||||
- 内容: 包含事件列表和链接
|
||||
|
||||
### 错误通知
|
||||
|
||||
当 workflow 失败时发送:
|
||||
- 收件人: 技术团队
|
||||
- 主题: "⚠️ AI Timeline Workflow 失败"
|
||||
- 内容: 错误详情和日志
|
||||
|
||||
## 监控指标
|
||||
|
||||
建议监控以下指标:
|
||||
- 每周添加的事件数量
|
||||
- workflow 执行时间
|
||||
- 失败率和错误类型
|
||||
- 去重率
|
||||
|
||||
## 优化建议
|
||||
|
||||
1. **AI 辅助筛选**: 使用 AI 模型评估新闻相关性
|
||||
2. **多源聚合**: 整合多个搜索 API
|
||||
3. **智能去重**: 基于标题相似度去重
|
||||
4. **自动翻译**: 自动生成英文翻译(titleEn, descriptionEn)
|
||||
@@ -1,435 +0,0 @@
|
||||
{
|
||||
"name": "Project Tag Reset - Multi AI Classifier",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "manual-trigger",
|
||||
"name": "手动触发",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "https://www.agentpark.fun/api/tags",
|
||||
"options": {}
|
||||
},
|
||||
"id": "fetch-tags",
|
||||
"name": "获取标签池",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [
|
||||
220,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "https://www.agentpark.fun/api/projects",
|
||||
"sendQuery": true,
|
||||
"specifyQuery": "keypair",
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"value": "100"
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"value": "latest"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"pagination": {
|
||||
"pagination": {
|
||||
"paginationMode": "updateAParameterInEachRequest",
|
||||
"parameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"type": "qs",
|
||||
"name": "page",
|
||||
"value": "={{($response.body.pagination.page ?? $response.body.pagination.currentPage ?? 1) + 1}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"paginationCompleteWhen": "other",
|
||||
"completeExpression": "={{!$response.body.pagination || (($response.body.pagination.page ?? $response.body.pagination.currentPage ?? 1) >= ($response.body.pagination.totalPages ?? 1))}}",
|
||||
"limitPagesFetched": true,
|
||||
"maxRequests": 20,
|
||||
"requestInterval": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "fetch-projects",
|
||||
"name": "获取项目列表",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [
|
||||
440,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const tagsResponse = $('获取标签池').first().json;\nconst project = $input.first().json.projects;\n\nif (!tagsResponse?.success || !Array.isArray(tagsResponse?.tags)) {\n throw new Error('Failed to fetch tags from /api/tags');\n}\n\nif (!project || typeof project !== 'object' || !project.slug) {\n throw new Error('Invalid project item after split');\n}\n\nconst allowedCategories = [\n 'FIXED_PROJECT_TYPE',\n 'TECH_STACK',\n 'AI_PARADIGM',\n 'PRODUCT_FORM',\n 'DOMAIN_SCENARIO',\n];\n\nconst tagPools = Object.fromEntries(\n allowedCategories.map((category) => [\n category,\n tagsResponse.tags\n .filter((tag) => tag.category === category)\n .map((tag) => ({\n id: tag.id,\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n })),\n ])\n);\n\nreturn [\n {\n json: {\n projectSlug: project.slug,\n projectName: project.name,\n projectNameEn: project.nameEn || '',\n projectDescription: project.description,\n projectDescriptionEn: project.descriptionEn || '',\n currentTags: Array.isArray(project.tags)\n ? project.tags.map((tag) => ({\n slug: tag.slug,\n name: tag.name,\n nameEn: tag.nameEn || '',\n }))\n : [],\n tagPools,\n },\n },\n];"
|
||||
},
|
||||
"id": "prepare-classification-items",
|
||||
"name": "准备分类输入",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
660,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": {
|
||||
"values": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You classify projects into FIXED_PROJECT_TYPE. Return STRICT JSON only."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- nameEn: {{$json.projectNameEn}}\\n- description: {{$json.projectDescription}}\\n- descriptionEn: {{$json.projectDescriptionEn}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (FIXED_PROJECT_TYPE):\\n{{JSON.stringify($json.tagPools.FIXED_PROJECT_TYPE)}}\\n\\nTask:\\n- Choose exactly ONE best slug from candidate pool.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"one-slug\"]\\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"id": "ai-fixed-project-type",
|
||||
"name": "AI 固定项目分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [
|
||||
880,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": {
|
||||
"values": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You classify DOMAIN_SCENARIO tags. Return STRICT JSON only."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (DOMAIN_SCENARIO):\\n{{JSON.stringify($json.tagPools.DOMAIN_SCENARIO)}}\\n\\nTask:\\n- Choose 1 to 3 slugs from candidate pool.\\n- Keep only the most representative domains for this project.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"id": "ai-domain-scenario",
|
||||
"name": "AI 领域场景分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [
|
||||
1100,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": {
|
||||
"values": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You classify PRODUCT_FORM tags. Return STRICT JSON only."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (PRODUCT_FORM):\\n{{JSON.stringify($json.tagPools.PRODUCT_FORM)}}\\n\\nTask:\\n- Choose 1 to 3 slugs from candidate pool that best represent product form.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"id": "ai-product-form",
|
||||
"name": "AI 产品形态分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [
|
||||
1320,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": {
|
||||
"values": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You classify TECH_STACK tags. Return STRICT JSON only."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (TECH_STACK):\\n{{JSON.stringify($json.tagPools.TECH_STACK)}}\\n\\nTask:\\n- Choose 1 to 8 slugs from candidate pool for the main technologies used by this project.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"id": "ai-tech-stack",
|
||||
"name": "AI 技术栈分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [
|
||||
1540,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": {
|
||||
"values": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You classify AI_PARADIGM tags. Return STRICT JSON only."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "=Project:\\n- slug: {{$json.projectSlug}}\\n- name: {{$json.projectName}}\\n- description: {{$json.projectDescription}}\\n- currentTags: {{JSON.stringify($json.currentTags)}}\\n\\nCandidate pool (AI_PARADIGM):\\n{{JSON.stringify($json.tagPools.AI_PARADIGM)}}\\n\\nTask:\\n- Choose 0 to 5 slugs from candidate pool for AI paradigm.\\n\\nOutput strict JSON only:\\n{\\n \"selected\": [\"slug1\", \"slug2\"]\\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"id": "ai-ai-paradigm",
|
||||
"name": "AI 技术范式分类",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [
|
||||
1760,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "function parseNodeJson(nodeJson) {\n const content = nodeJson?.message?.content || nodeJson?.text || nodeJson?.response || nodeJson;\n\n if (typeof content === 'string') {\n const trimmed = content.trim();\n try {\n return JSON.parse(trimmed);\n } catch {\n const jsonMatch = trimmed.match(/\\{[\\s\\S]*\\}/);\n if (jsonMatch) {\n return JSON.parse(jsonMatch[0]);\n }\n return { selected: [] };\n }\n }\n\n if (typeof content === 'object' && content !== null) {\n return content;\n }\n\n return { selected: [] };\n}\n\nfunction normalizeSelected(parsed, pool, minCount, maxCount) {\n const poolSlugs = pool.map((tag) => String(tag.slug).toLowerCase());\n const allowed = new Set(poolSlugs);\n const selectedRaw = Array.isArray(parsed?.selected) ? parsed.selected : [];\n const selected = [];\n\n for (const slug of selectedRaw) {\n const normalizedSlug = String(slug || '').trim().toLowerCase();\n if (!normalizedSlug || !allowed.has(normalizedSlug) || selected.includes(normalizedSlug)) {\n continue;\n }\n selected.push(normalizedSlug);\n if (selected.length >= maxCount) {\n break;\n }\n }\n\n if (selected.length < minCount) {\n return poolSlugs.slice(0, minCount);\n }\n return selected;\n}\n\nconst preparedItems = $items('准备分类输入', 0);\nconst fixedItems = $items('AI 固定项目分类', 0);\nconst domainItems = $items('AI 领域场景分类', 0);\nconst productItems = $items('AI 产品形态分类', 0);\nconst techItems = $items('AI 技术栈分类', 0);\nconst paradigmItems = $items('AI 技术范式分类', 0);\n\nif (\n preparedItems.length !== fixedItems.length ||\n preparedItems.length !== domainItems.length ||\n preparedItems.length !== productItems.length ||\n preparedItems.length !== techItems.length ||\n preparedItems.length !== paradigmItems.length\n) {\n throw new Error('AI node output item count mismatch');\n}\n\nconst output = [];\nfor (let i = 0; i < preparedItems.length; i++) {\n const base = preparedItems[i].json;\n const pools = base.tagPools;\n\n const fixed = normalizeSelected(\n parseNodeJson(fixedItems[i].json),\n pools.FIXED_PROJECT_TYPE,\n 1,\n 1\n );\n const domains = normalizeSelected(\n parseNodeJson(domainItems[i].json),\n pools.DOMAIN_SCENARIO,\n 1,\n 3\n );\n const productForms = normalizeSelected(\n parseNodeJson(productItems[i].json),\n pools.PRODUCT_FORM,\n 1,\n 3\n );\n const techStack = normalizeSelected(\n parseNodeJson(techItems[i].json),\n pools.TECH_STACK,\n 1,\n 8\n );\n const paradigms = normalizeSelected(\n parseNodeJson(paradigmItems[i].json),\n pools.AI_PARADIGM,\n 0,\n 5\n );\n\n output.push({\n json: {\n projectSlug: base.projectSlug,\n selectedTagSlugsByCategory: {\n FIXED_PROJECT_TYPE: fixed,\n TECH_STACK: techStack,\n AI_PARADIGM: paradigms,\n PRODUCT_FORM: productForms,\n DOMAIN_SCENARIO: domains,\n },\n },\n });\n}\n\nreturn output;"
|
||||
},
|
||||
"id": "build-reset-payload",
|
||||
"name": "组装重置载荷",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1980,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://www.agentpark.fun/api/tags/reset-projects",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"dryRun\": false,\n \"replaceAllCategories\": true,\n \"projects\": [\n {\n \"projectSlug\": \"{{$json.projectSlug}}\",\n \"selectedTagSlugsByCategory\": {{JSON.stringify($json.selectedTagSlugsByCategory)}}\n }\n ]\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "reset-project-tags",
|
||||
"name": "调用标签重置接口",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.4,
|
||||
"position": [
|
||||
2200,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const results = $input.all().map((item) => item.json);\nconst summary = {\n totalRequests: results.length,\n successRequests: results.filter((r) => r.success === true).length,\n failedRequests: results.filter((r) => r.success !== true).length,\n timestamp: new Date().toISOString(),\n};\n\nreturn [{ json: { summary, results } }];"
|
||||
},
|
||||
"id": "summarize-results",
|
||||
"name": "汇总执行结果",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
2420,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "splitOutItems",
|
||||
"fieldToSplitOut": "projects",
|
||||
"include": "noOtherFields",
|
||||
"options": {}
|
||||
},
|
||||
"id": "split-projects",
|
||||
"name": "拆分项目列表",
|
||||
"type": "n8n-nodes-base.itemLists",
|
||||
"typeVersion": 3.1,
|
||||
"position": [
|
||||
560,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"手动触发": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "获取标签池",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"获取标签池": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "获取项目列表",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"获取项目列表": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "拆分项目列表",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"准备分类输入": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 固定项目分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI 固定项目分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 领域场景分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI 领域场景分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 产品形态分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI 产品形态分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 技术栈分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI 技术栈分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "AI 技术范式分类",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"AI 技术范式分类": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "组装重置载荷",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"组装重置载荷": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "调用标签重置接口",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"调用标签重置接口": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "汇总执行结果",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"拆分项目列表": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "准备分类输入",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# Project Tag Reset Workflow
|
||||
|
||||
## Goal
|
||||
|
||||
Reset every project's tags according to the new taxonomy, using **one dedicated AI node per tag category**:
|
||||
|
||||
- `FIXED_PROJECT_TYPE`
|
||||
- `DOMAIN_SCENARIO`
|
||||
- `PRODUCT_FORM`
|
||||
- `TECH_STACK`
|
||||
- `AI_PARADIGM`
|
||||
|
||||
Each AI node must select only from its own category pool.
|
||||
|
||||
## Files
|
||||
|
||||
- Workflow JSON: `docs/n8n/project-tag-reset-workflow.json`
|
||||
- API endpoint used by workflow: `POST /api/tags/reset-projects`
|
||||
|
||||
## Required Environment Variables in n8n
|
||||
|
||||
- `WEBHOOK_API_KEY`: same key configured on Next.js server
|
||||
|
||||
## Fixed Base URL
|
||||
|
||||
- All workflow API URLs are hardcoded to `https://www.agentpark.fun`
|
||||
|
||||
## Execution Flow
|
||||
|
||||
1. `手动触发`
|
||||
2. `获取标签池` (`HTTP GET /api/tags`) 读取标签池
|
||||
3. `获取项目列表` (`HTTP GET /api/projects`) 使用 Query 参数(`limit=100,page=1,sort=latest`)+ HTTP 节点内置分页拉取全部项目
|
||||
4. `拆分项目列表` 将每页 `projects[]` 拆分为“单项目一条记录”
|
||||
5. `准备分类输入` 仅做轻量字段整理(不再负责分页/HTTP 请求)
|
||||
6. 五个独立 AI 节点分别分类:
|
||||
- `AI 固定项目分类`
|
||||
- `AI 领域场景分类`
|
||||
- `AI 产品形态分类`
|
||||
- `AI 技术栈分类`
|
||||
- `AI 技术范式分类`
|
||||
7. `组装重置载荷` 归一化 AI 输出并生成 `selectedTagSlugsByCategory`
|
||||
8. `调用标签重置接口` (`HTTP POST /api/tags/reset-projects`) 回写标签
|
||||
9. `汇总执行结果` 输出成功/失败统计
|
||||
|
||||
## Notes
|
||||
|
||||
- Workflow uses HTTP node built-in pagination for `/api/projects` (`limit=100`) to process full project volume.
|
||||
- `page` 不再写死在 URL 上,避免出现重复请求同一页导致的 identical response 停止问题。
|
||||
- Pagination and project fan-out no longer rely on complex Code-node network logic.
|
||||
- Workflow no longer depends on `SITE_BASE_URL`; base URL is fixed to `https://www.agentpark.fun`.
|
||||
- Endpoint supports `dryRun`. You can set `"dryRun": true` first in the request node for safe validation.
|
||||
- Endpoint performs category validation and rejects cross-category slug usage.
|
||||
@@ -1,151 +0,0 @@
|
||||
{
|
||||
"name": "Tag Janitor - Daily Cleanup",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"rule": {
|
||||
"interval": [
|
||||
{
|
||||
"triggerAtHour": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "schedule",
|
||||
"name": "Daily 3AM UTC",
|
||||
"type": "n8n-nodes-base.scheduleTrigger",
|
||||
"typeVersion": 1.2,
|
||||
"position": [0, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"url": "={{$env.SITE_BASE_URL}}/api/tags",
|
||||
"options": {}
|
||||
},
|
||||
"id": "fetch-tags",
|
||||
"name": "Fetch Tags",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [220, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const response = $input.first().json;\nif (!response.success) {\n throw new Error('Failed to fetch tags: ' + JSON.stringify(response));\n}\n\nconst tags = response.tags;\nconst formatted = tags.map(t => ({\n id: t.id,\n name: t.name,\n nameEn: t.nameEn || '',\n projectCount: t._count?.projects || 0\n}));\n\n// Sort by project count descending\nformatted.sort((a, b) => b.projectCount - a.projectCount);\n\nreturn [{ json: { tags: formatted, total: formatted.length } }];"
|
||||
},
|
||||
"id": "preprocess",
|
||||
"name": "Preprocess Tags",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [440, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"model": "gpt-4o",
|
||||
"messages": {
|
||||
"values": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are a tag management expert. Analyze tags and identify semantic duplicates for merging and missing English names for completion. Output STRICT JSON only."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Analyze these tags and identify:\n1. Semantic duplicates to merge (e.g., \"机器学习\" and \"ML\" → keep higher projectCount)\n2. Tags missing nameEn that need English names\n\nTags ({{$json.total}} total):\n{{JSON.stringify($json.tags)}}\n\nOutput STRICT JSON (no markdown):\n{\n \"merges\": [\n {\n \"target\": { \"name\": \"保留的标签\", \"nameEn\": \"Canonical Name\" },\n \"sourceTagIds\": [\"id1\", \"id2\"]\n }\n ],\n \"updates\": [\n { \"tagId\": \"id\", \"nameEn\": \"English Name\" }\n ]\n}\n\nRules:\n- Keep tag with higher projectCount as merge target\n- target can use { \"id\": \"existing\" } to keep existing tag\n- Only include tags needing action (empty arrays if none)\n- nameEn must be proper English, not pinyin"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0.1
|
||||
}
|
||||
},
|
||||
"id": "ai-analyze",
|
||||
"name": "AI Analyze Tags",
|
||||
"type": "@n8n/n8n-nodes-langchain.openAi",
|
||||
"typeVersion": 1.8,
|
||||
"position": [660, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const response = $input.first().json;\nlet plan;\n\ntry {\n const content = response.message?.content || response.text || response;\n plan = typeof content === 'string' ? JSON.parse(content) : content;\n} catch (e) {\n throw new Error('Failed to parse AI response: ' + e.message);\n}\n\nif (!Array.isArray(plan.merges)) plan.merges = [];\nif (!Array.isArray(plan.updates)) plan.updates = [];\n\nfor (const merge of plan.merges) {\n if (merge.target.id && merge.sourceTagIds.includes(merge.target.id)) {\n throw new Error('Self-merge detected: ' + merge.target.id);\n }\n}\n\nif (plan.merges.length === 0 && plan.updates.length === 0) {\n return [{ json: { skipped: true, reason: 'No changes needed' } }];\n}\n\nreturn [{ json: plan }];"
|
||||
},
|
||||
"id": "validate-json",
|
||||
"name": "Validate JSON",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [880, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"boolean": [
|
||||
{
|
||||
"value1": "={{$json.skipped}}",
|
||||
"value2": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "check-skip",
|
||||
"name": "Check Skip",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2,
|
||||
"position": [1100, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "={{$env.SITE_BASE_URL}}/api/tags/maintenance",
|
||||
"sendBody": true,
|
||||
"specifyBody": "json",
|
||||
"jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"updates\": {{JSON.stringify($json.updates)}},\n \"merges\": {{JSON.stringify($json.merges)}}\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "execute-maintenance",
|
||||
"name": "Execute Maintenance",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [1320, -100]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const maintenanceResult = $('Execute Maintenance').first()?.json || {};\nconst skipped = $('Check Skip').first()?.json?.skipped;\n\nif (skipped) {\n return [{ json: {\n status: 'skipped',\n message: 'No changes needed',\n timestamp: new Date().toISOString()\n }}];\n}\n\nconst result = maintenanceResult.result || {};\n\nreturn [{ json: {\n status: maintenanceResult.success ? 'success' : 'failed',\n updatedCount: result.updatedCount || 0,\n mergedCount: result.mergedCount || 0,\n deletedTagCount: result.deletedTagCount || 0,\n timestamp: new Date().toISOString(),\n error: maintenanceResult.error || null\n}}];"
|
||||
},
|
||||
"id": "summarize",
|
||||
"name": "Summarize Results",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [1540, 0]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Daily 3AM UTC": {
|
||||
"main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Fetch Tags": {
|
||||
"main": [[{ "node": "Preprocess Tags", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Preprocess Tags": {
|
||||
"main": [[{ "node": "AI Analyze Tags", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"AI Analyze Tags": {
|
||||
"main": [[{ "node": "Validate JSON", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Validate JSON": {
|
||||
"main": [[{ "node": "Check Skip", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Check Skip": {
|
||||
"main": [
|
||||
[{ "node": "Execute Maintenance", "type": "main", "index": 0 }],
|
||||
[{ "node": "Summarize Results", "type": "main", "index": 0 }]
|
||||
]
|
||||
},
|
||||
"Execute Maintenance": {
|
||||
"main": [[{ "node": "Summarize Results", "type": "main", "index": 0 }]]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user