@ainyc/canonry 1.28.2 → 1.30.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.
@@ -716,6 +716,49 @@ var wordpressAuditPageDtoSchema = z7.object({
716
716
  schemaPresent: z7.boolean(),
717
717
  issues: z7.array(wordpressAuditIssueDtoSchema).default([])
718
718
  });
719
+ var wordpressBulkMetaEntryResultDtoSchema = z7.object({
720
+ slug: z7.string(),
721
+ status: z7.enum(["applied", "skipped", "manual"]),
722
+ error: z7.string().optional(),
723
+ manualAssist: wordpressManualAssistDtoSchema.optional()
724
+ });
725
+ var wordpressBulkMetaResultDtoSchema = z7.object({
726
+ env: wordpressEnvSchema,
727
+ strategy: z7.enum(["plugin", "manual"]),
728
+ results: z7.array(wordpressBulkMetaEntryResultDtoSchema)
729
+ });
730
+ var wordpressSchemaDeployEntryResultDtoSchema = z7.object({
731
+ slug: z7.string(),
732
+ status: z7.enum(["deployed", "stripped", "skipped", "failed"]),
733
+ schemasInjected: z7.array(z7.string()).optional(),
734
+ manualAssist: wordpressManualAssistDtoSchema.optional(),
735
+ error: z7.string().optional()
736
+ });
737
+ var wordpressSchemaDeployResultDtoSchema = z7.object({
738
+ env: wordpressEnvSchema,
739
+ results: z7.array(wordpressSchemaDeployEntryResultDtoSchema)
740
+ });
741
+ var wordpressSchemaStatusPageDtoSchema = z7.object({
742
+ slug: z7.string(),
743
+ title: z7.string(),
744
+ canonrySchemas: z7.array(z7.string()),
745
+ thirdPartySchemas: z7.array(z7.string()),
746
+ hasCanonrySchema: z7.boolean()
747
+ });
748
+ var wordpressSchemaStatusResultDtoSchema = z7.object({
749
+ env: wordpressEnvSchema,
750
+ pages: z7.array(wordpressSchemaStatusPageDtoSchema)
751
+ });
752
+ var wordpressOnboardStepDtoSchema = z7.object({
753
+ name: z7.string(),
754
+ status: z7.enum(["completed", "skipped", "failed"]),
755
+ summary: z7.string().optional(),
756
+ error: z7.string().optional()
757
+ });
758
+ var wordpressOnboardResultDtoSchema = z7.object({
759
+ projectName: z7.string(),
760
+ steps: z7.array(wordpressOnboardStepDtoSchema)
761
+ });
719
762
  var wordpressDiffDtoSchema = z7.object({
720
763
  slug: z7.string(),
721
764
  live: wordpressDiffPageDtoSchema,
@@ -766,6 +809,7 @@ var querySnapshotDtoSchema = z8.object({
766
809
  answerText: z8.string().nullable().optional(),
767
810
  citedDomains: z8.array(z8.string()).default([]),
768
811
  competitorOverlap: z8.array(z8.string()).default([]),
812
+ recommendedCompetitors: z8.array(z8.string()).default([]),
769
813
  groundingSources: z8.array(groundingSourceSchema).default([]),
770
814
  searchQueries: z8.array(z8.string()).default([]),
771
815
  model: z8.string().nullable().optional(),
@@ -783,27 +827,112 @@ var auditLogEntrySchema = z8.object({
783
827
  createdAt: z8.string()
784
828
  });
785
829
 
786
- // ../contracts/src/schedule.ts
830
+ // ../contracts/src/snapshot.ts
787
831
  import { z as z9 } from "zod";
788
- var scheduleDtoSchema = z9.object({
832
+ var snapshotAccuracySchema = z9.enum(["yes", "no", "unknown", "not-mentioned"]);
833
+ var snapshotRequestSchema = z9.object({
834
+ companyName: z9.string().min(1),
835
+ domain: z9.string().min(1),
836
+ phrases: z9.array(z9.string().min(1)).optional().default([]),
837
+ competitors: z9.array(z9.string().min(1)).optional().default([])
838
+ });
839
+ var snapshotCompetitorEntrySchema = z9.object({
840
+ name: z9.string(),
841
+ count: z9.number().int().nonnegative()
842
+ });
843
+ var snapshotAuditFactorSchema = z9.object({
789
844
  id: z9.string(),
790
- projectId: z9.string(),
791
- cronExpr: z9.string(),
792
- preset: z9.string().nullable().optional(),
793
- timezone: z9.string().default("UTC"),
794
- enabled: z9.boolean().default(true),
795
- providers: z9.array(providerNameSchema).default([]),
796
- lastRunAt: z9.string().nullable().optional(),
797
- nextRunAt: z9.string().nullable().optional(),
798
- createdAt: z9.string(),
799
- updatedAt: z9.string()
845
+ name: z9.string(),
846
+ weight: z9.number(),
847
+ score: z9.number(),
848
+ grade: z9.string(),
849
+ status: z9.enum(["pass", "partial", "fail"]),
850
+ findings: z9.array(z9.object({
851
+ type: z9.string(),
852
+ message: z9.string()
853
+ })).default([]),
854
+ recommendations: z9.array(z9.string()).default([])
855
+ });
856
+ var snapshotAuditSchema = z9.object({
857
+ url: z9.string(),
858
+ finalUrl: z9.string(),
859
+ auditedAt: z9.string(),
860
+ overallScore: z9.number(),
861
+ overallGrade: z9.string(),
862
+ summary: z9.string(),
863
+ factors: z9.array(snapshotAuditFactorSchema).default([])
864
+ });
865
+ var snapshotProfileSchema = z9.object({
866
+ industry: z9.string(),
867
+ summary: z9.string(),
868
+ services: z9.array(z9.string()).default([]),
869
+ categoryTerms: z9.array(z9.string()).default([])
870
+ });
871
+ var snapshotProviderResultSchema = z9.object({
872
+ provider: z9.string(),
873
+ displayName: z9.string(),
874
+ model: z9.string().nullable().optional(),
875
+ mentioned: z9.boolean(),
876
+ cited: z9.boolean(),
877
+ describedAccurately: snapshotAccuracySchema,
878
+ accuracyNotes: z9.string().nullable().optional(),
879
+ incorrectClaims: z9.array(z9.string()).default([]),
880
+ recommendedCompetitors: z9.array(z9.string()).default([]),
881
+ citedDomains: z9.array(z9.string()).default([]),
882
+ groundingSources: z9.array(groundingSourceSchema).default([]),
883
+ searchQueries: z9.array(z9.string()).default([]),
884
+ answerText: z9.string(),
885
+ error: z9.string().nullable().optional()
886
+ });
887
+ var snapshotQueryResultSchema = z9.object({
888
+ phrase: z9.string(),
889
+ providerResults: z9.array(snapshotProviderResultSchema).default([])
890
+ });
891
+ var snapshotSummarySchema = z9.object({
892
+ totalQueries: z9.number().int().nonnegative(),
893
+ totalProviders: z9.number().int().nonnegative(),
894
+ totalComparisons: z9.number().int().nonnegative(),
895
+ mentionCount: z9.number().int().nonnegative(),
896
+ citationCount: z9.number().int().nonnegative(),
897
+ topCompetitors: z9.array(snapshotCompetitorEntrySchema).default([]),
898
+ visibilityGap: z9.string(),
899
+ whatThisMeans: z9.array(z9.string()).default([]),
900
+ recommendedActions: z9.array(z9.string()).default([])
901
+ });
902
+ var snapshotReportSchema = z9.object({
903
+ companyName: z9.string(),
904
+ domain: z9.string(),
905
+ homepageUrl: z9.string(),
906
+ generatedAt: z9.string(),
907
+ phrases: z9.array(z9.string()).default([]),
908
+ competitors: z9.array(z9.string()).default([]),
909
+ profile: snapshotProfileSchema,
910
+ audit: snapshotAuditSchema,
911
+ queryResults: z9.array(snapshotQueryResultSchema).default([]),
912
+ summary: snapshotSummarySchema
913
+ });
914
+
915
+ // ../contracts/src/schedule.ts
916
+ import { z as z10 } from "zod";
917
+ var scheduleDtoSchema = z10.object({
918
+ id: z10.string(),
919
+ projectId: z10.string(),
920
+ cronExpr: z10.string(),
921
+ preset: z10.string().nullable().optional(),
922
+ timezone: z10.string().default("UTC"),
923
+ enabled: z10.boolean().default(true),
924
+ providers: z10.array(providerNameSchema).default([]),
925
+ lastRunAt: z10.string().nullable().optional(),
926
+ nextRunAt: z10.string().nullable().optional(),
927
+ createdAt: z10.string(),
928
+ updatedAt: z10.string()
800
929
  });
801
- var scheduleUpsertRequestSchema = z9.object({
802
- preset: z9.string().optional(),
803
- cron: z9.string().optional(),
804
- timezone: z9.string().optional().default("UTC"),
805
- enabled: z9.boolean().optional().default(true),
806
- providers: z9.array(providerNameSchema).optional().default([])
930
+ var scheduleUpsertRequestSchema = z10.object({
931
+ preset: z10.string().optional(),
932
+ cron: z10.string().optional(),
933
+ timezone: z10.string().optional().default("UTC"),
934
+ enabled: z10.boolean().optional().default(true),
935
+ providers: z10.array(providerNameSchema).optional().default([])
807
936
  }).refine(
808
937
  (data) => data.preset && !data.cron || !data.preset && data.cron,
809
938
  { message: 'Exactly one of "preset" or "cron" must be provided' }
@@ -902,34 +1031,41 @@ function categoryLabel(category) {
902
1031
  }
903
1032
 
904
1033
  // ../contracts/src/ga.ts
905
- import { z as z10 } from "zod";
906
- var ga4ConnectionDtoSchema = z10.object({
907
- id: z10.string(),
908
- projectId: z10.string(),
909
- propertyId: z10.string(),
910
- clientEmail: z10.string(),
911
- connected: z10.boolean(),
912
- createdAt: z10.string(),
913
- updatedAt: z10.string()
1034
+ import { z as z11 } from "zod";
1035
+ var ga4ConnectionDtoSchema = z11.object({
1036
+ id: z11.string(),
1037
+ projectId: z11.string(),
1038
+ propertyId: z11.string(),
1039
+ clientEmail: z11.string(),
1040
+ connected: z11.boolean(),
1041
+ createdAt: z11.string(),
1042
+ updatedAt: z11.string()
914
1043
  });
915
- var ga4TrafficSnapshotDtoSchema = z10.object({
916
- date: z10.string(),
917
- landingPage: z10.string(),
918
- sessions: z10.number(),
919
- organicSessions: z10.number(),
920
- users: z10.number()
1044
+ var ga4TrafficSnapshotDtoSchema = z11.object({
1045
+ date: z11.string(),
1046
+ landingPage: z11.string(),
1047
+ sessions: z11.number(),
1048
+ organicSessions: z11.number(),
1049
+ users: z11.number()
921
1050
  });
922
- var ga4TrafficSummaryDtoSchema = z10.object({
923
- totalSessions: z10.number(),
924
- totalOrganicSessions: z10.number(),
925
- totalUsers: z10.number(),
926
- topPages: z10.array(z10.object({
927
- landingPage: z10.string(),
928
- sessions: z10.number(),
929
- organicSessions: z10.number(),
930
- users: z10.number()
1051
+ var ga4AiReferralDtoSchema = z11.object({
1052
+ source: z11.string(),
1053
+ medium: z11.string(),
1054
+ sessions: z11.number(),
1055
+ users: z11.number()
1056
+ });
1057
+ var ga4TrafficSummaryDtoSchema = z11.object({
1058
+ totalSessions: z11.number(),
1059
+ totalOrganicSessions: z11.number(),
1060
+ totalUsers: z11.number(),
1061
+ topPages: z11.array(z11.object({
1062
+ landingPage: z11.string(),
1063
+ sessions: z11.number(),
1064
+ organicSessions: z11.number(),
1065
+ users: z11.number()
931
1066
  })),
932
- lastSyncedAt: z10.string().nullable()
1067
+ aiReferrals: z11.array(ga4AiReferralDtoSchema),
1068
+ lastSyncedAt: z11.string().nullable()
933
1069
  });
934
1070
 
935
1071
  // ../api-routes/src/auth.ts
@@ -951,6 +1087,7 @@ __export(schema_exports, {
951
1087
  bingKeywordStats: () => bingKeywordStats,
952
1088
  bingUrlInspections: () => bingUrlInspections,
953
1089
  competitors: () => competitors,
1090
+ gaAiReferrals: () => gaAiReferrals,
954
1091
  gaConnections: () => gaConnections,
955
1092
  gaTrafficSnapshots: () => gaTrafficSnapshots,
956
1093
  gaTrafficSummaries: () => gaTrafficSummaries,
@@ -1028,6 +1165,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
1028
1165
  answerText: text("answer_text"),
1029
1166
  citedDomains: text("cited_domains").notNull().default("[]"),
1030
1167
  competitorOverlap: text("competitor_overlap").notNull().default("[]"),
1168
+ recommendedCompetitors: text("recommended_competitors").notNull().default("[]"),
1031
1169
  location: text("location"),
1032
1170
  screenshotPath: text("screenshot_path"),
1033
1171
  rawResponse: text("raw_response"),
@@ -1221,6 +1359,20 @@ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
1221
1359
  index("idx_ga_traffic_project_date").on(table.projectId, table.date),
1222
1360
  index("idx_ga_traffic_page").on(table.landingPage)
1223
1361
  ]);
1362
+ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
1363
+ id: text("id").primaryKey(),
1364
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
1365
+ date: text("date").notNull(),
1366
+ source: text("source").notNull(),
1367
+ medium: text("medium").notNull(),
1368
+ sessions: integer("sessions").notNull().default(0),
1369
+ users: integer("users").notNull().default(0),
1370
+ syncedAt: text("synced_at").notNull()
1371
+ }, (table) => [
1372
+ index("idx_ga_ai_ref_project_date").on(table.projectId, table.date),
1373
+ index("idx_ga_ai_ref_source").on(table.source),
1374
+ uniqueIndex("idx_ga_ai_ref_unique").on(table.projectId, table.date, table.source, table.medium)
1375
+ ]);
1224
1376
  var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
1225
1377
  id: text("id").primaryKey(),
1226
1378
  projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
@@ -1550,7 +1702,23 @@ var MIGRATIONS = [
1550
1702
  // v15: Bing URL inspections — document_size, anchor_count, discovery_date columns
1551
1703
  `ALTER TABLE bing_url_inspections ADD COLUMN document_size INTEGER`,
1552
1704
  `ALTER TABLE bing_url_inspections ADD COLUMN anchor_count INTEGER`,
1553
- `ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT`
1705
+ `ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT`,
1706
+ // v16: Recommended competitor names extracted from run answers
1707
+ `ALTER TABLE query_snapshots ADD COLUMN recommended_competitors TEXT NOT NULL DEFAULT '[]'`,
1708
+ // v17: GA4 AI referral tracking — ga_ai_referrals table
1709
+ `CREATE TABLE IF NOT EXISTS ga_ai_referrals (
1710
+ id TEXT PRIMARY KEY,
1711
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
1712
+ date TEXT NOT NULL,
1713
+ source TEXT NOT NULL,
1714
+ medium TEXT NOT NULL,
1715
+ sessions INTEGER NOT NULL DEFAULT 0,
1716
+ users INTEGER NOT NULL DEFAULT 0,
1717
+ synced_at TEXT NOT NULL
1718
+ )`,
1719
+ `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_project_date ON ga_ai_referrals(project_id, date)`,
1720
+ `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_source ON ga_ai_referrals(source)`,
1721
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique ON ga_ai_referrals(project_id, date, source, medium)`
1554
1722
  ];
1555
1723
  function migrate(db) {
1556
1724
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -2459,6 +2627,7 @@ async function runRoutes(app, opts) {
2459
2627
  answerText: querySnapshots.answerText,
2460
2628
  citedDomains: querySnapshots.citedDomains,
2461
2629
  competitorOverlap: querySnapshots.competitorOverlap,
2630
+ recommendedCompetitors: querySnapshots.recommendedCompetitors,
2462
2631
  location: querySnapshots.location,
2463
2632
  rawResponse: querySnapshots.rawResponse,
2464
2633
  createdAt: querySnapshots.createdAt
@@ -2477,6 +2646,7 @@ async function runRoutes(app, opts) {
2477
2646
  answerText: s.answerText,
2478
2647
  citedDomains: tryParseJson(s.citedDomains, []),
2479
2648
  competitorOverlap: tryParseJson(s.competitorOverlap, []),
2649
+ recommendedCompetitors: tryParseJson(s.recommendedCompetitors, []),
2480
2650
  model: s.model ?? rawParsed.model,
2481
2651
  location: s.location,
2482
2652
  groundingSources: rawParsed.groundingSources,
@@ -3089,6 +3259,7 @@ async function historyRoutes(app) {
3089
3259
  answerText: querySnapshots.answerText,
3090
3260
  citedDomains: querySnapshots.citedDomains,
3091
3261
  competitorOverlap: querySnapshots.competitorOverlap,
3262
+ recommendedCompetitors: querySnapshots.recommendedCompetitors,
3092
3263
  location: querySnapshots.location,
3093
3264
  createdAt: querySnapshots.createdAt
3094
3265
  }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc2(querySnapshots.createdAt)).all();
@@ -3108,6 +3279,7 @@ async function historyRoutes(app) {
3108
3279
  answerText: s.answerText,
3109
3280
  citedDomains: tryParseJson2(s.citedDomains, []),
3110
3281
  competitorOverlap: tryParseJson2(s.competitorOverlap, []),
3282
+ recommendedCompetitors: tryParseJson2(s.recommendedCompetitors, []),
3111
3283
  location: s.location,
3112
3284
  createdAt: s.createdAt
3113
3285
  })),
@@ -4286,6 +4458,34 @@ var routeCatalog = [
4286
4458
  501: { description: "Google settings updates are not supported." }
4287
4459
  }
4288
4460
  },
4461
+ {
4462
+ method: "post",
4463
+ path: "/api/v1/snapshot",
4464
+ summary: "Generate a one-shot AI perception snapshot",
4465
+ tags: ["snapshot"],
4466
+ requestBody: {
4467
+ required: true,
4468
+ content: {
4469
+ "application/json": {
4470
+ schema: {
4471
+ type: "object",
4472
+ required: ["companyName", "domain"],
4473
+ properties: {
4474
+ companyName: stringSchema,
4475
+ domain: stringSchema,
4476
+ phrases: stringArraySchema,
4477
+ competitors: stringArraySchema
4478
+ }
4479
+ }
4480
+ }
4481
+ }
4482
+ },
4483
+ responses: {
4484
+ 200: { description: "Snapshot report returned." },
4485
+ 400: { description: "Invalid snapshot input." },
4486
+ 501: { description: "Snapshot reporting is not supported." }
4487
+ }
4488
+ },
4289
4489
  {
4290
4490
  method: "put",
4291
4491
  path: "/api/v1/settings/bing",
@@ -5225,6 +5425,45 @@ var routeCatalog = [
5225
5425
  404: { description: "Project, connection, or page not found." }
5226
5426
  }
5227
5427
  },
5428
+ {
5429
+ method: "post",
5430
+ path: "/api/v1/projects/{name}/wordpress/pages/meta/bulk",
5431
+ summary: "Bulk update SEO meta for multiple pages",
5432
+ tags: ["wordpress"],
5433
+ parameters: [nameParameter],
5434
+ requestBody: {
5435
+ required: true,
5436
+ content: {
5437
+ "application/json": {
5438
+ schema: {
5439
+ type: "object",
5440
+ required: ["entries"],
5441
+ properties: {
5442
+ entries: {
5443
+ type: "array",
5444
+ items: {
5445
+ type: "object",
5446
+ required: ["slug"],
5447
+ properties: {
5448
+ slug: stringSchema,
5449
+ title: stringSchema,
5450
+ description: stringSchema,
5451
+ noindex: booleanSchema
5452
+ }
5453
+ }
5454
+ },
5455
+ env: { type: "string", enum: ["live", "staging"] }
5456
+ }
5457
+ }
5458
+ }
5459
+ }
5460
+ },
5461
+ responses: {
5462
+ 200: { description: "Bulk SEO meta update results returned." },
5463
+ 400: { description: "Invalid entries or environment." },
5464
+ 404: { description: "Project or connection not found." }
5465
+ }
5466
+ },
5228
5467
  {
5229
5468
  method: "get",
5230
5469
  path: "/api/v1/projects/{name}/wordpress/schema",
@@ -5266,6 +5505,48 @@ var routeCatalog = [
5266
5505
  404: { description: "Project, connection, or page not found." }
5267
5506
  }
5268
5507
  },
5508
+ {
5509
+ method: "post",
5510
+ path: "/api/v1/projects/{name}/wordpress/schema/deploy",
5511
+ summary: "Deploy JSON-LD schema to WordPress pages",
5512
+ tags: ["wordpress"],
5513
+ parameters: [nameParameter],
5514
+ requestBody: {
5515
+ required: true,
5516
+ content: {
5517
+ "application/json": {
5518
+ schema: {
5519
+ type: "object",
5520
+ required: ["profile"],
5521
+ properties: {
5522
+ profile: {
5523
+ type: "object",
5524
+ description: "Business profile and per-slug schema mapping"
5525
+ },
5526
+ env: { type: "string", enum: ["live", "staging"] }
5527
+ }
5528
+ }
5529
+ }
5530
+ }
5531
+ },
5532
+ responses: {
5533
+ 200: { description: "Schema deployment results returned." },
5534
+ 400: { description: "Invalid profile or environment." },
5535
+ 404: { description: "Project or connection not found." }
5536
+ }
5537
+ },
5538
+ {
5539
+ method: "get",
5540
+ path: "/api/v1/projects/{name}/wordpress/schema/status",
5541
+ summary: "Get JSON-LD schema status for all pages",
5542
+ tags: ["wordpress"],
5543
+ parameters: [nameParameter, wordpressEnvQueryParameter],
5544
+ responses: {
5545
+ 200: { description: "Schema status per page returned." },
5546
+ 400: { description: "Invalid environment." },
5547
+ 404: { description: "Project or connection not found." }
5548
+ }
5549
+ },
5269
5550
  {
5270
5551
  method: "get",
5271
5552
  path: "/api/v1/projects/{name}/wordpress/llms-txt",
@@ -5353,6 +5634,39 @@ var routeCatalog = [
5353
5634
  404: { description: "Project or connection not found." }
5354
5635
  }
5355
5636
  },
5637
+ {
5638
+ method: "post",
5639
+ path: "/api/v1/projects/{name}/wordpress/onboard",
5640
+ summary: "Full WordPress onboarding workflow",
5641
+ tags: ["wordpress"],
5642
+ parameters: [nameParameter],
5643
+ requestBody: {
5644
+ required: true,
5645
+ content: {
5646
+ "application/json": {
5647
+ schema: {
5648
+ type: "object",
5649
+ required: ["url", "username", "appPassword"],
5650
+ properties: {
5651
+ url: stringSchema,
5652
+ stagingUrl: stringSchema,
5653
+ username: stringSchema,
5654
+ appPassword: stringSchema,
5655
+ defaultEnv: { type: "string", enum: ["live", "staging"] },
5656
+ profile: objectSchema,
5657
+ skipSchema: booleanSchema,
5658
+ skipSubmit: booleanSchema
5659
+ }
5660
+ }
5661
+ }
5662
+ }
5663
+ },
5664
+ responses: {
5665
+ 200: { description: "Onboarding result with step-by-step status." },
5666
+ 400: { description: "Invalid onboarding request." },
5667
+ 404: { description: "Project not found." }
5668
+ }
5669
+ },
5356
5670
  // GA4 routes
5357
5671
  {
5358
5672
  method: "post",
@@ -5406,7 +5720,7 @@ var routeCatalog = [
5406
5720
  {
5407
5721
  method: "post",
5408
5722
  path: "/api/v1/projects/{name}/ga/sync",
5409
- summary: "Sync GA4 traffic data",
5723
+ summary: "Sync GA4 traffic and AI referral data",
5410
5724
  tags: ["ga4"],
5411
5725
  parameters: [nameParameter],
5412
5726
  requestBody: {
@@ -5431,7 +5745,7 @@ var routeCatalog = [
5431
5745
  {
5432
5746
  method: "get",
5433
5747
  path: "/api/v1/projects/{name}/ga/traffic",
5434
- summary: "Get GA4 landing page traffic",
5748
+ summary: "Get GA4 landing page traffic and AI referral sources",
5435
5749
  tags: ["ga4"],
5436
5750
  parameters: [nameParameter, limitQueryParameter],
5437
5751
  responses: {
@@ -5631,6 +5945,38 @@ async function settingsRoutes(app, opts) {
5631
5945
  });
5632
5946
  }
5633
5947
 
5948
+ // ../api-routes/src/snapshot.ts
5949
+ async function snapshotRoutes(app, opts) {
5950
+ app.post("/snapshot", async (request, reply) => {
5951
+ const parsed = snapshotRequestSchema.safeParse(request.body);
5952
+ if (!parsed.success) {
5953
+ const err = validationError("Invalid snapshot payload", {
5954
+ issues: parsed.error.issues.map((issue) => ({
5955
+ path: issue.path.join("."),
5956
+ message: issue.message
5957
+ }))
5958
+ });
5959
+ return reply.status(err.statusCode).send(err.toJSON());
5960
+ }
5961
+ if (!opts.onSnapshotRequested) {
5962
+ const err = notImplemented("Snapshot reporting is not supported in this deployment");
5963
+ return reply.status(err.statusCode).send(err.toJSON());
5964
+ }
5965
+ try {
5966
+ const report = await opts.onSnapshotRequested(parsed.data);
5967
+ return reply.send(report);
5968
+ } catch (err) {
5969
+ request.log.error({ err }, "Snapshot report generation failed");
5970
+ return reply.status(500).send({
5971
+ error: {
5972
+ code: "INTERNAL_ERROR",
5973
+ message: err instanceof Error ? err.message : "Failed to generate snapshot report"
5974
+ }
5975
+ });
5976
+ }
5977
+ });
5978
+ }
5979
+
5634
5980
  // ../api-routes/src/telemetry.ts
5635
5981
  async function telemetryRoutes(app, opts) {
5636
5982
  app.get("/telemetry", async (_request, reply) => {
@@ -7664,6 +8010,15 @@ async function batchRunReports(accessToken, propertyId, requests) {
7664
8010
  function formatDate(d) {
7665
8011
  return d.toISOString().split("T")[0];
7666
8012
  }
8013
+ var AI_REFERRAL_SOURCE_FILTERS = [
8014
+ { matchType: "CONTAINS", value: "perplexity" },
8015
+ { matchType: "CONTAINS", value: "gemini" },
8016
+ { matchType: "CONTAINS", value: "chatgpt" },
8017
+ { matchType: "CONTAINS", value: "openai" },
8018
+ { matchType: "CONTAINS", value: "claude" },
8019
+ { matchType: "CONTAINS", value: "anthropic" },
8020
+ { matchType: "CONTAINS", value: "copilot" }
8021
+ ];
7667
8022
  async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
7668
8023
  const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
7669
8024
  const endDate = /* @__PURE__ */ new Date();
@@ -7790,6 +8145,61 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
7790
8145
  ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
7791
8146
  return summary;
7792
8147
  }
8148
+ async function fetchAiReferrals(accessToken, propertyId, days) {
8149
+ const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
8150
+ const endDate = /* @__PURE__ */ new Date();
8151
+ const startDate = /* @__PURE__ */ new Date();
8152
+ startDate.setDate(startDate.getDate() - syncDays);
8153
+ ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
8154
+ const PAGE_SIZE = 1e3;
8155
+ const rows = [];
8156
+ let offset = 0;
8157
+ while (true) {
8158
+ const request = {
8159
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
8160
+ dimensions: [
8161
+ { name: "date" },
8162
+ { name: "sessionSource" },
8163
+ { name: "sessionMedium" }
8164
+ ],
8165
+ metrics: [
8166
+ { name: "sessions" },
8167
+ { name: "totalUsers" }
8168
+ ],
8169
+ dimensionFilter: {
8170
+ orGroup: {
8171
+ expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
8172
+ filter: {
8173
+ fieldName: "sessionSource",
8174
+ stringFilter: { matchType, value }
8175
+ }
8176
+ }))
8177
+ }
8178
+ },
8179
+ limit: PAGE_SIZE,
8180
+ offset
8181
+ };
8182
+ const response = await runReport(accessToken, propertyId, request);
8183
+ const pageRows = (response.rows ?? []).map((row) => ({
8184
+ date: row.dimensionValues[0].value,
8185
+ source: row.dimensionValues[1].value,
8186
+ medium: row.dimensionValues[2].value,
8187
+ sessions: parseInt(row.metricValues[0].value, 10) || 0,
8188
+ users: parseInt(row.metricValues[1].value, 10) || 0
8189
+ }));
8190
+ rows.push(...pageRows);
8191
+ const totalRows = response.rowCount ?? 0;
8192
+ offset += pageRows.length;
8193
+ if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
8194
+ }
8195
+ for (const row of rows) {
8196
+ if (row.date.length === 8 && !row.date.includes("-")) {
8197
+ row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
8198
+ }
8199
+ }
8200
+ ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
8201
+ return rows;
8202
+ }
7793
8203
 
7794
8204
  // ../api-routes/src/ga.ts
7795
8205
  function gaLog(level, action, ctx) {
@@ -7875,6 +8285,7 @@ async function ga4Routes(app, opts) {
7875
8285
  }
7876
8286
  app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
7877
8287
  app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
8288
+ app.db.delete(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).run();
7878
8289
  store.deleteConnection(project.name);
7879
8290
  writeAuditLog(app.db, {
7880
8291
  projectId: project.id,
@@ -7893,7 +8304,7 @@ async function ga4Routes(app, opts) {
7893
8304
  if (!conn) {
7894
8305
  return { connected: false, propertyId: null, clientEmail: null, lastSyncedAt: null };
7895
8306
  }
7896
- const latestSync = app.db.select({ syncedAt: gaTrafficSnapshots.syncedAt }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).orderBy(desc6(gaTrafficSnapshots.syncedAt)).limit(1).get();
8307
+ const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
7897
8308
  return {
7898
8309
  connected: true,
7899
8310
  propertyId: conn.propertyId,
@@ -7924,11 +8335,13 @@ async function ga4Routes(app, opts) {
7924
8335
  }
7925
8336
  let rows;
7926
8337
  let summary;
8338
+ let aiReferrals;
7927
8339
  try {
7928
8340
  ;
7929
- [rows, summary] = await Promise.all([
8341
+ [rows, summary, aiReferrals] = await Promise.all([
7930
8342
  fetchTrafficByLandingPage(accessToken, conn.propertyId, days),
7931
- fetchAggregateSummary(accessToken, conn.propertyId, days)
8343
+ fetchAggregateSummary(accessToken, conn.propertyId, days),
8344
+ fetchAiReferrals(accessToken, conn.propertyId, days)
7932
8345
  ]);
7933
8346
  } catch (e) {
7934
8347
  const msg = e instanceof Error ? e.message : String(e);
@@ -7937,17 +8350,14 @@ async function ga4Routes(app, opts) {
7937
8350
  }
7938
8351
  const now = (/* @__PURE__ */ new Date()).toISOString();
7939
8352
  app.db.transaction((tx) => {
8353
+ tx.delete(gaTrafficSnapshots).where(
8354
+ and6(
8355
+ eq16(gaTrafficSnapshots.projectId, project.id),
8356
+ sql3`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
8357
+ sql3`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
8358
+ )
8359
+ ).run();
7940
8360
  if (rows.length > 0) {
7941
- const dates = rows.map((r) => r.date);
7942
- const minDate = dates.reduce((a, b) => a < b ? a : b);
7943
- const maxDate = dates.reduce((a, b) => a > b ? a : b);
7944
- tx.delete(gaTrafficSnapshots).where(
7945
- and6(
7946
- eq16(gaTrafficSnapshots.projectId, project.id),
7947
- sql3`${gaTrafficSnapshots.date} >= ${minDate}`,
7948
- sql3`${gaTrafficSnapshots.date} <= ${maxDate}`
7949
- )
7950
- ).run();
7951
8361
  for (const row of rows) {
7952
8362
  tx.insert(gaTrafficSnapshots).values({
7953
8363
  id: crypto16.randomUUID(),
@@ -7961,6 +8371,27 @@ async function ga4Routes(app, opts) {
7961
8371
  }).run();
7962
8372
  }
7963
8373
  }
8374
+ tx.delete(gaAiReferrals).where(
8375
+ and6(
8376
+ eq16(gaAiReferrals.projectId, project.id),
8377
+ sql3`${gaAiReferrals.date} >= ${summary.periodStart}`,
8378
+ sql3`${gaAiReferrals.date} <= ${summary.periodEnd}`
8379
+ )
8380
+ ).run();
8381
+ if (aiReferrals.length > 0) {
8382
+ for (const row of aiReferrals) {
8383
+ tx.insert(gaAiReferrals).values({
8384
+ id: crypto16.randomUUID(),
8385
+ projectId: project.id,
8386
+ date: row.date,
8387
+ source: row.source,
8388
+ medium: row.medium,
8389
+ sessions: row.sessions,
8390
+ users: row.users,
8391
+ syncedAt: now
8392
+ }).run();
8393
+ }
8394
+ }
7964
8395
  tx.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
7965
8396
  tx.insert(gaTrafficSummaries).values({
7966
8397
  id: crypto16.randomUUID(),
@@ -7973,10 +8404,17 @@ async function ga4Routes(app, opts) {
7973
8404
  syncedAt: now
7974
8405
  }).run();
7975
8406
  });
7976
- gaLog("info", "sync.complete", { projectId: project.id, rowCount: rows.length, days, totalUsers: summary.totalUsers });
8407
+ gaLog("info", "sync.complete", {
8408
+ projectId: project.id,
8409
+ rowCount: rows.length,
8410
+ aiReferralCount: aiReferrals.length,
8411
+ days,
8412
+ totalUsers: summary.totalUsers
8413
+ });
7977
8414
  return {
7978
8415
  synced: true,
7979
8416
  rowCount: rows.length,
8417
+ aiReferralCount: aiReferrals.length,
7980
8418
  days,
7981
8419
  syncedAt: now
7982
8420
  };
@@ -8002,7 +8440,13 @@ async function ga4Routes(app, opts) {
8002
8440
  organicSessions: sql3`SUM(${gaTrafficSnapshots.organicSessions})`,
8003
8441
  users: sql3`SUM(${gaTrafficSnapshots.users})`
8004
8442
  }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql3`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8005
- const latestSync = app.db.select({ syncedAt: gaTrafficSnapshots.syncedAt }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).orderBy(desc6(gaTrafficSnapshots.syncedAt)).limit(1).get();
8443
+ const aiReferrals = app.db.select({
8444
+ source: gaAiReferrals.source,
8445
+ medium: gaAiReferrals.medium,
8446
+ sessions: sql3`SUM(${gaAiReferrals.sessions})`,
8447
+ users: sql3`SUM(${gaAiReferrals.users})`
8448
+ }).from(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).groupBy(gaAiReferrals.source, gaAiReferrals.medium).orderBy(sql3`SUM(${gaAiReferrals.sessions}) DESC`).all();
8449
+ const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
8006
8450
  return {
8007
8451
  totalSessions: summary?.totalSessions ?? 0,
8008
8452
  totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
@@ -8013,6 +8457,12 @@ async function ga4Routes(app, opts) {
8013
8457
  organicSessions: r.organicSessions ?? 0,
8014
8458
  users: r.users ?? 0
8015
8459
  })),
8460
+ aiReferrals: aiReferrals.map((r) => ({
8461
+ source: r.source,
8462
+ medium: r.medium,
8463
+ sessions: r.sessions ?? 0,
8464
+ users: r.users ?? 0
8465
+ })),
8016
8466
  lastSyncedAt: latestSync?.syncedAt ?? null
8017
8467
  };
8018
8468
  });
@@ -8054,6 +8504,115 @@ var WordpressApiError = class extends Error {
8054
8504
  }
8055
8505
  };
8056
8506
 
8507
+ // ../integration-wordpress/src/schema-templates.ts
8508
+ var SUPPORTED_TYPES = /* @__PURE__ */ new Set([
8509
+ "LocalBusiness",
8510
+ "Organization",
8511
+ "FAQPage",
8512
+ "Service",
8513
+ "WebPage"
8514
+ ]);
8515
+ function isSupportedSchemaType(type) {
8516
+ return SUPPORTED_TYPES.has(type);
8517
+ }
8518
+ function buildAddress(address) {
8519
+ return {
8520
+ "@type": "PostalAddress",
8521
+ ...address.street ? { streetAddress: address.street } : {},
8522
+ ...address.city ? { addressLocality: address.city } : {},
8523
+ ...address.state ? { addressRegion: address.state } : {},
8524
+ ...address.zip ? { postalCode: address.zip } : {},
8525
+ ...address.country ? { addressCountry: address.country } : {}
8526
+ };
8527
+ }
8528
+ function generateLocalBusiness(profile) {
8529
+ const schema = {
8530
+ "@context": "https://schema.org",
8531
+ "@type": "LocalBusiness",
8532
+ name: profile.name
8533
+ };
8534
+ if (profile.url) schema.url = profile.url;
8535
+ if (profile.description) schema.description = profile.description;
8536
+ if (profile.phone) schema.telephone = profile.phone;
8537
+ if (profile.email) schema.email = profile.email;
8538
+ if (profile.address) schema.address = buildAddress(profile.address);
8539
+ return schema;
8540
+ }
8541
+ function generateOrganization(profile) {
8542
+ const schema = {
8543
+ "@context": "https://schema.org",
8544
+ "@type": "Organization",
8545
+ name: profile.name
8546
+ };
8547
+ if (profile.url) schema.url = profile.url;
8548
+ if (profile.description) schema.description = profile.description;
8549
+ if (profile.phone) schema.telephone = profile.phone;
8550
+ if (profile.email) schema.email = profile.email;
8551
+ if (profile.address) schema.address = buildAddress(profile.address);
8552
+ return schema;
8553
+ }
8554
+ function generateFAQPage(profile, faqs) {
8555
+ const schema = {
8556
+ "@context": "https://schema.org",
8557
+ "@type": "FAQPage",
8558
+ name: profile.name
8559
+ };
8560
+ if (faqs && faqs.length > 0) {
8561
+ schema.mainEntity = faqs.map((faq) => ({
8562
+ "@type": "Question",
8563
+ name: faq.q,
8564
+ acceptedAnswer: {
8565
+ "@type": "Answer",
8566
+ text: faq.a
8567
+ }
8568
+ }));
8569
+ }
8570
+ return schema;
8571
+ }
8572
+ function generateService(profile) {
8573
+ const schema = {
8574
+ "@context": "https://schema.org",
8575
+ "@type": "Service",
8576
+ name: profile.name
8577
+ };
8578
+ if (profile.url) schema.url = profile.url;
8579
+ if (profile.description) schema.description = profile.description;
8580
+ if (profile.address) schema.areaServed = buildAddress(profile.address);
8581
+ return schema;
8582
+ }
8583
+ function generateWebPage(profile) {
8584
+ const schema = {
8585
+ "@context": "https://schema.org",
8586
+ "@type": "WebPage",
8587
+ name: profile.name
8588
+ };
8589
+ if (profile.url) schema.url = profile.url;
8590
+ if (profile.description) schema.description = profile.description;
8591
+ return schema;
8592
+ }
8593
+ function generateSchema(type, profile, overrides) {
8594
+ switch (type) {
8595
+ case "LocalBusiness":
8596
+ return generateLocalBusiness(profile);
8597
+ case "Organization":
8598
+ return generateOrganization(profile);
8599
+ case "FAQPage":
8600
+ return generateFAQPage(profile, overrides?.faqs);
8601
+ case "Service":
8602
+ return generateService(profile);
8603
+ case "WebPage":
8604
+ return generateWebPage(profile);
8605
+ default:
8606
+ throw new Error(`Unsupported schema type: ${type}`);
8607
+ }
8608
+ }
8609
+ function parseSchemaPageEntry(entry) {
8610
+ if (typeof entry === "string") {
8611
+ return { type: entry };
8612
+ }
8613
+ return { type: entry.type, faqs: entry.faqs };
8614
+ }
8615
+
8057
8616
  // ../integration-wordpress/src/wordpress-client.ts
8058
8617
  import crypto17 from "crypto";
8059
8618
  var PAGE_FIELDS = "id,slug,status,link,modified,modified_gmt,title,content,meta";
@@ -8490,22 +9049,287 @@ async function setSeoMeta(connection, slug, body, env) {
8490
9049
  );
8491
9050
  return getPageDetail(connection, slug, site.env, plugins);
8492
9051
  }
8493
- async function getLlmsTxt(connection, env) {
9052
+ async function detectSeoWriteStrategy(connection, env) {
8494
9053
  const site = resolveEnvironment(connection, env);
8495
- const url = `${site.siteUrl}/llms.txt`;
9054
+ const plugins = await listActivePlugins(connection, site.env);
9055
+ const pages = await listPages(connection, site.env);
9056
+ if (pages.length === 0) {
9057
+ return { strategy: "manual", plugins };
9058
+ }
9059
+ const samplePage = await getPageBySlug(connection, pages[0].slug, site.env);
9060
+ const writeTargets = resolveSeoWriteTargets(samplePage.meta, plugins);
8496
9061
  return {
8497
- env: site.env,
8498
- url,
8499
- content: await fetchText(url)
9062
+ strategy: writeTargets.length > 0 ? "plugin" : "manual",
9063
+ plugins
8500
9064
  };
8501
9065
  }
8502
- async function buildManualLlmsTxtUpdate(connection, content, env) {
8503
- const site = resolveEnvironment(connection, env);
9066
+ function buildManualMetaAssist(siteUrl, slug, link, meta) {
9067
+ const fields = [];
9068
+ if (meta.title != null) fields.push(`Title: ${meta.title}`);
9069
+ if (meta.description != null) fields.push(`Description: ${meta.description}`);
9070
+ if (meta.noindex != null) fields.push(`Noindex: ${meta.noindex}`);
8504
9071
  return {
8505
9072
  manualRequired: true,
8506
- targetUrl: `${site.siteUrl}/llms.txt`,
8507
- adminUrl: `${site.siteUrl}/wp-admin/`,
8508
- content,
9073
+ targetUrl: link ?? `${siteUrl}/${slug}`,
9074
+ adminUrl: `${siteUrl}/wp-admin/`,
9075
+ content: fields.join("\n"),
9076
+ nextSteps: [
9077
+ `Open the WordPress editor for page "${slug}".`,
9078
+ "Install an SEO plugin (Yoast SEO, Rank Math, or AIOSEO) to manage meta fields via REST, or set the values manually in the page editor.",
9079
+ "Apply the meta values listed above.",
9080
+ "Publish/update the page."
9081
+ ]
9082
+ };
9083
+ }
9084
+ async function bulkSetSeoMeta(connection, entries, env) {
9085
+ const site = resolveEnvironment(connection, env);
9086
+ const { strategy, plugins } = await detectSeoWriteStrategy(connection, site.env);
9087
+ const results = await mapWithConcurrency(
9088
+ entries,
9089
+ 3,
9090
+ async (entry) => {
9091
+ try {
9092
+ const page = await getPageBySlug(connection, entry.slug, site.env);
9093
+ if (strategy === "manual") {
9094
+ return {
9095
+ slug: entry.slug,
9096
+ status: "manual",
9097
+ manualAssist: buildManualMetaAssist(
9098
+ site.siteUrl,
9099
+ entry.slug,
9100
+ page.link,
9101
+ entry
9102
+ )
9103
+ };
9104
+ }
9105
+ const writeTargets = resolveSeoWriteTargets(page.meta, plugins);
9106
+ if (writeTargets.length === 0 || !page.meta) {
9107
+ return {
9108
+ slug: entry.slug,
9109
+ status: "manual",
9110
+ manualAssist: buildManualMetaAssist(
9111
+ site.siteUrl,
9112
+ entry.slug,
9113
+ page.link,
9114
+ entry
9115
+ )
9116
+ };
9117
+ }
9118
+ const patch = {};
9119
+ for (const target of SEO_TARGETS) {
9120
+ if (entry.title != null && writeTargets.includes(target.titleKey)) patch[target.titleKey] = entry.title;
9121
+ if (entry.description != null && writeTargets.includes(target.descriptionKey)) patch[target.descriptionKey] = entry.description;
9122
+ if (entry.noindex != null && writeTargets.includes(target.noindexKey)) {
9123
+ patch[target.noindexKey] = encodeNoindexValue(target.noindexKey, entry.noindex);
9124
+ }
9125
+ }
9126
+ if (Object.keys(patch).length === 0) {
9127
+ return {
9128
+ slug: entry.slug,
9129
+ status: "manual",
9130
+ manualAssist: buildManualMetaAssist(
9131
+ site.siteUrl,
9132
+ entry.slug,
9133
+ page.link,
9134
+ entry
9135
+ )
9136
+ };
9137
+ }
9138
+ await fetchJson(
9139
+ connection,
9140
+ site.siteUrl,
9141
+ `/wp-json/wp/v2/pages/${page.id}`,
9142
+ {
9143
+ method: "POST",
9144
+ body: JSON.stringify({
9145
+ meta: { ...page.meta ?? {}, ...patch }
9146
+ })
9147
+ }
9148
+ );
9149
+ return { slug: entry.slug, status: "applied" };
9150
+ } catch (error) {
9151
+ if (error instanceof WordpressApiError && error.code === "NOT_FOUND") {
9152
+ return { slug: entry.slug, status: "skipped", error: `Page "${entry.slug}" not found` };
9153
+ }
9154
+ return {
9155
+ slug: entry.slug,
9156
+ status: "skipped",
9157
+ error: error instanceof Error ? error.message : String(error)
9158
+ };
9159
+ }
9160
+ }
9161
+ );
9162
+ return { env: site.env, strategy, results };
9163
+ }
9164
+ var CANONRY_SCHEMA_START = "<!-- canonry:schema:start -->";
9165
+ var CANONRY_SCHEMA_END = "<!-- canonry:schema:end -->";
9166
+ function stripCanonrySchema(content) {
9167
+ const regex = new RegExp(
9168
+ `${escapeRegExp(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp(CANONRY_SCHEMA_END)}`,
9169
+ "g"
9170
+ );
9171
+ return content.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
9172
+ }
9173
+ function escapeRegExp(str) {
9174
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9175
+ }
9176
+ function injectCanonrySchema(content, schemas) {
9177
+ if (schemas.length === 0) return content;
9178
+ const blocks = schemas.map((schema) => `<script type="application/ld+json">${JSON.stringify(schema).replace(/<\//g, "<\\/")}</script>`).join("\n");
9179
+ const injection = `
9180
+
9181
+ ${CANONRY_SCHEMA_START}
9182
+ ${blocks}
9183
+ ${CANONRY_SCHEMA_END}`;
9184
+ const stripped = stripCanonrySchema(content);
9185
+ return stripped + injection;
9186
+ }
9187
+ async function verifySchemaInjection(connection, slug, env) {
9188
+ const page = await getPageBySlug(connection, slug, env);
9189
+ const raw = page.content?.raw ?? page.content?.rendered ?? "";
9190
+ return raw.includes(CANONRY_SCHEMA_START);
9191
+ }
9192
+ async function deploySchema(connection, slug, schemas, env) {
9193
+ const site = resolveEnvironment(connection, env);
9194
+ try {
9195
+ const page = await getPageBySlug(connection, slug, site.env);
9196
+ const currentContent = page.content?.raw ?? page.content?.rendered ?? "";
9197
+ const updatedContent = injectCanonrySchema(currentContent, schemas);
9198
+ await fetchJson(
9199
+ connection,
9200
+ site.siteUrl,
9201
+ `/wp-json/wp/v2/pages/${page.id}`,
9202
+ {
9203
+ method: "POST",
9204
+ body: JSON.stringify({ content: updatedContent })
9205
+ }
9206
+ );
9207
+ const persisted = await verifySchemaInjection(connection, slug, site.env);
9208
+ if (!persisted) {
9209
+ return {
9210
+ slug,
9211
+ status: "stripped",
9212
+ schemasInjected: schemas.map((s) => String(s["@type"] ?? "Unknown")),
9213
+ manualAssist: {
9214
+ manualRequired: true,
9215
+ targetUrl: page.link ?? `${site.siteUrl}/${slug}`,
9216
+ adminUrl: `${site.siteUrl}/wp-admin/`,
9217
+ content: schemas.map((s) => JSON.stringify(s, null, 2)).join("\n\n"),
9218
+ nextSteps: [
9219
+ `WordPress stripped the schema <script> tags for page "${slug}". The connected user likely lacks the unfiltered_html capability.`,
9220
+ "Grant the user Administrator or Super Admin role, or add the schema manually in the page editor or via a schema plugin.",
9221
+ "Paste the JSON-LD blocks provided above."
9222
+ ]
9223
+ }
9224
+ };
9225
+ }
9226
+ return {
9227
+ slug,
9228
+ status: "deployed",
9229
+ schemasInjected: schemas.map((s) => String(s["@type"] ?? "Unknown"))
9230
+ };
9231
+ } catch (error) {
9232
+ if (error instanceof WordpressApiError && error.code === "NOT_FOUND") {
9233
+ return { slug, status: "skipped", error: `Page "${slug}" not found` };
9234
+ }
9235
+ return {
9236
+ slug,
9237
+ status: "failed",
9238
+ error: error instanceof Error ? error.message : String(error)
9239
+ };
9240
+ }
9241
+ }
9242
+ async function deploySchemaFromProfile(connection, profile, env) {
9243
+ const site = resolveEnvironment(connection, env);
9244
+ const slugEntries = Object.entries(profile.pages);
9245
+ const results = await mapWithConcurrency(
9246
+ slugEntries,
9247
+ 3,
9248
+ async ([slug, entries]) => {
9249
+ const parsed = entries.map(parseSchemaPageEntry);
9250
+ const unsupported = parsed.filter((p) => !isSupportedSchemaType(p.type));
9251
+ if (unsupported.length > 0) {
9252
+ return {
9253
+ slug,
9254
+ status: "failed",
9255
+ error: `Unsupported schema type(s): ${unsupported.map((u) => u.type).join(", ")}`
9256
+ };
9257
+ }
9258
+ const schemas = parsed.map((p) => generateSchema(p.type, profile.business, { faqs: p.faqs }));
9259
+ return deploySchema(connection, slug, schemas, site.env);
9260
+ }
9261
+ );
9262
+ return { env: site.env, results };
9263
+ }
9264
+ async function getSchemaStatus(connection, env) {
9265
+ const site = resolveEnvironment(connection, env);
9266
+ const pages = await listPages(connection, site.env);
9267
+ const details = await mapWithConcurrency(
9268
+ pages,
9269
+ 5,
9270
+ async (page) => getPageDetail(connection, page.slug, site.env)
9271
+ );
9272
+ const statusPages = details.map((page) => {
9273
+ const rawContent = page.content;
9274
+ const hasCanonryMarker = rawContent.includes(CANONRY_SCHEMA_START);
9275
+ const allSchemaTypes = page.schemaBlocks.map((b) => b.type);
9276
+ const canonrySchemas = [];
9277
+ const thirdPartySchemas = [];
9278
+ if (hasCanonryMarker) {
9279
+ const markerRegex = new RegExp(
9280
+ `${escapeRegExp(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp(CANONRY_SCHEMA_END)}`
9281
+ );
9282
+ const match = markerRegex.exec(rawContent);
9283
+ if (match?.[1]) {
9284
+ const jsonLdRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
9285
+ let jsonMatch;
9286
+ while ((jsonMatch = jsonLdRegex.exec(match[1])) !== null) {
9287
+ try {
9288
+ const parsed = JSON.parse(jsonMatch[1].trim());
9289
+ canonrySchemas.push(String(parsed["@type"] ?? "Unknown"));
9290
+ } catch {
9291
+ }
9292
+ }
9293
+ }
9294
+ }
9295
+ const canonryCounts = /* @__PURE__ */ new Map();
9296
+ for (const t of canonrySchemas) {
9297
+ canonryCounts.set(t, (canonryCounts.get(t) ?? 0) + 1);
9298
+ }
9299
+ for (const schemaType of allSchemaTypes) {
9300
+ const remaining = canonryCounts.get(schemaType) ?? 0;
9301
+ if (remaining > 0) {
9302
+ canonryCounts.set(schemaType, remaining - 1);
9303
+ } else {
9304
+ thirdPartySchemas.push(schemaType);
9305
+ }
9306
+ }
9307
+ return {
9308
+ slug: page.slug,
9309
+ title: page.title,
9310
+ canonrySchemas,
9311
+ thirdPartySchemas,
9312
+ hasCanonrySchema: hasCanonryMarker
9313
+ };
9314
+ });
9315
+ return { env: site.env, pages: statusPages };
9316
+ }
9317
+ async function getLlmsTxt(connection, env) {
9318
+ const site = resolveEnvironment(connection, env);
9319
+ const url = `${site.siteUrl}/llms.txt`;
9320
+ return {
9321
+ env: site.env,
9322
+ url,
9323
+ content: await fetchText(url)
9324
+ };
9325
+ }
9326
+ async function buildManualLlmsTxtUpdate(connection, content, env) {
9327
+ const site = resolveEnvironment(connection, env);
9328
+ return {
9329
+ manualRequired: true,
9330
+ targetUrl: `${site.siteUrl}/llms.txt`,
9331
+ adminUrl: `${site.siteUrl}/wp-admin/`,
9332
+ content,
8509
9333
  nextSteps: [
8510
9334
  `Open your hosting file manager or SSH session for ${site.siteUrl}.`,
8511
9335
  "Create or update the file llms.txt at the site root.",
@@ -8935,6 +9759,39 @@ async function wordpressRoutes(app, opts) {
8935
9759
  return updated;
8936
9760
  });
8937
9761
  });
9762
+ app.post("/projects/:name/wordpress/pages/meta/bulk", async (request, reply) => {
9763
+ return withWordpressErrorHandling(reply, async () => {
9764
+ const store = requireStore(reply);
9765
+ if (!store) return;
9766
+ const project = resolveProject(app.db, request.params.name);
9767
+ const connection = requireConnection(store, project.name, reply);
9768
+ if (!connection) return;
9769
+ const entries = request.body?.entries;
9770
+ if (!Array.isArray(entries) || entries.length === 0) {
9771
+ const err = validationError("entries array is required and must not be empty");
9772
+ return reply.status(err.statusCode).send(err.toJSON());
9773
+ }
9774
+ for (const entry of entries) {
9775
+ if (!entry.slug?.trim()) {
9776
+ const err = validationError("each entry must have a slug");
9777
+ return reply.status(err.statusCode).send(err.toJSON());
9778
+ }
9779
+ }
9780
+ const env = parseEnvInput(request.body?.env);
9781
+ const result = await bulkSetSeoMeta(connection, entries, env);
9782
+ const applied = result.results.filter((r) => r.status === "applied");
9783
+ if (applied.length > 0) {
9784
+ writeAuditLog(app.db, {
9785
+ projectId: project.id,
9786
+ actor: "api",
9787
+ action: "wordpress.page-meta-updated",
9788
+ entityType: "wordpress_page",
9789
+ entityId: `bulk(${applied.map((r) => r.slug).join(",")})`
9790
+ });
9791
+ }
9792
+ return result;
9793
+ });
9794
+ });
8938
9795
  app.get("/projects/:name/wordpress/schema", async (request, reply) => {
8939
9796
  return withWordpressErrorHandling(reply, async () => {
8940
9797
  const store = requireStore(reply);
@@ -8968,6 +9825,33 @@ async function wordpressRoutes(app, opts) {
8968
9825
  return buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
8969
9826
  });
8970
9827
  });
9828
+ app.post("/projects/:name/wordpress/schema/deploy", async (request, reply) => {
9829
+ return withWordpressErrorHandling(reply, async () => {
9830
+ const store = requireStore(reply);
9831
+ if (!store) return;
9832
+ const project = resolveProject(app.db, request.params.name);
9833
+ const connection = requireConnection(store, project.name, reply);
9834
+ if (!connection) return;
9835
+ const profile = request.body?.profile;
9836
+ if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) {
9837
+ const err = validationError("profile with business.name and non-empty pages is required");
9838
+ return reply.status(err.statusCode).send(err.toJSON());
9839
+ }
9840
+ const env = parseEnvInput(request.body?.env);
9841
+ return deploySchemaFromProfile(connection, profile, env);
9842
+ });
9843
+ });
9844
+ app.get("/projects/:name/wordpress/schema/status", async (request, reply) => {
9845
+ return withWordpressErrorHandling(reply, async () => {
9846
+ const store = requireStore(reply);
9847
+ if (!store) return;
9848
+ const project = resolveProject(app.db, request.params.name);
9849
+ const connection = requireConnection(store, project.name, reply);
9850
+ if (!connection) return;
9851
+ const env = parseEnvInput(request.query?.env);
9852
+ return getSchemaStatus(connection, env);
9853
+ });
9854
+ });
8971
9855
  app.get("/projects/:name/wordpress/llms-txt", async (request, reply) => {
8972
9856
  return withWordpressErrorHandling(reply, async () => {
8973
9857
  const store = requireStore(reply);
@@ -9051,6 +9935,201 @@ async function wordpressRoutes(app, opts) {
9051
9935
  return buildManualStagingPush(connection);
9052
9936
  });
9053
9937
  });
9938
+ app.post("/projects/:name/wordpress/onboard", async (request, reply) => {
9939
+ return withWordpressErrorHandling(reply, async () => {
9940
+ const store = requireStore(reply);
9941
+ if (!store) return;
9942
+ const project = resolveProject(app.db, request.params.name);
9943
+ const { url, username, appPassword, stagingUrl, profile, skipSchema, skipSubmit } = request.body ?? {};
9944
+ if (!url || !username || !appPassword) {
9945
+ const err = validationError("url, username, and appPassword are required");
9946
+ return reply.status(err.statusCode).send(err.toJSON());
9947
+ }
9948
+ const defaultEnv = parseEnvInput(request.body?.defaultEnv, "defaultEnv") ?? (stagingUrl ? "staging" : "live");
9949
+ if (defaultEnv === "staging" && !stagingUrl) {
9950
+ const err = validationError('defaultEnv "staging" requires stagingUrl');
9951
+ return reply.status(err.statusCode).send(err.toJSON());
9952
+ }
9953
+ const steps = [];
9954
+ let connection = null;
9955
+ let pageUrls = [];
9956
+ try {
9957
+ const now = (/* @__PURE__ */ new Date()).toISOString();
9958
+ const existing = store.getConnection(project.name);
9959
+ const nextConnection = {
9960
+ projectName: project.name,
9961
+ url,
9962
+ stagingUrl,
9963
+ username,
9964
+ appPassword,
9965
+ defaultEnv,
9966
+ createdAt: existing?.createdAt ?? now,
9967
+ updatedAt: now
9968
+ };
9969
+ await verifyWordpressConnection(nextConnection);
9970
+ connection = store.upsertConnection(nextConnection);
9971
+ writeAuditLog(app.db, {
9972
+ projectId: project.id,
9973
+ actor: "api",
9974
+ action: "wordpress.connected",
9975
+ entityType: "wordpress_connection",
9976
+ entityId: project.name
9977
+ });
9978
+ steps.push({ name: "connect", status: "completed", summary: `Connected to ${url}` });
9979
+ } catch (err) {
9980
+ const msg = err instanceof Error ? err.message : String(err);
9981
+ steps.push({ name: "connect", status: "failed", error: msg });
9982
+ return { projectName: project.name, steps };
9983
+ }
9984
+ let auditIssues = [];
9985
+ let auditPages = [];
9986
+ try {
9987
+ const audit = await runAudit(connection);
9988
+ const issueCount = audit.issues?.length ?? 0;
9989
+ const pageCount = audit.pages?.length ?? 0;
9990
+ auditIssues = audit.issues;
9991
+ auditPages = audit.pages;
9992
+ const pageSummaries = await listPages(connection);
9993
+ pageUrls = pageSummaries.map((p) => p.link).filter((link) => typeof link === "string" && link.length > 0);
9994
+ steps.push({ name: "audit", status: "completed", summary: `${pageCount} pages audited, ${issueCount} issues` });
9995
+ } catch (err) {
9996
+ const msg = err instanceof Error ? err.message : String(err);
9997
+ steps.push({ name: "audit", status: "failed", error: msg });
9998
+ return { projectName: project.name, steps };
9999
+ }
10000
+ try {
10001
+ const metaEntries = [];
10002
+ for (const issue of auditIssues) {
10003
+ if (issue.code === "missing-meta-description" || issue.code === "missing-seo-title") {
10004
+ const existing = metaEntries.find((e) => e.slug === issue.slug);
10005
+ const page = auditPages.find((p) => p.slug === issue.slug);
10006
+ if (!existing) {
10007
+ metaEntries.push({
10008
+ slug: issue.slug,
10009
+ title: issue.code === "missing-seo-title" ? page?.title ?? issue.slug : void 0,
10010
+ description: issue.code === "missing-meta-description" ? page?.title ?? issue.slug : void 0
10011
+ });
10012
+ } else {
10013
+ if (issue.code === "missing-seo-title" && !existing.title) {
10014
+ existing.title = page?.title ?? issue.slug;
10015
+ }
10016
+ if (issue.code === "missing-meta-description" && !existing.description) {
10017
+ existing.description = page?.title ?? issue.slug;
10018
+ }
10019
+ }
10020
+ }
10021
+ }
10022
+ if (metaEntries.length === 0) {
10023
+ steps.push({ name: "set-meta", status: "skipped", summary: "No pages with missing meta found" });
10024
+ } else {
10025
+ const result = await bulkSetSeoMeta(connection, metaEntries);
10026
+ const applied = result.results.filter((r) => r.status === "applied").length;
10027
+ const manual = result.results.filter((r) => r.status === "manual").length;
10028
+ const skipped = result.results.filter((r) => r.status === "skipped").length;
10029
+ steps.push({
10030
+ name: "set-meta",
10031
+ status: "completed",
10032
+ summary: `${applied} applied, ${manual} manual-assist, ${skipped} skipped`
10033
+ });
10034
+ }
10035
+ } catch (err) {
10036
+ const msg = err instanceof Error ? err.message : String(err);
10037
+ steps.push({ name: "set-meta", status: "failed", error: msg });
10038
+ return { projectName: project.name, steps };
10039
+ }
10040
+ if (skipSchema || !profile) {
10041
+ steps.push({
10042
+ name: "schema-deploy",
10043
+ status: "skipped",
10044
+ summary: skipSchema ? "Skipped via --skip-schema" : "No --profile provided"
10045
+ });
10046
+ } else {
10047
+ try {
10048
+ if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) {
10049
+ steps.push({ name: "schema-deploy", status: "skipped", summary: "Profile missing business.name or pages" });
10050
+ } else {
10051
+ const result = await deploySchemaFromProfile(connection, profile);
10052
+ const deployed = result.results.filter((r) => r.status === "deployed").length;
10053
+ const stripped = result.results.filter((r) => r.status === "stripped").length;
10054
+ const skipped = result.results.filter((r) => r.status === "skipped").length;
10055
+ steps.push({
10056
+ name: "schema-deploy",
10057
+ status: "completed",
10058
+ summary: `${deployed} deployed, ${stripped} stripped (manual-assist), ${skipped} skipped`
10059
+ });
10060
+ }
10061
+ } catch (err) {
10062
+ const msg = err instanceof Error ? err.message : String(err);
10063
+ steps.push({ name: "schema-deploy", status: "failed", error: msg });
10064
+ return { projectName: project.name, steps };
10065
+ }
10066
+ }
10067
+ if (skipSubmit || pageUrls.length === 0) {
10068
+ const reason = skipSubmit ? "Skipped via --skip-submit" : "No page URLs to submit";
10069
+ steps.push({ name: "google-submit", status: "skipped", summary: reason });
10070
+ steps.push({ name: "bing-submit", status: "skipped", summary: reason });
10071
+ } else {
10072
+ try {
10073
+ const authHeader = request.headers.authorization;
10074
+ const googleRes = await app.inject({
10075
+ method: "POST",
10076
+ url: `${opts.routePrefix ?? "/api/v1"}/projects/${encodeURIComponent(project.name)}/google/indexing/request`,
10077
+ payload: { urls: pageUrls },
10078
+ headers: authHeader ? { authorization: authHeader } : {}
10079
+ });
10080
+ if (googleRes.statusCode === 200) {
10081
+ const body = JSON.parse(googleRes.body);
10082
+ const succeeded = body.results?.filter((r) => r.status === "success").length ?? 0;
10083
+ steps.push({ name: "google-submit", status: "completed", summary: `${succeeded}/${pageUrls.length} URLs submitted` });
10084
+ } else {
10085
+ const body = JSON.parse(googleRes.body);
10086
+ const msg = body.message || body.error || `HTTP ${googleRes.statusCode}`;
10087
+ if (googleRes.statusCode === 400 || googleRes.statusCode === 404) {
10088
+ steps.push({ name: "google-submit", status: "skipped", summary: msg });
10089
+ } else {
10090
+ steps.push({ name: "google-submit", status: "failed", error: msg });
10091
+ }
10092
+ }
10093
+ } catch (err) {
10094
+ const msg = err instanceof Error ? err.message : String(err);
10095
+ steps.push({ name: "google-submit", status: "skipped", summary: `Google not available: ${msg}` });
10096
+ }
10097
+ try {
10098
+ const authHeader = request.headers.authorization;
10099
+ const bingRes = await app.inject({
10100
+ method: "POST",
10101
+ url: `${opts.routePrefix ?? "/api/v1"}/projects/${encodeURIComponent(project.name)}/bing/request-indexing`,
10102
+ payload: { urls: pageUrls },
10103
+ headers: authHeader ? { authorization: authHeader } : {}
10104
+ });
10105
+ if (bingRes.statusCode === 200) {
10106
+ const body = JSON.parse(bingRes.body);
10107
+ const succeeded = body.results?.filter((r) => r.status === "success").length ?? 0;
10108
+ steps.push({ name: "bing-submit", status: "completed", summary: `${succeeded}/${pageUrls.length} URLs submitted` });
10109
+ } else {
10110
+ const body = JSON.parse(bingRes.body);
10111
+ const msg = body.message || body.error || `HTTP ${bingRes.statusCode}`;
10112
+ if (bingRes.statusCode === 400 || bingRes.statusCode === 404) {
10113
+ steps.push({ name: "bing-submit", status: "skipped", summary: msg });
10114
+ } else {
10115
+ steps.push({ name: "bing-submit", status: "failed", error: msg });
10116
+ }
10117
+ }
10118
+ } catch (err) {
10119
+ const msg = err instanceof Error ? err.message : String(err);
10120
+ steps.push({ name: "bing-submit", status: "skipped", summary: `Bing not available: ${msg}` });
10121
+ }
10122
+ }
10123
+ writeAuditLog(app.db, {
10124
+ projectId: project.id,
10125
+ actor: "api",
10126
+ action: "wordpress.onboarded",
10127
+ entityType: "wordpress_connection",
10128
+ entityId: project.name
10129
+ });
10130
+ return { projectName: project.name, steps };
10131
+ });
10132
+ });
9054
10133
  }
9055
10134
 
9056
10135
  // ../api-routes/src/index.ts
@@ -9119,6 +10198,9 @@ async function apiRoutes(app, opts) {
9119
10198
  bing: opts.bingSettingsSummary,
9120
10199
  onBingUpdate: opts.onBingSettingsUpdate
9121
10200
  });
10201
+ await api.register(snapshotRoutes, {
10202
+ onSnapshotRequested: opts.onSnapshotRequested
10203
+ });
9122
10204
  await api.register(scheduleRoutes, {
9123
10205
  onScheduleUpdated: opts.onScheduleUpdated,
9124
10206
  validProviderNames: opts.providerAdapters?.map((a) => a.name)
@@ -9140,7 +10222,8 @@ async function apiRoutes(app, opts) {
9140
10222
  onInspectSitemapRequested: opts.onInspectSitemapRequested
9141
10223
  });
9142
10224
  await api.register(wordpressRoutes, {
9143
- wordpressConnectionStore: opts.wordpressConnectionStore
10225
+ wordpressConnectionStore: opts.wordpressConnectionStore,
10226
+ routePrefix: opts.routePrefix ?? "/api/v1"
9144
10227
  });
9145
10228
  await api.register(cdpRoutes, {
9146
10229
  getCdpStatus: opts.getCdpStatus,
@@ -11125,7 +12208,7 @@ function emit(entry) {
11125
12208
  }
11126
12209
  }
11127
12210
  function createLogger(module) {
11128
- function log7(level, action, ctx) {
12211
+ function log8(level, action, ctx) {
11129
12212
  const entry = {
11130
12213
  ts: (/* @__PURE__ */ new Date()).toISOString(),
11131
12214
  level,
@@ -11136,9 +12219,9 @@ function createLogger(module) {
11136
12219
  emit(entry);
11137
12220
  }
11138
12221
  return {
11139
- info: (action, ctx) => log7("info", action, ctx),
11140
- warn: (action, ctx) => log7("warn", action, ctx),
11141
- error: (action, ctx) => log7("error", action, ctx)
12222
+ info: (action, ctx) => log8("info", action, ctx),
12223
+ warn: (action, ctx) => log8("warn", action, ctx),
12224
+ error: (action, ctx) => log8("error", action, ctx)
11142
12225
  };
11143
12226
  }
11144
12227
 
@@ -11340,6 +12423,12 @@ var JobRunner = class {
11340
12423
  log.info("query.result", { runId, provider: providerName, keyword: kw.keyword, citedDomains: normalized.citedDomains, groundingSources: normalized.groundingSources.map((s) => s.uri), matchDomains: allDomains });
11341
12424
  const citationState = determineCitationState(normalized, allDomains);
11342
12425
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
12426
+ const extractedCompetitors = extractRecommendedCompetitors(
12427
+ normalized.answerText,
12428
+ allDomains,
12429
+ normalized.citedDomains,
12430
+ overlap
12431
+ );
11343
12432
  let screenshotRelPath = null;
11344
12433
  if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
11345
12434
  const snapshotId = crypto18.randomUUID();
@@ -11358,6 +12447,7 @@ var JobRunner = class {
11358
12447
  answerText: normalized.answerText,
11359
12448
  citedDomains: JSON.stringify(normalized.citedDomains),
11360
12449
  competitorOverlap: JSON.stringify(overlap),
12450
+ recommendedCompetitors: JSON.stringify(extractedCompetitors),
11361
12451
  location: runLocation?.label ?? null,
11362
12452
  screenshotPath: screenshotRelPath,
11363
12453
  rawResponse: JSON.stringify({
@@ -11379,6 +12469,7 @@ var JobRunner = class {
11379
12469
  answerText: normalized.answerText,
11380
12470
  citedDomains: JSON.stringify(normalized.citedDomains),
11381
12471
  competitorOverlap: JSON.stringify(overlap),
12472
+ recommendedCompetitors: JSON.stringify(extractedCompetitors),
11382
12473
  location: runLocation?.label ?? null,
11383
12474
  rawResponse: JSON.stringify({
11384
12475
  model: raw.model,
@@ -11602,6 +12693,97 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
11602
12693
  }
11603
12694
  return [...overlapSet];
11604
12695
  }
12696
+ function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, competitorDomains) {
12697
+ if (!answerText || answerText.length < 20) return [];
12698
+ const ownBrandKeys = new Set(
12699
+ ownDomains.flatMap((domain) => collectBrandKeysFromDomain(domain))
12700
+ );
12701
+ const knownCompetitorKeys = new Set(
12702
+ [...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandKeysFromDomain(domain)).filter((key) => !ownBrandKeys.has(key))
12703
+ );
12704
+ if (knownCompetitorKeys.size === 0) return [];
12705
+ const candidatePatterns = [
12706
+ /^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013–-]/gm,
12707
+ /\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\*\*/g,
12708
+ /^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm,
12709
+ /\[([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\]\(https?:\/\/[^\s)]+\)/g
12710
+ ];
12711
+ const genericKeys = /* @__PURE__ */ new Set([
12712
+ "additional",
12713
+ "best",
12714
+ "benefits",
12715
+ "bottomline",
12716
+ "comparison",
12717
+ "conclusion",
12718
+ "directorylisting",
12719
+ "example",
12720
+ "expertise",
12721
+ "features",
12722
+ "finalthoughts",
12723
+ "howitworks",
12724
+ "important",
12725
+ "keybenefits",
12726
+ "keyfeatures",
12727
+ "major",
12728
+ "note",
12729
+ "notable",
12730
+ "option",
12731
+ "other",
12732
+ "overview",
12733
+ "pricing",
12734
+ "pros",
12735
+ "reviews",
12736
+ "step",
12737
+ "summary",
12738
+ "top",
12739
+ "verdict",
12740
+ "whattolookfor",
12741
+ "whyitmatters",
12742
+ "whyitstandsout",
12743
+ "whywechoseit"
12744
+ ]);
12745
+ const seen = /* @__PURE__ */ new Map();
12746
+ for (const pattern of candidatePatterns) {
12747
+ let match;
12748
+ while ((match = pattern.exec(answerText)) !== null) {
12749
+ const candidate = cleanCandidateName(match[1] ?? "");
12750
+ const candidateKey = brandKeyFromText(candidate);
12751
+ if (!candidateKey) continue;
12752
+ if (genericKeys.has(candidateKey)) continue;
12753
+ if (candidate.split(/\s+/).length > 6) continue;
12754
+ if (matchesBrandKey(candidateKey, ownBrandKeys)) continue;
12755
+ if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue;
12756
+ if (!seen.has(candidateKey)) seen.set(candidateKey, candidate);
12757
+ }
12758
+ }
12759
+ return [...seen.values()].slice(0, 10);
12760
+ }
12761
+ function cleanCandidateName(candidate) {
12762
+ return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
12763
+ }
12764
+ function brandKeyFromText(value) {
12765
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
12766
+ }
12767
+ function collectBrandKeysFromDomain(domain) {
12768
+ const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
12769
+ const labels = hostname.split(".").filter(Boolean);
12770
+ const keys = /* @__PURE__ */ new Set();
12771
+ const hostnameKey = hostname.replace(/[^a-z0-9]/gi, "").toLowerCase();
12772
+ if (hostnameKey.length >= 4) keys.add(hostnameKey);
12773
+ for (const label of labels) {
12774
+ const key = label.replace(/[^a-z0-9]/gi, "").toLowerCase();
12775
+ if (key.length >= 4) keys.add(key);
12776
+ }
12777
+ return [...keys];
12778
+ }
12779
+ function matchesBrandKey(candidateKey, brandKeys) {
12780
+ for (const brandKey of brandKeys) {
12781
+ if (candidateKey === brandKey) return true;
12782
+ if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true;
12783
+ if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true;
12784
+ }
12785
+ return false;
12786
+ }
11605
12787
 
11606
12788
  // src/gsc-sync.ts
11607
12789
  import crypto19 from "crypto";
@@ -12279,6 +13461,9 @@ var Notifier = class {
12279
13461
  }
12280
13462
  };
12281
13463
 
13464
+ // src/snapshot-service.ts
13465
+ import { runAeoAudit } from "@ainyc/aeo-audit";
13466
+
12282
13467
  // src/site-fetch.ts
12283
13468
  import https2 from "https";
12284
13469
  var FETCH_TIMEOUT_MS = 1e4;
@@ -12390,10 +13575,641 @@ function stripHtml2(html) {
12390
13575
  return text2;
12391
13576
  }
12392
13577
 
13578
+ // src/snapshot-format.ts
13579
+ function formatAuditFactorScore(factor) {
13580
+ return `${factor.score}/100 (${factor.weight}% weight)`;
13581
+ }
13582
+
13583
+ // src/snapshot-service.ts
13584
+ var log6 = createLogger("Snapshot");
13585
+ var ANALYSIS_PROVIDER_PRIORITY = ["openai", "claude", "gemini", "perplexity", "local"];
13586
+ var SNAPSHOT_QUERY_COUNT = 6;
13587
+ var ProviderExecutionGate2 = class {
13588
+ constructor(maxConcurrency, maxPerMinute) {
13589
+ this.maxConcurrency = maxConcurrency;
13590
+ this.maxPerMinute = maxPerMinute;
13591
+ }
13592
+ window = [];
13593
+ waiters = [];
13594
+ rateLimitChain = Promise.resolve();
13595
+ inFlight = 0;
13596
+ async run(task) {
13597
+ await this.acquire();
13598
+ try {
13599
+ await this.waitForRateLimit();
13600
+ return await task();
13601
+ } finally {
13602
+ this.release();
13603
+ }
13604
+ }
13605
+ async acquire() {
13606
+ if (this.inFlight < Math.max(1, this.maxConcurrency)) {
13607
+ this.inFlight++;
13608
+ return;
13609
+ }
13610
+ await new Promise((resolve) => {
13611
+ this.waiters.push(resolve);
13612
+ });
13613
+ this.inFlight++;
13614
+ }
13615
+ release() {
13616
+ this.inFlight = Math.max(0, this.inFlight - 1);
13617
+ const next = this.waiters.shift();
13618
+ next?.();
13619
+ }
13620
+ async waitForRateLimit() {
13621
+ let releaseChain;
13622
+ const previousChain = this.rateLimitChain;
13623
+ this.rateLimitChain = new Promise((resolve) => {
13624
+ releaseChain = resolve;
13625
+ });
13626
+ await previousChain;
13627
+ try {
13628
+ const now = Date.now();
13629
+ const windowStart = now - 6e4;
13630
+ while (this.window.length > 0 && this.window[0] < windowStart) {
13631
+ this.window.shift();
13632
+ }
13633
+ if (this.window.length >= this.maxPerMinute) {
13634
+ const oldestInWindow = this.window[0];
13635
+ const waitMs = oldestInWindow + 6e4 - now + 50;
13636
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
13637
+ const nowAfterWait = Date.now();
13638
+ const newWindowStart = nowAfterWait - 6e4;
13639
+ while (this.window.length > 0 && this.window[0] < newWindowStart) {
13640
+ this.window.shift();
13641
+ }
13642
+ }
13643
+ this.window.push(Date.now());
13644
+ } finally {
13645
+ releaseChain?.();
13646
+ }
13647
+ }
13648
+ };
13649
+ var SnapshotService = class {
13650
+ constructor(registry) {
13651
+ this.registry = registry;
13652
+ }
13653
+ async createReport(input) {
13654
+ const companyName = input.companyName.trim();
13655
+ const domain = normalizeDomain(input.domain);
13656
+ const manualPhrases = normalizeStringList(input.phrases ?? []);
13657
+ const manualCompetitors = normalizeStringList(input.competitors ?? []);
13658
+ const providers = this.registry.getAll();
13659
+ if (providers.length === 0) {
13660
+ throw new Error("No providers configured. Add at least one provider API key before running canonry snapshot.");
13661
+ }
13662
+ const analysisProvider = pickAnalysisProvider(this.registry.getApiProviders());
13663
+ const homepageUrl = `https://${extractHostname2(domain)}`;
13664
+ const [siteText, audit] = await Promise.all([
13665
+ fetchSiteText(domain),
13666
+ this.runAudit(homepageUrl)
13667
+ ]);
13668
+ if (manualPhrases.length === 0 && !siteText) {
13669
+ throw new Error(
13670
+ `Could not analyze https://${extractHostname2(domain)}. Try again with a reachable homepage or pass manual category queries via --phrases.`
13671
+ );
13672
+ }
13673
+ const profile = await this.buildProfile({
13674
+ companyName,
13675
+ domain,
13676
+ siteText,
13677
+ audit,
13678
+ manualPhrases,
13679
+ analysisProvider
13680
+ });
13681
+ const queryResults = await this.runSnapshotQueries({
13682
+ companyName,
13683
+ domain,
13684
+ phrases: profile.phrases,
13685
+ providers,
13686
+ manualCompetitors
13687
+ });
13688
+ const batchAssessment = await this.analyzeResponses({
13689
+ companyName,
13690
+ domain,
13691
+ profile,
13692
+ audit,
13693
+ queryResults,
13694
+ manualCompetitors,
13695
+ analysisProvider
13696
+ });
13697
+ const enrichedResults = applyBatchAssessment(queryResults, batchAssessment);
13698
+ const summary = buildSnapshotSummary(companyName, profile.phrases, providers, enrichedResults, audit, batchAssessment);
13699
+ const reportCompetitors = uniqueStrings([
13700
+ ...manualCompetitors,
13701
+ ...summary.topCompetitors.map((entry) => entry.name)
13702
+ ]);
13703
+ return {
13704
+ companyName,
13705
+ domain,
13706
+ homepageUrl,
13707
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
13708
+ phrases: profile.phrases,
13709
+ competitors: reportCompetitors,
13710
+ profile: {
13711
+ industry: profile.industry,
13712
+ summary: profile.summary,
13713
+ services: profile.services,
13714
+ categoryTerms: profile.categoryTerms
13715
+ },
13716
+ audit,
13717
+ queryResults: enrichedResults,
13718
+ summary
13719
+ };
13720
+ }
13721
+ async runAudit(homepageUrl) {
13722
+ try {
13723
+ const report = await runAeoAudit(homepageUrl);
13724
+ return mapAuditReport(report);
13725
+ } catch (err) {
13726
+ const message = err instanceof Error ? err.message : String(err);
13727
+ log6.warn("audit.failed", { homepageUrl, error: message });
13728
+ return {
13729
+ url: homepageUrl,
13730
+ finalUrl: homepageUrl,
13731
+ auditedAt: (/* @__PURE__ */ new Date()).toISOString(),
13732
+ overallScore: 0,
13733
+ overallGrade: "N/A",
13734
+ summary: `Technical audit unavailable: ${message}`,
13735
+ factors: []
13736
+ };
13737
+ }
13738
+ }
13739
+ async buildProfile(ctx) {
13740
+ if (ctx.analysisProvider && ctx.siteText) {
13741
+ const prompt = buildProfilePrompt(ctx);
13742
+ try {
13743
+ const raw = await ctx.analysisProvider.adapter.generateText(prompt, ctx.analysisProvider.config);
13744
+ const parsed = parseJsonObject(raw);
13745
+ const parsedPhrases = ctx.manualPhrases.length > 0 ? ctx.manualPhrases : normalizeStringList(parsed.phrases ?? []).slice(0, SNAPSHOT_QUERY_COUNT);
13746
+ if (ctx.manualPhrases.length === 0 && parsedPhrases.length === 0) {
13747
+ throw new Error("no phrases returned");
13748
+ }
13749
+ return {
13750
+ industry: parsed.industry?.trim() || "Unknown",
13751
+ summary: parsed.summary?.trim() || ctx.audit.summary,
13752
+ services: uniqueStrings(parsed.services ?? []).slice(0, 6),
13753
+ categoryTerms: uniqueStrings(parsed.categoryTerms ?? []).slice(0, 8),
13754
+ phrases: parsedPhrases
13755
+ };
13756
+ } catch (err) {
13757
+ log6.warn("profile.generation-failed", {
13758
+ domain: ctx.domain,
13759
+ provider: ctx.analysisProvider.adapter.name,
13760
+ error: err instanceof Error ? err.message : String(err)
13761
+ });
13762
+ }
13763
+ }
13764
+ if (ctx.manualPhrases.length === 0) {
13765
+ throw new Error(
13766
+ "Automatic category-query generation requires a configured API provider. Add OpenAI, Claude, Gemini, Perplexity, or Local, or pass --phrases manually."
13767
+ );
13768
+ }
13769
+ return {
13770
+ industry: "Unknown",
13771
+ summary: ctx.audit.summary,
13772
+ services: [],
13773
+ categoryTerms: [],
13774
+ phrases: ctx.manualPhrases
13775
+ };
13776
+ }
13777
+ async runSnapshotQueries(ctx) {
13778
+ const gates = /* @__PURE__ */ new Map();
13779
+ for (const provider of ctx.providers) {
13780
+ gates.set(
13781
+ provider.adapter.name,
13782
+ new ProviderExecutionGate2(
13783
+ provider.config.quotaPolicy.maxConcurrency,
13784
+ provider.config.quotaPolicy.maxRequestsPerMinute
13785
+ )
13786
+ );
13787
+ }
13788
+ const competitorDomains = ctx.manualCompetitors.filter(isDomainLike);
13789
+ return Promise.all(ctx.phrases.map(async (phrase) => ({
13790
+ phrase,
13791
+ providerResults: await Promise.all(ctx.providers.map(async (provider) => {
13792
+ const gate = gates.get(provider.adapter.name);
13793
+ return gate.run(async () => {
13794
+ try {
13795
+ const raw = await provider.adapter.executeTrackedQuery(
13796
+ {
13797
+ keyword: phrase,
13798
+ canonicalDomains: [ctx.domain],
13799
+ competitorDomains
13800
+ },
13801
+ provider.config
13802
+ );
13803
+ const normalized = provider.adapter.normalizeResult(raw);
13804
+ const preliminaryCompetitors = extractCompetitorsFromResponse({
13805
+ answerText: normalized.answerText,
13806
+ citedDomains: normalized.citedDomains,
13807
+ manualCompetitors: ctx.manualCompetitors,
13808
+ targetDomain: ctx.domain
13809
+ });
13810
+ return {
13811
+ provider: provider.adapter.name,
13812
+ displayName: provider.adapter.displayName,
13813
+ model: raw.model,
13814
+ mentioned: mentionsTargetCompany(normalized.answerText, ctx.companyName, ctx.domain),
13815
+ cited: citesTargetDomain(normalized.citedDomains, normalized.groundingSources, ctx.domain),
13816
+ describedAccurately: "unknown",
13817
+ accuracyNotes: null,
13818
+ incorrectClaims: [],
13819
+ recommendedCompetitors: preliminaryCompetitors,
13820
+ citedDomains: uniqueStrings(normalized.citedDomains),
13821
+ groundingSources: normalized.groundingSources,
13822
+ searchQueries: uniqueStrings(normalized.searchQueries),
13823
+ answerText: normalized.answerText,
13824
+ error: null
13825
+ };
13826
+ } catch (err) {
13827
+ return {
13828
+ provider: provider.adapter.name,
13829
+ displayName: provider.adapter.displayName,
13830
+ model: provider.config.model ?? provider.adapter.modelRegistry.defaultModel,
13831
+ mentioned: false,
13832
+ cited: false,
13833
+ describedAccurately: "unknown",
13834
+ accuracyNotes: null,
13835
+ incorrectClaims: [],
13836
+ recommendedCompetitors: [],
13837
+ citedDomains: [],
13838
+ groundingSources: [],
13839
+ searchQueries: [],
13840
+ answerText: "",
13841
+ error: err instanceof Error ? err.message : String(err)
13842
+ };
13843
+ }
13844
+ });
13845
+ }))
13846
+ })));
13847
+ }
13848
+ async analyzeResponses(ctx) {
13849
+ if (!ctx.analysisProvider) {
13850
+ return buildFallbackBatchAssessment(ctx.companyName, ctx.audit);
13851
+ }
13852
+ const responses = ctx.queryResults.flatMap(
13853
+ (query) => query.providerResults.filter((result) => !result.error).map((result) => ({
13854
+ phrase: query.phrase,
13855
+ provider: result.provider,
13856
+ displayName: result.displayName,
13857
+ heuristicMentioned: result.mentioned,
13858
+ heuristicCited: result.cited,
13859
+ heuristicCompetitors: result.recommendedCompetitors,
13860
+ citedDomains: result.citedDomains,
13861
+ groundingSources: result.groundingSources.map((source) => source.uri),
13862
+ answerText: clipText(result.answerText, 2e3)
13863
+ }))
13864
+ );
13865
+ if (responses.length === 0) {
13866
+ return buildFallbackBatchAssessment(ctx.companyName, ctx.audit);
13867
+ }
13868
+ try {
13869
+ const prompt = buildBatchAnalysisPrompt({
13870
+ companyName: ctx.companyName,
13871
+ domain: ctx.domain,
13872
+ profile: ctx.profile,
13873
+ audit: ctx.audit,
13874
+ responses,
13875
+ manualCompetitors: ctx.manualCompetitors
13876
+ });
13877
+ const raw = await ctx.analysisProvider.adapter.generateText(prompt, ctx.analysisProvider.config);
13878
+ const parsed = parseJsonObject(raw);
13879
+ return {
13880
+ assessments: (parsed.assessments ?? []).filter((assessment) => assessment.phrase && assessment.provider).map((assessment) => {
13881
+ const hasReviewedCompetitors = assessment.recommendedCompetitors !== void 0;
13882
+ return {
13883
+ phrase: assessment.phrase,
13884
+ provider: assessment.provider,
13885
+ mentioned: assessment.mentioned,
13886
+ describedAccurately: assessment.describedAccurately,
13887
+ accuracyNotes: assessment.accuracyNotes ?? null,
13888
+ incorrectClaims: uniqueStrings(assessment.incorrectClaims ?? []).slice(0, 5),
13889
+ ...hasReviewedCompetitors ? {
13890
+ recommendedCompetitors: uniqueStrings(assessment.recommendedCompetitors ?? []).slice(0, 10)
13891
+ } : {}
13892
+ };
13893
+ }),
13894
+ whatThisMeans: uniqueStrings(parsed.whatThisMeans ?? []).slice(0, 4),
13895
+ recommendedActions: uniqueStrings(parsed.recommendedActions ?? []).slice(0, 4)
13896
+ };
13897
+ } catch (err) {
13898
+ log6.warn("response.analysis-failed", {
13899
+ provider: ctx.analysisProvider.adapter.name,
13900
+ error: err instanceof Error ? err.message : String(err)
13901
+ });
13902
+ return buildFallbackBatchAssessment(ctx.companyName, ctx.audit);
13903
+ }
13904
+ }
13905
+ };
13906
+ function pickAnalysisProvider(providers) {
13907
+ return [...providers].sort((a, b) => {
13908
+ const aIndex = ANALYSIS_PROVIDER_PRIORITY.indexOf(a.adapter.name);
13909
+ const bIndex = ANALYSIS_PROVIDER_PRIORITY.indexOf(b.adapter.name);
13910
+ return (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex);
13911
+ })[0];
13912
+ }
13913
+ function buildProfilePrompt(ctx) {
13914
+ const instructions = [
13915
+ "You are an AEO and SEO expert building a sales snapshot for an uninformed corporate prospect.",
13916
+ "Use ONLY the homepage text below. Do not browse or invent facts.",
13917
+ "Infer the company category, summarize what it sells, and generate non-branded category queries buyers would ask an AI assistant.",
13918
+ 'Never produce brand queries like "what does Acme do?"',
13919
+ `Return strict JSON with keys: industry, summary, services, categoryTerms, phrases.`,
13920
+ `phrases must contain exactly ${SNAPSHOT_QUERY_COUNT} buyer-style category/recommendation queries unless manual phrases are provided.`
13921
+ ];
13922
+ if (ctx.manualPhrases.length > 0) {
13923
+ instructions.push('Manual phrases were already supplied. Echo them back unchanged in the "phrases" array.');
13924
+ }
13925
+ return [
13926
+ ...instructions,
13927
+ "",
13928
+ `Company: ${ctx.companyName}`,
13929
+ `Domain: ${ctx.domain}`,
13930
+ `Existing audit summary: ${ctx.audit.summary}`,
13931
+ "",
13932
+ "Homepage text:",
13933
+ ctx.siteText
13934
+ ].join("\n");
13935
+ }
13936
+ function buildBatchAnalysisPrompt(ctx) {
13937
+ return [
13938
+ "You are reviewing AI answer-engine responses for a sales-facing AEO snapshot report.",
13939
+ "Use ONLY the provided facts and responses. Do not invent companies or claims.",
13940
+ "Return strict JSON with keys: assessments, whatThisMeans, recommendedActions.",
13941
+ "Each assessment must include: phrase, provider, mentioned, describedAccurately, accuracyNotes, incorrectClaims, recommendedCompetitors.",
13942
+ "describedAccurately must be one of: yes, no, unknown, not-mentioned.",
13943
+ "",
13944
+ "CRITICAL \u2014 recommendedCompetitors extraction:",
13945
+ "For each response, extract EVERY specific company/brand/product name that the AI recommended or listed as an alternative.",
13946
+ 'Include the company name exactly as it appears in the response (e.g. "Accenture", "Deloitte", "C3.ai").',
13947
+ 'Do NOT include generic terms like "consulting firms" or directories like "G2" or "Clutch".',
13948
+ "Do NOT include the target company itself.",
13949
+ "This is the most important field \u2014 it shows the prospect who AI recommends INSTEAD of them.",
13950
+ "",
13951
+ `Target company: ${ctx.companyName}`,
13952
+ `Target domain: ${ctx.domain}`,
13953
+ `Industry: ${ctx.profile.industry}`,
13954
+ `Summary: ${ctx.profile.summary}`,
13955
+ `Services: ${ctx.profile.services.join(", ") || "unknown"}`,
13956
+ `Category terms: ${ctx.profile.categoryTerms.join(", ") || "unknown"}`,
13957
+ `Manual competitor hints: ${ctx.manualCompetitors.join(", ") || "none"}`,
13958
+ `Technical audit: ${ctx.audit.overallScore}/100 (${ctx.audit.overallGrade}) \u2014 ${ctx.audit.summary}`,
13959
+ "",
13960
+ "Responses JSON:",
13961
+ JSON.stringify(ctx.responses, null, 2)
13962
+ ].join("\n");
13963
+ }
13964
+ function buildFallbackBatchAssessment(companyName, audit) {
13965
+ return {
13966
+ assessments: [],
13967
+ whatThisMeans: [
13968
+ `${companyName} needs category-level visibility, not just branded comprehension.`,
13969
+ `The technical baseline is ${audit.overallScore}/100 (${audit.overallGrade}), so weak site signals may be making AI systems prefer better-structured alternatives.`
13970
+ ],
13971
+ recommendedActions: buildFallbackRecommendedActions(audit)
13972
+ };
13973
+ }
13974
+ function applyBatchAssessment(queryResults, batchAssessment) {
13975
+ const assessmentMap = /* @__PURE__ */ new Map();
13976
+ for (const assessment of batchAssessment.assessments) {
13977
+ assessmentMap.set(`${assessment.phrase}::${assessment.provider}`, assessment);
13978
+ }
13979
+ return queryResults.map((query) => ({
13980
+ phrase: query.phrase,
13981
+ providerResults: query.providerResults.map((result) => {
13982
+ const assessment = assessmentMap.get(`${query.phrase}::${result.provider}`);
13983
+ if (!assessment) {
13984
+ return {
13985
+ ...result,
13986
+ describedAccurately: result.mentioned ? "unknown" : "not-mentioned"
13987
+ };
13988
+ }
13989
+ const reviewedCompetitors = assessment.recommendedCompetitors;
13990
+ const recommendedCompetitors = reviewedCompetitors !== void 0 ? uniqueStrings(reviewedCompetitors) : result.recommendedCompetitors;
13991
+ return {
13992
+ ...result,
13993
+ mentioned: result.mentioned || assessment.mentioned === true,
13994
+ describedAccurately: assessment.describedAccurately ?? (result.mentioned ? "unknown" : "not-mentioned"),
13995
+ accuracyNotes: assessment.accuracyNotes ?? result.accuracyNotes ?? null,
13996
+ incorrectClaims: uniqueStrings([
13997
+ ...result.incorrectClaims,
13998
+ ...assessment.incorrectClaims ?? []
13999
+ ]),
14000
+ recommendedCompetitors
14001
+ };
14002
+ })
14003
+ }));
14004
+ }
14005
+ function buildSnapshotSummary(companyName, phrases, providers, queryResults, audit, batchAssessment) {
14006
+ const allResults = queryResults.flatMap((query) => query.providerResults);
14007
+ const successfulResults = allResults.filter((result) => !result.error);
14008
+ const failedComparisons = allResults.length - successfulResults.length;
14009
+ const mentionCount = successfulResults.filter((result) => result.mentioned).length;
14010
+ const citationCount = successfulResults.filter((result) => result.cited).length;
14011
+ const totalComparisons = successfulResults.length;
14012
+ const competitorCounts = /* @__PURE__ */ new Map();
14013
+ for (const result of successfulResults) {
14014
+ for (const competitor of result.recommendedCompetitors) {
14015
+ competitorCounts.set(competitor, (competitorCounts.get(competitor) ?? 0) + 1);
14016
+ }
14017
+ }
14018
+ const topCompetitors = [...competitorCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 10).map(([name, count]) => ({ name, count }));
14019
+ const defaultMeaning = totalComparisons > 0 ? `${companyName} was mentioned in ${mentionCount}/${totalComparisons} successful provider-query response${totalComparisons === 1 ? "" : "s"} across ${phrases.length} category queries.` : `No successful provider responses were returned across ${phrases.length} category queries.`;
14020
+ const failureNote = failedComparisons > 0 ? [
14021
+ `${failedComparisons} provider response${failedComparisons === 1 ? "" : "s"} failed and ${failedComparisons === 1 ? "was" : "were"} excluded from visibility totals.`
14022
+ ] : [];
14023
+ const whatThisMeans = batchAssessment.whatThisMeans.length > 0 ? batchAssessment.whatThisMeans : [
14024
+ defaultMeaning
14025
+ ];
14026
+ const combinedWhatThisMeans = uniqueStrings([...whatThisMeans, ...failureNote]).slice(0, 5);
14027
+ const recommendedActions = batchAssessment.recommendedActions.length > 0 ? batchAssessment.recommendedActions : buildFallbackRecommendedActions(audit);
14028
+ return {
14029
+ totalQueries: phrases.length,
14030
+ totalProviders: providers.length,
14031
+ totalComparisons,
14032
+ mentionCount,
14033
+ citationCount,
14034
+ topCompetitors,
14035
+ visibilityGap: buildVisibilityGap(
14036
+ companyName,
14037
+ phrases.length,
14038
+ providers.length,
14039
+ totalComparisons,
14040
+ mentionCount,
14041
+ citationCount,
14042
+ failedComparisons
14043
+ ),
14044
+ whatThisMeans: combinedWhatThisMeans,
14045
+ recommendedActions
14046
+ };
14047
+ }
14048
+ function buildVisibilityGap(companyName, queryCount, providerCount, totalComparisons, mentionCount, citationCount, failedComparisons) {
14049
+ const successfulLabel = `${totalComparisons} successful provider response${totalComparisons === 1 ? "" : "s"}`;
14050
+ const failureSuffix = failedComparisons > 0 ? ` ${failedComparisons} provider response${failedComparisons === 1 ? "" : "s"} failed.` : "";
14051
+ if (totalComparisons === 0) {
14052
+ return `No providers returned successful answers across ${queryCount} category queries and ${providerCount} providers.${failureSuffix}`.trim();
14053
+ }
14054
+ if (mentionCount === 0) {
14055
+ return `${companyName} was not mentioned in any of the ${successfulLabel} across ${queryCount} category queries and ${providerCount} providers.${failureSuffix}`.trim();
14056
+ }
14057
+ if (citationCount === 0) {
14058
+ return `${companyName} was mentioned in ${mentionCount}/${totalComparisons} successful provider response${totalComparisons === 1 ? "" : "s"}, but never linked or cited directly.${failureSuffix}`.trim();
14059
+ }
14060
+ return `${companyName} was mentioned in ${mentionCount}/${totalComparisons} successful provider response${totalComparisons === 1 ? "" : "s"} and cited in ${citationCount}/${totalComparisons}.${failureSuffix}`.trim();
14061
+ }
14062
+ function buildFallbackRecommendedActions(audit) {
14063
+ const weakestFactors = [...audit.factors].sort((a, b) => a.score - b.score || a.name.localeCompare(b.name)).slice(0, 3).map((factor) => `Improve ${factor.name.toLowerCase()}: ${formatAuditFactorScore(factor)}`);
14064
+ const defaults = [
14065
+ "Publish category pages that explicitly describe the services AI should recommend you for.",
14066
+ "Add machine-readable trust signals such as schema, FAQs, and llms.txt support.",
14067
+ "Build comparison and proof content that makes the category fit unmistakable."
14068
+ ];
14069
+ return uniqueStrings([...weakestFactors, ...defaults]).slice(0, 4);
14070
+ }
14071
+ function mentionsTargetCompany(answerText, companyName, domain) {
14072
+ const haystack = normalizeText(answerText);
14073
+ if (!haystack) return false;
14074
+ const fullName = normalizeText(companyName);
14075
+ if (fullName && haystack.includes(fullName)) {
14076
+ return true;
14077
+ }
14078
+ const targetTokens = uniqueStrings([
14079
+ ...extractDistinctiveTokens(companyName),
14080
+ ...extractDistinctiveTokens(extractHostname2(domain).split(".")[0] ?? "")
14081
+ ]);
14082
+ if (targetTokens.length === 0) return false;
14083
+ let matches = 0;
14084
+ for (const token of targetTokens) {
14085
+ if (new RegExp(`\\b${escapeRegExp2(token)}\\b`, "i").test(answerText)) {
14086
+ matches++;
14087
+ }
14088
+ }
14089
+ return matches >= Math.min(2, targetTokens.length) || matches >= 1 && targetTokens.length === 1;
14090
+ }
14091
+ function citesTargetDomain(citedDomains, groundingSources, targetDomain) {
14092
+ const normalizedTarget = extractHostname2(targetDomain);
14093
+ for (const domain of citedDomains) {
14094
+ if (domainMatches2(domain, normalizedTarget)) {
14095
+ return true;
14096
+ }
14097
+ }
14098
+ for (const source of groundingSources) {
14099
+ if (source.uri && source.uri.toLowerCase().includes(normalizedTarget.toLowerCase())) {
14100
+ return true;
14101
+ }
14102
+ if (source.title && source.title.toLowerCase().includes(normalizedTarget.toLowerCase())) {
14103
+ return true;
14104
+ }
14105
+ }
14106
+ return false;
14107
+ }
14108
+ function extractCompetitorsFromResponse(ctx) {
14109
+ const competitors2 = /* @__PURE__ */ new Set();
14110
+ const lowerAnswer = ctx.answerText.toLowerCase();
14111
+ const targetDomain = extractHostname2(ctx.targetDomain);
14112
+ for (const hint of ctx.manualCompetitors) {
14113
+ if (isDomainLike(hint)) {
14114
+ const normalizedHint = normalizeDomain(hint);
14115
+ if (domainMatches2(normalizedHint, targetDomain)) continue;
14116
+ if (ctx.citedDomains.some((domain) => domainMatches2(domain, normalizedHint)) || lowerAnswer.includes(normalizedHint.toLowerCase())) {
14117
+ competitors2.add(normalizedHint);
14118
+ }
14119
+ continue;
14120
+ }
14121
+ if (hint.length >= 3 && lowerAnswer.includes(hint.toLowerCase())) {
14122
+ competitors2.add(hint);
14123
+ }
14124
+ }
14125
+ return [...competitors2].slice(0, 6);
14126
+ }
14127
+ function mapAuditReport(report) {
14128
+ return {
14129
+ url: report.url,
14130
+ finalUrl: report.finalUrl,
14131
+ auditedAt: report.auditedAt,
14132
+ overallScore: report.overallScore,
14133
+ overallGrade: report.overallGrade,
14134
+ summary: report.summary,
14135
+ factors: report.factors.map(mapAuditFactor)
14136
+ };
14137
+ }
14138
+ function mapAuditFactor(factor) {
14139
+ return {
14140
+ id: factor.id,
14141
+ name: factor.name,
14142
+ weight: factor.weight,
14143
+ score: factor.score,
14144
+ grade: factor.grade,
14145
+ status: factor.status,
14146
+ findings: factor.findings.map((finding) => ({
14147
+ type: finding.type,
14148
+ message: finding.message
14149
+ })),
14150
+ recommendations: factor.recommendations
14151
+ };
14152
+ }
14153
+ function parseJsonObject(input) {
14154
+ const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
14155
+ const candidate = fenced?.[1] ?? input;
14156
+ const start = candidate.indexOf("{");
14157
+ const end = candidate.lastIndexOf("}");
14158
+ const json = start >= 0 && end >= start ? candidate.slice(start, end + 1) : candidate;
14159
+ return JSON.parse(json);
14160
+ }
14161
+ function normalizeText(value) {
14162
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
14163
+ }
14164
+ function normalizeStringList(values) {
14165
+ const items = values.flatMap((value) => value.split(","));
14166
+ return uniqueStrings(
14167
+ items.map((value) => value.trim()).filter(Boolean)
14168
+ );
14169
+ }
14170
+ function uniqueStrings(values) {
14171
+ if (!Array.isArray(values)) return [];
14172
+ return [...new Set(
14173
+ values.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean)
14174
+ )];
14175
+ }
14176
+ function normalizeDomain(value) {
14177
+ const trimmed = value.trim();
14178
+ if (!trimmed) return trimmed;
14179
+ try {
14180
+ const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
14181
+ return url.hostname.replace(/^www\./, "").toLowerCase();
14182
+ } catch {
14183
+ return trimmed.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^www\./, "").toLowerCase();
14184
+ }
14185
+ }
14186
+ function extractHostname2(value) {
14187
+ return normalizeDomain(value);
14188
+ }
14189
+ function domainMatches2(candidate, target) {
14190
+ const normalizedCandidate = normalizeDomain(candidate);
14191
+ const normalizedTarget = normalizeDomain(target);
14192
+ return normalizedCandidate === normalizedTarget || normalizedCandidate.endsWith(`.${normalizedTarget}`);
14193
+ }
14194
+ function extractDistinctiveTokens(value) {
14195
+ return normalizeText(value).split(" ").filter((token) => token.length >= 4).filter((token) => !["llc", "inc", "corp", "company", "group", "services", "solutions", "agency"].includes(token));
14196
+ }
14197
+ function isDomainLike(value) {
14198
+ const normalized = normalizeDomain(value);
14199
+ return normalized.includes(".") && !normalized.includes(" ");
14200
+ }
14201
+ function clipText(value, length) {
14202
+ if (value.length <= length) return value;
14203
+ return `${value.slice(0, length - 3)}...`;
14204
+ }
14205
+ function escapeRegExp2(value) {
14206
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14207
+ }
14208
+
12393
14209
  // src/server.ts
12394
14210
  var _require2 = createRequire2(import.meta.url);
12395
14211
  var { version: PKG_VERSION } = _require2("../package.json");
12396
- var log6 = createLogger("Server");
14212
+ var log7 = createLogger("Server");
12397
14213
  var DEFAULT_QUOTA = {
12398
14214
  maxConcurrency: 2,
12399
14215
  maxRequestsPerMinute: 10,
@@ -12483,7 +14299,7 @@ async function createServer(opts) {
12483
14299
  quota: opts.config.geminiQuota
12484
14300
  };
12485
14301
  }
12486
- log6.info("providers.configured", { providers: Object.keys(providers).filter((k) => {
14302
+ log7.info("providers.configured", { providers: Object.keys(providers).filter((k) => {
12487
14303
  const p = providers[k];
12488
14304
  return p?.apiKey || p?.baseUrl || p?.vertexProject;
12489
14305
  }) });
@@ -12520,6 +14336,7 @@ async function createServer(opts) {
12520
14336
  jobRunner.recoverStaleRuns();
12521
14337
  const notifier = new Notifier(opts.db, serverUrl);
12522
14338
  jobRunner.onRunCompleted = (runId, projectId) => notifier.onRunCompleted(runId, projectId);
14339
+ const snapshotService = new SnapshotService(registry);
12523
14340
  const scheduler = new Scheduler(opts.db, {
12524
14341
  onRunCreated: (runId, projectId, providers2) => {
12525
14342
  jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
@@ -13026,6 +14843,9 @@ async function createServer(opts) {
13026
14843
  });
13027
14844
  const raw = await provider.adapter.generateText(prompt, provider.config);
13028
14845
  return parseKeywordResponse(raw, count);
14846
+ },
14847
+ onSnapshotRequested: async (input) => {
14848
+ return snapshotService.createReport(input);
13029
14849
  }
13030
14850
  });
13031
14851
  const dirname2 = path6.dirname(fileURLToPath(import.meta.url));
@@ -13156,6 +14976,7 @@ export {
13156
14976
  notificationEventSchema,
13157
14977
  effectiveDomains,
13158
14978
  setGoogleAuthConfig,
14979
+ formatAuditFactorScore,
13159
14980
  apiKeys,
13160
14981
  createClient,
13161
14982
  migrate,