@canonry/canonry 4.161.0 → 4.163.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.
Files changed (29) hide show
  1. package/assets/assets/{AuditHistoryPanel-C_1twF00.js → AuditHistoryPanel-FDQx9TS5.js} +1 -1
  2. package/assets/assets/{BacklinksPage-CfAuMCaN.js → BacklinksPage-DVvsQsdk.js} +1 -1
  3. package/assets/assets/ChartPrimitives-BoYe2M-i.js +1 -0
  4. package/assets/assets/{HistoryPage-DrRxOL3j.js → HistoryPage-31HR6TIJ.js} +1 -1
  5. package/assets/assets/{MeasurementPropertyPage-BFtxx5Jw.js → MeasurementPropertyPage-Dps7MF7r.js} +1 -1
  6. package/assets/assets/ProjectPage-BijTUrpk.js +9 -0
  7. package/assets/assets/{RunRow-DVZXibR2.js → RunRow-9DiFrBlc.js} +1 -1
  8. package/assets/assets/{RunsPage-CAUMy7By.js → RunsPage-DQlAc4J9.js} +1 -1
  9. package/assets/assets/{SettingsPage-CcSOmQAl.js → SettingsPage-c1nxJsbL.js} +1 -1
  10. package/assets/assets/SiteHealthSection-0B0oUUQx.js +4 -0
  11. package/assets/assets/{TrafficPage-BWM33jnJ.js → TrafficPage-HzwQyLzl.js} +1 -1
  12. package/assets/assets/{TrafficSourceDetailPage-DvEZ_JGY.js → TrafficSourceDetailPage-BTVAQjRd.js} +1 -1
  13. package/assets/assets/{extract-error-message-CnKOJkYB.js → extract-error-message-B5bhJRmf.js} +1 -1
  14. package/assets/assets/{index-CACRaOmZ.css → index-CHluSPlN.css} +1 -1
  15. package/assets/assets/{index-CQGAqmDx.js → index-CQsvCUM0.js} +20 -20
  16. package/assets/assets/{react-sigma_core.esm.min-CHCAIMBW.js → react-sigma_core.esm.min-6BxcniCA.js} +1 -1
  17. package/assets/index.html +2 -2
  18. package/dist/{chunk-EXDO7EPL.js → chunk-ESH3LPYQ.js} +3456 -3231
  19. package/dist/{chunk-J27J2DF6.js → chunk-G5UZTODF.js} +143 -34
  20. package/dist/{chunk-HCGTKQCG.js → chunk-INXI6PO7.js} +3 -3
  21. package/dist/{chunk-W6OWEPZG.js → chunk-MRHYSEZB.js} +80 -30
  22. package/dist/cli.js +29 -8
  23. package/dist/index.js +4 -4
  24. package/dist/{intelligence-service-HKDIVTZB.js → intelligence-service-AFAFGLCM.js} +2 -2
  25. package/dist/mcp.js +2 -2
  26. package/package.json +11 -11
  27. package/assets/assets/ChartPrimitives-DXVLCdBi.js +0 -1
  28. package/assets/assets/ProjectPage-Djvhx4HD.js +0 -12
  29. package/assets/assets/SiteHealthSection-DYjEWd1I.js +0 -4
@@ -183,6 +183,7 @@ import {
183
183
  buildHarvestAnchorTerms,
184
184
  buildMeasurementExecutionIdentity,
185
185
  buildMeasurementRunManifestV1,
186
+ calendarDateRange,
186
187
  calendarMonthBounds,
187
188
  canonicalMeasurementExecutionIdentityJson,
188
189
  canonicalMeasurementPlanJson,
@@ -324,6 +325,7 @@ import {
324
325
  keywordDtoSchema,
325
326
  keywordGenerateRequestSchema,
326
327
  latestProjectRunDtoSchema,
328
+ linearTrend,
327
329
  locationContextSchema,
328
330
  loginRequestSchema,
329
331
  mapWithConcurrency,
@@ -537,8 +539,9 @@ import {
537
539
  surfaceClassFromCompetitorType,
538
540
  surfaceClassLabel,
539
541
  telemetryEventAcceptedDtoSchema,
540
- templateLinkDetection,
541
542
  templateLinkRatio,
543
+ templateLinkSource,
544
+ templateLinkUbiquityAvailable,
542
545
  textContainsAnyBrandAlias,
543
546
  textContainsBrandAlias,
544
547
  textContainsDomain,
@@ -578,7 +581,7 @@ import {
578
581
  wordpressSchemaDeployResultDtoSchema,
579
582
  wordpressSchemaStatusResultDtoSchema,
580
583
  wordpressStatusDtoSchema
581
- } from "./chunk-EXDO7EPL.js";
584
+ } from "./chunk-ESH3LPYQ.js";
582
585
 
583
586
  // src/intelligence-service.ts
584
587
  import { eq as eq59, desc as desc26, asc as asc11, and as and47, ne as ne7, or as or13, inArray as inArray21, gte as gte14, lte as lte12 } from "drizzle-orm";
@@ -1342,6 +1345,14 @@ var siteCrawlSnapshots = sqliteTable("site_crawl_snapshots", {
1342
1345
  * pass for "this site has no nav".
1343
1346
  */
1344
1347
  templateDetection: text("template_detection"),
1348
+ /**
1349
+ * The crawler's landmark ruleset version, from the crawl summary's
1350
+ * `linkPlacementRulesetVersion`. NULL means this scan recorded no placement
1351
+ * at all, which is the one thing that decides whether its links could be
1352
+ * classified by where they sit or only by how often they repeat. It cannot be
1353
+ * backfilled: a pre-4.7.0 crawl never observed placement.
1354
+ */
1355
+ linkPlacementRulesetVersion: text("link_placement_ruleset_version"),
1345
1356
  createdAt: text("created_at").notNull(),
1346
1357
  updatedAt: text("updated_at").notNull()
1347
1358
  }, (table) => [
@@ -1435,28 +1446,46 @@ var siteCrawlEdges = sqliteTable("site_crawl_edges", {
1435
1446
  nofollowOccurrences: integer("nofollow_occurrences").notNull().default(0),
1436
1447
  anchors: text("anchors", { mode: "json" }).$type().notNull().default([]),
1437
1448
  /**
1438
- * True when EVERY (target page, anchor) pair on this link appears on at
1439
- * least `TEMPLATE_LINK_RATIO_THRESHOLD` of the attempt's fetched pages: a
1440
- * nav, header, or footer link rather than an editorial one. Computed once at
1441
- * publish time by the contract's `classifyTemplateLinks`, so the map, the
1442
- * API filters, and the agents all read the same decision.
1449
+ * True for a nav, header, or footer link rather than an editorial one.
1450
+ * Computed once at publish time by the contract's `classifyTemplateLinkEdge`,
1451
+ * so the map, the API filters, and the agents all read the same decision.
1443
1452
  *
1444
- * One row aggregates every anchor between the same two pages, so a link that
1445
- * is BOTH a footer link and an in-prose link is one row with two anchors. It
1446
- * is editorial, because the in-prose link exists.
1453
+ * The rule is DOM placement where the crawl recorded it: any occurrence
1454
+ * inside a main or article landmark makes the whole link editorial, and
1455
+ * navigation with no content occurrence makes it chrome. Where the page
1456
+ * declares no landmark that answers the question, `template_ratio` below is
1457
+ * the fallback.
1447
1458
  *
1448
- * NULL means this row was never classified, which reads report as
1449
- * `unavailable-legacy-scan`. It never means "not a template link": the
1450
- * too-few-pages guard writes an explicit `false` and records its reason on
1451
- * the snapshot instead.
1459
+ * Classification writes a STRICT BOOLEAN and never a NULL. A link no rule
1460
+ * could measure is a real `false` whose `templateSource` reads `unmeasured`,
1461
+ * because "not shown to be chrome" is what a content link means here. A third
1462
+ * stored state would be invisible to every reader that treats this as a
1463
+ * boolean (the layout input, the graph sample, the totals, the map legend,
1464
+ * the inspector tiles) while only the two SQL link filters honoured it, and
1465
+ * one definition of a content link is the whole point of the column.
1466
+ *
1467
+ * NULL therefore means exactly one thing: this row predates classification
1468
+ * entirely, which reads report as `unavailable-legacy-scan`. It never means
1469
+ * "not a template link".
1452
1470
  */
1453
1471
  isTemplate: integer("is_template", { mode: "boolean" }),
1454
1472
  /**
1455
- * Share of fetched pages carrying this link's LEAST ubiquitous anchor, which
1456
- * is the one that decides `is_template`. NULL when the link has no
1457
- * measurable pair (an unresolved target or no anchor).
1473
+ * Share of fetched pages carrying this link's LEAST ubiquitous anchor. NULL
1474
+ * when the link has no measurable pair (an unresolved target or no anchor)
1475
+ * OR when DOM placement decided it, because it is the fallback rule's own
1476
+ * evidence and had no vote.
1458
1477
  */
1459
1478
  templateRatio: real("template_ratio"),
1479
+ /**
1480
+ * Occurrences of this link split by where they sat in the source page, from
1481
+ * the crawler's `placementOccurrences`. NULL means this scan recorded no
1482
+ * placement for this link, which is a real state (a pre-4.7.0 crawl) and
1483
+ * never zeros: `{0, 0, 0}` is what a redirect or canonical edge legitimately
1484
+ * carries, since a non-anchor edge has no position in a page.
1485
+ */
1486
+ placementNavigationOccurrences: integer("placement_navigation_occurrences"),
1487
+ placementContentOccurrences: integer("placement_content_occurrences"),
1488
+ placementUnknownOccurrences: integer("placement_unknown_occurrences"),
1460
1489
  createdAt: text("created_at").notNull(),
1461
1490
  updatedAt: text("updated_at").notNull()
1462
1491
  }, (table) => [
@@ -2999,7 +3028,7 @@ function* streamSiteCrawlTemplateLinkEdges(tx, attemptId, batchSize = 2e3) {
2999
3028
  let after = "";
3000
3029
  for (; ; ) {
3001
3030
  const rows = tx.all(sql2`
3002
- SELECT edge_key, source_node_key, target_node_key, anchors
3031
+ SELECT edge_key, source_node_key, target_node_key, anchors, relation
3003
3032
  FROM site_crawl_edges
3004
3033
  WHERE attempt_id = ${attemptId} AND edge_key > ${after}
3005
3034
  ORDER BY edge_key
@@ -3010,7 +3039,8 @@ function* streamSiteCrawlTemplateLinkEdges(tx, attemptId, batchSize = 2e3) {
3010
3039
  edgeKey: row.edge_key,
3011
3040
  sourceNodeKey: row.source_node_key,
3012
3041
  targetNodeKey: row.target_node_key,
3013
- anchors: parseJsonColumn(row.anchors, [])
3042
+ anchors: parseJsonColumn(row.anchors, []),
3043
+ relation: row.relation
3014
3044
  }));
3015
3045
  after = rows[rows.length - 1].edge_key;
3016
3046
  if (rows.length < batchSize) return;
@@ -3033,7 +3063,8 @@ function backfillSiteCrawlTemplateLinks(tx) {
3033
3063
  ON s.project_id = a.project_id AND s.run_id = a.run_id AND s.attempt_id = a.id
3034
3064
  `));
3035
3065
  for (const attempt of attempts) {
3036
- const detection = templateLinkDetection(attempt.pages_fetched);
3066
+ const ubiquityAvailable = templateLinkUbiquityAvailable(attempt.pages_fetched);
3067
+ const detection = ubiquityAvailable ? SiteHealthTemplateDetections.applied : SiteHealthTemplateDetections["unavailable-too-few-pages"];
3037
3068
  const scope = { attemptId: attempt.attempt_id, projectId: attempt.project_id, runId: attempt.run_id };
3038
3069
  tx.run(sql2`
3039
3070
  UPDATE site_crawl_edges SET is_template = 0, template_ratio = NULL
@@ -6204,6 +6235,35 @@ var MIGRATION_VERSIONS = [
6204
6235
  updated_at TEXT NOT NULL
6205
6236
  )`
6206
6237
  ]
6238
+ },
6239
+ {
6240
+ version: 138,
6241
+ name: "site-crawl-link-placement",
6242
+ // The crawler (aeo-audit 4.7.0) now reports where each link occurrence sat
6243
+ // in its page, from the page's own landmarks. That is ground truth about a
6244
+ // link, and it replaces ubiquity as the rule that separates nav from
6245
+ // content: ubiquity keys on (target URL, anchor text), so it cannot see an
6246
+ // editorial link whose anchor text matches the nav's, which is the common
6247
+ // case because good anchor text reuses the destination's name. Measured on
6248
+ // canonry.ai: 53 newly added editorial links moved the content-link count
6249
+ // by ZERO.
6250
+ //
6251
+ // These columns are deliberately nullable and deliberately NOT backfilled.
6252
+ // A crawl captured before the landmark ruleset existed never observed
6253
+ // placement, so there is nothing to derive from and any value written here
6254
+ // would be invented. Those scans keep the ubiquity classification they
6255
+ // already have, and their snapshots keep a NULL ruleset version, which is
6256
+ // exactly what makes a read say `applied` rather than `applied-placement`.
6257
+ // Re-running a scan is what upgrades it.
6258
+ //
6259
+ // Idempotent: `ALTER TABLE ADD COLUMN` errors with "duplicate column name",
6260
+ // which the runner already swallows, and there is no data pass to repeat.
6261
+ statements: [
6262
+ `ALTER TABLE site_crawl_edges ADD COLUMN placement_navigation_occurrences INTEGER`,
6263
+ `ALTER TABLE site_crawl_edges ADD COLUMN placement_content_occurrences INTEGER`,
6264
+ `ALTER TABLE site_crawl_edges ADD COLUMN placement_unknown_occurrences INTEGER`,
6265
+ `ALTER TABLE site_crawl_snapshots ADD COLUMN link_placement_ruleset_version TEXT`
6266
+ ]
6207
6267
  }
6208
6268
  ];
6209
6269
  function addRunsMeasurementPlanVersionForeignKey(tx) {
@@ -28443,7 +28503,7 @@ var crawlLimitParameter = {
28443
28503
  var linkKindParameter = {
28444
28504
  name: "linkKind",
28445
28505
  in: "query",
28446
- description: 'Restrict links to `content` (excludes nav, header, and footer links) or `template` (only those). Defaults to `all`. A scan whose links were never classified matches neither, which is why every link response also carries `templateDetection`: an empty `content` list means "could not tell" when detection is not `applied`.',
28506
+ description: 'Restrict links to `content` (excludes nav, header, and footer links) or `template` (only those). Defaults to `all`. A link neither rule could classify matches neither filter, which is why every link response also carries `templateDetection` and every edge carries `templateSource`: an empty `content` list means "could not tell" under an `unavailable-*` detection, and `templateSource` says whether a link that IS classified was decided by where it sits in the page (`placement`) or by how many pages repeat it (`ubiquity`).',
28447
28507
  schema: { type: "string", enum: ["all", "content", "template"], default: "all" }
28448
28508
  };
28449
28509
  var measurementOverviewSortParameter = {
@@ -34008,7 +34068,7 @@ var routeCatalog = [
34008
34068
  method: "get",
34009
34069
  path: "/api/v1/projects/{name}/technical-aeo/graph",
34010
34070
  summary: "Get a persisted Site Health graph projection",
34011
- description: "Returns the deterministic graph projection computed once when the latest complete or selected historical crawl was published. ForceAtlas2 positions and the exact internal-anchor edge sample are persisted, so reads run no layout physics and never rescan the crawl edge table. Nav, header, and footer links are excluded from the layout physics but retained in the sample and tagged `isTemplate`, so a viewer can draw them without a refetch and without any node moving. The response is bounded to 20,000 nodes / 50,000 edges; `layout`, `omittedNodes`, `omittedEdges`, and `sampled` disclose legacy/unavailable layouts and intentional truncation, and `templateDetection` says whether template links could be told apart at all.",
34071
+ description: "Returns the deterministic graph projection computed once when the latest complete or selected historical crawl was published. ForceAtlas2 positions and the exact internal-anchor edge sample are persisted, so reads run no layout physics and never rescan the crawl edge table. Nav, header, and footer links are excluded from the layout physics but retained in the sample and tagged `isTemplate`, so a viewer can draw them without a refetch and without any node moving. The response is bounded to 20,000 nodes / 50,000 edges; `layout`, `omittedNodes`, `omittedEdges`, and `sampled` disclose legacy/unavailable layouts and intentional truncation, and `templateDetection` says whether template links could be told apart at all and by which rule.",
34012
34072
  tags: ["technical-aeo"],
34013
34073
  parameters: [
34014
34074
  nameParameter,
@@ -34148,7 +34208,7 @@ var routeCatalog = [
34148
34208
  method: "get",
34149
34209
  path: "/api/v1/projects/{name}/technical-aeo/internal-links",
34150
34210
  summary: "List persisted internal crawl links",
34151
- description: "Cursor-paged internal edges for the latest or selected crawl. Optional source/target/followability/link-kind filters remain project-, run-, and attempt-scoped. `total` counts exactly what the requested filters match, and `templateDetection` says whether nav and footer links could be told apart for this scan.",
34211
+ description: "Cursor-paged internal edges for the latest or selected crawl. Optional source/target/followability/link-kind filters remain project-, run-, and attempt-scoped. `total` counts exactly what the requested filters match, `templateDetection` says whether nav and footer links could be told apart for this scan and by which rule, and the `templateSource` plus `placementOccurrences` on each edge show the evidence behind its own classification.",
34152
34212
  tags: ["technical-aeo"],
34153
34213
  parameters: [
34154
34214
  nameParameter,
@@ -37627,6 +37687,7 @@ async function googleRoutes(app, opts) {
37627
37687
  clicks: sql13`COALESCE(SUM(${gscSearchData.clicks}), 0)`,
37628
37688
  impressions: sql13`COALESCE(SUM(${gscSearchData.impressions}), 0)`
37629
37689
  }).from(gscSearchData).where(and30(...conditions)).groupBy(gscSearchData.date).orderBy(gscSearchData.date).all();
37690
+ const propertyDates = new Set(dailyTotals.map((d) => d.date));
37630
37691
  const daily = mergeGscDailyTotalsWithFallback(
37631
37692
  dailyTotals,
37632
37693
  dimensionedRows.map((r) => ({
@@ -37639,15 +37700,36 @@ async function googleRoutes(app, opts) {
37639
37700
  date: d.date,
37640
37701
  clicks: d.clicks,
37641
37702
  impressions: d.impressions,
37642
- ctr: d.impressions > 0 ? d.clicks / d.impressions : 0
37703
+ ctr: d.impressions > 0 ? d.clicks / d.impressions : 0,
37704
+ position: propertyDates.has(d.date) ? d.position : null
37643
37705
  }));
37644
37706
  const totalClicks = daily.reduce((sum, d) => sum + d.clicks, 0);
37645
37707
  const totalImpressions = daily.reduce((sum, d) => sum + d.impressions, 0);
37708
+ let positionWeight = 0;
37709
+ let positionWeighted = 0;
37710
+ for (const d of daily) {
37711
+ if (d.position === null || d.impressions <= 0) continue;
37712
+ positionWeight += d.impressions;
37713
+ positionWeighted += d.position * d.impressions;
37714
+ }
37715
+ const byDate = new Map(daily.map((d) => [d.date, d]));
37716
+ const denseDates = daily.length === 0 ? [] : calendarDateRange(daily[0].date, daily[daily.length - 1].date);
37717
+ const densify = (pick) => denseDates.map((date) => {
37718
+ const row = byDate.get(date);
37719
+ return row ? pick(row) : null;
37720
+ });
37646
37721
  return {
37647
37722
  totals: {
37648
37723
  clicks: totalClicks,
37649
37724
  impressions: totalImpressions,
37650
37725
  ctr: totalImpressions > 0 ? totalClicks / totalImpressions : 0,
37726
+ position: positionWeight > 0 ? positionWeighted / positionWeight : null,
37727
+ /**
37728
+ * How many days actually carried a property-level position. Below
37729
+ * `days`, the position figure describes a SUBSET of the window, and a
37730
+ * surface must say so rather than present it as the window's average.
37731
+ */
37732
+ positionDays: daily.filter((d) => d.position !== null).length,
37651
37733
  days: daily.length
37652
37734
  },
37653
37735
  daily,
@@ -37661,7 +37743,16 @@ async function googleRoutes(app, opts) {
37661
37743
  // contain the rows beside it. When only one side is given, the other is
37662
37744
  // dropped rather than reported reversed: an absent bound is honest about
37663
37745
  // being unspecified, a reversed pair is not.
37664
- window: resolveReportedWindow(resolvedWindow, startDate, endDate)
37746
+ window: resolveReportedWindow(resolvedWindow, startDate, endDate),
37747
+ // Fitted server-side so the dashboard, the CLI, and the report all draw
37748
+ // the SAME line (UI/CLI parity — a chart-only regression is invisible to
37749
+ // an agent), and fitted over the CALENDAR, not over the rows.
37750
+ trends: {
37751
+ clicks: linearTrend(densify((d) => d.clicks)),
37752
+ impressions: linearTrend(densify((d) => d.impressions)),
37753
+ ctr: linearTrend(densify((d) => d.ctr)),
37754
+ position: linearTrend(densify((d) => d.position))
37755
+ }
37665
37756
  };
37666
37757
  });
37667
37758
  app.get("/projects/:name/google/gsc/top-pages", async (request) => {
@@ -59091,7 +59182,15 @@ function countLivePageHealthChecks(auditFields) {
59091
59182
  }
59092
59183
  return checks;
59093
59184
  }
59094
- function mapCrawlEdge(row) {
59185
+ function mapPlacementOccurrences(row) {
59186
+ const navigation = row.placementNavigationOccurrences;
59187
+ const content = row.placementContentOccurrences;
59188
+ const unknown = row.placementUnknownOccurrences;
59189
+ if (navigation == null || content == null || unknown == null) return null;
59190
+ return { navigation, content, unknown };
59191
+ }
59192
+ function mapCrawlEdge(row, detection) {
59193
+ const placementOccurrences = mapPlacementOccurrences(row);
59095
59194
  return {
59096
59195
  edgeKey: row.edgeKey,
59097
59196
  sourceNodeKey: row.sourceNodeKey,
@@ -59106,7 +59205,13 @@ function mapCrawlEdge(row) {
59106
59205
  nofollowOccurrences: row.nofollowOccurrences,
59107
59206
  anchors: row.anchors,
59108
59207
  isTemplate: row.isTemplate,
59109
- templateRatio: row.templateRatio
59208
+ templateRatio: row.templateRatio,
59209
+ templateSource: templateLinkSource(detection, {
59210
+ isTemplate: row.isTemplate,
59211
+ templateRatio: row.templateRatio,
59212
+ placementOccurrences
59213
+ }),
59214
+ placementOccurrences
59110
59215
  };
59111
59216
  }
59112
59217
  function templateDetectionOf(value) {
@@ -59739,7 +59844,8 @@ async function technicalAeoRoutes(app, opts) {
59739
59844
  distance: distances.get(row.nodeKey) ?? 0,
59740
59845
  relationToFocus: row.nodeKey === focus.nodeKey ? "focus" : directRelations.get(row.nodeKey) ?? "transitive"
59741
59846
  })).sort((a, b) => a.distance - b.distance || a.nodeKey.localeCompare(b.nodeKey));
59742
- const edges = [...edgeRows.values()].filter((edge) => edge.targetNodeKey && persistedNodeKeys.has(edge.sourceNodeKey) && persistedNodeKeys.has(edge.targetNodeKey)).sort((a, b) => a.edgeKey.localeCompare(b.edgeKey)).map(mapCrawlEdge);
59847
+ const subgraphDetection = templateDetectionOf(snapshot.templateDetection);
59848
+ const edges = [...edgeRows.values()].filter((edge) => edge.targetNodeKey && persistedNodeKeys.has(edge.sourceNodeKey) && persistedNodeKeys.has(edge.targetNodeKey)).sort((a, b) => a.edgeKey.localeCompare(b.edgeKey)).map((row) => mapCrawlEdge(row, subgraphDetection));
59743
59849
  const omittedNodes = omittedNodeKeys.size + Math.max(0, distances.size - pageRows.length);
59744
59850
  const omittedEdges = omittedEdgeKeys.size + (queryTruncated ? 1 : 0) + Math.max(0, edgeRows.size - edges.length);
59745
59851
  const truncated = omittedNodes > 0 || omittedEdges > 0;
@@ -59888,6 +59994,7 @@ async function technicalAeoRoutes(app, opts) {
59888
59994
  inArray20(siteCrawlPages.nodeKey, pathKeys)
59889
59995
  )).all();
59890
59996
  const pageByKey = new Map(pathRows.map((row) => [row.nodeKey, row]));
59997
+ const pathDetection = templateDetectionOf(snapshot.templateDetection);
59891
59998
  return {
59892
59999
  project: project.name,
59893
60000
  runId: snapshot.runId,
@@ -59899,7 +60006,7 @@ async function technicalAeoRoutes(app, opts) {
59899
60006
  maxDepth,
59900
60007
  visitedNodes: visited.size,
59901
60008
  nodes: pathKeys.map((key) => mapCrawlPage(pageByKey.get(key))),
59902
- edges: pathEdges.map(mapCrawlEdge)
60009
+ edges: pathEdges.map((row) => mapCrawlEdge(row, pathDetection))
59903
60010
  };
59904
60011
  });
59905
60012
  app.get("/projects/:name/technical-aeo/changes", async (request) => {
@@ -60177,8 +60284,10 @@ async function technicalAeoRoutes(app, opts) {
60177
60284
  )).all();
60178
60285
  const beforePages = new Map(pageRowsFor(beforeScope).map((row) => [row.nodeKey, mapCrawlPage(row)]));
60179
60286
  const afterPages = new Map(pageRowsFor(afterScope).map((row) => [row.nodeKey, mapCrawlPage(row)]));
60180
- const beforeLinks = new Map(edgeRowsFor(beforeScope).map((row) => [row.edgeKey, mapCrawlEdge(row)]));
60181
- const afterLinks = new Map(edgeRowsFor(afterScope).map((row) => [row.edgeKey, mapCrawlEdge(row)]));
60287
+ const beforeDetection = templateDetectionOf(beforeSnapshot.templateDetection);
60288
+ const afterDetection = templateDetectionOf(afterSnapshot.templateDetection);
60289
+ const beforeLinks = new Map(edgeRowsFor(beforeScope).map((row) => [row.edgeKey, mapCrawlEdge(row, beforeDetection)]));
60290
+ const afterLinks = new Map(edgeRowsFor(afterScope).map((row) => [row.edgeKey, mapCrawlEdge(row, afterDetection)]));
60182
60291
  const changes = visibleKeys.map((row) => {
60183
60292
  if (row.entity === "page") {
60184
60293
  const before2 = beforePages.get(row.key) ?? null;
@@ -60469,7 +60578,7 @@ async function technicalAeoRoutes(app, opts) {
60469
60578
  nextCursor: nextOffset < total ? encodeCursor2(nextOffset) : null,
60470
60579
  templateDetection,
60471
60580
  linkKind,
60472
- edges: rows.map(mapCrawlEdge)
60581
+ edges: rows.map((row) => mapCrawlEdge(row, templateDetection))
60473
60582
  };
60474
60583
  });
60475
60584
  app.get("/projects/:name/technical-aeo/internal-links/neighbors", async (request) => {
@@ -60532,8 +60641,8 @@ async function technicalAeoRoutes(app, opts) {
60532
60641
  url: request.query.url ?? null,
60533
60642
  templateDetection,
60534
60643
  linkKind,
60535
- inbound: inboundRows.slice(0, limit).map(mapCrawlEdge),
60536
- outbound: outboundRows.slice(0, limit).map(mapCrawlEdge),
60644
+ inbound: inboundRows.slice(0, limit).map((row) => mapCrawlEdge(row, templateDetection)),
60645
+ outbound: outboundRows.slice(0, limit).map((row) => mapCrawlEdge(row, templateDetection)),
60537
60646
  inboundTruncated: inboundRows.length > limit,
60538
60647
  outboundTruncated: outboundRows.length > limit
60539
60648
  };
@@ -81,7 +81,7 @@ import {
81
81
  trafficConnectWordpressRequestSchema,
82
82
  trafficEventKindSchema,
83
83
  trafficSeriesGranularitySchema
84
- } from "./chunk-EXDO7EPL.js";
84
+ } from "./chunk-ESH3LPYQ.js";
85
85
 
86
86
  // src/config.ts
87
87
  import fs from "fs";
@@ -8637,7 +8637,7 @@ var technicalAeoStructureInputSchema = z3.object({
8637
8637
  limit: z3.number().int().positive().max(100).optional()
8638
8638
  });
8639
8639
  var linkKindSchema = z3.enum(["all", "content", "template"]).optional().describe(
8640
- "Restrict to content links (excludes nav, header, and footer links) or to template links only. Defaults to all. Check templateDetection before reading an empty content list as a real zero."
8640
+ "Restrict to content links (excludes nav, header, and footer links) or to template links only. Defaults to all. Check templateDetection before reading an empty content list as a real zero, and before comparing a count with an older scan: it says whether the split came from where each link sits in the page or from how many pages repeat it."
8641
8641
  );
8642
8642
  var technicalAeoInternalLinksInputSchema = z3.object({
8643
8643
  project: projectNameSchema,
@@ -10651,7 +10651,7 @@ var canonryMcpTools = [
10651
10651
  defineTool({
10652
10652
  name: "canonry_technical_aeo_internal_links",
10653
10653
  title: "List Technical AEO internal links",
10654
- description: "Read a bounded, cursor-paged list of persisted internal crawl edges. Filter by source URL, target URL, followability, or link kind. Nav, header, and footer links are marked isTemplate; templateDetection reports whether that classification ran, so an empty content-only list is not evidence of no content links. Use the neighbors tool for one page rather than loading a graph.",
10654
+ description: "Read a bounded, cursor-paged list of persisted internal crawl edges. Filter by source URL, target URL, followability, or link kind. Nav, header, and footer links are marked isTemplate; templateSource says which rule decided each one (placement, ubiquity, or unmeasured when neither had evidence) and templateDetection reports the same for the scan, so an empty content-only list is not evidence of no content links and two scans classified by different rules are never compared as if they were the same measurement. Use the neighbors tool for one page rather than loading a graph.",
10655
10655
  access: "read",
10656
10656
  tier: "monitoring",
10657
10657
  inputSchema: technicalAeoInternalLinksInputSchema,
@@ -10,7 +10,7 @@ import {
10
10
  loadConfig,
11
11
  loadConfigRaw,
12
12
  saveConfigPatch
13
- } from "./chunk-HCGTKQCG.js";
13
+ } from "./chunk-INXI6PO7.js";
14
14
  import {
15
15
  CC_CACHE_DIR,
16
16
  DUCKDB_SPEC,
@@ -139,7 +139,7 @@ import {
139
139
  siteCrawlSnapshots,
140
140
  toAlertView,
141
141
  usageCounters
142
- } from "./chunk-J27J2DF6.js";
142
+ } from "./chunk-G5UZTODF.js";
143
143
  import {
144
144
  AGENT_MEMORY_VALUE_MAX_BYTES,
145
145
  AGENT_PROVIDER_IDS,
@@ -172,7 +172,7 @@ import {
172
172
  SITE_CRAWL_GRAPH_MAX_NODES,
173
173
  SKILL_MANIFEST_FILENAME,
174
174
  SchedulableRunKinds,
175
- SiteHealthTemplateDetections,
175
+ SiteHealthLinkClassificationSources,
176
176
  SkillsClients,
177
177
  TEMPLATE_LINK_RATIO_THRESHOLD,
178
178
  TrafficEventConfidences,
@@ -193,11 +193,13 @@ import {
193
193
  bucketOnboardingCount,
194
194
  buildRunErrorFromMessages,
195
195
  classifySkillFile,
196
+ classifyTemplateLinkEdge,
196
197
  cloudflareQueueNameSchema,
197
198
  coerceSkillManifest,
198
199
  computeBacklinkSummaryMetrics,
199
200
  contentActionLabel,
200
201
  contentBriefDtoSchema,
202
+ createTemplateLinkDetectionTally,
201
203
  createTemplateLinkPairIndex,
202
204
  deriveCitedUrlCandidates,
203
205
  deriveIndexCoverage,
@@ -220,7 +222,6 @@ import {
220
222
  isGhostTelemetryEvent,
221
223
  isRetryableHttpError,
222
224
  isSelfLink,
223
- isTemplateLinkRatio,
224
225
  isVertexGroundingRedirect,
225
226
  mapWithConcurrency,
226
227
  normalizeIdTokens,
@@ -229,6 +230,7 @@ import {
229
230
  normalizeSiteAuditRunRequest,
230
231
  normalizeUrlPath,
231
232
  notFound,
233
+ observeTemplateLinkDetection,
232
234
  observeTemplateLinkEdges,
233
235
  parseMeasurementRunManifestV1,
234
236
  parseOriginList,
@@ -244,13 +246,14 @@ import {
244
246
  splitList,
245
247
  startOfDayHourInTimeZone,
246
248
  templateLinkDetection,
247
- templateLinkRatio,
249
+ templateLinkPlacementAvailable,
250
+ templateLinkUbiquityAvailable,
248
251
  textContainsBrandAlias,
249
252
  textContainsDomain,
250
253
  validationError,
251
254
  winnabilityClassLabel,
252
255
  withRetry
253
- } from "./chunk-EXDO7EPL.js";
256
+ } from "./chunk-ESH3LPYQ.js";
254
257
 
255
258
  // src/telemetry.ts
256
259
  import crypto from "crypto";
@@ -7304,7 +7307,7 @@ var COMPONENT_PACKING_SPACING_FACTOR = 3;
7304
7307
  function siteCrawlGraphLayoutSettingsFingerprint(settings) {
7305
7308
  return crypto16.createHash("sha256").update(JSON.stringify(settings)).digest("hex").slice(0, 8);
7306
7309
  }
7307
- var SITE_CRAWL_GRAPH_LAYOUT_ALGORITHM = "site-health-fa2-v4";
7310
+ var SITE_CRAWL_GRAPH_LAYOUT_ALGORITHM = "site-health-fa2-v5";
7308
7311
  var SITE_CRAWL_GRAPH_LAYOUT_VERSION = `${SITE_CRAWL_GRAPH_LAYOUT_ALGORITHM}-${siteCrawlGraphLayoutSettingsFingerprint({
7309
7312
  forceAtlas: FORCE_ATLAS_SETTINGS,
7310
7313
  noverlap: NOVERLAP_SETTINGS,
@@ -7860,6 +7863,13 @@ function deleteSiteCrawlGraphLayout(db, scope) {
7860
7863
  import { and as and11, asc as asc2, eq as eq14, gt, inArray as inArray4 } from "drizzle-orm";
7861
7864
  var TEMPLATE_LINK_READ_BATCH = 2e3;
7862
7865
  var TEMPLATE_LINK_WRITE_BATCH = 500;
7866
+ function placementOccurrencesOf(row) {
7867
+ const { placementNavigationOccurrences: navigation } = row;
7868
+ const { placementContentOccurrences: content } = row;
7869
+ const { placementUnknownOccurrences: unknown } = row;
7870
+ if (navigation == null || content == null || unknown == null) return null;
7871
+ return { navigation, content, unknown };
7872
+ }
7863
7873
  function* streamAttemptEdges(db, scope) {
7864
7874
  let after = "";
7865
7875
  for (; ; ) {
@@ -7867,7 +7877,11 @@ function* streamAttemptEdges(db, scope) {
7867
7877
  edgeKey: siteCrawlEdges.edgeKey,
7868
7878
  sourceNodeKey: siteCrawlEdges.sourceNodeKey,
7869
7879
  targetNodeKey: siteCrawlEdges.targetNodeKey,
7870
- anchors: siteCrawlEdges.anchors
7880
+ anchors: siteCrawlEdges.anchors,
7881
+ relation: siteCrawlEdges.relation,
7882
+ placementNavigationOccurrences: siteCrawlEdges.placementNavigationOccurrences,
7883
+ placementContentOccurrences: siteCrawlEdges.placementContentOccurrences,
7884
+ placementUnknownOccurrences: siteCrawlEdges.placementUnknownOccurrences
7871
7885
  }).from(siteCrawlEdges).where(and11(
7872
7886
  eq14(siteCrawlEdges.projectId, scope.projectId),
7873
7887
  eq14(siteCrawlEdges.runId, scope.runId),
@@ -7875,7 +7889,14 @@ function* streamAttemptEdges(db, scope) {
7875
7889
  gt(siteCrawlEdges.edgeKey, after)
7876
7890
  )).orderBy(asc2(siteCrawlEdges.edgeKey)).limit(TEMPLATE_LINK_READ_BATCH).all();
7877
7891
  if (rows.length === 0) return;
7878
- yield rows;
7892
+ yield rows.map((row) => ({
7893
+ edgeKey: row.edgeKey,
7894
+ sourceNodeKey: row.sourceNodeKey,
7895
+ targetNodeKey: row.targetNodeKey,
7896
+ anchors: row.anchors,
7897
+ relation: row.relation,
7898
+ placementOccurrences: placementOccurrencesOf(row)
7899
+ }));
7879
7900
  after = rows[rows.length - 1].edgeKey;
7880
7901
  if (rows.length < TEMPLATE_LINK_READ_BATCH) return;
7881
7902
  }
@@ -7885,39 +7906,56 @@ function* chunked(values, size = TEMPLATE_LINK_WRITE_BATCH) {
7885
7906
  yield values.slice(offset, offset + size);
7886
7907
  }
7887
7908
  }
7888
- function classifySiteCrawlTemplateLinks(db, scope, pagesFetched) {
7909
+ function writeGroupKey(isTemplate, templateRatio) {
7910
+ return `${isTemplate ? "1" : "0"}${templateRatio ?? ""}`;
7911
+ }
7912
+ function classifySiteCrawlTemplateLinks(db, scope, pagesFetched, placementRulesetVersion) {
7889
7913
  const scopeFilter = and11(
7890
7914
  eq14(siteCrawlEdges.projectId, scope.projectId),
7891
7915
  eq14(siteCrawlEdges.runId, scope.runId),
7892
7916
  eq14(siteCrawlEdges.attemptId, scope.attemptId)
7893
7917
  );
7894
- const detection = templateLinkDetection(pagesFetched);
7918
+ const placementAvailable = templateLinkPlacementAvailable(placementRulesetVersion);
7919
+ const ubiquityAvailable = templateLinkUbiquityAvailable(pagesFetched);
7895
7920
  db.update(siteCrawlEdges).set({ isTemplate: false, templateRatio: null }).where(scopeFilter).run();
7896
- if (detection !== SiteHealthTemplateDetections.applied) {
7897
- return { detection, templateEdgeCount: 0 };
7898
- }
7899
7921
  const index = createTemplateLinkPairIndex();
7900
- for (const batch of streamAttemptEdges(db, scope)) observeTemplateLinkEdges(index, batch);
7901
- const edgeKeysByRatio = /* @__PURE__ */ new Map();
7922
+ if (ubiquityAvailable) {
7923
+ for (const batch of streamAttemptEdges(db, scope)) observeTemplateLinkEdges(index, batch);
7924
+ }
7925
+ const edgeKeysByValue = /* @__PURE__ */ new Map();
7902
7926
  let templateEdgeCount = 0;
7927
+ let placementEdgeCount = 0;
7928
+ const tally = createTemplateLinkDetectionTally();
7903
7929
  for (const batch of streamAttemptEdges(db, scope)) {
7904
7930
  for (const edge of batch) {
7905
- const ratio = templateLinkRatio(index, pagesFetched, edge);
7906
- if (ratio == null) continue;
7907
- const group = edgeKeysByRatio.get(ratio);
7908
- if (group) group.push(edge.edgeKey);
7909
- else edgeKeysByRatio.set(ratio, [edge.edgeKey]);
7910
- if (isTemplateLinkRatio(ratio)) templateEdgeCount += 1;
7931
+ const decision = classifyTemplateLinkEdge(edge, { index, pagesFetched, placementAvailable, ubiquityAvailable });
7932
+ observeTemplateLinkDetection(tally, edge, decision);
7933
+ if (decision.source === SiteHealthLinkClassificationSources.placement) placementEdgeCount += 1;
7934
+ if (decision.isTemplate) templateEdgeCount += 1;
7935
+ if (!decision.isTemplate && decision.templateRatio == null) continue;
7936
+ const key = writeGroupKey(decision.isTemplate, decision.templateRatio);
7937
+ const group = edgeKeysByValue.get(key);
7938
+ if (group) group.edgeKeys.push(decision.edgeKey);
7939
+ else {
7940
+ edgeKeysByValue.set(key, {
7941
+ isTemplate: decision.isTemplate,
7942
+ templateRatio: decision.templateRatio,
7943
+ edgeKeys: [decision.edgeKey]
7944
+ });
7945
+ }
7911
7946
  }
7912
7947
  }
7913
7948
  const now = (/* @__PURE__ */ new Date()).toISOString();
7914
- for (const [ratio, edgeKeys] of edgeKeysByRatio) {
7915
- const isTemplate = isTemplateLinkRatio(ratio);
7916
- for (const chunk of chunked(edgeKeys)) {
7917
- db.update(siteCrawlEdges).set({ isTemplate, templateRatio: ratio, updatedAt: now }).where(and11(scopeFilter, inArray4(siteCrawlEdges.edgeKey, chunk))).run();
7949
+ for (const group of edgeKeysByValue.values()) {
7950
+ for (const chunk of chunked(group.edgeKeys)) {
7951
+ db.update(siteCrawlEdges).set({ isTemplate: group.isTemplate, templateRatio: group.templateRatio, updatedAt: now }).where(and11(scopeFilter, inArray4(siteCrawlEdges.edgeKey, chunk))).run();
7918
7952
  }
7919
7953
  }
7920
- return { detection, templateEdgeCount };
7954
+ return {
7955
+ detection: templateLinkDetection({ placementAvailable, ubiquityAvailable, tally }),
7956
+ templateEdgeCount,
7957
+ placementEdgeCount
7958
+ };
7921
7959
  }
7922
7960
 
7923
7961
  // src/execute-site-audit.ts
@@ -8230,6 +8268,11 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
8230
8268
  for (const page of pages) persistPage(tx, page, now);
8231
8269
  bindReferences(tx, changedUrls, now);
8232
8270
  };
8271
+ const edgePlacementColumns = (edge) => ({
8272
+ placementNavigationOccurrences: edge.placementOccurrences?.navigation ?? null,
8273
+ placementContentOccurrences: edge.placementOccurrences?.content ?? null,
8274
+ placementUnknownOccurrences: edge.placementOccurrences?.unknown ?? null
8275
+ });
8233
8276
  const persistEdge = (tx, edge, now) => {
8234
8277
  if (isSelfLink(edge.from, edge.to)) return;
8235
8278
  observedEdges.set(edge.key, edge);
@@ -8250,6 +8293,7 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
8250
8293
  followableOccurrences: edge.followableOccurrences,
8251
8294
  nofollowOccurrences: edge.nofollowOccurrences,
8252
8295
  anchors: edge.anchorSummaries.map((anchor) => anchor.text),
8296
+ ...edgePlacementColumns(edge),
8253
8297
  createdAt: now,
8254
8298
  updatedAt: now
8255
8299
  }).onConflictDoUpdate({
@@ -8266,6 +8310,7 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
8266
8310
  followableOccurrences: edge.followableOccurrences,
8267
8311
  nofollowOccurrences: edge.nofollowOccurrences,
8268
8312
  anchors: edge.anchorSummaries.map((anchor) => anchor.text),
8313
+ ...edgePlacementColumns(edge),
8269
8314
  updatedAt: now
8270
8315
  }
8271
8316
  }).run();
@@ -8373,16 +8418,20 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
8373
8418
  if (crawlSummary.complete && !hasAuditedPages) {
8374
8419
  throw emptyCompleteCrawlError(crawlSummary.rootUrl, crawlSummary.finalRootUrl, crawlSummary.pagesObserved);
8375
8420
  }
8421
+ const placementRulesetVersion = crawlSummary.linkPlacementRulesetVersion ?? null;
8376
8422
  const templateLinks = classifySiteCrawlTemplateLinks(
8377
8423
  db,
8378
8424
  { projectId, runId, attemptId },
8379
- crawlSummary.pagesFetched
8425
+ crawlSummary.pagesFetched,
8426
+ placementRulesetVersion
8380
8427
  );
8381
8428
  log12.info("template-links", {
8382
8429
  runId,
8383
8430
  projectId,
8384
8431
  detection: templateLinks.detection,
8385
- templateEdges: templateLinks.templateEdgeCount
8432
+ templateEdges: templateLinks.templateEdgeCount,
8433
+ placementEdges: templateLinks.placementEdgeCount,
8434
+ placementRulesetVersion
8386
8435
  });
8387
8436
  const graphLayout = await prepareSiteCrawlGraphLayout(db, {
8388
8437
  projectId,
@@ -8497,6 +8546,7 @@ async function executeSiteAudit(db, runId, projectId, opts = {}) {
8497
8546
  deadLinksChecked,
8498
8547
  deadLinksFound,
8499
8548
  templateDetection: templateLinks.detection,
8549
+ linkPlacementRulesetVersion: placementRulesetVersion,
8500
8550
  createdAt: finishedAt,
8501
8551
  updatedAt: finishedAt
8502
8552
  }).run();
@@ -9034,7 +9084,7 @@ function readStoredGroundingSources(rawResponse) {
9034
9084
  return result;
9035
9085
  }
9036
9086
  async function backfillInsightsCommand(project, opts) {
9037
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-HKDIVTZB.js");
9087
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-AFAFGLCM.js");
9038
9088
  const config = loadConfig();
9039
9089
  const db = createClient(config.database);
9040
9090
  migrate(db);