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