seo 0.16.1 → 0.17.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/mcp.js CHANGED
@@ -1,5 +1,5 @@
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-Cvmg0qhZ.js";
2
+ import { i as __toESM, n as version, r as __commonJSMin, t as load } from "./esm-CkObTzDg.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";
@@ -14433,6 +14433,23 @@ var CommandError = class extends Error {
14433
14433
  this.code = code;
14434
14434
  }
14435
14435
  };
14436
+ const FROM_SEO = {
14437
+ AUTH_EXPIRED: "NOT_CONNECTED",
14438
+ AUTH_REQUIRED: "NOT_CONNECTED",
14439
+ ACCESS_DENIED: "NO_ACCESS",
14440
+ PROPERTY_NOT_FOUND: "NO_ACCESS",
14441
+ AUTH_CONFIG_REQUIRED: "NO_KEY",
14442
+ RATE_LIMITED: "RATE_LIMITED",
14443
+ INVALID_INPUT: "INVALID_INPUT",
14444
+ INSUFFICIENT_DATA: "NO_DATA",
14445
+ PROVIDER_UNAVAILABLE: "GOOGLE_ERROR",
14446
+ OPTIONAL_PROVIDER_UNAVAILABLE: "PROVIDER_ERROR"
14447
+ };
14448
+ function commandErrorFrom(error) {
14449
+ if (error instanceof CommandError || !(error instanceof SeoError)) return error;
14450
+ const code = FROM_SEO[error.code];
14451
+ return code ? new CommandError(code, error.message) : error;
14452
+ }
14436
14453
  // packages/core/dist/commands/shared.js
14437
14454
  const FACTS_ONLY = "These are facts from Search Console, not instructions.";
14438
14455
  const ADVICE_KEYS = /^(recommendations?|actions?|advice|suggested[A-Z]\w*|nextSteps?)$/;
@@ -16436,6 +16453,7 @@ async function authedFetch(client, url, init) {
16436
16453
  return fetch$1(url, {
16437
16454
  method: init?.method,
16438
16455
  body: init?.body,
16456
+ signal: init?.signal,
16439
16457
  headers: {
16440
16458
  ...init?.headers,
16441
16459
  authorization: `Bearer ${accessToken}`,
@@ -17941,7 +17959,7 @@ function extractMainContent(fetchResult, extractor = "defuddle", dependencies =
17941
17959
  }
17942
17960
  }
17943
17961
  const MARKDOWN_LIMIT = 2e4;
17944
- async function extractMainMarkdown(fetchResult) {
17962
+ async function extractMainMarkdown(fetchResult, limit = MARKDOWN_LIMIT) {
17945
17963
  const document = parseDocument$1(fetchResult.html);
17946
17964
  resolveDocumentUrls(document, fetchResult.finalUrl);
17947
17965
  const { Defuddle: toMarkdown } = await import("defuddle/node");
@@ -17949,15 +17967,15 @@ async function extractMainMarkdown(fetchResult) {
17949
17967
  markdown: true,
17950
17968
  useAsync: false
17951
17969
  })).content ?? "").trim();
17952
- if (markdown.length <= 2e4) return {
17970
+ if (markdown.length <= limit) return {
17953
17971
  markdown,
17954
17972
  characters: markdown.length,
17955
17973
  truncated: false
17956
17974
  };
17957
- const cut = markdown.slice(0, MARKDOWN_LIMIT);
17975
+ const cut = markdown.slice(0, limit);
17958
17976
  const end = cut.lastIndexOf("\n");
17959
17977
  return {
17960
- markdown: (end > 1e4 ? cut.slice(0, end) : cut).trimEnd(),
17978
+ markdown: (end > limit / 2 ? cut.slice(0, end) : cut).trimEnd(),
17961
17979
  characters: markdown.length,
17962
17980
  truncated: true
17963
17981
  };
@@ -18510,7 +18528,7 @@ function softAuthenticationGate($, finalUrl, title) {
18510
18528
  if (passwordForms.length === 0) return void 0;
18511
18529
  const pagePath = new URL(finalUrl).pathname;
18512
18530
  const action = $(passwordForms[0]).attr("action");
18513
- const actionUrl = action ? httpUrl(action, finalUrl) : void 0;
18531
+ const actionUrl = action ? httpUrl$1(action, finalUrl) : void 0;
18514
18532
  const actionPath = actionUrl ? new URL(actionUrl).pathname : void 0;
18515
18533
  const indicators = [
18516
18534
  ...LOGIN_PATH_PATTERN.test(pagePath) ? ["login-path"] : [],
@@ -18561,7 +18579,7 @@ function absoluteUrl$1(value, base) {
18561
18579
  return;
18562
18580
  }
18563
18581
  }
18564
- function httpUrl(value, base) {
18582
+ function httpUrl$1(value, base) {
18565
18583
  const resolved = absoluteUrl$1(value, base);
18566
18584
  if (!resolved) return void 0;
18567
18585
  const protocol = new URL(resolved).protocol;
@@ -18758,15 +18776,15 @@ async function extractPage(fetchResult, extractor = "defuddle", dependencies = {
18758
18776
  let invalidHreflangCount = 0;
18759
18777
  for (const element of $("link[rel~=\"alternate\"][hreflang][href]").toArray()) {
18760
18778
  const language = $(element).attr("hreflang") ?? "";
18761
- const href = httpUrl($(element).attr("href"), fetchResult.finalUrl);
18779
+ const href = httpUrl$1($(element).attr("href"), fetchResult.finalUrl);
18762
18780
  if (language && href) hreflang.push({
18763
18781
  hreflang: language,
18764
18782
  href
18765
18783
  });
18766
18784
  else invalidHreflangCount += 1;
18767
18785
  }
18768
- const markdownAlternates = [...new Set($("head link[rel~=\"alternate\"][type][href]").toArray().filter((element) => $(element).attr("type")?.trim().toLowerCase() === "text/markdown").map((element) => httpUrl($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
18769
- const describedBy = [...new Set($("head link[rel][href]").toArray().filter((element) => ($(element).attr("rel") ?? "").toLowerCase().split(/\s+/u).includes("describedby")).map((element) => httpUrl($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
18786
+ const markdownAlternates = [...new Set($("head link[rel~=\"alternate\"][type][href]").toArray().filter((element) => $(element).attr("type")?.trim().toLowerCase() === "text/markdown").map((element) => httpUrl$1($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
18787
+ const describedBy = [...new Set($("head link[rel][href]").toArray().filter((element) => ($(element).attr("rel") ?? "").toLowerCase().split(/\s+/u).includes("describedby")).map((element) => httpUrl$1($(element).attr("href"), fetchResult.finalUrl)).filter((value) => Boolean(value)))].sort();
18770
18788
  const openGraph = Object.fromEntries($("meta[property^=\"og:\"]").toArray().map((element) => [$(element).attr("property") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
18771
18789
  const twitter = Object.fromEntries($("meta[name^=\"twitter:\"]").toArray().map((element) => [$(element).attr("name") ?? "", $(element).attr("content") ?? ""]).filter(([key, value]) => key && value));
18772
18790
  const structuredData = extractStructuredData($, fetchResult.finalUrl, { retainJsonLd: extractor !== "crawler" });
@@ -19183,11 +19201,11 @@ function recordHostFetch(input) {
19183
19201
  return snapshotForHost(input.host);
19184
19202
  }
19185
19203
  // packages/core/dist/fetch/page-fetcher/redirects.js
19186
- async function fetchWithRedirectChain(url, signal) {
19204
+ async function fetchWithRedirectChain(url, signal, fetch = publicHttpFetch) {
19187
19205
  const redirectChain = [];
19188
19206
  let currentUrl = url;
19189
19207
  for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
19190
- const response = await publicHttpFetch(currentUrl, {
19208
+ const response = await fetch(currentUrl, {
19191
19209
  redirect: "manual",
19192
19210
  signal
19193
19211
  });
@@ -20425,7 +20443,8 @@ async function inspectUrl(input) {
20425
20443
  siteUrl: input.siteUrl,
20426
20444
  inspectionUrl,
20427
20445
  ...input.languageCode ? { languageCode: input.languageCode } : {}
20428
- })
20446
+ }),
20447
+ signal: input.signal
20429
20448
  });
20430
20449
  } catch (error) {
20431
20450
  finalize(reservation, "consumed");
@@ -23982,6 +24001,15 @@ const crawlAiAvailabilitySchema = z.enum([
23982
24001
  "rate-limited",
23983
24002
  "unreachable"
23984
24003
  ]);
24004
+ const crawlFetchErrorKindSchema = z.enum([
24005
+ "dns",
24006
+ "tls",
24007
+ "timeout",
24008
+ "redirect-limit",
24009
+ "too-large",
24010
+ "aborted",
24011
+ "unknown"
24012
+ ]);
23985
24013
  const crawlAiLinkCheckSchema = z.object({
23986
24014
  url: z.string().url(),
23987
24015
  status: z.number().int().optional(),
@@ -23997,6 +24025,7 @@ const crawlAiSignalsSchema = z.object({
23997
24025
  contentType: z.string().optional(),
23998
24026
  servesHtml: z.boolean().optional(),
23999
24027
  error: z.string().optional(),
24028
+ errorKind: crawlFetchErrorKindSchema.optional(),
24000
24029
  sitemapUrls: z.array(z.string().url()),
24001
24030
  contentSignals: z.array(z.string()).optional(),
24002
24031
  botAccess: z.array(z.object({
@@ -24019,6 +24048,7 @@ const crawlAiSignalsSchema = z.object({
24019
24048
  contentType: z.string().optional(),
24020
24049
  servesHtml: z.boolean().optional(),
24021
24050
  error: z.string().optional(),
24051
+ errorKind: crawlFetchErrorKindSchema.optional(),
24022
24052
  links: z.object({
24023
24053
  found: z.number().int().nonnegative(),
24024
24054
  checked: z.number().int().nonnegative(),
@@ -25073,10 +25103,11 @@ function agentReadyIssues(input) {
25073
25103
  }
25074
25104
  // packages/core/dist/analyze/crawler/crawl-control.js
25075
25105
  const CRAWL_CANCELLED = Symbol("crawl-cancelled");
25106
+ const REQUEST_TIMEOUT_CAP_MS = 5e3;
25076
25107
  function abortController(input) {
25077
25108
  const controller = new AbortController();
25078
25109
  const abort = () => controller.abort();
25079
- const timer = setTimeout(abort, Math.min(input.timeoutMs, 5e3));
25110
+ const timer = setTimeout(abort, Math.min(input.timeoutMs, REQUEST_TIMEOUT_CAP_MS));
25080
25111
  if (input.signal?.aborted) controller.abort();
25081
25112
  else input.signal?.addEventListener("abort", abort, { once: true });
25082
25113
  return {
@@ -25516,6 +25547,405 @@ async function collectCrawlSiteChecks(input) {
25516
25547
  }]
25517
25548
  };
25518
25549
  }
25550
+ // packages/core/dist/analyze/monitoring/crawl-page.js
25551
+ function resolvedUrl(href, base) {
25552
+ if (!href) return void 0;
25553
+ try {
25554
+ const url = new URL(href, base);
25555
+ return ["http:", "https:"].includes(url.protocol) ? url.toString() : void 0;
25556
+ } catch {
25557
+ return;
25558
+ }
25559
+ }
25560
+ function hashText(value) {
25561
+ return createHash("sha256").update(value).digest("hex");
25562
+ }
25563
+ function headerValue(headers, name) {
25564
+ const lower = name.toLowerCase();
25565
+ return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
25566
+ }
25567
+ function safeResponseHeaders(headers) {
25568
+ const blocked = /* @__PURE__ */ new Set([
25569
+ "authorization",
25570
+ "cookie",
25571
+ "proxy-authorization",
25572
+ "set-cookie",
25573
+ "www-authenticate"
25574
+ ]);
25575
+ return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key.toLowerCase())));
25576
+ }
25577
+ function truncate$1(value, max) {
25578
+ return value.length > max ? value.slice(0, max).trimEnd() : value;
25579
+ }
25580
+ function sameDocumentUrl(left, right) {
25581
+ if (!left || !right) return false;
25582
+ try {
25583
+ const a = new URL(left);
25584
+ const b = new URL(right);
25585
+ a.hash = "";
25586
+ b.hash = "";
25587
+ return a.toString() === b.toString();
25588
+ } catch {
25589
+ return left === right;
25590
+ }
25591
+ }
25592
+ function isHtmlContentType(value) {
25593
+ return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(value ?? "");
25594
+ }
25595
+ function fetchFailureKind(error, signal) {
25596
+ const record = error;
25597
+ const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
25598
+ const message = error instanceof Error ? error.message : String(error);
25599
+ if (signal?.aborted) return "aborted";
25600
+ if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
25601
+ if ([
25602
+ "ENOTFOUND",
25603
+ "EAI_AGAIN",
25604
+ "EAI_FAIL"
25605
+ ].includes(code)) return "dns";
25606
+ if (code.includes("CERT") || code.includes("TLS") || code.includes("SSL") || /CERTIFICATE|\bTLS\b|\bSSL\b/i.test(message)) return "tls";
25607
+ if (code === "ETIMEDOUT" || /TIMED?\s*OUT|TIMEOUT|ABORT(?:ED|ERROR)/i.test(message)) return "timeout";
25608
+ return "unknown";
25609
+ }
25610
+ function responseObservation(requestedUrl, fetched, extraction, extractionError) {
25611
+ const observation = {
25612
+ requestedUrl,
25613
+ outcome: "response",
25614
+ finalUrl: fetched.finalUrl,
25615
+ status: fetched.status,
25616
+ contentType: headerValue(fetched.headers, "content-type"),
25617
+ durationMs: fetched.diagnostics.durationMs,
25618
+ redirectChain: fetched.diagnostics.redirectChain,
25619
+ accessBlock: fetched.diagnostics.accessBlock,
25620
+ robotsTxt: fetched.robotsTxt
25621
+ };
25622
+ if (extraction === "failed") {
25623
+ if (!extractionError) throw new Error("Failed extraction observations require an error.");
25624
+ return {
25625
+ ...observation,
25626
+ extraction,
25627
+ extractionError
25628
+ };
25629
+ }
25630
+ return {
25631
+ ...observation,
25632
+ extraction
25633
+ };
25634
+ }
25635
+ function unextractedPage(fetched, contentType) {
25636
+ const xRobotsTag = headerValue(fetched.headers, "x-robots-tag");
25637
+ const canonicalEvidence = extractHttpCanonicalEvidence(fetched.headers, fetched.finalUrl);
25638
+ const noindex = hasXRobotsDirective(xRobotsTag, "noindex");
25639
+ const knownNonHtml = Boolean(contentType);
25640
+ return {
25641
+ url: fetched.finalUrl,
25642
+ finalUrl: fetched.finalUrl,
25643
+ status: fetched.status,
25644
+ contentType,
25645
+ responseTimeMs: fetched.diagnostics.durationMs,
25646
+ sizeBytes: Buffer.byteLength(fetched.html),
25647
+ usedJs: fetched.usedJs,
25648
+ fetchSource: fetched.diagnostics.source,
25649
+ cacheState: fetched.diagnostics.cache,
25650
+ fetchDiagnostics: fetched.diagnostics,
25651
+ accessBlock: fetched.diagnostics.accessBlock,
25652
+ blocked: fetched.diagnostics.blocked,
25653
+ robotsTxt: fetched.robotsTxt,
25654
+ xRobotsTag,
25655
+ canonical: canonicalEvidence.selectedUrl,
25656
+ canonicalRaw: canonicalEvidence.selectedRaw ?? canonicalEvidence.candidates[0]?.raw,
25657
+ canonicalStatus: canonicalEvidence.status,
25658
+ canonicalCandidates: canonicalEvidence.candidates,
25659
+ indexable: fetched.status >= 200 && fetched.status < 300 && !noindex,
25660
+ indexability: noindex ? "X-Robots-Tag noindex" : knownNonHtml ? "Non-HTML response" : "Unknown response media type",
25661
+ declaredIndexability: noindex ? "noindex" : knownNonHtml ? "not-html" : "unknown",
25662
+ extractionStatus: knownNonHtml ? "not-applicable" : "unknown-media-type",
25663
+ wordCount: 0,
25664
+ contentHash: hashText(fetched.html),
25665
+ outgoingInternalCount: 0,
25666
+ outgoingExternalCount: 0,
25667
+ isHttps: new URL(fetched.finalUrl).protocol === "https:",
25668
+ hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
25669
+ compression: headerValue(fetched.headers, "content-encoding")
25670
+ };
25671
+ }
25672
+ function statusOnlyPage(fetched) {
25673
+ const contentType = headerValue(fetched.headers, "content-type");
25674
+ return {
25675
+ url: fetched.finalUrl,
25676
+ finalUrl: fetched.finalUrl,
25677
+ status: fetched.status,
25678
+ contentType,
25679
+ responseTimeMs: fetched.diagnostics.durationMs,
25680
+ auditScope: "status",
25681
+ contentAuditAllowed: false,
25682
+ indexable: false,
25683
+ indexability: "Not evaluated by status-only health probe",
25684
+ declaredIndexability: "unknown",
25685
+ extractionStatus: "not-applicable",
25686
+ wordCount: 0,
25687
+ contentHash: "",
25688
+ outgoingInternalCount: 0,
25689
+ outgoingExternalCount: 0
25690
+ };
25691
+ }
25692
+ function indexabilityReason(page) {
25693
+ if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
25694
+ if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
25695
+ if (hasXRobotsDirective(page.xRobotsTag, "noindex")) return "X-Robots-Tag noindex";
25696
+ }
25697
+ async function crawlOne(url, opts = {}) {
25698
+ const startedAt = Date.now();
25699
+ let fetched;
25700
+ try {
25701
+ fetched = await fetchPage(url, opts);
25702
+ } catch (error) {
25703
+ if (error instanceof OriginBackpressureError) return {
25704
+ request: {
25705
+ requestedUrl: url,
25706
+ outcome: "skipped",
25707
+ reason: "origin-backpressure",
25708
+ error: error.message,
25709
+ extraction: "not-applicable"
25710
+ },
25711
+ urls: []
25712
+ };
25713
+ if (error instanceof RobotsAccessError) return {
25714
+ request: {
25715
+ requestedUrl: url,
25716
+ outcome: "skipped",
25717
+ reason: error.reason,
25718
+ robotsTxt: {
25719
+ url: error.evidence.url,
25720
+ allowed: error.evidence.allowed,
25721
+ availability: error.evidence.availability,
25722
+ status: error.evidence.status,
25723
+ error: error.evidence.error,
25724
+ matchedLine: error.evidence.matchedLine
25725
+ },
25726
+ extraction: "not-applicable"
25727
+ },
25728
+ urls: []
25729
+ };
25730
+ const message = error instanceof Error ? error.message : String(error);
25731
+ return {
25732
+ request: {
25733
+ requestedUrl: url,
25734
+ outcome: "failure",
25735
+ durationMs: Date.now() - startedAt,
25736
+ failureKind: fetchFailureKind(error, opts.signal),
25737
+ error: message,
25738
+ extraction: "not-applicable"
25739
+ },
25740
+ urls: [],
25741
+ warning: `${url}: ${message}`
25742
+ };
25743
+ }
25744
+ return snapshotFetchedPage(url, fetched);
25745
+ }
25746
+ async function snapshotFetchedPage(url, fetched) {
25747
+ const contentType = headerValue(fetched.headers, "content-type");
25748
+ if (fetched.diagnostics.accessBlock) {
25749
+ const page = unextractedPage(fetched, contentType);
25750
+ page.contentAuditAllowed = false;
25751
+ page.indexable = false;
25752
+ page.indexability = "Crawler access blocked";
25753
+ page.declaredIndexability = "unknown";
25754
+ page.extractionStatus = "not-applicable";
25755
+ return {
25756
+ request: responseObservation(url, fetched, "not-applicable"),
25757
+ page,
25758
+ urls: []
25759
+ };
25760
+ }
25761
+ if (!isHtmlContentType(contentType)) return {
25762
+ request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
25763
+ page: unextractedPage(fetched, contentType),
25764
+ urls: []
25765
+ };
25766
+ try {
25767
+ const extracted = await extractPage(fetched, "crawler");
25768
+ const crawlerEvidence = extracted.crawlerEvidence;
25769
+ if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
25770
+ const h1 = crawlerEvidence.h1;
25771
+ const uniqueInternalLinks = crawlerEvidence.internalLinks;
25772
+ const uniqueExternalLinks = crawlerEvidence.externalLinks;
25773
+ const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
25774
+ const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
25775
+ const page = {
25776
+ url: extracted.finalUrl,
25777
+ finalUrl: extracted.finalUrl,
25778
+ status: fetched.status,
25779
+ contentType,
25780
+ responseHeaders: safeResponseHeaders(fetched.headers),
25781
+ responseTimeMs: fetched.diagnostics.durationMs,
25782
+ sizeBytes: Buffer.byteLength(fetched.html),
25783
+ usedJs: fetched.usedJs,
25784
+ fetchSource: fetched.diagnostics.source,
25785
+ cacheState: fetched.diagnostics.cache,
25786
+ fetchDiagnostics: fetched.diagnostics,
25787
+ blocked: fetched.diagnostics.blocked,
25788
+ robotsTxt: fetched.robotsTxt,
25789
+ title: extracted.title,
25790
+ metaDescription: extracted.metaDescription,
25791
+ canonical,
25792
+ canonicalRaw: extracted.canonicalEvidence?.selectedRaw ?? extracted.canonicalEvidence?.candidates[0]?.raw ?? extracted.canonical,
25793
+ canonicalStatus: extracted.canonicalEvidence?.status,
25794
+ canonicalCandidates: extracted.canonicalEvidence?.candidates,
25795
+ metaRobots: extracted.metaRobots,
25796
+ xRobotsTag: extracted.xRobotsTag,
25797
+ h1,
25798
+ h1Count: crawlerEvidence.h1Count,
25799
+ h2Count: crawlerEvidence.h2Count,
25800
+ h3Count: crawlerEvidence.h3Count,
25801
+ indexable: false,
25802
+ extractionStatus: "complete",
25803
+ wordCount: extracted.wordCount,
25804
+ contentExtraction: extracted.contentExtraction,
25805
+ warnings: [
25806
+ ...extracted.warnings,
25807
+ ...extracted.canonicalEvidence?.status === "invalid" || extracted.canonical && !canonical ? ["Ignored a malformed canonical URL."] : [],
25808
+ ...extracted.canonicalEvidence?.status === "conflicting" ? ["Found conflicting canonical declarations."] : [],
25809
+ ...extracted.canonicalEvidence?.status === "outside-head-only" ? ["Ignored canonical link elements outside the document head because Google does not accept them."] : [],
25810
+ ...extracted.canonicalEvidence?.status === "streamed-outside-head" ? ["Checked the canonical target that Next.js streamed into the body of this response. Google documents that it only accepts canonical links in the head."] : []
25811
+ ],
25812
+ contentHash: hashText([
25813
+ extracted.title,
25814
+ extracted.metaDescription,
25815
+ h1,
25816
+ extracted.canonical,
25817
+ extracted.contentText
25818
+ ].join("\n")),
25819
+ mainContentHash: hashText(extracted.contentText),
25820
+ textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
25821
+ contentSample: truncate$1(extracted.contentText, 300),
25822
+ contentSketch: crawlerEvidence.contentSketch,
25823
+ softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
25824
+ metaRefresh: crawlerEvidence.metaRefresh,
25825
+ tabbedContent: crawlerEvidence.tabbedContent,
25826
+ lang: extracted.lang,
25827
+ hasViewport: extracted.hasViewport,
25828
+ isHttps: new URL(extracted.finalUrl).protocol === "https:",
25829
+ hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
25830
+ compression: headerValue(fetched.headers, "content-encoding"),
25831
+ hreflang: extracted.hreflang,
25832
+ mixedContentCount: extracted.mixedContentUrls.length,
25833
+ mixedContentSamples: extracted.mixedContentUrls.slice(0, 25),
25834
+ imagesTotal: extracted.imagesTotal,
25835
+ imagesMissingAlt: extracted.imagesMissingAlt,
25836
+ oversizedImageCandidates: extracted.oversizedImageCandidates,
25837
+ outgoingInternalCount: uniqueInternalLinks.length,
25838
+ outgoingExternalCount: uniqueExternalLinks.length,
25839
+ sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
25840
+ sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
25841
+ internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
25842
+ externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
25843
+ markdownAlternates: extracted.markdownAlternates,
25844
+ describedBy: extracted.describedBy,
25845
+ schemaTypes: extracted.schemaTypes,
25846
+ structuredDataFormats: extracted.structuredDataFormats ?? [],
25847
+ googleRichResults: richResults.assessments,
25848
+ googleRichResultsSelection: richResults.selection,
25849
+ schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
25850
+ schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
25851
+ invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
25852
+ socialProfileLinks: crawlerEvidence.socialProfileLinks,
25853
+ invalidJsonLdCount: extracted.invalidJsonLdCount,
25854
+ invalidJsonLdSamples: extracted.invalidJsonLdSamples,
25855
+ unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
25856
+ openGraphTitle: extracted.openGraph["og:title"],
25857
+ openGraphDescription: extracted.openGraph["og:description"],
25858
+ openGraphImage: extracted.openGraph["og:image"],
25859
+ twitterCard: extracted.twitter["twitter:card"],
25860
+ author: extracted.author,
25861
+ hasDate: extracted.hasDate,
25862
+ geo: {
25863
+ semanticHtml: extracted.semanticHtml,
25864
+ structuredData: (extracted.structuredDataFormats?.length ?? 0) > 0,
25865
+ hasAuthor: extracted.hasAuthor,
25866
+ hasDate: extracted.hasDate,
25867
+ questionHeadings: extracted.questionHeadings,
25868
+ listCount: extracted.listCount,
25869
+ tableCount: extracted.tableCount,
25870
+ structuredBlocks: extracted.structuredBlocks,
25871
+ answerable: extracted.answerable,
25872
+ hasFaqSchema: extracted.schemaTypes.includes("FAQPage"),
25873
+ hasQapageSchema: extracted.schemaTypes.includes("QAPage")
25874
+ }
25875
+ };
25876
+ page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
25877
+ page.indexable = !page.indexability;
25878
+ page.declaredIndexability = page.metaRefresh ? "meta-refresh" : hasMetaRobotsDirective(page.metaRobots, "noindex") || hasXRobotsDirective(page.xRobotsTag, "noindex") ? "noindex" : page.robotsTxt?.allowed === false ? "robots-blocked" : page.canonicalStatus === "conflicting" ? "canonical-conflict" : page.canonical && !sameDocumentUrl(page.canonical, page.finalUrl) ? "canonical-hint-other" : "indexable-candidate";
25879
+ return {
25880
+ request: responseObservation(url, fetched, "complete"),
25881
+ page,
25882
+ urls: uniqueInternalLinks
25883
+ };
25884
+ } catch (error) {
25885
+ const message = error instanceof Error ? error.message : String(error);
25886
+ return {
25887
+ request: responseObservation(url, fetched, "failed", message),
25888
+ urls: [],
25889
+ warning: `${url}: content extraction failed: ${message}`
25890
+ };
25891
+ }
25892
+ }
25893
+ async function crawlStatusOnly(url, opts = {}) {
25894
+ const startedAt = Date.now();
25895
+ try {
25896
+ const fetched = await fetchPageStatus(url, {
25897
+ ...opts,
25898
+ js: "off",
25899
+ refresh: true
25900
+ });
25901
+ return {
25902
+ request: responseObservation(url, fetched, "not-applicable"),
25903
+ page: statusOnlyPage(fetched),
25904
+ urls: []
25905
+ };
25906
+ } catch (error) {
25907
+ if (error instanceof OriginBackpressureError) return {
25908
+ request: {
25909
+ requestedUrl: url,
25910
+ outcome: "skipped",
25911
+ reason: "origin-backpressure",
25912
+ error: error.message,
25913
+ extraction: "not-applicable"
25914
+ },
25915
+ urls: []
25916
+ };
25917
+ if (error instanceof RobotsAccessError) return {
25918
+ request: {
25919
+ requestedUrl: url,
25920
+ outcome: "skipped",
25921
+ reason: error.reason,
25922
+ robotsTxt: {
25923
+ url: error.evidence.url,
25924
+ allowed: error.evidence.allowed,
25925
+ availability: error.evidence.availability,
25926
+ status: error.evidence.status,
25927
+ error: error.evidence.error,
25928
+ matchedLine: error.evidence.matchedLine
25929
+ },
25930
+ extraction: "not-applicable"
25931
+ },
25932
+ urls: []
25933
+ };
25934
+ const message = error instanceof Error ? error.message : String(error);
25935
+ return {
25936
+ request: {
25937
+ requestedUrl: url,
25938
+ outcome: "failure",
25939
+ durationMs: Date.now() - startedAt,
25940
+ failureKind: fetchFailureKind(error, opts.signal),
25941
+ error: message,
25942
+ extraction: "not-applicable"
25943
+ },
25944
+ urls: [],
25945
+ warning: `${url}: ${message}`
25946
+ };
25947
+ }
25948
+ }
25519
25949
  // packages/core/dist/analyze/crawler/site-crawl-ai-signals.js
25520
25950
  const MAX_AUXILIARY_RESPONSE_BYTES = 2097152;
25521
25951
  const LLMS_LINK_LIMIT = 20;
@@ -25559,8 +25989,21 @@ function availabilityOf(status) {
25559
25989
  if (status >= 400 && status < 500) return "absent";
25560
25990
  return "unreachable";
25561
25991
  }
25562
- function errorText(error) {
25563
- return error instanceof Error ? error.message : String(error);
25992
+ function errorText$1(error) {
25993
+ if (!(error instanceof Error)) return String(error);
25994
+ const cause = error.cause instanceof Error ? error.cause.message : "";
25995
+ return cause && !error.message.includes(cause) ? `${error.message}: ${cause}` : error.message;
25996
+ }
25997
+ function requestError$1(error, timeoutMs, signal) {
25998
+ const errorKind = fetchErrorKind(error, signal);
25999
+ return {
26000
+ error: errorKind === "timeout" ? `No response within ${Math.min(timeoutMs, REQUEST_TIMEOUT_CAP_MS) / 1e3} seconds.` : errorText$1(error),
26001
+ errorKind
26002
+ };
26003
+ }
26004
+ function fetchErrorKind(error, signal) {
26005
+ if (error instanceof ResponseSizeLimitError) return "too-large";
26006
+ return fetchFailureKind(error, signal);
25564
26007
  }
25565
26008
  async function get$1(input, url, headers, readBody = true) {
25566
26009
  const controller = abortController({
@@ -25591,7 +26034,7 @@ async function get$1(input, url, headers, readBody = true) {
25591
26034
  availability: "unreachable",
25592
26035
  contentType: "",
25593
26036
  text: "",
25594
- error: errorText(error)
26037
+ ...requestError$1(error, input.timeoutMs, input.signal)
25595
26038
  };
25596
26039
  } finally {
25597
26040
  controller.cleanup();
@@ -25618,22 +26061,29 @@ function llmsLinks(text, base) {
25618
26061
  } catch {}
25619
26062
  return links;
25620
26063
  }
25621
- async function checkLlmsTxt(input) {
26064
+ async function readLlmsTxt(input) {
25622
26065
  const result = await get$1(input, new URL("/llms.txt", input.url).toString());
25623
26066
  const servesHtml = result.availability === "available" && isHtml(result.contentType);
25624
26067
  const availability = servesHtml ? "absent" : result.availability;
25625
26068
  const exists = availability === "available";
25626
- const base = {
25627
- url: result.url,
25628
- exists,
25629
- availability,
25630
- ...result.status === void 0 ? {} : { status: result.status },
25631
- ...result.contentType ? { contentType: result.contentType } : {},
25632
- ...servesHtml ? { servesHtml } : {},
25633
- ...result.error ? { error: result.error } : {}
26069
+ return {
26070
+ llmsTxt: {
26071
+ url: result.url,
26072
+ exists,
26073
+ availability,
26074
+ ...result.status === void 0 ? {} : { status: result.status },
26075
+ ...result.contentType ? { contentType: result.contentType } : {},
26076
+ ...servesHtml ? { servesHtml } : {},
26077
+ ...result.error ? { error: result.error } : {},
26078
+ ...result.errorKind ? { errorKind: result.errorKind } : {}
26079
+ },
26080
+ text: exists ? result.text : ""
25634
26081
  };
25635
- if (!exists) return base;
25636
- const found = llmsLinks(result.text, result.url);
26082
+ }
26083
+ async function checkLlmsTxt(input) {
26084
+ const { llmsTxt: base, text } = await readLlmsTxt(input);
26085
+ if (!base.exists) return base;
26086
+ const found = llmsLinks(text, base.url);
25637
26087
  const broken = [];
25638
26088
  let checked = 0;
25639
26089
  for (const url of found.slice(0, LLMS_LINK_LIMIT)) {
@@ -25683,13 +26133,20 @@ function sitemapUrlsFromRobots(text) {
25683
26133
  return [...urls];
25684
26134
  }
25685
26135
  async function checkRobotsAiAccess(input) {
25686
- const result = await get$1(input, new URL("/robots.txt", input.url).toString());
26136
+ const robotsUrl = new URL("/robots.txt", input.url).toString();
26137
+ const result = await get$1(input, robotsUrl);
25687
26138
  const servesHtml = result.availability === "available" && isHtml(result.contentType);
25688
26139
  const availability = servesHtml ? "absent" : result.availability;
25689
26140
  const exists = availability === "available";
25690
26141
  const unknown = result.availability === "rate-limited" || result.availability === "unreachable";
25691
26142
  const declared = exists ? declaredUserAgents(result.text) : /* @__PURE__ */ new Set();
25692
- const parsed = parseRobots(result.url, exists ? result.text : "");
26143
+ const parsed = parseRobots(robotsUrl, exists ? result.text : "");
26144
+ const ruleHosts = /* @__PURE__ */ new Set([new URL(robotsUrl).host, new URL(result.url).host]);
26145
+ const isAllowed = (url, userAgent) => {
26146
+ const target = new URL(url);
26147
+ const local = ruleHosts.has(target.host) ? new URL(`${target.pathname}${target.search}`, robotsUrl).toString() : url;
26148
+ return parsed.isAllowed(local, userAgent) ?? true;
26149
+ };
25693
26150
  return {
25694
26151
  robotsTxt: {
25695
26152
  url: result.url,
@@ -25699,17 +26156,18 @@ async function checkRobotsAiAccess(input) {
25699
26156
  ...result.contentType ? { contentType: result.contentType } : {},
25700
26157
  ...servesHtml ? { servesHtml } : {},
25701
26158
  ...unknown ? { error: result.error ?? `robots.txt returned HTTP ${result.status}.` } : {},
26159
+ ...result.errorKind ? { errorKind: result.errorKind } : {},
25702
26160
  sitemapUrls: exists ? sitemapUrlsFromRobots(result.text) : [],
25703
26161
  ...unknown ? {} : { contentSignals: exists ? contentSignalsFromRobots(result.text) : [] },
25704
26162
  botAccess: AI_BOTS.map(([userAgent, purpose]) => ({
25705
26163
  userAgent,
25706
26164
  purpose,
25707
- allowed: unknown ? null : parsed.isAllowed(input.url, userAgent) ?? true,
26165
+ allowed: unknown ? null : isAllowed(input.url, userAgent),
25708
26166
  declared: declared.has(userAgent.toLowerCase()),
25709
26167
  coveredByWildcard: declared.has("*")
25710
26168
  }))
25711
26169
  },
25712
- allows: (url) => unknown ? null : parsed.isAllowed(url, SEO_CRAWLER_USER_AGENT) ?? true
26170
+ allows: (url) => unknown ? null : isAllowed(url, SEO_CRAWLER_USER_AGENT)
25713
26171
  };
25714
26172
  }
25715
26173
  async function checkAgentResources(input) {
@@ -26083,405 +26541,6 @@ async function crawlDataSources(input) {
26083
26541
  });
26084
26542
  return dataSources;
26085
26543
  }
26086
- // packages/core/dist/analyze/monitoring/crawl-page.js
26087
- function resolvedUrl(href, base) {
26088
- if (!href) return void 0;
26089
- try {
26090
- const url = new URL(href, base);
26091
- return ["http:", "https:"].includes(url.protocol) ? url.toString() : void 0;
26092
- } catch {
26093
- return;
26094
- }
26095
- }
26096
- function hashText(value) {
26097
- return createHash("sha256").update(value).digest("hex");
26098
- }
26099
- function headerValue(headers, name) {
26100
- const lower = name.toLowerCase();
26101
- return headers[name] ?? headers[lower] ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lower)?.[1];
26102
- }
26103
- function safeResponseHeaders(headers) {
26104
- const blocked = /* @__PURE__ */ new Set([
26105
- "authorization",
26106
- "cookie",
26107
- "proxy-authorization",
26108
- "set-cookie",
26109
- "www-authenticate"
26110
- ]);
26111
- return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key.toLowerCase())));
26112
- }
26113
- function truncate$1(value, max) {
26114
- return value.length > max ? value.slice(0, max).trimEnd() : value;
26115
- }
26116
- function sameDocumentUrl(left, right) {
26117
- if (!left || !right) return false;
26118
- try {
26119
- const a = new URL(left);
26120
- const b = new URL(right);
26121
- a.hash = "";
26122
- b.hash = "";
26123
- return a.toString() === b.toString();
26124
- } catch {
26125
- return left === right;
26126
- }
26127
- }
26128
- function isHtmlContentType(value) {
26129
- return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(value ?? "");
26130
- }
26131
- function fetchFailureKind(error, signal) {
26132
- const record = error;
26133
- const code = String(record?.code ?? record?.cause?.code ?? "").toUpperCase();
26134
- const message = error instanceof Error ? error.message : String(error);
26135
- if (signal?.aborted) return "aborted";
26136
- if (/TOO MANY REDIRECTS/i.test(message)) return "redirect-limit";
26137
- if ([
26138
- "ENOTFOUND",
26139
- "EAI_AGAIN",
26140
- "EAI_FAIL"
26141
- ].includes(code)) return "dns";
26142
- if (code.includes("CERT") || code.includes("TLS") || code.includes("SSL") || /CERTIFICATE|\bTLS\b|\bSSL\b/i.test(message)) return "tls";
26143
- if (code === "ETIMEDOUT" || /TIMED?\s*OUT|TIMEOUT|ABORT(?:ED|ERROR)/i.test(message)) return "timeout";
26144
- return "unknown";
26145
- }
26146
- function responseObservation(requestedUrl, fetched, extraction, extractionError) {
26147
- const observation = {
26148
- requestedUrl,
26149
- outcome: "response",
26150
- finalUrl: fetched.finalUrl,
26151
- status: fetched.status,
26152
- contentType: headerValue(fetched.headers, "content-type"),
26153
- durationMs: fetched.diagnostics.durationMs,
26154
- redirectChain: fetched.diagnostics.redirectChain,
26155
- accessBlock: fetched.diagnostics.accessBlock,
26156
- robotsTxt: fetched.robotsTxt
26157
- };
26158
- if (extraction === "failed") {
26159
- if (!extractionError) throw new Error("Failed extraction observations require an error.");
26160
- return {
26161
- ...observation,
26162
- extraction,
26163
- extractionError
26164
- };
26165
- }
26166
- return {
26167
- ...observation,
26168
- extraction
26169
- };
26170
- }
26171
- function unextractedPage(fetched, contentType) {
26172
- const xRobotsTag = headerValue(fetched.headers, "x-robots-tag");
26173
- const canonicalEvidence = extractHttpCanonicalEvidence(fetched.headers, fetched.finalUrl);
26174
- const noindex = hasXRobotsDirective(xRobotsTag, "noindex");
26175
- const knownNonHtml = Boolean(contentType);
26176
- return {
26177
- url: fetched.finalUrl,
26178
- finalUrl: fetched.finalUrl,
26179
- status: fetched.status,
26180
- contentType,
26181
- responseTimeMs: fetched.diagnostics.durationMs,
26182
- sizeBytes: Buffer.byteLength(fetched.html),
26183
- usedJs: fetched.usedJs,
26184
- fetchSource: fetched.diagnostics.source,
26185
- cacheState: fetched.diagnostics.cache,
26186
- fetchDiagnostics: fetched.diagnostics,
26187
- accessBlock: fetched.diagnostics.accessBlock,
26188
- blocked: fetched.diagnostics.blocked,
26189
- robotsTxt: fetched.robotsTxt,
26190
- xRobotsTag,
26191
- canonical: canonicalEvidence.selectedUrl,
26192
- canonicalRaw: canonicalEvidence.selectedRaw ?? canonicalEvidence.candidates[0]?.raw,
26193
- canonicalStatus: canonicalEvidence.status,
26194
- canonicalCandidates: canonicalEvidence.candidates,
26195
- indexable: fetched.status >= 200 && fetched.status < 300 && !noindex,
26196
- indexability: noindex ? "X-Robots-Tag noindex" : knownNonHtml ? "Non-HTML response" : "Unknown response media type",
26197
- declaredIndexability: noindex ? "noindex" : knownNonHtml ? "not-html" : "unknown",
26198
- extractionStatus: knownNonHtml ? "not-applicable" : "unknown-media-type",
26199
- wordCount: 0,
26200
- contentHash: hashText(fetched.html),
26201
- outgoingInternalCount: 0,
26202
- outgoingExternalCount: 0,
26203
- isHttps: new URL(fetched.finalUrl).protocol === "https:",
26204
- hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
26205
- compression: headerValue(fetched.headers, "content-encoding")
26206
- };
26207
- }
26208
- function statusOnlyPage(fetched) {
26209
- const contentType = headerValue(fetched.headers, "content-type");
26210
- return {
26211
- url: fetched.finalUrl,
26212
- finalUrl: fetched.finalUrl,
26213
- status: fetched.status,
26214
- contentType,
26215
- responseTimeMs: fetched.diagnostics.durationMs,
26216
- auditScope: "status",
26217
- contentAuditAllowed: false,
26218
- indexable: false,
26219
- indexability: "Not evaluated by status-only health probe",
26220
- declaredIndexability: "unknown",
26221
- extractionStatus: "not-applicable",
26222
- wordCount: 0,
26223
- contentHash: "",
26224
- outgoingInternalCount: 0,
26225
- outgoingExternalCount: 0
26226
- };
26227
- }
26228
- function indexabilityReason(page) {
26229
- if (page.status < 200 || page.status >= 300) return `Status ${page.status}`;
26230
- if (hasMetaRobotsDirective(page.metaRobots, "noindex")) return "Meta robots noindex";
26231
- if (hasXRobotsDirective(page.xRobotsTag, "noindex")) return "X-Robots-Tag noindex";
26232
- }
26233
- async function crawlOne(url, opts = {}) {
26234
- const startedAt = Date.now();
26235
- let fetched;
26236
- try {
26237
- fetched = await fetchPage(url, opts);
26238
- } catch (error) {
26239
- if (error instanceof OriginBackpressureError) return {
26240
- request: {
26241
- requestedUrl: url,
26242
- outcome: "skipped",
26243
- reason: "origin-backpressure",
26244
- error: error.message,
26245
- extraction: "not-applicable"
26246
- },
26247
- urls: []
26248
- };
26249
- if (error instanceof RobotsAccessError) return {
26250
- request: {
26251
- requestedUrl: url,
26252
- outcome: "skipped",
26253
- reason: error.reason,
26254
- robotsTxt: {
26255
- url: error.evidence.url,
26256
- allowed: error.evidence.allowed,
26257
- availability: error.evidence.availability,
26258
- status: error.evidence.status,
26259
- error: error.evidence.error,
26260
- matchedLine: error.evidence.matchedLine
26261
- },
26262
- extraction: "not-applicable"
26263
- },
26264
- urls: []
26265
- };
26266
- const message = error instanceof Error ? error.message : String(error);
26267
- return {
26268
- request: {
26269
- requestedUrl: url,
26270
- outcome: "failure",
26271
- durationMs: Date.now() - startedAt,
26272
- failureKind: fetchFailureKind(error, opts.signal),
26273
- error: message,
26274
- extraction: "not-applicable"
26275
- },
26276
- urls: [],
26277
- warning: `${url}: ${message}`
26278
- };
26279
- }
26280
- return snapshotFetchedPage(url, fetched);
26281
- }
26282
- async function snapshotFetchedPage(url, fetched) {
26283
- const contentType = headerValue(fetched.headers, "content-type");
26284
- if (fetched.diagnostics.accessBlock) {
26285
- const page = unextractedPage(fetched, contentType);
26286
- page.contentAuditAllowed = false;
26287
- page.indexable = false;
26288
- page.indexability = "Crawler access blocked";
26289
- page.declaredIndexability = "unknown";
26290
- page.extractionStatus = "not-applicable";
26291
- return {
26292
- request: responseObservation(url, fetched, "not-applicable"),
26293
- page,
26294
- urls: []
26295
- };
26296
- }
26297
- if (!isHtmlContentType(contentType)) return {
26298
- request: responseObservation(url, fetched, contentType ? "not-applicable" : "unknown-media-type"),
26299
- page: unextractedPage(fetched, contentType),
26300
- urls: []
26301
- };
26302
- try {
26303
- const extracted = await extractPage(fetched, "crawler");
26304
- const crawlerEvidence = extracted.crawlerEvidence;
26305
- if (!crawlerEvidence) throw new Error("Crawler extraction did not return crawl evidence.");
26306
- const h1 = crawlerEvidence.h1;
26307
- const uniqueInternalLinks = crawlerEvidence.internalLinks;
26308
- const uniqueExternalLinks = crawlerEvidence.externalLinks;
26309
- const canonical = extracted.canonicalEvidence?.selectedUrl ?? resolvedUrl(extracted.canonical, extracted.finalUrl);
26310
- const richResults = selectGoogleRichResultAssessments(extracted.googleRichResults ?? []);
26311
- const page = {
26312
- url: extracted.finalUrl,
26313
- finalUrl: extracted.finalUrl,
26314
- status: fetched.status,
26315
- contentType,
26316
- responseHeaders: safeResponseHeaders(fetched.headers),
26317
- responseTimeMs: fetched.diagnostics.durationMs,
26318
- sizeBytes: Buffer.byteLength(fetched.html),
26319
- usedJs: fetched.usedJs,
26320
- fetchSource: fetched.diagnostics.source,
26321
- cacheState: fetched.diagnostics.cache,
26322
- fetchDiagnostics: fetched.diagnostics,
26323
- blocked: fetched.diagnostics.blocked,
26324
- robotsTxt: fetched.robotsTxt,
26325
- title: extracted.title,
26326
- metaDescription: extracted.metaDescription,
26327
- canonical,
26328
- canonicalRaw: extracted.canonicalEvidence?.selectedRaw ?? extracted.canonicalEvidence?.candidates[0]?.raw ?? extracted.canonical,
26329
- canonicalStatus: extracted.canonicalEvidence?.status,
26330
- canonicalCandidates: extracted.canonicalEvidence?.candidates,
26331
- metaRobots: extracted.metaRobots,
26332
- xRobotsTag: extracted.xRobotsTag,
26333
- h1,
26334
- h1Count: crawlerEvidence.h1Count,
26335
- h2Count: crawlerEvidence.h2Count,
26336
- h3Count: crawlerEvidence.h3Count,
26337
- indexable: false,
26338
- extractionStatus: "complete",
26339
- wordCount: extracted.wordCount,
26340
- contentExtraction: extracted.contentExtraction,
26341
- warnings: [
26342
- ...extracted.warnings,
26343
- ...extracted.canonicalEvidence?.status === "invalid" || extracted.canonical && !canonical ? ["Ignored a malformed canonical URL."] : [],
26344
- ...extracted.canonicalEvidence?.status === "conflicting" ? ["Found conflicting canonical declarations."] : [],
26345
- ...extracted.canonicalEvidence?.status === "outside-head-only" ? ["Ignored canonical link elements outside the document head because Google does not accept them."] : [],
26346
- ...extracted.canonicalEvidence?.status === "streamed-outside-head" ? ["Checked the canonical target that Next.js streamed into the body of this response. Google documents that it only accepts canonical links in the head."] : []
26347
- ],
26348
- contentHash: hashText([
26349
- extracted.title,
26350
- extracted.metaDescription,
26351
- h1,
26352
- extracted.canonical,
26353
- extracted.contentText
26354
- ].join("\n")),
26355
- mainContentHash: hashText(extracted.contentText),
26356
- textRatio: fetched.html.length ? Math.min(1, extracted.contentText.length / fetched.html.length) : void 0,
26357
- contentSample: truncate$1(extracted.contentText, 300),
26358
- contentSketch: crawlerEvidence.contentSketch,
26359
- softAuthenticationGate: crawlerEvidence.softAuthenticationGate,
26360
- metaRefresh: crawlerEvidence.metaRefresh,
26361
- tabbedContent: crawlerEvidence.tabbedContent,
26362
- lang: extracted.lang,
26363
- hasViewport: extracted.hasViewport,
26364
- isHttps: new URL(extracted.finalUrl).protocol === "https:",
26365
- hasHsts: Boolean(headerValue(fetched.headers, "strict-transport-security")),
26366
- compression: headerValue(fetched.headers, "content-encoding"),
26367
- hreflang: extracted.hreflang,
26368
- mixedContentCount: extracted.mixedContentUrls.length,
26369
- mixedContentSamples: extracted.mixedContentUrls.slice(0, 25),
26370
- imagesTotal: extracted.imagesTotal,
26371
- imagesMissingAlt: extracted.imagesMissingAlt,
26372
- oversizedImageCandidates: extracted.oversizedImageCandidates,
26373
- outgoingInternalCount: uniqueInternalLinks.length,
26374
- outgoingExternalCount: uniqueExternalLinks.length,
26375
- sampleInternalLinks: uniqueInternalLinks.slice(0, 25),
26376
- sampleExternalLinks: uniqueExternalLinks.slice(0, 25),
26377
- internalAnchorSamples: crawlerEvidence.internalAnchorSamples,
26378
- externalAnchorSamples: crawlerEvidence.externalAnchorSamples,
26379
- markdownAlternates: extracted.markdownAlternates,
26380
- describedBy: extracted.describedBy,
26381
- schemaTypes: extracted.schemaTypes,
26382
- structuredDataFormats: extracted.structuredDataFormats ?? [],
26383
- googleRichResults: richResults.assessments,
26384
- googleRichResultsSelection: richResults.selection,
26385
- schemaSameAs: [...new Set((extracted.schemaSameAsEvidence ?? []).map((evidence) => evidence.url))].slice(0, 50),
26386
- schemaSameAsEvidence: (extracted.schemaSameAsEvidence ?? []).slice(0, 50),
26387
- invalidSchemaSameAs: (extracted.invalidSchemaSameAs ?? []).slice(0, 25),
26388
- socialProfileLinks: crawlerEvidence.socialProfileLinks,
26389
- invalidJsonLdCount: extracted.invalidJsonLdCount,
26390
- invalidJsonLdSamples: extracted.invalidJsonLdSamples,
26391
- unrecognizedJsonLdTypes: extracted.unrecognizedJsonLdTypes ?? [],
26392
- openGraphTitle: extracted.openGraph["og:title"],
26393
- openGraphDescription: extracted.openGraph["og:description"],
26394
- openGraphImage: extracted.openGraph["og:image"],
26395
- twitterCard: extracted.twitter["twitter:card"],
26396
- author: extracted.author,
26397
- hasDate: extracted.hasDate,
26398
- geo: {
26399
- semanticHtml: extracted.semanticHtml,
26400
- structuredData: (extracted.structuredDataFormats?.length ?? 0) > 0,
26401
- hasAuthor: extracted.hasAuthor,
26402
- hasDate: extracted.hasDate,
26403
- questionHeadings: extracted.questionHeadings,
26404
- listCount: extracted.listCount,
26405
- tableCount: extracted.tableCount,
26406
- structuredBlocks: extracted.structuredBlocks,
26407
- answerable: extracted.answerable,
26408
- hasFaqSchema: extracted.schemaTypes.includes("FAQPage"),
26409
- hasQapageSchema: extracted.schemaTypes.includes("QAPage")
26410
- }
26411
- };
26412
- page.indexability = page.metaRefresh ? `Meta refresh to ${page.metaRefresh.targetUrl}` : indexabilityReason(page);
26413
- page.indexable = !page.indexability;
26414
- page.declaredIndexability = page.metaRefresh ? "meta-refresh" : hasMetaRobotsDirective(page.metaRobots, "noindex") || hasXRobotsDirective(page.xRobotsTag, "noindex") ? "noindex" : page.robotsTxt?.allowed === false ? "robots-blocked" : page.canonicalStatus === "conflicting" ? "canonical-conflict" : page.canonical && !sameDocumentUrl(page.canonical, page.finalUrl) ? "canonical-hint-other" : "indexable-candidate";
26415
- return {
26416
- request: responseObservation(url, fetched, "complete"),
26417
- page,
26418
- urls: uniqueInternalLinks
26419
- };
26420
- } catch (error) {
26421
- const message = error instanceof Error ? error.message : String(error);
26422
- return {
26423
- request: responseObservation(url, fetched, "failed", message),
26424
- urls: [],
26425
- warning: `${url}: content extraction failed: ${message}`
26426
- };
26427
- }
26428
- }
26429
- async function crawlStatusOnly(url, opts = {}) {
26430
- const startedAt = Date.now();
26431
- try {
26432
- const fetched = await fetchPageStatus(url, {
26433
- ...opts,
26434
- js: "off",
26435
- refresh: true
26436
- });
26437
- return {
26438
- request: responseObservation(url, fetched, "not-applicable"),
26439
- page: statusOnlyPage(fetched),
26440
- urls: []
26441
- };
26442
- } catch (error) {
26443
- if (error instanceof OriginBackpressureError) return {
26444
- request: {
26445
- requestedUrl: url,
26446
- outcome: "skipped",
26447
- reason: "origin-backpressure",
26448
- error: error.message,
26449
- extraction: "not-applicable"
26450
- },
26451
- urls: []
26452
- };
26453
- if (error instanceof RobotsAccessError) return {
26454
- request: {
26455
- requestedUrl: url,
26456
- outcome: "skipped",
26457
- reason: error.reason,
26458
- robotsTxt: {
26459
- url: error.evidence.url,
26460
- allowed: error.evidence.allowed,
26461
- availability: error.evidence.availability,
26462
- status: error.evidence.status,
26463
- error: error.evidence.error,
26464
- matchedLine: error.evidence.matchedLine
26465
- },
26466
- extraction: "not-applicable"
26467
- },
26468
- urls: []
26469
- };
26470
- const message = error instanceof Error ? error.message : String(error);
26471
- return {
26472
- request: {
26473
- requestedUrl: url,
26474
- outcome: "failure",
26475
- durationMs: Date.now() - startedAt,
26476
- failureKind: fetchFailureKind(error, opts.signal),
26477
- error: message,
26478
- extraction: "not-applicable"
26479
- },
26480
- urls: [],
26481
- warning: `${url}: ${message}`
26482
- };
26483
- }
26484
- }
26485
26544
  // packages/core/dist/analyze/monitoring/sitemaps.js
26486
26545
  const INVALID_LOC_SAMPLE_LIMIT = 10;
26487
26546
  const LASTMOD_SAMPLE_LIMIT = 10;
@@ -36533,16 +36592,83 @@ async function indexCoverageSignals(input, dependencies = defaultDependencies$1)
36533
36592
  }
36534
36593
  };
36535
36594
  }
36536
- // packages/core/dist/analyze/monitoring/redirect-trace.js
36537
- function resolveNextUrl(location, baseUrl) {
36595
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([
36596
+ 301,
36597
+ 302,
36598
+ 303,
36599
+ 307,
36600
+ 308
36601
+ ]);
36602
+ function errorText(error) {
36603
+ if (!(error instanceof Error)) return String(error);
36604
+ const cause = error.cause;
36605
+ const detail = typeof cause?.code === "string" ? cause.code : typeof cause?.message === "string" ? cause.message : "";
36606
+ return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message;
36607
+ }
36608
+ function httpUrl(value, base) {
36538
36609
  try {
36539
- const next = new URL(location, baseUrl);
36540
- next.hash = "";
36541
- return next.toString();
36610
+ const url = new URL(value, base);
36611
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
36612
+ url.hash = "";
36613
+ return url.toString();
36542
36614
  } catch {
36543
- return;
36615
+ return null;
36616
+ }
36617
+ }
36618
+ async function traceRedirects(url, options = {}) {
36619
+ const maxHops = Math.min(Math.max(Math.trunc(options.maxHops ?? 10), 0), 10);
36620
+ const timeoutMs = options.timeoutMs ?? 1e4;
36621
+ const request = options.fetch ?? ((to, init) => fetch(to, init));
36622
+ const steps = [];
36623
+ const end = (final_url, final_status, outcome, error) => ({
36624
+ url,
36625
+ steps,
36626
+ hops: steps.filter((step) => step.location !== null).length,
36627
+ final_url,
36628
+ final_status,
36629
+ outcome,
36630
+ ...error ? { error } : {}
36631
+ });
36632
+ const start = httpUrl(url);
36633
+ if (!start) return end(url, null, "error", "Not an http or https URL.");
36634
+ let current = start;
36635
+ const seen = /* @__PURE__ */ new Set();
36636
+ while (true) {
36637
+ seen.add(current);
36638
+ const startedAt = Date.now();
36639
+ let response;
36640
+ try {
36641
+ response = await request(current, {
36642
+ redirect: "manual",
36643
+ headers: {
36644
+ "user-agent": SEO_CRAWLER_USER_AGENT,
36645
+ accept: "text/html,application/xhtml+xml,*/*;q=0.8"
36646
+ },
36647
+ signal: AbortSignal.timeout(timeoutMs)
36648
+ });
36649
+ } catch (error) {
36650
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") ? end(current, null, "timeout", `No response within ${timeoutMs} ms.`) : end(current, null, "error", errorText(error));
36651
+ }
36652
+ const ms = Date.now() - startedAt;
36653
+ const header = response.headers.get("location");
36654
+ const redirects = REDIRECT_STATUSES.has(response.status) && header !== null;
36655
+ const location = redirects ? httpUrl(header, current) : null;
36656
+ if (options.onResponse) await options.onResponse(response, current);
36657
+ await response.body?.cancel().catch(() => void 0);
36658
+ steps.push({
36659
+ url: current,
36660
+ status: response.status,
36661
+ location,
36662
+ ms
36663
+ });
36664
+ if (!redirects) return end(current, response.status, "done");
36665
+ if (!location) return end(current, response.status, "error", `The Location header is not an http or https URL: ${header.slice(0, 200)}`);
36666
+ if (seen.has(location)) return end(current, response.status, "loop");
36667
+ if (steps.length > maxHops) return end(current, response.status, "too_many");
36668
+ current = location;
36544
36669
  }
36545
36670
  }
36671
+ // packages/core/dist/analyze/monitoring/redirect-trace.js
36546
36672
  function redirectIssue(input) {
36547
36673
  const issues = [];
36548
36674
  const finalStatus = input.chain.at(-1)?.status ?? 0;
@@ -36557,55 +36683,28 @@ function redirectIssue(input) {
36557
36683
  return [...new Set(issues)];
36558
36684
  }
36559
36685
  async function redirectTrace(input) {
36560
- const maxHops = input.maxHops ?? 10;
36561
36686
  const warnings = [];
36562
- const chain = [];
36563
- const seen = /* @__PURE__ */ new Set();
36564
- let currentUrl = new URL(input.url).toString();
36565
- let stoppedForLoop = false;
36566
- let stoppedForLimit = false;
36567
- for (let hop = 0; hop <= maxHops; hop += 1) {
36568
- if (seen.has(currentUrl)) {
36569
- stoppedForLoop = true;
36570
- break;
36571
- }
36572
- seen.add(currentUrl);
36573
- const startedAt = Date.now();
36574
- const controller = new AbortController();
36575
- const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? 2e4);
36576
- try {
36577
- const response = await publicHttpFetch(currentUrl, {
36578
- redirect: "manual",
36579
- signal: controller.signal
36580
- });
36581
- const location = response.headers.get("location") ?? void 0;
36582
- const nextUrl = location ? resolveNextUrl(location, currentUrl) : void 0;
36583
- chain.push({
36584
- url: currentUrl,
36585
- status: response.status,
36586
- location,
36587
- nextUrl,
36588
- durationMs: Date.now() - startedAt
36589
- });
36590
- if (response.status < 300 || response.status >= 400) break;
36591
- if (!nextUrl) break;
36592
- currentUrl = nextUrl;
36593
- } catch (error) {
36594
- warnings.push(`${currentUrl}: ${error instanceof Error ? error.message : String(error)}`);
36595
- break;
36596
- } finally {
36597
- clearTimeout(timer);
36598
- }
36599
- }
36600
- const lastStep = chain.at(-1);
36601
- if (lastStep && lastStep.status >= 300 && lastStep.status < 400 && chain.length > maxHops) stoppedForLimit = true;
36602
- const finalUrl = lastStep?.nextUrl ?? lastStep?.url ?? currentUrl;
36603
- const final = await crawlOne(finalUrl, {
36687
+ const trace = await traceRedirects(new URL(input.url).toString(), {
36688
+ maxHops: input.maxHops,
36689
+ timeoutMs: input.timeoutMs
36690
+ });
36691
+ const chain = trace.steps.map((step) => ({
36692
+ url: step.url,
36693
+ status: step.status,
36694
+ ...step.location ? {
36695
+ location: step.location,
36696
+ nextUrl: step.location
36697
+ } : {},
36698
+ durationMs: step.ms
36699
+ }));
36700
+ if (trace.error) warnings.push(`${trace.final_url}: ${trace.error}`);
36701
+ const finalUrl = trace.final_url;
36702
+ const final = trace.outcome === "done" ? await crawlOne(finalUrl, {
36604
36703
  refresh: input.refresh,
36605
36704
  js: input.js ?? "auto"
36606
- });
36607
- if (final.warning) warnings.push(final.warning);
36608
- const finalPage = final.page ? {
36705
+ }) : void 0;
36706
+ if (final?.warning) warnings.push(final.warning);
36707
+ const finalPage = final?.page ? {
36609
36708
  title: final.page.title,
36610
36709
  canonical: final.page.canonical,
36611
36710
  metaRobots: final.page.metaRobots,
@@ -36617,16 +36716,18 @@ async function redirectTrace(input) {
36617
36716
  finalIndexable: finalPage?.indexable,
36618
36717
  canonical: finalPage?.canonical,
36619
36718
  finalUrl,
36620
- stoppedForLoop,
36621
- stoppedForLimit
36719
+ stoppedForLoop: trace.outcome === "loop",
36720
+ stoppedForLimit: trace.outcome === "too_many"
36622
36721
  });
36623
36722
  return {
36624
36723
  url: input.url,
36625
36724
  finalUrl,
36626
36725
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
36627
36726
  summary: {
36628
- hops: Math.max(0, chain.length - 1),
36629
- finalStatus: final.page?.status ?? chain.at(-1)?.status ?? 0,
36727
+ hops: trace.hops,
36728
+ finalStatus: trace.final_status,
36729
+ outcome: trace.outcome,
36730
+ ...trace.error ? { error: trace.error } : {},
36630
36731
  finalIndexable: finalPage?.indexable,
36631
36732
  issues
36632
36733
  },
@@ -40425,7 +40526,7 @@ function extractText(source) {
40425
40526
  flush();
40426
40527
  return finish(x, blocks);
40427
40528
  }
40428
- function extractMetaLines(source) {
40529
+ function extractMetaLines(source, metaKind) {
40429
40530
  const x = new Extractor(source);
40430
40531
  const blocks = [];
40431
40532
  for (let line = 0; line < x.starts.length; line++) {
@@ -40437,7 +40538,7 @@ function extractMetaLines(source) {
40437
40538
  const from = start + (label ? label[0].length : 0);
40438
40539
  const built = decodeValue(source, from, end, false);
40439
40540
  const value = source.slice(from, end).trim();
40440
- const kind = label ? label[1]?.toLowerCase() : value.length <= 70 ? "title" : "description";
40541
+ const kind = label ? label[1]?.toLowerCase() : metaKind ? metaKind : value.length <= 70 ? "title" : "description";
40441
40542
  const block = makeBlock(kind, built, { field: kind });
40442
40543
  if (block) blocks.push(block);
40443
40544
  }
@@ -40483,8 +40584,8 @@ function formatFromPath(path) {
40483
40584
  function defaultPreset(format) {
40484
40585
  return format === "html" || format === "astro" ? "landing" : "blog";
40485
40586
  }
40486
- function extract(source, format, preset) {
40487
- const extracted = preset === "meta" && format === "text" ? extractMetaLines(source) : format === "md" || format === "mdx" ? extractMarkdown(source, format === "mdx") : format === "html" || format === "astro" ? extractHtml(source, format === "astro") : extractText(source);
40587
+ function extract(source, format, preset, metaKind) {
40588
+ const extracted = preset === "meta" && format === "text" ? extractMetaLines(source, metaKind) : format === "md" || format === "mdx" ? extractMarkdown(source, format === "mdx") : format === "html" || format === "astro" ? extractHtml(source, format === "astro") : extractText(source);
40488
40589
  return {
40489
40590
  source,
40490
40591
  format,
@@ -41146,6 +41247,153 @@ function runPhraseRules(ctx) {
41146
41247
  }
41147
41248
  }
41148
41249
  }
41250
+ // packages/core/dist/serp/browser.js
41251
+ const SERP_PIXEL_BUDGETS = {
41252
+ title: {
41253
+ desktop: 600,
41254
+ mobile: 560
41255
+ },
41256
+ description: {
41257
+ desktop: 920,
41258
+ mobile: 680
41259
+ }
41260
+ };
41261
+ const FONT_PX = {
41262
+ title: 20,
41263
+ description: 14
41264
+ };
41265
+ const ASCII = [
41266
+ 278,
41267
+ 278,
41268
+ 355,
41269
+ 556,
41270
+ 556,
41271
+ 889,
41272
+ 667,
41273
+ 191,
41274
+ 333,
41275
+ 333,
41276
+ 389,
41277
+ 584,
41278
+ 278,
41279
+ 333,
41280
+ 278,
41281
+ 278,
41282
+ 556,
41283
+ 556,
41284
+ 556,
41285
+ 556,
41286
+ 556,
41287
+ 556,
41288
+ 556,
41289
+ 556,
41290
+ 556,
41291
+ 556,
41292
+ 278,
41293
+ 278,
41294
+ 584,
41295
+ 584,
41296
+ 584,
41297
+ 556,
41298
+ 1015,
41299
+ 667,
41300
+ 667,
41301
+ 722,
41302
+ 722,
41303
+ 667,
41304
+ 611,
41305
+ 778,
41306
+ 722,
41307
+ 278,
41308
+ 500,
41309
+ 667,
41310
+ 556,
41311
+ 833,
41312
+ 722,
41313
+ 778,
41314
+ 667,
41315
+ 778,
41316
+ 722,
41317
+ 667,
41318
+ 611,
41319
+ 722,
41320
+ 667,
41321
+ 944,
41322
+ 667,
41323
+ 667,
41324
+ 611,
41325
+ 278,
41326
+ 278,
41327
+ 278,
41328
+ 469,
41329
+ 556,
41330
+ 333,
41331
+ 556,
41332
+ 556,
41333
+ 500,
41334
+ 556,
41335
+ 556,
41336
+ 278,
41337
+ 556,
41338
+ 556,
41339
+ 222,
41340
+ 222,
41341
+ 500,
41342
+ 222,
41343
+ 833,
41344
+ 556,
41345
+ 556,
41346
+ 556,
41347
+ 556,
41348
+ 333,
41349
+ 500,
41350
+ 278,
41351
+ 556,
41352
+ 500,
41353
+ 722,
41354
+ 500,
41355
+ 500,
41356
+ 500,
41357
+ 334,
41358
+ 260,
41359
+ 334,
41360
+ 584
41361
+ ];
41362
+ const OTHER = {
41363
+ "‘": 222,
41364
+ "’": 222,
41365
+ "“": 333,
41366
+ "”": 333,
41367
+ "–": 556,
41368
+ "—": 1e3,
41369
+ "…": 1e3,
41370
+ "•": 350,
41371
+ "·": 278,
41372
+ "©": 737,
41373
+ "®": 737,
41374
+ "™": 1e3,
41375
+ "£": 556,
41376
+ "€": 556,
41377
+ "°": 400,
41378
+ "×": 584
41379
+ };
41380
+ const WIDE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
41381
+ const AVERAGE = 556;
41382
+ function units(char) {
41383
+ const code = char.codePointAt(0) ?? 0;
41384
+ if (code >= 32 && code <= 126) return ASCII[code - 32] ?? AVERAGE;
41385
+ const known = OTHER[char];
41386
+ if (known !== void 0) return known;
41387
+ if (WIDE.test(char)) return 1e3;
41388
+ const base = char.normalize("NFD").codePointAt(0) ?? 0;
41389
+ if (base >= 32 && base <= 126) return ASCII[base - 32] ?? AVERAGE;
41390
+ return AVERAGE;
41391
+ }
41392
+ function serpTextWidth(value, kind) {
41393
+ let total = 0;
41394
+ for (const char of value) total += units(char);
41395
+ return total * FONT_PX[kind] / 1e3;
41396
+ }
41149
41397
  // packages/core/dist/lint/rules-seo.js
41150
41398
  const SEO_RULES = {
41151
41399
  dash: {
@@ -41247,7 +41495,8 @@ function lengths(ctx) {
41247
41495
  const length = block.text.length;
41248
41496
  if (length >= low && length <= high) continue;
41249
41497
  const what = title ? "title" : "description";
41250
- ctx.hit(rule, block, 0, block.text.length, `The ${what} is ${length} characters. Aim for ${low} to ${high}.`, length < low ? title ? "Add the words people search for, or one true reason to click, such as \"free\" or \"for any site\"." : "Say what the page contains and who it is for in one or two sentences." : title ? "Cut words after the main topic. Search results cut long titles off at about 60 characters." : "Cut it to one specific sentence. Search results cut descriptions off at about 160 characters.");
41498
+ const pixels = Math.ceil(serpTextWidth(block.text, what));
41499
+ ctx.hit(rule, block, 0, block.text.length, `The ${what} is ${length} characters, about ${pixels} pixels wide. Aim for ${low} to ${high} characters; Google shows about ${SERP_PIXEL_BUDGETS[what].desktop} pixels of a ${what} on desktop.`, length < low ? title ? "Add the words people search for, or one true reason to click, such as \"free\" or \"for any site\"." : "Say what the page contains and who it is for in one or two sentences." : title ? "Cut words after the main topic. Search results cut long titles off at about 60 characters." : "Cut it to one specific sentence. Search results cut descriptions off at about 160 characters.");
41251
41500
  }
41252
41501
  }
41253
41502
  const HEADING_CLICHE = /^(?:a )?(?:deep dive|under the hood|demystifying\b|why (?:it|this|that) matters|a closer look|the ultimate guide|everything you need to know|mastering\b|unlocking\b|the power of\b|the future of\b|what you'll learn|key takeaways|final thoughts|wrapping up|conclusion|in conclusion|tl;?dr|overview|introduction|the road ahead|looking ahead|challenges and (?:legacy|opportunities|future)|future outlook|the bottom line|let's dive in|get started today)/i;
@@ -41854,6 +42103,7 @@ const LINT_PRESETS = [
41854
42103
  "docs",
41855
42104
  "meta"
41856
42105
  ];
42106
+ const LINT_META_KINDS = ["title", "description"];
41857
42107
  const LINT_FORMATS = [
41858
42108
  "md",
41859
42109
  "mdx",
@@ -41861,6 +42111,11 @@ const LINT_FORMATS = [
41861
42111
  "astro",
41862
42112
  "text"
41863
42113
  ];
42114
+ const LINT_CATEGORIES = [
42115
+ "ai",
42116
+ "copy",
42117
+ "seo"
42118
+ ];
41864
42119
  const LINT_MAX_CHARS = 2e5;
41865
42120
  // packages/core/dist/lint/lint.js
41866
42121
  const LINT_RULES = [
@@ -41875,6 +42130,15 @@ const LINT_RULES = [
41875
42130
  ...Object.values(SEO_RULES)
41876
42131
  ];
41877
42132
  const AI_RULES = new Set(LINT_RULES.filter((rule) => rule.family === "ai").map((rule) => rule.id));
42133
+ const RULE_CATEGORY = new Map(LINT_RULES.map((rule) => [rule.id, rule.family]));
42134
+ const LENS_CATEGORY = {
42135
+ contrast: "ai",
42136
+ metaphor: "ai",
42137
+ staccato: "ai",
42138
+ anaphora: "ai",
42139
+ "mic-drop": "ai"
42140
+ };
42141
+ const lensCategory = (lens) => LENS_CATEGORY[lens] ?? "copy";
41878
42142
  function position(doc, block, index) {
41879
42143
  const offset = block.map[Math.min(index, block.map.length - 1)] ?? 0;
41880
42144
  const line = lineOf(doc.lineStarts, offset);
@@ -41901,7 +42165,7 @@ function lintText(text, options = {}) {
41901
42165
  if (text.length > 2e5) throw new RangeError(`The text is ${text.length.toLocaleString("en-US")} characters; the most one check reads is ${LINT_MAX_CHARS.toLocaleString("en-US")}.`);
41902
42166
  const format = options.format ?? "md";
41903
42167
  const preset = options.preset ?? defaultPreset(format);
41904
- const doc = extract(text, format, preset);
42168
+ const doc = extract(text, format, preset, options.metaKind);
41905
42169
  const ctx = new Context(doc, preset, LINT_RULES);
41906
42170
  runStructureRules(ctx);
41907
42171
  runPhraseRules(ctx);
@@ -41916,7 +42180,10 @@ function lintText(text, options = {}) {
41916
42180
  const lower = quote.toLowerCase();
41917
42181
  return !phrases.some((phrase) => lower.includes(phrase));
41918
42182
  };
41919
- const hits = dedupe(ctx.hits);
42183
+ const only = options.only?.length ? new Set(options.only) : null;
42184
+ const checks = (category) => !only || only.has(category);
42185
+ const ruleCategory = (rule) => RULE_CATEGORY.get(rule) ?? "copy";
42186
+ const hits = dedupe(ctx.hits).filter((hit) => checks(ruleCategory(hit.rule)));
41920
42187
  const findings = [];
41921
42188
  for (const hit of hits) {
41922
42189
  const at = position(doc, hit.block, hit.start);
@@ -41924,6 +42191,7 @@ function lintText(text, options = {}) {
41924
42191
  if (!allowed(hit.rule, at.line, quote)) continue;
41925
42192
  findings.push({
41926
42193
  rule: hit.rule,
42194
+ category: ruleCategory(hit.rule),
41927
42195
  level: hit.level,
41928
42196
  line: at.line,
41929
42197
  column: at.column,
@@ -41932,12 +42200,13 @@ function lintText(text, options = {}) {
41932
42200
  fix: hit.fix
41933
42201
  });
41934
42202
  }
41935
- for (const found of text.matchAll(RAW_LEAKS)) {
42203
+ for (const found of checks("ai") ? text.matchAll(RAW_LEAKS) : []) {
41936
42204
  const offset = found.index ?? 0;
41937
42205
  const line = lineOf(doc.lineStarts, offset);
41938
42206
  if (!allowed("chatbot-residue", line + 1, found[0])) continue;
41939
42207
  findings.push({
41940
42208
  rule: "chatbot-residue",
42209
+ category: "ai",
41941
42210
  level: "error",
41942
42211
  line: line + 1,
41943
42212
  column: offset - (doc.lineStarts[line] ?? 0) + 1,
@@ -41949,6 +42218,7 @@ function lintText(text, options = {}) {
41949
42218
  const review = [];
41950
42219
  const seen = /* @__PURE__ */ new Set();
41951
42220
  for (const item of ctx.reviews) {
42221
+ if (!checks(lensCategory(item.lens))) continue;
41952
42222
  if (hits.some((hit) => overlaps(item, hit))) continue;
41953
42223
  const at = position(doc, item.block, item.start);
41954
42224
  const quote = excerpt(item.block, item.start, item.end);
@@ -41957,6 +42227,7 @@ function lintText(text, options = {}) {
41957
42227
  seen.add(key);
41958
42228
  review.push({
41959
42229
  lens: item.lens,
42230
+ category: lensCategory(item.lens),
41960
42231
  line: at.line,
41961
42232
  column: at.column,
41962
42233
  quote,
@@ -41966,17 +42237,25 @@ function lintText(text, options = {}) {
41966
42237
  const order = (a, b) => a.line - b.line || a.column - b.column;
41967
42238
  findings.sort((a, b) => order(a, b) || a.rule.localeCompare(b.rule) || a.excerpt.localeCompare(b.excerpt));
41968
42239
  review.sort((a, b) => order(a, b) || a.lens.localeCompare(b.lens) || a.quote.localeCompare(b.quote));
42240
+ const categories = Object.fromEntries(LINT_CATEGORIES.map((category) => [category, findings.filter((finding) => finding.category === category).length]));
41969
42241
  return {
41970
42242
  format,
41971
42243
  preset,
41972
42244
  words: ctx.words,
41973
42245
  errors: findings.filter((finding) => finding.level === "error").length,
41974
42246
  warnings: findings.filter((finding) => finding.level === "warning").length,
42247
+ categories,
42248
+ ...only ? { only: LINT_CATEGORIES.filter((category) => only.has(category)) } : {},
41975
42249
  findings,
41976
42250
  review
41977
42251
  };
41978
42252
  }
41979
42253
  const plural$2 = (count, word) => `${count.toLocaleString("en-US")} ${word}${count === 1 ? "" : "s"}`;
42254
+ const CATEGORY_NAMES = {
42255
+ ai: "AI patterns",
42256
+ copy: "copy",
42257
+ seo: "SEO"
42258
+ };
41980
42259
  function lintReportText(files) {
41981
42260
  const lines = [];
41982
42261
  let errors = 0;
@@ -41991,7 +42270,12 @@ function lintReportText(files) {
41991
42270
  errors += result.errors;
41992
42271
  warnings += result.warnings;
41993
42272
  reviews += result.review.length;
41994
- lines.push(`${file.path} (${result.preset}, ${plural$2(result.words, "word")}): ${plural$2(result.errors, "error")}, ${plural$2(result.warnings, "warning")}, ${result.review.length.toLocaleString("en-US")} to review`);
42273
+ const only = result.only ? `, only ${result.only.map((category) => CATEGORY_NAMES[category]).join(" and ")}` : "";
42274
+ const counted = LINT_CATEGORIES.filter((category) => result.categories[category] > 0);
42275
+ const split = counted.length ? ` (${counted.map((category) => `${category} ${result.categories[category].toLocaleString("en-US")}`).join(", ")})` : "";
42276
+ lines.push(`${file.path} (${result.preset}, ${plural$2(result.words, "word")}${only}): ${plural$2(result.errors, "error")}, ${plural$2(result.warnings, "warning")}${split}, ${result.review.length.toLocaleString("en-US")} to review`);
42277
+ if (file.note) lines.push(` ${file.note}`);
42278
+ if (file.partial) lines.push(` Partly checked: ${file.partial}`);
41995
42279
  for (const finding of result.findings) lines.push(` ${finding.line}:${finding.column} ${finding.level} ${finding.rule} "${finding.excerpt}"`, ` ${finding.message}`, ` Fix: ${finding.fix}`);
41996
42280
  if (result.review.length) {
41997
42281
  lines.push(" To review (your call):");
@@ -42000,7 +42284,14 @@ function lintReportText(files) {
42000
42284
  lines.push("");
42001
42285
  }
42002
42286
  const checked = files.filter((file) => "result" in file).length;
42003
- if (files.length > 1) lines.push(`${plural$2(checked, "file")} checked: ${plural$2(errors, "error")}, ${plural$2(warnings, "warning")}, ${reviews.toLocaleString("en-US")} to review.`);
42287
+ const skipped = files.length - checked;
42288
+ const partial = files.filter((file) => "partial" in file && file.partial).length;
42289
+ if (files.length > 1 || skipped || partial) {
42290
+ const gaps = [skipped ? `${skipped.toLocaleString("en-US")} not checked` : "", partial ? `${partial.toLocaleString("en-US")} partly checked` : ""].filter(Boolean);
42291
+ lines.push(`${plural$2(checked, "file")} checked${gaps.length ? ` (${gaps.join(", ")})` : ""}: ${plural$2(errors, "error")}, ${plural$2(warnings, "warning")}, ${reviews.toLocaleString("en-US")} to review.`);
42292
+ }
42293
+ if (!checked) lines.push("Nothing was checked, so this is not a clean result. The reason is beside each file above.");
42294
+ else if (skipped || partial) lines.push("Not an all-clear: the totals cover only the text that was checked.");
42004
42295
  if (reviews) lines.push("Errors and warnings each come with a fix. Review items are judgement calls: change a line only when it fails the question.");
42005
42296
  return lines.join("\n").trimEnd();
42006
42297
  }
@@ -42224,7 +42515,114 @@ async function aiQueriesReport(input, searchAnalytics) {
42224
42515
  ]
42225
42516
  };
42226
42517
  }
42518
+ // packages/core/dist/commands/research-ahrefs.js
42519
+ async function ahrefs(key, input, missed, totals) {
42520
+ const where = targetParts(input).ahrefs;
42521
+ const get = async (path, params) => {
42522
+ const url = new URL(`https://api.ahrefs.com/v3/${path}`);
42523
+ for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
42524
+ return readJson(await call("ahrefs", new Request(url, { headers: {
42525
+ authorization: `Bearer ${key}`,
42526
+ accept: "application/json"
42527
+ } })), "Ahrefs");
42528
+ };
42529
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
42530
+ if (input.action === "keyword_ideas") return eachKeyword(input.keywords.slice(0, 5), missed, async (seed) => {
42531
+ return ((await get("keywords-explorer/matching-terms", {
42532
+ select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
42533
+ country: input.country,
42534
+ keywords: seed,
42535
+ limit: String(input.limit),
42536
+ order_by: "volume:desc"
42537
+ })).keywords ?? []).map((row) => ({
42538
+ seed,
42539
+ ...flattenIntents(row)
42540
+ }));
42541
+ });
42542
+ if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
42543
+ select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
42544
+ country: input.country,
42545
+ keywords: input.keywords.slice(0, 100).join(",")
42546
+ })).keywords ?? []).map(flattenIntents);
42547
+ if (input.action === "ranked_keywords") {
42548
+ const [data, metrics] = await Promise.all([get("site-explorer/organic-keywords", {
42549
+ select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
42550
+ ...where,
42551
+ country: input.country,
42552
+ date: today,
42553
+ limit: String(input.limit),
42554
+ order_by: "sum_traffic:desc"
42555
+ }), get("site-explorer/metrics", {
42556
+ ...where,
42557
+ country: input.country,
42558
+ date: today
42559
+ })]);
42560
+ const total = metrics.metrics;
42561
+ if (typeof total?.org_traffic === "number") totals.siteTotal = {
42562
+ metric: "org_traffic",
42563
+ traffic: total.org_traffic,
42564
+ keywords: typeof total.org_keywords === "number" ? total.org_keywords : null
42565
+ };
42566
+ return data.keywords ?? [];
42567
+ }
42568
+ if (input.action === "top_pages") return (await get("site-explorer/top-pages", {
42569
+ select: "url,sum_traffic,keywords,top_keyword,top_keyword_volume,top_keyword_best_position,referring_domains",
42570
+ ...where,
42571
+ country: input.country,
42572
+ date: today,
42573
+ limit: String(input.limit),
42574
+ order_by: "sum_traffic:desc"
42575
+ })).pages ?? [];
42576
+ if (input.action === "pages_by_backlinks") return (await get("site-explorer/pages-by-backlinks", {
42577
+ select: "url_to,title_target,http_code_target,refdomains_target,links_to_target,dofollow_to_target,first_seen_link",
42578
+ ...where,
42579
+ history: "live",
42580
+ limit: String(input.limit),
42581
+ order_by: "refdomains_target:desc"
42582
+ })).pages ?? [];
42583
+ if (input.action === "serp") return eachKeyword(input.keywords.slice(0, serpKeywords(input)), missed, async (keyword) => {
42584
+ return ((await get("serp-overview/serp-overview", {
42585
+ select: "position,type,url,title,domain_rating,traffic,keywords",
42586
+ keyword,
42587
+ country: input.country,
42588
+ top_positions: String(serpDepth(input))
42589
+ })).positions ?? []).map((row) => ({
42590
+ keyword,
42591
+ ...row
42592
+ }));
42593
+ });
42594
+ if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
42595
+ select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
42596
+ ...where,
42597
+ history: "live",
42598
+ limit: String(input.limit),
42599
+ order_by: "domain_rating:desc"
42600
+ })).refdomains ?? [];
42601
+ return (await get("site-explorer/organic-competitors", {
42602
+ select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
42603
+ target: input.target,
42604
+ country: input.country,
42605
+ date: today,
42606
+ mode: "subdomains",
42607
+ limit: String(input.limit),
42608
+ order_by: "keywords_common:desc"
42609
+ })).competitors ?? [];
42610
+ }
42611
+ function flattenIntents(row) {
42612
+ const intents = row.intents;
42613
+ if (!intents || typeof intents !== "object") return row;
42614
+ const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
42615
+ return {
42616
+ ...row,
42617
+ intents: names.join(" ") || null
42618
+ };
42619
+ }
42227
42620
  // packages/core/dist/commands/research-providers.js
42621
+ const RESEARCH_SCOPES = [
42622
+ "domain",
42623
+ "prefix",
42624
+ "exact"
42625
+ ];
42228
42626
  function need(headers, provider) {
42229
42627
  const values = provider === "dataforseo" ? [headers.get("x-dataforseo-login"), headers.get("x-dataforseo-password")] : [headers.get("x-ahrefs-key")];
42230
42628
  if (values.some((value) => !value)) throw new CommandError("NO_KEY", `No ${provider} key. Hosted MCP: save it at https://seoskill.dev/account#keys. CLI and local MCP: run \`seo login\` to use the account's keys, or \`seo providers ${provider} connect\`.`);
@@ -42260,8 +42658,17 @@ async function call(provider, request) {
42260
42658
  }
42261
42659
  }
42262
42660
  const wait = (ms) => new Promise((done) => setTimeout(done, ms));
42661
+ const KEYWORD_ACTIONS = [
42662
+ "keyword_metrics",
42663
+ "keyword_ideas",
42664
+ "serp",
42665
+ "serp_overlap"
42666
+ ];
42667
+ const SERP_KEYWORDS = 5;
42668
+ const OVERLAP_KEYWORDS = 10;
42669
+ const OVERLAP_DEPTH = 10;
42263
42670
  function requireInput(input) {
42264
- const needsKeywords = input.action === "keyword_metrics" || input.action === "serp";
42671
+ const needsKeywords = KEYWORD_ACTIONS.includes(input.action);
42265
42672
  if (needsKeywords && input.keywords.length === 0) throw new CommandError("INVALID_INPUT", `${input.action} needs keywords.`);
42266
42673
  if (input.action === "link_gap" && !input.target) throw new CommandError("INVALID_INPUT", "link_gap needs a target domain and competitors.");
42267
42674
  if (!needsKeywords && !input.target) throw new CommandError("INVALID_INPUT", `${input.action} needs a target domain or URL.`);
@@ -42282,16 +42689,19 @@ async function eachKeyword(keywords, missed, rowsFor) {
42282
42689
  async function research(provider, headers, input) {
42283
42690
  requireInput(input);
42284
42691
  if (input.action === "link_gap") return linkGap(provider, headers, input);
42692
+ if (input.action === "serp_overlap") return serpOverlap(provider, headers, input);
42285
42693
  const keys = need(headers, provider);
42286
42694
  if (provider === "dataforseo") return dataForSeo(keys, input);
42287
42695
  const missed = [];
42288
- const rows = await ahrefs(keys[0] ?? "", input, missed);
42696
+ const totals = {};
42697
+ const rows = await ahrefs(keys[0] ?? "", input, missed, totals);
42289
42698
  return {
42290
42699
  rows,
42291
42700
  costUsd: null,
42292
42701
  partial: false,
42293
42702
  missed,
42294
- noData: withoutData(input, rows, missed, 100)
42703
+ noData: withoutData(input, rows, missed, 100),
42704
+ ...totals
42295
42705
  };
42296
42706
  }
42297
42707
  function countryName(code) {
@@ -42391,13 +42801,16 @@ async function dataForSeo(keys, input) {
42391
42801
  costUsd: Math.round(spent.cost * 1e4) / 1e4,
42392
42802
  partial: spent.partial,
42393
42803
  missed: spent.missed,
42394
- noData: withoutData(input, rows, spent.missed, 700)
42804
+ noData: withoutData(input, rows, spent.missed, 700),
42805
+ ...spent.available !== void 0 ? { available: spent.available } : {},
42806
+ ...spent.siteTotal ? { siteTotal: spent.siteTotal } : {},
42807
+ ...spent.rankFailed ? { rankFailed: true } : {}
42395
42808
  };
42396
42809
  }
42397
42810
  function withoutData(input, rows, missed, keywordLimit) {
42398
42811
  if (input.action === "keyword_metrics" || input.action === "serp") {
42399
- const max = input.action === "serp" ? 5 : keywordLimit;
42400
- const answered = new Set(rows.map((row) => String(row.keyword ?? "").toLowerCase()));
42812
+ const max = input.action === "serp" ? serpKeywords(input) : keywordLimit;
42813
+ const answered = new Set(rows.filter((row) => input.action === "serp" || (row.search_volume ?? row.volume ?? null) !== null).map((row) => String(row.keyword ?? "").toLowerCase()));
42401
42814
  const failed = new Set(missed);
42402
42815
  return input.keywords.slice(0, max).filter((keyword) => !failed.has(keyword) && !answered.has(keyword.toLowerCase()));
42403
42816
  }
@@ -42405,8 +42818,14 @@ function withoutData(input, rows, missed, keywordLimit) {
42405
42818
  }
42406
42819
  async function dataForSeoRows(keys, input, spent) {
42407
42820
  const language = dataForSeoLanguage(input.country);
42408
- if (!language && input.action !== "serp" && input.action !== "backlinks") throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
42821
+ if (!language && ![
42822
+ "serp",
42823
+ "backlinks",
42824
+ "pages_by_backlinks"
42825
+ ].includes(input.action)) throw new CommandError("INVALID_INPUT", `DataForSEO has no ${input.action} data for country "${input.country}".`);
42409
42826
  const market = dataForSeoMarket(input.country, language);
42827
+ const where = targetParts(input);
42828
+ let header = null;
42410
42829
  const post = async (path, task, attempts = 1) => {
42411
42830
  const done = await dataForSeoTask(keys, path, {
42412
42831
  ...market,
@@ -42417,6 +42836,9 @@ async function dataForSeoRows(keys, input, spent) {
42417
42836
  });
42418
42837
  spent.cost += done.cost;
42419
42838
  spent.partial ||= done.partial;
42839
+ header = done.result;
42840
+ const total = done.result?.total_count;
42841
+ if (typeof total === "number") spent.available = total;
42420
42842
  if (done.noResults && attempts > 1) return post(path, task, attempts - 1);
42421
42843
  return done.result?.items ?? [];
42422
42844
  };
@@ -42430,47 +42852,133 @@ async function dataForSeoRows(keys, input, spent) {
42430
42852
  competition: "keyword_info.competition",
42431
42853
  main_intent: "search_intent_info.main_intent"
42432
42854
  }));
42433
- if (input.action === "ranked_keywords") return (await post("v3/dataforseo_labs/google/ranked_keywords/live", {
42434
- target: bareTarget(input.target),
42855
+ if (input.action === "keyword_ideas") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (seed) => {
42856
+ return (await post("v3/dataforseo_labs/google/keyword_suggestions/live", {
42857
+ keyword: seed,
42858
+ include_seed_keyword: true,
42859
+ limit: input.limit,
42860
+ order_by: ["keyword_info.search_volume,desc"]
42861
+ })).map((item) => ({
42862
+ seed,
42863
+ ...pick(item, {
42864
+ keyword: "keyword",
42865
+ search_volume: "keyword_info.search_volume",
42866
+ keyword_difficulty: "keyword_properties.keyword_difficulty",
42867
+ cpc: "keyword_info.cpc",
42868
+ main_intent: "search_intent_info.main_intent"
42869
+ })
42870
+ }));
42871
+ });
42872
+ if (input.action === "ranked_keywords") {
42873
+ const items = await post("v3/dataforseo_labs/google/ranked_keywords/live", {
42874
+ target: where.scope === "exact" ? where.page : where.host,
42875
+ limit: input.limit,
42876
+ order_by: ["ranked_serp_element.serp_item.etv,desc"],
42877
+ ...where.scope === "prefix" ? { filters: [
42878
+ "ranked_serp_element.serp_item.relative_url",
42879
+ "like",
42880
+ `${where.path}%`
42881
+ ] } : {}
42882
+ });
42883
+ const organic = get(header, "metrics.organic");
42884
+ const etv = get(organic, "etv");
42885
+ if (typeof etv === "number") {
42886
+ const count = get(organic, "count");
42887
+ spent.siteTotal = {
42888
+ metric: "etv",
42889
+ traffic: Math.round(etv),
42890
+ keywords: typeof count === "number" ? count : null
42891
+ };
42892
+ }
42893
+ return items.map((item) => pick(item, {
42894
+ keyword: "keyword_data.keyword",
42895
+ position: "ranked_serp_element.serp_item.rank_absolute",
42896
+ url: "ranked_serp_element.serp_item.url",
42897
+ search_volume: "keyword_data.keyword_info.search_volume",
42898
+ etv: "ranked_serp_element.serp_item.etv",
42899
+ keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
42900
+ main_intent: "keyword_data.search_intent_info.main_intent"
42901
+ }));
42902
+ }
42903
+ if (input.action === "serp") {
42904
+ const rows = await eachKeyword(input.keywords.slice(0, serpKeywords(input)), spent.missed, async (keyword) => {
42905
+ return (await post("v3/serp/google/organic/live/advanced", {
42906
+ keyword,
42907
+ depth: serpDepth(input)
42908
+ }, 2)).filter((item) => item.type === "organic").map((item) => {
42909
+ const row = pick(item, {
42910
+ position: "rank_absolute",
42911
+ url: "url",
42912
+ domain: "domain",
42913
+ title: "title"
42914
+ });
42915
+ if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
42916
+ const crumb = String(item.breadcrumb ?? "");
42917
+ const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
42918
+ row.url = null;
42919
+ row.domain = site?.[1] ?? null;
42920
+ row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
42921
+ }
42922
+ return {
42923
+ keyword,
42924
+ ...row
42925
+ };
42926
+ });
42927
+ });
42928
+ return input.noRank ? rows : withDomainRank(keys, rows, spent);
42929
+ }
42930
+ if (input.action === "top_pages") return (await post("v3/dataforseo_labs/google/relevant_pages/live", {
42931
+ target: where.host,
42435
42932
  limit: input.limit,
42436
- order_by: ["ranked_serp_element.serp_item.etv,desc"]
42933
+ order_by: ["metrics.organic.etv,desc"],
42934
+ ...where.scope === "domain" ? {} : { filters: where.scope === "prefix" ? [
42935
+ "page_address",
42936
+ "like",
42937
+ likePrefix(where.host, where.path)
42938
+ ] : [
42939
+ "page_address",
42940
+ "=",
42941
+ where.page
42942
+ ] }
42437
42943
  })).map((item) => pick(item, {
42438
- keyword: "keyword_data.keyword",
42439
- position: "ranked_serp_element.serp_item.rank_absolute",
42440
- url: "ranked_serp_element.serp_item.url",
42441
- search_volume: "keyword_data.keyword_info.search_volume",
42442
- etv: "ranked_serp_element.serp_item.etv",
42443
- keyword_difficulty: "keyword_data.keyword_properties.keyword_difficulty",
42444
- main_intent: "keyword_data.search_intent_info.main_intent"
42944
+ url: "page_address",
42945
+ etv: "metrics.organic.etv",
42946
+ keywords: "metrics.organic.count",
42947
+ pos_1: "metrics.organic.pos_1",
42948
+ pos_2_3: "metrics.organic.pos_2_3",
42949
+ pos_4_10: "metrics.organic.pos_4_10"
42950
+ }));
42951
+ if (input.action === "pages_by_backlinks") return (await post("v3/backlinks/domain_pages/live", {
42952
+ target: where.host,
42953
+ limit: input.limit,
42954
+ order_by: ["page_summary.referring_domains,desc"],
42955
+ ...where.scope === "domain" ? {} : { filters: where.scope === "prefix" ? [
42956
+ "page",
42957
+ "like",
42958
+ likePrefix(where.host, where.path)
42959
+ ] : [
42960
+ "page",
42961
+ "=",
42962
+ where.page
42963
+ ] }
42964
+ })).map((item) => pick(item, {
42965
+ url: "page",
42966
+ status: "status_code",
42967
+ title: "meta.title",
42968
+ referring_domains: "page_summary.referring_domains",
42969
+ backlinks: "page_summary.backlinks",
42970
+ rank: "page_summary.rank",
42971
+ first_seen: "page_summary.first_seen"
42445
42972
  }));
42446
- if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), spent.missed, async (keyword) => {
42447
- return (await post("v3/serp/google/organic/live/advanced", {
42448
- keyword,
42449
- depth: Math.min(input.limit, 100)
42450
- }, 2)).filter((item) => item.type === "organic").map((item) => {
42451
- const row = pick(item, {
42452
- position: "rank_absolute",
42453
- url: "url",
42454
- domain: "domain",
42455
- title: "title"
42456
- });
42457
- if (/^https?:\/\/(www\.)?google\.[a-z.]+\/goto\b/.test(String(row.url))) {
42458
- const crumb = String(item.breadcrumb ?? "");
42459
- const site = /^https?:\/\/([^/\s›]+)/.exec(crumb);
42460
- row.url = null;
42461
- row.domain = site?.[1] ?? null;
42462
- row.url_hint = site ? crumb.replace(/^https?:\/\//, "") : null;
42463
- }
42464
- return {
42465
- keyword,
42466
- ...row
42467
- };
42468
- });
42469
- });
42470
42973
  if (input.action === "backlinks") return (await post("v3/backlinks/referring_domains/live", {
42471
- target: bareTarget(input.target),
42974
+ target: where.scope === "exact" ? where.page : where.host,
42472
42975
  limit: input.limit,
42473
- order_by: ["rank,desc"]
42976
+ order_by: ["rank,desc"],
42977
+ ...where.scope === "prefix" ? { backlinks_filters: [
42978
+ "url_to",
42979
+ "like",
42980
+ likePrefix(where.host, where.path)
42981
+ ] } : {}
42474
42982
  })).map((item) => pick(item, {
42475
42983
  domain: "domain",
42476
42984
  rank: "rank",
@@ -42489,6 +42997,87 @@ async function dataForSeoRows(keys, input, spent) {
42489
42997
  organic_etv: "full_domain_metrics.organic.etv"
42490
42998
  }));
42491
42999
  }
43000
+ async function withDomainRank(keys, rows, spent) {
43001
+ const domains = [...new Set(rows.map((row) => String(row.domain ?? "")).filter(Boolean))].slice(0, 1e3);
43002
+ const ranks = /* @__PURE__ */ new Map();
43003
+ if (domains.length) try {
43004
+ const done = await dataForSeoTask(keys, "v3/backlinks/bulk_ranks/live", {
43005
+ targets: domains,
43006
+ rank_scale: "one_hundred"
43007
+ });
43008
+ spent.cost += done.cost;
43009
+ const items = done.result?.items ?? [];
43010
+ for (const item of items) if (item.target) ranks.set(item.target, item.rank ?? null);
43011
+ } catch (error) {
43012
+ spent.cost += error.cost ?? 0;
43013
+ spent.rankFailed = true;
43014
+ }
43015
+ return rows.map((row) => {
43016
+ const next = {};
43017
+ for (const [name, value] of Object.entries(row)) {
43018
+ next[name] = value;
43019
+ if (name === "domain") next.domain_rank = ranks.get(String(value ?? "")) ?? null;
43020
+ }
43021
+ return next;
43022
+ });
43023
+ }
43024
+ const siteOf = (url) => {
43025
+ try {
43026
+ return new URL(String(url)).hostname;
43027
+ } catch {
43028
+ return "";
43029
+ }
43030
+ };
43031
+ const serpKeywords = (input) => input.action === "serp_overlap" ? OVERLAP_KEYWORDS : SERP_KEYWORDS;
43032
+ const serpDepth = (input) => Math.max(1, Math.min(input.depth ?? 20, 100));
43033
+ async function serpOverlap(provider, headers, input) {
43034
+ const serps = await research(provider, headers, {
43035
+ ...input,
43036
+ action: "serp",
43037
+ depth: OVERLAP_DEPTH,
43038
+ limit: OVERLAP_DEPTH,
43039
+ noRank: true
43040
+ });
43041
+ const keywords = [...new Set(input.keywords.slice(0, OVERLAP_KEYWORDS))].filter((keyword) => !serps.missed.includes(keyword) && !serps.noData.includes(keyword));
43042
+ const host = (value) => String(value ?? "").toLowerCase().replace(/^www\./, "");
43043
+ const top = new Map(keywords.map((keyword) => {
43044
+ const rows = serps.rows.filter((row) => row.keyword === keyword).slice(0, OVERLAP_DEPTH);
43045
+ const pages = new Set(rows.map((row) => row.url).filter((url) => typeof url === "string" && url !== "").map((url) => url.replace(/\/$/, "").toLowerCase()));
43046
+ const sites = new Set(rows.map((row) => host(row.domain ?? siteOf(row.url))).filter(Boolean));
43047
+ return [keyword, {
43048
+ results: rows.length,
43049
+ pages,
43050
+ sites
43051
+ }];
43052
+ }));
43053
+ const rows = [];
43054
+ for (const [i, a] of keywords.entries()) for (const b of keywords.slice(i + 1)) {
43055
+ const left = top.get(a);
43056
+ const right = top.get(b);
43057
+ if (!left || !right) continue;
43058
+ rows.push({
43059
+ keyword_a: a,
43060
+ keyword_b: b,
43061
+ shared_pages: [...left.pages].filter((url) => right.pages.has(url)).length,
43062
+ shared_sites: [...left.sites].filter((site) => right.sites.has(site)).length,
43063
+ pages_compared_a: left.pages.size,
43064
+ pages_compared_b: right.pages.size,
43065
+ results_a: left.results,
43066
+ results_b: right.results
43067
+ });
43068
+ }
43069
+ rows.sort((x, y) => Number(y.shared_pages) - Number(x.shared_pages) || Number(y.shared_sites) - Number(x.shared_sites) || String(x.keyword_a).localeCompare(String(y.keyword_a)) || String(x.keyword_b).localeCompare(String(y.keyword_b)));
43070
+ const results = [...top.values()];
43071
+ return {
43072
+ ...serps,
43073
+ rows,
43074
+ compared: {
43075
+ results: results.reduce((sum, item) => sum + item.results, 0),
43076
+ withPage: results.reduce((sum, item) => sum + item.pages.size, 0),
43077
+ withSite: results.reduce((sum, item) => sum + item.sites.size, 0)
43078
+ }
43079
+ };
43080
+ }
42492
43081
  const GAP_DOMAINS = 1e3;
42493
43082
  const GAP_COMPETITORS = 5;
42494
43083
  const SHORTENERS = [
@@ -42530,6 +43119,7 @@ async function linkGap(provider, headers, input) {
42530
43119
  ...input,
42531
43120
  action: "backlinks",
42532
43121
  target: site,
43122
+ scope: "domain",
42533
43123
  limit: GAP_DOMAINS
42534
43124
  });
42535
43125
  cost += got.costUsd ?? 0;
@@ -42576,77 +43166,49 @@ async function linkGap(provider, headers, input) {
42576
43166
  ...linkLists.length ? { leftOut: linkLists } : {}
42577
43167
  };
42578
43168
  }
42579
- function isPage(target) {
42580
- return /\/./.test(bareTarget(target));
42581
- }
42582
43169
  function bareTarget(target) {
42583
43170
  return target.replace(/^https?:\/\//, "").replace(/\/$/, "");
42584
43171
  }
42585
- async function ahrefs(key, input, missed) {
42586
- const get = async (path, params) => {
42587
- const url = new URL(`https://api.ahrefs.com/v3/${path}`);
42588
- for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
42589
- return readJson(await call("ahrefs", new Request(url, { headers: {
42590
- authorization: `Bearer ${key}`,
42591
- accept: "application/json"
42592
- } })), "Ahrefs");
42593
- };
42594
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
42595
- if (input.action === "keyword_metrics") return ((await get("keywords-explorer/overview", {
42596
- select: "keyword,volume,difficulty,cpc,traffic_potential,intents",
42597
- country: input.country,
42598
- keywords: input.keywords.slice(0, 100).join(",")
42599
- })).keywords ?? []).map(flattenIntents);
42600
- if (input.action === "ranked_keywords") return (await get("site-explorer/organic-keywords", {
42601
- select: "keyword,best_position,best_position_url,volume,sum_traffic,keyword_difficulty,cpc",
42602
- target: input.target,
42603
- country: input.country,
42604
- date: today,
42605
- mode: isPage(input.target) ? "exact" : "subdomains",
42606
- limit: String(input.limit),
42607
- order_by: "sum_traffic:desc"
42608
- })).keywords ?? [];
42609
- if (input.action === "serp") return eachKeyword(input.keywords.slice(0, 5), missed, async (keyword) => {
42610
- return ((await get("serp-overview/serp-overview", {
42611
- select: "position,type,url,title,domain_rating,traffic,keywords",
42612
- keyword,
42613
- country: input.country,
42614
- top_positions: String(Math.min(input.limit, 100))
42615
- })).positions ?? []).map((row) => ({
42616
- keyword,
42617
- ...row
42618
- }));
42619
- });
42620
- if (input.action === "backlinks") return (await get("site-explorer/refdomains", {
42621
- select: "domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen",
42622
- target: input.target,
42623
- mode: isPage(input.target) ? "exact" : "subdomains",
42624
- history: "live",
42625
- limit: String(input.limit),
42626
- order_by: "domain_rating:desc"
42627
- })).refdomains ?? [];
42628
- return (await get("site-explorer/organic-competitors", {
42629
- select: "competitor_domain,keywords_common,keywords_competitor,keywords_target,share,traffic,domain_rating",
42630
- target: input.target,
42631
- country: input.country,
42632
- date: today,
42633
- mode: "subdomains",
42634
- limit: String(input.limit),
42635
- order_by: "keywords_common:desc"
42636
- })).competitors ?? [];
42637
- }
42638
- function flattenIntents(row) {
42639
- const intents = row.intents;
42640
- if (!intents || typeof intents !== "object") return row;
42641
- const names = Object.entries(intents).filter(([, value]) => value === true).map(([name]) => name.replace(/^is_/, ""));
42642
- return {
42643
- ...row,
42644
- intents: names.join(" ") || null
43172
+ function researchScope(target, scope) {
43173
+ if (scope) return scope;
43174
+ const bare = target.trim().replace(/^https?:\/\//, "");
43175
+ if (!/\/./.test(bare)) return "domain";
43176
+ return bare.endsWith("/") ? "prefix" : "exact";
43177
+ }
43178
+ function targetParts(input) {
43179
+ const scope = researchScope(input.target, input.scope);
43180
+ const bare = input.target.trim().replace(/^https?:\/\//, "");
43181
+ const slash = bare.indexOf("/");
43182
+ const host = (slash === -1 ? bare : bare.slice(0, slash)).toLowerCase();
43183
+ let path = slash === -1 ? "/" : bare.slice(slash);
43184
+ if (scope === "prefix" && !path.endsWith("/")) path += "/";
43185
+ const scheme = /^http:\/\//.test(input.target.trim()) ? "http" : "https";
43186
+ return {
43187
+ scope,
43188
+ host,
43189
+ path,
43190
+ page: `${scheme}://${host}${path}`,
43191
+ ahrefs: scope === "domain" ? {
43192
+ target: host,
43193
+ mode: "subdomains"
43194
+ } : scope === "prefix" ? {
43195
+ target: `${host}${path}`,
43196
+ mode: "prefix"
43197
+ } : {
43198
+ target: `${host}${path}`,
43199
+ mode: "exact"
43200
+ }
42645
43201
  };
42646
43202
  }
43203
+ const likePrefix = (host, path) => `%${host}${path}%`;
42647
43204
  function rowsText(input) {
42648
43205
  const total = input.rows.length;
42649
- if (input.detail === "full") return `${input.title}\n\n${JSON.stringify(input.rows.slice(input.offset, input.offset + input.limit))}`;
43206
+ if (input.detail === "full") {
43207
+ const page = input.rows.slice(input.offset, input.offset + input.limit);
43208
+ const end = input.offset + page.length;
43209
+ const cut = end < total ? `\n\nRows ${input.offset + 1} to ${end} of ${total}. More: ${input.more ? input.more(end).replace("detail \"rows\"", "detail \"full\"").replace("--detail rows", "--detail full") : `detail "full", offset ${end}`}.` : "";
43210
+ return `${input.title}\n\n${JSON.stringify(page)}${cut}`;
43211
+ }
42650
43212
  const size = input.detail === "summary" ? Math.min(input.limit, 25) : input.limit;
42651
43213
  const page = input.rows.slice(input.offset, input.offset + size);
42652
43214
  const columns = [...new Set(page.flatMap((row) => Object.keys(row)))];
@@ -42658,6 +43220,14 @@ function rowsText(input) {
42658
43220
  end < total ? `More: ${input.more ? input.more(end) : `detail "rows", offset ${end}`}.` : ""
42659
43221
  ].filter(Boolean).join("\n\n");
42660
43222
  }
43223
+ const SPAM_TLDS = /\.(xyz|top|click|buzz|icu|cfd|sbs|monster|rest|lol)$/;
43224
+ function spamHint(query) {
43225
+ const text = query.trim().toLowerCase();
43226
+ if (/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(text) && SPAM_TLDS.test(text)) return "host name on a TLD common in referral spam";
43227
+ if (/^\d+: /.test(text) || /\btest keyword\b/.test(text)) return "rank tracker test query";
43228
+ if ((text.match(/[=,]/g) ?? []).length >= 6 && /"/.test(text)) return "pasted spreadsheet or tool export";
43229
+ return null;
43230
+ }
42661
43231
  const DIMENSIONS = [
42662
43232
  "query",
42663
43233
  "page",
@@ -42728,6 +43298,14 @@ async function runRows(input, deps) {
42728
43298
  }
42729
43299
  rows.sort((a, b) => dateAt >= 0 ? String(a.date).localeCompare(String(b.date)) : Number(b.clicks ?? 0) - Number(a.clicks ?? 0));
42730
43300
  const notes = [];
43301
+ if (dimensions.indexOf("query") >= 0) {
43302
+ const flagged = rows.filter((row) => spamHint(String(row.query ?? "")));
43303
+ if (flagged.length) {
43304
+ for (const row of rows) row.spam_hint = spamHint(String(row.query ?? ""));
43305
+ const impressions = flagged.reduce((sum, row) => sum + Number(row.impressions ?? 0), 0);
43306
+ notes.push(`${flagged.length} of ${rows.length} queries look like spam or tool noise (spam_hint, a heuristic over the query text: host names on spam-heavy TLDs, rank tracker test queries, pasted exports), with ${impressions} impressions. They stay in the rows and the totals; Google may or may not count them as real searches.`);
43307
+ }
43308
+ }
42731
43309
  if (!dimensions.includes("query") && !dimensions.includes("page")) {
42732
43310
  const sum = (key) => rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
42733
43311
  notes.push(`Totals: clicks ${sum("clicks")}, impressions ${sum("impressions")}; previous period clicks ${sum("previous_clicks")}.`);
@@ -42843,24 +43421,29 @@ const hostOf$2 = (url) => {
42843
43421
  return null;
42844
43422
  }
42845
43423
  };
43424
+ const PLACEHOLDERS = [
43425
+ "example.com",
43426
+ "example.org",
43427
+ "example.net"
43428
+ ];
43429
+ const PLACEHOLDER = /^your-?(domain|site|url|website|company|blog)(\.[a-z]+)?$/;
43430
+ const isPlaceholder = (host) => PLACEHOLDER.test(host) || PLACEHOLDERS.some((name) => host === name || host.endsWith(`.${name}`));
42846
43431
  const NOT_SOURCES = [
42847
43432
  "dataforseo.com",
42848
43433
  "images.openai.com",
42849
43434
  "gstatic.com",
42850
43435
  "googleusercontent.com",
42851
- "ggpht.com",
42852
- "example.com",
42853
- "example.org",
42854
- "example.net"
43436
+ "ggpht.com"
42855
43437
  ];
42856
43438
  const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i;
42857
43439
  const isSource = (url) => {
42858
43440
  const host = hostOf$2(url);
42859
- if (!host || IMAGE.test(url)) return false;
43441
+ if (!host || IMAGE.test(url) || isPlaceholder(host)) return false;
42860
43442
  return !NOT_SOURCES.some((name) => host === name || host.endsWith(`.${name}`));
42861
43443
  };
42862
43444
  const empty = {
42863
43445
  cited_url: null,
43446
+ mentioned_as: null,
42864
43447
  sources: [],
42865
43448
  model: null,
42866
43449
  answered_at: null
@@ -42882,12 +43465,14 @@ function judge(engine, task, site) {
42882
43465
  ...empty,
42883
43466
  answered_at
42884
43467
  };
42885
- const linked = shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? [];
42886
- const all = [...shown.sources, ...linked];
42887
- const cited = all.find((url) => onSite(url, site.domain)) ?? null;
43468
+ const linked = (shown.markdown.match(/https?:\/\/[^\s)\]]+/g) ?? []).map((url) => url.replace(/[`'"‘’“”()[\]{}<>.,;:!?*]+$/, ""));
43469
+ const all = [...shown.sources, ...linked].filter(isSource);
43470
+ const onTarget = (url) => site.domains.some((domain) => onSite(url, domain));
43471
+ const cited = all.find(onTarget) ?? null;
42888
43472
  const base = {
42889
43473
  cited_url: cited,
42890
- sources: [...new Set(all.filter((url) => !onSite(url, site.domain) && isSource(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
43474
+ mentioned_as: null,
43475
+ sources: [...new Set(all.filter((url) => !onTarget(url)).map(hostOf$2).filter((host) => Boolean(host)))].slice(0, MAX_SOURCES),
42891
43476
  model: shown.model,
42892
43477
  answered_at
42893
43478
  };
@@ -42896,9 +43481,12 @@ function judge(engine, task, site) {
42896
43481
  ...base
42897
43482
  };
42898
43483
  const text = prose(shown.markdown);
43484
+ const lower = text.toLowerCase();
43485
+ const mentioned_as = site.names.find((name) => lower.includes(name)) ?? site.terms.find((term) => isBrandQuery(text, [term])) ?? null;
42899
43486
  return {
42900
- result: text.toLowerCase().includes(site.domain) || isBrandQuery(text, site.terms) ? "mentioned" : "neither",
42901
- ...base
43487
+ result: mentioned_as ? "mentioned" : "neither",
43488
+ ...base,
43489
+ mentioned_as
42902
43490
  };
42903
43491
  }
42904
43492
  async function marketsFor(keys, country) {
@@ -42926,9 +43514,22 @@ async function runAiAnswers(input, headers, deps, more) {
42926
43514
  const keys = dataForSeoKeys(headers);
42927
43515
  const domain = registrableDomain(input.site)?.domain;
42928
43516
  if (!domain) throw new CommandError("INVALID_INPUT", `ai_answers needs a site with a domain, not "${input.site}".`);
43517
+ const domains = [domain];
43518
+ for (const value of input.also ?? []) {
43519
+ const other = registrableDomain(value)?.domain;
43520
+ if (!other) throw new CommandError("INVALID_INPUT", `ai_answers takes domains in also, not "${value}".`);
43521
+ if (!domains.includes(other)) domains.push(other);
43522
+ }
43523
+ if (domains.length > 5) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 4 other domains in also.`);
43524
+ const names = domains.filter((name) => !isPlaceholder(name));
43525
+ const brand = [...new Set((input.brand ?? []).map((b) => b.trim()).filter(Boolean))];
42929
43526
  const site = {
42930
- domain,
42931
- terms: deriveBrandTerms({ siteUrl: input.site })
43527
+ domains,
43528
+ names,
43529
+ terms: [...new Set(brand.length ? brand.flatMap((name) => deriveBrandTerms({
43530
+ name,
43531
+ siteUrl: ""
43532
+ })) : names.flatMap((name) => deriveBrandTerms({ siteUrl: name })))]
42932
43533
  };
42933
43534
  const given = [...new Set((input.questions ?? []).map((q) => q.trim()).filter(Boolean))];
42934
43535
  if (given.length > 10) throw new CommandError("INVALID_INPUT", `ai_answers takes up to 10 questions, not ${given.length}.`);
@@ -43056,8 +43657,9 @@ async function runAiAnswers(input, headers, deps, more) {
43056
43657
  `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}.`,
43057
43658
  ...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).`] : [],
43058
43659
  ...topCited.length ? [`Other sites these answers cite: ${topCited.map(([host, n]) => `${host} (${n})`).join(", ")}.`] : [],
43660
+ ...domains.filter((name) => !names.includes(name)).map((name) => `${name} is a placeholder domain that answers write into examples, so no answer counts as citing or mentioning it.`),
43059
43661
  "Each row is one sample of an answer that changes from run to run, not a ranking.",
43060
- `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.`,
43662
+ `cited: the answer lists or links a page on ${domains.join(", ")} or a subdomain. mentioned: no such source, but the answer text names ${[...names, ...site.terms].join(" or ") || "nothing to match"}, a text match (brand names ${brand.length ? "as given" : "from the domain names; pass brand names to match others"}); mentioned_as is the one found. 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.`,
43061
43663
  `Engines: ${ENGINES.map((engine) => `${engine.name} is ${engine.means}`).join("; ")}.`
43062
43664
  ] : []];
43063
43665
  const shown = rows.map((row) => ({
@@ -43066,6 +43668,7 @@ async function runAiAnswers(input, headers, deps, more) {
43066
43668
  engine: row.engine,
43067
43669
  result: row.result,
43068
43670
  cited_url: row.cited_url,
43671
+ mentioned_as: row.mentioned_as,
43069
43672
  sources: row.sources.join(", "),
43070
43673
  model: row.model,
43071
43674
  answered_at: row.answered_at,
@@ -43093,6 +43696,9 @@ async function runAiAnswers(input, headers, deps, more) {
43093
43696
  ...range
43094
43697
  },
43095
43698
  questions: [...new Set(rows.map((row) => row.question))],
43699
+ domains,
43700
+ brandTerms: site.terms,
43701
+ brandTermsFrom: brand.length ? "given" : "domains",
43096
43702
  costUsd: cost,
43097
43703
  pendingTasks: left,
43098
43704
  tasks: refs,
@@ -44543,7 +45149,7 @@ function labelOf(e) {
44543
45149
  };
44544
45150
  if (!last) return {
44545
45151
  label: "fix",
44546
- why: `The redirects loop or pass 5 hops (${hops.map((hop) => hop.status).join(" > ")}).`
45152
+ why: `The redirects loop or pass 10 hops (${hops.map((hop) => hop.status).join(" > ")}).`
44547
45153
  };
44548
45154
  if (last.error) return {
44549
45155
  label: "check",
@@ -44613,7 +45219,7 @@ function labelOf(e) {
44613
45219
  why: `${coverage.startsWith("Crawled") ? "Google crawled it but has not indexed it, and gives no further reason" : coverage.startsWith("Discovered") ? "Google knows the URL but has not crawled it yet" : "Google has no record of this URL"}.${e.inSitemap ? " The sitemap lists it." : ""} It returns ${end} now.`
44614
45220
  };
44615
45221
  const robots = last.robots ? ` with robots "${last.robots}"` : " with no robots meta tag or X-Robots-Tag header";
44616
- if (/noindex/i.test(coverage) && !/noindex/i.test(last.robots ?? "")) return {
45222
+ if (/noindex/i.test(coverage) && last.google_noindex === false) return {
44617
45223
  label: "check",
44618
45224
  why: `Google saw noindex when it last crawled${status.last_crawl ? ` (${status.last_crawl.slice(0, 10)})` : ""}, but it ${now}${robots}, so Google's reason may be out of date.${e.inSitemap ? " The sitemap lists it." : ""}`
44619
45225
  };
@@ -44639,7 +45245,7 @@ function labelOf(e) {
44639
45245
  };
44640
45246
  return {
44641
45247
  label: "check",
44642
- why: /noindex/i.test(last.robots ?? "") ? `${via}${robots} and the sitemap does not list it. Whether it should be indexed is the site's call.` : `${via}${robots}. No rule here settles this reason.`
45248
+ why: last.google_noindex ? `${via}${robots} and the sitemap does not list it. Whether it should be indexed is the site's call.` : `${via}${robots}. No rule here settles this reason.`
44643
45249
  };
44644
45250
  }
44645
45251
  function indexingRow(e, sources, inspectedAt) {
@@ -45507,38 +46113,79 @@ async function markIssues(input, deps) {
45507
46113
  };
45508
46114
  }
45509
46115
  // packages/core/dist/commands/lint.js
45510
- async function runLint(input, readFiles) {
46116
+ async function pageSource(url, readPage) {
46117
+ const page = await readPage(url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) }));
46118
+ if ("error" in page) return {
46119
+ path: url,
46120
+ skipped: `The page could not be read (${page.error}).`
46121
+ };
46122
+ if (!page.markdown) return {
46123
+ path: url,
46124
+ skipped: "The page has no main content to read."
46125
+ };
46126
+ return {
46127
+ path: url,
46128
+ text: page.markdown,
46129
+ note: "Checked the main content extracted from the live page (the Markdown `seo page --detail full` shows), not the whole HTML.",
46130
+ ...page.truncated ? { partial: `the main content has ${page.characters.toLocaleString("en-US")} characters; the first ${page.markdown.length.toLocaleString("en-US")} were checked.` } : {}
46131
+ };
46132
+ }
46133
+ async function runLint(input, readFiles, readPage) {
45511
46134
  const hasText = typeof input.text === "string" && input.text.length > 0;
45512
46135
  const hasPaths = Boolean(input.paths?.length);
45513
- if (hasText === hasPaths) throw new CommandError("INVALID_INPUT", readFiles ? "Send text, or paths to files on this machine, but not both." : "Send the copy to check as text.");
46136
+ const hasUrl = typeof input.url === "string" && input.url.length > 0;
46137
+ if ([
46138
+ hasText,
46139
+ hasPaths,
46140
+ hasUrl
46141
+ ].filter(Boolean).length !== 1) throw new CommandError("INVALID_INPUT", readFiles ? "Send one of text, paths to files on this machine, or a url." : "Send the copy to check as text, or a url.");
45514
46142
  if (hasPaths && !readFiles) throw new CommandError("INVALID_INPUT", "This server cannot read files. Send the copy as text, or run `seo lint <files>` in the CLI.");
46143
+ if (hasUrl) {
46144
+ if (!/^https?:\/\/[^\s/]+/i.test(input.url)) throw new CommandError("INVALID_INPUT", "url is a full http or https address, such as https://example.com/page.");
46145
+ if (!readPage) throw new CommandError("INVALID_INPUT", `This server cannot read live pages. Run \`seo lint --url ${input.url}\` in the CLI or the local MCP server, or send the page copy as text.`);
46146
+ if (input.format && input.format !== "md") throw new CommandError("INVALID_INPUT", "A url is checked as the Markdown of its main content, so format is md or left out.");
46147
+ }
45515
46148
  if (hasText && input.text.length > 2e5) throw new CommandError("INVALID_INPUT", `text is ${input.text.length.toLocaleString("en-US")} characters; the most one check reads is ${LINT_MAX_CHARS.toLocaleString("en-US")}. Send one page at a time.`);
46149
+ const only = input.only?.length ? LINT_CATEGORIES.filter((category) => input.only?.includes(category)) : void 0;
45516
46150
  const files = (hasText ? [{
45517
46151
  path: "text",
45518
46152
  text: input.text
45519
- }] : await readFiles(input.paths ?? [])).map((source) => {
46153
+ }] : hasUrl ? [await pageSource(input.url, readPage)] : await readFiles(input.paths ?? [])).map((source) => {
45520
46154
  if ("skipped" in source) return source;
45521
- const format = input.format ?? (hasText ? "md" : formatFromPath(source.path));
46155
+ const format = input.format ?? (hasPaths ? formatFromPath(source.path) : hasText && input.preset === "meta" ? "text" : "md");
45522
46156
  return {
45523
46157
  path: source.path,
46158
+ ...source.note ? { note: source.note } : {},
46159
+ ...source.partial ? { partial: source.partial } : {},
45524
46160
  result: lintText(source.text, {
45525
46161
  format,
45526
46162
  preset: input.preset ?? defaultPreset(format),
45527
46163
  ignoreRules: input.ignoreRules,
45528
- ignorePhrases: input.ignorePhrases
46164
+ ignorePhrases: input.ignorePhrases,
46165
+ only,
46166
+ metaKind: input.metaKind
45529
46167
  })
45530
46168
  };
45531
46169
  });
46170
+ const checked = files.flatMap((file) => "result" in file ? [file] : []);
46171
+ const total = (count) => checked.reduce((sum, file) => sum + count(file), 0);
45532
46172
  return {
45533
46173
  text: lintReportText(files),
45534
46174
  data: {
45535
46175
  files: files.map((file) => "skipped" in file ? file : {
45536
46176
  path: file.path,
46177
+ ...file.note ? { note: file.note } : {},
46178
+ ...file.partial ? { partial: file.partial } : {},
45537
46179
  ...file.result
45538
46180
  }),
45539
- errors: files.reduce((sum, file) => sum + ("result" in file ? file.result.errors : 0), 0),
45540
- warnings: files.reduce((sum, file) => sum + ("result" in file ? file.result.warnings : 0), 0),
45541
- review: files.reduce((sum, file) => sum + ("result" in file ? file.result.review.length : 0), 0)
46181
+ errors: total((file) => file.result.errors),
46182
+ warnings: total((file) => file.result.warnings),
46183
+ review: total((file) => file.result.review.length),
46184
+ checked: checked.length,
46185
+ skipped: files.length - checked.length,
46186
+ partial: checked.filter((file) => file.partial).length,
46187
+ categories: Object.fromEntries(LINT_CATEGORIES.map((category) => [category, total((file) => file.result.categories[category])])),
46188
+ ...only ? { only } : {}
45542
46189
  }
45543
46190
  };
45544
46191
  }
@@ -45546,7 +46193,8 @@ async function runLint(input, readFiles) {
45546
46193
  const MAX_PAGES = 20;
45547
46194
  const TIMEOUT_MS$2 = 6e3;
45548
46195
  const MAX_BYTES$3 = 1e6;
45549
- const clean = (value) => value?.trim().replace(/\s+/g, " ") || null;
46196
+ const clean = (value) => value === null ? null : value.trim().replace(/\s+/g, " ");
46197
+ const BOT_META = /^([a-z][a-z0-9_-]*bot(-[a-z]+)?|slurp|yandex|baiduspider)$/;
45550
46198
  async function boundedText(response, maxBytes = MAX_BYTES$3) {
45551
46199
  if (!response.body) return "";
45552
46200
  const reader = response.body.getReader();
@@ -45563,8 +46211,8 @@ async function boundedText(response, maxBytes = MAX_BYTES$3) {
45563
46211
  return text;
45564
46212
  }
45565
46213
  function headFacts(html, url) {
45566
- let title = "";
45567
- let h1 = "";
46214
+ let title = null;
46215
+ let h1 = null;
45568
46216
  let inTitle = false;
45569
46217
  let inH1 = false;
45570
46218
  let titleDone = false;
@@ -45572,14 +46220,25 @@ function headFacts(html, url) {
45572
46220
  let description = null;
45573
46221
  let canonical = null;
45574
46222
  const robots = [];
46223
+ const google = [];
45575
46224
  const parser = new Parser({
45576
46225
  onopentag(name, attributes) {
45577
- if (name === "title" && !titleDone) inTitle = true;
45578
- if (name === "h1" && !h1Done) inH1 = true;
46226
+ if (name === "title" && !titleDone) {
46227
+ inTitle = true;
46228
+ title ??= "";
46229
+ }
46230
+ if (name === "h1" && !h1Done) {
46231
+ inH1 = true;
46232
+ h1 ??= "";
46233
+ }
45579
46234
  if (name === "meta") {
45580
- const metaName = attributes.name?.toLowerCase();
45581
- if (metaName === "description" && description === null) description = attributes.content ?? null;
45582
- if (metaName === "robots" && attributes.content) robots.push(attributes.content);
46235
+ const metaName = attributes.name?.trim().toLowerCase() ?? "";
46236
+ const content = attributes.content?.trim();
46237
+ if (metaName === "description" && description === null) description = attributes.content ?? "";
46238
+ if (content && (metaName === "robots" || BOT_META.test(metaName))) {
46239
+ robots.push(metaName === "robots" ? content : `${metaName}: ${content}`);
46240
+ if (metaName === "robots" || metaName === "googlebot") google.push(content);
46241
+ }
45583
46242
  }
45584
46243
  if (name === "link" && canonical === null && attributes.rel?.toLowerCase().split(/\s+/).includes("canonical") && attributes.href) try {
45585
46244
  canonical = new URL(attributes.href, url).toString();
@@ -45588,8 +46247,8 @@ function headFacts(html, url) {
45588
46247
  }
45589
46248
  },
45590
46249
  ontext(text) {
45591
- if (inTitle) title += text;
45592
- if (inH1) h1 += text;
46250
+ if (inTitle) title = (title ?? "") + text;
46251
+ if (inH1) h1 = (h1 ?? "") + text;
45593
46252
  },
45594
46253
  onclosetag(name) {
45595
46254
  if (name === "title" && inTitle) {
@@ -45612,10 +46271,11 @@ function headFacts(html, url) {
45612
46271
  description: clean(description),
45613
46272
  h1: clean(h1),
45614
46273
  canonical,
45615
- robots: robots.length ? robots.join(", ") : null
46274
+ robots: robots.length ? robots.join(", ") : null,
46275
+ google_noindex: hasMetaRobotsDirective(google, "noindex")
45616
46276
  };
45617
46277
  }
45618
- async function facts(url) {
46278
+ async function facts(url, given) {
45619
46279
  const out = {
45620
46280
  url,
45621
46281
  status: null,
@@ -45624,10 +46284,11 @@ async function facts(url) {
45624
46284
  description: null,
45625
46285
  h1: null,
45626
46286
  canonical: null,
45627
- robots: null
46287
+ robots: null,
46288
+ google_noindex: null
45628
46289
  };
45629
46290
  try {
45630
- const response = await fetch(url, {
46291
+ const response = given ?? await fetch(url, {
45631
46292
  redirect: "manual",
45632
46293
  headers: {
45633
46294
  "user-agent": SEO_CRAWLER_USER_AGENT,
@@ -45639,53 +46300,71 @@ async function facts(url) {
45639
46300
  const location = response.headers.get("location");
45640
46301
  if (location) out.redirects_to = new URL(location, url).toString();
45641
46302
  const headerRobots = response.headers.get("x-robots-tag");
46303
+ const headerNoindex = hasXRobotsDirective(headerRobots ?? void 0, "noindex");
45642
46304
  const auth = response.headers.get("www-authenticate");
45643
46305
  if (auth) out.www_authenticate = auth.slice(0, 200);
45644
46306
  if (response.status !== 200 || !response.headers.get("content-type")?.includes("html")) {
45645
46307
  out.robots = headerRobots;
46308
+ out.google_noindex = headerNoindex;
45646
46309
  await response.body?.cancel();
45647
46310
  return out;
45648
46311
  }
45649
46312
  const head = headFacts(await boundedText(response), url);
45650
46313
  Object.assign(out, head);
45651
46314
  out.robots = [headerRobots, head.robots].filter(Boolean).join(", ") || null;
46315
+ out.google_noindex = headerNoindex || head.google_noindex;
45652
46316
  } catch (error) {
45653
46317
  out.error = error instanceof Error ? error.message : "fetch failed";
45654
46318
  }
45655
46319
  return out;
45656
46320
  }
45657
46321
  function checkPages(urls) {
45658
- return Promise.all(urls.slice(0, MAX_PAGES).map(facts));
46322
+ return Promise.all(urls.slice(0, MAX_PAGES).map((url) => facts(url)));
45659
46323
  }
45660
- const MAX_HOPS = 5;
45661
46324
  const redirectKind = (status) => status === 301 || status === 308 ? "permanent" : status === 302 || status === 303 || status === 307 ? "temporary" : null;
45662
46325
  async function liveCheck(url) {
45663
- const first = await facts(url);
45664
- const hops = [];
45665
- const seen = /* @__PURE__ */ new Set([url]);
45666
- let last = first;
45667
- while (last.redirects_to && last.status !== null) {
45668
- const kind = redirectKind(last.status);
45669
- if (!kind) break;
45670
- const to = last.redirects_to;
45671
- hops.push({
45672
- from: last.url,
45673
- status: last.status,
45674
- to,
46326
+ const pages = [];
46327
+ const trace = await traceRedirects(url, {
46328
+ timeoutMs: TIMEOUT_MS$2,
46329
+ onResponse: async (response, at) => {
46330
+ pages.push(await facts(at, response));
46331
+ }
46332
+ });
46333
+ const hops = trace.steps.flatMap((step) => {
46334
+ const kind = redirectKind(step.status);
46335
+ return step.location && kind ? [{
46336
+ from: step.url,
46337
+ status: step.status,
46338
+ to: step.location,
45675
46339
  kind
45676
- });
45677
- if (seen.has(to) || hops.length > MAX_HOPS) return {
45678
- first,
45679
- hops,
45680
- last: null
45681
- };
45682
- seen.add(to);
45683
- last = await facts(to);
45684
- }
46340
+ }] : [];
46341
+ });
46342
+ if (trace.outcome === "loop" || trace.outcome === "too_many") return {
46343
+ first: pages[0],
46344
+ hops,
46345
+ last: null
46346
+ };
46347
+ let last = pages.at(-1);
46348
+ if (trace.error) last = trace.final_status !== null && last ? {
46349
+ ...last,
46350
+ error: trace.error
46351
+ } : {
46352
+ url: trace.final_url,
46353
+ status: null,
46354
+ redirects_to: null,
46355
+ title: null,
46356
+ description: null,
46357
+ h1: null,
46358
+ canonical: null,
46359
+ robots: null,
46360
+ google_noindex: null,
46361
+ error: trace.error
46362
+ };
46363
+ const lastPage = last;
45685
46364
  return {
45686
- first,
46365
+ first: pages.length > 1 || !trace.error ? pages[0] : lastPage,
45687
46366
  hops,
45688
- last
46367
+ last: lastPage
45689
46368
  };
45690
46369
  }
45691
46370
  function temporaryRedirectNote(pages) {
@@ -46271,7 +46950,15 @@ function reportUrls(rows, origin) {
46271
46950
  return found;
46272
46951
  }
46273
46952
  function pageFactsTable(checked, origin) {
46274
- const rows = table$1(checked, [
46953
+ const rows = table$1(checked.map((page) => {
46954
+ const row = { ...page };
46955
+ for (const key of [
46956
+ "title",
46957
+ "description",
46958
+ "h1"
46959
+ ]) if (page[key] === "") row[key] = "(empty)";
46960
+ return row;
46961
+ }), [
46275
46962
  "url",
46276
46963
  "status",
46277
46964
  "redirects_to",
@@ -46499,6 +47186,38 @@ async function runBingReport(input, deps) {
46499
47186
  data: report
46500
47187
  };
46501
47188
  }
47189
+ // packages/core/dist/commands/research-cache.js
47190
+ const RESEARCH_TTL_SECONDS = 86400;
47191
+ function workersResearchStore() {
47192
+ const cache = globalThis.caches?.default;
47193
+ if (!cache) return null;
47194
+ const request = (key) => new Request(`https://research-cache.invalid/${key}`);
47195
+ return {
47196
+ get: async (key) => (await cache.match(request(key)))?.text() ?? null,
47197
+ put: (key, value, ttl) => cache.put(request(key), new Response(value, { headers: { "cache-control": `max-age=${ttl}` } }))
47198
+ };
47199
+ }
47200
+ async function researchKey(parts) {
47201
+ const bytes = new TextEncoder().encode(JSON.stringify(parts));
47202
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
47203
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
47204
+ }
47205
+ async function readResearch(store, key, limit) {
47206
+ if (!store) return null;
47207
+ try {
47208
+ const text = await store.get(key);
47209
+ if (!text) return null;
47210
+ const saved = JSON.parse(text);
47211
+ const complete = saved.result.rows.length < saved.limit;
47212
+ return saved.limit >= limit || complete ? saved : null;
47213
+ } catch {
47214
+ return null;
47215
+ }
47216
+ }
47217
+ async function saveResearch(store, key, saved) {
47218
+ if (!store) return;
47219
+ await store.put(key, JSON.stringify(saved), RESEARCH_TTL_SECONDS).catch(() => void 0);
47220
+ }
46502
47221
  // packages/core/dist/commands/research.js
46503
47222
  const RESEARCH_ACTIONS = [
46504
47223
  "keyword_metrics",
@@ -46507,10 +47226,14 @@ const RESEARCH_ACTIONS = [
46507
47226
  "competitors",
46508
47227
  "backlinks",
46509
47228
  "link_gap",
46510
- "ai_answers"
47229
+ "ai_answers",
47230
+ "top_pages",
47231
+ "pages_by_backlinks",
47232
+ "keyword_ideas",
47233
+ "serp_overlap"
46511
47234
  ];
46512
47235
  const RESEARCH_PROVIDERS = ["dataforseo", "ahrefs"];
46513
- 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.`;
47236
+ const RESEARCH_HELP = `provider is optional: without it the call uses the saved key, DataForSEO when both are saved. The target is a domain (the whole site), a path ending in / such as example.com/blog/ (every URL under it), or one page; scope overrides that. keyword_metrics (keywords: volume, difficulty, CPC), keyword_ideas (up to 5 seed keywords: searches that contain each seed, by volume), ranked_keywords (target: keywords a site, subfolder or page ranks for, with the provider's traffic total for the whole target), top_pages (target: the pages with the most estimated organic traffic), serp (keywords, up to 5: the ranking pages, each with its domain's authority: DataForSEO domain_rank 0 to 100, Ahrefs domain_rating; depth sets results per keyword, default 20; rows run position by position across the keywords), serp_overlap (2 to 10 keywords: for each pair, how many of their top 10 pages and sites are the same, to decide one page or several; results without a real page are counted, not compared), competitors (target: domains that share keywords), backlinks (target: referring domains), pages_by_backlinks (target: the pages with the most 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.`;
46514
47237
  const GENERIC_PLATFORMS = [
46515
47238
  "youtube.com",
46516
47239
  "facebook.com",
@@ -46548,47 +47271,108 @@ function realCompetitors(rows, target) {
46548
47271
  note: `Left out ${gone} ${gone === 1 ? "row" : "rows"}: ${reasons.join(" and ")}. The fixed platform list is ${GENERIC_PLATFORMS.join(", ")}.`
46549
47272
  };
46550
47273
  }
47274
+ const KEY_HEADER = {
47275
+ dataforseo: "x-dataforseo-login",
47276
+ ahrefs: "x-ahrefs-key"
47277
+ };
47278
+ function chooseProvider(asked, keys) {
47279
+ return asked ?? RESEARCH_PROVIDERS.find((name) => keys.has(KEY_HEADER[name])) ?? "dataforseo";
47280
+ }
47281
+ const VOLUME_ACTIONS = ["keyword_metrics", "keyword_ideas"];
47282
+ const VOLUME_MODEL = {
47283
+ dataforseo: "DataForSEO search_volume is Google Ads' average monthly searches over the last 12 months, rounded into Google's bands.",
47284
+ ahrefs: "Ahrefs volume is Ahrefs' own estimate of average monthly searches, from clickstream data and its keyword database."
47285
+ };
47286
+ const VOLUMES_DIFFER = "The two providers can differ two to eight times for the same keyword, so compare keywords within one provider. A null volume is unknown, not zero.";
47287
+ const TRAFFIC_MODEL = {
47288
+ dataforseo: "DataForSEO etv (estimated traffic volume): each keyword's monthly search volume times DataForSEO's click-through rate for the ranking position, summed over the keywords in DataForSEO's database for this country",
47289
+ ahrefs: "Ahrefs organic traffic (sum_traffic, org_traffic): Ahrefs' estimate of monthly organic visits from its own keyword database, volumes and click-through model, for this country"
47290
+ };
47291
+ const PROVIDERS_DIFFER = "Providers track different keywords and use different click models, so their traffic for the same site or page can differ tenfold or more. Compare a site with itself in one provider, not across providers.";
46551
47292
  async function runResearch(input, keys, options) {
46552
47293
  const country = input.country ?? "us";
46553
47294
  const target = input.target ?? "";
46554
47295
  const offset = input.offset ?? 0;
46555
- const language = input.provider === "dataforseo" ? dataForSeoLanguage(country) : null;
46556
47296
  if (input.action === "ai_answers") throw new CommandError("INVALID_INPUT", "ai_answers runs on its own.");
46557
- const { rows: found, costUsd, partial, missed, noData, floor, leftOut } = await research(input.provider, keys, {
47297
+ const provider = chooseProvider(input.provider, keys);
47298
+ const language = provider === "dataforseo" ? dataForSeoLanguage(country) : null;
47299
+ const scope = [
47300
+ "ranked_keywords",
47301
+ "top_pages",
47302
+ "backlinks",
47303
+ "pages_by_backlinks"
47304
+ ].includes(input.action) && target ? researchScope(target, input.scope) : null;
47305
+ const limit = Math.min(1e3, Math.max(100, offset + 50));
47306
+ const request = {
46558
47307
  action: input.action,
46559
47308
  keywords: input.keywords ?? [],
46560
47309
  target,
47310
+ ...scope ? { scope } : {},
46561
47311
  competitors: input.competitors ?? [],
46562
47312
  country,
46563
- limit: Math.min(1e3, Math.max(100, offset + 50))
47313
+ ...input.action === "serp" ? { depth: input.depth ?? 20 } : {}
47314
+ };
47315
+ const store = options.store ?? workersResearchStore();
47316
+ const key = await researchKey([
47317
+ provider,
47318
+ provider === "dataforseo" ? keys.get("x-dataforseo-login") : keys.get("x-ahrefs-key"),
47319
+ request
47320
+ ]);
47321
+ const saved = await readResearch(store, key, limit);
47322
+ const result = saved?.result ?? await research(provider, keys, {
47323
+ ...request,
47324
+ limit
47325
+ });
47326
+ if (!saved) await saveResearch(store, key, {
47327
+ limit,
47328
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
47329
+ result
46564
47330
  });
47331
+ const { rows: fetched, costUsd: paid, partial, missed, noData, floor, leftOut, available, siteTotal, rankFailed, compared } = result;
47332
+ const costUsd = saved ? 0 : paid;
47333
+ const order = new Map(request.keywords.map((word, index) => [word, index]));
47334
+ const found = input.action === "serp" && request.keywords.length > 1 ? [...fetched].sort((a, b) => Number(a.position ?? 1e9) - Number(b.position ?? 1e9) || (order.get(String(a.keyword)) ?? 0) - (order.get(String(b.keyword)) ?? 0)) : fetched;
46565
47335
  const { rows, note } = input.action === "competitors" ? realCompetitors(found, target) : {
46566
47336
  rows: found,
46567
47337
  note: null
46568
47338
  };
46569
47339
  const unresolved = rows.filter((row) => row.url === null && row.url_hint !== void 0).length;
46570
47340
  const drKey = keys.get("x-ahrefs-key") ?? options.drKey;
46571
- const byDr = input.action === "link_gap" && input.provider === "dataforseo" && drKey;
47341
+ const byDr = input.action === "link_gap" && provider === "dataforseo" && drKey;
46572
47342
  const rated = byDr ? await sortedByDomainRating(rows, drKey) : await withDomainRating(rows, drKey, offset);
46573
47343
  const targetDr = target ? (await domainRatings([target], drKey)).get(bareDomain(target)) : void 0;
46574
47344
  const hasTargetDr = targetDr !== void 0 && targetDr !== null;
47345
+ const detail = input.detail ?? "summary";
47346
+ const size = detail === "full" ? Math.max(rated.rows.length, 1) : 50;
47347
+ const shown = rated.rows.slice(offset, offset + size);
47348
+ const next = offset + shown.length < rated.rows.length ? offset + shown.length : null;
47349
+ const perKeyword = input.action === "serp" ? Object.fromEntries(request.keywords.map((word) => [word, rated.rows.filter((row) => row.keyword === word).length])) : null;
46575
47350
  return {
46576
47351
  text: rowsText({
46577
- title: `${input.provider} ${input.action}${target ? ` for ${target}` : ""}, ${country}${language ? `, language ${language}` : ""}.`,
47352
+ title: `${provider} ${input.action}${target ? ` for ${target}` : ""}${scope ? ` (${SCOPE_TEXT[scope]})` : ""}${LINK_ACTIONS.includes(input.action) ? ", all countries" : `, ${country}${language ? `, language ${language}` : ""}`}.`,
46578
47353
  rows: rated.rows,
46579
47354
  offset,
46580
- limit: 50,
46581
- detail: input.detail ?? "summary",
47355
+ limit: size,
47356
+ detail,
46582
47357
  notes: [
46583
- `${input.provider} estimates, in the provider’s own units.`,
46584
- ...costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
47358
+ `${provider} estimates, in the provider’s own units.`,
47359
+ ...compared ? [`Compared: ${compared.withPage} of ${compared.results} top results had a real page, so shared_pages counts only those; ${compared.results - compared.withPage} came back as Google redirect links and could not be compared by page. ${compared.withSite} had a known site for shared_sites. Unique pages and sites are counted per keyword.${compared.withPage < compared.results / 2 && provider === "dataforseo" ? " With this few pages, shared_pages cannot settle one page or several; provider ahrefs returns real pages." : ""}`] : [],
47360
+ ...VOLUME_ACTIONS.includes(input.action) ? [`Volume model: ${VOLUME_MODEL[provider]} ${VOLUMES_DIFFER}`] : [],
47361
+ ...perKeyword ? [`Results per keyword: ${Object.entries(perKeyword).map(([word, count]) => `${word} ${count}`).join(", ")}. Rows run position by position across the keywords.`] : [],
47362
+ ...siteTotal ? [siteTotalNote(provider, siteTotal, rows.length)] : [],
47363
+ ...available !== void 0 && available > found.length ? [`Capped: ${provider} has ${available.toLocaleString("en-US")} rows for this; this read the first ${found.length.toLocaleString("en-US")} in its order.`] : [],
47364
+ ...!found.length && scope ? [emptyNote(provider, input.action)] : [],
47365
+ ...input.action === "serp" && provider === "dataforseo" ? [rankFailed ? "domain_rank is missing: the DataForSEO rank call failed, so it is unknown, not zero." : "domain_rank is DataForSEO's own authority score for the domain, 0 to 100. It is not Ahrefs' Domain Rating."] : [],
47366
+ ...input.action === "serp" && !drKey && provider === "dataforseo" ? ["No domain_rating column: Domain Rating by Ahrefs needs an Ahrefs key (or the hosted MCP at https://seoskill.dev/mcp, which adds it)."] : [],
47367
+ ...store && rated.rows.length > 50 ? ["The result is kept for a day, so the next pages, and the same rows in full, cost nothing more."] : [],
47368
+ ...saved ? [`Read from the result saved at ${saved.savedAt}, at no new cost${paid ? ` (it cost $${paid} then)` : ""}. Results are kept for a day.`] : costUsd !== null ? [`DataForSEO cost: $${costUsd}.`] : [],
46585
47369
  ...partial ? ["Partial: DataForSEO could not fetch some pages of results, so rows may be missing."] : [],
46586
- ...missed.length ? [`Partial: ${input.provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
46587
- ...noData.length ? [noDataNote(input, noData)] : [],
47370
+ ...missed.length ? [`Partial: ${provider} failed for ${missed.join(", ")}, so those keywords have no rows. Ask for them again.`] : [],
47371
+ ...noData.length ? [noDataNote(provider, input.action, noData)] : [],
46588
47372
  ...byDr ? [rows.length > GAP_RATED ? `Sorted by Domain Rating for the top ${GAP_RATED} rows by DataForSEO rank; the ${rows.length - GAP_RATED} rows after them follow by rank, with no Domain Rating.` : "Sorted by Domain Rating, then DataForSEO rank."] : [],
46589
47373
  ...leftOut?.length ? [`Left out ${leftOut.length} link lists, URL shorteners and directories that link to most sites (a guess from the host name): ${leftOut.join(", ")}.`] : [],
46590
47374
  ...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.`] : [],
46591
- ...unresolved ? [unresolvedNote(input, unresolved, rows.length)] : [],
47375
+ ...unresolved ? [unresolvedNote(unresolved, rows.length)] : [],
46592
47376
  ...note ? [note] : [],
46593
47377
  ...hasTargetDr ? [`${bareDomain(target)} domain_rating: ${targetDr}.`] : [],
46594
47378
  ...rated.added || hasTargetDr ? [DR_ATTRIBUTION] : []
@@ -46596,15 +47380,22 @@ async function runResearch(input, keys, options) {
46596
47380
  more: options.call
46597
47381
  }),
46598
47382
  data: {
46599
- provider: input.provider,
47383
+ provider,
46600
47384
  action: input.action,
46601
47385
  target: target || null,
47386
+ scope,
47387
+ siteTotal: siteTotal ? {
47388
+ ...siteTotal,
47389
+ model: TRAFFIC_MODEL[provider]
47390
+ } : null,
47391
+ available: available ?? null,
46602
47392
  country,
46603
47393
  language,
46604
47394
  targetDomainRating: hasTargetDr ? targetDr : null,
46605
47395
  attribution: rated.added || hasTargetDr ? DR_ATTRIBUTION : null,
46606
47396
  leftOut: note,
46607
47397
  costUsd,
47398
+ savedAt: saved?.savedAt ?? null,
46608
47399
  partial,
46609
47400
  missedKeywords: missed,
46610
47401
  noDataKeywords: noData,
@@ -46613,19 +47404,40 @@ async function runResearch(input, keys, options) {
46613
47404
  cutAt: floor ?? null,
46614
47405
  leftOut: leftOut ?? []
46615
47406
  } : {},
47407
+ ...perKeyword ? { resultsPerKeyword: perKeyword } : {},
47408
+ ...compared ? { compared } : {},
46616
47409
  total: rated.rows.length,
46617
47410
  offset,
46618
- rows: rated.rows.slice(offset, offset + 50)
47411
+ next,
47412
+ rowsLeftOut: rated.rows.length - shown.length,
47413
+ rows: shown
46619
47414
  }
46620
47415
  };
46621
47416
  }
46622
- function noDataNote(input, keywords) {
47417
+ const LINK_ACTIONS = [
47418
+ "backlinks",
47419
+ "pages_by_backlinks",
47420
+ "link_gap"
47421
+ ];
47422
+ const SCOPE_TEXT = {
47423
+ domain: "the whole site with its subdomains",
47424
+ prefix: "every URL under this path",
47425
+ exact: "this page only"
47426
+ };
47427
+ function siteTotalNote(provider, total, shown) {
47428
+ const keywords = total.keywords !== null ? ` from ${total.keywords.toLocaleString("en-US")} keywords` : "";
47429
+ return `Total for the whole target: ${total.metric} ${total.traffic.toLocaleString("en-US")} a month${keywords} (the rows below are ${shown.toLocaleString("en-US")} of them). Model: ${TRAFFIC_MODEL[provider]}. ${PROVIDERS_DIFFER}`;
47430
+ }
47431
+ function emptyNote(provider, action) {
47432
+ return `No rows: ${provider} has no ${action === "backlinks" || action === "pages_by_backlinks" ? "links" : "rankings"} for this target in its database. That does not show the target has none; check the target and scope, or ask the other provider.`;
47433
+ }
47434
+ function noDataNote(provider, action, keywords) {
46623
47435
  const list = keywords.join(", ");
46624
- 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" : ""}.`;
46625
- return `No data: ${input.provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
47436
+ if (action === "serp") return `No results: ${provider} got an empty results page for ${list}${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${provider === "dataforseo" ? ", or use provider ahrefs" : ""}.`;
47437
+ return `No data: ${provider} has no metrics for ${keywords.length} of the keywords asked for (${list}). Their volume is unknown, not zero.`;
46626
47438
  }
46627
- function unresolvedNote(input, count, total) {
46628
- 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." : ""}`;
47439
+ function unresolvedNote(count, total) {
47440
+ return `${count} of ${total} results have no exact page: DataForSEO returned Google redirect links for them. Their domain and url_hint come from Google's breadcrumb, which is shortened. For exact pages, use provider ahrefs.`;
46629
47441
  }
46630
47442
  const GAP_RATED = 200;
46631
47443
  async function sortedByDomainRating(rows, key) {
@@ -46795,14 +47607,33 @@ async function runPage(input, deps) {
46795
47607
  }]
46796
47608
  });
46797
47609
  const full = input.detail === "full";
46798
- const [live, indexed, sitemap, now, then, markdown] = await Promise.all([
46799
- deps.liveCheck(input.url),
46800
- deps.inspect(input.site, input.url).catch(() => null),
46801
- sitemapUrls(input.site),
46802
- query(range),
46803
- query(before),
46804
- full && deps.pageMarkdown ? deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })) : null
47610
+ const pending = /* @__PURE__ */ new Set();
47611
+ const leg = (name, work, done) => {
47612
+ const report = deps.progress;
47613
+ if (!report) return work;
47614
+ pending.add(name);
47615
+ const finish = (line) => {
47616
+ pending.delete(name);
47617
+ report(`${line}.${pending.size ? ` Waiting on ${[...pending].join(", ")}.` : ""}`);
47618
+ };
47619
+ return work.then((value) => {
47620
+ finish(done(value));
47621
+ return value;
47622
+ }, (error) => {
47623
+ finish(`${name} failed`);
47624
+ throw error;
47625
+ });
47626
+ };
47627
+ deps.progress?.(`Checking ${input.url}: the live page, Google index status, sitemaps and Search Console queries.`);
47628
+ const [live, inspection, sitemap, [now, then], markdown] = await Promise.all([
47629
+ leg("live page", deps.liveCheck(input.url), (value) => `Live page: ${value.first.status ?? value.first.error ?? "no answer"}${value.hops.length ? ` and ${value.hops.length} redirects` : ""}`),
47630
+ leg("Google index status", inspectWithin(deps, input), (value) => value.index ? `Google index status: ${value.index.coverage ?? value.index.verdict ?? "no reason returned"}` : `Google index status: not available (${value.error?.code})`),
47631
+ leg("sitemaps", sitemapUrls(input.site), (value) => `Sitemaps: ${value.urls.length} URLs from ${value.files} ${value.files === 1 ? "file" : "files"}${value.partial ? " (partial)" : ""}`),
47632
+ leg("Search Console queries", Promise.all([query(range), query(before)]), ([current]) => `Search Console: ${current.rows.length} queries`),
47633
+ full && deps.pageMarkdown ? leg("main content", deps.pageMarkdown(input.url).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })), () => "Main content read") : null
46805
47634
  ]);
47635
+ const indexed = inspection.index;
47636
+ const indexError = inspection.error;
46806
47637
  const facts = live.first;
46807
47638
  const reason = indexed ? indexingRow({
46808
47639
  url: input.url,
@@ -46849,8 +47680,9 @@ async function runPage(input, deps) {
46849
47680
  title: `${input.url} in ${input.site}, ${range.startDate} to ${range.endDate} against ${before.startDate} to ${before.endDate}.`,
46850
47681
  notes: [
46851
47682
  `Live page (fetched now):\n${pageFactsTable([facts], null)}${live.hops.length ? `\nRedirects: ${live.hops.map((hop) => `${hop.status} to ${hop.to}`).join(", ")}${live.last ? `, which returns ${live.last.status ?? live.last.error}` : ", then loop or pass 5 hops"}.` : ""}`,
46852
- indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : "Google index status: not available for this URL.",
47683
+ indexed ? `Google index status:\n${table$1([indexed], Object.keys(indexed), null)}` : indexErrorText(indexError, input, deps),
46853
47684
  ...reason ? [`Google's reason: ${reason.reason}. Label: ${reason.label}. ${reason.why} (Labels are rules over this evidence, not Google's; ${deps.call("index")} explains them.)`] : [],
47685
+ ...indexed && /^URL is unknown to Google$/i.test(indexed.coverage ?? "") ? [unknownNext(input, sitemap.urls.includes(input.url), deps)] : [],
46854
47686
  `Query totals: clicks ${sum(now.rows, "clicks")} (was ${sum(then.rows, "clicks")}), impressions ${sum(now.rows, "impressions")} (was ${sum(then.rows, "impressions")}). Search Console hides rare queries, so these can be lower than the page total.`,
46855
47687
  deps.pageMarkdown ? `Main content as Markdown: ${deps.call("page", {
46856
47688
  url: input.url,
@@ -46877,6 +47709,7 @@ async function runPage(input, deps) {
46877
47709
  comparisonRange: before,
46878
47710
  page: facts,
46879
47711
  index: indexed,
47712
+ indexError,
46880
47713
  reason,
46881
47714
  totalQueries: rows.length,
46882
47715
  queries: rows.slice(offset, offset + 50),
@@ -46884,6 +47717,56 @@ async function runPage(input, deps) {
46884
47717
  }
46885
47718
  };
46886
47719
  }
47720
+ const INSPECT_TIMEOUT_MS = 6e4;
47721
+ async function inspectWithin(deps, input) {
47722
+ let timer;
47723
+ const stop = new AbortController();
47724
+ const timeout = new Promise((resolve) => {
47725
+ timer = setTimeout(() => {
47726
+ resolve({
47727
+ code: "TIMEOUT",
47728
+ message: `URL Inspection did not answer within ${INSPECT_TIMEOUT_MS / 1e3} seconds.`
47729
+ });
47730
+ stop.abort();
47731
+ }, INSPECT_TIMEOUT_MS);
47732
+ });
47733
+ try {
47734
+ return await Promise.race([deps.inspect(input.site, input.url, stop.signal).then((index) => ({
47735
+ index,
47736
+ error: null
47737
+ })), timeout.then((error) => ({
47738
+ index: null,
47739
+ error
47740
+ }))]);
47741
+ } catch (error) {
47742
+ const known = commandErrorFrom(error);
47743
+ return {
47744
+ index: null,
47745
+ error: {
47746
+ code: known instanceof CommandError ? known.code : "GOOGLE_ERROR",
47747
+ message: known instanceof Error ? known.message : String(known)
47748
+ }
47749
+ };
47750
+ } finally {
47751
+ clearTimeout(timer);
47752
+ }
47753
+ }
47754
+ function indexErrorText(error, input, deps) {
47755
+ const again = deps.call("page", { url: input.url });
47756
+ const message = error?.message ?? "No reason was returned.";
47757
+ const why = {
47758
+ RATE_LIMITED: `Google's URL Inspection quota for ${input.site} stopped the check. ${message} Next: run ${again} again once the quota resets, or inspect the URL by hand in Search Console's URL Inspection tool.`,
47759
+ TIMEOUT: `${message} The rest of this report went ahead without it. Next: run ${again} again later, or inspect the URL by hand in Search Console's URL Inspection tool.`,
47760
+ NO_ACCESS: `The connected Google account cannot inspect URLs in ${input.site}. ${message} Next: check the account's access under Settings, Users and permissions in Search Console, or sign in with an account that has the property.`,
47761
+ NOT_CONNECTED: `The Google sign-in did not work. ${message} Next: sign in again, then run ${again}.`,
47762
+ NO_KEY: `Google sign-in is not set up here. ${message}`,
47763
+ INVALID_INPUT: `Google refused the request. ${message} Next: check that the URL belongs to ${input.site}.`
47764
+ };
47765
+ return `Google index status: not available. ${(error && why[error.code]) ?? `URL Inspection failed. ${message} Next: run ${again} again later.`}`;
47766
+ }
47767
+ function unknownNext(input, inSitemap, deps) {
47768
+ return `Next for a page Google does not know: seo cannot submit URLs to Google. Use Request indexing in Search Console's URL Inspection tool for this URL, and ${inSitemap ? "check that the Sitemaps report in Search Console has the sitemap that lists it, with a recent read date" : "add the URL to a sitemap and submit that sitemap in Search Console"}. Google finds pages through links, so ${deps.call("internal-links", { url: input.url })} lists pages that could link here. To see whether other URLs on the site are unknown too, run ${deps.call("index", { site: input.site })}.`;
47769
+ }
46887
47770
  function markdownText(markdown, url) {
46888
47771
  if (!markdown) return `Main content as Markdown: not on this server; it runs in the CLI and the local MCP server. ${CLI_LINK} Then run \`npx -y seo page --url ${url} --detail full\`.`;
46889
47772
  if ("error" in markdown) return `Main content as Markdown: the page could not be read (${markdown.error}).`;
@@ -47354,9 +48237,12 @@ const listSites = async (surface) => runSites({
47354
48237
  });
47355
48238
  const lintInput = {
47356
48239
  text: z.string().max(LINT_MAX_CHARS).optional().describe(`The copy to check: a page, post, title or description, up to ${LINT_MAX_CHARS.toLocaleString("en-US")} characters. Markdown, MDX or HTML as written, so line numbers match.`),
48240
+ url: z.url().optional().describe("Optional instead of text: a live page. Its main content is extracted as Markdown (as seo_page with detail full shows it) and checked with the blog preset. Only the CLI and the local MCP server read pages."),
47357
48241
  format: z.enum(LINT_FORMATS).optional().describe("Optional. md (default for text), mdx, html, astro or text. Code, tags and URLs are skipped; captions and titles are checked."),
47358
- preset: z.enum(LINT_PRESETS).optional().describe("Optional. blog (default for Markdown), landing (default for HTML: stricter on buzzwords, weak links and claims with no number), docs, or meta (one title or description per line)."),
48242
+ preset: z.enum(LINT_PRESETS).optional().describe("Optional. blog (default for Markdown), landing (default for HTML: stricter on buzzwords, weak links and claims with no number), docs, or meta (one title or description per line; start a line with \"title:\" or \"description:\" to say which)."),
48243
+ meta_kind: z.enum(LINT_META_KINDS).optional().describe("Optional, with the meta preset: title or description, what a line with no \"title:\" or \"description:\" label is. Default: a line up to 70 characters is a title, a longer one a description."),
47359
48244
  ignore_rules: z.array(z.string().min(1).max(60)).max(50).optional().describe("Optional rule ids or review lenses to skip."),
48245
+ only: z.array(z.enum(LINT_CATEGORIES)).max(LINT_CATEGORIES.length).optional().describe("Optional categories to check: ai (patterns common in AI-written text), copy (weak copy and house style, such as dashes) or seo (titles, descriptions, headings). Default: all."),
47360
48246
  ignore_phrases: z.array(z.string().min(1).max(200)).max(50).optional().describe("Optional phrases to allow, such as a product name a rule keeps matching.")
47361
48247
  };
47362
48248
  const SHARED_TOOLS = [
@@ -47515,30 +48401,37 @@ const SHARED_TOOLS = [
47515
48401
  title: "Keyword, competitor, backlink and AI answer data",
47516
48402
  description: `Data from the person’s own DataForSEO or Ahrefs account, which costs them credits. It uses the key the person saved on their seoskill.dev account (or on this machine for the CLI). Actions: ${RESEARCH_HELP}`,
47517
48403
  input: z.object({
47518
- provider: z.enum(RESEARCH_PROVIDERS),
48404
+ provider: z.enum(RESEARCH_PROVIDERS).optional().describe("Optional. Without it, the saved key; DataForSEO when both are saved."),
47519
48405
  action: z.enum(RESEARCH_ACTIONS),
47520
48406
  keywords: z.array(z.string().min(1)).max(100).default([]),
47521
- target: z.string().default("").describe("Domain or URL."),
48407
+ target: z.string().default("").describe("Domain (the whole site), a path ending in / for every URL under it, such as example.com/blog/, or one page."),
48408
+ scope: z.enum(RESEARCH_SCOPES).optional().describe("Optional: domain, prefix or exact, to read the target another way than its shape says."),
47522
48409
  competitors: z.array(z.string().min(1)).max(5).default([]).describe("link_gap only: 2 to 5 competitor domains."),
47523
48410
  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."),
48411
+ also: z.array(z.string().min(1)).max(4).default([]).describe("ai_answers only: the brand's other domains, such as claude.ai beside claude.com. A citation to any of them counts as citing the target."),
48412
+ brand: z.array(z.string().min(1).max(60)).max(10).default([]).describe("ai_answers only: the brand names that count as a mention, such as Datadog for datadoghq.com. Default: each domain's name."),
47524
48413
  country: z.string().regex(/^[a-z]{2}$/).default("us").describe("Two-letter country code."),
48414
+ depth: z.number().int().min(1).max(100).optional().describe(`serp only: results per keyword, default 20. Deeper costs more.`),
47525
48415
  ...detail
47526
48416
  }),
47527
48417
  run: async (surface, input) => {
47528
48418
  const more = (offset) => `seo_research with the same input, detail "rows", offset ${offset}`;
47529
48419
  if (input.action === "ai_answers") {
47530
- if (input.provider !== "dataforseo") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
48420
+ if (input.provider === "ahrefs") throw new CommandError("INVALID_INPUT", "ai_answers uses dataforseo.");
47531
48421
  return runAiAnswers({
47532
48422
  site: input.keywords.length || input.tasks.length ? input.target : await surface.resolveSite(input.target),
47533
48423
  country: input.country,
47534
48424
  detail: input.detail,
47535
48425
  offset: input.offset,
47536
48426
  questions: input.keywords,
47537
- tasks: input.tasks
47538
- }, await surface.keys(), surface.deps, (offset, tasks) => `seo_research with the same provider, action and target, tasks ${JSON.stringify(tasks)}, detail "rows", offset ${offset}. Passing the tasks reads these answers back at no cost; without them every question is asked and paid for again`);
48427
+ tasks: input.tasks,
48428
+ also: input.also,
48429
+ brand: input.brand
48430
+ }, await surface.keys(), surface.deps, (offset, tasks) => `seo_research with the same provider, action, target, also and brand, tasks ${JSON.stringify(tasks)}, detail "rows", offset ${offset}. Passing the tasks reads these answers back at no cost; without them every question is asked and paid for again`);
47539
48431
  }
47540
48432
  return runResearch(input, await surface.keys(), {
47541
48433
  drKey: surface.drKey,
48434
+ store: surface.researchStore,
47542
48435
  call: more
47543
48436
  });
47544
48437
  }
@@ -47610,20 +48503,26 @@ const SHARED_TOOLS = [
47610
48503
  name: "seo_lint",
47611
48504
  command: "lint",
47612
48505
  title: "Check copy for AI writing patterns",
47613
- description: "Checks landing page copy, blog posts, titles and meta descriptions for patterns common in AI-written text (such as \"isn't X, it's Y\" reframes, \", not X\" contrasts, two-beat fragments, stock idioms and kicker lines), weak copy (dash punctuation, buzzwords, vague link text) and SEO rules (title 30 to 60 characters, description 70 to 160, one H1). Each error and warning has a line, the words, and a concrete fix. Review items are judgement calls for you: read each quote and change it only when it fails the question. It runs offline and reads no accounts.",
48506
+ description: "Checks landing page copy, blog posts, titles and meta descriptions for patterns common in AI-written text (such as \"isn't X, it's Y\" reframes, \", not X\" contrasts, two-beat fragments, stock idioms and kicker lines), weak copy (dash punctuation, buzzwords, vague link text) and SEO rules (title 30 to 60 characters, description 70 to 160, one H1). Each error and warning has a line, the words, and a concrete fix. Each finding has a category (ai, copy or seo); only limits the check, such as only [\"ai\"] to leave out house style like dashes. Review items are judgement calls for you: read each quote and change it only when it fails the question. A url checks the main content of a live page (CLI and local MCP only). It reads no accounts.",
47614
48507
  input: z.object(lintInput),
47615
48508
  localInput: z.object({
47616
48509
  ...lintInput,
47617
48510
  paths: z.array(z.string().min(1)).max(20).optional().describe("Optional instead of text: files, folders or globs on this machine (.md, .mdx, .html, .astro, .txt). The format comes from each file name.")
47618
48511
  }),
47619
- run: async (surface, input) => runLint({
47620
- text: input.text,
47621
- paths: input.paths,
47622
- format: input.format,
47623
- preset: input.preset,
47624
- ignoreRules: input.ignore_rules,
47625
- ignorePhrases: input.ignore_phrases
47626
- }, surface.readFiles)
48512
+ run: async (surface, input) => {
48513
+ const pageMarkdown = surface.deps.pageMarkdown;
48514
+ return runLint({
48515
+ text: input.text,
48516
+ paths: input.paths,
48517
+ url: input.url,
48518
+ format: input.format,
48519
+ preset: input.preset,
48520
+ ignoreRules: input.ignore_rules,
48521
+ ignorePhrases: input.ignore_phrases,
48522
+ only: input.only,
48523
+ metaKind: input.meta_kind
48524
+ }, surface.readFiles, pageMarkdown && ((url) => pageMarkdown(url, 2e5)));
48525
+ }
47627
48526
  })
47628
48527
  ];
47629
48528
  function indexFromStore(shared) {
@@ -47939,10 +48838,11 @@ function nodeCommandDeps(options) {
47939
48838
  }),
47940
48839
  checkPages,
47941
48840
  liveCheck,
47942
- inspect: async (site, url) => indexStatusFrom(await inspectUrl({
48841
+ inspect: async (site, url, signal) => indexStatusFrom(await inspectUrl({
47943
48842
  siteUrl: site,
47944
48843
  inspectionUrl: url,
47945
- accountEmail: options.account
48844
+ accountEmail: options.account,
48845
+ signal
47946
48846
  })),
47947
48847
  inspectionQuota: (site) => urlInspectionQuota({
47948
48848
  siteUrl: site,
@@ -47952,7 +48852,7 @@ function nodeCommandDeps(options) {
47952
48852
  read: (site, urls) => options.refresh ? /* @__PURE__ */ new Map() : readSavedInspections(site, urls),
47953
48853
  save: saveInspection
47954
48854
  },
47955
- pageMarkdown: async (url) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh })),
48855
+ pageMarkdown: async (url, limit) => extractMainMarkdown(await fetchPage(url, { refresh: options.refresh }), limit),
47956
48856
  call: options.call,
47957
48857
  bing: localBing,
47958
48858
  bingConnect: LOCAL_BING_CONNECT,
@@ -47964,6 +48864,35 @@ function nodeCommandDeps(options) {
47964
48864
  ...cloudLinked() ? { week: cloudWeek } : {}
47965
48865
  };
47966
48866
  }
48867
+ function localResearchStore(refresh = false) {
48868
+ const at = (key) => ({
48869
+ provider: "research",
48870
+ credentialScope: "-",
48871
+ operation: "research",
48872
+ request: key
48873
+ });
48874
+ return {
48875
+ get: async (key) => {
48876
+ if (refresh) return null;
48877
+ try {
48878
+ return readProviderCache(at(key), z.string())?.data ?? null;
48879
+ } catch {
48880
+ return null;
48881
+ }
48882
+ },
48883
+ put: async (key, value, ttlSeconds) => {
48884
+ try {
48885
+ writeProviderCache(at(key), {
48886
+ data: value,
48887
+ ttlMs: ttlSeconds * 1e3,
48888
+ rowCount: null,
48889
+ sourceCostMicros: null,
48890
+ taskIds: []
48891
+ });
48892
+ } catch {}
48893
+ }
48894
+ };
48895
+ }
47967
48896
  const LOCAL_BING_CONNECT = "Connect Bing on your seoskill.dev account and run `seo login`, or run `seo providers bing connect` with an API key from Bing Webmaster Tools (Settings, API access).";
47968
48897
  let bing;
47969
48898
  function localBing() {
@@ -48080,6 +49009,14 @@ async function runDoctor(dependencies = {}) {
48080
49009
  detail: paths.configDir,
48081
49010
  fix: existsSync(paths.configDir) ? void 0 : "Run `seo start` or `seo auth setup-client`."
48082
49011
  });
49012
+ if (cloudLinked() && !serviceAccount.configured) {
49013
+ checks.push(await accountCheck());
49014
+ return {
49015
+ ok: checks.every((check) => check.status !== "fail"),
49016
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
49017
+ checks
49018
+ };
49019
+ }
48083
49020
  checks.push({
48084
49021
  id: "oauth-client",
48085
49022
  label: "Google credentials",
@@ -48114,6 +49051,32 @@ async function runDoctor(dependencies = {}) {
48114
49051
  checks
48115
49052
  };
48116
49053
  }
49054
+ async function accountCheck() {
49055
+ const via = process.env.SEO_TOKEN?.trim() ? "SEO_TOKEN" : "`seo login`";
49056
+ try {
49057
+ const emails = await cloudGoogleEmails();
49058
+ return emails.length ? {
49059
+ id: "google-login",
49060
+ label: "Google login",
49061
+ status: "pass",
49062
+ detail: `Linked to a ${CLOUD_URL} account through ${via}, with Google sign-ins for ${emails.join(", ")}. The local Google client, token and scopes are not used.`
49063
+ } : {
49064
+ id: "google-login",
49065
+ label: "Google login",
49066
+ status: "fail",
49067
+ detail: `Linked to a ${CLOUD_URL} account through ${via}, but the account has no Google sign-in.`,
49068
+ fix: `Connect Google at ${CLOUD_URL}/account, then run \`seo doctor\` again.`
49069
+ };
49070
+ } catch (error) {
49071
+ return {
49072
+ id: "google-login",
49073
+ label: "Google login",
49074
+ status: "fail",
49075
+ detail: `Linked to a ${CLOUD_URL} account through ${via}, but the account could not be read: ${error instanceof Error ? error.message : String(error)}`,
49076
+ fix: via === "SEO_TOKEN" ? `Make a new token at ${CLOUD_URL}/account and set SEO_TOKEN again.` : "Run `seo login` again."
49077
+ };
49078
+ }
49079
+ }
48117
49080
  const MAX_BYTES$1 = LINT_MAX_CHARS * 4;
48118
49081
  const EXTENSIONS = /* @__PURE__ */ new Set([
48119
49082
  ".md",
@@ -48131,6 +49094,10 @@ const SKIP_DIRS = /* @__PURE__ */ new Set([
48131
49094
  ".astro",
48132
49095
  ".next"
48133
49096
  ]);
49097
+ const CODE_TAGS = /(<(script|style|svg)\b[^>]*>)([\s\S]*?)(<\/\2\s*>)/gi;
49098
+ function withoutCode(html) {
49099
+ return html.replace(CODE_TAGS, (_whole, open, _name, body, close) => `${open}${body.replace(/[^\n]/g, "")}${close}`);
49100
+ }
48134
49101
  function tooMany() {
48135
49102
  return new SeoError("INVALID_INPUT", `More than 100 files match. Point seo lint at fewer files or a smaller folder.`);
48136
49103
  }
@@ -48183,16 +49150,27 @@ async function readLintFiles(inputs, cwd = process.cwd()) {
48183
49150
  continue;
48184
49151
  }
48185
49152
  const text = await readFile(path, "utf8");
48186
- if (text.length > 2e5) {
49153
+ if (text.length <= 2e5) {
49154
+ sources.push({
49155
+ path: label,
49156
+ text
49157
+ });
49158
+ continue;
49159
+ }
49160
+ const size = `It has ${text.length.toLocaleString("en-US")} characters`;
49161
+ const html = [".html", ".htm"].includes(extname(path).toLowerCase());
49162
+ const readable = html ? withoutCode(text) : text;
49163
+ if (readable.length > 2e5) {
48187
49164
  sources.push({
48188
49165
  path: label,
48189
- skipped: `It has ${text.length.toLocaleString("en-US")} characters; seo lint reads up to ${LINT_MAX_CHARS.toLocaleString("en-US")}.`
49166
+ skipped: `${size}${html ? `, ${readable.length.toLocaleString("en-US")} without scripts, styles and SVG` : ""}; seo lint reads up to ${LINT_MAX_CHARS.toLocaleString("en-US")}.`
48190
49167
  });
48191
49168
  continue;
48192
49169
  }
48193
49170
  sources.push({
48194
49171
  path: label,
48195
- text
49172
+ text: readable,
49173
+ note: `${size}, so the insides of its script, style and SVG tags were left out first (${readable.length.toLocaleString("en-US")} remain). Line numbers match; a column on a line that held one of those tags can be off.`
48196
49174
  });
48197
49175
  }
48198
49176
  return sources;
@@ -49470,7 +50448,7 @@ function registerMonitoringTools(server) {
49470
50448
  refresh,
49471
50449
  js: resolveJsOption(js, "auto")
49472
50450
  });
49473
- return toolSuccess(`Traced ${result.summary.hops} hops. Final status ${result.summary.finalStatus}. ${result.summary.issues.length} issues.`, result);
50451
+ return toolSuccess(`Traced ${result.summary.hops} hops. ${result.summary.finalStatus === null ? `No final response (${result.summary.outcome}: ${result.summary.error ?? "unknown"}).` : `Final status ${result.summary.finalStatus}.`} ${result.summary.issues.length} issues.`, result);
49474
50452
  } catch (error) {
49475
50453
  return toolError(error);
49476
50454
  }
@@ -51203,6 +52181,7 @@ function registerSharedTools(server, options = {}) {
51203
52181
  keys: localResearchKeys,
51204
52182
  refreshClicky: localRefreshClicky,
51205
52183
  drKey: null,
52184
+ researchStore: localResearchStore(),
51206
52185
  accounts: localAccounts,
51207
52186
  signIn: LOCAL_SIGN_IN.signIn,
51208
52187
  connect: "run `seo login` to use your seoskill.dev account (Google, Bing and keys in one place), or `seo auth login`, `seo providers bing connect` and `seo providers dataforseo connect` or `seo providers ahrefs connect` on this machine only.",