@canonry/canonry 4.158.0 → 4.159.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 (50) hide show
  1. package/README.md +2 -2
  2. package/THIRD_PARTY_NOTICES.md +102 -0
  3. package/assets/THIRD_PARTY_NOTICES.md +102 -0
  4. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +1 -0
  5. package/assets/assets/{AuditHistoryPanel-McQfjoU9.js → AuditHistoryPanel-BiloGcQA.js} +1 -1
  6. package/assets/assets/{BacklinksPage-Da8y1Wv8.js → BacklinksPage-CPM1O2bC.js} +1 -1
  7. package/assets/assets/{HistoryPage-9MJfhRyT.js → HistoryPage-1YOjtdJy.js} +1 -1
  8. package/assets/assets/{MeasurementPropertyPage-BiNAHEJW.js → MeasurementPropertyPage-CVDTY_gC.js} +1 -1
  9. package/assets/assets/ProjectPage-DnGXMx1n.js +12 -0
  10. package/assets/assets/{RunRow-DCG2LBlS.js → RunRow-fIaadH4h.js} +1 -1
  11. package/assets/assets/{RunsPage-ByWA_FSk.js → RunsPage-DfifdM5z.js} +1 -1
  12. package/assets/assets/{SettingsPage-DdSeKAKF.js → SettingsPage-efA2EEmS.js} +1 -1
  13. package/assets/assets/SiteHealthSection-BROtQGfu.js +4 -0
  14. package/assets/assets/{TrafficPage-5u8rPcBt.js → TrafficPage-Wk43Bm_q.js} +1 -1
  15. package/assets/assets/{TrafficSourceDetailPage-Dm-fK87u.js → TrafficSourceDetailPage-BhBvoLWu.js} +1 -1
  16. package/assets/assets/extract-error-message-YGzZFvWs.js +1 -0
  17. package/assets/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  18. package/assets/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  19. package/assets/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  20. package/assets/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  21. package/assets/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  22. package/assets/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  23. package/assets/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  24. package/assets/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  25. package/assets/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  26. package/assets/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  27. package/assets/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  28. package/assets/assets/index-CACRaOmZ.css +1 -0
  29. package/assets/assets/index-DmsIZQAm.js +83 -0
  30. package/assets/assets/{react-sigma_core.esm.min-ComIyDKi.js → react-sigma_core.esm.min-DM9XptbM.js} +2 -2
  31. package/assets/assets/{vendor-lucide-BtYdosYk.js → vendor-lucide-nYKv-BB7.js} +1 -1
  32. package/assets/assets/{vendor-markdown-D274_wsI.js → vendor-markdown-Bn7fRwrx.js} +1 -1
  33. package/assets/assets/{vendor-radix-BxMXSwbC.js → vendor-radix-4UOaVWza.js} +1 -1
  34. package/assets/assets/{vendor-recharts-C4ktw_aE.js → vendor-recharts-D8rt5lKn.js} +1 -1
  35. package/assets/assets/{vendor-tanstack-pFxw0eGP.js → vendor-tanstack-Dock0eH1.js} +1 -1
  36. package/assets/index.html +7 -7
  37. package/dist/{chunk-YP4K2KXH.js → chunk-5AFWHKKI.js} +776 -32
  38. package/dist/{chunk-4VZP64GZ.js → chunk-BZPBVOAM.js} +625 -262
  39. package/dist/{chunk-LK6YFHG4.js → chunk-ZEG43TY2.js} +45 -15
  40. package/dist/{chunk-DEVAEC2S.js → chunk-ZL3VY435.js} +213 -8
  41. package/dist/cli.js +42 -4
  42. package/dist/index.d.ts +9 -0
  43. package/dist/index.js +4 -4
  44. package/dist/{intelligence-service-LQ6ZRWMF.js → intelligence-service-DHY5CYWX.js} +2 -2
  45. package/dist/mcp.js +2 -2
  46. package/package.json +11 -10
  47. package/assets/assets/ProjectPage-mWSMjXrR.js +0 -15
  48. package/assets/assets/extract-error-message-CtxkAd6E.js +0 -1
  49. package/assets/assets/index-DUfg50Pl.js +0 -83
  50. package/assets/assets/index-cXb3mfeQ.css +0 -1
@@ -67,6 +67,7 @@ import {
67
67
  MeasurementEvidenceShapes,
68
68
  MeasurementPlanValidationError,
69
69
  MeasurementRunScopeError,
70
+ PROJECTS_WRITE_SCOPE,
70
71
  READ_ONLY_SCOPE,
71
72
  ResearchQueryStatuses,
72
73
  ResearchRunStatuses,
@@ -92,6 +93,8 @@ import {
92
93
  SchedulableRunKinds,
93
94
  SiteAuditTrendDirections,
94
95
  SiteCrawlFetchedStates,
96
+ SiteHealthLinkKinds,
97
+ SiteHealthTemplateDetections,
95
98
  TrafficEventConfidences,
96
99
  TrafficEventKinds,
97
100
  TrafficEvidenceKinds,
@@ -221,6 +224,7 @@ import {
221
224
  contentTargetsResponseDtoSchema,
222
225
  cosineSimilarity,
223
226
  createApiKeyRequestSchema,
227
+ createTemplateLinkPairIndex,
224
228
  createUserRequestSchema,
225
229
  createdApiKeyDtoSchema,
226
230
  dedupeReportActions,
@@ -313,6 +317,7 @@ import {
313
317
  isBrowserProvider,
314
318
  isReadOnlyKey,
315
319
  isRetryableHttpError,
320
+ isTemplateLinkRatio,
316
321
  isTrendBaseline,
317
322
  isVertexGroundingRedirect,
318
323
  isoDateDaysBeforeInTimeZone,
@@ -424,6 +429,7 @@ import {
424
429
  normalizeModelId,
425
430
  normalizeProjectAliases,
426
431
  normalizeProjectDomain,
432
+ normalizeProjectName,
427
433
  normalizeQueryText,
428
434
  normalizeSiteAuditRunRequest,
429
435
  normalizeUrlPath,
@@ -431,6 +437,7 @@ import {
431
437
  notFound,
432
438
  notImplemented,
433
439
  notificationDtoSchema,
440
+ observeTemplateLinkEdges,
434
441
  onboardingTelemetryEventSchema,
435
442
  operationInProgress,
436
443
  orderLocationsDefaultFirst,
@@ -446,6 +453,7 @@ import {
446
453
  parseWindow,
447
454
  pickClusterRepresentative,
448
455
  projectConfigSchema,
456
+ projectCreateRequestSchema,
449
457
  projectDtoSchema,
450
458
  projectOverviewDtoSchema,
451
459
  projectReportDtoSchema,
@@ -491,9 +499,11 @@ import {
491
499
  segmentCrawlerHits,
492
500
  serializeRunError,
493
501
  settingsDtoSchema,
502
+ siteAuditLivePageHealthSchema,
494
503
  siteAuditPageFactorSchema,
495
504
  siteAuditPagesResponseSchema,
496
505
  siteAuditRequestIdentity,
506
+ siteAuditRunProgressSchema,
497
507
  siteAuditRunRequestSchema,
498
508
  siteAuditRunResponseSchema,
499
509
  siteAuditScoreSchema,
@@ -509,10 +519,12 @@ import {
509
519
  siteCrawlStructureResponseSchema,
510
520
  siteCrawlSummarySchema,
511
521
  siteHealthChangesResponseSchema,
522
+ siteHealthLinkKindSchema,
512
523
  siteHealthPathResponseSchema,
513
524
  siteHealthScansResponseSchema,
514
525
  siteHealthStateSchema,
515
526
  siteHealthSubgraphResponseSchema,
527
+ siteHealthTemplateDetectionSchema,
516
528
  snapshotDiffResponseSchema,
517
529
  snapshotListResponseSchema,
518
530
  snapshotReportSchema,
@@ -524,6 +536,8 @@ import {
524
536
  surfaceClassFromCompetitorType,
525
537
  surfaceClassLabel,
526
538
  telemetryEventAcceptedDtoSchema,
539
+ templateLinkDetection,
540
+ templateLinkRatio,
527
541
  textContainsAnyBrandAlias,
528
542
  textContainsBrandAlias,
529
543
  textContainsDomain,
@@ -563,7 +577,7 @@ import {
563
577
  wordpressSchemaDeployResultDtoSchema,
564
578
  wordpressSchemaStatusResultDtoSchema,
565
579
  wordpressStatusDtoSchema
566
- } from "./chunk-DEVAEC2S.js";
580
+ } from "./chunk-ZL3VY435.js";
567
581
 
568
582
  // src/intelligence-service.ts
569
583
  import { eq as eq58, desc as desc26, asc as asc11, and as and46, ne as ne7, or as or12, inArray as inArray20, gte as gte14, lte as lte11 } from "drizzle-orm";
@@ -1313,6 +1327,13 @@ var siteCrawlSnapshots = sqliteTable("site_crawl_snapshots", {
1313
1327
  deadLinkState: text("dead_link_state").notNull().default("disabled"),
1314
1328
  deadLinksChecked: integer("dead_links_checked").notNull().default(0),
1315
1329
  deadLinksFound: integer("dead_links_found").notNull().default(0),
1330
+ /**
1331
+ * Whether nav/header/footer link detection ran for this scan, and if not,
1332
+ * why. NULL means the scan predates detection, which reads report as
1333
+ * `unavailable-legacy-scan` rather than letting an empty template-link list
1334
+ * pass for "this site has no nav".
1335
+ */
1336
+ templateDetection: text("template_detection"),
1316
1337
  createdAt: text("created_at").notNull(),
1317
1338
  updatedAt: text("updated_at").notNull()
1318
1339
  }, (table) => [
@@ -1352,6 +1373,13 @@ var siteCrawlPages = sqliteTable("site_crawl_pages", {
1352
1373
  canonicalNodeKey: text("canonical_node_key"),
1353
1374
  indexabilityState: text("indexability_state").notNull().default("unknown"),
1354
1375
  indexabilityReasons: text("indexability_reasons", { mode: "json" }).$type().notNull().default([]),
1376
+ /**
1377
+ * Derived Site Health state, computed once at publish time by the contract's
1378
+ * `deriveSiteHealthState`. Stored so reads can filter on an index instead of
1379
+ * materializing every page row; NULL means a snapshot published before this
1380
+ * column existed, which reads must report rather than treat as a state.
1381
+ */
1382
+ healthState: text("health_state"),
1355
1383
  auditState: text("audit_state").notNull().default("pending"),
1356
1384
  auditScore: real("audit_score"),
1357
1385
  auditFields: text("audit_fields", { mode: "json" }).$type().notNull().default({}),
@@ -1370,6 +1398,11 @@ var siteCrawlPages = sqliteTable("site_crawl_pages", {
1370
1398
  index("idx_site_crawl_pages_read").on(table.projectId, table.runId, table.attemptId, table.inventoryEligible, table.auditScore, table.url),
1371
1399
  index("idx_site_crawl_pages_parent").on(table.projectId, table.runId, table.attemptId, table.parentPath, table.path),
1372
1400
  index("idx_site_crawl_pages_url").on(table.projectId, table.runId, table.attemptId, table.url),
1401
+ index("idx_site_crawl_pages_health").on(table.projectId, table.runId, table.attemptId, table.healthState, table.path, table.nodeKey),
1402
+ // The in-progress Page Health preview reads only completed audits below its
1403
+ // threshold, ordered worst-first. Keep its stable tie-breaker in the index
1404
+ // so a growing crawl never needs a transient sort before its small limit.
1405
+ index("idx_site_crawl_pages_live_preview").on(table.projectId, table.runId, table.attemptId, table.auditState, table.auditScore, table.nodeKey),
1373
1406
  foreignKey({
1374
1407
  name: "site_crawl_pages_attempt_fk",
1375
1408
  columns: [table.projectId, table.runId, table.attemptId],
@@ -1393,6 +1426,29 @@ var siteCrawlEdges = sqliteTable("site_crawl_edges", {
1393
1426
  followableOccurrences: integer("followable_occurrences").notNull().default(1),
1394
1427
  nofollowOccurrences: integer("nofollow_occurrences").notNull().default(0),
1395
1428
  anchors: text("anchors", { mode: "json" }).$type().notNull().default([]),
1429
+ /**
1430
+ * True when EVERY (target page, anchor) pair on this link appears on at
1431
+ * least `TEMPLATE_LINK_RATIO_THRESHOLD` of the attempt's fetched pages: a
1432
+ * nav, header, or footer link rather than an editorial one. Computed once at
1433
+ * publish time by the contract's `classifyTemplateLinks`, so the map, the
1434
+ * API filters, and the agents all read the same decision.
1435
+ *
1436
+ * One row aggregates every anchor between the same two pages, so a link that
1437
+ * is BOTH a footer link and an in-prose link is one row with two anchors. It
1438
+ * is editorial, because the in-prose link exists.
1439
+ *
1440
+ * NULL means this row was never classified, which reads report as
1441
+ * `unavailable-legacy-scan`. It never means "not a template link": the
1442
+ * too-few-pages guard writes an explicit `false` and records its reason on
1443
+ * the snapshot instead.
1444
+ */
1445
+ isTemplate: integer("is_template", { mode: "boolean" }),
1446
+ /**
1447
+ * Share of fetched pages carrying this link's LEAST ubiquitous anchor, which
1448
+ * is the one that decides `is_template`. NULL when the link has no
1449
+ * measurable pair (an unresolved target or no anchor).
1450
+ */
1451
+ templateRatio: real("template_ratio"),
1396
1452
  createdAt: text("created_at").notNull(),
1397
1453
  updatedAt: text("updated_at").notNull()
1398
1454
  }, (table) => [
@@ -1401,6 +1457,17 @@ var siteCrawlEdges = sqliteTable("site_crawl_edges", {
1401
1457
  index("idx_site_crawl_edges_inbound").on(table.projectId, table.runId, table.attemptId, table.targetNodeKey, table.edgeKey),
1402
1458
  index("idx_site_crawl_edges_source_url").on(table.projectId, table.runId, table.attemptId, table.sourceUrl),
1403
1459
  index("idx_site_crawl_edges_target_url").on(table.projectId, table.runId, table.attemptId, table.targetUrl),
1460
+ // Backs the `linkKind` filter on the internal-link reads, whose ORDER BY is
1461
+ // the trailing edge key, so a filtered page terminates at LIMIT instead of
1462
+ // sorting every match in a temp b-tree.
1463
+ index("idx_site_crawl_edges_template").on(
1464
+ table.projectId,
1465
+ table.runId,
1466
+ table.attemptId,
1467
+ table.internal,
1468
+ table.isTemplate,
1469
+ table.edgeKey
1470
+ ),
1404
1471
  // Publish-time graph sampling filters this exact scope and consumes the
1405
1472
  // highest-occurrence internal anchors first. The mixed-direction suffix
1406
1473
  // avoids a million-row temporary ORDER BY at the maximum crawl budget.
@@ -1429,8 +1496,17 @@ var siteCrawlGraphLayouts = sqliteTable("site_crawl_graph_layouts", {
1429
1496
  failureCode: text("failure_code"),
1430
1497
  totalNodes: integer("total_nodes").notNull().default(0),
1431
1498
  totalEdges: integer("total_edges").notNull().default(0),
1499
+ /** Share of `totalEdges` classified as nav, header, or footer links. */
1500
+ totalTemplateEdges: integer("total_template_edges").notNull().default(0),
1432
1501
  nodeCount: integer("node_count").notNull().default(0),
1433
1502
  edgeCount: integer("edge_count").notNull().default(0),
1503
+ /**
1504
+ * True when template links were kept out of the ForceAtlas2 physics, so
1505
+ * these positions describe content structure. Layouts published before
1506
+ * template detection keep `false`: the migration classifies their links, but
1507
+ * it deliberately does not rewrite immutable coordinates.
1508
+ */
1509
+ templateLinksExcluded: integer("template_links_excluded", { mode: "boolean" }).notNull().default(false),
1434
1510
  createdAt: text("created_at").notNull(),
1435
1511
  updatedAt: text("updated_at").notNull()
1436
1512
  }, (table) => [
@@ -1480,6 +1556,13 @@ var siteCrawlGraphEdges = sqliteTable("site_crawl_graph_edges", {
1480
1556
  targetNodeKey: text("target_node_key").notNull(),
1481
1557
  followable: integer("followable", { mode: "boolean" }).notNull(),
1482
1558
  occurrences: integer("occurrences").notNull(),
1559
+ /**
1560
+ * Denormalized from `site_crawl_edges` the same way `followable` and
1561
+ * `occurrences` are, so a bounded map read never joins the full link table.
1562
+ * Template links stay IN the sample: the map hides them by default, and a
1563
+ * viewer switching them on must not trigger a refetch or a re-layout.
1564
+ */
1565
+ isTemplate: integer("is_template", { mode: "boolean" }).notNull().default(false),
1483
1566
  createdAt: text("created_at").notNull()
1484
1567
  }, (table) => [
1485
1568
  uniqueIndex("idx_site_crawl_graph_edges_attempt_edge").on(table.projectId, table.runId, table.attemptId, table.edgeKey),
@@ -2867,6 +2950,138 @@ function relinkOrphanedSnapshotQueryIds(tx) {
2867
2950
  }
2868
2951
  }
2869
2952
  }
2953
+ function dropSiteCrawlSelfLinks(tx) {
2954
+ tx.run(sql2.raw("DELETE FROM site_crawl_edges WHERE source_url = target_url"));
2955
+ tx.run(sql2.raw("DELETE FROM site_crawl_graph_edges WHERE source_node_key = target_node_key"));
2956
+ }
2957
+ function backfillSiteCrawlPageHealthState(tx) {
2958
+ const BATCH = 500;
2959
+ for (; ; ) {
2960
+ const rows = tx.all(sql2.raw(`
2961
+ SELECT id, node_key, fetch_state, indexability_state, indexability_reasons, canonical_node_key
2962
+ FROM site_crawl_pages
2963
+ WHERE health_state IS NULL
2964
+ LIMIT ${BATCH}
2965
+ `));
2966
+ if (rows.length === 0) return;
2967
+ for (const row of rows) {
2968
+ const healthState = deriveSiteHealthState({
2969
+ fetchState: row.fetch_state,
2970
+ indexabilityState: row.indexability_state,
2971
+ indexabilityReasons: parseJsonColumn(row.indexability_reasons, []),
2972
+ canonicalNodeKey: row.canonical_node_key,
2973
+ nodeKey: row.node_key
2974
+ });
2975
+ tx.run(sql2`UPDATE site_crawl_pages SET health_state = ${healthState} WHERE id = ${row.id}`);
2976
+ }
2977
+ if (rows.length < BATCH) return;
2978
+ }
2979
+ }
2980
+ function* streamSiteCrawlTemplateLinkEdges(tx, attemptId, batchSize = 2e3) {
2981
+ let after = "";
2982
+ for (; ; ) {
2983
+ const rows = tx.all(sql2`
2984
+ SELECT edge_key, source_node_key, target_node_key, anchors
2985
+ FROM site_crawl_edges
2986
+ WHERE attempt_id = ${attemptId} AND edge_key > ${after}
2987
+ ORDER BY edge_key
2988
+ LIMIT ${batchSize}
2989
+ `);
2990
+ if (rows.length === 0) return;
2991
+ yield rows.map((row) => ({
2992
+ edgeKey: row.edge_key,
2993
+ sourceNodeKey: row.source_node_key,
2994
+ targetNodeKey: row.target_node_key,
2995
+ anchors: parseJsonColumn(row.anchors, [])
2996
+ }));
2997
+ after = rows[rows.length - 1].edge_key;
2998
+ if (rows.length < batchSize) return;
2999
+ }
3000
+ }
3001
+ function* chunked(values, size = 500) {
3002
+ for (let offset = 0; offset < values.length; offset += size) {
3003
+ yield values.slice(offset, offset + size);
3004
+ }
3005
+ }
3006
+ function backfillSiteCrawlTemplateLinks(tx) {
3007
+ const attempts = tx.all(sql2.raw(`
3008
+ SELECT
3009
+ a.id AS attempt_id,
3010
+ a.project_id AS project_id,
3011
+ a.run_id AS run_id,
3012
+ COALESCE(NULLIF(a.pages_fetched, 0), s.pages_fetched, 0) AS pages_fetched
3013
+ FROM site_crawl_attempts AS a
3014
+ LEFT JOIN site_crawl_snapshots AS s
3015
+ ON s.project_id = a.project_id AND s.run_id = a.run_id AND s.attempt_id = a.id
3016
+ `));
3017
+ for (const attempt of attempts) {
3018
+ const detection = templateLinkDetection(attempt.pages_fetched);
3019
+ const scope = { attemptId: attempt.attempt_id, projectId: attempt.project_id, runId: attempt.run_id };
3020
+ tx.run(sql2`
3021
+ UPDATE site_crawl_edges SET is_template = 0, template_ratio = NULL
3022
+ WHERE project_id = ${scope.projectId} AND run_id = ${scope.runId} AND attempt_id = ${scope.attemptId}
3023
+ `);
3024
+ tx.run(sql2`
3025
+ UPDATE site_crawl_graph_edges SET is_template = 0
3026
+ WHERE project_id = ${scope.projectId} AND run_id = ${scope.runId} AND attempt_id = ${scope.attemptId}
3027
+ `);
3028
+ if (detection === SiteHealthTemplateDetections.applied) {
3029
+ const index2 = createTemplateLinkPairIndex();
3030
+ for (const batch of streamSiteCrawlTemplateLinkEdges(tx, scope.attemptId)) {
3031
+ observeTemplateLinkEdges(index2, batch);
3032
+ }
3033
+ const keysByRatio = /* @__PURE__ */ new Map();
3034
+ const templateEdgeKeys = [];
3035
+ for (const batch of streamSiteCrawlTemplateLinkEdges(tx, scope.attemptId)) {
3036
+ for (const edge of batch) {
3037
+ const ratio = templateLinkRatio(index2, attempt.pages_fetched, edge);
3038
+ if (ratio == null) continue;
3039
+ const group = keysByRatio.get(ratio);
3040
+ if (group) group.push(edge.edgeKey);
3041
+ else keysByRatio.set(ratio, [edge.edgeKey]);
3042
+ if (isTemplateLinkRatio(ratio)) templateEdgeKeys.push(edge.edgeKey);
3043
+ }
3044
+ }
3045
+ for (const [ratio, edgeKeys] of keysByRatio) {
3046
+ const isTemplate = isTemplateLinkRatio(ratio) ? 1 : 0;
3047
+ for (const chunk of chunked(edgeKeys)) {
3048
+ tx.run(sql2`
3049
+ UPDATE site_crawl_edges SET is_template = ${isTemplate}, template_ratio = ${ratio}
3050
+ WHERE project_id = ${scope.projectId} AND run_id = ${scope.runId} AND attempt_id = ${scope.attemptId}
3051
+ AND edge_key IN (SELECT value FROM json_each(${JSON.stringify(chunk)}))
3052
+ `);
3053
+ }
3054
+ }
3055
+ for (const chunk of chunked(templateEdgeKeys)) {
3056
+ tx.run(sql2`
3057
+ UPDATE site_crawl_graph_edges SET is_template = 1
3058
+ WHERE project_id = ${scope.projectId} AND run_id = ${scope.runId} AND attempt_id = ${scope.attemptId}
3059
+ AND edge_key IN (SELECT value FROM json_each(${JSON.stringify(chunk)}))
3060
+ `);
3061
+ }
3062
+ }
3063
+ tx.run(sql2`
3064
+ UPDATE site_crawl_graph_layouts
3065
+ SET total_template_edges = (
3066
+ SELECT COUNT(*)
3067
+ FROM site_crawl_edges AS e
3068
+ INNER JOIN site_crawl_pages AS source_page
3069
+ ON source_page.project_id = e.project_id AND source_page.run_id = e.run_id
3070
+ AND source_page.attempt_id = e.attempt_id AND source_page.node_key = e.source_node_key
3071
+ INNER JOIN site_crawl_pages AS target_page
3072
+ ON target_page.project_id = e.project_id AND target_page.run_id = e.run_id
3073
+ AND target_page.attempt_id = e.attempt_id AND target_page.node_key = e.target_node_key
3074
+ WHERE e.project_id = ${scope.projectId} AND e.run_id = ${scope.runId} AND e.attempt_id = ${scope.attemptId}
3075
+ AND e.internal = 1 AND e.relation = 'anchor' AND e.is_template = 1
3076
+ )
3077
+ WHERE project_id = ${scope.projectId} AND run_id = ${scope.runId} AND attempt_id = ${scope.attemptId}
3078
+ `);
3079
+ tx.run(sql2`
3080
+ UPDATE site_crawl_snapshots SET template_detection = ${detection}
3081
+ WHERE project_id = ${scope.projectId} AND run_id = ${scope.runId} AND attempt_id = ${scope.attemptId}
3082
+ `);
3083
+ }
3084
+ }
2870
3085
  function backfillQuerySnapshotServedModel(tx) {
2871
3086
  tx.run(sql2.raw(`
2872
3087
  UPDATE query_snapshots
@@ -5809,6 +6024,127 @@ var MIGRATION_VERSIONS = [
5809
6024
  `CREATE INDEX IF NOT EXISTS idx_traffic_event_receipts_expires
5810
6025
  ON traffic_event_receipts(source_id, expires_at)`
5811
6026
  ]
6027
+ },
6028
+ {
6029
+ version: 130,
6030
+ name: "site-crawl-pages-persisted-health-state",
6031
+ // Site Health state is derived from fetch state, indexability, the
6032
+ // crawler's reasons, and canonical identity together. Filtering it by
6033
+ // recomputing in JS meant reading every page row on every request. It is
6034
+ // now written once at publish time by the SAME contract function the map
6035
+ // and the agents use, so reads are an ordinary indexed WHERE.
6036
+ //
6037
+ // Backfilled in TypeScript inside this transaction (see
6038
+ // `backfillSiteCrawlPageHealthState`) so existing scans are filterable
6039
+ // immediately. The derivation is never rewritten in SQL: the backfill
6040
+ // calls the contract's own function, so there is no second implementation
6041
+ // to drift. `unavailable-legacy-scan` therefore survives only as the
6042
+ // honest answer for a row this could not reach.
6043
+ //
6044
+ // Idempotent: `ALTER TABLE ADD COLUMN` errors with "duplicate column
6045
+ // name" on retry, which the runner already swallows, and the backfill
6046
+ // only touches NULL rows.
6047
+ statements: [
6048
+ `ALTER TABLE site_crawl_pages ADD COLUMN health_state TEXT`,
6049
+ // Trailing columns match the ORDER BY the page list actually issues
6050
+ // (`sort=path`), so a filtered read terminates at LIMIT instead of
6051
+ // sorting every match in a temp b-tree on every cursor page.
6052
+ `CREATE INDEX IF NOT EXISTS idx_site_crawl_pages_health
6053
+ ON site_crawl_pages(project_id, run_id, attempt_id, health_state, path, node_key)`
6054
+ ],
6055
+ run: backfillSiteCrawlPageHealthState
6056
+ },
6057
+ {
6058
+ version: 131,
6059
+ name: "site-crawl-template-link-classification",
6060
+ // A real site's internal links are bimodal: a (target page, anchor) pair
6061
+ // sits on nearly every page or on nearly none. The ubiquitous half is nav,
6062
+ // header, and footer chrome, and drawing it buries the content structure
6063
+ // the map exists to show. Classification is computed once at publish time
6064
+ // by the contract's own function, so the map, the API filters, and the
6065
+ // agents cannot disagree about which links are chrome.
6066
+ //
6067
+ // `is_template` and `template_detection` are deliberately nullable:
6068
+ // NULL means "never classified" and reads report it as
6069
+ // `unavailable-legacy-scan`. The too-few-pages guard writes an explicit
6070
+ // `false` plus its reason on the snapshot, so an empty template-link list
6071
+ // can never pass for "this site has no nav".
6072
+ //
6073
+ // Idempotent: `ALTER TABLE ADD COLUMN` errors with "duplicate column
6074
+ // name", which the runner already swallows, and the backfill reclassifies
6075
+ // each attempt from its own stored rows.
6076
+ statements: [
6077
+ `ALTER TABLE site_crawl_edges ADD COLUMN is_template INTEGER`,
6078
+ `ALTER TABLE site_crawl_edges ADD COLUMN template_ratio REAL`,
6079
+ `ALTER TABLE site_crawl_graph_edges ADD COLUMN is_template INTEGER NOT NULL DEFAULT 0`,
6080
+ `ALTER TABLE site_crawl_graph_layouts ADD COLUMN total_template_edges INTEGER NOT NULL DEFAULT 0`,
6081
+ `ALTER TABLE site_crawl_graph_layouts ADD COLUMN template_links_excluded INTEGER NOT NULL DEFAULT 0`,
6082
+ `ALTER TABLE site_crawl_snapshots ADD COLUMN template_detection TEXT`,
6083
+ // Trailing edge key matches the ORDER BY the internal-link reads issue,
6084
+ // so a filtered page terminates at LIMIT.
6085
+ `CREATE INDEX IF NOT EXISTS idx_site_crawl_edges_template
6086
+ ON site_crawl_edges(project_id, run_id, attempt_id, internal, is_template, edge_key)`
6087
+ ],
6088
+ run: backfillSiteCrawlTemplateLinks
6089
+ },
6090
+ {
6091
+ version: 132,
6092
+ name: "site-crawl-drop-self-links",
6093
+ // A page linking to itself is not a link to or from another page, and the
6094
+ // crawl engine already excludes self-loops from a page's inbound and
6095
+ // outbound metrics. The edge tables kept them, so the stored edges
6096
+ // disagreed with the page rows produced by the same crawl: a self-loop
6097
+ // appeared in BOTH neighbour lists, so every self-linking page read one
6098
+ // higher in each direction than its own tiles.
6099
+ //
6100
+ // The writer now drops them, which fixes new scans. This clears the ones
6101
+ // already stored so an existing scan is correct without a re-crawl.
6102
+ // Comparing the stored normalized URLs is exactly the rule the writer
6103
+ // applies (`isSelfLink`), expressed as the equality SQL can do without
6104
+ // reimplementing any derivation.
6105
+ //
6106
+ // Idempotent: a re-run deletes nothing once the rows are gone.
6107
+ statements: [],
6108
+ run: dropSiteCrawlSelfLinks
6109
+ },
6110
+ {
6111
+ version: 133,
6112
+ name: "site-crawl-reclassify-template-links-per-anchor",
6113
+ // v131 classified a link as chrome when its MOST ubiquitous anchor was
6114
+ // ubiquitous. One stored link row aggregates every anchor the crawl saw
6115
+ // between the same two pages, so on any site with a comprehensive footer
6116
+ // (which is most sites) an in-prose link to a footer-linked target shared
6117
+ // a row with that page's footer link and inherited its ratio. The
6118
+ // editorial link was marked chrome, hidden from the map, and dropped from
6119
+ // every content count. Observed on canonry.ai: /aeo-methodology had 24
6120
+ // outbound links, all marked chrome at ratio 0.96, with anchors like
6121
+ // "Explore the Canonry platform" that are plainly editorial.
6122
+ //
6123
+ // The rule is now the LEAST ubiquitous anchor: chrome only when every
6124
+ // anchor on the link is chrome. This re-runs the same backfill hook, which
6125
+ // reclassifies each attempt from its own stored rows through the corrected
6126
+ // contract function, so an existing scan is right without a re-crawl.
6127
+ //
6128
+ // Statements stay empty because v131 already added every column; only the
6129
+ // values were wrong. Re-running is safe: the hook resets each attempt to
6130
+ // "classified, not chrome" before it writes, so it is idempotent and its
6131
+ // result depends on nothing but the stored links.
6132
+ statements: [],
6133
+ run: backfillSiteCrawlTemplateLinks
6134
+ },
6135
+ {
6136
+ version: 134,
6137
+ name: "site-crawl-live-page-health-preview-index",
6138
+ // The live Page Health preview is deliberately small and stable while a
6139
+ // crawl writes more pages: completed audits below the display threshold,
6140
+ // ordered worst-first by score and then node key. Cover the exact WHERE +
6141
+ // ORDER BY shape so the read stops at LIMIT without sorting every current
6142
+ // crawl row in a temporary b-tree. Its standard composite-index SQL is
6143
+ // also PostgreSQL-compatible; the query shape has no SQLite-only syntax.
6144
+ statements: [
6145
+ `CREATE INDEX IF NOT EXISTS idx_site_crawl_pages_live_preview
6146
+ ON site_crawl_pages(project_id, run_id, attempt_id, audit_state, audit_score, node_key)`
6147
+ ]
5812
6148
  }
5813
6149
  ];
5814
6150
  function addRunsMeasurementPlanVersionForeignKey(tx) {
@@ -8680,8 +9016,14 @@ function isOverviewRead(url) {
8680
9016
  "citations/visibility"
8681
9017
  ])).has(rest);
8682
9018
  }
8683
- function isTechnicalAeoRead(url) {
9019
+ function isTechnicalAeoRead(request, url) {
8684
9020
  const rest = projectRouteRest(url);
9021
+ if (rest === "runs") {
9022
+ return queryValue(request, "kind") === RunKinds["site-audit"];
9023
+ }
9024
+ if (rest && /^technical-aeo\/runs\/[^/]+\/(?:progress|page-health-preview)$/.test(rest)) {
9025
+ return true;
9026
+ }
8685
9027
  return rest === "technical-aeo" || rest === "technical-aeo/pages" || rest === "technical-aeo/trend" || rest === "technical-aeo/crawl" || rest === "technical-aeo/graph" || rest === "technical-aeo/subgraph" || rest === "technical-aeo/path" || rest === "technical-aeo/changes" || rest === "technical-aeo/crawl/pages/audit" || rest === "technical-aeo/crawl/pages" || rest === "technical-aeo/runs" || rest === "technical-aeo/structure" || rest === "technical-aeo/internal-links" || rest === "technical-aeo/internal-links/neighbors" || rest === "technical-aeo/dead-links";
8686
9028
  }
8687
9029
  function isReportRead(url) {
@@ -8698,7 +9040,7 @@ function enforceEmbedProjectTabs(request, configuredTabs) {
8698
9040
  const url = request.url.split("?")[0];
8699
9041
  if (isProjectShellRead(request, url)) return;
8700
9042
  if (tabs.includes("overview") && isOverviewRead(url)) return;
8701
- if (tabs.includes("technical-aeo") && isTechnicalAeoRead(url)) return;
9043
+ if (tabs.includes("technical-aeo") && isTechnicalAeoRead(request, url)) return;
8702
9044
  if (tabs.includes("report") && isReportRead(url)) return;
8703
9045
  throw forbidden("This endpoint is not available for the configured embed tabs.");
8704
9046
  }
@@ -9047,6 +9389,104 @@ function pruneProviderModelsForProviders(models, providers) {
9047
9389
 
9048
9390
  // ../api-routes/src/projects.ts
9049
9391
  async function projectRoutes(app, opts) {
9392
+ app.post("/projects", async (request, reply) => {
9393
+ requireAdminSession(request);
9394
+ if (request.principal?.projectId) {
9395
+ throw forbidden("This API key is limited to one project, and cannot create projects for this install.");
9396
+ }
9397
+ requireScope(request, PROJECTS_WRITE_SCOPE);
9398
+ const parsedBody = projectCreateRequestSchema.safeParse(request.body);
9399
+ if (!parsedBody.success) {
9400
+ throw validationError("Invalid project payload", {
9401
+ issues: parsedBody.error.issues.map((issue) => ({
9402
+ path: issue.path.join("."),
9403
+ message: issue.message
9404
+ }))
9405
+ });
9406
+ }
9407
+ const body = parsedBody.data;
9408
+ const name = normalizeProjectName(body.name);
9409
+ if (!name) {
9410
+ throw validationError("Project name must contain at least one letter or number after normalization.");
9411
+ }
9412
+ const canonicalDomain = hostOf(body.canonicalDomain);
9413
+ if (!canonicalDomain) {
9414
+ throw validationError("canonicalDomain must be a valid hostname or http(s) URL.");
9415
+ }
9416
+ const validNames = opts.providerAdapters?.map((adapter) => adapter.name) ?? [];
9417
+ if (validNames.length && body.providers?.length) {
9418
+ const invalid = body.providers.filter((p) => !validNames.includes(p));
9419
+ if (invalid.length) {
9420
+ throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
9421
+ invalidProviders: invalid,
9422
+ validProviders: validNames
9423
+ });
9424
+ }
9425
+ }
9426
+ const nextProviders = body.providers ?? [];
9427
+ const providerModels = pruneProviderModelsForProviders(
9428
+ validateProviderModels(body.providerModels ?? {}, opts.providerAdapters),
9429
+ nextProviders
9430
+ );
9431
+ assertProviderModelScope(request, {}, providerModels, nextProviders);
9432
+ const nextLocations = body.locations ?? [];
9433
+ const duplicateLabels = findDuplicateLocationLabels(nextLocations);
9434
+ if (duplicateLabels.length > 0) {
9435
+ throw validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
9436
+ duplicateLabels
9437
+ });
9438
+ }
9439
+ const nextDefaultLocation = body.defaultLocation ?? null;
9440
+ if (!hasLocationLabel(nextLocations, nextDefaultLocation)) {
9441
+ throw validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
9442
+ defaultLocation: nextDefaultLocation
9443
+ });
9444
+ }
9445
+ const normalizedCollision = app.db.select({
9446
+ name: projects.name
9447
+ }).from(projects).all().find((project) => normalizeProjectName(project.name) === name);
9448
+ if (normalizedCollision) throw alreadyExists("Project", name);
9449
+ const id = crypto6.randomUUID();
9450
+ const now = (/* @__PURE__ */ new Date()).toISOString();
9451
+ const nextAliases = normalizeProjectAliases(body.displayName, body.aliases ?? []);
9452
+ const inserted = app.db.transaction((tx) => {
9453
+ const result = tx.insert(projects).values({
9454
+ id,
9455
+ name,
9456
+ displayName: body.displayName,
9457
+ canonicalDomain,
9458
+ ownedDomains: body.ownedDomains ?? [],
9459
+ aliases: nextAliases,
9460
+ country: body.country,
9461
+ language: body.language,
9462
+ tags: body.tags ?? [],
9463
+ labels: body.labels ?? {},
9464
+ providers: nextProviders,
9465
+ providerModels,
9466
+ measurement: body.measurement ?? DEFAULT_MEASUREMENT_CONFIG,
9467
+ locations: nextLocations,
9468
+ defaultLocation: nextDefaultLocation,
9469
+ autoExtractBacklinks: body.autoExtractBacklinks ?? false,
9470
+ configSource: body.configSource ?? "api",
9471
+ configRevision: 1,
9472
+ createdAt: now,
9473
+ updatedAt: now
9474
+ }).onConflictDoNothing().run();
9475
+ if (result.changes !== 1) return false;
9476
+ writeAuditLog(tx, {
9477
+ projectId: id,
9478
+ actor: "api",
9479
+ action: "project.created",
9480
+ entityType: "project",
9481
+ entityId: id
9482
+ });
9483
+ return true;
9484
+ });
9485
+ if (!inserted) throw alreadyExists("Project", name);
9486
+ opts.onProjectUpserted?.(id, name);
9487
+ const created = app.db.select().from(projects).where(eq4(projects.id, id)).get();
9488
+ return reply.status(201).send(formatProject(created));
9489
+ });
9050
9490
  app.put("/projects/:name", async (request, reply) => {
9051
9491
  const { name } = request.params;
9052
9492
  const parsedBody = projectUpsertRequestSchema.safeParse(request.body);
@@ -27389,6 +27829,7 @@ var SCHEMA_TABLE = {
27389
27829
  NotificationDto: notificationDtoSchema,
27390
27830
  OnboardingTelemetryEvent: onboardingTelemetryEventSchema,
27391
27831
  OrganicEvidenceDto: organicEvidenceDtoSchema,
27832
+ ProjectCreateRequest: projectCreateRequestSchema,
27392
27833
  ProjectDto: projectDtoSchema,
27393
27834
  ProjectUpsertRequest: projectUpsertRequestSchema,
27394
27835
  ProjectConfig: projectConfigSchema,
@@ -27402,7 +27843,9 @@ var SCHEMA_TABLE = {
27402
27843
  SchedulableRunKind: schedulableRunKindSchema,
27403
27844
  ScheduleDto: scheduleDtoSchema,
27404
27845
  SettingsDto: settingsDtoSchema,
27846
+ SiteAuditLivePageHealthDto: siteAuditLivePageHealthSchema,
27405
27847
  SiteAuditPagesResponseDto: siteAuditPagesResponseSchema,
27848
+ SiteAuditRunProgressDto: siteAuditRunProgressSchema,
27406
27849
  SiteAuditRunResponseDto: siteAuditRunResponseSchema,
27407
27850
  SiteAuditScoreDto: siteAuditScoreSchema,
27408
27851
  SiteAuditTrendResponseDto: siteAuditTrendResponseSchema,
@@ -27890,6 +28333,12 @@ var crawlLimitParameter = {
27890
28333
  description: "Page size. Defaults to 100 (50 for structure/neighbors); maximum 200 (100 for structure/neighbors).",
27891
28334
  schema: { type: "integer", minimum: 1, maximum: 200 }
27892
28335
  };
28336
+ var linkKindParameter = {
28337
+ name: "linkKind",
28338
+ in: "query",
28339
+ 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`.',
28340
+ schema: { type: "string", enum: ["all", "content", "template"], default: "all" }
28341
+ };
27893
28342
  var measurementOverviewSortParameter = {
27894
28343
  name: "sort",
27895
28344
  in: "query",
@@ -28031,6 +28480,27 @@ var routeCatalog = [
28031
28480
  404: errorResponse("Project or connection not found.")
28032
28481
  }
28033
28482
  },
28483
+ {
28484
+ method: "post",
28485
+ path: "/api/v1/projects",
28486
+ summary: "Create a project",
28487
+ description: "Creates a new project with create-only semantics for the domain-first launchpad. The server normalizes the project name and canonical domain; a normalized-name collision returns 409 and never updates the existing project.",
28488
+ tags: ["projects"],
28489
+ requestBody: {
28490
+ required: true,
28491
+ content: {
28492
+ "application/json": {
28493
+ schema: { $ref: "#/components/schemas/ProjectCreateRequest" }
28494
+ }
28495
+ }
28496
+ },
28497
+ responses: {
28498
+ 201: jsonResponse("Project created.", "ProjectDto"),
28499
+ 400: errorResponse("Invalid project payload, normalized name, or canonical domain."),
28500
+ 403: errorResponse("The caller lacks broad projects.write authority."),
28501
+ 409: errorResponse("A project with the normalized name already exists.")
28502
+ }
28503
+ },
28034
28504
  {
28035
28505
  method: "put",
28036
28506
  path: "/api/v1/projects/{name}",
@@ -33408,13 +33878,14 @@ var routeCatalog = [
33408
33878
  method: "get",
33409
33879
  path: "/api/v1/projects/{name}/technical-aeo/graph",
33410
33880
  summary: "Get a persisted Site Health graph projection",
33411
- 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. The response is bounded to 20,000 nodes / 50,000 edges; `layout`, `omittedNodes`, `omittedEdges`, and `sampled` disclose legacy/unavailable layouts and intentional truncation.",
33881
+ 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.",
33412
33882
  tags: ["technical-aeo"],
33413
33883
  parameters: [
33414
33884
  nameParameter,
33415
33885
  { name: "runId", in: "query", description: "Historical site-audit run ID. Omit for the latest complete crawl.", schema: stringSchema },
33416
33886
  { name: "maxNodes", in: "query", description: "Maximum graph nodes. Defaults to and is capped at 20,000.", schema: { type: "integer", minimum: 1, maximum: 2e4 } },
33417
- { name: "maxEdges", in: "query", description: "Maximum graph edges. Defaults to and is capped at 50,000.", schema: { type: "integer", minimum: 1, maximum: 5e4 } }
33887
+ { name: "maxEdges", in: "query", description: "Maximum graph edges. Defaults to and is capped at 50,000.", schema: { type: "integer", minimum: 1, maximum: 5e4 } },
33888
+ linkKindParameter
33418
33889
  ],
33419
33890
  responses: {
33420
33891
  200: jsonResponse("Bounded Site Health graph projection returned.", "SiteCrawlGraphResponseDto"),
@@ -33513,7 +33984,8 @@ var routeCatalog = [
33513
33984
  { name: "inventoryEligible", in: "query", description: "Filter Canonry technical inventory eligibility (`true` or `false`).", schema: booleanSchema },
33514
33985
  { name: "fetchState", in: "query", description: "Filter by persisted fetch state.", schema: stringSchema },
33515
33986
  { name: "indexabilityState", in: "query", description: "Filter by crawler-derived indexability state; this is not Google index coverage.", schema: stringSchema },
33516
- { name: "healthState", in: "query", description: "Filter by the derived Site Health state shared with the dashboard and agents. Unlike `indexabilityState` this folds fetch state, canonical identity, and the crawler reasons into one decision, so `hidden` also covers redirects, robots blocks, non-HTML, and canonical-away pages. Values: `eligible`, `hidden`, `failed`, `unchecked`.", schema: { type: "string", enum: ["eligible", "hidden", "failed", "unchecked"] } },
33987
+ { name: "nodeKey", in: "query", description: "Return only the page with this node key. Combined with a filter it answers whether that exact page is in the filtered set, without paging through the list.", schema: stringSchema },
33988
+ { name: "healthState", in: "query", description: "Filter by the derived Site Health state shared with the dashboard and agents. Unlike `indexabilityState` this folds fetch state, canonical identity, and the crawler reasons into one decision. `hidden` means the SITE told answer engines not to index the page (noindex, canonical-away, robots.txt); a non-HTML file is `resource` and a redirect is `redirect`, because neither is suppressed. Values: `eligible`, `hidden`, `resource`, `redirect`, `failed`, `unchecked`.", schema: { type: "string", enum: ["eligible", "hidden", "resource", "redirect", "failed", "unchecked"] } },
33517
33989
  { name: "auditState", in: "query", description: "Filter by audit state.", schema: stringSchema },
33518
33990
  { name: "sort", in: "query", description: "Sort order. Defaults to `url`.", schema: { type: "string", enum: ["url", "path", "score-asc", "score-desc"] } },
33519
33991
  crawlCursorParameter,
@@ -33546,7 +34018,7 @@ var routeCatalog = [
33546
34018
  method: "get",
33547
34019
  path: "/api/v1/projects/{name}/technical-aeo/internal-links",
33548
34020
  summary: "List persisted internal crawl links",
33549
- description: "Cursor-paged internal edges for the latest or selected crawl. Optional source/target/followability filters remain project-, run-, and attempt-scoped.",
34021
+ 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.",
33550
34022
  tags: ["technical-aeo"],
33551
34023
  parameters: [
33552
34024
  nameParameter,
@@ -33554,6 +34026,7 @@ var routeCatalog = [
33554
34026
  { name: "sourceUrl", in: "query", description: "Restrict to a source URL.", schema: stringSchema },
33555
34027
  { name: "targetUrl", in: "query", description: "Restrict to a target URL.", schema: stringSchema },
33556
34028
  { name: "followable", in: "query", description: "Restrict to followable or nofollow link observations.", schema: booleanSchema },
34029
+ linkKindParameter,
33557
34030
  crawlCursorParameter,
33558
34031
  crawlLimitParameter
33559
34032
  ],
@@ -33573,6 +34046,7 @@ var routeCatalog = [
33573
34046
  { name: "runId", in: "query", description: "Historical site-audit run ID. Omit for the latest crawl.", schema: stringSchema },
33574
34047
  { name: "nodeKey", in: "query", description: "Canonical crawl node key.", schema: stringSchema },
33575
34048
  { name: "url", in: "query", description: "Canonical crawl URL.", schema: stringSchema },
34049
+ linkKindParameter,
33576
34050
  { name: "limit", in: "query", description: "Maximum inbound and outbound edges independently. Defaults to 50; maximum 100.", schema: { type: "integer", minimum: 1, maximum: 100 } }
33577
34051
  ],
33578
34052
  responses: {
@@ -33613,6 +34087,36 @@ var routeCatalog = [
33613
34087
  404: errorResponse("Project not found.")
33614
34088
  }
33615
34089
  },
34090
+ {
34091
+ method: "get",
34092
+ path: "/api/v1/projects/{name}/technical-aeo/runs/{runId}/progress",
34093
+ summary: "Get exact stored Site Health run progress",
34094
+ description: "Returns durable run, crawl-attempt, and terminal graph-layout state for one exact non-probe site-audit run. It performs no network work and intentionally returns raw counters instead of a completion percentage.",
34095
+ tags: ["technical-aeo"],
34096
+ parameters: [
34097
+ nameParameter,
34098
+ { name: "runId", in: "path", required: true, description: "Exact site-audit run ID.", schema: stringSchema }
34099
+ ],
34100
+ responses: {
34101
+ 200: jsonResponse("Exact stored Site Health progress returned.", "SiteAuditRunProgressDto"),
34102
+ 404: errorResponse("Project or non-probe site-audit run not found.")
34103
+ }
34104
+ },
34105
+ {
34106
+ method: "get",
34107
+ path: "/api/v1/projects/{name}/technical-aeo/runs/{runId}/page-health-preview",
34108
+ summary: "Get a bounded, provisional Page Health preview",
34109
+ description: "Returns a small durable, provisional preview for one exact non-probe site-audit run while it is running: the count of audited pages plus at most 12 actionable low-score examples without finding prose. Queued scans return waiting. Terminal scans return no examples so callers use the immutable Page Health results instead. It performs no network work.",
34110
+ tags: ["technical-aeo"],
34111
+ parameters: [
34112
+ nameParameter,
34113
+ { name: "runId", in: "path", required: true, description: "Exact site-audit run ID.", schema: stringSchema }
34114
+ ],
34115
+ responses: {
34116
+ 200: jsonResponse("Bounded provisional Page Health preview returned.", "SiteAuditLivePageHealthDto"),
34117
+ 404: errorResponse("Project or non-probe site-audit run not found.")
34118
+ }
34119
+ },
33616
34120
  {
33617
34121
  method: "post",
33618
34122
  path: "/api/v1/projects/{name}/technical-aeo/runs",
@@ -33642,6 +34146,7 @@ var routeCatalog = [
33642
34146
  200: jsonResponse("Site-audit run queued (or the in-flight run returned).", "SiteAuditRunResponseDto"),
33643
34147
  400: errorResponse("Invalid site-audit request."),
33644
34148
  409: errorResponse("A crawl with different effective options is already queued or running."),
34149
+ 422: errorResponse("The site-audit executor is unavailable on this deployment."),
33645
34150
  404: errorResponse("Project not found.")
33646
34151
  }
33647
34152
  }
@@ -57062,7 +57567,7 @@ function dedupeStrings(input) {
57062
57567
 
57063
57568
  // ../api-routes/src/technical-aeo.ts
57064
57569
  import crypto44 from "crypto";
57065
- import { and as and44, asc as asc10, count, desc as desc24, eq as eq56, inArray as inArray19, isNotNull as isNotNull3, lt as lt8, or as or11, sql as sql21 } from "drizzle-orm";
57570
+ import { and as and44, asc as asc10, count, desc as desc24, eq as eq56, inArray as inArray19, isNotNull as isNotNull3, isNull as isNull5, lt as lt8, or as or11, sql as sql21 } from "drizzle-orm";
57066
57571
  import { alias } from "drizzle-orm/sqlite-core";
57067
57572
  var FETCHED_SITE_CRAWL_STATES = /* @__PURE__ */ new Set([
57068
57573
  ...SiteCrawlFetchedStates,
@@ -57072,6 +57577,22 @@ var FETCHED_SITE_CRAWL_STATES = /* @__PURE__ */ new Set([
57072
57577
  "fetched"
57073
57578
  ]);
57074
57579
  var SURFACEABLE_STATUSES = [RunStatuses.completed, RunStatuses.partial];
57580
+ function siteAuditRunPhase(status, attempt, layoutState) {
57581
+ switch (status) {
57582
+ case RunStatuses.queued:
57583
+ return "queued";
57584
+ case RunStatuses.running:
57585
+ return (attempt?.pagesFetched ?? 0) > 0 ? "checking" : "discovering";
57586
+ case RunStatuses.completed:
57587
+ return attempt && layoutState === "pending" ? "arranging-map" : "completed";
57588
+ case RunStatuses.partial:
57589
+ return attempt && layoutState === "pending" ? "arranging-map" : "partial";
57590
+ case RunStatuses.failed:
57591
+ return "failed";
57592
+ case RunStatuses.cancelled:
57593
+ return "cancelled";
57594
+ }
57595
+ }
57075
57596
  function emptyScore(projectName) {
57076
57597
  return {
57077
57598
  project: projectName,
@@ -57199,6 +57720,21 @@ function mapCrawlPageAuditEvidence(row) {
57199
57720
  criticalDefects: parsedCriticalDefects.flatMap((result) => result.success ? [result.data] : [])
57200
57721
  };
57201
57722
  }
57723
+ var LIVE_PAGE_HEALTH_CANDIDATE_LIMIT = 48;
57724
+ var LIVE_PAGE_HEALTH_EXAMPLE_LIMIT = 12;
57725
+ function countLivePageHealthChecks(auditFields) {
57726
+ const rawFactors = Array.isArray(auditFields.factors) ? auditFields.factors : [];
57727
+ const rawCriticalDefects = Array.isArray(auditFields.criticalDefects) ? auditFields.criticalDefects : [];
57728
+ let checks = 0;
57729
+ for (const rawFactor of rawFactors) {
57730
+ const factor = siteCrawlAuditFactorSchema.safeParse(rawFactor);
57731
+ if (factor.success && factor.data.applicable !== false && factor.data.status !== "pass") checks++;
57732
+ }
57733
+ for (const rawCriticalDefect of rawCriticalDefects) {
57734
+ if (siteCrawlCriticalDefectSchema.safeParse(rawCriticalDefect).success) checks++;
57735
+ }
57736
+ return checks;
57737
+ }
57202
57738
  function mapCrawlEdge(row) {
57203
57739
  return {
57204
57740
  edgeKey: row.edgeKey,
@@ -57212,9 +57748,28 @@ function mapCrawlEdge(row) {
57212
57748
  occurrences: row.occurrences,
57213
57749
  followableOccurrences: row.followableOccurrences,
57214
57750
  nofollowOccurrences: row.nofollowOccurrences,
57215
- anchors: row.anchors
57751
+ anchors: row.anchors,
57752
+ isTemplate: row.isTemplate,
57753
+ templateRatio: row.templateRatio
57216
57754
  };
57217
57755
  }
57756
+ function templateDetectionOf(value) {
57757
+ const parsed = siteHealthTemplateDetectionSchema.safeParse(value);
57758
+ return parsed.success ? parsed.data : SiteHealthTemplateDetections["unavailable-legacy-scan"];
57759
+ }
57760
+ function parseLinkKind(value) {
57761
+ if (value === void 0 || value === "") return SiteHealthLinkKinds.all;
57762
+ const allowed = siteHealthLinkKindSchema.safeParse(value);
57763
+ if (!allowed.success) {
57764
+ throw validationError(`"linkKind" must be one of: ${siteHealthLinkKindSchema.options.join(", ")}`);
57765
+ }
57766
+ return allowed.data;
57767
+ }
57768
+ function linkKindFilter(linkKind) {
57769
+ if (linkKind === SiteHealthLinkKinds.content) return eq56(siteCrawlEdges.isTemplate, false);
57770
+ if (linkKind === SiteHealthLinkKinds.template) return eq56(siteCrawlEdges.isTemplate, true);
57771
+ return void 0;
57772
+ }
57218
57773
  var graphSourceNode = alias(siteCrawlGraphNodes, "site_crawl_graph_source_node");
57219
57774
  var graphTargetNode = alias(siteCrawlGraphNodes, "site_crawl_graph_target_node");
57220
57775
  var PAGE_CHANGE_PREDICATE = sql21`(
@@ -57510,6 +58065,7 @@ async function technicalAeoRoutes(app, opts) {
57510
58065
  });
57511
58066
  app.get("/projects/:name/technical-aeo/graph", async (request) => {
57512
58067
  const project = resolveProject(app.db, request.params.name);
58068
+ const linkKind = parseLinkKind(request.query.linkKind);
57513
58069
  const target = resolveCrawl(project.id, request.query.runId);
57514
58070
  if (!target) {
57515
58071
  assertKnownAuditRun(project.id, request.query.runId);
@@ -57519,8 +58075,13 @@ async function technicalAeoRoutes(app, opts) {
57519
58075
  runId: null,
57520
58076
  rootNodeKey: null,
57521
58077
  layout: { state: "unavailable", version: null, reason: "no-crawl" },
58078
+ // Nothing was classified because nothing was crawled.
58079
+ templateDetection: SiteHealthTemplateDetections["unavailable-legacy-scan"],
58080
+ linkKind,
57522
58081
  totalNodes: 0,
57523
58082
  totalEdges: 0,
58083
+ totalTemplateEdges: 0,
58084
+ totalContentEdges: 0,
57524
58085
  nodes: [],
57525
58086
  edges: [],
57526
58087
  omittedNodes: 0,
@@ -57529,6 +58090,7 @@ async function technicalAeoRoutes(app, opts) {
57529
58090
  };
57530
58091
  }
57531
58092
  const snapshot = target.snapshot;
58093
+ const templateDetection = templateDetectionOf(snapshot.templateDetection);
57532
58094
  if (!snapshot.detailsAvailable || !snapshot.attemptId) {
57533
58095
  return {
57534
58096
  project: project.name,
@@ -57536,8 +58098,12 @@ async function technicalAeoRoutes(app, opts) {
57536
58098
  runId: snapshot.runId,
57537
58099
  rootNodeKey: null,
57538
58100
  layout: { state: "unavailable", version: null, reason: "details-unavailable" },
58101
+ templateDetection,
58102
+ linkKind,
57539
58103
  totalNodes: 0,
57540
58104
  totalEdges: 0,
58105
+ totalTemplateEdges: 0,
58106
+ totalContentEdges: 0,
57541
58107
  nodes: [],
57542
58108
  edges: [],
57543
58109
  omittedNodes: 0,
@@ -57561,8 +58127,12 @@ async function technicalAeoRoutes(app, opts) {
57561
58127
  runId: snapshot.runId,
57562
58128
  rootNodeKey,
57563
58129
  layout: { state: "unavailable", version: null, reason: "legacy-snapshot" },
58130
+ templateDetection,
58131
+ linkKind,
57564
58132
  totalNodes: 0,
57565
58133
  totalEdges: 0,
58134
+ totalTemplateEdges: 0,
58135
+ totalContentEdges: 0,
57566
58136
  nodes: [],
57567
58137
  edges: [],
57568
58138
  omittedNodes: 0,
@@ -57583,8 +58153,12 @@ async function technicalAeoRoutes(app, opts) {
57583
58153
  version: null,
57584
58154
  reason: persistedLayout.failureCode === "empty-crawl" ? "empty-crawl" : "layout-failed"
57585
58155
  },
58156
+ templateDetection,
58157
+ linkKind,
57586
58158
  totalNodes: totalNodes2,
57587
58159
  totalEdges: totalEdges2,
58160
+ totalTemplateEdges: persistedLayout.totalTemplateEdges,
58161
+ totalContentEdges: Math.max(0, totalEdges2 - persistedLayout.totalTemplateEdges),
57588
58162
  nodes: [],
57589
58163
  edges: [],
57590
58164
  omittedNodes: totalNodes2,
@@ -57626,12 +58200,14 @@ async function technicalAeoRoutes(app, opts) {
57626
58200
  ...row,
57627
58201
  healthState: deriveSiteHealthState({ ...row, indexabilityReasons, canonicalNodeKey })
57628
58202
  }));
58203
+ const graphLinkKindFilter = linkKind === SiteHealthLinkKinds.all ? void 0 : eq56(siteCrawlGraphEdges.isTemplate, linkKind === SiteHealthLinkKinds.template);
57629
58204
  const edges = app.db.select({
57630
58205
  edgeKey: siteCrawlGraphEdges.edgeKey,
57631
58206
  sourceNodeKey: siteCrawlGraphEdges.sourceNodeKey,
57632
58207
  targetNodeKey: siteCrawlGraphEdges.targetNodeKey,
57633
58208
  followable: siteCrawlGraphEdges.followable,
57634
- occurrences: siteCrawlGraphEdges.occurrences
58209
+ occurrences: siteCrawlGraphEdges.occurrences,
58210
+ isTemplate: siteCrawlGraphEdges.isTemplate
57635
58211
  }).from(siteCrawlGraphEdges).innerJoin(graphSourceNode, and44(
57636
58212
  eq56(graphSourceNode.projectId, siteCrawlGraphEdges.projectId),
57637
58213
  eq56(graphSourceNode.runId, siteCrawlGraphEdges.runId),
@@ -57648,10 +58224,12 @@ async function technicalAeoRoutes(app, opts) {
57648
58224
  eq56(siteCrawlGraphEdges.projectId, project.id),
57649
58225
  eq56(siteCrawlGraphEdges.runId, snapshot.runId),
57650
58226
  eq56(siteCrawlGraphEdges.attemptId, snapshot.attemptId),
57651
- lt8(siteCrawlGraphEdges.sampleRank, maxEdges)
58227
+ lt8(siteCrawlGraphEdges.sampleRank, maxEdges),
58228
+ graphLinkKindFilter
57652
58229
  )).orderBy(asc10(siteCrawlGraphEdges.sampleRank)).all();
57653
58230
  const totalNodes = persistedLayout.totalNodes;
57654
58231
  const totalEdges = persistedLayout.totalEdges;
58232
+ const totalTemplateEdges = persistedLayout.totalTemplateEdges;
57655
58233
  const omittedNodes = Math.max(0, totalNodes - nodes.length);
57656
58234
  const omittedEdges = Math.max(0, totalEdges - edges.length);
57657
58235
  return {
@@ -57659,9 +58237,21 @@ async function technicalAeoRoutes(app, opts) {
57659
58237
  hasCrawlData: true,
57660
58238
  runId: snapshot.runId,
57661
58239
  rootNodeKey,
57662
- layout: { state: "ready", version: persistedLayout.layoutVersion, computedAt: persistedLayout.createdAt },
58240
+ layout: {
58241
+ state: "ready",
58242
+ version: persistedLayout.layoutVersion,
58243
+ computedAt: persistedLayout.createdAt,
58244
+ templateLinksExcluded: persistedLayout.templateLinksExcluded
58245
+ },
58246
+ templateDetection,
58247
+ linkKind,
57663
58248
  totalNodes,
58249
+ // Deliberately still every graph-compatible link, whatever `linkKind`
58250
+ // asked for. The two counts below split it; none of the three moves
58251
+ // because a caller narrowed the payload.
57664
58252
  totalEdges,
58253
+ totalTemplateEdges,
58254
+ totalContentEdges: Math.max(0, totalEdges - totalTemplateEdges),
57665
58255
  nodes,
57666
58256
  edges,
57667
58257
  omittedNodes,
@@ -58343,41 +58933,52 @@ async function technicalAeoRoutes(app, opts) {
58343
58933
  const target = resolveCrawl(project.id, request.query.runId);
58344
58934
  if (!target) {
58345
58935
  assertKnownAuditRun(project.id, request.query.runId);
58346
- return { project: project.name, hasCrawlData: false, runId: null, total: 0, nextCursor: null, pages: [] };
58936
+ return { project: project.name, hasCrawlData: false, runId: null, total: 0, nextCursor: null, healthStateFilter: null, pages: [] };
58347
58937
  }
58348
58938
  const snapshot = target.snapshot;
58349
58939
  if (!snapshot.detailsAvailable || !snapshot.attemptId) {
58350
- return { project: project.name, hasCrawlData: true, runId: snapshot.runId, total: 0, nextCursor: null, pages: [] };
58940
+ return { project: project.name, hasCrawlData: true, runId: snapshot.runId, total: 0, nextCursor: null, healthStateFilter: null, pages: [] };
58351
58941
  }
58352
58942
  const filters = [
58353
58943
  eq56(siteCrawlPages.projectId, project.id),
58354
58944
  eq56(siteCrawlPages.runId, snapshot.runId),
58355
58945
  eq56(siteCrawlPages.attemptId, snapshot.attemptId)
58356
58946
  ];
58947
+ if (request.query.nodeKey) filters.push(eq56(siteCrawlPages.nodeKey, request.query.nodeKey));
58357
58948
  const inventoryEligible = parseBoolean2(request.query.inventoryEligible);
58358
58949
  if (inventoryEligible != null) filters.push(eq56(siteCrawlPages.inventoryEligible, inventoryEligible));
58359
58950
  if (request.query.fetchState) filters.push(eq56(siteCrawlPages.fetchState, request.query.fetchState));
58360
58951
  if (request.query.indexabilityState) filters.push(eq56(siteCrawlPages.indexabilityState, request.query.indexabilityState));
58361
58952
  if (request.query.auditState) filters.push(eq56(siteCrawlPages.auditState, request.query.auditState));
58362
58953
  const healthState = parseSiteHealthState(request.query.healthState);
58363
- const where = and44(...filters);
58364
58954
  const limit = parseBoundedLimit(request.query.limit, 100, 200);
58365
58955
  const offset = decodeCursor(request.query.cursor);
58366
58956
  const orderBy = request.query.sort === "score-desc" ? [desc24(siteCrawlPages.auditScore), asc10(siteCrawlPages.nodeKey)] : request.query.sort === "score-asc" ? [asc10(siteCrawlPages.auditScore), asc10(siteCrawlPages.nodeKey)] : request.query.sort === "path" ? [asc10(siteCrawlPages.path), asc10(siteCrawlPages.nodeKey)] : [asc10(siteCrawlPages.url), asc10(siteCrawlPages.nodeKey)];
58367
- let total;
58368
- let rows;
58957
+ let healthStateFilter = null;
58369
58958
  if (healthState) {
58370
- const candidates = app.db.select().from(siteCrawlPages).where(where).orderBy(...orderBy).limit(MAX_STRUCTURE_SOURCE_ROWS + 1).all();
58371
- if (candidates.length > MAX_STRUCTURE_SOURCE_ROWS) {
58372
- throw validationError(`Persisted crawl exceeds the ${MAX_STRUCTURE_SOURCE_ROWS}-page health-state filter limit`);
58373
- }
58374
- const matching = candidates.filter((row) => deriveSiteHealthState(row) === healthState);
58375
- total = matching.length;
58376
- rows = matching.slice(offset, offset + limit);
58377
- } else {
58378
- total = app.db.select({ value: count() }).from(siteCrawlPages).where(where).get()?.value ?? 0;
58379
- rows = app.db.select().from(siteCrawlPages).where(where).orderBy(...orderBy).limit(limit).offset(offset).all();
58959
+ const legacyRow = app.db.select({ id: siteCrawlPages.id }).from(siteCrawlPages).where(and44(
58960
+ eq56(siteCrawlPages.projectId, project.id),
58961
+ eq56(siteCrawlPages.runId, snapshot.runId),
58962
+ eq56(siteCrawlPages.attemptId, snapshot.attemptId),
58963
+ isNull5(siteCrawlPages.healthState)
58964
+ )).limit(1).get();
58965
+ healthStateFilter = legacyRow ? "unavailable-legacy-scan" : "applied";
58966
+ if (healthStateFilter === "applied") filters.push(eq56(siteCrawlPages.healthState, healthState));
58967
+ }
58968
+ if (healthStateFilter === "unavailable-legacy-scan") {
58969
+ return {
58970
+ project: project.name,
58971
+ hasCrawlData: true,
58972
+ runId: snapshot.runId,
58973
+ total: 0,
58974
+ nextCursor: null,
58975
+ healthStateFilter,
58976
+ pages: []
58977
+ };
58380
58978
  }
58979
+ const where = and44(...filters);
58980
+ const total = app.db.select({ value: count() }).from(siteCrawlPages).where(where).get()?.value ?? 0;
58981
+ const rows = app.db.select().from(siteCrawlPages).where(where).orderBy(...orderBy).limit(limit).offset(offset).all();
58381
58982
  const nextOffset = offset + rows.length;
58382
58983
  return {
58383
58984
  project: project.name,
@@ -58385,6 +58986,7 @@ async function technicalAeoRoutes(app, opts) {
58385
58986
  runId: snapshot.runId,
58386
58987
  total,
58387
58988
  nextCursor: nextOffset < total ? encodeCursor2(nextOffset) : null,
58989
+ healthStateFilter,
58388
58990
  pages: rows.map(mapCrawlPage)
58389
58991
  };
58390
58992
  });
@@ -58457,20 +59059,41 @@ async function technicalAeoRoutes(app, opts) {
58457
59059
  });
58458
59060
  app.get("/projects/:name/technical-aeo/internal-links", async (request) => {
58459
59061
  const project = resolveProject(app.db, request.params.name);
59062
+ const linkKind = parseLinkKind(request.query.linkKind);
58460
59063
  const target = resolveCrawl(project.id, request.query.runId);
58461
59064
  if (!target) {
58462
59065
  assertKnownAuditRun(project.id, request.query.runId);
58463
- return { project: project.name, hasCrawlData: false, runId: null, total: 0, nextCursor: null, edges: [] };
59066
+ return {
59067
+ project: project.name,
59068
+ hasCrawlData: false,
59069
+ runId: null,
59070
+ total: 0,
59071
+ nextCursor: null,
59072
+ templateDetection: SiteHealthTemplateDetections["unavailable-legacy-scan"],
59073
+ linkKind,
59074
+ edges: []
59075
+ };
58464
59076
  }
58465
59077
  const snapshot = target.snapshot;
59078
+ const templateDetection = templateDetectionOf(snapshot.templateDetection);
58466
59079
  if (!snapshot.detailsAvailable || !snapshot.attemptId) {
58467
- return { project: project.name, hasCrawlData: true, runId: snapshot.runId, total: 0, nextCursor: null, edges: [] };
59080
+ return {
59081
+ project: project.name,
59082
+ hasCrawlData: true,
59083
+ runId: snapshot.runId,
59084
+ total: 0,
59085
+ nextCursor: null,
59086
+ templateDetection,
59087
+ linkKind,
59088
+ edges: []
59089
+ };
58468
59090
  }
58469
59091
  const filters = [
58470
59092
  eq56(siteCrawlEdges.projectId, project.id),
58471
59093
  eq56(siteCrawlEdges.runId, snapshot.runId),
58472
59094
  eq56(siteCrawlEdges.attemptId, snapshot.attemptId),
58473
- eq56(siteCrawlEdges.internal, true)
59095
+ eq56(siteCrawlEdges.internal, true),
59096
+ linkKindFilter(linkKind)
58474
59097
  ];
58475
59098
  if (request.query.sourceUrl) filters.push(eq56(siteCrawlEdges.sourceUrl, request.query.sourceUrl));
58476
59099
  if (request.query.targetUrl) filters.push(eq56(siteCrawlEdges.targetUrl, request.query.targetUrl));
@@ -58488,11 +59111,14 @@ async function technicalAeoRoutes(app, opts) {
58488
59111
  runId: snapshot.runId,
58489
59112
  total,
58490
59113
  nextCursor: nextOffset < total ? encodeCursor2(nextOffset) : null,
59114
+ templateDetection,
59115
+ linkKind,
58491
59116
  edges: rows.map(mapCrawlEdge)
58492
59117
  };
58493
59118
  });
58494
59119
  app.get("/projects/:name/technical-aeo/internal-links/neighbors", async (request) => {
58495
59120
  const project = resolveProject(app.db, request.params.name);
59121
+ const linkKind = parseLinkKind(request.query.linkKind);
58496
59122
  if (!request.query.nodeKey && !request.query.url) {
58497
59123
  throw validationError("Provide nodeKey or url");
58498
59124
  }
@@ -58505,6 +59131,8 @@ async function technicalAeoRoutes(app, opts) {
58505
59131
  runId: null,
58506
59132
  nodeKey: request.query.nodeKey ?? null,
58507
59133
  url: request.query.url ?? null,
59134
+ templateDetection: SiteHealthTemplateDetections["unavailable-legacy-scan"],
59135
+ linkKind,
58508
59136
  inbound: [],
58509
59137
  outbound: [],
58510
59138
  inboundTruncated: false,
@@ -58512,6 +59140,7 @@ async function technicalAeoRoutes(app, opts) {
58512
59140
  };
58513
59141
  }
58514
59142
  const snapshot = target.snapshot;
59143
+ const templateDetection = templateDetectionOf(snapshot.templateDetection);
58515
59144
  if (!snapshot.detailsAvailable || !snapshot.attemptId) {
58516
59145
  return {
58517
59146
  project: project.name,
@@ -58519,6 +59148,8 @@ async function technicalAeoRoutes(app, opts) {
58519
59148
  runId: snapshot.runId,
58520
59149
  nodeKey: request.query.nodeKey ?? null,
58521
59150
  url: request.query.url ?? null,
59151
+ templateDetection,
59152
+ linkKind,
58522
59153
  inbound: [],
58523
59154
  outbound: [],
58524
59155
  inboundTruncated: false,
@@ -58529,7 +59160,8 @@ async function technicalAeoRoutes(app, opts) {
58529
59160
  eq56(siteCrawlEdges.projectId, project.id),
58530
59161
  eq56(siteCrawlEdges.runId, snapshot.runId),
58531
59162
  eq56(siteCrawlEdges.attemptId, snapshot.attemptId),
58532
- eq56(siteCrawlEdges.internal, true)
59163
+ eq56(siteCrawlEdges.internal, true),
59164
+ linkKindFilter(linkKind)
58533
59165
  ];
58534
59166
  const inboundMatch = request.query.nodeKey && request.query.url ? or11(eq56(siteCrawlEdges.targetNodeKey, request.query.nodeKey), eq56(siteCrawlEdges.targetUrl, request.query.url)) : request.query.nodeKey ? eq56(siteCrawlEdges.targetNodeKey, request.query.nodeKey) : eq56(siteCrawlEdges.targetUrl, request.query.url);
58535
59167
  const outboundMatch = request.query.nodeKey && request.query.url ? or11(eq56(siteCrawlEdges.sourceNodeKey, request.query.nodeKey), eq56(siteCrawlEdges.sourceUrl, request.query.url)) : request.query.nodeKey ? eq56(siteCrawlEdges.sourceNodeKey, request.query.nodeKey) : eq56(siteCrawlEdges.sourceUrl, request.query.url);
@@ -58542,6 +59174,8 @@ async function technicalAeoRoutes(app, opts) {
58542
59174
  runId: snapshot.runId,
58543
59175
  nodeKey: request.query.nodeKey ?? null,
58544
59176
  url: request.query.url ?? null,
59177
+ templateDetection,
59178
+ linkKind,
58545
59179
  inbound: inboundRows.slice(0, limit).map(mapCrawlEdge),
58546
59180
  outbound: outboundRows.slice(0, limit).map(mapCrawlEdge),
58547
59181
  inboundTruncated: inboundRows.length > limit,
@@ -58631,12 +59265,121 @@ async function technicalAeoRoutes(app, opts) {
58631
59265
  }))
58632
59266
  };
58633
59267
  });
59268
+ app.get("/projects/:name/technical-aeo/runs/:runId/progress", async (request) => {
59269
+ const project = resolveProject(app.db, request.params.name);
59270
+ const run = app.db.select().from(runs).where(and44(
59271
+ eq56(runs.id, request.params.runId),
59272
+ eq56(runs.projectId, project.id),
59273
+ eq56(runs.kind, RunKinds["site-audit"]),
59274
+ notProbeRun()
59275
+ )).get();
59276
+ if (!run) throw notFound("Site audit run", request.params.runId);
59277
+ const attempt = app.db.select().from(siteCrawlAttempts).where(and44(
59278
+ eq56(siteCrawlAttempts.projectId, project.id),
59279
+ eq56(siteCrawlAttempts.runId, run.id)
59280
+ )).orderBy(desc24(siteCrawlAttempts.attemptNumber), desc24(siteCrawlAttempts.updatedAt)).limit(1).get();
59281
+ const persistedLayout = attempt ? app.db.select().from(siteCrawlGraphLayouts).where(and44(
59282
+ eq56(siteCrawlGraphLayouts.projectId, project.id),
59283
+ eq56(siteCrawlGraphLayouts.runId, run.id),
59284
+ eq56(siteCrawlGraphLayouts.attemptId, attempt.id)
59285
+ )).get() : void 0;
59286
+ const layoutState = persistedLayout?.state === "ready" || persistedLayout?.state === "unavailable" ? persistedLayout.state : run.status === RunStatuses.completed || run.status === RunStatuses.partial ? "unavailable" : "pending";
59287
+ return {
59288
+ project: project.name,
59289
+ runId: run.id,
59290
+ status: run.status,
59291
+ phase: siteAuditRunPhase(run.status, attempt, layoutState),
59292
+ attempt: attempt ? {
59293
+ id: attempt.id,
59294
+ state: attempt.state,
59295
+ pagesDiscovered: attempt.pagesDiscovered,
59296
+ pagesFetched: attempt.pagesFetched,
59297
+ pagesEligible: attempt.pagesEligible,
59298
+ pagesErrored: attempt.pagesErrored,
59299
+ edgesDiscovered: attempt.edgesDiscovered,
59300
+ lastUpdatedAt: attempt.updatedAt,
59301
+ startedAt: attempt.startedAt,
59302
+ finishedAt: attempt.finishedAt,
59303
+ error: attempt.error
59304
+ } : null,
59305
+ layout: {
59306
+ state: layoutState,
59307
+ layoutVersion: persistedLayout?.layoutVersion ?? null,
59308
+ failureCode: persistedLayout?.failureCode ?? null,
59309
+ updatedAt: persistedLayout?.updatedAt ?? null
59310
+ },
59311
+ error: run.error ?? attempt?.error ?? null
59312
+ };
59313
+ });
59314
+ app.get("/projects/:name/technical-aeo/runs/:runId/page-health-preview", async (request) => app.db.transaction((tx) => {
59315
+ const project = tx.select().from(projects).where(eq56(projects.name, request.params.name)).get();
59316
+ if (!project) throw notFound("Project", request.params.name);
59317
+ const run = tx.select().from(runs).where(and44(
59318
+ eq56(runs.id, request.params.runId),
59319
+ eq56(runs.projectId, project.id),
59320
+ eq56(runs.kind, RunKinds["site-audit"]),
59321
+ notProbeRun()
59322
+ )).get();
59323
+ if (!run) throw notFound("Site audit run", request.params.runId);
59324
+ const attempt = tx.select().from(siteCrawlAttempts).where(and44(
59325
+ eq56(siteCrawlAttempts.projectId, project.id),
59326
+ eq56(siteCrawlAttempts.runId, run.id)
59327
+ )).orderBy(desc24(siteCrawlAttempts.attemptNumber), desc24(siteCrawlAttempts.updatedAt)).limit(1).get();
59328
+ const state = run.status === RunStatuses.queued ? "waiting" : run.status === RunStatuses.running ? "collecting" : "terminal";
59329
+ const base = {
59330
+ project: project.name,
59331
+ runId: run.id,
59332
+ status: run.status,
59333
+ state,
59334
+ attemptId: attempt?.id ?? null,
59335
+ updatedAt: attempt?.updatedAt ?? null
59336
+ };
59337
+ if (!attempt) {
59338
+ return { ...base, pagesAudited: 0, examples: [] };
59339
+ }
59340
+ const auditedWhere = and44(
59341
+ eq56(siteCrawlPages.projectId, project.id),
59342
+ eq56(siteCrawlPages.runId, run.id),
59343
+ eq56(siteCrawlPages.attemptId, attempt.id),
59344
+ eq56(siteCrawlPages.auditState, "success")
59345
+ );
59346
+ const pagesAudited = tx.select({ value: count() }).from(siteCrawlPages).where(auditedWhere).get()?.value ?? 0;
59347
+ if (state !== "collecting") {
59348
+ return { ...base, pagesAudited, examples: [] };
59349
+ }
59350
+ const candidates = tx.select({
59351
+ nodeKey: siteCrawlPages.nodeKey,
59352
+ url: siteCrawlPages.url,
59353
+ auditScore: siteCrawlPages.auditScore,
59354
+ auditFields: siteCrawlPages.auditFields
59355
+ }).from(siteCrawlPages).where(and44(
59356
+ auditedWhere,
59357
+ isNotNull3(siteCrawlPages.auditScore),
59358
+ lt8(siteCrawlPages.auditScore, 70)
59359
+ )).orderBy(asc10(siteCrawlPages.auditScore), asc10(siteCrawlPages.nodeKey)).limit(LIVE_PAGE_HEALTH_CANDIDATE_LIMIT).all();
59360
+ const examples = candidates.flatMap((candidate) => {
59361
+ if (candidate.auditScore == null) return [];
59362
+ const checksNeedingAttention = countLivePageHealthChecks(candidate.auditFields);
59363
+ return checksNeedingAttention > 0 ? [{
59364
+ nodeKey: candidate.nodeKey,
59365
+ url: candidate.url,
59366
+ auditScore: candidate.auditScore,
59367
+ checksNeedingAttention
59368
+ }] : [];
59369
+ }).slice(0, LIVE_PAGE_HEALTH_EXAMPLE_LIMIT);
59370
+ return { ...base, pagesAudited, examples };
59371
+ }));
58634
59372
  app.post("/projects/:name/technical-aeo/runs", async (request) => {
58635
59373
  const project = resolveProject(app.db, request.params.name);
58636
59374
  const parsed = siteAuditRunRequestSchema.safeParse(request.body ?? {});
58637
59375
  if (!parsed.success) {
58638
59376
  throw validationError(parsed.error.issues[0]?.message ?? "Invalid site-audit request");
58639
59377
  }
59378
+ if (!opts.onSiteAuditRequested) {
59379
+ throw missingDependency("Site Health execution is not available on this deployment.", {
59380
+ reason: "no-site-audit-handler"
59381
+ });
59382
+ }
58640
59383
  const effectiveRequest = normalizeSiteAuditRunRequest(parsed.data);
58641
59384
  const identityKey = siteAuditRequestIdentity(effectiveRequest);
58642
59385
  const result = app.db.transaction((tx) => {
@@ -58687,7 +59430,7 @@ async function technicalAeoRoutes(app, opts) {
58687
59430
  return { runId, status: RunStatuses.queued, created: true };
58688
59431
  });
58689
59432
  if (result.created) {
58690
- opts.onSiteAuditRequested?.(result.runId, project.id, {
59433
+ opts.onSiteAuditRequested(result.runId, project.id, {
58691
59434
  sitemapUrl: effectiveRequest.sitemapUrl ?? void 0,
58692
59435
  limit: parsed.data.limit,
58693
59436
  maxPages: effectiveRequest.maxPages,
@@ -60092,6 +60835,7 @@ export {
60092
60835
  latestQueryBasketRevision,
60093
60836
  ensureCurrentQueryBasketRevision,
60094
60837
  queueRunIfProjectIdle,
60838
+ resolveMeasurementSitemapTarget,
60095
60839
  measurementRunCompleteness,
60096
60840
  nextRunFromCron,
60097
60841
  resolveWebhookTarget,