@ainyc/canonry 1.29.0 → 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(),
@@ -1004,6 +1048,12 @@ var ga4TrafficSnapshotDtoSchema = z11.object({
1004
1048
  organicSessions: z11.number(),
1005
1049
  users: z11.number()
1006
1050
  });
1051
+ var ga4AiReferralDtoSchema = z11.object({
1052
+ source: z11.string(),
1053
+ medium: z11.string(),
1054
+ sessions: z11.number(),
1055
+ users: z11.number()
1056
+ });
1007
1057
  var ga4TrafficSummaryDtoSchema = z11.object({
1008
1058
  totalSessions: z11.number(),
1009
1059
  totalOrganicSessions: z11.number(),
@@ -1014,6 +1064,7 @@ var ga4TrafficSummaryDtoSchema = z11.object({
1014
1064
  organicSessions: z11.number(),
1015
1065
  users: z11.number()
1016
1066
  })),
1067
+ aiReferrals: z11.array(ga4AiReferralDtoSchema),
1017
1068
  lastSyncedAt: z11.string().nullable()
1018
1069
  });
1019
1070
 
@@ -1036,6 +1087,7 @@ __export(schema_exports, {
1036
1087
  bingKeywordStats: () => bingKeywordStats,
1037
1088
  bingUrlInspections: () => bingUrlInspections,
1038
1089
  competitors: () => competitors,
1090
+ gaAiReferrals: () => gaAiReferrals,
1039
1091
  gaConnections: () => gaConnections,
1040
1092
  gaTrafficSnapshots: () => gaTrafficSnapshots,
1041
1093
  gaTrafficSummaries: () => gaTrafficSummaries,
@@ -1113,6 +1165,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
1113
1165
  answerText: text("answer_text"),
1114
1166
  citedDomains: text("cited_domains").notNull().default("[]"),
1115
1167
  competitorOverlap: text("competitor_overlap").notNull().default("[]"),
1168
+ recommendedCompetitors: text("recommended_competitors").notNull().default("[]"),
1116
1169
  location: text("location"),
1117
1170
  screenshotPath: text("screenshot_path"),
1118
1171
  rawResponse: text("raw_response"),
@@ -1306,6 +1359,20 @@ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
1306
1359
  index("idx_ga_traffic_project_date").on(table.projectId, table.date),
1307
1360
  index("idx_ga_traffic_page").on(table.landingPage)
1308
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
+ ]);
1309
1376
  var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
1310
1377
  id: text("id").primaryKey(),
1311
1378
  projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
@@ -1635,7 +1702,23 @@ var MIGRATIONS = [
1635
1702
  // v15: Bing URL inspections — document_size, anchor_count, discovery_date columns
1636
1703
  `ALTER TABLE bing_url_inspections ADD COLUMN document_size INTEGER`,
1637
1704
  `ALTER TABLE bing_url_inspections ADD COLUMN anchor_count INTEGER`,
1638
- `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)`
1639
1722
  ];
1640
1723
  function migrate(db) {
1641
1724
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -2544,6 +2627,7 @@ async function runRoutes(app, opts) {
2544
2627
  answerText: querySnapshots.answerText,
2545
2628
  citedDomains: querySnapshots.citedDomains,
2546
2629
  competitorOverlap: querySnapshots.competitorOverlap,
2630
+ recommendedCompetitors: querySnapshots.recommendedCompetitors,
2547
2631
  location: querySnapshots.location,
2548
2632
  rawResponse: querySnapshots.rawResponse,
2549
2633
  createdAt: querySnapshots.createdAt
@@ -2562,6 +2646,7 @@ async function runRoutes(app, opts) {
2562
2646
  answerText: s.answerText,
2563
2647
  citedDomains: tryParseJson(s.citedDomains, []),
2564
2648
  competitorOverlap: tryParseJson(s.competitorOverlap, []),
2649
+ recommendedCompetitors: tryParseJson(s.recommendedCompetitors, []),
2565
2650
  model: s.model ?? rawParsed.model,
2566
2651
  location: s.location,
2567
2652
  groundingSources: rawParsed.groundingSources,
@@ -3174,6 +3259,7 @@ async function historyRoutes(app) {
3174
3259
  answerText: querySnapshots.answerText,
3175
3260
  citedDomains: querySnapshots.citedDomains,
3176
3261
  competitorOverlap: querySnapshots.competitorOverlap,
3262
+ recommendedCompetitors: querySnapshots.recommendedCompetitors,
3177
3263
  location: querySnapshots.location,
3178
3264
  createdAt: querySnapshots.createdAt
3179
3265
  }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc2(querySnapshots.createdAt)).all();
@@ -3193,6 +3279,7 @@ async function historyRoutes(app) {
3193
3279
  answerText: s.answerText,
3194
3280
  citedDomains: tryParseJson2(s.citedDomains, []),
3195
3281
  competitorOverlap: tryParseJson2(s.competitorOverlap, []),
3282
+ recommendedCompetitors: tryParseJson2(s.recommendedCompetitors, []),
3196
3283
  location: s.location,
3197
3284
  createdAt: s.createdAt
3198
3285
  })),
@@ -5338,6 +5425,45 @@ var routeCatalog = [
5338
5425
  404: { description: "Project, connection, or page not found." }
5339
5426
  }
5340
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
+ },
5341
5467
  {
5342
5468
  method: "get",
5343
5469
  path: "/api/v1/projects/{name}/wordpress/schema",
@@ -5379,6 +5505,48 @@ var routeCatalog = [
5379
5505
  404: { description: "Project, connection, or page not found." }
5380
5506
  }
5381
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
+ },
5382
5550
  {
5383
5551
  method: "get",
5384
5552
  path: "/api/v1/projects/{name}/wordpress/llms-txt",
@@ -5466,6 +5634,39 @@ var routeCatalog = [
5466
5634
  404: { description: "Project or connection not found." }
5467
5635
  }
5468
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
+ },
5469
5670
  // GA4 routes
5470
5671
  {
5471
5672
  method: "post",
@@ -5519,7 +5720,7 @@ var routeCatalog = [
5519
5720
  {
5520
5721
  method: "post",
5521
5722
  path: "/api/v1/projects/{name}/ga/sync",
5522
- summary: "Sync GA4 traffic data",
5723
+ summary: "Sync GA4 traffic and AI referral data",
5523
5724
  tags: ["ga4"],
5524
5725
  parameters: [nameParameter],
5525
5726
  requestBody: {
@@ -5544,7 +5745,7 @@ var routeCatalog = [
5544
5745
  {
5545
5746
  method: "get",
5546
5747
  path: "/api/v1/projects/{name}/ga/traffic",
5547
- summary: "Get GA4 landing page traffic",
5748
+ summary: "Get GA4 landing page traffic and AI referral sources",
5548
5749
  tags: ["ga4"],
5549
5750
  parameters: [nameParameter, limitQueryParameter],
5550
5751
  responses: {
@@ -7809,6 +8010,15 @@ async function batchRunReports(accessToken, propertyId, requests) {
7809
8010
  function formatDate(d) {
7810
8011
  return d.toISOString().split("T")[0];
7811
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
+ ];
7812
8022
  async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
7813
8023
  const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
7814
8024
  const endDate = /* @__PURE__ */ new Date();
@@ -7935,6 +8145,61 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
7935
8145
  ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
7936
8146
  return summary;
7937
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
+ }
7938
8203
 
7939
8204
  // ../api-routes/src/ga.ts
7940
8205
  function gaLog(level, action, ctx) {
@@ -8020,6 +8285,7 @@ async function ga4Routes(app, opts) {
8020
8285
  }
8021
8286
  app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
8022
8287
  app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
8288
+ app.db.delete(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).run();
8023
8289
  store.deleteConnection(project.name);
8024
8290
  writeAuditLog(app.db, {
8025
8291
  projectId: project.id,
@@ -8038,7 +8304,7 @@ async function ga4Routes(app, opts) {
8038
8304
  if (!conn) {
8039
8305
  return { connected: false, propertyId: null, clientEmail: null, lastSyncedAt: null };
8040
8306
  }
8041
- 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();
8042
8308
  return {
8043
8309
  connected: true,
8044
8310
  propertyId: conn.propertyId,
@@ -8069,11 +8335,13 @@ async function ga4Routes(app, opts) {
8069
8335
  }
8070
8336
  let rows;
8071
8337
  let summary;
8338
+ let aiReferrals;
8072
8339
  try {
8073
8340
  ;
8074
- [rows, summary] = await Promise.all([
8341
+ [rows, summary, aiReferrals] = await Promise.all([
8075
8342
  fetchTrafficByLandingPage(accessToken, conn.propertyId, days),
8076
- fetchAggregateSummary(accessToken, conn.propertyId, days)
8343
+ fetchAggregateSummary(accessToken, conn.propertyId, days),
8344
+ fetchAiReferrals(accessToken, conn.propertyId, days)
8077
8345
  ]);
8078
8346
  } catch (e) {
8079
8347
  const msg = e instanceof Error ? e.message : String(e);
@@ -8082,17 +8350,14 @@ async function ga4Routes(app, opts) {
8082
8350
  }
8083
8351
  const now = (/* @__PURE__ */ new Date()).toISOString();
8084
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();
8085
8360
  if (rows.length > 0) {
8086
- const dates = rows.map((r) => r.date);
8087
- const minDate = dates.reduce((a, b) => a < b ? a : b);
8088
- const maxDate = dates.reduce((a, b) => a > b ? a : b);
8089
- tx.delete(gaTrafficSnapshots).where(
8090
- and6(
8091
- eq16(gaTrafficSnapshots.projectId, project.id),
8092
- sql3`${gaTrafficSnapshots.date} >= ${minDate}`,
8093
- sql3`${gaTrafficSnapshots.date} <= ${maxDate}`
8094
- )
8095
- ).run();
8096
8361
  for (const row of rows) {
8097
8362
  tx.insert(gaTrafficSnapshots).values({
8098
8363
  id: crypto16.randomUUID(),
@@ -8106,6 +8371,27 @@ async function ga4Routes(app, opts) {
8106
8371
  }).run();
8107
8372
  }
8108
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
+ }
8109
8395
  tx.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
8110
8396
  tx.insert(gaTrafficSummaries).values({
8111
8397
  id: crypto16.randomUUID(),
@@ -8118,10 +8404,17 @@ async function ga4Routes(app, opts) {
8118
8404
  syncedAt: now
8119
8405
  }).run();
8120
8406
  });
8121
- 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
+ });
8122
8414
  return {
8123
8415
  synced: true,
8124
8416
  rowCount: rows.length,
8417
+ aiReferralCount: aiReferrals.length,
8125
8418
  days,
8126
8419
  syncedAt: now
8127
8420
  };
@@ -8147,7 +8440,13 @@ async function ga4Routes(app, opts) {
8147
8440
  organicSessions: sql3`SUM(${gaTrafficSnapshots.organicSessions})`,
8148
8441
  users: sql3`SUM(${gaTrafficSnapshots.users})`
8149
8442
  }).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql3`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8150
- 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();
8151
8450
  return {
8152
8451
  totalSessions: summary?.totalSessions ?? 0,
8153
8452
  totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
@@ -8158,6 +8457,12 @@ async function ga4Routes(app, opts) {
8158
8457
  organicSessions: r.organicSessions ?? 0,
8159
8458
  users: r.users ?? 0
8160
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
+ })),
8161
8466
  lastSyncedAt: latestSync?.syncedAt ?? null
8162
8467
  };
8163
8468
  });
@@ -8199,6 +8504,115 @@ var WordpressApiError = class extends Error {
8199
8504
  }
8200
8505
  };
8201
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
+
8202
8616
  // ../integration-wordpress/src/wordpress-client.ts
8203
8617
  import crypto17 from "crypto";
8204
8618
  var PAGE_FIELDS = "id,slug,status,link,modified,modified_gmt,title,content,meta";
@@ -8635,6 +9049,271 @@ async function setSeoMeta(connection, slug, body, env) {
8635
9049
  );
8636
9050
  return getPageDetail(connection, slug, site.env, plugins);
8637
9051
  }
9052
+ async function detectSeoWriteStrategy(connection, env) {
9053
+ const site = resolveEnvironment(connection, env);
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);
9061
+ return {
9062
+ strategy: writeTargets.length > 0 ? "plugin" : "manual",
9063
+ plugins
9064
+ };
9065
+ }
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}`);
9071
+ return {
9072
+ manualRequired: true,
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
+ }
8638
9317
  async function getLlmsTxt(connection, env) {
8639
9318
  const site = resolveEnvironment(connection, env);
8640
9319
  const url = `${site.siteUrl}/llms.txt`;
@@ -9080,6 +9759,39 @@ async function wordpressRoutes(app, opts) {
9080
9759
  return updated;
9081
9760
  });
9082
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
+ });
9083
9795
  app.get("/projects/:name/wordpress/schema", async (request, reply) => {
9084
9796
  return withWordpressErrorHandling(reply, async () => {
9085
9797
  const store = requireStore(reply);
@@ -9113,6 +9825,33 @@ async function wordpressRoutes(app, opts) {
9113
9825
  return buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
9114
9826
  });
9115
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
+ });
9116
9855
  app.get("/projects/:name/wordpress/llms-txt", async (request, reply) => {
9117
9856
  return withWordpressErrorHandling(reply, async () => {
9118
9857
  const store = requireStore(reply);
@@ -9196,6 +9935,201 @@ async function wordpressRoutes(app, opts) {
9196
9935
  return buildManualStagingPush(connection);
9197
9936
  });
9198
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
+ });
9199
10133
  }
9200
10134
 
9201
10135
  // ../api-routes/src/index.ts
@@ -9288,7 +10222,8 @@ async function apiRoutes(app, opts) {
9288
10222
  onInspectSitemapRequested: opts.onInspectSitemapRequested
9289
10223
  });
9290
10224
  await api.register(wordpressRoutes, {
9291
- wordpressConnectionStore: opts.wordpressConnectionStore
10225
+ wordpressConnectionStore: opts.wordpressConnectionStore,
10226
+ routePrefix: opts.routePrefix ?? "/api/v1"
9292
10227
  });
9293
10228
  await api.register(cdpRoutes, {
9294
10229
  getCdpStatus: opts.getCdpStatus,
@@ -11488,6 +12423,12 @@ var JobRunner = class {
11488
12423
  log.info("query.result", { runId, provider: providerName, keyword: kw.keyword, citedDomains: normalized.citedDomains, groundingSources: normalized.groundingSources.map((s) => s.uri), matchDomains: allDomains });
11489
12424
  const citationState = determineCitationState(normalized, allDomains);
11490
12425
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
12426
+ const extractedCompetitors = extractRecommendedCompetitors(
12427
+ normalized.answerText,
12428
+ allDomains,
12429
+ normalized.citedDomains,
12430
+ overlap
12431
+ );
11491
12432
  let screenshotRelPath = null;
11492
12433
  if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
11493
12434
  const snapshotId = crypto18.randomUUID();
@@ -11506,6 +12447,7 @@ var JobRunner = class {
11506
12447
  answerText: normalized.answerText,
11507
12448
  citedDomains: JSON.stringify(normalized.citedDomains),
11508
12449
  competitorOverlap: JSON.stringify(overlap),
12450
+ recommendedCompetitors: JSON.stringify(extractedCompetitors),
11509
12451
  location: runLocation?.label ?? null,
11510
12452
  screenshotPath: screenshotRelPath,
11511
12453
  rawResponse: JSON.stringify({
@@ -11527,6 +12469,7 @@ var JobRunner = class {
11527
12469
  answerText: normalized.answerText,
11528
12470
  citedDomains: JSON.stringify(normalized.citedDomains),
11529
12471
  competitorOverlap: JSON.stringify(overlap),
12472
+ recommendedCompetitors: JSON.stringify(extractedCompetitors),
11530
12473
  location: runLocation?.label ?? null,
11531
12474
  rawResponse: JSON.stringify({
11532
12475
  model: raw.model,
@@ -11750,6 +12693,97 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
11750
12693
  }
11751
12694
  return [...overlapSet];
11752
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
+ }
11753
12787
 
11754
12788
  // src/gsc-sync.ts
11755
12789
  import crypto19 from "crypto";
@@ -12825,7 +13859,7 @@ var SnapshotService = class {
12825
13859
  heuristicCompetitors: result.recommendedCompetitors,
12826
13860
  citedDomains: result.citedDomains,
12827
13861
  groundingSources: result.groundingSources.map((source) => source.uri),
12828
- answerText: clipText(result.answerText, 420)
13862
+ answerText: clipText(result.answerText, 2e3)
12829
13863
  }))
12830
13864
  );
12831
13865
  if (responses.length === 0) {
@@ -12853,7 +13887,7 @@ var SnapshotService = class {
12853
13887
  accuracyNotes: assessment.accuracyNotes ?? null,
12854
13888
  incorrectClaims: uniqueStrings(assessment.incorrectClaims ?? []).slice(0, 5),
12855
13889
  ...hasReviewedCompetitors ? {
12856
- recommendedCompetitors: uniqueStrings(assessment.recommendedCompetitors ?? []).slice(0, 6)
13890
+ recommendedCompetitors: uniqueStrings(assessment.recommendedCompetitors ?? []).slice(0, 10)
12857
13891
  } : {}
12858
13892
  };
12859
13893
  }),
@@ -12906,7 +13940,13 @@ function buildBatchAnalysisPrompt(ctx) {
12906
13940
  "Return strict JSON with keys: assessments, whatThisMeans, recommendedActions.",
12907
13941
  "Each assessment must include: phrase, provider, mentioned, describedAccurately, accuracyNotes, incorrectClaims, recommendedCompetitors.",
12908
13942
  "describedAccurately must be one of: yes, no, unknown, not-mentioned.",
12909
- "recommendedCompetitors should list actual competitor brands or domains named in the answer, not generic directories unless that is the only thing recommended.",
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.",
12910
13950
  "",
12911
13951
  `Target company: ${ctx.companyName}`,
12912
13952
  `Target domain: ${ctx.domain}`,
@@ -13042,7 +14082,7 @@ function mentionsTargetCompany(answerText, companyName, domain) {
13042
14082
  if (targetTokens.length === 0) return false;
13043
14083
  let matches = 0;
13044
14084
  for (const token of targetTokens) {
13045
- if (new RegExp(`\\b${escapeRegExp(token)}\\b`, "i").test(answerText)) {
14085
+ if (new RegExp(`\\b${escapeRegExp2(token)}\\b`, "i").test(answerText)) {
13046
14086
  matches++;
13047
14087
  }
13048
14088
  }
@@ -13128,7 +14168,10 @@ function normalizeStringList(values) {
13128
14168
  );
13129
14169
  }
13130
14170
  function uniqueStrings(values) {
13131
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
14171
+ if (!Array.isArray(values)) return [];
14172
+ return [...new Set(
14173
+ values.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean)
14174
+ )];
13132
14175
  }
13133
14176
  function normalizeDomain(value) {
13134
14177
  const trimmed = value.trim();
@@ -13159,7 +14202,7 @@ function clipText(value, length) {
13159
14202
  if (value.length <= length) return value;
13160
14203
  return `${value.slice(0, length - 3)}...`;
13161
14204
  }
13162
- function escapeRegExp(value) {
14205
+ function escapeRegExp2(value) {
13163
14206
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13164
14207
  }
13165
14208