Files
zhuiguang-ai/src/lib/logo-fetcher.ts
T
ZhuiGuangAI Dev e263e28c81 全面审查优化: 43项修复(安全/性能/可靠性)+ 文档更新
🔴 P0 安全: .env入gitignore, CSP/HSTS, Logo安全(5MB限制+SVG消毒+SHA256), middleware权限收紧(VIP不能访问admin), 发布去重, GitHub认证, 删除危险API, skill-discoverer绕过审核修复, prisma默认连接修复
🔴 P0 可靠性: deepseek/github超时重试, 脚本层DeepSeek保护, 脚本启动验证, OAuth竞态修复
🔴 P0 性能: skill-discoverer N+1优化(↓94%), 后台API分页限制, getUserStats聚合优化
🟡 P1: 分类动态加载, 星级字段统一, fetch-logos并发Bug修复, 批量发布冲突不删除, check-tools重试, 前端空指针修复(13处), logo-fetcher日志, rate-limit清理, 公开API缓存
2026-05-26 00:30:36 +08:00

601 lines
22 KiB
TypeScript

import { prisma } from "@/lib/prisma";
interface LogoFetchConfig {
enabledMethods: string[];
commonPaths: string[];
faviconServices: { name: string; url: string; enabled: boolean }[];
minFileSize: number;
requestTimeout: number;
}
const logoCache = new Map<string, { url: string; timestamp: number }>();
const CACHE_TTL = 3600000;
function getCachedLogo(domain: string): string | null {
const cached = logoCache.get(domain);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.url;
}
if (cached) logoCache.delete(domain);
return null;
}
function setCachedLogo(domain: string, url: string) {
if (logoCache.size > 500) {
const keys = Array.from(logoCache.keys());
for (let i = 0; i < 100 && i < keys.length; i++) {
logoCache.delete(keys[i]);
}
}
logoCache.set(domain, { url, timestamp: Date.now() });
}
const DEFAULT_CONFIG: LogoFetchConfig = {
enabledMethods: [
"html_link_icon",
"html_meta_og",
"html_manifest",
"html_img_logo",
"html_header_logo",
"html_svg_logo",
"common_paths",
"favicon_services",
"brandfetch",
"screenshot_fallback",
],
commonPaths: [
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
"/apple-touch-icon-180x180.png",
"/apple-touch-icon-152x152.png",
"/logo.png",
"/logo.svg",
"/images/logo.png",
"/images/logo.svg",
"/img/logo.png",
"/img/logo.svg",
"/assets/logo.png",
"/assets/logo.svg",
"/assets/images/logo.png",
"/assets/img/logo.png",
"/static/logo.png",
"/static/logo.svg",
"/static/images/logo.png",
"/public/logo.png",
"/favicon-192x192.png",
"/favicon-32x32.png",
"/android-chrome-192x192.png",
"/mstile-150x150.png",
"/icons/icon-192x192.png",
"/icons/icon-512x512.png",
"/icons/apple-touch-icon.png",
"/_next/static/media/logo.svg",
"/_next/static/media/logo.png",
"/favicon.ico",
],
faviconServices: [
{ name: "favicon.im", url: "https://favicon.im/{domain}", enabled: true },
{ name: "Google Favicons 128", url: "https://www.google.com/s2/favicons?domain={domain}&sz=128", enabled: true },
{ name: "Google Favicons 64", url: "https://www.google.com/s2/favicons?domain={domain}&sz=64", enabled: true },
{ name: "Clearbit", url: "https://logo.clearbit.com/{domain}", enabled: true },
{ name: "DuckDuckGo", url: "https://icons.duckduckgo.com/ip3/{domain}.ico", enabled: true },
{ name: "Icon Horse", url: "https://icon.horse/icon/{domain}", enabled: true },
{ name: "Yandex Favicon", url: "https://favicon.yandex.net/favicon/v2/{domain}?size=128", enabled: true },
{ name: "Splitbee", url: "https://icon.splitbee.io/{domain}", enabled: true },
{ name: "The Favicon API", url: "https://favicone.com/{domain}?s=128", enabled: true },
{ name: "Favicon Grabr", url: "https://favicongrabr.com/api/grab/{domain}", enabled: false },
{ name: "Besticon", url: "https://besticon-demo.herokuapp.com/icon?url={domain}&size=128", enabled: false },
],
minFileSize: 50,
requestTimeout: 10000,
};
async function getConfig(): Promise<LogoFetchConfig> {
try {
const row = await prisma.systemConfig.findUnique({ where: { key: "logoFetchConfig" } });
if (row?.value && typeof row.value === "object") {
return { ...DEFAULT_CONFIG, ...(row.value as Partial<LogoFetchConfig>) };
}
} catch (err) { console.warn("logo-fetcher: Failed to load logoFetchConfig from database, using defaults", err); }
return DEFAULT_CONFIG;
}
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
function resolveUrl(href: string, origin: string): string {
if (href.startsWith("//")) return "https:" + href;
if (href.startsWith("/")) return `${origin}${href}`;
if (href.startsWith("http")) return href;
return `${origin}/${href}`;
}
async function checkImageUrl(url: string, timeout: number, minSize: number): Promise<string> {
try {
const res = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(timeout),
headers: {
"User-Agent": UA,
Accept: "image/*,*/*",
},
redirect: "follow",
});
if (res.ok) {
const ct = res.headers.get("content-type") || "";
if (ct.includes("image") || ct.includes("icon") || ct.includes("svg") || ct.includes("octet-stream")) {
const buf = await res.arrayBuffer();
if (buf.byteLength > minSize) {
return res.url || url;
}
}
}
} catch (err) { console.warn("logo-fetcher: Failed to check image URL", err); }
return "";
}
async function fetchManifestIcons(origin: string, html: string, timeout: number, minSize: number): Promise<string> {
const manifestPatterns = [
/<link[^>]+rel=["']manifest["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']manifest["']/i,
];
let manifestHref = "";
for (const pattern of manifestPatterns) {
const match = html.match(pattern);
if (match?.[1]) {
manifestHref = resolveUrl(match[1], origin);
break;
}
}
if (!manifestHref) {
for (const candidate of ["/manifest.json", "/manifest.webmanifest", "/site.webmanifest"]) {
try {
const res = await fetch(`${origin}${candidate}`, {
method: "HEAD",
signal: AbortSignal.timeout(timeout),
headers: { "User-Agent": UA },
});
if (res.ok) {
manifestHref = `${origin}${candidate}`;
break;
}
} catch (err) { console.warn("logo-fetcher: Failed to probe manifest candidate path", err); }
}
}
if (!manifestHref) return "";
try {
const res = await fetch(manifestHref, {
signal: AbortSignal.timeout(timeout),
headers: { "User-Agent": UA, Accept: "application/json,*/*" },
});
if (res.ok) {
const data = await res.json();
const icons: { src: string; sizes?: string; type?: string }[] = data.icons || [];
const sorted = icons.sort((a, b) => {
const sizeA = parseInt((a.sizes || "0").split("x")[0]) || 0;
const sizeB = parseInt((b.sizes || "0").split("x")[0]) || 0;
return sizeB - sizeA;
});
for (const icon of sorted) {
if (!icon.src) continue;
const iconUrl = resolveUrl(icon.src, origin);
const valid = await checkImageUrl(iconUrl, timeout, minSize);
if (valid) return valid;
}
}
} catch (err) { console.warn("logo-fetcher: Failed to fetch manifest icons", err); }
return "";
}
function extractSvgAsDataUri(html: string): string {
const svgPatterns = [
/<svg[^>]+class=["'][^"']*(?:logo|brand|site-icon)[^"']*["'][^>]*>[\s\S]*?<\/svg>/gi,
/<svg[^>]+id=["'][^"']*(?:logo|brand)[^"']*["'][^>]*>[\s\S]*?<\/svg>/gi,
/<svg[^>]+aria-label=["'][^"']*(?:logo|Logo)[^"']*["'][^>]*>[\s\S]*?<\/svg>/gi,
];
for (const pattern of svgPatterns) {
const match = html.match(pattern);
if (match?.[0]) {
const svg = match[0].trim();
if (svg.length > 50 && svg.length < 50000) {
const encoded = svg
.replace(/"/g, "'")
.replace(/%/g, "%25")
.replace(/#/g, "%23")
.replace(/{/g, "%7B")
.replace(/}/g, "%7D")
.replace(/</g, "%3C")
.replace(/>/g, "%3E");
return `data:image/svg+xml,${encoded}`;
}
}
}
return "";
}
async function fetchSvgLogo(origin: string, html: string, timeout: number, minSize: number): Promise<string> {
const dataUri = extractSvgAsDataUri(html);
if (dataUri) return dataUri;
const svgSrcPatterns = [
/<img[^>]+(?:src|data-src)=["']([^"']*(?:logo|brand)[^"']*\.svg)["']/gi,
/<img[^>]+(?:src|data-src)=["']([^"']*\.svg)["'][^>]+class=["'][^"']*(?:logo|brand)[^"']*["']/gi,
/<object[^>]+data=["']([^"']*\.svg)["']/gi,
/<embed[^>]+src=["']([^"']*\.svg)["']/gi,
];
const svgCandidates: string[] = [];
for (const pattern of svgSrcPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
if (match[1] && !match[1].includes("data:")) {
svgCandidates.push(resolveUrl(match[1], origin));
}
}
}
for (const c of svgCandidates) {
try {
const res = await fetch(c, {
signal: AbortSignal.timeout(timeout),
headers: { "User-Agent": UA, Accept: "image/svg+xml,*/*" },
redirect: "follow",
});
if (res.ok) {
const ct = res.headers.get("content-type") || "";
if (ct.includes("svg") || ct.includes("image")) {
const text = await res.text();
if (text.includes("<svg") && text.length > 50) {
return res.url || c;
}
}
}
} catch (err) { console.warn("logo-fetcher: Failed to fetch SVG logo candidate", err); }
}
return "";
}
async function fetchHeaderLogo(origin: string, html: string, timeout: number, minSize: number): Promise<string> {
const headerPatterns = [
/<header[^>]*>[\s\S]*?<img[^>]+(?:src|data-src)=["']([^"']+)["'][^>]*>[\s\S]*?<\/header>/gi,
/<nav[^>]*>[\s\S]*?<img[^>]+(?:src|data-src)=["']([^"']+)["'][^>]*>[\s\S]*?<\/nav>/gi,
/<div[^>]+class=["'][^"']*(?:header|navbar|topbar|nav-bar|site-header|main-header)[^"']*["'][^>]*>[\s\S]*?<img[^>]+(?:src|data-src)=["']([^"']+)["']/gi,
];
for (const pattern of headerPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
const src = match[1] || match[2];
if (src && !src.includes("data:") && src.length > 5) {
const resolved = resolveUrl(src, origin);
if (resolved.match(/\.(png|jpg|jpeg|svg|webp|ico)(\?|$)/i)) {
const valid = await checkImageUrl(resolved, timeout, minSize);
if (valid) return valid;
}
}
}
}
const headerSection = html.match(/<header[^>]*>([\s\S]*?)<\/header>/i);
if (headerSection) {
const imgInHeader = /<img[^>]+(?:src|data-src)=["']([^"']+)["']/gi;
let imgMatch: RegExpExecArray | null;
while ((imgMatch = imgInHeader.exec(headerSection[1])) !== null) {
if (imgMatch[1] && !imgMatch[1].includes("data:") && imgMatch[1].length > 5) {
const resolved = resolveUrl(imgMatch[1], origin);
const valid = await checkImageUrl(resolved, timeout, minSize);
if (valid) return valid;
}
}
}
return "";
}
async function fetchBrandfetch(domain: string, timeout: number): Promise<string> {
try {
const res = await fetch(`https://api.brandfetch.io/v2/brands/${domain}`, {
signal: AbortSignal.timeout(timeout),
headers: {
"User-Agent": UA,
Accept: "application/json",
},
});
if (res.ok) {
const data = await res.json();
const logos: { formats: { src: string; format: string; size?: number; background?: string }[] }[] = data.logos || [];
if (logos.length > 0 && logos[0].formats?.length > 0) {
const sorted = logos[0].formats.sort((a, b) => {
const prioA = a.format === "svg" ? 100 : (a.size || 0);
const prioB = b.format === "svg" ? 100 : (b.size || 0);
return prioB - prioA;
});
const best = sorted[0];
if (best?.src) return best.src;
}
const icons: { formats: { src: string; format: string; size?: number }[] }[] = data.icons || [];
if (icons.length > 0 && icons[0].formats?.length > 0) {
const sorted = icons[0].formats.sort((a, b) => (b.size || 0) - (a.size || 0));
const best = sorted[0];
if (best?.src) return best.src;
}
}
} catch (err) { console.warn("logo-fetcher: Failed to fetch brand from Brandfetch", err); }
return "";
}
async function fetchScreenshotFallback(domain: string, timeout: number): Promise<string> {
const screenshotServices = [
`https://image.thum.io/get/width/128/crop/128/${domain}`,
`https://api.microlink.io/?url=https://${domain}&screenshot=true&meta=false&embed=screenshot.url`,
];
for (const serviceUrl of screenshotServices) {
try {
const res = await fetch(serviceUrl, {
signal: AbortSignal.timeout(timeout * 2),
headers: { "User-Agent": UA },
redirect: "follow",
});
if (res.ok) {
const ct = res.headers.get("content-type") || "";
if (ct.includes("json")) {
const data = await res.json();
const imageUrl = data?.screenshot?.url || data?.data?.screenshot?.url;
if (imageUrl && typeof imageUrl === "string") {
return imageUrl;
}
} else if (ct.includes("image")) {
const buf = await res.arrayBuffer();
if (buf.byteLength > 500) {
return res.url || serviceUrl;
}
}
}
} catch (err) { console.warn("logo-fetcher: Failed to fetch screenshot fallback", err); }
}
return "";
}
export interface LogoFetchResult {
url: string;
method: string;
}
export async function fetchLogoWithMethod(websiteUrl: string, html?: string): Promise<LogoFetchResult> {
try {
const urlObj = new URL(websiteUrl);
const domain = urlObj.hostname;
if (!html) {
const cached = getCachedLogo(domain);
if (cached !== null) return { url: cached, method: cached ? "cache" : "" };
}
const cfg = await getConfig();
const origin = urlObj.origin;
const timeout = cfg.requestTimeout || 10000;
const minSize = cfg.minFileSize || 50;
if (html && cfg.enabledMethods.includes("html_link_icon")) {
const linkPatterns = [
/<link[^>]+rel=["'](?:apple-touch-icon(?:-precomposed)?|icon|shortcut icon|mask-icon)["'][^>]+(?:href|content)=["']([^"']+)["']/gi,
/<link[^>]+(?:href|content)=["']([^"']+)["'][^>]+rel=["'](?:apple-touch-icon(?:-precomposed)?|icon|shortcut icon|mask-icon)["']/gi,
/<link[^>]+rel=["'](?:apple-touch-icon(?:-precomposed)?|icon|shortcut icon|mask-icon)["'][^>]+(?:href|content)=([^>\s]+)/gi,
/<link[^>]+(?:href|content)=([^>\s]+)[^>]+rel=["'](?:apple-touch-icon(?:-precomposed)?|icon|shortcut icon|mask-icon)["']/gi,
/<link[^>]+sizes=["'][^"']*192[^"']*["'][^>]+href=["']([^"']+)["']/gi,
/<link[^>]+sizes=["'][^"']*180[^"']*["'][^>]+href=["']([^"']+)["']/gi,
/<link[^>]+sizes=["'][^"']*152[^"']*["'][^>]+href=["']([^"']+)["']/gi,
];
const candidates: string[] = [];
for (const pattern of linkPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(html)) !== null) {
const href = match[1];
if (!href || href.includes("data:")) continue;
const resolved = resolveUrl(href, origin);
if (resolved.includes("apple-touch-icon") || resolved.includes("icon-") || resolved.includes("192") || resolved.includes("180")) {
const valid = await checkImageUrl(resolved, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: "HTML Link图标" };
}
}
candidates.push(resolved);
}
}
for (const c of candidates) {
if (!c.includes("favicon.im")) {
const valid = await checkImageUrl(c, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: "HTML Link图标" };
}
}
}
}
if (html && cfg.enabledMethods.includes("html_meta_og")) {
const metaPatterns = [
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+name=["']twitter:image:src["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+property=["']og:image["'][^>]+content=([^>\s]+)/i,
/<meta[^>]+content=([^>\s]+)[^>]+property=["']og:image["']/i,
];
for (const pattern of metaPatterns) {
const match = html.match(pattern);
if (match?.[1] && !match[1].includes("data:")) {
const resolved = resolveUrl(match[1], origin);
const valid = await checkImageUrl(resolved, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: "OG/Twitter图片" };
}
}
}
}
if (html && cfg.enabledMethods.includes("html_manifest")) {
const manifestResult = await fetchManifestIcons(origin, html, timeout, minSize);
if (manifestResult) {
setCachedLogo(domain, manifestResult);
return { url: manifestResult, method: "PWA Manifest" };
}
}
if (html && cfg.enabledMethods.includes("html_img_logo")) {
const imgPatterns = [
/<img[^>]+(?:src|data-src)=["']([^"']*(?:logo|brand)[^"']*)["']/gi,
/<img[^>]+(?:src|data-src)=["']([^"']*(?:Logo|Brand)[^"']*)["']/gi,
/<img[^>]+class=["'][^"']*(?:logo|brand|site-icon)[^"']*["'][^>]+(?:src|data-src)=["']([^"']+)["']/gi,
/<img[^>]+(?:src|data-src)=["']([^"']+)["'][^>]+class=["'][^"']*(?:logo|brand|site-icon)[^"']*["']/gi,
/<img[^>]+alt=["'][^"']*(?:logo|Logo)[^"']*["'][^>]+(?:src|data-src)=["']([^"']+)["']/gi,
];
const imgCandidates: string[] = [];
for (const pattern of imgPatterns) {
let imgMatch: RegExpExecArray | null;
while ((imgMatch = pattern.exec(html)) !== null) {
if (imgMatch[1] && !imgMatch[1].includes("data:") && imgMatch[1].length > 5) {
imgCandidates.push(resolveUrl(imgMatch[1], origin));
}
}
}
for (const c of imgCandidates) {
const valid = await checkImageUrl(c, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: "HTML Logo图片" };
}
}
}
if (html && cfg.enabledMethods.includes("html_header_logo")) {
const headerResult = await fetchHeaderLogo(origin, html, timeout, minSize);
if (headerResult) {
setCachedLogo(domain, headerResult);
return { url: headerResult, method: "Header区域Logo" };
}
}
if (html && cfg.enabledMethods.includes("html_svg_logo")) {
const svgResult = await fetchSvgLogo(origin, html, timeout, minSize);
if (svgResult) {
setCachedLogo(domain, svgResult);
return { url: svgResult, method: "SVG Logo" };
}
}
if (cfg.enabledMethods.includes("common_paths")) {
for (const p of cfg.commonPaths) {
const testUrl = `${origin}${p}`;
const valid = await checkImageUrl(testUrl, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: "常见路径探测" };
}
}
}
if (cfg.enabledMethods.includes("favicon_services")) {
for (const svc of cfg.faviconServices) {
if (!svc.enabled) continue;
try {
const serviceUrl = svc.url.replace("{domain}", domain);
if (svc.url.includes("favicongrabr") || svc.url.includes("icon.horse")) {
const apiRes = await fetch(serviceUrl, {
signal: AbortSignal.timeout(timeout),
headers: { "User-Agent": UA },
});
if (apiRes.ok) {
const ct = apiRes.headers.get("content-type") || "";
if (ct.includes("json")) {
const data = await apiRes.json();
const icons: { src: string; sizes?: string }[] = data.icons || [];
const sorted = icons.sort((a, b) => {
const sizeA = parseInt((a.sizes || "0").split("x")[0]) || 0;
const sizeB = parseInt((b.sizes || "0").split("x")[0]) || 0;
return sizeB - sizeA;
});
const best = sorted.find((i) => i.src.includes("apple-touch-icon"))
|| sorted.find((i) => i.sizes === "128x128" || i.sizes === "192x192")
|| sorted[0];
if (best?.src) {
const valid = await checkImageUrl(best.src, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: `Favicon服务(${svc.name})` };
}
}
} else if (ct.includes("image")) {
const buf = await apiRes.arrayBuffer();
if (buf.byteLength > minSize) {
const result = apiRes.url || serviceUrl;
setCachedLogo(domain, result);
return { url: result, method: `Favicon服务(${svc.name})` };
}
}
}
} else {
const valid = await checkImageUrl(serviceUrl, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: `Favicon服务(${svc.name})` };
}
}
} catch (err) { console.warn("logo-fetcher: Failed to fetch favicon from service", err); }
}
}
if (cfg.enabledMethods.includes("brandfetch")) {
const brandfetchResult = await fetchBrandfetch(domain, timeout);
if (brandfetchResult) {
setCachedLogo(domain, brandfetchResult);
return { url: brandfetchResult, method: "Brandfetch品牌库" };
}
}
if (cfg.enabledMethods.includes("screenshot_fallback")) {
const screenshotResult = await fetchScreenshotFallback(domain, timeout);
if (screenshotResult) {
setCachedLogo(domain, screenshotResult);
return { url: screenshotResult, method: "截图兜底" };
}
}
const fallbackUrl = `${origin}/favicon.ico`;
const valid = await checkImageUrl(fallbackUrl, timeout, minSize);
if (valid) {
setCachedLogo(domain, valid);
return { url: valid, method: "Favicon默认路径" };
}
setCachedLogo(domain, "");
return { url: "", method: "" };
} catch (err) {
console.warn("logo-fetcher: Failed to fetch logo for URL", err);
return { url: "", method: "" };
}
}
export async function fetchLogo(websiteUrl: string, html?: string): Promise<string> {
const result = await fetchLogoWithMethod(websiteUrl, html);
return result.url;
}