@prenta/core 5.8.1 → 5.10.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.
Files changed (201) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actions.d.ts +5 -0
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +35 -3
  5. package/dist/actions.js.map +1 -1
  6. package/dist/ai/brand-voice-alignment.d.ts.map +1 -1
  7. package/dist/ai/brand-voice-alignment.js +2 -0
  8. package/dist/ai/brand-voice-alignment.js.map +1 -1
  9. package/dist/ai/brand-voice-analyze.d.ts.map +1 -1
  10. package/dist/ai/brand-voice-analyze.js +1 -0
  11. package/dist/ai/brand-voice-analyze.js.map +1 -1
  12. package/dist/ai/index.d.ts +1 -1
  13. package/dist/ai/index.d.ts.map +1 -1
  14. package/dist/ai/index.js +1 -1
  15. package/dist/ai/index.js.map +1 -1
  16. package/dist/ai/runtime.d.ts +40 -2
  17. package/dist/ai/runtime.d.ts.map +1 -1
  18. package/dist/ai/runtime.js +60 -0
  19. package/dist/ai/runtime.js.map +1 -1
  20. package/dist/api/handler-factory.d.ts.map +1 -1
  21. package/dist/api/handler-factory.js +15 -6
  22. package/dist/api/handler-factory.js.map +1 -1
  23. package/dist/api/handlers.d.ts.map +1 -1
  24. package/dist/api/handlers.js +2 -0
  25. package/dist/api/handlers.js.map +1 -1
  26. package/dist/api/route-helpers.d.ts +8 -13
  27. package/dist/api/route-helpers.d.ts.map +1 -1
  28. package/dist/api/route-helpers.js +19 -39
  29. package/dist/api/route-helpers.js.map +1 -1
  30. package/dist/api/routes/scheduling.d.ts.map +1 -1
  31. package/dist/api/routes/scheduling.js +8 -1
  32. package/dist/api/routes/scheduling.js.map +1 -1
  33. package/dist/api/routes/seo-geo.d.ts +23 -0
  34. package/dist/api/routes/seo-geo.d.ts.map +1 -0
  35. package/dist/api/routes/seo-geo.js +188 -0
  36. package/dist/api/routes/seo-geo.js.map +1 -0
  37. package/dist/api/routes/seo.d.ts.map +1 -1
  38. package/dist/api/routes/seo.js +49 -4
  39. package/dist/api/routes/seo.js.map +1 -1
  40. package/dist/astro-redirects.d.ts +5 -0
  41. package/dist/astro-redirects.d.ts.map +1 -1
  42. package/dist/astro-redirects.js +13 -1
  43. package/dist/astro-redirects.js.map +1 -1
  44. package/dist/config/collection-lookup.d.ts +21 -0
  45. package/dist/config/collection-lookup.d.ts.map +1 -0
  46. package/dist/config/collection-lookup.js +44 -0
  47. package/dist/config/collection-lookup.js.map +1 -0
  48. package/dist/cron/index.d.ts +8 -0
  49. package/dist/cron/index.d.ts.map +1 -1
  50. package/dist/cron/index.js +21 -0
  51. package/dist/cron/index.js.map +1 -1
  52. package/dist/db/model-types.d.ts +21 -0
  53. package/dist/db/model-types.d.ts.map +1 -1
  54. package/dist/forms/attribution.d.ts +6 -2
  55. package/dist/forms/attribution.d.ts.map +1 -1
  56. package/dist/forms/attribution.js +5 -0
  57. package/dist/forms/attribution.js.map +1 -1
  58. package/dist/forms/index.d.ts +2 -2
  59. package/dist/forms/index.d.ts.map +1 -1
  60. package/dist/forms/index.js +1 -1
  61. package/dist/forms/index.js.map +1 -1
  62. package/dist/generated/browser.d.ts +18 -0
  63. package/dist/generated/client.d.ts +18 -0
  64. package/dist/generated/internal/class.d.ts +22 -0
  65. package/dist/generated/internal/class.js +4 -4
  66. package/dist/generated/internal/prismaNamespace.d.ts +187 -1
  67. package/dist/generated/internal/prismaNamespace.js +32 -0
  68. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +36 -0
  69. package/dist/generated/internal/prismaNamespaceBrowser.js +32 -0
  70. package/dist/generated/models/GeoCrawlDaily.d.ts +1144 -0
  71. package/dist/generated/models/GeoCrawlDaily.js +1 -0
  72. package/dist/generated/models/GeoReferralDaily.d.ts +1116 -0
  73. package/dist/generated/models/GeoReferralDaily.js +1 -0
  74. package/dist/generated/models.d.ts +2 -0
  75. package/dist/index.d.ts +7 -4
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +5 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/public-geo.d.ts +39 -0
  80. package/dist/public-geo.d.ts.map +1 -0
  81. package/dist/public-geo.js +79 -0
  82. package/dist/public-geo.js.map +1 -0
  83. package/dist/security/rate-limit-policy.d.ts +23 -0
  84. package/dist/security/rate-limit-policy.d.ts.map +1 -1
  85. package/dist/security/rate-limit-policy.js +36 -2
  86. package/dist/security/rate-limit-policy.js.map +1 -1
  87. package/dist/seo/ai-bots.d.ts +38 -7
  88. package/dist/seo/ai-bots.d.ts.map +1 -1
  89. package/dist/seo/ai-bots.js +82 -27
  90. package/dist/seo/ai-bots.js.map +1 -1
  91. package/dist/seo/ai-crawler-log.d.ts +2 -0
  92. package/dist/seo/ai-crawler-log.d.ts.map +1 -1
  93. package/dist/seo/ai-crawler-log.js +19 -1
  94. package/dist/seo/ai-crawler-log.js.map +1 -1
  95. package/dist/seo/ai-referrers.d.ts +25 -0
  96. package/dist/seo/ai-referrers.d.ts.map +1 -0
  97. package/dist/seo/ai-referrers.js +96 -0
  98. package/dist/seo/ai-referrers.js.map +1 -0
  99. package/dist/seo/audit-engine.d.ts +5 -0
  100. package/dist/seo/audit-engine.d.ts.map +1 -1
  101. package/dist/seo/audit-engine.js +63 -0
  102. package/dist/seo/audit-engine.js.map +1 -1
  103. package/dist/seo/audit-runner.d.ts.map +1 -1
  104. package/dist/seo/audit-runner.js +23 -1
  105. package/dist/seo/audit-runner.js.map +1 -1
  106. package/dist/seo/autopilot-gate.d.ts.map +1 -1
  107. package/dist/seo/autopilot-gate.js +30 -2
  108. package/dist/seo/autopilot-gate.js.map +1 -1
  109. package/dist/seo/autopilot-policy.d.ts +2 -2
  110. package/dist/seo/autopilot-policy.d.ts.map +1 -1
  111. package/dist/seo/autopilot-policy.js +1 -0
  112. package/dist/seo/autopilot-policy.js.map +1 -1
  113. package/dist/seo/autopilot-revert.d.ts.map +1 -1
  114. package/dist/seo/autopilot-revert.js +5 -2
  115. package/dist/seo/autopilot-revert.js.map +1 -1
  116. package/dist/seo/autopilot.d.ts.map +1 -1
  117. package/dist/seo/autopilot.js +18 -11
  118. package/dist/seo/autopilot.js.map +1 -1
  119. package/dist/seo/config-store.d.ts +4 -0
  120. package/dist/seo/config-store.d.ts.map +1 -1
  121. package/dist/seo/config-store.js +3 -0
  122. package/dist/seo/config-store.js.map +1 -1
  123. package/dist/seo/digest.d.ts +23 -0
  124. package/dist/seo/digest.d.ts.map +1 -1
  125. package/dist/seo/digest.js +89 -3
  126. package/dist/seo/digest.js.map +1 -1
  127. package/dist/seo/faq-eligibility.d.ts +32 -0
  128. package/dist/seo/faq-eligibility.d.ts.map +1 -0
  129. package/dist/seo/faq-eligibility.js +61 -0
  130. package/dist/seo/faq-eligibility.js.map +1 -0
  131. package/dist/seo/faq-schema.d.ts +13 -0
  132. package/dist/seo/faq-schema.d.ts.map +1 -0
  133. package/dist/seo/faq-schema.js +41 -0
  134. package/dist/seo/faq-schema.js.map +1 -0
  135. package/dist/seo/faq-signals.d.ts +54 -0
  136. package/dist/seo/faq-signals.d.ts.map +1 -0
  137. package/dist/seo/faq-signals.js +176 -0
  138. package/dist/seo/faq-signals.js.map +1 -0
  139. package/dist/seo/geo-record.d.ts +75 -0
  140. package/dist/seo/geo-record.d.ts.map +1 -0
  141. package/dist/seo/geo-record.js +252 -0
  142. package/dist/seo/geo-record.js.map +1 -0
  143. package/dist/seo/geo-report.d.ts +96 -0
  144. package/dist/seo/geo-report.d.ts.map +1 -0
  145. package/dist/seo/geo-report.js +315 -0
  146. package/dist/seo/geo-report.js.map +1 -0
  147. package/dist/seo/geo-retention.d.ts +19 -0
  148. package/dist/seo/geo-retention.d.ts.map +1 -0
  149. package/dist/seo/geo-retention.js +23 -0
  150. package/dist/seo/geo-retention.js.map +1 -0
  151. package/dist/seo/index.d.ts +10 -3
  152. package/dist/seo/index.d.ts.map +1 -1
  153. package/dist/seo/index.js +5 -1
  154. package/dist/seo/index.js.map +1 -1
  155. package/dist/seo/issue-fix-ai.d.ts +34 -5
  156. package/dist/seo/issue-fix-ai.d.ts.map +1 -1
  157. package/dist/seo/issue-fix-ai.js +59 -23
  158. package/dist/seo/issue-fix-ai.js.map +1 -1
  159. package/dist/seo/issue-fix-apply.d.ts +32 -2
  160. package/dist/seo/issue-fix-apply.d.ts.map +1 -1
  161. package/dist/seo/issue-fix-apply.js +231 -3
  162. package/dist/seo/issue-fix-apply.js.map +1 -1
  163. package/dist/seo/issue-fix-faq.d.ts +131 -0
  164. package/dist/seo/issue-fix-faq.d.ts.map +1 -0
  165. package/dist/seo/issue-fix-faq.js +455 -0
  166. package/dist/seo/issue-fix-faq.js.map +1 -0
  167. package/dist/seo/issue-fix-verify.d.ts +9 -0
  168. package/dist/seo/issue-fix-verify.d.ts.map +1 -1
  169. package/dist/seo/issue-fix-verify.js +61 -0
  170. package/dist/seo/issue-fix-verify.js.map +1 -1
  171. package/dist/seo/issue-fix.d.ts +5 -2
  172. package/dist/seo/issue-fix.d.ts.map +1 -1
  173. package/dist/seo/issue-fix.js +35 -0
  174. package/dist/seo/issue-fix.js.map +1 -1
  175. package/dist/seo/page-meta.d.ts +8 -0
  176. package/dist/seo/page-meta.d.ts.map +1 -1
  177. package/dist/seo/page-meta.js +85 -12
  178. package/dist/seo/page-meta.js.map +1 -1
  179. package/dist/seo/proposals-bulk-apply.d.ts +6 -3
  180. package/dist/seo/proposals-bulk-apply.d.ts.map +1 -1
  181. package/dist/seo/proposals-bulk-apply.js +1 -0
  182. package/dist/seo/proposals-bulk-apply.js.map +1 -1
  183. package/dist/seo/proposals.d.ts +24 -0
  184. package/dist/seo/proposals.d.ts.map +1 -1
  185. package/dist/seo/proposals.js +35 -4
  186. package/dist/seo/proposals.js.map +1 -1
  187. package/dist/seo/search-console/sync.d.ts +8 -0
  188. package/dist/seo/search-console/sync.d.ts.map +1 -1
  189. package/dist/seo/search-console/sync.js +6 -3
  190. package/dist/seo/search-console/sync.js.map +1 -1
  191. package/dist/seo/seo-field-values.d.ts +18 -0
  192. package/dist/seo/seo-field-values.d.ts.map +1 -0
  193. package/dist/seo/seo-field-values.js +25 -0
  194. package/dist/seo/seo-field-values.js.map +1 -0
  195. package/dist/site.d.ts +5 -0
  196. package/dist/site.d.ts.map +1 -1
  197. package/dist/site.js +3 -0
  198. package/dist/site.js.map +1 -1
  199. package/package.json +1 -1
  200. package/prisma/migrations/0018_geo_daily/migration.sql +35 -0
  201. package/prisma/schema.prisma +41 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * GEO measurement write path: daily (date, bot, path) crawl buckets and
3
+ * (date, engine, path) referral buckets (spec 2026-09-08 §3).
4
+ *
5
+ * Both writers are best-effort — a storage failure or a consumer schema that
6
+ * predates the tables must never break a public response. Server-side callers
7
+ * (`recordAiCrawlerHit`, the beacon route) DO await them: on serverless an
8
+ * un-awaited write may be dropped when the response ends. The fire-and-forget
9
+ * part of the pipeline is the site middleware's beacon `fetch`, not these.
10
+ *
11
+ * The kill switch (`SeoModuleSettings.geoMeasurementEnabled`) is enforced
12
+ * *here*, inside the writers, so it covers every caller — the public beacon and
13
+ * the server-side surfaces (`/llms.txt`, `/llms-full.txt`, `.md`, honeypot)
14
+ * alike. See {@link isGeoMeasurementEnabled}.
15
+ *
16
+ * Query budget (spec §3): an existing `(date, actor, path)` row costs exactly
17
+ * one `updateMany`; only a new path pays for the memoised distinct-path count
18
+ * (one `count` per `(date, actor)` per {@link GEO_CAP_MEMO_MS}) plus one
19
+ * `upsert`. The settings read behind the kill switch is memoised for
20
+ * {@link GEO_SETTINGS_MEMO_MS}.
21
+ */
22
+ import { hasModel } from '../api/route-helpers.js';
23
+ import { normalizeRedirectPath } from '../redirects/graph.js';
24
+ import { startOfDayUTC } from './autopilot-ledger.js';
25
+ import { getSeoOverrides, resolveSeoModuleSettings } from './config-store.js';
26
+ export { GEO_RETENTION_DEFAULT_DAYS, GEO_RETENTION_MAX_DAYS, GEO_RETENTION_MIN_DAYS, clampGeoRetentionDays, } from './geo-retention.js';
27
+ /**
28
+ * Max distinct paths per `(date, bot)` — the rest of that bot's day rolls into
29
+ * {@link GEO_OTHER_PATH}, so a crawler (or an attacker replaying the public
30
+ * beacon) cannot grow the table by inventing paths.
31
+ *
32
+ * This is a **soft bound**: the distinct-path count is memoised per isolate
33
+ * for {@link GEO_CAP_MEMO_MS}, so N cold isolates (serverless) that each read
34
+ * a below-cap count in the same minute can together overshoot to ≈ N × cap
35
+ * rows for that `(date, actor)` before their memos expire. Bounded, not exact.
36
+ */
37
+ export const GEO_CRAWL_PATH_CAP = 500;
38
+ /**
39
+ * Same cap and the same soft-bound caveat, keyed by `(date, engine)`. Referral
40
+ * beacons are as unauthenticated as crawl beacons, so "a path must exist on
41
+ * the site to be linked" is not a property the server can rely on.
42
+ */
43
+ export const GEO_REFERRAL_PATH_CAP = GEO_CRAWL_PATH_CAP;
44
+ export const GEO_OTHER_PATH = '(other)';
45
+ /** How long a `(date, actor)` distinct-path count is trusted before re-counting. */
46
+ export const GEO_CAP_MEMO_MS = 60_000;
47
+ /** How long the kill-switch setting is trusted before re-reading it. */
48
+ export const GEO_SETTINGS_MEMO_MS = 30_000;
49
+ /** Minimum gap between two storage-failure `console.warn`s per process. */
50
+ export const GEO_WARN_THROTTLE_MS = 60_000;
51
+ export const GEO_MAX_PATH_LEN = 2048;
52
+ export const GEO_MAX_ACTOR_LEN = 64;
53
+ /** Largest `POST /geo/beacon` body accepted; a real payload is under 200 bytes. */
54
+ export const GEO_MAX_BEACON_BYTES = 4096;
55
+ /** UTC calendar day — the same convention as `SearchPerfDaily`. */
56
+ export function geoDay(now) {
57
+ return startOfDayUTC(now);
58
+ }
59
+ /**
60
+ * Characters a percent-encoded `url.pathname` can contain: RFC 3986 unreserved
61
+ * (`A-Z a-z 0-9 - . _ ~`) plus `/` and the `%` of an escape. The middleware
62
+ * sends `url.pathname` verbatim (never decoded), so spaces, quotes, unicode,
63
+ * angle brackets and sub-delims can only arrive from a hand-crafted beacon.
64
+ */
65
+ const GEO_PATH_CHARS = /^[A-Za-z0-9/_\-.~%]*$/;
66
+ /**
67
+ * Site-relative path for storage: lower-cased, no query/hash, no trailing
68
+ * slash. Absolute URLs, over-long inputs and anything outside
69
+ * {@link GEO_PATH_CHARS} are rejected (`null` → the hit is dropped).
70
+ */
71
+ export function normalizeGeoPath(input) {
72
+ if (typeof input !== 'string' || input.length > GEO_MAX_PATH_LEN)
73
+ return null;
74
+ const trimmed = input.trim();
75
+ // absolute (`https://…`) and protocol-relative (`//host/…`) URLs are not paths
76
+ if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('//'))
77
+ return null;
78
+ const normalized = normalizeRedirectPath(input);
79
+ if (!normalized.startsWith('/') || normalized.startsWith('//'))
80
+ return null;
81
+ return GEO_PATH_CHARS.test(normalized) ? normalized : null;
82
+ }
83
+ // ─── Kill-switch memo (one settings read per 30 s per process) ───────────────
84
+ let settingsMemo = null;
85
+ /** Test hook; also called after a successful settings write so it takes effect at once. */
86
+ export function resetGeoSettingsMemo() {
87
+ settingsMemo = null;
88
+ }
89
+ /**
90
+ * `SeoModuleSettings.geoMeasurementEnabled`, memoised for
91
+ * {@link GEO_SETTINGS_MEMO_MS}. Both writers consult it, so flipping the switch
92
+ * stops server-side recording as well as the beacon. `getSeoOverrides` never
93
+ * throws (it returns `null` on any read failure), which resolves to the
94
+ * default — measurement on.
95
+ */
96
+ export async function isGeoMeasurementEnabled(db) {
97
+ const now = Date.now();
98
+ if (settingsMemo && now - settingsMemo.at < GEO_SETTINGS_MEMO_MS)
99
+ return settingsMemo.enabled;
100
+ const s = resolveSeoModuleSettings(await getSeoOverrides(db));
101
+ settingsMemo = { enabled: s.geoMeasurementEnabled, at: now };
102
+ return s.geoMeasurementEnabled;
103
+ }
104
+ // ─── Warn throttle ───────────────────────────────────────────────────────────
105
+ let lastWarnAt = 0;
106
+ /** Test hook. */
107
+ export function resetGeoWarnThrottle() {
108
+ lastWarnAt = 0;
109
+ }
110
+ /**
111
+ * One `console.warn` per {@link GEO_WARN_THROTTLE_MS} per process, shared by
112
+ * both writers: a bot storm against a broken database must not flood the log
113
+ * with one line per dropped hit (spec "Failure handling").
114
+ */
115
+ function warnThrottled(message, err) {
116
+ const now = Date.now();
117
+ if (now - lastWarnAt < GEO_WARN_THROTTLE_MS)
118
+ return;
119
+ lastWarnAt = now;
120
+ console.warn(message, err);
121
+ }
122
+ let capMemo = new Map();
123
+ /** Test hook. */
124
+ export function resetGeoCapMemo() {
125
+ capMemo = new Map();
126
+ }
127
+ const MEMO_KEY_SEP = '|';
128
+ /** Day first so {@link evictOtherDays} can read it off the key. */
129
+ function memoKey(date, kind, actor) {
130
+ return [date.toISOString(), kind, actor].join(MEMO_KEY_SEP);
131
+ }
132
+ /**
133
+ * Drops memo entries for any day other than `date`. Called when a new entry is
134
+ * inserted, so the map never grows past (actors seen today); the map is small,
135
+ * so a linear sweep is fine.
136
+ */
137
+ function evictOtherDays(date) {
138
+ const today = date.toISOString();
139
+ for (const key of capMemo.keys()) {
140
+ if (key.slice(0, key.indexOf(MEMO_KEY_SEP)) !== today)
141
+ capMemo.delete(key);
142
+ }
143
+ }
144
+ async function distinctPathCount(spec) {
145
+ const key = memoKey(spec.date, spec.kind, spec.actor);
146
+ const memo = capMemo.get(key);
147
+ if (memo && spec.nowMs - memo.at < GEO_CAP_MEMO_MS)
148
+ return memo.count;
149
+ const count = await spec.delegate.count({
150
+ where: { date: spec.date, [spec.actorField]: spec.actor },
151
+ });
152
+ evictOtherDays(spec.date);
153
+ capMemo.set(key, { count, at: spec.nowMs });
154
+ return count;
155
+ }
156
+ function bumpMemo(spec) {
157
+ const memo = capMemo.get(memoKey(spec.date, spec.kind, spec.actor));
158
+ if (memo)
159
+ memo.count += 1;
160
+ }
161
+ /**
162
+ * Upsert one `(date, actor, path)` bucket under the per-`(date, actor)`
163
+ * distinct-path cap. Shared by both writers so the cap, memo and overflow
164
+ * semantics cannot drift between crawls and referrals. Throws on storage
165
+ * failure — the callers translate that into `'skipped'`.
166
+ */
167
+ async function recordDailyBucket(spec) {
168
+ const { delegate, date, actorField, actor, path, counterField, extra = {} } = spec;
169
+ const actorWhere = { date, [actorField]: actor };
170
+ const touch = { [counterField]: { increment: 1 }, ...extra };
171
+ // Hot path: an existing bucket is a single UPDATE … WHERE (date, actor, path).
172
+ const { count: updated } = await delegate.updateMany({
173
+ where: { ...actorWhere, path },
174
+ data: touch,
175
+ });
176
+ if (updated > 0)
177
+ return 'recorded';
178
+ // New path for this (date, actor): consult the memoised distinct-path count.
179
+ const distinct = await distinctPathCount(spec);
180
+ const capped = distinct >= spec.cap;
181
+ const target = capped ? GEO_OTHER_PATH : path;
182
+ // upsert (not create) so a concurrent insert of the same key increments
183
+ // instead of failing on the unique constraint.
184
+ await delegate.upsert({
185
+ where: { [`date_${actorField}_path`]: { ...actorWhere, path: target } },
186
+ update: touch,
187
+ create: { ...actorWhere, path: target, [counterField]: 1, ...extra },
188
+ });
189
+ if (capped)
190
+ return 'other';
191
+ bumpMemo(spec);
192
+ return 'recorded';
193
+ }
194
+ // ─── Writers ─────────────────────────────────────────────────────────────────
195
+ export async function recordGeoCrawl(db, input) {
196
+ if (!db || !hasModel(db, 'geoCrawlDaily'))
197
+ return 'skipped';
198
+ const path = normalizeGeoPath(input.path);
199
+ if (!path)
200
+ return 'skipped';
201
+ const now = input.now ?? new Date();
202
+ try {
203
+ if (!(await isGeoMeasurementEnabled(db)))
204
+ return 'skipped';
205
+ return await recordDailyBucket({
206
+ kind: 'crawl',
207
+ delegate: db.geoCrawlDaily,
208
+ date: geoDay(now),
209
+ actorField: 'bot',
210
+ actor: input.bot,
211
+ path,
212
+ counterField: 'hits',
213
+ cap: GEO_CRAWL_PATH_CAP,
214
+ extra: { category: input.category },
215
+ nowMs: now.getTime(),
216
+ });
217
+ }
218
+ catch (err) {
219
+ // best-effort — this runs behind public surfaces and must never throw
220
+ warnThrottled('[prenta/seo] recordGeoCrawl failed:', err);
221
+ return 'skipped';
222
+ }
223
+ }
224
+ export async function recordGeoReferral(db, input) {
225
+ if (!db || !hasModel(db, 'geoReferralDaily'))
226
+ return 'skipped';
227
+ const path = normalizeGeoPath(input.path);
228
+ if (!path)
229
+ return 'skipped';
230
+ const now = input.now ?? new Date();
231
+ try {
232
+ if (!(await isGeoMeasurementEnabled(db)))
233
+ return 'skipped';
234
+ return await recordDailyBucket({
235
+ kind: 'referral',
236
+ delegate: db.geoReferralDaily,
237
+ date: geoDay(now),
238
+ actorField: 'engine',
239
+ actor: input.engine,
240
+ path,
241
+ counterField: 'visits',
242
+ cap: GEO_REFERRAL_PATH_CAP,
243
+ nowMs: now.getTime(),
244
+ });
245
+ }
246
+ catch (err) {
247
+ // best-effort — this runs behind public surfaces and must never throw
248
+ warnThrottled('[prenta/seo] recordGeoReferral failed:', err);
249
+ return 'skipped';
250
+ }
251
+ }
252
+ //# sourceMappingURL=geo-record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"geo-record.js","sourceRoot":"","sources":["../../src/seo/geo-record.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,96 @@
1
+ import type { PrentaCMSConfig } from '../config/types.js';
2
+ import { type AiBotCategory } from './ai-bots.js';
3
+ import type { SeoModuleSettings } from './config-store.js';
4
+ export declare const GEO_REPORT_WINDOWS: readonly [7, 28, 90];
5
+ export type GeoReportDays = (typeof GEO_REPORT_WINDOWS)[number];
6
+ export declare const GEO_TOP_PATHS = 20;
7
+ /** `days` query param → one of the three windows (default 28). */
8
+ export declare function clampGeoDays(raw: string | number | null | undefined): GeoReportDays;
9
+ /**
10
+ * `blocked_still_crawling` = robots.txt disallows this bot's category, yet it
11
+ * was observed in the window — the signal the UI surfaces most prominently.
12
+ */
13
+ export type GeoBotPolicy = 'allowed' | 'blocked' | 'blocked_still_crawling';
14
+ /**
15
+ * Why `enabled` is `false`: the kill switch in SEO settings is off
16
+ * (`settings`), or the GEO tables are not in the connected schema yet
17
+ * (`unavailable` — migration `0018_geo_daily` has not been deployed).
18
+ */
19
+ export type GeoDisabledReason = 'settings' | 'unavailable';
20
+ export interface SeoGeoReport {
21
+ enabled: boolean;
22
+ /** Present only when `enabled` is `false`. */
23
+ disabledReason?: GeoDisabledReason;
24
+ window: {
25
+ from: string;
26
+ to: string;
27
+ days: number;
28
+ };
29
+ /** ISO date of the earliest row across both tables, or null. */
30
+ firstSeenAt: string | null;
31
+ crawl: {
32
+ /**
33
+ * Whole-window crawl volume: every row in the window, including the
34
+ * `GEO_OTHER_PATH` bucket and (for `prevHits`) bots that crawled only in
35
+ * the prior window and therefore have no entry in `bots`.
36
+ */
37
+ total: {
38
+ hits: number;
39
+ prevHits: number | null;
40
+ };
41
+ series: Array<{
42
+ date: string;
43
+ hits: Record<string, number>;
44
+ }>;
45
+ bots: Array<{
46
+ bot: string;
47
+ category: AiBotCategory;
48
+ hits: number;
49
+ prevHits: number | null;
50
+ lastSeenAt: string | null;
51
+ policy: GeoBotPolicy;
52
+ }>;
53
+ topPaths: Array<{
54
+ path: string;
55
+ hits: number;
56
+ bots: string[];
57
+ entityId?: string;
58
+ entityTitle?: string;
59
+ /** Collection slug of the matched document — the admin routes the editor link by it. */
60
+ collection?: string;
61
+ }>;
62
+ /** Hits that rolled into `GEO_OTHER_PATH` under the per-(day, bot) path cap. */
63
+ otherHits: number;
64
+ };
65
+ referrals: {
66
+ /** Whole-window referral volume, including the `GEO_OTHER_PATH` bucket. */
67
+ total: {
68
+ visits: number;
69
+ prevVisits: number | null;
70
+ };
71
+ engines: Array<{
72
+ engine: string;
73
+ visits: number;
74
+ prevVisits: number | null;
75
+ }>;
76
+ /** ≤ `GEO_TOP_PATHS`, excludes `GEO_OTHER_PATH`. */
77
+ topPaths: Array<{
78
+ path: string;
79
+ visits: number;
80
+ engines: string[];
81
+ entityId?: string;
82
+ entityTitle?: string;
83
+ collection?: string;
84
+ }>;
85
+ /** Visits that rolled into `GEO_OTHER_PATH` under the per-(day, engine) path cap. */
86
+ otherVisits: number;
87
+ };
88
+ }
89
+ export interface BuildSeoGeoReportOptions {
90
+ days: GeoReportDays;
91
+ settings: SeoModuleSettings;
92
+ config: PrentaCMSConfig | null | undefined;
93
+ now?: Date;
94
+ }
95
+ export declare function buildSeoGeoReport(db: unknown, opts: BuildSeoGeoReportOptions): Promise<SeoGeoReport>;
96
+ //# sourceMappingURL=geo-report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"geo-report.d.ts","sourceRoot":"","sources":["../../src/seo/geo-report.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,315 @@
1
+ /**
2
+ * `GET /seo/geo` aggregation (spec 2026-09-08-geo-measurement-design.md §5).
3
+ *
4
+ * A bounded number of `groupBy` / `aggregate` passes per table over the
5
+ * current + prior windows (never one query per bot or per path), then
6
+ * in-memory shaping. Prior-window (`prev*`) values are `null` whenever the
7
+ * prior window is not fully covered by data — it starts before `firstSeenAt`
8
+ * or before the retention horizon — so the UI never shows a delta against a
9
+ * partial baseline. Windows are UTC calendar days.
10
+ */
11
+ import { hasModel } from '../api/route-helpers.js';
12
+ import { aiBotStanceFor } from './ai-bots.js';
13
+ import { GEO_OTHER_PATH, geoDay } from './geo-record.js';
14
+ import { buildPathEntityMap } from './search-console/sync.js';
15
+ export const GEO_REPORT_WINDOWS = [7, 28, 90];
16
+ export const GEO_TOP_PATHS = 20;
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+ /** `days` query param → one of the three windows (default 28). */
19
+ export function clampGeoDays(raw) {
20
+ const n = typeof raw === 'string' ? Number.parseInt(raw, 10) : raw;
21
+ return GEO_REPORT_WINDOWS.includes(n) ? n : 28;
22
+ }
23
+ const iso = (d) => new Date(d).toISOString().slice(0, 10);
24
+ const addDays = (d, n) => new Date(d.getTime() + n * DAY_MS);
25
+ /** Locale-pinned so the output order does not depend on the host's ICU data. */
26
+ const byName = (a, b) => a.localeCompare(b, 'en');
27
+ function emptyReport(days, from, to, disabledReason) {
28
+ return {
29
+ enabled: false,
30
+ disabledReason,
31
+ window: { from: iso(from), to: iso(to), days },
32
+ firstSeenAt: null,
33
+ crawl: { total: { hits: 0, prevHits: null }, series: [], bots: [], topPaths: [], otherHits: 0 },
34
+ referrals: {
35
+ total: { visits: 0, prevVisits: null },
36
+ engines: [],
37
+ topPaths: [],
38
+ otherVisits: 0,
39
+ },
40
+ };
41
+ }
42
+ function asCategory(v) {
43
+ return v === 'training' || v === 'retrieval' || v === 'search' ? v : 'retrieval';
44
+ }
45
+ function policyFor(category, hits, aiPolicy, legacy) {
46
+ if (aiBotStanceFor(category, aiPolicy, legacy) === 'allow')
47
+ return 'allowed';
48
+ return hits > 0 ? 'blocked_still_crawling' : 'blocked';
49
+ }
50
+ /** Collect the distinct second-column values per path from a `(path, x)` groupBy. */
51
+ function namesByPath(rows, key) {
52
+ const sets = new Map();
53
+ for (const r of rows) {
54
+ let set = sets.get(r.path);
55
+ if (!set) {
56
+ set = new Set();
57
+ sets.set(r.path, set);
58
+ }
59
+ set.add(r[key]);
60
+ }
61
+ const out = new Map();
62
+ for (const [path, set] of sets)
63
+ out.set(path, Array.from(set).sort(byName));
64
+ return out;
65
+ }
66
+ /* eslint-disable @typescript-eslint/no-explicit-any -- the Prisma client is
67
+ * passed as `unknown` and probed with `hasModel`; delegate shapes vary by
68
+ * consumer schema version. Query results are narrowed to the row interfaces
69
+ * above at each call site. */
70
+ /**
71
+ * Best-effort path → entity join: `SearchPageInsight` first (already
72
+ * path-keyed), then the published-document index for anything missing, then
73
+ * one title lookup. Every step is optional — a failure yields fewer
74
+ * annotations, never an error.
75
+ */
76
+ async function entityLookup(db, config, paths) {
77
+ const out = new Map();
78
+ if (paths.length === 0)
79
+ return out;
80
+ const entityIds = new Map(); // path → entityId
81
+ try {
82
+ if (hasModel(db, 'searchPageInsight')) {
83
+ const rows = await db.searchPageInsight.findMany({
84
+ where: { path: { in: paths }, entityId: { not: null } },
85
+ select: { path: true, entityId: true },
86
+ });
87
+ for (const r of rows)
88
+ if (r.entityId)
89
+ entityIds.set(r.path, r.entityId);
90
+ }
91
+ }
92
+ catch {
93
+ /* insight join is optional */
94
+ }
95
+ const missing = paths.filter((p) => !entityIds.has(p));
96
+ if (missing.length > 0) {
97
+ try {
98
+ const index = await buildPathEntityMap(db, config ?? null);
99
+ for (const p of missing) {
100
+ const hit = index.get(p);
101
+ if (hit)
102
+ entityIds.set(p, hit.entityId);
103
+ }
104
+ }
105
+ catch {
106
+ /* fallback index is optional */
107
+ }
108
+ }
109
+ if (entityIds.size === 0)
110
+ return out;
111
+ const docMeta = new Map();
112
+ try {
113
+ if (hasModel(db, 'document')) {
114
+ const docs = await db.document.findMany({
115
+ where: { id: { in: Array.from(new Set(entityIds.values())) }, deletedAt: null },
116
+ select: { id: true, title: true, collection: true },
117
+ });
118
+ for (const d of docs)
119
+ docMeta.set(d.id, { title: d.title, collection: d.collection });
120
+ }
121
+ }
122
+ catch {
123
+ /* titles / collections are optional */
124
+ }
125
+ for (const [path, entityId] of entityIds) {
126
+ const meta = docMeta.get(entityId);
127
+ const ref = { entityId };
128
+ if (meta?.title)
129
+ ref.entityTitle = meta.title;
130
+ if (meta?.collection)
131
+ ref.collection = meta.collection;
132
+ out.set(path, ref);
133
+ }
134
+ return out;
135
+ }
136
+ export async function buildSeoGeoReport(db, opts) {
137
+ const d = db;
138
+ const now = opts.now ?? new Date();
139
+ const to = geoDay(now);
140
+ const from = addDays(to, -(opts.days - 1));
141
+ const prevTo = addDays(from, -1);
142
+ const prevFrom = addDays(prevTo, -(opts.days - 1));
143
+ if (!d || !hasModel(d, 'geoCrawlDaily') || !hasModel(d, 'geoReferralDaily')) {
144
+ return emptyReport(opts.days, from, to, 'unavailable');
145
+ }
146
+ if (!opts.settings.geoMeasurementEnabled)
147
+ return emptyReport(opts.days, from, to, 'settings');
148
+ // firstSeenAt across both tables (one MIN per table).
149
+ const [firstCrawl, firstRef] = await Promise.all([
150
+ d.geoCrawlDaily.aggregate({ _min: { date: true } }),
151
+ d.geoReferralDaily.aggregate({ _min: { date: true } }),
152
+ ]);
153
+ const firstDates = [];
154
+ for (const agg of [firstCrawl, firstRef]) {
155
+ const date = agg?._min?.date;
156
+ if (date)
157
+ firstDates.push(new Date(date).getTime());
158
+ }
159
+ const firstSeen = firstDates.length > 0 ? new Date(Math.min(...firstDates)) : null;
160
+ // The prior window is comparable only when data plausibly covers all of it:
161
+ // it must start no earlier than max(firstSeenAt, retention horizon). The
162
+ // horizon mirrors what `processCleanup` keeps — it deletes
163
+ // `date < startOfDayUTC(now − N days)`, so the row dated exactly N days ago
164
+ // is the oldest one still present.
165
+ const retentionStart = addDays(to, -opts.settings.geoRetentionDays);
166
+ const earliestAvailable = firstSeen && firstSeen.getTime() > retentionStart.getTime() ? firstSeen : retentionStart;
167
+ const prevComparable = firstSeen !== null && prevFrom.getTime() >= earliestAvailable.getTime();
168
+ const prev = (n) => (prevComparable ? n : null);
169
+ const cur = { date: { gte: from, lte: to } };
170
+ const prior = { date: { gte: prevFrom, lte: prevTo } };
171
+ // ── Crawl ──────────────────────────────────────────────────────────────
172
+ const [seriesRows, botRows, prevBotRows, pathRows, otherAgg] = await Promise.all([
173
+ d.geoCrawlDaily.groupBy({ by: ['date', 'bot'], where: cur, _sum: { hits: true } }),
174
+ d.geoCrawlDaily.groupBy({
175
+ by: ['bot', 'category'],
176
+ where: cur,
177
+ _sum: { hits: true },
178
+ _max: { date: true },
179
+ }),
180
+ d.geoCrawlDaily.groupBy({ by: ['bot'], where: prior, _sum: { hits: true } }),
181
+ d.geoCrawlDaily.groupBy({
182
+ by: ['path'],
183
+ where: { ...cur, path: { not: GEO_OTHER_PATH } },
184
+ _sum: { hits: true },
185
+ orderBy: [{ _sum: { hits: 'desc' } }, { path: 'asc' }],
186
+ take: GEO_TOP_PATHS,
187
+ }),
188
+ d.geoCrawlDaily.aggregate({ where: { ...cur, path: GEO_OTHER_PATH }, _sum: { hits: true } }),
189
+ ]);
190
+ const series = [];
191
+ const byDay = new Map();
192
+ for (let t = from.getTime(); t <= to.getTime(); t += DAY_MS) {
193
+ const key = iso(new Date(t));
194
+ const bucket = {};
195
+ byDay.set(key, bucket);
196
+ series.push({ date: key, hits: bucket });
197
+ }
198
+ for (const r of seriesRows) {
199
+ const bucket = byDay.get(iso(r.date));
200
+ if (bucket)
201
+ bucket[r.bot] = (bucket[r.bot] ?? 0) + (r._sum?.hits ?? 0);
202
+ }
203
+ const prevByBot = new Map();
204
+ let totalPrevHits = 0;
205
+ for (const r of prevBotRows) {
206
+ const hits = r._sum?.hits ?? 0;
207
+ prevByBot.set(r.bot, (prevByBot.get(r.bot) ?? 0) + hits);
208
+ totalPrevHits += hits;
209
+ }
210
+ const aiPolicy = opts.config?.seo?.robots?.aiPolicy;
211
+ const legacyBlock = opts.config?.seo?.robots?.blockAIBots;
212
+ // A bot's category is re-derived from the registry on every write, so a
213
+ // (bot, category) split only happens across a registry change; fold it.
214
+ const botAgg = new Map();
215
+ for (const r of botRows) {
216
+ const hits = r._sum?.hits ?? 0;
217
+ const lastSeen = r._max?.date ? new Date(r._max.date) : null;
218
+ const existing = botAgg.get(r.bot);
219
+ if (!existing) {
220
+ botAgg.set(r.bot, { category: asCategory(r.category), hits, lastSeen });
221
+ continue;
222
+ }
223
+ existing.hits += hits;
224
+ if (lastSeen && (!existing.lastSeen || lastSeen > existing.lastSeen)) {
225
+ existing.lastSeen = lastSeen;
226
+ existing.category = asCategory(r.category);
227
+ }
228
+ }
229
+ const bots = Array.from(botAgg, ([bot, agg]) => ({
230
+ bot,
231
+ category: agg.category,
232
+ hits: agg.hits,
233
+ prevHits: prev(prevByBot.get(bot) ?? 0),
234
+ lastSeenAt: agg.lastSeen ? iso(agg.lastSeen) : null,
235
+ policy: policyFor(agg.category, agg.hits, aiPolicy, legacyBlock),
236
+ })).sort((a, b) => b.hits - a.hits || byName(a.bot, b.bot));
237
+ // `botRows` has no path filter, so this is the whole current window
238
+ // (`GEO_OTHER_PATH` rows included) — unlike `topPaths`, which excludes them.
239
+ const totalHits = bots.reduce((s, b) => s + b.hits, 0);
240
+ const topCrawlPaths = pathRows.map((r) => ({ path: r.path, hits: r._sum?.hits ?? 0 }));
241
+ let crawlBotsByPath = new Map();
242
+ if (topCrawlPaths.length > 0) {
243
+ const rows = await d.geoCrawlDaily.groupBy({
244
+ by: ['path', 'bot'],
245
+ where: { ...cur, path: { in: topCrawlPaths.map((p) => p.path) } },
246
+ });
247
+ crawlBotsByPath = namesByPath(rows, 'bot');
248
+ }
249
+ // ── Referrals ──────────────────────────────────────────────────────────
250
+ const [engineRows, prevEngineRows, refPathRows, refOtherAgg] = await Promise.all([
251
+ d.geoReferralDaily.groupBy({ by: ['engine'], where: cur, _sum: { visits: true } }),
252
+ d.geoReferralDaily.groupBy({ by: ['engine'], where: prior, _sum: { visits: true } }),
253
+ d.geoReferralDaily.groupBy({
254
+ by: ['path'],
255
+ where: { ...cur, path: { not: GEO_OTHER_PATH } },
256
+ _sum: { visits: true },
257
+ orderBy: [{ _sum: { visits: 'desc' } }, { path: 'asc' }],
258
+ take: GEO_TOP_PATHS,
259
+ }),
260
+ d.geoReferralDaily.aggregate({
261
+ where: { ...cur, path: GEO_OTHER_PATH },
262
+ _sum: { visits: true },
263
+ }),
264
+ ]);
265
+ const prevByEngine = new Map();
266
+ for (const r of prevEngineRows) {
267
+ prevByEngine.set(r.engine, (prevByEngine.get(r.engine) ?? 0) + (r._sum?.visits ?? 0));
268
+ }
269
+ const engines = engineRows
270
+ .map((r) => ({
271
+ engine: r.engine,
272
+ visits: r._sum?.visits ?? 0,
273
+ prevVisits: prev(prevByEngine.get(r.engine) ?? 0),
274
+ }))
275
+ .sort((a, b) => b.visits - a.visits || byName(a.engine, b.engine));
276
+ const totalVisits = engines.reduce((s, e) => s + e.visits, 0);
277
+ let totalPrev = 0;
278
+ for (const n of prevByEngine.values())
279
+ totalPrev += n;
280
+ const topRefPaths = refPathRows.map((r) => ({ path: r.path, visits: r._sum?.visits ?? 0 }));
281
+ let enginesByPath = new Map();
282
+ if (topRefPaths.length > 0) {
283
+ const rows = await d.geoReferralDaily.groupBy({
284
+ by: ['path', 'engine'],
285
+ where: { ...cur, path: { in: topRefPaths.map((p) => p.path) } },
286
+ });
287
+ enginesByPath = namesByPath(rows, 'engine');
288
+ }
289
+ // ── Entity join (best-effort) ──────────────────────────────────────────
290
+ const allPaths = Array.from(new Set([...topCrawlPaths.map((p) => p.path), ...topRefPaths.map((p) => p.path)]));
291
+ const entities = await entityLookup(d, opts.config, allPaths);
292
+ const withEntity = (row) => {
293
+ const e = entities.get(row.path);
294
+ return e ? { ...row, ...e } : row;
295
+ };
296
+ return {
297
+ enabled: true,
298
+ window: { from: iso(from), to: iso(to), days: opts.days },
299
+ firstSeenAt: firstSeen ? iso(firstSeen) : null,
300
+ crawl: {
301
+ total: { hits: totalHits, prevHits: prev(totalPrevHits) },
302
+ series,
303
+ bots,
304
+ topPaths: topCrawlPaths.map((p) => withEntity({ ...p, bots: crawlBotsByPath.get(p.path) ?? [] })),
305
+ otherHits: otherAgg?._sum?.hits ?? 0,
306
+ },
307
+ referrals: {
308
+ total: { visits: totalVisits, prevVisits: prev(totalPrev) },
309
+ engines,
310
+ topPaths: topRefPaths.map((p) => withEntity({ ...p, engines: enginesByPath.get(p.path) ?? [] })),
311
+ otherVisits: refOtherAgg?._sum?.visits ?? 0,
312
+ },
313
+ };
314
+ }
315
+ //# sourceMappingURL=geo-report.js.map