Files
zhuiguang-ai/scripts/task3-check-tools.mjs
T

209 lines
7.0 KiB
JavaScript

import { PrismaClient } from "@prisma/client";
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
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 });
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, categoryId: true },
});
const firstCategory = await prisma.category.findFirst({ select: { id: true } });
const defaultCategoryId = firstCategory?.id || 1;
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;
// 自动发布判定:只要求"网站可达";Logo 仅作提示、不阻断发布
// 原因:checkLogo 用 HEAD 探测,很多站点/CDN 不支持 HEAD(返回 405/403),而前端 <img> 用 GET 渲染,
// HEAD 失败 ≠ 坏链。原先要求 websiteOk && logoOk 同时成立,导致 292 个待审核工具 0 产出。
const skipReasons = { noWebsiteUrl: 0, websiteUnreachable: 0, slugConflict: 0, publishError: 0 };
let logoBadHint = 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 (!pt.websiteUrl) {
skipReasons.noWebsiteUrl++;
} else if (!websiteOk) {
skipReasons.websiteUnreachable++;
} else {
if (!logoOk) logoBadHint++;
const slug = pt.slug || pt.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
const existingSlug = await prisma.tool.findUnique({ where: { slug } });
if (existingSlug) {
skipReasons.slugConflict++;
} else {
await prisma.tool.create({
data: {
name: pt.name,
slug,
categoryId: pt.categoryId || defaultCategoryId,
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,
},
});
await prisma.pendingTool.delete({ where: { id: pt.id } });
autoPublished++;
console.log(` ✅ 自动发布: ${pt.name}${logoOk ? "" : " (Logo 校验未通过,已放行)"}`);
}
}
checked++;
} catch (e) {
skipReasons.publishError++;
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,
logoBadHint,
skipReasons,
duration: `${(duration / 1000).toFixed(1)}s`,
};
console.log(`\n🏁 [Task3] 完成: 巡检${tools.length + pendingTools.length}个, 自动发布${autoPublished}个, 网站失效${websiteFails}个`);
console.log(` 待审核未发布原因: 网站不可达${skipReasons.websiteUnreachable} / 无网址${skipReasons.noWebsiteUrl} / slug冲突${skipReasons.slugConflict} / 异常${skipReasons.publishError};其中 Logo 校验未通过${logoBadHint}个已放行`);
await prisma.taskLog.create({
data: {
taskKey: "task3-check-tools",
taskName: "工具巡检",
status: "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: "task3-check-tools",
taskName: "工具巡检",
status: "failed",
startedAt: new Date(),
finishedAt: new Date(),
duration: 0,
error: e.message,
},
});
} catch {}
}
process.exit(1);
}).finally(() => prisma.$disconnect());