@canonry/canonry 4.136.0 → 4.136.2

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.
Files changed (30) hide show
  1. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +4 -0
  2. package/assets/agent-workspace/skills/canonry/references/indexing.md +26 -12
  3. package/assets/assets/{AuditHistoryPanel-DKaiIlcO.js → AuditHistoryPanel-DtUGiN21.js} +1 -1
  4. package/assets/assets/{BacklinksPage-Z2_ws8c2.js → BacklinksPage-BGwtmwfz.js} +1 -1
  5. package/assets/assets/{ChartPrimitives-CF_ocuvj.js → ChartPrimitives-Df2UC1g3.js} +1 -1
  6. package/assets/assets/{HistoryPage-DDKO8_D8.js → HistoryPage-AtaMVEUx.js} +1 -1
  7. package/assets/assets/ProjectPage-OlBjxXNx.js +8 -0
  8. package/assets/assets/{RunRow-BhtCgyqA.js → RunRow-Cmf-5xVo.js} +1 -1
  9. package/assets/assets/{RunsPage-B8Nw3wP-.js → RunsPage-BAPy9Yri.js} +1 -1
  10. package/assets/assets/{SettingsPage-DsmQhytl.js → SettingsPage-BXO7WpTo.js} +1 -1
  11. package/assets/assets/{TrafficPage-COJs6eRH.js → TrafficPage-IH-wiPu2.js} +1 -1
  12. package/assets/assets/{TrafficSourceDetailPage-BI6IwzMQ.js → TrafficSourceDetailPage-CTTN-P52.js} +1 -1
  13. package/assets/assets/{arrow-left-C-lGPq-u.js → arrow-left-BwzAIeJ_.js} +1 -1
  14. package/assets/assets/{extract-error-message-8h0hF1I1.js → extract-error-message-BIcIoJLN.js} +1 -1
  15. package/assets/assets/index-BPpSrJY0.js +210 -0
  16. package/assets/assets/index-CglduHCN.css +1 -0
  17. package/assets/assets/{trash-2-CnOZwYKe.js → trash-2-C50OeBAV.js} +1 -1
  18. package/assets/index.html +2 -2
  19. package/dist/{chunk-PXEEWJRV.js → chunk-GGIRLRAY.js} +3093 -399
  20. package/dist/{chunk-3PFRVEWP.js → chunk-MXR3GI5L.js} +54 -122
  21. package/dist/{chunk-PAR6P5H6.js → chunk-SMFUCXHI.js} +132 -6
  22. package/dist/{chunk-TPVMHYHD.js → chunk-YVMQWVNY.js} +291 -206
  23. package/dist/cli.js +184 -15
  24. package/dist/index.js +4 -4
  25. package/dist/{intelligence-service-BMEJ5GFZ.js → intelligence-service-BD6BHTEJ.js} +2 -2
  26. package/dist/mcp.js +2 -2
  27. package/package.json +9 -9
  28. package/assets/assets/ProjectPage-hmNsqBov.js +0 -8
  29. package/assets/assets/index-B4ksyQXo.css +0 -1
  30. package/assets/assets/index-BoSfFv1t.js +0 -210
@@ -238,13 +238,18 @@ import {
238
238
  gscCoverageSnapshotDtoSchema,
239
239
  gscCoverageSummaryDtoSchema,
240
240
  gscDeindexedRowSchema,
241
+ gscDiscoverSitemapsResponseDtoSchema,
241
242
  gscPerformanceDailyDtoSchema,
242
243
  gscSearchDataDtoSchema,
243
244
  gscSiteListResponseDtoSchema,
244
245
  gscSitemapListResponseDtoSchema,
246
+ gscSubmitSitemapsRequestDtoSchema,
247
+ gscSubmitSitemapsResponseDtoSchema,
245
248
  gscUrlInspectionDtoSchema,
246
249
  hasLocationLabel,
247
250
  healthSnapshotDtoSchema,
251
+ hostMatchesAnyDomain,
252
+ hostMatchesDomain,
248
253
  hostOf,
249
254
  indexingRequestResponseDtoSchema,
250
255
  internalError,
@@ -333,6 +338,9 @@ import {
333
338
  summarizeCheckResults,
334
339
  surfaceClassFromCompetitorType,
335
340
  surfaceClassLabel,
341
+ textContainsAnyBrandAlias,
342
+ textContainsBrandAlias,
343
+ textContainsDomain,
336
344
  trafficBackfillResponseSchema,
337
345
  trafficConnectVercelRequestSchema,
338
346
  trafficConnectWordpressRequestSchema,
@@ -365,7 +373,7 @@ import {
365
373
  wordpressSchemaDeployResultDtoSchema,
366
374
  wordpressSchemaStatusResultDtoSchema,
367
375
  wordpressStatusDtoSchema
368
- } from "./chunk-PXEEWJRV.js";
376
+ } from "./chunk-GGIRLRAY.js";
369
377
 
370
378
  // src/intelligence-service.ts
371
379
  import { eq as eq44, desc as desc21, asc as asc9, and as and35, ne as ne6, or as or10, inArray as inArray15, gte as gte12, lte as lte8 } from "drizzle-orm";
@@ -5463,24 +5471,19 @@ function groupInsights(insights2, keyFn = (i) => JSON.stringify([i.query, i.prov
5463
5471
  var TRANSACTIONAL_RE = /\b(?:buy|price|pricing|cost|hire|near me|services?|agency|consultant|company)\b/i;
5464
5472
  var INFORMATIONAL_RE = /\b(?:what|how|why|when|guide|tutorial|vs|versus|alternatives?|examples?|definition)\b/i;
5465
5473
  var MIN_BRAND_TOKEN_LENGTH = 3;
5466
- function compact(value) {
5467
- return value.toLowerCase().replace(/[^a-z0-9]/g, "");
5468
- }
5469
5474
  function buildBrandTokens(canonicalDomain, brandNames = []) {
5470
5475
  const seen = /* @__PURE__ */ new Set();
5471
- const stem = canonicalDomain.toLowerCase().replace(/\.[a-z]{2,}$/, "");
5472
- const stemCompact = compact(stem);
5476
+ const stemCompact = brandKeyFromText(brandLabelFromDomain(canonicalDomain));
5473
5477
  if (stemCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(stemCompact);
5474
5478
  for (const name of brandNames) {
5475
5479
  if (!name) continue;
5476
- const nameCompact = compact(name);
5480
+ const nameCompact = brandKeyFromText(name);
5477
5481
  if (nameCompact.length >= MIN_BRAND_TOKEN_LENGTH) seen.add(nameCompact);
5478
5482
  }
5479
5483
  return [...seen];
5480
5484
  }
5481
5485
  function categorizeQueryByIntent(query, brandTokens) {
5482
- const compactQuery = compact(query);
5483
- if (brandTokens.length > 0 && brandTokens.some((t) => compactQuery.includes(t))) {
5486
+ if (textContainsAnyBrandAlias(query, brandTokens)) {
5484
5487
  return "brand";
5485
5488
  }
5486
5489
  if (TRANSACTIONAL_RE.test(query)) return "lead-gen";
@@ -5548,12 +5551,7 @@ function buildCitationScorecard(snapshots, queryLookup) {
5548
5551
 
5549
5552
  // ../intelligence/src/domain-matching.ts
5550
5553
  function citedDomainBelongsToProject(citedDomain, projectDomains) {
5551
- const candidate = normalizeProjectDomain(citedDomain);
5552
- for (const domain of projectDomains) {
5553
- const normalized = normalizeProjectDomain(domain);
5554
- if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
5555
- }
5556
- return false;
5554
+ return hostMatchesAnyDomain(citedDomain, projectDomains);
5557
5555
  }
5558
5556
 
5559
5557
  // ../intelligence/src/competitor-landscape.ts
@@ -5575,11 +5573,8 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
5575
5573
  entry.count++;
5576
5574
  if (q) entry.queries.add(q);
5577
5575
  }
5578
- const competitorNorm = normalizeUrlDomain(competitor);
5579
5576
  for (const gs of snap.groundingSources) {
5580
- const host = normalizeUrlDomain(extractHostFromUri(gs.uri));
5581
- if (!host) continue;
5582
- if (host === competitorNorm || host.endsWith(`.${competitorNorm}`)) {
5577
+ if (hostMatchesDomain(gs.uri, competitor)) {
5583
5578
  const entry = competitorMap.get(competitor);
5584
5579
  const pageQueries = entry.pages.get(gs.uri) ?? /* @__PURE__ */ new Set();
5585
5580
  if (q) pageQueries.add(q);
@@ -5613,16 +5608,6 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
5613
5608
  competitorRows.sort((a, b) => b.citationCount - a.citationCount);
5614
5609
  return { projectCitationCount, competitors: competitorRows };
5615
5610
  }
5616
- function normalizeUrlDomain(domain) {
5617
- return domain.toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "");
5618
- }
5619
- function extractHostFromUri(uri) {
5620
- try {
5621
- return new URL(uri).hostname;
5622
- } catch {
5623
- return "";
5624
- }
5625
- }
5626
5611
 
5627
5612
  // ../intelligence/src/mention-landscape.ts
5628
5613
  function buildMentionLandscape(snapshots, competitorDomains, projectBrandNames, projectDomains, queryLookup) {
@@ -6040,10 +6025,9 @@ function buildCompetitorPressureScore(snapshots, competitorDomains, totalTracked
6040
6025
  trend: []
6041
6026
  };
6042
6027
  }
6043
- const competitorSet = new Set(competitorDomains);
6044
6028
  let overlapCount = 0;
6045
6029
  for (const snap of snapshots) {
6046
- if (snap.competitorOverlap.some((d) => competitorSet.has(d))) {
6030
+ if (snap.competitorOverlap.some((domain) => hostMatchesAnyDomain(domain, competitorDomains))) {
6047
6031
  overlapCount++;
6048
6032
  }
6049
6033
  }
@@ -6068,7 +6052,7 @@ function buildOverviewCompetitors(snapshots, competitors2, queryLookup) {
6068
6052
  return competitors2.map((competitor, index2) => {
6069
6053
  const citedQuerySet = /* @__PURE__ */ new Set();
6070
6054
  for (const snap of snapshots) {
6071
- if (snap.competitorOverlap.includes(competitor.domain) || snap.citedDomains.includes(competitor.domain)) {
6055
+ if (snap.competitorOverlap.some((domain) => hostMatchesDomain(domain, competitor.domain)) || snap.citedDomains.some((domain) => hostMatchesDomain(domain, competitor.domain))) {
6072
6056
  if (snap.queryId) citedQuerySet.add(snap.queryId);
6073
6057
  }
6074
6058
  }
@@ -6215,15 +6199,22 @@ function buildMentionShare(snapshots, options) {
6215
6199
  let projectMentionSnapshots = 0;
6216
6200
  let snapshotsWithAnswerText = 0;
6217
6201
  const competitorCounts = /* @__PURE__ */ new Map();
6218
- for (const c of options.competitors) competitorCounts.set(c.domain, 0);
6202
+ const competitorAliases = /* @__PURE__ */ new Map();
6203
+ for (const competitor of options.competitors) {
6204
+ competitorCounts.set(competitor.domain, 0);
6205
+ competitorAliases.set(
6206
+ competitor.domain,
6207
+ competitor.brandTokens.filter((alias) => brandKeyFromText(alias).length >= 3)
6208
+ );
6209
+ }
6219
6210
  for (const snap of snapshots) {
6220
6211
  const text2 = snap.answerText ?? "";
6221
6212
  if (text2.length === 0) continue;
6222
6213
  snapshotsWithAnswerText++;
6223
6214
  if (snap.projectMentioned) projectMentionSnapshots++;
6224
- const answerBrandKey = brandKeyFromText(text2);
6225
6215
  for (const competitor of options.competitors) {
6226
- if (competitorMentioned(text2, answerBrandKey, competitor.brandTokens)) {
6216
+ const aliases = competitorAliases.get(competitor.domain) ?? [];
6217
+ if (textContainsAnyBrandAlias(text2, aliases)) {
6227
6218
  competitorCounts.set(competitor.domain, (competitorCounts.get(competitor.domain) ?? 0) + 1);
6228
6219
  }
6229
6220
  }
@@ -6266,16 +6257,6 @@ function mentionShareTone(score) {
6266
6257
  if (score >= 25) return "caution";
6267
6258
  return "negative";
6268
6259
  }
6269
- function competitorMentioned(text2, answerBrandKey, brandTokens) {
6270
- for (const token of brandTokens) {
6271
- if (token.length < 3) continue;
6272
- const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6273
- if (new RegExp(`\\b${escaped}\\b`, "i").test(text2)) return true;
6274
- const tokenBrandKey = brandKeyFromText(token);
6275
- if (tokenBrandKey.length >= 3 && answerBrandKey.includes(tokenBrandKey)) return true;
6276
- }
6277
- return false;
6278
- }
6279
6260
  function describe(parts) {
6280
6261
  const { score, projectMentionSnapshots, competitorMentionSnapshots, perCompetitor } = parts;
6281
6262
  if (projectMentionSnapshots === 0 && competitorMentionSnapshots === 0) {
@@ -9776,14 +9757,7 @@ async function analyticsRoutes(app) {
9776
9757
  }
9777
9758
  var ANCHOR_SWEEP_SCAN_LIMIT = 60;
9778
9759
  function isProviderInfraDomain(uri) {
9779
- try {
9780
- const host = new URL(uri).hostname.toLowerCase();
9781
- for (const blocked of AI_PROVIDER_INFRA_DOMAINS) {
9782
- if (host === blocked || host.endsWith(`.${blocked}`)) return true;
9783
- }
9784
- } catch {
9785
- }
9786
- return false;
9760
+ return hostMatchesAnyDomain(uri, AI_PROVIDER_INFRA_DOMAINS);
9787
9761
  }
9788
9762
  function parseGroundingSources(rawResponse) {
9789
9763
  const parsed = parseJsonColumn(rawResponse, {});
@@ -10215,12 +10189,12 @@ import { and as and9, eq as eq13, desc as desc5, inArray as inArray6 } from "dri
10215
10189
  var RECENT_RUNS_WINDOW = 5;
10216
10190
  function loadOrchestratorInput(db, project, locationFilter = void 0) {
10217
10191
  const projectId = project.id;
10218
- const ownDomain = normalizeDomain(project.canonicalDomain);
10192
+ const ownDomain = hostOf(project.canonicalDomain) ?? "";
10219
10193
  const ownedDomains = project.ownedDomains ?? [];
10220
- const ourDomains = /* @__PURE__ */ new Set([ownDomain, ...ownedDomains.map(normalizeDomain)]);
10194
+ const ourDomains = /* @__PURE__ */ new Set([ownDomain, ...ownedDomains.map((domain) => hostOf(domain) ?? "")]);
10221
10195
  const trackedQueries = listQueries(db, projectId);
10222
10196
  const candidateQueryStrings = trackedQueries.filter(isBlogShapedQuery);
10223
- const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
10197
+ const trackedCompetitors = listCompetitorDomains(db, projectId).map((domain) => hostOf(domain) ?? "");
10224
10198
  const competitorSet = new Set(trackedCompetitors);
10225
10199
  const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW, locationFilter);
10226
10200
  const latestRunId = recentRunIds[0] ?? "";
@@ -10261,7 +10235,7 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
10261
10235
  }
10262
10236
  function loadDomainClasses(db, projectId) {
10263
10237
  const rows = db.select({ domain: domainClassifications.domain, competitorType: domainClassifications.competitorType }).from(domainClassifications).where(eq13(domainClassifications.projectId, projectId)).all();
10264
- return new Map(rows.map((r) => [normalizeDomain(r.domain), r.competitorType]));
10238
+ return new Map(rows.map((r) => [hostOf(r.domain) ?? "", r.competitorType]));
10265
10239
  }
10266
10240
  function buildQueryIntentModifiers(project, locationFilter) {
10267
10241
  if (locationFilter === void 0 || locationFilter === null) return [];
@@ -10480,21 +10454,22 @@ function aggregateCandidate(opts) {
10480
10454
  const isLatestRun = snap.runId === opts.latestRunId;
10481
10455
  const competitorOverlap = snap.competitorOverlap;
10482
10456
  for (const domain of competitorOverlap) {
10483
- const normalized = normalizeDomain(domain);
10484
- if (!opts.competitorSet.has(normalized)) continue;
10485
- competitorTally.set(normalized, (competitorTally.get(normalized) ?? 0) + 1);
10457
+ const normalized = hostOf(domain) ?? "";
10458
+ const competitor = findMatchingDomain(normalized, opts.competitorSet);
10459
+ if (!competitor) continue;
10460
+ competitorTally.set(competitor, (competitorTally.get(competitor) ?? 0) + 1);
10486
10461
  }
10487
10462
  const grounding = extractGroundingSources(snap.rawResponse);
10488
10463
  for (const g of grounding) {
10489
- const domain = normalizeDomain(extractHostFromUri2(g.uri));
10464
+ const domain = hostOf(g.uri) ?? "";
10490
10465
  if (!domain) continue;
10491
- if (opts.ourDomains.has(domain)) {
10466
+ if (findMatchingDomain(domain, opts.ourDomains)) {
10492
10467
  if (isLatestRun) ourCitedInLatestRun = true;
10493
10468
  recordGroundingHit(ourGroundingTally, g, domain, snap.provider);
10494
10469
  continue;
10495
10470
  }
10496
10471
  citedSurfaceTally.set(domain, (citedSurfaceTally.get(domain) ?? 0) + 1);
10497
- if (!opts.competitorSet.has(domain)) continue;
10472
+ if (!findMatchingDomain(domain, opts.competitorSet)) continue;
10498
10473
  recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
10499
10474
  }
10500
10475
  }
@@ -10516,6 +10491,9 @@ function aggregateCandidate(opts) {
10516
10491
  runsOfHistory: new Set(opts.snapshots.map((s) => s.runId)).size
10517
10492
  };
10518
10493
  }
10494
+ function findMatchingDomain(candidate, domains) {
10495
+ return [...domains].find((domain) => hostMatchesDomain(candidate, domain));
10496
+ }
10519
10497
  function recordGroundingHit(tally, g, domain, provider) {
10520
10498
  const existing = tally.get(g.uri);
10521
10499
  if (existing) {
@@ -10568,16 +10546,6 @@ function extractGroundingSources(rawResponse) {
10568
10546
  }
10569
10547
  return [];
10570
10548
  }
10571
- function extractHostFromUri2(uri) {
10572
- try {
10573
- return new URL(uri).hostname;
10574
- } catch {
10575
- return "";
10576
- }
10577
- }
10578
- function normalizeDomain(domain) {
10579
- return domain.toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "");
10580
- }
10581
10549
  function extractPath2(url) {
10582
10550
  if (!url) return "";
10583
10551
  const trimmed = url.trim();
@@ -14940,10 +14908,7 @@ function stableUnique(values, normalize) {
14940
14908
  return result;
14941
14909
  }
14942
14910
  function matchesHost(value, marketingHosts) {
14943
- const normalized = normalizeHost(value);
14944
- return marketingHosts.some(
14945
- (candidate) => normalized === candidate || normalized.endsWith(`.${candidate}`)
14946
- );
14911
+ return hostMatchesAnyDomain(value, marketingHosts);
14947
14912
  }
14948
14913
  function normalizeLandingPage(value) {
14949
14914
  return normalizeUrlPath(value) ?? UNKNOWN_LANDING_PAGE;
@@ -15013,10 +14978,10 @@ function buildGaMeasurementAnalysis(db, projectName, options = {}) {
15013
14978
  const conditions = [];
15014
14979
  if (parsedHostScope.data === "marketing") {
15015
14980
  const lowerHost = sql9`lower(${hostColumn})`;
15016
- const normalizedHost2 = sql9`case when ${lowerHost} like 'www.%' then substr(${lowerHost}, 5) else ${lowerHost} end`;
14981
+ const normalizedHost = sql9`case when ${lowerHost} like 'www.%' then substr(${lowerHost}, 5) else ${lowerHost} end`;
15017
14982
  conditions.push(or4(...marketingHosts.flatMap((host) => [
15018
- sql9`${normalizedHost2} = ${host}`,
15019
- sql9`${normalizedHost2} like ${`%.${host}`}`
14983
+ sql9`${normalizedHost} = ${host}`,
14984
+ sql9`${normalizedHost} like ${`%.${host}`}`
15020
14985
  ])));
15021
14986
  }
15022
14987
  if (pathPrefix && pathPrefix !== "/") {
@@ -15273,11 +15238,7 @@ var normalizedPath = (value) => {
15273
15238
  return normalized.split("?")[0] || "/";
15274
15239
  };
15275
15240
  var inRange = (date, startDate, endDate) => date >= startDate && date <= endDate;
15276
- var normalizedHost = (value) => value.trim().toLowerCase().replace(/^www\./, "");
15277
- var matchesMarketingHost = (value, marketingHosts) => {
15278
- const hostname = normalizedHost(value);
15279
- return marketingHosts.some((candidate) => hostname === candidate || hostname.endsWith(`.${candidate}`));
15280
- };
15241
+ var matchesMarketingHost = (value, marketingHosts) => hostMatchesAnyDomain(value, marketingHosts);
15281
15242
  var pageMatchesMarketingHost = (value, marketingHosts) => {
15282
15243
  return matchesMarketingHost(hostOf(value) ?? "", marketingHosts);
15283
15244
  };
@@ -15357,7 +15318,9 @@ function buildOrganicEvidence(db, projectName, periodDays) {
15357
15318
  const nativeAcquisition = measurement.acquisition;
15358
15319
  const nativeAcquisitionActive = nativeAcquisition.status !== "never-synced";
15359
15320
  const marketingHosts = measurement.filters.marketingHosts;
15360
- const normalizedMarketingHosts = [...new Set(marketingHosts.map(normalizedHost).filter(Boolean))];
15321
+ const normalizedMarketingHosts = [...new Set(
15322
+ marketingHosts.map((host) => hostOf(host)).filter((host) => host !== null)
15323
+ )];
15361
15324
  const nativeStartDate = nativeAcquisition.periods[0]?.startDate;
15362
15325
  const nativeEndDate = nativeAcquisition.periods.at(-1)?.endDate;
15363
15326
  const gscCoverage = aggregateCoverage(db.select({
@@ -15810,7 +15773,7 @@ async function citationRoutes(app) {
15810
15773
  queryId: s.queryId,
15811
15774
  runCreatedAt: runCreatedAt.get(s.runId) ?? s.createdAt
15812
15775
  }));
15813
- const projectCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq19(competitors.projectId, project.id)).all().map((c) => normalizeDomain2(c.domain)).filter((d) => d.length > 0);
15776
+ const projectCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq19(competitors.projectId, project.id)).all().map((c) => hostOf(c.domain) ?? "").filter((d) => d.length > 0);
15814
15777
  const response = computeCitationVisibility({
15815
15778
  queries: projectQueries.map((q) => ({ id: q.id, query: q.query })),
15816
15779
  snapshots,
@@ -15884,18 +15847,19 @@ function computeCitationVisibility(input) {
15884
15847
  totalProviders: providers.length
15885
15848
  });
15886
15849
  }
15887
- const competitorSet = new Set(competitorDomains);
15888
15850
  const competitorGaps = [];
15889
15851
  const queryById = new Map(qs.map((q) => [q.id, q.query]));
15890
15852
  for (const snap of latestByPair.values()) {
15891
15853
  if (citationStateToCited(snap.citationState)) continue;
15892
- if (competitorSet.size === 0) continue;
15854
+ if (competitorDomains.length === 0) continue;
15893
15855
  const cited = snap.citedDomains;
15894
15856
  const overlap = snap.competitorOverlap;
15895
15857
  const candidates = new Set(
15896
- [...cited, ...overlap].map((d) => normalizeDomain2(d)).filter((d) => d.length > 0)
15858
+ [...cited, ...overlap].map((d) => hostOf(d) ?? "").filter((d) => d.length > 0)
15859
+ );
15860
+ const citingCompetitors = competitorDomains.filter(
15861
+ (competitor) => [...candidates].some((candidate) => hostMatchesDomain(candidate, competitor))
15897
15862
  );
15898
- const citingCompetitors = Array.from(candidates).filter((d) => competitorSet.has(d));
15899
15863
  if (citingCompetitors.length === 0) continue;
15900
15864
  competitorGaps.push({
15901
15865
  queryId: snap.queryId,
@@ -15937,9 +15901,6 @@ function computeCitationVisibility(input) {
15937
15901
  status: "ready"
15938
15902
  };
15939
15903
  }
15940
- function normalizeDomain2(domain) {
15941
- return domain.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "");
15942
- }
15943
15904
 
15944
15905
  // ../api-routes/src/visibility-stats.ts
15945
15906
  import { and as and16, desc as desc9, eq as eq20, inArray as inArray9 } from "drizzle-orm";
@@ -15949,9 +15910,6 @@ var VISIBILITY_COMPARE_MIN_RUNS = 5;
15949
15910
  function basketPairKey(queryId, provider) {
15950
15911
  return JSON.stringify([queryId, provider]);
15951
15912
  }
15952
- function citedHostMatches(citedHost, competitorHost) {
15953
- return citedHost === competitorHost || citedHost.endsWith(`.${competitorHost}`);
15954
- }
15955
15913
  function restrict(snapshots, attribution, pairs) {
15956
15914
  const out = [];
15957
15915
  for (const snap of snapshots) {
@@ -16032,7 +15990,7 @@ function countPeriod(snaps, competitors2) {
16032
15990
  if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
16033
15991
  const citedHosts = snap.citedDomains.map((d) => hostOf(d)).filter((h) => h !== null && h.length > 0);
16034
15992
  for (const compHost of competitorHosts) {
16035
- if (citedHosts.some((ch) => citedHostMatches(ch, compHost))) competitorCited += 1;
15993
+ if (citedHosts.some((ch) => hostMatchesDomain(ch, compHost))) competitorCited += 1;
16036
15994
  }
16037
15995
  }
16038
15996
  }
@@ -17487,7 +17445,9 @@ var SCHEMA_TABLE = {
17487
17445
  GscDeindexedRowDto: gscDeindexedRowSchema,
17488
17446
  GscPerformanceDailyDto: gscPerformanceDailyDtoSchema,
17489
17447
  GscSearchDataDto: gscSearchDataDtoSchema,
17448
+ GscDiscoverSitemapsResponseDto: gscDiscoverSitemapsResponseDtoSchema,
17490
17449
  GscSiteListResponseDto: gscSiteListResponseDtoSchema,
17450
+ GscSubmitSitemapsResponseDto: gscSubmitSitemapsResponseDtoSchema,
17491
17451
  GscSitemapListResponseDto: gscSitemapListResponseDtoSchema,
17492
17452
  GscUrlInspectionDto: gscUrlInspectionDtoSchema,
17493
17453
  HealthSnapshotDto: healthSnapshotDtoSchema,
@@ -17910,6 +17870,34 @@ var routeCatalog = [
17910
17870
  200: rawJsonResponse("OpenAPI document.", looseObjectSchema)
17911
17871
  }
17912
17872
  },
17873
+ {
17874
+ method: "post",
17875
+ path: "/api/v1/projects/{name}/google/gsc/sitemaps/submit",
17876
+ summary: "Submit GSC sitemaps",
17877
+ description: "Submits sitemap URLs to Google Search Console sequentially. An accepted result means Google accepted the submit/refetch request; it does not indicate indexing.",
17878
+ tags: ["google"],
17879
+ parameters: [nameParameter],
17880
+ requestBody: {
17881
+ required: true,
17882
+ content: {
17883
+ "application/json": {
17884
+ schema: {
17885
+ type: "object",
17886
+ required: ["sitemapUrls"],
17887
+ additionalProperties: false,
17888
+ properties: {
17889
+ sitemapUrls: { type: "array", minItems: 1, maxItems: 50, items: { type: "string", format: "uri", pattern: "^https?://" } }
17890
+ }
17891
+ }
17892
+ }
17893
+ }
17894
+ },
17895
+ responses: {
17896
+ 200: jsonResponse("Per-sitemap submission results returned.", "GscSubmitSitemapsResponseDto"),
17897
+ 400: errorResponse("Invalid sitemap request, property ownership, or OAuth scope."),
17898
+ 404: errorResponse("Project or connection not found.")
17899
+ }
17900
+ },
17913
17901
  {
17914
17902
  method: "put",
17915
17903
  path: "/api/v1/projects/{name}",
@@ -19487,7 +19475,15 @@ var routeCatalog = [
19487
19475
  path: "/api/v1/projects/{name}/google/gsc/sitemaps",
19488
19476
  summary: "List GSC sitemaps",
19489
19477
  tags: ["google"],
19490
- parameters: [nameParameter],
19478
+ parameters: [
19479
+ nameParameter,
19480
+ {
19481
+ name: "sitemapIndex",
19482
+ in: "query",
19483
+ description: "Optional owned sitemap-index URL. When present, returns that index's child entries.",
19484
+ schema: { type: "string", format: "uri", pattern: "^https?://" }
19485
+ }
19486
+ ],
19491
19487
  responses: {
19492
19488
  200: jsonResponse("GSC sitemaps returned.", "GscSitemapListResponseDto"),
19493
19489
  400: errorResponse("Invalid sitemap request."),
@@ -19501,8 +19497,7 @@ var routeCatalog = [
19501
19497
  tags: ["google"],
19502
19498
  parameters: [nameParameter],
19503
19499
  responses: {
19504
- // TODO: Add `DiscoverSitemapsResponse` Zod schema in contracts.
19505
- 200: rawJsonResponse("Discovered sitemaps and queued run returned.", looseObjectSchema),
19500
+ 200: jsonResponse("Discovered sitemaps and queued run returned.", "GscDiscoverSitemapsResponseDto"),
19506
19501
  400: errorResponse("Invalid sitemap discovery request."),
19507
19502
  404: errorResponse("Project or connection not found.")
19508
19503
  }
@@ -23230,7 +23225,8 @@ function buildGbpSummary(input) {
23230
23225
  // ../integration-google/src/constants.ts
23231
23226
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
23232
23227
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
23233
- var GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
23228
+ var GSC_SCOPE = "https://www.googleapis.com/auth/webmasters";
23229
+ var GSC_READONLY_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
23234
23230
  var INDEXING_SCOPE = "https://www.googleapis.com/auth/indexing";
23235
23231
  var GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
23236
23232
  var URL_INSPECTION_API = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect";
@@ -23480,7 +23476,7 @@ function gscClientLog(level, action, ctx) {
23480
23476
  const stream = level === "error" ? process.stderr : process.stdout;
23481
23477
  stream.write(JSON.stringify(entry) + "\n");
23482
23478
  }
23483
- async function gscFetch(accessToken, url, opts) {
23479
+ async function gscFetchResponse(accessToken, url, opts) {
23484
23480
  const method = opts?.method ?? "GET";
23485
23481
  const headers = {
23486
23482
  Authorization: `Bearer ${accessToken}`,
@@ -23506,26 +23502,50 @@ async function gscFetch(accessToken, url, opts) {
23506
23502
  const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
23507
23503
  throw new GoogleApiError(`GSC API error (${res.status}): ${detail}`, res.status);
23508
23504
  }
23509
- return await res.json();
23505
+ return res;
23506
+ }
23507
+ async function gscFetchJson(accessToken, url, opts) {
23508
+ const res = await gscFetchResponse(accessToken, url, opts);
23509
+ const text2 = await res.text();
23510
+ if (!text2) {
23511
+ throw new GoogleApiError("GSC API returned an empty response where JSON was expected", res.status);
23512
+ }
23513
+ try {
23514
+ return JSON.parse(text2);
23515
+ } catch {
23516
+ throw new GoogleApiError("GSC API returned invalid JSON", res.status);
23517
+ }
23510
23518
  }
23511
23519
  async function listSites(accessToken) {
23512
23520
  validateAccessToken(accessToken);
23513
- const data = await gscFetch(
23521
+ const data = await gscFetchJson(
23514
23522
  accessToken,
23515
23523
  `${GSC_API_BASE}/sites`
23516
23524
  );
23517
23525
  return data.siteEntry ?? [];
23518
23526
  }
23519
- async function listSitemaps(accessToken, siteUrl) {
23527
+ async function listSitemaps(accessToken, siteUrl, sitemapIndex) {
23520
23528
  validateAccessToken(accessToken);
23521
23529
  validateSiteUrl(siteUrl);
23530
+ if (sitemapIndex != null) validateUrl(sitemapIndex);
23522
23531
  const encodedSiteUrl = encodeURIComponent(siteUrl);
23523
- const data = await gscFetch(
23532
+ const sitemapIndexQuery = sitemapIndex == null ? "" : `?sitemapIndex=${encodeURIComponent(sitemapIndex)}`;
23533
+ const data = await gscFetchJson(
23524
23534
  accessToken,
23525
- `${GSC_API_BASE}/sites/${encodedSiteUrl}/sitemaps`
23535
+ `${GSC_API_BASE}/sites/${encodedSiteUrl}/sitemaps${sitemapIndexQuery}`
23526
23536
  );
23527
23537
  return data.sitemap ?? [];
23528
23538
  }
23539
+ async function submitSitemap(accessToken, siteUrl, sitemapUrl) {
23540
+ validateAccessToken(accessToken);
23541
+ validateSiteUrl(siteUrl);
23542
+ validateUrl(sitemapUrl);
23543
+ await gscFetchResponse(
23544
+ accessToken,
23545
+ `${GSC_API_BASE}/sites/${encodeURIComponent(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`,
23546
+ { method: "PUT" }
23547
+ );
23548
+ }
23529
23549
  async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
23530
23550
  validateAccessToken(accessToken);
23531
23551
  validateSiteUrl(siteUrl);
@@ -23555,7 +23575,7 @@ async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
23555
23575
  requestBody.dimensionFilterGroups = filters;
23556
23576
  }
23557
23577
  const encodedSiteUrl = encodeURIComponent(siteUrl);
23558
- const data = await gscFetch(
23578
+ const data = await gscFetchJson(
23559
23579
  accessToken,
23560
23580
  `${GSC_API_BASE}/sites/${encodedSiteUrl}/searchAnalytics/query`,
23561
23581
  { method: "POST", body: requestBody }
@@ -23572,7 +23592,7 @@ async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
23572
23592
  async function publishUrlNotification(accessToken, url, type = "URL_UPDATED") {
23573
23593
  validateAccessToken(accessToken);
23574
23594
  validateUrl(url);
23575
- return gscFetch(
23595
+ return gscFetchJson(
23576
23596
  accessToken,
23577
23597
  `${INDEXING_API_BASE}/urlNotifications:publish`,
23578
23598
  {
@@ -23585,7 +23605,7 @@ async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
23585
23605
  validateAccessToken(accessToken);
23586
23606
  validateUrl(inspectionUrl);
23587
23607
  validateSiteUrl(siteUrl);
23588
- return gscFetch(
23608
+ return gscFetchJson(
23589
23609
  accessToken,
23590
23610
  URL_INSPECTION_API,
23591
23611
  {
@@ -24779,11 +24799,9 @@ function stableStringify3(value) {
24779
24799
 
24780
24800
  // ../api-routes/src/google.ts
24781
24801
  function isOnProjectDomain(url, projectDomain) {
24782
- try {
24783
- return new URL(url).hostname.toLowerCase().replace(/^www\./, "") === projectDomain;
24784
- } catch {
24785
- return false;
24786
- }
24802
+ const urlHost = hostOf(url);
24803
+ const projectHost = hostOf(projectDomain);
24804
+ return urlHost !== null && projectHost !== null && urlHost === projectHost;
24787
24805
  }
24788
24806
  function scopesForConnectionType(type) {
24789
24807
  switch (type) {
@@ -24795,6 +24813,31 @@ function scopesForConnectionType(type) {
24795
24813
  return [GBP_SCOPE];
24796
24814
  }
24797
24815
  }
24816
+ function isSitemapOwnedByProperty(sitemapUrl, propertyId, canonicalDomain) {
24817
+ let sitemap;
24818
+ try {
24819
+ sitemap = new URL(sitemapUrl);
24820
+ } catch {
24821
+ return false;
24822
+ }
24823
+ if (sitemap.protocol !== "http:" && sitemap.protocol !== "https:") return false;
24824
+ if (/^sc-domain:/i.test(propertyId)) {
24825
+ const domain = propertyId.slice("sc-domain:".length).toLowerCase();
24826
+ return hostMatchesDomain(sitemap.hostname, domain);
24827
+ }
24828
+ if (/^\d+$/.test(propertyId)) {
24829
+ return hostOf(sitemap.hostname) === hostOf(canonicalDomain);
24830
+ }
24831
+ try {
24832
+ const property = new URL(propertyId);
24833
+ if (property.protocol !== "http:" && property.protocol !== "https:") return false;
24834
+ if (sitemap.origin !== property.origin) return false;
24835
+ const prefix = property.pathname.endsWith("/") ? property.pathname : `${property.pathname}/`;
24836
+ return sitemap.pathname === property.pathname || sitemap.pathname.startsWith(prefix);
24837
+ } catch {
24838
+ return false;
24839
+ }
24840
+ }
24798
24841
  function signState(payload, secret) {
24799
24842
  return crypto19.createHmac("sha256", secret).update(payload).digest("hex");
24800
24843
  }
@@ -25454,17 +25497,83 @@ async function googleRoutes(app, opts) {
25454
25497
  }
25455
25498
  const store = requireConnectionStore();
25456
25499
  const project = resolveProject(app.db, request.params.name);
25500
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
25501
+ if (!conn?.propertyId) {
25502
+ throw validationError('No GSC property configured for this connection. Set one with "canonry google set-property".');
25503
+ }
25504
+ const sitemapIndex = request.query.sitemapIndex;
25505
+ if (sitemapIndex && !isSitemapOwnedByProperty(sitemapIndex, conn.propertyId, project.canonicalDomain)) {
25506
+ throw validationError(`sitemapIndex must belong to the configured GSC property "${conn.propertyId}".`);
25507
+ }
25457
25508
  try {
25458
25509
  const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
25459
25510
  if (!propertyId) {
25460
25511
  throw validationError('No GSC property configured for this connection. Set one with "canonry google set-property".');
25461
25512
  }
25462
- const sitemaps = await listSitemaps(accessToken, propertyId);
25463
- return { sitemaps };
25513
+ const entries = await listSitemaps(accessToken, propertyId, sitemapIndex);
25514
+ const sitemaps = sitemapIndex ? entries.map((sitemap) => ({ ...sitemap, parentSitemapUrl: sitemapIndex })) : entries;
25515
+ const indexes = sitemaps.filter((sitemap) => sitemap.isSitemapsIndex).length;
25516
+ return {
25517
+ sitemaps,
25518
+ summary: { total: sitemaps.length, indexes, files: sitemaps.length - indexes },
25519
+ preferredSubmissionUrls: indexes > 0 ? sitemaps.filter((sitemap) => sitemap.isSitemapsIndex).map((sitemap) => sitemap.path) : sitemaps.map((sitemap) => sitemap.path)
25520
+ };
25464
25521
  } catch (err) {
25465
25522
  throw gscErrorToAppError(err, "Failed to list Search Console sitemaps");
25466
25523
  }
25467
25524
  });
25525
+ app.post("/projects/:name/google/gsc/sitemaps/submit", async (request) => {
25526
+ const parsed = gscSubmitSitemapsRequestDtoSchema.safeParse(request.body ?? {});
25527
+ if (!parsed.success) {
25528
+ throw validationError("sitemapUrls must contain between 1 and 50 valid sitemap URLs");
25529
+ }
25530
+ const sitemapUrls = [...new Set(parsed.data.sitemapUrls)];
25531
+ const project = resolveProject(app.db, request.params.name);
25532
+ const store = requireConnectionStore();
25533
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
25534
+ if (!conn) {
25535
+ throw validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
25536
+ }
25537
+ if (!conn.propertyId) {
25538
+ throw validationError('No GSC property configured for this connection. Set one with "canonry google set-property".');
25539
+ }
25540
+ const invalidUrls = sitemapUrls.filter((url) => !isSitemapOwnedByProperty(url, conn.propertyId, project.canonicalDomain));
25541
+ if (invalidUrls.length > 0) {
25542
+ throw validationError(`Sitemap URLs must belong to the configured GSC property "${conn.propertyId}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`);
25543
+ }
25544
+ if (!(conn.scopes ?? []).includes(GSC_SCOPE)) {
25545
+ throw validationError('This GSC connection has the read-only webmasters scope and cannot submit sitemaps. Reconnect with "canonry google connect" to grant the full webmasters scope.');
25546
+ }
25547
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
25548
+ if (!googleClientId || !googleClientSecret) {
25549
+ throw validationError("Google OAuth is not configured");
25550
+ }
25551
+ let accessToken;
25552
+ try {
25553
+ ({ accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret));
25554
+ } catch (err) {
25555
+ throw gscErrorToAppError(err, "Failed to authorize Search Console sitemap submission");
25556
+ }
25557
+ const results = [];
25558
+ for (const sitemapUrl of sitemapUrls) {
25559
+ try {
25560
+ await submitSitemap(accessToken, conn.propertyId, sitemapUrl);
25561
+ const submittedAt = (/* @__PURE__ */ new Date()).toISOString();
25562
+ writeAuditLog(app.db, {
25563
+ projectId: project.id,
25564
+ actor: "api",
25565
+ action: "google.sitemap.submitted",
25566
+ entityType: "sitemap",
25567
+ entityId: sitemapUrl
25568
+ });
25569
+ results.push({ sitemapUrl, status: "accepted", submittedAt });
25570
+ } catch (err) {
25571
+ results.push({ sitemapUrl, status: "error", error: err instanceof Error ? err.message : String(err) });
25572
+ }
25573
+ }
25574
+ const accepted = results.filter((result) => result.status === "accepted").length;
25575
+ return { summary: { total: results.length, accepted, failed: results.length - accepted }, results };
25576
+ });
25468
25577
  app.post("/projects/:name/google/gsc/discover-sitemaps", async (request) => {
25469
25578
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
25470
25579
  if (!googleClientId || !googleClientSecret) {
@@ -25487,9 +25596,11 @@ async function googleRoutes(app, opts) {
25487
25596
  throw gscErrorToAppError(err, "Failed to discover Search Console sitemaps");
25488
25597
  }
25489
25598
  if (sitemaps.length === 0) {
25490
- throw validationError("No sitemaps found for this GSC property. Submit a sitemap in Google Search Console first.");
25599
+ throw validationError(
25600
+ `No sitemaps found for this GSC property. Submit one with "canonry google submit-sitemap ${request.params.name} <url>" first.`
25601
+ );
25491
25602
  }
25492
- const primary = sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0];
25603
+ const primary = sitemaps.find((s) => s.isSitemapsIndex) ?? sitemaps[0];
25493
25604
  const sitemapUrl = primary.path;
25494
25605
  store.updateConnection(project.canonicalDomain, "gsc", {
25495
25606
  sitemapUrl,
@@ -38276,9 +38387,7 @@ function normalizeHost2(host) {
38276
38387
  return host.trim().toLowerCase().replace(/^www\./, "");
38277
38388
  }
38278
38389
  function hostMatches(host, domain) {
38279
- const normalizedHost2 = normalizeHost2(host);
38280
- const normalizedDomain = normalizeHost2(domain);
38281
- return normalizedHost2 === normalizedDomain || normalizedHost2.endsWith(`.${normalizedDomain}`);
38390
+ return hostMatchesDomain(host, domain);
38282
38391
  }
38283
38392
  function utmTokenMatchesDomain(utmSource, domain) {
38284
38393
  if (hostMatches(utmSource, domain)) return true;
@@ -38287,12 +38396,7 @@ function utmTokenMatchesDomain(utmSource, domain) {
38287
38396
  return Boolean(firstLabel) && normalizedUtm === firstLabel;
38288
38397
  }
38289
38398
  function hostFromUrl(value) {
38290
- if (!value) return null;
38291
- try {
38292
- return normalizeHost2(new URL(value).hostname);
38293
- } catch {
38294
- return null;
38295
- }
38399
+ return hostOf(value);
38296
38400
  }
38297
38401
  function utmSourceFromQuery(queryString) {
38298
38402
  if (!queryString) return null;
@@ -38462,13 +38566,9 @@ function sessionWindowBucket(value, windowMs) {
38462
38566
  if (Number.isNaN(date.getTime())) return value;
38463
38567
  return new Date(Math.floor(date.getTime() / windowMs) * windowMs).toISOString();
38464
38568
  }
38465
- function normalizeHost3(host) {
38466
- if (!host) return null;
38467
- return host.trim().toLowerCase().replace(/^www\./, "") || null;
38468
- }
38469
38569
  function sameHost(a, b) {
38470
- const normalizedA = normalizeHost3(a);
38471
- const normalizedB = normalizeHost3(b);
38570
+ const normalizedA = hostOf(a);
38571
+ const normalizedB = hostOf(b);
38472
38572
  return !!normalizedA && !!normalizedB && normalizedA === normalizedB;
38473
38573
  }
38474
38574
  function pathFromSameOriginReferer(event) {
@@ -42064,7 +42164,6 @@ var PLACES_CHECK_BY_ID = Object.fromEntries(
42064
42164
  );
42065
42165
 
42066
42166
  // ../api-routes/src/doctor/checks/google-auth.ts
42067
- var REQUIRED_GSC_SCOPES = [GSC_SCOPE, INDEXING_SCOPE];
42068
42167
  async function resolveAccessToken(ctx) {
42069
42168
  if (!ctx.project) {
42070
42169
  return { ok: false, output: skippedNoProject4() };
@@ -42339,8 +42438,19 @@ var scopesCheck2 = {
42339
42438
  };
42340
42439
  }
42341
42440
  const granted = new Set(conn.scopes ?? []);
42342
- const missing = REQUIRED_GSC_SCOPES.filter((scope) => !granted.has(scope));
42343
- if (missing.length === 0) {
42441
+ const hasFullGscScope = granted.has(GSC_SCOPE);
42442
+ const hasReadOnlyGscScope = granted.has(GSC_READONLY_SCOPE);
42443
+ const hasIndexingScope = granted.has(INDEXING_SCOPE);
42444
+ if (!hasFullGscScope && !hasReadOnlyGscScope) {
42445
+ return {
42446
+ status: CheckStatuses.fail,
42447
+ code: "google.auth.required-scope-missing",
42448
+ summary: "Neither the full nor read-only Search Console scope is granted, so GSC reads will fail.",
42449
+ remediation: `Reconnect to grant a Search Console scope: \`canonry google connect ${ctx.project.name} --type gsc\`.`,
42450
+ details: { granted: [...granted], missing: [GSC_SCOPE, GSC_READONLY_SCOPE] }
42451
+ };
42452
+ }
42453
+ if (hasFullGscScope && hasIndexingScope) {
42344
42454
  return {
42345
42455
  status: CheckStatuses.ok,
42346
42456
  code: "google.auth.scopes-ok",
@@ -42349,14 +42459,22 @@ var scopesCheck2 = {
42349
42459
  details: { granted: [...granted] }
42350
42460
  };
42351
42461
  }
42352
- const gscMissing = missing.includes(GSC_SCOPE);
42353
- const indexingOnlyMissing = !gscMissing && missing.includes(INDEXING_SCOPE) && missing.length === 1;
42462
+ if (!hasFullGscScope) {
42463
+ const missing = [GSC_SCOPE, ...hasIndexingScope ? [] : [INDEXING_SCOPE]];
42464
+ return {
42465
+ status: CheckStatuses.warn,
42466
+ code: "google.auth.sitemap-write-scope-missing",
42467
+ summary: hasIndexingScope ? "The read-only Search Console scope supports existing GSC reads, but sitemap submission is unavailable." : "The read-only Search Console scope supports existing GSC reads, but sitemap submission and the Indexing API are unavailable.",
42468
+ remediation: `Reconnect to grant the full Search Console webmasters scope: \`canonry google connect ${ctx.project.name} --type gsc\`.`,
42469
+ details: { granted: [...granted], missing }
42470
+ };
42471
+ }
42354
42472
  return {
42355
- status: indexingOnlyMissing ? CheckStatuses.warn : CheckStatuses.fail,
42356
- code: indexingOnlyMissing ? "google.auth.indexing-scope-missing" : "google.auth.required-scope-missing",
42357
- summary: indexingOnlyMissing ? "Indexing API scope is not granted \u2014 `canonry google request-indexing` will fail." : `Missing required scopes: ${missing.join(", ")}.`,
42473
+ status: CheckStatuses.warn,
42474
+ code: "google.auth.indexing-scope-missing",
42475
+ summary: "Indexing API scope is not granted \u2014 `canonry google request-indexing` will fail.",
42358
42476
  remediation: `Reconnect to grant missing scopes: \`canonry google connect ${ctx.project.name} --type gsc\`.`,
42359
- details: { granted: [...granted], missing }
42477
+ details: { granted: [...granted], missing: [INDEXING_SCOPE] }
42360
42478
  };
42361
42479
  }
42362
42480
  };
@@ -43710,7 +43828,7 @@ function upsertDomainClassifications(db, projectId, sessionId, competitorMap) {
43710
43828
  if (competitorMap.length === 0) return;
43711
43829
  const now = (/* @__PURE__ */ new Date()).toISOString();
43712
43830
  for (const entry of competitorMap) {
43713
- const domain = normalizeDomain(entry.domain);
43831
+ const domain = hostOf(entry.domain) ?? "";
43714
43832
  if (!domain) continue;
43715
43833
  db.insert(domainClassifications).values({
43716
43834
  id: crypto31.randomUUID(),
@@ -44377,38 +44495,20 @@ function createLogger(module) {
44377
44495
  }
44378
44496
 
44379
44497
  // src/citation-utils.ts
44380
- function domainMatches(domain, canonicalDomain) {
44381
- const normalized = normalizeProjectDomain(canonicalDomain);
44382
- const d = normalizeProjectDomain(domain);
44383
- return d === normalized || d.endsWith(`.${normalized}`);
44384
- }
44385
44498
  function pickProjectCitedDomain(citedDomains, projectDomains) {
44386
44499
  for (const cited of citedDomains) {
44387
- if (projectDomains.some((pd) => domainMatches(cited, pd))) return cited;
44500
+ if (projectDomains.some((pd) => hostMatchesDomain(cited, pd))) return cited;
44388
44501
  }
44389
44502
  return void 0;
44390
44503
  }
44391
44504
  function determineCitationState(normalized, domains) {
44392
44505
  for (const canonicalDomain of domains) {
44393
- const bareDomain = normalizeProjectDomain(canonicalDomain);
44394
- if (normalized.citedDomains.some((d) => domainMatches(d, bareDomain))) {
44506
+ if (normalized.citedDomains.some((d) => hostMatchesDomain(d, canonicalDomain))) {
44395
44507
  return "cited";
44396
44508
  }
44397
- const lowerDomain = bareDomain.toLowerCase();
44398
44509
  for (const source of normalized.groundingSources) {
44399
- try {
44400
- const uri = source.uri.toLowerCase();
44401
- if (lowerDomain.includes(".") && uri.includes(lowerDomain)) {
44402
- return "cited";
44403
- }
44404
- } catch {
44405
- }
44406
- if (source.title) {
44407
- const titleLower = source.title.toLowerCase().replace(/^www\./, "");
44408
- if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
44409
- return "cited";
44410
- }
44411
- }
44510
+ if (hostMatchesDomain(source.uri, canonicalDomain)) return "cited";
44511
+ if (source.title && hostMatchesDomain(source.title, canonicalDomain)) return "cited";
44412
44512
  }
44413
44513
  }
44414
44514
  return "not-cited";
@@ -44417,27 +44517,25 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
44417
44517
  const overlapSet = /* @__PURE__ */ new Set();
44418
44518
  for (const d of normalized.citedDomains) {
44419
44519
  for (const cd of competitorDomains) {
44420
- if (domainMatches(d, cd)) {
44520
+ if (hostMatchesDomain(d, cd)) {
44421
44521
  overlapSet.add(cd);
44422
44522
  }
44423
44523
  }
44424
44524
  }
44425
44525
  for (const source of normalized.groundingSources) {
44426
- const uri = source.uri.toLowerCase();
44427
44526
  for (const cd of competitorDomains) {
44428
- if (uri.includes(cd.toLowerCase())) {
44527
+ if (hostMatchesDomain(source.uri, cd)) {
44429
44528
  overlapSet.add(cd);
44430
44529
  }
44431
44530
  }
44432
44531
  }
44433
44532
  if (normalized.answerText) {
44434
- const lowerAnswer = normalized.answerText.toLowerCase();
44435
44533
  for (const cd of competitorDomains) {
44436
- if (lowerAnswer.includes(cd.toLowerCase())) {
44534
+ if (textContainsDomain(normalized.answerText, cd)) {
44437
44535
  overlapSet.add(cd);
44438
44536
  }
44439
44537
  const brand = brandLabelFromDomain(cd);
44440
- if (brand.length >= 4 && new RegExp(`\\b${escapeRegExp5(brand)}\\b`, "i").test(lowerAnswer)) {
44538
+ if (brandKeyFromText(brand).length >= 4 && textContainsBrandAlias(normalized.answerText, brand)) {
44441
44539
  overlapSet.add(cd);
44442
44540
  }
44443
44541
  }
@@ -44448,27 +44546,24 @@ function computeCitedCompetitorDomains(citedDomains, competitorDomains) {
44448
44546
  const citedCompetitors = /* @__PURE__ */ new Set();
44449
44547
  for (const citedDomain of citedDomains) {
44450
44548
  for (const competitorDomain of competitorDomains) {
44451
- if (domainMatches(citedDomain, competitorDomain)) citedCompetitors.add(competitorDomain);
44549
+ if (hostMatchesDomain(citedDomain, competitorDomain)) citedCompetitors.add(competitorDomain);
44452
44550
  }
44453
44551
  }
44454
44552
  return [...citedCompetitors];
44455
44553
  }
44456
- function escapeRegExp5(value) {
44457
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44458
- }
44459
44554
  function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, competitorDomains, ownBrandNames = []) {
44460
44555
  if (!answerText || answerText.length < 20) return [];
44461
- const ownBrandKeys = new Set(
44462
- ownDomains.flatMap((domain) => collectBrandKeysFromDomain(domain))
44556
+ const ownBrandAliases = new Set(
44557
+ ownDomains.flatMap((domain) => collectBrandAliasesFromDomain(domain))
44463
44558
  );
44464
44559
  for (const name of ownBrandNames) {
44465
- const key = brandKeyFromText(name);
44466
- if (key.length >= 4) ownBrandKeys.add(key);
44560
+ if (brandKeyFromText(name).length >= 4) ownBrandAliases.add(name);
44467
44561
  }
44468
- const knownCompetitorKeys = new Set(
44469
- [...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandKeysFromDomain(domain)).filter((key) => !ownBrandKeys.has(key))
44562
+ const ownKeys = new Set([...ownBrandAliases].map(brandKeyFromText));
44563
+ const knownCompetitorAliases = new Set(
44564
+ [...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandAliasesFromDomain(domain)).filter((alias) => !ownKeys.has(brandKeyFromText(alias)))
44470
44565
  );
44471
- if (knownCompetitorKeys.size === 0) return [];
44566
+ if (knownCompetitorAliases.size === 0) return [];
44472
44567
  const candidatePatterns = [
44473
44568
  /^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013-]/gm,
44474
44569
  /\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50})\*\*/g,
@@ -44518,8 +44613,8 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
44518
44613
  if (!candidateKey) continue;
44519
44614
  if (genericKeys.has(candidateKey)) continue;
44520
44615
  if (candidate.split(/\s+/).length > 6) continue;
44521
- if (matchesBrandKey(candidateKey, ownBrandKeys)) continue;
44522
- if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue;
44616
+ if (matchesBrandAlias(candidate, ownBrandAliases)) continue;
44617
+ if (!matchesBrandAlias(candidate, knownCompetitorAliases)) continue;
44523
44618
  if (!seen.has(candidateKey)) seen.set(candidateKey, candidate);
44524
44619
  }
44525
44620
  }
@@ -44528,27 +44623,17 @@ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, com
44528
44623
  function cleanCandidateName(candidate) {
44529
44624
  return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
44530
44625
  }
44531
- function collectBrandKeysFromDomain(domain) {
44626
+ function collectBrandAliasesFromDomain(domain) {
44532
44627
  const reg = registrableDomain(domain);
44533
- if (!reg) {
44534
- const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
44535
- const fallback = hostname.replace(/[^a-z0-9]/gi, "").toLowerCase();
44536
- return fallback.length >= 4 ? [fallback] : [];
44537
- }
44538
- const keys = /* @__PURE__ */ new Set();
44539
- const fullKey = reg.replace(/[^a-z0-9]/gi, "").toLowerCase();
44540
- if (fullKey.length >= 4) keys.add(fullKey);
44541
- const brand = brandLabelFromDomain(reg).replace(/[^a-z0-9]/gi, "").toLowerCase();
44542
- if (brand.length >= 4) keys.add(brand);
44543
- return [...keys];
44544
- }
44545
- function matchesBrandKey(candidateKey, brandKeys) {
44546
- for (const brandKey of brandKeys) {
44547
- if (candidateKey === brandKey) return true;
44548
- if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true;
44549
- if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true;
44550
- }
44551
- return false;
44628
+ if (!reg) return [];
44629
+ const aliases = /* @__PURE__ */ new Set();
44630
+ if (brandKeyFromText(reg).length >= 4) aliases.add(reg);
44631
+ const brand = brandLabelFromDomain(reg);
44632
+ if (brandKeyFromText(brand).length >= 4) aliases.add(brand);
44633
+ return [...aliases];
44634
+ }
44635
+ function matchesBrandAlias(candidate, aliases) {
44636
+ return [...aliases].some((alias) => textContainsBrandAlias(candidate, alias));
44552
44637
  }
44553
44638
 
44554
44639
  // src/intelligence-service.ts