seo 0.15.1 → 0.16.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/dist/cli.js +83 -83
- package/dist/cli.js.map +1 -1
- package/dist/{dist-BnpsvtXl.js → dist-Bv1JGvEz.js} +1 -1
- package/dist/{dist-DoPZlCJq.js → dist-CHIJxr2Y.js} +140 -136
- package/dist/dist-CHIJxr2Y.js.map +1 -0
- package/dist/{esm-D12ZCG_1.js → esm-CV5s7esU.js} +2 -2
- package/dist/{esm-D12ZCG_1.js.map → esm-CV5s7esU.js.map} +1 -1
- package/dist/index.d.ts +55 -7
- package/dist/index.js +969 -236
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +970 -237
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
- package/skills/seo/SKILL.md +10 -1
- package/dist/dist-DoPZlCJq.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { n as version, t as load } from "./esm-
|
|
2
|
+
import { n as version, t as load } from "./esm-CV5s7esU.js";
|
|
3
3
|
import { SEO_PROVIDER_API_VERSION } from "./provider-sdk.js";
|
|
4
4
|
import crypto, { createHash, randomUUID, sign } from "node:crypto";
|
|
5
5
|
import { z } from "zod";
|
|
@@ -343,6 +343,24 @@ const RULE_DEFINITIONS = [
|
|
|
343
343
|
suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
|
|
344
344
|
}
|
|
345
345
|
},
|
|
346
|
+
{
|
|
347
|
+
id: "crawl_single_page_reach",
|
|
348
|
+
title: "Crawl could reach only the start page",
|
|
349
|
+
category: "links",
|
|
350
|
+
defaultSeverity: "high",
|
|
351
|
+
whyItMatters: "The start page had no crawlable internal links and no sitemap returned URLs, so the crawl checked one page. Every other finding in this report covers that page only. The page probably builds its links with JavaScript.",
|
|
352
|
+
howToFix: "Rerun with `--rendering on` so the crawler reads the links a browser sees, or pass the pages to check with `--mode list`. For crawlers that do not run JavaScript, put plain `<a href>` links in the initial HTML and publish a sitemap.",
|
|
353
|
+
impactIfIgnored: "Crawlers and audits that do not run JavaScript can find only this page, so the rest of the site stays unchecked and harder to discover.",
|
|
354
|
+
howToVerify: "Rerun the crawl and confirm it retains more than one page and the start page reports internal links.",
|
|
355
|
+
agentHints: {
|
|
356
|
+
evidenceFields: [
|
|
357
|
+
"page.outgoingInternalCount",
|
|
358
|
+
"report.sitemapDiscovery.urlsReturned",
|
|
359
|
+
"page.fetchDiagnostics.rendering"
|
|
360
|
+
],
|
|
361
|
+
suggestedCommands: ["seo crawl <url> --rendering on --json", "seo crawl --mode list --urls <url>,<url> --json"]
|
|
362
|
+
}
|
|
363
|
+
},
|
|
346
364
|
{
|
|
347
365
|
id: "soft_404",
|
|
348
366
|
title: "Unknown URLs return a successful response",
|
|
@@ -351,7 +369,7 @@ const RULE_DEFINITIONS = [
|
|
|
351
369
|
whyItMatters: "Known-nonexistent URLs returned a successful response or redirected to another successful page. Search engines and users cannot reliably distinguish missing content from a real page.",
|
|
352
370
|
howToFix: "Return 404 or 410 for missing URLs. If a replacement exists, redirect only the specific retired URL to its closest relevant replacement rather than sending every unknown path to a generic page.",
|
|
353
371
|
impactIfIgnored: "Missing URLs can remain in crawl queues, waste crawl attention, and create confusing search or monitoring evidence.",
|
|
354
|
-
howToVerify: "Request several
|
|
372
|
+
howToVerify: "Request several made-up paths, including one at the site root and one nested path, and confirm each returns 404 or 410 without redirecting to a generic successful page.",
|
|
355
373
|
agentHints: {
|
|
356
374
|
evidenceFields: ["report.siteChecks.soft404"],
|
|
357
375
|
suggestedCommands: ["seo crawl <url> --json"]
|
|
@@ -1803,7 +1821,7 @@ function day$3(value) {
|
|
|
1803
1821
|
const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
|
|
1804
1822
|
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
|
1805
1823
|
}
|
|
1806
|
-
const count$
|
|
1824
|
+
const count$4 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
1807
1825
|
function list$2(data, label) {
|
|
1808
1826
|
if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
|
|
1809
1827
|
return data.filter((item) => Boolean(item) && typeof item === "object");
|
|
@@ -1819,8 +1837,8 @@ async function bingTraffic(call, site) {
|
|
|
1819
1837
|
let invalidRows = 0;
|
|
1820
1838
|
for (const row of list$2(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
|
|
1821
1839
|
const date = day$3(row.Date);
|
|
1822
|
-
const clicks = count$
|
|
1823
|
-
const impressions = count$
|
|
1840
|
+
const clicks = count$4(row.Clicks);
|
|
1841
|
+
const impressions = count$4(row.Impressions);
|
|
1824
1842
|
if (!date || clicks === null || impressions === null) {
|
|
1825
1843
|
invalidRows += 1;
|
|
1826
1844
|
continue;
|
|
@@ -1843,9 +1861,9 @@ async function keyed(call, method, params) {
|
|
|
1843
1861
|
for (const row of list$2(await call(method, params), "row list")) {
|
|
1844
1862
|
const date = day$3(row.Date);
|
|
1845
1863
|
const key = typeof row.Query === "string" ? row.Query.trim() : "";
|
|
1846
|
-
const clicks = count$
|
|
1847
|
-
const impressions = count$
|
|
1848
|
-
const position = count$
|
|
1864
|
+
const clicks = count$4(row.Clicks);
|
|
1865
|
+
const impressions = count$4(row.Impressions);
|
|
1866
|
+
const position = count$4(row.AvgImpressionPosition);
|
|
1849
1867
|
if (!date || !key || clicks === null || impressions === null) {
|
|
1850
1868
|
invalidRows += 1;
|
|
1851
1869
|
continue;
|
|
@@ -2377,6 +2395,14 @@ function sharedReader(read, redirects = /* @__PURE__ */ new Map()) {
|
|
|
2377
2395
|
};
|
|
2378
2396
|
}
|
|
2379
2397
|
const MAX_STATUS_BYTES = 10485760;
|
|
2398
|
+
async function rankingUpdates() {
|
|
2399
|
+
const incidents = await fetch("https://status.search.google.com/incidents.json", { signal: AbortSignal.timeout(8e3) }).then((response) => response.ok ? readJson(response, "Google Search Status", { maxBytes: MAX_STATUS_BYTES }) : null).catch(() => null);
|
|
2400
|
+
if (!Array.isArray(incidents)) return "Google Search Status could not be read.";
|
|
2401
|
+
return searchUpdatesFromIncidents(withRankingHistory(incidents), {
|
|
2402
|
+
product: "Ranking",
|
|
2403
|
+
limit: Number.POSITIVE_INFINITY
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2380
2406
|
async function googleUpdates(window) {
|
|
2381
2407
|
if (!window) return [];
|
|
2382
2408
|
const incidents = await fetch("https://status.search.google.com/incidents.json", { signal: AbortSignal.timeout(8e3) }).then((response) => response.ok ? readJson(response, "Google Search Status", { maxBytes: MAX_STATUS_BYTES }) : null).catch(() => null);
|
|
@@ -2709,7 +2735,7 @@ async function readableSites(google, signIn) {
|
|
|
2709
2735
|
permissionLevel: entry.permissionLevel ?? ""
|
|
2710
2736
|
})).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
|
|
2711
2737
|
}
|
|
2712
|
-
const hostOf$
|
|
2738
|
+
const hostOf$3 = (value) => {
|
|
2713
2739
|
try {
|
|
2714
2740
|
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
2715
2741
|
} catch {
|
|
@@ -2727,14 +2753,14 @@ async function runSites(input) {
|
|
|
2727
2753
|
};
|
|
2728
2754
|
})), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
|
|
2729
2755
|
const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
|
|
2730
|
-
const bingByHost = new Map(bingSites.map((site) => [hostOf$
|
|
2756
|
+
const bingByHost = new Map(bingSites.map((site) => [hostOf$3(site.url), site]));
|
|
2731
2757
|
const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
|
|
2732
2758
|
...property,
|
|
2733
2759
|
...named ? { account: account.email ?? "" } : {},
|
|
2734
|
-
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$
|
|
2760
|
+
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
|
|
2735
2761
|
})));
|
|
2736
|
-
const googleHosts = new Set(properties.map((property) => hostOf$
|
|
2737
|
-
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$
|
|
2762
|
+
const googleHosts = new Set(properties.map((property) => hostOf$3(property.siteUrl)));
|
|
2763
|
+
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$3(site.url)));
|
|
2738
2764
|
const failed = google.filter((account) => account.properties instanceof Error);
|
|
2739
2765
|
const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
|
|
2740
2766
|
property: property.property.replace("properties/", ""),
|
|
@@ -8113,6 +8139,56 @@ function systemBrowserCandidates(input) {
|
|
|
8113
8139
|
product: path.includes("google-chrome") ? "Google Chrome" : "Chromium"
|
|
8114
8140
|
}));
|
|
8115
8141
|
}
|
|
8142
|
+
function listDirectorySafely(path) {
|
|
8143
|
+
try {
|
|
8144
|
+
return readdirSync(path);
|
|
8145
|
+
} catch {
|
|
8146
|
+
return [];
|
|
8147
|
+
}
|
|
8148
|
+
}
|
|
8149
|
+
function versionParts(name) {
|
|
8150
|
+
return (name.match(/\d+/g) ?? []).map(Number);
|
|
8151
|
+
}
|
|
8152
|
+
function newestFirst(left, right) {
|
|
8153
|
+
const a = versionParts(left);
|
|
8154
|
+
const b = versionParts(right);
|
|
8155
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
8156
|
+
const difference = (b[index] ?? -1) - (a[index] ?? -1);
|
|
8157
|
+
if (difference) return difference;
|
|
8158
|
+
}
|
|
8159
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
8160
|
+
}
|
|
8161
|
+
const MAX_CACHED_BROWSER_VERSIONS = 20;
|
|
8162
|
+
function versionDirectories(root, prefix, listDirectory) {
|
|
8163
|
+
return listDirectory(root).filter((name) => name.startsWith(prefix)).sort(newestFirst).slice(0, MAX_CACHED_BROWSER_VERSIONS).map((name) => join(root, name));
|
|
8164
|
+
}
|
|
8165
|
+
const CHROME_FOR_TESTING_APP = join("Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
|
|
8166
|
+
/**
|
|
8167
|
+
* Browsers that other local tools already downloaded: agent-browser keeps
|
|
8168
|
+
* Chrome for Testing builds and Playwright keeps one Chromium per revision.
|
|
8169
|
+
*/
|
|
8170
|
+
function cachedBrowserCandidates(input) {
|
|
8171
|
+
const agentBrowserExecutables = input.platform === "darwin" ? [CHROME_FOR_TESTING_APP] : input.platform === "win32" ? ["chrome.exe"] : ["chrome"];
|
|
8172
|
+
const agentBrowser = versionDirectories(join(input.homeDirectory, ".agent-browser", "browsers"), "chrome-", input.listDirectory).flatMap((directory) => agentBrowserExecutables.map((executable) => ({
|
|
8173
|
+
path: join(directory, executable),
|
|
8174
|
+
source: "agent-browser-cache",
|
|
8175
|
+
product: "agent-browser Chrome for Testing"
|
|
8176
|
+
})));
|
|
8177
|
+
const configuredRoot = input.environment.PLAYWRIGHT_BROWSERS_PATH?.trim();
|
|
8178
|
+
const playwrightRoot = configuredRoot && configuredRoot !== "0" ? configuredRoot : input.platform === "darwin" ? join(input.homeDirectory, "Library", "Caches", "ms-playwright") : input.platform === "win32" ? join(input.environment.LOCALAPPDATA ?? join(input.homeDirectory, "AppData", "Local"), "ms-playwright") : join(input.environment.XDG_CACHE_HOME ?? join(input.homeDirectory, ".cache"), "ms-playwright");
|
|
8179
|
+
const playwrightExecutables = input.platform === "darwin" ? [
|
|
8180
|
+
join("chrome-mac-arm64", CHROME_FOR_TESTING_APP),
|
|
8181
|
+
join("chrome-mac-x64", CHROME_FOR_TESTING_APP),
|
|
8182
|
+
join("chrome-mac", CHROME_FOR_TESTING_APP),
|
|
8183
|
+
join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")
|
|
8184
|
+
] : input.platform === "win32" ? [join("chrome-win64", "chrome.exe"), join("chrome-win", "chrome.exe")] : [join("chrome-linux64", "chrome"), join("chrome-linux", "chrome")];
|
|
8185
|
+
const playwright = versionDirectories(playwrightRoot, "chromium-", input.listDirectory).flatMap((directory) => playwrightExecutables.map((executable) => ({
|
|
8186
|
+
path: join(directory, executable),
|
|
8187
|
+
source: "playwright-cache",
|
|
8188
|
+
product: "Playwright Chromium"
|
|
8189
|
+
})));
|
|
8190
|
+
return [...agentBrowser, ...playwright];
|
|
8191
|
+
}
|
|
8116
8192
|
/**
|
|
8117
8193
|
* Resolve a browser without downloading one during normal package installation.
|
|
8118
8194
|
* The explicit path wins so CI and managed workstations can select their own
|
|
@@ -8144,11 +8220,23 @@ function resolveBrowserExecutable(playwrightExecutablePath, dependencies = {}) {
|
|
|
8144
8220
|
product: "Playwright Chromium"
|
|
8145
8221
|
}
|
|
8146
8222
|
};
|
|
8147
|
-
const
|
|
8148
|
-
|
|
8149
|
-
|
|
8223
|
+
const platform = dependencies.platform ?? process.platform;
|
|
8224
|
+
const homeDirectory = dependencies.homeDirectory ?? homedir();
|
|
8225
|
+
const systemBrowser = systemBrowserCandidates({
|
|
8226
|
+
platform,
|
|
8227
|
+
homeDirectory,
|
|
8150
8228
|
environment
|
|
8151
8229
|
}).find((candidate) => fileExists(candidate.path));
|
|
8230
|
+
if (systemBrowser) return {
|
|
8231
|
+
status: "available",
|
|
8232
|
+
browser: systemBrowser
|
|
8233
|
+
};
|
|
8234
|
+
const browser = cachedBrowserCandidates({
|
|
8235
|
+
platform,
|
|
8236
|
+
homeDirectory,
|
|
8237
|
+
environment,
|
|
8238
|
+
listDirectory: dependencies.listDirectory ?? listDirectorySafely
|
|
8239
|
+
}).find((candidate) => fileExists(candidate.path));
|
|
8152
8240
|
return browser ? {
|
|
8153
8241
|
status: "available",
|
|
8154
8242
|
browser
|
|
@@ -11312,38 +11400,61 @@ function isValuablePage(page) {
|
|
|
11312
11400
|
function normalizedMetadata(value) {
|
|
11313
11401
|
return value?.trim().replace(/\s+/g, " ").toLowerCase() || void 0;
|
|
11314
11402
|
}
|
|
11315
|
-
|
|
11316
|
-
|
|
11403
|
+
/**
|
|
11404
|
+
* A page whose canonical names another crawled page in its group is already
|
|
11405
|
+
* handled. A group counts only when two or more of its pages are
|
|
11406
|
+
* self-canonical or declare no canonical, and only those pages are reported.
|
|
11407
|
+
*/
|
|
11408
|
+
function duplicateGroups(pages, read) {
|
|
11409
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
11317
11410
|
for (const page of pages) {
|
|
11318
11411
|
if (!isAuditableHtmlPage(page)) continue;
|
|
11319
|
-
const
|
|
11320
|
-
|
|
11321
|
-
|
|
11322
|
-
|
|
11323
|
-
|
|
11324
|
-
sampleUrls: [],
|
|
11325
|
-
value: raw
|
|
11412
|
+
const entry = read(page);
|
|
11413
|
+
if (!entry) continue;
|
|
11414
|
+
const group = grouped.get(entry.key) ?? {
|
|
11415
|
+
value: entry.value,
|
|
11416
|
+
pages: []
|
|
11326
11417
|
};
|
|
11327
|
-
|
|
11328
|
-
|
|
11329
|
-
|
|
11418
|
+
group.pages.push(page);
|
|
11419
|
+
grouped.set(entry.key, group);
|
|
11420
|
+
}
|
|
11421
|
+
const duplicates = /* @__PURE__ */ new Map();
|
|
11422
|
+
for (const [key, group] of grouped) {
|
|
11423
|
+
if (group.pages.length < 2) continue;
|
|
11424
|
+
const groupUrls = /* @__PURE__ */ new Set();
|
|
11425
|
+
for (const page of group.pages) for (const value of [page.url, page.finalUrl]) {
|
|
11426
|
+
const url = urlKey(value);
|
|
11427
|
+
if (url) groupUrls.add(url);
|
|
11428
|
+
}
|
|
11429
|
+
const counted = group.pages.filter((page) => {
|
|
11430
|
+
const canonical = urlKey(page.canonical);
|
|
11431
|
+
return !canonical || canonical === urlKey(page.url) || canonical === urlKey(page.finalUrl) || !groupUrls.has(canonical);
|
|
11432
|
+
});
|
|
11433
|
+
if (counted.length < 2) continue;
|
|
11434
|
+
duplicates.set(key, {
|
|
11435
|
+
count: counted.length,
|
|
11436
|
+
sampleUrls: counted.slice(0, 10).map((page) => page.url),
|
|
11437
|
+
value: group.value,
|
|
11438
|
+
urls: new Set(counted.map((page) => page.url))
|
|
11439
|
+
});
|
|
11330
11440
|
}
|
|
11331
|
-
return
|
|
11441
|
+
return duplicates;
|
|
11442
|
+
}
|
|
11443
|
+
function metadataDuplicates(pages, read) {
|
|
11444
|
+
return duplicateGroups(pages, (page) => {
|
|
11445
|
+
const value = read(page)?.trim();
|
|
11446
|
+
const key = normalizedMetadata(value);
|
|
11447
|
+
return key && value ? {
|
|
11448
|
+
key,
|
|
11449
|
+
value
|
|
11450
|
+
} : void 0;
|
|
11451
|
+
});
|
|
11332
11452
|
}
|
|
11333
11453
|
function contentDuplicates(pages) {
|
|
11334
|
-
|
|
11335
|
-
|
|
11336
|
-
|
|
11337
|
-
|
|
11338
|
-
const existing = counts.get(page.mainContentHash) ?? {
|
|
11339
|
-
count: 0,
|
|
11340
|
-
sampleUrls: []
|
|
11341
|
-
};
|
|
11342
|
-
existing.count += 1;
|
|
11343
|
-
if (existing.sampleUrls.length < 10) existing.sampleUrls.push(page.url);
|
|
11344
|
-
counts.set(page.mainContentHash, existing);
|
|
11345
|
-
}
|
|
11346
|
-
return new Map([...counts].filter(([, value]) => value.count > 1));
|
|
11454
|
+
return duplicateGroups(pages, (page) => page.wordCount >= 20 && page.mainContentHash ? {
|
|
11455
|
+
key: page.mainContentHash,
|
|
11456
|
+
value: page.mainContentHash
|
|
11457
|
+
} : void 0);
|
|
11347
11458
|
}
|
|
11348
11459
|
const QUERY_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
11349
11460
|
"a",
|
|
@@ -11553,7 +11664,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
11553
11664
|
title,
|
|
11554
11665
|
...titleWidth
|
|
11555
11666
|
}));
|
|
11556
|
-
if (title && duplicateTitle) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
|
|
11667
|
+
if (title && duplicateTitle?.urls.has(page.url)) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
|
|
11557
11668
|
title,
|
|
11558
11669
|
duplicateCount: duplicateTitle.count,
|
|
11559
11670
|
sampleUrls: duplicateTitle.sampleUrls
|
|
@@ -11561,7 +11672,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
11561
11672
|
const metaDescription = page.metaDescription?.trim();
|
|
11562
11673
|
const duplicateDescription = duplicateDescriptions.get(normalizedMetadata(metaDescription) ?? "");
|
|
11563
11674
|
if (!metaDescription) issues.push(issue$1("missing_meta_description", page));
|
|
11564
|
-
if (metaDescription && duplicateDescription) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
|
|
11675
|
+
if (metaDescription && duplicateDescription?.urls.has(page.url)) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
|
|
11565
11676
|
metaDescription,
|
|
11566
11677
|
duplicateCount: duplicateDescription.count,
|
|
11567
11678
|
sampleUrls: duplicateDescription.sampleUrls
|
|
@@ -11609,7 +11720,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
11609
11720
|
finalUrl: page.finalUrl
|
|
11610
11721
|
}));
|
|
11611
11722
|
const contentDuplicate = page.mainContentHash ? duplicateContent.get(page.mainContentHash) : void 0;
|
|
11612
|
-
if (contentDuplicate) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
|
|
11723
|
+
if (contentDuplicate?.urls.has(page.url)) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
|
|
11613
11724
|
mainContentHash: page.mainContentHash,
|
|
11614
11725
|
duplicateCount: contentDuplicate.count,
|
|
11615
11726
|
sampleUrls: contentDuplicate.sampleUrls
|
|
@@ -11684,6 +11795,7 @@ const CRAWL_SKIP_REASONS = [
|
|
|
11684
11795
|
"off-origin",
|
|
11685
11796
|
"origin-backpressure",
|
|
11686
11797
|
"queue-safety-limit",
|
|
11798
|
+
"query-variant-limit",
|
|
11687
11799
|
"robots-disallowed",
|
|
11688
11800
|
"robots-uncertain",
|
|
11689
11801
|
"legacy-unclassified"
|
|
@@ -11697,6 +11809,7 @@ const SKIP_IMPACT = {
|
|
|
11697
11809
|
"off-origin": "non-impacting",
|
|
11698
11810
|
"origin-backpressure": "coverage-affecting",
|
|
11699
11811
|
"queue-safety-limit": "coverage-affecting",
|
|
11812
|
+
"query-variant-limit": "coverage-affecting",
|
|
11700
11813
|
"robots-disallowed": "coverage-affecting",
|
|
11701
11814
|
"robots-uncertain": "coverage-affecting",
|
|
11702
11815
|
"legacy-unclassified": "coverage-affecting"
|
|
@@ -12351,6 +12464,10 @@ const VALUE_WEIGHT = {
|
|
|
12351
12464
|
medium: .75,
|
|
12352
12465
|
low: .15
|
|
12353
12466
|
};
|
|
12467
|
+
const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
|
|
12468
|
+
function reachRank(ruleId) {
|
|
12469
|
+
return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
|
|
12470
|
+
}
|
|
12354
12471
|
function matchesPattern(pattern, value) {
|
|
12355
12472
|
if (!pattern.includes("*")) return value.includes(pattern);
|
|
12356
12473
|
const parts = pattern.split("*");
|
|
@@ -12458,7 +12575,7 @@ function topFixes(report, filters = {}) {
|
|
|
12458
12575
|
expected: rule?.howToVerify ?? ""
|
|
12459
12576
|
}
|
|
12460
12577
|
};
|
|
12461
|
-
}).sort((a, b) => b.score - a.score || SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity] || b.count - a.count || a.ruleId.localeCompare(b.ruleId)).slice(0, filters.limit ?? 10);
|
|
12578
|
+
}).sort((a, b) => reachRank(a.ruleId) - reachRank(b.ruleId) || b.score - a.score || SEVERITY_SCORE[b.severity] - SEVERITY_SCORE[a.severity] || b.count - a.count || a.ruleId.localeCompare(b.ruleId)).slice(0, filters.limit ?? 10);
|
|
12462
12579
|
}
|
|
12463
12580
|
function reviewObservations(report, filters = {}) {
|
|
12464
12581
|
return topFixes(report, {
|
|
@@ -13643,6 +13760,7 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
13643
13760
|
source: z.enum([
|
|
13644
13761
|
"environment",
|
|
13645
13762
|
"playwright-cache",
|
|
13763
|
+
"agent-browser-cache",
|
|
13646
13764
|
"system"
|
|
13647
13765
|
]),
|
|
13648
13766
|
product: z.string(),
|
|
@@ -14346,55 +14464,6 @@ function agentReadyIssues(input) {
|
|
|
14346
14464
|
}
|
|
14347
14465
|
return issues;
|
|
14348
14466
|
}
|
|
14349
|
-
// packages/core/src/analyze/crawler/crawl-caveats.ts
|
|
14350
|
-
function crawlCaveats(input) {
|
|
14351
|
-
const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
|
|
14352
|
-
if (input.queueSafetySkippedUrls > 0) caveats.push(input.queueSafetyInventoryCapped ? `Left at least ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. The excluded-URL inventory reached its reporting cap. Increase --max-pages to inspect more.` : `Left ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
|
|
14353
|
-
if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
|
|
14354
|
-
if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
|
|
14355
|
-
return caveats;
|
|
14356
|
-
}
|
|
14357
|
-
// packages/core/src/analyze/crawler/crawl-control.ts
|
|
14358
|
-
const CRAWL_CANCELLED = Symbol("crawl-cancelled");
|
|
14359
|
-
function abortController(input) {
|
|
14360
|
-
const controller = new AbortController();
|
|
14361
|
-
const abort = () => controller.abort();
|
|
14362
|
-
const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
|
|
14363
|
-
if (input.signal?.aborted) controller.abort();
|
|
14364
|
-
else input.signal?.addEventListener("abort", abort, { once: true });
|
|
14365
|
-
return {
|
|
14366
|
-
signal: controller.signal,
|
|
14367
|
-
cleanup: () => {
|
|
14368
|
-
clearTimeout(timer);
|
|
14369
|
-
input.signal?.removeEventListener("abort", abort);
|
|
14370
|
-
}
|
|
14371
|
-
};
|
|
14372
|
-
}
|
|
14373
|
-
function cancellationRace(signal) {
|
|
14374
|
-
if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
|
|
14375
|
-
return new Promise((resolve) => {
|
|
14376
|
-
signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
|
|
14377
|
-
});
|
|
14378
|
-
}
|
|
14379
|
-
// packages/core/src/analyze/crawler/crawl-memory.ts
|
|
14380
|
-
const MEBIBYTE$1 = 1048576;
|
|
14381
|
-
const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE$1;
|
|
14382
|
-
const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE$1;
|
|
14383
|
-
const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE$1;
|
|
14384
|
-
const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE$1;
|
|
14385
|
-
const SYSTEM_MEMORY_FRACTION = .1;
|
|
14386
|
-
const SYSTEM_RSS_FRACTION = .2;
|
|
14387
|
-
function crawlMemoryLimitBytes(totalMemoryBytes) {
|
|
14388
|
-
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
|
|
14389
|
-
return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
|
|
14390
|
-
}
|
|
14391
|
-
function crawlRssLimitBytes(totalMemoryBytes) {
|
|
14392
|
-
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
|
|
14393
|
-
return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
|
|
14394
|
-
}
|
|
14395
|
-
function crawlMemoryPressure(input) {
|
|
14396
|
-
return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
|
|
14397
|
-
}
|
|
14398
14467
|
// packages/core/src/analyze/crawler/crawl-url-queue.ts
|
|
14399
14468
|
function before(left, right) {
|
|
14400
14469
|
return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
|
|
@@ -14474,6 +14543,85 @@ var CrawlUrlQueue = class {
|
|
|
14474
14543
|
}
|
|
14475
14544
|
}
|
|
14476
14545
|
};
|
|
14546
|
+
// packages/core/src/analyze/crawler/crawl-caveats.ts
|
|
14547
|
+
function crawlCaveats(input) {
|
|
14548
|
+
const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
|
|
14549
|
+
if (input.queueSafetySkippedUrls > 0) caveats.push(input.queueSafetyInventoryCapped ? `Left at least ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. The excluded-URL inventory reached its reporting cap. Increase --max-pages to inspect more.` : `Left ${input.queueSafetySkippedUrls} unique eligible same-origin URLs unqueued to keep this crawl bounded. Increase --max-pages to inspect more.`);
|
|
14550
|
+
if (input.originBackpressureSkippedUrls > 0) caveats.push(`Stopped ${input.originBackpressureSkippedUrls} queued URL${input.originBackpressureSkippedUrls === 1 ? "" : "s"} because the origin stayed slow. This is incomplete crawl evidence, not a failed URL check.`);
|
|
14551
|
+
if (input.memoryPressureSkippedUrls > 0) caveats.push(`Left ${input.memoryPressureSkippedUrls} eligible URL${input.memoryPressureSkippedUrls === 1 ? "" : "s"} unchecked after the local memory safety limit was reached. Start with --health or lower --max-pages.`);
|
|
14552
|
+
const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
|
|
14553
|
+
if (renderingFallbackUrls > 0) caveats.push(`JavaScript rendering could not run for ${renderingFallbackUrls} page${renderingFallbackUrls === 1 ? "" : "s"}, so ${renderingFallbackUrls === 1 ? "it was" : "they were"} audited from raw HTML. Client-rendered content, metadata and links may be missing. Install Chromium or set SEO_BROWSER_EXECUTABLE_PATH, then rerun.`);
|
|
14554
|
+
const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
|
|
14555
|
+
if (queryVariantSkippedUrls > 0) caveats.push(`Left ${queryVariantSkippedUrls} query-string variant${queryVariantSkippedUrls === 1 ? "" : "s"} unqueued after 5 variants of the same path, so tracking parameters do not fill the crawl.`);
|
|
14556
|
+
for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
|
|
14557
|
+
return caveats;
|
|
14558
|
+
}
|
|
14559
|
+
/**
|
|
14560
|
+
* A site crawl that found no internal links on the start page and no sitemap
|
|
14561
|
+
* URLs checked one page, so its other findings cannot stand for the site.
|
|
14562
|
+
*/
|
|
14563
|
+
function crawlReachIssues(input) {
|
|
14564
|
+
const [page] = input.pages;
|
|
14565
|
+
if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
|
|
14566
|
+
const rule = explainRule("crawl_single_page_reach");
|
|
14567
|
+
if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
|
|
14568
|
+
return [{
|
|
14569
|
+
ruleId: "crawl_single_page_reach",
|
|
14570
|
+
title: rule.title,
|
|
14571
|
+
category: rule.category,
|
|
14572
|
+
severity: rule.defaultSeverity,
|
|
14573
|
+
url: page.url,
|
|
14574
|
+
detail: page.usedJs ? "The rendered start page had 0 internal links and no sitemap returned URLs, so the crawl checked only this page. List the pages to check with --mode list." : "The start page had 0 internal links in its HTML and no sitemap returned URLs, so the crawl checked only this page. It probably builds its links with JavaScript. Rerun with --rendering on, or list the pages with --mode list.",
|
|
14575
|
+
evidence: {
|
|
14576
|
+
startUrl: input.startUrl,
|
|
14577
|
+
crawledPages: 1,
|
|
14578
|
+
outgoingInternalCount: 0,
|
|
14579
|
+
sitemapUrlsReturned: 0,
|
|
14580
|
+
rendered: Boolean(page.usedJs)
|
|
14581
|
+
}
|
|
14582
|
+
}];
|
|
14583
|
+
}
|
|
14584
|
+
// packages/core/src/analyze/crawler/crawl-control.ts
|
|
14585
|
+
const CRAWL_CANCELLED = Symbol("crawl-cancelled");
|
|
14586
|
+
function abortController(input) {
|
|
14587
|
+
const controller = new AbortController();
|
|
14588
|
+
const abort = () => controller.abort();
|
|
14589
|
+
const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
|
|
14590
|
+
if (input.signal?.aborted) controller.abort();
|
|
14591
|
+
else input.signal?.addEventListener("abort", abort, { once: true });
|
|
14592
|
+
return {
|
|
14593
|
+
signal: controller.signal,
|
|
14594
|
+
cleanup: () => {
|
|
14595
|
+
clearTimeout(timer);
|
|
14596
|
+
input.signal?.removeEventListener("abort", abort);
|
|
14597
|
+
}
|
|
14598
|
+
};
|
|
14599
|
+
}
|
|
14600
|
+
function cancellationRace(signal) {
|
|
14601
|
+
if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
|
|
14602
|
+
return new Promise((resolve) => {
|
|
14603
|
+
signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
|
|
14604
|
+
});
|
|
14605
|
+
}
|
|
14606
|
+
// packages/core/src/analyze/crawler/crawl-memory.ts
|
|
14607
|
+
const MEBIBYTE$1 = 1048576;
|
|
14608
|
+
const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE$1;
|
|
14609
|
+
const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE$1;
|
|
14610
|
+
const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE$1;
|
|
14611
|
+
const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE$1;
|
|
14612
|
+
const SYSTEM_MEMORY_FRACTION = .1;
|
|
14613
|
+
const SYSTEM_RSS_FRACTION = .2;
|
|
14614
|
+
function crawlMemoryLimitBytes(totalMemoryBytes) {
|
|
14615
|
+
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
|
|
14616
|
+
return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
|
|
14617
|
+
}
|
|
14618
|
+
function crawlRssLimitBytes(totalMemoryBytes) {
|
|
14619
|
+
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
|
|
14620
|
+
return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
|
|
14621
|
+
}
|
|
14622
|
+
function crawlMemoryPressure(input) {
|
|
14623
|
+
return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
|
|
14624
|
+
}
|
|
14477
14625
|
// packages/core/src/analyze/crawler/external-link-checks.ts
|
|
14478
14626
|
const EXTERNAL_LINK_SELECTION_LIMIT = 200;
|
|
14479
14627
|
const EXTERNAL_LINK_STATES = [
|
|
@@ -14706,11 +14854,15 @@ function observationFromPage(requestedUrl, page) {
|
|
|
14706
14854
|
};
|
|
14707
14855
|
}
|
|
14708
14856
|
// packages/core/src/analyze/crawler/site-checks.ts
|
|
14709
|
-
const PROBE_COUNT =
|
|
14857
|
+
const PROBE_COUNT = 3;
|
|
14710
14858
|
function probeUrls(startUrl) {
|
|
14711
14859
|
const origin = new URL(startUrl).origin;
|
|
14712
14860
|
const seed = createHash("sha256").update(origin).digest("hex").slice(0, 12);
|
|
14713
|
-
return
|
|
14861
|
+
return [
|
|
14862
|
+
`/${seed}-not-found`,
|
|
14863
|
+
`/${seed}-not-found/${seed}-missing`,
|
|
14864
|
+
`/.well-known/seo-audit/not-found-${seed}-1`
|
|
14865
|
+
].map((path) => new URL(path, origin).toString());
|
|
14714
14866
|
}
|
|
14715
14867
|
function probeObservation(url, result) {
|
|
14716
14868
|
const request = result.request;
|
|
@@ -14747,7 +14899,7 @@ async function collectCrawlSiteChecks(input) {
|
|
|
14747
14899
|
const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
|
|
14748
14900
|
const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
|
|
14749
14901
|
const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
|
|
14750
|
-
const status =
|
|
14902
|
+
const status = successful.length > 0 ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
|
|
14751
14903
|
const checks = { soft404: {
|
|
14752
14904
|
status,
|
|
14753
14905
|
probes,
|
|
@@ -14768,7 +14920,7 @@ async function collectCrawlSiteChecks(input) {
|
|
|
14768
14920
|
category: rule.category,
|
|
14769
14921
|
severity: rule.defaultSeverity,
|
|
14770
14922
|
url: new URL("/", input.startUrl).toString(),
|
|
14771
|
-
detail: `${successful.length}
|
|
14923
|
+
detail: `${successful.length} of ${probes.length} made-up paths returned a successful response or redirect: ${successful.map((probe) => `${new URL(probe.url).pathname} (${probe.status})`).join(", ")}.`,
|
|
14772
14924
|
evidence: checks.soft404
|
|
14773
14925
|
}]
|
|
14774
14926
|
};
|
|
@@ -17543,6 +17695,9 @@ async function crawlSite(input, dependencies) {
|
|
|
17543
17695
|
const queueSafetyDeferredUrls = /* @__PURE__ */ new Set();
|
|
17544
17696
|
const maxTrackedQueueSafetyUrls = Math.min(5e4, Math.max(1e3, config.maxPages * 5));
|
|
17545
17697
|
let queueSafetyInventoryCapped = false;
|
|
17698
|
+
const queryVariantsByPath = /* @__PURE__ */ new Map();
|
|
17699
|
+
const queryVariantSkipped = /* @__PURE__ */ new Set();
|
|
17700
|
+
let renderingFallbackUrls = 0;
|
|
17546
17701
|
let failedUrls = 0;
|
|
17547
17702
|
let observedInternalLinks = 0;
|
|
17548
17703
|
let memoryLimited = false;
|
|
@@ -17645,6 +17800,20 @@ async function crawlSite(input, dependencies) {
|
|
|
17645
17800
|
});
|
|
17646
17801
|
return;
|
|
17647
17802
|
}
|
|
17803
|
+
const parsed = new URL(normalized);
|
|
17804
|
+
const pathKey = parsed.search ? `${parsed.origin}${parsed.pathname}` : "";
|
|
17805
|
+
if (pathKey && (queryVariantsByPath.get(pathKey) ?? 0) >= 5) {
|
|
17806
|
+
if (!queryVariantSkipped.has(normalized) && queryVariantSkipped.size < maxTrackedQueueSafetyUrls) {
|
|
17807
|
+
queryVariantSkipped.add(normalized);
|
|
17808
|
+
recordSkip("query-variant-limit");
|
|
17809
|
+
emitStatus("url_skipped", {
|
|
17810
|
+
url: normalized,
|
|
17811
|
+
depth,
|
|
17812
|
+
reason: "query_variant_limit"
|
|
17813
|
+
});
|
|
17814
|
+
}
|
|
17815
|
+
return;
|
|
17816
|
+
}
|
|
17648
17817
|
if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
|
|
17649
17818
|
if (!queueSafetyDeferredUrls.has(normalized)) {
|
|
17650
17819
|
if (queueSafetyDeferredUrls.size < maxTrackedQueueSafetyUrls) {
|
|
@@ -17660,6 +17829,7 @@ async function crawlSite(input, dependencies) {
|
|
|
17660
17829
|
}
|
|
17661
17830
|
return;
|
|
17662
17831
|
}
|
|
17832
|
+
if (pathKey) queryVariantsByPath.set(pathKey, (queryVariantsByPath.get(pathKey) ?? 0) + 1);
|
|
17663
17833
|
discovered.add(normalized);
|
|
17664
17834
|
queuedUrls += 1;
|
|
17665
17835
|
queue.push({
|
|
@@ -17812,6 +17982,9 @@ async function crawlSite(input, dependencies) {
|
|
|
17812
17982
|
continue;
|
|
17813
17983
|
}
|
|
17814
17984
|
const page = result.page;
|
|
17985
|
+
const rendering = page.fetchDiagnostics?.rendering;
|
|
17986
|
+
if (rendering?.status === "unavailable" && config.js === "on") throw new SeoError("INVALID_INPUT", `--rendering on needs a local Chrome or Chromium browser, and none could start. ${rendering.error ?? ""} Install Chromium, or set SEO_BROWSER_EXECUTABLE_PATH to a Chromium executable, then rerun. Use --rendering auto to accept raw HTML.`.replace(/\s+/g, " ").trim());
|
|
17987
|
+
if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
|
|
17815
17988
|
const finalUrl = normalizeUrl$1(page.finalUrl, config.url);
|
|
17816
17989
|
if (!finalUrl) {
|
|
17817
17990
|
failedUrls += 1;
|
|
@@ -18034,6 +18207,12 @@ async function crawlSite(input, dependencies) {
|
|
|
18034
18207
|
...startPage.responseHeaders?.["content-signal"] ? { contentSignal: startPage.responseHeaders["content-signal"] } : {}
|
|
18035
18208
|
} } : {}
|
|
18036
18209
|
} : aiSignals?.signals;
|
|
18210
|
+
const reachIssues = followLinks && config.maxDepth > 0 && config.maxPages > 1 && !cancelled ? crawlReachIssues({
|
|
18211
|
+
startUrl: config.url,
|
|
18212
|
+
pages: pages.filter((page) => page.status >= 200 && page.status < 300),
|
|
18213
|
+
crawledPages: pages.length,
|
|
18214
|
+
sitemapUrlsReturned: sitemapDiscovery?.urlsReturned ?? 0
|
|
18215
|
+
}) : [];
|
|
18037
18216
|
const report = createCrawlReport({
|
|
18038
18217
|
config,
|
|
18039
18218
|
site,
|
|
@@ -18048,6 +18227,7 @@ async function crawlSite(input, dependencies) {
|
|
|
18048
18227
|
...externalLinkVerification ? { externalLinkVerification } : {},
|
|
18049
18228
|
...siteCheckResult ? { siteChecks: siteCheckResult.checks } : {},
|
|
18050
18229
|
additionalIssues: [
|
|
18230
|
+
...reachIssues,
|
|
18051
18231
|
...siteCheckResult?.issues ?? [],
|
|
18052
18232
|
...sitemapDiscovery ? sitemapHostIssues(sitemapDiscovery, config.url) : [],
|
|
18053
18233
|
...ai ? agentReadyIssues({
|
|
@@ -18065,7 +18245,10 @@ async function crawlSite(input, dependencies) {
|
|
|
18065
18245
|
queueSafetySkippedUrls,
|
|
18066
18246
|
queueSafetyInventoryCapped,
|
|
18067
18247
|
originBackpressureSkippedUrls,
|
|
18068
|
-
memoryPressureSkippedUrls
|
|
18248
|
+
memoryPressureSkippedUrls,
|
|
18249
|
+
queryVariantSkippedUrls: queryVariantSkipped.size,
|
|
18250
|
+
renderingFallbackUrls,
|
|
18251
|
+
reachIssues
|
|
18069
18252
|
}),
|
|
18070
18253
|
stats: {
|
|
18071
18254
|
discoveredUrls: discovered.size,
|
|
@@ -18620,7 +18803,7 @@ function analyzeCtrUnderperformersFromRows(input) {
|
|
|
18620
18803
|
// packages/core/src/analyze/ctr-underperformers-report.ts
|
|
18621
18804
|
const MAX_GSC_ROWS$3 = 1e5;
|
|
18622
18805
|
const DEFAULT_DAYS$3 = 28;
|
|
18623
|
-
const MAX_DAYS$
|
|
18806
|
+
const MAX_DAYS$3 = 548;
|
|
18624
18807
|
function plural$8(count, singular, pluralLabel = `${singular}s`) {
|
|
18625
18808
|
return count === 1 ? singular : pluralLabel;
|
|
18626
18809
|
}
|
|
@@ -18639,12 +18822,12 @@ async function ctrUnderperformersReportWith(input, dependencies) {
|
|
|
18639
18822
|
maximum: 100,
|
|
18640
18823
|
label: "limit"
|
|
18641
18824
|
});
|
|
18642
|
-
const explicitRange = explicitDateRange(input, MAX_DAYS$
|
|
18825
|
+
const explicitRange = explicitDateRange(input, MAX_DAYS$3);
|
|
18643
18826
|
const days = explicitRange?.days ?? integerOption$1({
|
|
18644
18827
|
value: input.days,
|
|
18645
18828
|
fallback: DEFAULT_DAYS$3,
|
|
18646
18829
|
minimum: 1,
|
|
18647
|
-
maximum: MAX_DAYS$
|
|
18830
|
+
maximum: MAX_DAYS$3,
|
|
18648
18831
|
label: "Days"
|
|
18649
18832
|
});
|
|
18650
18833
|
const now = dependencies.now();
|
|
@@ -21885,7 +22068,7 @@ function quickWinTemplateSummaries(items) {
|
|
|
21885
22068
|
}
|
|
21886
22069
|
// packages/core/src/analyze/site-diagnostics/quick-wins-report.ts
|
|
21887
22070
|
const DEFAULT_DAYS$2 = 28;
|
|
21888
|
-
const MAX_DAYS$
|
|
22071
|
+
const MAX_DAYS$2 = 548;
|
|
21889
22072
|
const DEFAULT_VERIFY_LIMIT$1 = 5;
|
|
21890
22073
|
const MAX_VERIFY_LIMIT = 100;
|
|
21891
22074
|
const MAX_SOURCE_ROWS$2 = 1e5;
|
|
@@ -21903,12 +22086,12 @@ function applyVerification$1(item, coverage) {
|
|
|
21903
22086
|
};
|
|
21904
22087
|
}
|
|
21905
22088
|
async function quickWinsReportWith(input, dependencies) {
|
|
21906
|
-
const explicitRange = explicitDateRange(input, MAX_DAYS$
|
|
22089
|
+
const explicitRange = explicitDateRange(input, MAX_DAYS$2);
|
|
21907
22090
|
const days = explicitRange?.days ?? integerOption$1({
|
|
21908
22091
|
value: input.days,
|
|
21909
22092
|
fallback: DEFAULT_DAYS$2,
|
|
21910
22093
|
minimum: 1,
|
|
21911
|
-
maximum: MAX_DAYS$
|
|
22094
|
+
maximum: MAX_DAYS$2,
|
|
21912
22095
|
label: "Days"
|
|
21913
22096
|
});
|
|
21914
22097
|
const verifyLimit = integerOption$1({
|
|
@@ -22327,7 +22510,7 @@ function analyzeStrikingDistanceRows(input) {
|
|
|
22327
22510
|
}
|
|
22328
22511
|
// packages/core/src/analyze/striking-distance-report.ts
|
|
22329
22512
|
const DEFAULT_DAYS$1 = 28;
|
|
22330
|
-
const MAX_DAYS = 548;
|
|
22513
|
+
const MAX_DAYS$1 = 548;
|
|
22331
22514
|
const DEFAULT_VERIFY_LIMIT = 5;
|
|
22332
22515
|
const MAX_SOURCE_ROWS$1 = 1e5;
|
|
22333
22516
|
function boundedInteger$2(value, fallback, minimum, maximum) {
|
|
@@ -22336,7 +22519,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
|
|
|
22336
22519
|
}
|
|
22337
22520
|
function reportDays(value) {
|
|
22338
22521
|
if (value === void 0) return DEFAULT_DAYS$1;
|
|
22339
|
-
if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS}.`);
|
|
22522
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_DAYS$1) throw new SeoError("INVALID_INPUT", `Days must be a whole number between 1 and ${MAX_DAYS$1}.`);
|
|
22340
22523
|
return value;
|
|
22341
22524
|
}
|
|
22342
22525
|
function dateRange(days, now) {
|
|
@@ -22376,7 +22559,7 @@ function verdict$1(analysis) {
|
|
|
22376
22559
|
return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
|
|
22377
22560
|
}
|
|
22378
22561
|
async function strikingDistanceWith(input, dependencies) {
|
|
22379
|
-
const explicitRange = explicitDateRange(input, MAX_DAYS);
|
|
22562
|
+
const explicitRange = explicitDateRange(input, MAX_DAYS$1);
|
|
22380
22563
|
const days = explicitRange?.days ?? reportDays(input.days);
|
|
22381
22564
|
const generatedAt = dependencies.now().toISOString();
|
|
22382
22565
|
const range = explicitRange?.range ?? dateRange(days, new Date(generatedAt));
|
|
@@ -23563,7 +23746,7 @@ function typesafeDecisionModel(input) {
|
|
|
23563
23746
|
const MAX_CONCURRENCY = 32;
|
|
23564
23747
|
const MIN_CONCURRENCY = 2;
|
|
23565
23748
|
const MAX_ATTEMPTS = 5;
|
|
23566
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23749
|
+
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23567
23750
|
var DecisionRunner = class {
|
|
23568
23751
|
model;
|
|
23569
23752
|
limit = MAX_CONCURRENCY;
|
|
@@ -23628,7 +23811,7 @@ var DecisionRunner = class {
|
|
|
23628
23811
|
} finally {
|
|
23629
23812
|
this.release();
|
|
23630
23813
|
}
|
|
23631
|
-
await sleep(delay);
|
|
23814
|
+
await sleep$1(delay);
|
|
23632
23815
|
}
|
|
23633
23816
|
this.failed += 1;
|
|
23634
23817
|
return null;
|
|
@@ -31789,6 +31972,7 @@ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
|
31789
31972
|
function requireInput(input) {
|
|
31790
31973
|
const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
|
|
31791
31974
|
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
31975
|
+
if (input.action === "link_gap" && !input.target) throw new CommandError("INVALID_INPUT", "link_gap needs a target domain and competitors.");
|
|
31792
31976
|
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
31793
31977
|
}
|
|
31794
31978
|
async function eachKeyword(keywords, missed, rowsFor) {
|
|
@@ -31806,14 +31990,17 @@ async function eachKeyword(keywords, missed, rowsFor) {
|
|
|
31806
31990
|
}
|
|
31807
31991
|
async function research(provider, headers, input) {
|
|
31808
31992
|
requireInput(input);
|
|
31993
|
+
if (input.action === "link_gap") return linkGap(provider, headers, input);
|
|
31809
31994
|
const keys = need(headers, provider);
|
|
31810
31995
|
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
31811
31996
|
const missed = [];
|
|
31997
|
+
const rows = await ahrefs(keys[0] ?? "", input, missed);
|
|
31812
31998
|
return {
|
|
31813
|
-
rows
|
|
31999
|
+
rows,
|
|
31814
32000
|
costUsd: null,
|
|
31815
32001
|
partial: false,
|
|
31816
|
-
missed
|
|
32002
|
+
missed,
|
|
32003
|
+
noData: withoutData(input, rows, missed, 100)
|
|
31817
32004
|
};
|
|
31818
32005
|
}
|
|
31819
32006
|
function countryName(code) {
|
|
@@ -31865,7 +32052,8 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
31865
32052
|
if (result?.status_code === NO_RESULTS) return {
|
|
31866
32053
|
cost: result.cost ?? 0,
|
|
31867
32054
|
result: null,
|
|
31868
|
-
partial: false
|
|
32055
|
+
partial: false,
|
|
32056
|
+
noResults: true
|
|
31869
32057
|
};
|
|
31870
32058
|
if (!result || result.status_code >= 4e4 && result.status_code !== PARTIAL) throw Object.assign(new CommandError("PROVIDER_ERROR", `dataforseo: ${result?.status_message ?? "no task result"}`), { cost: result?.cost ?? 0 });
|
|
31871
32059
|
return {
|
|
@@ -31876,24 +32064,59 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
31876
32064
|
}
|
|
31877
32065
|
const NO_RESULTS = 40102;
|
|
31878
32066
|
const PARTIAL = 40106;
|
|
32067
|
+
const queued = (task) => ({
|
|
32068
|
+
id: String(task.id ?? ""),
|
|
32069
|
+
status_code: Number(task.status_code ?? 0),
|
|
32070
|
+
status_message: String(task.status_message ?? ""),
|
|
32071
|
+
cost: Number(task.cost ?? 0),
|
|
32072
|
+
data: task.data ?? {},
|
|
32073
|
+
result: task.result?.[0] ?? null
|
|
32074
|
+
});
|
|
32075
|
+
async function dataForSeoPost([login, password], path, tasks) {
|
|
32076
|
+
return ((await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
|
|
32077
|
+
method: "POST",
|
|
32078
|
+
headers: {
|
|
32079
|
+
authorization: `Basic ${btoa(`${login}:${password}`)}`,
|
|
32080
|
+
"content-type": "application/json"
|
|
32081
|
+
},
|
|
32082
|
+
body: JSON.stringify(tasks)
|
|
32083
|
+
})), "DataForSEO")).tasks ?? []).map(queued);
|
|
32084
|
+
}
|
|
32085
|
+
const DATAFORSEO_NOT_READY = /* @__PURE__ */ new Set([40601, 40602]);
|
|
32086
|
+
async function dataForSeoGet([login, password], path) {
|
|
32087
|
+
const task = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, { headers: { authorization: `Basic ${btoa(`${login}:${password}`)}` } })), "DataForSEO")).tasks?.[0];
|
|
32088
|
+
if (!task) throw new CommandError("PROVIDER_ERROR", `dataforseo: no task at ${path}`);
|
|
32089
|
+
return queued(task);
|
|
32090
|
+
}
|
|
31879
32091
|
async function dataForSeo(keys, input) {
|
|
31880
32092
|
const spent = {
|
|
31881
32093
|
cost: 0,
|
|
31882
32094
|
partial: false,
|
|
31883
32095
|
missed: []
|
|
31884
32096
|
};
|
|
32097
|
+
const rows = await dataForSeoRows(keys, input, spent);
|
|
31885
32098
|
return {
|
|
31886
|
-
rows
|
|
32099
|
+
rows,
|
|
31887
32100
|
costUsd: Math.round(spent.cost * 1e4) / 1e4,
|
|
31888
32101
|
partial: spent.partial,
|
|
31889
|
-
missed: spent.missed
|
|
32102
|
+
missed: spent.missed,
|
|
32103
|
+
noData: withoutData(input, rows, spent.missed, 700)
|
|
31890
32104
|
};
|
|
31891
32105
|
}
|
|
32106
|
+
function withoutData(input, rows, missed, keywordLimit) {
|
|
32107
|
+
if (input.action === "keyword_metrics" || input.action === "serp") {
|
|
32108
|
+
const max = input.action === "serp" ? 5 : keywordLimit;
|
|
32109
|
+
const answered = new Set(rows.map((row) => String(row.keyword ?? "").toLowerCase()));
|
|
32110
|
+
const failed = new Set(missed);
|
|
32111
|
+
return input.keywords.slice(0, max).filter((keyword) => !failed.has(keyword) && !answered.has(keyword.toLowerCase()));
|
|
32112
|
+
}
|
|
32113
|
+
return [];
|
|
32114
|
+
}
|
|
31892
32115
|
async function dataForSeoRows(keys, input, spent) {
|
|
31893
32116
|
const language = dataForSeoLanguage(input.country);
|
|
31894
32117
|
if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
|
|
31895
32118
|
const market = dataForSeoMarket(input.country, language);
|
|
31896
|
-
const post = async (path, task) => {
|
|
32119
|
+
const post = async (path, task, attempts = 1) => {
|
|
31897
32120
|
const done = await dataForSeoTask(keys, path, {
|
|
31898
32121
|
...market,
|
|
31899
32122
|
...task
|
|
@@ -31903,6 +32126,7 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
31903
32126
|
});
|
|
31904
32127
|
spent.cost += done.cost;
|
|
31905
32128
|
spent.partial ||= done.partial;
|
|
32129
|
+
if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
|
|
31906
32130
|
return done.result?.items ?? [];
|
|
31907
32131
|
};
|
|
31908
32132
|
const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
|
|
@@ -31932,7 +32156,7 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
31932
32156
|
return (await post("v3/serp/google/organic/live/advanced", {
|
|
31933
32157
|
keyword,
|
|
31934
32158
|
depth: Math.min(input.limit, 100)
|
|
31935
|
-
})).filter((item) => item.type === "organic").map((item) => {
|
|
32159
|
+
}, 2)).filter((item) => item.type === "organic").map((item) => {
|
|
31936
32160
|
const row = pick(item, {
|
|
31937
32161
|
position: "rank_absolute",
|
|
31938
32162
|
url: "url",
|
|
@@ -31940,9 +32164,11 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
31940
32164
|
title: "title"
|
|
31941
32165
|
});
|
|
31942
32166
|
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
31943
|
-
const
|
|
32167
|
+
const crumb = String(item.breadcrumb ?? "");
|
|
32168
|
+
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
31944
32169
|
row.url = null;
|
|
31945
32170
|
row.domain = site?.[1] ?? null;
|
|
32171
|
+
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
31946
32172
|
}
|
|
31947
32173
|
return {
|
|
31948
32174
|
keyword,
|
|
@@ -31972,6 +32198,65 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
31972
32198
|
organic_etv: "full_domain_metrics.organic.etv"
|
|
31973
32199
|
}));
|
|
31974
32200
|
}
|
|
32201
|
+
const GAP_DOMAINS = 1e3;
|
|
32202
|
+
const GAP_COMPETITORS = 5;
|
|
32203
|
+
async function linkGap(provider, headers, input) {
|
|
32204
|
+
const target = bareTarget(input.target).replace(/^www\./, "");
|
|
32205
|
+
const competitors = [...new Set((input.competitors ?? []).map((site) => bareTarget(site).replace(/^www\./, "")))].filter((site) => site && site !== target);
|
|
32206
|
+
if (competitors.length < 2 || competitors.length > GAP_COMPETITORS) throw new CommandError("INVALID_INPUT", `link_gap needs a target and 2 to ${GAP_COMPETITORS} competitors.`);
|
|
32207
|
+
const score = provider === "ahrefs" ? "domain_rating" : "rank";
|
|
32208
|
+
const sites = [target, ...competitors];
|
|
32209
|
+
const lists = [];
|
|
32210
|
+
let cost = 0;
|
|
32211
|
+
let partial = false;
|
|
32212
|
+
for (const site of sites) {
|
|
32213
|
+
const got = await research(provider, headers, {
|
|
32214
|
+
...input,
|
|
32215
|
+
action: "backlinks",
|
|
32216
|
+
target: site,
|
|
32217
|
+
limit: GAP_DOMAINS
|
|
32218
|
+
});
|
|
32219
|
+
cost += got.costUsd ?? 0;
|
|
32220
|
+
partial ||= got.partial;
|
|
32221
|
+
lists.push(got.rows);
|
|
32222
|
+
}
|
|
32223
|
+
const domainOf = (row) => String(row.domain ?? "").toLowerCase().replace(/^www\./, "");
|
|
32224
|
+
const authority = (row) => Number(row[score] ?? 0);
|
|
32225
|
+
const floors = lists.filter((rows) => rows.length >= GAP_DOMAINS).map((rows) => Math.min(...rows.map(authority)));
|
|
32226
|
+
const floor = floors.length ? Math.max(...floors) : null;
|
|
32227
|
+
const own = new Set((lists[0] ?? []).map(domainOf));
|
|
32228
|
+
const found = /* @__PURE__ */ new Map();
|
|
32229
|
+
competitors.forEach((site, index) => {
|
|
32230
|
+
for (const row of lists[index + 1] ?? []) {
|
|
32231
|
+
const domain = domainOf(row);
|
|
32232
|
+
if (!domain || own.has(domain) || domain === target) continue;
|
|
32233
|
+
if (domain.endsWith(`.${target}`)) continue;
|
|
32234
|
+
const entry = found.get(domain) ?? {
|
|
32235
|
+
value: 0,
|
|
32236
|
+
sites: []
|
|
32237
|
+
};
|
|
32238
|
+
entry.value = Math.max(entry.value, authority(row));
|
|
32239
|
+
if (!entry.sites.includes(site)) entry.sites.push(site);
|
|
32240
|
+
found.set(domain, entry);
|
|
32241
|
+
}
|
|
32242
|
+
});
|
|
32243
|
+
return {
|
|
32244
|
+
rows: [...found.entries()].filter(([, entry]) => entry.sites.length >= 2 && (floor === null || entry.value >= floor)).sort(([a, left], [b, right]) => right.value - left.value || right.sites.length - left.sites.length || a.localeCompare(b)).map(([domain, entry]) => ({
|
|
32245
|
+
domain,
|
|
32246
|
+
[score]: entry.value,
|
|
32247
|
+
competitors_linking: entry.sites.length,
|
|
32248
|
+
links_to: entry.sites.join(" ")
|
|
32249
|
+
})),
|
|
32250
|
+
costUsd: provider === "dataforseo" ? Math.round(cost * 1e4) / 1e4 : null,
|
|
32251
|
+
partial,
|
|
32252
|
+
missed: [],
|
|
32253
|
+
noData: [],
|
|
32254
|
+
...floor !== null ? { floor: {
|
|
32255
|
+
field: score,
|
|
32256
|
+
value: floor
|
|
32257
|
+
} } : {}
|
|
32258
|
+
};
|
|
32259
|
+
}
|
|
31975
32260
|
function isPage(target) {
|
|
31976
32261
|
return /\/./.test(bareTarget(target));
|
|
31977
32262
|
}
|
|
@@ -32062,7 +32347,7 @@ const DIMENSIONS = [
|
|
|
32062
32347
|
"device",
|
|
32063
32348
|
"searchAppearance"
|
|
32064
32349
|
];
|
|
32065
|
-
const ROWS = 25e3;
|
|
32350
|
+
const ROWS$1 = 25e3;
|
|
32066
32351
|
async function runRows(input, deps) {
|
|
32067
32352
|
const dimensions = input.dimensions?.length ? input.dimensions : ["query"];
|
|
32068
32353
|
const filters = input.filters ?? [];
|
|
@@ -32071,7 +32356,7 @@ async function runRows(input, deps) {
|
|
|
32071
32356
|
const query = {
|
|
32072
32357
|
dimensions,
|
|
32073
32358
|
type: "web",
|
|
32074
|
-
maxRows: ROWS,
|
|
32359
|
+
maxRows: ROWS$1,
|
|
32075
32360
|
...filters.length ? { dimensionFilterGroups: [{
|
|
32076
32361
|
groupType: "and",
|
|
32077
32362
|
filters
|
|
@@ -32128,9 +32413,9 @@ async function runRows(input, deps) {
|
|
|
32128
32413
|
const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
|
32129
32414
|
notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
|
|
32130
32415
|
}
|
|
32131
|
-
notes.push(`Compared with ${previous.startDate} to ${previous.endDate}${before.rowsFetched >= ROWS ? " (earlier period hit the row limit)" : ""}. 0 clicks with no position means the row had no impressions in the current period.`);
|
|
32416
|
+
notes.push(`Compared with ${previous.startDate} to ${previous.endDate}${before.rowsFetched >= ROWS$1 ? " (earlier period hit the row limit)" : ""}. 0 clicks with no position means the row had no impressions in the current period.`);
|
|
32132
32417
|
if (pageAt >= 0) notes.push("Page URLs with #fragments are merged into their page.");
|
|
32133
|
-
if (current.rowsFetched >= ROWS) notes.push(`Search Console has more than ${ROWS} rows; add a filter to narrow it.`);
|
|
32418
|
+
if (current.rowsFetched >= ROWS$1) notes.push(`Search Console has more than ${ROWS$1} rows; add a filter to narrow it.`);
|
|
32134
32419
|
notes.push("Search Console hides some rare queries for privacy, so query rows do not add up to the site totals.");
|
|
32135
32420
|
const detail = input.detail ?? "summary";
|
|
32136
32421
|
return {
|
|
@@ -32175,7 +32460,8 @@ const ENGINES = [
|
|
|
32175
32460
|
{
|
|
32176
32461
|
name: "chatgpt",
|
|
32177
32462
|
means: "a ChatGPT search, read by DataForSEO",
|
|
32178
|
-
|
|
32463
|
+
post: "v3/ai_optimization/chat_gpt/llm_scraper/task_post",
|
|
32464
|
+
get: "v3/ai_optimization/chat_gpt/llm_scraper/task_get/advanced/",
|
|
32179
32465
|
languages: "v3/ai_optimization/chat_gpt/llm_scraper/languages",
|
|
32180
32466
|
task: (question, market) => ({
|
|
32181
32467
|
keyword: question,
|
|
@@ -32195,7 +32481,8 @@ const ENGINES = [
|
|
|
32195
32481
|
{
|
|
32196
32482
|
name: "google_ai_mode",
|
|
32197
32483
|
means: "Google AI Mode",
|
|
32198
|
-
|
|
32484
|
+
post: "v3/serp/google/ai_mode/task_post",
|
|
32485
|
+
get: "v3/serp/google/ai_mode/task_get/advanced/",
|
|
32199
32486
|
languages: "v3/serp/google/ai_mode/languages",
|
|
32200
32487
|
task: (question, market) => ({
|
|
32201
32488
|
keyword: question,
|
|
@@ -32206,7 +32493,8 @@ const ENGINES = [
|
|
|
32206
32493
|
{
|
|
32207
32494
|
name: "google_ai_overview",
|
|
32208
32495
|
means: "the AI Overview on a Google search",
|
|
32209
|
-
|
|
32496
|
+
post: "v3/serp/google/organic/task_post",
|
|
32497
|
+
get: "v3/serp/google/organic/task_get/advanced/",
|
|
32210
32498
|
languages: "v3/serp/google/languages",
|
|
32211
32499
|
task: (question, market) => ({
|
|
32212
32500
|
keyword: question,
|
|
@@ -32228,56 +32516,71 @@ function onSite(url, domain) {
|
|
|
32228
32516
|
return false;
|
|
32229
32517
|
}
|
|
32230
32518
|
}
|
|
32231
|
-
|
|
32232
|
-
|
|
32233
|
-
result: "unavailable",
|
|
32234
|
-
model: null,
|
|
32235
|
-
cited_url: null,
|
|
32236
|
-
answered_at: null,
|
|
32237
|
-
cost_usd: 0,
|
|
32238
|
-
error: market
|
|
32239
|
-
};
|
|
32519
|
+
const MAX_SOURCES = 8;
|
|
32520
|
+
const hostOf$2 = (url) => {
|
|
32240
32521
|
try {
|
|
32241
|
-
|
|
32242
|
-
|
|
32243
|
-
|
|
32244
|
-
cost_usd: done.cost
|
|
32245
|
-
};
|
|
32246
|
-
const shown = done.result && engine.shown(done.result);
|
|
32247
|
-
if (!shown) return {
|
|
32248
|
-
result: "no_answer",
|
|
32249
|
-
model: null,
|
|
32250
|
-
cited_url: null,
|
|
32251
|
-
...answered
|
|
32252
|
-
};
|
|
32253
|
-
const base = {
|
|
32254
|
-
model: shown.model,
|
|
32255
|
-
...answered
|
|
32256
|
-
};
|
|
32257
|
-
const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
|
|
32258
|
-
const cited = [...shown.sources, ...linked].find((url) => onSite(url, site.domain));
|
|
32259
|
-
if (cited) return {
|
|
32260
|
-
result: "cited",
|
|
32261
|
-
cited_url: cited,
|
|
32262
|
-
...base
|
|
32263
|
-
};
|
|
32264
|
-
const text = prose(shown.markdown);
|
|
32265
|
-
return {
|
|
32266
|
-
result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
|
|
32267
|
-
cited_url: null,
|
|
32268
|
-
...base
|
|
32269
|
-
};
|
|
32270
|
-
} catch (error) {
|
|
32271
|
-
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
32272
|
-
return {
|
|
32273
|
-
result: "unavailable",
|
|
32274
|
-
model: null,
|
|
32275
|
-
cited_url: null,
|
|
32276
|
-
answered_at: null,
|
|
32277
|
-
cost_usd: error.cost ?? 0,
|
|
32278
|
-
error: error instanceof Error ? error.message.slice(0, 200) : "failed"
|
|
32279
|
-
};
|
|
32522
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
32523
|
+
} catch {
|
|
32524
|
+
return null;
|
|
32280
32525
|
}
|
|
32526
|
+
};
|
|
32527
|
+
const NOT_SOURCES = [
|
|
32528
|
+
"dataforseo.com",
|
|
32529
|
+
"images.openai.com",
|
|
32530
|
+
"gstatic.com",
|
|
32531
|
+
"googleusercontent.com",
|
|
32532
|
+
"ggpht.com",
|
|
32533
|
+
"example.com",
|
|
32534
|
+
"example.org",
|
|
32535
|
+
"example.net"
|
|
32536
|
+
];
|
|
32537
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i;
|
|
32538
|
+
const isSource = (url) => {
|
|
32539
|
+
const host = hostOf$2(url);
|
|
32540
|
+
if (!host || IMAGE.test(url)) return false;
|
|
32541
|
+
return !NOT_SOURCES.some((name) => host === name || host.endsWith(`.${name}`));
|
|
32542
|
+
};
|
|
32543
|
+
const empty = {
|
|
32544
|
+
cited_url: null,
|
|
32545
|
+
sources: [],
|
|
32546
|
+
model: null,
|
|
32547
|
+
answered_at: null
|
|
32548
|
+
};
|
|
32549
|
+
function judge(engine, task, site) {
|
|
32550
|
+
if (task.status_code === 40102) return {
|
|
32551
|
+
result: "no_answer",
|
|
32552
|
+
...empty
|
|
32553
|
+
};
|
|
32554
|
+
if (task.status_code >= 4e4 || !task.result) return {
|
|
32555
|
+
result: "unavailable",
|
|
32556
|
+
...empty,
|
|
32557
|
+
error: `dataforseo: ${task.status_message}`.slice(0, 200)
|
|
32558
|
+
};
|
|
32559
|
+
const answered_at = String(task.result.datetime ?? "").slice(0, 10) || null;
|
|
32560
|
+
const shown = engine.shown(task.result);
|
|
32561
|
+
if (!shown) return {
|
|
32562
|
+
result: "no_answer",
|
|
32563
|
+
...empty,
|
|
32564
|
+
answered_at
|
|
32565
|
+
};
|
|
32566
|
+
const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
|
|
32567
|
+
const all = [...shown.sources, ...linked];
|
|
32568
|
+
const cited = all.find((url) => onSite(url, site.domain)) ?? null;
|
|
32569
|
+
const base = {
|
|
32570
|
+
cited_url: cited,
|
|
32571
|
+
sources: [...new Set(all.filter((url) => !onSite(url, site.domain) && isSource(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
|
|
32572
|
+
model: shown.model,
|
|
32573
|
+
answered_at
|
|
32574
|
+
};
|
|
32575
|
+
if (cited) return {
|
|
32576
|
+
result: "cited",
|
|
32577
|
+
...base
|
|
32578
|
+
};
|
|
32579
|
+
const text = prose(shown.markdown);
|
|
32580
|
+
return {
|
|
32581
|
+
result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
|
|
32582
|
+
...base
|
|
32583
|
+
};
|
|
32281
32584
|
}
|
|
32282
32585
|
async function marketsFor(keys, country) {
|
|
32283
32586
|
const code = country.toUpperCase();
|
|
@@ -32295,45 +32598,162 @@ async function marketsFor(keys, country) {
|
|
|
32295
32598
|
}));
|
|
32296
32599
|
}
|
|
32297
32600
|
const usd = (value) => Math.round(value * 1e4) / 1e4;
|
|
32601
|
+
const FIRST_WAIT_MS = 1e4;
|
|
32602
|
+
const ROUND_WAIT_MS = 5e3;
|
|
32603
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
32604
|
+
const TASK_PATTERN = /^([a-z_]+):([0-9a-f-]{20,64})$/;
|
|
32605
|
+
const AI_ANSWER_TASK_PATTERN = TASK_PATTERN;
|
|
32298
32606
|
async function runAiAnswers(input, headers, deps, more) {
|
|
32299
32607
|
const keys = dataForSeoKeys(headers);
|
|
32300
32608
|
const domain = registrableDomain(input.site)?.domain;
|
|
32301
32609
|
if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
|
|
32302
|
-
const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
|
|
32303
|
-
if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
|
|
32304
|
-
const range = dateWindow({}, QUESTION_DAYS);
|
|
32305
|
-
let questions;
|
|
32306
|
-
if (given.length) questions = given.map((query) => ({
|
|
32307
|
-
query,
|
|
32308
|
-
impressions: null
|
|
32309
|
-
}));
|
|
32310
|
-
else questions = ((await aiQueriesReport({
|
|
32311
|
-
site: input.site,
|
|
32312
|
-
...range
|
|
32313
|
-
}, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
|
|
32314
32610
|
const site = {
|
|
32315
32611
|
domain,
|
|
32316
32612
|
terms: deriveBrandTerms({ siteUrl: input.site })
|
|
32317
32613
|
};
|
|
32318
|
-
const
|
|
32319
|
-
|
|
32320
|
-
|
|
32321
|
-
|
|
32322
|
-
|
|
32323
|
-
|
|
32324
|
-
|
|
32325
|
-
|
|
32326
|
-
|
|
32327
|
-
|
|
32328
|
-
|
|
32614
|
+
const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
|
|
32615
|
+
if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
|
|
32616
|
+
const tasks = [...new Set(input.tasks ?? [])];
|
|
32617
|
+
const range = dateWindow({}, QUESTION_DAYS);
|
|
32618
|
+
const rows = [];
|
|
32619
|
+
const waiting = [];
|
|
32620
|
+
const blank = (question, impressions) => ({
|
|
32621
|
+
question,
|
|
32622
|
+
impressions,
|
|
32623
|
+
task: null,
|
|
32624
|
+
cost_usd: 0,
|
|
32625
|
+
...empty
|
|
32626
|
+
});
|
|
32627
|
+
if (tasks.length) for (const value of tasks) {
|
|
32628
|
+
const match = TASK_PATTERN.exec(value);
|
|
32629
|
+
const engine = ENGINES.find((item) => item.name === match?.[1]);
|
|
32630
|
+
if (!match || !engine) throw new CommandError("INVALID_INPUT", `"${value}" is not an ai_answers task. Tasks look like <engine>:<id>, with the engine one of ${ENGINES.map((item) => item.name).join(", ")}.`);
|
|
32631
|
+
const row = {
|
|
32632
|
+
...blank("", null),
|
|
32633
|
+
engine: engine.name,
|
|
32634
|
+
result: "pending",
|
|
32635
|
+
task: value
|
|
32636
|
+
};
|
|
32637
|
+
rows.push(row);
|
|
32638
|
+
waiting.push({
|
|
32639
|
+
row,
|
|
32640
|
+
engine,
|
|
32641
|
+
id: match[2]
|
|
32642
|
+
});
|
|
32643
|
+
}
|
|
32644
|
+
else {
|
|
32645
|
+
let questions;
|
|
32646
|
+
if (given.length) questions = given.map((query) => ({
|
|
32647
|
+
query,
|
|
32648
|
+
impressions: null
|
|
32649
|
+
}));
|
|
32650
|
+
else questions = ((await aiQueriesReport({
|
|
32651
|
+
site: input.site,
|
|
32652
|
+
...range
|
|
32653
|
+
}, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
|
|
32654
|
+
const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
|
|
32655
|
+
await Promise.all(ENGINES.map(async (engine) => {
|
|
32656
|
+
const engineRows = questions.map((question) => ({
|
|
32657
|
+
...blank(question.query, question.impressions),
|
|
32658
|
+
engine: engine.name,
|
|
32659
|
+
result: "unavailable"
|
|
32660
|
+
}));
|
|
32661
|
+
rows.push(...engineRows);
|
|
32662
|
+
if (!questions.length) return;
|
|
32663
|
+
const market = markets.get(engine.name) ?? "no market";
|
|
32664
|
+
if (typeof market === "string") {
|
|
32665
|
+
for (const row of engineRows) row.error = market;
|
|
32666
|
+
return;
|
|
32667
|
+
}
|
|
32668
|
+
let queuedTasks;
|
|
32669
|
+
try {
|
|
32670
|
+
queuedTasks = await dataForSeoPost(keys, engine.post, questions.map((question) => ({
|
|
32671
|
+
...engine.task(question.query, market),
|
|
32672
|
+
priority: 2
|
|
32673
|
+
})));
|
|
32674
|
+
} catch (error) {
|
|
32675
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
32676
|
+
for (const row of engineRows) row.error = error instanceof Error ? error.message.slice(0, 200) : "failed";
|
|
32677
|
+
return;
|
|
32678
|
+
}
|
|
32679
|
+
engineRows.forEach((row, index) => {
|
|
32680
|
+
const task = queuedTasks[index];
|
|
32681
|
+
row.cost_usd = task?.cost ?? 0;
|
|
32682
|
+
if (!task || task.status_code >= 4e4 || !task.id) {
|
|
32683
|
+
row.error = `dataforseo: ${task?.status_message ?? "no task"}`;
|
|
32684
|
+
return;
|
|
32685
|
+
}
|
|
32686
|
+
row.result = "pending";
|
|
32687
|
+
row.task = `${engine.name}:${task.id}`;
|
|
32688
|
+
waiting.push({
|
|
32689
|
+
row,
|
|
32690
|
+
engine,
|
|
32691
|
+
id: task.id
|
|
32692
|
+
});
|
|
32693
|
+
});
|
|
32694
|
+
}));
|
|
32695
|
+
}
|
|
32696
|
+
const deadline = Date.now() + (input.waitSeconds ?? 40) * 1e3;
|
|
32697
|
+
let pending = waiting;
|
|
32698
|
+
let first = !tasks.length;
|
|
32699
|
+
while (pending.length) {
|
|
32700
|
+
const pause = first ? FIRST_WAIT_MS : ROUND_WAIT_MS;
|
|
32701
|
+
first = false;
|
|
32702
|
+
if (!tasks.length || pending !== waiting) {
|
|
32703
|
+
if (Date.now() + pause > deadline) break;
|
|
32704
|
+
await sleep(pause);
|
|
32705
|
+
}
|
|
32706
|
+
const still = [];
|
|
32707
|
+
await Promise.all(pending.map(async (item) => {
|
|
32708
|
+
try {
|
|
32709
|
+
const task = await dataForSeoGet(keys, `${item.engine.get}${item.id}`);
|
|
32710
|
+
if (!item.row.question) item.row.question = String(task.data.keyword ?? "");
|
|
32711
|
+
if (DATAFORSEO_NOT_READY.has(task.status_code)) {
|
|
32712
|
+
still.push(item);
|
|
32713
|
+
return;
|
|
32714
|
+
}
|
|
32715
|
+
Object.assign(item.row, judge(item.engine, task, site), { task: null });
|
|
32716
|
+
} catch (error) {
|
|
32717
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
32718
|
+
still.push(item);
|
|
32719
|
+
}
|
|
32720
|
+
}));
|
|
32721
|
+
pending = still;
|
|
32722
|
+
if (tasks.length && Date.now() >= deadline) break;
|
|
32723
|
+
}
|
|
32724
|
+
rows.sort((a, b) => a.question.localeCompare(b.question) || ENGINES.findIndex((e) => e.name === a.engine) - ENGINES.findIndex((e) => e.name === b.engine));
|
|
32725
|
+
const cost = usd(rows.reduce((sum, row) => sum + row.cost_usd, 0));
|
|
32726
|
+
const count = (result) => rows.filter((row) => row.result === result).length;
|
|
32727
|
+
const left = rows.filter((row) => row.task).map((row) => row.task);
|
|
32728
|
+
const cites = /* @__PURE__ */ new Map();
|
|
32729
|
+
for (const row of rows) for (const host of row.sources) cites.set(host, (cites.get(host) ?? 0) + 1);
|
|
32730
|
+
const topCited = [...cites].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10);
|
|
32731
|
+
const questionCount = new Set(rows.map((row) => row.question)).size;
|
|
32732
|
+
const notes = [tasks.length ? `${tasks.length === 1 ? "One answer" : `${tasks.length} answers`} queued by an earlier run, read back at no cost.` : given.length ? `The ${given.length} questions you gave, asked as written. Impressions are blank because they don't come from Search Console.` : questionCount ? `The top ${questionCount} askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, by impressions. No calls were made for other questions.` : `No askable questions in Search Console’s AI-shaped queries for ${range.startDate} to ${range.endDate}, so no answers were checked and nothing was spent.`, ...rows.length ? [
|
|
32733
|
+
`Cited ${count("cited")}, mentioned ${count("mentioned")}, neither ${count("neither")}, no answer shown ${count("no_answer")}, unavailable ${count("unavailable")}, not ready ${count("pending")}. DataForSEO cost: $${cost}.`,
|
|
32734
|
+
...left.length ? [`${left.length === 1 ? "One answer wasn't" : `${left.length} answers weren't`} ready yet. They're paid for: ask ai_answers again with the same target and tasks ${JSON.stringify(left)} in a minute to read them at no cost (DataForSEO keeps them for 30 days).`] : [],
|
|
32735
|
+
...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
|
|
32329
32736
|
"Each row is one sample of an answer that changes from run to run, not a ranking.",
|
|
32330
|
-
`cited: the answer lists or links a page on ${domain} or a subdomain. mentioned: no such source, but the answer text names ${[domain, ...site.terms].join(" or ")}, a text match. no_answer: the engine showed no AI answer for the question. model: the model DataForSEO reports for the answer, when it reports one.`,
|
|
32737
|
+
`cited: the answer lists or links a page on ${domain} or a subdomain. mentioned: no such source, but the answer text names ${[domain, ...site.terms].join(" or ")}, a text match. no_answer: the engine showed no AI answer for the question. sources: the other sites the answer cites, up to ${MAX_SOURCES}. model: the model DataForSEO reports for the answer, when it reports one.`,
|
|
32331
32738
|
`Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
|
|
32332
32739
|
] : []];
|
|
32740
|
+
const shown = rows.map((row) => ({
|
|
32741
|
+
question: row.question,
|
|
32742
|
+
impressions: row.impressions,
|
|
32743
|
+
engine: row.engine,
|
|
32744
|
+
result: row.result,
|
|
32745
|
+
cited_url: row.cited_url,
|
|
32746
|
+
sources: row.sources.join(", "),
|
|
32747
|
+
model: row.model,
|
|
32748
|
+
answered_at: row.answered_at,
|
|
32749
|
+
cost_usd: row.cost_usd,
|
|
32750
|
+
task: row.task,
|
|
32751
|
+
error: row.error ?? null
|
|
32752
|
+
}));
|
|
32333
32753
|
return {
|
|
32334
32754
|
text: rowsText({
|
|
32335
32755
|
title: `AI answers for ${input.site}, ${input.country}.`,
|
|
32336
|
-
rows:
|
|
32756
|
+
rows: shown,
|
|
32337
32757
|
offset: input.offset,
|
|
32338
32758
|
limit: 50,
|
|
32339
32759
|
detail: input.detail,
|
|
@@ -32345,15 +32765,23 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
32345
32765
|
action: "ai_answers",
|
|
32346
32766
|
site: input.site,
|
|
32347
32767
|
country: input.country,
|
|
32348
|
-
questionsFrom: given.length ? { section: "given" } : {
|
|
32768
|
+
questionsFrom: tasks.length ? { section: "tasks" } : given.length ? { section: "given" } : {
|
|
32349
32769
|
section: "ai-queries",
|
|
32350
32770
|
...range
|
|
32351
32771
|
},
|
|
32352
|
-
questions:
|
|
32772
|
+
questions: [...new Set(rows.map((row) => row.question))],
|
|
32353
32773
|
costUsd: cost,
|
|
32354
|
-
|
|
32774
|
+
pendingTasks: left,
|
|
32775
|
+
citedSites: topCited.map(([host, answers]) => ({
|
|
32776
|
+
host,
|
|
32777
|
+
answers
|
|
32778
|
+
})),
|
|
32779
|
+
total: rows.length,
|
|
32355
32780
|
offset: input.offset,
|
|
32356
|
-
rows:
|
|
32781
|
+
rows: rows.slice(input.offset, input.offset + 50).map(({ error, ...row }) => error ? {
|
|
32782
|
+
...row,
|
|
32783
|
+
error
|
|
32784
|
+
} : row)
|
|
32357
32785
|
}
|
|
32358
32786
|
};
|
|
32359
32787
|
}
|
|
@@ -32730,7 +33158,7 @@ async function totals$1(found, access, range, before, pages) {
|
|
|
32730
33158
|
notes
|
|
32731
33159
|
};
|
|
32732
33160
|
}
|
|
32733
|
-
const change$
|
|
33161
|
+
const change$2 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
|
|
32734
33162
|
function engineText(rows) {
|
|
32735
33163
|
const shown = rows.filter((row) => row.sessions || row.previousSessions);
|
|
32736
33164
|
if (shown.length < 2) return "";
|
|
@@ -32740,7 +33168,7 @@ function aiText(ai) {
|
|
|
32740
33168
|
const list = (rows, key) => rows.slice(0, AI_ROWS).map((row) => `${row[key]} ${row.sessions} (was ${row.previousSessions})`).join(", ");
|
|
32741
33169
|
if (!ai.sessions && !ai.previousSessions) return "Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): none in either period.";
|
|
32742
33170
|
return [
|
|
32743
|
-
`Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): ${ai.sessions} sessions (was ${ai.previousSessions}, ${change$
|
|
33171
|
+
`Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): ${ai.sessions} sessions (was ${ai.previousSessions}, ${change$2(ai.sessionsChangePct)}). By source: ${list(ai.bySource, "source")}.`,
|
|
32744
33172
|
ai.topLandingPages.length ? `Top landing pages from AI assistants: ${list(ai.topLandingPages, "page")}.` : "",
|
|
32745
33173
|
"Only visits that pass a referrer or utm_source count; AI apps that send none show as Direct."
|
|
32746
33174
|
].filter(Boolean).join(" ");
|
|
@@ -32857,7 +33285,7 @@ async function analyticsBeside(access, site, range, before, pages) {
|
|
|
32857
33285
|
const keyEvents = organic.keyEvents || organic.previousKeyEvents ? `, Organic Search key events ${organic.keyEvents} (was ${organic.previousKeyEvents})` : "";
|
|
32858
33286
|
return {
|
|
32859
33287
|
text: [
|
|
32860
|
-
`Google Analytics, property ${data.propertyName} (${data.property}), matched by its web stream ${data.webStream}${pages ? `, landing pages whose path contains "${pages}"` : ""}: Organic Search sessions from Google ${google.sessions} (was ${google.previousSessions}, ${change$
|
|
33288
|
+
`Google Analytics, property ${data.propertyName} (${data.property}), matched by its web stream ${data.webStream}${pages ? `, landing pages whose path contains "${pages}"` : ""}: Organic Search sessions from Google ${google.sessions} (was ${google.previousSessions}, ${change$2(google.sessionsChangePct)}), from every search engine ${organic.sessions} (was ${organic.previousSessions}, ${change$2(organic.sessionsChangePct)})${keyEvents}, sessions from all channels ${all.sessions} (was ${all.previousSessions}, ${change$2(all.sessionsChangePct)}).`,
|
|
32861
33289
|
engineText(data.organicSearchByEngine),
|
|
32862
33290
|
"Sessions are Google Analytics’ own count, not Search Console clicks.",
|
|
32863
33291
|
...data.notes,
|
|
@@ -33084,7 +33512,7 @@ function auditPreview(snapshot) {
|
|
|
33084
33512
|
}
|
|
33085
33513
|
const WEEK_MS = 6048e5;
|
|
33086
33514
|
const day$1 = (ms) => ms === null ? "" : new Date(ms).toISOString().slice(0, 10);
|
|
33087
|
-
const count$
|
|
33515
|
+
const count$3 = (value) => value.toLocaleString("en-US");
|
|
33088
33516
|
function relative$2(value, origin) {
|
|
33089
33517
|
if (!value) return "";
|
|
33090
33518
|
return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
|
|
@@ -33105,7 +33533,7 @@ function rowsTable(rows, checks) {
|
|
|
33105
33533
|
changed(row) ? row.prev_coverage ?? "" : "",
|
|
33106
33534
|
changed(row) ? day$1(row.changed_at) : "",
|
|
33107
33535
|
day$1(row.checked_at),
|
|
33108
|
-
`${count$
|
|
33536
|
+
`${count$3(row.clicks)} (${count$3(row.previous_clicks)})${row.lost ? " lost" : ""}`,
|
|
33109
33537
|
row.in_sitemap ? "yes" : "no",
|
|
33110
33538
|
row.google_canonical && row.google_canonical !== row.url ? relative$2(row.google_canonical, checks.origin) : "",
|
|
33111
33539
|
row.error ?? ""
|
|
@@ -33113,12 +33541,12 @@ function rowsTable(rows, checks) {
|
|
|
33113
33541
|
}
|
|
33114
33542
|
function head(site, checks) {
|
|
33115
33543
|
const { queued } = checks;
|
|
33116
|
-
const quota = checks.properties.map((item) => `${item.property} (${count$
|
|
33544
|
+
const quota = checks.properties.map((item) => `${item.property} (${count$3(item.urls)} URLs: ${count$3(item.top)} top pages checked daily, the rest every ${item.cycle_days === 1 ? "day" : `${item.cycle_days} days`}; ${count$3(item.used_today)} of ${count$3(checks.per_day)} checks used today)`).join(", ");
|
|
33117
33545
|
return [
|
|
33118
33546
|
`Google indexing of ${site}, from the URL Inspection results this server keeps for it (as of ${new Date(checks.as_of).toISOString().slice(0, 16).replace("T", " ")} UTC).`,
|
|
33119
|
-
checks.dropped_this_week || checks.returned_this_week ? `In the last 7 days ${count$
|
|
33120
|
-
`Covered: ${count$
|
|
33121
|
-
queued.total ? `Queued: ${count$
|
|
33547
|
+
checks.dropped_this_week || checks.returned_this_week ? `In the last 7 days ${count$3(checks.dropped_this_week)} ${checks.dropped_this_week === 1 ? "URL" : "URLs"} dropped out of Google's index and ${count$3(checks.returned_this_week)} came back; they are listed first. A top page's drop counts only after a second check an hour later.` : null,
|
|
33548
|
+
`Covered: ${count$3(checks.covered)} of ${count$3(checks.known)} known URLs have a current result. Known URLs are the sitemap's, the crawl's and the Search Console pages with clicks or impressions. Each property spends its full ${count$3(checks.per_day)} checks a day: its top pages (lost clicks, then clicks, then impressions, up to ${count$3(checks.top_per_day)}) daily, the rest as often as the quota allows, and a page the weekly crawl saw change at once.`,
|
|
33549
|
+
queued.total ? `Queued: ${count$3(queued.total)} (${count$3(queued.lost)} pages that lost their clicks, ${count$3(queued.search)} with clicks or impressions, ${count$3(queued.sitemap)} sitemap URLs never checked, ${count$3(queued.stale)} older or changed), checked in that order. Time left: ${etaText(checks.eta_hours)}, at up to ${count$3(checks.per_day)} checks a day per property, spread over the day.` : "Queued: nothing; every known URL has a current result.",
|
|
33122
33550
|
quota ? `Checked through ${quota}.` : null,
|
|
33123
33551
|
checks.stopped ? `Checks stopped early: ${checks.stopped}` : null,
|
|
33124
33552
|
"A URL without a result has none here, so this is not an all-clear for it."
|
|
@@ -33127,9 +33555,9 @@ function head(site, checks) {
|
|
|
33127
33555
|
function suggestionsText(checks) {
|
|
33128
33556
|
if (!checks.suggestions.length) return null;
|
|
33129
33557
|
return [
|
|
33130
|
-
`The sitemap has more URLs than these properties can check every 7 days. Each Search Console property gets its own ${count$
|
|
33558
|
+
`The sitemap has more URLs than these properties can check every 7 days. Each Search Console property gets its own ${count$3(checks.per_day)} checks a day, so adding these folders as URL-prefix properties (on the same Google account) would check more:`,
|
|
33131
33559
|
"property to add | sitemap URLs in it | more URLs checked a week",
|
|
33132
|
-
...checks.suggestions.map((item) => `${item.property} | ${count$
|
|
33560
|
+
...checks.suggestions.map((item) => `${item.property} | ${count$3(item.urls)} | ${count$3(item.gain)}`),
|
|
33133
33561
|
"The next day the checks find a new property in Search Console and use its quota for the URLs in that folder."
|
|
33134
33562
|
].join("\n");
|
|
33135
33563
|
}
|
|
@@ -33144,7 +33572,7 @@ function runAuditChecks(input, checks, call) {
|
|
|
33144
33572
|
data: checks
|
|
33145
33573
|
};
|
|
33146
33574
|
if (!checks.started) return {
|
|
33147
|
-
text: `${site} is in the weekly site audit, and its Google checks are starting now: pages that lost their clicks first, then pages by clicks and impressions, then sitemap URLs. ${checks.known ? `${count$
|
|
33575
|
+
text: `${site} is in the weekly site audit, and its Google checks are starting now: pages that lost their clicks first, then pages by clicks and impressions, then sitemap URLs. ${checks.known ? `${count$3(checks.known)} URLs are known so far. ` : ""}Run ${call("index", { site })} again in a few minutes for the coverage and the time left.`,
|
|
33148
33576
|
data: checks
|
|
33149
33577
|
};
|
|
33150
33578
|
const more = (offset) => input.paged ? `More: ${call("index", {
|
|
@@ -33168,7 +33596,7 @@ function runAuditChecks(input, checks, call) {
|
|
|
33168
33596
|
const reasons = checks.reasons.length ? [
|
|
33169
33597
|
"Google's reason at each URL's latest check:",
|
|
33170
33598
|
"reason | verdict | urls | changed to it this week",
|
|
33171
|
-
...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$
|
|
33599
|
+
...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$3(item.urls)} | ${count$3(item.changed_this_week)}`)
|
|
33172
33600
|
].join("\n") : "No URL has a result yet.";
|
|
33173
33601
|
const shown = checks.rows.slice(0, 20);
|
|
33174
33602
|
return {
|
|
@@ -33225,7 +33653,7 @@ async function bingFor(deps, property) {
|
|
|
33225
33653
|
missing: site ? `${site.url} is in the connected Bing account but is not verified, so Bing gives no data for it. Verify it at ${BING_HOME}.` : `The connected Bing account does not have ${property.replace(/^sc-domain:/, "")}. Add and verify it at ${BING_HOME} (it can import sites from Search Console). New sites take a few days to show data.`
|
|
33226
33654
|
};
|
|
33227
33655
|
}
|
|
33228
|
-
function days(range) {
|
|
33656
|
+
function days$1(range) {
|
|
33229
33657
|
return Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 864e5) + 1;
|
|
33230
33658
|
}
|
|
33231
33659
|
async function bingTotals(source, range, before) {
|
|
@@ -33238,7 +33666,7 @@ async function bingTotals(source, range, before) {
|
|
|
33238
33666
|
clicks: rows.reduce((total, row) => total + row.clicks, 0),
|
|
33239
33667
|
impressions: rows.reduce((total, row) => total + row.impressions, 0),
|
|
33240
33668
|
days: new Set(rows.map((row) => row.date)).size,
|
|
33241
|
-
expectedDays: days(window)
|
|
33669
|
+
expectedDays: days$1(window)
|
|
33242
33670
|
};
|
|
33243
33671
|
};
|
|
33244
33672
|
return {
|
|
@@ -33248,14 +33676,14 @@ async function bingTotals(source, range, before) {
|
|
|
33248
33676
|
invalidRows: traffic.invalidRows
|
|
33249
33677
|
};
|
|
33250
33678
|
}
|
|
33251
|
-
const change = (now, before) => {
|
|
33679
|
+
const change$1 = (now, before) => {
|
|
33252
33680
|
const value = pct(now, before);
|
|
33253
33681
|
return value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
|
|
33254
33682
|
};
|
|
33255
33683
|
function enginesText(google, bing) {
|
|
33256
33684
|
if ("missing" in bing) return `Bing: ${bing.missing}`;
|
|
33257
33685
|
const { current: now, previous: then } = bing;
|
|
33258
|
-
const lines = [`Search engines, same dates. Google (Search Console): clicks ${google.clicks} (was ${google.previousClicks}, ${change(google.clicks, google.previousClicks)}), impressions ${google.impressions}. Bing (${bing.site}): clicks ${now.clicks} (was ${then.clicks}, ${change(now.clicks, then.clicks)}), impressions ${now.impressions} (was ${then.impressions}).`];
|
|
33686
|
+
const lines = [`Search engines, same dates. Google (Search Console): clicks ${google.clicks} (was ${google.previousClicks}, ${change$1(google.clicks, google.previousClicks)}), impressions ${google.impressions}. Bing (${bing.site}): clicks ${now.clicks} (was ${then.clicks}, ${change$1(now.clicks, then.clicks)}), impressions ${now.impressions} (was ${then.impressions}).`];
|
|
33259
33687
|
const both = google.clicks + now.clicks;
|
|
33260
33688
|
if (both > 0) lines.push(`Share of these clicks: Google ${Math.round(google.clicks / both * 100)}%, Bing ${Math.round(now.clicks / both * 100)}%.`);
|
|
33261
33689
|
if (now.days < now.expectedDays || then.days < then.expectedDays) lines.push(`Bing has data for ${now.days} of ${now.expectedDays} days (earlier period ${then.days} of ${then.expectedDays}), so its totals are partial.`);
|
|
@@ -33409,7 +33837,7 @@ async function runBingRows(input, deps) {
|
|
|
33409
33837
|
}
|
|
33410
33838
|
};
|
|
33411
33839
|
}
|
|
33412
|
-
function moved(rows, range, before, name, limit) {
|
|
33840
|
+
function moved$1(rows, range, before, name, limit) {
|
|
33413
33841
|
const now = sumWeeks(rows, range);
|
|
33414
33842
|
const then = sumWeeks(rows, before);
|
|
33415
33843
|
const old = new Map(then.rows.map((row) => [row.key, row]));
|
|
@@ -33447,8 +33875,8 @@ async function bingOverview(source, range, before) {
|
|
|
33447
33875
|
]);
|
|
33448
33876
|
if ("missing" in totals) throw new CommandError("NOT_CONNECTED", totals.missing);
|
|
33449
33877
|
const { current: now, previous: then } = totals;
|
|
33450
|
-
const q = moved(queries.rows, range, before, "query", 15);
|
|
33451
|
-
const p = moved(pages.rows, range, before, "page", 15);
|
|
33878
|
+
const q = moved$1(queries.rows, range, before, "query", 15);
|
|
33879
|
+
const p = moved$1(pages.rows, range, before, "page", 15);
|
|
33452
33880
|
return {
|
|
33453
33881
|
bingSite: source.site,
|
|
33454
33882
|
range,
|
|
@@ -33499,7 +33927,7 @@ function bingOverviewText(report, heading, origin, rowsPerList) {
|
|
|
33499
33927
|
};
|
|
33500
33928
|
return [
|
|
33501
33929
|
`${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
|
|
33502
|
-
t.days || t.previousDays ? `Bing totals: clicks ${t.clicks} (was ${t.previousClicks}, ${change(t.clicks, t.previousClicks)}), impressions ${t.impressions} (was ${t.previousImpressions}, ${change(t.impressions, t.previousImpressions)}).` : "Bing totals: unknown. Bing has no daily data for these dates.",
|
|
33930
|
+
t.days || t.previousDays ? `Bing totals: clicks ${t.clicks} (was ${t.previousClicks}, ${change$1(t.clicks, t.previousClicks)}), impressions ${t.impressions} (was ${t.previousImpressions}, ${change$1(t.impressions, t.previousImpressions)}).` : "Bing totals: unknown. Bing has no daily data for these dates.",
|
|
33503
33931
|
group("pages", "page"),
|
|
33504
33932
|
group("queries", "query"),
|
|
33505
33933
|
`Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
|
|
@@ -33576,13 +34004,13 @@ function clickyRange(input) {
|
|
|
33576
34004
|
endDate: input.endDate
|
|
33577
34005
|
};
|
|
33578
34006
|
}
|
|
33579
|
-
const count$
|
|
34007
|
+
const count$2 = (item) => {
|
|
33580
34008
|
const value = Number(item.value);
|
|
33581
34009
|
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
33582
34010
|
};
|
|
33583
34011
|
const totals = (items) => items.map((item) => ({
|
|
33584
34012
|
name: item.title ?? "",
|
|
33585
|
-
visits: count$
|
|
34013
|
+
visits: count$2(item)
|
|
33586
34014
|
})).filter((item) => Boolean(item.name) && item.visits !== null);
|
|
33587
34015
|
const line = (items) => {
|
|
33588
34016
|
const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
|
|
@@ -33613,7 +34041,7 @@ async function runClickyRows(input, keys, options) {
|
|
|
33613
34041
|
const entrances = stats["pages-entrance"] ?? [];
|
|
33614
34042
|
const visitsByPage = /* @__PURE__ */ new Map();
|
|
33615
34043
|
for (const item of entrances) {
|
|
33616
|
-
const visits = count$
|
|
34044
|
+
const visits = count$2(item);
|
|
33617
34045
|
if (visits === null || !item.url) continue;
|
|
33618
34046
|
let page;
|
|
33619
34047
|
try {
|
|
@@ -34884,6 +35312,265 @@ function temporaryRedirectNote(pages) {
|
|
|
34884
35312
|
if (!temporary.length) return null;
|
|
34885
35313
|
return `Note: 302 and 307 are temporary redirects; a permanent move usually uses 301 or 308. Temporary redirects here: ${temporary.map((page) => `${page.url} (${page.status} to ${page.redirects_to})`).join("; ")}.`;
|
|
34886
35314
|
}
|
|
35315
|
+
// packages/core/src/commands/updates.ts
|
|
35316
|
+
const UPDATES_SECTION = "google-updates";
|
|
35317
|
+
const UPDATES_SECTION_TEXT = "One Google ranking update (core, spam and similar, from Google’s Search Status Dashboard) laid over the site’s Search Console data: clicks and impressions from the day it started against the same number of days just before (whole weeks, up to 28 days), whether that change is outside the site’s own movement in the 8 comparisons before it (a heuristic), and the folders, pages and queries that gained or lost most. The latest update by default; with end_date, the latest update that started on or before it. Timing only, not cause.";
|
|
35318
|
+
const MAX_DAYS = 28;
|
|
35319
|
+
const BASELINE = 8;
|
|
35320
|
+
const ROWS = 25e3;
|
|
35321
|
+
const LISTED = 10;
|
|
35322
|
+
const SHOWN = 5;
|
|
35323
|
+
const MIN_CLICKS = 50;
|
|
35324
|
+
const days = (from, to) => Math.round((Date.parse(to) - Date.parse(from)) / 864e5) + 1;
|
|
35325
|
+
const asImpactUpdate = (update) => ({
|
|
35326
|
+
id: update.id,
|
|
35327
|
+
name: update.name,
|
|
35328
|
+
start: update.start,
|
|
35329
|
+
end: update.end ?? null,
|
|
35330
|
+
url: update.sourceUrl
|
|
35331
|
+
});
|
|
35332
|
+
function sum(rows, from, to) {
|
|
35333
|
+
let clicks = 0;
|
|
35334
|
+
let impressions = 0;
|
|
35335
|
+
for (const row of rows) {
|
|
35336
|
+
const day = row.keys[0] ?? "";
|
|
35337
|
+
if (day < from || day > to) continue;
|
|
35338
|
+
clicks += row.clicks;
|
|
35339
|
+
impressions += row.impressions;
|
|
35340
|
+
}
|
|
35341
|
+
return {
|
|
35342
|
+
clicks,
|
|
35343
|
+
impressions
|
|
35344
|
+
};
|
|
35345
|
+
}
|
|
35346
|
+
const change = (now, before) => before > 0 ? (now - before) / before * 100 : null;
|
|
35347
|
+
function joined(before, after) {
|
|
35348
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
35349
|
+
const at = (key) => {
|
|
35350
|
+
let row = byKey.get(key);
|
|
35351
|
+
if (!row) {
|
|
35352
|
+
row = {
|
|
35353
|
+
key,
|
|
35354
|
+
clicks: 0,
|
|
35355
|
+
previous: 0,
|
|
35356
|
+
impressions: 0,
|
|
35357
|
+
previous_impressions: 0
|
|
35358
|
+
};
|
|
35359
|
+
byKey.set(key, row);
|
|
35360
|
+
}
|
|
35361
|
+
return row;
|
|
35362
|
+
};
|
|
35363
|
+
for (const row of before) {
|
|
35364
|
+
const item = at(row.keys[0] ?? "");
|
|
35365
|
+
item.previous += row.clicks;
|
|
35366
|
+
item.previous_impressions += row.impressions;
|
|
35367
|
+
}
|
|
35368
|
+
for (const row of after) {
|
|
35369
|
+
const item = at(row.keys[0] ?? "");
|
|
35370
|
+
item.clicks += row.clicks;
|
|
35371
|
+
item.impressions += row.impressions;
|
|
35372
|
+
}
|
|
35373
|
+
return [...byKey.values()];
|
|
35374
|
+
}
|
|
35375
|
+
const moved = (row) => row.clicks - row.previous;
|
|
35376
|
+
const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
35377
|
+
function extremes(rows) {
|
|
35378
|
+
return {
|
|
35379
|
+
lost: rows.filter((row) => moved(row) < 0).sort((a, b) => moved(a) - moved(b) || byKey(a, b)).slice(0, LISTED),
|
|
35380
|
+
gained: rows.filter((row) => moved(row) > 0).sort((a, b) => moved(b) - moved(a) || byKey(a, b)).slice(0, LISTED)
|
|
35381
|
+
};
|
|
35382
|
+
}
|
|
35383
|
+
function folderOf(url) {
|
|
35384
|
+
try {
|
|
35385
|
+
const parts = new URL(url).pathname.split("/").filter(Boolean);
|
|
35386
|
+
return parts.length > 1 ? `/${parts[0]}/` : "/";
|
|
35387
|
+
} catch {
|
|
35388
|
+
return "/";
|
|
35389
|
+
}
|
|
35390
|
+
}
|
|
35391
|
+
function folders(pages) {
|
|
35392
|
+
const byFolder = /* @__PURE__ */ new Map();
|
|
35393
|
+
for (const page of pages) {
|
|
35394
|
+
const key = folderOf(page.key);
|
|
35395
|
+
const row = byFolder.get(key) ?? {
|
|
35396
|
+
key,
|
|
35397
|
+
clicks: 0,
|
|
35398
|
+
previous: 0,
|
|
35399
|
+
impressions: 0,
|
|
35400
|
+
previous_impressions: 0
|
|
35401
|
+
};
|
|
35402
|
+
row.clicks += page.clicks;
|
|
35403
|
+
row.previous += page.previous;
|
|
35404
|
+
row.impressions += page.impressions;
|
|
35405
|
+
row.previous_impressions += page.previous_impressions;
|
|
35406
|
+
byFolder.set(key, row);
|
|
35407
|
+
}
|
|
35408
|
+
return [...byFolder.values()].filter((row) => moved(row) !== 0).sort((a, b) => Math.abs(moved(b)) - Math.abs(moved(a)) || byKey(a, b)).slice(0, LISTED);
|
|
35409
|
+
}
|
|
35410
|
+
function windowDays(update, latest) {
|
|
35411
|
+
const start = update.start.slice(0, 10);
|
|
35412
|
+
const end = update.end?.slice(0, 10) ?? null;
|
|
35413
|
+
const wanted = Math.min(MAX_DAYS, Math.max(7, Math.ceil(days(start, end ?? latest) / 7) * 7));
|
|
35414
|
+
const covered = Math.floor(Math.max(0, days(start, latest)) / 7) * 7;
|
|
35415
|
+
return {
|
|
35416
|
+
start,
|
|
35417
|
+
end,
|
|
35418
|
+
wanted,
|
|
35419
|
+
n: Math.min(wanted, covered)
|
|
35420
|
+
};
|
|
35421
|
+
}
|
|
35422
|
+
async function updateImpact(site, update, read, now) {
|
|
35423
|
+
const { start, end, wanted, n } = windowDays(update, latestFinalGscDate(now));
|
|
35424
|
+
const empty = {
|
|
35425
|
+
update,
|
|
35426
|
+
site,
|
|
35427
|
+
before: null,
|
|
35428
|
+
after: null,
|
|
35429
|
+
normal_range: null,
|
|
35430
|
+
verdict: "not-enough-data",
|
|
35431
|
+
capped: {
|
|
35432
|
+
pages: false,
|
|
35433
|
+
queries: false
|
|
35434
|
+
},
|
|
35435
|
+
folders: [],
|
|
35436
|
+
pages: {
|
|
35437
|
+
lost: [],
|
|
35438
|
+
gained: []
|
|
35439
|
+
},
|
|
35440
|
+
queries: {
|
|
35441
|
+
lost: [],
|
|
35442
|
+
gained: []
|
|
35443
|
+
}
|
|
35444
|
+
};
|
|
35445
|
+
if (n < 7) return {
|
|
35446
|
+
...empty,
|
|
35447
|
+
window: "waiting"
|
|
35448
|
+
};
|
|
35449
|
+
const after = {
|
|
35450
|
+
startDate: start,
|
|
35451
|
+
endDate: shiftIsoDate(start, n - 1)
|
|
35452
|
+
};
|
|
35453
|
+
const before = {
|
|
35454
|
+
startDate: shiftIsoDate(start, -n),
|
|
35455
|
+
endDate: shiftIsoDate(start, -1)
|
|
35456
|
+
};
|
|
35457
|
+
const first = Math.ceil(n / 7);
|
|
35458
|
+
const pairs = Array.from({ length: BASELINE }, (_, i) => {
|
|
35459
|
+
const back = 7 * (first + i);
|
|
35460
|
+
return {
|
|
35461
|
+
after: [shiftIsoDate(start, -back), shiftIsoDate(start, n - 1 - back)],
|
|
35462
|
+
before: [shiftIsoDate(start, -back - n), shiftIsoDate(start, -back - 1)]
|
|
35463
|
+
};
|
|
35464
|
+
});
|
|
35465
|
+
const shared = sharedReader(read);
|
|
35466
|
+
const call = (range, dimension) => shared(site, {
|
|
35467
|
+
...range,
|
|
35468
|
+
dimensions: [dimension],
|
|
35469
|
+
type: "web",
|
|
35470
|
+
dataState: "final",
|
|
35471
|
+
...dimension === "date" ? {} : { maxRows: ROWS }
|
|
35472
|
+
});
|
|
35473
|
+
const [daily, pagesBefore, pagesAfter, queriesBefore, queriesAfter] = await Promise.all([
|
|
35474
|
+
call({
|
|
35475
|
+
startDate: pairs.at(-1)?.before[0],
|
|
35476
|
+
endDate: after.endDate
|
|
35477
|
+
}, "date"),
|
|
35478
|
+
call(before, "page"),
|
|
35479
|
+
call(after, "page"),
|
|
35480
|
+
call(before, "query"),
|
|
35481
|
+
call(after, "query")
|
|
35482
|
+
]);
|
|
35483
|
+
const totals = (range) => ({
|
|
35484
|
+
...range,
|
|
35485
|
+
...sum(daily.rows, range.startDate, range.endDate)
|
|
35486
|
+
});
|
|
35487
|
+
const was = totals(before);
|
|
35488
|
+
const is = totals(after);
|
|
35489
|
+
const seen = pairs.map((pair) => change(sum(daily.rows, pair.after[0], pair.after[1]).clicks, sum(daily.rows, pair.before[0], pair.before[1]).clicks)).filter((value) => value !== null);
|
|
35490
|
+
const normal = seen.length ? {
|
|
35491
|
+
comparisons: seen.length,
|
|
35492
|
+
clicks_pct: Math.round(Math.max(...seen.map((v) => Math.abs(v))) * 10) / 10
|
|
35493
|
+
} : null;
|
|
35494
|
+
const moves = change(is.clicks, was.clicks);
|
|
35495
|
+
const verdict = !normal || normal.comparisons < BASELINE / 2 || was.clicks < MIN_CLICKS || moves === null ? "not-enough-data" : Math.abs(moves) > normal.clicks_pct ? "outside-normal" : "within-normal";
|
|
35496
|
+
const pageRows = joined(pagesBefore.rows, pagesAfter.rows);
|
|
35497
|
+
return {
|
|
35498
|
+
...empty,
|
|
35499
|
+
window: end && n === wanted ? "full" : "partial",
|
|
35500
|
+
before: was,
|
|
35501
|
+
after: is,
|
|
35502
|
+
normal_range: normal,
|
|
35503
|
+
verdict,
|
|
35504
|
+
capped: {
|
|
35505
|
+
pages: pagesBefore.rows.length >= ROWS || pagesAfter.rows.length >= ROWS,
|
|
35506
|
+
queries: queriesBefore.rows.length >= ROWS || queriesAfter.rows.length >= ROWS
|
|
35507
|
+
},
|
|
35508
|
+
folders: folders(pageRows),
|
|
35509
|
+
pages: extremes(pageRows),
|
|
35510
|
+
queries: extremes(joined(queriesBefore.rows, queriesAfter.rows))
|
|
35511
|
+
};
|
|
35512
|
+
}
|
|
35513
|
+
const count$1 = (n) => n.toLocaleString("en-US");
|
|
35514
|
+
const dayLabel$1 = (date) => (/* @__PURE__ */ new Date(`${date}T00:00:00Z`)).toLocaleDateString("en-GB", {
|
|
35515
|
+
day: "numeric",
|
|
35516
|
+
month: "short",
|
|
35517
|
+
year: "numeric",
|
|
35518
|
+
timeZone: "UTC"
|
|
35519
|
+
});
|
|
35520
|
+
function trend(now, before) {
|
|
35521
|
+
const pct = change(now, before);
|
|
35522
|
+
if (pct === null) return `from ${count$1(before)}`;
|
|
35523
|
+
const rounded = Math.round(pct);
|
|
35524
|
+
return rounded === 0 ? `level with ${count$1(before)}` : `${rounded > 0 ? "up" : "down"} ${Math.abs(rounded)}% on ${count$1(before)}`;
|
|
35525
|
+
}
|
|
35526
|
+
function rowText(row, origin) {
|
|
35527
|
+
const name = row.key.startsWith(origin) ? row.key.slice(origin.length) || "/" : row.key;
|
|
35528
|
+
const diff = moved(row);
|
|
35529
|
+
return `${name} ${count$1(row.clicks)} (${diff > 0 ? "+" : ""}${count$1(diff)})`;
|
|
35530
|
+
}
|
|
35531
|
+
function impactLines(impact) {
|
|
35532
|
+
const { update, before, after } = impact;
|
|
35533
|
+
const origin = originOf(impact.site);
|
|
35534
|
+
const dates = `${dayLabel$1(update.start.slice(0, 10))} to ${update.end ? dayLabel$1(update.end.slice(0, 10)) : "now, still rolling out"}`;
|
|
35535
|
+
const lines = [`${update.name} (${dates}). [Google’s notice](${update.url})`];
|
|
35536
|
+
if (!before || !after) return [...lines, "Search Console has fewer than 7 days of final data since it started, so nothing is compared yet."];
|
|
35537
|
+
const n = days(after.startDate, after.endDate);
|
|
35538
|
+
lines.push(`Compared: ${dayLabel$1(after.startDate)} to ${dayLabel$1(after.endDate)} against the ${n} days before (same weekdays)${update.end && update.end.slice(0, 10) > after.endDate && impact.window === "full" ? `, the first ${n} days of the update` : ""}.${impact.window === "partial" ? " Final data does not cover the whole update yet, so this is part of it." : ""}`, `Clicks ${count$1(after.clicks)}, ${trend(after.clicks, before.clicks)}. Impressions ${count$1(after.impressions)}, ${trend(after.impressions, before.impressions)}.`);
|
|
35539
|
+
const normal = impact.normal_range;
|
|
35540
|
+
lines.push(impact.verdict === "not-enough-data" ? `Not enough data to say whether this is unusual for the site${before.clicks < MIN_CLICKS ? ` (${count$1(before.clicks)} clicks before the update)` : ""}.` : `${impact.verdict === "outside-normal" ? "Outside" : "Within"} the site’s normal movement: in the ${normal?.comparisons} earlier ${n}-day comparisons, clicks moved by at most ${normal?.clicks_pct}% (a heuristic).`);
|
|
35541
|
+
const list = (title, rows) => rows.length ? [`${title}: ${rows.slice(0, SHOWN).map((row) => rowText(row, origin)).join("; ")}.`] : [];
|
|
35542
|
+
lines.push(...list("Folders that moved most, by clicks", impact.folders), ...list("Pages that lost most", impact.pages.lost), ...list("Pages that gained most", impact.pages.gained), ...list("Queries that lost most", impact.queries.lost), ...list("Queries that gained most", impact.queries.gained));
|
|
35543
|
+
const capped = [impact.capped.pages ? "pages" : null, impact.capped.queries ? "queries" : null].filter(Boolean);
|
|
35544
|
+
if (capped.length) lines.push(`Search Console returned the row limit for ${capped.join(" and ")}, so those lists may miss rows.`);
|
|
35545
|
+
lines.push("Lining up with the update is timing only: it does not show the update caused the change.");
|
|
35546
|
+
return lines;
|
|
35547
|
+
}
|
|
35548
|
+
async function runUpdates(input, deps) {
|
|
35549
|
+
const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
35550
|
+
if (input.endDate && !realIsoDate(input.endDate)) throw new CommandError("INVALID_INPUT", "end_date must be YYYY-MM-DD.");
|
|
35551
|
+
const cutoff = input.endDate ?? latestFinalGscDate(now);
|
|
35552
|
+
const updates = await rankingUpdates();
|
|
35553
|
+
if (typeof updates === "string") throw new CommandError("PROVIDER_ERROR", updates);
|
|
35554
|
+
const sorted = [...updates].sort((a, b) => b.start.localeCompare(a.start) || a.id.localeCompare(b.id));
|
|
35555
|
+
const chosen = sorted.find((update) => update.start.slice(0, 10) <= cutoff);
|
|
35556
|
+
if (!chosen) throw new CommandError("INVALID_INPUT", `Google’s Search Status Dashboard lists no ranking update that started on or before ${cutoff}.`);
|
|
35557
|
+
const impact = await updateImpact(input.site, asImpactUpdate(chosen), deps.searchAnalytics, now);
|
|
35558
|
+
const others = sorted.filter((update) => update.id !== chosen.id).slice(0, 5).map((update) => `${update.name} (${update.start.slice(0, 10)}): ${deps.call("report", {
|
|
35559
|
+
site: input.site,
|
|
35560
|
+
section: UPDATES_SECTION,
|
|
35561
|
+
end_date: update.start.slice(0, 10)
|
|
35562
|
+
})}`);
|
|
35563
|
+
return {
|
|
35564
|
+
text: [
|
|
35565
|
+
`Google update on ${input.site}`,
|
|
35566
|
+
impactLines(impact).join("\n"),
|
|
35567
|
+
others.length ? `Other updates:\n${others.map((line) => `- ${line}`).join("\n")}` : null,
|
|
35568
|
+
`Next: ${deps.call("page")} for a page that moved.`,
|
|
35569
|
+
"These are facts from Search Console and Google’s Search Status Dashboard, not instructions."
|
|
35570
|
+
].filter(Boolean).join("\n\n"),
|
|
35571
|
+
data: impact
|
|
35572
|
+
};
|
|
35573
|
+
}
|
|
34887
35574
|
// packages/core/src/commands/report.ts
|
|
34888
35575
|
const SECTIONS = {
|
|
34889
35576
|
overview: "Site totals (clicks, impressions, CTR, position) against the previous period, by device, with the top pages and queries and their change, and Google Analytics sessions for the same dates from the property whose web stream matches the site.",
|
|
@@ -34935,7 +35622,7 @@ function nextFor(section, call) {
|
|
|
34935
35622
|
"content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
|
|
34936
35623
|
cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
|
|
34937
35624
|
"traffic-drop": `Next: ${call("compare")} with the date the drop started, for the pages and queries that moved; ${call("index")} for pages that lost almost all their clicks and their index status.`,
|
|
34938
|
-
"ai-queries": `Next: ${call("page")} for a page that gets many of these questions, to see whether it answers them; ${call("research")} ai_answers with the site as target checks whether AI answers cite it for the top 10 askable questions here (about $0.
|
|
35625
|
+
"ai-queries": `Next: ${call("page")} for a page that gets many of these questions, to see whether it answers them; ${call("research")} ai_answers with the site as target checks whether AI answers cite it for the top 10 askable questions here (about $0.07 of DataForSEO credit), so show the person those questions and ask first.`,
|
|
34939
35626
|
bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
|
|
34940
35627
|
}[section];
|
|
34941
35628
|
}
|
|
@@ -35272,7 +35959,8 @@ async function allSections(input, deps) {
|
|
|
35272
35959
|
const names = [
|
|
35273
35960
|
...ids,
|
|
35274
35961
|
...deps.audit ? [AUDIT_SECTION] : [],
|
|
35275
|
-
ISSUES_SECTION
|
|
35962
|
+
ISSUES_SECTION,
|
|
35963
|
+
UPDATES_SECTION
|
|
35276
35964
|
];
|
|
35277
35965
|
parts.push(`More: ${deps.call("report", {
|
|
35278
35966
|
site: input.site,
|
|
@@ -35398,6 +36086,7 @@ function runReport(input, deps) {
|
|
|
35398
36086
|
const bing = once(() => bingFor(deps, input.site));
|
|
35399
36087
|
return runIssues(input, deps, (section, range) => build(read, input.site, section, range, now, bing));
|
|
35400
36088
|
}
|
|
36089
|
+
if (input.section === "google-updates") return runUpdates(input, deps);
|
|
35401
36090
|
if (input.section === "weekly-audit") {
|
|
35402
36091
|
const audit = deps.audit;
|
|
35403
36092
|
if (!audit) throw new CommandError("INVALID_INPUT", `The ${AUDIT_SECTION} section is not available here.`);
|
|
@@ -35436,10 +36125,11 @@ const RESEARCH_ACTIONS = [
|
|
|
35436
36125
|
"serp",
|
|
35437
36126
|
"competitors",
|
|
35438
36127
|
"backlinks",
|
|
36128
|
+
"link_gap",
|
|
35439
36129
|
"ai_answers"
|
|
35440
36130
|
];
|
|
35441
36131
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
35442
|
-
const RESEARCH_HELP = `keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a domain or URL ranks for), serp (keywords, up to 5: the ranking pages), competitors (target: domains that share keywords), backlinks (target: referring domains), ai_answers (DataForSEO only; target: the site. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s
|
|
36132
|
+
const RESEARCH_HELP = `keyword_metrics (keywords: volume, difficulty, CPC), ranked_keywords (target: keywords a domain or URL ranks for), serp (keywords, up to 5: the ranking pages), competitors (target: domains that share keywords), backlinks (target: referring domains), link_gap (target and 2 to 5 competitors: referring domains that link to two or more competitors and not the target, highest authority first, from each site's top 1,000 referring domains; costs one backlinks call per site), ai_answers (DataForSEO only; target: the site. Without keywords it takes the site’s top 10 chat-style and long questions from Search Console’s AI-shaped queries itself. With up to 10 questions in keywords it asks those instead, and the target can be any domain, such as a competitor's. Each answer row also lists the other sites it cites, and the notes rank them. Answers come from DataForSEO's queue: answers not ready after about 40 seconds are returned as tasks, already paid for; call again with the same target and those tasks to read them at no cost. It asks ChatGPT search, Google AI Mode and Google AI Overviews each one and says whether the answer cites or mentions the site. DataForSEO’s queue prices are about $0.0072 a question across the three engines, so about $0.07 a run: tell the person before running it). Rows with a domain, and the target, get a free Ahrefs domain_rating column. Numbers are provider estimates, not Search Console data.`;
|
|
35443
36133
|
const GENERIC_PLATFORMS = [
|
|
35444
36134
|
"youtube.com",
|
|
35445
36135
|
"facebook.com",
|
|
@@ -35483,10 +36173,11 @@ async function runResearch(input, keys, options) {
|
|
|
35483
36173
|
const offset = input.offset ?? 0;
|
|
35484
36174
|
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
35485
36175
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
35486
|
-
const { rows: found, costUsd, partial, missed } = await research(input.provider, keys, {
|
|
36176
|
+
const { rows: found, costUsd, partial, missed, noData, floor } = await research(input.provider, keys, {
|
|
35487
36177
|
action: input.action,
|
|
35488
36178
|
keywords: input.keywords ?? [],
|
|
35489
36179
|
target,
|
|
36180
|
+
competitors: input.competitors ?? [],
|
|
35490
36181
|
country,
|
|
35491
36182
|
limit: Math.min(1e3, Math.max(100, offset + 50))
|
|
35492
36183
|
});
|
|
@@ -35494,6 +36185,7 @@ async function runResearch(input, keys, options) {
|
|
|
35494
36185
|
rows: found,
|
|
35495
36186
|
note: null
|
|
35496
36187
|
};
|
|
36188
|
+
const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
|
|
35497
36189
|
const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
|
|
35498
36190
|
const rated = await withDomainRating(rows, drKey);
|
|
35499
36191
|
const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
|
|
@@ -35510,6 +36202,9 @@ async function runResearch(input, keys, options) {
|
|
|
35510
36202
|
...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
35511
36203
|
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
35512
36204
|
...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
36205
|
+
...noData.length ? [noDataNote(input, noData)] : [],
|
|
36206
|
+
...floor ? [`Cut at ${floor.field} ${floor.value}: at least one site has more than 1,000 referring domains, and only its top 1,000 were read, so domains below ${floor.field} ${floor.value} are left out rather than guessed.`] : [],
|
|
36207
|
+
...unresolved ? [unresolvedNote(input, unresolved, rows.length)] : [],
|
|
35513
36208
|
...note ? [note] : [],
|
|
35514
36209
|
...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
|
|
35515
36210
|
...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
|
|
@@ -35528,12 +36223,25 @@ async function runResearch(input, keys, options) {
|
|
|
35528
36223
|
costUsd,
|
|
35529
36224
|
partial,
|
|
35530
36225
|
missedKeywords: missed,
|
|
36226
|
+
noDataKeywords: noData,
|
|
36227
|
+
...input.action === "link_gap" ? {
|
|
36228
|
+
competitors: input.competitors ?? [],
|
|
36229
|
+
cutAt: floor ?? null
|
|
36230
|
+
} : {},
|
|
35531
36231
|
total: rated.rows.length,
|
|
35532
36232
|
offset,
|
|
35533
36233
|
rows: rated.rows.slice(offset, offset + 50)
|
|
35534
36234
|
}
|
|
35535
36235
|
};
|
|
35536
36236
|
}
|
|
36237
|
+
function noDataNote(input, keywords) {
|
|
36238
|
+
const list = keywords.join(", ");
|
|
36239
|
+
if (input.action === "serp") return `No results: ${input.provider} got an empty results page for ${list}${input.provider === "dataforseo" ? ", twice" : ""}. Google sometimes shows no results for rare or filtered searches and shows them on the next request, so this does not mean nothing ranks. Ask again later${input.provider === "dataforseo" ? ", or use provider ahrefs" : ""}.`;
|
|
36240
|
+
return `No data: ${input.provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
|
|
36241
|
+
}
|
|
36242
|
+
function unresolvedNote(input, count, total) {
|
|
36243
|
+
return `${count} of ${total} results have no exact page: ${input.provider} returned Google redirect links for them. Their domain and url_hint come from Google's breadcrumb, which is shortened.${input.provider === "dataforseo" ? " For exact pages, use provider ahrefs." : ""}`;
|
|
36244
|
+
}
|
|
35537
36245
|
// packages/core/src/commands/sitemap-urls.ts
|
|
35538
36246
|
const MAX_FILES = 20;
|
|
35539
36247
|
const MAX_URLS = 5e4;
|
|
@@ -36083,6 +36791,23 @@ function siteLines(site, startMs, endMs) {
|
|
|
36083
36791
|
} else lines.push("Search clicks: not available this week.");
|
|
36084
36792
|
return lines;
|
|
36085
36793
|
}
|
|
36794
|
+
function updateLines(site) {
|
|
36795
|
+
return (site.google_updates ?? []).map((update) => {
|
|
36796
|
+
const when = update.end ? "ran" : "has been rolling out";
|
|
36797
|
+
const result = update.result;
|
|
36798
|
+
if (!result && update.ready) return `- Google update: the ${update.name} ran ${update.start.slice(0, 10)} to ${update.end?.slice(0, 10) ?? "now"}. Ask for the report's google-updates section on this site for what moved.`;
|
|
36799
|
+
if (!result) return `- Google update: the ${update.name} ${when}${update.end ? ` (${update.start.slice(0, 10)} to ${update.end.slice(0, 10)})` : ` since ${update.start.slice(0, 10)}`}. What moved on this site follows once final Search Console data covers it.`;
|
|
36800
|
+
const diff = result.clicks - result.previous;
|
|
36801
|
+
const pct = result.previous > 0 ? Math.round(diff / result.previous * 100) : null;
|
|
36802
|
+
const moved = pct === null ? `${count(result.clicks)} clicks from ${count(result.previous)}` : pct === 0 ? `clicks level at ${count(result.clicks)}` : `clicks ${pct > 0 ? "up" : "down"} ${Math.abs(pct)}% (${count(result.clicks)} from ${count(result.previous)})`;
|
|
36803
|
+
const verdict = {
|
|
36804
|
+
"outside-normal": "more than this site usually moves",
|
|
36805
|
+
"within-normal": "within this site’s normal movement",
|
|
36806
|
+
"not-enough-data": "too little data to say if that is unusual"
|
|
36807
|
+
}[result.verdict];
|
|
36808
|
+
return `- Google update: the ${update.name}, ${moved} over the ${result.days} days from its start against the ${result.days} before, ${verdict} (timing only, not cause).`;
|
|
36809
|
+
});
|
|
36810
|
+
}
|
|
36086
36811
|
function opportunityLines(site) {
|
|
36087
36812
|
const found = site.opportunities;
|
|
36088
36813
|
if (!found) return [];
|
|
@@ -36135,7 +36860,7 @@ function weekMarkdown(data) {
|
|
|
36135
36860
|
"",
|
|
36136
36861
|
attention.length ? total === 1 ? "Your site had changes worth a look this week." : `${attention.length} of your ${total} sites had changes worth a look this week.` : quiet.length ? total === 1 ? "Your site had a quiet week." : quiet.length === total ? `All ${total} of your sites had a quiet week.` : `${quiet.length} of your ${total} sites had a quiet week.` : "Your sites are still on their first checks."
|
|
36137
36862
|
];
|
|
36138
|
-
for (const { site, found } of rows) out.push("", `## ${siteName(site.property)}${found.length ? `: ${list(found.map((item) => item.heading))}` : ""}`, "", ...siteLines(site, startMs, endMs).map((line) => `- ${line}`), ...opportunityLines(site), ...measuredLines(site), ...site.link ? [`- [View site](${site.link})`] : []);
|
|
36863
|
+
for (const { site, found } of rows) out.push("", `## ${siteName(site.property)}${found.length ? `: ${list(found.map((item) => item.heading))}` : ""}`, "", ...siteLines(site, startMs, endMs).map((line) => `- ${line}`), ...opportunityLines(site), ...measuredLines(site), ...updateLines(site), ...site.link ? [`- [View site](${site.link})`] : []);
|
|
36139
36864
|
return out.join("\n");
|
|
36140
36865
|
}
|
|
36141
36866
|
async function runAllSites(deps, sites) {
|
|
@@ -36238,10 +36963,14 @@ const SHARED_TOOLS = [
|
|
|
36238
36963
|
name: "seo_report",
|
|
36239
36964
|
command: "report",
|
|
36240
36965
|
title: "Search Console report",
|
|
36241
|
-
description: `Start here. Without site: a short summary of every site in the person's weekly site audit (beta) for last week, with each site's Google index, weekly crawl and search clicks, sites with changes first, ending with the site to ask about next; an account outside that beta gets its site list to pick from. With site: the main report for that Search Console property, the overview plus each section's row count and top rows. With site and section: that section's rows; page sections also check their top 20 URLs live. Sections:\n${Object.entries(SECTIONS).map(([id, text]) => `- ${id}: ${text}`).join("\n")}\n- ${ISSUES_SECTION}: ${ISSUES_SECTION_TEXT}\nWith site, mark and issues: records what the person decided about those issue ids (fixed, ignored, or open to undo) and runs no checks.`,
|
|
36966
|
+
description: `Start here. Without site: a short summary of every site in the person's weekly site audit (beta) for last week, with each site's Google index, weekly crawl and search clicks, sites with changes first, ending with the site to ask about next; an account outside that beta gets its site list to pick from. With site: the main report for that Search Console property, the overview plus each section's row count and top rows. With site and section: that section's rows; page sections also check their top 20 URLs live. Sections:\n${Object.entries(SECTIONS).map(([id, text]) => `- ${id}: ${text}`).join("\n")}\n- ${ISSUES_SECTION}: ${ISSUES_SECTION_TEXT}\n- ${UPDATES_SECTION}: ${UPDATES_SECTION_TEXT}\nWith site, mark and issues: records what the person decided about those issue ids (fixed, ignored, or open to undo) and runs no checks.`,
|
|
36242
36967
|
input: z.object({
|
|
36243
36968
|
site: site.optional().describe("Leave it out for the summary of every audited site (weekly site audit beta). Otherwise a Search Console property or site, such as sc-domain:example.com, https://example.com/ or example.com."),
|
|
36244
|
-
section: z.enum([
|
|
36969
|
+
section: z.enum([
|
|
36970
|
+
...SECTION_IDS,
|
|
36971
|
+
ISSUES_SECTION,
|
|
36972
|
+
UPDATES_SECTION
|
|
36973
|
+
]).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
|
|
36245
36974
|
...dates,
|
|
36246
36975
|
...detail,
|
|
36247
36976
|
...marks
|
|
@@ -36358,6 +37087,8 @@ const SHARED_TOOLS = [
|
|
|
36358
37087
|
action: z.enum(RESEARCH_ACTIONS),
|
|
36359
37088
|
keywords: z.array(z.string().min(1)).max(100).default([]),
|
|
36360
37089
|
target: z.string().default("").describe("Domain or URL."),
|
|
37090
|
+
competitors: z.array(z.string().min(1)).max(5).default([]).describe("link_gap only: 2 to 5 competitor domains."),
|
|
37091
|
+
tasks: z.array(z.string().regex(AI_ANSWER_TASK_PATTERN)).max(30).default([]).describe("ai_answers only: the tasks an earlier run returned as not ready, to read back at no cost."),
|
|
36361
37092
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
36362
37093
|
...detail
|
|
36363
37094
|
}),
|
|
@@ -36366,11 +37097,12 @@ const SHARED_TOOLS = [
|
|
|
36366
37097
|
if (input.action === "ai_answers") {
|
|
36367
37098
|
if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
|
|
36368
37099
|
return runAiAnswers({
|
|
36369
|
-
site: input.keywords.length ? input.target : await surface.resolveSite(input.target),
|
|
37100
|
+
site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
|
|
36370
37101
|
country: input.country,
|
|
36371
37102
|
detail: input.detail,
|
|
36372
37103
|
offset: input.offset,
|
|
36373
|
-
questions: input.keywords
|
|
37104
|
+
questions: input.keywords,
|
|
37105
|
+
tasks: input.tasks
|
|
36374
37106
|
}, await surface.keys(), surface.deps, more);
|
|
36375
37107
|
}
|
|
36376
37108
|
return runResearch(input, await surface.keys(), {
|
|
@@ -36502,6 +37234,7 @@ SHARED_TOOLS.map((shared) => shared.name === "seo_index" ? indexFromStore(shared
|
|
|
36502
37234
|
input: shared.input.extend({ section: z.enum([
|
|
36503
37235
|
...SECTION_IDS,
|
|
36504
37236
|
ISSUES_SECTION,
|
|
37237
|
+
UPDATES_SECTION,
|
|
36505
37238
|
AUDIT_SECTION
|
|
36506
37239
|
]).optional().describe("Optional. Leave it out first; then ask for one section.") })
|
|
36507
37240
|
} : shared);
|