import { PrismaClient } from "@prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import OpenAI from "openai"; import "dotenv/config"; import { withRetry } from "./lib/retry.mjs"; const rawUrl = process.env.DATABASE_URL; if (!rawUrl) { console.error("DATABASE_URL not set"); process.exit(1); } const base = rawUrl.replace("mysql://", "mariadb://"); const sep = base.includes("?") ? "&" : "?"; const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`; const adapter = new PrismaMariaDb(connectionString); const prisma = new PrismaClient({ adapter }); const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1", timeout: 120000, maxRetries: 2 }); const EXTRACT_PROMPT = `你是一个AI工具信息提取助手。从以下科技新闻内容中,提取出现的AI工具/产品信息。只提取明确提到的AI软件工具、AI平台、AI产品,不要提取开源项目库(GitHub仓库)、不要提取硬件、芯片。每个工具返回以下信息(如果信息不足则跳过该工具): 新闻内容: {content} 返回纯JSON数组: [{ "name": "工具名称", "description": "一句话简介(20字以内)", "websiteUrl": "官网或产品主页URL", "category": "分类关键词(只能从以下选一):文本生成/图像生成/视频生成/语音音频/代码编程/办公效率/营销创作/数据AI/教育学习/设计制作" }]`; function extractJSON(str) { const trimmed = str.trim(); const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); let jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : trimmed; const arrayMatch = jsonStr.match(/\[[\s\S]*\]/); if (arrayMatch) { try { return JSON.parse(arrayMatch[0]); } catch {} } return JSON.parse(jsonStr); } async function fetchRSS(url) { try { const res = await withRetry(async () => { return await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; ToolBot/1.0)" }, signal: AbortSignal.timeout(10000), }); }, { maxRetries: 3, label: `RSS ${url}` }); if (!res.ok) return []; const xml = await res.text(); const items = []; const regex = /([\s\S]*?)<\/item>/g; let m; while ((m = regex.exec(xml)) !== null) { const b = m[1]; const t = (b.match(/<!\[CDATA\[([\s\S]*?)\]\]><\/title>/) || b.match(/<title>([\s\S]*?)<\/title>/))?.[1] || ""; const d = (b.match(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/) || b.match(/<description>([\s\S]*?)<\/description>/))?.[1] || ""; items.push({ title: t.trim(), content: d.replace(/<[^>]+>/g, "").trim().substring(0, 500) }); } return items.slice(0, 20); } catch { return []; } } async function checkWebsite(url) { try { const res = await withRetry(async () => { return await fetch(url, { method: "GET", signal: AbortSignal.timeout(8000), redirect: "follow" }); }, { maxRetries: 2, label: `Website ${url}` }); return res.ok; } catch { return false; } } async function checkLogo(logoUrl) { if (!logoUrl) return false; if (logoUrl.startsWith("/logos/") || logoUrl.startsWith("data:")) return true; try { const res = await withRetry(async () => { return await fetch(logoUrl, { method: "HEAD", signal: AbortSignal.timeout(5000) }); }, { maxRetries: 2, label: `Logo ${logoUrl}` }); return res.ok; } catch { return false; } } async function main() { if (!process.env.DEEPSEEK_API_KEY) throw new Error("缺少环境变量: DEEPSEEK_API_KEY"); if (!process.env.DATABASE_URL) throw new Error("缺少环境变量: DATABASE_URL"); const startTime = new Date(); console.log(`🔍 [Task1] ${startTime.toISOString().split("T")[0]} 发现新AI工具...`); const rssSources = [ { url: "https://36kr.com/feed", name: "36kr" }, { url: "https://www.ithome.com/rss/", name: "IT之家" }, { url: "https://www.oschina.net/news/rss", name: "开源中国" }, ]; let totalNews = 0; let foundTools = 0; let importedTools = 0; const errors = []; const existingNames = new Set([ ...(await prisma.tool.findMany({ select: { name: true } })).map((t) => t.name.toLowerCase()), ...(await prisma.pendingTool.findMany({ select: { name: true } })).map((t) => t.name.toLowerCase()), ]); const existingUrls = new Set([ ...(await prisma.tool.findMany({ select: { websiteUrl: true } })).map((t) => t.websiteUrl || ""), ...(await prisma.pendingTool.findMany({ select: { websiteUrl: true } })).map((t) => t.websiteUrl || ""), ]); const categoryMap: Record<string, number> = {}; const categories = await prisma.category.findMany({ select: { id: true, name: true, slug: true } }); for (const c of categories) { const name = c.name.toLowerCase(); const slugLower = c.slug.toLowerCase(); if (name.includes("文本") || slugLower.includes("text")) categoryMap["文本生成"] = c.id; else if (name.includes("图像") || slugLower.includes("image")) categoryMap["图像生成"] = c.id; else if (name.includes("视频") || slugLower.includes("video")) categoryMap["视频生成"] = c.id; else if (name.includes("语音") || name.includes("音频") || slugLower.includes("audio")) categoryMap["语音音频"] = c.id; else if (name.includes("编程") || name.includes("代码") || slugLower.includes("code")) categoryMap["代码编程"] = c.id; else if (name.includes("办公") || slugLower.includes("office")) categoryMap["办公效率"] = c.id; else if (name.includes("营销") || slugLower.includes("marketing")) categoryMap["营销创作"] = c.id; else if (name.includes("数据") || name.includes("AI") || slugLower.includes("data")) categoryMap["数据AI"] = c.id; else if (name.includes("教育") || slugLower.includes("education")) categoryMap["教育学习"] = c.id; else if (name.includes("设计") || slugLower.includes("design")) categoryMap["设计制作"] = c.id; } for (const src of rssSources) { console.log(` 📡 ${src.name}...`); const news = await fetchRSS(src.url); totalNews += news.length; console.log(` 获取 ${news.length} 条`); for (const item of news) { const text = `${item.title} ${item.content}`; try { const completion = await withRetry(async () => { return await openai.chat.completions.create({ model: process.env.DEEPSEEK_MODEL || "deepseek-v4-pro", messages: [{ role: "user", content: EXTRACT_PROMPT.replace("{content}", text.substring(0, 1500)) }], temperature: 0.2, }, { signal: AbortSignal.timeout(120000) }); }, { maxRetries: 3, label: "DeepSeek API" }); const content = completion.choices[0]?.message?.content; if (!content) continue; let tools; try { tools = extractJSON(content); } catch { continue; } if (!Array.isArray(tools)) continue; foundTools += tools.length; for (const tool of tools) { if (!tool.name || !tool.websiteUrl) continue; if (existingNames.has(tool.name.toLowerCase()) || existingUrls.has(tool.websiteUrl)) continue; const websiteOk = await checkWebsite(tool.websiteUrl); const logoOk = await checkLogo(tool.logoUrl || ""); const catId = categoryMap[tool.category] || categoryMap["数据AI"] || categories[0]?.id || 1; await prisma.pendingTool.create({ data: { name: tool.name, description: tool.description || "", websiteUrl: tool.websiteUrl, logoUrl: tool.logoUrl, categoryId: catId, sourceUrl: "AI自动发现", status: "pending", checkWebsiteOk: websiteOk, checkLogoOk: logoOk, checkDescOk: true, checkCategoryOk: true, checkDetail: { source: src.name, extractedAt: new Date().toISOString() }, }, }); existingNames.add(tool.name.toLowerCase()); existingUrls.add(tool.websiteUrl); importedTools++; console.log(` ✅ ${tool.name} (网站:${websiteOk ? "OK" : "FAIL"}, Logo:${logoOk ? "OK" : "FAIL"})`); } } catch (e) { errors.push(`${src.name}/${item.title}: ${e.message}`); } await new Promise((r) => setTimeout(r, 300)); } } const endTime = new Date(); const duration = endTime.getTime() - startTime.getTime(); const result = { totalNews, foundTools, importedTools, duration: `${(duration / 1000).toFixed(1)}s`, }; console.log(`\n🏁 [Task1] 完成: 新闻${totalNews}条, 发现${foundTools}个, 入库${importedTools}个, 耗时${result.duration}`); if (errors.length > 0) console.log(` 错误: ${errors.slice(0, 3).join("; ")}`); await prisma.taskLog.create({ data: { taskKey: "task1-discover-tools", taskName: "发现AI工具", status: errors.length > 0 && importedTools === 0 ? "failed" : "completed", startedAt: startTime, finishedAt: endTime, duration, result, error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null, }, }); await prisma.$disconnect(); } main() .then(() => { console.log("Done"); process.exit(0); }) .catch(async (e) => { console.error("❌ 任务一失败:", e.message); if (prisma) { try { await prisma.taskLog.create({ data: { taskKey: "task1-discover-tools", taskName: "发现AI工具", status: "failed", startedAt: new Date(), finishedAt: new Date(), duration: 0, error: e.message, }, }); } catch {} } process.exit(1); }).finally(() => prisma.$disconnect());