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/mcp.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
|
|
2
|
-
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-
|
|
2
|
+
import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-CV5s7esU.js";
|
|
3
3
|
import { createHash, randomUUID, sign } from "node:crypto";
|
|
4
4
|
import { ZodOptional, z } from "zod";
|
|
5
5
|
import { arch, homedir, platform, tmpdir, totalmem } from "node:os";
|
|
6
6
|
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
7
|
import "open";
|
|
8
|
-
import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { chmodSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import lockfile from "proper-lockfile";
|
|
10
10
|
import "node:http";
|
|
11
11
|
import { Headers as Headers$1, fetch as fetch$1 } from "undici";
|
|
@@ -12461,6 +12461,24 @@ const RULE_DEFINITIONS = [
|
|
|
12461
12461
|
suggestedCommands: ["seo crawl <url> --max-pages 1 --json"]
|
|
12462
12462
|
}
|
|
12463
12463
|
},
|
|
12464
|
+
{
|
|
12465
|
+
id: "crawl_single_page_reach",
|
|
12466
|
+
title: "Crawl could reach only the start page",
|
|
12467
|
+
category: "links",
|
|
12468
|
+
defaultSeverity: "high",
|
|
12469
|
+
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.",
|
|
12470
|
+
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.",
|
|
12471
|
+
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.",
|
|
12472
|
+
howToVerify: "Rerun the crawl and confirm it retains more than one page and the start page reports internal links.",
|
|
12473
|
+
agentHints: {
|
|
12474
|
+
evidenceFields: [
|
|
12475
|
+
"page.outgoingInternalCount",
|
|
12476
|
+
"report.sitemapDiscovery.urlsReturned",
|
|
12477
|
+
"page.fetchDiagnostics.rendering"
|
|
12478
|
+
],
|
|
12479
|
+
suggestedCommands: ["seo crawl <url> --rendering on --json", "seo crawl --mode list --urls <url>,<url> --json"]
|
|
12480
|
+
}
|
|
12481
|
+
},
|
|
12464
12482
|
{
|
|
12465
12483
|
id: "soft_404",
|
|
12466
12484
|
title: "Unknown URLs return a successful response",
|
|
@@ -12469,7 +12487,7 @@ const RULE_DEFINITIONS = [
|
|
|
12469
12487
|
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.",
|
|
12470
12488
|
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.",
|
|
12471
12489
|
impactIfIgnored: "Missing URLs can remain in crawl queues, waste crawl attention, and create confusing search or monitoring evidence.",
|
|
12472
|
-
howToVerify: "Request several
|
|
12490
|
+
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.",
|
|
12473
12491
|
agentHints: {
|
|
12474
12492
|
evidenceFields: ["report.siteChecks.soft404"],
|
|
12475
12493
|
suggestedCommands: ["seo crawl <url> --json"]
|
|
@@ -13918,7 +13936,7 @@ function day$3(value) {
|
|
|
13918
13936
|
const date = legacy ? new Date(Number(legacy[1])) : new Date(value);
|
|
13919
13937
|
return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
|
|
13920
13938
|
}
|
|
13921
|
-
const count$
|
|
13939
|
+
const count$4 = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
13922
13940
|
function list$2(data, label) {
|
|
13923
13941
|
if (!Array.isArray(data)) throw new SeoError("OPTIONAL_PROVIDER_UNAVAILABLE", `Bing Webmaster returned an invalid ${label}.`);
|
|
13924
13942
|
return data.filter((item) => Boolean(item) && typeof item === "object");
|
|
@@ -13934,8 +13952,8 @@ async function bingTraffic(call, site) {
|
|
|
13934
13952
|
let invalidRows = 0;
|
|
13935
13953
|
for (const row of list$2(await call("GetRankAndTrafficStats", { siteUrl: site }), "traffic list")) {
|
|
13936
13954
|
const date = day$3(row.Date);
|
|
13937
|
-
const clicks = count$
|
|
13938
|
-
const impressions = count$
|
|
13955
|
+
const clicks = count$4(row.Clicks);
|
|
13956
|
+
const impressions = count$4(row.Impressions);
|
|
13939
13957
|
if (!date || clicks === null || impressions === null) {
|
|
13940
13958
|
invalidRows += 1;
|
|
13941
13959
|
continue;
|
|
@@ -13958,9 +13976,9 @@ async function keyed(call, method, params) {
|
|
|
13958
13976
|
for (const row of list$2(await call(method, params), "row list")) {
|
|
13959
13977
|
const date = day$3(row.Date);
|
|
13960
13978
|
const key = typeof row.Query === "string" ? row.Query.trim() : "";
|
|
13961
|
-
const clicks = count$
|
|
13962
|
-
const impressions = count$
|
|
13963
|
-
const position = count$
|
|
13979
|
+
const clicks = count$4(row.Clicks);
|
|
13980
|
+
const impressions = count$4(row.Impressions);
|
|
13981
|
+
const position = count$4(row.AvgImpressionPosition);
|
|
13964
13982
|
if (!date || !key || clicks === null || impressions === null) {
|
|
13965
13983
|
invalidRows += 1;
|
|
13966
13984
|
continue;
|
|
@@ -14491,6 +14509,14 @@ function sharedReader(read, redirects = /* @__PURE__ */ new Map()) {
|
|
|
14491
14509
|
};
|
|
14492
14510
|
}
|
|
14493
14511
|
const MAX_STATUS_BYTES = 10485760;
|
|
14512
|
+
async function rankingUpdates() {
|
|
14513
|
+
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);
|
|
14514
|
+
if (!Array.isArray(incidents)) return "Google Search Status could not be read.";
|
|
14515
|
+
return searchUpdatesFromIncidents(withRankingHistory(incidents), {
|
|
14516
|
+
product: "Ranking",
|
|
14517
|
+
limit: Number.POSITIVE_INFINITY
|
|
14518
|
+
});
|
|
14519
|
+
}
|
|
14494
14520
|
async function googleUpdates(window) {
|
|
14495
14521
|
if (!window) return [];
|
|
14496
14522
|
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);
|
|
@@ -14823,7 +14849,7 @@ async function readableSites(google, signIn) {
|
|
|
14823
14849
|
permissionLevel: entry.permissionLevel ?? ""
|
|
14824
14850
|
})).sort((a, b) => a.siteUrl.localeCompare(b.siteUrl));
|
|
14825
14851
|
}
|
|
14826
|
-
const hostOf$
|
|
14852
|
+
const hostOf$3 = (value) => {
|
|
14827
14853
|
try {
|
|
14828
14854
|
return new URL(value.includes("://") ? value : `https://${value.replace(/^sc-domain:/, "")}`).hostname.replace(/^www\./, "");
|
|
14829
14855
|
} catch {
|
|
@@ -14841,14 +14867,14 @@ async function runSites(input) {
|
|
|
14841
14867
|
};
|
|
14842
14868
|
})), input.bing ? listBingSites(input.bing).catch((error) => error instanceof Error ? error : new Error(String(error))) : Promise.resolve(null)]);
|
|
14843
14869
|
const bingSites = Array.isArray(bing) ? bing.filter((site) => site.verified) : [];
|
|
14844
|
-
const bingByHost = new Map(bingSites.map((site) => [hostOf$
|
|
14870
|
+
const bingByHost = new Map(bingSites.map((site) => [hostOf$3(site.url), site]));
|
|
14845
14871
|
const properties = google.flatMap((account) => account.properties instanceof Error ? [] : account.properties.map((property) => ({
|
|
14846
14872
|
...property,
|
|
14847
14873
|
...named ? { account: account.email ?? "" } : {},
|
|
14848
|
-
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$
|
|
14874
|
+
...Array.isArray(bing) ? { bing: bingByHost.get(hostOf$3(property.siteUrl))?.url ?? "" } : {}
|
|
14849
14875
|
})));
|
|
14850
|
-
const googleHosts = new Set(properties.map((property) => hostOf$
|
|
14851
|
-
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$
|
|
14876
|
+
const googleHosts = new Set(properties.map((property) => hostOf$3(property.siteUrl)));
|
|
14877
|
+
const bingOnly = bingSites.filter((site) => !googleHosts.has(hostOf$3(site.url)));
|
|
14852
14878
|
const failed = google.filter((account) => account.properties instanceof Error);
|
|
14853
14879
|
const analytics = google.flatMap((account) => (account.analytics ?? []).flatMap((summary) => summary.propertySummaries.map((property) => ({
|
|
14854
14880
|
property: property.property.replace("properties/", ""),
|
|
@@ -19543,6 +19569,56 @@ function systemBrowserCandidates(input) {
|
|
|
19543
19569
|
product: path.includes("google-chrome") ? "Google Chrome" : "Chromium"
|
|
19544
19570
|
}));
|
|
19545
19571
|
}
|
|
19572
|
+
function listDirectorySafely(path) {
|
|
19573
|
+
try {
|
|
19574
|
+
return readdirSync(path);
|
|
19575
|
+
} catch {
|
|
19576
|
+
return [];
|
|
19577
|
+
}
|
|
19578
|
+
}
|
|
19579
|
+
function versionParts(name) {
|
|
19580
|
+
return (name.match(/\d+/g) ?? []).map(Number);
|
|
19581
|
+
}
|
|
19582
|
+
function newestFirst(left, right) {
|
|
19583
|
+
const a = versionParts(left);
|
|
19584
|
+
const b = versionParts(right);
|
|
19585
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
19586
|
+
const difference = (b[index] ?? -1) - (a[index] ?? -1);
|
|
19587
|
+
if (difference) return difference;
|
|
19588
|
+
}
|
|
19589
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
19590
|
+
}
|
|
19591
|
+
const MAX_CACHED_BROWSER_VERSIONS = 20;
|
|
19592
|
+
function versionDirectories(root, prefix, listDirectory) {
|
|
19593
|
+
return listDirectory(root).filter((name) => name.startsWith(prefix)).sort(newestFirst).slice(0, MAX_CACHED_BROWSER_VERSIONS).map((name) => join(root, name));
|
|
19594
|
+
}
|
|
19595
|
+
const CHROME_FOR_TESTING_APP = join("Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
|
|
19596
|
+
/**
|
|
19597
|
+
* Browsers that other local tools already downloaded: agent-browser keeps
|
|
19598
|
+
* Chrome for Testing builds and Playwright keeps one Chromium per revision.
|
|
19599
|
+
*/
|
|
19600
|
+
function cachedBrowserCandidates(input) {
|
|
19601
|
+
const agentBrowserExecutables = input.platform === "darwin" ? [CHROME_FOR_TESTING_APP] : input.platform === "win32" ? ["chrome.exe"] : ["chrome"];
|
|
19602
|
+
const agentBrowser = versionDirectories(join(input.homeDirectory, ".agent-browser", "browsers"), "chrome-", input.listDirectory).flatMap((directory) => agentBrowserExecutables.map((executable) => ({
|
|
19603
|
+
path: join(directory, executable),
|
|
19604
|
+
source: "agent-browser-cache",
|
|
19605
|
+
product: "agent-browser Chrome for Testing"
|
|
19606
|
+
})));
|
|
19607
|
+
const configuredRoot = input.environment.PLAYWRIGHT_BROWSERS_PATH?.trim();
|
|
19608
|
+
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");
|
|
19609
|
+
const playwrightExecutables = input.platform === "darwin" ? [
|
|
19610
|
+
join("chrome-mac-arm64", CHROME_FOR_TESTING_APP),
|
|
19611
|
+
join("chrome-mac-x64", CHROME_FOR_TESTING_APP),
|
|
19612
|
+
join("chrome-mac", CHROME_FOR_TESTING_APP),
|
|
19613
|
+
join("chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium")
|
|
19614
|
+
] : input.platform === "win32" ? [join("chrome-win64", "chrome.exe"), join("chrome-win", "chrome.exe")] : [join("chrome-linux64", "chrome"), join("chrome-linux", "chrome")];
|
|
19615
|
+
const playwright = versionDirectories(playwrightRoot, "chromium-", input.listDirectory).flatMap((directory) => playwrightExecutables.map((executable) => ({
|
|
19616
|
+
path: join(directory, executable),
|
|
19617
|
+
source: "playwright-cache",
|
|
19618
|
+
product: "Playwright Chromium"
|
|
19619
|
+
})));
|
|
19620
|
+
return [...agentBrowser, ...playwright];
|
|
19621
|
+
}
|
|
19546
19622
|
/**
|
|
19547
19623
|
* Resolve a browser without downloading one during normal package installation.
|
|
19548
19624
|
* The explicit path wins so CI and managed workstations can select their own
|
|
@@ -19574,11 +19650,23 @@ function resolveBrowserExecutable(playwrightExecutablePath, dependencies = {}) {
|
|
|
19574
19650
|
product: "Playwright Chromium"
|
|
19575
19651
|
}
|
|
19576
19652
|
};
|
|
19577
|
-
const
|
|
19578
|
-
|
|
19579
|
-
|
|
19653
|
+
const platform = dependencies.platform ?? process.platform;
|
|
19654
|
+
const homeDirectory = dependencies.homeDirectory ?? homedir();
|
|
19655
|
+
const systemBrowser = systemBrowserCandidates({
|
|
19656
|
+
platform,
|
|
19657
|
+
homeDirectory,
|
|
19580
19658
|
environment
|
|
19581
19659
|
}).find((candidate) => fileExists(candidate.path));
|
|
19660
|
+
if (systemBrowser) return {
|
|
19661
|
+
status: "available",
|
|
19662
|
+
browser: systemBrowser
|
|
19663
|
+
};
|
|
19664
|
+
const browser = cachedBrowserCandidates({
|
|
19665
|
+
platform,
|
|
19666
|
+
homeDirectory,
|
|
19667
|
+
environment,
|
|
19668
|
+
listDirectory: dependencies.listDirectory ?? listDirectorySafely
|
|
19669
|
+
}).find((candidate) => fileExists(candidate.path));
|
|
19582
19670
|
return browser ? {
|
|
19583
19671
|
status: "available",
|
|
19584
19672
|
browser
|
|
@@ -22686,38 +22774,61 @@ function isValuablePage(page) {
|
|
|
22686
22774
|
function normalizedMetadata(value) {
|
|
22687
22775
|
return value?.trim().replace(/\s+/g, " ").toLowerCase() || void 0;
|
|
22688
22776
|
}
|
|
22689
|
-
|
|
22690
|
-
|
|
22777
|
+
/**
|
|
22778
|
+
* A page whose canonical names another crawled page in its group is already
|
|
22779
|
+
* handled. A group counts only when two or more of its pages are
|
|
22780
|
+
* self-canonical or declare no canonical, and only those pages are reported.
|
|
22781
|
+
*/
|
|
22782
|
+
function duplicateGroups(pages, read) {
|
|
22783
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
22691
22784
|
for (const page of pages) {
|
|
22692
22785
|
if (!isAuditableHtmlPage(page)) continue;
|
|
22693
|
-
const
|
|
22694
|
-
|
|
22695
|
-
|
|
22696
|
-
|
|
22697
|
-
|
|
22698
|
-
sampleUrls: [],
|
|
22699
|
-
value: raw
|
|
22786
|
+
const entry = read(page);
|
|
22787
|
+
if (!entry) continue;
|
|
22788
|
+
const group = grouped.get(entry.key) ?? {
|
|
22789
|
+
value: entry.value,
|
|
22790
|
+
pages: []
|
|
22700
22791
|
};
|
|
22701
|
-
|
|
22702
|
-
|
|
22703
|
-
|
|
22792
|
+
group.pages.push(page);
|
|
22793
|
+
grouped.set(entry.key, group);
|
|
22794
|
+
}
|
|
22795
|
+
const duplicates = /* @__PURE__ */ new Map();
|
|
22796
|
+
for (const [key, group] of grouped) {
|
|
22797
|
+
if (group.pages.length < 2) continue;
|
|
22798
|
+
const groupUrls = /* @__PURE__ */ new Set();
|
|
22799
|
+
for (const page of group.pages) for (const value of [page.url, page.finalUrl]) {
|
|
22800
|
+
const url = urlKey(value);
|
|
22801
|
+
if (url) groupUrls.add(url);
|
|
22802
|
+
}
|
|
22803
|
+
const counted = group.pages.filter((page) => {
|
|
22804
|
+
const canonical = urlKey(page.canonical);
|
|
22805
|
+
return !canonical || canonical === urlKey(page.url) || canonical === urlKey(page.finalUrl) || !groupUrls.has(canonical);
|
|
22806
|
+
});
|
|
22807
|
+
if (counted.length < 2) continue;
|
|
22808
|
+
duplicates.set(key, {
|
|
22809
|
+
count: counted.length,
|
|
22810
|
+
sampleUrls: counted.slice(0, 10).map((page) => page.url),
|
|
22811
|
+
value: group.value,
|
|
22812
|
+
urls: new Set(counted.map((page) => page.url))
|
|
22813
|
+
});
|
|
22704
22814
|
}
|
|
22705
|
-
return
|
|
22815
|
+
return duplicates;
|
|
22816
|
+
}
|
|
22817
|
+
function metadataDuplicates(pages, read) {
|
|
22818
|
+
return duplicateGroups(pages, (page) => {
|
|
22819
|
+
const value = read(page)?.trim();
|
|
22820
|
+
const key = normalizedMetadata(value);
|
|
22821
|
+
return key && value ? {
|
|
22822
|
+
key,
|
|
22823
|
+
value
|
|
22824
|
+
} : void 0;
|
|
22825
|
+
});
|
|
22706
22826
|
}
|
|
22707
22827
|
function contentDuplicates(pages) {
|
|
22708
|
-
|
|
22709
|
-
|
|
22710
|
-
|
|
22711
|
-
|
|
22712
|
-
const existing = counts.get(page.mainContentHash) ?? {
|
|
22713
|
-
count: 0,
|
|
22714
|
-
sampleUrls: []
|
|
22715
|
-
};
|
|
22716
|
-
existing.count += 1;
|
|
22717
|
-
if (existing.sampleUrls.length < 10) existing.sampleUrls.push(page.url);
|
|
22718
|
-
counts.set(page.mainContentHash, existing);
|
|
22719
|
-
}
|
|
22720
|
-
return new Map([...counts].filter(([, value]) => value.count > 1));
|
|
22828
|
+
return duplicateGroups(pages, (page) => page.wordCount >= 20 && page.mainContentHash ? {
|
|
22829
|
+
key: page.mainContentHash,
|
|
22830
|
+
value: page.mainContentHash
|
|
22831
|
+
} : void 0);
|
|
22721
22832
|
}
|
|
22722
22833
|
const QUERY_STOP_WORDS = /* @__PURE__ */ new Set([
|
|
22723
22834
|
"a",
|
|
@@ -22927,7 +23038,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
22927
23038
|
title,
|
|
22928
23039
|
...titleWidth
|
|
22929
23040
|
}));
|
|
22930
|
-
if (title && duplicateTitle) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
|
|
23041
|
+
if (title && duplicateTitle?.urls.has(page.url)) issues.push(issue$1("title_duplicate", page, duplicateTitle.value, {
|
|
22931
23042
|
title,
|
|
22932
23043
|
duplicateCount: duplicateTitle.count,
|
|
22933
23044
|
sampleUrls: duplicateTitle.sampleUrls
|
|
@@ -22935,7 +23046,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
22935
23046
|
const metaDescription = page.metaDescription?.trim();
|
|
22936
23047
|
const duplicateDescription = duplicateDescriptions.get(normalizedMetadata(metaDescription) ?? "");
|
|
22937
23048
|
if (!metaDescription) issues.push(issue$1("missing_meta_description", page));
|
|
22938
|
-
if (metaDescription && duplicateDescription) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
|
|
23049
|
+
if (metaDescription && duplicateDescription?.urls.has(page.url)) issues.push(issue$1("meta_description_duplicate", page, duplicateDescription.value, {
|
|
22939
23050
|
metaDescription,
|
|
22940
23051
|
duplicateCount: duplicateDescription.count,
|
|
22941
23052
|
sampleUrls: duplicateDescription.sampleUrls
|
|
@@ -22983,7 +23094,7 @@ function auditCrawlPages(pages, opts = {}) {
|
|
|
22983
23094
|
finalUrl: page.finalUrl
|
|
22984
23095
|
}));
|
|
22985
23096
|
const contentDuplicate = page.mainContentHash ? duplicateContent.get(page.mainContentHash) : void 0;
|
|
22986
|
-
if (contentDuplicate) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
|
|
23097
|
+
if (contentDuplicate?.urls.has(page.url)) issues.push(issue$1("duplicate_content", page, "Duplicate main content", {
|
|
22987
23098
|
mainContentHash: page.mainContentHash,
|
|
22988
23099
|
duplicateCount: contentDuplicate.count,
|
|
22989
23100
|
sampleUrls: contentDuplicate.sampleUrls
|
|
@@ -23058,6 +23169,7 @@ const CRAWL_SKIP_REASONS = [
|
|
|
23058
23169
|
"off-origin",
|
|
23059
23170
|
"origin-backpressure",
|
|
23060
23171
|
"queue-safety-limit",
|
|
23172
|
+
"query-variant-limit",
|
|
23061
23173
|
"robots-disallowed",
|
|
23062
23174
|
"robots-uncertain",
|
|
23063
23175
|
"legacy-unclassified"
|
|
@@ -23071,6 +23183,7 @@ const SKIP_IMPACT = {
|
|
|
23071
23183
|
"off-origin": "non-impacting",
|
|
23072
23184
|
"origin-backpressure": "coverage-affecting",
|
|
23073
23185
|
"queue-safety-limit": "coverage-affecting",
|
|
23186
|
+
"query-variant-limit": "coverage-affecting",
|
|
23074
23187
|
"robots-disallowed": "coverage-affecting",
|
|
23075
23188
|
"robots-uncertain": "coverage-affecting",
|
|
23076
23189
|
"legacy-unclassified": "coverage-affecting"
|
|
@@ -23159,6 +23272,10 @@ const VALUE_WEIGHT = {
|
|
|
23159
23272
|
medium: .75,
|
|
23160
23273
|
low: .15
|
|
23161
23274
|
};
|
|
23275
|
+
const CRAWL_REACH_RULES = /* @__PURE__ */ new Set(["crawl_single_page_reach"]);
|
|
23276
|
+
function reachRank(ruleId) {
|
|
23277
|
+
return CRAWL_REACH_RULES.has(ruleId) ? 0 : 1;
|
|
23278
|
+
}
|
|
23162
23279
|
function matchesPattern(pattern, value) {
|
|
23163
23280
|
if (!pattern.includes("*")) return value.includes(pattern);
|
|
23164
23281
|
const parts = pattern.split("*");
|
|
@@ -23266,7 +23383,7 @@ function topFixes(report, filters = {}) {
|
|
|
23266
23383
|
expected: rule?.howToVerify ?? ""
|
|
23267
23384
|
}
|
|
23268
23385
|
};
|
|
23269
|
-
}).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);
|
|
23386
|
+
}).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);
|
|
23270
23387
|
}
|
|
23271
23388
|
function reviewObservations(report, filters = {}) {
|
|
23272
23389
|
return topFixes(report, {
|
|
@@ -24106,6 +24223,7 @@ const crawlPageSnapshotSchema = z.object({
|
|
|
24106
24223
|
source: z.enum([
|
|
24107
24224
|
"environment",
|
|
24108
24225
|
"playwright-cache",
|
|
24226
|
+
"agent-browser-cache",
|
|
24109
24227
|
"system"
|
|
24110
24228
|
]),
|
|
24111
24229
|
product: z.string(),
|
|
@@ -24794,55 +24912,6 @@ function agentReadyIssues(input) {
|
|
|
24794
24912
|
}
|
|
24795
24913
|
return issues;
|
|
24796
24914
|
}
|
|
24797
|
-
// packages/core/dist/analyze/crawler/crawl-caveats.js
|
|
24798
|
-
function crawlCaveats(input) {
|
|
24799
|
-
const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
|
|
24800
|
-
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.`);
|
|
24801
|
-
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.`);
|
|
24802
|
-
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.`);
|
|
24803
|
-
return caveats;
|
|
24804
|
-
}
|
|
24805
|
-
// packages/core/dist/analyze/crawler/crawl-control.js
|
|
24806
|
-
const CRAWL_CANCELLED = Symbol("crawl-cancelled");
|
|
24807
|
-
function abortController(input) {
|
|
24808
|
-
const controller = new AbortController();
|
|
24809
|
-
const abort = () => controller.abort();
|
|
24810
|
-
const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
|
|
24811
|
-
if (input.signal?.aborted) controller.abort();
|
|
24812
|
-
else input.signal?.addEventListener("abort", abort, { once: true });
|
|
24813
|
-
return {
|
|
24814
|
-
signal: controller.signal,
|
|
24815
|
-
cleanup: () => {
|
|
24816
|
-
clearTimeout(timer);
|
|
24817
|
-
input.signal?.removeEventListener("abort", abort);
|
|
24818
|
-
}
|
|
24819
|
-
};
|
|
24820
|
-
}
|
|
24821
|
-
function cancellationRace(signal) {
|
|
24822
|
-
if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
|
|
24823
|
-
return new Promise((resolve) => {
|
|
24824
|
-
signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
|
|
24825
|
-
});
|
|
24826
|
-
}
|
|
24827
|
-
// packages/core/dist/analyze/crawler/crawl-memory.js
|
|
24828
|
-
const MEBIBYTE = 1048576;
|
|
24829
|
-
const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE;
|
|
24830
|
-
const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE;
|
|
24831
|
-
const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE;
|
|
24832
|
-
const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE;
|
|
24833
|
-
const SYSTEM_MEMORY_FRACTION = .1;
|
|
24834
|
-
const SYSTEM_RSS_FRACTION = .2;
|
|
24835
|
-
function crawlMemoryLimitBytes(totalMemoryBytes) {
|
|
24836
|
-
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
|
|
24837
|
-
return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
|
|
24838
|
-
}
|
|
24839
|
-
function crawlRssLimitBytes(totalMemoryBytes) {
|
|
24840
|
-
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
|
|
24841
|
-
return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
|
|
24842
|
-
}
|
|
24843
|
-
function crawlMemoryPressure(input) {
|
|
24844
|
-
return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
|
|
24845
|
-
}
|
|
24846
24915
|
// packages/core/dist/analyze/crawler/crawl-url-queue.js
|
|
24847
24916
|
function before(left, right) {
|
|
24848
24917
|
return left.depth < right.depth || left.depth === right.depth && left.url < right.url;
|
|
@@ -24922,6 +24991,85 @@ var CrawlUrlQueue = class {
|
|
|
24922
24991
|
}
|
|
24923
24992
|
}
|
|
24924
24993
|
};
|
|
24994
|
+
// packages/core/dist/analyze/crawler/crawl-caveats.js
|
|
24995
|
+
function crawlCaveats(input) {
|
|
24996
|
+
const caveats = input.cancelled ? ["Crawl cancelled before all queued URLs finished."] : input.pageLimitReached ? [`Stopped after reaching maxPages (${input.maxPages}).`] : [];
|
|
24997
|
+
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.`);
|
|
24998
|
+
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.`);
|
|
24999
|
+
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.`);
|
|
25000
|
+
const renderingFallbackUrls = input.renderingFallbackUrls ?? 0;
|
|
25001
|
+
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.`);
|
|
25002
|
+
const queryVariantSkippedUrls = input.queryVariantSkippedUrls ?? 0;
|
|
25003
|
+
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.`);
|
|
25004
|
+
for (const issue of input.reachIssues ?? []) if (issue.detail) caveats.push(issue.detail);
|
|
25005
|
+
return caveats;
|
|
25006
|
+
}
|
|
25007
|
+
/**
|
|
25008
|
+
* A site crawl that found no internal links on the start page and no sitemap
|
|
25009
|
+
* URLs checked one page, so its other findings cannot stand for the site.
|
|
25010
|
+
*/
|
|
25011
|
+
function crawlReachIssues(input) {
|
|
25012
|
+
const [page] = input.pages;
|
|
25013
|
+
if (input.crawledPages !== 1 || input.pages.length !== 1 || !page || page.outgoingInternalCount > 0 || input.sitemapUrlsReturned > 0) return [];
|
|
25014
|
+
const rule = explainRule("crawl_single_page_reach");
|
|
25015
|
+
if (!rule) throw new Error("Missing rule guidance for crawl_single_page_reach");
|
|
25016
|
+
return [{
|
|
25017
|
+
ruleId: "crawl_single_page_reach",
|
|
25018
|
+
title: rule.title,
|
|
25019
|
+
category: rule.category,
|
|
25020
|
+
severity: rule.defaultSeverity,
|
|
25021
|
+
url: page.url,
|
|
25022
|
+
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.",
|
|
25023
|
+
evidence: {
|
|
25024
|
+
startUrl: input.startUrl,
|
|
25025
|
+
crawledPages: 1,
|
|
25026
|
+
outgoingInternalCount: 0,
|
|
25027
|
+
sitemapUrlsReturned: 0,
|
|
25028
|
+
rendered: Boolean(page.usedJs)
|
|
25029
|
+
}
|
|
25030
|
+
}];
|
|
25031
|
+
}
|
|
25032
|
+
// packages/core/dist/analyze/crawler/crawl-control.js
|
|
25033
|
+
const CRAWL_CANCELLED = Symbol("crawl-cancelled");
|
|
25034
|
+
function abortController(input) {
|
|
25035
|
+
const controller = new AbortController();
|
|
25036
|
+
const abort = () => controller.abort();
|
|
25037
|
+
const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
|
|
25038
|
+
if (input.signal?.aborted) controller.abort();
|
|
25039
|
+
else input.signal?.addEventListener("abort", abort, { once: true });
|
|
25040
|
+
return {
|
|
25041
|
+
signal: controller.signal,
|
|
25042
|
+
cleanup: () => {
|
|
25043
|
+
clearTimeout(timer);
|
|
25044
|
+
input.signal?.removeEventListener("abort", abort);
|
|
25045
|
+
}
|
|
25046
|
+
};
|
|
25047
|
+
}
|
|
25048
|
+
function cancellationRace(signal) {
|
|
25049
|
+
if (signal.aborted) return Promise.resolve(CRAWL_CANCELLED);
|
|
25050
|
+
return new Promise((resolve) => {
|
|
25051
|
+
signal.addEventListener("abort", () => resolve(CRAWL_CANCELLED), { once: true });
|
|
25052
|
+
});
|
|
25053
|
+
}
|
|
25054
|
+
// packages/core/dist/analyze/crawler/crawl-memory.js
|
|
25055
|
+
const MEBIBYTE = 1048576;
|
|
25056
|
+
const MIN_CRAWL_MEMORY_LIMIT = 384 * MEBIBYTE;
|
|
25057
|
+
const MAX_CRAWL_MEMORY_LIMIT = 640 * MEBIBYTE;
|
|
25058
|
+
const MIN_CRAWL_RSS_LIMIT = 512 * MEBIBYTE;
|
|
25059
|
+
const MAX_CRAWL_RSS_LIMIT = 896 * MEBIBYTE;
|
|
25060
|
+
const SYSTEM_MEMORY_FRACTION = .1;
|
|
25061
|
+
const SYSTEM_RSS_FRACTION = .2;
|
|
25062
|
+
function crawlMemoryLimitBytes(totalMemoryBytes) {
|
|
25063
|
+
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_MEMORY_LIMIT;
|
|
25064
|
+
return Math.min(MAX_CRAWL_MEMORY_LIMIT, Math.max(MIN_CRAWL_MEMORY_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_MEMORY_FRACTION)));
|
|
25065
|
+
}
|
|
25066
|
+
function crawlRssLimitBytes(totalMemoryBytes) {
|
|
25067
|
+
if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) return MAX_CRAWL_RSS_LIMIT;
|
|
25068
|
+
return Math.min(MAX_CRAWL_RSS_LIMIT, Math.max(MIN_CRAWL_RSS_LIMIT, Math.floor(totalMemoryBytes * SYSTEM_RSS_FRACTION)));
|
|
25069
|
+
}
|
|
25070
|
+
function crawlMemoryPressure(input) {
|
|
25071
|
+
return input.memoryUsage.heapUsed + input.memoryUsage.external >= crawlMemoryLimitBytes(input.totalMemoryBytes) || input.memoryUsage.rss >= crawlRssLimitBytes(input.totalMemoryBytes);
|
|
25072
|
+
}
|
|
24925
25073
|
// packages/core/dist/analyze/crawler/external-link-checks.js
|
|
24926
25074
|
const EXTERNAL_LINK_SELECTION_LIMIT = 200;
|
|
24927
25075
|
const EXTERNAL_LINK_STATES = [
|
|
@@ -25154,11 +25302,15 @@ function observationFromPage(requestedUrl, page) {
|
|
|
25154
25302
|
};
|
|
25155
25303
|
}
|
|
25156
25304
|
// packages/core/dist/analyze/crawler/site-checks.js
|
|
25157
|
-
const PROBE_COUNT =
|
|
25305
|
+
const PROBE_COUNT = 3;
|
|
25158
25306
|
function probeUrls(startUrl) {
|
|
25159
25307
|
const origin = new URL(startUrl).origin;
|
|
25160
25308
|
const seed = createHash("sha256").update(origin).digest("hex").slice(0, 12);
|
|
25161
|
-
return
|
|
25309
|
+
return [
|
|
25310
|
+
`/${seed}-not-found`,
|
|
25311
|
+
`/${seed}-not-found/${seed}-missing`,
|
|
25312
|
+
`/.well-known/seo-audit/not-found-${seed}-1`
|
|
25313
|
+
].map((path) => new URL(path, origin).toString());
|
|
25162
25314
|
}
|
|
25163
25315
|
function probeObservation(url, result) {
|
|
25164
25316
|
const request = result.request;
|
|
@@ -25195,7 +25347,7 @@ async function collectCrawlSiteChecks(input) {
|
|
|
25195
25347
|
const conclusive = probes.filter((probe) => probe.status !== void 0 && !probe.accessBlocked);
|
|
25196
25348
|
const successful = conclusive.filter((probe) => (probe.status ?? 0) >= 200 && (probe.status ?? 0) < 400);
|
|
25197
25349
|
const correctMissing = conclusive.filter((probe) => probe.status === 404 || probe.status === 410);
|
|
25198
|
-
const status =
|
|
25350
|
+
const status = successful.length > 0 ? "warning" : correctMissing.length === PROBE_COUNT ? "pass" : "unknown";
|
|
25199
25351
|
const checks = { soft404: {
|
|
25200
25352
|
status,
|
|
25201
25353
|
probes,
|
|
@@ -25216,7 +25368,7 @@ async function collectCrawlSiteChecks(input) {
|
|
|
25216
25368
|
category: rule.category,
|
|
25217
25369
|
severity: rule.defaultSeverity,
|
|
25218
25370
|
url: new URL("/", input.startUrl).toString(),
|
|
25219
|
-
detail: `${successful.length}
|
|
25371
|
+
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(", ")}.`,
|
|
25220
25372
|
evidence: checks.soft404
|
|
25221
25373
|
}]
|
|
25222
25374
|
};
|
|
@@ -27883,6 +28035,9 @@ async function crawlSite(input, dependencies) {
|
|
|
27883
28035
|
const queueSafetyDeferredUrls = /* @__PURE__ */ new Set();
|
|
27884
28036
|
const maxTrackedQueueSafetyUrls = Math.min(5e4, Math.max(1e3, config.maxPages * 5));
|
|
27885
28037
|
let queueSafetyInventoryCapped = false;
|
|
28038
|
+
const queryVariantsByPath = /* @__PURE__ */ new Map();
|
|
28039
|
+
const queryVariantSkipped = /* @__PURE__ */ new Set();
|
|
28040
|
+
let renderingFallbackUrls = 0;
|
|
27886
28041
|
let failedUrls = 0;
|
|
27887
28042
|
let observedInternalLinks = 0;
|
|
27888
28043
|
let memoryLimited = false;
|
|
@@ -27985,6 +28140,20 @@ async function crawlSite(input, dependencies) {
|
|
|
27985
28140
|
});
|
|
27986
28141
|
return;
|
|
27987
28142
|
}
|
|
28143
|
+
const parsed = new URL(normalized);
|
|
28144
|
+
const pathKey = parsed.search ? `${parsed.origin}${parsed.pathname}` : "";
|
|
28145
|
+
if (pathKey && (queryVariantsByPath.get(pathKey) ?? 0) >= 5) {
|
|
28146
|
+
if (!queryVariantSkipped.has(normalized) && queryVariantSkipped.size < maxTrackedQueueSafetyUrls) {
|
|
28147
|
+
queryVariantSkipped.add(normalized);
|
|
28148
|
+
recordSkip("query-variant-limit");
|
|
28149
|
+
emitStatus("url_skipped", {
|
|
28150
|
+
url: normalized,
|
|
28151
|
+
depth,
|
|
28152
|
+
reason: "query_variant_limit"
|
|
28153
|
+
});
|
|
28154
|
+
}
|
|
28155
|
+
return;
|
|
28156
|
+
}
|
|
27988
28157
|
if (queue.size + visited.size + inFlight.size >= config.maxPages * 5) {
|
|
27989
28158
|
if (!queueSafetyDeferredUrls.has(normalized)) {
|
|
27990
28159
|
if (queueSafetyDeferredUrls.size < maxTrackedQueueSafetyUrls) {
|
|
@@ -28000,6 +28169,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28000
28169
|
}
|
|
28001
28170
|
return;
|
|
28002
28171
|
}
|
|
28172
|
+
if (pathKey) queryVariantsByPath.set(pathKey, (queryVariantsByPath.get(pathKey) ?? 0) + 1);
|
|
28003
28173
|
discovered.add(normalized);
|
|
28004
28174
|
queuedUrls += 1;
|
|
28005
28175
|
queue.push({
|
|
@@ -28152,6 +28322,9 @@ async function crawlSite(input, dependencies) {
|
|
|
28152
28322
|
continue;
|
|
28153
28323
|
}
|
|
28154
28324
|
const page = result.page;
|
|
28325
|
+
const rendering = page.fetchDiagnostics?.rendering;
|
|
28326
|
+
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());
|
|
28327
|
+
if (rendering?.status === "unavailable" || rendering?.status === "failed") renderingFallbackUrls += 1;
|
|
28155
28328
|
const finalUrl = normalizeUrl$1(page.finalUrl, config.url);
|
|
28156
28329
|
if (!finalUrl) {
|
|
28157
28330
|
failedUrls += 1;
|
|
@@ -28374,6 +28547,12 @@ async function crawlSite(input, dependencies) {
|
|
|
28374
28547
|
...startPage.responseHeaders?.["content-signal"] ? { contentSignal: startPage.responseHeaders["content-signal"] } : {}
|
|
28375
28548
|
} } : {}
|
|
28376
28549
|
} : aiSignals?.signals;
|
|
28550
|
+
const reachIssues = followLinks && config.maxDepth > 0 && config.maxPages > 1 && !cancelled ? crawlReachIssues({
|
|
28551
|
+
startUrl: config.url,
|
|
28552
|
+
pages: pages.filter((page) => page.status >= 200 && page.status < 300),
|
|
28553
|
+
crawledPages: pages.length,
|
|
28554
|
+
sitemapUrlsReturned: sitemapDiscovery?.urlsReturned ?? 0
|
|
28555
|
+
}) : [];
|
|
28377
28556
|
const report = createCrawlReport({
|
|
28378
28557
|
config,
|
|
28379
28558
|
site,
|
|
@@ -28388,6 +28567,7 @@ async function crawlSite(input, dependencies) {
|
|
|
28388
28567
|
...externalLinkVerification ? { externalLinkVerification } : {},
|
|
28389
28568
|
...siteCheckResult ? { siteChecks: siteCheckResult.checks } : {},
|
|
28390
28569
|
additionalIssues: [
|
|
28570
|
+
...reachIssues,
|
|
28391
28571
|
...siteCheckResult?.issues ?? [],
|
|
28392
28572
|
...sitemapDiscovery ? sitemapHostIssues(sitemapDiscovery, config.url) : [],
|
|
28393
28573
|
...ai ? agentReadyIssues({
|
|
@@ -28405,7 +28585,10 @@ async function crawlSite(input, dependencies) {
|
|
|
28405
28585
|
queueSafetySkippedUrls,
|
|
28406
28586
|
queueSafetyInventoryCapped,
|
|
28407
28587
|
originBackpressureSkippedUrls,
|
|
28408
|
-
memoryPressureSkippedUrls
|
|
28588
|
+
memoryPressureSkippedUrls,
|
|
28589
|
+
queryVariantSkippedUrls: queryVariantSkipped.size,
|
|
28590
|
+
renderingFallbackUrls,
|
|
28591
|
+
reachIssues
|
|
28409
28592
|
}),
|
|
28410
28593
|
stats: {
|
|
28411
28594
|
discoveredUrls: discovered.size,
|
|
@@ -28958,7 +29141,7 @@ function analyzeCtrUnderperformersFromRows(input) {
|
|
|
28958
29141
|
// packages/core/dist/analyze/ctr-underperformers-report.js
|
|
28959
29142
|
const MAX_GSC_ROWS$3 = 1e5;
|
|
28960
29143
|
const DEFAULT_DAYS$3 = 28;
|
|
28961
|
-
const MAX_DAYS$
|
|
29144
|
+
const MAX_DAYS$3 = 548;
|
|
28962
29145
|
function plural$8(count, singular, pluralLabel = `${singular}s`) {
|
|
28963
29146
|
return count === 1 ? singular : pluralLabel;
|
|
28964
29147
|
}
|
|
@@ -28977,12 +29160,12 @@ async function ctrUnderperformersReportWith(input, dependencies) {
|
|
|
28977
29160
|
maximum: 100,
|
|
28978
29161
|
label: "limit"
|
|
28979
29162
|
});
|
|
28980
|
-
const explicitRange = explicitDateRange(input, MAX_DAYS$
|
|
29163
|
+
const explicitRange = explicitDateRange(input, MAX_DAYS$3);
|
|
28981
29164
|
const days = explicitRange?.days ?? integerOption$1({
|
|
28982
29165
|
value: input.days,
|
|
28983
29166
|
fallback: DEFAULT_DAYS$3,
|
|
28984
29167
|
minimum: 1,
|
|
28985
|
-
maximum: MAX_DAYS$
|
|
29168
|
+
maximum: MAX_DAYS$3,
|
|
28986
29169
|
label: "Days"
|
|
28987
29170
|
});
|
|
28988
29171
|
const now = dependencies.now();
|
|
@@ -32171,7 +32354,7 @@ function quickWinTemplateSummaries(items) {
|
|
|
32171
32354
|
}
|
|
32172
32355
|
// packages/core/dist/analyze/site-diagnostics/quick-wins-report.js
|
|
32173
32356
|
const DEFAULT_DAYS$2 = 28;
|
|
32174
|
-
const MAX_DAYS$
|
|
32357
|
+
const MAX_DAYS$2 = 548;
|
|
32175
32358
|
const DEFAULT_VERIFY_LIMIT$1 = 5;
|
|
32176
32359
|
const MAX_VERIFY_LIMIT = 100;
|
|
32177
32360
|
const MAX_SOURCE_ROWS$2 = 1e5;
|
|
@@ -32189,12 +32372,12 @@ function applyVerification$1(item, coverage) {
|
|
|
32189
32372
|
};
|
|
32190
32373
|
}
|
|
32191
32374
|
async function quickWinsReportWith(input, dependencies) {
|
|
32192
|
-
const explicitRange = explicitDateRange(input, MAX_DAYS$
|
|
32375
|
+
const explicitRange = explicitDateRange(input, MAX_DAYS$2);
|
|
32193
32376
|
const days = explicitRange?.days ?? integerOption$1({
|
|
32194
32377
|
value: input.days,
|
|
32195
32378
|
fallback: DEFAULT_DAYS$2,
|
|
32196
32379
|
minimum: 1,
|
|
32197
|
-
maximum: MAX_DAYS$
|
|
32380
|
+
maximum: MAX_DAYS$2,
|
|
32198
32381
|
label: "Days"
|
|
32199
32382
|
});
|
|
32200
32383
|
const verifyLimit = integerOption$1({
|
|
@@ -32613,7 +32796,7 @@ function analyzeStrikingDistanceRows(input) {
|
|
|
32613
32796
|
}
|
|
32614
32797
|
// packages/core/dist/analyze/striking-distance-report.js
|
|
32615
32798
|
const DEFAULT_DAYS$1 = 28;
|
|
32616
|
-
const MAX_DAYS = 548;
|
|
32799
|
+
const MAX_DAYS$1 = 548;
|
|
32617
32800
|
const DEFAULT_VERIFY_LIMIT = 5;
|
|
32618
32801
|
const MAX_SOURCE_ROWS$1 = 1e5;
|
|
32619
32802
|
function boundedInteger$2(value, fallback, minimum, maximum) {
|
|
@@ -32622,7 +32805,7 @@ function boundedInteger$2(value, fallback, minimum, maximum) {
|
|
|
32622
32805
|
}
|
|
32623
32806
|
function reportDays(value) {
|
|
32624
32807
|
if (value === void 0) return DEFAULT_DAYS$1;
|
|
32625
|
-
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}.`);
|
|
32808
|
+
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}.`);
|
|
32626
32809
|
return value;
|
|
32627
32810
|
}
|
|
32628
32811
|
function dateRange(days, now) {
|
|
@@ -32662,7 +32845,7 @@ function verdict$1(analysis) {
|
|
|
32662
32845
|
return `${countLabel(analysis.summary.eligibleRows, "eligible query/page row")} found; ${countLabel(analysis.summary.returnedRows, "row")} returned for review.`;
|
|
32663
32846
|
}
|
|
32664
32847
|
async function strikingDistanceWith(input, dependencies) {
|
|
32665
|
-
const explicitRange = explicitDateRange(input, MAX_DAYS);
|
|
32848
|
+
const explicitRange = explicitDateRange(input, MAX_DAYS$1);
|
|
32666
32849
|
const days = explicitRange?.days ?? reportDays(input.days);
|
|
32667
32850
|
const generatedAt = dependencies.now().toISOString();
|
|
32668
32851
|
const range = explicitRange?.range ?? dateRange(days, new Date(generatedAt));
|
|
@@ -33849,7 +34032,7 @@ function typesafeDecisionModel(input) {
|
|
|
33849
34032
|
const MAX_CONCURRENCY = 32;
|
|
33850
34033
|
const MIN_CONCURRENCY = 2;
|
|
33851
34034
|
const MAX_ATTEMPTS = 5;
|
|
33852
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
34035
|
+
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
33853
34036
|
var DecisionRunner = class {
|
|
33854
34037
|
model;
|
|
33855
34038
|
limit = MAX_CONCURRENCY;
|
|
@@ -33914,7 +34097,7 @@ var DecisionRunner = class {
|
|
|
33914
34097
|
} finally {
|
|
33915
34098
|
this.release();
|
|
33916
34099
|
}
|
|
33917
|
-
await sleep(delay);
|
|
34100
|
+
await sleep$1(delay);
|
|
33918
34101
|
}
|
|
33919
34102
|
this.failed += 1;
|
|
33920
34103
|
return null;
|
|
@@ -41868,6 +42051,7 @@ const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
|
41868
42051
|
function requireInput(input) {
|
|
41869
42052
|
const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
|
|
41870
42053
|
if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
|
|
42054
|
+
if (input.action === "link_gap" && !input.target) throw new CommandError("INVALID_INPUT", "link_gap needs a target domain and competitors.");
|
|
41871
42055
|
if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
|
|
41872
42056
|
}
|
|
41873
42057
|
async function eachKeyword(keywords, missed, rowsFor) {
|
|
@@ -41885,14 +42069,17 @@ async function eachKeyword(keywords, missed, rowsFor) {
|
|
|
41885
42069
|
}
|
|
41886
42070
|
async function research(provider, headers, input) {
|
|
41887
42071
|
requireInput(input);
|
|
42072
|
+
if (input.action === "link_gap") return linkGap(provider, headers, input);
|
|
41888
42073
|
const keys = need(headers, provider);
|
|
41889
42074
|
if (provider === "dataforseo") return dataForSeo(keys, input);
|
|
41890
42075
|
const missed = [];
|
|
42076
|
+
const rows = await ahrefs(keys[0] ?? "", input, missed);
|
|
41891
42077
|
return {
|
|
41892
|
-
rows
|
|
42078
|
+
rows,
|
|
41893
42079
|
costUsd: null,
|
|
41894
42080
|
partial: false,
|
|
41895
|
-
missed
|
|
42081
|
+
missed,
|
|
42082
|
+
noData: withoutData(input, rows, missed, 100)
|
|
41896
42083
|
};
|
|
41897
42084
|
}
|
|
41898
42085
|
function countryName(code) {
|
|
@@ -41944,7 +42131,8 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
41944
42131
|
if (result?.status_code === NO_RESULTS) return {
|
|
41945
42132
|
cost: result.cost ?? 0,
|
|
41946
42133
|
result: null,
|
|
41947
|
-
partial: false
|
|
42134
|
+
partial: false,
|
|
42135
|
+
noResults: true
|
|
41948
42136
|
};
|
|
41949
42137
|
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 });
|
|
41950
42138
|
return {
|
|
@@ -41955,24 +42143,59 @@ async function dataForSeoTask([login, password], path, task) {
|
|
|
41955
42143
|
}
|
|
41956
42144
|
const NO_RESULTS = 40102;
|
|
41957
42145
|
const PARTIAL = 40106;
|
|
42146
|
+
const queued = (task) => ({
|
|
42147
|
+
id: String(task.id ?? ""),
|
|
42148
|
+
status_code: Number(task.status_code ?? 0),
|
|
42149
|
+
status_message: String(task.status_message ?? ""),
|
|
42150
|
+
cost: Number(task.cost ?? 0),
|
|
42151
|
+
data: task.data ?? {},
|
|
42152
|
+
result: task.result?.[0] ?? null
|
|
42153
|
+
});
|
|
42154
|
+
async function dataForSeoPost([login, password], path, tasks) {
|
|
42155
|
+
return ((await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, {
|
|
42156
|
+
method: "POST",
|
|
42157
|
+
headers: {
|
|
42158
|
+
authorization: `Basic ${btoa(`${login}:${password}`)}`,
|
|
42159
|
+
"content-type": "application/json"
|
|
42160
|
+
},
|
|
42161
|
+
body: JSON.stringify(tasks)
|
|
42162
|
+
})), "DataForSEO")).tasks ?? []).map(queued);
|
|
42163
|
+
}
|
|
42164
|
+
const DATAFORSEO_NOT_READY = /* @__PURE__ */ new Set([40601, 40602]);
|
|
42165
|
+
async function dataForSeoGet([login, password], path) {
|
|
42166
|
+
const task = (await readJson(await call("dataforseo", new Request(`https://api.dataforseo.com/${path}`, { headers: { authorization: `Basic ${btoa(`${login}:${password}`)}` } })), "DataForSEO")).tasks?.[0];
|
|
42167
|
+
if (!task) throw new CommandError("PROVIDER_ERROR", `dataforseo: no task at ${path}`);
|
|
42168
|
+
return queued(task);
|
|
42169
|
+
}
|
|
41958
42170
|
async function dataForSeo(keys, input) {
|
|
41959
42171
|
const spent = {
|
|
41960
42172
|
cost: 0,
|
|
41961
42173
|
partial: false,
|
|
41962
42174
|
missed: []
|
|
41963
42175
|
};
|
|
42176
|
+
const rows = await dataForSeoRows(keys, input, spent);
|
|
41964
42177
|
return {
|
|
41965
|
-
rows
|
|
42178
|
+
rows,
|
|
41966
42179
|
costUsd: Math.round(spent.cost * 1e4) / 1e4,
|
|
41967
42180
|
partial: spent.partial,
|
|
41968
|
-
missed: spent.missed
|
|
42181
|
+
missed: spent.missed,
|
|
42182
|
+
noData: withoutData(input, rows, spent.missed, 700)
|
|
41969
42183
|
};
|
|
41970
42184
|
}
|
|
42185
|
+
function withoutData(input, rows, missed, keywordLimit) {
|
|
42186
|
+
if (input.action === "keyword_metrics" || input.action === "serp") {
|
|
42187
|
+
const max = input.action === "serp" ? 5 : keywordLimit;
|
|
42188
|
+
const answered = new Set(rows.map((row) => String(row.keyword ?? "").toLowerCase()));
|
|
42189
|
+
const failed = new Set(missed);
|
|
42190
|
+
return input.keywords.slice(0, max).filter((keyword) => !failed.has(keyword) && !answered.has(keyword.toLowerCase()));
|
|
42191
|
+
}
|
|
42192
|
+
return [];
|
|
42193
|
+
}
|
|
41971
42194
|
async function dataForSeoRows(keys, input, spent) {
|
|
41972
42195
|
const language = dataForSeoLanguage(input.country);
|
|
41973
42196
|
if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
|
|
41974
42197
|
const market = dataForSeoMarket(input.country, language);
|
|
41975
|
-
const post = async (path, task) => {
|
|
42198
|
+
const post = async (path, task, attempts = 1) => {
|
|
41976
42199
|
const done = await dataForSeoTask(keys, path, {
|
|
41977
42200
|
...market,
|
|
41978
42201
|
...task
|
|
@@ -41982,6 +42205,7 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
41982
42205
|
});
|
|
41983
42206
|
spent.cost += done.cost;
|
|
41984
42207
|
spent.partial ||= done.partial;
|
|
42208
|
+
if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
|
|
41985
42209
|
return done.result?.items ?? [];
|
|
41986
42210
|
};
|
|
41987
42211
|
const get = (value, path) => path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, value);
|
|
@@ -42011,7 +42235,7 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
42011
42235
|
return (await post("v3/serp/google/organic/live/advanced", {
|
|
42012
42236
|
keyword,
|
|
42013
42237
|
depth: Math.min(input.limit, 100)
|
|
42014
|
-
})).filter((item) => item.type === "organic").map((item) => {
|
|
42238
|
+
}, 2)).filter((item) => item.type === "organic").map((item) => {
|
|
42015
42239
|
const row = pick(item, {
|
|
42016
42240
|
position: "rank_absolute",
|
|
42017
42241
|
url: "url",
|
|
@@ -42019,9 +42243,11 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
42019
42243
|
title: "title"
|
|
42020
42244
|
});
|
|
42021
42245
|
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
|
|
42022
|
-
const
|
|
42246
|
+
const crumb = String(item.breadcrumb ?? "");
|
|
42247
|
+
const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
|
|
42023
42248
|
row.url = null;
|
|
42024
42249
|
row.domain = site?.[1] ?? null;
|
|
42250
|
+
row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
|
|
42025
42251
|
}
|
|
42026
42252
|
return {
|
|
42027
42253
|
keyword,
|
|
@@ -42051,6 +42277,65 @@ async function dataForSeoRows(keys, input, spent) {
|
|
|
42051
42277
|
organic_etv: "full_domain_metrics.organic.etv"
|
|
42052
42278
|
}));
|
|
42053
42279
|
}
|
|
42280
|
+
const GAP_DOMAINS = 1e3;
|
|
42281
|
+
const GAP_COMPETITORS = 5;
|
|
42282
|
+
async function linkGap(provider, headers, input) {
|
|
42283
|
+
const target = bareTarget(input.target).replace(/^www\./, "");
|
|
42284
|
+
const competitors = [...new Set((input.competitors ?? []).map((site) => bareTarget(site).replace(/^www\./, "")))].filter((site) => site && site !== target);
|
|
42285
|
+
if (competitors.length < 2 || competitors.length > GAP_COMPETITORS) throw new CommandError("INVALID_INPUT", `link_gap needs a target and 2 to ${GAP_COMPETITORS} competitors.`);
|
|
42286
|
+
const score = provider === "ahrefs" ? "domain_rating" : "rank";
|
|
42287
|
+
const sites = [target, ...competitors];
|
|
42288
|
+
const lists = [];
|
|
42289
|
+
let cost = 0;
|
|
42290
|
+
let partial = false;
|
|
42291
|
+
for (const site of sites) {
|
|
42292
|
+
const got = await research(provider, headers, {
|
|
42293
|
+
...input,
|
|
42294
|
+
action: "backlinks",
|
|
42295
|
+
target: site,
|
|
42296
|
+
limit: GAP_DOMAINS
|
|
42297
|
+
});
|
|
42298
|
+
cost += got.costUsd ?? 0;
|
|
42299
|
+
partial ||= got.partial;
|
|
42300
|
+
lists.push(got.rows);
|
|
42301
|
+
}
|
|
42302
|
+
const domainOf = (row) => String(row.domain ?? "").toLowerCase().replace(/^www\./, "");
|
|
42303
|
+
const authority = (row) => Number(row[score] ?? 0);
|
|
42304
|
+
const floors = lists.filter((rows) => rows.length >= GAP_DOMAINS).map((rows) => Math.min(...rows.map(authority)));
|
|
42305
|
+
const floor = floors.length ? Math.max(...floors) : null;
|
|
42306
|
+
const own = new Set((lists[0] ?? []).map(domainOf));
|
|
42307
|
+
const found = /* @__PURE__ */ new Map();
|
|
42308
|
+
competitors.forEach((site, index) => {
|
|
42309
|
+
for (const row of lists[index + 1] ?? []) {
|
|
42310
|
+
const domain = domainOf(row);
|
|
42311
|
+
if (!domain || own.has(domain) || domain === target) continue;
|
|
42312
|
+
if (domain.endsWith(`.${target}`)) continue;
|
|
42313
|
+
const entry = found.get(domain) ?? {
|
|
42314
|
+
value: 0,
|
|
42315
|
+
sites: []
|
|
42316
|
+
};
|
|
42317
|
+
entry.value = Math.max(entry.value, authority(row));
|
|
42318
|
+
if (!entry.sites.includes(site)) entry.sites.push(site);
|
|
42319
|
+
found.set(domain, entry);
|
|
42320
|
+
}
|
|
42321
|
+
});
|
|
42322
|
+
return {
|
|
42323
|
+
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]) => ({
|
|
42324
|
+
domain,
|
|
42325
|
+
[score]: entry.value,
|
|
42326
|
+
competitors_linking: entry.sites.length,
|
|
42327
|
+
links_to: entry.sites.join(" ")
|
|
42328
|
+
})),
|
|
42329
|
+
costUsd: provider === "dataforseo" ? Math.round(cost * 1e4) / 1e4 : null,
|
|
42330
|
+
partial,
|
|
42331
|
+
missed: [],
|
|
42332
|
+
noData: [],
|
|
42333
|
+
...floor !== null ? { floor: {
|
|
42334
|
+
field: score,
|
|
42335
|
+
value: floor
|
|
42336
|
+
} } : {}
|
|
42337
|
+
};
|
|
42338
|
+
}
|
|
42054
42339
|
function isPage(target) {
|
|
42055
42340
|
return /\/./.test(bareTarget(target));
|
|
42056
42341
|
}
|
|
@@ -42141,7 +42426,7 @@ const DIMENSIONS = [
|
|
|
42141
42426
|
"device",
|
|
42142
42427
|
"searchAppearance"
|
|
42143
42428
|
];
|
|
42144
|
-
const ROWS = 25e3;
|
|
42429
|
+
const ROWS$1 = 25e3;
|
|
42145
42430
|
async function runRows(input, deps) {
|
|
42146
42431
|
const dimensions = input.dimensions?.length ? input.dimensions : ["query"];
|
|
42147
42432
|
const filters = input.filters ?? [];
|
|
@@ -42150,7 +42435,7 @@ async function runRows(input, deps) {
|
|
|
42150
42435
|
const query = {
|
|
42151
42436
|
dimensions,
|
|
42152
42437
|
type: "web",
|
|
42153
|
-
maxRows: ROWS,
|
|
42438
|
+
maxRows: ROWS$1,
|
|
42154
42439
|
...filters.length ? { dimensionFilterGroups: [{
|
|
42155
42440
|
groupType: "and",
|
|
42156
42441
|
filters
|
|
@@ -42207,9 +42492,9 @@ async function runRows(input, deps) {
|
|
|
42207
42492
|
const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
|
|
42208
42493
|
notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
|
|
42209
42494
|
}
|
|
42210
|
-
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.`);
|
|
42495
|
+
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.`);
|
|
42211
42496
|
if (pageAt >= 0) notes.push("Page URLs with #fragments are merged into their page.");
|
|
42212
|
-
if (current.rowsFetched >= ROWS) notes.push(`Search Console has more than ${ROWS} rows; add a filter to narrow it.`);
|
|
42497
|
+
if (current.rowsFetched >= ROWS$1) notes.push(`Search Console has more than ${ROWS$1} rows; add a filter to narrow it.`);
|
|
42213
42498
|
notes.push("Search Console hides some rare queries for privacy, so query rows do not add up to the site totals.");
|
|
42214
42499
|
const detail = input.detail ?? "summary";
|
|
42215
42500
|
return {
|
|
@@ -42254,7 +42539,8 @@ const ENGINES = [
|
|
|
42254
42539
|
{
|
|
42255
42540
|
name: "chatgpt",
|
|
42256
42541
|
means: "a ChatGPT search, read by DataForSEO",
|
|
42257
|
-
|
|
42542
|
+
post: "v3/ai_optimization/chat_gpt/llm_scraper/task_post",
|
|
42543
|
+
get: "v3/ai_optimization/chat_gpt/llm_scraper/task_get/advanced/",
|
|
42258
42544
|
languages: "v3/ai_optimization/chat_gpt/llm_scraper/languages",
|
|
42259
42545
|
task: (question, market) => ({
|
|
42260
42546
|
keyword: question,
|
|
@@ -42274,7 +42560,8 @@ const ENGINES = [
|
|
|
42274
42560
|
{
|
|
42275
42561
|
name: "google_ai_mode",
|
|
42276
42562
|
means: "Google AI Mode",
|
|
42277
|
-
|
|
42563
|
+
post: "v3/serp/google/ai_mode/task_post",
|
|
42564
|
+
get: "v3/serp/google/ai_mode/task_get/advanced/",
|
|
42278
42565
|
languages: "v3/serp/google/ai_mode/languages",
|
|
42279
42566
|
task: (question, market) => ({
|
|
42280
42567
|
keyword: question,
|
|
@@ -42285,7 +42572,8 @@ const ENGINES = [
|
|
|
42285
42572
|
{
|
|
42286
42573
|
name: "google_ai_overview",
|
|
42287
42574
|
means: "the AI Overview on a Google search",
|
|
42288
|
-
|
|
42575
|
+
post: "v3/serp/google/organic/task_post",
|
|
42576
|
+
get: "v3/serp/google/organic/task_get/advanced/",
|
|
42289
42577
|
languages: "v3/serp/google/languages",
|
|
42290
42578
|
task: (question, market) => ({
|
|
42291
42579
|
keyword: question,
|
|
@@ -42307,56 +42595,71 @@ function onSite(url, domain) {
|
|
|
42307
42595
|
return false;
|
|
42308
42596
|
}
|
|
42309
42597
|
}
|
|
42310
|
-
|
|
42311
|
-
|
|
42312
|
-
result: "unavailable",
|
|
42313
|
-
model: null,
|
|
42314
|
-
cited_url: null,
|
|
42315
|
-
answered_at: null,
|
|
42316
|
-
cost_usd: 0,
|
|
42317
|
-
error: market
|
|
42318
|
-
};
|
|
42598
|
+
const MAX_SOURCES = 8;
|
|
42599
|
+
const hostOf$2 = (url) => {
|
|
42319
42600
|
try {
|
|
42320
|
-
|
|
42321
|
-
|
|
42322
|
-
|
|
42323
|
-
cost_usd: done.cost
|
|
42324
|
-
};
|
|
42325
|
-
const shown = done.result && engine.shown(done.result);
|
|
42326
|
-
if (!shown) return {
|
|
42327
|
-
result: "no_answer",
|
|
42328
|
-
model: null,
|
|
42329
|
-
cited_url: null,
|
|
42330
|
-
...answered
|
|
42331
|
-
};
|
|
42332
|
-
const base = {
|
|
42333
|
-
model: shown.model,
|
|
42334
|
-
...answered
|
|
42335
|
-
};
|
|
42336
|
-
const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
|
|
42337
|
-
const cited = [...shown.sources, ...linked].find((url) => onSite(url, site.domain));
|
|
42338
|
-
if (cited) return {
|
|
42339
|
-
result: "cited",
|
|
42340
|
-
cited_url: cited,
|
|
42341
|
-
...base
|
|
42342
|
-
};
|
|
42343
|
-
const text = prose(shown.markdown);
|
|
42344
|
-
return {
|
|
42345
|
-
result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
|
|
42346
|
-
cited_url: null,
|
|
42347
|
-
...base
|
|
42348
|
-
};
|
|
42349
|
-
} catch (error) {
|
|
42350
|
-
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
42351
|
-
return {
|
|
42352
|
-
result: "unavailable",
|
|
42353
|
-
model: null,
|
|
42354
|
-
cited_url: null,
|
|
42355
|
-
answered_at: null,
|
|
42356
|
-
cost_usd: error.cost ?? 0,
|
|
42357
|
-
error: error instanceof Error ? error.message.slice(0, 200) : "failed"
|
|
42358
|
-
};
|
|
42601
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
42602
|
+
} catch {
|
|
42603
|
+
return null;
|
|
42359
42604
|
}
|
|
42605
|
+
};
|
|
42606
|
+
const NOT_SOURCES = [
|
|
42607
|
+
"dataforseo.com",
|
|
42608
|
+
"images.openai.com",
|
|
42609
|
+
"gstatic.com",
|
|
42610
|
+
"googleusercontent.com",
|
|
42611
|
+
"ggpht.com",
|
|
42612
|
+
"example.com",
|
|
42613
|
+
"example.org",
|
|
42614
|
+
"example.net"
|
|
42615
|
+
];
|
|
42616
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i;
|
|
42617
|
+
const isSource = (url) => {
|
|
42618
|
+
const host = hostOf$2(url);
|
|
42619
|
+
if (!host || IMAGE.test(url)) return false;
|
|
42620
|
+
return !NOT_SOURCES.some((name) => host === name || host.endsWith(`.${name}`));
|
|
42621
|
+
};
|
|
42622
|
+
const empty = {
|
|
42623
|
+
cited_url: null,
|
|
42624
|
+
sources: [],
|
|
42625
|
+
model: null,
|
|
42626
|
+
answered_at: null
|
|
42627
|
+
};
|
|
42628
|
+
function judge(engine, task, site) {
|
|
42629
|
+
if (task.status_code === 40102) return {
|
|
42630
|
+
result: "no_answer",
|
|
42631
|
+
...empty
|
|
42632
|
+
};
|
|
42633
|
+
if (task.status_code >= 4e4 || !task.result) return {
|
|
42634
|
+
result: "unavailable",
|
|
42635
|
+
...empty,
|
|
42636
|
+
error: `dataforseo: ${task.status_message}`.slice(0, 200)
|
|
42637
|
+
};
|
|
42638
|
+
const answered_at = String(task.result.datetime ?? "").slice(0, 10) || null;
|
|
42639
|
+
const shown = engine.shown(task.result);
|
|
42640
|
+
if (!shown) return {
|
|
42641
|
+
result: "no_answer",
|
|
42642
|
+
...empty,
|
|
42643
|
+
answered_at
|
|
42644
|
+
};
|
|
42645
|
+
const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
|
|
42646
|
+
const all = [...shown.sources, ...linked];
|
|
42647
|
+
const cited = all.find((url) => onSite(url, site.domain)) ?? null;
|
|
42648
|
+
const base = {
|
|
42649
|
+
cited_url: cited,
|
|
42650
|
+
sources: [...new Set(all.filter((url) => !onSite(url, site.domain) && isSource(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
|
|
42651
|
+
model: shown.model,
|
|
42652
|
+
answered_at
|
|
42653
|
+
};
|
|
42654
|
+
if (cited) return {
|
|
42655
|
+
result: "cited",
|
|
42656
|
+
...base
|
|
42657
|
+
};
|
|
42658
|
+
const text = prose(shown.markdown);
|
|
42659
|
+
return {
|
|
42660
|
+
result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
|
|
42661
|
+
...base
|
|
42662
|
+
};
|
|
42360
42663
|
}
|
|
42361
42664
|
async function marketsFor(keys, country) {
|
|
42362
42665
|
const code = country.toUpperCase();
|
|
@@ -42374,45 +42677,162 @@ async function marketsFor(keys, country) {
|
|
|
42374
42677
|
}));
|
|
42375
42678
|
}
|
|
42376
42679
|
const usd = (value) => Math.round(value * 1e4) / 1e4;
|
|
42680
|
+
const FIRST_WAIT_MS = 1e4;
|
|
42681
|
+
const ROUND_WAIT_MS = 5e3;
|
|
42682
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
42683
|
+
const TASK_PATTERN = /^([a-z_]+):([0-9a-f-]{20,64})$/;
|
|
42684
|
+
const AI_ANSWER_TASK_PATTERN = TASK_PATTERN;
|
|
42377
42685
|
async function runAiAnswers(input, headers, deps, more) {
|
|
42378
42686
|
const keys = dataForSeoKeys(headers);
|
|
42379
42687
|
const domain = registrableDomain(input.site)?.domain;
|
|
42380
42688
|
if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
|
|
42381
|
-
const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
|
|
42382
|
-
if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
|
|
42383
|
-
const range = dateWindow({}, QUESTION_DAYS);
|
|
42384
|
-
let questions;
|
|
42385
|
-
if (given.length) questions = given.map((query) => ({
|
|
42386
|
-
query,
|
|
42387
|
-
impressions: null
|
|
42388
|
-
}));
|
|
42389
|
-
else questions = ((await aiQueriesReport({
|
|
42390
|
-
site: input.site,
|
|
42391
|
-
...range
|
|
42392
|
-
}, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
|
|
42393
42689
|
const site = {
|
|
42394
42690
|
domain,
|
|
42395
42691
|
terms: deriveBrandTerms({ siteUrl: input.site })
|
|
42396
42692
|
};
|
|
42397
|
-
const
|
|
42398
|
-
|
|
42399
|
-
|
|
42400
|
-
|
|
42401
|
-
|
|
42402
|
-
|
|
42403
|
-
|
|
42404
|
-
|
|
42405
|
-
|
|
42406
|
-
|
|
42407
|
-
|
|
42693
|
+
const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
|
|
42694
|
+
if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
|
|
42695
|
+
const tasks = [...new Set(input.tasks ?? [])];
|
|
42696
|
+
const range = dateWindow({}, QUESTION_DAYS);
|
|
42697
|
+
const rows = [];
|
|
42698
|
+
const waiting = [];
|
|
42699
|
+
const blank = (question, impressions) => ({
|
|
42700
|
+
question,
|
|
42701
|
+
impressions,
|
|
42702
|
+
task: null,
|
|
42703
|
+
cost_usd: 0,
|
|
42704
|
+
...empty
|
|
42705
|
+
});
|
|
42706
|
+
if (tasks.length) for (const value of tasks) {
|
|
42707
|
+
const match = TASK_PATTERN.exec(value);
|
|
42708
|
+
const engine = ENGINES.find((item) => item.name === match?.[1]);
|
|
42709
|
+
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(", ")}.`);
|
|
42710
|
+
const row = {
|
|
42711
|
+
...blank("", null),
|
|
42712
|
+
engine: engine.name,
|
|
42713
|
+
result: "pending",
|
|
42714
|
+
task: value
|
|
42715
|
+
};
|
|
42716
|
+
rows.push(row);
|
|
42717
|
+
waiting.push({
|
|
42718
|
+
row,
|
|
42719
|
+
engine,
|
|
42720
|
+
id: match[2]
|
|
42721
|
+
});
|
|
42722
|
+
}
|
|
42723
|
+
else {
|
|
42724
|
+
let questions;
|
|
42725
|
+
if (given.length) questions = given.map((query) => ({
|
|
42726
|
+
query,
|
|
42727
|
+
impressions: null
|
|
42728
|
+
}));
|
|
42729
|
+
else questions = ((await aiQueriesReport({
|
|
42730
|
+
site: input.site,
|
|
42731
|
+
...range
|
|
42732
|
+
}, deps.searchAnalytics)).items ?? []).filter((item) => item.askable).slice(0, 10);
|
|
42733
|
+
const markets = questions.length ? await marketsFor(keys, input.country) : /* @__PURE__ */ new Map();
|
|
42734
|
+
await Promise.all(ENGINES.map(async (engine) => {
|
|
42735
|
+
const engineRows = questions.map((question) => ({
|
|
42736
|
+
...blank(question.query, question.impressions),
|
|
42737
|
+
engine: engine.name,
|
|
42738
|
+
result: "unavailable"
|
|
42739
|
+
}));
|
|
42740
|
+
rows.push(...engineRows);
|
|
42741
|
+
if (!questions.length) return;
|
|
42742
|
+
const market = markets.get(engine.name) ?? "no market";
|
|
42743
|
+
if (typeof market === "string") {
|
|
42744
|
+
for (const row of engineRows) row.error = market;
|
|
42745
|
+
return;
|
|
42746
|
+
}
|
|
42747
|
+
let queuedTasks;
|
|
42748
|
+
try {
|
|
42749
|
+
queuedTasks = await dataForSeoPost(keys, engine.post, questions.map((question) => ({
|
|
42750
|
+
...engine.task(question.query, market),
|
|
42751
|
+
priority: 2
|
|
42752
|
+
})));
|
|
42753
|
+
} catch (error) {
|
|
42754
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
42755
|
+
for (const row of engineRows) row.error = error instanceof Error ? error.message.slice(0, 200) : "failed";
|
|
42756
|
+
return;
|
|
42757
|
+
}
|
|
42758
|
+
engineRows.forEach((row, index) => {
|
|
42759
|
+
const task = queuedTasks[index];
|
|
42760
|
+
row.cost_usd = task?.cost ?? 0;
|
|
42761
|
+
if (!task || task.status_code >= 4e4 || !task.id) {
|
|
42762
|
+
row.error = `dataforseo: ${task?.status_message ?? "no task"}`;
|
|
42763
|
+
return;
|
|
42764
|
+
}
|
|
42765
|
+
row.result = "pending";
|
|
42766
|
+
row.task = `${engine.name}:${task.id}`;
|
|
42767
|
+
waiting.push({
|
|
42768
|
+
row,
|
|
42769
|
+
engine,
|
|
42770
|
+
id: task.id
|
|
42771
|
+
});
|
|
42772
|
+
});
|
|
42773
|
+
}));
|
|
42774
|
+
}
|
|
42775
|
+
const deadline = Date.now() + (input.waitSeconds ?? 40) * 1e3;
|
|
42776
|
+
let pending = waiting;
|
|
42777
|
+
let first = !tasks.length;
|
|
42778
|
+
while (pending.length) {
|
|
42779
|
+
const pause = first ? FIRST_WAIT_MS : ROUND_WAIT_MS;
|
|
42780
|
+
first = false;
|
|
42781
|
+
if (!tasks.length || pending !== waiting) {
|
|
42782
|
+
if (Date.now() + pause > deadline) break;
|
|
42783
|
+
await sleep(pause);
|
|
42784
|
+
}
|
|
42785
|
+
const still = [];
|
|
42786
|
+
await Promise.all(pending.map(async (item) => {
|
|
42787
|
+
try {
|
|
42788
|
+
const task = await dataForSeoGet(keys, `${item.engine.get}${item.id}`);
|
|
42789
|
+
if (!item.row.question) item.row.question = String(task.data.keyword ?? "");
|
|
42790
|
+
if (DATAFORSEO_NOT_READY.has(task.status_code)) {
|
|
42791
|
+
still.push(item);
|
|
42792
|
+
return;
|
|
42793
|
+
}
|
|
42794
|
+
Object.assign(item.row, judge(item.engine, task, site), { task: null });
|
|
42795
|
+
} catch (error) {
|
|
42796
|
+
if (error instanceof CommandError && error.code === "NO_KEY") throw error;
|
|
42797
|
+
still.push(item);
|
|
42798
|
+
}
|
|
42799
|
+
}));
|
|
42800
|
+
pending = still;
|
|
42801
|
+
if (tasks.length && Date.now() >= deadline) break;
|
|
42802
|
+
}
|
|
42803
|
+
rows.sort((a, b) => a.question.localeCompare(b.question) || ENGINES.findIndex((e) => e.name === a.engine) - ENGINES.findIndex((e) => e.name === b.engine));
|
|
42804
|
+
const cost = usd(rows.reduce((sum, row) => sum + row.cost_usd, 0));
|
|
42805
|
+
const count = (result) => rows.filter((row) => row.result === result).length;
|
|
42806
|
+
const left = rows.filter((row) => row.task).map((row) => row.task);
|
|
42807
|
+
const cites = /* @__PURE__ */ new Map();
|
|
42808
|
+
for (const row of rows) for (const host of row.sources) cites.set(host, (cites.get(host) ?? 0) + 1);
|
|
42809
|
+
const topCited = [...cites].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10);
|
|
42810
|
+
const questionCount = new Set(rows.map((row) => row.question)).size;
|
|
42811
|
+
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 ? [
|
|
42812
|
+
`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}.`,
|
|
42813
|
+
...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).`] : [],
|
|
42814
|
+
...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
|
|
42408
42815
|
"Each row is one sample of an answer that changes from run to run, not a ranking.",
|
|
42409
|
-
`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.`,
|
|
42816
|
+
`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.`,
|
|
42410
42817
|
`Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
|
|
42411
42818
|
] : []];
|
|
42819
|
+
const shown = rows.map((row) => ({
|
|
42820
|
+
question: row.question,
|
|
42821
|
+
impressions: row.impressions,
|
|
42822
|
+
engine: row.engine,
|
|
42823
|
+
result: row.result,
|
|
42824
|
+
cited_url: row.cited_url,
|
|
42825
|
+
sources: row.sources.join(", "),
|
|
42826
|
+
model: row.model,
|
|
42827
|
+
answered_at: row.answered_at,
|
|
42828
|
+
cost_usd: row.cost_usd,
|
|
42829
|
+
task: row.task,
|
|
42830
|
+
error: row.error ?? null
|
|
42831
|
+
}));
|
|
42412
42832
|
return {
|
|
42413
42833
|
text: rowsText({
|
|
42414
42834
|
title: `AI answers for ${input.site}, ${input.country}.`,
|
|
42415
|
-
rows:
|
|
42835
|
+
rows: shown,
|
|
42416
42836
|
offset: input.offset,
|
|
42417
42837
|
limit: 50,
|
|
42418
42838
|
detail: input.detail,
|
|
@@ -42424,15 +42844,23 @@ async function runAiAnswers(input, headers, deps, more) {
|
|
|
42424
42844
|
action: "ai_answers",
|
|
42425
42845
|
site: input.site,
|
|
42426
42846
|
country: input.country,
|
|
42427
|
-
questionsFrom: given.length ? { section: "given" } : {
|
|
42847
|
+
questionsFrom: tasks.length ? { section: "tasks" } : given.length ? { section: "given" } : {
|
|
42428
42848
|
section: "ai-queries",
|
|
42429
42849
|
...range
|
|
42430
42850
|
},
|
|
42431
|
-
questions:
|
|
42851
|
+
questions: [...new Set(rows.map((row) => row.question))],
|
|
42432
42852
|
costUsd: cost,
|
|
42433
|
-
|
|
42853
|
+
pendingTasks: left,
|
|
42854
|
+
citedSites: topCited.map(([host, answers]) => ({
|
|
42855
|
+
host,
|
|
42856
|
+
answers
|
|
42857
|
+
})),
|
|
42858
|
+
total: rows.length,
|
|
42434
42859
|
offset: input.offset,
|
|
42435
|
-
rows:
|
|
42860
|
+
rows: rows.slice(input.offset, input.offset + 50).map(({ error, ...row }) => error ? {
|
|
42861
|
+
...row,
|
|
42862
|
+
error
|
|
42863
|
+
} : row)
|
|
42436
42864
|
}
|
|
42437
42865
|
};
|
|
42438
42866
|
}
|
|
@@ -42804,7 +43232,7 @@ async function totals$1(found, access, range, before, pages) {
|
|
|
42804
43232
|
notes
|
|
42805
43233
|
};
|
|
42806
43234
|
}
|
|
42807
|
-
const change$
|
|
43235
|
+
const change$2 = (value) => value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
|
|
42808
43236
|
function engineText(rows) {
|
|
42809
43237
|
const shown = rows.filter((row) => row.sessions || row.previousSessions);
|
|
42810
43238
|
if (shown.length < 2) return "";
|
|
@@ -42814,7 +43242,7 @@ function aiText(ai) {
|
|
|
42814
43242
|
const list = (rows, key) => rows.slice(0, AI_ROWS).map((row) => `${row[key]} ${row.sessions} (was ${row.previousSessions})`).join(", ");
|
|
42815
43243
|
if (!ai.sessions && !ai.previousSessions) return "Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): none in either period.";
|
|
42816
43244
|
return [
|
|
42817
|
-
`Visits from AI assistants (ChatGPT, Perplexity, Claude, Gemini, Copilot and others): ${ai.sessions} sessions (was ${ai.previousSessions}, ${change$
|
|
43245
|
+
`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")}.`,
|
|
42818
43246
|
ai.topLandingPages.length ? `Top landing pages from AI assistants: ${list(ai.topLandingPages, "page")}.` : "",
|
|
42819
43247
|
"Only visits that pass a referrer or utm_source count; AI apps that send none show as Direct."
|
|
42820
43248
|
].filter(Boolean).join(" ");
|
|
@@ -42931,7 +43359,7 @@ async function analyticsBeside(access, site, range, before, pages) {
|
|
|
42931
43359
|
const keyEvents = organic.keyEvents || organic.previousKeyEvents ? `, Organic Search key events ${organic.keyEvents} (was ${organic.previousKeyEvents})` : "";
|
|
42932
43360
|
return {
|
|
42933
43361
|
text: [
|
|
42934
|
-
`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$
|
|
43362
|
+
`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)}).`,
|
|
42935
43363
|
engineText(data.organicSearchByEngine),
|
|
42936
43364
|
"Sessions are Google Analytics’ own count, not Search Console clicks.",
|
|
42937
43365
|
...data.notes,
|
|
@@ -43158,7 +43586,7 @@ function auditPreview(snapshot) {
|
|
|
43158
43586
|
}
|
|
43159
43587
|
const WEEK_MS = 6048e5;
|
|
43160
43588
|
const day$1 = (ms) => ms === null ? "" : new Date(ms).toISOString().slice(0, 10);
|
|
43161
|
-
const count$
|
|
43589
|
+
const count$3 = (value) => value.toLocaleString("en-US");
|
|
43162
43590
|
function relative$2(value, origin) {
|
|
43163
43591
|
if (!value) return "";
|
|
43164
43592
|
return origin && value.startsWith(origin) ? value.slice(origin.length) || "/" : value;
|
|
@@ -43179,7 +43607,7 @@ function rowsTable(rows, checks) {
|
|
|
43179
43607
|
changed(row) ? row.prev_coverage ?? "" : "",
|
|
43180
43608
|
changed(row) ? day$1(row.changed_at) : "",
|
|
43181
43609
|
day$1(row.checked_at),
|
|
43182
|
-
`${count$
|
|
43610
|
+
`${count$3(row.clicks)} (${count$3(row.previous_clicks)})${row.lost ? " lost" : ""}`,
|
|
43183
43611
|
row.in_sitemap ? "yes" : "no",
|
|
43184
43612
|
row.google_canonical && row.google_canonical !== row.url ? relative$2(row.google_canonical, checks.origin) : "",
|
|
43185
43613
|
row.error ?? ""
|
|
@@ -43187,12 +43615,12 @@ function rowsTable(rows, checks) {
|
|
|
43187
43615
|
}
|
|
43188
43616
|
function head(site, checks) {
|
|
43189
43617
|
const { queued } = checks;
|
|
43190
|
-
const quota = checks.properties.map((item) => `${item.property} (${count$
|
|
43618
|
+
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(", ");
|
|
43191
43619
|
return [
|
|
43192
43620
|
`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).`,
|
|
43193
|
-
checks.dropped_this_week || checks.returned_this_week ? `In the last 7 days ${count$
|
|
43194
|
-
`Covered: ${count$
|
|
43195
|
-
queued.total ? `Queued: ${count$
|
|
43621
|
+
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,
|
|
43622
|
+
`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.`,
|
|
43623
|
+
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.",
|
|
43196
43624
|
quota ? `Checked through ${quota}.` : null,
|
|
43197
43625
|
checks.stopped ? `Checks stopped early: ${checks.stopped}` : null,
|
|
43198
43626
|
"A URL without a result has none here, so this is not an all-clear for it."
|
|
@@ -43201,9 +43629,9 @@ function head(site, checks) {
|
|
|
43201
43629
|
function suggestionsText(checks) {
|
|
43202
43630
|
if (!checks.suggestions.length) return null;
|
|
43203
43631
|
return [
|
|
43204
|
-
`The sitemap has more URLs than these properties can check every 7 days. Each Search Console property gets its own ${count$
|
|
43632
|
+
`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:`,
|
|
43205
43633
|
"property to add | sitemap URLs in it | more URLs checked a week",
|
|
43206
|
-
...checks.suggestions.map((item) => `${item.property} | ${count$
|
|
43634
|
+
...checks.suggestions.map((item) => `${item.property} | ${count$3(item.urls)} | ${count$3(item.gain)}`),
|
|
43207
43635
|
"The next day the checks find a new property in Search Console and use its quota for the URLs in that folder."
|
|
43208
43636
|
].join("\n");
|
|
43209
43637
|
}
|
|
@@ -43218,7 +43646,7 @@ function runAuditChecks(input, checks, call) {
|
|
|
43218
43646
|
data: checks
|
|
43219
43647
|
};
|
|
43220
43648
|
if (!checks.started) return {
|
|
43221
|
-
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$
|
|
43649
|
+
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.`,
|
|
43222
43650
|
data: checks
|
|
43223
43651
|
};
|
|
43224
43652
|
const more = (offset) => input.paged ? `More: ${call("index", {
|
|
@@ -43242,7 +43670,7 @@ function runAuditChecks(input, checks, call) {
|
|
|
43242
43670
|
const reasons = checks.reasons.length ? [
|
|
43243
43671
|
"Google's reason at each URL's latest check:",
|
|
43244
43672
|
"reason | verdict | urls | changed to it this week",
|
|
43245
|
-
...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$
|
|
43673
|
+
...checks.reasons.map((item) => `${item.coverage ?? ""} | ${item.verdict ?? ""} | ${count$3(item.urls)} | ${count$3(item.changed_this_week)}`)
|
|
43246
43674
|
].join("\n") : "No URL has a result yet.";
|
|
43247
43675
|
const shown = checks.rows.slice(0, 20);
|
|
43248
43676
|
return {
|
|
@@ -43299,7 +43727,7 @@ async function bingFor(deps, property) {
|
|
|
43299
43727
|
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.`
|
|
43300
43728
|
};
|
|
43301
43729
|
}
|
|
43302
|
-
function days(range) {
|
|
43730
|
+
function days$1(range) {
|
|
43303
43731
|
return Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 864e5) + 1;
|
|
43304
43732
|
}
|
|
43305
43733
|
async function bingTotals(source, range, before) {
|
|
@@ -43312,7 +43740,7 @@ async function bingTotals(source, range, before) {
|
|
|
43312
43740
|
clicks: rows.reduce((total, row) => total + row.clicks, 0),
|
|
43313
43741
|
impressions: rows.reduce((total, row) => total + row.impressions, 0),
|
|
43314
43742
|
days: new Set(rows.map((row) => row.date)).size,
|
|
43315
|
-
expectedDays: days(window)
|
|
43743
|
+
expectedDays: days$1(window)
|
|
43316
43744
|
};
|
|
43317
43745
|
};
|
|
43318
43746
|
return {
|
|
@@ -43322,14 +43750,14 @@ async function bingTotals(source, range, before) {
|
|
|
43322
43750
|
invalidRows: traffic.invalidRows
|
|
43323
43751
|
};
|
|
43324
43752
|
}
|
|
43325
|
-
const change = (now, before) => {
|
|
43753
|
+
const change$1 = (now, before) => {
|
|
43326
43754
|
const value = pct(now, before);
|
|
43327
43755
|
return value === null ? "n/a" : `${value > 0 ? "+" : ""}${value}%`;
|
|
43328
43756
|
};
|
|
43329
43757
|
function enginesText(google, bing) {
|
|
43330
43758
|
if ("missing" in bing) return `Bing: ${bing.missing}`;
|
|
43331
43759
|
const { current: now, previous: then } = bing;
|
|
43332
|
-
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}).`];
|
|
43760
|
+
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}).`];
|
|
43333
43761
|
const both = google.clicks + now.clicks;
|
|
43334
43762
|
if (both > 0) lines.push(`Share of these clicks: Google ${Math.round(google.clicks / both * 100)}%, Bing ${Math.round(now.clicks / both * 100)}%.`);
|
|
43335
43763
|
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.`);
|
|
@@ -43483,7 +43911,7 @@ async function runBingRows(input, deps) {
|
|
|
43483
43911
|
}
|
|
43484
43912
|
};
|
|
43485
43913
|
}
|
|
43486
|
-
function moved(rows, range, before, name, limit) {
|
|
43914
|
+
function moved$1(rows, range, before, name, limit) {
|
|
43487
43915
|
const now = sumWeeks(rows, range);
|
|
43488
43916
|
const then = sumWeeks(rows, before);
|
|
43489
43917
|
const old = new Map(then.rows.map((row) => [row.key, row]));
|
|
@@ -43521,8 +43949,8 @@ async function bingOverview(source, range, before) {
|
|
|
43521
43949
|
]);
|
|
43522
43950
|
if ("missing" in totals) throw new CommandError("NOT_CONNECTED", totals.missing);
|
|
43523
43951
|
const { current: now, previous: then } = totals;
|
|
43524
|
-
const q = moved(queries.rows, range, before, "query", 15);
|
|
43525
|
-
const p = moved(pages.rows, range, before, "page", 15);
|
|
43952
|
+
const q = moved$1(queries.rows, range, before, "query", 15);
|
|
43953
|
+
const p = moved$1(pages.rows, range, before, "page", 15);
|
|
43526
43954
|
return {
|
|
43527
43955
|
bingSite: source.site,
|
|
43528
43956
|
range,
|
|
@@ -43573,7 +44001,7 @@ function bingOverviewText(report, heading, origin, rowsPerList) {
|
|
|
43573
44001
|
};
|
|
43574
44002
|
return [
|
|
43575
44003
|
`${heading}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
|
|
43576
|
-
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.",
|
|
44004
|
+
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.",
|
|
43577
44005
|
group("pages", "page"),
|
|
43578
44006
|
group("queries", "query"),
|
|
43579
44007
|
`Limits:\n${report.caveats.map((line) => `- ${line}`).join("\n")}`
|
|
@@ -43650,13 +44078,13 @@ function clickyRange(input) {
|
|
|
43650
44078
|
endDate: input.endDate
|
|
43651
44079
|
};
|
|
43652
44080
|
}
|
|
43653
|
-
const count$
|
|
44081
|
+
const count$2 = (item) => {
|
|
43654
44082
|
const value = Number(item.value);
|
|
43655
44083
|
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
43656
44084
|
};
|
|
43657
44085
|
const totals = (items) => items.map((item) => ({
|
|
43658
44086
|
name: item.title ?? "",
|
|
43659
|
-
visits: count$
|
|
44087
|
+
visits: count$2(item)
|
|
43660
44088
|
})).filter((item) => Boolean(item.name) && item.visits !== null);
|
|
43661
44089
|
const line = (items) => {
|
|
43662
44090
|
const top = items.slice(0, 10).map((item) => `${item.name} ${item.visits}`);
|
|
@@ -43687,7 +44115,7 @@ async function runClickyRows(input, keys, options) {
|
|
|
43687
44115
|
const entrances = stats["pages-entrance"] ?? [];
|
|
43688
44116
|
const visitsByPage = /* @__PURE__ */ new Map();
|
|
43689
44117
|
for (const item of entrances) {
|
|
43690
|
-
const visits = count$
|
|
44118
|
+
const visits = count$2(item);
|
|
43691
44119
|
if (visits === null || !item.url) continue;
|
|
43692
44120
|
let page;
|
|
43693
44121
|
try {
|
|
@@ -45016,6 +45444,265 @@ function temporaryRedirectNote(pages) {
|
|
|
45016
45444
|
if (!temporary.length) return null;
|
|
45017
45445
|
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("; ")}.`;
|
|
45018
45446
|
}
|
|
45447
|
+
// packages/core/dist/commands/updates.js
|
|
45448
|
+
const UPDATES_SECTION = "google-updates";
|
|
45449
|
+
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.";
|
|
45450
|
+
const MAX_DAYS = 28;
|
|
45451
|
+
const BASELINE = 8;
|
|
45452
|
+
const ROWS = 25e3;
|
|
45453
|
+
const LISTED = 10;
|
|
45454
|
+
const SHOWN = 5;
|
|
45455
|
+
const MIN_CLICKS = 50;
|
|
45456
|
+
const days = (from, to) => Math.round((Date.parse(to) - Date.parse(from)) / 864e5) + 1;
|
|
45457
|
+
const asImpactUpdate = (update) => ({
|
|
45458
|
+
id: update.id,
|
|
45459
|
+
name: update.name,
|
|
45460
|
+
start: update.start,
|
|
45461
|
+
end: update.end ?? null,
|
|
45462
|
+
url: update.sourceUrl
|
|
45463
|
+
});
|
|
45464
|
+
function sum(rows, from, to) {
|
|
45465
|
+
let clicks = 0;
|
|
45466
|
+
let impressions = 0;
|
|
45467
|
+
for (const row of rows) {
|
|
45468
|
+
const day = row.keys[0] ?? "";
|
|
45469
|
+
if (day < from || day > to) continue;
|
|
45470
|
+
clicks += row.clicks;
|
|
45471
|
+
impressions += row.impressions;
|
|
45472
|
+
}
|
|
45473
|
+
return {
|
|
45474
|
+
clicks,
|
|
45475
|
+
impressions
|
|
45476
|
+
};
|
|
45477
|
+
}
|
|
45478
|
+
const change = (now, before) => before > 0 ? (now - before) / before * 100 : null;
|
|
45479
|
+
function joined(before, after) {
|
|
45480
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
45481
|
+
const at = (key) => {
|
|
45482
|
+
let row = byKey.get(key);
|
|
45483
|
+
if (!row) {
|
|
45484
|
+
row = {
|
|
45485
|
+
key,
|
|
45486
|
+
clicks: 0,
|
|
45487
|
+
previous: 0,
|
|
45488
|
+
impressions: 0,
|
|
45489
|
+
previous_impressions: 0
|
|
45490
|
+
};
|
|
45491
|
+
byKey.set(key, row);
|
|
45492
|
+
}
|
|
45493
|
+
return row;
|
|
45494
|
+
};
|
|
45495
|
+
for (const row of before) {
|
|
45496
|
+
const item = at(row.keys[0] ?? "");
|
|
45497
|
+
item.previous += row.clicks;
|
|
45498
|
+
item.previous_impressions += row.impressions;
|
|
45499
|
+
}
|
|
45500
|
+
for (const row of after) {
|
|
45501
|
+
const item = at(row.keys[0] ?? "");
|
|
45502
|
+
item.clicks += row.clicks;
|
|
45503
|
+
item.impressions += row.impressions;
|
|
45504
|
+
}
|
|
45505
|
+
return [...byKey.values()];
|
|
45506
|
+
}
|
|
45507
|
+
const moved = (row) => row.clicks - row.previous;
|
|
45508
|
+
const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
45509
|
+
function extremes(rows) {
|
|
45510
|
+
return {
|
|
45511
|
+
lost: rows.filter((row) => moved(row) < 0).sort((a, b) => moved(a) - moved(b) || byKey(a, b)).slice(0, LISTED),
|
|
45512
|
+
gained: rows.filter((row) => moved(row) > 0).sort((a, b) => moved(b) - moved(a) || byKey(a, b)).slice(0, LISTED)
|
|
45513
|
+
};
|
|
45514
|
+
}
|
|
45515
|
+
function folderOf(url) {
|
|
45516
|
+
try {
|
|
45517
|
+
const parts = new URL(url).pathname.split("/").filter(Boolean);
|
|
45518
|
+
return parts.length > 1 ? `/${parts[0]}/` : "/";
|
|
45519
|
+
} catch {
|
|
45520
|
+
return "/";
|
|
45521
|
+
}
|
|
45522
|
+
}
|
|
45523
|
+
function folders(pages) {
|
|
45524
|
+
const byFolder = /* @__PURE__ */ new Map();
|
|
45525
|
+
for (const page of pages) {
|
|
45526
|
+
const key = folderOf(page.key);
|
|
45527
|
+
const row = byFolder.get(key) ?? {
|
|
45528
|
+
key,
|
|
45529
|
+
clicks: 0,
|
|
45530
|
+
previous: 0,
|
|
45531
|
+
impressions: 0,
|
|
45532
|
+
previous_impressions: 0
|
|
45533
|
+
};
|
|
45534
|
+
row.clicks += page.clicks;
|
|
45535
|
+
row.previous += page.previous;
|
|
45536
|
+
row.impressions += page.impressions;
|
|
45537
|
+
row.previous_impressions += page.previous_impressions;
|
|
45538
|
+
byFolder.set(key, row);
|
|
45539
|
+
}
|
|
45540
|
+
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);
|
|
45541
|
+
}
|
|
45542
|
+
function windowDays(update, latest) {
|
|
45543
|
+
const start = update.start.slice(0, 10);
|
|
45544
|
+
const end = update.end?.slice(0, 10) ?? null;
|
|
45545
|
+
const wanted = Math.min(MAX_DAYS, Math.max(7, Math.ceil(days(start, end ?? latest) / 7) * 7));
|
|
45546
|
+
const covered = Math.floor(Math.max(0, days(start, latest)) / 7) * 7;
|
|
45547
|
+
return {
|
|
45548
|
+
start,
|
|
45549
|
+
end,
|
|
45550
|
+
wanted,
|
|
45551
|
+
n: Math.min(wanted, covered)
|
|
45552
|
+
};
|
|
45553
|
+
}
|
|
45554
|
+
async function updateImpact(site, update, read, now) {
|
|
45555
|
+
const { start, end, wanted, n } = windowDays(update, latestFinalGscDate(now));
|
|
45556
|
+
const empty = {
|
|
45557
|
+
update,
|
|
45558
|
+
site,
|
|
45559
|
+
before: null,
|
|
45560
|
+
after: null,
|
|
45561
|
+
normal_range: null,
|
|
45562
|
+
verdict: "not-enough-data",
|
|
45563
|
+
capped: {
|
|
45564
|
+
pages: false,
|
|
45565
|
+
queries: false
|
|
45566
|
+
},
|
|
45567
|
+
folders: [],
|
|
45568
|
+
pages: {
|
|
45569
|
+
lost: [],
|
|
45570
|
+
gained: []
|
|
45571
|
+
},
|
|
45572
|
+
queries: {
|
|
45573
|
+
lost: [],
|
|
45574
|
+
gained: []
|
|
45575
|
+
}
|
|
45576
|
+
};
|
|
45577
|
+
if (n < 7) return {
|
|
45578
|
+
...empty,
|
|
45579
|
+
window: "waiting"
|
|
45580
|
+
};
|
|
45581
|
+
const after = {
|
|
45582
|
+
startDate: start,
|
|
45583
|
+
endDate: shiftIsoDate(start, n - 1)
|
|
45584
|
+
};
|
|
45585
|
+
const before = {
|
|
45586
|
+
startDate: shiftIsoDate(start, -n),
|
|
45587
|
+
endDate: shiftIsoDate(start, -1)
|
|
45588
|
+
};
|
|
45589
|
+
const first = Math.ceil(n / 7);
|
|
45590
|
+
const pairs = Array.from({ length: BASELINE }, (_, i) => {
|
|
45591
|
+
const back = 7 * (first + i);
|
|
45592
|
+
return {
|
|
45593
|
+
after: [shiftIsoDate(start, -back), shiftIsoDate(start, n - 1 - back)],
|
|
45594
|
+
before: [shiftIsoDate(start, -back - n), shiftIsoDate(start, -back - 1)]
|
|
45595
|
+
};
|
|
45596
|
+
});
|
|
45597
|
+
const shared = sharedReader(read);
|
|
45598
|
+
const call = (range, dimension) => shared(site, {
|
|
45599
|
+
...range,
|
|
45600
|
+
dimensions: [dimension],
|
|
45601
|
+
type: "web",
|
|
45602
|
+
dataState: "final",
|
|
45603
|
+
...dimension === "date" ? {} : { maxRows: ROWS }
|
|
45604
|
+
});
|
|
45605
|
+
const [daily, pagesBefore, pagesAfter, queriesBefore, queriesAfter] = await Promise.all([
|
|
45606
|
+
call({
|
|
45607
|
+
startDate: pairs.at(-1)?.before[0],
|
|
45608
|
+
endDate: after.endDate
|
|
45609
|
+
}, "date"),
|
|
45610
|
+
call(before, "page"),
|
|
45611
|
+
call(after, "page"),
|
|
45612
|
+
call(before, "query"),
|
|
45613
|
+
call(after, "query")
|
|
45614
|
+
]);
|
|
45615
|
+
const totals = (range) => ({
|
|
45616
|
+
...range,
|
|
45617
|
+
...sum(daily.rows, range.startDate, range.endDate)
|
|
45618
|
+
});
|
|
45619
|
+
const was = totals(before);
|
|
45620
|
+
const is = totals(after);
|
|
45621
|
+
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);
|
|
45622
|
+
const normal = seen.length ? {
|
|
45623
|
+
comparisons: seen.length,
|
|
45624
|
+
clicks_pct: Math.round(Math.max(...seen.map((v) => Math.abs(v))) * 10) / 10
|
|
45625
|
+
} : null;
|
|
45626
|
+
const moves = change(is.clicks, was.clicks);
|
|
45627
|
+
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";
|
|
45628
|
+
const pageRows = joined(pagesBefore.rows, pagesAfter.rows);
|
|
45629
|
+
return {
|
|
45630
|
+
...empty,
|
|
45631
|
+
window: end && n === wanted ? "full" : "partial",
|
|
45632
|
+
before: was,
|
|
45633
|
+
after: is,
|
|
45634
|
+
normal_range: normal,
|
|
45635
|
+
verdict,
|
|
45636
|
+
capped: {
|
|
45637
|
+
pages: pagesBefore.rows.length >= ROWS || pagesAfter.rows.length >= ROWS,
|
|
45638
|
+
queries: queriesBefore.rows.length >= ROWS || queriesAfter.rows.length >= ROWS
|
|
45639
|
+
},
|
|
45640
|
+
folders: folders(pageRows),
|
|
45641
|
+
pages: extremes(pageRows),
|
|
45642
|
+
queries: extremes(joined(queriesBefore.rows, queriesAfter.rows))
|
|
45643
|
+
};
|
|
45644
|
+
}
|
|
45645
|
+
const count$1 = (n) => n.toLocaleString("en-US");
|
|
45646
|
+
const dayLabel$1 = (date) => (/* @__PURE__ */ new Date(`${date}T00:00:00Z`)).toLocaleDateString("en-GB", {
|
|
45647
|
+
day: "numeric",
|
|
45648
|
+
month: "short",
|
|
45649
|
+
year: "numeric",
|
|
45650
|
+
timeZone: "UTC"
|
|
45651
|
+
});
|
|
45652
|
+
function trend(now, before) {
|
|
45653
|
+
const pct = change(now, before);
|
|
45654
|
+
if (pct === null) return `from ${count$1(before)}`;
|
|
45655
|
+
const rounded = Math.round(pct);
|
|
45656
|
+
return rounded === 0 ? `level with ${count$1(before)}` : `${rounded > 0 ? "up" : "down"} ${Math.abs(rounded)}% on ${count$1(before)}`;
|
|
45657
|
+
}
|
|
45658
|
+
function rowText(row, origin) {
|
|
45659
|
+
const name = row.key.startsWith(origin) ? row.key.slice(origin.length) || "/" : row.key;
|
|
45660
|
+
const diff = moved(row);
|
|
45661
|
+
return `${name} ${count$1(row.clicks)} (${diff > 0 ? "+" : ""}${count$1(diff)})`;
|
|
45662
|
+
}
|
|
45663
|
+
function impactLines(impact) {
|
|
45664
|
+
const { update, before, after } = impact;
|
|
45665
|
+
const origin = originOf(impact.site);
|
|
45666
|
+
const dates = `${dayLabel$1(update.start.slice(0, 10))} to ${update.end ? dayLabel$1(update.end.slice(0, 10)) : "now, still rolling out"}`;
|
|
45667
|
+
const lines = [`${update.name} (${dates}). [Google’s notice](${update.url})`];
|
|
45668
|
+
if (!before || !after) return [...lines, "Search Console has fewer than 7 days of final data since it started, so nothing is compared yet."];
|
|
45669
|
+
const n = days(after.startDate, after.endDate);
|
|
45670
|
+
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)}.`);
|
|
45671
|
+
const normal = impact.normal_range;
|
|
45672
|
+
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).`);
|
|
45673
|
+
const list = (title, rows) => rows.length ? [`${title}: ${rows.slice(0, SHOWN).map((row) => rowText(row, origin)).join("; ")}.`] : [];
|
|
45674
|
+
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));
|
|
45675
|
+
const capped = [impact.capped.pages ? "pages" : null, impact.capped.queries ? "queries" : null].filter(Boolean);
|
|
45676
|
+
if (capped.length) lines.push(`Search Console returned the row limit for ${capped.join(" and ")}, so those lists may miss rows.`);
|
|
45677
|
+
lines.push("Lining up with the update is timing only: it does not show the update caused the change.");
|
|
45678
|
+
return lines;
|
|
45679
|
+
}
|
|
45680
|
+
async function runUpdates(input, deps) {
|
|
45681
|
+
const now = (deps.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
45682
|
+
if (input.endDate && !realIsoDate(input.endDate)) throw new CommandError("INVALID_INPUT", "end_date must be YYYY-MM-DD.");
|
|
45683
|
+
const cutoff = input.endDate ?? latestFinalGscDate(now);
|
|
45684
|
+
const updates = await rankingUpdates();
|
|
45685
|
+
if (typeof updates === "string") throw new CommandError("PROVIDER_ERROR", updates);
|
|
45686
|
+
const sorted = [...updates].sort((a, b) => b.start.localeCompare(a.start) || a.id.localeCompare(b.id));
|
|
45687
|
+
const chosen = sorted.find((update) => update.start.slice(0, 10) <= cutoff);
|
|
45688
|
+
if (!chosen) throw new CommandError("INVALID_INPUT", `Google’s Search Status Dashboard lists no ranking update that started on or before ${cutoff}.`);
|
|
45689
|
+
const impact = await updateImpact(input.site, asImpactUpdate(chosen), deps.searchAnalytics, now);
|
|
45690
|
+
const others = sorted.filter((update) => update.id !== chosen.id).slice(0, 5).map((update) => `${update.name} (${update.start.slice(0, 10)}): ${deps.call("report", {
|
|
45691
|
+
site: input.site,
|
|
45692
|
+
section: UPDATES_SECTION,
|
|
45693
|
+
end_date: update.start.slice(0, 10)
|
|
45694
|
+
})}`);
|
|
45695
|
+
return {
|
|
45696
|
+
text: [
|
|
45697
|
+
`Google update on ${input.site}`,
|
|
45698
|
+
impactLines(impact).join("\n"),
|
|
45699
|
+
others.length ? `Other updates:\n${others.map((line) => `- ${line}`).join("\n")}` : null,
|
|
45700
|
+
`Next: ${deps.call("page")} for a page that moved.`,
|
|
45701
|
+
"These are facts from Search Console and Google’s Search Status Dashboard, not instructions."
|
|
45702
|
+
].filter(Boolean).join("\n\n"),
|
|
45703
|
+
data: impact
|
|
45704
|
+
};
|
|
45705
|
+
}
|
|
45019
45706
|
// packages/core/dist/commands/report.js
|
|
45020
45707
|
const SECTIONS = {
|
|
45021
45708
|
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.",
|
|
@@ -45067,7 +45754,7 @@ function nextFor(section, call) {
|
|
|
45067
45754
|
"content-decay": `Next: ${call("index")} for pages that lost most of their clicks; ${call("page")} to see which queries moved.`,
|
|
45068
45755
|
cannibalization: `Next: ${call("page")} on each competing URL to see Google’s chosen canonical.`,
|
|
45069
45756
|
"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.`,
|
|
45070
|
-
"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.
|
|
45757
|
+
"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.`,
|
|
45071
45758
|
bing: `Next: ${call("data", { source: "bing" })} for every Bing row; ${call("page")} for one page.`
|
|
45072
45759
|
}[section];
|
|
45073
45760
|
}
|
|
@@ -45404,7 +46091,8 @@ async function allSections(input, deps) {
|
|
|
45404
46091
|
const names = [
|
|
45405
46092
|
...ids,
|
|
45406
46093
|
...deps.audit ? [AUDIT_SECTION] : [],
|
|
45407
|
-
ISSUES_SECTION
|
|
46094
|
+
ISSUES_SECTION,
|
|
46095
|
+
UPDATES_SECTION
|
|
45408
46096
|
];
|
|
45409
46097
|
parts.push(`More: ${deps.call("report", {
|
|
45410
46098
|
site: input.site,
|
|
@@ -45530,6 +46218,7 @@ function runReport$1(input, deps) {
|
|
|
45530
46218
|
const bing = once(() => bingFor(deps, input.site));
|
|
45531
46219
|
return runIssues(input, deps, (section, range) => build(read, input.site, section, range, now, bing));
|
|
45532
46220
|
}
|
|
46221
|
+
if (input.section === "google-updates") return runUpdates(input, deps);
|
|
45533
46222
|
if (input.section === "weekly-audit") {
|
|
45534
46223
|
const audit = deps.audit;
|
|
45535
46224
|
if (!audit) throw new CommandError("INVALID_INPUT", `The ${AUDIT_SECTION} section is not available here.`);
|
|
@@ -45568,10 +46257,11 @@ const RESEARCH_ACTIONS = [
|
|
|
45568
46257
|
"serp",
|
|
45569
46258
|
"competitors",
|
|
45570
46259
|
"backlinks",
|
|
46260
|
+
"link_gap",
|
|
45571
46261
|
"ai_answers"
|
|
45572
46262
|
];
|
|
45573
46263
|
const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
|
|
45574
|
-
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
|
|
46264
|
+
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.`;
|
|
45575
46265
|
const GENERIC_PLATFORMS = [
|
|
45576
46266
|
"youtube.com",
|
|
45577
46267
|
"facebook.com",
|
|
@@ -45615,10 +46305,11 @@ async function runResearch(input, keys, options) {
|
|
|
45615
46305
|
const offset = input.offset ?? 0;
|
|
45616
46306
|
const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
|
|
45617
46307
|
if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
|
|
45618
|
-
const { rows: found, costUsd, partial, missed } = await research(input.provider, keys, {
|
|
46308
|
+
const { rows: found, costUsd, partial, missed, noData, floor } = await research(input.provider, keys, {
|
|
45619
46309
|
action: input.action,
|
|
45620
46310
|
keywords: input.keywords ?? [],
|
|
45621
46311
|
target,
|
|
46312
|
+
competitors: input.competitors ?? [],
|
|
45622
46313
|
country,
|
|
45623
46314
|
limit: Math.min(1e3, Math.max(100, offset + 50))
|
|
45624
46315
|
});
|
|
@@ -45626,6 +46317,7 @@ async function runResearch(input, keys, options) {
|
|
|
45626
46317
|
rows: found,
|
|
45627
46318
|
note: null
|
|
45628
46319
|
};
|
|
46320
|
+
const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
|
|
45629
46321
|
const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
|
|
45630
46322
|
const rated = await withDomainRating(rows, drKey);
|
|
45631
46323
|
const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
|
|
@@ -45642,6 +46334,9 @@ async function runResearch(input, keys, options) {
|
|
|
45642
46334
|
...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
|
|
45643
46335
|
...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
|
|
45644
46336
|
...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
|
|
46337
|
+
...noData.length ? [noDataNote(input, noData)] : [],
|
|
46338
|
+
...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.`] : [],
|
|
46339
|
+
...unresolved ? [unresolvedNote(input, unresolved, rows.length)] : [],
|
|
45645
46340
|
...note ? [note] : [],
|
|
45646
46341
|
...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
|
|
45647
46342
|
...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
|
|
@@ -45660,12 +46355,25 @@ async function runResearch(input, keys, options) {
|
|
|
45660
46355
|
costUsd,
|
|
45661
46356
|
partial,
|
|
45662
46357
|
missedKeywords: missed,
|
|
46358
|
+
noDataKeywords: noData,
|
|
46359
|
+
...input.action === "link_gap" ? {
|
|
46360
|
+
competitors: input.competitors ?? [],
|
|
46361
|
+
cutAt: floor ?? null
|
|
46362
|
+
} : {},
|
|
45663
46363
|
total: rated.rows.length,
|
|
45664
46364
|
offset,
|
|
45665
46365
|
rows: rated.rows.slice(offset, offset + 50)
|
|
45666
46366
|
}
|
|
45667
46367
|
};
|
|
45668
46368
|
}
|
|
46369
|
+
function noDataNote(input, keywords) {
|
|
46370
|
+
const list = keywords.join(", ");
|
|
46371
|
+
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" : ""}.`;
|
|
46372
|
+
return `No data: ${input.provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
|
|
46373
|
+
}
|
|
46374
|
+
function unresolvedNote(input, count, total) {
|
|
46375
|
+
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." : ""}`;
|
|
46376
|
+
}
|
|
45669
46377
|
// packages/core/dist/commands/sitemap-urls.js
|
|
45670
46378
|
const MAX_FILES = 20;
|
|
45671
46379
|
const MAX_URLS = 5e4;
|
|
@@ -46244,6 +46952,23 @@ function siteLines(site, startMs, endMs) {
|
|
|
46244
46952
|
} else lines.push("Search clicks: not available this week.");
|
|
46245
46953
|
return lines;
|
|
46246
46954
|
}
|
|
46955
|
+
function updateLines(site) {
|
|
46956
|
+
return (site.google_updates ?? []).map((update) => {
|
|
46957
|
+
const when = update.end ? "ran" : "has been rolling out";
|
|
46958
|
+
const result = update.result;
|
|
46959
|
+
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.`;
|
|
46960
|
+
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.`;
|
|
46961
|
+
const diff = result.clicks - result.previous;
|
|
46962
|
+
const pct = result.previous > 0 ? Math.round(diff / result.previous * 100) : null;
|
|
46963
|
+
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)})`;
|
|
46964
|
+
const verdict = {
|
|
46965
|
+
"outside-normal": "more than this site usually moves",
|
|
46966
|
+
"within-normal": "within this site’s normal movement",
|
|
46967
|
+
"not-enough-data": "too little data to say if that is unusual"
|
|
46968
|
+
}[result.verdict];
|
|
46969
|
+
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).`;
|
|
46970
|
+
});
|
|
46971
|
+
}
|
|
46247
46972
|
function opportunityLines(site) {
|
|
46248
46973
|
const found = site.opportunities;
|
|
46249
46974
|
if (!found) return [];
|
|
@@ -46296,7 +47021,7 @@ function weekMarkdown(data) {
|
|
|
46296
47021
|
"",
|
|
46297
47022
|
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."
|
|
46298
47023
|
];
|
|
46299
|
-
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})`] : []);
|
|
47024
|
+
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})`] : []);
|
|
46300
47025
|
return out.join("\n");
|
|
46301
47026
|
}
|
|
46302
47027
|
async function runAllSites(deps, sites) {
|
|
@@ -46399,10 +47124,14 @@ const SHARED_TOOLS = [
|
|
|
46399
47124
|
name: "seo_report",
|
|
46400
47125
|
command: "report",
|
|
46401
47126
|
title: "Search Console report",
|
|
46402
|
-
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.`,
|
|
47127
|
+
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.`,
|
|
46403
47128
|
input: z.object({
|
|
46404
47129
|
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."),
|
|
46405
|
-
section: z.enum([
|
|
47130
|
+
section: z.enum([
|
|
47131
|
+
...SECTION_IDS,
|
|
47132
|
+
ISSUES_SECTION,
|
|
47133
|
+
UPDATES_SECTION
|
|
47134
|
+
]).optional().describe("Optional, needs site. Leave it out first; then ask for one section."),
|
|
46406
47135
|
...dates,
|
|
46407
47136
|
...detail,
|
|
46408
47137
|
...marks
|
|
@@ -46519,6 +47248,8 @@ const SHARED_TOOLS = [
|
|
|
46519
47248
|
action: z.enum(RESEARCH_ACTIONS),
|
|
46520
47249
|
keywords: z.array(z.string().min(1)).max(100).default([]),
|
|
46521
47250
|
target: z.string().default("").describe("Domain or URL."),
|
|
47251
|
+
competitors: z.array(z.string().min(1)).max(5).default([]).describe("link_gap only: 2 to 5 competitor domains."),
|
|
47252
|
+
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."),
|
|
46522
47253
|
country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
|
|
46523
47254
|
...detail
|
|
46524
47255
|
}),
|
|
@@ -46527,11 +47258,12 @@ const SHARED_TOOLS = [
|
|
|
46527
47258
|
if (input.action === "ai_answers") {
|
|
46528
47259
|
if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
|
|
46529
47260
|
return runAiAnswers({
|
|
46530
|
-
site: input.keywords.length ? input.target : await surface.resolveSite(input.target),
|
|
47261
|
+
site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
|
|
46531
47262
|
country: input.country,
|
|
46532
47263
|
detail: input.detail,
|
|
46533
47264
|
offset: input.offset,
|
|
46534
|
-
questions: input.keywords
|
|
47265
|
+
questions: input.keywords,
|
|
47266
|
+
tasks: input.tasks
|
|
46535
47267
|
}, await surface.keys(), surface.deps, more);
|
|
46536
47268
|
}
|
|
46537
47269
|
return runResearch(input, await surface.keys(), {
|
|
@@ -46663,6 +47395,7 @@ SHARED_TOOLS.map((shared) => shared.name === "seo_index" ? indexFromStore(shared
|
|
|
46663
47395
|
input: shared.input.extend({ section: z.enum([
|
|
46664
47396
|
...SECTION_IDS,
|
|
46665
47397
|
ISSUES_SECTION,
|
|
47398
|
+
UPDATES_SECTION,
|
|
46666
47399
|
AUDIT_SECTION
|
|
46667
47400
|
]).optional().describe("Optional. Leave it out first; then ask for one section.") })
|
|
46668
47401
|
} : shared);
|