@cloudcreate/adsense-check 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -77
- package/dist/chunk-GW4SHZYX.js +1699 -0
- package/dist/cli.js +219 -92
- package/dist/index.d.ts +47 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-V2YZ36NU.js +0 -1015
|
@@ -0,0 +1,1699 @@
|
|
|
1
|
+
// src/browser.ts
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
var BrowserManager = class {
|
|
4
|
+
browser = null;
|
|
5
|
+
async launch() {
|
|
6
|
+
if (!this.browser) {
|
|
7
|
+
this.browser = await chromium.launch({ headless: true });
|
|
8
|
+
}
|
|
9
|
+
return this.browser;
|
|
10
|
+
}
|
|
11
|
+
async newPage() {
|
|
12
|
+
const browser = await this.launch();
|
|
13
|
+
const context = await browser.newContext({
|
|
14
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
15
|
+
viewport: { width: 1280, height: 720 }
|
|
16
|
+
});
|
|
17
|
+
return context.newPage();
|
|
18
|
+
}
|
|
19
|
+
async newMobilePage() {
|
|
20
|
+
const browser = await this.launch();
|
|
21
|
+
const context = await browser.newContext({
|
|
22
|
+
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
23
|
+
viewport: { width: 390, height: 844 },
|
|
24
|
+
isMobile: true,
|
|
25
|
+
hasTouch: true
|
|
26
|
+
});
|
|
27
|
+
return context.newPage();
|
|
28
|
+
}
|
|
29
|
+
async close() {
|
|
30
|
+
if (this.browser) {
|
|
31
|
+
await this.browser.close();
|
|
32
|
+
this.browser = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
async function fetchPage(page, url, timeout = 3e4) {
|
|
37
|
+
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
38
|
+
const status = response?.status() ?? 0;
|
|
39
|
+
let text = await page.evaluate(() => document.body?.innerText ?? "");
|
|
40
|
+
if (text.replace(/\s+/g, "").length < 100) {
|
|
41
|
+
try {
|
|
42
|
+
await page.waitForLoadState("networkidle", { timeout: 1e4 });
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
await page.waitForTimeout(2e3);
|
|
46
|
+
text = await page.evaluate(() => document.body?.innerText ?? "");
|
|
47
|
+
}
|
|
48
|
+
const content = await page.content();
|
|
49
|
+
const links = await page.evaluate(
|
|
50
|
+
() => Array.from(document.querySelectorAll("a[href]")).map((a) => a.href).filter((href) => href.startsWith("http"))
|
|
51
|
+
);
|
|
52
|
+
const linkDetails = await page.evaluate(
|
|
53
|
+
() => Array.from(document.querySelectorAll("a[href]")).filter((a) => a.href.startsWith("http")).map((a) => ({
|
|
54
|
+
href: a.href,
|
|
55
|
+
text: a.innerText.trim()
|
|
56
|
+
}))
|
|
57
|
+
);
|
|
58
|
+
const navText = await page.evaluate(() => {
|
|
59
|
+
const nav = document.querySelector("nav");
|
|
60
|
+
return nav?.innerText ?? "";
|
|
61
|
+
});
|
|
62
|
+
const footerText = await page.evaluate(() => {
|
|
63
|
+
const footer = document.querySelector("footer");
|
|
64
|
+
return footer?.innerText ?? "";
|
|
65
|
+
});
|
|
66
|
+
const title = await page.title();
|
|
67
|
+
const signals = await page.evaluate(() => {
|
|
68
|
+
const AD_DOMAINS = /googlesyndication|doubleclick|adservice|adsense|pagead|adnxs|amazon-adsystem|facebook\.com\/plugins/i;
|
|
69
|
+
const iframes = Array.from(document.querySelectorAll("iframe"));
|
|
70
|
+
const visibleIframes = iframes.filter((f) => {
|
|
71
|
+
const rect = f.getBoundingClientRect();
|
|
72
|
+
const style = getComputedStyle(f);
|
|
73
|
+
if (rect.width <= 50 || rect.height <= 50) return false;
|
|
74
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
75
|
+
const src = f.src || f.getAttribute("data-src") || f.getAttribute("data-lazy-src") || "";
|
|
76
|
+
if (AD_DOMAINS.test(src)) return false;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
const iframeSrcs = visibleIframes.map(
|
|
80
|
+
(f) => f.src || f.getAttribute("data-src") || f.getAttribute("data-lazy-src") || f.getAttribute("data-lazyloaded-src") || ""
|
|
81
|
+
).filter(Boolean);
|
|
82
|
+
const gameLinkPatterns = /\/(game|play|games)\//i;
|
|
83
|
+
const gameLinks = Array.from(document.querySelectorAll("a[href]")).filter(
|
|
84
|
+
(a) => gameLinkPatterns.test(a.href)
|
|
85
|
+
).length;
|
|
86
|
+
return {
|
|
87
|
+
iframeCount: visibleIframes.length,
|
|
88
|
+
iframeSrcs,
|
|
89
|
+
canvasCount: document.querySelectorAll("canvas").length,
|
|
90
|
+
articleCount: document.querySelectorAll("article").length,
|
|
91
|
+
textLength: (document.body?.innerText ?? "").replace(/\s+/g, "").length,
|
|
92
|
+
gameLinks
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
return { status, content, text, links, linkDetails, navText, footerText, title, url, signals };
|
|
96
|
+
}
|
|
97
|
+
async function checkRobotsTxt(origin) {
|
|
98
|
+
try {
|
|
99
|
+
const resp = await fetch(`${origin}/robots.txt`);
|
|
100
|
+
return resp.ok;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function checkSitemap(origin) {
|
|
106
|
+
try {
|
|
107
|
+
const resp = await fetch(`${origin}/sitemap.xml`);
|
|
108
|
+
return resp.ok;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
var NON_CONTENT_EXTENSIONS = /\.(xml|txt|json|pdf|zip|tar|gz|rar|exe|dmg|apk|css|js|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot)$/i;
|
|
114
|
+
var NON_CONTENT_PATHS = /^(\/(ads\.txt|robots\.txt|sitemap\.xml|favicon\.ico|manifest\.json|sw\.js|service-worker\.js|humans\.txt|security\.txt|\.well-known))/i;
|
|
115
|
+
function isContentUrl(url) {
|
|
116
|
+
try {
|
|
117
|
+
const { pathname } = new URL(url);
|
|
118
|
+
if (NON_CONTENT_EXTENSIONS.test(pathname)) return false;
|
|
119
|
+
if (NON_CONTENT_PATHS.test(pathname)) return false;
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
var MAX_SITEMAP_DEPTH = 3;
|
|
126
|
+
async function fetchSitemapRecursive(sitemapUrl, seen, depth) {
|
|
127
|
+
if (depth > MAX_SITEMAP_DEPTH) return [];
|
|
128
|
+
const norm = sitemapUrl.replace(/\/+$/, "");
|
|
129
|
+
if (seen.has(norm)) return [];
|
|
130
|
+
seen.add(norm);
|
|
131
|
+
try {
|
|
132
|
+
const resp = await fetch(sitemapUrl);
|
|
133
|
+
if (!resp.ok) return [];
|
|
134
|
+
const text = await resp.text();
|
|
135
|
+
const sitemapRefs = text.match(/<sitemap>[\s\S]*?<\/sitemap>/g);
|
|
136
|
+
if (sitemapRefs && depth < MAX_SITEMAP_DEPTH) {
|
|
137
|
+
const childUrls = [];
|
|
138
|
+
for (const ref of sitemapRefs) {
|
|
139
|
+
const locMatch = ref.match(/<loc>(.*?)<\/loc>/);
|
|
140
|
+
if (locMatch && locMatch[1].startsWith("http")) {
|
|
141
|
+
childUrls.push(locMatch[1]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const results = await Promise.all(
|
|
145
|
+
childUrls.map((u) => fetchSitemapRecursive(u, seen, depth + 1))
|
|
146
|
+
);
|
|
147
|
+
return results.flat();
|
|
148
|
+
}
|
|
149
|
+
const matches = text.match(/<loc>(.*?)<\/loc>/g);
|
|
150
|
+
if (!matches) return [];
|
|
151
|
+
return matches.map((m) => m.replace(/<\/?loc>/g, "")).filter((u) => u.startsWith("http"));
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function fetchSitemapUrls(origin) {
|
|
157
|
+
const seen = /* @__PURE__ */ new Set();
|
|
158
|
+
const urls = await fetchSitemapRecursive(`${origin}/sitemap.xml`, seen, 0);
|
|
159
|
+
return [...new Set(urls)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/i18n.ts
|
|
163
|
+
var en = {
|
|
164
|
+
// Categories
|
|
165
|
+
"cat.content": "Content Quality",
|
|
166
|
+
"cat.pages": "Required Pages",
|
|
167
|
+
"cat.structure": "Site Structure",
|
|
168
|
+
"cat.performance": "Performance",
|
|
169
|
+
"cat.policy": "Policy Compliance",
|
|
170
|
+
"cat.ai": "AI Content Analysis",
|
|
171
|
+
// Content items
|
|
172
|
+
"item.content.ratio": "Content Ratio",
|
|
173
|
+
"item.content.home": "Homepage Content",
|
|
174
|
+
"item.content.subpage": "Subpage Depth",
|
|
175
|
+
"item.content.template": "Template Detection",
|
|
176
|
+
"item.content.filler": "Filler Detection",
|
|
177
|
+
"item.content.dup": "Cross-Page Duplication",
|
|
178
|
+
"item.content.freshness": "Content Freshness",
|
|
179
|
+
"item.content.scale": "Site Scale",
|
|
180
|
+
"item.content.game_desc": "Game Descriptions",
|
|
181
|
+
"item.content.iframe_quality": "Iframe Quality",
|
|
182
|
+
"item.content.game_variety": "Game Variety",
|
|
183
|
+
// Structure items
|
|
184
|
+
"item.structure.internal": "Internal Links",
|
|
185
|
+
"item.structure.deadlinks": "Dead Links",
|
|
186
|
+
// Performance items
|
|
187
|
+
"item.perf.speed": "Load Speed",
|
|
188
|
+
"item.perf.overflow": "Mobile Overflow",
|
|
189
|
+
"item.perf.font": "Mobile Font Size",
|
|
190
|
+
"item.perf.popup": "Popup Detection",
|
|
191
|
+
// Policy items
|
|
192
|
+
"item.policy.keywords": "Violating Keywords",
|
|
193
|
+
// AI items
|
|
194
|
+
"item.ai.quality": "Content Value",
|
|
195
|
+
"item.ai.originality": "Originality",
|
|
196
|
+
"item.ai.compliance": "Compliance",
|
|
197
|
+
"item.ai.suggestions": "AI Suggestions",
|
|
198
|
+
// Required page names
|
|
199
|
+
"page.about": "About",
|
|
200
|
+
"page.privacy": "Privacy Policy",
|
|
201
|
+
"page.contact": "Contact",
|
|
202
|
+
"page.terms": "Terms of Service",
|
|
203
|
+
// Content messages
|
|
204
|
+
"content.ratio.pass": "Content-to-boilerplate ratio is healthy across pages",
|
|
205
|
+
"content.ratio.fail": "{count} page(s) have low content ratio (<30%), mostly boilerplate",
|
|
206
|
+
"content.home.pass": "Homepage content is substantial ({chars} chars)",
|
|
207
|
+
"content.home.fail": "Homepage content is thin ({chars} chars, recommend 500+)",
|
|
208
|
+
"content.subpage.pass": "All subpages have sufficient content",
|
|
209
|
+
"content.subpage.warn": "{thin}/{total} subpages have thin content (<300 chars)",
|
|
210
|
+
"content.subpage.fail": "{thin}/{total} subpages have thin content (<300 chars)",
|
|
211
|
+
"content.template.pass": "Page structure diversity is good (similarity {pct}%)",
|
|
212
|
+
"content.template.fail": "Page structure similarity {pct}% \u2014 likely mass-produced template pages",
|
|
213
|
+
"content.filler.pass": "No obvious filler/padding content detected",
|
|
214
|
+
"content.filler.warn": "{count} instances of filler content detected",
|
|
215
|
+
"content.dup.pass": "Cross-page content uniqueness is good ({pct}% overlap)",
|
|
216
|
+
"content.dup.warn": "{pct}% of content segments are duplicated across pages",
|
|
217
|
+
"content.freshness.pass": "Site has recent updates (latest: {date})",
|
|
218
|
+
"content.freshness.warn_old": "Latest update: {date} \u2014 over 6 months old",
|
|
219
|
+
"content.freshness.warn_none": "No date information found in pages",
|
|
220
|
+
"content.scale.warn": "Only {count} pages found (recommend 10+ valuable content pages)",
|
|
221
|
+
"content.scale.pass_small": "Site has {count} pages",
|
|
222
|
+
"content.scale.pass": "Site scale is good ({count} pages)",
|
|
223
|
+
// Game-specific messages
|
|
224
|
+
"content.game_desc.pass": "{total} game page(s) have sufficient description text",
|
|
225
|
+
"content.game_desc.warn": "{thin}/{total} game pages lack description text (recommend 100+ chars of gameplay info)",
|
|
226
|
+
"content.iframe_quality.pass": "{count} game iframe(s) embedded",
|
|
227
|
+
"content.iframe_quality.warn": "{count} game iframes detected \u2014 ensure each has proper title and size attributes",
|
|
228
|
+
"content.game_variety.pass": "Game pages show good variety",
|
|
229
|
+
"content.game_variety.warn": "Game pages are {pct}% similar \u2014 may look like mass-produced content",
|
|
230
|
+
// Site type detection
|
|
231
|
+
"detector.type.content": "Content Site",
|
|
232
|
+
"detector.type.game": "Game Site",
|
|
233
|
+
"detector.signals": "Signals: {details}",
|
|
234
|
+
// Required pages messages
|
|
235
|
+
"pages.found": "Found {name} page ({path})",
|
|
236
|
+
"pages.missing_required": "{name} page not found (required)",
|
|
237
|
+
"pages.missing_optional": "{name} page not found (recommended)",
|
|
238
|
+
// Structure messages
|
|
239
|
+
"structure.h1.pass": "Page has exactly one H1 tag",
|
|
240
|
+
"structure.h1.warn_none": "Page is missing H1 tag",
|
|
241
|
+
"structure.h1.warn_multi": "Page has {count} H1 tags (recommend 1)",
|
|
242
|
+
"structure.robots.pass": "robots.txt exists",
|
|
243
|
+
"structure.robots.warn": "robots.txt not found (recommended)",
|
|
244
|
+
"structure.sitemap.pass": "sitemap.xml exists",
|
|
245
|
+
"structure.sitemap.warn": "sitemap.xml not found (recommended)",
|
|
246
|
+
"structure.links.pass": "Homepage has {count} internal links",
|
|
247
|
+
"structure.links.warn": "Homepage has only {count} internal links (recommend more navigation)",
|
|
248
|
+
"structure.deadlinks.pass": "No broken links detected",
|
|
249
|
+
"structure.deadlinks.fail": "{count} broken link(s) detected",
|
|
250
|
+
// Performance messages
|
|
251
|
+
"perf.speed.pass": "Load time {time}s",
|
|
252
|
+
"perf.speed.warn": "Load time {time}s (recommend under 3s)",
|
|
253
|
+
"perf.speed.fail": "Load time {time}s (too slow, impacts user experience)",
|
|
254
|
+
"perf.speed.timeout": "Page load timed out (30s)",
|
|
255
|
+
"perf.viewport.pass": "Viewport meta tag present",
|
|
256
|
+
"perf.viewport.warn": "Missing viewport meta tag",
|
|
257
|
+
"perf.overflow.pass": "No horizontal overflow on mobile",
|
|
258
|
+
"perf.overflow.warn": "Horizontal scroll detected on mobile viewport",
|
|
259
|
+
"perf.font.pass": "Mobile font sizes are adequate",
|
|
260
|
+
"perf.font.warn": "Some text is smaller than 12px, hard to read on mobile",
|
|
261
|
+
"perf.popup.pass": "No intrusive popups/overlays detected",
|
|
262
|
+
"perf.popup.warn": "{count} potential popup/overlay element(s) detected",
|
|
263
|
+
// Policy messages
|
|
264
|
+
"policy.keywords.pass": "No policy-violating keywords found",
|
|
265
|
+
"policy.keywords.fail": "{count} potentially violating keyword(s) found",
|
|
266
|
+
// AI messages
|
|
267
|
+
"ai.skip": "AI_API_KEY not configured, skipping AI analysis",
|
|
268
|
+
"ai.fail": "AI analysis failed: {error}",
|
|
269
|
+
"ai.suggestion_count": "{count} suggestion(s)",
|
|
270
|
+
"ai.suggest_enable": "Tip: use --ai flag to enable AI content quality analysis for deeper insights",
|
|
271
|
+
// Reporter
|
|
272
|
+
"report.title": "AdSense Checklist Report",
|
|
273
|
+
"report.composite_score": "Composite Score",
|
|
274
|
+
"report.score": "Score",
|
|
275
|
+
"report.ready": "READY \u2014 can submit for AdSense review",
|
|
276
|
+
"report.mostly": "MOSTLY READY \u2014 fix {count} warning(s) before submitting",
|
|
277
|
+
"report.notready": "NOT READY \u2014 {count} failure(s) must be fixed",
|
|
278
|
+
"report.pages": "{count} pages analyzed",
|
|
279
|
+
"report.pages_ok": "+ {count} page(s) with no issues",
|
|
280
|
+
"report.saved": "Report saved",
|
|
281
|
+
"report.page_details": "Page Details",
|
|
282
|
+
"report.content_label": "Content",
|
|
283
|
+
// Two-group scoring
|
|
284
|
+
"report.hard_requirements": "Hard Requirements",
|
|
285
|
+
"report.soft_scoring": "Soft Scoring",
|
|
286
|
+
"report.hard.ready": "READY \u2014 all requirements met",
|
|
287
|
+
"report.hard.warn": "NEEDS FIXES \u2014 {count} warning(s) to address",
|
|
288
|
+
"report.hard.fail": "NOT READY \u2014 {count} failure(s) must be fixed",
|
|
289
|
+
"report.warning_ratio": "Warning ratio: {count}/{total} ({pct}%)",
|
|
290
|
+
"report.warning_penalty": "Score penalty: -{points}",
|
|
291
|
+
// Group labels
|
|
292
|
+
"group.required_pages": "Required Pages",
|
|
293
|
+
"group.basic_structure": "Basic Structure",
|
|
294
|
+
"group.performance_min": "Performance Baseline",
|
|
295
|
+
"group.policy": "Policy Compliance",
|
|
296
|
+
"group.site_scale": "Site Scale",
|
|
297
|
+
"group.content_quality": "Content Quality",
|
|
298
|
+
"group.ai_analysis": "AI Content Analysis",
|
|
299
|
+
"group.page_quality": "Page Quality",
|
|
300
|
+
"group.user_experience": "User Experience",
|
|
301
|
+
"group.content_relevance": "Content Relevance",
|
|
302
|
+
// Topic & relevance
|
|
303
|
+
"item.relevance.topic": "Topic Relevance",
|
|
304
|
+
"detector.type.tool": "Tool Site",
|
|
305
|
+
"detector.type.unsupported": "Unsupported Type",
|
|
306
|
+
"topic.info": "Site topic: {topic}",
|
|
307
|
+
"topic.description": "{description}",
|
|
308
|
+
"topic.unsupported_warning": "This site type ({type}) is not supported by AdSense checklist"
|
|
309
|
+
};
|
|
310
|
+
var zh = {
|
|
311
|
+
// 分类
|
|
312
|
+
"cat.content": "\u5185\u5BB9\u8D28\u91CF",
|
|
313
|
+
"cat.pages": "\u5FC5\u8981\u9875\u9762",
|
|
314
|
+
"cat.structure": "\u7F51\u7AD9\u7ED3\u6784",
|
|
315
|
+
"cat.performance": "\u6027\u80FD\u4F53\u9A8C",
|
|
316
|
+
"cat.policy": "\u653F\u7B56\u5408\u89C4",
|
|
317
|
+
"cat.ai": "AI \u5185\u5BB9\u5206\u6790",
|
|
318
|
+
// 内容检查项
|
|
319
|
+
"item.content.ratio": "\u6709\u6548\u5185\u5BB9\u6BD4\u7387",
|
|
320
|
+
"item.content.home": "\u9996\u9875\u5B9E\u8D28\u5185\u5BB9",
|
|
321
|
+
"item.content.subpage": "\u5185\u9875\u5185\u5BB9\u6DF1\u5EA6",
|
|
322
|
+
"item.content.template": "\u6A21\u677F\u5316\u68C0\u6D4B",
|
|
323
|
+
"item.content.filler": "\u51D1\u5B57\u6570\u68C0\u6D4B",
|
|
324
|
+
"item.content.dup": "\u8DE8\u9875\u5185\u5BB9\u91CD\u590D",
|
|
325
|
+
"item.content.freshness": "\u5185\u5BB9\u65B0\u9C9C\u5EA6",
|
|
326
|
+
"item.content.scale": "\u7AD9\u70B9\u89C4\u6A21",
|
|
327
|
+
"item.content.game_desc": "\u6E38\u620F\u63CF\u8FF0",
|
|
328
|
+
"item.content.iframe_quality": "Iframe \u8D28\u91CF",
|
|
329
|
+
"item.content.game_variety": "\u6E38\u620F\u591A\u6837\u6027",
|
|
330
|
+
// 结构检查项
|
|
331
|
+
"item.structure.internal": "\u5185\u90E8\u94FE\u63A5",
|
|
332
|
+
"item.structure.deadlinks": "\u6B7B\u94FE\u68C0\u6D4B",
|
|
333
|
+
// 性能检查项
|
|
334
|
+
"item.perf.speed": "\u52A0\u8F7D\u901F\u5EA6",
|
|
335
|
+
"item.perf.overflow": "\u79FB\u52A8\u7AEF\u6EA2\u51FA",
|
|
336
|
+
"item.perf.font": "\u79FB\u52A8\u7AEF\u5B57\u4F53",
|
|
337
|
+
"item.perf.popup": "\u5F39\u7A97\u68C0\u6D4B",
|
|
338
|
+
// 合规检查项
|
|
339
|
+
"item.policy.keywords": "\u8FDD\u89C4\u5173\u952E\u8BCD",
|
|
340
|
+
// AI 检查项
|
|
341
|
+
"item.ai.quality": "\u5185\u5BB9\u4EF7\u503C\u8BC4\u4F30",
|
|
342
|
+
"item.ai.originality": "\u539F\u521B\u6027\u8BC4\u4F30",
|
|
343
|
+
"item.ai.compliance": "\u5408\u89C4\u6027\u8BC4\u4F30",
|
|
344
|
+
"item.ai.suggestions": "AI \u5EFA\u8BAE",
|
|
345
|
+
// 必要页面名称
|
|
346
|
+
"page.about": "About",
|
|
347
|
+
"page.privacy": "\u9690\u79C1\u653F\u7B56",
|
|
348
|
+
"page.contact": "\u8054\u7CFB\u65B9\u5F0F",
|
|
349
|
+
"page.terms": "\u670D\u52A1\u6761\u6B3E",
|
|
350
|
+
// 内容消息
|
|
351
|
+
"content.ratio.pass": "\u5404\u9875\u9762\u6B63\u6587\u5360\u6BD4\u6B63\u5E38\uFF0C\u6A21\u677F\u5143\u7D20\u5360\u6BD4\u5408\u7406",
|
|
352
|
+
"content.ratio.fail": "{count} \u4E2A\u9875\u9762\u6B63\u6587\u5360\u6BD4\u8FC7\u4F4E\uFF08<30%\uFF09\uFF0C\u5927\u91CF\u5185\u5BB9\u4E3A\u5BFC\u822A/\u9875\u811A\u7B49\u6A21\u677F\u5143\u7D20",
|
|
353
|
+
"content.home.pass": "\u9996\u9875\u6B63\u6587\u5185\u5BB9\u5145\u8DB3 ({chars} \u5B57)",
|
|
354
|
+
"content.home.fail": "\u9996\u9875\u6B63\u6587\u5185\u5BB9\u4E0D\u8DB3 ({chars} \u5B57\uFF0C\u5EFA\u8BAE 500+ \u5B57)",
|
|
355
|
+
"content.subpage.pass": "\u6240\u6709\u5185\u9875\u6B63\u6587\u5185\u5BB9\u5145\u8DB3",
|
|
356
|
+
"content.subpage.warn": "{thin}/{total} \u4E2A\u5185\u9875\u6B63\u6587\u5185\u5BB9\u4E0D\u8DB3 (<300 \u5B57)",
|
|
357
|
+
"content.subpage.fail": "{thin}/{total} \u4E2A\u5185\u9875\u6B63\u6587\u5185\u5BB9\u4E0D\u8DB3 (<300 \u5B57)",
|
|
358
|
+
"content.template.pass": "\u9875\u9762\u7ED3\u6784\u591A\u6837\u6027\u6B63\u5E38 (\u76F8\u4F3C\u5EA6 {pct}%)",
|
|
359
|
+
"content.template.fail": "\u9875\u9762\u7ED3\u6784\u76F8\u4F3C\u5EA6 {pct}%\uFF0C\u7591\u4F3C\u6A21\u677F\u6279\u91CF\u751F\u6210",
|
|
360
|
+
"content.filler.pass": "\u672A\u68C0\u6D4B\u5230\u660E\u663E\u7684\u586B\u5145/\u51D1\u5B57\u6570\u5185\u5BB9",
|
|
361
|
+
"content.filler.warn": "\u68C0\u6D4B\u5230 {count} \u5904\u7591\u4F3C\u51D1\u5B57\u6570\u7684\u586B\u5145\u5185\u5BB9",
|
|
362
|
+
"content.dup.pass": "\u5404\u9875\u9762\u5185\u5BB9\u72EC\u7ACB\u6027\u826F\u597D (\u91CD\u590D\u7387 {pct}%)",
|
|
363
|
+
"content.dup.warn": "{pct}% \u7684\u5185\u5BB9\u7247\u6BB5\u5728\u591A\u4E2A\u9875\u9762\u91CD\u590D\u51FA\u73B0",
|
|
364
|
+
"content.freshness.pass": "\u6700\u8FD1\u6709\u66F4\u65B0\u5185\u5BB9 (\u6700\u65B0: {date})",
|
|
365
|
+
"content.freshness.warn_old": "\u6700\u8FD1\u66F4\u65B0: {date}\uFF0C\u8D85\u8FC7 6 \u4E2A\u6708\u672A\u66F4\u65B0",
|
|
366
|
+
"content.freshness.warn_none": "\u9875\u9762\u4E2D\u672A\u68C0\u6D4B\u5230\u65E5\u671F\u4FE1\u606F\uFF0C\u65E0\u6CD5\u5224\u65AD\u5185\u5BB9\u65F6\u6548\u6027",
|
|
367
|
+
"content.scale.warn": "\u7AD9\u70B9\u4EC5 {count} \u4E2A\u9875\u9762\uFF08\u5EFA\u8BAE\u81F3\u5C11 10+ \u4E2A\u6709\u4EF7\u503C\u7684\u5185\u5BB9\u9875\uFF09",
|
|
368
|
+
"content.scale.pass_small": "\u7AD9\u70B9\u6709 {count} \u4E2A\u9875\u9762",
|
|
369
|
+
"content.scale.pass": "\u7AD9\u70B9\u89C4\u6A21\u826F\u597D ({count} \u4E2A\u9875\u9762)",
|
|
370
|
+
// 游戏站专用消息
|
|
371
|
+
"content.game_desc.pass": "{total} \u4E2A\u6E38\u620F\u9875\u9762\u6709\u8DB3\u591F\u7684\u63CF\u8FF0\u6587\u5B57",
|
|
372
|
+
"content.game_desc.warn": "{thin}/{total} \u4E2A\u6E38\u620F\u9875\u9762\u7F3A\u5C11\u63CF\u8FF0\u6587\u5B57\uFF08\u5EFA\u8BAE 100+ \u5B57\u7684\u73A9\u6CD5\u8BF4\u660E\uFF09",
|
|
373
|
+
"content.iframe_quality.pass": "\u5D4C\u5165\u4E86 {count} \u4E2A\u6E38\u620F iframe",
|
|
374
|
+
"content.iframe_quality.warn": "\u68C0\u6D4B\u5230 {count} \u4E2A\u6E38\u620F iframe \u2014 \u786E\u4FDD\u6BCF\u4E2A\u90FD\u6709 title \u548C\u5408\u7406\u5C3A\u5BF8",
|
|
375
|
+
"content.game_variety.pass": "\u6E38\u620F\u9875\u9762\u591A\u6837\u6027\u6B63\u5E38",
|
|
376
|
+
"content.game_variety.warn": "\u6E38\u620F\u9875\u9762\u76F8\u4F3C\u5EA6 {pct}% \u2014 \u53EF\u80FD\u662F\u6A21\u677F\u6279\u91CF\u751F\u6210",
|
|
377
|
+
// 站点类型检测
|
|
378
|
+
"detector.type.content": "\u5185\u5BB9\u7AD9",
|
|
379
|
+
"detector.type.game": "\u6E38\u620F\u7AD9",
|
|
380
|
+
"detector.signals": "\u68C0\u6D4B\u4FE1\u53F7: {details}",
|
|
381
|
+
// 必要页面消息
|
|
382
|
+
"pages.found": "\u627E\u5230 {name} \u9875\u9762 ({path})",
|
|
383
|
+
"pages.missing_required": "\u672A\u627E\u5230 {name} \u9875\u9762\uFF08\u5FC5\u9700\uFF09",
|
|
384
|
+
"pages.missing_optional": "\u672A\u627E\u5230 {name} \u9875\u9762\uFF08\u5EFA\u8BAE\u6DFB\u52A0\uFF09",
|
|
385
|
+
// 结构消息
|
|
386
|
+
"structure.h1.pass": "\u9875\u9762\u6709\u4E14\u4EC5\u6709\u4E00\u4E2A H1 \u6807\u7B7E",
|
|
387
|
+
"structure.h1.warn_none": "\u9875\u9762\u7F3A\u5C11 H1 \u6807\u7B7E",
|
|
388
|
+
"structure.h1.warn_multi": "\u9875\u9762\u6709 {count} \u4E2A H1 \u6807\u7B7E\uFF08\u5EFA\u8BAE\u4FDD\u7559 1 \u4E2A\uFF09",
|
|
389
|
+
"structure.robots.pass": "robots.txt \u5B58\u5728",
|
|
390
|
+
"structure.robots.warn": "\u672A\u627E\u5230 robots.txt\uFF08\u5EFA\u8BAE\u6DFB\u52A0\uFF09",
|
|
391
|
+
"structure.sitemap.pass": "sitemap.xml \u5B58\u5728",
|
|
392
|
+
"structure.sitemap.warn": "\u672A\u627E\u5230 sitemap.xml\uFF08\u5EFA\u8BAE\u6DFB\u52A0\uFF09",
|
|
393
|
+
"structure.links.pass": "\u9996\u9875\u6709 {count} \u4E2A\u5185\u90E8\u94FE\u63A5",
|
|
394
|
+
"structure.links.warn": "\u9996\u9875\u4EC5 {count} \u4E2A\u5185\u90E8\u94FE\u63A5\uFF08\u5EFA\u8BAE\u589E\u52A0\u5BFC\u822A\u94FE\u63A5\uFF09",
|
|
395
|
+
"structure.deadlinks.pass": "\u672A\u68C0\u6D4B\u5230\u6B7B\u94FE",
|
|
396
|
+
"structure.deadlinks.fail": "\u68C0\u6D4B\u5230 {count} \u4E2A\u6B7B\u94FE",
|
|
397
|
+
// 性能消息
|
|
398
|
+
"perf.speed.pass": "\u52A0\u8F7D\u65F6\u95F4 {time}s",
|
|
399
|
+
"perf.speed.warn": "\u52A0\u8F7D\u65F6\u95F4 {time}s\uFF08\u5EFA\u8BAE\u4F18\u5316\u5230 3s \u4EE5\u5185\uFF09",
|
|
400
|
+
"perf.speed.fail": "\u52A0\u8F7D\u65F6\u95F4 {time}s\uFF08\u8FC7\u6162\uFF0C\u4E25\u91CD\u5F71\u54CD\u7528\u6237\u4F53\u9A8C\uFF09",
|
|
401
|
+
"perf.speed.timeout": "\u9875\u9762\u52A0\u8F7D\u8D85\u65F6\uFF0830s\uFF09",
|
|
402
|
+
"perf.viewport.pass": "\u5B58\u5728 viewport meta \u6807\u7B7E",
|
|
403
|
+
"perf.viewport.warn": "\u7F3A\u5C11 viewport meta \u6807\u7B7E",
|
|
404
|
+
"perf.overflow.pass": "\u79FB\u52A8\u7AEF\u9875\u9762\u65E0\u6A2A\u5411\u6EA2\u51FA",
|
|
405
|
+
"perf.overflow.warn": "\u79FB\u52A8\u7AEF\u9875\u9762\u5B58\u5728\u6A2A\u5411\u6EDA\u52A8\uFF08body \u5BBD\u5EA6\u8D85\u51FA\u89C6\u53E3\uFF09",
|
|
406
|
+
"perf.font.pass": "\u79FB\u52A8\u7AEF\u5B57\u53F7\u9002\u4E2D",
|
|
407
|
+
"perf.font.warn": "\u90E8\u5206\u6587\u5B57\u5B57\u53F7\u5C0F\u4E8E 12px\uFF0C\u79FB\u52A8\u7AEF\u9605\u8BFB\u56F0\u96BE",
|
|
408
|
+
"perf.popup.pass": "\u672A\u68C0\u6D4B\u5230\u660E\u663E\u7684\u5F39\u7A97/\u906E\u7F69\u5C42",
|
|
409
|
+
"perf.popup.warn": "\u68C0\u6D4B\u5230 {count} \u4E2A\u53EF\u80FD\u7684\u5F39\u7A97/\u906E\u7F69\u5C42\uFF08\u8FC7\u591A\u5F39\u7A97\u4F1A\u5F71\u54CD\u5BA1\u6838\uFF09",
|
|
410
|
+
// 合规消息
|
|
411
|
+
"policy.keywords.pass": "\u672A\u68C0\u6D4B\u5230\u660E\u663E\u7684\u8FDD\u89C4\u5173\u952E\u8BCD",
|
|
412
|
+
"policy.keywords.fail": "\u68C0\u6D4B\u5230 {count} \u4E2A\u53EF\u7591\u5173\u952E\u8BCD",
|
|
413
|
+
// AI 消息
|
|
414
|
+
"ai.skip": "\u672A\u914D\u7F6E AI_API_KEY\uFF0C\u8DF3\u8FC7 AI \u5206\u6790",
|
|
415
|
+
"ai.fail": "AI \u5206\u6790\u5931\u8D25: {error}",
|
|
416
|
+
"ai.suggestion_count": "{count} \u6761\u6539\u8FDB\u5EFA\u8BAE",
|
|
417
|
+
"ai.suggest_enable": "\u63D0\u793A: \u4F7F\u7528 --ai \u53C2\u6570\u542F\u7528 AI \u5185\u5BB9\u8D28\u91CF\u5206\u6790\uFF0C\u83B7\u53D6\u66F4\u6DF1\u5165\u7684\u5BA1\u67E5\u5EFA\u8BAE",
|
|
418
|
+
// 报告
|
|
419
|
+
"report.title": "AdSense \u5BA1\u6838\u68C0\u67E5\u62A5\u544A",
|
|
420
|
+
"report.composite_score": "\u7EFC\u5408\u8BC4\u5206",
|
|
421
|
+
"report.score": "\u8BC4\u5206",
|
|
422
|
+
"report.ready": "READY \u2014 \u53EF\u4EE5\u63D0\u4EA4 AdSense \u5BA1\u6838",
|
|
423
|
+
"report.mostly": "MOSTLY READY \u2014 \u4FEE\u590D {count} \u9879\u8B66\u544A\u540E\u53EF\u63D0\u4EA4\u5BA1\u6838",
|
|
424
|
+
"report.notready": "NOT READY \u2014 {count} \u9879\u5931\u8D25\u9700\u8981\u4FEE\u590D",
|
|
425
|
+
"report.pages": "\u5DF2\u5206\u6790 {count} \u4E2A\u9875\u9762",
|
|
426
|
+
"report.pages_ok": "+ {count} \u4E2A\u9875\u9762\u65E0\u95EE\u9898",
|
|
427
|
+
"report.saved": "\u62A5\u544A\u5DF2\u4FDD\u5B58",
|
|
428
|
+
"report.page_details": "\u9010\u9875\u8BE6\u60C5",
|
|
429
|
+
"report.content_label": "\u6B63\u6587",
|
|
430
|
+
// 两组评分
|
|
431
|
+
"report.hard_requirements": "\u786C\u6027\u8981\u6C42",
|
|
432
|
+
"report.soft_scoring": "\u67D4\u6027\u8BC4\u5206",
|
|
433
|
+
"report.hard.ready": "READY \u2014 \u6240\u6709\u5FC5\u8981\u9879\u8FBE\u6807",
|
|
434
|
+
"report.hard.warn": "NEEDS FIXES \u2014 {count} \u9879\u8B66\u544A\u5F85\u4FEE\u590D",
|
|
435
|
+
"report.hard.fail": "NOT READY \u2014 {count} \u9879\u5931\u8D25\u5FC5\u987B\u4FEE\u590D",
|
|
436
|
+
"report.warning_ratio": "\u8B66\u544A\u6BD4\u4F8B: {count}/{total} ({pct}%)",
|
|
437
|
+
"report.warning_penalty": "\u6263\u5206: -{points}",
|
|
438
|
+
// 分组标签
|
|
439
|
+
"group.required_pages": "\u5FC5\u8981\u9875\u9762",
|
|
440
|
+
"group.basic_structure": "\u57FA\u7840\u7ED3\u6784",
|
|
441
|
+
"group.performance_min": "\u6027\u80FD\u5E95\u7EBF",
|
|
442
|
+
"group.policy": "\u653F\u7B56\u5408\u89C4",
|
|
443
|
+
"group.site_scale": "\u7AD9\u70B9\u89C4\u6A21",
|
|
444
|
+
"group.content_quality": "\u5185\u5BB9\u8D28\u91CF",
|
|
445
|
+
"group.ai_analysis": "AI \u5185\u5BB9\u5206\u6790",
|
|
446
|
+
"group.page_quality": "\u9875\u9762\u8D28\u91CF",
|
|
447
|
+
"group.user_experience": "\u7528\u6237\u4F53\u9A8C",
|
|
448
|
+
"group.content_relevance": "\u5185\u5BB9\u76F8\u5173\u6027",
|
|
449
|
+
// 主题和相关性
|
|
450
|
+
"item.relevance.topic": "\u4E3B\u9898\u76F8\u5173\u6027",
|
|
451
|
+
"detector.type.tool": "\u5DE5\u5177\u7AD9",
|
|
452
|
+
"detector.type.unsupported": "\u4E0D\u652F\u6301\u7684\u7C7B\u578B",
|
|
453
|
+
"topic.info": "\u7AD9\u70B9\u4E3B\u9898: {topic}",
|
|
454
|
+
"topic.description": "{description}",
|
|
455
|
+
"topic.unsupported_warning": "\u8BE5\u7AD9\u70B9\u7C7B\u578B\uFF08{type}\uFF09\u4E0D\u5728 AdSense \u68C0\u67E5\u652F\u6301\u8303\u56F4\u5185"
|
|
456
|
+
};
|
|
457
|
+
var langMap = { en, zh };
|
|
458
|
+
function getSupportedLangs() {
|
|
459
|
+
return Object.keys(langMap);
|
|
460
|
+
}
|
|
461
|
+
function isValidLang(lang) {
|
|
462
|
+
return lang in langMap;
|
|
463
|
+
}
|
|
464
|
+
function t(key, lang, vars) {
|
|
465
|
+
const dict = langMap[lang] ?? langMap["en"];
|
|
466
|
+
let msg = dict[key] ?? en[key] ?? key;
|
|
467
|
+
if (vars) {
|
|
468
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
469
|
+
msg = msg.replace(`{${k}}`, String(v));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return msg;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/ai/topic.ts
|
|
476
|
+
function getApiEndpoint() {
|
|
477
|
+
const base = process.env.AI_API_BASE || "https://api.deepseek.com";
|
|
478
|
+
return `${base.replace(/\/$/, "")}/chat/completions`;
|
|
479
|
+
}
|
|
480
|
+
function getModel() {
|
|
481
|
+
return process.env.AI_MODEL || "deepseek-chat";
|
|
482
|
+
}
|
|
483
|
+
async function callAI(prompt, apiKey, maxTokens = 1024) {
|
|
484
|
+
const response = await fetch(getApiEndpoint(), {
|
|
485
|
+
method: "POST",
|
|
486
|
+
headers: {
|
|
487
|
+
"Content-Type": "application/json",
|
|
488
|
+
Authorization: `Bearer ${apiKey}`
|
|
489
|
+
},
|
|
490
|
+
body: JSON.stringify({
|
|
491
|
+
model: getModel(),
|
|
492
|
+
max_tokens: maxTokens,
|
|
493
|
+
messages: [{ role: "user", content: prompt }]
|
|
494
|
+
})
|
|
495
|
+
});
|
|
496
|
+
if (!response.ok) {
|
|
497
|
+
throw new Error(`AI API error: ${response.status} ${response.statusText}`);
|
|
498
|
+
}
|
|
499
|
+
const data = await response.json();
|
|
500
|
+
return data.choices?.[0]?.message?.content ?? "";
|
|
501
|
+
}
|
|
502
|
+
function extractJson(text) {
|
|
503
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
504
|
+
if (jsonMatch) {
|
|
505
|
+
return JSON.parse(jsonMatch[0]);
|
|
506
|
+
}
|
|
507
|
+
throw new Error("No JSON found in response");
|
|
508
|
+
}
|
|
509
|
+
var VALID_TYPES = ["content", "tool", "game"];
|
|
510
|
+
async function analyzeSiteTopic(homepage, lang = "en", apiKey) {
|
|
511
|
+
const langName = lang === "zh" ? "\u4E2D\u6587" : "English";
|
|
512
|
+
const content = homepage.text.slice(0, 2e3);
|
|
513
|
+
const prompt = `You are a web analyst. Determine the type and topic of this website.
|
|
514
|
+
|
|
515
|
+
Homepage title: ${homepage.title}
|
|
516
|
+
Navigation: ${homepage.navText.slice(0, 500)}
|
|
517
|
+
Homepage content (first 2000 chars):
|
|
518
|
+
${content}
|
|
519
|
+
|
|
520
|
+
Classify this website into ONE of these types:
|
|
521
|
+
- "content": informational site (news, blog, reference materials, educational content)
|
|
522
|
+
- "tool": utility/tool site (calculator, converter, generator, online tool)
|
|
523
|
+
- "game": online game site (playable games, game portal)
|
|
524
|
+
- "unsupported": e-commerce, SaaS product, social media, forum, portfolio, or anything not fitting above categories
|
|
525
|
+
|
|
526
|
+
Reply language: ${langName}
|
|
527
|
+
|
|
528
|
+
Reply in ${langName} with JSON:
|
|
529
|
+
{
|
|
530
|
+
"type": "content|tool|game|unsupported",
|
|
531
|
+
"topic": "Main topic in 3-5 words (e.g. 'Excel translation reference')",
|
|
532
|
+
"description": "One sentence describing what this site does",
|
|
533
|
+
"confidence": "high|medium|low",
|
|
534
|
+
"reasoning": "Brief explanation of why this type was chosen"
|
|
535
|
+
}`;
|
|
536
|
+
try {
|
|
537
|
+
const text = await callAI(prompt, apiKey);
|
|
538
|
+
const result = extractJson(text);
|
|
539
|
+
const type = VALID_TYPES.includes(result.type) ? result.type : "unsupported";
|
|
540
|
+
return {
|
|
541
|
+
type,
|
|
542
|
+
topic: result.topic ?? "Unknown",
|
|
543
|
+
description: result.description ?? "",
|
|
544
|
+
confidence: result.confidence ?? "medium",
|
|
545
|
+
reasoning: result.reasoning ?? ""
|
|
546
|
+
};
|
|
547
|
+
} catch {
|
|
548
|
+
return {
|
|
549
|
+
type: "unsupported",
|
|
550
|
+
topic: "Unknown",
|
|
551
|
+
description: "Topic analysis failed",
|
|
552
|
+
confidence: "low",
|
|
553
|
+
reasoning: "AI analysis failed"
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/detector.ts
|
|
559
|
+
var GAME_NAV_KEYWORDS = /\b(games?|play\b|arcade|puzzle|action)\b/i;
|
|
560
|
+
var GAME_NAV_KEYWORDS_ZH = /游戏|玩游戏/;
|
|
561
|
+
var TOOL_NAV_KEYWORDS = /\b(calculator|converter|generator|tool|translat|calculat|checker|analyzer|formatter|validator|encoder|decoder)\b/i;
|
|
562
|
+
var TOOL_NAV_KEYWORDS_ZH = /计算器|转换器|工具|翻译/;
|
|
563
|
+
var GAME_IFRAME_PATTERNS = [
|
|
564
|
+
/game/i,
|
|
565
|
+
/play/i,
|
|
566
|
+
/html5/i,
|
|
567
|
+
/unity/i,
|
|
568
|
+
/gamedistribution/i,
|
|
569
|
+
/gameflare/i,
|
|
570
|
+
/gamepix/i,
|
|
571
|
+
/crazygames/i,
|
|
572
|
+
/poki/i,
|
|
573
|
+
/y8/i,
|
|
574
|
+
/friv/i,
|
|
575
|
+
/itch\.io/i,
|
|
576
|
+
/htmlgames/i,
|
|
577
|
+
/gameflare/i,
|
|
578
|
+
/embed/i
|
|
579
|
+
];
|
|
580
|
+
function detectSiteType(pagesSignals, navText, manualType) {
|
|
581
|
+
if (manualType) {
|
|
582
|
+
return { type: manualType, confidence: "high", signals: { iframeRatio: 0, canvasRatio: 0, articleRatio: 0, navGameKeywords: false } };
|
|
583
|
+
}
|
|
584
|
+
const total = pagesSignals.length;
|
|
585
|
+
if (total === 0) {
|
|
586
|
+
return { type: "content", confidence: "low", signals: { iframeRatio: 0, canvasRatio: 0, articleRatio: 0, navGameKeywords: false } };
|
|
587
|
+
}
|
|
588
|
+
let pagesWithIframe = 0;
|
|
589
|
+
let pagesWithCanvas = 0;
|
|
590
|
+
let pagesWithArticle = 0;
|
|
591
|
+
let pagesWithGameIframe = 0;
|
|
592
|
+
let firstPageIframes = 0;
|
|
593
|
+
let firstPageCanvas = 0;
|
|
594
|
+
let totalGameLinks = 0;
|
|
595
|
+
for (let i = 0; i < pagesSignals.length; i++) {
|
|
596
|
+
const sig = pagesSignals[i];
|
|
597
|
+
if (sig.iframeCount > 0) pagesWithIframe++;
|
|
598
|
+
if (sig.canvasCount > 0) pagesWithCanvas++;
|
|
599
|
+
if (sig.articleCount > 0) pagesWithArticle++;
|
|
600
|
+
totalGameLinks += sig.gameLinks || 0;
|
|
601
|
+
if (i === 0) {
|
|
602
|
+
firstPageIframes = sig.iframeCount;
|
|
603
|
+
firstPageCanvas = sig.canvasCount;
|
|
604
|
+
}
|
|
605
|
+
const hasGameIframe = sig.iframeSrcs.some((src) => GAME_IFRAME_PATTERNS.some((p) => p.test(src)));
|
|
606
|
+
if (hasGameIframe) pagesWithGameIframe++;
|
|
607
|
+
}
|
|
608
|
+
const avgGameLinks = totalGameLinks / total;
|
|
609
|
+
const iframeRatio = pagesWithIframe / total;
|
|
610
|
+
const canvasRatio = pagesWithCanvas / total;
|
|
611
|
+
const articleRatio = pagesWithArticle / total;
|
|
612
|
+
const gameIframeRatio = pagesWithGameIframe / total;
|
|
613
|
+
const navGameKeywords = GAME_NAV_KEYWORDS.test(navText) || GAME_NAV_KEYWORDS_ZH.test(navText);
|
|
614
|
+
let gameScore = 0;
|
|
615
|
+
if (gameIframeRatio >= 0.3) gameScore += 5;
|
|
616
|
+
else if (gameIframeRatio >= 0.1) gameScore += 3;
|
|
617
|
+
if (iframeRatio >= 0.3) gameScore += 2;
|
|
618
|
+
else if (firstPageIframes >= 1) gameScore += 1;
|
|
619
|
+
if (canvasRatio >= 0.1) gameScore += 4;
|
|
620
|
+
if (firstPageCanvas >= 1) gameScore += 3;
|
|
621
|
+
if (navGameKeywords) gameScore += 3;
|
|
622
|
+
if (firstPageIframes >= 3) gameScore += 3;
|
|
623
|
+
if (avgGameLinks >= 5) gameScore += 3;
|
|
624
|
+
else if (avgGameLinks >= 2) gameScore += 2;
|
|
625
|
+
else if (totalGameLinks >= 3) gameScore += 1;
|
|
626
|
+
if (articleRatio >= 0.7 && gameScore < 3) gameScore -= 2;
|
|
627
|
+
const isGame = gameScore >= 3;
|
|
628
|
+
let type;
|
|
629
|
+
let confidence;
|
|
630
|
+
if (isGame) {
|
|
631
|
+
type = "game";
|
|
632
|
+
confidence = gameScore >= 6 ? "high" : "medium";
|
|
633
|
+
} else {
|
|
634
|
+
const navToolKeywords = TOOL_NAV_KEYWORDS.test(navText) || TOOL_NAV_KEYWORDS_ZH.test(navText);
|
|
635
|
+
if (navToolKeywords) {
|
|
636
|
+
type = "tool";
|
|
637
|
+
confidence = "medium";
|
|
638
|
+
} else {
|
|
639
|
+
type = "content";
|
|
640
|
+
confidence = "high";
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return { type, confidence, signals: { iframeRatio, canvasRatio, articleRatio, navGameKeywords } };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/checks/content.ts
|
|
647
|
+
function extractMainContent(text, allPageTexts) {
|
|
648
|
+
const paragraphs = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
|
|
649
|
+
if (allPageTexts.length <= 1) return paragraphs.join("\n\n");
|
|
650
|
+
const otherTexts = allPageTexts.filter((t2) => t2 !== text);
|
|
651
|
+
const threshold = Math.ceil(otherTexts.length * 0.6);
|
|
652
|
+
return paragraphs.filter((para) => {
|
|
653
|
+
if (para.length < 20) return true;
|
|
654
|
+
const normalized = para.replace(/\s+/g, " ").slice(0, 100);
|
|
655
|
+
const count = otherTexts.filter((o) => o.replace(/\s+/g, " ").includes(normalized)).length;
|
|
656
|
+
return count < threshold;
|
|
657
|
+
}).join("\n\n");
|
|
658
|
+
}
|
|
659
|
+
function detectTemplatePages(pages) {
|
|
660
|
+
if (pages.length < 3) return { isTemplate: false, similarity: 0 };
|
|
661
|
+
const structures = pages.map(
|
|
662
|
+
(p) => p.text.replace(/[a-zA-Z一-鿿]+/g, "W").replace(/\d+/g, "N").replace(/\s+/g, " ").slice(0, 1e3)
|
|
663
|
+
);
|
|
664
|
+
let total = 0, pairs = 0;
|
|
665
|
+
for (let i = 0; i < structures.length; i++) {
|
|
666
|
+
for (let j = i + 1; j < structures.length; j++) {
|
|
667
|
+
const longer = structures[i].length > structures[j].length ? structures[i] : structures[j];
|
|
668
|
+
const shorter = structures[i].length > structures[j].length ? structures[j] : structures[i];
|
|
669
|
+
let common = 0;
|
|
670
|
+
for (let k = 0; k < shorter.length; k++) {
|
|
671
|
+
if (shorter[k] === longer[k]) common++;
|
|
672
|
+
}
|
|
673
|
+
total += common / longer.length;
|
|
674
|
+
pairs++;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const sim = pairs > 0 ? total / pairs : 0;
|
|
678
|
+
return { isTemplate: sim > 0.6, similarity: Math.round(sim * 100) };
|
|
679
|
+
}
|
|
680
|
+
function checkFreshness(pages) {
|
|
681
|
+
const patterns = [
|
|
682
|
+
/(\d{4})[年/\-.](\d{1,2})[月/\-.](\d{1,2})/g,
|
|
683
|
+
/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})/gi,
|
|
684
|
+
/(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+(\d{4})/gi
|
|
685
|
+
];
|
|
686
|
+
const now = /* @__PURE__ */ new Date();
|
|
687
|
+
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1e3);
|
|
688
|
+
let latest = /* @__PURE__ */ new Date(0), latestStr = "";
|
|
689
|
+
const stale = [];
|
|
690
|
+
let hasAny = false;
|
|
691
|
+
for (const page of pages) {
|
|
692
|
+
let recent = false;
|
|
693
|
+
for (const p of patterns) {
|
|
694
|
+
for (const m of [...page.text.matchAll(p)]) {
|
|
695
|
+
hasAny = true;
|
|
696
|
+
try {
|
|
697
|
+
let ds;
|
|
698
|
+
if (p.source.includes("January|February")) ds = `${m[1]} ${m[2]} ${m[3]}`;
|
|
699
|
+
else if (p.source.includes("Jan|Feb")) ds = `${m[1]} ${m[2]} ${m[3]}`;
|
|
700
|
+
else ds = `${m[1]}-${m[2].padStart(2, "0")}-${m[3].padStart(2, "0")}`;
|
|
701
|
+
const d = new Date(ds);
|
|
702
|
+
if (!isNaN(d.getTime()) && d > /* @__PURE__ */ new Date("2020-01-01") && d <= now) {
|
|
703
|
+
if (d > latest) {
|
|
704
|
+
latest = d;
|
|
705
|
+
latestStr = ds;
|
|
706
|
+
}
|
|
707
|
+
if (d >= sixMonthsAgo) recent = true;
|
|
708
|
+
}
|
|
709
|
+
} catch {
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (!recent && page.text.length > 200) stale.push(page.url);
|
|
714
|
+
}
|
|
715
|
+
return { hasRecent: hasAny && latest >= sixMonthsAgo, latestDate: latestStr || "", stalePages: stale };
|
|
716
|
+
}
|
|
717
|
+
function checkTemplateDetection(pages, lang) {
|
|
718
|
+
if (pages.length < 3) return null;
|
|
719
|
+
const tpl = detectTemplatePages(pages);
|
|
720
|
+
return { name: t("item.content.template", lang), status: tpl.isTemplate ? "fail" : "pass", message: t(tpl.isTemplate ? "content.template.fail" : "content.template.pass", lang, { pct: tpl.similarity }) };
|
|
721
|
+
}
|
|
722
|
+
function checkCrossPageDuplication(pages, lang) {
|
|
723
|
+
if (pages.length <= 1) return null;
|
|
724
|
+
const chunkSize = 200;
|
|
725
|
+
let dup = 0;
|
|
726
|
+
const chunks = /* @__PURE__ */ new Set();
|
|
727
|
+
for (const page of pages) {
|
|
728
|
+
const text = page.text.replace(/\s+/g, " ");
|
|
729
|
+
for (let i = 0; i < text.length - chunkSize; i += chunkSize) {
|
|
730
|
+
const c = text.slice(i, i + chunkSize);
|
|
731
|
+
if (chunks.has(c)) dup++;
|
|
732
|
+
else chunks.add(c);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const total = pages.reduce((s, p) => s + Math.max(1, Math.floor(p.text.replace(/\s+/g, " ").length / chunkSize)), 0);
|
|
736
|
+
const pct = total > 0 ? Math.round(dup / total * 100) : 0;
|
|
737
|
+
return { name: t("item.content.dup", lang), status: pct > 30 ? "warn" : "pass", message: t(pct > 30 ? "content.dup.warn" : "content.dup.pass", lang, { pct }) };
|
|
738
|
+
}
|
|
739
|
+
function checkFreshnessItem(pages, lang) {
|
|
740
|
+
const fresh = checkFreshness(pages);
|
|
741
|
+
if (fresh.hasRecent) return { name: t("item.content.freshness", lang), status: "pass", message: t("content.freshness.pass", lang, { date: fresh.latestDate }) };
|
|
742
|
+
if (fresh.latestDate) return { name: t("item.content.freshness", lang), status: "warn", message: t("content.freshness.warn_old", lang, { date: fresh.latestDate }) };
|
|
743
|
+
return { name: t("item.content.freshness", lang), status: "warn", message: t("content.freshness.warn_none", lang) };
|
|
744
|
+
}
|
|
745
|
+
function checkSiteScale(sitePageCount, lang) {
|
|
746
|
+
if (sitePageCount === void 0) return null;
|
|
747
|
+
const key = sitePageCount < 10 ? "content.scale.warn" : sitePageCount < 30 ? "content.scale.pass_small" : "content.scale.pass";
|
|
748
|
+
return { name: t("item.content.scale", lang), status: sitePageCount < 10 ? "warn" : "pass", message: t(key, lang, { count: sitePageCount }) };
|
|
749
|
+
}
|
|
750
|
+
function checkContentSite(pages, lang) {
|
|
751
|
+
const items = [];
|
|
752
|
+
const allTexts = pages.map((p) => p.text);
|
|
753
|
+
const lowRatio = [];
|
|
754
|
+
for (const page of pages) {
|
|
755
|
+
const main = extractMainContent(page.text, allTexts);
|
|
756
|
+
const total = page.text.replace(/\s+/g, "").length;
|
|
757
|
+
const content = main.replace(/\s+/g, "").length;
|
|
758
|
+
const ratio = total > 0 ? content / total : 1;
|
|
759
|
+
if (ratio < 0.3 && total > 200) lowRatio.push({ url: page.url, ratio: Math.round(ratio * 100), chars: content });
|
|
760
|
+
}
|
|
761
|
+
items.push(
|
|
762
|
+
lowRatio.length > 0 ? { name: t("item.content.ratio", lang), status: "fail", message: t("content.ratio.fail", lang, { count: lowRatio.length }), detail: lowRatio.map((p) => `${new URL(p.url).pathname}: ${p.ratio}% (${p.chars} chars)`).join("; ") } : { name: t("item.content.ratio", lang), status: "pass", message: t("content.ratio.pass", lang) }
|
|
763
|
+
);
|
|
764
|
+
let thinCount = 0;
|
|
765
|
+
for (const page of pages) {
|
|
766
|
+
const main = extractMainContent(page.text, allTexts);
|
|
767
|
+
const chars = main.replace(/\s+/g, "").length;
|
|
768
|
+
if (pages.indexOf(page) === 0) {
|
|
769
|
+
items.push(
|
|
770
|
+
chars >= 500 ? { name: t("item.content.home", lang), status: "pass", message: t("content.home.pass", lang, { chars }) } : { name: t("item.content.home", lang), status: "fail", message: t("content.home.fail", lang, { chars }) }
|
|
771
|
+
);
|
|
772
|
+
} else {
|
|
773
|
+
if (chars < 300) thinCount++;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (pages.length > 1) {
|
|
777
|
+
const key = thinCount === 0 ? "content.subpage.pass" : thinCount > (pages.length - 1) * 0.5 ? "content.subpage.fail" : "content.subpage.warn";
|
|
778
|
+
items.push({ name: t("item.content.subpage", lang), status: thinCount === 0 ? "pass" : thinCount > (pages.length - 1) * 0.5 ? "fail" : "warn", message: t(key, lang, { thin: thinCount, total: pages.length - 1 }) });
|
|
779
|
+
}
|
|
780
|
+
const fillers = [/(?:总之|综上所述|总的来说|简单来说|众所周知|毫无疑问|显而易见)/g, /(?:in conclusion|as we all know|it goes without saying|needless to say)/gi, /(.{10,30})\1{3,}/g];
|
|
781
|
+
let fillerCount = 0;
|
|
782
|
+
for (const page of pages) for (const f of fillers) {
|
|
783
|
+
const m = page.text.match(f);
|
|
784
|
+
if (m) fillerCount += m.length;
|
|
785
|
+
}
|
|
786
|
+
items.push({ name: t("item.content.filler", lang), status: fillerCount > pages.length * 3 ? "warn" : "pass", message: t(fillerCount > pages.length * 3 ? "content.filler.warn" : "content.filler.pass", lang, { count: fillerCount }) });
|
|
787
|
+
return items;
|
|
788
|
+
}
|
|
789
|
+
function checkGameSite(pages, pagesSignals, lang) {
|
|
790
|
+
const items = [];
|
|
791
|
+
const subpages = pages.slice(1);
|
|
792
|
+
const subpageSignals = pagesSignals.slice(1);
|
|
793
|
+
if (subpages.length > 0) {
|
|
794
|
+
let thinDesc = 0;
|
|
795
|
+
const thinPages = [];
|
|
796
|
+
for (let i = 0; i < subpages.length; i++) {
|
|
797
|
+
const sig = subpageSignals[i];
|
|
798
|
+
if (sig && (sig.iframeCount > 0 || sig.canvasCount > 0)) {
|
|
799
|
+
if (sig.textLength < 100) {
|
|
800
|
+
thinDesc++;
|
|
801
|
+
try {
|
|
802
|
+
thinPages.push(new URL(subpages[i].url).pathname);
|
|
803
|
+
} catch {
|
|
804
|
+
thinPages.push(subpages[i].url);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const gamePages = subpageSignals.filter((s) => s.iframeCount > 0 || s.canvasCount > 0).length;
|
|
810
|
+
if (gamePages > 0) {
|
|
811
|
+
const ratio = thinDesc / gamePages;
|
|
812
|
+
items.push(
|
|
813
|
+
ratio > 0.5 ? { name: t("item.content.game_desc", lang), status: "warn", message: t("content.game_desc.warn", lang, { thin: thinDesc, total: gamePages }), detail: thinPages.slice(0, 5).join(", ") } : { name: t("item.content.game_desc", lang), status: "pass", message: t("content.game_desc.pass", lang, { total: gamePages }) }
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
let iframesWithTitle = 0;
|
|
818
|
+
let totalIframes = 0;
|
|
819
|
+
for (const sig of pagesSignals) {
|
|
820
|
+
totalIframes += sig.iframeCount;
|
|
821
|
+
}
|
|
822
|
+
if (totalIframes > 0) {
|
|
823
|
+
items.push({
|
|
824
|
+
name: t("item.content.iframe_quality", lang),
|
|
825
|
+
status: totalIframes > 20 ? "warn" : "pass",
|
|
826
|
+
message: t(totalIframes > 20 ? "content.iframe_quality.warn" : "content.iframe_quality.pass", lang, { count: totalIframes })
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
if (subpages.length >= 3) {
|
|
830
|
+
const tpl = detectTemplatePages(subpages);
|
|
831
|
+
items.push({
|
|
832
|
+
name: t("item.content.game_variety", lang),
|
|
833
|
+
status: tpl.isTemplate ? "warn" : "pass",
|
|
834
|
+
message: t(tpl.isTemplate ? "content.game_variety.warn" : "content.game_variety.pass", lang, { pct: tpl.similarity })
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return items;
|
|
838
|
+
}
|
|
839
|
+
function checkContentQuality(pages, sitePageCount, lang, siteType = "content", pagesSignals) {
|
|
840
|
+
const items = [];
|
|
841
|
+
if (siteType === "game") {
|
|
842
|
+
if (pagesSignals) {
|
|
843
|
+
items.push(...checkGameSite(pages, pagesSignals, lang));
|
|
844
|
+
}
|
|
845
|
+
} else {
|
|
846
|
+
items.push(...checkContentSite(pages, lang));
|
|
847
|
+
}
|
|
848
|
+
const tplItem = checkTemplateDetection(pages, lang);
|
|
849
|
+
if (tplItem) items.push(tplItem);
|
|
850
|
+
const dupItem = checkCrossPageDuplication(pages, lang);
|
|
851
|
+
if (dupItem) items.push(dupItem);
|
|
852
|
+
items.push(checkFreshnessItem(pages, lang));
|
|
853
|
+
const scaleItem = checkSiteScale(sitePageCount, lang);
|
|
854
|
+
if (scaleItem) items.push(scaleItem);
|
|
855
|
+
return { name: t("cat.content", lang), items };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// src/checks/pages.ts
|
|
859
|
+
var REQUIRED_PAGES = [
|
|
860
|
+
{ nameKey: "page.about", required: true, urlPatterns: [/\/about/i], textPatterns: [/about/i, /关于我们/, /关于/] },
|
|
861
|
+
{ nameKey: "page.privacy", required: true, urlPatterns: [/\/privacy/i], textPatterns: [/privacy/i, /隐私/] },
|
|
862
|
+
{ nameKey: "page.contact", required: true, urlPatterns: [/\/contact/i], textPatterns: [/contact/i, /联系/] },
|
|
863
|
+
{ nameKey: "page.terms", required: false, urlPatterns: [/\/terms/i, /\/legal/i], textPatterns: [/terms/i, /legal/i, /条款/] }
|
|
864
|
+
];
|
|
865
|
+
async function checkRequiredPages(input, lang) {
|
|
866
|
+
const items = [];
|
|
867
|
+
const { allLinks, sitemapUrls } = input;
|
|
868
|
+
for (const page of REQUIRED_PAGES) {
|
|
869
|
+
const displayName = t(page.nameKey, lang);
|
|
870
|
+
let found = false, foundUrl = "";
|
|
871
|
+
for (const p of page.urlPatterns) {
|
|
872
|
+
const link = allLinks.find((l) => p.test(l.href));
|
|
873
|
+
if (link) {
|
|
874
|
+
found = true;
|
|
875
|
+
foundUrl = link.href;
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
const sm = sitemapUrls.find((u) => p.test(u));
|
|
879
|
+
if (sm) {
|
|
880
|
+
found = true;
|
|
881
|
+
foundUrl = sm;
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (!found) {
|
|
886
|
+
for (const p of page.textPatterns) {
|
|
887
|
+
const link = allLinks.find((l) => p.test(l.text));
|
|
888
|
+
if (link) {
|
|
889
|
+
found = true;
|
|
890
|
+
foundUrl = link.href;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const path = foundUrl ? (() => {
|
|
896
|
+
try {
|
|
897
|
+
return new URL(foundUrl).pathname;
|
|
898
|
+
} catch {
|
|
899
|
+
return "";
|
|
900
|
+
}
|
|
901
|
+
})() : "";
|
|
902
|
+
items.push(
|
|
903
|
+
found ? { name: displayName, status: "pass", message: t("pages.found", lang, { name: displayName, path }) } : { name: displayName, status: page.required ? "fail" : "warn", message: t(page.required ? "pages.missing_required" : "pages.missing_optional", lang, { name: displayName }) }
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
return { name: t("cat.pages", lang), items };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// src/checks/structure.ts
|
|
910
|
+
async function checkSiteStructure(origin, links, h1Count, deadLinks = [], lang) {
|
|
911
|
+
const items = [];
|
|
912
|
+
if (h1Count === 1) items.push({ name: "H1", status: "pass", message: t("structure.h1.pass", lang) });
|
|
913
|
+
else if (h1Count === 0) items.push({ name: "H1", status: "warn", message: t("structure.h1.warn_none", lang) });
|
|
914
|
+
else items.push({ name: "H1", status: "warn", message: t("structure.h1.warn_multi", lang, { count: h1Count }) });
|
|
915
|
+
const hasRobots = await checkRobotsTxt(origin);
|
|
916
|
+
items.push({ name: "robots.txt", status: hasRobots ? "pass" : "warn", message: t(hasRobots ? "structure.robots.pass" : "structure.robots.warn", lang) });
|
|
917
|
+
const hasSitemap = await checkSitemap(origin);
|
|
918
|
+
items.push({ name: "sitemap.xml", status: hasSitemap ? "pass" : "warn", message: t(hasSitemap ? "structure.sitemap.pass" : "structure.sitemap.warn", lang) });
|
|
919
|
+
const internal = links.filter((l) => {
|
|
920
|
+
try {
|
|
921
|
+
return new URL(l).origin === origin;
|
|
922
|
+
} catch {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
items.push({ name: t("item.structure.internal", lang), status: internal.length >= 5 ? "pass" : "warn", message: t(internal.length >= 5 ? "structure.links.pass" : "structure.links.warn", lang, { count: internal.length }) });
|
|
927
|
+
items.push({ name: t("item.structure.deadlinks", lang), status: deadLinks.length > 0 ? "fail" : "pass", message: t(deadLinks.length > 0 ? "structure.deadlinks.fail" : "structure.deadlinks.pass", lang, { count: deadLinks.length }), detail: deadLinks.length > 0 ? deadLinks.join(", ") : void 0 });
|
|
928
|
+
return { name: t("cat.structure", lang), items };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// src/checks/performance.ts
|
|
932
|
+
async function checkPerformance(page, url, browser, lang) {
|
|
933
|
+
const items = [];
|
|
934
|
+
const start = Date.now();
|
|
935
|
+
try {
|
|
936
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
937
|
+
const ms = Date.now() - start;
|
|
938
|
+
const sec = (ms / 1e3).toFixed(1);
|
|
939
|
+
if (ms < 3e3) items.push({ name: t("item.perf.speed", lang), status: "pass", message: t("perf.speed.pass", lang, { time: sec }) });
|
|
940
|
+
else if (ms < 6e3) items.push({ name: t("item.perf.speed", lang), status: "warn", message: t("perf.speed.warn", lang, { time: sec }) });
|
|
941
|
+
else items.push({ name: t("item.perf.speed", lang), status: "fail", message: t("perf.speed.fail", lang, { time: sec }) });
|
|
942
|
+
} catch {
|
|
943
|
+
items.push({ name: t("item.perf.speed", lang), status: "fail", message: t("perf.speed.timeout", lang) });
|
|
944
|
+
}
|
|
945
|
+
const hasViewport = await page.evaluate(() => !!document.querySelector('meta[name="viewport"]'));
|
|
946
|
+
items.push({ name: "Viewport", status: hasViewport ? "pass" : "warn", message: t(hasViewport ? "perf.viewport.pass" : "perf.viewport.warn", lang) });
|
|
947
|
+
try {
|
|
948
|
+
const ctx = await browser.newContext({ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
|
949
|
+
const mp = await ctx.newPage();
|
|
950
|
+
await mp.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
951
|
+
const mobile = await mp.evaluate(() => ({
|
|
952
|
+
overflow: document.body.scrollWidth > window.innerWidth,
|
|
953
|
+
smallFont: Array.from(document.querySelectorAll("p,span,a,li")).some((el) => {
|
|
954
|
+
const s = parseFloat(getComputedStyle(el).fontSize);
|
|
955
|
+
return s > 0 && s < 12;
|
|
956
|
+
})
|
|
957
|
+
}));
|
|
958
|
+
items.push({ name: t("item.perf.overflow", lang), status: mobile.overflow ? "warn" : "pass", message: t(mobile.overflow ? "perf.overflow.warn" : "perf.overflow.pass", lang) });
|
|
959
|
+
items.push({ name: t("item.perf.font", lang), status: mobile.smallFont ? "warn" : "pass", message: t(mobile.smallFont ? "perf.font.warn" : "perf.font.pass", lang) });
|
|
960
|
+
await ctx.close();
|
|
961
|
+
} catch {
|
|
962
|
+
}
|
|
963
|
+
const popups = await page.evaluate(() => {
|
|
964
|
+
return Array.from(document.querySelectorAll('[class*="modal"],[class*="popup"],[class*="overlay"],[id*="modal"],[id*="popup"]')).filter((el) => {
|
|
965
|
+
const s = getComputedStyle(el);
|
|
966
|
+
return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
|
|
967
|
+
}).length;
|
|
968
|
+
});
|
|
969
|
+
items.push({ name: t("item.perf.popup", lang), status: popups > 0 ? "warn" : "pass", message: t(popups > 0 ? "perf.popup.warn" : "perf.popup.pass", lang, { count: popups }) });
|
|
970
|
+
return { name: t("cat.performance", lang), items };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// src/checks/policy.ts
|
|
974
|
+
var BLACKLIST = [
|
|
975
|
+
/\b(porn|xxx|nude|naked|sex\s*tube)\b/i,
|
|
976
|
+
/\b(gamble|casino|betting|lottery)\b/i,
|
|
977
|
+
/\b(hack|crack|pirate|torrent|warez)\b/i,
|
|
978
|
+
/\b(drug|marijuana|cocaine|heroin)\b/i,
|
|
979
|
+
/色情|赌博|毒品|暴力|盗版/
|
|
980
|
+
];
|
|
981
|
+
function checkPolicyCompliance(pages, lang) {
|
|
982
|
+
const items = [];
|
|
983
|
+
const violations = [];
|
|
984
|
+
for (const page of pages) {
|
|
985
|
+
for (const p of BLACKLIST) {
|
|
986
|
+
const m = page.text.match(p);
|
|
987
|
+
if (m) violations.push({ url: page.url, match: m[0] });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
items.push(
|
|
991
|
+
violations.length > 0 ? { name: t("item.policy.keywords", lang), status: "fail", message: t("policy.keywords.fail", lang, { count: violations.length }), detail: violations.map((v) => `${v.url}: "${v.match}"`).join("; ") } : { name: t("item.policy.keywords", lang), status: "pass", message: t("policy.keywords.pass", lang) }
|
|
992
|
+
);
|
|
993
|
+
return { name: t("cat.policy", lang), items };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// src/ai/analyzer.ts
|
|
997
|
+
function getApiEndpoint2() {
|
|
998
|
+
const base = process.env.AI_API_BASE || "https://api.deepseek.com";
|
|
999
|
+
return `${base.replace(/\/$/, "")}/chat/completions`;
|
|
1000
|
+
}
|
|
1001
|
+
function getApiKey() {
|
|
1002
|
+
return process.env.AI_API_KEY;
|
|
1003
|
+
}
|
|
1004
|
+
function getModel2() {
|
|
1005
|
+
return process.env.AI_MODEL || "deepseek-chat";
|
|
1006
|
+
}
|
|
1007
|
+
async function callAI2(prompt, maxTokens = 4096) {
|
|
1008
|
+
const response = await fetch(getApiEndpoint2(), {
|
|
1009
|
+
method: "POST",
|
|
1010
|
+
headers: {
|
|
1011
|
+
"Content-Type": "application/json",
|
|
1012
|
+
Authorization: `Bearer ${getApiKey()}`
|
|
1013
|
+
},
|
|
1014
|
+
body: JSON.stringify({
|
|
1015
|
+
model: getModel2(),
|
|
1016
|
+
max_tokens: maxTokens,
|
|
1017
|
+
messages: [{ role: "user", content: prompt }]
|
|
1018
|
+
})
|
|
1019
|
+
});
|
|
1020
|
+
if (!response.ok) {
|
|
1021
|
+
throw new Error(`AI API error: ${response.status} ${response.statusText}`);
|
|
1022
|
+
}
|
|
1023
|
+
const data = await response.json();
|
|
1024
|
+
return data.choices?.[0]?.message?.content ?? "";
|
|
1025
|
+
}
|
|
1026
|
+
function extractJson2(text) {
|
|
1027
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
1028
|
+
if (jsonMatch) {
|
|
1029
|
+
return JSON.parse(jsonMatch[0]);
|
|
1030
|
+
}
|
|
1031
|
+
throw new Error("No JSON found in response");
|
|
1032
|
+
}
|
|
1033
|
+
var AI_LANG_NAMES = {
|
|
1034
|
+
en: "English",
|
|
1035
|
+
zh: "\u4E2D\u6587"
|
|
1036
|
+
};
|
|
1037
|
+
function getAiLangName(lang) {
|
|
1038
|
+
return AI_LANG_NAMES[lang] ?? lang;
|
|
1039
|
+
}
|
|
1040
|
+
var PAGE_CHARS = 5e3;
|
|
1041
|
+
var CONCURRENCY = 3;
|
|
1042
|
+
async function analyzePage(page, langName, date, siteTopic) {
|
|
1043
|
+
const content = page.text.slice(0, PAGE_CHARS);
|
|
1044
|
+
const topicContext = siteTopic ? `
|
|
1045
|
+
Site topic: ${siteTopic.topic}
|
|
1046
|
+
Site type: ${siteTopic.type}
|
|
1047
|
+
Site description: ${siteTopic.description}
|
|
1048
|
+
|
|
1049
|
+
Evaluate whether this page is relevant to the site's topic and provides value to users interested in "${siteTopic.topic}".` : "";
|
|
1050
|
+
const prompt = `You are a Google AdSense review expert. Analyze this page for "low value content" issues.
|
|
1051
|
+
Current date: ${date}
|
|
1052
|
+
Reply language: ${langName}
|
|
1053
|
+
${topicContext}
|
|
1054
|
+
|
|
1055
|
+
Low value content signs:
|
|
1056
|
+
- Thin content lacking substantial information
|
|
1057
|
+
- Machine-generated or scraped content
|
|
1058
|
+
- No unique value for users
|
|
1059
|
+
- Padded/repetitive content to fill space
|
|
1060
|
+
- Template-like structure with minimal real content
|
|
1061
|
+
|
|
1062
|
+
Page: ${page.url}
|
|
1063
|
+
|
|
1064
|
+
Content:
|
|
1065
|
+
${content}
|
|
1066
|
+
|
|
1067
|
+
Reply in ${langName} with JSON:
|
|
1068
|
+
{
|
|
1069
|
+
"status": "pass|warn|fail",
|
|
1070
|
+
"relevance": "relevant|tangential|off-topic",
|
|
1071
|
+
"assessment": "Detailed assessment: content depth, originality, user value, specific issues found",
|
|
1072
|
+
"suggestions": ["Specific actionable suggestion to improve this page"]
|
|
1073
|
+
}`;
|
|
1074
|
+
try {
|
|
1075
|
+
const text = await callAI2(prompt, 2048);
|
|
1076
|
+
const result = extractJson2(text);
|
|
1077
|
+
return {
|
|
1078
|
+
url: page.url,
|
|
1079
|
+
status: result.status ?? "warn",
|
|
1080
|
+
relevance: result.relevance,
|
|
1081
|
+
assessment: result.assessment ?? "",
|
|
1082
|
+
suggestions: result.suggestions ?? []
|
|
1083
|
+
};
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
return {
|
|
1086
|
+
url: page.url,
|
|
1087
|
+
status: "warn",
|
|
1088
|
+
assessment: `Analysis failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1089
|
+
suggestions: []
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
async function analyzeOverall(pageAnalyses, langName, date) {
|
|
1094
|
+
const summaries = pageAnalyses.map(
|
|
1095
|
+
(p, i) => `Page ${i + 1} (${p.url}): [${p.status}] ${p.assessment.slice(0, 200)}`
|
|
1096
|
+
).join("\n");
|
|
1097
|
+
const prompt = `You are a Google AdSense review expert. Based on per-page analyses below, give an overall site assessment.
|
|
1098
|
+
Current date: ${date}
|
|
1099
|
+
Reply language: ${langName}
|
|
1100
|
+
|
|
1101
|
+
Per-page results:
|
|
1102
|
+
${summaries}
|
|
1103
|
+
|
|
1104
|
+
Based on these results, provide an overall assessment in ${langName} with JSON:
|
|
1105
|
+
{
|
|
1106
|
+
"contentQuality": { "status": "pass|warn|fail", "detail": "Overall content value assessment considering all pages" },
|
|
1107
|
+
"originality": { "status": "pass|warn|fail", "detail": "Overall originality assessment across the site" },
|
|
1108
|
+
"compliance": { "status": "pass|warn|fail", "detail": "Overall AdSense policy compliance" },
|
|
1109
|
+
"suggestions": ["Top priority site-wide improvement suggestion"]
|
|
1110
|
+
}`;
|
|
1111
|
+
try {
|
|
1112
|
+
const text = await callAI2(prompt, 2048);
|
|
1113
|
+
const result = extractJson2(text);
|
|
1114
|
+
return {
|
|
1115
|
+
contentQuality: result.contentQuality ?? { status: "warn", detail: "Parse error" },
|
|
1116
|
+
originality: result.originality ?? { status: "warn", detail: "Parse error" },
|
|
1117
|
+
compliance: result.compliance ?? { status: "warn", detail: "Parse error" },
|
|
1118
|
+
suggestions: result.suggestions ?? []
|
|
1119
|
+
};
|
|
1120
|
+
} catch {
|
|
1121
|
+
return {
|
|
1122
|
+
contentQuality: { status: "warn", detail: "Overall analysis failed" },
|
|
1123
|
+
originality: { status: "warn", detail: "N/A" },
|
|
1124
|
+
compliance: { status: "warn", detail: "N/A" },
|
|
1125
|
+
suggestions: []
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
async function analyzeWithAI(pages, lang = "en", apiKey, onProgress, siteTopic) {
|
|
1130
|
+
const key = apiKey || getApiKey();
|
|
1131
|
+
const empty = {
|
|
1132
|
+
contentQuality: { status: "skip", detail: t("ai.skip", lang) },
|
|
1133
|
+
originality: { status: "skip", detail: "N/A" },
|
|
1134
|
+
compliance: { status: "skip", detail: "N/A" },
|
|
1135
|
+
suggestions: [],
|
|
1136
|
+
pageAnalyses: []
|
|
1137
|
+
};
|
|
1138
|
+
if (!key) return empty;
|
|
1139
|
+
const langName = getAiLangName(lang);
|
|
1140
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1141
|
+
try {
|
|
1142
|
+
const pageAnalyses = [];
|
|
1143
|
+
const progress = onProgress ?? (() => {
|
|
1144
|
+
});
|
|
1145
|
+
for (let i = 0; i < pages.length; i += CONCURRENCY) {
|
|
1146
|
+
const batch = pages.slice(i, i + CONCURRENCY);
|
|
1147
|
+
const batchNum = Math.floor(i / CONCURRENCY) + 1;
|
|
1148
|
+
const totalBatches = Math.ceil(pages.length / CONCURRENCY);
|
|
1149
|
+
progress(`AI: batch ${batchNum}/${totalBatches} (${batch.map((p) => {
|
|
1150
|
+
try {
|
|
1151
|
+
return new URL(p.url).pathname;
|
|
1152
|
+
} catch {
|
|
1153
|
+
return p.url;
|
|
1154
|
+
}
|
|
1155
|
+
}).join(", ")})`);
|
|
1156
|
+
const results = await Promise.all(
|
|
1157
|
+
batch.map((p) => analyzePage(p, langName, date, siteTopic))
|
|
1158
|
+
);
|
|
1159
|
+
pageAnalyses.push(...results);
|
|
1160
|
+
}
|
|
1161
|
+
progress("AI: generating overall assessment...");
|
|
1162
|
+
const overall = await analyzeOverall(pageAnalyses, langName, date);
|
|
1163
|
+
return {
|
|
1164
|
+
...overall,
|
|
1165
|
+
pageAnalyses
|
|
1166
|
+
};
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
return {
|
|
1169
|
+
...empty,
|
|
1170
|
+
contentQuality: { status: "warn", detail: t("ai.fail", lang, { error: err instanceof Error ? err.message : String(err) }) }
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// src/classifier.ts
|
|
1176
|
+
var REQUIRED_PATTERNS = [/\/about/i, /\/privacy/i, /\/contact/i, /\/terms/i, /\/legal/i];
|
|
1177
|
+
var CONTENT_PREFIXES = ["/blog/", "/news/", "/guides/", "/articles/", "/posts/", "/tutorials/", "/wiki/"];
|
|
1178
|
+
var GAME_PREFIXES = ["/games/", "/game/", "/play/", "/online-games/"];
|
|
1179
|
+
var LISTING_PATHS = ["/blog", "/news", "/guides", "/articles", "/games", "/play", "/categories", "/tags", "/archive"];
|
|
1180
|
+
var UTILITY_PATTERNS = [/\/download/i, /\/search/i, /\/login/i, /\/signup/i, /\/register/i, /\/sitemap/i, /\/404/i];
|
|
1181
|
+
function classifyPage(url) {
|
|
1182
|
+
let pathname;
|
|
1183
|
+
try {
|
|
1184
|
+
pathname = new URL(url).pathname;
|
|
1185
|
+
} catch {
|
|
1186
|
+
return "unknown";
|
|
1187
|
+
}
|
|
1188
|
+
if (pathname === "/" || pathname === "") return "homepage";
|
|
1189
|
+
if (REQUIRED_PATTERNS.some((p) => p.test(pathname))) return "required";
|
|
1190
|
+
if (UTILITY_PATTERNS.some((p) => p.test(pathname))) return "utility";
|
|
1191
|
+
const normalizedPath = pathname.replace(/\/$/, "");
|
|
1192
|
+
for (const prefix of CONTENT_PREFIXES) {
|
|
1193
|
+
if (normalizedPath.startsWith(prefix.replace(/\/$/, "/"))) {
|
|
1194
|
+
const suffix = normalizedPath.slice(prefix.replace(/\/$/, "").length);
|
|
1195
|
+
if (suffix.length > 1 && suffix.includes("/")) return "content";
|
|
1196
|
+
if (suffix.length > 1 && !suffix.includes("/")) return "content";
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
for (const prefix of GAME_PREFIXES) {
|
|
1200
|
+
if (normalizedPath.startsWith(prefix.replace(/\/$/, "/"))) {
|
|
1201
|
+
const suffix = normalizedPath.slice(prefix.replace(/\/$/, "").length);
|
|
1202
|
+
if (suffix.length > 1) return "game_detail";
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (LISTING_PATHS.some((p) => normalizedPath === p || normalizedPath === p.replace(/\/$/, ""))) return "listing";
|
|
1206
|
+
const langPrefix = normalizedPath.match(/^\/[a-z]{2}(\/|$)/);
|
|
1207
|
+
if (langPrefix) {
|
|
1208
|
+
const rest = normalizedPath.slice(3);
|
|
1209
|
+
if (!rest) return "listing";
|
|
1210
|
+
for (const prefix of CONTENT_PREFIXES) {
|
|
1211
|
+
if (rest.startsWith(prefix.replace(/\/$/, "/"))) {
|
|
1212
|
+
const suffix = rest.slice(prefix.replace(/\/$/, "").length);
|
|
1213
|
+
if (suffix.length > 1) return "content";
|
|
1214
|
+
return "listing";
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
for (const prefix of GAME_PREFIXES) {
|
|
1218
|
+
if (rest.startsWith(prefix.replace(/\/$/, "/"))) {
|
|
1219
|
+
const suffix = rest.slice(prefix.replace(/\/$/, "").length);
|
|
1220
|
+
if (suffix.length > 1) return "game_detail";
|
|
1221
|
+
return "listing";
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (REQUIRED_PATTERNS.some((p) => p.test(rest))) return "required";
|
|
1225
|
+
}
|
|
1226
|
+
return "unknown";
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/scorer.ts
|
|
1230
|
+
function scoreFromChecks(checks) {
|
|
1231
|
+
if (checks.length === 0) return 100;
|
|
1232
|
+
let totalWeight = 0;
|
|
1233
|
+
let earnedWeight = 0;
|
|
1234
|
+
for (const c of checks) {
|
|
1235
|
+
totalWeight += c.weight;
|
|
1236
|
+
if (c.status === "pass") earnedWeight += c.weight;
|
|
1237
|
+
else if (c.status === "warn") earnedWeight += c.weight * 0.4;
|
|
1238
|
+
}
|
|
1239
|
+
return totalWeight > 0 ? Math.round(earnedWeight / totalWeight * 100) : 100;
|
|
1240
|
+
}
|
|
1241
|
+
function scorePage(pageType, contentChars, contentRatio, issues, siteType, aiStatus) {
|
|
1242
|
+
const checks = [];
|
|
1243
|
+
if (pageType === "homepage") {
|
|
1244
|
+
checks.push({ label: "Content depth", status: contentChars >= 500 ? "pass" : contentChars >= 200 ? "warn" : "fail", weight: 3 });
|
|
1245
|
+
checks.push({ label: "Content ratio", status: contentRatio >= 40 ? "pass" : contentRatio >= 20 ? "warn" : "fail", weight: 2 });
|
|
1246
|
+
} else if (pageType === "content") {
|
|
1247
|
+
checks.push({ label: "Content depth", status: contentChars >= 500 ? "pass" : contentChars >= 300 ? "warn" : "fail", weight: 4 });
|
|
1248
|
+
checks.push({ label: "Content ratio", status: contentRatio >= 40 ? "pass" : contentRatio >= 20 ? "warn" : "fail", weight: 2 });
|
|
1249
|
+
if (issues.length > 0) checks.push({ label: "Issues", status: "warn", weight: 1 });
|
|
1250
|
+
} else if (pageType === "game_detail") {
|
|
1251
|
+
if (siteType === "game") {
|
|
1252
|
+
checks.push({ label: "Game description", status: contentChars >= 100 ? "pass" : "warn", weight: 3 });
|
|
1253
|
+
} else {
|
|
1254
|
+
checks.push({ label: "Content depth", status: contentChars >= 300 ? "pass" : contentChars >= 100 ? "warn" : "fail", weight: 3 });
|
|
1255
|
+
}
|
|
1256
|
+
checks.push({ label: "Content ratio", status: contentRatio >= 30 ? "pass" : contentRatio >= 15 ? "warn" : "fail", weight: 2 });
|
|
1257
|
+
} else if (pageType === "required") {
|
|
1258
|
+
checks.push({ label: "Exists", status: contentChars > 0 ? "pass" : "fail", weight: 3 });
|
|
1259
|
+
checks.push({ label: "Content depth", status: contentChars >= 300 ? "pass" : contentChars >= 100 ? "warn" : "fail", weight: 2 });
|
|
1260
|
+
} else if (pageType === "listing") {
|
|
1261
|
+
checks.push({ label: "Content", status: contentChars >= 200 ? "pass" : contentChars >= 50 ? "warn" : "fail", weight: 2 });
|
|
1262
|
+
} else if (pageType === "utility") {
|
|
1263
|
+
checks.push({ label: "Functional", status: contentChars > 0 ? "pass" : "warn", weight: 1 });
|
|
1264
|
+
} else {
|
|
1265
|
+
checks.push({ label: "Content", status: contentChars >= 300 ? "pass" : contentChars >= 100 ? "warn" : "fail", weight: 2 });
|
|
1266
|
+
}
|
|
1267
|
+
let score = scoreFromChecks(checks);
|
|
1268
|
+
if (aiStatus === "fail") score = 0;
|
|
1269
|
+
else if (aiStatus === "warn") score = Math.min(score, 70);
|
|
1270
|
+
return { score, checks };
|
|
1271
|
+
}
|
|
1272
|
+
function statusToScore(status) {
|
|
1273
|
+
if (status === "pass") return 100;
|
|
1274
|
+
if (status === "warn") return 40;
|
|
1275
|
+
if (status === "skip") return 0;
|
|
1276
|
+
return 0;
|
|
1277
|
+
}
|
|
1278
|
+
function scoreCategory(category) {
|
|
1279
|
+
if (category.items.length === 0) return { name: category.name, score: 0, maxScore: 0 };
|
|
1280
|
+
const total = category.items.length * 100;
|
|
1281
|
+
const earned = category.items.reduce((sum, item) => sum + statusToScore(item.status), 0);
|
|
1282
|
+
return { name: category.name, score: earned, maxScore: total };
|
|
1283
|
+
}
|
|
1284
|
+
var SOFT_WEIGHTS = {
|
|
1285
|
+
pageQuality: 0.25,
|
|
1286
|
+
// per-page scores (AI-adjusted)
|
|
1287
|
+
aiAnalysis: 0.35,
|
|
1288
|
+
// AI content analysis
|
|
1289
|
+
contentQuality: 0.2,
|
|
1290
|
+
// mechanical content checks
|
|
1291
|
+
userExperience: 0.2
|
|
1292
|
+
// font, popup checks
|
|
1293
|
+
};
|
|
1294
|
+
var HARD_COMPOSITE_WEIGHT = 0.4;
|
|
1295
|
+
var SOFT_COMPOSITE_WEIGHT = 0.6;
|
|
1296
|
+
function computeCompositeScore(pageScores, hardCategories, softCategories) {
|
|
1297
|
+
const hardItems = hardCategories.flatMap((c) => c.items);
|
|
1298
|
+
const hardPass = hardItems.filter((i) => i.status === "pass").length;
|
|
1299
|
+
const hardFail = hardItems.filter((i) => i.status === "fail").length;
|
|
1300
|
+
const hardWarn = hardItems.filter((i) => i.status === "warn").length;
|
|
1301
|
+
const hardTotal = hardItems.length;
|
|
1302
|
+
const hardPassRate = hardTotal > 0 ? hardPass / hardTotal * 100 : 100;
|
|
1303
|
+
let hardStatus = "ready";
|
|
1304
|
+
if (hardFail > 0) hardStatus = "fail";
|
|
1305
|
+
else if (hardWarn > 0) hardStatus = "warn";
|
|
1306
|
+
const allPages = pageScores.length;
|
|
1307
|
+
const ai = softCategories.find((c) => c.name.includes("AI") || c.name.includes("ai"));
|
|
1308
|
+
let pageQuality;
|
|
1309
|
+
if (allPages > 0) {
|
|
1310
|
+
const avgPageScore = pageScores.reduce((s, p) => s + p.score, 0) / allPages;
|
|
1311
|
+
pageQuality = avgPageScore;
|
|
1312
|
+
} else {
|
|
1313
|
+
pageQuality = 100;
|
|
1314
|
+
}
|
|
1315
|
+
let aiScore = 100;
|
|
1316
|
+
if (ai && ai.items.length > 0) {
|
|
1317
|
+
aiScore = ai.items.reduce((sum, item) => sum + statusToScore(item.status), 0) / ai.items.length;
|
|
1318
|
+
}
|
|
1319
|
+
const contentCats = softCategories.filter((c) => c !== ai);
|
|
1320
|
+
let contentScore = 100;
|
|
1321
|
+
if (contentCats.length > 0) {
|
|
1322
|
+
let totalEarned = 0;
|
|
1323
|
+
let totalItems = 0;
|
|
1324
|
+
for (const cat of contentCats) {
|
|
1325
|
+
for (const item of cat.items) {
|
|
1326
|
+
totalEarned += statusToScore(item.status);
|
|
1327
|
+
totalItems++;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
contentScore = totalItems > 0 ? totalEarned / totalItems : 100;
|
|
1331
|
+
}
|
|
1332
|
+
let uxScore = 100;
|
|
1333
|
+
const softScore = Math.round(
|
|
1334
|
+
pageQuality * SOFT_WEIGHTS.pageQuality + aiScore * SOFT_WEIGHTS.aiAnalysis + contentScore * SOFT_WEIGHTS.contentQuality + uxScore * SOFT_WEIGHTS.userExperience
|
|
1335
|
+
);
|
|
1336
|
+
const allItems = [...hardItems, ...softCategories.flatMap((c) => c.items)];
|
|
1337
|
+
const totalWarn = allItems.filter((i) => i.status === "warn").length;
|
|
1338
|
+
const totalAll = allItems.length;
|
|
1339
|
+
const warningRatio = totalAll > 0 ? totalWarn / totalAll : 0;
|
|
1340
|
+
const warningPenalty = warningRatio > 0.15 ? Math.round((warningRatio - 0.15) * 100) : 0;
|
|
1341
|
+
const base = hardPassRate * HARD_COMPOSITE_WEIGHT + softScore * SOFT_COMPOSITE_WEIGHT;
|
|
1342
|
+
const compositeScore = Math.min(100, Math.max(0, Math.round(base - warningPenalty)));
|
|
1343
|
+
const categoryScores = [];
|
|
1344
|
+
for (const cat of [...hardCategories, ...softCategories]) {
|
|
1345
|
+
categoryScores.push(scoreCategory(cat));
|
|
1346
|
+
}
|
|
1347
|
+
return { compositeScore, categoryScores, hardStatus, softScore, warningRatio, warningPenalty };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// src/checker.ts
|
|
1351
|
+
function extractMainContent2(text, allPageTexts) {
|
|
1352
|
+
const paragraphs = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
|
|
1353
|
+
if (allPageTexts.length <= 1) return paragraphs.join("\n\n");
|
|
1354
|
+
const otherTexts = allPageTexts.filter((t2) => t2 !== text);
|
|
1355
|
+
const threshold = Math.ceil(otherTexts.length * 0.6);
|
|
1356
|
+
return paragraphs.filter((para) => {
|
|
1357
|
+
if (para.length < 20) return true;
|
|
1358
|
+
const normalized = para.replace(/\s+/g, " ").slice(0, 100);
|
|
1359
|
+
const count = otherTexts.filter((o) => o.replace(/\s+/g, " ").includes(normalized)).length;
|
|
1360
|
+
return count < threshold;
|
|
1361
|
+
}).join("\n\n");
|
|
1362
|
+
}
|
|
1363
|
+
function buildPageDetails(pages, aiAnalyses, siteType) {
|
|
1364
|
+
const allTexts = pages.map((p) => p.text);
|
|
1365
|
+
const aiMap = new Map(aiAnalyses.map((a) => [a.url, a]));
|
|
1366
|
+
return pages.map((page) => {
|
|
1367
|
+
const totalChars = page.text.replace(/\s+/g, "").length;
|
|
1368
|
+
const mainContent = extractMainContent2(page.text, allTexts);
|
|
1369
|
+
const contentChars = mainContent.replace(/\s+/g, "").length;
|
|
1370
|
+
const contentRatio = totalChars > 0 ? Math.round(contentChars / totalChars * 100) : 0;
|
|
1371
|
+
const issues = [];
|
|
1372
|
+
let contentStatus = "pass";
|
|
1373
|
+
if (siteType === "content") {
|
|
1374
|
+
if (contentRatio < 30 && totalChars > 200) {
|
|
1375
|
+
issues.push(`Content ratio only ${contentRatio}%, mostly boilerplate`);
|
|
1376
|
+
contentStatus = "fail";
|
|
1377
|
+
}
|
|
1378
|
+
if (contentChars < 300) {
|
|
1379
|
+
issues.push(`Thin content (${contentChars} chars)`);
|
|
1380
|
+
contentStatus = contentStatus === "fail" ? "fail" : "warn";
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
const pageType = classifyPage(page.url);
|
|
1384
|
+
const ai = aiMap.get(page.url);
|
|
1385
|
+
const aiStatus = ai?.status;
|
|
1386
|
+
const relevance = ai?.relevance;
|
|
1387
|
+
const { score } = scorePage(pageType, contentChars, contentRatio, issues, siteType, aiStatus);
|
|
1388
|
+
const detail = { url: page.url, title: page.title, pageType, totalChars, contentChars, contentRatio, contentStatus, issues, score };
|
|
1389
|
+
if (relevance) detail.relevance = relevance;
|
|
1390
|
+
if (ai) detail.ai = { status: ai.status, assessment: ai.assessment, suggestions: ai.suggestions };
|
|
1391
|
+
return detail;
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
function discoverChildLinks(pageUrl, pageLinks, origin, maxPerListing) {
|
|
1395
|
+
const pagePath = (() => {
|
|
1396
|
+
try {
|
|
1397
|
+
return new URL(pageUrl).pathname;
|
|
1398
|
+
} catch {
|
|
1399
|
+
return "";
|
|
1400
|
+
}
|
|
1401
|
+
})();
|
|
1402
|
+
if (!pagePath || pagePath === "/") return [];
|
|
1403
|
+
const childLinks = pageLinks.filter((link) => {
|
|
1404
|
+
try {
|
|
1405
|
+
const linkUrl = new URL(link);
|
|
1406
|
+
if (linkUrl.origin !== origin) return false;
|
|
1407
|
+
if (!isContentUrl(link)) return false;
|
|
1408
|
+
const linkPath = linkUrl.pathname;
|
|
1409
|
+
if (!linkPath.startsWith(pagePath)) return false;
|
|
1410
|
+
if (linkPath === pagePath || linkPath === pagePath.replace(/\/$/, "") + "/") return false;
|
|
1411
|
+
const suffix = linkPath.slice(pagePath.replace(/\/$/, "").length).replace(/^\//, "");
|
|
1412
|
+
return suffix.includes("/") || suffix.length > 0;
|
|
1413
|
+
} catch {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
return childLinks.slice(0, maxPerListing);
|
|
1418
|
+
}
|
|
1419
|
+
function freshnessScore(url) {
|
|
1420
|
+
try {
|
|
1421
|
+
const path = new URL(url).pathname;
|
|
1422
|
+
const m = path.match(/\/(20[12]\d)(?:[\/\-](0?[1-9]|1[0-2])(?:[\/\-](0?[1-9]|[12]\d|3[01]))?)?/);
|
|
1423
|
+
if (m) {
|
|
1424
|
+
const year = parseInt(m[1]);
|
|
1425
|
+
const month = m[2] ? parseInt(m[2]) : 6;
|
|
1426
|
+
const day = m[3] ? parseInt(m[3]) : 15;
|
|
1427
|
+
return new Date(year, month - 1, day).getTime();
|
|
1428
|
+
}
|
|
1429
|
+
} catch {
|
|
1430
|
+
}
|
|
1431
|
+
return 0;
|
|
1432
|
+
}
|
|
1433
|
+
function sortByFreshness(urls) {
|
|
1434
|
+
const scored = urls.map((u, i) => ({ url: u, score: freshnessScore(u), index: i }));
|
|
1435
|
+
scored.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
1436
|
+
return scored.map((s) => s.url);
|
|
1437
|
+
}
|
|
1438
|
+
async function check(options) {
|
|
1439
|
+
const { url, maxCrawl = 50, maxPages = 50, maxContent = 20, sampleMin = 20, sampleRatio = 0.2, skipAi = false, timeout = 3e4, apiKey, lang = "en", siteType: manualType, onProgress } = options;
|
|
1440
|
+
const phase1Limit = Math.min(maxPages, maxCrawl);
|
|
1441
|
+
const origin = new URL(url).origin;
|
|
1442
|
+
const browser = new BrowserManager();
|
|
1443
|
+
const progress = onProgress ?? (() => {
|
|
1444
|
+
});
|
|
1445
|
+
try {
|
|
1446
|
+
progress("Launching browser...");
|
|
1447
|
+
const homepage = await browser.newPage();
|
|
1448
|
+
progress(`Fetching ${url}...`);
|
|
1449
|
+
const homeData = await fetchPage(homepage, url, timeout);
|
|
1450
|
+
const h1Count = await homepage.evaluate(() => document.querySelectorAll("h1").length);
|
|
1451
|
+
progress("Fetching sitemap...");
|
|
1452
|
+
const sitemapUrls = await fetchSitemapUrls(origin);
|
|
1453
|
+
let siteTopic;
|
|
1454
|
+
if (!skipAi) {
|
|
1455
|
+
try {
|
|
1456
|
+
const apiKeyResolved = apiKey || process.env.AI_API_KEY;
|
|
1457
|
+
if (apiKeyResolved) {
|
|
1458
|
+
progress("AI: analyzing site topic...");
|
|
1459
|
+
siteTopic = await analyzeSiteTopic(
|
|
1460
|
+
{ title: homeData.title, text: homeData.text, navText: homeData.navText + " " + homeData.footerText },
|
|
1461
|
+
lang,
|
|
1462
|
+
apiKeyResolved
|
|
1463
|
+
);
|
|
1464
|
+
progress(`AI: site type = ${siteTopic.type}, topic = ${siteTopic.topic}`);
|
|
1465
|
+
}
|
|
1466
|
+
} catch {
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
const pages = [
|
|
1470
|
+
{ url: homeData.url, text: homeData.text, title: homeData.title, links: homeData.links }
|
|
1471
|
+
];
|
|
1472
|
+
const allSignals = [homeData.signals];
|
|
1473
|
+
const internalLinks = homeData.links.filter((l) => {
|
|
1474
|
+
try {
|
|
1475
|
+
return new URL(l).origin === origin && isContentUrl(l);
|
|
1476
|
+
} catch {
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
const sitemapInternal = sitemapUrls.filter((u) => {
|
|
1481
|
+
try {
|
|
1482
|
+
return new URL(u).origin === origin && isContentUrl(u);
|
|
1483
|
+
} catch {
|
|
1484
|
+
return false;
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
const allInternal = [.../* @__PURE__ */ new Set([...internalLinks, ...sitemapInternal])];
|
|
1488
|
+
const uniqueLinks = allInternal.slice(0, phase1Limit);
|
|
1489
|
+
const deadLinks = [];
|
|
1490
|
+
const crawledUrls = /* @__PURE__ */ new Set([url.replace(/\/+$/, "")]);
|
|
1491
|
+
async function crawlPage(link) {
|
|
1492
|
+
const norm = link.replace(/\/+$/, "");
|
|
1493
|
+
if (crawledUrls.has(norm)) return;
|
|
1494
|
+
crawledUrls.add(norm);
|
|
1495
|
+
try {
|
|
1496
|
+
const pg = await browser.newPage();
|
|
1497
|
+
const data = await fetchPage(pg, link, timeout);
|
|
1498
|
+
if (data.status >= 400) {
|
|
1499
|
+
deadLinks.push(`${link} (${data.status})`);
|
|
1500
|
+
} else {
|
|
1501
|
+
pages.push({ url: link, text: data.text, title: data.title, links: data.links });
|
|
1502
|
+
allSignals.push(data.signals);
|
|
1503
|
+
}
|
|
1504
|
+
await pg.close();
|
|
1505
|
+
} catch {
|
|
1506
|
+
deadLinks.push(`${link} (timeout)`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
progress(`Phase 1: Crawling ${uniqueLinks.length} pages...`);
|
|
1510
|
+
for (let i = 0; i < uniqueLinks.length; i++) {
|
|
1511
|
+
const link = uniqueLinks[i];
|
|
1512
|
+
progress(`Phase 1: [${i + 1}/${uniqueLinks.length}] ${new URL(link).pathname}`);
|
|
1513
|
+
await crawlPage(link);
|
|
1514
|
+
}
|
|
1515
|
+
const CHILDREN_PER_LISTING = 10;
|
|
1516
|
+
const MAX_DISCOVERY_DEPTH = 3;
|
|
1517
|
+
const discoveredContent = /* @__PURE__ */ new Set();
|
|
1518
|
+
const discoveryQueue = pages.map((p) => ({ url: p.url, links: p.links, depth: 0 }));
|
|
1519
|
+
const seenInDiscovery = new Set([...crawledUrls].map((u) => u.replace(/\/+$/, "")));
|
|
1520
|
+
while (discoveryQueue.length > 0) {
|
|
1521
|
+
const current = discoveryQueue.shift();
|
|
1522
|
+
if (current.depth > MAX_DISCOVERY_DEPTH) continue;
|
|
1523
|
+
const children = discoverChildLinks(current.url, current.links, origin, CHILDREN_PER_LISTING);
|
|
1524
|
+
for (const child of children) {
|
|
1525
|
+
const norm = child.replace(/\/+$/, "");
|
|
1526
|
+
if (seenInDiscovery.has(norm)) continue;
|
|
1527
|
+
seenInDiscovery.add(norm);
|
|
1528
|
+
const pt = classifyPage(child);
|
|
1529
|
+
if (pt === "listing" || pt === "unknown") {
|
|
1530
|
+
if (current.depth < MAX_DISCOVERY_DEPTH) {
|
|
1531
|
+
}
|
|
1532
|
+
} else {
|
|
1533
|
+
discoveredContent.add(child);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
for (const smUrl of sitemapInternal) {
|
|
1538
|
+
const norm = smUrl.replace(/\/+$/, "");
|
|
1539
|
+
if (!crawledUrls.has(norm) && !discoveredContent.has(norm)) {
|
|
1540
|
+
const pt = classifyPage(smUrl);
|
|
1541
|
+
if (pt !== "listing" && pt !== "unknown") {
|
|
1542
|
+
discoveredContent.add(smUrl);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const remainingBudget = Math.max(0, maxCrawl - crawledUrls.size);
|
|
1547
|
+
const phase2Limit = Math.min(maxContent, remainingBudget);
|
|
1548
|
+
const sortedContent = sortByFreshness([...discoveredContent]);
|
|
1549
|
+
const toCrawl = sortedContent.slice(0, phase2Limit);
|
|
1550
|
+
if (toCrawl.length > 0) progress(`Phase 2: Crawling ${toCrawl.length} content pages (from ${discoveredContent.size} discovered)...`);
|
|
1551
|
+
for (let i = 0; i < toCrawl.length; i++) {
|
|
1552
|
+
const link = toCrawl[i];
|
|
1553
|
+
progress(`Phase 2: [${i + 1}/${toCrawl.length}] ${new URL(link).pathname}`);
|
|
1554
|
+
await crawlPage(link);
|
|
1555
|
+
const crawledPage = pages[pages.length - 1];
|
|
1556
|
+
if (crawledPage && crawledPage.url === link) {
|
|
1557
|
+
const deeperChildren = discoverChildLinks(link, crawledPage.links, origin, CHILDREN_PER_LISTING);
|
|
1558
|
+
for (const dc of deeperChildren) {
|
|
1559
|
+
const dnorm = dc.replace(/\/+$/, "");
|
|
1560
|
+
if (!crawledUrls.has(dnorm) && !discoveredContent.has(dnorm) && classifyPage(dc) !== "listing" && classifyPage(dc) !== "unknown") {
|
|
1561
|
+
if (i + discoveredContent.size < maxContent * 2) {
|
|
1562
|
+
discoveredContent.add(dc);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1569
|
+
const uniquePages = pages.filter((p) => {
|
|
1570
|
+
const norm = p.url.replace(/\/+$/, "").split("#")[0];
|
|
1571
|
+
if (seen.has(norm)) return false;
|
|
1572
|
+
seen.add(norm);
|
|
1573
|
+
return true;
|
|
1574
|
+
});
|
|
1575
|
+
const totalDiscovered = discoveredContent.size;
|
|
1576
|
+
const sixMonthsAgo = Date.now() - 180 * 24 * 60 * 60 * 1e3;
|
|
1577
|
+
const recentCount = [...discoveredContent].filter((u) => freshnessScore(u) >= sixMonthsAgo).length;
|
|
1578
|
+
const sampledCount = toCrawl.length;
|
|
1579
|
+
const samplePct = totalDiscovered > 0 ? Math.round(sampledCount / totalDiscovered * 100) : 0;
|
|
1580
|
+
const confidence = samplePct >= 50 ? "high" : samplePct >= 20 ? "medium" : "low";
|
|
1581
|
+
progress(`Pages: ${totalDiscovered} discovered, ${recentCount} recent (6mo), ${sampledCount} sampled (${samplePct}%, confidence: ${confidence})`);
|
|
1582
|
+
progress("Detecting site type...");
|
|
1583
|
+
const domResult = detectSiteType(allSignals, homeData.navText + " " + homeData.footerText, manualType);
|
|
1584
|
+
let siteType;
|
|
1585
|
+
let siteTypeConfidence;
|
|
1586
|
+
if (siteTopic && siteTopic.type !== "unsupported") {
|
|
1587
|
+
siteType = siteTopic.type;
|
|
1588
|
+
siteTypeConfidence = siteTopic.confidence;
|
|
1589
|
+
} else if (siteTopic?.type === "unsupported") {
|
|
1590
|
+
siteType = "unsupported";
|
|
1591
|
+
siteTypeConfidence = siteTopic.confidence;
|
|
1592
|
+
} else {
|
|
1593
|
+
siteType = domResult.type;
|
|
1594
|
+
siteTypeConfidence = domResult.confidence;
|
|
1595
|
+
}
|
|
1596
|
+
progress("Running checks...");
|
|
1597
|
+
const allCategories = [];
|
|
1598
|
+
const contentCat = checkContentQuality(uniquePages, allInternal.length, lang, siteType, allSignals);
|
|
1599
|
+
const scaleItem = contentCat.items.find((i) => i.name === t("item.content.scale", lang));
|
|
1600
|
+
const contentItems = scaleItem ? contentCat.items.filter((i) => i !== scaleItem) : contentCat.items;
|
|
1601
|
+
allCategories.push({ name: contentCat.name, items: contentItems, group: "soft" });
|
|
1602
|
+
if (scaleItem) {
|
|
1603
|
+
allCategories.push({ name: t("group.site_scale", lang), items: [scaleItem], group: "hard" });
|
|
1604
|
+
}
|
|
1605
|
+
const pagesCat = await checkRequiredPages({ allLinks: homeData.linkDetails, navText: homeData.navText, footerText: homeData.footerText, sitemapUrls }, lang);
|
|
1606
|
+
allCategories.push({ ...pagesCat, group: "hard" });
|
|
1607
|
+
const structCat = await checkSiteStructure(origin, homeData.links, h1Count, deadLinks, lang);
|
|
1608
|
+
allCategories.push({ ...structCat, group: "hard" });
|
|
1609
|
+
const playBrowser = await browser.launch();
|
|
1610
|
+
const perfPage = await browser.newPage();
|
|
1611
|
+
const perfCat = await checkPerformance(perfPage, url, playBrowser, lang);
|
|
1612
|
+
await perfPage.close();
|
|
1613
|
+
const hardPerfNames = [t("item.perf.speed", lang), "Viewport", t("item.perf.overflow", lang)];
|
|
1614
|
+
const hardPerfItems = perfCat.items.filter((i) => hardPerfNames.includes(i.name));
|
|
1615
|
+
const softPerfItems = perfCat.items.filter((i) => !hardPerfNames.includes(i.name));
|
|
1616
|
+
if (hardPerfItems.length > 0) allCategories.push({ name: t("group.performance_min", lang), items: hardPerfItems, group: "hard" });
|
|
1617
|
+
if (softPerfItems.length > 0) allCategories.push({ name: t("group.user_experience", lang), items: softPerfItems, group: "soft" });
|
|
1618
|
+
const policyCat = checkPolicyCompliance(uniquePages, lang);
|
|
1619
|
+
allCategories.push({ ...policyCat, group: "hard" });
|
|
1620
|
+
let pageAnalyses = [];
|
|
1621
|
+
if (!skipAi) {
|
|
1622
|
+
try {
|
|
1623
|
+
progress(`AI analysis: ${uniquePages.length} pages...`);
|
|
1624
|
+
const aiResult = await analyzeWithAI(uniquePages, lang, apiKey, progress, siteTopic);
|
|
1625
|
+
pageAnalyses = aiResult.pageAnalyses;
|
|
1626
|
+
const aiItems = [
|
|
1627
|
+
{ name: t("item.ai.quality", lang), status: aiResult.contentQuality.status, message: aiResult.contentQuality.detail.slice(0, 200) },
|
|
1628
|
+
{ name: t("item.ai.originality", lang), status: aiResult.originality.status, message: aiResult.originality.detail.slice(0, 200) },
|
|
1629
|
+
{ name: t("item.ai.compliance", lang), status: aiResult.compliance.status, message: aiResult.compliance.detail.slice(0, 200) }
|
|
1630
|
+
];
|
|
1631
|
+
if (aiResult.suggestions.length > 0) {
|
|
1632
|
+
aiItems.push({ name: t("item.ai.suggestions", lang), status: "warn", message: t("ai.suggestion_count", lang, { count: aiResult.suggestions.length }), detail: aiResult.suggestions.join("; ") });
|
|
1633
|
+
}
|
|
1634
|
+
allCategories.push({ name: t("group.ai_analysis", lang), items: aiItems, group: "soft" });
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
allCategories.push({ name: t("group.ai_analysis", lang), items: [{ name: "AI", status: "skip", message: t("ai.fail", lang, { error: err instanceof Error ? err.message : String(err) }) }], group: "soft" });
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
const pageDetails = buildPageDetails(uniquePages, pageAnalyses, siteType);
|
|
1640
|
+
if (pageAnalyses.length > 0) {
|
|
1641
|
+
const withRelevance = pageAnalyses.filter((a) => a.relevance);
|
|
1642
|
+
if (withRelevance.length > 0) {
|
|
1643
|
+
const offTopic = withRelevance.filter((a) => a.relevance === "off-topic").length;
|
|
1644
|
+
const tangential = withRelevance.filter((a) => a.relevance === "tangential").length;
|
|
1645
|
+
const offTopicRatio = offTopic / withRelevance.length;
|
|
1646
|
+
const relevanceStatus = offTopicRatio > 0.3 ? "fail" : offTopicRatio > 0.1 ? "warn" : "pass";
|
|
1647
|
+
const msg = offTopic > 0 || tangential > 0 ? `${offTopic} off-topic, ${tangential} tangential out of ${withRelevance.length} pages` : `All ${withRelevance.length} pages relevant to site topic`;
|
|
1648
|
+
allCategories.push({
|
|
1649
|
+
name: t("group.content_relevance", lang),
|
|
1650
|
+
items: [{ name: t("item.relevance.topic", lang), status: relevanceStatus, message: msg }],
|
|
1651
|
+
group: "soft"
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
const hardCategories = allCategories.filter((c) => c.group === "hard");
|
|
1656
|
+
const softCategories = allCategories.filter((c) => c.group === "soft");
|
|
1657
|
+
const allItems = allCategories.flatMap((c) => c.items);
|
|
1658
|
+
const pageScoresForComposite = pageDetails.map((p) => ({ pageType: p.pageType, score: p.score }));
|
|
1659
|
+
const { compositeScore, categoryScores, hardStatus, softScore, warningRatio, warningPenalty } = computeCompositeScore(pageScoresForComposite, hardCategories, softCategories);
|
|
1660
|
+
return {
|
|
1661
|
+
url,
|
|
1662
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1663
|
+
lang,
|
|
1664
|
+
siteType,
|
|
1665
|
+
siteTypeConfidence,
|
|
1666
|
+
siteTopic,
|
|
1667
|
+
samplingInfo: { totalDiscovered, recentCount, sampledCount, samplePct, confidence },
|
|
1668
|
+
categories: allCategories,
|
|
1669
|
+
hardCategories,
|
|
1670
|
+
softCategories,
|
|
1671
|
+
score: allItems.filter((i) => i.status === "pass").length,
|
|
1672
|
+
totalChecks: allItems.length,
|
|
1673
|
+
passed: allItems.filter((i) => i.status === "pass").length,
|
|
1674
|
+
warned: allItems.filter((i) => i.status === "warn").length,
|
|
1675
|
+
failed: allItems.filter((i) => i.status === "fail").length,
|
|
1676
|
+
skipped: allItems.filter((i) => i.status === "skip").length,
|
|
1677
|
+
pages: pageDetails,
|
|
1678
|
+
compositeScore,
|
|
1679
|
+
categoryScores,
|
|
1680
|
+
hardStatus,
|
|
1681
|
+
softScore,
|
|
1682
|
+
warningRatio,
|
|
1683
|
+
warningPenalty
|
|
1684
|
+
};
|
|
1685
|
+
} finally {
|
|
1686
|
+
await browser.close();
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
export {
|
|
1691
|
+
BrowserManager,
|
|
1692
|
+
fetchPage,
|
|
1693
|
+
getSupportedLangs,
|
|
1694
|
+
isValidLang,
|
|
1695
|
+
t,
|
|
1696
|
+
analyzeSiteTopic,
|
|
1697
|
+
detectSiteType,
|
|
1698
|
+
check
|
|
1699
|
+
};
|