feat: 新增 Tag Janitor API 用于 n8n 自动化标签合并
- 新增 GET /api/tags 接口返回标签列表及项目计数 - 新增 POST /api/tags/maintenance 接口支持批量 nameEn 补全和标签合并 - 合并逻辑包含 ProjectTag 去重处理(避免复合主键冲突) - 成功后触发 ISR revalidatePath 刷新项目列表页 - 新增 Zod 校验 schemas(TagMaintenanceRequestSchema 等) - 包含 n8n workflow JSON 模板及文档
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
# 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
|
||||
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"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