@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.
- package/assets/assets/index-DmFB_uXa.js +246 -0
- package/assets/assets/{index-DwPC0zVy.css → index-r6biprHB.css} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-IWUQVYU3.js → chunk-LMSO32GF.js} +1068 -25
- package/dist/cli.js +376 -12
- package/dist/index.js +1 -1
- package/package.json +6 -6
- package/assets/assets/index-D1pCtUfW.js +0 -246
|
@@ -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:
|
|
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", {
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
"
|
|
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${
|
|
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
|
-
|
|
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
|
|
14205
|
+
function escapeRegExp2(value) {
|
|
13163
14206
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13164
14207
|
}
|
|
13165
14208
|
|