@reddoorla/maintenance 0.86.0 → 0.87.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/dist/airtable-ZBUPQJSC.js +9 -0
- package/dist/{announce-MXTUQ6C7.js → announce-I2Z4JN3I.js} +49 -28
- package/dist/announce-I2Z4JN3I.js.map +1 -0
- package/dist/attachments-NLZIJ5NU.js +9 -0
- package/dist/{bump-deps-EC74CPLS.js → bump-deps-CGVELRLK.js} +2 -2
- package/dist/{chunk-2ZPT7GLV.js → chunk-3JQ2FO3D.js} +3 -3
- package/dist/chunk-3JQ2FO3D.js.map +1 -0
- package/dist/{chunk-KTH5ZODA.js → chunk-7BZUOZPU.js} +2 -2
- package/dist/{chunk-LDWZSLSU.js → chunk-7NYN2P6I.js} +7 -4
- package/dist/{chunk-LDWZSLSU.js.map → chunk-7NYN2P6I.js.map} +1 -1
- package/dist/{chunk-3VQWSE3F.js → chunk-7UAYMAKR.js} +4 -4
- package/dist/{chunk-BYK66EWO.js → chunk-BBUSGEES.js} +52 -46
- package/dist/chunk-BBUSGEES.js.map +1 -0
- package/dist/{chunk-SLUQ7R6I.js → chunk-C6CLRKPV.js} +5 -5
- package/dist/{chunk-P2FE5AI6.js → chunk-CROKUHJB.js} +2 -2
- package/dist/{chunk-W7Z3BGCI.js → chunk-DEWJMMG3.js} +2 -2
- package/dist/{chunk-ACBRLLFT.js → chunk-F3JWOPTG.js} +7 -3
- package/dist/chunk-F3JWOPTG.js.map +1 -0
- package/dist/{chunk-TERE5SDF.js → chunk-HEWSRXYV.js} +4 -4
- package/dist/chunk-HHMMUR4P.js +335 -0
- package/dist/chunk-HHMMUR4P.js.map +1 -0
- package/dist/{chunk-X3B723J4.js → chunk-HSI6Q7MI.js} +38 -9
- package/dist/chunk-HSI6Q7MI.js.map +1 -0
- package/dist/{chunk-Z7AXYKQB.js → chunk-IY6256GK.js} +1 -1
- package/dist/{chunk-CQR4LIM7.js → chunk-MT2JINSS.js} +5 -1
- package/dist/{chunk-CQR4LIM7.js.map → chunk-MT2JINSS.js.map} +1 -1
- package/dist/{chunk-F5KM4LEQ.js → chunk-NCMZMLY7.js} +3 -3
- package/dist/{chunk-2NDRHXMR.js → chunk-NDWYJLRF.js} +17 -140
- package/dist/chunk-NDWYJLRF.js.map +1 -0
- package/dist/{chunk-PPE5KZJK.js → chunk-NXNPDCF3.js} +3 -3
- package/dist/{chunk-ITIOFN7A.js → chunk-P6AORG32.js} +29 -10
- package/dist/chunk-P6AORG32.js.map +1 -0
- package/dist/{chunk-N45M3FDK.js → chunk-SDDPUAWH.js} +4 -4
- package/dist/{chunk-ZKXSX4LO.js → chunk-TD2NNXLZ.js} +9 -9
- package/dist/{chunk-ILUNZHYV.js → chunk-YFAXOD7Z.js} +10 -6
- package/dist/chunk-YFAXOD7Z.js.map +1 -0
- package/dist/{chunk-2EVEFOZY.js → chunk-ZBDZTJUO.js} +2 -2
- package/dist/chunk-ZGBEIWSC.js +146 -0
- package/dist/chunk-ZGBEIWSC.js.map +1 -0
- package/dist/cli/bin.js +32 -27
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +15 -11
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/{convert-to-pnpm-6L6P74WR.js → convert-to-pnpm-QFJOEP5V.js} +2 -2
- package/dist/{db-B35OGV3G.js → db-UXNIZ2RG.js} +6 -6
- package/dist/{digest-BL6UJYKH.js → digest-FW6QMZZJ.js} +17 -16
- package/dist/{digest-BL6UJYKH.js.map → digest-FW6QMZZJ.js.map} +1 -1
- package/dist/{email-VTTTKKL5.js → email-KJXASVH5.js} +3 -3
- package/dist/{ensure-site-DFTB7MN3.js → ensure-site-4DCEBAPM.js} +19 -11
- package/dist/ensure-site-4DCEBAPM.js.map +1 -0
- package/dist/fleet-state-7TJ2GYT2.js +39 -0
- package/dist/{forms-notify-target-75U6OU7L.js → forms-notify-target-2EAWCWBW.js} +7 -4
- package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
- package/dist/{github-signals-IJUPGCUX.js → github-signals-E2WPYTBK.js} +9 -9
- package/dist/{header-image-AWLLQA4L.js → header-image-6IC53W27.js} +7 -7
- package/dist/{header-images-335L2AKB.js → header-images-SQBCE5OD.js} +21 -1
- package/dist/header-images-SQBCE5OD.js.map +1 -0
- package/dist/{health-endpoint-WJC2UMYS.js → health-endpoint-RFXL2F4A.js} +2 -2
- package/dist/health-mirror-L5JBEJVA.js +41 -0
- package/dist/health-mirror-L5JBEJVA.js.map +1 -0
- package/dist/{import-airtable-HE43FB4H.js → import-airtable-OHVY344G.js} +7 -5
- package/dist/index.d.ts +149 -0
- package/dist/index.js +147 -50
- package/dist/index.js.map +1 -1
- package/dist/{init-NJEOEO7I.js → init-XRAQZYID.js} +9 -9
- package/dist/{launch-XNAX427U.js → launch-IX4Y5JJC.js} +55 -26
- package/dist/launch-IX4Y5JJC.js.map +1 -0
- package/dist/{lighthouse-airtable-LBVIYYP5.js → lighthouse-airtable-3GUWHETW.js} +3 -3
- package/dist/{onboard-GK4PHPNI.js → onboard-O4IAHJMO.js} +2 -2
- package/dist/{orchestrate-77UO4BYJ.js → orchestrate-YLWQ6KK2.js} +10 -9
- package/dist/{parity-MWS24A3H.js → parity-56KA2DGR.js} +4 -4
- package/dist/{pipeline-S6CVHDGB.js → pipeline-3AANZFCR.js} +2 -2
- package/dist/{preflight-6WBNU2GZ.js → preflight-TNORKGCX.js} +11 -10
- package/dist/{preflight-6WBNU2GZ.js.map → preflight-TNORKGCX.js.map} +1 -1
- package/dist/{prismic-ci-3RHOZUUT.js → prismic-ci-G2AK53VL.js} +5 -5
- package/dist/{prismic-models-QBGUT2Q3.js → prismic-models-O6Z3OK3A.js} +13 -8
- package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
- package/dist/prospect/types.d.ts +267 -0
- package/dist/prospect/types.js +1 -0
- package/dist/{prospect-audit-IUNCJ2LV.js → prospect-audit-KSXUNQ5J.js} +4 -4
- package/dist/recipes/sync-configs.js +2 -2
- package/dist/{render-AQAVQDPZ.js → render-IFOGFB5T.js} +3 -3
- package/dist/render-IFOGFB5T.js.map +1 -0
- package/dist/render-from-row-BWSOIYDS.js +17 -0
- package/dist/render-from-row-BWSOIYDS.js.map +1 -0
- package/dist/{renovate-dispatch-QJY75ODR.js → renovate-dispatch-6GQWTRKX.js} +10 -10
- package/dist/{renovate-dispatch-QJY75ODR.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
- package/dist/{report-4EVNDHOZ.js → report-R3E3Y7HR.js} +63 -21
- package/dist/report-R3E3Y7HR.js.map +1 -0
- package/dist/report-mirror-4MNORYQ4.js +49 -0
- package/dist/report-mirror-4MNORYQ4.js.map +1 -0
- package/dist/rerender-3FM6DNRH.js +32 -0
- package/dist/rerender-3FM6DNRH.js.map +1 -0
- package/dist/{self-updating-6DWI2Z56.js → self-updating-UCIWESAX.js} +2 -2
- package/dist/{selftest-RC36MTKP.js → selftest-VGHWPN72.js} +22 -20
- package/dist/{selftest-RC36MTKP.js.map → selftest-VGHWPN72.js.map} +1 -1
- package/dist/site-mirror-NIREX5ME.js +52 -0
- package/dist/site-mirror-NIREX5ME.js.map +1 -0
- package/dist/{smoke-suite-VYY2LXM2.js → smoke-suite-U3UKD3NS.js} +2 -2
- package/dist/{submissions-Q7DRFOCD.js → submissions-IQEJX6FK.js} +2 -2
- package/dist/{svelte-codemods-25J2FO22.js → svelte-codemods-GOKXTBHW.js} +2 -2
- package/dist/{sync-34HNZZ56.js → sync-CTUVXMCK.js} +4 -4
- package/dist/{sync-configs-3SE6PSCO.js → sync-configs-SPTLFJCC.js} +4 -4
- package/dist/{upgrade-WBRQ62FX.js → upgrade-W6ZR3FJH.js} +2 -2
- package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
- package/dist/{websites-SXWTP72S.js → websites-D6WYFAES.js} +2 -2
- package/dist/websites-D6WYFAES.js.map +1 -0
- package/dist/{write-audits-to-airtable-MBEILLPU.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
- package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
- package/package.json +5 -1
- package/dist/airtable-G7WBQQMI.js +0 -9
- package/dist/announce-MXTUQ6C7.js.map +0 -1
- package/dist/chunk-2NDRHXMR.js.map +0 -1
- package/dist/chunk-2ZPT7GLV.js.map +0 -1
- package/dist/chunk-ACBRLLFT.js.map +0 -1
- package/dist/chunk-BYK66EWO.js.map +0 -1
- package/dist/chunk-ILUNZHYV.js.map +0 -1
- package/dist/chunk-ITIOFN7A.js.map +0 -1
- package/dist/chunk-X3B723J4.js.map +0 -1
- package/dist/ensure-site-DFTB7MN3.js.map +0 -1
- package/dist/forms-notify-target-75U6OU7L.js.map +0 -1
- package/dist/header-images-335L2AKB.js.map +0 -1
- package/dist/health-mirror-4B2L7T2Y.js +0 -75
- package/dist/health-mirror-4B2L7T2Y.js.map +0 -1
- package/dist/launch-XNAX427U.js.map +0 -1
- package/dist/prismic-models-QBGUT2Q3.js.map +0 -1
- package/dist/report-4EVNDHOZ.js.map +0 -1
- /package/dist/{airtable-G7WBQQMI.js.map → airtable-ZBUPQJSC.js.map} +0 -0
- /package/dist/{import-airtable-HE43FB4H.js.map → attachments-NLZIJ5NU.js.map} +0 -0
- /package/dist/{bump-deps-EC74CPLS.js.map → bump-deps-CGVELRLK.js.map} +0 -0
- /package/dist/{chunk-KTH5ZODA.js.map → chunk-7BZUOZPU.js.map} +0 -0
- /package/dist/{chunk-3VQWSE3F.js.map → chunk-7UAYMAKR.js.map} +0 -0
- /package/dist/{chunk-SLUQ7R6I.js.map → chunk-C6CLRKPV.js.map} +0 -0
- /package/dist/{chunk-P2FE5AI6.js.map → chunk-CROKUHJB.js.map} +0 -0
- /package/dist/{chunk-W7Z3BGCI.js.map → chunk-DEWJMMG3.js.map} +0 -0
- /package/dist/{chunk-TERE5SDF.js.map → chunk-HEWSRXYV.js.map} +0 -0
- /package/dist/{chunk-Z7AXYKQB.js.map → chunk-IY6256GK.js.map} +0 -0
- /package/dist/{chunk-F5KM4LEQ.js.map → chunk-NCMZMLY7.js.map} +0 -0
- /package/dist/{chunk-PPE5KZJK.js.map → chunk-NXNPDCF3.js.map} +0 -0
- /package/dist/{chunk-N45M3FDK.js.map → chunk-SDDPUAWH.js.map} +0 -0
- /package/dist/{chunk-ZKXSX4LO.js.map → chunk-TD2NNXLZ.js.map} +0 -0
- /package/dist/{chunk-2EVEFOZY.js.map → chunk-ZBDZTJUO.js.map} +0 -0
- /package/dist/{convert-to-pnpm-6L6P74WR.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
- /package/dist/{db-B35OGV3G.js.map → db-UXNIZ2RG.js.map} +0 -0
- /package/dist/{email-VTTTKKL5.js.map → email-KJXASVH5.js.map} +0 -0
- /package/dist/{lighthouse-airtable-LBVIYYP5.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
- /package/dist/{github-signals-IJUPGCUX.js.map → github-signals-E2WPYTBK.js.map} +0 -0
- /package/dist/{header-image-AWLLQA4L.js.map → header-image-6IC53W27.js.map} +0 -0
- /package/dist/{health-endpoint-WJC2UMYS.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
- /package/dist/{orchestrate-77UO4BYJ.js.map → import-airtable-OHVY344G.js.map} +0 -0
- /package/dist/{init-NJEOEO7I.js.map → init-XRAQZYID.js.map} +0 -0
- /package/dist/{parity-MWS24A3H.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
- /package/dist/{onboard-GK4PHPNI.js.map → onboard-O4IAHJMO.js.map} +0 -0
- /package/dist/{pipeline-S6CVHDGB.js.map → orchestrate-YLWQ6KK2.js.map} +0 -0
- /package/dist/{render-AQAVQDPZ.js.map → parity-56KA2DGR.js.map} +0 -0
- /package/dist/{websites-SXWTP72S.js.map → pipeline-3AANZFCR.js.map} +0 -0
- /package/dist/{prismic-ci-3RHOZUUT.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-MBEILLPU.js.map → prospect/types.js.map} +0 -0
- /package/dist/{prospect-audit-IUNCJ2LV.js.map → prospect-audit-KSXUNQ5J.js.map} +0 -0
- /package/dist/{self-updating-6DWI2Z56.js.map → self-updating-UCIWESAX.js.map} +0 -0
- /package/dist/{smoke-suite-VYY2LXM2.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
- /package/dist/{submissions-Q7DRFOCD.js.map → submissions-IQEJX6FK.js.map} +0 -0
- /package/dist/{svelte-codemods-25J2FO22.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
- /package/dist/{sync-34HNZZ56.js.map → sync-CTUVXMCK.js.map} +0 -0
- /package/dist/{sync-configs-3SE6PSCO.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
- /package/dist/{upgrade-WBRQ62FX.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
- /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SITE_FIELDS,
|
|
3
|
+
healthColumnFor,
|
|
4
|
+
mapReportRecord,
|
|
5
|
+
mapWebsiteRecord,
|
|
6
|
+
scheduleColumnFor,
|
|
7
|
+
siteValueFor
|
|
8
|
+
} from "./chunk-P6AORG32.js";
|
|
9
|
+
import {
|
|
10
|
+
parseAutoEvidence,
|
|
11
|
+
toReportType
|
|
12
|
+
} from "./chunk-F3JWOPTG.js";
|
|
13
|
+
import {
|
|
14
|
+
MAINTENANCE_CHECKLIST,
|
|
15
|
+
TESTING_CHECKLIST
|
|
16
|
+
} from "./chunk-VAD43MOR.js";
|
|
17
|
+
import {
|
|
18
|
+
canonicalizeStatus,
|
|
19
|
+
parseNotifyRouting,
|
|
20
|
+
parseSecurityAdvisories,
|
|
21
|
+
toFrequency,
|
|
22
|
+
toPrismicModelsVerdict,
|
|
23
|
+
toVerdict,
|
|
24
|
+
trimToNull
|
|
25
|
+
} from "./chunk-7NYN2P6I.js";
|
|
26
|
+
|
|
27
|
+
// src/db/fleet-state.ts
|
|
28
|
+
var str = (v) => typeof v === "string" ? v : null;
|
|
29
|
+
var num = (v) => typeof v === "number" ? v : null;
|
|
30
|
+
var bool = (v) => typeof v === "number" ? v !== 0 : null;
|
|
31
|
+
function parseAwc(raw) {
|
|
32
|
+
if (typeof raw !== "string" || raw.trim() === "") return [];
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (!Array.isArray(parsed)) return [];
|
|
36
|
+
return parsed.filter((x) => typeof x === "string").map((s) => s.trim()).filter(Boolean);
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function rowFromJoined(r) {
|
|
42
|
+
const name = String(r.name ?? "");
|
|
43
|
+
const headerFilename = str(r.header_image_filename);
|
|
44
|
+
const headerType = str(r.header_image_type);
|
|
45
|
+
return {
|
|
46
|
+
id: String(r.id),
|
|
47
|
+
name,
|
|
48
|
+
url: str(r.url) ?? "",
|
|
49
|
+
// `sites.status` holds the RAW Airtable cell (the importer stores it verbatim
|
|
50
|
+
// so the hourly parity check compares raw-to-raw). Canonicalizing HERE — the
|
|
51
|
+
// twin of mapRow's seam — is what keeps the #558 equivalence instrument green.
|
|
52
|
+
status: canonicalizeStatus(r.status),
|
|
53
|
+
statusRaw: str(r.status),
|
|
54
|
+
pointOfContact: str(r.point_of_contact),
|
|
55
|
+
maintenanceFreq: toFrequency(r.maintenance_freq, `${name} maintenance`),
|
|
56
|
+
testingFreq: toFrequency(r.testing_freq, `${name} testing`),
|
|
57
|
+
maintenanceFreqRaw: str(r.maintenance_freq),
|
|
58
|
+
testingFreqRaw: str(r.testing_freq),
|
|
59
|
+
maintenanceDay: str(r.maintenance_day),
|
|
60
|
+
testingDay: str(r.testing_day),
|
|
61
|
+
ga4PropertyId: str(r.ga4_property_id),
|
|
62
|
+
searchQuery: str(r.search_query),
|
|
63
|
+
searchConsoleProperty: str(r.search_console_property),
|
|
64
|
+
analyticsSoftFailAt: str(r.analytics_soft_fail_at),
|
|
65
|
+
gitRepo: str(r.git_repo),
|
|
66
|
+
reportRecipientsTo: str(r.report_recipients_to),
|
|
67
|
+
reportRecipientsCc: str(r.report_recipients_cc),
|
|
68
|
+
acceptedWatchConditions: parseAwc(r.accepted_watch_conditions),
|
|
69
|
+
headerImage: headerFilename !== null && headerType !== null ? { url: "", filename: headerFilename, type: headerType } : null,
|
|
70
|
+
pScore: num(r.p_score),
|
|
71
|
+
rScore: num(r.r_score),
|
|
72
|
+
bpScore: num(r.bp_score),
|
|
73
|
+
seoScore: num(r.seo_score),
|
|
74
|
+
lastLighthouseAuditAt: str(r.lighthouse_at),
|
|
75
|
+
a11yViolations: num(r.a11y_violations),
|
|
76
|
+
depsDrifted: num(r.deps_drifted),
|
|
77
|
+
depsMajorBehind: num(r.deps_major_behind),
|
|
78
|
+
depsOutdated: num(r.deps_outdated),
|
|
79
|
+
depsMajorOutdated: num(r.deps_major_outdated),
|
|
80
|
+
securityVulnsCritical: num(r.vulns_critical),
|
|
81
|
+
securityVulnsHigh: num(r.vulns_high),
|
|
82
|
+
securityVulnsModerate: num(r.vulns_moderate),
|
|
83
|
+
securityVulnsLow: num(r.vulns_low),
|
|
84
|
+
securityAutoFixAttempts: num(r.auto_fix_attempts),
|
|
85
|
+
lastSecurityAuditAt: str(r.security_audit_at),
|
|
86
|
+
securityAdvisories: parseSecurityAdvisories(r.security_advisories),
|
|
87
|
+
certDaysRemaining: num(r.cert_days_remaining),
|
|
88
|
+
domainCheckedAt: str(r.domain_checked_at),
|
|
89
|
+
netlifyId: trimToNull(r.netlify_id),
|
|
90
|
+
deployStatus: str(r.deploy_status),
|
|
91
|
+
lastDeployAt: str(r.last_deploy_at),
|
|
92
|
+
deployLogUrl: str(r.deploy_log_url),
|
|
93
|
+
deployCheckedAt: str(r.deploy_checked_at),
|
|
94
|
+
functionHealth: toVerdict(r.function_health),
|
|
95
|
+
cmsReachable: toVerdict(r.cms_reachable),
|
|
96
|
+
turnstileWidget: toVerdict(r.turnstile_widget),
|
|
97
|
+
functionHealthCheckedAt: str(r.function_health_checked_at),
|
|
98
|
+
crossbrowserOk: bool(r.crossbrowser_ok),
|
|
99
|
+
mobileOk: bool(r.mobile_ok),
|
|
100
|
+
linksOk: bool(r.links_ok),
|
|
101
|
+
brokenLinks: num(r.broken_links),
|
|
102
|
+
browserCheckedAt: str(r.browser_checked_at),
|
|
103
|
+
reachableOk: toVerdict(r.uptime_reachable),
|
|
104
|
+
titleMetaOk: toVerdict(r.titles_meta_ok),
|
|
105
|
+
copyIntro: trimToNull(r.copy_intro),
|
|
106
|
+
copyContact: trimToNull(r.copy_contact),
|
|
107
|
+
copyFooter: trimToNull(r.copy_footer),
|
|
108
|
+
launchedAt: str(r.launched_at),
|
|
109
|
+
newsletterWebhook: trimToNull(r.newsletter_webhook),
|
|
110
|
+
notifyRouting: parseNotifyRouting(r.notify_routing),
|
|
111
|
+
notifyRoutingRaw: trimToNull(r.notify_routing),
|
|
112
|
+
mailchimpApiKey: trimToNull(r.mailchimp_api_key),
|
|
113
|
+
mailchimpAudienceId: trimToNull(r.mailchimp_audience_id),
|
|
114
|
+
requireTurnstile: typeof r.require_turnstile === "number" ? r.require_turnstile !== 0 : false,
|
|
115
|
+
renovateFailingCis: num(r.renovate_failing_cis),
|
|
116
|
+
defaultBranchCi: str(r.default_branch_ci),
|
|
117
|
+
lastCommitAt: str(r.last_commit_at),
|
|
118
|
+
githubSignalsAt: str(r.github_signals_at),
|
|
119
|
+
smokeOk: toVerdict(r.smoke_ok),
|
|
120
|
+
lastSmokeAt: str(r.last_smoke_at),
|
|
121
|
+
formE2eOk: toVerdict(r.form_e2e_ok),
|
|
122
|
+
formE2eCheckedAt: str(r.form_e2e_checked_at),
|
|
123
|
+
prismicModels: toPrismicModelsVerdict(r.prismic_models),
|
|
124
|
+
prismicModelsCheckedAt: str(r.prismic_models_checked_at),
|
|
125
|
+
prismicModelsDrift: str(r.prismic_models_drift),
|
|
126
|
+
prismicAckUntil: str(r.prismic_ack_until),
|
|
127
|
+
nextMaintenanceAt: str(r.next_maintenance_at),
|
|
128
|
+
nextTestingAt: str(r.next_testing_at)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
var SITE_COLUMNS = [
|
|
132
|
+
"sites.id",
|
|
133
|
+
"sites.slug",
|
|
134
|
+
"sites.name",
|
|
135
|
+
"sites.url",
|
|
136
|
+
"sites.status",
|
|
137
|
+
"sites.point_of_contact",
|
|
138
|
+
"sites.maintenance_freq",
|
|
139
|
+
"sites.testing_freq",
|
|
140
|
+
"sites.maintenance_day",
|
|
141
|
+
"sites.testing_day",
|
|
142
|
+
"sites.ga4_property_id",
|
|
143
|
+
"sites.search_query",
|
|
144
|
+
"sites.search_console_property",
|
|
145
|
+
"sites.git_repo",
|
|
146
|
+
"sites.netlify_id",
|
|
147
|
+
"sites.report_recipients_to",
|
|
148
|
+
"sites.report_recipients_cc",
|
|
149
|
+
"sites.copy_intro",
|
|
150
|
+
"sites.copy_contact",
|
|
151
|
+
"sites.copy_footer",
|
|
152
|
+
"sites.newsletter_webhook",
|
|
153
|
+
"sites.mailchimp_api_key",
|
|
154
|
+
"sites.mailchimp_audience_id",
|
|
155
|
+
"sites.notify_routing",
|
|
156
|
+
"sites.require_turnstile",
|
|
157
|
+
"sites.accepted_watch_conditions",
|
|
158
|
+
"sites.prismic_ack_until",
|
|
159
|
+
"sites.launched_at",
|
|
160
|
+
"sites.header_image_filename",
|
|
161
|
+
"sites.header_image_type",
|
|
162
|
+
"sites.header_image_generated_at",
|
|
163
|
+
"sites.legacy"
|
|
164
|
+
];
|
|
165
|
+
function joined(db) {
|
|
166
|
+
return db.selectFrom("sites").leftJoin("site_health", "site_health.site_id", "sites.id").leftJoin("site_schedule", "site_schedule.site_id", "sites.id").select(SITE_COLUMNS).selectAll(["site_health", "site_schedule"]);
|
|
167
|
+
}
|
|
168
|
+
async function mirrorSiteField(db, siteId, airtableColumn, value) {
|
|
169
|
+
await mirrorSiteFields(db, siteId, { [airtableColumn]: value });
|
|
170
|
+
}
|
|
171
|
+
async function mirrorSiteFields(db, siteId, fields) {
|
|
172
|
+
const patch = {};
|
|
173
|
+
for (const [airtableColumn, value] of Object.entries(fields)) {
|
|
174
|
+
const col = SITE_FIELDS[airtableColumn];
|
|
175
|
+
if (!col)
|
|
176
|
+
throw new Error(`mirrorSiteFields: importer claims no sites column for '${airtableColumn}'`);
|
|
177
|
+
patch[col] = siteValueFor(col, value);
|
|
178
|
+
}
|
|
179
|
+
if (Object.keys(patch).length === 0) return true;
|
|
180
|
+
const res = await db.updateTable("sites").set(patch).where("id", "=", siteId).executeTakeFirst();
|
|
181
|
+
return res.numUpdatedRows > 0n;
|
|
182
|
+
}
|
|
183
|
+
async function mirrorSiteInsert(db, rec, computedAt) {
|
|
184
|
+
const { site, health, schedule } = mapWebsiteRecord(rec, computedAt);
|
|
185
|
+
await db.insertInto("sites").values(site).onConflict((oc) => oc.column("id").doUpdateSet(site)).execute();
|
|
186
|
+
await db.insertInto("site_health").values(health).onConflict((oc) => oc.column("site_id").doUpdateSet(health)).execute();
|
|
187
|
+
await db.insertInto("site_schedule").values(schedule).onConflict((oc) => oc.column("site_id").doUpdateSet(schedule)).execute();
|
|
188
|
+
}
|
|
189
|
+
async function mirrorHealthFields(db, siteId, fields) {
|
|
190
|
+
const patch = {};
|
|
191
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
192
|
+
const m = healthColumnFor(field);
|
|
193
|
+
if (!m)
|
|
194
|
+
throw new Error(`mirrorHealthFields: importer claims no site_health column for '${field}'`);
|
|
195
|
+
patch[m.col] = m.coerce(value);
|
|
196
|
+
}
|
|
197
|
+
if (Object.keys(patch).length === 0) return true;
|
|
198
|
+
const res = await db.updateTable("site_health").set(patch).where("site_id", "=", siteId).executeTakeFirst();
|
|
199
|
+
return res.numUpdatedRows > 0n;
|
|
200
|
+
}
|
|
201
|
+
async function mirrorScheduleFields(db, siteId, fields, computedAt) {
|
|
202
|
+
const patch = {};
|
|
203
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
204
|
+
const m = scheduleColumnFor(field);
|
|
205
|
+
if (!m)
|
|
206
|
+
throw new Error(
|
|
207
|
+
`mirrorScheduleFields: importer claims no site_schedule column for '${field}'`
|
|
208
|
+
);
|
|
209
|
+
patch[m.col] = m.coerce(value);
|
|
210
|
+
}
|
|
211
|
+
if (Object.keys(patch).length === 0) return true;
|
|
212
|
+
patch.computed_at = computedAt;
|
|
213
|
+
const res = await db.updateTable("site_schedule").set(patch).where("site_id", "=", siteId).executeTakeFirst();
|
|
214
|
+
return res.numUpdatedRows > 0n;
|
|
215
|
+
}
|
|
216
|
+
async function getSiteBySlug(db, slug) {
|
|
217
|
+
const r = await joined(db).where("sites.slug", "=", slug).executeTakeFirst();
|
|
218
|
+
return r ? rowFromJoined(r) : null;
|
|
219
|
+
}
|
|
220
|
+
async function getSiteById(db, id) {
|
|
221
|
+
const r = await joined(db).where("sites.id", "=", id).executeTakeFirst();
|
|
222
|
+
return r ? rowFromJoined(r) : null;
|
|
223
|
+
}
|
|
224
|
+
async function listSites(db) {
|
|
225
|
+
const rows = await joined(db).orderBy("sites.name").execute();
|
|
226
|
+
return rows.map((r) => rowFromJoined(r));
|
|
227
|
+
}
|
|
228
|
+
var CHECKLIST_FIELD_BY_KEY = new Map(
|
|
229
|
+
[...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map((i) => [i.key, i.field])
|
|
230
|
+
);
|
|
231
|
+
function checklistFromStored(raw) {
|
|
232
|
+
let stored = {};
|
|
233
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(raw);
|
|
236
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
237
|
+
stored = parsed;
|
|
238
|
+
} catch {
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const out = {};
|
|
242
|
+
for (const [key, field] of CHECKLIST_FIELD_BY_KEY) out[field] = Boolean(stored[key]);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
function reportRowFromDb(r) {
|
|
246
|
+
const p = r.lighthouse_performance;
|
|
247
|
+
const a = r.lighthouse_accessibility;
|
|
248
|
+
const b = r.lighthouse_best_practices;
|
|
249
|
+
const seo = r.lighthouse_seo;
|
|
250
|
+
const lighthouse = typeof p === "number" && typeof a === "number" && typeof b === "number" && typeof seo === "number" ? { performance: p, accessibility: a, bestPractices: b, seo } : null;
|
|
251
|
+
return {
|
|
252
|
+
id: r.id,
|
|
253
|
+
reportId: r.report_id ?? "",
|
|
254
|
+
siteId: r.site_id ?? "",
|
|
255
|
+
reportType: toReportType(r.report_type ?? void 0),
|
|
256
|
+
period: r.period,
|
|
257
|
+
periodStart: r.period_start,
|
|
258
|
+
periodEnd: r.period_end,
|
|
259
|
+
completedOn: r.completed_on,
|
|
260
|
+
lighthouse,
|
|
261
|
+
gaUsersCurrent: r.ga_users_current,
|
|
262
|
+
gaUsersPrevious: r.ga_users_previous,
|
|
263
|
+
searchFoundPage1: bool(r.search_found_page1),
|
|
264
|
+
searchPosition: r.search_position,
|
|
265
|
+
lastTestedDate: r.last_tested_date,
|
|
266
|
+
commentary: r.commentary,
|
|
267
|
+
subjectOverride: r.subject_override,
|
|
268
|
+
draftReady: r.draft_ready !== 0,
|
|
269
|
+
approvedToSend: r.approved_to_send !== 0,
|
|
270
|
+
sentAt: r.sent_at,
|
|
271
|
+
approvedAt: r.approved_at,
|
|
272
|
+
approvedBy: r.approved_by,
|
|
273
|
+
deliveryStatus: r.delivery_status ?? "pending",
|
|
274
|
+
// The body lives IN the row (rendered_html) — the link points at the
|
|
275
|
+
// dashboard's own preview route instead of an EXPIRING Airtable signed URL.
|
|
276
|
+
// Strictly better for the operator: the old link 404'd once the URL aged out.
|
|
277
|
+
renderedHtmlAttachment: r.rendered_html !== null ? { url: `/api/reports/${r.id}/preview`, filename: `${r.report_id ?? r.id}.html` } : null,
|
|
278
|
+
resendMessageId: r.resend_message_id,
|
|
279
|
+
checklist: checklistFromStored(r.checklist),
|
|
280
|
+
autoEvidence: parseAutoEvidence(r.checklist_auto_evidence),
|
|
281
|
+
sendOverride: r.send_override !== 0,
|
|
282
|
+
overrideReason: r.override_reason,
|
|
283
|
+
overrideBy: r.override_by,
|
|
284
|
+
overrideAt: r.override_at
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async function listAllReports(db) {
|
|
288
|
+
const rows = await db.selectFrom("reports").selectAll().orderBy("period_start", "desc").orderBy("id").execute();
|
|
289
|
+
return rows.map(reportRowFromDb);
|
|
290
|
+
}
|
|
291
|
+
async function listReportsForSite(db, siteId) {
|
|
292
|
+
const rows = await db.selectFrom("reports").selectAll().where("site_id", "=", siteId).orderBy("period_start", "desc").orderBy("id").execute();
|
|
293
|
+
return rows.map(reportRowFromDb);
|
|
294
|
+
}
|
|
295
|
+
async function mirrorReportPatch(db, reportId, patch) {
|
|
296
|
+
if (Object.keys(patch).length === 0) return;
|
|
297
|
+
await db.updateTable("reports").set(patch).where("id", "=", reportId).execute();
|
|
298
|
+
}
|
|
299
|
+
async function mirrorReportInsert(db, rec) {
|
|
300
|
+
const row = mapReportRecord(rec, null);
|
|
301
|
+
const { rendered_html: _rh, ...rowSansHtml } = row;
|
|
302
|
+
void _rh;
|
|
303
|
+
await db.insertInto("reports").values(row).onConflict((oc) => oc.column("id").doUpdateSet(rowSansHtml)).execute();
|
|
304
|
+
}
|
|
305
|
+
async function storeRenderedHtml(db, reportId, html) {
|
|
306
|
+
await db.updateTable("reports").set({ rendered_html: html }).where("id", "=", reportId).execute();
|
|
307
|
+
}
|
|
308
|
+
async function getReportById(db, id) {
|
|
309
|
+
const r = await db.selectFrom("reports").selectAll().where("id", "=", id).executeTakeFirst();
|
|
310
|
+
return r ? reportRowFromDb(r) : null;
|
|
311
|
+
}
|
|
312
|
+
async function getReportHtml(db, id) {
|
|
313
|
+
const r = await db.selectFrom("reports").select(["rendered_html", "report_id"]).where("id", "=", id).executeTakeFirst();
|
|
314
|
+
if (!r || r.rendered_html === null) return null;
|
|
315
|
+
return { html: r.rendered_html, reportId: r.report_id };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export {
|
|
319
|
+
mirrorSiteField,
|
|
320
|
+
mirrorSiteFields,
|
|
321
|
+
mirrorSiteInsert,
|
|
322
|
+
mirrorHealthFields,
|
|
323
|
+
mirrorScheduleFields,
|
|
324
|
+
getSiteBySlug,
|
|
325
|
+
getSiteById,
|
|
326
|
+
listSites,
|
|
327
|
+
listAllReports,
|
|
328
|
+
listReportsForSite,
|
|
329
|
+
mirrorReportPatch,
|
|
330
|
+
mirrorReportInsert,
|
|
331
|
+
storeRenderedHtml,
|
|
332
|
+
getReportById,
|
|
333
|
+
getReportHtml
|
|
334
|
+
};
|
|
335
|
+
//# sourceMappingURL=chunk-HHMMUR4P.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/fleet-state.ts"],"sourcesContent":["/** Phase 2 of the Airtable → Turso migration (#539): the fleet-state READ layer.\n *\n * Returns the exact `WebsiteRow` shape the Airtable module returns, so every\n * repoint is an import-only swap at a composition root — the same trick\n * listSubmissionsForSite used for the hybrid-db cutover. The raw values live\n * in `sites`/`site_health`/`site_schedule` (written by the importer / hourly\n * sync); coercion to `WebsiteRow` happens HERE, reusing the Airtable module's\n * own exported coercers (`toVerdict`, `toFrequency`, `parseNotifyRouting`,\n * `parseSecurityAdvisories`, `trimToNull`) so there is one truth for each.\n *\n * The equivalence instrument (tests/db/fleet-state.test.ts) pins this module\n * to `mapRow` field-by-field: for a fixture record, `mapRow(rec)` must deep-\n * equal the row read back through here after an import. A new WebsiteRow\n * field fails that test until this module carries it.\n *\n * `headerImage` is the ONE deliberate exception (design D5): Airtable stopped\n * being its source — the bytes belong in `sites.header_image*`. Those columns\n * ARE now written: the header-image CLI dual-writes on every generation and the\n * one-shot backfill copied the rest, so as of 2026-08-25 production carries a\n * BLOB for 12 of the 13 maintained sites (the 13th, LA Homelessness Youth, has\n * no header image in Airtable either — its reports are blocked at approve for\n * exactly that). An earlier version of this comment said the columns were empty\n * fleet-wide; that was true when written and is no longer.\n *\n * The `url` is \"\" because the bytes live in the row itself, not behind a signed\n * URL — which is why `url` is NOT a usable handle. A consumer that needs the\n * image calls `loadHeaderImage(db, siteId)` for the bytes; the send path still\n * fetches the Airtable attachment, and moving it over is its own change.\n */\nimport type { Selectable, Updateable } from \"kysely\";\nimport type { Db } from \"./client.js\";\nimport type { ReportsTable, SiteHealthTable, SiteScheduleTable, SitesTable } from \"./schema.js\";\nimport {\n SITE_FIELDS,\n siteValueFor,\n healthColumnFor,\n scheduleColumnFor,\n mapReportRecord,\n mapWebsiteRecord,\n type RawRecord,\n} from \"./import-airtable.js\";\nimport type { AirtableCellValue } from \"../reports/airtable/websites.js\";\nimport {\n toReportType,\n parseAutoEvidence,\n type ReportRow,\n type DeliveryStatus,\n} from \"../reports/airtable/reports.js\";\nimport { MAINTENANCE_CHECKLIST, TESTING_CHECKLIST } from \"../reports/checklist.js\";\nimport type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport { canonicalizeStatus } from \"../reports/airtable/site-status.js\";\nimport {\n parseNotifyRouting,\n parseSecurityAdvisories,\n toFrequency,\n toPrismicModelsVerdict,\n toVerdict,\n trimToNull,\n} from \"../reports/airtable/websites.js\";\n\ntype JoinedRow = Record<string, unknown>;\n\nconst str = (v: unknown): string | null => (typeof v === \"string\" ? v : null);\nconst num = (v: unknown): number | null => (typeof v === \"number\" ? v : null);\n/** Stored 1/0/null (importer's b01n) → boolean | null. */\nconst bool = (v: unknown): boolean | null => (typeof v === \"number\" ? v !== 0 : null);\n\n/** Stored JSON array (importer-normalized) → the trimmed string[] mapRow yields. */\nfunction parseAwc(raw: unknown): string[] {\n if (typeof raw !== \"string\" || raw.trim() === \"\") return [];\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) return [];\n return parsed\n .filter((x): x is string => typeof x === \"string\")\n .map((s) => s.trim())\n .filter(Boolean);\n } catch {\n return [];\n }\n}\n\nfunction rowFromJoined(r: JoinedRow): WebsiteRow {\n const name = String(r.name ?? \"\");\n const headerFilename = str(r.header_image_filename);\n const headerType = str(r.header_image_type);\n return {\n id: String(r.id),\n name,\n url: str(r.url) ?? \"\",\n // `sites.status` holds the RAW Airtable cell (the importer stores it verbatim\n // so the hourly parity check compares raw-to-raw). Canonicalizing HERE — the\n // twin of mapRow's seam — is what keeps the #558 equivalence instrument green.\n status: canonicalizeStatus(r.status),\n statusRaw: str(r.status),\n pointOfContact: str(r.point_of_contact),\n maintenanceFreq: toFrequency(r.maintenance_freq, `${name} maintenance`),\n testingFreq: toFrequency(r.testing_freq, `${name} testing`),\n maintenanceFreqRaw: str(r.maintenance_freq),\n testingFreqRaw: str(r.testing_freq),\n maintenanceDay: str(r.maintenance_day),\n testingDay: str(r.testing_day),\n ga4PropertyId: str(r.ga4_property_id),\n searchQuery: str(r.search_query),\n searchConsoleProperty: str(r.search_console_property),\n analyticsSoftFailAt: str(r.analytics_soft_fail_at),\n gitRepo: str(r.git_repo),\n reportRecipientsTo: str(r.report_recipients_to),\n reportRecipientsCc: str(r.report_recipients_cc),\n acceptedWatchConditions: parseAwc(r.accepted_watch_conditions),\n headerImage:\n headerFilename !== null && headerType !== null\n ? { url: \"\", filename: headerFilename, type: headerType }\n : null,\n pScore: num(r.p_score),\n rScore: num(r.r_score),\n bpScore: num(r.bp_score),\n seoScore: num(r.seo_score),\n lastLighthouseAuditAt: str(r.lighthouse_at),\n a11yViolations: num(r.a11y_violations),\n depsDrifted: num(r.deps_drifted),\n depsMajorBehind: num(r.deps_major_behind),\n depsOutdated: num(r.deps_outdated),\n depsMajorOutdated: num(r.deps_major_outdated),\n securityVulnsCritical: num(r.vulns_critical),\n securityVulnsHigh: num(r.vulns_high),\n securityVulnsModerate: num(r.vulns_moderate),\n securityVulnsLow: num(r.vulns_low),\n securityAutoFixAttempts: num(r.auto_fix_attempts),\n lastSecurityAuditAt: str(r.security_audit_at),\n securityAdvisories: parseSecurityAdvisories(r.security_advisories),\n certDaysRemaining: num(r.cert_days_remaining),\n domainCheckedAt: str(r.domain_checked_at),\n netlifyId: trimToNull(r.netlify_id),\n deployStatus: str(r.deploy_status),\n lastDeployAt: str(r.last_deploy_at),\n deployLogUrl: str(r.deploy_log_url),\n deployCheckedAt: str(r.deploy_checked_at),\n functionHealth: toVerdict(r.function_health),\n cmsReachable: toVerdict(r.cms_reachable),\n turnstileWidget: toVerdict(r.turnstile_widget),\n functionHealthCheckedAt: str(r.function_health_checked_at),\n crossbrowserOk: bool(r.crossbrowser_ok),\n mobileOk: bool(r.mobile_ok),\n linksOk: bool(r.links_ok),\n brokenLinks: num(r.broken_links),\n browserCheckedAt: str(r.browser_checked_at),\n reachableOk: toVerdict(r.uptime_reachable),\n titleMetaOk: toVerdict(r.titles_meta_ok),\n copyIntro: trimToNull(r.copy_intro),\n copyContact: trimToNull(r.copy_contact),\n copyFooter: trimToNull(r.copy_footer),\n launchedAt: str(r.launched_at),\n newsletterWebhook: trimToNull(r.newsletter_webhook),\n notifyRouting: parseNotifyRouting(r.notify_routing),\n notifyRoutingRaw: trimToNull(r.notify_routing),\n mailchimpApiKey: trimToNull(r.mailchimp_api_key),\n mailchimpAudienceId: trimToNull(r.mailchimp_audience_id),\n requireTurnstile: typeof r.require_turnstile === \"number\" ? r.require_turnstile !== 0 : false,\n renovateFailingCis: num(r.renovate_failing_cis),\n defaultBranchCi: str(r.default_branch_ci),\n lastCommitAt: str(r.last_commit_at),\n githubSignalsAt: str(r.github_signals_at),\n smokeOk: toVerdict(r.smoke_ok),\n lastSmokeAt: str(r.last_smoke_at),\n formE2eOk: toVerdict(r.form_e2e_ok),\n formE2eCheckedAt: str(r.form_e2e_checked_at),\n prismicModels: toPrismicModelsVerdict(r.prismic_models),\n prismicModelsCheckedAt: str(r.prismic_models_checked_at),\n prismicModelsDrift: str(r.prismic_models_drift),\n prismicAckUntil: str(r.prismic_ack_until),\n nextMaintenanceAt: str(r.next_maintenance_at),\n nextTestingAt: str(r.next_testing_at),\n };\n}\n\n/** Every sites column EXCEPT the header-image BLOB. Since the 2026-08-24\n * backfill, header_image holds 0.7–3.5 MB JPEGs — a selectAll would haul\n * megabytes into every site lookup (per LEAD on the ingest path, ×44 on the\n * fleet list) and Turso bills the bytes. WebsiteRow only needs the metadata\n * columns; the bytes have their own dedicated readers when a consumer wants\n * them. Kept in lockstep with SitesTable by the blob-exclusion test. */\nconst SITE_COLUMNS = [\n \"sites.id\",\n \"sites.slug\",\n \"sites.name\",\n \"sites.url\",\n \"sites.status\",\n \"sites.point_of_contact\",\n \"sites.maintenance_freq\",\n \"sites.testing_freq\",\n \"sites.maintenance_day\",\n \"sites.testing_day\",\n \"sites.ga4_property_id\",\n \"sites.search_query\",\n \"sites.search_console_property\",\n \"sites.git_repo\",\n \"sites.netlify_id\",\n \"sites.report_recipients_to\",\n \"sites.report_recipients_cc\",\n \"sites.copy_intro\",\n \"sites.copy_contact\",\n \"sites.copy_footer\",\n \"sites.newsletter_webhook\",\n \"sites.mailchimp_api_key\",\n \"sites.mailchimp_audience_id\",\n \"sites.notify_routing\",\n \"sites.require_turnstile\",\n \"sites.accepted_watch_conditions\",\n \"sites.prismic_ack_until\",\n \"sites.launched_at\",\n \"sites.header_image_filename\",\n \"sites.header_image_type\",\n \"sites.header_image_generated_at\",\n \"sites.legacy\",\n] as const;\n\n/** The three-table join every read composes from. site_health/site_schedule\n * rows are upserted alongside sites by the importer, but LEFT JOIN anyway —\n * a site row must never vanish from a surface because a health row is absent. */\nfunction joined(db: Db) {\n return db\n .selectFrom(\"sites\")\n .leftJoin(\"site_health\", \"site_health.site_id\", \"sites.id\")\n .leftJoin(\"site_schedule\", \"site_schedule.site_id\", \"sites.id\")\n .select(SITE_COLUMNS)\n .selectAll([\"site_health\", \"site_schedule\"]);\n}\n\n/** Write-through for the site-detail editor (Phase 2): after the Airtable\n * write (still the Phase 2 source of truth), mirror the same cell into\n * `sites` so a Turso-reading page renders the edit immediately instead of\n * waiting for the next hourly sync. The column mapping is the IMPORTER's own\n * map — one truth — and the value gets the importer's empty-clears-to-null\n * semantics. Throws on a column the importer doesn't claim (the lockstep\n * test makes that unreachable for the editor's allowlist); the caller treats\n * a mirror failure as non-fatal — the hourly sync converges it.\n *\n * Handles the non-text columns too (`Require Turnstile` is a checkbox,\n * `Accepted Watch Conditions` a multi-select): the coercion is NOT repeated\n * here, it is delegated to the importer's own `siteValueFor`. That delegation\n * is the whole safety property — parity compares raw-to-raw, so a mirror that\n * stored `\"true\"` where the importer stores `1` would red every hourly run. */\nexport async function mirrorSiteField(\n db: Db,\n siteId: string,\n airtableColumn: string,\n value: AirtableCellValue,\n): Promise<void> {\n await mirrorSiteFields(db, siteId, { [airtableColumn]: value });\n}\n\n/** The multi-column form, and where the work actually happens (#539 Phase 5).\n *\n * `updateLaunched` is why it exists: it flips `Status` AND stamps `Launched at`\n * in one Airtable update, and mirroring those as two separate UPDATEs would\n * open a window where Turso says a site is maintained but never launched.\n *\n * Same contract as {@link mirrorHealthFields}, deliberately — it takes the\n * EXACT FieldSet just written to Airtable (the writers return it, so the mirror\n * cannot carry a different payload), and returns whether a `sites` row matched:\n * a site the hourly sync hasn't imported yet updates 0 rows → false, so the\n * caller can count it honestly instead of claiming it mirrored. An empty\n * FieldSet runs no SQL and returns true — nothing to mirror is not a miss. */\nexport async function mirrorSiteFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n): Promise<boolean> {\n const patch: Record<string, string | number | null> = {};\n for (const [airtableColumn, value] of Object.entries(fields)) {\n const col = SITE_FIELDS[airtableColumn];\n if (!col)\n throw new Error(`mirrorSiteFields: importer claims no sites column for '${airtableColumn}'`);\n // Empty text still clears to null (the importer's `s()` does the same); the\n // non-text columns get their own coercion inside siteValueFor.\n patch[col] = siteValueFor(col, value);\n }\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"sites\")\n .set(patch as Updateable<SitesTable>)\n .where(\"id\", \"=\", siteId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** Mirror a NEWLY CREATED Airtable Websites record into Turso (#539 Phase 5).\n *\n * `ensure-site` CREATES a row, and every other site mirror is an UPDATE, which\n * does nothing at all for a row that does not exist yet — so a site\n * bootstrapped at 09:05 was invisible until the 09:20 sync, and every mirror\n * the rest of the bootstrap fired reported `mirrored=missed` with nothing to\n * update.\n *\n * Maps with the IMPORTER's own `mapWebsiteRecord`, which is also exactly what\n * parity diffs against: that delegation is what makes the mirrored rows\n * parity-clean by construction rather than by a column list someone has to\n * remember to extend.\n *\n * All THREE rows, not just `sites`. Parity reverse-checks `site_health` and\n * `site_schedule` per site and reports a missing one as `(row) ABSENT`, and a\n * later `mirrorHealthFields` would return `missed` forever with no row to hit.\n *\n * Upserts rather than inserts because `ensure-site` is re-run to RESUME a\n * bootstrap. The header_image* columns survive that by construction —\n * `mapWebsiteRecord` does not carry them (Airtable stopped being their source,\n * design D5), so the conflict branch cannot blank a stored plate whose bytes\n * live in no other store. */\nexport async function mirrorSiteInsert(db: Db, rec: RawRecord, computedAt: string): Promise<void> {\n const { site, health, schedule } = mapWebsiteRecord(rec, computedAt);\n await db\n .insertInto(\"sites\")\n .values(site)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(site))\n .execute();\n await db\n .insertInto(\"site_health\")\n .values(health)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(health))\n .execute();\n await db\n .insertInto(\"site_schedule\")\n .values(schedule)\n .onConflict((oc) => oc.column(\"site_id\").doUpdateSet(schedule))\n .execute();\n}\n\n/** Write-through mirror for the nightly health writers (#539 Phase 3). Takes\n * the EXACT FieldSet just written to Airtable (updateAuditFields /\n * updateGitHubSignals return it), so the mirror can never carry a different\n * payload than the Airtable write it shadows. Resolution + coercion come from\n * the importer's healthColumnFor — one truth; an Airtable column no\n * site_health column claims throws (same contract as mirrorSiteField).\n * Partial by design: absent fields stay untouched, matching\n * updateGitHubSignals' deliberate omission of a null lastCommitAt. Returns\n * whether a site_health row matched: a site the hourly sync hasn't imported\n * yet updates 0 rows → false, so the caller can count it honestly\n * (mirror_missed) instead of claiming it mirrored — it still converges on the\n * next sync, like every mirror. An empty FieldSet runs no SQL and returns\n * true: nothing to mirror is not a miss. */\nexport async function mirrorHealthFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n): Promise<boolean> {\n // Per-column value types (number vs text) are guaranteed by healthColumnFor's\n // coercion — the numeric/text split lives there, once — so the patch builds\n // untyped and casts at the .set() boundary (the importer's own idiom).\n const patch: Record<string, string | number | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = healthColumnFor(field);\n if (!m)\n throw new Error(`mirrorHealthFields: importer claims no site_health column for '${field}'`);\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n const res = await db\n .updateTable(\"site_health\")\n .set(patch as Updateable<SiteHealthTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n // kysely/libSQL reports numUpdatedRows as a BigInt — compare in BigInt.\n return res.numUpdatedRows > 0n;\n}\n\n/** The site_schedule twin of {@link mirrorHealthFields}, for the nightly\n * next-due write-back. `computedAt` stamps when THIS computation ran — the\n * hourly sync overwrites it with its own import stamp, same as every mirrored\n * value. Empty fields → full no-op (no lone computed_at stamp for a write\n * that carried nothing). Same return contract as mirrorHealthFields: false\n * when the UPDATE matched no site_schedule row (site not yet imported), true\n * otherwise — including the empty-fields no-op. */\nexport async function mirrorScheduleFields(\n db: Db,\n siteId: string,\n fields: Record<string, unknown>,\n computedAt: string,\n): Promise<boolean> {\n const patch: Record<string, string | null> = {};\n for (const [field, value] of Object.entries(fields)) {\n const m = scheduleColumnFor(field);\n if (!m)\n throw new Error(\n `mirrorScheduleFields: importer claims no site_schedule column for '${field}'`,\n );\n patch[m.col] = m.coerce(value);\n }\n if (Object.keys(patch).length === 0) return true;\n patch.computed_at = computedAt;\n const res = await db\n .updateTable(\"site_schedule\")\n .set(patch as Updateable<SiteScheduleTable>)\n .where(\"site_id\", \"=\", siteId)\n .executeTakeFirst();\n return res.numUpdatedRows > 0n;\n}\n\n/** Same contract as the Airtable getWebsiteBySlug: slug is siteSlug(Name),\n * precomputed into the UNIQUE sites.slug column at import time. */\nexport async function getSiteBySlug(db: Db, slug: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.slug\", \"=\", slug).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** By Airtable rec id (the PK) — approve-report's lookup shape. */\nexport async function getSiteById(db: Db, id: string): Promise<WebsiteRow | null> {\n const r = await joined(db).where(\"sites.id\", \"=\", id).executeTakeFirst();\n return r ? rowFromJoined(r as JoinedRow) : null;\n}\n\n/** Same contract as the Airtable listWebsites: every site, one row each.\n * Name-ordered for determinism (Airtable returned table order; no consumer\n * is order-sensitive — the cockpit groups and sorts itself). */\nexport async function listSites(db: Db): Promise<WebsiteRow[]> {\n const rows = await joined(db).orderBy(\"sites.name\").execute();\n return rows.map((r) => rowFromJoined(r as JoinedRow));\n}\n\n// ————————————————————————— reports —————————————————————————\n\n/** stable checklist key → Airtable column name. The importer stores stable keys\n * (mapReportRecord); `ReportRow.checklist` exposes Airtable column names — one\n * derived map, built from the checklist definitions themselves. */\nconst CHECKLIST_FIELD_BY_KEY: ReadonlyMap<string, string> = new Map(\n [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map((i) => [i.key, i.field]),\n);\n\nfunction checklistFromStored(raw: string | null): Record<string, boolean> {\n let stored: Record<string, unknown> = {};\n if (typeof raw === \"string\" && raw.trim() !== \"\") {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed))\n stored = parsed as Record<string, unknown>;\n } catch {\n // fall through to all-false — a bad blob must not throw the page\n }\n }\n const out: Record<string, boolean> = {};\n for (const [key, field] of CHECKLIST_FIELD_BY_KEY) out[field] = Boolean(stored[key]);\n return out;\n}\n\nfunction reportRowFromDb(r: Selectable<ReportsTable>): ReportRow {\n const p = r.lighthouse_performance;\n const a = r.lighthouse_accessibility;\n const b = r.lighthouse_best_practices;\n const seo = r.lighthouse_seo;\n const lighthouse =\n typeof p === \"number\" &&\n typeof a === \"number\" &&\n typeof b === \"number\" &&\n typeof seo === \"number\"\n ? { performance: p, accessibility: a, bestPractices: b, seo }\n : null;\n return {\n id: r.id,\n reportId: r.report_id ?? \"\",\n siteId: r.site_id ?? \"\",\n reportType: toReportType(r.report_type ?? undefined),\n period: r.period,\n periodStart: r.period_start,\n periodEnd: r.period_end,\n completedOn: r.completed_on,\n lighthouse,\n gaUsersCurrent: r.ga_users_current,\n gaUsersPrevious: r.ga_users_previous,\n searchFoundPage1: bool(r.search_found_page1),\n searchPosition: r.search_position,\n lastTestedDate: r.last_tested_date,\n commentary: r.commentary,\n subjectOverride: r.subject_override,\n draftReady: r.draft_ready !== 0,\n approvedToSend: r.approved_to_send !== 0,\n sentAt: r.sent_at,\n approvedAt: r.approved_at,\n approvedBy: r.approved_by,\n deliveryStatus: (r.delivery_status ?? \"pending\") as DeliveryStatus,\n // The body lives IN the row (rendered_html) — the link points at the\n // dashboard's own preview route instead of an EXPIRING Airtable signed URL.\n // Strictly better for the operator: the old link 404'd once the URL aged out.\n renderedHtmlAttachment:\n r.rendered_html !== null\n ? { url: `/api/reports/${r.id}/preview`, filename: `${r.report_id ?? r.id}.html` }\n : null,\n resendMessageId: r.resend_message_id,\n checklist: checklistFromStored(r.checklist),\n autoEvidence: parseAutoEvidence(r.checklist_auto_evidence),\n sendOverride: r.send_override !== 0,\n overrideReason: r.override_reason,\n overrideBy: r.override_by,\n overrideAt: r.override_at,\n };\n}\n\n/** Same contract as the Airtable listAllReports. Newest-period-first (Airtable\n * returned manual table order; every consumer filters/sorts itself). */\nexport async function listAllReports(db: Db): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .selectAll()\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map(reportRowFromDb);\n}\n\n/** Same contract as the Airtable listReportsForSite — served by idx_reports_site. */\nexport async function listReportsForSite(db: Db, siteId: string): Promise<ReportRow[]> {\n const rows = await db\n .selectFrom(\"reports\")\n .selectAll()\n .where(\"site_id\", \"=\", siteId)\n .orderBy(\"period_start\", \"desc\")\n .orderBy(\"id\")\n .execute();\n return rows.map(reportRowFromDb);\n}\n\n/** Columns a report writer mirrors after its Airtable write (same pattern as\n * mirrorSiteField): the approve/override flow, the resend-webhook's delivery\n * status, and — since #539 Phase 5 — the drafting path's queue flag and a\n * re-run's refreshed scores.\n *\n * `rendered_html` stays deliberately OUT even though the drafting path now\n * writes it: bodies go through `storeRenderedHtml`, so a request handler\n * holding a patch cannot accidentally write megabytes of HTML. `sent_at` and\n * `resend_message_id` stay out because nothing but the send batch writes them\n * and the hourly sync converges those. */\nexport type ReportMirrorPatch = Partial<\n Pick<\n ReportsTable,\n | \"approved_to_send\"\n | \"approved_at\"\n | \"approved_by\"\n | \"send_override\"\n | \"override_reason\"\n | \"override_by\"\n | \"override_at\"\n | \"delivery_status\"\n // Phase 4 report review: the console edits commentary and re-renders the\n // page immediately after the write, so the mirror has to carry it or the\n // operator sees their own save as a no-op until the next hourly sync.\n | \"commentary\"\n // Phase 5 drafting path. `draft_ready` is the queue flag `queueDraft`\n // writes — for the new draft AND for every row it supersedes, so without it\n // the console shows a site with two queued reports until the next sync.\n | \"draft_ready\"\n // A re-run (announce/launch reuse) refreshes an existing row's scores so the\n // eventually-sent email is not stale; the console reads the same numbers.\n | \"lighthouse_performance\"\n | \"lighthouse_accessibility\"\n | \"lighthouse_best_practices\"\n | \"lighthouse_seo\"\n | \"ga_users_current\"\n | \"ga_users_previous\"\n | \"search_found_page1\"\n | \"search_position\"\n | \"completed_on\"\n >\n>;\n\n/** Mirror an Airtable report write into Turso so the page re-render after an\n * approve/override/bounce shows the new state immediately instead of after\n * the next hourly sync. Callers treat a failure as non-fatal (the sync\n * converges it); an empty patch is a no-op, never invalid SQL. */\nexport async function mirrorReportPatch(\n db: Db,\n reportId: string,\n patch: ReportMirrorPatch,\n): Promise<void> {\n if (Object.keys(patch).length === 0) return;\n await db.updateTable(\"reports\").set(patch).where(\"id\", \"=\", reportId).execute();\n}\n\n/** Mirror a NEWLY CREATED Airtable Reports record into Turso (#539 Phase 5).\n *\n * Every other report mirror is an UPDATE, which silently does nothing for a row\n * that does not exist yet — so a draft created at 09:05 was invisible to the\n * Turso-backed console until the 09:20 sync. Takes the raw record Airtable\n * echoed back from the create, and maps it with the IMPORTER's own\n * `mapReportRecord`: parity diffs Turso against exactly that function, so\n * delegating is what makes the mirrored row parity-clean by construction rather\n * than by a column list someone has to remember to extend.\n *\n * Upsert, not insert, and the conflict branch drops `rendered_html` for the\n * same reason the importer's does: the body is written later by a separate\n * sharp-bearing step, so a re-mirror carrying null would blank a render that\n * had already succeeded. */\nexport async function mirrorReportInsert(db: Db, rec: RawRecord): Promise<void> {\n const row = mapReportRecord(rec, null);\n const { rendered_html: _rh, ...rowSansHtml } = row;\n void _rh;\n await db\n .insertInto(\"reports\")\n .values(row)\n .onConflict((oc) => oc.column(\"id\").doUpdateSet(rowSansHtml))\n .execute();\n}\n\n/**\n * Store a freshly rendered report body, replacing whatever was there.\n *\n * Deliberately NOT part of `ReportMirrorPatch`: that patch is the request path's\n * write-through (approve, override, delivery status), and a rendered body is\n * produced by a batch job with sharp, never by a dashboard POST. Keeping it out\n * means a handler cannot accidentally write megabytes of HTML from a request.\n *\n * It REPLACES unconditionally. The importer's `when-missing` mode skips a report\n * that already has a body — correct for an import, wrong here: a refresh whose\n * entire purpose is showing the newest commentary must overwrite.\n */\nexport async function storeRenderedHtml(db: Db, reportId: string, html: string): Promise<void> {\n await db.updateTable(\"reports\").set({ rendered_html: html }).where(\"id\", \"=\", reportId).execute();\n}\n\n/** By rec id (the PK) — approve-report's read. */\nexport async function getReportById(db: Db, id: string): Promise<ReportRow | null> {\n const r = await db.selectFrom(\"reports\").selectAll().where(\"id\", \"=\", id).executeTakeFirst();\n return r ? reportRowFromDb(r) : null;\n}\n\n/** The preview route's read: the stored rendered body, or null when the report\n * has none (imported while its signed URL was expired, or predates rendering). */\nexport async function getReportHtml(\n db: Db,\n id: string,\n): Promise<{ html: string; reportId: string | null } | null> {\n const r = await db\n .selectFrom(\"reports\")\n .select([\"rendered_html\", \"report_id\"])\n .where(\"id\", \"=\", id)\n .executeTakeFirst();\n if (!r || r.rendered_html === null) return null;\n return { html: r.rendered_html, reportId: r.report_id };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -720,9 +720,8 @@ var AnalyzeSchema = z.object({
|
|
|
720
720
|
businessName: z.string(),
|
|
721
721
|
business: z.string(),
|
|
722
722
|
entityClarity: z.object({ score: z.number().min(0).max(100), missing: z.array(z.string()) }),
|
|
723
|
-
// 6-10, not just "an array":
|
|
724
|
-
//
|
|
725
|
-
// starving that stage of questions to ask.
|
|
723
|
+
// 6-10, not just "an array": a thin or empty response must fail loudly here
|
|
724
|
+
// rather than quietly starving the report's Answers section.
|
|
726
725
|
buyerQuestions: z.array(
|
|
727
726
|
z.object({
|
|
728
727
|
question: z.string(),
|
|
@@ -732,6 +731,14 @@ var AnalyzeSchema = z.object({
|
|
|
732
731
|
evidence: z.string().nullable()
|
|
733
732
|
})
|
|
734
733
|
).min(6).max(10),
|
|
734
|
+
// Seeds the live-search probes in the next stage. Deliberately NOT the same
|
|
735
|
+
// strings as buyerQuestions: those are written about THIS site and read
|
|
736
|
+
// correctly only beside it ("What services does this agency offer?"), so as
|
|
737
|
+
// standalone searches they are unanswerable — proved in production, where an
|
|
738
|
+
// engine handed one replied "I don't have any context about who 'they'
|
|
739
|
+
// refers to" and the category score collapsed to a measurement of our own
|
|
740
|
+
// malformed prompt. A probe query must stand alone with no antecedent.
|
|
741
|
+
categoryQueries: z.array(z.string()).min(3).max(5),
|
|
735
742
|
// No documented floor (a clean site may legitimately need none), but an
|
|
736
743
|
// unbounded array had no cost/context ceiling either — a report's fix list
|
|
737
744
|
// is a prioritized top set, not an exhaustive audit, so it's bounded the
|
|
@@ -780,6 +787,14 @@ Return:
|
|
|
780
787
|
the site answers it (yes/partial/no), whether there is a passage an AI could quote verbatim, the page
|
|
781
788
|
it lives on, and the evidence quote. evidence must be an EXACT substring of that page's quoted text \u2014
|
|
782
789
|
copied verbatim, never paraphrased or invented \u2014 or null when no exact quote supports the answer.
|
|
790
|
+
- categoryQueries: 3-5 searches a buyer types BEFORE they have heard of this company, chosen so that
|
|
791
|
+
this company deserves to appear in the results. Each one is sent verbatim to a live answer engine on
|
|
792
|
+
its own, with no other context, so it must stand alone: name the service and the place or the
|
|
793
|
+
qualifier a buyer would use ("packaging design agency Los Angeles", "how much does a rebrand cost").
|
|
794
|
+
Never refer to the company \u2014 not by name, and not as "this agency", "they", "them" or "you". A query
|
|
795
|
+
that names the company measures nothing (the engine just echoes the name back); a query that points
|
|
796
|
+
at it with a pronoun has no antecedent and the engine will answer that it does not know who is meant.
|
|
797
|
+
These are searches, not conversational questions, and they are not the buyerQuestions above.
|
|
783
798
|
- fixes: prioritized, concrete, specific to this site. No generic SEO advice.
|
|
784
799
|
- narrative: two or three plain sentences per report section, addressed to the business owner. No
|
|
785
800
|
jargon, no hedging.`;
|
|
@@ -909,6 +924,7 @@ async function analyzeSite(url, crawl, checks, deps = defaultAnalyzeDeps()) {
|
|
|
909
924
|
// src/prospect/probes.ts
|
|
910
925
|
var MAX_QUERIES = 8;
|
|
911
926
|
var SNIPPET_CHARS = 300;
|
|
927
|
+
var PROBE_MODEL = "claude-sonnet-5";
|
|
912
928
|
function domainOf(raw) {
|
|
913
929
|
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
914
930
|
try {
|
|
@@ -933,7 +949,7 @@ function buildQueries(input) {
|
|
|
933
949
|
const candidates = [
|
|
934
950
|
{ query: `who is ${name}`, kind: "branded" },
|
|
935
951
|
{ query: `${name} reviews`, kind: "branded" },
|
|
936
|
-
...input.
|
|
952
|
+
...input.categoryQueries.slice(0, 3).map((query) => ({ query, kind: "category" })),
|
|
937
953
|
...input.competitors.slice(0, 2).map((c) => ({ query: `${name} vs ${c}`, kind: "competitor" }))
|
|
938
954
|
];
|
|
939
955
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -945,6 +961,16 @@ function buildQueries(input) {
|
|
|
945
961
|
}
|
|
946
962
|
return deduped.slice(0, MAX_QUERIES);
|
|
947
963
|
}
|
|
964
|
+
function escapeRegExp(s) {
|
|
965
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
966
|
+
}
|
|
967
|
+
function mentionsBrand(answer, brand) {
|
|
968
|
+
if (!brand) return false;
|
|
969
|
+
return new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand)}($|[^a-z0-9])`, "i").test(answer);
|
|
970
|
+
}
|
|
971
|
+
function isDistinctiveName(brand) {
|
|
972
|
+
return /\s/.test(brand) || brand.includes(".");
|
|
973
|
+
}
|
|
948
974
|
function isRateLimited(err) {
|
|
949
975
|
const message = err instanceof Error ? err.message : String(err);
|
|
950
976
|
return /429|529/.test(message);
|
|
@@ -978,7 +1004,7 @@ async function runVisibilityProbes(input, engines, opts = {}) {
|
|
|
978
1004
|
}
|
|
979
1005
|
const citedDomains = reply.citedDomains.map(domainOf);
|
|
980
1006
|
const domainCited = citedDomains.some((d) => isSameSite(d, prospect));
|
|
981
|
-
const brandMentioned =
|
|
1007
|
+
const brandMentioned = mentionsBrand(reply.answer.toLowerCase(), brand);
|
|
982
1008
|
for (const d of citedDomains) {
|
|
983
1009
|
if (isSameSite(d, prospect)) continue;
|
|
984
1010
|
competitorCounts.set(d, (competitorCounts.get(d) ?? 0) + 1);
|
|
@@ -1002,7 +1028,10 @@ async function runVisibilityProbes(input, engines, opts = {}) {
|
|
|
1002
1028
|
throw new Error("no visibility engine returned an answer");
|
|
1003
1029
|
}
|
|
1004
1030
|
const categoryAnswers = answers.filter((a) => a.kind === "category");
|
|
1005
|
-
const
|
|
1031
|
+
const nameIsDistinctive = isDistinctiveName(brand);
|
|
1032
|
+
const visibleCategory = categoryAnswers.filter(
|
|
1033
|
+
(a) => a.domainCited || a.brandMentioned && nameIsDistinctive
|
|
1034
|
+
).length;
|
|
1006
1035
|
const visibilityScore = categoryAnswers.length === 0 ? null : Math.round(visibleCategory / categoryAnswers.length * 100);
|
|
1007
1036
|
const brandedRecognized = answers.some((a) => a.kind === "branded" && a.domainCited);
|
|
1008
1037
|
return {
|
|
@@ -1050,7 +1079,7 @@ function claudeWebSearchEngine(createMessage) {
|
|
|
1050
1079
|
const collected = [];
|
|
1051
1080
|
for (let turn = 0; turn < MAX_CLAUDE_TURNS; turn++) {
|
|
1052
1081
|
const res = await create({
|
|
1053
|
-
model:
|
|
1082
|
+
model: PROBE_MODEL,
|
|
1054
1083
|
max_tokens: 4e3,
|
|
1055
1084
|
tools: [{ type: "web_search_20260209", name: "web_search", max_uses: 4 }],
|
|
1056
1085
|
messages
|
|
@@ -1175,7 +1204,7 @@ async function runProspectAudit(url, opts, deps = {}) {
|
|
|
1175
1204
|
{
|
|
1176
1205
|
url,
|
|
1177
1206
|
business: businessName ?? "",
|
|
1178
|
-
|
|
1207
|
+
categoryQueries: analyze.ok ? analyze.data.categoryQueries : [],
|
|
1179
1208
|
competitors: opts.competitors ?? []
|
|
1180
1209
|
},
|
|
1181
1210
|
deps.engines ?? defaultEngines(),
|
|
@@ -1206,4 +1235,4 @@ export {
|
|
|
1206
1235
|
ANALYZE_SKIPPED,
|
|
1207
1236
|
runProspectAudit
|
|
1208
1237
|
};
|
|
1209
|
-
//# sourceMappingURL=chunk-
|
|
1238
|
+
//# sourceMappingURL=chunk-HSI6Q7MI.js.map
|