2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL;
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
const result = await prisma.$queryRawUnsafe("SELECT migration_name, finished_at FROM _prisma_migrations ORDER BY started_at");
|
||||
console.log("Applied migrations:");
|
||||
for (const row of result) {
|
||||
console.log(` ${row.migration_name} (${row.finished_at})`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd /home/ubuntu/zhuiguang-ai
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
node scripts/task1-discover-tools.mjs >> /home/ubuntu/zhuiguang-ai/logs/task1-discover-tools.log 2>&1
|
||||
+70
-31
@@ -3,6 +3,7 @@ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { Octokit } from "octokit";
|
||||
import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
@@ -165,13 +166,17 @@ function genSlug(name) {
|
||||
|
||||
async function searchRepos(query, minStars, max) {
|
||||
const q = `${query} stars:>${minStars} pushed:>2024-01-01`;
|
||||
const { data } = await octokit.rest.search.repos({ q, sort: "stars", order: "desc", per_page: max });
|
||||
const { data } = await withRetry(async () => {
|
||||
return await octokit.rest.search.repos({ q, sort: "stars", order: "desc", per_page: max });
|
||||
}, { maxRetries: 3, label: `GitHub Search "${query}"` });
|
||||
return data.items;
|
||||
}
|
||||
|
||||
async function getReadme(owner, repo) {
|
||||
try {
|
||||
const { data } = await octokit.rest.repos.getReadme({ owner, repo });
|
||||
const { data } = await withRetry(async () => {
|
||||
return await octokit.rest.repos.getReadme({ owner, repo });
|
||||
}, { maxRetries: 3, label: `GitHub README ${owner}/${repo}` });
|
||||
return Buffer.from(data.content, "base64").toString("utf-8");
|
||||
} catch { return ""; }
|
||||
}
|
||||
@@ -181,9 +186,11 @@ async function genReview(repoName, desc, stars, license, readme) {
|
||||
.replace("{repoName}", repoName).replace("{description}", desc || "暂无描述")
|
||||
.replace("{stars}", stars.toString()).replace("{license}", license || "未知")
|
||||
.replace("{readmeSummary}", readme.substring(0, 1500));
|
||||
const { choices } = await openai.chat.completions.create({
|
||||
model: "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.3,
|
||||
});
|
||||
const { choices } = await withRetry(async () => {
|
||||
return await openai.chat.completions.create({
|
||||
model: "deepseek-v4-pro", messages: [{ role: "user", content: prompt }], temperature: 0.3,
|
||||
});
|
||||
}, { maxRetries: 3, label: "DeepSeek Review API" });
|
||||
const content = choices[0]?.message?.content;
|
||||
if (!content) throw new Error("Empty response");
|
||||
let review = extractJSON(content);
|
||||
@@ -199,15 +206,16 @@ async function genReview(repoName, desc, stars, license, readme) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`🚀 [Daily Discover] ${new Date().toISOString().split("T")[0]} 开始...\n`);
|
||||
const DAILY_LIMIT = 10;
|
||||
const startTime = new Date();
|
||||
console.log(`🚀 [Task4] ${new Date().toISOString().split("T")[0]} 发现新AI技能...\n`);
|
||||
const DAILY_LIMIT = 30;
|
||||
let discovered = 0, skipped = 0, reviewed = 0, failed = 0;
|
||||
|
||||
for (const query of SEARCH_QUERIES) {
|
||||
if (discovered >= DAILY_LIMIT) break;
|
||||
console.log(`🔍 "${query}"`);
|
||||
let repos;
|
||||
try { repos = await searchRepos(query, 500, 5); } catch (e) { console.error(` ❌ 搜索失败: ${e.message}`); failed++; continue; }
|
||||
try { repos = await searchRepos(query, 1000, 5); } catch (e) { console.error(` ❌ 搜索失败: ${e.message}`); failed++; continue; }
|
||||
|
||||
for (const repo of repos) {
|
||||
if (discovered >= DAILY_LIMIT) break;
|
||||
@@ -225,36 +233,31 @@ async function main() {
|
||||
const [owner, name] = repo.full_name.split("/");
|
||||
const readme = await getReadme(owner, name);
|
||||
let reviewData = null;
|
||||
try { reviewData = await genReview(repo.full_name, repo.description || "", repo.stargazers_count, repo.license?.spdx_id || "Unknown", readme); console.log(` ✅ 评测: ${reviewData.overall}`); reviewed++; }
|
||||
catch (e) { console.log(` ⚠️ 评测失败: ${e.message}`); }
|
||||
try { reviewData = await genReview(repo.full_name, repo.description || "", repo.stargazers_count, repo.license?.spdx_id || "Unknown", readme); console.log(` ✅ 预评测: ${reviewData.overall}`); reviewed++; }
|
||||
catch (e) { console.log(` ⚠️ 预评测失败: ${e.message}`); }
|
||||
|
||||
const categoryId = await guessCategory(repo.full_name, repo.description || "", reviewData?.tags || []);
|
||||
const skill = await prisma.skill.create({
|
||||
const pendingSkill = await prisma.pendingSkill.create({
|
||||
data: {
|
||||
name: repo.full_name.substring(0, 100), slug, categoryId,
|
||||
description: repo.description || "", sourceUrl: repo.html_url, sourceType: "github",
|
||||
rating: reviewData?.overall || 0,
|
||||
features: { forks: repo.forks_count, language: repo.language, license: repo.license?.spdx_id || null, topics: repo.topics || [] },
|
||||
tags: reviewData?.tags || [], status: "draft",
|
||||
lastReviewedAt: reviewData ? new Date() : null,
|
||||
githubStarsHistory: [{ date: new Date().toISOString().split("T")[0], stars: repo.stargazers_count }],
|
||||
features: {
|
||||
forks: repo.forks_count,
|
||||
language: repo.language,
|
||||
license: repo.license?.spdx_id || null,
|
||||
topics: repo.topics || [],
|
||||
stars: repo.stargazers_count,
|
||||
reviewData: reviewData || null,
|
||||
},
|
||||
tags: reviewData?.tags || [],
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
console.log(` ✅ Skill #${skill.id}`);
|
||||
|
||||
if (reviewData) {
|
||||
await prisma.skillReview.create({
|
||||
data: {
|
||||
skillId: skill.id, capability: reviewData.capability.score, devExp: reviewData.devExp.score,
|
||||
costLicense: reviewData.costLicense.score, community: reviewData.community.score,
|
||||
performance: reviewData.performance.score, overall: reviewData.overall,
|
||||
reviews: reviewData, status: "DRAFT",
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ✅ PendingSkill #${pendingSkill.id}`);
|
||||
|
||||
await prisma.reviewGenerationLog.create({
|
||||
data: { skillId: skill.id, status: "SUCCESS", prompt: `daily-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
|
||||
data: { skillId: pendingSkill.id, status: "SUCCESS", prompt: `task4-discover: ${repo.full_name}`, response: reviewData ? JSON.stringify(reviewData) : null },
|
||||
});
|
||||
discovered++;
|
||||
} catch (e) {
|
||||
@@ -262,14 +265,50 @@ async function main() {
|
||||
failed++;
|
||||
try {
|
||||
await prisma.reviewGenerationLog.create({
|
||||
data: { skillId: 0, status: "FAILED", prompt: `daily-discover: ${repo.full_name}`, error: e.message?.substring(0, 500) || "Unknown" },
|
||||
data: { skillId: 0, status: "FAILED", prompt: `task4-discover: ${repo.full_name}`, error: e.message?.substring(0, 500) || "Unknown" },
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n🏁 [Daily Discover] 完成: 发现${discovered} / 跳过${skipped} / 评测${reviewed} / 失败${failed}`);
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
const result = { discovered, skipped, reviewed, failed, duration: `${(duration / 1000).toFixed(1)}s` };
|
||||
|
||||
console.log(`\n🏁 [Task4] 完成: 发现${discovered} / 跳过${skipped} / 评测${reviewed} / 失败${failed}`);
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task4-discover-skills",
|
||||
taskName: "发现AI技能",
|
||||
status: "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: endTime,
|
||||
duration,
|
||||
result,
|
||||
error: failed > discovered * 2 ? `失败率过高: ${failed}/${discovered}` : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error("💥 异常:", e); process.exit(1); }).finally(() => prisma.$disconnect());
|
||||
main().catch(async (e) => {
|
||||
console.error("💥 异常:", e);
|
||||
if (prisma) {
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task4-discover-skills",
|
||||
taskName: "发现AI技能",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
duration: 0,
|
||||
error: e.message,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
}).finally(() => prisma.$disconnect());
|
||||
|
||||
+34
-4
@@ -244,13 +244,16 @@ function extractJSON(str) {
|
||||
}
|
||||
|
||||
async function generateDailyNews() {
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString("zh-CN", {
|
||||
const now = new Date();
|
||||
const cnOffset = 8 * 60 * 60 * 1000;
|
||||
const cnTime = new Date(now.getTime() + cnOffset);
|
||||
const dateStr = cnTime.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "Asia/Shanghai",
|
||||
});
|
||||
const dateISO = today.toISOString().split("T")[0];
|
||||
const dateISO = cnTime.toISOString().split("T")[0];
|
||||
|
||||
console.log(`📰 开始生成 ${dateStr} 的AI日报...`);
|
||||
|
||||
@@ -344,10 +347,37 @@ async function generateDailyNews() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startTime = new Date();
|
||||
try {
|
||||
await generateDailyNews();
|
||||
const report = await generateDailyNews();
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "daily-news",
|
||||
taskName: "AI日报生成",
|
||||
status: "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: endTime,
|
||||
duration,
|
||||
result: { reportId: report.id, itemCount: report.items.length, title: report.title },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 日报生成失败:", error.message);
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "daily-news",
|
||||
taskName: "AI日报生成",
|
||||
status: "failed",
|
||||
startedAt: startTime,
|
||||
finishedAt: new Date(),
|
||||
duration: new Date().getTime() - startTime.getTime(),
|
||||
error: error.message,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL;
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
name: "综合讨论区", slug: "general",
|
||||
children: [
|
||||
{ name: "新手入门", slug: "newbie" },
|
||||
{ name: "AI资讯", slug: "ai-news" },
|
||||
{ name: "观点讨论", slug: "discuss" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AI工具", slug: "ai-tools",
|
||||
children: [
|
||||
{ name: "聊天写作", slug: "chat-writing" },
|
||||
{ name: "图像创作", slug: "image-gen" },
|
||||
{ name: "视频音频", slug: "video-audio" },
|
||||
{ name: "代码编程", slug: "code-tools" },
|
||||
{ name: "效率办公", slug: "productivity" },
|
||||
{ name: "数据分析", slug: "data-analysis" },
|
||||
{ name: "教育学习", slug: "edu-tools" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "开源技能", slug: "skills",
|
||||
children: [
|
||||
{ name: "LLM框架", slug: "llm-framework" },
|
||||
{ name: "Agent框架", slug: "agent-framework" },
|
||||
{ name: "RAG技术", slug: "rag" },
|
||||
{ name: "模型训练", slug: "model-training" },
|
||||
{ name: "部署推理", slug: "deploy-infer" },
|
||||
{ name: "多模态", slug: "multimodal" },
|
||||
{ name: "数据工程", slug: "data-eng" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "技术深度", slug: "tech",
|
||||
children: [
|
||||
{ name: "算法原理", slug: "algorithms" },
|
||||
{ name: "模型评测", slug: "model-review" },
|
||||
{ name: "安全对齐", slug: "safety" },
|
||||
{ name: "硬件加速", slug: "hardware" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "教程与分享", slug: "tutorials",
|
||||
children: [
|
||||
{ name: "入门教程", slug: "beginner-guide" },
|
||||
{ name: "实战项目", slug: "projects" },
|
||||
{ name: "最佳实践", slug: "best-practice" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "社区互动", slug: "social",
|
||||
children: [
|
||||
{ name: "求助问答", slug: "qa" },
|
||||
{ name: "求职招聘", slug: "jobs" },
|
||||
{ name: "闲聊灌水", slug: "random" },
|
||||
{ name: "反馈建议", slug: "feedback" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("Fixing forum category parent-child relationships...\n");
|
||||
|
||||
for (const cat of CATEGORIES) {
|
||||
const parent = await prisma.forumCategory.findUnique({
|
||||
where: { slug: cat.slug },
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
console.log(` ✗ Parent "${cat.name}" not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` Parent: ${cat.name} (id=${parent.id})`);
|
||||
|
||||
for (const child of cat.children) {
|
||||
const existing = await prisma.forumCategory.findUnique({
|
||||
where: { slug: child.slug },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.forumCategory.create({
|
||||
data: {
|
||||
name: child.name,
|
||||
slug: child.slug,
|
||||
parentId: parent.id,
|
||||
},
|
||||
});
|
||||
console.log(` + Created: ${child.name}`);
|
||||
} else if (existing.parentId !== parent.id) {
|
||||
await prisma.forumCategory.update({
|
||||
where: { id: existing.id },
|
||||
data: { parentId: parent.id },
|
||||
});
|
||||
console.log(` ✓ Fixed: ${child.name} (parentId: ${existing.parentId} → ${parent.id})`);
|
||||
} else {
|
||||
console.log(` ✓ OK: ${child.name}`);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("Done! All category relationships are correct.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
async function withRetry(fn, { maxRetries = 3, baseDelay = 1000, label = "operation" } = {}) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, attempt - 1);
|
||||
console.warn(` ⚠ ${label} 第${attempt}次失败,${delay / 1000}s后重试...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export { withRetry };
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL;
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
console.log("Updating user levels from old enum to new...");
|
||||
|
||||
const result = await prisma.$executeRawUnsafe(
|
||||
"UPDATE users SET level = 'LV01' WHERE level NOT REGEXP '^LV[0-9][0-9]$'"
|
||||
);
|
||||
|
||||
console.log(`Updated: ${result} rows`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL;
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('Starting manual migration...');
|
||||
|
||||
// Step 1: Change level column to VARCHAR
|
||||
await prisma.$executeRawUnsafe(
|
||||
"ALTER TABLE `users` MODIFY COLUMN `level` VARCHAR(20) NOT NULL DEFAULT 'LV01'"
|
||||
);
|
||||
console.log('✓ Step 1: level column changed to VARCHAR');
|
||||
|
||||
// Step 2: Map old enum values
|
||||
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV01' WHERE `level` = 'BRONZE'");
|
||||
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV03' WHERE `level` = 'SILVER'");
|
||||
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV06' WHERE `level` = 'GOLD'");
|
||||
await prisma.$executeRawUnsafe("UPDATE `users` SET `level` = 'LV10' WHERE `level` = 'PLATINUM'");
|
||||
console.log('✓ Step 2: Old enum values mapped to new levels');
|
||||
|
||||
// Step 3: Add bio column (if not exists)
|
||||
try {
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE `users` ADD COLUMN `bio` TEXT NULL");
|
||||
console.log('✓ Step 3: bio column added');
|
||||
} catch (e) {
|
||||
console.log('✓ Step 3: bio column may already exist, skipping');
|
||||
}
|
||||
|
||||
// Step 4: Add parent_id column (if not exists)
|
||||
try {
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE `forum_categories` ADD COLUMN `parent_id` INT NULL");
|
||||
console.log('✓ Step 4: parent_id column added');
|
||||
} catch (e) {
|
||||
console.log('✓ Step 4: parent_id column may already exist, skipping');
|
||||
}
|
||||
|
||||
// Step 5: Add foreign key (if not exists)
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(
|
||||
"ALTER TABLE `forum_categories` ADD CONSTRAINT `forum_categories_parent_id_fkey` " +
|
||||
"FOREIGN KEY (`parent_id`) REFERENCES `forum_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE"
|
||||
);
|
||||
console.log('✓ Step 5: Foreign key added');
|
||||
} catch (e) {
|
||||
console.log('✓ Step 5: Foreign key may already exist, skipping');
|
||||
}
|
||||
|
||||
// Step 6: Add points_history table (if not exists)
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`CREATE TABLE IF NOT EXISTS \`points_history\` (
|
||||
\`id\` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
\`user_id\` INT NOT NULL,
|
||||
\`points\` INT NOT NULL,
|
||||
\`reason\` VARCHAR(50) NOT NULL,
|
||||
\`detail\` VARCHAR(200) NULL,
|
||||
\`ref_type\` VARCHAR(50) NULL,
|
||||
\`ref_id\` INT NULL,
|
||||
\`created_at\` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX \`idx_user_created\` (\`user_id\`, \`created_at\`),
|
||||
FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
);
|
||||
console.log('✓ Step 6: points_history table created');
|
||||
} catch (e) {
|
||||
console.log('✓ Step 6: points_history table may already exist, skipping');
|
||||
}
|
||||
|
||||
// Step 7: Add is_answer column to forum_posts (if not exists)
|
||||
try {
|
||||
await prisma.$executeRawUnsafe("ALTER TABLE `forum_posts` ADD COLUMN `is_answer` BOOLEAN NOT NULL DEFAULT false");
|
||||
console.log('✓ Step 7: is_answer column added');
|
||||
} catch (e) {
|
||||
console.log('✓ Step 7: is_answer column may already exist, skipping');
|
||||
}
|
||||
|
||||
console.log('\n✓ Migration completed successfully!');
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,130 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL;
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=5&pool_timeout=10`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const CATEGORIES = [
|
||||
{
|
||||
name: "综合讨论区", slug: "general", icon: "💬", sortOrder: 1,
|
||||
description: "AI话题综合交流",
|
||||
children: [
|
||||
{ name: "新手入门", slug: "newbie", icon: "🌱", description: "新人报到,快速上手" },
|
||||
{ name: "AI资讯", slug: "ai-news", icon: "📰", description: "行业动态与新闻" },
|
||||
{ name: "观点讨论", slug: "discuss", icon: "💡", description: "AI发展趋势讨论" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AI工具", slug: "ai-tools", icon: "🤖", sortOrder: 2,
|
||||
description: "AI工具推荐、测评与讨论",
|
||||
children: [
|
||||
{ name: "聊天写作", slug: "chat-writing", icon: "✍️", description: "ChatGPT/Claude/文心一言等" },
|
||||
{ name: "图像创作", slug: "image-gen", icon: "🎨", description: "Midjourney/Stable Diffusion等" },
|
||||
{ name: "视频音频", slug: "video-audio", icon: "🎬", description: "Sora/Runway/Suno等" },
|
||||
{ name: "代码编程", slug: "code-tools", icon: "💻", description: "GitHub Copilot/Cursor等" },
|
||||
{ name: "效率办公", slug: "productivity", icon: "📊", description: "Notion AI/办公自动化等" },
|
||||
{ name: "数据分析", slug: "data-analysis", icon: "📈", description: "商业智能与数据分析工具" },
|
||||
{ name: "教育学习", slug: "edu-tools", icon: "📚", description: "在线教育与学习辅助" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "开源技能", slug: "skills", icon: "⚡", sortOrder: 3,
|
||||
description: "开源AI框架、模型与工具技能",
|
||||
children: [
|
||||
{ name: "LLM框架", slug: "llm-framework", icon: "🧠", description: "LangChain/LlamaIndex等" },
|
||||
{ name: "Agent框架", slug: "agent-framework", icon: "🤖", description: "AutoGPT/CrewAI/MetaGPT等" },
|
||||
{ name: "RAG技术", slug: "rag", icon: "📖", description: "向量数据库/检索增强生成" },
|
||||
{ name: "模型训练", slug: "model-training", icon: "🔬", description: "LoRA/Fine-tune/PEFT等" },
|
||||
{ name: "部署推理", slug: "deploy-infer", icon: "🚀", description: "vLLM/Ollama/TensorRT等" },
|
||||
{ name: "多模态", slug: "multimodal", icon: "🎯", description: "视觉/语音/多模态技术" },
|
||||
{ name: "数据工程", slug: "data-eng", icon: "🗃️", description: "数据清洗/标注/管理工具" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "技术深度", slug: "tech", icon: "🔧", sortOrder: 4,
|
||||
description: "AI技术深度探讨",
|
||||
children: [
|
||||
{ name: "算法原理", slug: "algorithms", icon: "🧮", description: "Transformer/注意力机制等" },
|
||||
{ name: "模型评测", slug: "model-review", icon: "📋", description: "Benchmark/对比评测" },
|
||||
{ name: "安全对齐", slug: "safety", icon: "🛡️", description: "RLHF/安全/伦理" },
|
||||
{ name: "硬件加速", slug: "hardware", icon: "🔌", description: "GPU/TPU/NPU优化" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "教程与分享", slug: "tutorials", icon: "📚", sortOrder: 5,
|
||||
description: "教程、指南与经验分享",
|
||||
children: [
|
||||
{ name: "入门教程", slug: "beginner-guide", icon: "🌟", description: "从零开始学AI" },
|
||||
{ name: "实战项目", slug: "projects", icon: "🏗️", description: "完整项目实战" },
|
||||
{ name: "最佳实践", slug: "best-practice", icon: "✅", description: "经验分享与踩坑记录" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "社区互动", slug: "social", icon: "🎪", sortOrder: 6,
|
||||
description: "社区活动与交流",
|
||||
children: [
|
||||
{ name: "求助问答", slug: "qa", icon: "❓", description: "有问题来这里" },
|
||||
{ name: "求职招聘", slug: "jobs", icon: "💼", description: "AI领域求职招聘" },
|
||||
{ name: "闲聊灌水", slug: "random", icon: "🎲", description: "轻松交流区" },
|
||||
{ name: "反馈建议", slug: "feedback", icon: "📢", description: "对追光AI的建议" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding forum categories (2-level)...");
|
||||
|
||||
for (const cat of CATEGORIES) {
|
||||
let parent = await prisma.forumCategory.findUnique({
|
||||
where: { slug: cat.slug },
|
||||
include: { children: true },
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
parent = await prisma.forumCategory.create({
|
||||
data: {
|
||||
name: cat.name,
|
||||
slug: cat.slug,
|
||||
icon: cat.icon,
|
||||
description: cat.description,
|
||||
sortOrder: cat.sortOrder,
|
||||
},
|
||||
include: { children: true },
|
||||
});
|
||||
console.log(` + ${cat.name}`);
|
||||
} else {
|
||||
console.log(` ✓ ${cat.name} (exists, checking children)`);
|
||||
}
|
||||
|
||||
const existingSlugs = new Set(parent.children.map(c => c.slug));
|
||||
|
||||
for (const child of cat.children) {
|
||||
if (existingSlugs.has(child.slug)) {
|
||||
console.log(` - ${child.name} (exists)`);
|
||||
} else {
|
||||
await prisma.forumCategory.create({
|
||||
data: {
|
||||
...child,
|
||||
parentId: parent.id,
|
||||
},
|
||||
});
|
||||
console.log(` + ${child.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
|
||||
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 TASK_DEFS = [
|
||||
{
|
||||
taskKey: "task1-discover-tools",
|
||||
taskName: "发现AI工具",
|
||||
enabled: true,
|
||||
cronExpr: "0 6 * * *",
|
||||
description: "每天早上6点从36kr/IT之家/开源中国RSS抓取新闻,由DeepSeek提取AI工具信息并入库待审核",
|
||||
config: { sources: ["36kr", "IT之家", "开源中国"], model: "deepseek-v4-pro" },
|
||||
},
|
||||
{
|
||||
taskKey: "task3-check-tools",
|
||||
taskName: "工具巡检",
|
||||
enabled: true,
|
||||
cronExpr: "0 3 * * *",
|
||||
description: "每天凌晨3点检查所有已发布工具的网站可访问性和Logo有效性,自动发布通过检查的待审核工具",
|
||||
config: { checkWebsite: true, checkLogo: true, autoPublish: true },
|
||||
},
|
||||
{
|
||||
taskKey: "task4-discover-skills",
|
||||
taskName: "发现AI技能",
|
||||
enabled: true,
|
||||
cronExpr: "0 5 * * *",
|
||||
description: "每天凌晨5点从GitHub搜索高星AI项目(stars≥1000),DeepSeek预评测后入库PendingSkill待审核",
|
||||
config: { minStars: 1000, dailyLimit: 30, model: "deepseek-v4-pro" },
|
||||
},
|
||||
{
|
||||
taskKey: "task5-update-stars",
|
||||
taskName: "更新技能星值",
|
||||
enabled: true,
|
||||
cronExpr: "0 1 * * *",
|
||||
description: "每天凌晨1点更新所有GitHub技能的最新Stars数,记录日/周/月变化趋势",
|
||||
config: { updateAll: true, historyDays: 90 },
|
||||
},
|
||||
{
|
||||
taskKey: "task6-review-hot",
|
||||
taskName: "评测热门技能",
|
||||
enabled: true,
|
||||
cronExpr: "0 4 * * *",
|
||||
description: "每天凌晨4点对2个最热门且未评测的GitHub技能进行五维深度评测,自动生成SkillReview",
|
||||
config: { dailyLimit: 2, model: "deepseek-v4-pro" },
|
||||
},
|
||||
{
|
||||
taskKey: "daily-news",
|
||||
taskName: "AI日报生成",
|
||||
enabled: true,
|
||||
cronExpr: "30 7 * * *",
|
||||
description: "每天早上7:30从多源RSS搜索真实AI新闻,由DeepSeek整理格式生成当日日报",
|
||||
config: { sources: ["36kr", "IT之家", "开源中国", "GoogleNews", "HackerNews"], model: "deepseek-v4-pro" },
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 初始化任务配置种子数据...\n");
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const def of TASK_DEFS) {
|
||||
const existing = await prisma.taskConfig.findUnique({
|
||||
where: { taskKey: def.taskKey },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.taskConfig.update({
|
||||
where: { taskKey: def.taskKey },
|
||||
data: {
|
||||
taskName: def.taskName,
|
||||
cronExpr: def.cronExpr,
|
||||
description: def.description,
|
||||
config: def.config,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
console.log(` 🔄 更新: ${def.taskName} (${def.taskKey})`);
|
||||
} else {
|
||||
await prisma.taskConfig.create({
|
||||
data: {
|
||||
taskKey: def.taskKey,
|
||||
taskName: def.taskName,
|
||||
enabled: def.enabled,
|
||||
cronExpr: def.cronExpr,
|
||||
description: def.description,
|
||||
config: def.config,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
console.log(` ✅ 创建: ${def.taskName} (${def.taskKey})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n🏁 完成: 创建${created}个, 更新${updated}个`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error("❌ 种子数据初始化失败:", e.message);
|
||||
if (prisma) await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
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 base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
|
||||
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" });
|
||||
|
||||
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 = /<item>([\s\S]*?)<\/item>/g;
|
||||
let m;
|
||||
while ((m = regex.exec(xml)) !== null) {
|
||||
const b = m[1];
|
||||
const t = (b.match(/<title><!\[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() {
|
||||
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: string[] = [];
|
||||
|
||||
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: "deepseek-v4-pro",
|
||||
messages: [{ role: "user", content: EXTRACT_PROMPT.replace("{content}", text.substring(0, 1500)) }],
|
||||
temperature: 0.2,
|
||||
});
|
||||
}, { 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().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 {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import "dotenv/config";
|
||||
import { withRetry } from "./lib/retry.mjs";
|
||||
|
||||
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
|
||||
const sep = base.includes("?") ? "&" : "?";
|
||||
const connectionString = `${base}${sep}connection_limit=20&pool_timeout=30`;
|
||||
const adapter = new PrismaMariaDb(connectionString);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function checkWebsite(url) {
|
||||
try {
|
||||
const res = await withRetry(async () => {
|
||||
return await fetch(url, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
redirect: "follow",
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; CheckBot/1.0)" },
|
||||
});
|
||||
}, { maxRetries: 2, label: `Website ${url}` });
|
||||
return res.status >= 200 && res.status < 400;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkLogo(logoUrl) {
|
||||
if (!logoUrl) return false;
|
||||
if (logoUrl.startsWith("/logos/")) return true;
|
||||
if (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() {
|
||||
const startTime = new Date();
|
||||
const now = new Date();
|
||||
const cnOffset = 8 * 60 * 60 * 1000;
|
||||
const cnTime = new Date(now.getTime() + cnOffset);
|
||||
const today = cnTime.toISOString().split("T")[0];
|
||||
|
||||
console.log(`🔍 [Task3] ${today} 巡检已入库工具...`);
|
||||
|
||||
const tools = await prisma.tool.findMany({
|
||||
select: { id: true, name: true, websiteUrl: true, logoUrl: true, status: true },
|
||||
});
|
||||
const pendingTools = await prisma.pendingTool.findMany({
|
||||
where: { status: "pending" },
|
||||
select: { id: true, name: true, websiteUrl: true, logoUrl: true, checkWebsiteOk: true, checkLogoOk: true },
|
||||
});
|
||||
|
||||
console.log(` 已发布工具: ${tools.length}, 待审核: ${pendingTools.length}`);
|
||||
|
||||
let checked = 0;
|
||||
let websiteFails = 0;
|
||||
let logoFails = 0;
|
||||
let autoPublished = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
try {
|
||||
const websiteOk = await checkWebsite(tool.websiteUrl);
|
||||
const logoOk = await checkLogo(tool.logoUrl);
|
||||
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { lastCheckedAt: new Date(), websiteOk, logoOk },
|
||||
});
|
||||
|
||||
if (!websiteOk) websiteFails++;
|
||||
if (!logoOk) logoFails++;
|
||||
checked++;
|
||||
} catch (e) {
|
||||
errors.push(`Tool ${tool.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
if (checked % 10 === 0) {
|
||||
console.log(` 进度: ${checked}/${tools.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n 已发布工具巡检完成: ${checked}个, 网站失效${websiteFails}个, Logo异常${logoFails}个`);
|
||||
|
||||
checked = 0;
|
||||
for (const pt of pendingTools) {
|
||||
try {
|
||||
const websiteOk = pt.websiteUrl ? await checkWebsite(pt.websiteUrl) : false;
|
||||
const logoOk = await checkLogo(pt.logoUrl);
|
||||
|
||||
await prisma.pendingTool.update({
|
||||
where: { id: pt.id },
|
||||
data: { checkWebsiteOk: websiteOk, checkLogoOk: logoOk, checkDescOk: true, checkCategoryOk: true },
|
||||
});
|
||||
|
||||
if (websiteOk && logoOk) {
|
||||
const slug = pt.slug || pt.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-" + Date.now().toString(36);
|
||||
const existingSlug = await prisma.tool.findUnique({ where: { slug } });
|
||||
if (!existingSlug) {
|
||||
await prisma.tool.create({
|
||||
data: {
|
||||
name: pt.name,
|
||||
slug,
|
||||
categoryId: pt.categoryId || 1,
|
||||
description: pt.description || "",
|
||||
logoUrl: pt.logoUrl,
|
||||
websiteUrl: pt.websiteUrl || "",
|
||||
pricingModel: pt.pricingModel || "freemium",
|
||||
pricingDetail: pt.pricingDetail,
|
||||
features: pt.features,
|
||||
tags: pt.tags,
|
||||
status: "published",
|
||||
websiteOk: true,
|
||||
logoOk: true,
|
||||
},
|
||||
});
|
||||
await prisma.pendingTool.delete({ where: { id: pt.id } });
|
||||
autoPublished++;
|
||||
console.log(` ✅ 自动发布: ${pt.name}`);
|
||||
}
|
||||
}
|
||||
checked++;
|
||||
} catch (e) {
|
||||
errors.push(`PendingTool ${pt.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
if (checked % 5 === 0) {
|
||||
console.log(` 待审核进度: ${checked}/${pendingTools.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
const result = {
|
||||
totalTools: tools.length,
|
||||
totalPending: pendingTools.length,
|
||||
websiteFails,
|
||||
logoFails,
|
||||
autoPublished,
|
||||
duration: `${(duration / 1000).toFixed(1)}s`,
|
||||
};
|
||||
|
||||
console.log(`\n🏁 [Task3] 完成: 巡检${tools.length + pendingTools.length}个, 自动发布${autoPublished}个, 网站失效${websiteFails}个`);
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task3-check-tools",
|
||||
taskName: "工具巡检",
|
||||
status: errors.length > 0 ? "completed" : "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: endTime,
|
||||
duration,
|
||||
result,
|
||||
error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error("❌ 任务三失败:", e.message);
|
||||
if (prisma) {
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task3-check-tools",
|
||||
taskName: "工具巡检",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
duration: 0,
|
||||
error: e.message,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { Octokit } from "octokit";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
|
||||
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 octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
|
||||
function extractOwnerRepo(sourceUrl) {
|
||||
const match = sourceUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startTime = new Date();
|
||||
const now = new Date();
|
||||
const cnOffset = 8 * 60 * 60 * 1000;
|
||||
const cnTime = new Date(now.getTime() + cnOffset);
|
||||
const today = cnTime.toISOString().split("T")[0];
|
||||
|
||||
console.log(`📈 [Task5] ${today} 更新GitHub星值...`);
|
||||
|
||||
const skills = await prisma.skill.findMany({
|
||||
where: { sourceUrl: { contains: "github.com" } },
|
||||
select: { id: true, name: true, sourceUrl: true, githubStars: true, githubStarsHistory: true },
|
||||
});
|
||||
|
||||
console.log(` 共 ${skills.length} 个GitHub技能需要更新`);
|
||||
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
const parsed = extractOwnerRepo(skill.sourceUrl);
|
||||
if (!parsed) {
|
||||
skipped++;
|
||||
console.log(` ⏭️ ${skill.name} - 无法解析GitHub URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await octokit.rest.repos.get({ owner: parsed.owner, repo: parsed.repo });
|
||||
const currentStars = data.stargazers_count;
|
||||
|
||||
const history = Array.isArray(skill.githubStarsHistory) ? [...skill.githubStarsHistory] : [];
|
||||
const prevEntry = history.length > 0 ? history[history.length - 1] : null;
|
||||
const prevStars = prevEntry ? prevEntry.stars : currentStars;
|
||||
|
||||
history.push({ date: today, stars: currentStars });
|
||||
if (history.length > 90) history.splice(0, history.length - 90);
|
||||
|
||||
const dayChange = currentStars - prevStars;
|
||||
|
||||
const weekAgo = history.find((h) => {
|
||||
const d = new Date(h.date);
|
||||
const diff = (cnTime.getTime() - d.getTime()) / (24 * 60 * 60 * 1000);
|
||||
return diff >= 6 && diff <= 8;
|
||||
});
|
||||
const weekChange = weekAgo ? currentStars - weekAgo.stars : dayChange * 7;
|
||||
|
||||
const monthAgo = history.find((h) => {
|
||||
const d = new Date(h.date);
|
||||
const diff = (cnTime.getTime() - d.getTime()) / (24 * 60 * 60 * 1000);
|
||||
return diff >= 28 && diff <= 32;
|
||||
});
|
||||
const monthChange = monthAgo ? currentStars - monthAgo.stars : weekChange * 4;
|
||||
|
||||
await prisma.skill.update({
|
||||
where: { id: skill.id },
|
||||
data: {
|
||||
githubStars: currentStars,
|
||||
starsChangeDay: dayChange,
|
||||
starsChangeWeek: weekChange,
|
||||
starsChangeMonth: monthChange,
|
||||
githubStarsHistory: history,
|
||||
},
|
||||
});
|
||||
|
||||
updated++;
|
||||
const arrow = dayChange >= 0 ? "↑" : "↓";
|
||||
console.log(` ✅ ${skill.name}: ${currentStars}⭐ (${arrow}${Math.abs(dayChange)}/日)`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
errors.push(`${skill.name}: ${e.message}`);
|
||||
console.log(` ❌ ${skill.name}: ${e.message}`);
|
||||
}
|
||||
|
||||
if (updated % 5 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
const result = {
|
||||
total: skills.length,
|
||||
updated,
|
||||
failed,
|
||||
skipped,
|
||||
duration: `${(duration / 1000).toFixed(1)}s`,
|
||||
};
|
||||
|
||||
console.log(`\n🏁 [Task5] 完成: 更新${updated} / 失败${failed}`);
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task5-update-stars",
|
||||
taskName: "更新技能星值",
|
||||
status: failed > skills.length * 0.5 ? "failed" : "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: endTime,
|
||||
duration,
|
||||
result,
|
||||
error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error("❌ 任务五失败:", e.message);
|
||||
if (prisma) {
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task5-update-stars",
|
||||
taskName: "更新技能星值",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
duration: 0,
|
||||
error: e.message,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { Octokit } from "octokit";
|
||||
import OpenAI from "openai";
|
||||
import "dotenv/config";
|
||||
|
||||
const base = process.env.DATABASE_URL || "mysql://localhost:3306/zhuiguang_ai?charset=utf8mb4";
|
||||
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 octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const openai = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com/v1" });
|
||||
|
||||
const REVIEW_PROMPT = `你是AI技术资深评测师。请对以下AI开源项目进行五维评测,每个维度1-5分。
|
||||
|
||||
评测维度:
|
||||
- capability:场景与能力(功能覆盖度、场景适配性、输出质量)
|
||||
- devExp:开发体验(安装便捷度、文档质量、API设计、调试友好度)
|
||||
- costLicense:成本与许可(开源协议友好度、部署成本、资源消耗)
|
||||
- community:社区健康(Stars趋势、贡献者数量、Issue响应速度、更新频率)
|
||||
- performance:性能稳定(推理速度、并发能力、稳定性、可扩展性)
|
||||
|
||||
项目信息:
|
||||
名称:{name}
|
||||
描述:{description}
|
||||
GitHub Stars:{stars}
|
||||
README摘要:{readme}
|
||||
|
||||
返回纯JSON:
|
||||
{
|
||||
"capability": 4.5,
|
||||
"devExp": 4.0,
|
||||
"costLicense": 5.0,
|
||||
"community": 4.5,
|
||||
"performance": 4.0,
|
||||
"overall": 4.4,
|
||||
"reviews": {
|
||||
"capability": {"score": 4.5, "summary": "一句话总结", "detail": "详细分析"},
|
||||
"devExp": {"score": 4.0, "summary": "一句话总结", "detail": "详细分析"},
|
||||
"costLicense": {"score": 5.0, "summary": "一句话总结", "detail": "详细分析"},
|
||||
"community": {"score": 4.5, "summary": "一句话总结", "detail": "详细分析"},
|
||||
"performance": {"score": 4.0, "summary": "一句话总结", "detail": "详细分析"}
|
||||
},
|
||||
"tags": ["标签1", "标签2"]
|
||||
}`;
|
||||
|
||||
function extractJSON(str) {
|
||||
const trimmed = str.trim();
|
||||
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
let jsonStr = codeBlockMatch ? codeBlockMatch[1].trim() : trimmed;
|
||||
const braceMatch = jsonStr.match(/\{[\s\S]*\}/);
|
||||
if (braceMatch) { try { return JSON.parse(braceMatch[0]); } catch {} }
|
||||
return JSON.parse(jsonStr);
|
||||
}
|
||||
|
||||
function extractOwnerRepo(sourceUrl) {
|
||||
const match = sourceUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
return { owner: match[1], repo: match[2].replace(/\.git$/, "") };
|
||||
}
|
||||
|
||||
async function getReadme(owner, repo) {
|
||||
try {
|
||||
const { data } = await octokit.rest.repos.getReadme({ owner, repo });
|
||||
const content = Buffer.from(data.content, "base64").toString("utf-8");
|
||||
return content.substring(0, 3000);
|
||||
} catch {
|
||||
return "(无法获取README)";
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startTime = new Date();
|
||||
const now = new Date();
|
||||
const cnOffset = 8 * 60 * 60 * 1000;
|
||||
const cnTime = new Date(now.getTime() + cnOffset);
|
||||
const today = cnTime.toISOString().split("T")[0];
|
||||
|
||||
console.log(`🔥 [Task6] ${today} 评测最热工具...`);
|
||||
|
||||
const unreviewed = await prisma.skill.findMany({
|
||||
where: {
|
||||
status: "published",
|
||||
sourceUrl: { contains: "github.com" },
|
||||
skillReview: null,
|
||||
},
|
||||
orderBy: [{ githubStars: "desc" }, { viewCount: "desc" }],
|
||||
take: 2,
|
||||
select: { id: true, name: true, description: true, sourceUrl: true, githubStars: true },
|
||||
});
|
||||
|
||||
if (unreviewed.length === 0) {
|
||||
console.log(" 所有热门技能已有评测,跳过");
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task6-review-hot",
|
||||
taskName: "评测热门技能",
|
||||
status: "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: new Date(),
|
||||
duration: new Date().getTime() - startTime.getTime(),
|
||||
result: { reviewed: 0, message: "所有热门技能已有评测" },
|
||||
},
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(` 待评测: ${unreviewed.map((s) => s.name).join(", ")}`);
|
||||
|
||||
let reviewed = 0;
|
||||
let failed = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const skill of unreviewed) {
|
||||
console.log(`\n 📦 评测: ${skill.name} (${skill.githubStars}⭐)`);
|
||||
|
||||
const parsed = extractOwnerRepo(skill.sourceUrl);
|
||||
let readme = "(无README)";
|
||||
let stars = skill.githubStars;
|
||||
|
||||
if (parsed) {
|
||||
readme = await getReadme(parsed.owner, parsed.repo);
|
||||
try {
|
||||
const { data } = await octokit.rest.repos.get({ owner: parsed.owner, repo: parsed.repo });
|
||||
stars = data.stargazers_count;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const prompt = REVIEW_PROMPT
|
||||
.replace("{name}", skill.name)
|
||||
.replace("{description}", skill.description || "")
|
||||
.replace("{stars}", String(stars))
|
||||
.replace("{readme}", readme.substring(0, 2000));
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "deepseek-v4-pro",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const content = completion.choices[0]?.message?.content;
|
||||
if (!content) throw new Error("DeepSeek返回为空");
|
||||
|
||||
const parsed_review = extractJSON(content);
|
||||
const scores = [parsed_review.capability, parsed_review.devExp, parsed_review.costLicense, parsed_review.community, parsed_review.performance].filter((s) => typeof s === "number");
|
||||
const overall = parsed_review.overall || (scores.length > 0 ? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10 : 3.0);
|
||||
|
||||
await prisma.skillReview.create({
|
||||
data: {
|
||||
skillId: skill.id,
|
||||
capability: parsed_review.capability || 3.0,
|
||||
devExp: parsed_review.devExp || 3.0,
|
||||
costLicense: parsed_review.costLicense || 3.0,
|
||||
community: parsed_review.community || 3.0,
|
||||
performance: parsed_review.performance || 3.0,
|
||||
overall,
|
||||
reviews: parsed_review.reviews || {},
|
||||
tags: parsed_review.tags || [],
|
||||
status: "PUBLISHED",
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.skill.update({
|
||||
where: { id: skill.id },
|
||||
data: { lastReviewedAt: new Date() },
|
||||
});
|
||||
|
||||
reviewed++;
|
||||
console.log(` ✅ 评测完成: overall=${overall}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
errors.push(`${skill.name}: ${e.message}`);
|
||||
console.error(` ❌ 评测失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - startTime.getTime();
|
||||
const result = {
|
||||
total: unreviewed.length,
|
||||
reviewed,
|
||||
failed,
|
||||
duration: `${(duration / 1000).toFixed(1)}s`,
|
||||
};
|
||||
|
||||
console.log(`\n🏁 [Task6] 完成: 评测${reviewed}个, 失败${failed}个`);
|
||||
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task6-review-hot",
|
||||
taskName: "评测热门技能",
|
||||
status: "completed",
|
||||
startedAt: startTime,
|
||||
finishedAt: endTime,
|
||||
duration,
|
||||
result,
|
||||
error: errors.length > 0 ? errors.slice(0, 5).join("\n") : null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error("❌ 任务六失败:", e.message);
|
||||
if (prisma) {
|
||||
try {
|
||||
await prisma.taskLog.create({
|
||||
data: {
|
||||
taskKey: "task6-review-hot",
|
||||
taskName: "评测热门技能",
|
||||
status: "failed",
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
duration: 0,
|
||||
error: e.message,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user