@prenta/core 7.7.0 → 7.7.1
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/CHANGELOG.md +6 -0
- package/dist/api/routes/seo/handlers/analysis.d.ts +4 -0
- package/dist/api/routes/seo/handlers/analysis.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/analysis.js +131 -0
- package/dist/api/routes/seo/handlers/analysis.js.map +1 -0
- package/dist/api/routes/seo/handlers/audits.d.ts +5 -0
- package/dist/api/routes/seo/handlers/audits.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/audits.js +121 -0
- package/dist/api/routes/seo/handlers/audits.js.map +1 -0
- package/dist/api/routes/seo/handlers/content.d.ts +4 -0
- package/dist/api/routes/seo/handlers/content.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/content.js +330 -0
- package/dist/api/routes/seo/handlers/content.js.map +1 -0
- package/dist/api/routes/seo/handlers/issue-helpers.d.ts +37 -0
- package/dist/api/routes/seo/handlers/issue-helpers.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issue-helpers.js +72 -0
- package/dist/api/routes/seo/handlers/issue-helpers.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-briefs.d.ts +7 -0
- package/dist/api/routes/seo/handlers/issues-briefs.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-briefs.js +341 -0
- package/dist/api/routes/seo/handlers/issues-briefs.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-fixes.d.ts +4 -0
- package/dist/api/routes/seo/handlers/issues-fixes.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-fixes.js +233 -0
- package/dist/api/routes/seo/handlers/issues-fixes.js.map +1 -0
- package/dist/api/routes/seo/handlers/issues-list.d.ts +7 -0
- package/dist/api/routes/seo/handlers/issues-list.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/issues-list.js +191 -0
- package/dist/api/routes/seo/handlers/issues-list.js.map +1 -0
- package/dist/api/routes/seo/handlers/links.d.ts +6 -0
- package/dist/api/routes/seo/handlers/links.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/links.js +383 -0
- package/dist/api/routes/seo/handlers/links.js.map +1 -0
- package/dist/api/routes/seo/handlers/overview.d.ts +4 -0
- package/dist/api/routes/seo/handlers/overview.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/overview.js +348 -0
- package/dist/api/routes/seo/handlers/overview.js.map +1 -0
- package/dist/api/routes/seo/handlers/pages.d.ts +2 -0
- package/dist/api/routes/seo/handlers/pages.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/pages.js +38 -0
- package/dist/api/routes/seo/handlers/pages.js.map +1 -0
- package/dist/api/routes/seo/handlers/proposals.d.ts +4 -0
- package/dist/api/routes/seo/handlers/proposals.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/proposals.js +93 -0
- package/dist/api/routes/seo/handlers/proposals.js.map +1 -0
- package/dist/api/routes/seo/handlers/public-files.d.ts +6 -0
- package/dist/api/routes/seo/handlers/public-files.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/public-files.js +328 -0
- package/dist/api/routes/seo/handlers/public-files.js.map +1 -0
- package/dist/api/routes/seo/handlers/shared.d.ts +24 -0
- package/dist/api/routes/seo/handlers/shared.d.ts.map +1 -0
- package/dist/api/routes/seo/handlers/shared.js +67 -0
- package/dist/api/routes/seo/handlers/shared.js.map +1 -0
- package/dist/api/routes/seo.d.ts +5 -5
- package/dist/api/routes/seo.d.ts.map +1 -1
- package/dist/api/routes/seo.js +56 -2610
- package/dist/api/routes/seo.js.map +1 -1
- package/package.json +1 -1
package/dist/api/routes/seo.js
CHANGED
|
@@ -1,2615 +1,61 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
import { guardedDb } from '../guarded-db.js';
|
|
14
|
-
import { recordAiCrawlerFromRequest, recordAiCrawlerHit } from '../../seo/ai-crawler-log.js';
|
|
15
|
-
import { detectAiBot } from '../../seo/ai-bots.js';
|
|
16
|
-
import { json, errorResponse, internalError, asRecord, normalizeSeoPage, hasModel, formatCompactNumber, modelNotAvailable, requireAuth, buildActionContext, WRITE_ROLES, ADMIN_ROLES, requireRole, linkHealthLimiter, seoAuditLimiter, aiGenerateLimiter, checkRateLimitAsync, clientIp, enforceDocumentReadAccess, apiKeyCollectionScopeDenial, clampPage, clampPageSize, } from '../route-helpers.js';
|
|
17
|
-
import { gatherNavigationLinkTargets } from '../../seo/nav-link-targets.js';
|
|
18
|
-
import { suggestInboundLinks } from '../../seo/link-suggest.js';
|
|
19
|
-
import { seoApplyFixBodySchema, seoBulkApplyBodySchema, seoLinkApplyBodySchema, validateBody, } from '../validate-body.js';
|
|
20
|
-
// Explicit risk order. A plain `severity: 'asc'` DB sort orders the values
|
|
21
|
-
// alphabetically (critical, info, warning) which wrongly ranks `info` above
|
|
22
|
-
// `warning`. Unknown severities sort last.
|
|
23
|
-
const seoLogger = createLogger('seo');
|
|
24
|
-
const SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
|
|
25
|
-
function severityRank(severity) {
|
|
26
|
-
return SEVERITY_RANK[String(severity)] ?? 99;
|
|
27
|
-
}
|
|
28
|
-
function planRequiredResponse(denial) {
|
|
29
|
-
return json({
|
|
30
|
-
error: denial.message,
|
|
31
|
-
code: 'plan_required',
|
|
32
|
-
requiredTier: denial.requiredTier,
|
|
33
|
-
feature: denial.feature,
|
|
34
|
-
}, 403);
|
|
35
|
-
}
|
|
36
|
-
function mapInternalLinkInsertError(err) {
|
|
37
|
-
const msg = err instanceof Error ? err.message : '';
|
|
38
|
-
switch (msg) {
|
|
39
|
-
case 'SOURCE_NOT_FOUND':
|
|
40
|
-
return errorResponse('Source document not found', 404);
|
|
41
|
-
case 'TARGET_NOT_FOUND':
|
|
42
|
-
return errorResponse('Target document not found', 404);
|
|
43
|
-
case 'NOT_WRAPPABLE':
|
|
44
|
-
return errorResponse('Anchor is not wrappable in the live source document.', 422);
|
|
45
|
-
case 'ALREADY_LINKED':
|
|
46
|
-
return errorResponse('Source already links to this target.', 422);
|
|
47
|
-
case 'TARGET_PATH_MISMATCH':
|
|
48
|
-
return errorResponse('Target path does not match the live document.', 422);
|
|
49
|
-
default:
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
function seoAiGenerationError(result) {
|
|
54
|
-
if (result.code === 'unavailable' || result.code === 'feature_disabled') {
|
|
55
|
-
return errorResponse(result.message, 503);
|
|
56
|
-
}
|
|
57
|
-
if (result.code === 'rate_limited' || result.code === 'budget_exceeded') {
|
|
58
|
-
return errorResponse(result.message, 429);
|
|
59
|
-
}
|
|
60
|
-
return errorResponse(result.message, 422);
|
|
61
|
-
}
|
|
62
|
-
/** Sort issues by risk (critical → warning → info), then most-recent first. */
|
|
63
|
-
export function sortIssuesBySeverity(issues) {
|
|
64
|
-
return [...issues].sort((a, b) => {
|
|
65
|
-
const rankDiff = severityRank(a.severity) - severityRank(b.severity);
|
|
66
|
-
if (rankDiff !== 0)
|
|
67
|
-
return rankDiff;
|
|
68
|
-
const at = a.lastDetectedAt ? new Date(a.lastDetectedAt).getTime() : 0;
|
|
69
|
-
const bt = b.lastDetectedAt ? new Date(b.lastDetectedAt).getTime() : 0;
|
|
70
|
-
return bt - at;
|
|
71
|
-
});
|
|
72
|
-
}
|
|
1
|
+
import { getSeoPages } from './seo/handlers/pages.js';
|
|
2
|
+
import { getSeoLinkHealth, getSeoLinkSnapshot, postSeoLinkAiIndex, postSeoLinkSuggest, postSeoLinkApply, } from './seo/handlers/links.js';
|
|
3
|
+
import { postSeoScan, postSeoAudit, getSeoAudits, getSeoAudit } from './seo/handlers/audits.js';
|
|
4
|
+
import { getSeoIssues, getSeoIssuesExportBrief, getSeoIssue, postSeoIssueResolve, postSeoIssueIgnore, postSeoIssueReopen, } from './seo/handlers/issues-list.js';
|
|
5
|
+
import { postSeoIssuesBatchContentBriefs, getSeoIssueContentBrief, postSeoIssueContentBrief, getSeoIssueContentResearch, postSeoIssueContentResearch, postSeoIssueResearchAndBrief, } from './seo/handlers/issues-briefs.js';
|
|
6
|
+
import { postSeoIssueSuggestFix, postSeoIssueApplyFix, postSeoIssueDismissFix, } from './seo/handlers/issues-fixes.js';
|
|
7
|
+
import { getSeoProposals, postSeoProposalsGenerate, postSeoProposalsBulkApply, } from './seo/handlers/proposals.js';
|
|
8
|
+
import { getSeoOverview, getSeoSearchPerformance, getSeoSearchInsights, } from './seo/handlers/overview.js';
|
|
9
|
+
import { getSeoContent, getSeoContentRecord, putSeoContentRecord } from './seo/handlers/content.js';
|
|
10
|
+
import { getSeoAnalysis, getSeoReadability, getSeoInternalLinks } from './seo/handlers/analysis.js';
|
|
11
|
+
import { getLlmsTxt, getLlmsFullTxt, getMarkdownForPath, getAiCrawlerTrap, getAiCrawlers, } from './seo/handlers/public-files.js';
|
|
12
|
+
export { sortIssuesBySeverity } from './seo/handlers/shared.js';
|
|
73
13
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* scope is checked inside `applyIssueFix` once the collection is known. JWT
|
|
77
|
-
* sessions already passed the route's `WRITE_ROLES` check and are never denied.
|
|
14
|
+
* SEO HTTP routes. Handlers live under `./seo/handlers` so this file stays a
|
|
15
|
+
* registration list. Request behavior is unchanged.
|
|
78
16
|
*/
|
|
79
|
-
function contentWriteGate(session) {
|
|
80
|
-
return (collection) => apiKeyCollectionScopeDenial(session, collection, 'update');
|
|
81
|
-
}
|
|
82
17
|
export function registerSeoRoutes(router) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
router.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
router.get('/seo/
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
126
|
-
if (roleErr)
|
|
127
|
-
return roleErr;
|
|
128
|
-
// Tight rate limit because each call fans out to many outbound requests.
|
|
129
|
-
const ip = clientIp(request);
|
|
130
|
-
const rateKey = isResolvedIp(ip)
|
|
131
|
-
? `link-health:${ip}`
|
|
132
|
-
: `link-health-user:${auth.session.userId}`;
|
|
133
|
-
if (!(await checkRateLimitAsync(linkHealthLimiter, rateKey))) {
|
|
134
|
-
return errorResponse('Too many link-health scans. Please try again later.', 429);
|
|
135
|
-
}
|
|
136
|
-
const MAX_LINKS_PER_PAGE = 50;
|
|
137
|
-
const MAX_TOTAL_LINKS = 500;
|
|
138
|
-
const DOC_BATCH_SIZE = 100;
|
|
139
|
-
const PER_LINK_TIMEOUT_MS = 4000;
|
|
140
|
-
const CONCURRENCY = 8;
|
|
141
|
-
const siteSeo = getPrentaConfig()?.seo;
|
|
142
|
-
const siteUrl = (siteSeo?.siteUrl ?? process.env.NEXT_PUBLIC_SITE_URL ?? '').replace(/\/+$/, '');
|
|
143
|
-
const trailingSlash = resolveTrailingSlashPolicy(siteSeo?.trailingSlash);
|
|
144
|
-
const queue = [];
|
|
145
|
-
const seenGlobal = new Set();
|
|
146
|
-
let docsScanned = 0;
|
|
147
|
-
let skip = 0;
|
|
148
|
-
scanLoop: while (queue.length < MAX_TOTAL_LINKS) {
|
|
149
|
-
const batch = await db().document.findMany({
|
|
150
|
-
where: { deletedAt: null, status: 'PUBLISHED' },
|
|
151
|
-
select: { id: true, title: true, data: true, collection: true },
|
|
152
|
-
orderBy: { updatedAt: 'desc' },
|
|
153
|
-
skip,
|
|
154
|
-
take: DOC_BATCH_SIZE,
|
|
155
|
-
});
|
|
156
|
-
if (batch.length === 0)
|
|
157
|
-
break;
|
|
158
|
-
docsScanned += batch.length;
|
|
159
|
-
skip += batch.length;
|
|
160
|
-
for (const doc of batch) {
|
|
161
|
-
const pageTitle = doc.title ?? doc.data?.title ?? doc.id;
|
|
162
|
-
// Walk unescaped string fields — never JSON.stringify (captures `\`
|
|
163
|
-
// from `\"` and produces mass false positives).
|
|
164
|
-
const urls = extractOutboundUrlsFromDocumentData(doc.data ?? {}, { siteUrl });
|
|
165
|
-
let countInPage = 0;
|
|
166
|
-
for (const clean of urls) {
|
|
167
|
-
if (seenGlobal.has(clean))
|
|
168
|
-
continue;
|
|
169
|
-
seenGlobal.add(clean);
|
|
170
|
-
if (countInPage >= MAX_LINKS_PER_PAGE)
|
|
171
|
-
break;
|
|
172
|
-
if (queue.length >= MAX_TOTAL_LINKS)
|
|
173
|
-
break scanLoop;
|
|
174
|
-
const isInternal = !!siteUrl && clean.startsWith(siteUrl);
|
|
175
|
-
queue.push({ docId: doc.id, pageTitle, clean, isInternal });
|
|
176
|
-
countInPage++;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
const linkResults = [];
|
|
181
|
-
let cursor = 0;
|
|
182
|
-
const worker = async () => {
|
|
183
|
-
while (cursor < queue.length) {
|
|
184
|
-
const idx = cursor++;
|
|
185
|
-
const job = queue[idx];
|
|
186
|
-
// GET + browser UA. safeFetch rejects private/loopback IPs and
|
|
187
|
-
// disables redirect following so 302→internal can't bypass SSRF.
|
|
188
|
-
// Retry once on status 0 — a single timeout was a known false
|
|
189
|
-
// positive on otherwise-200 pages.
|
|
190
|
-
const probed = await probeOutboundLink(job.clean, {
|
|
191
|
-
timeoutMs: PER_LINK_TIMEOUT_MS,
|
|
192
|
-
retryUnreachable: true,
|
|
193
|
-
});
|
|
194
|
-
const kind = classifyLinkHealthIssue(probed.status);
|
|
195
|
-
const slashTarget = job.isInternal && !kind ? trailingSlashMismatchTarget(job.clean, trailingSlash) : null;
|
|
196
|
-
if (!kind && !slashTarget)
|
|
197
|
-
continue;
|
|
198
|
-
linkResults.push({
|
|
199
|
-
id: `${job.docId}-${idx}`,
|
|
200
|
-
page: job.pageTitle,
|
|
201
|
-
url: job.clean,
|
|
202
|
-
status: kind ? probed.status : 308,
|
|
203
|
-
type: job.isInternal ? 'internal' : 'external',
|
|
204
|
-
kind: kind ?? 'redirected',
|
|
205
|
-
...(probed.redirectTarget || slashTarget
|
|
206
|
-
? { redirectTarget: probed.redirectTarget ?? slashTarget ?? undefined }
|
|
207
|
-
: {}),
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
212
|
-
return json({
|
|
213
|
-
data: {
|
|
214
|
-
truncated: queue.length >= MAX_TOTAL_LINKS,
|
|
215
|
-
docsScanned,
|
|
216
|
-
checked: queue.length,
|
|
217
|
-
issues: linkResults,
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
catch (err) {
|
|
222
|
-
return internalError(err);
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
router.get('/seo/links/snapshot', async (request) => {
|
|
226
|
-
try {
|
|
227
|
-
const auth = await requireAuth(request);
|
|
228
|
-
if (auth.error)
|
|
229
|
-
return auth.error;
|
|
230
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
231
|
-
if (roleErr)
|
|
232
|
-
return roleErr;
|
|
233
|
-
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
234
|
-
const { buildLinkGraphSnapshot } = await import('../../seo/link-graph.js');
|
|
235
|
-
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
236
|
-
const cfg = getPrentaConfig();
|
|
237
|
-
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
238
|
-
const siteUrl = cfg?.seo?.siteUrl?.replace(/\/+$/, '') ?? '';
|
|
239
|
-
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
240
|
-
const docRows = await db()
|
|
241
|
-
.document.findMany({
|
|
242
|
-
where: {
|
|
243
|
-
deletedAt: null,
|
|
244
|
-
status: 'PUBLISHED',
|
|
245
|
-
collection: { in: Object.keys(collectionTypes) },
|
|
246
|
-
},
|
|
247
|
-
select: { id: true, collection: true },
|
|
248
|
-
})
|
|
249
|
-
.catch(() => []);
|
|
250
|
-
const collections = Object.fromEntries(docRows.map((d) => [d.id, d.collection]));
|
|
251
|
-
let brokenLinkCount = 0;
|
|
252
|
-
if (hasModel(db(), 'seoIssue')) {
|
|
253
|
-
brokenLinkCount = await db()
|
|
254
|
-
.seoIssue.count({
|
|
255
|
-
where: {
|
|
256
|
-
status: { in: ['open', 'recurring'] },
|
|
257
|
-
type: 'broken-internal-link',
|
|
258
|
-
},
|
|
259
|
-
})
|
|
260
|
-
.catch(() => 0);
|
|
261
|
-
}
|
|
262
|
-
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
263
|
-
const moduleSettings = resolveSeoModuleSettings(overrides);
|
|
264
|
-
// Global nav/footer menus link to top-level pages from site chrome, not
|
|
265
|
-
// from document bodies. Feed those targets in so nav-only reachable pages
|
|
266
|
-
// are not mis-reported as orphaned. Best-effort: no-op when the navigation
|
|
267
|
-
// plugin isn't installed.
|
|
268
|
-
const navLinks = await gatherNavigationLinkTargets(db());
|
|
269
|
-
const snapshot = buildLinkGraphSnapshot({
|
|
270
|
-
entities,
|
|
271
|
-
siteUrl,
|
|
272
|
-
collections,
|
|
273
|
-
brokenLinkCount,
|
|
274
|
-
aiIndexLastRunAt: moduleSettings.linkAiIndexLastRunAt,
|
|
275
|
-
navLinks,
|
|
276
|
-
});
|
|
277
|
-
return json({ data: snapshot });
|
|
278
|
-
}
|
|
279
|
-
catch (err) {
|
|
280
|
-
return internalError(err, 'seo/links/snapshot');
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
router.post('/seo/links/ai-index', async (request) => {
|
|
284
|
-
try {
|
|
285
|
-
const auth = await requireAuth(request);
|
|
286
|
-
if (auth.error)
|
|
287
|
-
return auth.error;
|
|
288
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
289
|
-
if (roleErr)
|
|
290
|
-
return roleErr;
|
|
291
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-ai-index:${auth.session.userId}`))) {
|
|
292
|
-
return errorResponse('AI link index rate limit reached. Try again later.', 429);
|
|
293
|
-
}
|
|
294
|
-
const { gatherAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
295
|
-
const { patchSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
296
|
-
const cfg = getPrentaConfig();
|
|
297
|
-
const collectionTypes = frontEndContentCollectionTypes(cfg);
|
|
298
|
-
const entities = await gatherAuditEntities(db(), { collectionTypes });
|
|
299
|
-
// Availability = the governed runtime can serve the link feature right
|
|
300
|
-
// now (toggle on, model + key resolvable, budget/cap not exhausted) AND
|
|
301
|
-
// the resolved provider has an embeddings API in the runtime. A
|
|
302
|
-
// text-only provider (Anthropic, xAI) resolves fine but `embedForFeature`
|
|
303
|
-
// would refuse with `no_model`, so suggestions stay on phrase matching.
|
|
304
|
-
const { embeddingsAvailableForProvider, resolveFeatureRuntime } = await import('../../ai/index.js');
|
|
305
|
-
const probe = await resolveFeatureRuntime(db(), 'ai.features.seoInternalLinks');
|
|
306
|
-
const aiAvailable = probe.ok && embeddingsAvailableForProvider(probe.providerKey);
|
|
307
|
-
const now = new Date().toISOString();
|
|
308
|
-
await patchSeoModuleSettings(db(), { linkAiIndexLastRunAt: now }, auth.session.userId);
|
|
309
|
-
await logEvent({
|
|
310
|
-
event: 'seo_link_ai_index',
|
|
311
|
-
userId: auth.session.userId,
|
|
312
|
-
details: { entitiesIndexed: entities.length, aiAvailable },
|
|
313
|
-
});
|
|
314
|
-
return json({
|
|
315
|
-
data: {
|
|
316
|
-
indexedAt: now,
|
|
317
|
-
entitiesIndexed: entities.length,
|
|
318
|
-
aiAvailable,
|
|
319
|
-
// No standing index/embeddings are persisted here — /seo/links/suggest
|
|
320
|
-
// rebuilds the corpus on demand and works with or without this run.
|
|
321
|
-
// Keep the copy honest: this refreshes the link corpus and records the
|
|
322
|
-
// run; it does not "build" anything suggestions depend on.
|
|
323
|
-
message: aiAvailable
|
|
324
|
-
? 'Link corpus refreshed. AI link suggestions are generated on demand — run them on orphaned or under-linked pages.'
|
|
325
|
-
: 'Link corpus refreshed. Connect an AI provider in Settings > AI to enable semantic link suggestions; phrase matching is active meanwhile.',
|
|
326
|
-
},
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
return internalError(err, 'seo/links/ai-index');
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
router.post('/seo/links/suggest', async (request) => {
|
|
334
|
-
try {
|
|
335
|
-
const auth = await requireAuth(request);
|
|
336
|
-
if (auth.error)
|
|
337
|
-
return auth.error;
|
|
338
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
339
|
-
if (roleErr)
|
|
340
|
-
return roleErr;
|
|
341
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-suggest:${auth.session.userId}`))) {
|
|
342
|
-
return errorResponse('Link suggestion rate limit reached. Try again later.', 429);
|
|
343
|
-
}
|
|
344
|
-
const body = (await request.json());
|
|
345
|
-
if (!body.targetEntityId || !body.targetEntityType) {
|
|
346
|
-
return errorResponse('targetEntityId and targetEntityType are required', 400);
|
|
347
|
-
}
|
|
348
|
-
const limit = Math.max(1, Math.min(20, typeof body.limit === 'number' ? body.limit : 8));
|
|
349
|
-
let result;
|
|
350
|
-
try {
|
|
351
|
-
result = await suggestInboundLinks({
|
|
352
|
-
db: db(),
|
|
353
|
-
targetEntityId: body.targetEntityId,
|
|
354
|
-
targetEntityType: body.targetEntityType,
|
|
355
|
-
limit,
|
|
356
|
-
userId: auth.session.userId,
|
|
357
|
-
});
|
|
358
|
-
}
|
|
359
|
-
catch (err) {
|
|
360
|
-
if (err instanceof Error && err.message === 'TARGET_NOT_FOUND') {
|
|
361
|
-
return errorResponse('Target document not found in link graph', 404);
|
|
362
|
-
}
|
|
363
|
-
throw err;
|
|
364
|
-
}
|
|
365
|
-
return json({
|
|
366
|
-
data: {
|
|
367
|
-
target: result.target,
|
|
368
|
-
suggestions: result.suggestions,
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
catch (err) {
|
|
373
|
-
return internalError(err, 'seo/links/suggest');
|
|
374
|
-
}
|
|
375
|
-
});
|
|
376
|
-
router.post('/seo/links/apply', async (request) => {
|
|
377
|
-
try {
|
|
378
|
-
const auth = await requireAuth(request);
|
|
379
|
-
if (auth.error)
|
|
380
|
-
return auth.error;
|
|
381
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
382
|
-
if (roleErr)
|
|
383
|
-
return roleErr;
|
|
384
|
-
const inlinePlanErr = assertPlanFeature('seo.inlineApply');
|
|
385
|
-
if (inlinePlanErr)
|
|
386
|
-
return planRequiredResponse(inlinePlanErr);
|
|
387
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `link-apply:${auth.session.userId}`))) {
|
|
388
|
-
return errorResponse('Link apply rate limit reached. Try again later.', 429);
|
|
389
|
-
}
|
|
390
|
-
const parsed = await validateBody(request, seoLinkApplyBodySchema);
|
|
391
|
-
if (!parsed.ok)
|
|
392
|
-
return parsed.error;
|
|
393
|
-
const { sourceEntityId, targetEntityId, anchorText, fingerprint } = parsed.data;
|
|
394
|
-
const source = await db().document.findFirst({
|
|
395
|
-
where: { id: sourceEntityId, deletedAt: null },
|
|
396
|
-
select: {
|
|
397
|
-
id: true,
|
|
398
|
-
collection: true,
|
|
399
|
-
updatedAt: true,
|
|
400
|
-
data: true,
|
|
401
|
-
slug: true,
|
|
402
|
-
status: true,
|
|
403
|
-
},
|
|
404
|
-
});
|
|
405
|
-
if (!source)
|
|
406
|
-
return errorResponse('Source document not found', 404);
|
|
407
|
-
const target = await db().document.findFirst({
|
|
408
|
-
where: { id: targetEntityId, deletedAt: null },
|
|
409
|
-
select: { id: true, collection: true, data: true, slug: true, status: true },
|
|
410
|
-
});
|
|
411
|
-
if (!target)
|
|
412
|
-
return errorResponse('Target document not found', 404);
|
|
413
|
-
const targetData = asRecord(target.data);
|
|
414
|
-
const targetSlug = (typeof targetData.slug === 'string' && targetData.slug) ||
|
|
415
|
-
(typeof target.slug === 'string' && target.slug) ||
|
|
416
|
-
target.id;
|
|
417
|
-
const collectionDef = getPrentaConfig()?.collections?.[target.collection];
|
|
418
|
-
const targetPath = documentPublicPath({
|
|
419
|
-
urlPrefix: collectionDef?.urlPrefix,
|
|
420
|
-
slug: targetSlug,
|
|
421
|
-
data: targetData,
|
|
422
|
-
});
|
|
423
|
-
const { applyAndVerifyInternalLinkInsert, buildInsertLinkFingerprint } = await import('../../seo/issue-fix-link-insert.js');
|
|
424
|
-
const liveFingerprint = buildInsertLinkFingerprint(sourceEntityId, targetEntityId, anchorText, source.updatedAt instanceof Date ? source.updatedAt : new Date(source.updatedAt));
|
|
425
|
-
if (fingerprint !== liveFingerprint) {
|
|
426
|
-
return errorResponse('This fix is stale — the page changed since the suggestion was loaded. Expand the row again.', 409);
|
|
427
|
-
}
|
|
428
|
-
const ctx = buildActionContext(auth.session, db());
|
|
429
|
-
let verified;
|
|
430
|
-
try {
|
|
431
|
-
const result = await applyAndVerifyInternalLinkInsert({
|
|
432
|
-
db: db(),
|
|
433
|
-
ctx,
|
|
434
|
-
sourceEntityId,
|
|
435
|
-
targetEntityId,
|
|
436
|
-
anchorText,
|
|
437
|
-
targetPath,
|
|
438
|
-
siteUrl: getPrentaConfig()?.seo?.siteUrl ?? '',
|
|
439
|
-
navLinks: await gatherNavigationLinkTargets(db()),
|
|
440
|
-
});
|
|
441
|
-
verified = result.verify;
|
|
442
|
-
}
|
|
443
|
-
catch (err) {
|
|
444
|
-
const mapped = mapInternalLinkInsertError(err);
|
|
445
|
-
if (mapped)
|
|
446
|
-
return mapped;
|
|
447
|
-
throw err;
|
|
448
|
-
}
|
|
449
|
-
if (!verified.ok) {
|
|
450
|
-
return json({ error: 'Fix applied but verification failed.', reason: verified.reason }, 409);
|
|
451
|
-
}
|
|
452
|
-
if (hasModel(db(), 'seoIssue')) {
|
|
453
|
-
await db().seoIssue.updateMany({
|
|
454
|
-
where: {
|
|
455
|
-
type: 'orphan-page',
|
|
456
|
-
entityId: targetEntityId,
|
|
457
|
-
status: { in: ['open', 'recurring'] },
|
|
458
|
-
},
|
|
459
|
-
data: {
|
|
460
|
-
status: 'resolved',
|
|
461
|
-
resolvedAt: new Date(),
|
|
462
|
-
resolvedById: auth.session.userId,
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
try {
|
|
467
|
-
await logEvent({
|
|
468
|
-
event: 'document_updated',
|
|
469
|
-
userId: auth.session.userId,
|
|
470
|
-
details: {
|
|
471
|
-
action: 'seo_internal_link_applied',
|
|
472
|
-
seoFix: 'insert-link',
|
|
473
|
-
sourceEntityId,
|
|
474
|
-
targetEntityId,
|
|
475
|
-
targetPath,
|
|
476
|
-
},
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
/* noop */
|
|
481
|
-
}
|
|
482
|
-
return json({
|
|
483
|
-
data: { sourceEntityId, targetEntityId, targetPath, applied: true },
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
catch (err) {
|
|
487
|
-
return internalError(err, 'seo/links/apply');
|
|
488
|
-
}
|
|
489
|
-
});
|
|
490
|
-
router.post('/seo/scan', async (request) => {
|
|
491
|
-
try {
|
|
492
|
-
const auth = await requireAuth(request);
|
|
493
|
-
if (auth.error)
|
|
494
|
-
return auth.error;
|
|
495
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
496
|
-
if (roleErr)
|
|
497
|
-
return roleErr;
|
|
498
|
-
// Expensive: loads + regex-scans every published doc. Rate-limit it (same
|
|
499
|
-
// budget as the full audit) so it can't be hammered into an OOM/CPU spike.
|
|
500
|
-
const scanIp = clientIp(request);
|
|
501
|
-
const scanKey = isResolvedIp(scanIp)
|
|
502
|
-
? `seo-scan:${scanIp}`
|
|
503
|
-
: `seo-scan-user:${auth.session.userId}`;
|
|
504
|
-
if (!(await checkRateLimitAsync(seoAuditLimiter, scanKey))) {
|
|
505
|
-
return errorResponse('Too many scans. Please try again later.', 429);
|
|
506
|
-
}
|
|
507
|
-
// Scope the scan to configured front-end content collections so structural
|
|
508
|
-
// (navigation/footer) and benchmark documents are never reported. Shares
|
|
509
|
-
// the exact scan logic with the cron path via processSeoScan.
|
|
510
|
-
const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
511
|
-
const allowedCollections = Object.keys(frontEndContentCollectionTypes(getPrentaConfig()));
|
|
512
|
-
const result = await processSeoScan(db(), { allowedCollections, maxDocuments: 5000 });
|
|
513
|
-
return json({ data: result });
|
|
514
|
-
}
|
|
515
|
-
catch (err) {
|
|
516
|
-
return internalError(err);
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
// ---------------------------------------------------------------------------
|
|
520
|
-
// SEO Operations Center — audit engine, issues, overview, content records
|
|
521
|
-
// ---------------------------------------------------------------------------
|
|
522
|
-
function seoIssueToApi(i) {
|
|
523
|
-
return {
|
|
524
|
-
id: i.id,
|
|
525
|
-
auditRunId: i.auditRunId ?? null,
|
|
526
|
-
entityType: i.entityType ?? null,
|
|
527
|
-
entityId: i.entityId ?? null,
|
|
528
|
-
entityTitle: i.entityTitle ?? null,
|
|
529
|
-
url: i.url ?? null,
|
|
530
|
-
severity: i.severity,
|
|
531
|
-
category: i.category,
|
|
532
|
-
type: i.type,
|
|
533
|
-
title: i.title,
|
|
534
|
-
description: i.description ?? null,
|
|
535
|
-
impact: i.impact ?? null,
|
|
536
|
-
recommendation: i.recommendation ?? null,
|
|
537
|
-
status: i.status,
|
|
538
|
-
fixable: i.fixable ?? false,
|
|
539
|
-
fixActionType: i.fixActionType ?? null,
|
|
540
|
-
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
541
|
-
source: i.source ?? 'audit',
|
|
542
|
-
firstDetectedAt: i.firstDetectedAt ?? null,
|
|
543
|
-
lastDetectedAt: i.lastDetectedAt ?? null,
|
|
544
|
-
resolvedAt: i.resolvedAt ?? null,
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Compute the set of front-end content collections the caller may read.
|
|
549
|
-
* Mirrors the per-document `enforceDocumentReadAccess` check the sibling
|
|
550
|
-
* `/seo/analysis|readability|…` endpoints run, but resolved once for the
|
|
551
|
-
* collection list so the aggregate `/seo/content` view can never surface SEO
|
|
552
|
-
* fields for a collection the caller's scope/role forbids.
|
|
553
|
-
*/
|
|
554
|
-
async function readableSeoCollections(session) {
|
|
555
|
-
const { frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
556
|
-
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
557
|
-
const allowed = new Set();
|
|
558
|
-
for (const slug of Object.keys(collectionTypes)) {
|
|
559
|
-
const err = await enforceDocumentReadAccess(session, slug);
|
|
560
|
-
if (!err)
|
|
561
|
-
allowed.add(slug);
|
|
562
|
-
}
|
|
563
|
-
return allowed;
|
|
564
|
-
}
|
|
565
|
-
async function buildSeoContentRecords(allowedCollections,
|
|
566
|
-
/**
|
|
567
|
-
* Restrict to specific documents, regardless of publication status. Used by
|
|
568
|
-
* the per-document GET, which must answer for a saved draft — the list
|
|
569
|
-
* endpoints deliberately stay published-only.
|
|
570
|
-
*/
|
|
571
|
-
documentIds, page) {
|
|
572
|
-
const { gatherAuditEntities, countAuditEntities, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
573
|
-
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
574
|
-
const { calculatePageSeoScore } = await import('../../seo/score.js');
|
|
575
|
-
const allTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
576
|
-
// When a readable-collection allow-list is supplied, scope the gather to it
|
|
577
|
-
// so records for forbidden collections are never even built.
|
|
578
|
-
const collectionTypes = allowedCollections
|
|
579
|
-
? Object.fromEntries(Object.entries(allTypes).filter(([slug]) => allowedCollections.has(slug)))
|
|
580
|
-
: allTypes;
|
|
581
|
-
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
582
|
-
const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
|
|
583
|
-
const [entities, total] = await Promise.all([
|
|
584
|
-
gatherAuditEntities(db(), {
|
|
585
|
-
collectionTypes,
|
|
586
|
-
documentIds,
|
|
587
|
-
skip: page?.skip,
|
|
588
|
-
take: page?.take,
|
|
589
|
-
}),
|
|
590
|
-
documentIds
|
|
591
|
-
? Promise.resolve(documentIds.length)
|
|
592
|
-
: countAuditEntities(db(), { collectionTypes }),
|
|
593
|
-
]);
|
|
594
|
-
const records = entities.map((e) => {
|
|
595
|
-
const score = calculatePageSeoScore(e);
|
|
596
|
-
return {
|
|
597
|
-
id: `${e.entityType}:${e.entityId}`,
|
|
598
|
-
entityType: e.entityType,
|
|
599
|
-
entityId: e.entityId,
|
|
600
|
-
title: e.title,
|
|
601
|
-
url: e.url,
|
|
602
|
-
canonicalUrl: e.canonicalUrl ?? null,
|
|
603
|
-
/** Rendered public title (after site title template). */
|
|
604
|
-
metaTitle: e.metaTitle ?? null,
|
|
605
|
-
/** Stored page-specific portion (`%page%` token). */
|
|
606
|
-
metaTitlePage: e.metaTitlePage ?? null,
|
|
607
|
-
siteName: siteSeo?.siteName ?? null,
|
|
608
|
-
titleTemplate: siteSeo?.titleTemplate ?? null,
|
|
609
|
-
metaDescription: e.metaDescription ?? null,
|
|
610
|
-
focusKeyword: e.focusKeyword ?? null,
|
|
611
|
-
keyTakeaway: e.keyTakeaway ?? null,
|
|
612
|
-
ogTitle: e.ogTitle ?? null,
|
|
613
|
-
ogDescription: e.ogDescription ?? null,
|
|
614
|
-
ogImageId: null,
|
|
615
|
-
ogImage: e.ogImage ?? null,
|
|
616
|
-
twitterTitle: null,
|
|
617
|
-
twitterDescription: null,
|
|
618
|
-
twitterImageId: null,
|
|
619
|
-
noindex: e.noindex ?? false,
|
|
620
|
-
nofollow: false,
|
|
621
|
-
// Permissive defaults; the single-record GET overlays what is stored.
|
|
622
|
-
nosnippet: false,
|
|
623
|
-
noarchive: false,
|
|
624
|
-
maxImagePreview: DEFAULT_DIRECTIVES.maxImagePreview,
|
|
625
|
-
structuredDataType: e.structuredDataType ?? null,
|
|
626
|
-
structuredData: null,
|
|
627
|
-
// llms.txt curation defaults; the single-record GET overlays the real
|
|
628
|
-
// stored values (the list endpoint doesn't surface these).
|
|
629
|
-
llmsInclude: 'AUTO',
|
|
630
|
-
llmsDescription: null,
|
|
631
|
-
llmsSection: null,
|
|
632
|
-
llmsOptional: false,
|
|
633
|
-
seoScore: score.score,
|
|
634
|
-
seoGrade: score.grade,
|
|
635
|
-
updatedAt: e.updatedAt instanceof Date
|
|
636
|
-
? e.updatedAt.toISOString()
|
|
637
|
-
: typeof e.updatedAt === 'string'
|
|
638
|
-
? e.updatedAt
|
|
639
|
-
: null,
|
|
640
|
-
};
|
|
641
|
-
});
|
|
642
|
-
return {
|
|
643
|
-
records,
|
|
644
|
-
total,
|
|
645
|
-
truncated: !documentIds && total > records.length,
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
/** Synced GSC data (trailing 28 days). Returns null when there is none yet. */
|
|
649
|
-
async function loadRealSearchPerformance(limit) {
|
|
650
|
-
if (!hasModel(db(), 'searchPageInsight'))
|
|
651
|
-
return null;
|
|
652
|
-
try {
|
|
653
|
-
const rows = await db().searchPageInsight.findMany({
|
|
654
|
-
where: { impressions28d: { gt: 0 } },
|
|
655
|
-
orderBy: { impressions28d: 'desc' },
|
|
656
|
-
take: limit,
|
|
657
|
-
});
|
|
658
|
-
if (rows.length === 0)
|
|
659
|
-
return null;
|
|
660
|
-
const ids = rows.map((r) => r.entityId).filter(Boolean);
|
|
661
|
-
const titles = new Map();
|
|
662
|
-
if (ids.length > 0) {
|
|
663
|
-
const docs = await db().document.findMany({
|
|
664
|
-
where: { id: { in: ids } },
|
|
665
|
-
select: { id: true, title: true },
|
|
666
|
-
});
|
|
667
|
-
for (const d of docs) {
|
|
668
|
-
if (d.title)
|
|
669
|
-
titles.set(d.id, d.title);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return rows.map((r) => ({
|
|
673
|
-
url: r.path,
|
|
674
|
-
title: (r.entityId && titles.get(r.entityId)) || r.path,
|
|
675
|
-
impressions: r.impressions28d,
|
|
676
|
-
clicks: r.clicks28d,
|
|
677
|
-
ctr: r.impressions28d > 0 ? r.clicks28d / r.impressions28d : 0,
|
|
678
|
-
averagePosition: r.avgPosition28d ?? 0,
|
|
679
|
-
}));
|
|
680
|
-
}
|
|
681
|
-
catch {
|
|
682
|
-
return null;
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
/**
|
|
686
|
-
* Synced GSC rows when Search Console is connected; otherwise (or before the
|
|
687
|
-
* first sync) an empty list. The admin renders its own "not connected" empty
|
|
688
|
-
* state, and MCP / REST consumers must never see fabricated rows.
|
|
689
|
-
*/
|
|
690
|
-
async function loadSearchPerformance(limit = 10, connected = false) {
|
|
691
|
-
if (!connected)
|
|
692
|
-
return [];
|
|
693
|
-
return (await loadRealSearchPerformance(limit)) ?? [];
|
|
694
|
-
}
|
|
695
|
-
/**
|
|
696
|
-
* Site-level 28d totals + prior-window deltas from the insight table for the
|
|
697
|
-
* overview metric cards. Position is impressions-weighted. Null when no
|
|
698
|
-
* synced data exists (metrics then fall back to the perf-row estimate,
|
|
699
|
-
* which is empty until Search Console is connected).
|
|
700
|
-
*/
|
|
701
|
-
async function loadSearchInsightStats() {
|
|
702
|
-
if (!hasModel(db(), 'searchPageInsight'))
|
|
703
|
-
return null;
|
|
704
|
-
try {
|
|
705
|
-
const rows = (await db().searchPageInsight.findMany({
|
|
706
|
-
where: { OR: [{ impressions28d: { gt: 0 } }, { impressionsPrev28d: { gt: 0 } }] },
|
|
707
|
-
select: {
|
|
708
|
-
impressions28d: true,
|
|
709
|
-
impressionsPrev28d: true,
|
|
710
|
-
avgPosition28d: true,
|
|
711
|
-
avgPositionPrev28d: true,
|
|
712
|
-
},
|
|
713
|
-
take: 2000,
|
|
714
|
-
}));
|
|
715
|
-
if (rows.length === 0)
|
|
716
|
-
return null;
|
|
717
|
-
let impressions28d = 0;
|
|
718
|
-
let impressionsPrev28d = 0;
|
|
719
|
-
let posWeight = 0;
|
|
720
|
-
let posWeightBase = 0;
|
|
721
|
-
let prevPosWeight = 0;
|
|
722
|
-
let prevPosWeightBase = 0;
|
|
723
|
-
for (const r of rows) {
|
|
724
|
-
impressions28d += r.impressions28d;
|
|
725
|
-
impressionsPrev28d += r.impressionsPrev28d;
|
|
726
|
-
if (r.avgPosition28d !== null && r.impressions28d > 0) {
|
|
727
|
-
posWeight += r.avgPosition28d * r.impressions28d;
|
|
728
|
-
posWeightBase += r.impressions28d;
|
|
729
|
-
}
|
|
730
|
-
if (r.avgPositionPrev28d !== null && r.impressionsPrev28d > 0) {
|
|
731
|
-
prevPosWeight += r.avgPositionPrev28d * r.impressionsPrev28d;
|
|
732
|
-
prevPosWeightBase += r.impressionsPrev28d;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
return {
|
|
736
|
-
impressions28d,
|
|
737
|
-
impressionsPrev28d,
|
|
738
|
-
avgPosition: posWeightBase > 0 ? posWeight / posWeightBase : null,
|
|
739
|
-
avgPositionPrev: prevPosWeightBase > 0 ? prevPosWeight / prevPosWeightBase : null,
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
catch {
|
|
743
|
-
return null;
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
router.post('/seo/audit', async (request) => {
|
|
747
|
-
try {
|
|
748
|
-
const auth = await requireAuth(request);
|
|
749
|
-
if (auth.error)
|
|
750
|
-
return auth.error;
|
|
751
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
752
|
-
if (roleErr)
|
|
753
|
-
return roleErr;
|
|
754
|
-
if (!hasModel(db(), 'seoAuditRun') || !hasModel(db(), 'seoIssue')) {
|
|
755
|
-
return modelNotAvailable('SeoAuditRun');
|
|
756
|
-
}
|
|
757
|
-
const ip = clientIp(request);
|
|
758
|
-
const rateKey = isResolvedIp(ip) ? `seo-audit:${ip}` : `seo-audit-user:${auth.session.userId}`;
|
|
759
|
-
if (!(await checkRateLimitAsync(seoAuditLimiter, rateKey))) {
|
|
760
|
-
return errorResponse('Too many audits. Please try again later.', 429);
|
|
761
|
-
}
|
|
762
|
-
const body = (await request.json().catch(() => ({})));
|
|
763
|
-
const scope = body?.scope ?? 'full';
|
|
764
|
-
const { runAndPersistAudit, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
765
|
-
const run = await runAndPersistAudit(db(), {
|
|
766
|
-
scope,
|
|
767
|
-
startedById: auth.session.userId,
|
|
768
|
-
collectionTypes: frontEndContentCollectionTypes(getPrentaConfig()),
|
|
769
|
-
});
|
|
770
|
-
try {
|
|
771
|
-
await logEvent({
|
|
772
|
-
event: 'settings_changed',
|
|
773
|
-
userId: auth.session.userId,
|
|
774
|
-
details: { action: 'seo_audit', auditRunId: run.id, scope },
|
|
775
|
-
});
|
|
776
|
-
}
|
|
777
|
-
catch {
|
|
778
|
-
/* audit-log failures never block */
|
|
779
|
-
}
|
|
780
|
-
return json({
|
|
781
|
-
data: {
|
|
782
|
-
id: run.id,
|
|
783
|
-
status: run.status,
|
|
784
|
-
siteScoreAfter: run.siteScoreAfter,
|
|
785
|
-
issuesFound: run.issuesFound,
|
|
786
|
-
criticalCount: run.criticalCount,
|
|
787
|
-
warningCount: run.warningCount,
|
|
788
|
-
infoCount: run.infoCount,
|
|
789
|
-
pagesScanned: run.pagesScanned,
|
|
790
|
-
renderedAudit: run.renderedAudit,
|
|
791
|
-
},
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
|
-
catch (err) {
|
|
795
|
-
return internalError(err, 'seo/audit');
|
|
796
|
-
}
|
|
797
|
-
});
|
|
798
|
-
router.get('/seo/audits', async (request) => {
|
|
799
|
-
try {
|
|
800
|
-
const auth = await requireAuth(request);
|
|
801
|
-
if (auth.error)
|
|
802
|
-
return auth.error;
|
|
803
|
-
if (!hasModel(db(), 'seoAuditRun'))
|
|
804
|
-
return json({ data: { runs: [] } });
|
|
805
|
-
const runs = await db().seoAuditRun.findMany({
|
|
806
|
-
orderBy: { startedAt: 'desc' },
|
|
807
|
-
take: 50,
|
|
808
|
-
});
|
|
809
|
-
return json({ data: { runs } });
|
|
810
|
-
}
|
|
811
|
-
catch (err) {
|
|
812
|
-
return internalError(err, 'seo/audits');
|
|
813
|
-
}
|
|
814
|
-
});
|
|
815
|
-
router.get('/seo/audits/:id', async (request, params) => {
|
|
816
|
-
try {
|
|
817
|
-
const auth = await requireAuth(request);
|
|
818
|
-
if (auth.error)
|
|
819
|
-
return auth.error;
|
|
820
|
-
if (!hasModel(db(), 'seoAuditRun'))
|
|
821
|
-
return modelNotAvailable('SeoAuditRun');
|
|
822
|
-
const run = await db().seoAuditRun.findUnique({ where: { id: params.id } });
|
|
823
|
-
if (!run)
|
|
824
|
-
return errorResponse('Audit run not found', 404);
|
|
825
|
-
return json({ data: run });
|
|
826
|
-
}
|
|
827
|
-
catch (err) {
|
|
828
|
-
return internalError(err, 'seo/audits/:id');
|
|
829
|
-
}
|
|
830
|
-
});
|
|
831
|
-
router.get('/seo/issues', async (request) => {
|
|
832
|
-
try {
|
|
833
|
-
const auth = await requireAuth(request);
|
|
834
|
-
if (auth.error)
|
|
835
|
-
return auth.error;
|
|
836
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
837
|
-
return json({ data: { issues: [] } });
|
|
838
|
-
const url = new URL(request.url);
|
|
839
|
-
const where = {};
|
|
840
|
-
const severity = url.searchParams.get('severity');
|
|
841
|
-
const category = url.searchParams.get('category');
|
|
842
|
-
const status = url.searchParams.get('status');
|
|
843
|
-
const auditRunId = url.searchParams.get('auditRunId');
|
|
844
|
-
if (severity)
|
|
845
|
-
where.severity = severity;
|
|
846
|
-
if (category)
|
|
847
|
-
where.category = category;
|
|
848
|
-
if (status)
|
|
849
|
-
where.status = status;
|
|
850
|
-
else
|
|
851
|
-
where.status = { in: ['open', 'recurring'] };
|
|
852
|
-
if (auditRunId)
|
|
853
|
-
where.auditRunId = auditRunId;
|
|
854
|
-
const issues = await db().seoIssue.findMany({
|
|
855
|
-
where,
|
|
856
|
-
orderBy: { lastDetectedAt: 'desc' },
|
|
857
|
-
take: 500,
|
|
858
|
-
});
|
|
859
|
-
return json({ data: { issues: sortIssuesBySeverity(issues).map(seoIssueToApi) } });
|
|
860
|
-
}
|
|
861
|
-
catch (err) {
|
|
862
|
-
return internalError(err, 'seo/issues');
|
|
863
|
-
}
|
|
864
|
-
});
|
|
865
|
-
router.get('/seo/issues/export-brief', async (request) => {
|
|
866
|
-
try {
|
|
867
|
-
const auth = await requireAuth(request);
|
|
868
|
-
if (auth.error)
|
|
869
|
-
return auth.error;
|
|
870
|
-
if (!hasModel(db(), 'seoIssue')) {
|
|
871
|
-
const { buildSeoFixBriefMarkdown, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
|
|
872
|
-
return json({
|
|
873
|
-
data: {
|
|
874
|
-
markdown: buildSeoFixBriefMarkdown({ issues: [], manualOnly: true }),
|
|
875
|
-
filename: fixBriefFilename(),
|
|
876
|
-
issueCount: 0,
|
|
877
|
-
},
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
const url = new URL(request.url);
|
|
881
|
-
const severity = url.searchParams.get('severity');
|
|
882
|
-
const status = url.searchParams.get('status');
|
|
883
|
-
const auditRunId = url.searchParams.get('auditRunId');
|
|
884
|
-
const manualOnly = url.searchParams.get('manualOnly') !== 'false';
|
|
885
|
-
const includeContentBriefs = url.searchParams.get('includeContentBriefs') !== 'false';
|
|
886
|
-
const includeContentResearch = url.searchParams.get('includeContentResearch') !== 'false';
|
|
887
|
-
const where = {};
|
|
888
|
-
if (severity)
|
|
889
|
-
where.severity = severity;
|
|
890
|
-
if (status)
|
|
891
|
-
where.status = status;
|
|
892
|
-
else
|
|
893
|
-
where.status = { in: ['open', 'recurring'] };
|
|
894
|
-
if (auditRunId)
|
|
895
|
-
where.auditRunId = auditRunId;
|
|
896
|
-
const issues = sortIssuesBySeverity(await db().seoIssue.findMany({
|
|
897
|
-
where,
|
|
898
|
-
orderBy: { lastDetectedAt: 'desc' },
|
|
899
|
-
take: 500,
|
|
900
|
-
}));
|
|
901
|
-
let runAuditDate = null;
|
|
902
|
-
let runId = auditRunId;
|
|
903
|
-
if (auditRunId && hasModel(db(), 'seoAuditRun')) {
|
|
904
|
-
const run = await db().seoAuditRun.findUnique({ where: { id: auditRunId } });
|
|
905
|
-
runAuditDate = run?.finishedAt ?? run?.startedAt ?? null;
|
|
906
|
-
}
|
|
907
|
-
else if (hasModel(db(), 'seoAuditRun')) {
|
|
908
|
-
const lastRun = await db().seoAuditRun.findFirst({
|
|
909
|
-
where: { status: 'COMPLETED' },
|
|
910
|
-
orderBy: { startedAt: 'desc' },
|
|
911
|
-
});
|
|
912
|
-
runId = lastRun?.id ?? null;
|
|
913
|
-
runAuditDate = lastRun?.finishedAt ?? lastRun?.startedAt ?? null;
|
|
914
|
-
}
|
|
915
|
-
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
916
|
-
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
917
|
-
const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
|
|
918
|
-
const siteSeo = mergedConfig?.seo;
|
|
919
|
-
const { buildSeoFixBriefMarkdown, filterFixBriefIssues, fixBriefFilename } = await import('../../seo/fix-brief-export.js');
|
|
920
|
-
const entityIds = [
|
|
921
|
-
...new Set(issues
|
|
922
|
-
.map((i) => i.entityId)
|
|
923
|
-
.filter((id) => typeof id === 'string' && id.length > 0)),
|
|
924
|
-
];
|
|
925
|
-
const updatedAtByEntity = new Map();
|
|
926
|
-
if ((includeContentBriefs || includeContentResearch) && entityIds.length > 0) {
|
|
927
|
-
const docs = await db().document.findMany({
|
|
928
|
-
where: { id: { in: entityIds }, deletedAt: null },
|
|
929
|
-
select: { id: true, updatedAt: true },
|
|
930
|
-
});
|
|
931
|
-
for (const doc of docs) {
|
|
932
|
-
updatedAtByEntity.set(doc.id, doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt));
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
const { contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
|
|
936
|
-
const { contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
|
|
937
|
-
const briefIssues = issues.map((i) => {
|
|
938
|
-
let contentBrief = null;
|
|
939
|
-
let contentResearch = null;
|
|
940
|
-
if (i.entityId) {
|
|
941
|
-
const updatedAt = updatedAtByEntity.get(i.entityId);
|
|
942
|
-
if (updatedAt) {
|
|
943
|
-
if (includeContentBriefs) {
|
|
944
|
-
contentBrief = contentBriefFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
|
|
945
|
-
}
|
|
946
|
-
if (includeContentResearch) {
|
|
947
|
-
contentResearch = contentResearchFromMetadata(i.metadata, i.type, i.entityId, updatedAt);
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
return {
|
|
952
|
-
id: i.id,
|
|
953
|
-
type: i.type,
|
|
954
|
-
title: i.title,
|
|
955
|
-
description: i.description ?? null,
|
|
956
|
-
impact: i.impact ?? null,
|
|
957
|
-
recommendation: i.recommendation ?? null,
|
|
958
|
-
severity: i.severity,
|
|
959
|
-
category: i.category,
|
|
960
|
-
status: i.status,
|
|
961
|
-
entityType: i.entityType ?? null,
|
|
962
|
-
entityId: i.entityId ?? null,
|
|
963
|
-
entityTitle: i.entityTitle ?? null,
|
|
964
|
-
url: i.url ?? null,
|
|
965
|
-
fixActionType: i.fixActionType ?? null,
|
|
966
|
-
fixable: i.fixable ?? false,
|
|
967
|
-
contentBrief,
|
|
968
|
-
contentResearch,
|
|
969
|
-
};
|
|
970
|
-
});
|
|
971
|
-
const markdown = buildSeoFixBriefMarkdown({
|
|
972
|
-
siteName: siteSeo?.siteName ?? null,
|
|
973
|
-
siteUrl: siteSeo?.siteUrl ?? null,
|
|
974
|
-
auditRunId: runId,
|
|
975
|
-
auditDate: runAuditDate,
|
|
976
|
-
issues: briefIssues,
|
|
977
|
-
manualOnly,
|
|
978
|
-
});
|
|
979
|
-
const issueCount = filterFixBriefIssues(briefIssues, manualOnly).length;
|
|
980
|
-
return json({
|
|
981
|
-
data: {
|
|
982
|
-
markdown,
|
|
983
|
-
filename: fixBriefFilename(siteSeo?.siteName, runAuditDate ?? undefined),
|
|
984
|
-
issueCount,
|
|
985
|
-
},
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
catch (err) {
|
|
989
|
-
return internalError(err, 'seo/issues/export-brief');
|
|
990
|
-
}
|
|
991
|
-
});
|
|
992
|
-
router.post('/seo/issues/batch-content-briefs', async (request) => {
|
|
993
|
-
try {
|
|
994
|
-
const auth = await requireAuth(request);
|
|
995
|
-
if (auth.error)
|
|
996
|
-
return auth.error;
|
|
997
|
-
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
998
|
-
if (roleErr)
|
|
999
|
-
return roleErr;
|
|
1000
|
-
const batchPlanErr = assertPlanFeature('seo.batchContentBriefs');
|
|
1001
|
-
if (batchPlanErr)
|
|
1002
|
-
return planRequiredResponse(batchPlanErr);
|
|
1003
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1004
|
-
return modelNotAvailable('SeoIssue');
|
|
1005
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-batch-briefs:${auth.session.userId}`))) {
|
|
1006
|
-
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
1007
|
-
}
|
|
1008
|
-
const body = (await request.json().catch(() => ({})));
|
|
1009
|
-
const severity = typeof body?.severity === 'string' ? body.severity : null;
|
|
1010
|
-
const status = typeof body?.status === 'string' ? body.status : null;
|
|
1011
|
-
const skipExisting = body?.skipExisting !== false;
|
|
1012
|
-
const includeResearch = body?.includeResearch === true;
|
|
1013
|
-
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
1014
|
-
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
1015
|
-
: undefined;
|
|
1016
|
-
const issueIds = Array.isArray(body?.issueIds)
|
|
1017
|
-
? body.issueIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
1018
|
-
: null;
|
|
1019
|
-
const where = {};
|
|
1020
|
-
if (severity)
|
|
1021
|
-
where.severity = severity;
|
|
1022
|
-
if (status)
|
|
1023
|
-
where.status = status;
|
|
1024
|
-
else
|
|
1025
|
-
where.status = { in: ['open', 'recurring'] };
|
|
1026
|
-
let issues = await db().seoIssue.findMany({
|
|
1027
|
-
where,
|
|
1028
|
-
orderBy: { lastDetectedAt: 'desc' },
|
|
1029
|
-
take: 500,
|
|
1030
|
-
});
|
|
1031
|
-
if (issueIds?.length) {
|
|
1032
|
-
const idSet = new Set(issueIds);
|
|
1033
|
-
issues = issues.filter((i) => idSet.has(i.id));
|
|
1034
|
-
}
|
|
1035
|
-
const { supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
1036
|
-
const eligible = issues.filter((i) => supportsContentBrief(i.type));
|
|
1037
|
-
const entityIds = [
|
|
1038
|
-
...new Set(eligible
|
|
1039
|
-
.map((i) => i.entityId)
|
|
1040
|
-
.filter((id) => typeof id === 'string' && id.length > 0)),
|
|
1041
|
-
];
|
|
1042
|
-
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
1043
|
-
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
1044
|
-
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
1045
|
-
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
1046
|
-
const mergedConfig = applySeoOverrides(getPrentaConfig(), overrides);
|
|
1047
|
-
const contexts = new Map();
|
|
1048
|
-
if (entityIds.length > 0) {
|
|
1049
|
-
const docs = await db().document.findMany({
|
|
1050
|
-
where: { id: { in: entityIds }, deletedAt: null },
|
|
1051
|
-
select: {
|
|
1052
|
-
id: true,
|
|
1053
|
-
collection: true,
|
|
1054
|
-
title: true,
|
|
1055
|
-
slug: true,
|
|
1056
|
-
data: true,
|
|
1057
|
-
structuredData: true,
|
|
1058
|
-
plainText: true,
|
|
1059
|
-
publishedAt: true,
|
|
1060
|
-
updatedAt: true,
|
|
1061
|
-
},
|
|
1062
|
-
});
|
|
1063
|
-
for (const doc of docs) {
|
|
1064
|
-
contexts.set(doc.id, {
|
|
1065
|
-
collection: doc.collection,
|
|
1066
|
-
entity: documentToAuditEntity(doc, collectionTypes, mergedConfig?.seo),
|
|
1067
|
-
updatedAt: doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt),
|
|
1068
|
-
});
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
const { batchGenerateContentBriefs } = await import('../../seo/issue-batch-briefs.js');
|
|
1072
|
-
const result = await batchGenerateContentBriefs(eligible.map((i) => ({
|
|
1073
|
-
id: i.id,
|
|
1074
|
-
type: i.type,
|
|
1075
|
-
title: i.title,
|
|
1076
|
-
description: i.description,
|
|
1077
|
-
recommendation: i.recommendation,
|
|
1078
|
-
entityId: i.entityId,
|
|
1079
|
-
entityType: i.entityType,
|
|
1080
|
-
metadata: i.metadata,
|
|
1081
|
-
})), contexts, { db: db(), userId: auth.session.userId, collection: 'pages' }, { skipExisting, includeResearch, competitorUrls });
|
|
1082
|
-
return json({ data: result });
|
|
1083
|
-
}
|
|
1084
|
-
catch (err) {
|
|
1085
|
-
return internalError(err, 'seo/issues/batch-content-briefs');
|
|
1086
|
-
}
|
|
1087
|
-
});
|
|
1088
|
-
router.get('/seo/issues/:id/content-brief', async (request, params) => {
|
|
1089
|
-
try {
|
|
1090
|
-
const auth = await requireAuth(request);
|
|
1091
|
-
if (auth.error)
|
|
1092
|
-
return auth.error;
|
|
1093
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1094
|
-
return modelNotAvailable('SeoIssue');
|
|
1095
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1096
|
-
if (!issue)
|
|
1097
|
-
return errorResponse('Issue not found', 404);
|
|
1098
|
-
const { supportsContentBrief, contentBriefFromMetadata } = await import('../../seo/issue-content-brief.js');
|
|
1099
|
-
if (!supportsContentBrief(issue.type)) {
|
|
1100
|
-
return json({ data: { supported: false, brief: null, stale: false } });
|
|
1101
|
-
}
|
|
1102
|
-
if (!issue.entityId) {
|
|
1103
|
-
return json({ data: { supported: true, brief: null, stale: false } });
|
|
1104
|
-
}
|
|
1105
|
-
const loaded = await loadIssueFixContext(issue);
|
|
1106
|
-
if ('error' in loaded)
|
|
1107
|
-
return loaded.error;
|
|
1108
|
-
const brief = contentBriefFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
|
|
1109
|
-
const raw = asRecord(issue.metadata)?.contentBrief;
|
|
1110
|
-
const stale = !!raw &&
|
|
1111
|
-
typeof raw === 'object' &&
|
|
1112
|
-
!Array.isArray(raw) &&
|
|
1113
|
-
typeof raw.fingerprint === 'string' &&
|
|
1114
|
-
!brief;
|
|
1115
|
-
return json({ data: { supported: true, brief, stale } });
|
|
1116
|
-
}
|
|
1117
|
-
catch (err) {
|
|
1118
|
-
return internalError(err, 'seo/issues/:id/content-brief');
|
|
1119
|
-
}
|
|
1120
|
-
});
|
|
1121
|
-
router.post('/seo/issues/:id/content-brief', async (request, params) => {
|
|
1122
|
-
try {
|
|
1123
|
-
const auth = await requireAuth(request);
|
|
1124
|
-
if (auth.error)
|
|
1125
|
-
return auth.error;
|
|
1126
|
-
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1127
|
-
if (roleErr)
|
|
1128
|
-
return roleErr;
|
|
1129
|
-
const planErr = assertPlanFeature('seo.contentBrief');
|
|
1130
|
-
if (planErr)
|
|
1131
|
-
return planRequiredResponse(planErr);
|
|
1132
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1133
|
-
return modelNotAvailable('SeoIssue');
|
|
1134
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-brief:${auth.session.userId}`))) {
|
|
1135
|
-
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
1136
|
-
}
|
|
1137
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1138
|
-
if (!issue)
|
|
1139
|
-
return errorResponse('Issue not found', 404);
|
|
1140
|
-
const loaded = await loadIssueFixContext(issue);
|
|
1141
|
-
if ('error' in loaded)
|
|
1142
|
-
return loaded.error;
|
|
1143
|
-
const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
1144
|
-
if (!supportsContentBrief(issue.type)) {
|
|
1145
|
-
return errorResponse('This issue type does not support content briefs.', 422);
|
|
1146
|
-
}
|
|
1147
|
-
const result = await generateContentBrief({
|
|
1148
|
-
id: issue.id,
|
|
1149
|
-
type: issue.type,
|
|
1150
|
-
title: issue.title,
|
|
1151
|
-
description: issue.description,
|
|
1152
|
-
recommendation: issue.recommendation,
|
|
1153
|
-
entityId: issue.entityId,
|
|
1154
|
-
}, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection });
|
|
1155
|
-
if (!result.ok) {
|
|
1156
|
-
return seoAiGenerationError(result);
|
|
1157
|
-
}
|
|
1158
|
-
const existingMeta = asRecord(issue.metadata);
|
|
1159
|
-
await db().seoIssue.update({
|
|
1160
|
-
where: { id: issue.id },
|
|
1161
|
-
data: { metadata: { ...existingMeta, ...contentBriefToMetadata(result.brief) } },
|
|
1162
|
-
});
|
|
1163
|
-
return json({ data: { brief: result.brief } });
|
|
1164
|
-
}
|
|
1165
|
-
catch (err) {
|
|
1166
|
-
return internalError(err, 'seo/issues/:id/content-brief');
|
|
1167
|
-
}
|
|
1168
|
-
});
|
|
1169
|
-
router.get('/seo/issues/:id/content-research', async (request, params) => {
|
|
1170
|
-
try {
|
|
1171
|
-
const auth = await requireAuth(request);
|
|
1172
|
-
if (auth.error)
|
|
1173
|
-
return auth.error;
|
|
1174
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1175
|
-
return modelNotAvailable('SeoIssue');
|
|
1176
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1177
|
-
if (!issue)
|
|
1178
|
-
return errorResponse('Issue not found', 404);
|
|
1179
|
-
const { supportsContentResearch, contentResearchFromMetadata } = await import('../../seo/issue-content-research.js');
|
|
1180
|
-
if (!supportsContentResearch(issue.type)) {
|
|
1181
|
-
return json({ data: { supported: false, research: null, stale: false } });
|
|
1182
|
-
}
|
|
1183
|
-
if (!issue.entityId) {
|
|
1184
|
-
return json({ data: { supported: true, research: null, stale: false } });
|
|
1185
|
-
}
|
|
1186
|
-
const loaded = await loadIssueFixContext(issue);
|
|
1187
|
-
if ('error' in loaded)
|
|
1188
|
-
return loaded.error;
|
|
1189
|
-
const research = contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt);
|
|
1190
|
-
const raw = asRecord(issue.metadata)?.contentResearch;
|
|
1191
|
-
const stale = !!raw &&
|
|
1192
|
-
typeof raw === 'object' &&
|
|
1193
|
-
!Array.isArray(raw) &&
|
|
1194
|
-
typeof raw.fingerprint === 'string' &&
|
|
1195
|
-
!research;
|
|
1196
|
-
return json({ data: { supported: true, research, stale } });
|
|
1197
|
-
}
|
|
1198
|
-
catch (err) {
|
|
1199
|
-
return internalError(err, 'seo/issues/:id/content-research');
|
|
1200
|
-
}
|
|
1201
|
-
});
|
|
1202
|
-
router.post('/seo/issues/:id/content-research', async (request, params) => {
|
|
1203
|
-
try {
|
|
1204
|
-
const auth = await requireAuth(request);
|
|
1205
|
-
if (auth.error)
|
|
1206
|
-
return auth.error;
|
|
1207
|
-
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1208
|
-
if (roleErr)
|
|
1209
|
-
return roleErr;
|
|
1210
|
-
const researchPlanErr = assertPlanFeature('seo.contentResearch');
|
|
1211
|
-
if (researchPlanErr)
|
|
1212
|
-
return planRequiredResponse(researchPlanErr);
|
|
1213
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1214
|
-
return modelNotAvailable('SeoIssue');
|
|
1215
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-content-research:${auth.session.userId}`))) {
|
|
1216
|
-
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
1217
|
-
}
|
|
1218
|
-
const body = (await request.json().catch(() => ({})));
|
|
1219
|
-
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
1220
|
-
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
1221
|
-
: undefined;
|
|
1222
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1223
|
-
if (!issue)
|
|
1224
|
-
return errorResponse('Issue not found', 404);
|
|
1225
|
-
const loaded = await loadIssueFixContext(issue);
|
|
1226
|
-
if ('error' in loaded)
|
|
1227
|
-
return loaded.error;
|
|
1228
|
-
const { generateContentResearch, contentResearchToMetadata, supportsContentResearch } = await import('../../seo/issue-content-research.js');
|
|
1229
|
-
if (!supportsContentResearch(issue.type)) {
|
|
1230
|
-
return errorResponse('This issue type does not support topic research.', 422);
|
|
1231
|
-
}
|
|
1232
|
-
const result = await generateContentResearch({
|
|
1233
|
-
id: issue.id,
|
|
1234
|
-
type: issue.type,
|
|
1235
|
-
title: issue.title,
|
|
1236
|
-
description: issue.description,
|
|
1237
|
-
recommendation: issue.recommendation,
|
|
1238
|
-
entityId: issue.entityId,
|
|
1239
|
-
}, { entity: loaded.entity, updatedAt: loaded.updatedAt }, { db: db(), userId: auth.session.userId, collection: loaded.collection }, { competitorUrls });
|
|
1240
|
-
if (!result.ok) {
|
|
1241
|
-
return seoAiGenerationError(result);
|
|
1242
|
-
}
|
|
1243
|
-
const existingMeta = asRecord(issue.metadata);
|
|
1244
|
-
await db().seoIssue.update({
|
|
1245
|
-
where: { id: issue.id },
|
|
1246
|
-
data: { metadata: { ...existingMeta, ...contentResearchToMetadata(result.research) } },
|
|
1247
|
-
});
|
|
1248
|
-
return json({ data: { research: result.research } });
|
|
1249
|
-
}
|
|
1250
|
-
catch (err) {
|
|
1251
|
-
return internalError(err, 'seo/issues/:id/content-research');
|
|
1252
|
-
}
|
|
1253
|
-
});
|
|
1254
|
-
router.post('/seo/issues/:id/research-and-brief', async (request, params) => {
|
|
1255
|
-
try {
|
|
1256
|
-
const auth = await requireAuth(request);
|
|
1257
|
-
if (auth.error)
|
|
1258
|
-
return auth.error;
|
|
1259
|
-
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1260
|
-
if (roleErr)
|
|
1261
|
-
return roleErr;
|
|
1262
|
-
const comboPlanErr = assertPlanFeature('seo.contentResearch');
|
|
1263
|
-
if (comboPlanErr)
|
|
1264
|
-
return planRequiredResponse(comboPlanErr);
|
|
1265
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1266
|
-
return modelNotAvailable('SeoIssue');
|
|
1267
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-research-brief:${auth.session.userId}`))) {
|
|
1268
|
-
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
1269
|
-
}
|
|
1270
|
-
const body = (await request.json().catch(() => ({})));
|
|
1271
|
-
const competitorUrls = Array.isArray(body?.competitorUrls)
|
|
1272
|
-
? body.competitorUrls.filter((u) => typeof u === 'string')
|
|
1273
|
-
: undefined;
|
|
1274
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1275
|
-
if (!issue)
|
|
1276
|
-
return errorResponse('Issue not found', 404);
|
|
1277
|
-
const loaded = await loadIssueFixContext(issue);
|
|
1278
|
-
if ('error' in loaded)
|
|
1279
|
-
return loaded.error;
|
|
1280
|
-
const { generateContentResearch, contentResearchToMetadata, supportsContentResearch, contentResearchFromMetadata, } = await import('../../seo/issue-content-research.js');
|
|
1281
|
-
const { generateContentBrief, contentBriefToMetadata, supportsContentBrief } = await import('../../seo/issue-content-brief.js');
|
|
1282
|
-
if (!supportsContentBrief(issue.type)) {
|
|
1283
|
-
return errorResponse('This issue type does not support content briefs.', 422);
|
|
1284
|
-
}
|
|
1285
|
-
const issueInput = {
|
|
1286
|
-
id: issue.id,
|
|
1287
|
-
type: issue.type,
|
|
1288
|
-
title: issue.title,
|
|
1289
|
-
description: issue.description,
|
|
1290
|
-
recommendation: issue.recommendation,
|
|
1291
|
-
entityId: issue.entityId,
|
|
1292
|
-
};
|
|
1293
|
-
const briefCtx = { entity: loaded.entity, updatedAt: loaded.updatedAt };
|
|
1294
|
-
const deps = { db: db(), userId: auth.session.userId, collection: loaded.collection };
|
|
1295
|
-
let research = supportsContentResearch(issue.type)
|
|
1296
|
-
? contentResearchFromMetadata(issue.metadata, issue.type, issue.entityId, loaded.updatedAt)
|
|
1297
|
-
: null;
|
|
1298
|
-
if (supportsContentResearch(issue.type) && !research) {
|
|
1299
|
-
const researchResult = await generateContentResearch(issueInput, briefCtx, deps, {
|
|
1300
|
-
competitorUrls,
|
|
1301
|
-
});
|
|
1302
|
-
if (!researchResult.ok) {
|
|
1303
|
-
return seoAiGenerationError(researchResult);
|
|
1304
|
-
}
|
|
1305
|
-
research = researchResult.research;
|
|
1306
|
-
}
|
|
1307
|
-
const briefResult = await generateContentBrief(issueInput, briefCtx, deps, { research });
|
|
1308
|
-
if (!briefResult.ok) {
|
|
1309
|
-
return seoAiGenerationError(briefResult);
|
|
1310
|
-
}
|
|
1311
|
-
const existingMeta = asRecord(issue.metadata);
|
|
1312
|
-
const metadata = {
|
|
1313
|
-
...existingMeta,
|
|
1314
|
-
...contentBriefToMetadata(briefResult.brief),
|
|
1315
|
-
...(research ? contentResearchToMetadata(research) : {}),
|
|
1316
|
-
};
|
|
1317
|
-
await db().seoIssue.update({
|
|
1318
|
-
where: { id: issue.id },
|
|
1319
|
-
data: { metadata },
|
|
1320
|
-
});
|
|
1321
|
-
return json({ data: { research, brief: briefResult.brief } });
|
|
1322
|
-
}
|
|
1323
|
-
catch (err) {
|
|
1324
|
-
return internalError(err, 'seo/issues/:id/research-and-brief');
|
|
1325
|
-
}
|
|
1326
|
-
});
|
|
1327
|
-
router.get('/seo/issues/:id', async (request, params) => {
|
|
1328
|
-
try {
|
|
1329
|
-
const auth = await requireAuth(request);
|
|
1330
|
-
if (auth.error)
|
|
1331
|
-
return auth.error;
|
|
1332
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1333
|
-
return modelNotAvailable('SeoIssue');
|
|
1334
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1335
|
-
if (!issue)
|
|
1336
|
-
return errorResponse('Issue not found', 404);
|
|
1337
|
-
return json({ data: seoIssueToApi(issue) });
|
|
1338
|
-
}
|
|
1339
|
-
catch (err) {
|
|
1340
|
-
return internalError(err, 'seo/issues/:id');
|
|
1341
|
-
}
|
|
1342
|
-
});
|
|
1343
|
-
async function setIssueStatus(request, issueId, status) {
|
|
1344
|
-
const auth = await requireAuth(request);
|
|
1345
|
-
if (auth.error)
|
|
1346
|
-
return auth.error;
|
|
1347
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1348
|
-
if (roleErr)
|
|
1349
|
-
return roleErr;
|
|
1350
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1351
|
-
return modelNotAvailable('SeoIssue');
|
|
1352
|
-
const existing = await db().seoIssue.findUnique({ where: { id: issueId } });
|
|
1353
|
-
if (!existing)
|
|
1354
|
-
return errorResponse('Issue not found', 404);
|
|
1355
|
-
const issue = await db().seoIssue.update({
|
|
1356
|
-
where: { id: issueId },
|
|
1357
|
-
data: {
|
|
1358
|
-
status,
|
|
1359
|
-
resolvedAt: status === 'resolved' ? new Date() : null,
|
|
1360
|
-
resolvedById: status === 'resolved' ? auth.session.userId : null,
|
|
1361
|
-
},
|
|
1362
|
-
});
|
|
1363
|
-
try {
|
|
1364
|
-
await logEvent({
|
|
1365
|
-
event: 'settings_changed',
|
|
1366
|
-
userId: auth.session.userId,
|
|
1367
|
-
details: { action: 'seo_issue_status', issueId, status },
|
|
1368
|
-
});
|
|
1369
|
-
}
|
|
1370
|
-
catch {
|
|
1371
|
-
/* noop */
|
|
1372
|
-
}
|
|
1373
|
-
return json({ data: seoIssueToApi(issue) });
|
|
1374
|
-
}
|
|
1375
|
-
router.post('/seo/issues/:id/resolve', (request, params) => setIssueStatus(request, params.id, 'resolved').catch((e) => internalError(e)));
|
|
1376
|
-
router.post('/seo/issues/:id/ignore', (request, params) => setIssueStatus(request, params.id, 'ignored').catch((e) => internalError(e)));
|
|
1377
|
-
router.post('/seo/issues/:id/reopen', (request, params) => setIssueStatus(request, params.id, 'open').catch((e) => internalError(e)));
|
|
1378
|
-
/** Load the audit entity + collection for an issue's linked document. */
|
|
1379
|
-
async function loadIssueFixContext(issue) {
|
|
1380
|
-
const { loadIssueFixContext: load } = await import('../../seo/issue-fix-apply.js');
|
|
1381
|
-
const loaded = await load(db(), issue);
|
|
1382
|
-
if (!loaded.ok)
|
|
1383
|
-
return { error: errorResponse(loaded.message, loaded.status) };
|
|
1384
|
-
return loaded.ctx;
|
|
1385
|
-
}
|
|
1386
|
-
router.post('/seo/issues/:id/suggest-fix', async (request, params) => {
|
|
1387
|
-
try {
|
|
1388
|
-
const auth = await requireAuth(request);
|
|
1389
|
-
if (auth.error)
|
|
1390
|
-
return auth.error;
|
|
1391
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1392
|
-
return modelNotAvailable('SeoIssue');
|
|
1393
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1394
|
-
if (!issue)
|
|
1395
|
-
return errorResponse('Issue not found', 404);
|
|
1396
|
-
const loaded = await loadIssueFixContext(issue);
|
|
1397
|
-
if ('error' in loaded)
|
|
1398
|
-
return loaded.error;
|
|
1399
|
-
const issueInput = {
|
|
1400
|
-
type: issue.type,
|
|
1401
|
-
title: issue.title,
|
|
1402
|
-
recommendation: issue.recommendation,
|
|
1403
|
-
entityType: issue.entityType,
|
|
1404
|
-
entityId: issue.entityId,
|
|
1405
|
-
};
|
|
1406
|
-
const fixCtx = {
|
|
1407
|
-
entity: loaded.entity,
|
|
1408
|
-
siteSeo: loaded.siteSeo,
|
|
1409
|
-
updatedAt: loaded.updatedAt,
|
|
1410
|
-
metadata: asRecord(issue.metadata),
|
|
1411
|
-
};
|
|
1412
|
-
let aiDeps;
|
|
1413
|
-
const { REDIRECT_FIX_TYPES } = await import('../../seo/issue-fix-redirect.js');
|
|
1414
|
-
const { INSERT_LINK_FIX_TYPES } = await import('../../seo/issue-fix-link-insert.js');
|
|
1415
|
-
if (REDIRECT_FIX_TYPES.has(issue.type) || INSERT_LINK_FIX_TYPES.has(issue.type)) {
|
|
1416
|
-
aiDeps = {
|
|
1417
|
-
db: db(),
|
|
1418
|
-
userId: auth.session.userId,
|
|
1419
|
-
collection: loaded.collection,
|
|
1420
|
-
};
|
|
1421
|
-
}
|
|
1422
|
-
else {
|
|
1423
|
-
const adminErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1424
|
-
if (!adminErr &&
|
|
1425
|
-
hasPlanFeature('seo.inlineApply') &&
|
|
1426
|
-
(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-fix-suggest:${auth.session.userId}`))) {
|
|
1427
|
-
aiDeps = {
|
|
1428
|
-
db: db(),
|
|
1429
|
-
userId: auth.session.userId,
|
|
1430
|
-
collection: loaded.collection,
|
|
1431
|
-
};
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
const { suggestIssueFix } = await import('../../seo/issue-fix.js');
|
|
1435
|
-
const { pendingFixToMetadata, scoreFixBrandAlignment } = await import('../../seo/issue-fix-ai.js');
|
|
1436
|
-
const result = await suggestIssueFix(issueInput, fixCtx, aiDeps);
|
|
1437
|
-
const existingMeta = asRecord(issue.metadata);
|
|
1438
|
-
let nextMetadata;
|
|
1439
|
-
if (result.cacheForApply) {
|
|
1440
|
-
// Deterministic brand-voice score stamped at generation (no LLM call)
|
|
1441
|
-
// so the proposals inbox can badge it without re-scoring.
|
|
1442
|
-
const { getAIConfig } = await import('../../ai/config-store.js');
|
|
1443
|
-
const brandVoice = await getAIConfig(db())
|
|
1444
|
-
.then((c) => c.settings.brandVoice)
|
|
1445
|
-
.catch((err) => {
|
|
1446
|
-
// Fail open: an unreadable AI config must not cost a working suggestion.
|
|
1447
|
-
seoLogger.warn('AI config unavailable for brand alignment', {
|
|
1448
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1449
|
-
});
|
|
1450
|
-
return undefined;
|
|
1451
|
-
});
|
|
1452
|
-
nextMetadata = {
|
|
1453
|
-
...existingMeta,
|
|
1454
|
-
...pendingFixToMetadata(result.suggestion, {
|
|
1455
|
-
brandAlignment: scoreFixBrandAlignment(result.suggestion.changes, brandVoice),
|
|
1456
|
-
}),
|
|
1457
|
-
};
|
|
1458
|
-
}
|
|
1459
|
-
else {
|
|
1460
|
-
nextMetadata = { ...existingMeta, pendingFix: null };
|
|
1461
|
-
}
|
|
1462
|
-
await db().seoIssue.update({
|
|
1463
|
-
where: { id: issue.id },
|
|
1464
|
-
data: { metadata: nextMetadata },
|
|
1465
|
-
});
|
|
1466
|
-
return json({ data: { suggestion: result.suggestion } });
|
|
1467
|
-
}
|
|
1468
|
-
catch (err) {
|
|
1469
|
-
return internalError(err, 'seo/issues/:id/suggest-fix');
|
|
1470
|
-
}
|
|
1471
|
-
});
|
|
1472
|
-
router.post('/seo/issues/:id/apply-fix', async (request, params) => {
|
|
1473
|
-
try {
|
|
1474
|
-
const auth = await requireAuth(request);
|
|
1475
|
-
if (auth.error)
|
|
1476
|
-
return auth.error;
|
|
1477
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1478
|
-
if (roleErr)
|
|
1479
|
-
return roleErr;
|
|
1480
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1481
|
-
return modelNotAvailable('SeoIssue');
|
|
1482
|
-
const parsed = await validateBody(request, seoApplyFixBodySchema);
|
|
1483
|
-
if (!parsed.ok)
|
|
1484
|
-
return parsed.error;
|
|
1485
|
-
const body = parsed.data;
|
|
1486
|
-
const [{ applyIssueFix, VERIFY_FAILED_MESSAGE }, { FAQ_HEADING_MAX, FAQ_MAX_ITEMS }] = await Promise.all([
|
|
1487
|
-
import('../../seo/issue-fix-apply.js'),
|
|
1488
|
-
import('../../seo/issue-fix-faq.js'),
|
|
1489
|
-
]);
|
|
1490
|
-
const fingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : undefined;
|
|
1491
|
-
// `includeItems` / `headingOverride` only mean something for `add-section`
|
|
1492
|
-
// fixes; they are validated for shape here and ignored by other branches.
|
|
1493
|
-
let includeItems;
|
|
1494
|
-
if (body?.includeItems !== undefined) {
|
|
1495
|
-
const raw = body.includeItems;
|
|
1496
|
-
if (!Array.isArray(raw) ||
|
|
1497
|
-
raw.length > FAQ_MAX_ITEMS ||
|
|
1498
|
-
!raw.every((n) => typeof n === 'number' && Number.isInteger(n) && n >= 0) ||
|
|
1499
|
-
new Set(raw).size !== raw.length) {
|
|
1500
|
-
return errorResponse(`includeItems must be an array of at most ${FAQ_MAX_ITEMS} distinct non-negative integers`, 400);
|
|
1501
|
-
}
|
|
1502
|
-
includeItems = raw;
|
|
1503
|
-
}
|
|
1504
|
-
let headingOverride;
|
|
1505
|
-
if (body?.headingOverride !== undefined) {
|
|
1506
|
-
// Trimmed length is the one that lands in the section, so it is the one measured.
|
|
1507
|
-
const trimmed = typeof body.headingOverride === 'string' ? body.headingOverride.trim() : null;
|
|
1508
|
-
if (trimmed === null || trimmed.length > FAQ_HEADING_MAX) {
|
|
1509
|
-
return errorResponse(`headingOverride must be a string of at most ${FAQ_HEADING_MAX} characters`, 400);
|
|
1510
|
-
}
|
|
1511
|
-
headingOverride = trimmed;
|
|
1512
|
-
}
|
|
1513
|
-
const result = await applyIssueFix(db(), {
|
|
1514
|
-
issueId: params.id,
|
|
1515
|
-
fingerprint,
|
|
1516
|
-
includeItems,
|
|
1517
|
-
headingOverride,
|
|
1518
|
-
actor: { userId: auth.session.userId, role: auth.session.role },
|
|
1519
|
-
authorizeContentWrite: contentWriteGate(auth.session),
|
|
1520
|
-
});
|
|
1521
|
-
switch (result.status) {
|
|
1522
|
-
case 'applied':
|
|
1523
|
-
return json({
|
|
1524
|
-
data: {
|
|
1525
|
-
issue: seoIssueToApi(result.issue),
|
|
1526
|
-
applied: result.applied,
|
|
1527
|
-
...(result.sectionId ? { sectionId: result.sectionId } : {}),
|
|
1528
|
-
},
|
|
1529
|
-
});
|
|
1530
|
-
case 'stale':
|
|
1531
|
-
return errorResponse(result.reason, 409);
|
|
1532
|
-
case 'verify-failed':
|
|
1533
|
-
return json({ error: VERIFY_FAILED_MESSAGE, reason: result.reason }, 409);
|
|
1534
|
-
case 'plan-required':
|
|
1535
|
-
return planRequiredResponse(result.denial);
|
|
1536
|
-
case 'not-found':
|
|
1537
|
-
return errorResponse(result.reason, 404);
|
|
1538
|
-
case 'not-allowed':
|
|
1539
|
-
return errorResponse(result.reason, result.httpStatus);
|
|
1540
|
-
case 'not-fixable':
|
|
1541
|
-
return errorResponse(result.reason, result.httpStatus);
|
|
1542
|
-
default: {
|
|
1543
|
-
const _exhaustive = result;
|
|
1544
|
-
return _exhaustive;
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
catch (err) {
|
|
1549
|
-
return internalError(err, 'seo/issues/:id/apply-fix');
|
|
1550
|
-
}
|
|
1551
|
-
});
|
|
1552
|
-
router.post('/seo/issues/:id/dismiss-fix', async (request, params) => {
|
|
1553
|
-
try {
|
|
1554
|
-
const auth = await requireAuth(request);
|
|
1555
|
-
if (auth.error)
|
|
1556
|
-
return auth.error;
|
|
1557
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1558
|
-
if (roleErr)
|
|
1559
|
-
return roleErr;
|
|
1560
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1561
|
-
return modelNotAvailable('SeoIssue');
|
|
1562
|
-
const issue = await db().seoIssue.findUnique({ where: { id: params.id } });
|
|
1563
|
-
if (!issue)
|
|
1564
|
-
return errorResponse('Issue not found', 404);
|
|
1565
|
-
const { pendingFixFromMetadata } = await import('../../seo/issue-fix-ai.js');
|
|
1566
|
-
const cached = pendingFixFromMetadata(issue.metadata);
|
|
1567
|
-
if (!cached)
|
|
1568
|
-
return json({ data: { dismissed: false } });
|
|
1569
|
-
// Sticky: remember which document state was rejected so batch
|
|
1570
|
-
// generation does not recreate it until the document changes. The
|
|
1571
|
-
// marker must match the entity-based live fingerprint the no-cache
|
|
1572
|
-
// path computes, which differs from the cached one for insert-link.
|
|
1573
|
-
const { dismissalFingerprintFor } = await import('../../seo/proposals.js');
|
|
1574
|
-
const entityDoc = typeof issue.entityId === 'string'
|
|
1575
|
-
? await db()
|
|
1576
|
-
.document.findUnique({ where: { id: issue.entityId }, select: { updatedAt: true } })
|
|
1577
|
-
.catch(() => null)
|
|
1578
|
-
: null;
|
|
1579
|
-
const dismissedFingerprint = dismissalFingerprintFor(issue, cached, entityDoc?.updatedAt ?? null);
|
|
1580
|
-
await db().seoIssue.update({
|
|
1581
|
-
where: { id: issue.id },
|
|
1582
|
-
data: {
|
|
1583
|
-
metadata: {
|
|
1584
|
-
...asRecord(issue.metadata),
|
|
1585
|
-
pendingFix: null,
|
|
1586
|
-
dismissedFingerprint,
|
|
1587
|
-
},
|
|
1588
|
-
},
|
|
1589
|
-
});
|
|
1590
|
-
try {
|
|
1591
|
-
await logEvent({
|
|
1592
|
-
event: 'seo_fix_dismissed',
|
|
1593
|
-
userId: auth.session.userId,
|
|
1594
|
-
details: {
|
|
1595
|
-
issueId: issue.id,
|
|
1596
|
-
issueType: issue.type,
|
|
1597
|
-
entityId: issue.entityId ?? null,
|
|
1598
|
-
fingerprint: dismissedFingerprint,
|
|
1599
|
-
source: cached.source,
|
|
1600
|
-
},
|
|
1601
|
-
});
|
|
1602
|
-
}
|
|
1603
|
-
catch {
|
|
1604
|
-
/* audit-log failures never block */
|
|
1605
|
-
}
|
|
1606
|
-
return json({ data: { dismissed: true } });
|
|
1607
|
-
}
|
|
1608
|
-
catch (err) {
|
|
1609
|
-
return internalError(err, 'seo/issues/:id/dismiss-fix');
|
|
1610
|
-
}
|
|
1611
|
-
});
|
|
1612
|
-
// Any authenticated user, like GET /seo/issues and GET /seo/overview: editors
|
|
1613
|
-
// read the inbox and act through the WRITE_ROLES mutation routes.
|
|
1614
|
-
router.get('/seo/proposals', async (request) => {
|
|
1615
|
-
try {
|
|
1616
|
-
const auth = await requireAuth(request);
|
|
1617
|
-
if (auth.error)
|
|
1618
|
-
return auth.error;
|
|
1619
|
-
const { listSeoProposals } = await import('../../seo/proposals.js');
|
|
1620
|
-
return json({ data: await listSeoProposals(db()) });
|
|
1621
|
-
}
|
|
1622
|
-
catch (err) {
|
|
1623
|
-
return internalError(err, 'seo/proposals');
|
|
1624
|
-
}
|
|
1625
|
-
});
|
|
1626
|
-
router.post('/seo/proposals/generate', async (request) => {
|
|
1627
|
-
try {
|
|
1628
|
-
const auth = await requireAuth(request);
|
|
1629
|
-
if (auth.error)
|
|
1630
|
-
return auth.error;
|
|
1631
|
-
const roleErr = requireRole(auth.session.role, ADMIN_ROLES);
|
|
1632
|
-
if (roleErr)
|
|
1633
|
-
return roleErr;
|
|
1634
|
-
const planErr = assertPlanFeature('seo.inlineApply');
|
|
1635
|
-
if (planErr)
|
|
1636
|
-
return planRequiredResponse(planErr);
|
|
1637
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1638
|
-
return modelNotAvailable('SeoIssue');
|
|
1639
|
-
const body = (await request.json().catch(() => ({})));
|
|
1640
|
-
if (body?.confirm !== true) {
|
|
1641
|
-
return errorResponse('Pass { "confirm": true } to generate proposals.', 400);
|
|
1642
|
-
}
|
|
1643
|
-
if (!(await checkRateLimitAsync(aiGenerateLimiter, `ai-seo-proposals-generate:${auth.session.userId}`))) {
|
|
1644
|
-
return errorResponse('AI rate limit reached. Try again in an hour.', 429);
|
|
1645
|
-
}
|
|
1646
|
-
const { generateSeoProposals } = await import('../../seo/proposals-generate.js');
|
|
1647
|
-
const result = await generateSeoProposals(db(), { userId: auth.session.userId });
|
|
1648
|
-
return json({ data: result });
|
|
1649
|
-
}
|
|
1650
|
-
catch (err) {
|
|
1651
|
-
return internalError(err, 'seo/proposals/generate');
|
|
1652
|
-
}
|
|
1653
|
-
});
|
|
1654
|
-
// WRITE_ROLES like the per-item apply-fix route; redirect items are
|
|
1655
|
-
// additionally admin-gated per item inside the service (spec §4).
|
|
1656
|
-
router.post('/seo/proposals/bulk-apply', async (request) => {
|
|
1657
|
-
try {
|
|
1658
|
-
const auth = await requireAuth(request);
|
|
1659
|
-
if (auth.error)
|
|
1660
|
-
return auth.error;
|
|
1661
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
1662
|
-
if (roleErr)
|
|
1663
|
-
return roleErr;
|
|
1664
|
-
if (!hasModel(db(), 'seoIssue'))
|
|
1665
|
-
return modelNotAvailable('SeoIssue');
|
|
1666
|
-
const parsed = await validateBody(request, seoBulkApplyBodySchema);
|
|
1667
|
-
if (!parsed.ok)
|
|
1668
|
-
return parsed.error;
|
|
1669
|
-
const rawItems = parsed.data.items;
|
|
1670
|
-
const { PROPOSAL_BULK_APPLY_MAX, bulkApplySeoProposals } = await import('../../seo/proposals-bulk-apply.js');
|
|
1671
|
-
if (rawItems.length > PROPOSAL_BULK_APPLY_MAX) {
|
|
1672
|
-
return errorResponse(`At most ${PROPOSAL_BULK_APPLY_MAX} items per bulk apply.`, 400);
|
|
1673
|
-
}
|
|
1674
|
-
const items = rawItems.map((item) => ({
|
|
1675
|
-
kind: item.kind,
|
|
1676
|
-
id: item.id,
|
|
1677
|
-
fingerprint: item.fingerprint,
|
|
1678
|
-
}));
|
|
1679
|
-
const result = await bulkApplySeoProposals(db(), {
|
|
1680
|
-
items,
|
|
1681
|
-
actor: { userId: auth.session.userId, role: auth.session.role },
|
|
1682
|
-
authorizeContentWrite: contentWriteGate(auth.session),
|
|
1683
|
-
});
|
|
1684
|
-
if (result.blocked) {
|
|
1685
|
-
return json({ error: result.reason, code: 'governance_blocked' }, 403);
|
|
1686
|
-
}
|
|
1687
|
-
return json({
|
|
1688
|
-
data: {
|
|
1689
|
-
results: result.results,
|
|
1690
|
-
applied: result.applied,
|
|
1691
|
-
failed: result.failed,
|
|
1692
|
-
pending: result.pending,
|
|
1693
|
-
},
|
|
1694
|
-
});
|
|
1695
|
-
}
|
|
1696
|
-
catch (err) {
|
|
1697
|
-
return internalError(err, 'seo/proposals/bulk-apply');
|
|
1698
|
-
}
|
|
1699
|
-
});
|
|
1700
|
-
router.get('/seo/overview', async (request) => {
|
|
1701
|
-
try {
|
|
1702
|
-
const auth = await requireAuth(request);
|
|
1703
|
-
if (auth.error)
|
|
1704
|
-
return auth.error;
|
|
1705
|
-
const { resolveSeoModuleSettings, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
1706
|
-
const { buildQuickWins } = await import('../../seo/audit-engine.js');
|
|
1707
|
-
const { gradeForSeoScore } = await import('../../seo/score.js');
|
|
1708
|
-
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
1709
|
-
const moduleSettings = resolveSeoModuleSettings(overrides);
|
|
1710
|
-
let lastRun = null;
|
|
1711
|
-
let openIssues = [];
|
|
1712
|
-
if (hasModel(db(), 'seoAuditRun')) {
|
|
1713
|
-
lastRun = await db().seoAuditRun.findFirst({
|
|
1714
|
-
where: { status: 'COMPLETED' },
|
|
1715
|
-
orderBy: { startedAt: 'desc' },
|
|
1716
|
-
});
|
|
1717
|
-
}
|
|
1718
|
-
if (hasModel(db(), 'seoIssue')) {
|
|
1719
|
-
const rawIssues = await db().seoIssue.findMany({
|
|
1720
|
-
where: { status: { in: ['open', 'recurring'] } },
|
|
1721
|
-
orderBy: { lastDetectedAt: 'desc' },
|
|
1722
|
-
take: 200,
|
|
1723
|
-
});
|
|
1724
|
-
openIssues = sortIssuesBySeverity(rawIssues);
|
|
1725
|
-
}
|
|
1726
|
-
const { frontEndContentCollectionTypes, countAuditEntities } = await import('../../seo/audit-runner.js');
|
|
1727
|
-
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
1728
|
-
const total = await countAuditEntities(db(), { collectionTypes }).catch(() => 0);
|
|
1729
|
-
const meta = (lastRun?.metadata ?? {});
|
|
1730
|
-
const indexable = typeof meta.indexableCount === 'number' && Number.isFinite(meta.indexableCount)
|
|
1731
|
-
? meta.indexableCount
|
|
1732
|
-
: total;
|
|
1733
|
-
const auditTruncated = meta.truncated === true;
|
|
1734
|
-
const perf = await loadSearchPerformance(10, moduleSettings.searchConsoleConnected);
|
|
1735
|
-
const searchStats = moduleSettings.searchConsoleConnected
|
|
1736
|
-
? await loadSearchInsightStats()
|
|
1737
|
-
: null;
|
|
1738
|
-
const summary = {
|
|
1739
|
-
total: openIssues.length,
|
|
1740
|
-
critical: openIssues.filter((i) => i.severity === 'critical').length,
|
|
1741
|
-
warning: openIssues.filter((i) => i.severity === 'warning').length,
|
|
1742
|
-
info: openIssues.filter((i) => i.severity === 'info').length,
|
|
1743
|
-
};
|
|
1744
|
-
const { countPendingProposals } = await import('../../seo/proposals.js');
|
|
1745
|
-
const pendingProposals = await countPendingProposals(db(), openIssues).catch(() => ({
|
|
1746
|
-
issueFixes: 0,
|
|
1747
|
-
redirects: 0,
|
|
1748
|
-
}));
|
|
1749
|
-
const scoreAfter = lastRun?.siteScoreAfter ?? null;
|
|
1750
|
-
const scoreBefore = lastRun?.siteScoreBefore ?? null;
|
|
1751
|
-
const breakdown = lastRun?.metadata?.breakdown ?? [];
|
|
1752
|
-
// Real site-level stats (weighted by impressions, with prior-window
|
|
1753
|
-
// trends) when synced data exists; the perf-row estimate otherwise.
|
|
1754
|
-
const avgPosition = searchStats?.avgPosition ??
|
|
1755
|
-
(perf.length > 0
|
|
1756
|
-
? perf.reduce((s, r) => s + r.averagePosition, 0) / perf.length
|
|
1757
|
-
: null);
|
|
1758
|
-
const impressions28d = searchStats?.impressions28d ?? perf.reduce((s, r) => s + r.impressions, 0);
|
|
1759
|
-
// Position: lower is better, so an "up" trend means the number dropped.
|
|
1760
|
-
let positionTrend = 'flat';
|
|
1761
|
-
if (searchStats?.avgPosition != null && searchStats.avgPositionPrev != null) {
|
|
1762
|
-
const delta = searchStats.avgPosition - searchStats.avgPositionPrev;
|
|
1763
|
-
if (delta <= -0.5)
|
|
1764
|
-
positionTrend = 'up';
|
|
1765
|
-
else if (delta >= 0.5)
|
|
1766
|
-
positionTrend = 'down';
|
|
1767
|
-
}
|
|
1768
|
-
let impressionsTrend = 'flat';
|
|
1769
|
-
if (searchStats && searchStats.impressionsPrev28d > 0) {
|
|
1770
|
-
const ratio = searchStats.impressions28d / searchStats.impressionsPrev28d;
|
|
1771
|
-
if (ratio >= 1.05)
|
|
1772
|
-
impressionsTrend = 'up';
|
|
1773
|
-
else if (ratio <= 0.95)
|
|
1774
|
-
impressionsTrend = 'down';
|
|
1775
|
-
}
|
|
1776
|
-
const metrics = [
|
|
1777
|
-
{
|
|
1778
|
-
label: 'Pages Indexed',
|
|
1779
|
-
value: String(indexable),
|
|
1780
|
-
helper: auditTruncated ? `${total} published (audit capped)` : `${total} published pages`,
|
|
1781
|
-
trend: 'flat',
|
|
1782
|
-
},
|
|
1783
|
-
{
|
|
1784
|
-
label: 'Open Issues',
|
|
1785
|
-
value: String(summary.total),
|
|
1786
|
-
helper: `${summary.critical} critical`,
|
|
1787
|
-
trend: summary.critical > 0 ? 'down' : 'flat',
|
|
1788
|
-
},
|
|
1789
|
-
{
|
|
1790
|
-
label: 'Avg. Position',
|
|
1791
|
-
value: avgPosition !== null ? avgPosition.toFixed(1) : '—',
|
|
1792
|
-
helper: moduleSettings.searchConsoleConnected
|
|
1793
|
-
? searchStats?.avgPositionPrev != null
|
|
1794
|
-
? `was ${searchStats.avgPositionPrev.toFixed(1)} the prior 28 days`
|
|
1795
|
-
: 'last 28 days'
|
|
1796
|
-
: 'Connect Search Console',
|
|
1797
|
-
trend: positionTrend,
|
|
1798
|
-
},
|
|
1799
|
-
{
|
|
1800
|
-
label: 'Impressions',
|
|
1801
|
-
value: impressions28d > 0 ? formatCompactNumber(impressions28d) : '—',
|
|
1802
|
-
helper: moduleSettings.searchConsoleConnected
|
|
1803
|
-
? searchStats && searchStats.impressionsPrev28d > 0
|
|
1804
|
-
? `was ${formatCompactNumber(searchStats.impressionsPrev28d)} the prior 28 days`
|
|
1805
|
-
: 'last 28 days'
|
|
1806
|
-
: 'Connect Search Console',
|
|
1807
|
-
trend: impressionsTrend,
|
|
1808
|
-
},
|
|
1809
|
-
];
|
|
1810
|
-
// Weight quick wins by real traffic when synced search data exists.
|
|
1811
|
-
const impressionsByEntityId = {};
|
|
1812
|
-
if (moduleSettings.searchConsoleConnected && hasModel(db(), 'searchPageInsight')) {
|
|
1813
|
-
try {
|
|
1814
|
-
const impressionRows = (await db().searchPageInsight.findMany({
|
|
1815
|
-
where: { entityId: { not: null }, impressions28d: { gt: 0 } },
|
|
1816
|
-
select: { entityId: true, impressions28d: true },
|
|
1817
|
-
take: 2000,
|
|
1818
|
-
}));
|
|
1819
|
-
for (const r of impressionRows) {
|
|
1820
|
-
if (r.entityId)
|
|
1821
|
-
impressionsByEntityId[r.entityId] = r.impressions28d;
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
catch {
|
|
1825
|
-
/* weighting is optional */
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
const quickWins = buildQuickWins(openIssues.map((i) => ({
|
|
1829
|
-
issueKey: i.issueKey,
|
|
1830
|
-
entityType: i.entityType,
|
|
1831
|
-
entityId: i.entityId,
|
|
1832
|
-
entityTitle: i.entityTitle,
|
|
1833
|
-
url: i.url,
|
|
1834
|
-
severity: i.severity,
|
|
1835
|
-
category: i.category,
|
|
1836
|
-
type: i.type,
|
|
1837
|
-
title: i.title,
|
|
1838
|
-
description: i.description ?? '',
|
|
1839
|
-
impact: i.impact ?? 'Low',
|
|
1840
|
-
recommendation: i.recommendation ?? '',
|
|
1841
|
-
fixable: i.fixable ?? false,
|
|
1842
|
-
fixActionType: i.fixActionType ?? null,
|
|
1843
|
-
relatedEntityIds: Array.isArray(i.relatedEntityIds) ? i.relatedEntityIds : [],
|
|
1844
|
-
})), { impressionsByEntityId }).map((w) => ({
|
|
1845
|
-
id: w.id,
|
|
1846
|
-
title: w.title,
|
|
1847
|
-
explanation: w.explanation,
|
|
1848
|
-
impact: w.impact,
|
|
1849
|
-
issueId: openIssues.find((i) => i.type === w.issueType)?.id ?? null,
|
|
1850
|
-
fixActionType: w.fixActionType,
|
|
1851
|
-
}));
|
|
1852
|
-
return json({
|
|
1853
|
-
data: {
|
|
1854
|
-
siteScore: {
|
|
1855
|
-
score: scoreAfter ?? 0,
|
|
1856
|
-
grade: gradeForSeoScore(scoreAfter ?? 0),
|
|
1857
|
-
delta: scoreAfter !== null && scoreBefore !== null ? scoreAfter - scoreBefore : null,
|
|
1858
|
-
breakdown,
|
|
1859
|
-
},
|
|
1860
|
-
metrics,
|
|
1861
|
-
issues: openIssues.slice(0, 7).map(seoIssueToApi),
|
|
1862
|
-
issuesSummary: summary,
|
|
1863
|
-
quickWins,
|
|
1864
|
-
searchPerformance: perf,
|
|
1865
|
-
searchConsoleConnected: moduleSettings.searchConsoleConnected,
|
|
1866
|
-
lastAuditRunId: lastRun?.id ?? null,
|
|
1867
|
-
auditTruncated,
|
|
1868
|
-
pendingProposals,
|
|
1869
|
-
},
|
|
1870
|
-
});
|
|
1871
|
-
}
|
|
1872
|
-
catch (err) {
|
|
1873
|
-
return internalError(err, 'seo/overview');
|
|
1874
|
-
}
|
|
1875
|
-
});
|
|
1876
|
-
router.get('/seo/search-performance', async (request) => {
|
|
1877
|
-
try {
|
|
1878
|
-
const auth = await requireAuth(request);
|
|
1879
|
-
if (auth.error)
|
|
1880
|
-
return auth.error;
|
|
1881
|
-
const url = new URL(request.url);
|
|
1882
|
-
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 10)));
|
|
1883
|
-
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
1884
|
-
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
1885
|
-
const rows = await loadSearchPerformance(limit, settings.searchConsoleConnected);
|
|
1886
|
-
return json({ data: { rows } });
|
|
1887
|
-
}
|
|
1888
|
-
catch (err) {
|
|
1889
|
-
return internalError(err, 'seo/search-performance');
|
|
1890
|
-
}
|
|
1891
|
-
});
|
|
1892
|
-
// Per-document search insight (synced GSC + CrUX) for the editor pane's
|
|
1893
|
-
// read-only stats block. `insight: null` when not synced or no data.
|
|
1894
|
-
router.get('/seo/search-insights/:entityId', async (request, params) => {
|
|
1895
|
-
try {
|
|
1896
|
-
const auth = await requireAuth(request);
|
|
1897
|
-
if (auth.error)
|
|
1898
|
-
return auth.error;
|
|
1899
|
-
const entityId = params.entityId;
|
|
1900
|
-
if (!entityId)
|
|
1901
|
-
return errorResponse('entityId is required', 400);
|
|
1902
|
-
const { getSeoOverrides, resolveSeoModuleSettings } = await import('../../seo/config-store.js');
|
|
1903
|
-
const settings = resolveSeoModuleSettings(await getSeoOverrides(db()));
|
|
1904
|
-
const connected = settings.searchConsoleConnected;
|
|
1905
|
-
if (!connected || !hasModel(db(), 'searchPageInsight')) {
|
|
1906
|
-
return json({ data: { connected, insight: null } });
|
|
1907
|
-
}
|
|
1908
|
-
const row = await db().searchPageInsight.findFirst({
|
|
1909
|
-
where: { entityId },
|
|
1910
|
-
select: {
|
|
1911
|
-
path: true,
|
|
1912
|
-
clicks28d: true,
|
|
1913
|
-
impressions28d: true,
|
|
1914
|
-
avgPosition28d: true,
|
|
1915
|
-
clicksPrev28d: true,
|
|
1916
|
-
impressionsPrev28d: true,
|
|
1917
|
-
avgPositionPrev28d: true,
|
|
1918
|
-
topQueries: true,
|
|
1919
|
-
lcpMs: true,
|
|
1920
|
-
inpMs: true,
|
|
1921
|
-
cls: true,
|
|
1922
|
-
cwvStatus: true,
|
|
1923
|
-
lastSyncedAt: true,
|
|
1924
|
-
},
|
|
1925
|
-
});
|
|
1926
|
-
return json({
|
|
1927
|
-
data: {
|
|
1928
|
-
connected,
|
|
1929
|
-
insight: row
|
|
1930
|
-
? {
|
|
1931
|
-
...row,
|
|
1932
|
-
topQueries: Array.isArray(row.topQueries) ? row.topQueries : [],
|
|
1933
|
-
lastSyncedAt: row.lastSyncedAt instanceof Date
|
|
1934
|
-
? row.lastSyncedAt.toISOString()
|
|
1935
|
-
: row.lastSyncedAt,
|
|
1936
|
-
}
|
|
1937
|
-
: null,
|
|
1938
|
-
},
|
|
1939
|
-
});
|
|
1940
|
-
}
|
|
1941
|
-
catch (err) {
|
|
1942
|
-
return internalError(err, 'seo/search-insights');
|
|
1943
|
-
}
|
|
1944
|
-
});
|
|
1945
|
-
router.get('/seo/content', async (request) => {
|
|
1946
|
-
try {
|
|
1947
|
-
const auth = await requireAuth(request);
|
|
1948
|
-
if (auth.error)
|
|
1949
|
-
return auth.error;
|
|
1950
|
-
// Scope the list to the collections this caller may read — otherwise a
|
|
1951
|
-
// collection-scoped API key (or a collection-restricted role) could pull
|
|
1952
|
-
// metaTitle/description/canonical/OG for every collection, the exact
|
|
1953
|
-
// bypass the sibling document-level SEO endpoints already prevent.
|
|
1954
|
-
const allowed = await readableSeoCollections(auth.session);
|
|
1955
|
-
const url = new URL(request.url);
|
|
1956
|
-
const page = clampPage(url.searchParams.get('page'));
|
|
1957
|
-
const pageSize = clampPageSize(url.searchParams.get('pageSize'), 100, 50);
|
|
1958
|
-
const result = await buildSeoContentRecords(allowed, undefined, {
|
|
1959
|
-
skip: (page - 1) * pageSize,
|
|
1960
|
-
take: pageSize,
|
|
1961
|
-
});
|
|
1962
|
-
return json({
|
|
1963
|
-
data: {
|
|
1964
|
-
records: result.records,
|
|
1965
|
-
total: result.total,
|
|
1966
|
-
page,
|
|
1967
|
-
pageSize,
|
|
1968
|
-
truncated: result.truncated,
|
|
1969
|
-
},
|
|
1970
|
-
});
|
|
1971
|
-
}
|
|
1972
|
-
catch (err) {
|
|
1973
|
-
return internalError(err, 'seo/content');
|
|
1974
|
-
}
|
|
1975
|
-
});
|
|
1976
|
-
router.get('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
1977
|
-
try {
|
|
1978
|
-
const auth = await requireAuth(request);
|
|
1979
|
-
if (auth.error)
|
|
1980
|
-
return auth.error;
|
|
1981
|
-
// Load the target first so we can enforce the collection's read-access
|
|
1982
|
-
// rule (requireCollectionScope + assertCollectionAccess) before returning
|
|
1983
|
-
// any SEO fields — parity with /seo/analysis and friends.
|
|
1984
|
-
const doc = await db().document.findFirst({
|
|
1985
|
-
where: { id: params.entityId, deletedAt: null },
|
|
1986
|
-
select: { data: true, collection: true },
|
|
1987
|
-
});
|
|
1988
|
-
if (!doc)
|
|
1989
|
-
return errorResponse('Content record not found', 404);
|
|
1990
|
-
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
1991
|
-
if (accessErr)
|
|
1992
|
-
return accessErr;
|
|
1993
|
-
// Scoped to THIS document and not filtered by publication status: a saved
|
|
1994
|
-
// draft has SEO worth editing, and the matching PUT accepts one. Resolving
|
|
1995
|
-
// through the published-only gather made drafts 404 here while remaining
|
|
1996
|
-
// writable — so an editor could save SEO and never read it back.
|
|
1997
|
-
const { records } = await buildSeoContentRecords(null, [params.entityId]);
|
|
1998
|
-
const record = records.find((r) => r.entityType === params.entityType && r.entityId === params.entityId);
|
|
1999
|
-
if (!record)
|
|
2000
|
-
return errorResponse('Content record not found', 404);
|
|
2001
|
-
// Overlay the real llms.txt curation values from the stored document
|
|
2002
|
-
// (the audit-entity pipeline that builds the base record doesn't carry
|
|
2003
|
-
// these convention keys).
|
|
2004
|
-
const data = asRecord(doc.data);
|
|
2005
|
-
const includeRaw = String(data.llmsInclude ?? '').toUpperCase();
|
|
2006
|
-
const merged = {
|
|
2007
|
-
...record,
|
|
2008
|
-
llmsInclude: includeRaw === 'ALWAYS' || includeRaw === 'NEVER'
|
|
2009
|
-
? includeRaw
|
|
2010
|
-
: 'AUTO',
|
|
2011
|
-
llmsDescription: typeof data.llmsDescription === 'string' ? data.llmsDescription : null,
|
|
2012
|
-
llmsSection: typeof data.llmsSection === 'string' ? data.llmsSection : null,
|
|
2013
|
-
llmsOptional: data.llmsOptional === true,
|
|
2014
|
-
// Robots directives live in the same content JSON. Overlaid here for
|
|
2015
|
-
// the same reason as the llms.txt keys: the audit-entity pipeline that
|
|
2016
|
-
// builds the base record does not carry them, so without this the
|
|
2017
|
-
// editor would read every page back as fully permissive and silently
|
|
2018
|
-
// overwrite a real restriction on the next save.
|
|
2019
|
-
nosnippet: data.nosnippet === true,
|
|
2020
|
-
noarchive: data.noarchive === true,
|
|
2021
|
-
maxImagePreview: coerceMaxImagePreview(data.maxImagePreview),
|
|
2022
|
-
};
|
|
2023
|
-
return json({ data: merged });
|
|
2024
|
-
}
|
|
2025
|
-
catch (err) {
|
|
2026
|
-
return internalError(err, 'seo/content/:id');
|
|
2027
|
-
}
|
|
2028
|
-
});
|
|
2029
|
-
router.put('/seo/content/:entityType/:entityId', async (request, params) => {
|
|
2030
|
-
try {
|
|
2031
|
-
const auth = await requireAuth(request);
|
|
2032
|
-
if (auth.error)
|
|
2033
|
-
return auth.error;
|
|
2034
|
-
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
2035
|
-
if (roleErr)
|
|
2036
|
-
return roleErr;
|
|
2037
|
-
const body = (await request.json().catch(() => null));
|
|
2038
|
-
if (!body || typeof body !== 'object') {
|
|
2039
|
-
return errorResponse('Request body must be an object', 400);
|
|
2040
|
-
}
|
|
2041
|
-
const doc = await db().document.findFirst({
|
|
2042
|
-
where: { id: params.entityId, deletedAt: null },
|
|
2043
|
-
select: { id: true, collection: true },
|
|
2044
|
-
});
|
|
2045
|
-
if (!doc)
|
|
2046
|
-
return errorResponse('Document not found', 404);
|
|
2047
|
-
// String SEO fields: payload key -> stored data key, each with a max
|
|
2048
|
-
// length so we never persist unbounded/megabyte values into the content
|
|
2049
|
-
// JSON. Canonical is stored as `canonical` to match normalizeSeoPage.
|
|
2050
|
-
const TEXT_FIELDS = {
|
|
2051
|
-
metaTitle: { target: 'metaTitle', max: 300 },
|
|
2052
|
-
metaDescription: { target: 'metaDescription', max: 1000 },
|
|
2053
|
-
canonicalUrl: { target: 'canonical', max: 2000 },
|
|
2054
|
-
focusKeyword: { target: 'focusKeyword', max: 200 },
|
|
2055
|
-
keyTakeaway: { target: 'keyTakeaway', max: 500 },
|
|
2056
|
-
ogTitle: { target: 'ogTitle', max: 300 },
|
|
2057
|
-
ogDescription: { target: 'ogDescription', max: 1000 },
|
|
2058
|
-
ogImage: { target: 'ogImage', max: 2000 },
|
|
2059
|
-
twitterTitle: { target: 'twitterTitle', max: 300 },
|
|
2060
|
-
twitterDescription: { target: 'twitterDescription', max: 1000 },
|
|
2061
|
-
twitterImage: { target: 'twitterImage', max: 2000 },
|
|
2062
|
-
structuredDataType: { target: 'structuredDataType', max: 100 },
|
|
2063
|
-
schemaType: { target: 'schemaType', max: 100 },
|
|
2064
|
-
llmsDescription: { target: 'llmsDescription', max: 500 },
|
|
2065
|
-
llmsSection: { target: 'llmsSection', max: 100 },
|
|
2066
|
-
};
|
|
2067
|
-
const patch = {};
|
|
2068
|
-
for (const [key, { target, max }] of Object.entries(TEXT_FIELDS)) {
|
|
2069
|
-
if (!(key in body))
|
|
2070
|
-
continue;
|
|
2071
|
-
const value = body[key];
|
|
2072
|
-
if (value === null || value === '') {
|
|
2073
|
-
patch[target] = '';
|
|
2074
|
-
continue;
|
|
2075
|
-
}
|
|
2076
|
-
if (typeof value !== 'string') {
|
|
2077
|
-
return errorResponse(`Field "${key}" must be a string`, 400);
|
|
2078
|
-
}
|
|
2079
|
-
if (value.length > max) {
|
|
2080
|
-
return errorResponse(`Field "${key}" exceeds the ${max}-character limit`, 400);
|
|
2081
|
-
}
|
|
2082
|
-
patch[target] = value;
|
|
2083
|
-
}
|
|
2084
|
-
if ('noindex' in body)
|
|
2085
|
-
patch.noindex = body.noindex === true;
|
|
2086
|
-
if ('nofollow' in body)
|
|
2087
|
-
patch.nofollow = body.nofollow === true;
|
|
2088
|
-
if ('nosnippet' in body)
|
|
2089
|
-
patch.nosnippet = body.nosnippet === true;
|
|
2090
|
-
if ('noarchive' in body)
|
|
2091
|
-
patch.noarchive = body.noarchive === true;
|
|
2092
|
-
if ('maxImagePreview' in body) {
|
|
2093
|
-
// Coerced rather than rejected: the field is an enum with a permissive
|
|
2094
|
-
// default, and refusing the write would block an otherwise valid patch
|
|
2095
|
-
// over a value the composer already knows how to fall back from.
|
|
2096
|
-
patch.maxImagePreview = coerceMaxImagePreview(body.maxImagePreview);
|
|
2097
|
-
}
|
|
2098
|
-
if ('llmsOptional' in body)
|
|
2099
|
-
patch.llmsOptional = body.llmsOptional === true;
|
|
2100
|
-
if ('llmsInclude' in body) {
|
|
2101
|
-
const v = String(body.llmsInclude ?? '').toUpperCase();
|
|
2102
|
-
if (v !== 'AUTO' && v !== 'ALWAYS' && v !== 'NEVER') {
|
|
2103
|
-
return errorResponse('llmsInclude must be one of AUTO, ALWAYS, NEVER', 400);
|
|
2104
|
-
}
|
|
2105
|
-
// AUTO is the default — store empty so the field falls back cleanly.
|
|
2106
|
-
patch.llmsInclude = v === 'AUTO' ? '' : v;
|
|
2107
|
-
}
|
|
2108
|
-
if ('llmsPriority' in body) {
|
|
2109
|
-
const n = body.llmsPriority;
|
|
2110
|
-
if (n !== null && typeof n !== 'number') {
|
|
2111
|
-
return errorResponse('llmsPriority must be a number', 400);
|
|
2112
|
-
}
|
|
2113
|
-
patch.llmsPriority = n ?? 0;
|
|
2114
|
-
}
|
|
2115
|
-
if ('structuredData' in body) {
|
|
2116
|
-
const sd = body.structuredData;
|
|
2117
|
-
if (sd !== null && (typeof sd !== 'object' || Array.isArray(sd))) {
|
|
2118
|
-
return errorResponse('structuredData must be a JSON object', 400);
|
|
2119
|
-
}
|
|
2120
|
-
if (sd && JSON.stringify(sd).length > 50_000) {
|
|
2121
|
-
return errorResponse('structuredData payload is too large', 400);
|
|
2122
|
-
}
|
|
2123
|
-
patch.structuredData = sd;
|
|
2124
|
-
}
|
|
2125
|
-
if (Object.keys(patch).length === 0) {
|
|
2126
|
-
return errorResponse('No recognized SEO fields in request body', 400);
|
|
2127
|
-
}
|
|
2128
|
-
if ('structuredDataType' in patch || 'schemaType' in patch) {
|
|
2129
|
-
Object.assign(patch, syncSchemaTypeFields(patch));
|
|
2130
|
-
}
|
|
2131
|
-
// Route through the data layer so the edit gets per-collection access
|
|
2132
|
-
// control, a Version snapshot, search re-indexing and the afterUpdate
|
|
2133
|
-
// hook — never write document.data directly. (updateDocumentSeoFields
|
|
2134
|
-
// preserves the undeclared SEO convention keys that the generic
|
|
2135
|
-
// updateDocument field-access layer would strip.)
|
|
2136
|
-
const ctx = buildActionContext(auth.session, db());
|
|
2137
|
-
try {
|
|
2138
|
-
await updateDocumentSeoFields(doc.collection, doc.id, patch, ctx);
|
|
2139
|
-
}
|
|
2140
|
-
catch (err) {
|
|
2141
|
-
const msg = err instanceof Error ? err.message : '';
|
|
2142
|
-
if (msg.includes('Access denied'))
|
|
2143
|
-
return errorResponse('Insufficient permissions', 403);
|
|
2144
|
-
if (msg.includes('not found'))
|
|
2145
|
-
return errorResponse('Document not found', 404);
|
|
2146
|
-
throw err;
|
|
2147
|
-
}
|
|
2148
|
-
try {
|
|
2149
|
-
await logEvent({
|
|
2150
|
-
event: 'document_updated',
|
|
2151
|
-
userId: auth.session.userId,
|
|
2152
|
-
details: { action: 'seo_fields_update', documentId: doc.id },
|
|
2153
|
-
});
|
|
2154
|
-
}
|
|
2155
|
-
catch {
|
|
2156
|
-
/* noop */
|
|
2157
|
-
}
|
|
2158
|
-
const { records } = await buildSeoContentRecords(null, [doc.id]);
|
|
2159
|
-
const record = records.find((r) => r.entityId === doc.id);
|
|
2160
|
-
return json({ data: record ?? { ok: true } });
|
|
2161
|
-
}
|
|
2162
|
-
catch (err) {
|
|
2163
|
-
return internalError(err, 'seo/content PUT');
|
|
2164
|
-
}
|
|
2165
|
-
});
|
|
2166
|
-
// ---------------------------------------------------------------------------
|
|
2167
|
-
// SEO analysis, readability, internal links, schema, meta
|
|
2168
|
-
// ---------------------------------------------------------------------------
|
|
2169
|
-
router.get('/seo/analysis/:documentId', async (request, params) => {
|
|
2170
|
-
try {
|
|
2171
|
-
const auth = await requireAuth(request);
|
|
2172
|
-
if (auth.error)
|
|
2173
|
-
return auth.error;
|
|
2174
|
-
const doc = await db().document.findFirst({
|
|
2175
|
-
where: { id: params.documentId, deletedAt: null },
|
|
2176
|
-
});
|
|
2177
|
-
if (!doc)
|
|
2178
|
-
return errorResponse('Not found', 404);
|
|
2179
|
-
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
2180
|
-
if (accessErr)
|
|
2181
|
-
return accessErr;
|
|
2182
|
-
const { analyzeContent } = await import('../../seo/analysis.js');
|
|
2183
|
-
const { documentToAuditEntity, frontEndContentCollectionTypes } = await import('../../seo/audit-runner.js');
|
|
2184
|
-
const { applySeoOverrides, getSeoOverrides } = await import('../../seo/config-store.js');
|
|
2185
|
-
const data = doc.data || {};
|
|
2186
|
-
// Same content assembly as readability + audit entity (`documentContentForSeoAnalysis`)
|
|
2187
|
-
// so MCP/admin scores agree and section-built pages still get plainText/sections.
|
|
2188
|
-
const collectionTypes = frontEndContentCollectionTypes(getPrentaConfig());
|
|
2189
|
-
const overrides = await getSeoOverrides(db()).catch(() => null);
|
|
2190
|
-
const siteSeo = applySeoOverrides(getPrentaConfig(), overrides)?.seo;
|
|
2191
|
-
const entity = documentToAuditEntity(doc, collectionTypes, siteSeo);
|
|
2192
|
-
const result = analyzeContent({
|
|
2193
|
-
title: entity.title,
|
|
2194
|
-
slug: doc.slug || data.slug || '',
|
|
2195
|
-
content: entity.content ?? '',
|
|
2196
|
-
// Rendered title (site template applied) — the same value the page
|
|
2197
|
-
// score measures, so both agree instead of one reading the page portion.
|
|
2198
|
-
metaTitle: entity.metaTitle ?? undefined,
|
|
2199
|
-
metaDescription: entity.metaDescription ?? undefined,
|
|
2200
|
-
focusKeyphrase: entity.focusKeyword ?? undefined,
|
|
2201
|
-
canonical: entity.canonicalUrl ?? undefined,
|
|
2202
|
-
ogTitle: entity.ogTitle ?? undefined,
|
|
2203
|
-
ogDescription: entity.ogDescription ?? undefined,
|
|
2204
|
-
ogImage: entity.ogImage ?? undefined,
|
|
2205
|
-
extraImages: extraImagesForSeoAnalysis(data),
|
|
2206
|
-
entityType: entity.entityType,
|
|
2207
|
-
sectionH1: firstSectionHeading(data) ?? undefined,
|
|
2208
|
-
isCornerstone: data.isCornerstone,
|
|
2209
|
-
});
|
|
2210
|
-
return new Response(JSON.stringify(result), {
|
|
2211
|
-
status: 200,
|
|
2212
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2213
|
-
});
|
|
2214
|
-
}
|
|
2215
|
-
catch (err) {
|
|
2216
|
-
return internalError(err, 'seo/analysis');
|
|
2217
|
-
}
|
|
2218
|
-
});
|
|
2219
|
-
router.get('/seo/readability/:documentId', async (request, params) => {
|
|
2220
|
-
try {
|
|
2221
|
-
const auth = await requireAuth(request);
|
|
2222
|
-
if (auth.error)
|
|
2223
|
-
return auth.error;
|
|
2224
|
-
const doc = await db().document.findFirst({
|
|
2225
|
-
where: { id: params.documentId, deletedAt: null },
|
|
2226
|
-
});
|
|
2227
|
-
if (!doc)
|
|
2228
|
-
return errorResponse('Not found', 404);
|
|
2229
|
-
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
2230
|
-
if (accessErr)
|
|
2231
|
-
return accessErr;
|
|
2232
|
-
const { calculateReadability, stripHtmlTags } = await import('../../seo/analysis.js');
|
|
2233
|
-
const { documentContentForSeoAnalysis } = await import('../../seo/document-content.js');
|
|
2234
|
-
const text = stripHtmlTags(documentContentForSeoAnalysis(doc));
|
|
2235
|
-
const result = calculateReadability(text);
|
|
2236
|
-
return new Response(JSON.stringify(result), {
|
|
2237
|
-
status: 200,
|
|
2238
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2239
|
-
});
|
|
2240
|
-
}
|
|
2241
|
-
catch (err) {
|
|
2242
|
-
return internalError(err, 'seo/readability');
|
|
2243
|
-
}
|
|
2244
|
-
});
|
|
2245
|
-
router.get('/seo/internal-links/:documentId', async (request, params) => {
|
|
2246
|
-
try {
|
|
2247
|
-
const auth = await requireAuth(request);
|
|
2248
|
-
if (auth.error)
|
|
2249
|
-
return auth.error;
|
|
2250
|
-
const doc = await db().document.findFirst({
|
|
2251
|
-
where: { id: params.documentId, deletedAt: null },
|
|
2252
|
-
});
|
|
2253
|
-
if (!doc)
|
|
2254
|
-
return errorResponse('Not found', 404);
|
|
2255
|
-
const accessErr = await enforceDocumentReadAccess(auth.session, doc.collection);
|
|
2256
|
-
if (accessErr)
|
|
2257
|
-
return accessErr;
|
|
2258
|
-
const data = doc.data || {};
|
|
2259
|
-
const title = doc.title || data.title || '';
|
|
2260
|
-
const keywords = title
|
|
2261
|
-
.split(/\s+/)
|
|
2262
|
-
.filter((w) => w.length > 3)
|
|
2263
|
-
.slice(0, 5);
|
|
2264
|
-
if (keywords.length === 0) {
|
|
2265
|
-
return new Response(JSON.stringify({ suggestions: [] }), {
|
|
2266
|
-
status: 200,
|
|
2267
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2268
|
-
});
|
|
2269
|
-
}
|
|
2270
|
-
const suggestions = await db().document.findMany({
|
|
2271
|
-
where: {
|
|
2272
|
-
id: { not: params.documentId },
|
|
2273
|
-
status: 'PUBLISHED',
|
|
2274
|
-
deletedAt: null,
|
|
2275
|
-
OR: keywords.map((kw) => ({
|
|
2276
|
-
title: { contains: kw, mode: 'insensitive' },
|
|
2277
|
-
})),
|
|
2278
|
-
},
|
|
2279
|
-
select: { id: true, title: true, slug: true, collection: true },
|
|
2280
|
-
take: 10,
|
|
2281
|
-
orderBy: { updatedAt: 'desc' },
|
|
2282
|
-
});
|
|
2283
|
-
return new Response(JSON.stringify({ suggestions }), {
|
|
2284
|
-
status: 200,
|
|
2285
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2286
|
-
});
|
|
2287
|
-
}
|
|
2288
|
-
catch (err) {
|
|
2289
|
-
return internalError(err, 'seo/internal-links');
|
|
2290
|
-
}
|
|
2291
|
-
});
|
|
2292
|
-
router.get('/llms.txt', async (request) => {
|
|
2293
|
-
try {
|
|
2294
|
-
// Public surface: must exclude soft-deleted docs and internal/system
|
|
2295
|
-
// collections (e.g. `__seo_config__`). Over-fetch then filter so the
|
|
2296
|
-
// 50-page cap still lands on real content after exclusion.
|
|
2297
|
-
const { isInternalCollection } = await import('../../seo/audit-runner.js');
|
|
2298
|
-
const { siteUrlFromRequest, loadEffectiveConfig } = await import('./seo-public.js');
|
|
2299
|
-
// Layer DB overrides (Settings → SEO) so an admin-edited siteName /
|
|
2300
|
-
// defaultMetaDescription flows into the llms heading + summary too, exactly
|
|
2301
|
-
// as sitemap.xml, robots.txt, /og.png and the feeds already do.
|
|
2302
|
-
const cfg = (await loadEffectiveConfig()) ?? getPrentaConfig();
|
|
2303
|
-
const llmsCfg = cfg?.seo?.llms;
|
|
2304
|
-
const base = siteUrlFromRequest(request, cfg);
|
|
2305
|
-
// Operators can disable the file entirely (it's optionality, not a
|
|
2306
|
-
// guaranteed traffic channel).
|
|
2307
|
-
if (llmsCfg?.disabled) {
|
|
2308
|
-
return new Response('Not found', { status: 404 });
|
|
2309
|
-
}
|
|
2310
|
-
const maxEntries = llmsCfg?.maxEntries ?? 50;
|
|
2311
|
-
const maxPerSection = llmsCfg?.maxPerSection ?? 25;
|
|
2312
|
-
// Restrict AUTO selection to the configured high-value collections when
|
|
2313
|
-
// set, so blog archives don't flood the curated guide.
|
|
2314
|
-
const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
|
|
2315
|
-
? new Set(llmsCfg.eligibleCollections)
|
|
2316
|
-
: null;
|
|
2317
|
-
const rawDocs = await db().document.findMany({
|
|
2318
|
-
where: { status: 'PUBLISHED', deletedAt: null },
|
|
2319
|
-
select: { title: true, slug: true, collection: true, updatedAt: true, data: true },
|
|
2320
|
-
orderBy: { updatedAt: 'desc' },
|
|
2321
|
-
take: 500,
|
|
2322
|
-
});
|
|
2323
|
-
// Per-doc curation + privacy boundary (noindex exclusion) live in the
|
|
2324
|
-
// shared selector so `/llms.txt` and `/llms-full.txt` can never diverge.
|
|
2325
|
-
const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
|
|
2326
|
-
const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible });
|
|
2327
|
-
const { generateLlmsTxt } = await import('../../seo/llms-txt.js');
|
|
2328
|
-
const pages = selected.map((d) => toLlmsPage(d, {
|
|
2329
|
-
base,
|
|
2330
|
-
urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix,
|
|
2331
|
-
trailingSlash: cfg?.seo?.trailingSlash,
|
|
2332
|
-
}));
|
|
2333
|
-
const txt = generateLlmsTxt({
|
|
2334
|
-
siteName: cfg?.seo?.siteName || 'Prenta CMS',
|
|
2335
|
-
siteUrl: base,
|
|
2336
|
-
siteDescription: llmsCfg?.summary || cfg?.seo?.defaultMetaDescription || 'Content managed by Prenta CMS',
|
|
2337
|
-
intro: llmsCfg?.intro,
|
|
2338
|
-
maxPages: maxEntries,
|
|
2339
|
-
maxPerSection,
|
|
2340
|
-
}, pages);
|
|
2341
|
-
// Opt-in honeypot: append a unique trap link. No human reads llms.txt, so
|
|
2342
|
-
// a fetch of this URL is an automated reader — recorded under the
|
|
2343
|
-
// "honeypot" surface in the AI-crawler panel.
|
|
2344
|
-
let body = txt;
|
|
2345
|
-
if (llmsCfg?.honeypot) {
|
|
2346
|
-
const trapUrl = `${base.replace(/\/$/, '')}/api/cms/ai-crawlers/trap`;
|
|
2347
|
-
body += `\n## Reference\n\n- [Complete content index](${trapUrl}): Full machine-readable content map.\n`;
|
|
2348
|
-
}
|
|
2349
|
-
// Measurement: if a known AI crawler fetched the index, count it.
|
|
2350
|
-
await recordAiCrawlerFromRequest(db(), request, 'llms');
|
|
2351
|
-
return new Response(body, {
|
|
2352
|
-
status: 200,
|
|
2353
|
-
headers: {
|
|
2354
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
2355
|
-
// The file itself is a machine-facing index, not a page to rank.
|
|
2356
|
-
'X-Robots-Tag': 'noindex',
|
|
2357
|
-
// Same CDN TTL as robots.txt — the index is cheap to regenerate
|
|
2358
|
-
// after publish but should not be fetched on every agent request.
|
|
2359
|
-
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
2360
|
-
},
|
|
2361
|
-
});
|
|
2362
|
-
}
|
|
2363
|
-
catch (err) {
|
|
2364
|
-
return internalError(err, 'llms.txt');
|
|
2365
|
-
}
|
|
2366
|
-
});
|
|
2367
|
-
// `/llms-full.txt` — same curated selection as `/llms.txt`, but inlines each
|
|
2368
|
-
// page's content rendered to clean Markdown so an agent can read the actual
|
|
2369
|
-
// body without fetching every URL. Opt-in (these files get large) and bounded
|
|
2370
|
-
// by a byte budget. noindex + never in the sitemap; HTML stays canonical.
|
|
2371
|
-
router.get('/llms-full.txt', async (request) => {
|
|
2372
|
-
try {
|
|
2373
|
-
const { isInternalCollection } = await import('../../seo/audit-runner.js');
|
|
2374
|
-
const { siteUrlFromRequest, loadEffectiveConfig } = await import('./seo-public.js');
|
|
2375
|
-
// Honor admin overrides (siteName / summary / defaultMetaDescription) the
|
|
2376
|
-
// same way `/llms.txt` and the other public SEO surfaces do.
|
|
2377
|
-
const cfg = (await loadEffectiveConfig()) ?? getPrentaConfig();
|
|
2378
|
-
const llmsCfg = cfg?.seo?.llms;
|
|
2379
|
-
const base = siteUrlFromRequest(request, cfg);
|
|
2380
|
-
// Gated independently from `/llms.txt`: must be both enabled overall and
|
|
2381
|
-
// have the full variant turned on.
|
|
2382
|
-
if (llmsCfg?.disabled || !llmsCfg?.fullEnabled) {
|
|
2383
|
-
return new Response('Not found', { status: 404 });
|
|
2384
|
-
}
|
|
2385
|
-
const maxEntries = llmsCfg?.maxEntries ?? 50;
|
|
2386
|
-
const maxBytes = llmsCfg?.maxFullBytes ?? 2_000_000;
|
|
2387
|
-
const eligible = Array.isArray(llmsCfg?.eligibleCollections) && llmsCfg.eligibleCollections.length > 0
|
|
2388
|
-
? new Set(llmsCfg.eligibleCollections)
|
|
2389
|
-
: null;
|
|
2390
|
-
const rawDocs = await db().document.findMany({
|
|
2391
|
-
where: { status: 'PUBLISHED', deletedAt: null },
|
|
2392
|
-
select: {
|
|
2393
|
-
title: true,
|
|
2394
|
-
slug: true,
|
|
2395
|
-
collection: true,
|
|
2396
|
-
updatedAt: true,
|
|
2397
|
-
data: true,
|
|
2398
|
-
plainText: true,
|
|
2399
|
-
},
|
|
2400
|
-
orderBy: { updatedAt: 'desc' },
|
|
2401
|
-
take: 500,
|
|
2402
|
-
});
|
|
2403
|
-
const { selectLlmsDocs, toLlmsPage } = await import('../../seo/llms-select.js');
|
|
2404
|
-
const { documentToMarkdown } = await import('../../seo/content-markdown.js');
|
|
2405
|
-
const selected = selectLlmsDocs(rawDocs, { isInternalCollection, eligible }).slice(0, maxEntries);
|
|
2406
|
-
const header = [
|
|
2407
|
-
`# ${cfg?.seo?.siteName || 'Prenta CMS'}`,
|
|
2408
|
-
'',
|
|
2409
|
-
`> ${llmsCfg?.summary || cfg?.seo?.defaultMetaDescription || 'Full content export for AI agents.'}`,
|
|
2410
|
-
'',
|
|
2411
|
-
].join('\n');
|
|
2412
|
-
const chunks = [header];
|
|
2413
|
-
let bytes = Buffer.byteLength(header, 'utf8');
|
|
2414
|
-
let truncated = false;
|
|
2415
|
-
for (const d of selected) {
|
|
2416
|
-
const page = toLlmsPage(d, {
|
|
2417
|
-
base,
|
|
2418
|
-
urlPrefix: cfg?.collections?.[d.collection]?.urlPrefix,
|
|
2419
|
-
trailingSlash: cfg?.seo?.trailingSlash,
|
|
2420
|
-
});
|
|
2421
|
-
const md = documentToMarkdown({
|
|
2422
|
-
title: d.title,
|
|
2423
|
-
data: d.data,
|
|
2424
|
-
plainText: d.plainText,
|
|
2425
|
-
}, { baseUrl: base });
|
|
2426
|
-
// Each entry is delimited by a horizontal rule and carries its canonical
|
|
2427
|
-
// URL so the inlined content stays traceable back to the live page.
|
|
2428
|
-
const block = `\n---\n\nURL: ${page.url}\n\n${md}\n`;
|
|
2429
|
-
const blockBytes = Buffer.byteLength(block, 'utf8');
|
|
2430
|
-
if (bytes + blockBytes > maxBytes) {
|
|
2431
|
-
truncated = true;
|
|
2432
|
-
break;
|
|
2433
|
-
}
|
|
2434
|
-
chunks.push(block);
|
|
2435
|
-
bytes += blockBytes;
|
|
2436
|
-
}
|
|
2437
|
-
if (truncated) {
|
|
2438
|
-
chunks.push('\n---\n\n> Output truncated at the configured size limit.\n');
|
|
2439
|
-
}
|
|
2440
|
-
await recordAiCrawlerFromRequest(db(), request, 'llms-full');
|
|
2441
|
-
return new Response(chunks.join(''), {
|
|
2442
|
-
status: 200,
|
|
2443
|
-
headers: {
|
|
2444
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
2445
|
-
'X-Robots-Tag': 'noindex',
|
|
2446
|
-
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
2447
|
-
},
|
|
2448
|
-
});
|
|
2449
|
-
}
|
|
2450
|
-
catch (err) {
|
|
2451
|
-
return internalError(err, 'llms-full.txt');
|
|
2452
|
-
}
|
|
2453
|
-
});
|
|
2454
|
-
// Per-page Markdown: `GET /md?path=/about` returns a clean Markdown rendering
|
|
2455
|
-
// of a single published page so an agent can fetch the content, not just the
|
|
2456
|
-
// link. Fresh installs expose this at clean `/{path}.md` URLs via a scaffolded
|
|
2457
|
-
// rewrite. CRITICAL: always noindex, never in the sitemap — the HTML page
|
|
2458
|
-
// stays canonical (per-page `.md` is the most common duplicate-content trap).
|
|
2459
|
-
router.get('/md', async (request) => {
|
|
2460
|
-
try {
|
|
2461
|
-
const cfg = getPrentaConfig();
|
|
2462
|
-
const url = new URL(request.url);
|
|
2463
|
-
const pathParam = url.searchParams.get('path');
|
|
2464
|
-
if (!pathParam) {
|
|
2465
|
-
return errorResponse('Missing required "path" query parameter', 400);
|
|
2466
|
-
}
|
|
2467
|
-
// Part of the AI/agent layer — honor the same master switch as llms.txt.
|
|
2468
|
-
if (cfg?.seo?.llms?.disabled) {
|
|
2469
|
-
return new Response('Not found', { status: 404 });
|
|
2470
|
-
}
|
|
2471
|
-
const { matchPathToCollection } = await import('../../seo/resolve-path.js');
|
|
2472
|
-
const match = matchPathToCollection(cfg, pathParam);
|
|
2473
|
-
if (!match) {
|
|
2474
|
-
return new Response('Not found', { status: 404 });
|
|
2475
|
-
}
|
|
2476
|
-
const slugFilter = match.isRoot
|
|
2477
|
-
? {
|
|
2478
|
-
OR: [
|
|
2479
|
-
{ data: { path: ['slug'], equals: 'home' } },
|
|
2480
|
-
{ data: { path: ['slug'], equals: 'index' } },
|
|
2481
|
-
{ slug: 'home' },
|
|
2482
|
-
{ slug: 'index' },
|
|
2483
|
-
],
|
|
2484
|
-
}
|
|
2485
|
-
: {
|
|
2486
|
-
OR: [{ data: { path: ['slug'], equals: match.slug } }, { slug: match.slug }],
|
|
2487
|
-
};
|
|
2488
|
-
const doc = await db().document.findFirst({
|
|
2489
|
-
where: {
|
|
2490
|
-
collection: match.collection,
|
|
2491
|
-
deletedAt: null,
|
|
2492
|
-
status: 'PUBLISHED',
|
|
2493
|
-
...slugFilter,
|
|
2494
|
-
},
|
|
2495
|
-
select: { title: true, data: true, plainText: true },
|
|
2496
|
-
});
|
|
2497
|
-
if (!doc) {
|
|
2498
|
-
return new Response('Not found', { status: 404 });
|
|
2499
|
-
}
|
|
2500
|
-
const data = doc.data || {};
|
|
2501
|
-
// The selection filter is the privacy boundary: noindexed or explicitly
|
|
2502
|
-
// excluded pages must not be fetchable as Markdown either.
|
|
2503
|
-
const { isLlmsNoindex, readLlmsInclude } = await import('../../seo/llms-select.js');
|
|
2504
|
-
if (isLlmsNoindex(data) || readLlmsInclude(data) === 'NEVER') {
|
|
2505
|
-
return new Response('Not found', { status: 404 });
|
|
2506
|
-
}
|
|
2507
|
-
const { documentToMarkdown } = await import('../../seo/content-markdown.js');
|
|
2508
|
-
const { siteUrlFromRequest } = await import('./seo-public.js');
|
|
2509
|
-
const base = siteUrlFromRequest(request, cfg);
|
|
2510
|
-
const md = documentToMarkdown({ title: doc.title, data, plainText: doc.plainText }, { baseUrl: base });
|
|
2511
|
-
await recordAiCrawlerFromRequest(db(), request, 'md');
|
|
2512
|
-
return new Response(md, {
|
|
2513
|
-
status: 200,
|
|
2514
|
-
headers: {
|
|
2515
|
-
'Content-Type': 'text/markdown; charset=utf-8',
|
|
2516
|
-
'X-Robots-Tag': 'noindex',
|
|
2517
|
-
},
|
|
2518
|
-
});
|
|
2519
|
-
}
|
|
2520
|
-
catch (err) {
|
|
2521
|
-
return internalError(err, 'md');
|
|
2522
|
-
}
|
|
2523
|
-
});
|
|
2524
|
-
// Honeypot: a unique trap URL appended to llms.txt only when
|
|
2525
|
-
// `seo.llms.honeypot` is enabled. No human visits it, so any fetch is an
|
|
2526
|
-
// automated reader that followed a link from the file. Always records (even
|
|
2527
|
-
// if the flag was later turned off, a cached llms.txt may still point here)
|
|
2528
|
-
// and returns a tiny noindex body.
|
|
2529
|
-
router.get('/ai-crawlers/trap', async (request) => {
|
|
2530
|
-
try {
|
|
2531
|
-
const detected = detectAiBot(request.headers.get('user-agent'));
|
|
2532
|
-
await recordAiCrawlerHit(db(), {
|
|
2533
|
-
bot: detected?.token ?? 'unknown',
|
|
2534
|
-
surface: 'honeypot',
|
|
2535
|
-
category: detected?.category ?? 'other',
|
|
2536
|
-
});
|
|
2537
|
-
return new Response('This endpoint exists to detect automated indexers.\n', {
|
|
2538
|
-
status: 200,
|
|
2539
|
-
headers: {
|
|
2540
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
2541
|
-
'X-Robots-Tag': 'noindex',
|
|
2542
|
-
},
|
|
2543
|
-
});
|
|
2544
|
-
}
|
|
2545
|
-
catch (err) {
|
|
2546
|
-
return internalError(err, 'ai-crawlers/trap');
|
|
2547
|
-
}
|
|
2548
|
-
});
|
|
2549
|
-
// Admin: aggregated AI-crawler activity for the SEO → Technical panel.
|
|
2550
|
-
// Any authenticated admin may read it (analytics, not a mutation).
|
|
2551
|
-
router.get('/ai-crawlers', async (request) => {
|
|
2552
|
-
try {
|
|
2553
|
-
const auth = await requireAuth(request);
|
|
2554
|
-
if (auth.error)
|
|
2555
|
-
return auth.error;
|
|
2556
|
-
if (!hasModel(db(), 'aiCrawlerHit')) {
|
|
2557
|
-
return json({
|
|
2558
|
-
data: { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } },
|
|
2559
|
-
});
|
|
2560
|
-
}
|
|
2561
|
-
const rows = await db().aiCrawlerHit.findMany({
|
|
2562
|
-
orderBy: { lastSeenAt: 'desc' },
|
|
2563
|
-
take: 500,
|
|
2564
|
-
});
|
|
2565
|
-
const totalHits = rows.reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
|
|
2566
|
-
const honeypotHits = rows
|
|
2567
|
-
.filter((r) => r.surface === 'honeypot')
|
|
2568
|
-
.reduce((sum, r) => sum + Number(r.hits ?? 0), 0);
|
|
2569
|
-
const uniqueBots = new Set(rows.map((r) => r.bot)).size;
|
|
2570
|
-
// Recency windowing. `hits` is a lifetime cumulative counter (one row per
|
|
2571
|
-
// bot×surface), so we can't derive per-window hit *counts* from it — but
|
|
2572
|
-
// `lastSeenAt` lets us answer the actionable question "which bots/surfaces
|
|
2573
|
-
// were active in the last 24h / 7d". Per-window hit-count trends would
|
|
2574
|
-
// need a (bot, surface, day) bucket table (a future additive migration);
|
|
2575
|
-
// this keeps storage bounded while making recency visible today.
|
|
2576
|
-
const nowMs = Date.now();
|
|
2577
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
2578
|
-
const within = (v, ms) => {
|
|
2579
|
-
if (!v)
|
|
2580
|
-
return false;
|
|
2581
|
-
const t = v instanceof Date ? v.getTime() : new Date(v).getTime();
|
|
2582
|
-
return Number.isFinite(t) && nowMs - t <= ms;
|
|
2583
|
-
};
|
|
2584
|
-
const windowSummary = (ms) => {
|
|
2585
|
-
const active = rows.filter((r) => within(r.lastSeenAt, ms));
|
|
2586
|
-
return {
|
|
2587
|
-
bots: new Set(active.map((r) => r.bot)).size,
|
|
2588
|
-
surfaces: new Set(active.map((r) => r.surface)).size,
|
|
2589
|
-
rows: active.length,
|
|
2590
|
-
};
|
|
2591
|
-
};
|
|
2592
|
-
return json({
|
|
2593
|
-
data: {
|
|
2594
|
-
enabled: true,
|
|
2595
|
-
rows: rows.map((r) => ({
|
|
2596
|
-
bot: r.bot,
|
|
2597
|
-
surface: r.surface,
|
|
2598
|
-
category: r.category,
|
|
2599
|
-
hits: Number(r.hits ?? 0),
|
|
2600
|
-
firstSeenAt: r.firstSeenAt,
|
|
2601
|
-
lastSeenAt: r.lastSeenAt,
|
|
2602
|
-
activeLast24h: within(r.lastSeenAt, DAY_MS),
|
|
2603
|
-
activeLast7d: within(r.lastSeenAt, 7 * DAY_MS),
|
|
2604
|
-
})),
|
|
2605
|
-
totals: { hits: totalHits, bots: uniqueBots, honeypot: honeypotHits },
|
|
2606
|
-
windows: { last24h: windowSummary(DAY_MS), last7d: windowSummary(7 * DAY_MS) },
|
|
2607
|
-
},
|
|
2608
|
-
});
|
|
2609
|
-
}
|
|
2610
|
-
catch (err) {
|
|
2611
|
-
return internalError(err, 'ai-crawlers');
|
|
2612
|
-
}
|
|
2613
|
-
});
|
|
18
|
+
router.get('/seo/pages', getSeoPages);
|
|
19
|
+
router.get('/seo/link-health', getSeoLinkHealth);
|
|
20
|
+
router.get('/seo/links/snapshot', getSeoLinkSnapshot);
|
|
21
|
+
router.post('/seo/links/ai-index', postSeoLinkAiIndex);
|
|
22
|
+
router.post('/seo/links/suggest', postSeoLinkSuggest);
|
|
23
|
+
router.post('/seo/links/apply', postSeoLinkApply);
|
|
24
|
+
router.post('/seo/scan', postSeoScan);
|
|
25
|
+
router.post('/seo/audit', postSeoAudit);
|
|
26
|
+
router.get('/seo/audits', getSeoAudits);
|
|
27
|
+
router.get('/seo/audits/:id', getSeoAudit);
|
|
28
|
+
router.get('/seo/issues', getSeoIssues);
|
|
29
|
+
router.get('/seo/issues/export-brief', getSeoIssuesExportBrief);
|
|
30
|
+
router.post('/seo/issues/batch-content-briefs', postSeoIssuesBatchContentBriefs);
|
|
31
|
+
router.get('/seo/issues/:id/content-brief', getSeoIssueContentBrief);
|
|
32
|
+
router.post('/seo/issues/:id/content-brief', postSeoIssueContentBrief);
|
|
33
|
+
router.get('/seo/issues/:id/content-research', getSeoIssueContentResearch);
|
|
34
|
+
router.post('/seo/issues/:id/content-research', postSeoIssueContentResearch);
|
|
35
|
+
router.post('/seo/issues/:id/research-and-brief', postSeoIssueResearchAndBrief);
|
|
36
|
+
router.get('/seo/issues/:id', getSeoIssue);
|
|
37
|
+
router.post('/seo/issues/:id/resolve', postSeoIssueResolve);
|
|
38
|
+
router.post('/seo/issues/:id/ignore', postSeoIssueIgnore);
|
|
39
|
+
router.post('/seo/issues/:id/reopen', postSeoIssueReopen);
|
|
40
|
+
router.post('/seo/issues/:id/suggest-fix', postSeoIssueSuggestFix);
|
|
41
|
+
router.post('/seo/issues/:id/apply-fix', postSeoIssueApplyFix);
|
|
42
|
+
router.post('/seo/issues/:id/dismiss-fix', postSeoIssueDismissFix);
|
|
43
|
+
router.get('/seo/proposals', getSeoProposals);
|
|
44
|
+
router.post('/seo/proposals/generate', postSeoProposalsGenerate);
|
|
45
|
+
router.post('/seo/proposals/bulk-apply', postSeoProposalsBulkApply);
|
|
46
|
+
router.get('/seo/overview', getSeoOverview);
|
|
47
|
+
router.get('/seo/search-performance', getSeoSearchPerformance);
|
|
48
|
+
router.get('/seo/search-insights/:entityId', getSeoSearchInsights);
|
|
49
|
+
router.get('/seo/content', getSeoContent);
|
|
50
|
+
router.get('/seo/content/:entityType/:entityId', getSeoContentRecord);
|
|
51
|
+
router.put('/seo/content/:entityType/:entityId', putSeoContentRecord);
|
|
52
|
+
router.get('/seo/analysis/:documentId', getSeoAnalysis);
|
|
53
|
+
router.get('/seo/readability/:documentId', getSeoReadability);
|
|
54
|
+
router.get('/seo/internal-links/:documentId', getSeoInternalLinks);
|
|
55
|
+
router.get('/llms.txt', getLlmsTxt);
|
|
56
|
+
router.get('/llms-full.txt', getLlmsFullTxt);
|
|
57
|
+
router.get('/md', getMarkdownForPath);
|
|
58
|
+
router.get('/ai-crawlers/trap', getAiCrawlerTrap);
|
|
59
|
+
router.get('/ai-crawlers', getAiCrawlers);
|
|
2614
60
|
}
|
|
2615
61
|
//# sourceMappingURL=seo.js.map
|