@canonry/canonry 5.17.0 → 5.19.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/{AuditHistoryPanel-CZUnfysJ.js → AuditHistoryPanel-C_p8jX7Q.js} +1 -1
- package/assets/assets/{BacklinksPage-C9qSkTUW.js → BacklinksPage-jD4nOGw1.js} +1 -1
- package/assets/assets/{HistoryPage-CYMWnCta.js → HistoryPage-DJGBbtBY.js} +1 -1
- package/assets/assets/MeasurementPropertyPage-Dmr3TVlo.js +1 -0
- package/assets/assets/ProjectPage-B_haCKiA.js +8 -0
- package/assets/assets/{RunRow-CwMUW3rM.js → RunRow-BMbwR2yk.js} +1 -1
- package/assets/assets/{RunsPage-DbzSmrnc.js → RunsPage-D8hHakq2.js} +1 -1
- package/assets/assets/{SettingsPage-DM-G5WzL.js → SettingsPage-QKnRcFA9.js} +1 -1
- package/assets/assets/{SiteHealthSection-BwmQJ9Z9.js → SiteHealthSection-CDkUXqmb.js} +3 -3
- package/assets/assets/{TrafficPage-DSumJzUk.js → TrafficPage-CMif8RlM.js} +2 -2
- package/assets/assets/{TrafficSourceDetailPage-D5BjIBkN.js → TrafficSourceDetailPage-DOCTP_4-.js} +1 -1
- package/assets/assets/{extract-error-message-DbpR8Tzb.js → extract-error-message-Dm8RvYE5.js} +1 -1
- package/assets/assets/index-lj0fuFl4.js +86 -0
- package/assets/assets/{react-sigma_core.esm.min-C3cM2I3z.js → react-sigma_core.esm.min-DOPuwLtN.js} +1 -1
- package/assets/assets/v2-overview-adapter-C19tUcDS.js +1 -0
- package/assets/index.html +1 -1
- package/dist/{chunk-2EE6RPWV.js → chunk-ABUO27BV.js} +3 -3
- package/dist/{chunk-GYX277BV.js → chunk-GUJY3RIM.js} +12 -12
- package/dist/{chunk-H6COEFVL.js → chunk-P2EFPLIO.js} +55 -21
- package/dist/{chunk-3OYWLQEE.js → chunk-QJWIOCEO.js} +2 -2
- package/dist/{chunk-XC24KCTL.js → chunk-SPCEXW3B.js} +14 -3969
- package/dist/{chunk-L36TC6LB.js → chunk-WLL7Q7JD.js} +57 -3
- package/dist/chunk-YHKKCXGY.js +3975 -0
- package/dist/cli.js +31 -20
- package/dist/{demo-server-CUBR2NVR.js → demo-server-BOIBS74F.js} +1071 -187
- package/dist/index.js +6 -6
- package/dist/{intelligence-service-6N7SIWLD.js → intelligence-service-IWR2H6OW.js} +2 -2
- package/dist/mcp.js +4 -4
- package/package.json +14 -14
- package/assets/assets/MeasurementPropertyPage-Bu4KDAdf.js +0 -1
- package/assets/assets/ProjectPage-Bg87LPit.js +0 -8
- package/assets/assets/index-BtHRel6S.js +0 -86
- package/assets/assets/v2-overview-adapter-DllH2EQb.js +0 -1
- package/dist/chunk-AOJU5LKX.js +0 -8
|
@@ -4,10 +4,11 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
layoutSiteCrawlGraphInput,
|
|
6
6
|
persistSiteCrawlGraphLayout
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-QJWIOCEO.js";
|
|
8
8
|
import {
|
|
9
|
-
PACKAGE_VERSION
|
|
10
|
-
|
|
9
|
+
PACKAGE_VERSION,
|
|
10
|
+
canonryMcpTools
|
|
11
|
+
} from "./chunk-YHKKCXGY.js";
|
|
11
12
|
import {
|
|
12
13
|
HISTORY_WINDOW_RUNS,
|
|
13
14
|
IntelligenceService,
|
|
@@ -83,12 +84,13 @@ import {
|
|
|
83
84
|
siteCrawlPages,
|
|
84
85
|
siteCrawlSnapshots,
|
|
85
86
|
trafficSources
|
|
86
|
-
} from "./chunk-
|
|
87
|
+
} from "./chunk-P2EFPLIO.js";
|
|
87
88
|
import {
|
|
88
89
|
RunKinds,
|
|
89
90
|
SiteCrawlFetchStates,
|
|
90
91
|
SiteCrawlIndexabilityReasons,
|
|
91
92
|
SiteCrawlIndexabilityStates,
|
|
93
|
+
aeroPreviewResponseSchema,
|
|
92
94
|
buildMeasurementExecutionIdentity,
|
|
93
95
|
buildMeasurementRunManifestV1,
|
|
94
96
|
buildSimpleMeasurementDefinition,
|
|
@@ -102,19 +104,1008 @@ import {
|
|
|
102
104
|
factorStatusFromScore,
|
|
103
105
|
measurementPlanV2ChecksumJson,
|
|
104
106
|
measurementPlanV2Schema,
|
|
107
|
+
parseStoredMeasurementPlanAnyVersion,
|
|
105
108
|
placementLinkDecision,
|
|
106
|
-
siteAuditPageFactorSchema
|
|
107
|
-
|
|
109
|
+
siteAuditPageFactorSchema,
|
|
110
|
+
wilsonInterval
|
|
111
|
+
} from "./chunk-WLL7Q7JD.js";
|
|
108
112
|
|
|
109
113
|
// src/demo-server.ts
|
|
110
114
|
import { dirname, join as join2 } from "path";
|
|
111
115
|
import { fileURLToPath } from "url";
|
|
112
116
|
|
|
117
|
+
// src/demo/aero-preview.ts
|
|
118
|
+
import { and, asc, desc, eq } from "drizzle-orm";
|
|
119
|
+
|
|
120
|
+
// src/demo/summit-sweeps.ts
|
|
121
|
+
var SUMMIT_SWEEP_COUNT = 6;
|
|
122
|
+
var SUMMIT_ENGINES = ["openai", "gemini", "claude"];
|
|
123
|
+
var RIVALS = {
|
|
124
|
+
roofcraft: { name: "RoofCraft", domain: "roofcraft.example", path: "/roof-replacement/", blurb: "A replacement contractor known for metal and shingle installs." },
|
|
125
|
+
everlast: { name: "Everlast Roofing", domain: "everlast-roofing.example", path: "/services/", blurb: "A repair and gutter company with a large emergency crew." }
|
|
126
|
+
};
|
|
127
|
+
var SUMMIT_COMPETITORS = Object.values(RIVALS).map((rival) => ({ domain: rival.domain, label: rival.name }));
|
|
128
|
+
var SUMMIT_QUERIES = [
|
|
129
|
+
{ key: "reviews", text: "Summit Roofing reviews", pages: ["/reviews/"], rivals: [], source: "https://home-services-reviews.example/roofers/", lead: "Recent homeowner reviews are mostly positive.", blurb: "Reviewers mention clear estimates, tidy crews and quick follow-up on warranty visits." },
|
|
130
|
+
{ key: "near-me", text: "roof repair contractor near me", pages: ["/services/roof-repair/", "/service-areas/"], rivals: ["everlast", "roofcraft"], source: "https://local-contractor-directory.example/roofing/", lead: "Several roofers in the area handle repair work.", blurb: "A local roofer covering leak repairs, storm damage and inspections across two dozen towns." },
|
|
131
|
+
{ key: "emergency-leak", text: "emergency roof leak repair", pages: ["/services/roof-repair/leak-repair/", "/services/roof-repair/emergency-tarping/"], rivals: ["everlast"], source: "https://homeowner-guides.example/roof-leaks/", lead: "For an active leak, look for a crew that can tarp the roof the same day.", blurb: "Offers emergency tarping and leak repair, with same-day visits in its service area." },
|
|
132
|
+
{ key: "metal-installer", text: "best metal roof installer", pages: ["/services/roof-replacement/metal-roofing/", "/guides/metal-vs-asphalt-shingles/"], rivals: ["roofcraft"], source: "https://metal-roofing-council.example/find-an-installer/", lead: "Metal roofs need an installer with standing-seam experience.", blurb: "Installs standing-seam and metal shingle roofs and publishes a metal versus asphalt comparison." },
|
|
133
|
+
{ key: "warranty", text: "Summit Roofing warranty", pages: ["/about/warranty/"], rivals: [], source: "https://homeowner-guides.example/roof-warranties/", lead: "Roofing warranties usually combine manufacturer and workmanship coverage.", blurb: "Backs replacements with a written workmanship warranty on top of the manufacturer coverage." },
|
|
134
|
+
{ key: "replacement-cost", text: "how much does a roof replacement cost", pages: ["/guides/how-much-does-a-roof-replacement-cost/", "/financing/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/roof-replacement/", lead: "Most full replacements land between 9,000 and 18,000 dollars, depending on size and material.", blurb: "Publishes a cost guide with typical price ranges and offers financing on full replacements." },
|
|
135
|
+
{ key: "storm-claims", text: "storm damage roof repair and insurance claims", pages: ["/services/roof-repair/storm-damage/", "/guides/filing-a-roof-insurance-claim/"], rivals: ["everlast"], source: "https://homeowner-guides.example/storm-claims/", lead: "Photograph the damage before any repair and call your insurer early.", blurb: "Documents storm damage for insurance claims and joins the adjuster visit." },
|
|
136
|
+
{ key: "asphalt-shingles", text: "asphalt shingle roof replacement", pages: ["/services/roof-replacement/asphalt-shingles/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/asphalt-shingles/", lead: "Architectural shingles are the most common replacement choice.", blurb: "Replaces asphalt shingle roofs with a choice of architectural shingle lines." },
|
|
137
|
+
{ key: "seamless-gutters", text: "seamless gutter installation", pages: ["/services/gutters/seamless-gutters/"], rivals: ["everlast"], source: "https://homeowner-guides.example/gutters/", lead: "Seamless gutters are formed on site, so they leak less at the joints.", blurb: "Installs seamless gutters and gutter guards, often alongside a roof replacement." },
|
|
138
|
+
{ key: "pre-purchase-inspection", text: "roof inspection before buying a house", pages: ["/services/roof-inspection/real-estate-inspection/", "/guides/real-estate-roof-inspections/"], rivals: ["roofcraft", "everlast"], source: "https://home-buyer-checklist.example/roof-inspection/", lead: "A roof inspection before closing can surface repairs to negotiate.", blurb: "Runs pre-purchase roof inspections with photo reports for buyers and agents." }
|
|
139
|
+
];
|
|
140
|
+
var SUMMIT_BRANDED_QUERY_KEYS = ["reviews", "warranty"];
|
|
141
|
+
var SUMMIT_ENGINE_PLANS = {
|
|
142
|
+
// Climbs steadily.
|
|
143
|
+
openai: {
|
|
144
|
+
mentioned: [2, 3, 3, 4, 5, 6],
|
|
145
|
+
mentionOrder: ["emergency-leak", "replacement-cost", "near-me", "storm-claims", "pre-purchase-inspection", "seamless-gutters", "asphalt-shingles", "metal-installer"],
|
|
146
|
+
cited: [1, 2, 2, 3, 4, 5],
|
|
147
|
+
citationOrder: ["replacement-cost", "storm-claims", "pre-purchase-inspection", "emergency-leak", "asphalt-shingles", "metal-installer", "seamless-gutters", "near-me"]
|
|
148
|
+
},
|
|
149
|
+
// Steady, then a dip and a recovery.
|
|
150
|
+
gemini: {
|
|
151
|
+
mentioned: [4, 4, 4, 2, 3, 5],
|
|
152
|
+
mentionOrder: ["near-me", "emergency-leak", "replacement-cost", "storm-claims", "seamless-gutters", "pre-purchase-inspection", "metal-installer", "asphalt-shingles"],
|
|
153
|
+
cited: [3, 3, 3, 1, 2, 3],
|
|
154
|
+
citationOrder: ["replacement-cost", "pre-purchase-inspection", "storm-claims", "metal-installer", "emergency-leak", "asphalt-shingles", "seamless-gutters", "near-me"]
|
|
155
|
+
},
|
|
156
|
+
// Swings from week to week.
|
|
157
|
+
claude: {
|
|
158
|
+
mentioned: [3, 1, 4, 2, 4, 3],
|
|
159
|
+
mentionOrder: ["emergency-leak", "replacement-cost", "storm-claims", "near-me", "pre-purchase-inspection", "asphalt-shingles", "seamless-gutters", "metal-installer"],
|
|
160
|
+
cited: [2, 1, 2, 1, 3, 2],
|
|
161
|
+
citationOrder: ["pre-purchase-inspection", "replacement-cost", "storm-claims", "emergency-leak", "asphalt-shingles", "near-me", "metal-installer", "seamless-gutters"]
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
var BRANDED_PLAN = {
|
|
165
|
+
reviews: {
|
|
166
|
+
openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
|
|
167
|
+
gemini: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
|
|
168
|
+
claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, false, false, false, false] }
|
|
169
|
+
},
|
|
170
|
+
warranty: {
|
|
171
|
+
openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
|
|
172
|
+
gemini: { mentioned: [true, true, true, false, true, true], cited: [true, true, true, false, true, true] },
|
|
173
|
+
claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, true, false, true, true] }
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
function isBranded(key) {
|
|
177
|
+
return SUMMIT_BRANDED_QUERY_KEYS.includes(key);
|
|
178
|
+
}
|
|
179
|
+
function summitAnswerPlan(sweep, engine, queryKey) {
|
|
180
|
+
if (isBranded(queryKey)) {
|
|
181
|
+
const plan2 = BRANDED_PLAN[queryKey][engine];
|
|
182
|
+
return { mentioned: plan2.mentioned[sweep], cited: plan2.cited[sweep] };
|
|
183
|
+
}
|
|
184
|
+
const plan = SUMMIT_ENGINE_PLANS[engine];
|
|
185
|
+
return {
|
|
186
|
+
mentioned: plan.mentionOrder.slice(0, plan.mentioned[sweep]).includes(queryKey),
|
|
187
|
+
cited: plan.citationOrder.slice(0, plan.cited[sweep]).includes(queryKey)
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function summitAnswer(input) {
|
|
191
|
+
const { sweep, engine, queryIndex, displayName, domain } = input;
|
|
192
|
+
const query = SUMMIT_QUERIES[queryIndex];
|
|
193
|
+
const engineIndex = SUMMIT_ENGINES.indexOf(engine);
|
|
194
|
+
const plan = summitAnswerPlan(sweep, engine, query.key);
|
|
195
|
+
const rivals = plan.mentioned ? (sweep + engineIndex + queryIndex) % 2 === 0 ? query.rivals.slice(0, 1) : [] : query.rivals;
|
|
196
|
+
const entries = [
|
|
197
|
+
...plan.mentioned ? [{ name: displayName, blurb: query.blurb }] : [],
|
|
198
|
+
...rivals.map((key) => ({ name: RIVALS[key].name, blurb: RIVALS[key].blurb }))
|
|
199
|
+
];
|
|
200
|
+
const answerText = isBranded(query.key) && !plan.mentioned ? `Fictional answer.
|
|
201
|
+
|
|
202
|
+
I could not find published details for that company. ${query.lead} Most residential roofers offer 5 to 10 years of workmanship coverage.` : [
|
|
203
|
+
"Fictional answer.",
|
|
204
|
+
query.lead,
|
|
205
|
+
entries.map((entry, index) => `${index + 1}. **${entry.name}**: ${entry.blurb}`).join("\n"),
|
|
206
|
+
"Get two or three written estimates before you commit."
|
|
207
|
+
].join("\n\n");
|
|
208
|
+
const citedRivals = rivals.filter((_, index) => (sweep + engineIndex + queryIndex + index) % 3 !== 0);
|
|
209
|
+
const citedUrls = [
|
|
210
|
+
...plan.cited ? [`https://${domain}${query.pages[(sweep + engineIndex) % query.pages.length]}`] : [],
|
|
211
|
+
...citedRivals.map((key) => `https://${RIVALS[key].domain}${RIVALS[key].path}`),
|
|
212
|
+
query.source
|
|
213
|
+
];
|
|
214
|
+
const citedDomains = citedUrls.map((url) => new URL(url).hostname);
|
|
215
|
+
const normalized = {
|
|
216
|
+
provider: engine,
|
|
217
|
+
answerText,
|
|
218
|
+
citedDomains,
|
|
219
|
+
groundingSources: citedUrls.map((uri) => ({ uri, title: "" })),
|
|
220
|
+
searchQueries: [query.text],
|
|
221
|
+
retrievalStatus: "used"
|
|
222
|
+
};
|
|
223
|
+
const domains = effectiveDomains({ canonicalDomain: domain, ownedDomains: [domain] });
|
|
224
|
+
const brandNames = effectiveBrandNames({ displayName, canonicalDomain: domain, ownedDomains: [domain] });
|
|
225
|
+
const competitorDomains = SUMMIT_COMPETITORS.map((competitor) => competitor.domain);
|
|
226
|
+
return {
|
|
227
|
+
answerText,
|
|
228
|
+
answerMentioned: determineAnswerMentioned(answerText, brandNames, domains),
|
|
229
|
+
citationState: determineCitationState(normalized, domains),
|
|
230
|
+
citedDomains,
|
|
231
|
+
citedUrls,
|
|
232
|
+
competitorOverlap: computeCompetitorOverlap(normalized, competitorDomains),
|
|
233
|
+
recommendedCompetitors: extractRecommendedCompetitors(answerText, domains, citedDomains, competitorDomains, brandNames)
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/demo/aero-preview.ts
|
|
238
|
+
var ENGINE_ORDER = ["openai", "gemini", "claude"];
|
|
239
|
+
var ENGINE_NAMES = { openai: "OpenAI", gemini: "Gemini", claude: "Claude" };
|
|
240
|
+
var SUMMIT_TOPICS = {
|
|
241
|
+
"reviews": "Reviews",
|
|
242
|
+
"warranty": "Warranty",
|
|
243
|
+
"near-me": "Local repair",
|
|
244
|
+
"emergency-leak": "Emergency repair",
|
|
245
|
+
"metal-installer": "Metal roofing",
|
|
246
|
+
"replacement-cost": "Replacement cost",
|
|
247
|
+
"storm-claims": "Storm damage",
|
|
248
|
+
"asphalt-shingles": "Shingle replacement",
|
|
249
|
+
"seamless-gutters": "Gutters",
|
|
250
|
+
"pre-purchase-inspection": "Inspections"
|
|
251
|
+
};
|
|
252
|
+
var DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
253
|
+
function formatPreviewDay(iso) {
|
|
254
|
+
return DAY.format(new Date(iso));
|
|
255
|
+
}
|
|
256
|
+
var engineName = (provider) => ENGINE_NAMES[provider] ?? provider;
|
|
257
|
+
var quoted = (text) => `"${text}"`;
|
|
258
|
+
var ratio = (numerator, denominator) => `${numerator}/${denominator}`;
|
|
259
|
+
var percent = (numerator, denominator) => denominator === 0 ? 0 : Math.round(numerator / denominator * 100);
|
|
260
|
+
var unique = (values) => [...new Set(values)];
|
|
261
|
+
function listJoin(items, conjunction = "and") {
|
|
262
|
+
if (items.length <= 1) return items[0] ?? "";
|
|
263
|
+
return `${items.slice(0, -1).join(", ")} ${conjunction} ${items.at(-1)}`;
|
|
264
|
+
}
|
|
265
|
+
function engineOrder(left, right) {
|
|
266
|
+
const rank = (provider) => {
|
|
267
|
+
const index = ENGINE_ORDER.indexOf(provider);
|
|
268
|
+
return index === -1 ? ENGINE_ORDER.length : index;
|
|
269
|
+
};
|
|
270
|
+
return rank(left) - rank(right) || (left < right ? -1 : left > right ? 1 : 0);
|
|
271
|
+
}
|
|
272
|
+
function tally(answers) {
|
|
273
|
+
return {
|
|
274
|
+
mentioned: answers.filter((answer) => answer.mentioned).length,
|
|
275
|
+
cited: answers.filter((answer) => answer.cited).length,
|
|
276
|
+
total: answers.length
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function withinNoise(before, after) {
|
|
280
|
+
const a = wilsonInterval(before.n, before.d);
|
|
281
|
+
const b = wilsonInterval(after.n, after.d);
|
|
282
|
+
if (!a || !b) return true;
|
|
283
|
+
return a.low <= b.high && b.low <= a.high;
|
|
284
|
+
}
|
|
285
|
+
function noisePhrase(within) {
|
|
286
|
+
return within ? "Within normal run-to-run noise." : "Bigger than normal run-to-run noise.";
|
|
287
|
+
}
|
|
288
|
+
function movement(current, previous, denominator) {
|
|
289
|
+
if (current === previous) return `${ratio(current, denominator)}, unchanged`;
|
|
290
|
+
return `${ratio(current, denominator)}, ${current > previous ? "up" : "down"} from ${ratio(previous, denominator)}`;
|
|
291
|
+
}
|
|
292
|
+
function step(name, args, result, durationMs, text) {
|
|
293
|
+
const tool = canonryMcpTools.find((candidate) => candidate.name === name);
|
|
294
|
+
if (!tool) throw new Error(`Aero preview names an unregistered tool: ${name}`);
|
|
295
|
+
const parsed = tool.inputSchema.safeParse(args);
|
|
296
|
+
if (!parsed.success) throw new Error(`Aero preview passes invalid arguments to ${name}: ${parsed.error.message}`);
|
|
297
|
+
const unknownKeys = Object.keys(args).filter((key) => !(key in parsed.data));
|
|
298
|
+
if (unknownKeys.length > 0) throw new Error(`Aero preview passes unknown arguments to ${name}: ${unknownKeys.join(", ")}`);
|
|
299
|
+
return { ...text ? { text } : {}, tool: { name, label: tool.title, arguments: args, result, durationMs } };
|
|
300
|
+
}
|
|
301
|
+
function recommendationClause(reason) {
|
|
302
|
+
if (!reason) return "";
|
|
303
|
+
const head = reason.split(/\s+[\u2014\u2013]\s+/)[0].trim();
|
|
304
|
+
const sentences = head.split(/(?<=\.)\s+/).filter(Boolean);
|
|
305
|
+
const last = (sentences.at(-1) ?? head).replace(/\.$/, "");
|
|
306
|
+
return last.charAt(0).toLowerCase() + last.slice(1);
|
|
307
|
+
}
|
|
308
|
+
function storedRecommendation(reasons) {
|
|
309
|
+
const clauses = reasons.map(recommendationClause).filter(Boolean);
|
|
310
|
+
if (clauses.length === 0) return "";
|
|
311
|
+
return `Stored recommendation${clauses.length === 1 ? "" : "s"}: ${clauses.join(", and ")}.`;
|
|
312
|
+
}
|
|
313
|
+
function loadFacts(db, project) {
|
|
314
|
+
const rows = db.select().from(runs).where(and(eq(runs.projectId, project.id), eq(runs.kind, "answer-visibility"), eq(runs.status, "completed"))).orderBy(asc(runs.createdAt), asc(runs.id)).all().filter((run) => run.trigger !== "probe");
|
|
315
|
+
const competitors2 = /* @__PURE__ */ new Map();
|
|
316
|
+
const properties = /* @__PURE__ */ new Map();
|
|
317
|
+
const sweeps = rows.map((run) => {
|
|
318
|
+
const classes = /* @__PURE__ */ new Map();
|
|
319
|
+
let planRevision = null;
|
|
320
|
+
if (run.measurementPlanVersionId) {
|
|
321
|
+
const version = db.select().from(measurementPlanVersions).where(eq(measurementPlanVersions.id, run.measurementPlanVersionId)).get();
|
|
322
|
+
if (!version) throw new Error(`Demo sweep ${run.id} names a missing plan revision`);
|
|
323
|
+
const plan = parseStoredMeasurementPlanAnyVersion(version.canonicalJson);
|
|
324
|
+
if (plan.schemaVersion !== 2) throw new Error(`Demo sweep ${run.id} is not measured by a portfolio plan`);
|
|
325
|
+
planRevision = version.revision;
|
|
326
|
+
for (const assignment of plan.assignments) classes.set(assignment.queryId, { queryClass: assignment.queryClass, targetKey: assignment.targetKey });
|
|
327
|
+
for (const group of plan.groups) {
|
|
328
|
+
const market = group.label.replace(/\s+market$/i, "");
|
|
329
|
+
for (const competitor of group.competitors) {
|
|
330
|
+
if (competitor.domain) competitors2.set(competitor.domain, { label: competitor.label, domain: competitor.domain });
|
|
331
|
+
}
|
|
332
|
+
for (const targetKey of group.targetKeys) {
|
|
333
|
+
const target = plan.targets.find((candidate) => candidate.stableKey === targetKey);
|
|
334
|
+
if (target) {
|
|
335
|
+
const pathPrefixes = target.urlMatchers.flatMap((matcher) => matcher.kind === "prefix" ? [matcher.pathPrefix] : []);
|
|
336
|
+
properties.set(targetKey, { key: targetKey, label: target.label, market, pathPrefixes });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
const definition = db.select().from(simpleMeasurementDefinitions).where(eq(simpleMeasurementDefinitions.runId, run.id)).get();
|
|
342
|
+
if (!definition) throw new Error(`Demo sweep ${run.id} has no frozen measurement definition`);
|
|
343
|
+
for (const query of definition.definition.queries) {
|
|
344
|
+
classes.set(query.queryId, { queryClass: query.queryClass === "branded" || query.queryClass === "non-brand" ? query.queryClass : null, targetKey: null });
|
|
345
|
+
}
|
|
346
|
+
for (const competitor of definition.definition.competitors ?? []) competitors2.set(competitor.domain, { label: competitor.label, domain: competitor.domain });
|
|
347
|
+
}
|
|
348
|
+
const answers = db.select().from(querySnapshots).where(eq(querySnapshots.runId, run.id)).all().map((row) => {
|
|
349
|
+
const assigned = row.queryId ? classes.get(row.queryId) : void 0;
|
|
350
|
+
const citedUrls = row.citedUrls ?? [];
|
|
351
|
+
return {
|
|
352
|
+
queryId: row.queryId ?? row.queryText ?? "",
|
|
353
|
+
query: row.queryText ?? "",
|
|
354
|
+
provider: row.provider,
|
|
355
|
+
queryClass: assigned?.queryClass ?? null,
|
|
356
|
+
targetKey: assigned?.targetKey ?? null,
|
|
357
|
+
mentioned: row.answerMentioned === true,
|
|
358
|
+
cited: row.citationState === "cited",
|
|
359
|
+
named: [...competitors2.values()].filter((competitor) => (row.answerText ?? "").includes(competitor.label)).map((competitor) => competitor.label),
|
|
360
|
+
competitorCited: [...competitors2.values()].filter((competitor) => row.citedDomains.some((domain) => domain === competitor.domain || domain.endsWith(`.${competitor.domain}`))).map((competitor) => competitor.label),
|
|
361
|
+
citedDomains: row.citedDomains,
|
|
362
|
+
ownCitedPaths: citedUrls.flatMap((url) => {
|
|
363
|
+
const parsed = new URL(url);
|
|
364
|
+
return parsed.hostname === project.domain ? [parsed.pathname] : [];
|
|
365
|
+
})
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
const manifest = run.measurementManifest;
|
|
369
|
+
return {
|
|
370
|
+
id: run.id,
|
|
371
|
+
createdAt: run.createdAt,
|
|
372
|
+
planRevision,
|
|
373
|
+
executionChecksum: run.measurementExecutionIdentity?.checksum ?? null,
|
|
374
|
+
expectedAnswers: Array.isArray(manifest?.expectedSlots) ? manifest.expectedSlots.length : null,
|
|
375
|
+
answers
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
if (sweeps.length < 2) throw new Error(`Demo project ${project.name} needs two completed sweeps for the Aero preview`);
|
|
379
|
+
return { project, sweeps, latest: sweeps.at(-1), previous: sweeps.at(-2), competitors: [...competitors2.values()], properties };
|
|
380
|
+
}
|
|
381
|
+
function enginesOf(sweep) {
|
|
382
|
+
return unique(sweep.answers.map((answer) => answer.provider)).sort(engineOrder);
|
|
383
|
+
}
|
|
384
|
+
function ofClass(sweep, queryClass, provider) {
|
|
385
|
+
return sweep.answers.filter((answer) => answer.queryClass === queryClass && (provider === void 0 || answer.provider === provider));
|
|
386
|
+
}
|
|
387
|
+
function queryIdsOf(answers) {
|
|
388
|
+
return unique(answers.map((answer) => answer.queryId));
|
|
389
|
+
}
|
|
390
|
+
function byQuery(answers) {
|
|
391
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
392
|
+
for (const answer of answers) grouped.set(answer.queryId, [...grouped.get(answer.queryId) ?? [], answer]);
|
|
393
|
+
return grouped;
|
|
394
|
+
}
|
|
395
|
+
function series(facts, queryClass, key, provider) {
|
|
396
|
+
return facts.sweeps.map((sweep) => tally(ofClass(sweep, queryClass, provider))[key]);
|
|
397
|
+
}
|
|
398
|
+
function summary(answers) {
|
|
399
|
+
const counts = tally(answers);
|
|
400
|
+
return {
|
|
401
|
+
queryCount: queryIdsOf(answers).length,
|
|
402
|
+
answerCount: counts.total,
|
|
403
|
+
mentionCoverage: { numerator: counts.mentioned, denominator: counts.total },
|
|
404
|
+
citationCoverage: { numerator: counts.cited, denominator: counts.total }
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function providerRollup(sweep, queryClass) {
|
|
408
|
+
return Object.fromEntries(enginesOf(sweep).map((provider) => {
|
|
409
|
+
const counts = tally(ofClass(sweep, queryClass, provider));
|
|
410
|
+
return [provider, { mentioned: counts.mentioned, cited: counts.cited, answers: counts.total }];
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
function namedInstead(answers) {
|
|
414
|
+
const missing = answers.filter((answer) => !answer.mentioned);
|
|
415
|
+
return unique(missing.flatMap((answer) => answer.named)).map((label) => ({
|
|
416
|
+
label,
|
|
417
|
+
engines: unique(missing.filter((answer) => answer.named.includes(label)).map((answer) => answer.provider)).sort(engineOrder)
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
function link(label, path) {
|
|
421
|
+
return `[${label}](${path})`;
|
|
422
|
+
}
|
|
423
|
+
function summitTopic(query) {
|
|
424
|
+
const key = SUMMIT_QUERIES.find((candidate) => candidate.text === query)?.key;
|
|
425
|
+
return key ? SUMMIT_TOPICS[key] ?? null : null;
|
|
426
|
+
}
|
|
427
|
+
function summitStatus(facts) {
|
|
428
|
+
const { project, latest, sweeps } = facts;
|
|
429
|
+
const engines = enginesOf(latest);
|
|
430
|
+
const queryCount = queryIdsOf(latest.answers).length;
|
|
431
|
+
const branded = ofClass(latest, "branded");
|
|
432
|
+
const nonBrand = ofClass(latest, "non-brand");
|
|
433
|
+
const brandedCounts = tally(branded);
|
|
434
|
+
const nonBrandCounts = tally(nonBrand);
|
|
435
|
+
const nonBrandQueries = queryIdsOf(nonBrand).length;
|
|
436
|
+
const perEngine = engines.map((provider) => ({ provider, ...tally(ofClass(latest, "non-brand", provider)) }));
|
|
437
|
+
const unnamed = [...byQuery(nonBrand).values()].filter((answers) => answers.every((answer2) => !answer2.mentioned));
|
|
438
|
+
const unnamedRivals = unique(unnamed.flatMap((answers) => namedInstead(answers).map((entry) => entry.label)));
|
|
439
|
+
const allAnswered = engines.every((provider) => latest.answers.filter((answer2) => answer2.provider === provider).length === queryCount);
|
|
440
|
+
const weakest = [...perEngine].sort((left, right) => left.mentioned - right.mentioned);
|
|
441
|
+
const uniqueWeakest = weakest.length > 1 && weakest[0].mentioned < weakest[1].mentioned ? weakest[0] : null;
|
|
442
|
+
const brandedMisses = branded.filter((answer2) => !answer2.cited);
|
|
443
|
+
const brandedUnnamed = branded.filter((answer2) => !answer2.mentioned);
|
|
444
|
+
const attention = [];
|
|
445
|
+
if (unnamed.length > 0) {
|
|
446
|
+
const queries2 = listJoin(unnamed.map((answers) => quoted(answers[0].query)), "or");
|
|
447
|
+
const rivals = unnamedRivals.length > 0 ? ` ${listJoin(unnamedRivals)} ${unnamedRivals.length === 1 ? "is" : "are"} named ${unnamed.length === 1 ? "there" : `on ${unnamed.length === 2 ? "both" : "all of them"}`}.` : "";
|
|
448
|
+
attention.push(`- No engine names ${project.displayName} on ${queries2}.${rivals}`);
|
|
449
|
+
}
|
|
450
|
+
if (uniqueWeakest) attention.push(`- ${engineName(uniqueWeakest.provider)} is the weakest engine on non-brand, naming you on ${uniqueWeakest.mentioned} of ${uniqueWeakest.total}.`);
|
|
451
|
+
if (brandedUnnamed.length === 0 && brandedMisses.length > 0) {
|
|
452
|
+
attention.push(`- Branded mentions are complete. The ${brandedMisses.length === 1 ? "one branded gap is a citation" : "branded gaps are citations"}: ${listJoin(brandedMisses.map((answer2) => `${engineName(answer2.provider)} does not cite your site on ${quoted(answer2.query)}`))}.`);
|
|
453
|
+
}
|
|
454
|
+
const answer = [
|
|
455
|
+
`**Latest sweep: ${formatPreviewDay(latest.createdAt)}, completed.** ${listJoin(engines.map(engineName))} ${allAnswered ? `all answered all ${queryCount} queries` : "answered"}, ${latest.answers.length} answers in total. That makes ${sweeps.length} sweeps so far; the first ran on ${formatPreviewDay(sweeps[0].createdAt)}.`,
|
|
456
|
+
[
|
|
457
|
+
"| Queries | Mentioned | Cited |",
|
|
458
|
+
"|---|---|---|",
|
|
459
|
+
`| Branded (${queryIdsOf(branded).length}) | ${ratio(brandedCounts.mentioned, brandedCounts.total)} | ${ratio(brandedCounts.cited, brandedCounts.total)} |`,
|
|
460
|
+
`| Non-brand (${nonBrandQueries}) | ${ratio(nonBrandCounts.mentioned, nonBrandCounts.total)} (${percent(nonBrandCounts.mentioned, nonBrandCounts.total)}%) | ${ratio(nonBrandCounts.cited, nonBrandCounts.total)} (${percent(nonBrandCounts.cited, nonBrandCounts.total)}%) |`
|
|
461
|
+
].join("\n"),
|
|
462
|
+
`Non-brand by engine, mentioned then cited: ${perEngine.map((entry) => `${engineName(entry.provider)} ${ratio(entry.mentioned, entry.total)} and ${ratio(entry.cited, entry.total)}`).join(", ")}.`,
|
|
463
|
+
...attention.length > 0 ? [`**Needs attention**
|
|
464
|
+
${attention.join("\n")}`] : [],
|
|
465
|
+
unnamed.length > 0 ? `Next: open ${unnamed.length === 1 ? "that query" : `those ${unnamed.length} queries`} and read the answers that name ${listJoin(unnamedRivals)}; that is the content your pages need to match. ${link("Open queries", `/projects/${project.name}/queries`)}` : `Next: keep the weekly sweep running and watch the weakest engine. ${link("Open queries", `/projects/${project.name}/queries`)}`
|
|
466
|
+
].join("\n\n");
|
|
467
|
+
return {
|
|
468
|
+
id: "status",
|
|
469
|
+
steps: [
|
|
470
|
+
step("canonry_project_overview", { project: project.name }, {
|
|
471
|
+
latestRun: { run: { id: latest.id, kind: "answer-visibility", status: "completed", createdAt: latest.createdAt } },
|
|
472
|
+
health: {
|
|
473
|
+
runId: latest.id,
|
|
474
|
+
totalPairs: latest.answers.length,
|
|
475
|
+
mentionedPairs: tally(latest.answers).mentioned,
|
|
476
|
+
citedPairs: tally(latest.answers).cited,
|
|
477
|
+
providerBreakdown: Object.fromEntries(engines.map((provider) => {
|
|
478
|
+
const counts = tally(latest.answers.filter((item) => item.provider === provider));
|
|
479
|
+
return [provider, { mentioned: counts.mentioned, cited: counts.cited, total: counts.total }];
|
|
480
|
+
}))
|
|
481
|
+
}
|
|
482
|
+
}, 820, "Checking the latest sweep and which engines answered."),
|
|
483
|
+
step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
|
|
484
|
+
selection: { queryClass: "all", run: { id: latest.id } },
|
|
485
|
+
populations: [
|
|
486
|
+
{ queryClass: "branded", summary: summary(branded) },
|
|
487
|
+
{
|
|
488
|
+
queryClass: "non-brand",
|
|
489
|
+
summary: summary(nonBrand),
|
|
490
|
+
providerRollup: providerRollup(latest, "non-brand"),
|
|
491
|
+
unnamedQueries: unnamed.map((answers) => ({ query: answers[0].query, namedInstead: namedInstead(answers).map((entry) => entry.label) }))
|
|
492
|
+
}
|
|
493
|
+
]
|
|
494
|
+
}, 1140, "The overview pools branded and non-brand, so reading the classes separately.")
|
|
495
|
+
],
|
|
496
|
+
answer
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function summitChanges(facts) {
|
|
500
|
+
const { project, latest, previous, sweeps } = facts;
|
|
501
|
+
const sameQueries = queryIdsOf(latest.answers).sort().join("\n") === queryIdsOf(previous.answers).sort().join("\n");
|
|
502
|
+
const lines = [];
|
|
503
|
+
const reading = (queryClass, key) => {
|
|
504
|
+
const now = tally(ofClass(latest, queryClass));
|
|
505
|
+
const before2 = tally(ofClass(previous, queryClass));
|
|
506
|
+
return { now: now[key], before: before2[key], total: now.total, beforeTotal: before2.total };
|
|
507
|
+
};
|
|
508
|
+
const verdicts = [];
|
|
509
|
+
for (const [key, heading] of [["mentioned", "Mentions"], ["cited", "Citations"]]) {
|
|
510
|
+
const rows = [];
|
|
511
|
+
for (const [queryClass, label] of [["non-brand", "Non-brand"], ["branded", "Branded"]]) {
|
|
512
|
+
const value = reading(queryClass, key);
|
|
513
|
+
if (value.total === 0) continue;
|
|
514
|
+
let line = `- ${label}: ${movement(value.now, value.before, value.total)}.`;
|
|
515
|
+
if (value.now !== value.before) {
|
|
516
|
+
const within = withinNoise({ n: value.before, d: value.beforeTotal }, { n: value.now, d: value.total });
|
|
517
|
+
verdicts.push(within);
|
|
518
|
+
line += ` ${noisePhrase(within)}`;
|
|
519
|
+
const history = series(facts, queryClass, key);
|
|
520
|
+
if (key === "mentioned" && value.now === Math.max(...history) && history.slice(0, -1).every((item) => item < value.now)) {
|
|
521
|
+
line += ` It is the highest of the ${sweeps.length} sweeps (${history.join(", ")}).`;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
rows.push(line);
|
|
525
|
+
}
|
|
526
|
+
lines.push(`**${heading}**
|
|
527
|
+
${rows.join("\n")}`);
|
|
528
|
+
}
|
|
529
|
+
const queryState = (sweep) => new Map([...byQuery(sweep.answers)].map(([queryId, answers]) => [queryId, {
|
|
530
|
+
query: answers[0].query,
|
|
531
|
+
mentioned: answers.some((answer2) => answer2.mentioned),
|
|
532
|
+
cited: answers.some((answer2) => answer2.cited)
|
|
533
|
+
}]));
|
|
534
|
+
const before = queryState(previous);
|
|
535
|
+
const after = queryState(latest);
|
|
536
|
+
const diff = [...after].flatMap(([queryId, state]) => {
|
|
537
|
+
const prior = before.get(queryId);
|
|
538
|
+
if (!prior || prior.mentioned === state.mentioned && prior.cited === state.cited) return [];
|
|
539
|
+
return [{
|
|
540
|
+
query: state.query,
|
|
541
|
+
run1MentionState: prior.mentioned ? "mentioned" : "not-mentioned",
|
|
542
|
+
run2MentionState: state.mentioned ? "mentioned" : "not-mentioned",
|
|
543
|
+
run1State: prior.cited ? "cited" : "not-cited",
|
|
544
|
+
run2State: state.cited ? "cited" : "not-cited"
|
|
545
|
+
}];
|
|
546
|
+
});
|
|
547
|
+
const gainedMention = diff.filter((row) => row.run2MentionState === "mentioned" && row.run1MentionState !== "mentioned");
|
|
548
|
+
const gainedCitation = diff.filter((row) => row.run2State === "cited" && row.run1State !== "cited");
|
|
549
|
+
const lost = diff.filter((row) => row.run1MentionState === "mentioned" && row.run2MentionState !== "mentioned" || row.run1State === "cited" && row.run2State !== "cited");
|
|
550
|
+
const citationFirsts = gainedCitation.map((row) => {
|
|
551
|
+
const everCited = sweeps.slice(0, -1).some((sweep) => sweep.answers.some((answer2) => answer2.query === row.query && answer2.cited));
|
|
552
|
+
const citedBy = latest.answers.filter((answer2) => answer2.query === row.query && answer2.cited).map((answer2) => engineName(answer2.provider));
|
|
553
|
+
return `${quoted(row.query)} gained a citation${everCited ? "" : `, its first from any engine (${listJoin(citedBy)})`}`;
|
|
554
|
+
});
|
|
555
|
+
const flipParts = [
|
|
556
|
+
...gainedMention.map((row) => `${quoted(row.query)} gained a mention`),
|
|
557
|
+
...citationFirsts
|
|
558
|
+
];
|
|
559
|
+
const flips = flipParts.length > 0 ? `At query level, ${listJoin(flipParts)}. ${lost.length === 0 ? "No query lost either signal." : `${listJoin(lost.map((row) => quoted(row.query)))} lost a signal.`}` : "No query changed state at query level.";
|
|
560
|
+
const climbs = enginesOf(latest).map((provider) => ({
|
|
561
|
+
provider,
|
|
562
|
+
mentions: series(facts, "non-brand", "mentioned", provider),
|
|
563
|
+
citations: series(facts, "non-brand", "cited", provider),
|
|
564
|
+
total: tally(ofClass(latest, "non-brand", provider)).total
|
|
565
|
+
})).filter((entry) => entry.mentions.every((value, index) => index === 0 || value >= entry.mentions[index - 1]) && entry.mentions.at(-1) > entry.mentions[0]).sort((left, right) => right.mentions.at(-1) - right.mentions[0] - (left.mentions.at(-1) - left.mentions[0]));
|
|
566
|
+
const climb = climbs.at(0);
|
|
567
|
+
const allWithin = verdicts.every(Boolean);
|
|
568
|
+
let trend = allWithin ? "Nothing between these two sweeps is bigger than normal run-to-run noise." : "At least one change is bigger than normal run-to-run noise.";
|
|
569
|
+
if (climb) {
|
|
570
|
+
const firstToLast = withinNoise({ n: climb.mentions[0], d: climb.total }, { n: climb.mentions.at(-1), d: climb.total });
|
|
571
|
+
trend += ` The direction worth watching is ${engineName(climb.provider)}: its non-brand mentions have not dropped in ${sweeps.length} sweeps (${climb.mentions.join(", ")} of ${climb.total}), and citations went from ${climb.citations[0]} to ${climb.citations.at(-1)} of ${climb.total}.`;
|
|
572
|
+
trend += firstToLast ? ` Even first to last is within noise at ${climb.total} answers a sweep, so treat it as a direction to confirm, not a win.` : ` First to last is bigger than noise at ${climb.total} answers a sweep.`;
|
|
573
|
+
}
|
|
574
|
+
const nextParts = [
|
|
575
|
+
...climb ? [`let the next sweep land before calling the ${engineName(climb.provider)} climb real`] : ["let the next sweep land before acting on any single move"],
|
|
576
|
+
...gainedCitation[0] ? [`check that the new ${quoted(gainedCitation[0].query)} citation holds`] : []
|
|
577
|
+
];
|
|
578
|
+
const answer = [
|
|
579
|
+
`**${formatPreviewDay(latest.createdAt)} vs ${formatPreviewDay(previous.createdAt)}${sameQueries ? `: the same ${queryIdsOf(latest.answers).length} queries on both sweeps` : ""}.**`,
|
|
580
|
+
...lines,
|
|
581
|
+
flips,
|
|
582
|
+
trend,
|
|
583
|
+
`Next: ${nextParts.join(", and ")}. ${link("Open queries", `/projects/${project.name}/queries`)}`
|
|
584
|
+
].join("\n\n");
|
|
585
|
+
const populations = ["branded", "non-brand"].map((queryClass) => {
|
|
586
|
+
const now = summary(ofClass(latest, queryClass));
|
|
587
|
+
const prior = tally(ofClass(previous, queryClass));
|
|
588
|
+
return {
|
|
589
|
+
queryClass,
|
|
590
|
+
summary: now,
|
|
591
|
+
comparison: {
|
|
592
|
+
previousRun: { id: previous.id, createdAt: previous.createdAt },
|
|
593
|
+
mentionCoverage: { previous: { numerator: prior.mentioned, denominator: prior.total } },
|
|
594
|
+
citationCoverage: { previous: { numerator: prior.cited, denominator: prior.total } }
|
|
595
|
+
},
|
|
596
|
+
...queryClass === "non-brand" ? { trend: { runs: sweeps.map((sweep) => sweep.id), mentioned: series(facts, "non-brand", "mentioned"), cited: series(facts, "non-brand", "cited"), answers: tally(ofClass(latest, "non-brand")).total } } : {}
|
|
597
|
+
};
|
|
598
|
+
});
|
|
599
|
+
const steps = [
|
|
600
|
+
step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
|
|
601
|
+
selection: { queryClass: "all", run: { id: latest.id } },
|
|
602
|
+
populations
|
|
603
|
+
}, 1060, `Comparing the ${formatPreviewDay(latest.createdAt)} sweep with ${formatPreviewDay(previous.createdAt)}, branded and non-brand kept apart.`),
|
|
604
|
+
step("canonry_snapshots_diff", { project: project.name, run1: previous.id, run2: latest.id }, { run1: previous.id, run2: latest.id, changed: diff }, 540, "Checking which queries flipped.")
|
|
605
|
+
];
|
|
606
|
+
if (climb) {
|
|
607
|
+
steps.push(step("canonry_visibility_report", { project: project.name, queryClass: "non-brand", provider: climb.provider }, {
|
|
608
|
+
selection: { queryClass: "non-brand", provider: climb.provider, run: { id: latest.id } },
|
|
609
|
+
trend: { mentioned: climb.mentions, cited: climb.citations, answers: climb.total }
|
|
610
|
+
}, 910, `${engineName(climb.provider)} has moved the most, so reading its trend.`));
|
|
611
|
+
}
|
|
612
|
+
return { id: "changes", steps, answer };
|
|
613
|
+
}
|
|
614
|
+
function summitGaps(facts) {
|
|
615
|
+
const { project, latest } = facts;
|
|
616
|
+
const nonBrand = ofClass(latest, "non-brand");
|
|
617
|
+
const groups = [...byQuery(nonBrand).values()];
|
|
618
|
+
const engines = enginesOf(latest);
|
|
619
|
+
const unnamed = groups.filter((answers) => answers.every((answer2) => !answer2.mentioned));
|
|
620
|
+
const partial = groups.filter((answers) => answers.some((answer2) => answer2.mentioned) && answers.some((answer2) => !answer2.mentioned));
|
|
621
|
+
const topicOf = (query) => summitTopic(query) ?? query;
|
|
622
|
+
const rivals = unique(unnamed.flatMap((answers) => namedInstead(answers).map((entry) => entry.label)));
|
|
623
|
+
const rows = unnamed.map((answers) => {
|
|
624
|
+
const query = answers[0].query;
|
|
625
|
+
const instead = namedInstead(answers).map((entry) => `${entry.label}, by ${entry.engines.length} of ${answers.length} engines`).join("; ") || "no tracked competitor";
|
|
626
|
+
const citedBy = answers.filter((answer2) => answer2.cited).map((answer2) => engineName(answer2.provider));
|
|
627
|
+
return `| ${topicOf(query)} | ${query} | ${instead} | ${citedBy.length} of ${answers.length}${citedBy.length > 0 ? ` (${listJoin(citedBy)})` : ""} |`;
|
|
628
|
+
});
|
|
629
|
+
const readButUnnamed = unnamed.flatMap((answers) => answers.filter((answer2) => answer2.cited && answer2.ownCitedPaths.length > 0).map((answer2) => `On ${quoted(answer2.query)}, ${engineName(answer2.provider)} cites ${answer2.ownCitedPaths[0]} without naming ${project.displayName}, so the page is read but the brand does not come through.`));
|
|
630
|
+
const partialLines = partial.map((answers) => {
|
|
631
|
+
const missing = answers.filter((answer2) => !answer2.mentioned);
|
|
632
|
+
const who = listJoin(missing.map((answer2) => engineName(answer2.provider)));
|
|
633
|
+
const instead = unique(missing.flatMap((answer2) => answer2.named));
|
|
634
|
+
const stillCited = missing.every((answer2) => answer2.cited);
|
|
635
|
+
return `- ${topicOf(answers[0].query)}, ${quoted(answers[0].query)}: ${who} ${instead.length > 0 ? `name${missing.length === 1 ? "s" : ""} ${listJoin(instead)} instead` : `do${missing.length === 1 ? "es" : ""} not name you`}${stillCited ? `, though ${missing.length === 1 ? "it still cites" : "both still cite"} your site` : ""}.`;
|
|
636
|
+
});
|
|
637
|
+
const pages = unique(unnamed.map((answers) => {
|
|
638
|
+
const cited = answers.find((answer2) => answer2.ownCitedPaths.length > 0)?.ownCitedPaths[0];
|
|
639
|
+
return cited ?? SUMMIT_QUERIES.find((candidate) => candidate.text === answers[0].query)?.pages[0];
|
|
640
|
+
}).filter((page) => Boolean(page)));
|
|
641
|
+
const answer = unnamed.length === 0 ? `**Every non-brand query is named by at least one engine on ${formatPreviewDay(latest.createdAt)}.**
|
|
642
|
+
|
|
643
|
+
${partialLines.join("\n")}
|
|
644
|
+
|
|
645
|
+
Next: work the partial gaps above. ${link("Open queries", `/projects/${project.name}/queries`)}` : [
|
|
646
|
+
`**${unnamed.length} of ${groups.length} non-brand queries are named by no engine on ${formatPreviewDay(latest.createdAt)}.** ${rivals.length === 1 ? `${rivals[0]} takes ${unnamed.length === 1 ? "it" : unnamed.length === 2 ? "both" : "all of them"}.` : ""}`.trim(),
|
|
647
|
+
["| Topic | Query | Named instead | Your site cited |", "|---|---|---|---|", ...rows].join("\n"),
|
|
648
|
+
...readButUnnamed,
|
|
649
|
+
...partialLines.length > 0 ? [`Partial gaps, where some engines name you and others do not:
|
|
650
|
+
${partialLines.join("\n")}`] : [],
|
|
651
|
+
`Next: make ${project.displayName} and the service explicit on ${listJoin(pages)}, then check ${unnamed.length === 1 ? "the query" : unnamed.length === 2 ? "both queries" : "these queries"} on the next sweep. ${link("Open queries", `/projects/${project.name}/queries`)}`
|
|
652
|
+
].join("\n\n");
|
|
653
|
+
const queryRows = groups.map((answers) => ({
|
|
654
|
+
query: answers[0].query,
|
|
655
|
+
mentionedBy: answers.filter((answer2) => answer2.mentioned).map((answer2) => answer2.provider).sort(engineOrder),
|
|
656
|
+
citedBy: answers.filter((answer2) => answer2.cited).map((answer2) => answer2.provider).sort(engineOrder)
|
|
657
|
+
}));
|
|
658
|
+
const missingAnswers = nonBrand.filter((answer2) => !answer2.mentioned).sort((left, right) => left.query.localeCompare(right.query) || engineOrder(left.provider, right.provider)).map((answer2) => ({
|
|
659
|
+
query: answer2.query,
|
|
660
|
+
provider: answer2.provider,
|
|
661
|
+
answerMentioned: false,
|
|
662
|
+
citationState: answer2.cited ? "cited" : "not-cited",
|
|
663
|
+
recommendedCompetitors: answer2.named
|
|
664
|
+
}));
|
|
665
|
+
return {
|
|
666
|
+
id: "gaps",
|
|
667
|
+
steps: [
|
|
668
|
+
step("canonry_visibility_report", { project: project.name, queryClass: "non-brand" }, {
|
|
669
|
+
selection: { queryClass: "non-brand", run: { id: latest.id } },
|
|
670
|
+
engines,
|
|
671
|
+
queries: queryRows
|
|
672
|
+
}, 980, "Reading the latest non-brand query rows."),
|
|
673
|
+
step("canonry_run_get", { runId: latest.id }, { id: latest.id, status: "completed", snapshots: missingAnswers }, 690, "Pulling the answers that leave you out to see who they name.")
|
|
674
|
+
],
|
|
675
|
+
answer
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
function summitInsights(db, facts) {
|
|
679
|
+
const { project, latest, sweeps } = facts;
|
|
680
|
+
const active = db.select().from(insights).where(and(eq(insights.projectId, project.id), eq(insights.dismissed, false))).orderBy(desc(insights.createdAt), asc(insights.id)).all();
|
|
681
|
+
const severe = active.filter((row) => row.severity === "high" || row.severity === "critical");
|
|
682
|
+
const fromLatest = active.filter((row) => row.runId === latest.id);
|
|
683
|
+
const medium = fromLatest.filter((row) => row.severity === "medium");
|
|
684
|
+
const low = fromLatest.filter((row) => row.severity === "low");
|
|
685
|
+
if (severe.length > 0) throw new Error("The Summit Aero preview assumes no high or critical insight; rewrite its insights turn");
|
|
686
|
+
const gaps = medium.filter((row) => row.type === "persistent-gap");
|
|
687
|
+
const gains = medium.filter((row) => row.type === "competitor-gained");
|
|
688
|
+
const firsts = medium.filter((row) => row.type === "first-citation");
|
|
689
|
+
const latestByQuery = byQuery(latest.answers.filter((answer2) => answer2.queryClass !== null));
|
|
690
|
+
const answersFor = (query) => [...latestByQuery.values()].find((answers) => answers[0].query === query) ?? [];
|
|
691
|
+
const previousFor = (query) => facts.previous.answers.filter((answer2) => answer2.query === query);
|
|
692
|
+
const sharedSource = (answers) => {
|
|
693
|
+
const own = project.domain;
|
|
694
|
+
const competitorDomains = new Set(facts.competitors.map((competitor) => competitor.domain));
|
|
695
|
+
const candidates = unique(answers.flatMap((answer2) => answer2.citedDomains)).filter((domain) => domain !== own && !competitorDomains.has(domain));
|
|
696
|
+
return candidates.find((domain) => answers.every((answer2) => answer2.citedDomains.includes(domain))) ?? null;
|
|
697
|
+
};
|
|
698
|
+
const ranked = [...gaps].sort((left, right) => {
|
|
699
|
+
const score = (row) => {
|
|
700
|
+
const answers = answersFor(row.query);
|
|
701
|
+
return (answers.every((answer2) => !answer2.mentioned) ? 0 : 2) + (gains.some((gain) => gain.query === row.query) ? 0 : 1);
|
|
702
|
+
};
|
|
703
|
+
return score(left) - score(right);
|
|
704
|
+
}).slice(0, 3);
|
|
705
|
+
const items = ranked.map((row, index) => {
|
|
706
|
+
const answers = answersFor(row.query);
|
|
707
|
+
const mentionedBy = answers.filter((answer2) => answer2.mentioned).map((answer2) => answer2.provider).sort(engineOrder);
|
|
708
|
+
const gain = gains.find((candidate) => candidate.query === row.query);
|
|
709
|
+
const parts = [];
|
|
710
|
+
if (mentionedBy.length === 0) {
|
|
711
|
+
const instead = namedInstead(answers);
|
|
712
|
+
const everMentioned = sweeps.some((sweep) => sweep.answers.some((answer2) => answer2.query === row.query && answer2.mentioned));
|
|
713
|
+
parts.push(`No engine names ${project.displayName} on it${everMentioned ? "" : ` in any of the ${sweeps.length} sweeps`}${instead.length > 0 ? `, and ${listJoin(instead.map((entry) => entry.engines.length === answers.length ? `all ${answers.length} engines name ${entry.label}` : `${listJoin(entry.engines.map(engineName))} name${entry.engines.length === 1 ? "s" : ""} ${entry.label}`))}` : ""}.`);
|
|
714
|
+
} else {
|
|
715
|
+
const newly = mentionedBy.filter((provider) => !previousFor(row.query).some((answer2) => answer2.provider === provider && answer2.mentioned));
|
|
716
|
+
parts.push(newly.length === mentionedBy.length ? `${listJoin(mentionedBy.map(engineName))} started naming you on this sweep, but no engine cites you.` : `${listJoin(mentionedBy.map(engineName))} name${mentionedBy.length === 1 ? "s" : ""} you, but no engine cites you.`);
|
|
717
|
+
const rivalsCited = unique(answers.flatMap((answer2) => answer2.competitorCited));
|
|
718
|
+
for (const rival of rivalsCited) {
|
|
719
|
+
const citing = answers.filter((answer2) => answer2.competitorCited.includes(rival)).map((answer2) => engineName(answer2.provider));
|
|
720
|
+
if (!gain) parts.push(`${listJoin(citing)} cite${citing.length === 1 ? "s" : ""} ${rival} instead.`);
|
|
721
|
+
}
|
|
722
|
+
const source = sharedSource(answers);
|
|
723
|
+
if (source) parts.push(`Every answer cites ${source}, so check how that site covers you.`);
|
|
724
|
+
}
|
|
725
|
+
const stored = storedRecommendation([gain?.recommendation?.reason, row.recommendation?.reason]);
|
|
726
|
+
const pages = mentionedBy.length === 0 ? SUMMIT_QUERIES.find((candidate) => candidate.text === row.query)?.pages ?? [] : [];
|
|
727
|
+
const title = gain ? `${quoted(row.query)}: uncited for ${row.title.match(/for (\d+) runs/)?.[1] ?? "several"} runs, and ${facts.competitors.find((competitor) => competitor.domain === gain.cause?.competitorDomain)?.label ?? gain.cause?.competitorDomain ?? "a competitor"} just earned a citation there` : row.title;
|
|
728
|
+
return `${index + 1}. **${title}.** ${parts.join(" ")} ${stored}${pages.length > 0 ? ` Start with ${listJoin(pages)}.` : ""}`;
|
|
729
|
+
});
|
|
730
|
+
const win = firsts.at(0);
|
|
731
|
+
const winLine = win ? `The win: ${quoted(win.query)} got its first citation from any engine (${engineName(win.provider)}). ${storedRecommendation([win.recommendation?.reason])}` : null;
|
|
732
|
+
const firstUnnamed = ranked.find((row) => answersFor(row.query).every((answer2) => !answer2.mentioned));
|
|
733
|
+
const answer = [
|
|
734
|
+
`None of the ${active.length} active insights is high or critical, so I ranked the medium ones from the latest sweep. The ${formatPreviewDay(latest.createdAt)} sweep added ${fromLatest.length}, ${medium.length} medium and ${low.length} low, and the medium ones come down to ${items.length} issues${win ? " plus one win" : ""}.`,
|
|
735
|
+
items.join("\n"),
|
|
736
|
+
...winLine ? [winLine] : [],
|
|
737
|
+
`Next: ${firstUnnamed ? `start with the ${quoted(firstUnnamed.query)} audit, since it is the only one of the ${items.length} where no engine names you` : "start with the first item"}. ${link("Open queries", `/projects/${project.name}/queries`)}`
|
|
738
|
+
].join("\n\n");
|
|
739
|
+
return {
|
|
740
|
+
id: "insights",
|
|
741
|
+
steps: [
|
|
742
|
+
step("canonry_insights_list", { project: project.name }, {
|
|
743
|
+
active: active.length,
|
|
744
|
+
highOrCritical: severe.length,
|
|
745
|
+
latestSweep: { runId: latest.id, count: fromLatest.length, medium: medium.length, low: low.length },
|
|
746
|
+
medium: medium.map((row) => ({ id: row.id, type: row.type, title: row.title, provider: row.provider, recommendation: row.recommendation?.action ?? null, ...row.cause?.cause ? { cause: row.cause.cause } : {} }))
|
|
747
|
+
}, 470, "Listing active insights."),
|
|
748
|
+
step("canonry_run_get", { runId: latest.id }, {
|
|
749
|
+
id: latest.id,
|
|
750
|
+
status: "completed",
|
|
751
|
+
snapshots: ranked.flatMap((row) => answersFor(row.query).sort((left, right) => engineOrder(left.provider, right.provider)).map((answer2) => ({
|
|
752
|
+
query: answer2.query,
|
|
753
|
+
provider: answer2.provider,
|
|
754
|
+
answerMentioned: answer2.mentioned,
|
|
755
|
+
citationState: answer2.cited ? "cited" : "not-cited",
|
|
756
|
+
recommendedCompetitors: answer2.named,
|
|
757
|
+
citedDomains: answer2.citedDomains
|
|
758
|
+
})))
|
|
759
|
+
}, 730, "All medium, so checking the answers behind each one before ranking them.")
|
|
760
|
+
],
|
|
761
|
+
answer
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function shortPropertyLabel(property) {
|
|
765
|
+
const prefix = `Harbor ${property.market} `;
|
|
766
|
+
return property.label.startsWith(prefix) ? property.label.slice(prefix.length) : property.label;
|
|
767
|
+
}
|
|
768
|
+
function markets(facts) {
|
|
769
|
+
return unique([...facts.properties.values()].map((property) => property.market));
|
|
770
|
+
}
|
|
771
|
+
function marketOf(facts, answer) {
|
|
772
|
+
return answer.targetKey ? facts.properties.get(answer.targetKey)?.market ?? null : null;
|
|
773
|
+
}
|
|
774
|
+
function loadDeadLinks(db, facts) {
|
|
775
|
+
const { project } = facts;
|
|
776
|
+
const audit = db.select().from(runs).where(and(eq(runs.projectId, project.id), eq(runs.kind, "site-audit"), eq(runs.status, "completed"))).orderBy(desc(runs.createdAt), desc(runs.id)).get();
|
|
777
|
+
if (!audit) return null;
|
|
778
|
+
const snapshot = db.select().from(siteCrawlSnapshots).where(and(eq(siteCrawlSnapshots.projectId, project.id), eq(siteCrawlSnapshots.runId, audit.id))).get();
|
|
779
|
+
const findings = db.select().from(siteCrawlFindings).where(and(eq(siteCrawlFindings.projectId, project.id), eq(siteCrawlFindings.runId, audit.id), eq(siteCrawlFindings.findingType, "dead-link"))).orderBy(asc(siteCrawlFindings.findingKey)).all();
|
|
780
|
+
if (!snapshot || findings.length === 0) return null;
|
|
781
|
+
const targets = findings.map((finding) => finding.targetNodeKey ?? "");
|
|
782
|
+
const sources = unique(findings.map((finding) => finding.sourceNodeKey ?? ""));
|
|
783
|
+
const tails = targets.map((target) => target.split("/").filter(Boolean).at(-1) ?? "");
|
|
784
|
+
const owners = sources.map((source) => [...facts.properties.values()].find((property) => property.pathPrefixes.some((prefix) => source.startsWith(`${prefix}/`)))?.key);
|
|
785
|
+
return {
|
|
786
|
+
state: snapshot.deadLinkState,
|
|
787
|
+
checked: snapshot.deadLinksChecked,
|
|
788
|
+
found: snapshot.deadLinksFound,
|
|
789
|
+
sources,
|
|
790
|
+
targets,
|
|
791
|
+
statusCodes: unique(findings.map((finding) => Number(finding.evidence.statusCode)).filter(Number.isFinite)),
|
|
792
|
+
sharedTarget: tails[0] && tails.every((tail) => tail === tails[0]) ? tails[0] : null,
|
|
793
|
+
onePerPropertyOffersPage: sources.length === findings.length && sources.length === facts.properties.size && sources.every((source) => source.endsWith("/offers/")) && owners.every(Boolean) && unique(owners).length === owners.length
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
function deadLinkStep(project, deadLinks, text) {
|
|
797
|
+
return step("canonry_technical_aeo_dead_links", { project: project.name }, {
|
|
798
|
+
state: deadLinks.state,
|
|
799
|
+
checked: deadLinks.checked,
|
|
800
|
+
found: deadLinks.found,
|
|
801
|
+
deadLinks: deadLinks.targets.slice(0, 2).map((target, index) => ({ sourceNodeKey: deadLinks.sources[index], targetNodeKey: target, evidence: { statusCode: deadLinks.statusCodes[0] } }))
|
|
802
|
+
}, 610, text);
|
|
803
|
+
}
|
|
804
|
+
function deadLinkDetail(deadLinks) {
|
|
805
|
+
const where = deadLinks.onePerPropertyOffersPage ? "one on each property's offers page" : `on ${deadLinks.sources.length} pages`;
|
|
806
|
+
const codes = deadLinks.statusCodes.join(" or ");
|
|
807
|
+
const what = deadLinks.sharedTarget ? `all pointing to an ${deadLinks.sharedTarget} page that returns ${codes}` : `all returning ${codes}`;
|
|
808
|
+
return `${where}, ${what}`;
|
|
809
|
+
}
|
|
810
|
+
function harborStatus(facts, deadLinks) {
|
|
811
|
+
const { project, latest } = facts;
|
|
812
|
+
const engines = enginesOf(latest);
|
|
813
|
+
const branded = ofClass(latest, "branded");
|
|
814
|
+
const nonBrand = ofClass(latest, "non-brand");
|
|
815
|
+
const queryCount = queryIdsOf(latest.answers).length;
|
|
816
|
+
const allAnswered = engines.every((provider) => latest.answers.filter((answer2) => answer2.provider === provider).length === queryCount);
|
|
817
|
+
const together = latest.answers.every((answer2) => answer2.mentioned === answer2.cited);
|
|
818
|
+
const reached = (answers) => unique(answers.filter((answer2) => answer2.mentioned).map((answer2) => answer2.targetKey)).length;
|
|
819
|
+
const propertyCount = facts.properties.size;
|
|
820
|
+
const row = (label, answers) => {
|
|
821
|
+
const counts = tally(answers);
|
|
822
|
+
return `| ${label} (${queryIdsOf(answers).length}) | ${ratio(counts.mentioned, counts.total)} | ${ratio(counts.cited, counts.total)} | ${engines.map((provider) => {
|
|
823
|
+
const engine = tally(answers.filter((answer2) => answer2.provider === provider));
|
|
824
|
+
return ratio(engine.mentioned, engine.total);
|
|
825
|
+
}).join(" | ")} |`;
|
|
826
|
+
};
|
|
827
|
+
const dropped = engines.map((provider) => ({ provider, answers: nonBrand.filter((answer2) => answer2.provider === provider) })).filter((entry) => entry.answers.length > 0 && entry.answers.every((answer2) => !answer2.mentioned));
|
|
828
|
+
const attention = [
|
|
829
|
+
...dropped.map((entry) => {
|
|
830
|
+
const instead = namedInstead(entry.answers);
|
|
831
|
+
const top = instead.sort((left, right) => right.engines.length - left.engines.length).at(0);
|
|
832
|
+
const count = top ? entry.answers.filter((answer2) => answer2.named.includes(top.label)).length : 0;
|
|
833
|
+
return `- ${engineName(entry.provider)} named Harbor on 0 of ${entry.answers.length} non-brand queries${top ? ` and named ${top.label} on ${count === entry.answers.length ? `all ${count}` : count} instead` : ""}.`;
|
|
834
|
+
}),
|
|
835
|
+
...deadLinks ? [`- The site audit found ${deadLinks.found} dead links, ${deadLinkDetail(deadLinks)}.`] : []
|
|
836
|
+
];
|
|
837
|
+
const answer = [
|
|
838
|
+
`**Latest sweep: ${formatPreviewDay(latest.createdAt)}, complete.** ${latest.expectedAnswers !== null ? `${latest.answers.length} of ${latest.expectedAnswers} expected answers came back: ` : ""}${listJoin(engines.map(engineName))} ${allAnswered ? `each answered all ${queryCount} queries` : "answered"} for ${propertyCount} properties in ${listJoin(markets(facts))}.`,
|
|
839
|
+
[
|
|
840
|
+
`| Queries | Mentioned | Cited | ${engines.map(engineName).join(" | ")} |`,
|
|
841
|
+
`|---|---|---|${engines.map(() => "---").join("|")}|`,
|
|
842
|
+
row("Branded", branded),
|
|
843
|
+
row("Non-brand", nonBrand)
|
|
844
|
+
].join("\n"),
|
|
845
|
+
`Engine columns count mentions.${together ? " In this sample every answer that names a property also cites it, so the two signals match." : ""}${reached(branded) === propertyCount && reached(nonBrand) === propertyCount ? ` All ${propertyCount} properties were named in both classes.` : ""}`,
|
|
846
|
+
...attention.length > 0 ? [`**Needs attention**
|
|
847
|
+
${attention.join("\n")}`] : [],
|
|
848
|
+
`Next: ${[
|
|
849
|
+
...dropped[0] ? [`read ${engineName(dropped[0].provider)}'s non-brand answers in one market`] : [],
|
|
850
|
+
...deadLinks ? [`remove or redirect the broken link on all ${deadLinks.sources.length} ${deadLinks.onePerPropertyOffersPage ? "offers pages" : "pages"}`] : []
|
|
851
|
+
].join(", and ") || "keep the weekly sweep running"}. ${link("Open portfolio", `/projects/${project.name}/portfolio`)}`
|
|
852
|
+
].join("\n\n");
|
|
853
|
+
const steps = [
|
|
854
|
+
step("canonry_measurement_data_quality", { project: project.name, runId: latest.id }, {
|
|
855
|
+
run: { state: "complete", displayedRunId: latest.id, planRevision: latest.planRevision, completedAt: latest.createdAt },
|
|
856
|
+
completeness: { expected: latest.expectedAnswers, answered: latest.answers.length, missing: Math.max(0, (latest.expectedAnswers ?? latest.answers.length) - latest.answers.length) },
|
|
857
|
+
comparison: { previousDisplayedRunId: facts.previous.id }
|
|
858
|
+
}, 520, "Checking that the latest sweep is complete before reading it."),
|
|
859
|
+
step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
|
|
860
|
+
selection: { queryClass: "all", run: { id: latest.id } },
|
|
861
|
+
populations: ["branded", "non-brand"].map((queryClass) => ({
|
|
862
|
+
queryClass,
|
|
863
|
+
summary: { ...summary(ofClass(latest, queryClass)), propertyReach: { numerator: reached(ofClass(latest, queryClass)), denominator: propertyCount } },
|
|
864
|
+
providerRollup: providerRollup(latest, queryClass)
|
|
865
|
+
}))
|
|
866
|
+
}, 1210, "Reading branded and non-brand separately, with the engine split.")
|
|
867
|
+
];
|
|
868
|
+
if (deadLinks) steps.push(deadLinkStep(project, deadLinks, "Checking the latest site audit for anything broken."));
|
|
869
|
+
return { id: "status", steps, answer };
|
|
870
|
+
}
|
|
871
|
+
function harborChanges(facts) {
|
|
872
|
+
const { project, latest, previous, sweeps } = facts;
|
|
873
|
+
const engines = enginesOf(latest);
|
|
874
|
+
const together = [latest, previous].every((sweep) => sweep.answers.every((answer2) => answer2.mentioned === answer2.cited));
|
|
875
|
+
const sameIdentity = latest.planRevision === previous.planRevision && latest.executionChecksum !== null && latest.executionChecksum === previous.executionChecksum;
|
|
876
|
+
const propertyMentions = (sweep, queryClass) => {
|
|
877
|
+
const counts = /* @__PURE__ */ new Map();
|
|
878
|
+
for (const answer2 of ofClass(sweep, queryClass)) if (answer2.targetKey && answer2.mentioned) counts.set(answer2.targetKey, (counts.get(answer2.targetKey) ?? 0) + 1);
|
|
879
|
+
return counts;
|
|
880
|
+
};
|
|
881
|
+
const changedProperties = (queryClass) => {
|
|
882
|
+
const now = propertyMentions(latest, queryClass);
|
|
883
|
+
const before = propertyMentions(previous, queryClass);
|
|
884
|
+
return [...facts.properties.values()].filter((property) => (now.get(property.key) ?? 0) !== (before.get(property.key) ?? 0)).map((property) => ({
|
|
885
|
+
targetKey: property.key,
|
|
886
|
+
label: property.label,
|
|
887
|
+
previous: before.get(property.key) ?? 0,
|
|
888
|
+
current: now.get(property.key) ?? 0,
|
|
889
|
+
engines: engines.length
|
|
890
|
+
}));
|
|
891
|
+
};
|
|
892
|
+
let usualMoves = 0;
|
|
893
|
+
const beyondDetail = /* @__PURE__ */ new Map();
|
|
894
|
+
const classLines = ["non-brand", "branded"].map((queryClass) => {
|
|
895
|
+
const now = tally(ofClass(latest, queryClass));
|
|
896
|
+
const before = tally(ofClass(previous, queryClass));
|
|
897
|
+
const label = queryClass === "branded" ? "Branded" : "Non-brand";
|
|
898
|
+
const perEngine = engines.map((provider) => ({
|
|
899
|
+
provider,
|
|
900
|
+
history: series(facts, queryClass, "mentioned", provider),
|
|
901
|
+
total: tally(ofClass(latest, queryClass, provider)).total
|
|
902
|
+
}));
|
|
903
|
+
const moves = perEngine.filter((entry) => entry.history.at(-1) !== entry.history.at(-2));
|
|
904
|
+
for (const entry of moves) {
|
|
905
|
+
const prior = entry.history.slice(0, -1);
|
|
906
|
+
const value = entry.history.at(-1);
|
|
907
|
+
const outside = value < Math.min(...prior) || value > Math.max(...prior);
|
|
908
|
+
if (!outside) usualMoves += 1;
|
|
909
|
+
else beyondDetail.set(`${entry.provider}:${queryClass}`, { provider: entry.provider, queryClass, history: entry.history, previousLow: Math.min(...prior), previousHigh: Math.max(...prior), total: entry.total });
|
|
910
|
+
}
|
|
911
|
+
const engineMoves = moves.map((entry) => `${engineName(entry.provider)} ${entry.history.at(-1) < entry.history.at(-2) ? "fell" : "rose"} from ${ratio(entry.history.at(-2), entry.total)} to ${ratio(entry.history.at(-1), entry.total)}`);
|
|
912
|
+
const changed = changedProperties(queryClass);
|
|
913
|
+
const totals = series(facts, queryClass, "mentioned");
|
|
914
|
+
const alternates = totals.length >= 4 && totals.every((value, index) => index < 2 || value === totals[index - 2]) && totals[0] !== totals[1];
|
|
915
|
+
let line = `**${label}:** `;
|
|
916
|
+
if (now.mentioned === before.mentioned) {
|
|
917
|
+
line += `${ratio(now.mentioned, now.total)} on both sweeps`;
|
|
918
|
+
line += engineMoves.length > 0 ? `, but the engine mix changed. ${engineMoves.join("; ")}.` : ".";
|
|
919
|
+
} else {
|
|
920
|
+
line += `${movement(now.mentioned, before.mentioned, now.total)}. ${noisePhrase(withinNoise({ n: before.mentioned, d: before.total }, { n: now.mentioned, d: now.total }))}`;
|
|
921
|
+
if (alternates) line += ` The ${label.toLowerCase()} total has alternated every sweep: ${totals.join(", ")}.`;
|
|
922
|
+
if (engineMoves.length > 0) line += ` ${engineMoves.join("; ")}.`;
|
|
923
|
+
}
|
|
924
|
+
if (changed.length === 0) {
|
|
925
|
+
line += " No property changed.";
|
|
926
|
+
} else {
|
|
927
|
+
const uniform = changed.every((property) => property.previous === changed[0].previous && property.current === changed[0].current);
|
|
928
|
+
const marketList = markets(facts);
|
|
929
|
+
const perMarket = marketList.map((market) => changed.filter((property) => facts.properties.get(property.targetKey)?.market === market).map((property) => shortPropertyLabel(facts.properties.get(property.targetKey))).sort().join("|"));
|
|
930
|
+
const who = perMarket.every((value) => value !== "" && value === perMarket[0]) ? `the ${listJoin(perMarket[0].split("|"))} in each of the ${marketList.length} markets` : listJoin(changed.map((property) => property.label));
|
|
931
|
+
line += uniform ? ` ${changed.length} ${changed.length === 1 ? "property" : "properties"} went from ${changed[0].previous} of ${engines.length} engines to ${changed[0].current} of ${engines.length}: ${who}.` : ` ${changed.length} ${changed.length === 1 ? "property" : "properties"} changed: ${who}.`;
|
|
932
|
+
}
|
|
933
|
+
return line;
|
|
934
|
+
});
|
|
935
|
+
const detail = [...beyondDetail.values()];
|
|
936
|
+
let noise = "";
|
|
937
|
+
if (detail.length > 0) {
|
|
938
|
+
noise = `**Bigger than the usual swing:** only ${listJoin(detail.map((entry) => `${engineName(entry.provider)} on ${entry.queryClass}`))}. ${detail.map((entry) => `Its series over ${sweeps.length} sweeps is ${entry.history.join(", ")}, so ${entry.history.at(-1)} is ${entry.history.at(-1) < entry.previousLow ? `below its previous low of ${entry.previousLow}` : `above its previous high of ${entry.previousHigh}`}`).join(". ")}.`;
|
|
939
|
+
if (usualMoves > 0) noise += ` Every other engine move stays inside the range that engine has swung through over ${sweeps.length} sweeps, so it is the usual swing, even though a Wilson interval at ${detail[0].total} answers would flag it.`;
|
|
940
|
+
} else {
|
|
941
|
+
noise = "Every engine stayed inside the range it has swung through before, so nothing here is bigger than the usual swing.";
|
|
942
|
+
}
|
|
943
|
+
const worst = detail.at(0);
|
|
944
|
+
const worstCompetitor = worst ? namedInstead(ofClass(latest, worst.queryClass, worst.provider))[0]?.label : void 0;
|
|
945
|
+
const answer = [
|
|
946
|
+
`**${formatPreviewDay(latest.createdAt)} vs ${formatPreviewDay(previous.createdAt)}${sameIdentity ? `: same plan revision (${latest.planRevision}) and the same engines and models on both sweeps` : ""}.**${together ? " In this sample every answer that names a property also cites it, so the citation numbers match the mention numbers below." : ""}`,
|
|
947
|
+
...classLines,
|
|
948
|
+
noise,
|
|
949
|
+
`Next: ${worst ? `confirm ${engineName(worst.provider)} ${worst.queryClass} on the next sweep before changing pages${worstCompetitor ? `; if it stays at ${worst.history.at(-1)}, compare the ${worstCompetitor} pages it cites with your market pages` : ""}` : "keep the weekly sweep running"}. ${link("Open portfolio", `/projects/${project.name}/portfolio`)}`
|
|
950
|
+
].join("\n\n");
|
|
951
|
+
const changesResult = (queryClass) => {
|
|
952
|
+
const now = tally(ofClass(latest, queryClass));
|
|
953
|
+
const before = tally(ofClass(previous, queryClass));
|
|
954
|
+
return {
|
|
955
|
+
current: { displayedRunId: latest.id, planRevision: latest.planRevision },
|
|
956
|
+
comparison: {
|
|
957
|
+
previous: { displayedRunId: previous.id, planRevision: previous.planRevision },
|
|
958
|
+
sameExecutionIdentity: sameIdentity,
|
|
959
|
+
metrics: {
|
|
960
|
+
mentionCoverage: { previous: { numerator: before.mentioned, denominator: before.total }, current: { numerator: now.mentioned, denominator: now.total } },
|
|
961
|
+
citationCoverage: { previous: { numerator: before.cited, denominator: before.total }, current: { numerator: now.cited, denominator: now.total } }
|
|
962
|
+
},
|
|
963
|
+
changedProperties: changedProperties(queryClass).map((property) => ({ label: property.label, mentioned: { previous: `${property.previous}/${property.engines}`, current: `${property.current}/${property.engines}` } }))
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
};
|
|
967
|
+
return {
|
|
968
|
+
id: "changes",
|
|
969
|
+
steps: [
|
|
970
|
+
step("canonry_measurement_changes", { project: project.name, queryClass: "non-brand" }, changesResult("non-brand"), 780, `Comparing ${formatPreviewDay(latest.createdAt)} with ${formatPreviewDay(previous.createdAt)}, non-brand first.`),
|
|
971
|
+
step("canonry_measurement_changes", { project: project.name, queryClass: "branded" }, changesResult("branded"), 690),
|
|
972
|
+
step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
|
|
973
|
+
selection: { queryClass: "all" },
|
|
974
|
+
trend: Object.fromEntries(["branded", "non-brand"].map((queryClass) => [queryClass, {
|
|
975
|
+
runs: sweeps.map((sweep) => sweep.id),
|
|
976
|
+
mentioned: series(facts, queryClass, "mentioned"),
|
|
977
|
+
byProvider: Object.fromEntries(engines.map((provider) => [provider, series(facts, queryClass, "mentioned", provider)]))
|
|
978
|
+
}]))
|
|
979
|
+
}, 1180, `Totals hide engine swings here, so reading each engine across all ${sweeps.length} sweeps.`)
|
|
980
|
+
],
|
|
981
|
+
answer
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
function harborGaps(facts) {
|
|
985
|
+
const { project, latest } = facts;
|
|
986
|
+
const engines = enginesOf(latest);
|
|
987
|
+
const groups = [...byQuery(latest.answers).values()];
|
|
988
|
+
const unnamed = groups.filter((answers) => answers.every((answer2) => !answer2.mentioned));
|
|
989
|
+
const namedCounts = groups.map((answers) => answers.filter((answer2) => answer2.mentioned).length);
|
|
990
|
+
const uniformCount = namedCounts.every((count) => count === namedCounts[0]) ? namedCounts[0] : null;
|
|
991
|
+
const cell = (market, queryClass) => {
|
|
992
|
+
const answers = ofClass(latest, queryClass).filter((answer2) => marketOf(facts, answer2) === market);
|
|
993
|
+
const queries2 = queryIdsOf(answers).length;
|
|
994
|
+
const missing2 = answers.filter((answer2) => !answer2.mentioned);
|
|
995
|
+
const byEngine = engines.map((provider) => ({ provider, answers: missing2.filter((answer2) => answer2.provider === provider) })).filter((entry) => entry.answers.length > 0);
|
|
996
|
+
const rivals = unique(missing2.flatMap((answer2) => answer2.named));
|
|
997
|
+
if (missing2.length === 0) return "none";
|
|
998
|
+
const rivalText = rivals.length > 0 ? listJoin(rivals) : "no tracked competitor";
|
|
999
|
+
if (byEngine.length === 1) return `${engineName(byEngine[0].provider)}: ${rivalText} on ${byEngine[0].answers.length === queries2 ? `all ${queries2}` : `${byEngine[0].answers.length} of ${queries2}`}`;
|
|
1000
|
+
return `${listJoin(byEngine.map((entry) => engineName(entry.provider)))}: ${rivalText} on ${byEngine.map((entry) => entry.answers.length).every((count) => count === byEngine[0].answers.length) ? `${byEngine[0].answers.length} each` : byEngine.map((entry) => `${entry.answers.length} (${engineName(entry.provider)})`).join(", ")}`;
|
|
1001
|
+
};
|
|
1002
|
+
const marketList = markets(facts);
|
|
1003
|
+
const rows = marketList.map((market) => `| ${market} | ${cell(market, "non-brand")} | ${cell(market, "branded")} |`);
|
|
1004
|
+
const topics = unique(ofClass(latest, "non-brand").map((answer2) => answer2.query.match(/\bfor (.+)$/)?.[1]).filter((topic) => Boolean(topic)));
|
|
1005
|
+
const nonBrandMissing = ofClass(latest, "non-brand").filter((answer2) => !answer2.mentioned);
|
|
1006
|
+
const nonBrandEngines = unique(nonBrandMissing.map((answer2) => answer2.provider));
|
|
1007
|
+
const nonBrandRivals = unique(nonBrandMissing.flatMap((answer2) => answer2.named));
|
|
1008
|
+
const brandedMissing = ofClass(latest, "branded").filter((answer2) => !answer2.mentioned);
|
|
1009
|
+
const brandedPattern = unique(brandedMissing.map((answer2) => answer2.provider)).sort(engineOrder).map((provider) => {
|
|
1010
|
+
const perMarket = marketList.map((market) => brandedMissing.filter((answer2) => answer2.provider === provider && marketOf(facts, answer2) === market && answer2.targetKey).map((answer2) => shortPropertyLabel(facts.properties.get(answer2.targetKey))).sort().join("|"));
|
|
1011
|
+
return perMarket.every((value) => value === perMarket[0]) && perMarket[0] ? `by ${engineName(provider)} on ${listJoin(perMarket[0].split("|"))}` : null;
|
|
1012
|
+
});
|
|
1013
|
+
const brandedRivals = unique(brandedMissing.flatMap((answer2) => answer2.named));
|
|
1014
|
+
const sentences = [];
|
|
1015
|
+
if (nonBrandMissing.length > 0 && nonBrandEngines.length === 1 && topics.length > 0) {
|
|
1016
|
+
sentences.push(`Non-brand covers ${listJoin(topics)} in each market. ${engineName(nonBrandEngines[0])} names ${listJoin(nonBrandRivals)} instead of Harbor on all of them, ${nonBrandMissing.length} in total.`);
|
|
1017
|
+
}
|
|
1018
|
+
if (brandedMissing.length > 0 && brandedPattern.every(Boolean)) {
|
|
1019
|
+
sentences.push(`On branded "reviews" queries, ${listJoin(brandedRivals)} ${brandedRivals.length === 1 ? "is" : "are"} named instead ${brandedPattern.join(", and ")}, in every market: ${brandedMissing.length} in total.`);
|
|
1020
|
+
}
|
|
1021
|
+
const answer = [
|
|
1022
|
+
unnamed.length === 0 ? `**No tracked query goes unnamed by every engine on ${formatPreviewDay(latest.createdAt)}.**${uniformCount !== null ? ` All ${groups.length} are named by ${uniformCount} of ${engines.length} engines.` : ""} The misses repeat the same way in every market:` : `**${unnamed.length} of ${groups.length} tracked queries are named by no engine on ${formatPreviewDay(latest.createdAt)}.** By market:`,
|
|
1023
|
+
['| Market | Non-brand, named instead | Branded "reviews", named instead |', "|---|---|---|", ...rows].join("\n"),
|
|
1024
|
+
...sentences,
|
|
1025
|
+
`Next: start with ${nonBrandEngines.length === 1 ? `${engineName(nonBrandEngines[0])} non-brand, since it covers every market; read one answer per market to see what ${listJoin(nonBrandRivals)} ${nonBrandRivals.length === 1 ? "is" : "are"} recommended for` : "the non-brand misses"}. ${link("Open portfolio", `/projects/${project.name}/portfolio`)}`
|
|
1026
|
+
].join("\n\n");
|
|
1027
|
+
const missing = latest.answers.filter((answer2) => !answer2.mentioned);
|
|
1028
|
+
const rollup = unique(missing.map((answer2) => `${answer2.provider}\0${answer2.queryClass}\0${answer2.named.join(",")}`)).map((key) => {
|
|
1029
|
+
const [provider, queryClass, named] = key.split("\0");
|
|
1030
|
+
const matching = missing.filter((answer2) => answer2.provider === provider && answer2.queryClass === queryClass && answer2.named.join(",") === named);
|
|
1031
|
+
return { provider, queryClass, namedInstead: named ? named.split(",") : [], answers: matching.length, markets: unique(matching.map((answer2) => marketOf(facts, answer2))).length };
|
|
1032
|
+
}).sort((left, right) => engineOrder(left.provider, right.provider));
|
|
1033
|
+
return {
|
|
1034
|
+
id: "gaps",
|
|
1035
|
+
steps: [
|
|
1036
|
+
step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
|
|
1037
|
+
selection: { queryClass: "all", run: { id: latest.id } },
|
|
1038
|
+
queries: { total: groups.length, namedByNoEngine: unnamed.length, namedByEngines: uniformCount !== null ? { [`${uniformCount}/${engines.length}`]: groups.length } : namedCounts }
|
|
1039
|
+
}, 1020, "Looking for queries no engine names Harbor on."),
|
|
1040
|
+
step("canonry_run_get", { runId: latest.id }, { id: latest.id, status: "completed", answersWithoutHarbor: missing.length, byEngine: rollup }, 760, "None, so reading who gets named on the answers that leave Harbor out.")
|
|
1041
|
+
],
|
|
1042
|
+
answer
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
function harborInsights(db, facts, deadLinks) {
|
|
1046
|
+
const { project, latest, sweeps } = facts;
|
|
1047
|
+
const active = db.select().from(insights).where(and(eq(insights.projectId, project.id), eq(insights.dismissed, false))).all();
|
|
1048
|
+
const sweepIds = new Set(sweeps.map((sweep) => sweep.id));
|
|
1049
|
+
const fromSweeps = active.filter((row) => row.runId !== null && sweepIds.has(row.runId));
|
|
1050
|
+
const engines = enginesOf(latest);
|
|
1051
|
+
const perEngine = engines.map((provider) => ({ provider, history: series(facts, "non-brand", "mentioned", provider), total: tally(ofClass(latest, "non-brand", provider)).total }));
|
|
1052
|
+
const dropped = perEngine.find((entry) => entry.history.at(-1) === 0);
|
|
1053
|
+
const droppedRival = dropped ? namedInstead(ofClass(latest, "non-brand", dropped.provider))[0]?.label : void 0;
|
|
1054
|
+
const others = perEngine.filter((entry) => entry !== dropped);
|
|
1055
|
+
const othersSwing = others.filter((entry) => Math.min(...entry.history) !== Math.max(...entry.history));
|
|
1056
|
+
const totals = series(facts, "non-brand", "mentioned");
|
|
1057
|
+
const totalDenominator = tally(ofClass(latest, "non-brand")).total;
|
|
1058
|
+
const findings = [];
|
|
1059
|
+
if (dropped) {
|
|
1060
|
+
const prior = dropped.history.slice(0, -1);
|
|
1061
|
+
findings.push(`**${engineName(dropped.provider)} stopped naming Harbor on non-brand.** It named Harbor on 0 of ${dropped.total} non-brand queries on ${formatPreviewDay(latest.createdAt)}${droppedRival ? ` and named ${droppedRival} on all ${dropped.total}` : ""}. Its previous low in ${sweeps.length} sweeps was ${Math.min(...prior)} of ${dropped.total}. Action: confirm on the next sweep, then compare the ${droppedRival ?? "competitor"} pages it cites with your market pages.`);
|
|
1062
|
+
findings.push(`**The engines swing every sweep.** ${engineName(dropped.provider)}'s non-brand series is ${dropped.history.join(", ")}${othersSwing.length > 0 ? `, and ${listJoin(othersSwing.map((entry) => engineName(entry.provider)))} move between ${Math.min(...othersSwing.flatMap((entry) => entry.history))} and ${Math.max(...othersSwing.flatMap((entry) => entry.history))} of ${othersSwing[0].total}` : ""}. Action: judge each week on the ${engines.length}-engine total, ${totals.at(-1) === totals.at(-2) ? `${totals.at(-1)} of ${totalDenominator} on both of the last two sweeps` : `${totals.at(-1)} of ${totalDenominator} now against ${totals.at(-2)} before`}, not on one engine's week.`);
|
|
1063
|
+
}
|
|
1064
|
+
if (deadLinks) {
|
|
1065
|
+
const detail = deadLinkDetail(deadLinks);
|
|
1066
|
+
findings.push(`**${deadLinks.found} dead links from the site audit.** There is ${detail}. Action: remove or redirect that link on all ${deadLinks.sources.length} pages. ${link("Open Site Health", `/projects/${project.name}/technical-aeo`)}`);
|
|
1067
|
+
}
|
|
1068
|
+
const answer = [
|
|
1069
|
+
`Only ${active.length} ${active.length === 1 ? "insight is" : "insights are"} stored for ${project.displayName}${fromSweeps.length === 0 ? `, and ${active.length === 2 ? "neither" : "none"} comes from a sweep` : ""}, so there is no severity ranking worth walking through. These are the ${findings.length} most important measured findings instead:`,
|
|
1070
|
+
findings.map((finding, index) => `${index + 1}. ${finding}`).join("\n"),
|
|
1071
|
+
`Next: ${deadLinks ? "fix the dead links now, since they need no sweep to confirm, and " : ""}${dropped ? `recheck ${engineName(dropped.provider)} non-brand after the next sweep` : "keep the weekly sweep running"}.`
|
|
1072
|
+
].join("\n\n");
|
|
1073
|
+
const steps = [
|
|
1074
|
+
step("canonry_insights_list", { project: project.name }, { active: active.length, fromSweeps: fromSweeps.length, fromLatestSweep: fromSweeps.filter((row) => row.runId === latest.id).length }, 430, "Listing active insights."),
|
|
1075
|
+
step("canonry_visibility_report", { project: project.name, queryClass: "non-brand" }, {
|
|
1076
|
+
selection: { queryClass: "non-brand", run: { id: latest.id } },
|
|
1077
|
+
summary: summary(ofClass(latest, "non-brand")),
|
|
1078
|
+
trend: { runs: sweeps.map((sweep) => sweep.id), byProvider: Object.fromEntries(perEngine.map((entry) => [entry.provider, entry.history])) },
|
|
1079
|
+
...dropped && droppedRival ? { namedInstead: { [dropped.provider]: { [droppedRival]: ofClass(latest, "non-brand", dropped.provider).filter((answer2) => answer2.named.includes(droppedRival)).length } } } : {}
|
|
1080
|
+
}, 1090, "Too few stored insights to rank, so reading the measured non-brand picture instead.")
|
|
1081
|
+
];
|
|
1082
|
+
if (deadLinks) steps.push(deadLinkStep(project, deadLinks, "And the latest site audit."));
|
|
1083
|
+
return { id: "insights", steps, answer };
|
|
1084
|
+
}
|
|
1085
|
+
function buildDemoAeroPreviews(db, context) {
|
|
1086
|
+
const seededAt = context.now.toISOString();
|
|
1087
|
+
const previews = /* @__PURE__ */ new Map();
|
|
1088
|
+
const summit = loadFacts(db, context.simple);
|
|
1089
|
+
previews.set(context.simple.name, aeroPreviewResponseSchema.parse({
|
|
1090
|
+
project: context.simple.name,
|
|
1091
|
+
seededAt,
|
|
1092
|
+
starters: [summitStatus(summit), summitChanges(summit), summitGaps(summit), summitInsights(db, summit)]
|
|
1093
|
+
}));
|
|
1094
|
+
const harbor = loadFacts(db, context.portfolio);
|
|
1095
|
+
const deadLinks = loadDeadLinks(db, harbor);
|
|
1096
|
+
previews.set(context.portfolio.name, aeroPreviewResponseSchema.parse({
|
|
1097
|
+
project: context.portfolio.name,
|
|
1098
|
+
seededAt,
|
|
1099
|
+
starters: [harborStatus(harbor, deadLinks), harborChanges(harbor), harborGaps(harbor), harborInsights(db, harbor, deadLinks)]
|
|
1100
|
+
}));
|
|
1101
|
+
return previews;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
113
1104
|
// src/demo/http.ts
|
|
114
1105
|
import { createHash, randomUUID } from "crypto";
|
|
115
1106
|
import { existsSync, readFileSync } from "fs";
|
|
116
1107
|
import { join } from "path";
|
|
117
|
-
import { desc, eq } from "drizzle-orm";
|
|
1108
|
+
import { desc as desc2, eq as eq2 } from "drizzle-orm";
|
|
118
1109
|
import Fastify from "fastify";
|
|
119
1110
|
import fastifyStatic from "@fastify/static";
|
|
120
1111
|
import rateLimit from "@fastify/rate-limit";
|
|
@@ -139,6 +1130,8 @@ var DEMO_READ_ROUTES = /* @__PURE__ */ new Set([
|
|
|
139
1130
|
"/api/v1/projects/:name/ads/operations/:operationKey",
|
|
140
1131
|
"/api/v1/projects/:name/ads/status",
|
|
141
1132
|
"/api/v1/projects/:name/ads/summary",
|
|
1133
|
+
// Scripted Aero preview built from seeded rows; the live agent routes stay closed.
|
|
1134
|
+
"/api/v1/projects/:name/agent/preview",
|
|
142
1135
|
"/api/v1/projects/:name/analytics/competitors",
|
|
143
1136
|
"/api/v1/projects/:name/analytics/gaps",
|
|
144
1137
|
"/api/v1/projects/:name/analytics/metrics",
|
|
@@ -375,9 +1368,9 @@ async function createDemoHttpServer(options) {
|
|
|
375
1368
|
reply.header("Cache-Control", "no-store");
|
|
376
1369
|
if (route === "/api/v1/projects/:name/bing/coverage") {
|
|
377
1370
|
const name = request.params.name;
|
|
378
|
-
const project = db.select().from(projects).where(
|
|
1371
|
+
const project = db.select().from(projects).where(eq2(projects.name, name)).get();
|
|
379
1372
|
if (!project) return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Project not found." } });
|
|
380
|
-
const rows = db.select().from(bingUrlInspections).where(
|
|
1373
|
+
const rows = db.select().from(bingUrlInspections).where(eq2(bingUrlInspections.projectId, project.id)).orderBy(desc2(bingUrlInspections.inspectedAt)).all();
|
|
381
1374
|
const latest = /* @__PURE__ */ new Map();
|
|
382
1375
|
for (const row of rows) if (!latest.has(row.url)) latest.set(row.url, row);
|
|
383
1376
|
const format = (row) => ({ url: row.url, httpCode: row.httpCode, inIndex: row.inIndex, lastCrawledDate: row.lastCrawledDate, inIndexDate: row.inIndexDate, inspectedAt: row.inspectedAt, documentSize: row.documentSize, anchorCount: row.anchorCount, discoveryDate: row.discoveryDate });
|
|
@@ -389,9 +1382,9 @@ async function createDemoHttpServer(options) {
|
|
|
389
1382
|
}
|
|
390
1383
|
if (route === "/api/v1/projects/:name/bing/performance") {
|
|
391
1384
|
const name = request.params.name;
|
|
392
|
-
const project = db.select().from(projects).where(
|
|
1385
|
+
const project = db.select().from(projects).where(eq2(projects.name, name)).get();
|
|
393
1386
|
if (!project) return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Project not found." } });
|
|
394
|
-
return reply.send(db.select().from(bingKeywordStats).where(
|
|
1387
|
+
return reply.send(db.select().from(bingKeywordStats).where(eq2(bingKeywordStats.projectId, project.id)).limit(200).all().map((row) => ({
|
|
395
1388
|
query: row.query,
|
|
396
1389
|
impressions: row.impressions,
|
|
397
1390
|
clicks: row.clicks,
|
|
@@ -411,6 +1404,12 @@ async function createDemoHttpServer(options) {
|
|
|
411
1404
|
app.get("/health", async () => ({ status: "ok", service: "canonry-demo", version: PACKAGE_VERSION, demo: true, workerEnabled: false }));
|
|
412
1405
|
app.get("/api/v1/session", async () => ({ authenticated: true, setupRequired: false }));
|
|
413
1406
|
app.get("/api/v1/demo", async () => ({ mode: "view-only", sampleData: true, seededAt: now.toISOString() }));
|
|
1407
|
+
const aeroPreviews = options.aeroPreviews ?? /* @__PURE__ */ new Map();
|
|
1408
|
+
app.get("/api/v1/projects/:name/agent/preview", async (request, reply) => {
|
|
1409
|
+
const preview = aeroPreviews.get(request.params.name);
|
|
1410
|
+
if (!preview) return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Project not found." } });
|
|
1411
|
+
return reply.send(preview);
|
|
1412
|
+
});
|
|
414
1413
|
await app.register(apiRoutes, { db, skipAuth: true, ...options.readOptions });
|
|
415
1414
|
await app.register(fastifyStatic, {
|
|
416
1415
|
root: join(assetsDir, "assets"),
|
|
@@ -646,7 +1645,7 @@ function seedProjectMarketing(db, project, now) {
|
|
|
646
1645
|
}
|
|
647
1646
|
|
|
648
1647
|
// src/demo/seed-exploration.ts
|
|
649
|
-
import { and, eq as
|
|
1648
|
+
import { and as and2, eq as eq3 } from "drizzle-orm";
|
|
650
1649
|
function seedDemoExploration(db, context) {
|
|
651
1650
|
const createdAt = context.now.toISOString();
|
|
652
1651
|
for (const project of [context.simple, context.portfolio]) {
|
|
@@ -674,7 +1673,7 @@ function seedDemoExploration(db, context) {
|
|
|
674
1673
|
}
|
|
675
1674
|
}
|
|
676
1675
|
function seedPageAudits(db, projectId, runId, root, createdAt) {
|
|
677
|
-
const crawlPages = db.select().from(siteCrawlPages).where(
|
|
1676
|
+
const crawlPages = db.select().from(siteCrawlPages).where(and2(eq3(siteCrawlPages.projectId, projectId), eq3(siteCrawlPages.runId, runId))).all();
|
|
678
1677
|
const audited = crawlPages.filter((page) => page.auditState === "success" && page.auditScore !== null);
|
|
679
1678
|
const pages = audited.map((page, index) => {
|
|
680
1679
|
const factors2 = siteAuditPageFactorSchema.array().parse(page.auditFields.factors);
|
|
@@ -744,10 +1743,10 @@ function harborProperties() {
|
|
|
744
1743
|
}
|
|
745
1744
|
|
|
746
1745
|
// src/demo/seed-answer-intelligence.ts
|
|
747
|
-
import { and as
|
|
1746
|
+
import { and as and3, asc as asc2, eq as eq4 } from "drizzle-orm";
|
|
748
1747
|
function seedDemoAnswerIntelligence(db, project) {
|
|
749
1748
|
const service2 = new IntelligenceService(db);
|
|
750
|
-
const sweeps = db.select().from(runs).where(
|
|
1749
|
+
const sweeps = db.select().from(runs).where(and3(eq4(runs.projectId, project.id), eq4(runs.kind, RunKinds["answer-visibility"]))).orderBy(asc2(runs.createdAt)).all();
|
|
751
1750
|
for (const [index, sweep] of sweeps.entries()) {
|
|
752
1751
|
const previous = index > 0 ? sweeps[index - 1] : null;
|
|
753
1752
|
const history = sweeps.slice(Math.max(0, index - (HISTORY_WINDOW_RUNS - 1)), index + 1);
|
|
@@ -787,123 +1786,6 @@ function seedDemoAnswerIntelligence(db, project) {
|
|
|
787
1786
|
}
|
|
788
1787
|
}
|
|
789
1788
|
|
|
790
|
-
// src/demo/summit-sweeps.ts
|
|
791
|
-
var SUMMIT_SWEEP_COUNT = 6;
|
|
792
|
-
var SUMMIT_ENGINES = ["openai", "gemini", "claude"];
|
|
793
|
-
var RIVALS = {
|
|
794
|
-
roofcraft: { name: "RoofCraft", domain: "roofcraft.example", path: "/roof-replacement/", blurb: "A replacement contractor known for metal and shingle installs." },
|
|
795
|
-
everlast: { name: "Everlast Roofing", domain: "everlast-roofing.example", path: "/services/", blurb: "A repair and gutter company with a large emergency crew." }
|
|
796
|
-
};
|
|
797
|
-
var SUMMIT_COMPETITORS = Object.values(RIVALS).map((rival) => ({ domain: rival.domain, label: rival.name }));
|
|
798
|
-
var SUMMIT_QUERIES = [
|
|
799
|
-
{ key: "reviews", text: "Summit Roofing reviews", pages: ["/reviews/"], rivals: [], source: "https://home-services-reviews.example/roofers/", lead: "Recent homeowner reviews are mostly positive.", blurb: "Reviewers mention clear estimates, tidy crews and quick follow-up on warranty visits." },
|
|
800
|
-
{ key: "near-me", text: "roof repair contractor near me", pages: ["/services/roof-repair/", "/service-areas/"], rivals: ["everlast", "roofcraft"], source: "https://local-contractor-directory.example/roofing/", lead: "Several roofers in the area handle repair work.", blurb: "A local roofer covering leak repairs, storm damage and inspections across two dozen towns." },
|
|
801
|
-
{ key: "emergency-leak", text: "emergency roof leak repair", pages: ["/services/roof-repair/leak-repair/", "/services/roof-repair/emergency-tarping/"], rivals: ["everlast"], source: "https://homeowner-guides.example/roof-leaks/", lead: "For an active leak, look for a crew that can tarp the roof the same day.", blurb: "Offers emergency tarping and leak repair, with same-day visits in its service area." },
|
|
802
|
-
{ key: "metal-installer", text: "best metal roof installer", pages: ["/services/roof-replacement/metal-roofing/", "/guides/metal-vs-asphalt-shingles/"], rivals: ["roofcraft"], source: "https://metal-roofing-council.example/find-an-installer/", lead: "Metal roofs need an installer with standing-seam experience.", blurb: "Installs standing-seam and metal shingle roofs and publishes a metal versus asphalt comparison." },
|
|
803
|
-
{ key: "warranty", text: "Summit Roofing warranty", pages: ["/about/warranty/"], rivals: [], source: "https://homeowner-guides.example/roof-warranties/", lead: "Roofing warranties usually combine manufacturer and workmanship coverage.", blurb: "Backs replacements with a written workmanship warranty on top of the manufacturer coverage." },
|
|
804
|
-
{ key: "replacement-cost", text: "how much does a roof replacement cost", pages: ["/guides/how-much-does-a-roof-replacement-cost/", "/financing/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/roof-replacement/", lead: "Most full replacements land between 9,000 and 18,000 dollars, depending on size and material.", blurb: "Publishes a cost guide with typical price ranges and offers financing on full replacements." },
|
|
805
|
-
{ key: "storm-claims", text: "storm damage roof repair and insurance claims", pages: ["/services/roof-repair/storm-damage/", "/guides/filing-a-roof-insurance-claim/"], rivals: ["everlast"], source: "https://homeowner-guides.example/storm-claims/", lead: "Photograph the damage before any repair and call your insurer early.", blurb: "Documents storm damage for insurance claims and joins the adjuster visit." },
|
|
806
|
-
{ key: "asphalt-shingles", text: "asphalt shingle roof replacement", pages: ["/services/roof-replacement/asphalt-shingles/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/asphalt-shingles/", lead: "Architectural shingles are the most common replacement choice.", blurb: "Replaces asphalt shingle roofs with a choice of architectural shingle lines." },
|
|
807
|
-
{ key: "seamless-gutters", text: "seamless gutter installation", pages: ["/services/gutters/seamless-gutters/"], rivals: ["everlast"], source: "https://homeowner-guides.example/gutters/", lead: "Seamless gutters are formed on site, so they leak less at the joints.", blurb: "Installs seamless gutters and gutter guards, often alongside a roof replacement." },
|
|
808
|
-
{ key: "pre-purchase-inspection", text: "roof inspection before buying a house", pages: ["/services/roof-inspection/real-estate-inspection/", "/guides/real-estate-roof-inspections/"], rivals: ["roofcraft", "everlast"], source: "https://home-buyer-checklist.example/roof-inspection/", lead: "A roof inspection before closing can surface repairs to negotiate.", blurb: "Runs pre-purchase roof inspections with photo reports for buyers and agents." }
|
|
809
|
-
];
|
|
810
|
-
var SUMMIT_BRANDED_QUERY_KEYS = ["reviews", "warranty"];
|
|
811
|
-
var SUMMIT_ENGINE_PLANS = {
|
|
812
|
-
// Climbs steadily.
|
|
813
|
-
openai: {
|
|
814
|
-
mentioned: [2, 3, 3, 4, 5, 6],
|
|
815
|
-
mentionOrder: ["emergency-leak", "replacement-cost", "near-me", "storm-claims", "pre-purchase-inspection", "seamless-gutters", "asphalt-shingles", "metal-installer"],
|
|
816
|
-
cited: [1, 2, 2, 3, 4, 5],
|
|
817
|
-
citationOrder: ["replacement-cost", "storm-claims", "pre-purchase-inspection", "emergency-leak", "asphalt-shingles", "metal-installer", "seamless-gutters", "near-me"]
|
|
818
|
-
},
|
|
819
|
-
// Steady, then a dip and a recovery.
|
|
820
|
-
gemini: {
|
|
821
|
-
mentioned: [4, 4, 4, 2, 3, 5],
|
|
822
|
-
mentionOrder: ["near-me", "emergency-leak", "replacement-cost", "storm-claims", "seamless-gutters", "pre-purchase-inspection", "metal-installer", "asphalt-shingles"],
|
|
823
|
-
cited: [3, 3, 3, 1, 2, 3],
|
|
824
|
-
citationOrder: ["replacement-cost", "pre-purchase-inspection", "storm-claims", "metal-installer", "emergency-leak", "asphalt-shingles", "seamless-gutters", "near-me"]
|
|
825
|
-
},
|
|
826
|
-
// Swings from week to week.
|
|
827
|
-
claude: {
|
|
828
|
-
mentioned: [3, 1, 4, 2, 4, 3],
|
|
829
|
-
mentionOrder: ["emergency-leak", "replacement-cost", "storm-claims", "near-me", "pre-purchase-inspection", "asphalt-shingles", "seamless-gutters", "metal-installer"],
|
|
830
|
-
cited: [2, 1, 2, 1, 3, 2],
|
|
831
|
-
citationOrder: ["pre-purchase-inspection", "replacement-cost", "storm-claims", "emergency-leak", "asphalt-shingles", "near-me", "metal-installer", "seamless-gutters"]
|
|
832
|
-
}
|
|
833
|
-
};
|
|
834
|
-
var BRANDED_PLAN = {
|
|
835
|
-
reviews: {
|
|
836
|
-
openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
|
|
837
|
-
gemini: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
|
|
838
|
-
claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, false, false, false, false] }
|
|
839
|
-
},
|
|
840
|
-
warranty: {
|
|
841
|
-
openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
|
|
842
|
-
gemini: { mentioned: [true, true, true, false, true, true], cited: [true, true, true, false, true, true] },
|
|
843
|
-
claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, true, false, true, true] }
|
|
844
|
-
}
|
|
845
|
-
};
|
|
846
|
-
function isBranded(key) {
|
|
847
|
-
return SUMMIT_BRANDED_QUERY_KEYS.includes(key);
|
|
848
|
-
}
|
|
849
|
-
function summitAnswerPlan(sweep, engine, queryKey) {
|
|
850
|
-
if (isBranded(queryKey)) {
|
|
851
|
-
const plan2 = BRANDED_PLAN[queryKey][engine];
|
|
852
|
-
return { mentioned: plan2.mentioned[sweep], cited: plan2.cited[sweep] };
|
|
853
|
-
}
|
|
854
|
-
const plan = SUMMIT_ENGINE_PLANS[engine];
|
|
855
|
-
return {
|
|
856
|
-
mentioned: plan.mentionOrder.slice(0, plan.mentioned[sweep]).includes(queryKey),
|
|
857
|
-
cited: plan.citationOrder.slice(0, plan.cited[sweep]).includes(queryKey)
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
function summitAnswer(input) {
|
|
861
|
-
const { sweep, engine, queryIndex, displayName, domain } = input;
|
|
862
|
-
const query = SUMMIT_QUERIES[queryIndex];
|
|
863
|
-
const engineIndex = SUMMIT_ENGINES.indexOf(engine);
|
|
864
|
-
const plan = summitAnswerPlan(sweep, engine, query.key);
|
|
865
|
-
const rivals = plan.mentioned ? (sweep + engineIndex + queryIndex) % 2 === 0 ? query.rivals.slice(0, 1) : [] : query.rivals;
|
|
866
|
-
const entries = [
|
|
867
|
-
...plan.mentioned ? [{ name: displayName, blurb: query.blurb }] : [],
|
|
868
|
-
...rivals.map((key) => ({ name: RIVALS[key].name, blurb: RIVALS[key].blurb }))
|
|
869
|
-
];
|
|
870
|
-
const answerText = isBranded(query.key) && !plan.mentioned ? `Fictional answer.
|
|
871
|
-
|
|
872
|
-
I could not find published details for that company. ${query.lead} Most residential roofers offer 5 to 10 years of workmanship coverage.` : [
|
|
873
|
-
"Fictional answer.",
|
|
874
|
-
query.lead,
|
|
875
|
-
entries.map((entry, index) => `${index + 1}. **${entry.name}**: ${entry.blurb}`).join("\n"),
|
|
876
|
-
"Get two or three written estimates before you commit."
|
|
877
|
-
].join("\n\n");
|
|
878
|
-
const citedRivals = rivals.filter((_, index) => (sweep + engineIndex + queryIndex + index) % 3 !== 0);
|
|
879
|
-
const citedUrls = [
|
|
880
|
-
...plan.cited ? [`https://${domain}${query.pages[(sweep + engineIndex) % query.pages.length]}`] : [],
|
|
881
|
-
...citedRivals.map((key) => `https://${RIVALS[key].domain}${RIVALS[key].path}`),
|
|
882
|
-
query.source
|
|
883
|
-
];
|
|
884
|
-
const citedDomains = citedUrls.map((url) => new URL(url).hostname);
|
|
885
|
-
const normalized = {
|
|
886
|
-
provider: engine,
|
|
887
|
-
answerText,
|
|
888
|
-
citedDomains,
|
|
889
|
-
groundingSources: citedUrls.map((uri) => ({ uri, title: "" })),
|
|
890
|
-
searchQueries: [query.text],
|
|
891
|
-
retrievalStatus: "used"
|
|
892
|
-
};
|
|
893
|
-
const domains = effectiveDomains({ canonicalDomain: domain, ownedDomains: [domain] });
|
|
894
|
-
const brandNames = effectiveBrandNames({ displayName, canonicalDomain: domain, ownedDomains: [domain] });
|
|
895
|
-
const competitorDomains = SUMMIT_COMPETITORS.map((competitor) => competitor.domain);
|
|
896
|
-
return {
|
|
897
|
-
answerText,
|
|
898
|
-
answerMentioned: determineAnswerMentioned(answerText, brandNames, domains),
|
|
899
|
-
citationState: determineCitationState(normalized, domains),
|
|
900
|
-
citedDomains,
|
|
901
|
-
citedUrls,
|
|
902
|
-
competitorOverlap: computeCompetitorOverlap(normalized, competitorDomains),
|
|
903
|
-
recommendedCompetitors: extractRecommendedCompetitors(answerText, domains, citedDomains, competitorDomains, brandNames)
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
|
-
|
|
907
1789
|
// src/demo/seed-core.ts
|
|
908
1790
|
var PROVIDERS = [
|
|
909
1791
|
{ provider: "openai", requestedModel: "gpt-5-demo", servedModel: "gpt-5-demo-2026-08-15" },
|
|
@@ -1731,7 +2613,7 @@ var guide = (slug) => `/guides/${slug}/`;
|
|
|
1731
2613
|
function summitRoofingInventory() {
|
|
1732
2614
|
const pages = [];
|
|
1733
2615
|
const links = [];
|
|
1734
|
-
const
|
|
2616
|
+
const link2 = (source, ...targets) => {
|
|
1735
2617
|
for (const target of targets) links.push([source, target]);
|
|
1736
2618
|
};
|
|
1737
2619
|
const projects2 = Array.from({ length: 36 }, (_, index) => {
|
|
@@ -1744,7 +2626,7 @@ function summitRoofingInventory() {
|
|
|
1744
2626
|
{ path: "/llms.txt", kind: "resource", contentType: "text/plain; charset=utf-8", sitemap: true },
|
|
1745
2627
|
{ path: "/services/", kind: "html", score: 84 }
|
|
1746
2628
|
);
|
|
1747
|
-
|
|
2629
|
+
link2(
|
|
1748
2630
|
"/",
|
|
1749
2631
|
...SERVICES.map(([key]) => service(key)),
|
|
1750
2632
|
"/service-areas/",
|
|
@@ -1756,12 +2638,12 @@ function summitRoofingInventory() {
|
|
|
1756
2638
|
"/financing/",
|
|
1757
2639
|
"/get-a-quote/"
|
|
1758
2640
|
);
|
|
1759
|
-
|
|
2641
|
+
link2("/services/", ...SERVICES.map(([key]) => service(key)), "/service-areas/");
|
|
1760
2642
|
for (const [categoryIndex, [category, subs]] of SERVICES.entries()) {
|
|
1761
2643
|
pages.push({ path: service(category), kind: "html", score: 86 - categoryIndex * 2 });
|
|
1762
2644
|
const categoryGuides = GUIDES.filter(([, key]) => key === category);
|
|
1763
2645
|
const categoryProjects = projects2.filter((project) => project.category === category);
|
|
1764
|
-
|
|
2646
|
+
link2(
|
|
1765
2647
|
service(category),
|
|
1766
2648
|
...subs.map((sub) => service(`${category}/${sub}`)),
|
|
1767
2649
|
"/get-a-quote/",
|
|
@@ -1773,17 +2655,17 @@ function summitRoofingInventory() {
|
|
|
1773
2655
|
const path = service(`${category}/${sub}`);
|
|
1774
2656
|
pages.push({ path, kind: "html", score: 83 - (subIndex * 7 + categoryIndex * 3) % 17 });
|
|
1775
2657
|
const project = categoryProjects.find((candidate) => candidate.sub === sub) ?? categoryProjects.at(0);
|
|
1776
|
-
|
|
1777
|
-
if (project)
|
|
2658
|
+
link2(path, service(category), service(`${category}/${subs[(subIndex + 1) % subs.length]}`), "/get-a-quote/");
|
|
2659
|
+
if (project) link2(path, project.path);
|
|
1778
2660
|
const related = categoryGuides.at(subIndex % Math.max(1, categoryGuides.length));
|
|
1779
|
-
if (related)
|
|
2661
|
+
if (related) link2(path, guide(related[0]));
|
|
1780
2662
|
}
|
|
1781
2663
|
}
|
|
1782
2664
|
pages.push({ path: "/service-areas/", kind: "html", score: 78 });
|
|
1783
|
-
|
|
2665
|
+
link2("/service-areas/", ...TOWNS.map(town));
|
|
1784
2666
|
for (const [townIndex, townKey] of TOWNS.entries()) {
|
|
1785
2667
|
pages.push({ path: town(townKey), kind: "html", score: 76 - townIndex % 6 * 2, factorScores: { "geographic-signals": 92 } });
|
|
1786
|
-
|
|
2668
|
+
link2(
|
|
1787
2669
|
town(townKey),
|
|
1788
2670
|
service("roof-replacement"),
|
|
1789
2671
|
service("roof-repair"),
|
|
@@ -1799,16 +2681,16 @@ function summitRoofingInventory() {
|
|
|
1799
2681
|
const path = `${town(townKey)}${serviceKey}/`;
|
|
1800
2682
|
const thin = THIN_TOWN_SERVICES.has(path);
|
|
1801
2683
|
pages.push({ path, kind: "html", score: thin ? 58 : 72, factorScores: thin ? { "content-depth": 34, "geographic-signals": 88 } : { "geographic-signals": 94 } });
|
|
1802
|
-
|
|
1803
|
-
if (path !== ORPHAN_TOWN_SERVICE)
|
|
2684
|
+
link2(path, town(townKey), service(serviceKey), "/get-a-quote/");
|
|
2685
|
+
if (path !== ORPHAN_TOWN_SERVICE) link2(town(townKey), path);
|
|
1804
2686
|
}
|
|
1805
2687
|
}
|
|
1806
2688
|
pages.push({ path: "/projects/", kind: "html", score: 80 });
|
|
1807
|
-
|
|
2689
|
+
link2("/projects/", ...projects2.map((project) => project.path));
|
|
1808
2690
|
for (const project of projects2) {
|
|
1809
2691
|
const thin = THIN_PROJECTS.has(project.index);
|
|
1810
2692
|
pages.push({ path: project.path, kind: "html", score: thin ? 60 : 77 - project.index % 7 * 2, factorScores: thin ? { "content-depth": 31 } : void 0 });
|
|
1811
|
-
|
|
2693
|
+
link2(
|
|
1812
2694
|
project.path,
|
|
1813
2695
|
service(project.category),
|
|
1814
2696
|
service(`${project.category}/${project.sub}`),
|
|
@@ -1825,21 +2707,21 @@ function summitRoofingInventory() {
|
|
|
1825
2707
|
{ path: "/guides/topic/", kind: "noindex", score: 70 },
|
|
1826
2708
|
{ path: "/guides/roof-maintenance-checklist.pdf", kind: "resource", contentType: "application/pdf" }
|
|
1827
2709
|
);
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2710
|
+
link2("/guides/", ...GUIDES.slice(0, 20).map(([slug]) => guide(slug)), "/guides/page/2/", "/guides/topic/");
|
|
2711
|
+
link2("/guides/page/2/", ...GUIDES.slice(20, 40).map(([slug]) => guide(slug)), "/guides/", "/guides/page/3/");
|
|
2712
|
+
link2("/guides/page/3/", ...GUIDES.slice(40).map(([slug]) => guide(slug)), "/guides/page/2/");
|
|
2713
|
+
link2("/guides/topic/", ...GUIDE_TOPICS2.map((topic) => `/guides/topic/${topic}/`));
|
|
1832
2714
|
for (const topic of GUIDE_TOPICS2) {
|
|
1833
2715
|
pages.push({ path: `/guides/topic/${topic}/`, kind: "noindex", score: 68 });
|
|
1834
|
-
|
|
2716
|
+
link2(`/guides/topic/${topic}/`, "/guides/", ...GUIDES.filter(([, , guideTopic]) => guideTopic === topic).map(([slug]) => guide(slug)));
|
|
1835
2717
|
}
|
|
1836
2718
|
for (const [guideIndex, [slug, serviceKey, topic]] of GUIDES.entries()) {
|
|
1837
2719
|
const noAuthor = guideIndex % 5 === 4;
|
|
1838
2720
|
pages.push({ path: guide(slug), kind: "html", score: 81 - guideIndex % 6 * 3, factorScores: noAuthor ? { "eeat-signals": 28 } : void 0 });
|
|
1839
|
-
|
|
2721
|
+
link2(guide(slug), service(serviceKey), guide(GUIDES[(guideIndex + 1) % GUIDES.length][0]), `/guides/topic/${topic}/`, "/get-a-quote/");
|
|
1840
2722
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
2723
|
+
link2(guide(GUIDES[18][0]), "/guides/roof-maintenance-checklist.pdf");
|
|
2724
|
+
link2(guide("what-a-roof-warranty-covers"), "/about/warranty/");
|
|
1843
2725
|
pages.push(
|
|
1844
2726
|
{ path: "/financing/", kind: "html", score: 79 },
|
|
1845
2727
|
{ path: "/financing/apply/", kind: "html", score: 71 },
|
|
@@ -1867,19 +2749,19 @@ function summitRoofingInventory() {
|
|
|
1867
2749
|
{ path: "/terms/", kind: "html", score: 63 },
|
|
1868
2750
|
{ path: "/accessibility/", kind: "html", score: 69 }
|
|
1869
2751
|
);
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
for (const role of ["roofing-crew-lead", "project-manager", "sales-estimator"])
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
2752
|
+
link2("/financing/", "/financing/apply/", "/financing/faq/", "/financing/financing-terms.pdf", "/get-a-quote/");
|
|
2753
|
+
link2("/financing/faq/", "/financing/apply/");
|
|
2754
|
+
link2("/reviews/", "/reviews/leave-a-review/", ...projects2.slice(0, 6).map((project) => project.path));
|
|
2755
|
+
link2("/about/", "/about/team/", "/about/certifications/", "/about/warranty/", "/about/community/", "/reviews/");
|
|
2756
|
+
link2("/about/warranty/", "/about/warranty/warranty-guide.pdf", guide("what-a-roof-warranty-covers"));
|
|
2757
|
+
link2("/about/certifications/", service("roof-replacement"));
|
|
2758
|
+
link2("/about/community/", "/projects/");
|
|
2759
|
+
link2("/careers/", "/careers/roofing-crew-lead/", "/careers/project-manager/", "/careers/sales-estimator/");
|
|
2760
|
+
for (const role of ["roofing-crew-lead", "project-manager", "sales-estimator"]) link2(`/careers/${role}/`, "/careers/");
|
|
2761
|
+
link2("/contact/", "/contact/thank-you/");
|
|
2762
|
+
link2("/get-a-quote/", "/get-a-quote/roof-details/", "/financing/");
|
|
2763
|
+
link2("/get-a-quote/roof-details/", "/get-a-quote/schedule/");
|
|
2764
|
+
link2("/get-a-quote/schedule/", "/get-a-quote/thank-you/");
|
|
1883
2765
|
pages.push(
|
|
1884
2766
|
{ path: "/roof-repair/", kind: "redirect", redirectTo: service("roof-repair") },
|
|
1885
2767
|
{ path: "/blog/", kind: "redirect", redirectTo: "/guides/" },
|
|
@@ -1888,32 +2770,32 @@ function summitRoofingInventory() {
|
|
|
1888
2770
|
{ path: "/service-areas/old-county/", kind: "redirect", redirectTo: "/service-areas/" },
|
|
1889
2771
|
{ path: "/free-estimate/", kind: "redirect", redirectTo: "/get-a-quote/" }
|
|
1890
2772
|
);
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2773
|
+
link2(service("roof-repair/leak-repair"), "/roof-repair/");
|
|
2774
|
+
link2(guide("how-to-spot-a-roof-leak"), "/roof-repair/");
|
|
2775
|
+
link2(guide("signs-you-need-a-new-roof"), "/blog/");
|
|
2776
|
+
link2(guide("roof-repair-vs-replacement"), "/blog/");
|
|
2777
|
+
link2(guide("metal-vs-asphalt-shingles"), "/services/metal-roofs/");
|
|
2778
|
+
link2(guide("winter-roof-care"), "/guides/ice-dam-prevention-2023/");
|
|
2779
|
+
link2(town(TOWNS.at(-1)), "/service-areas/old-county/");
|
|
2780
|
+
link2(projects2[0].path, "/free-estimate/");
|
|
2781
|
+
link2("/financing/faq/", "/free-estimate/");
|
|
1900
2782
|
pages.push(
|
|
1901
2783
|
{ path: "/careers/estimator-2025/", kind: "broken" },
|
|
1902
2784
|
{ path: "/financing/spring-promo/", kind: "broken" },
|
|
1903
2785
|
{ path: "/guides/hail-season-update/", kind: "broken" },
|
|
1904
2786
|
{ path: `/projects/${TOWNS[2]}-reroof-draft/`, kind: "broken" }
|
|
1905
2787
|
);
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2788
|
+
link2("/careers/", "/careers/estimator-2025/");
|
|
2789
|
+
link2("/financing/", "/financing/spring-promo/");
|
|
2790
|
+
link2(guide("what-to-do-after-hail-damage"), "/guides/hail-season-update/");
|
|
2791
|
+
link2(guide("storm-season-preparation"), "/guides/hail-season-update/");
|
|
2792
|
+
link2("/projects/", `/projects/${TOWNS[2]}-reroof-draft/`);
|
|
1911
2793
|
pages.push(
|
|
1912
2794
|
{ path: "/spring-roof-special/", kind: "html", score: 52, factorScores: { "content-freshness": 18 } },
|
|
1913
2795
|
{ path: "/guides/choosing-roof-color/", kind: "html", score: 74 }
|
|
1914
2796
|
);
|
|
1915
|
-
|
|
1916
|
-
|
|
2797
|
+
link2("/spring-roof-special/", "/get-a-quote/");
|
|
2798
|
+
link2("/guides/choosing-roof-color/", service("roof-replacement"));
|
|
1917
2799
|
return { pages, templateLinks: [...HEADER, ...FOOTER], links };
|
|
1918
2800
|
}
|
|
1919
2801
|
|
|
@@ -2010,7 +2892,7 @@ function seedLocalAndCommercialSignals(db, input) {
|
|
|
2010
2892
|
}
|
|
2011
2893
|
|
|
2012
2894
|
// src/demo/stores.ts
|
|
2013
|
-
import { and as
|
|
2895
|
+
import { and as and4, eq as eq5 } from "drizzle-orm";
|
|
2014
2896
|
function refuseMutation() {
|
|
2015
2897
|
throw new Error("The public demo stores are view only.");
|
|
2016
2898
|
}
|
|
@@ -2029,9 +2911,9 @@ function demoReadOptions(db, context) {
|
|
|
2029
2911
|
// allowed, and no Google client id, secret, token, or transport is wired.
|
|
2030
2912
|
googleStateSecret: "public-demo-no-oauth-callbacks",
|
|
2031
2913
|
googleConnectionStore: {
|
|
2032
|
-
listConnections: (domain) => db.select().from(googleConnections).where(
|
|
2914
|
+
listConnections: (domain) => db.select().from(googleConnections).where(eq5(googleConnections.domain, domain)).all().map(mapGoogle),
|
|
2033
2915
|
getConnection: (domain, type) => {
|
|
2034
|
-
const row = db.select().from(googleConnections).where(
|
|
2916
|
+
const row = db.select().from(googleConnections).where(and4(eq5(googleConnections.domain, domain), eq5(googleConnections.connectionType, type))).get();
|
|
2035
2917
|
return row ? mapGoogle(row) : void 0;
|
|
2036
2918
|
},
|
|
2037
2919
|
upsertConnection: refuseMutation,
|
|
@@ -2040,7 +2922,7 @@ function demoReadOptions(db, context) {
|
|
|
2040
2922
|
},
|
|
2041
2923
|
bingConnectionStore: {
|
|
2042
2924
|
getConnection: (domain) => {
|
|
2043
|
-
const row = db.select().from(bingConnections).where(
|
|
2925
|
+
const row = db.select().from(bingConnections).where(eq5(bingConnections.domain, domain)).get();
|
|
2044
2926
|
return row ? { domain, siteUrl: row.siteUrl, apiKey: "", createdAt: row.createdAt, updatedAt: row.updatedAt } : void 0;
|
|
2045
2927
|
},
|
|
2046
2928
|
upsertConnection: refuseMutation,
|
|
@@ -2107,12 +2989,14 @@ async function createDemoServer(options = {}) {
|
|
|
2107
2989
|
await seedDemoSignals(db, context);
|
|
2108
2990
|
seedDemoExploration(db, context);
|
|
2109
2991
|
seedDemoMarketing(db, context);
|
|
2992
|
+
const aeroPreviews = buildDemoAeroPreviews(db, context);
|
|
2110
2993
|
const app = await createDemoHttpServer({
|
|
2111
2994
|
db,
|
|
2112
2995
|
now: context.now,
|
|
2113
2996
|
trustProxy: options.trustProxy,
|
|
2114
2997
|
assetsDir: options.assetsDir ?? join2(dirname(fileURLToPath(import.meta.url)), "..", "assets"),
|
|
2115
|
-
readOptions: demoReadOptions(db, context)
|
|
2998
|
+
readOptions: demoReadOptions(db, context),
|
|
2999
|
+
aeroPreviews
|
|
2116
3000
|
});
|
|
2117
3001
|
db.$client.pragma("query_only = ON");
|
|
2118
3002
|
app.addHook("onClose", async () => {
|