@consilioweb/payload-seo-analyzer 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/client.cjs +902 -85
- package/dist/client.js +902 -85
- package/dist/index.cjs +1431 -225
- package/dist/index.d.cts +114 -3
- package/dist/index.d.ts +114 -3
- package/dist/index.js +1435 -235
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual, createCipheriv, scryptSync, createDecipheriv } from 'crypto';
|
|
1
2
|
import { promises } from 'dns';
|
|
2
|
-
import { randomBytes, timingSafeEqual, createCipheriv, createDecipheriv, scryptSync } from 'crypto';
|
|
3
3
|
|
|
4
4
|
// src/constants.ts
|
|
5
5
|
var TITLE_LENGTH_MIN = 30;
|
|
@@ -3119,6 +3119,451 @@ function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
|
|
|
3119
3119
|
}
|
|
3120
3120
|
};
|
|
3121
3121
|
}
|
|
3122
|
+
var ALGO = "aes-256-gcm";
|
|
3123
|
+
var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
|
|
3124
|
+
var FORMAT_VERSION = "v1";
|
|
3125
|
+
function deriveKey(secret) {
|
|
3126
|
+
const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
|
|
3127
|
+
if (explicit) {
|
|
3128
|
+
const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
|
|
3129
|
+
if (buf.length === 32) return buf;
|
|
3130
|
+
throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
|
|
3131
|
+
}
|
|
3132
|
+
if (!secret) {
|
|
3133
|
+
throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
|
|
3134
|
+
}
|
|
3135
|
+
return scryptSync(secret, KEY_NAMESPACE, 32);
|
|
3136
|
+
}
|
|
3137
|
+
function encryptToken(plaintext, secret) {
|
|
3138
|
+
const key = deriveKey(secret);
|
|
3139
|
+
const iv = randomBytes(12);
|
|
3140
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
3141
|
+
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
3142
|
+
const tag = cipher.getAuthTag();
|
|
3143
|
+
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
|
|
3144
|
+
}
|
|
3145
|
+
function decryptToken(payload, secret) {
|
|
3146
|
+
const parts = payload.split(":");
|
|
3147
|
+
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
3148
|
+
throw new Error("Invalid encrypted token format.");
|
|
3149
|
+
}
|
|
3150
|
+
const key = deriveKey(secret);
|
|
3151
|
+
const iv = Buffer.from(parts[1], "base64");
|
|
3152
|
+
const tag = Buffer.from(parts[2], "base64");
|
|
3153
|
+
const enc = Buffer.from(parts[3], "base64");
|
|
3154
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
3155
|
+
decipher.setAuthTag(tag);
|
|
3156
|
+
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
3157
|
+
return dec.toString("utf8");
|
|
3158
|
+
}
|
|
3159
|
+
function safeEqual(a, b) {
|
|
3160
|
+
const ba = Buffer.from(a);
|
|
3161
|
+
const bb = Buffer.from(b);
|
|
3162
|
+
if (ba.length !== bb.length) return false;
|
|
3163
|
+
return timingSafeEqual(ba, bb);
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
// src/helpers/gscClient.ts
|
|
3167
|
+
var GSC_AUTH_COLLECTION = "seo-gsc-auth";
|
|
3168
|
+
var GSC_SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
|
|
3169
|
+
function isGscAdmin(user) {
|
|
3170
|
+
if (!user) return false;
|
|
3171
|
+
if (user.role === "admin") return true;
|
|
3172
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3173
|
+
return false;
|
|
3174
|
+
}
|
|
3175
|
+
function resolveGscSiteUrl(seoConfig) {
|
|
3176
|
+
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
3177
|
+
}
|
|
3178
|
+
function getGscOAuthConfig(basePath, seoConfig) {
|
|
3179
|
+
const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
|
|
3180
|
+
const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
|
|
3181
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
3182
|
+
if (!clientId || !clientSecret || !siteUrl) return null;
|
|
3183
|
+
return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
|
|
3184
|
+
}
|
|
3185
|
+
async function getOrCreateGscAuthDoc(payload) {
|
|
3186
|
+
const found = await payload.find({ collection: GSC_AUTH_COLLECTION, limit: 1, overrideAccess: true });
|
|
3187
|
+
if (found.docs.length > 0) return found.docs[0];
|
|
3188
|
+
return payload.create({ collection: GSC_AUTH_COLLECTION, data: {}, overrideAccess: true });
|
|
3189
|
+
}
|
|
3190
|
+
async function gscTokenRequest(cfg, body) {
|
|
3191
|
+
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
3192
|
+
method: "POST",
|
|
3193
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3194
|
+
body: new URLSearchParams({
|
|
3195
|
+
client_id: cfg.clientId,
|
|
3196
|
+
client_secret: cfg.clientSecret,
|
|
3197
|
+
...body
|
|
3198
|
+
}).toString()
|
|
3199
|
+
});
|
|
3200
|
+
const json = await resp.json();
|
|
3201
|
+
if (!resp.ok) {
|
|
3202
|
+
throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
|
|
3203
|
+
}
|
|
3204
|
+
return json;
|
|
3205
|
+
}
|
|
3206
|
+
async function getGscAccessToken(payload, cfg, authDoc) {
|
|
3207
|
+
if (!authDoc?.refreshTokenEnc) throw new Error("not_connected");
|
|
3208
|
+
const secret = payload.secret || "";
|
|
3209
|
+
let refreshToken;
|
|
3210
|
+
try {
|
|
3211
|
+
refreshToken = decryptToken(authDoc.refreshTokenEnc, secret);
|
|
3212
|
+
} catch {
|
|
3213
|
+
throw new Error("decrypt_failed");
|
|
3214
|
+
}
|
|
3215
|
+
const tokens = await gscTokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
|
|
3216
|
+
const accessToken = tokens.access_token;
|
|
3217
|
+
if (!accessToken) throw new Error("refresh_failed");
|
|
3218
|
+
return accessToken;
|
|
3219
|
+
}
|
|
3220
|
+
async function queryGscSearchAnalytics(accessToken, property, body) {
|
|
3221
|
+
const resp = await fetch(
|
|
3222
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
|
|
3223
|
+
{
|
|
3224
|
+
method: "POST",
|
|
3225
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
3226
|
+
body: JSON.stringify(body)
|
|
3227
|
+
}
|
|
3228
|
+
);
|
|
3229
|
+
const json = await resp.json();
|
|
3230
|
+
if (!resp.ok) {
|
|
3231
|
+
const err = json.error?.message || resp.status;
|
|
3232
|
+
throw new Error(`GSC query failed: ${err}`);
|
|
3233
|
+
}
|
|
3234
|
+
return json.rows || [];
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// src/endpoints/aiAltText.ts
|
|
3238
|
+
var DEFAULT_MODEL2 = "claude-opus-4-8";
|
|
3239
|
+
var ALT_MAX = 125;
|
|
3240
|
+
var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
3241
|
+
var SUPPORTED_MIME = {
|
|
3242
|
+
"image/jpeg": "image/jpeg",
|
|
3243
|
+
"image/jpg": "image/jpeg",
|
|
3244
|
+
"image/png": "image/png",
|
|
3245
|
+
"image/gif": "image/gif",
|
|
3246
|
+
"image/webp": "image/webp"
|
|
3247
|
+
};
|
|
3248
|
+
function isAdmin4(user) {
|
|
3249
|
+
if (!user) return false;
|
|
3250
|
+
if (user.role === "admin") return true;
|
|
3251
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3252
|
+
return false;
|
|
3253
|
+
}
|
|
3254
|
+
function resolveImageUrl(media, siteUrl) {
|
|
3255
|
+
const raw = typeof media.url === "string" && media.url || (typeof media.filename === "string" ? `/media/${media.filename}` : "");
|
|
3256
|
+
if (!raw) return null;
|
|
3257
|
+
let absolute;
|
|
3258
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
3259
|
+
absolute = raw;
|
|
3260
|
+
} else if (siteUrl) {
|
|
3261
|
+
absolute = `${siteUrl.replace(/\/$/, "")}${raw.startsWith("/") ? "" : "/"}${raw}`;
|
|
3262
|
+
} else {
|
|
3263
|
+
return null;
|
|
3264
|
+
}
|
|
3265
|
+
try {
|
|
3266
|
+
const target = new URL(absolute);
|
|
3267
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return null;
|
|
3268
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
3269
|
+
if (siteUrl) allowed.add(new URL(siteUrl).origin);
|
|
3270
|
+
if (process.env.SEO_MEDIA_ORIGIN) allowed.add(new URL(process.env.SEO_MEDIA_ORIGIN).origin);
|
|
3271
|
+
if (allowed.size > 0 && !allowed.has(target.origin)) return null;
|
|
3272
|
+
if (allowed.size === 0) return null;
|
|
3273
|
+
return target.toString();
|
|
3274
|
+
} catch {
|
|
3275
|
+
return null;
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
async function generateAltText(apiKey, model, base64, mediaType, language, context) {
|
|
3279
|
+
const systemPrompt = `You write concise, descriptive image ALT text for accessibility and SEO.
|
|
3280
|
+
Rules:
|
|
3281
|
+
- Describe what is actually visible in the image.
|
|
3282
|
+
- Maximum ${ALT_MAX} characters.
|
|
3283
|
+
- Write in ${language === "en" ? "English" : "French"}.
|
|
3284
|
+
- Do NOT start with "image of", "photo of", "picture of" or similar.
|
|
3285
|
+
- No quotes around the result. Return ONLY the alt text, nothing else.`;
|
|
3286
|
+
const userText = `Filename: ${context.filename}${context.title ? `
|
|
3287
|
+
Page/context: ${context.title}` : ""}
|
|
3288
|
+
Write the alt text for this image:`;
|
|
3289
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
3290
|
+
method: "POST",
|
|
3291
|
+
headers: {
|
|
3292
|
+
"Content-Type": "application/json",
|
|
3293
|
+
"x-api-key": apiKey,
|
|
3294
|
+
"anthropic-version": "2023-06-01"
|
|
3295
|
+
},
|
|
3296
|
+
body: JSON.stringify({
|
|
3297
|
+
model,
|
|
3298
|
+
max_tokens: 150,
|
|
3299
|
+
system: systemPrompt,
|
|
3300
|
+
messages: [
|
|
3301
|
+
{
|
|
3302
|
+
role: "user",
|
|
3303
|
+
content: [
|
|
3304
|
+
{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } },
|
|
3305
|
+
{ type: "text", text: userText }
|
|
3306
|
+
]
|
|
3307
|
+
}
|
|
3308
|
+
]
|
|
3309
|
+
})
|
|
3310
|
+
});
|
|
3311
|
+
if (!response.ok) {
|
|
3312
|
+
const body = await response.text();
|
|
3313
|
+
throw new Error(`Claude API error ${response.status}: ${body}`);
|
|
3314
|
+
}
|
|
3315
|
+
const data = await response.json();
|
|
3316
|
+
if (data.stop_reason === "refusal") return null;
|
|
3317
|
+
const text = (data.content?.find((b) => b.type === "text")?.text || "").trim().replace(/^["']|["']$/g, "");
|
|
3318
|
+
if (!text) return null;
|
|
3319
|
+
return text.length > ALT_MAX ? text.slice(0, ALT_MAX).trim() : text;
|
|
3320
|
+
}
|
|
3321
|
+
function createAltTextAuditHandler(uploadsCollection) {
|
|
3322
|
+
return async (req) => {
|
|
3323
|
+
try {
|
|
3324
|
+
if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
3325
|
+
const url = new URL(req.url);
|
|
3326
|
+
const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get("limit") || "50", 10)));
|
|
3327
|
+
try {
|
|
3328
|
+
const missing = await req.payload.find({
|
|
3329
|
+
collection: uploadsCollection,
|
|
3330
|
+
where: { or: [{ alt: { exists: false } }, { alt: { equals: "" } }] },
|
|
3331
|
+
limit,
|
|
3332
|
+
depth: 0,
|
|
3333
|
+
overrideAccess: true
|
|
3334
|
+
});
|
|
3335
|
+
const items = missing.docs.map((d) => ({
|
|
3336
|
+
id: d.id,
|
|
3337
|
+
filename: d.filename || "",
|
|
3338
|
+
url: d.url || "",
|
|
3339
|
+
mimeType: d.mimeType || "",
|
|
3340
|
+
alt: d.alt || ""
|
|
3341
|
+
}));
|
|
3342
|
+
return Response.json(
|
|
3343
|
+
{ collection: uploadsCollection, missingCount: missing.totalDocs, items },
|
|
3344
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3345
|
+
);
|
|
3346
|
+
} catch {
|
|
3347
|
+
return Response.json(
|
|
3348
|
+
{ collection: uploadsCollection, missingCount: 0, items: [], note: "no_alt_field" },
|
|
3349
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3350
|
+
);
|
|
3351
|
+
}
|
|
3352
|
+
} catch (error) {
|
|
3353
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3354
|
+
req.payload.logger.error(`[seo] alt-text-audit error: ${message}`);
|
|
3355
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3356
|
+
}
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
function createAiAltTextHandler(uploadsCollection, seoConfig) {
|
|
3360
|
+
return async (req) => {
|
|
3361
|
+
try {
|
|
3362
|
+
if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
3363
|
+
const body = await parseJsonBody(req);
|
|
3364
|
+
const collection = typeof body.collection === "string" ? body.collection : uploadsCollection;
|
|
3365
|
+
const id = body.id != null ? String(body.id) : void 0;
|
|
3366
|
+
const apply = body.apply === true;
|
|
3367
|
+
const providedAlt = typeof body.altText === "string" ? body.altText.trim() : void 0;
|
|
3368
|
+
if (!id) return Response.json({ error: "Missing required field: id" }, { status: 400 });
|
|
3369
|
+
if (apply && providedAlt) {
|
|
3370
|
+
const alt2 = providedAlt.slice(0, ALT_MAX);
|
|
3371
|
+
await req.payload.update({ collection, id, data: { alt: alt2 }, overrideAccess: true });
|
|
3372
|
+
return Response.json({ alt: alt2, applied: true, method: "manual" });
|
|
3373
|
+
}
|
|
3374
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3375
|
+
if (!apiKey) {
|
|
3376
|
+
return Response.json(
|
|
3377
|
+
{ error: "AI not configured. Set ANTHROPIC_API_KEY to generate alt text.", code: "no_api_key" },
|
|
3378
|
+
{ status: 400 }
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
let media;
|
|
3382
|
+
try {
|
|
3383
|
+
media = await req.payload.findByID({ collection, id, depth: 0, overrideAccess: true });
|
|
3384
|
+
} catch {
|
|
3385
|
+
return Response.json({ error: `Media not found: ${collection}/${id}` }, { status: 404 });
|
|
3386
|
+
}
|
|
3387
|
+
const mime = media.mimeType || "";
|
|
3388
|
+
const mediaType = SUPPORTED_MIME[mime.toLowerCase()];
|
|
3389
|
+
if (!mediaType) {
|
|
3390
|
+
return Response.json(
|
|
3391
|
+
{ error: `Unsupported image type for vision: ${mime || "unknown"} (use JPEG, PNG, GIF or WebP).` },
|
|
3392
|
+
{ status: 422 }
|
|
3393
|
+
);
|
|
3394
|
+
}
|
|
3395
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
3396
|
+
const imageUrl = resolveImageUrl(media, siteUrl);
|
|
3397
|
+
if (!imageUrl) {
|
|
3398
|
+
return Response.json(
|
|
3399
|
+
{ error: "Could not resolve a safe image URL (must be on the site origin or SEO_MEDIA_ORIGIN)." },
|
|
3400
|
+
{ status: 422 }
|
|
3401
|
+
);
|
|
3402
|
+
}
|
|
3403
|
+
let base64;
|
|
3404
|
+
try {
|
|
3405
|
+
const imgResp = await fetch(imageUrl);
|
|
3406
|
+
if (!imgResp.ok) throw new Error(`fetch ${imgResp.status}`);
|
|
3407
|
+
const buf = Buffer.from(await imgResp.arrayBuffer());
|
|
3408
|
+
if (buf.byteLength > MAX_IMAGE_BYTES) {
|
|
3409
|
+
return Response.json({ error: "Image too large for vision (max 5 MB)." }, { status: 413 });
|
|
3410
|
+
}
|
|
3411
|
+
base64 = buf.toString("base64");
|
|
3412
|
+
} catch (e) {
|
|
3413
|
+
return Response.json({ error: `Could not fetch image: ${e instanceof Error ? e.message : "error"}` }, { status: 502 });
|
|
3414
|
+
}
|
|
3415
|
+
const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL2;
|
|
3416
|
+
const language = seoConfig?.locale === "en" ? "en" : "fr";
|
|
3417
|
+
let alt;
|
|
3418
|
+
try {
|
|
3419
|
+
alt = await generateAltText(apiKey, model, base64, mediaType, language, {
|
|
3420
|
+
filename: media.filename || "",
|
|
3421
|
+
title: typeof body.context === "string" ? body.context : void 0
|
|
3422
|
+
});
|
|
3423
|
+
} catch (e) {
|
|
3424
|
+
req.payload.logger.error(`[seo] ai-alt-text Claude error: ${e instanceof Error ? e.message : "unknown"}`);
|
|
3425
|
+
return Response.json({ error: "Alt-text generation failed." }, { status: 502 });
|
|
3426
|
+
}
|
|
3427
|
+
if (!alt) {
|
|
3428
|
+
return Response.json({ error: "The model did not return alt text (possibly declined)." }, { status: 502 });
|
|
3429
|
+
}
|
|
3430
|
+
let applied = false;
|
|
3431
|
+
if (apply) {
|
|
3432
|
+
await req.payload.update({ collection, id, data: { alt }, overrideAccess: true });
|
|
3433
|
+
applied = true;
|
|
3434
|
+
}
|
|
3435
|
+
return Response.json({ alt, applied, method: "ai", model });
|
|
3436
|
+
} catch (error) {
|
|
3437
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3438
|
+
req.payload.logger.error(`[seo] ai-alt-text error: ${message}`);
|
|
3439
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3440
|
+
}
|
|
3441
|
+
};
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
// src/endpoints/aiContentBrief.ts
|
|
3445
|
+
var DEFAULT_MODEL3 = "claude-opus-4-8";
|
|
3446
|
+
var trimList = (arr, max, itemMax = 160) => Array.isArray(arr) ? arr.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean).slice(0, max).map((x) => x.length > itemMax ? `${x.slice(0, itemMax - 1)}\u2026` : x) : [];
|
|
3447
|
+
function parseBrief(raw) {
|
|
3448
|
+
let s = raw.trim();
|
|
3449
|
+
if (s.startsWith("```")) s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
3450
|
+
if (!s.startsWith("{")) {
|
|
3451
|
+
const start = s.indexOf("{");
|
|
3452
|
+
const end = s.lastIndexOf("}");
|
|
3453
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
3454
|
+
s = s.slice(start, end + 1);
|
|
3455
|
+
}
|
|
3456
|
+
try {
|
|
3457
|
+
const p = JSON.parse(s);
|
|
3458
|
+
return sanitizeBrief({
|
|
3459
|
+
outline: Array.isArray(p.outline) ? p.outline.map((o) => {
|
|
3460
|
+
const r = o || {};
|
|
3461
|
+
return { level: r.level === "h3" ? "h3" : "h2", text: typeof r.text === "string" ? r.text : "" };
|
|
3462
|
+
}) : [],
|
|
3463
|
+
entities: trimList(p.entities, 30),
|
|
3464
|
+
questions: trimList(p.questions, 15),
|
|
3465
|
+
internalLinkIdeas: trimList(p.internalLinkIdeas, 10),
|
|
3466
|
+
recommendedWordCount: typeof p.recommendedWordCount === "number" ? p.recommendedWordCount : 0,
|
|
3467
|
+
notes: trimList(p.notes, 6)
|
|
3468
|
+
});
|
|
3469
|
+
} catch {
|
|
3470
|
+
return null;
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
function sanitizeBrief(b) {
|
|
3474
|
+
return {
|
|
3475
|
+
outline: b.outline.filter((o) => o.text && o.text.trim()).slice(0, 25).map((o) => ({ level: o.level === "h3" ? "h3" : "h2", text: o.text.trim().slice(0, 160) })),
|
|
3476
|
+
entities: trimList(b.entities, 30),
|
|
3477
|
+
questions: trimList(b.questions, 15),
|
|
3478
|
+
internalLinkIdeas: trimList(b.internalLinkIdeas, 10),
|
|
3479
|
+
recommendedWordCount: Math.min(1e4, Math.max(0, Math.round(b.recommendedWordCount || 0))),
|
|
3480
|
+
notes: trimList(b.notes, 6)
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
async function callClaudeBrief(apiKey, model, language, params) {
|
|
3484
|
+
const systemPrompt = `You are an SEO content strategist applying June 2026 best practices.
|
|
3485
|
+
Produce a concise WRITING BRIEF for the target keyword so a writer can create a page that ranks AND is citable by AI engines.
|
|
3486
|
+
Rules:
|
|
3487
|
+
- Base the brief on genuine search intent for the keyword; cover entities and questions a complete page must address.
|
|
3488
|
+
- Be specific and non-generic; no filler. Write in ${language === "en" ? "English" : "French"}.
|
|
3489
|
+
- Do not invent facts, brands, prices or statistics.
|
|
3490
|
+
Return ONLY a JSON object (no markdown, no prose) with EXACTLY this shape:
|
|
3491
|
+
{"outline":[{"level":"h2"|"h3","text":string}],"entities":[string],"questions":[string],"internalLinkIdeas":[string],"recommendedWordCount":number,"notes":[string]}
|
|
3492
|
+
- outline: 5-12 headings (logical H2/H3 structure).
|
|
3493
|
+
- entities: 8-20 key terms/concepts to mention.
|
|
3494
|
+
- questions: 4-10 questions the page should answer (People-Also-Ask style).
|
|
3495
|
+
- internalLinkIdeas: 3-8 topics worth linking to internally.
|
|
3496
|
+
- notes: up to 4 short strategic tips.`;
|
|
3497
|
+
const userPrompt = `Target keyword: ${params.keyword}
|
|
3498
|
+
${params.pageTitle ? `Existing page title: ${params.pageTitle}` : ""}
|
|
3499
|
+
${params.existingContent ? `Existing content (first 2000 chars, complement it \u2014 don't repeat):
|
|
3500
|
+
${params.existingContent.substring(0, 2e3)}` : ""}
|
|
3501
|
+
|
|
3502
|
+
Return the JSON brief now:`;
|
|
3503
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
3504
|
+
method: "POST",
|
|
3505
|
+
headers: { "Content-Type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
|
|
3506
|
+
body: JSON.stringify({
|
|
3507
|
+
model,
|
|
3508
|
+
max_tokens: 1500,
|
|
3509
|
+
system: systemPrompt,
|
|
3510
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
3511
|
+
})
|
|
3512
|
+
});
|
|
3513
|
+
if (!response.ok) {
|
|
3514
|
+
const body = await response.text();
|
|
3515
|
+
throw new Error(`Claude API error ${response.status}: ${body}`);
|
|
3516
|
+
}
|
|
3517
|
+
const data = await response.json();
|
|
3518
|
+
if (data.stop_reason === "refusal") return null;
|
|
3519
|
+
const text = (data.content?.find((b) => b.type === "text")?.text || "").trim();
|
|
3520
|
+
if (!text) return null;
|
|
3521
|
+
return parseBrief(text);
|
|
3522
|
+
}
|
|
3523
|
+
function createAiContentBriefHandler(targetCollections, seoConfig) {
|
|
3524
|
+
return async (req) => {
|
|
3525
|
+
try {
|
|
3526
|
+
if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
3527
|
+
const body = await parseJsonBody(req);
|
|
3528
|
+
const keyword = typeof body.keyword === "string" ? body.keyword.trim() : "";
|
|
3529
|
+
if (!keyword) return Response.json({ error: "Missing required field: keyword" }, { status: 400 });
|
|
3530
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3531
|
+
if (!apiKey) {
|
|
3532
|
+
return Response.json(
|
|
3533
|
+
{ error: "AI not configured. Set ANTHROPIC_API_KEY to generate a content brief.", code: "no_api_key" },
|
|
3534
|
+
{ status: 400 }
|
|
3535
|
+
);
|
|
3536
|
+
}
|
|
3537
|
+
let pageTitle;
|
|
3538
|
+
let existingContent;
|
|
3539
|
+
const collection = typeof body.collection === "string" ? body.collection : void 0;
|
|
3540
|
+
const id = body.id != null ? String(body.id) : void 0;
|
|
3541
|
+
if (collection && id && (!targetCollections || targetCollections.includes(collection))) {
|
|
3542
|
+
try {
|
|
3543
|
+
const doc = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
|
|
3544
|
+
pageTitle = doc.title || void 0;
|
|
3545
|
+
existingContent = extractDocContent(doc).text || void 0;
|
|
3546
|
+
} catch {
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL3;
|
|
3550
|
+
const language = seoConfig?.locale === "en" ? "en" : "fr";
|
|
3551
|
+
let brief;
|
|
3552
|
+
try {
|
|
3553
|
+
brief = await callClaudeBrief(apiKey, model, language, { keyword, pageTitle, existingContent });
|
|
3554
|
+
} catch (e) {
|
|
3555
|
+
req.payload.logger.error(`[seo] ai-content-brief Claude error: ${e instanceof Error ? e.message : "unknown"}`);
|
|
3556
|
+
return Response.json({ error: "Content brief generation failed." }, { status: 502 });
|
|
3557
|
+
}
|
|
3558
|
+
if (!brief) return Response.json({ error: "The model did not return a brief (possibly declined)." }, { status: 502 });
|
|
3559
|
+
return Response.json({ keyword, brief, model });
|
|
3560
|
+
} catch (error) {
|
|
3561
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3562
|
+
req.payload.logger.error(`[seo] ai-content-brief error: ${message}`);
|
|
3563
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
}
|
|
3122
3567
|
|
|
3123
3568
|
// src/endpoints/cannibalization.ts
|
|
3124
3569
|
function canonicalIntent(keyword) {
|
|
@@ -3704,7 +4149,7 @@ function getDateThreshold(period) {
|
|
|
3704
4149
|
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
3705
4150
|
}
|
|
3706
4151
|
}
|
|
3707
|
-
function
|
|
4152
|
+
function isAdmin5(user) {
|
|
3708
4153
|
if (!user) return false;
|
|
3709
4154
|
if (user.role === "admin") return true;
|
|
3710
4155
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -3809,7 +4254,7 @@ function createPerformanceHandler() {
|
|
|
3809
4254
|
});
|
|
3810
4255
|
}
|
|
3811
4256
|
if (method === "POST") {
|
|
3812
|
-
if (!
|
|
4257
|
+
if (!isAdmin5(req.user)) {
|
|
3813
4258
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
3814
4259
|
}
|
|
3815
4260
|
const body = await parseJsonBody(req);
|
|
@@ -4107,96 +4552,12 @@ function createCoreWebVitalsHandler(seoConfig) {
|
|
|
4107
4552
|
}
|
|
4108
4553
|
};
|
|
4109
4554
|
}
|
|
4110
|
-
var ALGO = "aes-256-gcm";
|
|
4111
|
-
var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
|
|
4112
|
-
var FORMAT_VERSION = "v1";
|
|
4113
|
-
function deriveKey(secret) {
|
|
4114
|
-
const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
|
|
4115
|
-
if (explicit) {
|
|
4116
|
-
const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
|
|
4117
|
-
if (buf.length === 32) return buf;
|
|
4118
|
-
throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
|
|
4119
|
-
}
|
|
4120
|
-
if (!secret) {
|
|
4121
|
-
throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
|
|
4122
|
-
}
|
|
4123
|
-
return scryptSync(secret, KEY_NAMESPACE, 32);
|
|
4124
|
-
}
|
|
4125
|
-
function encryptToken(plaintext, secret) {
|
|
4126
|
-
const key = deriveKey(secret);
|
|
4127
|
-
const iv = randomBytes(12);
|
|
4128
|
-
const cipher = createCipheriv(ALGO, key, iv);
|
|
4129
|
-
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
4130
|
-
const tag = cipher.getAuthTag();
|
|
4131
|
-
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
|
|
4132
|
-
}
|
|
4133
|
-
function decryptToken(payload, secret) {
|
|
4134
|
-
const parts = payload.split(":");
|
|
4135
|
-
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
4136
|
-
throw new Error("Invalid encrypted token format.");
|
|
4137
|
-
}
|
|
4138
|
-
const key = deriveKey(secret);
|
|
4139
|
-
const iv = Buffer.from(parts[1], "base64");
|
|
4140
|
-
const tag = Buffer.from(parts[2], "base64");
|
|
4141
|
-
const enc = Buffer.from(parts[3], "base64");
|
|
4142
|
-
const decipher = createDecipheriv(ALGO, key, iv);
|
|
4143
|
-
decipher.setAuthTag(tag);
|
|
4144
|
-
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
4145
|
-
return dec.toString("utf8");
|
|
4146
|
-
}
|
|
4147
|
-
function safeEqual(a, b) {
|
|
4148
|
-
const ba = Buffer.from(a);
|
|
4149
|
-
const bb = Buffer.from(b);
|
|
4150
|
-
if (ba.length !== bb.length) return false;
|
|
4151
|
-
return timingSafeEqual(ba, bb);
|
|
4152
|
-
}
|
|
4153
|
-
|
|
4154
|
-
// src/endpoints/gscOAuth.ts
|
|
4155
|
-
var AUTH_COLLECTION = "seo-gsc-auth";
|
|
4156
|
-
var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
|
|
4157
|
-
function isAdmin5(user) {
|
|
4158
|
-
if (!user) return false;
|
|
4159
|
-
if (user.role === "admin") return true;
|
|
4160
|
-
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
4161
|
-
return false;
|
|
4162
|
-
}
|
|
4163
|
-
function resolveSiteUrl2(seoConfig) {
|
|
4164
|
-
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
4165
|
-
}
|
|
4166
|
-
function getOAuthConfig(basePath, seoConfig) {
|
|
4167
|
-
const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
|
|
4168
|
-
const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
|
|
4169
|
-
const siteUrl = resolveSiteUrl2(seoConfig);
|
|
4170
|
-
if (!clientId || !clientSecret || !siteUrl) return null;
|
|
4171
|
-
return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
|
|
4172
|
-
}
|
|
4173
|
-
async function getOrCreateAuthDoc(payload) {
|
|
4174
|
-
const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
|
|
4175
|
-
if (found.docs.length > 0) return found.docs[0];
|
|
4176
|
-
return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
|
|
4177
|
-
}
|
|
4178
|
-
async function tokenRequest(cfg, body) {
|
|
4179
|
-
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
4180
|
-
method: "POST",
|
|
4181
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4182
|
-
body: new URLSearchParams({
|
|
4183
|
-
client_id: cfg.clientId,
|
|
4184
|
-
client_secret: cfg.clientSecret,
|
|
4185
|
-
...body
|
|
4186
|
-
}).toString()
|
|
4187
|
-
});
|
|
4188
|
-
const json = await resp.json();
|
|
4189
|
-
if (!resp.ok) {
|
|
4190
|
-
throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
|
|
4191
|
-
}
|
|
4192
|
-
return json;
|
|
4193
|
-
}
|
|
4194
4555
|
function createGscStatusHandler(basePath, seoConfig) {
|
|
4195
4556
|
return async (req) => {
|
|
4196
4557
|
try {
|
|
4197
4558
|
if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
4198
|
-
const cfg =
|
|
4199
|
-
const doc = await
|
|
4559
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4560
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4200
4561
|
return Response.json(
|
|
4201
4562
|
{
|
|
4202
4563
|
configured: !!cfg,
|
|
@@ -4218,8 +4579,8 @@ function createGscStatusHandler(basePath, seoConfig) {
|
|
|
4218
4579
|
function createGscAuthStartHandler(basePath, seoConfig) {
|
|
4219
4580
|
return async (req) => {
|
|
4220
4581
|
try {
|
|
4221
|
-
if (!
|
|
4222
|
-
const cfg =
|
|
4582
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4583
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4223
4584
|
if (!cfg) {
|
|
4224
4585
|
return Response.json(
|
|
4225
4586
|
{ error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
|
|
@@ -4227,9 +4588,9 @@ function createGscAuthStartHandler(basePath, seoConfig) {
|
|
|
4227
4588
|
);
|
|
4228
4589
|
}
|
|
4229
4590
|
const state = randomBytes(24).toString("hex");
|
|
4230
|
-
const doc = await
|
|
4591
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4231
4592
|
await req.payload.update({
|
|
4232
|
-
collection:
|
|
4593
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4233
4594
|
id: doc.id,
|
|
4234
4595
|
data: { pendingState: state },
|
|
4235
4596
|
overrideAccess: true
|
|
@@ -4238,7 +4599,7 @@ function createGscAuthStartHandler(basePath, seoConfig) {
|
|
|
4238
4599
|
authUrl.searchParams.set("client_id", cfg.clientId);
|
|
4239
4600
|
authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
|
|
4240
4601
|
authUrl.searchParams.set("response_type", "code");
|
|
4241
|
-
authUrl.searchParams.set("scope",
|
|
4602
|
+
authUrl.searchParams.set("scope", GSC_SCOPES);
|
|
4242
4603
|
authUrl.searchParams.set("access_type", "offline");
|
|
4243
4604
|
authUrl.searchParams.set("prompt", "consent");
|
|
4244
4605
|
authUrl.searchParams.set("state", state);
|
|
@@ -4257,10 +4618,10 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4257
4618
|
{ status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
|
|
4258
4619
|
);
|
|
4259
4620
|
try {
|
|
4260
|
-
if (!
|
|
4621
|
+
if (!isGscAdmin(req.user)) {
|
|
4261
4622
|
return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
|
|
4262
4623
|
}
|
|
4263
|
-
const cfg =
|
|
4624
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4264
4625
|
if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
|
|
4265
4626
|
const url = new URL(req.url);
|
|
4266
4627
|
const code = url.searchParams.get("code");
|
|
@@ -4268,11 +4629,11 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4268
4629
|
const oauthError = url.searchParams.get("error");
|
|
4269
4630
|
if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
|
|
4270
4631
|
if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
|
|
4271
|
-
const doc = await
|
|
4632
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4272
4633
|
if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
|
|
4273
4634
|
return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
|
|
4274
4635
|
}
|
|
4275
|
-
const tokens = await
|
|
4636
|
+
const tokens = await gscTokenRequest(cfg, {
|
|
4276
4637
|
code,
|
|
4277
4638
|
redirect_uri: cfg.redirectUri,
|
|
4278
4639
|
grant_type: "authorization_code"
|
|
@@ -4298,14 +4659,14 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4298
4659
|
const secret = req.payload.secret || "";
|
|
4299
4660
|
const refreshTokenEnc = encryptToken(refreshToken, secret);
|
|
4300
4661
|
await req.payload.update({
|
|
4301
|
-
collection:
|
|
4662
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4302
4663
|
id: doc.id,
|
|
4303
4664
|
data: {
|
|
4304
4665
|
refreshTokenEnc,
|
|
4305
4666
|
pendingState: null,
|
|
4306
4667
|
connectedEmail: email,
|
|
4307
4668
|
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4308
|
-
scope: tokens.scope ||
|
|
4669
|
+
scope: tokens.scope || GSC_SCOPES,
|
|
4309
4670
|
propertyUrl: doc.propertyUrl || cfg.siteUrl
|
|
4310
4671
|
},
|
|
4311
4672
|
overrideAccess: true
|
|
@@ -4321,26 +4682,26 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4321
4682
|
function createGscDataHandler(basePath, seoConfig) {
|
|
4322
4683
|
return async (req) => {
|
|
4323
4684
|
try {
|
|
4324
|
-
if (!
|
|
4325
|
-
const cfg =
|
|
4685
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4686
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4326
4687
|
if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
|
|
4327
|
-
const doc = await
|
|
4688
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4328
4689
|
if (!doc.refreshTokenEnc) {
|
|
4329
4690
|
return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
|
|
4330
4691
|
}
|
|
4331
|
-
|
|
4332
|
-
let refreshToken;
|
|
4692
|
+
let accessToken;
|
|
4333
4693
|
try {
|
|
4334
|
-
|
|
4335
|
-
} catch {
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4694
|
+
accessToken = await getGscAccessToken(req.payload, cfg, doc);
|
|
4695
|
+
} catch (e) {
|
|
4696
|
+
const code = e instanceof Error ? e.message : "refresh_failed";
|
|
4697
|
+
if (code === "decrypt_failed") {
|
|
4698
|
+
return Response.json(
|
|
4699
|
+
{ error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
|
|
4700
|
+
{ status: 409 }
|
|
4701
|
+
);
|
|
4702
|
+
}
|
|
4703
|
+
return Response.json({ error: "Could not refresh access token." }, { status: 502 });
|
|
4340
4704
|
}
|
|
4341
|
-
const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
|
|
4342
|
-
const accessToken = tokens.access_token;
|
|
4343
|
-
if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
|
|
4344
4705
|
const url = new URL(req.url);
|
|
4345
4706
|
const today = /* @__PURE__ */ new Date();
|
|
4346
4707
|
const defaultEnd = today.toISOString().slice(0, 10);
|
|
@@ -4350,21 +4711,19 @@ function createGscDataHandler(basePath, seoConfig) {
|
|
|
4350
4711
|
const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
|
|
4351
4712
|
const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
|
|
4352
4713
|
const property = doc.propertyUrl || cfg.siteUrl;
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
{
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
const err = gscJson.error?.message || gscResp.status;
|
|
4364
|
-
return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
|
|
4714
|
+
let rows;
|
|
4715
|
+
try {
|
|
4716
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
4717
|
+
startDate,
|
|
4718
|
+
endDate,
|
|
4719
|
+
dimensions: [dimension],
|
|
4720
|
+
rowLimit
|
|
4721
|
+
});
|
|
4722
|
+
} catch (e) {
|
|
4723
|
+
return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
|
|
4365
4724
|
}
|
|
4366
4725
|
return Response.json(
|
|
4367
|
-
{ property, startDate, endDate, dimension, rows
|
|
4726
|
+
{ property, startDate, endDate, dimension, rows },
|
|
4368
4727
|
{ headers: { "Cache-Control": "no-store" } }
|
|
4369
4728
|
);
|
|
4370
4729
|
} catch (error) {
|
|
@@ -4377,10 +4736,10 @@ function createGscDataHandler(basePath, seoConfig) {
|
|
|
4377
4736
|
function createGscDisconnectHandler() {
|
|
4378
4737
|
return async (req) => {
|
|
4379
4738
|
try {
|
|
4380
|
-
if (!
|
|
4381
|
-
const doc = await
|
|
4739
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4740
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4382
4741
|
await req.payload.update({
|
|
4383
|
-
collection:
|
|
4742
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4384
4743
|
id: doc.id,
|
|
4385
4744
|
data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
|
|
4386
4745
|
overrideAccess: true
|
|
@@ -4925,7 +5284,33 @@ function createLinkGraphHandler(targetCollections, globals = []) {
|
|
|
4925
5284
|
};
|
|
4926
5285
|
}
|
|
4927
5286
|
|
|
4928
|
-
// src/
|
|
5287
|
+
// src/helpers/buildSchema.ts
|
|
5288
|
+
var SCHEMA_TYPES = [
|
|
5289
|
+
"Article",
|
|
5290
|
+
"LocalBusiness",
|
|
5291
|
+
"BreadcrumbList",
|
|
5292
|
+
"FAQPage",
|
|
5293
|
+
"Product",
|
|
5294
|
+
"Organization",
|
|
5295
|
+
"Person",
|
|
5296
|
+
"Event",
|
|
5297
|
+
"Recipe",
|
|
5298
|
+
"Video"
|
|
5299
|
+
];
|
|
5300
|
+
function resolveSiteUrl2(explicit) {
|
|
5301
|
+
return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
|
|
5302
|
+
}
|
|
5303
|
+
function getSchemaImageUrl(metaImage, heroMedia, siteUrl) {
|
|
5304
|
+
const img = metaImage || heroMedia;
|
|
5305
|
+
if (!img) return void 0;
|
|
5306
|
+
if (typeof img.url === "string") {
|
|
5307
|
+
return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
|
|
5308
|
+
}
|
|
5309
|
+
if (typeof img.filename === "string") {
|
|
5310
|
+
return `${siteUrl}/media/${img.filename}`;
|
|
5311
|
+
}
|
|
5312
|
+
return void 0;
|
|
5313
|
+
}
|
|
4929
5314
|
function detectSchemaType(collection, doc) {
|
|
4930
5315
|
if (collection === "posts") return "Article";
|
|
4931
5316
|
const layout = doc.layout;
|
|
@@ -4946,16 +5331,25 @@ function detectSchemaType(collection, doc) {
|
|
|
4946
5331
|
}
|
|
4947
5332
|
return "Article";
|
|
4948
5333
|
}
|
|
5334
|
+
function buildAuthors(authors) {
|
|
5335
|
+
return authors.filter((a) => a && typeof a === "object").map((a) => {
|
|
5336
|
+
const author = a;
|
|
5337
|
+
return {
|
|
5338
|
+
"@type": "Person",
|
|
5339
|
+
name: author.name || author.firstName || "Author"
|
|
5340
|
+
};
|
|
5341
|
+
});
|
|
5342
|
+
}
|
|
4949
5343
|
function buildArticleSchema(doc, siteUrl) {
|
|
4950
5344
|
const meta = doc.meta || {};
|
|
4951
5345
|
const heroMedia = doc.hero?.media;
|
|
4952
|
-
const imageUrl =
|
|
5346
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
4953
5347
|
const schema = {
|
|
4954
5348
|
"@context": "https://schema.org",
|
|
4955
5349
|
"@type": "Article",
|
|
4956
5350
|
headline: meta.title || doc.title || "",
|
|
4957
5351
|
description: meta.description || "",
|
|
4958
|
-
datePublished: doc.createdAt || void 0,
|
|
5352
|
+
datePublished: doc.publishedAt || doc.createdAt || void 0,
|
|
4959
5353
|
dateModified: doc.updatedAt || void 0,
|
|
4960
5354
|
mainEntityOfPage: {
|
|
4961
5355
|
"@type": "WebPage",
|
|
@@ -4968,24 +5362,46 @@ function buildArticleSchema(doc, siteUrl) {
|
|
|
4968
5362
|
}
|
|
4969
5363
|
return schema;
|
|
4970
5364
|
}
|
|
4971
|
-
function
|
|
5365
|
+
function buildLocationNode(loc, doc, siteUrl) {
|
|
4972
5366
|
const meta = doc.meta || {};
|
|
4973
|
-
const
|
|
4974
|
-
"@
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
url: `${siteUrl}/${doc.slug || ""}`
|
|
5367
|
+
const node = {
|
|
5368
|
+
"@type": typeof loc.type === "string" && loc.type || "LocalBusiness",
|
|
5369
|
+
name: loc.name || doc.title || meta.title || "",
|
|
5370
|
+
description: loc.description || meta.description || "",
|
|
5371
|
+
url: loc.url || `${siteUrl}/${doc.slug || ""}`
|
|
4979
5372
|
};
|
|
4980
|
-
if (
|
|
4981
|
-
if (
|
|
4982
|
-
if (
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
5373
|
+
if (loc.telephone) node.telephone = loc.telephone;
|
|
5374
|
+
if (loc.email) node.email = loc.email;
|
|
5375
|
+
if (loc.priceRange) node.priceRange = loc.priceRange;
|
|
5376
|
+
const address = loc.address;
|
|
5377
|
+
if (address && typeof address === "object") {
|
|
5378
|
+
node.address = { "@type": "PostalAddress", ...address };
|
|
5379
|
+
} else if (typeof address === "string" && address) {
|
|
5380
|
+
node.address = address;
|
|
5381
|
+
}
|
|
5382
|
+
const geo = loc.geo || {};
|
|
5383
|
+
const lat = geo.latitude ?? loc.latitude ?? loc.lat;
|
|
5384
|
+
const lng = geo.longitude ?? loc.longitude ?? loc.lng;
|
|
5385
|
+
if (lat != null && lng != null) {
|
|
5386
|
+
node.geo = { "@type": "GeoCoordinates", latitude: lat, longitude: lng };
|
|
5387
|
+
}
|
|
5388
|
+
if (Array.isArray(loc.openingHours) && loc.openingHours.length > 0) {
|
|
5389
|
+
node.openingHours = loc.openingHours;
|
|
5390
|
+
} else if (typeof loc.openingHours === "string" && loc.openingHours) {
|
|
5391
|
+
node.openingHours = loc.openingHours;
|
|
5392
|
+
}
|
|
5393
|
+
return node;
|
|
5394
|
+
}
|
|
5395
|
+
function buildLocalBusinessSchema(doc, siteUrl) {
|
|
5396
|
+
const locations = Array.isArray(doc.locations) ? doc.locations.filter((l) => !!l && typeof l === "object") : [];
|
|
5397
|
+
if (locations.length > 1) {
|
|
5398
|
+
return {
|
|
5399
|
+
"@context": "https://schema.org",
|
|
5400
|
+
"@graph": locations.map((loc) => buildLocationNode(loc, doc, siteUrl))
|
|
4986
5401
|
};
|
|
4987
5402
|
}
|
|
4988
|
-
|
|
5403
|
+
const base = locations.length === 1 ? locations[0] : doc;
|
|
5404
|
+
return { "@context": "https://schema.org", ...buildLocationNode(base, doc, siteUrl) };
|
|
4989
5405
|
}
|
|
4990
5406
|
function buildBreadcrumbSchema(doc, siteUrl) {
|
|
4991
5407
|
const slug = doc.slug || "";
|
|
@@ -5054,7 +5470,7 @@ function buildFAQSchema(doc) {
|
|
|
5054
5470
|
function buildProductSchema(doc, siteUrl) {
|
|
5055
5471
|
const meta = doc.meta || {};
|
|
5056
5472
|
const heroMedia = doc.hero?.media;
|
|
5057
|
-
const imageUrl =
|
|
5473
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
5058
5474
|
const schema = {
|
|
5059
5475
|
"@context": "https://schema.org",
|
|
5060
5476
|
"@type": "Product",
|
|
@@ -5127,7 +5543,7 @@ function buildEventSchema(doc, siteUrl) {
|
|
|
5127
5543
|
function buildRecipeSchema(doc, siteUrl) {
|
|
5128
5544
|
const meta = doc.meta || {};
|
|
5129
5545
|
const heroMedia = doc.hero?.media;
|
|
5130
|
-
const imageUrl =
|
|
5546
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
5131
5547
|
const schema = {
|
|
5132
5548
|
"@context": "https://schema.org",
|
|
5133
5549
|
"@type": "Recipe",
|
|
@@ -5144,7 +5560,7 @@ function buildRecipeSchema(doc, siteUrl) {
|
|
|
5144
5560
|
function buildVideoSchema(doc, siteUrl) {
|
|
5145
5561
|
const meta = doc.meta || {};
|
|
5146
5562
|
const heroMedia = doc.hero?.media;
|
|
5147
|
-
const imageUrl =
|
|
5563
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
5148
5564
|
const schema = {
|
|
5149
5565
|
"@context": "https://schema.org",
|
|
5150
5566
|
"@type": "VideoObject",
|
|
@@ -5157,26 +5573,51 @@ function buildVideoSchema(doc, siteUrl) {
|
|
|
5157
5573
|
if (doc.duration) schema.duration = doc.duration;
|
|
5158
5574
|
return schema;
|
|
5159
5575
|
}
|
|
5160
|
-
function
|
|
5161
|
-
const
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5576
|
+
function buildJsonLd(doc, options = {}) {
|
|
5577
|
+
const siteUrl = resolveSiteUrl2(options.siteUrl);
|
|
5578
|
+
const schemaType = options.type || detectSchemaType(options.collection || "", doc);
|
|
5579
|
+
let jsonLd;
|
|
5580
|
+
switch (schemaType) {
|
|
5581
|
+
case "Article":
|
|
5582
|
+
jsonLd = buildArticleSchema(doc, siteUrl);
|
|
5583
|
+
break;
|
|
5584
|
+
case "LocalBusiness":
|
|
5585
|
+
jsonLd = buildLocalBusinessSchema(doc, siteUrl);
|
|
5586
|
+
break;
|
|
5587
|
+
case "BreadcrumbList":
|
|
5588
|
+
jsonLd = buildBreadcrumbSchema(doc, siteUrl);
|
|
5589
|
+
break;
|
|
5590
|
+
case "FAQPage":
|
|
5591
|
+
jsonLd = buildFAQSchema(doc);
|
|
5592
|
+
break;
|
|
5593
|
+
case "Product":
|
|
5594
|
+
jsonLd = buildProductSchema(doc, siteUrl);
|
|
5595
|
+
break;
|
|
5596
|
+
case "Organization":
|
|
5597
|
+
jsonLd = buildOrganizationSchema(doc, siteUrl);
|
|
5598
|
+
break;
|
|
5599
|
+
case "Person":
|
|
5600
|
+
jsonLd = buildPersonSchema(doc, siteUrl);
|
|
5601
|
+
break;
|
|
5602
|
+
case "Event":
|
|
5603
|
+
jsonLd = buildEventSchema(doc, siteUrl);
|
|
5604
|
+
break;
|
|
5605
|
+
case "Recipe":
|
|
5606
|
+
jsonLd = buildRecipeSchema(doc, siteUrl);
|
|
5607
|
+
break;
|
|
5608
|
+
case "Video":
|
|
5609
|
+
jsonLd = buildVideoSchema(doc, siteUrl);
|
|
5610
|
+
break;
|
|
5611
|
+
}
|
|
5612
|
+
const cleaned = JSON.parse(JSON.stringify(jsonLd));
|
|
5613
|
+
return { type: schemaType, jsonLd: cleaned };
|
|
5614
|
+
}
|
|
5615
|
+
function renderJsonLdScript(doc, options = {}) {
|
|
5616
|
+
const { jsonLd } = buildJsonLd(doc, options);
|
|
5617
|
+
return `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
|
|
5179
5618
|
}
|
|
5619
|
+
|
|
5620
|
+
// src/endpoints/schemaGenerator.ts
|
|
5180
5621
|
function createSchemaGeneratorHandler(targetCollections) {
|
|
5181
5622
|
return async (req) => {
|
|
5182
5623
|
try {
|
|
@@ -5188,74 +5629,30 @@ function createSchemaGeneratorHandler(targetCollections) {
|
|
|
5188
5629
|
const id = url.searchParams.get("id");
|
|
5189
5630
|
const typeOverrideRaw = url.searchParams.get("type");
|
|
5190
5631
|
if (!collection || !id) {
|
|
5191
|
-
return Response.json(
|
|
5192
|
-
{ error: "Missing required query params: collection, id" },
|
|
5193
|
-
{ status: 400 }
|
|
5194
|
-
);
|
|
5632
|
+
return Response.json({ error: "Missing required query params: collection, id" }, { status: 400 });
|
|
5195
5633
|
}
|
|
5196
|
-
|
|
5197
|
-
if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
|
|
5634
|
+
if (typeOverrideRaw !== null && !SCHEMA_TYPES.includes(typeOverrideRaw)) {
|
|
5198
5635
|
return Response.json(
|
|
5199
|
-
{ error: `Invalid schema type. Valid types: ${
|
|
5636
|
+
{ error: `Invalid schema type. Valid types: ${SCHEMA_TYPES.join(", ")}` },
|
|
5200
5637
|
{ status: 400 }
|
|
5201
5638
|
);
|
|
5202
5639
|
}
|
|
5203
|
-
const typeOverride = typeOverrideRaw;
|
|
5640
|
+
const typeOverride = typeOverrideRaw || void 0;
|
|
5204
5641
|
if (targetCollections && !targetCollections.includes(collection)) {
|
|
5205
5642
|
return Response.json({ error: "Collection not allowed" }, { status: 403 });
|
|
5206
5643
|
}
|
|
5207
5644
|
let doc;
|
|
5208
5645
|
try {
|
|
5209
|
-
const result = await req.payload.findByID({
|
|
5210
|
-
collection,
|
|
5211
|
-
id,
|
|
5212
|
-
depth: 1,
|
|
5213
|
-
overrideAccess: true
|
|
5214
|
-
});
|
|
5646
|
+
const result = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
|
|
5215
5647
|
doc = result;
|
|
5216
5648
|
} catch {
|
|
5217
5649
|
return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
|
|
5218
5650
|
}
|
|
5219
|
-
const
|
|
5220
|
-
const schemaType = typeOverride || detectSchemaType(collection, doc);
|
|
5221
|
-
let jsonLd;
|
|
5222
|
-
switch (schemaType) {
|
|
5223
|
-
case "Article":
|
|
5224
|
-
jsonLd = buildArticleSchema(doc, siteUrl);
|
|
5225
|
-
break;
|
|
5226
|
-
case "LocalBusiness":
|
|
5227
|
-
jsonLd = buildLocalBusinessSchema(doc, siteUrl);
|
|
5228
|
-
break;
|
|
5229
|
-
case "BreadcrumbList":
|
|
5230
|
-
jsonLd = buildBreadcrumbSchema(doc, siteUrl);
|
|
5231
|
-
break;
|
|
5232
|
-
case "FAQPage":
|
|
5233
|
-
jsonLd = buildFAQSchema(doc);
|
|
5234
|
-
break;
|
|
5235
|
-
case "Product":
|
|
5236
|
-
jsonLd = buildProductSchema(doc, siteUrl);
|
|
5237
|
-
break;
|
|
5238
|
-
case "Organization":
|
|
5239
|
-
jsonLd = buildOrganizationSchema(doc, siteUrl);
|
|
5240
|
-
break;
|
|
5241
|
-
case "Person":
|
|
5242
|
-
jsonLd = buildPersonSchema(doc, siteUrl);
|
|
5243
|
-
break;
|
|
5244
|
-
case "Event":
|
|
5245
|
-
jsonLd = buildEventSchema(doc, siteUrl);
|
|
5246
|
-
break;
|
|
5247
|
-
case "Recipe":
|
|
5248
|
-
jsonLd = buildRecipeSchema(doc, siteUrl);
|
|
5249
|
-
break;
|
|
5250
|
-
case "Video":
|
|
5251
|
-
jsonLd = buildVideoSchema(doc, siteUrl);
|
|
5252
|
-
break;
|
|
5253
|
-
}
|
|
5254
|
-
const cleaned = JSON.parse(JSON.stringify(jsonLd));
|
|
5651
|
+
const { type, jsonLd } = buildJsonLd(doc, { collection, type: typeOverride });
|
|
5255
5652
|
return Response.json({
|
|
5256
|
-
type
|
|
5257
|
-
jsonLd
|
|
5258
|
-
html: `<script type="application/ld+json">${JSON.stringify(
|
|
5653
|
+
type,
|
|
5654
|
+
jsonLd,
|
|
5655
|
+
html: `<script type="application/ld+json">${JSON.stringify(jsonLd, null, 2)}</script>`
|
|
5259
5656
|
});
|
|
5260
5657
|
} catch (error) {
|
|
5261
5658
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -5742,15 +6139,200 @@ function createSitemapHandler(targetCollections) {
|
|
|
5742
6139
|
}
|
|
5743
6140
|
});
|
|
5744
6141
|
} catch (error) {
|
|
5745
|
-
const message = error instanceof Error ? error.message : "Internal server error";
|
|
5746
|
-
req.payload.logger.error(`[seo] sitemap.xml generation error: ${message}`);
|
|
5747
|
-
return new Response(
|
|
5748
|
-
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>',
|
|
5749
|
-
{
|
|
5750
|
-
headers: { "Content-Type": "application/xml" },
|
|
5751
|
-
status: 500
|
|
5752
|
-
}
|
|
5753
|
-
);
|
|
6142
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
6143
|
+
req.payload.logger.error(`[seo] sitemap.xml generation error: ${message}`);
|
|
6144
|
+
return new Response(
|
|
6145
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>',
|
|
6146
|
+
{
|
|
6147
|
+
headers: { "Content-Type": "application/xml" },
|
|
6148
|
+
status: 500
|
|
6149
|
+
}
|
|
6150
|
+
);
|
|
6151
|
+
}
|
|
6152
|
+
};
|
|
6153
|
+
}
|
|
6154
|
+
|
|
6155
|
+
// src/endpoints/sitemapExtensions.ts
|
|
6156
|
+
function escapeXml2(str) {
|
|
6157
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6158
|
+
}
|
|
6159
|
+
function resolveSiteUrl3(seoConfig) {
|
|
6160
|
+
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
|
|
6161
|
+
}
|
|
6162
|
+
function docPath(slug) {
|
|
6163
|
+
return slug === "home" || slug === "" ? "" : `/${slug}`;
|
|
6164
|
+
}
|
|
6165
|
+
function xmlResponse(xml, status = 200) {
|
|
6166
|
+
return new Response(xml, {
|
|
6167
|
+
status,
|
|
6168
|
+
headers: { "Content-Type": "application/xml", "Cache-Control": "public, max-age=3600, s-maxage=3600" }
|
|
6169
|
+
});
|
|
6170
|
+
}
|
|
6171
|
+
function mediaUrl(media, siteUrl) {
|
|
6172
|
+
if (typeof media.url === "string" && media.url) {
|
|
6173
|
+
return media.url.startsWith("http") ? media.url : `${siteUrl}${media.url}`;
|
|
6174
|
+
}
|
|
6175
|
+
if (typeof media.filename === "string" && media.filename) {
|
|
6176
|
+
return `${siteUrl}/media/${media.filename}`;
|
|
6177
|
+
}
|
|
6178
|
+
return void 0;
|
|
6179
|
+
}
|
|
6180
|
+
function collectMediaUrls(node, mimePrefix, siteUrl, out, depth = 0) {
|
|
6181
|
+
if (!node || typeof node !== "object" || depth > 8) return;
|
|
6182
|
+
if (Array.isArray(node)) {
|
|
6183
|
+
for (const item of node) collectMediaUrls(item, mimePrefix, siteUrl, out, depth + 1);
|
|
6184
|
+
return;
|
|
6185
|
+
}
|
|
6186
|
+
const obj = node;
|
|
6187
|
+
const mime = typeof obj.mimeType === "string" ? obj.mimeType : "";
|
|
6188
|
+
if (mime.startsWith(mimePrefix)) {
|
|
6189
|
+
const url = mediaUrl(obj, siteUrl);
|
|
6190
|
+
if (url) out.add(url);
|
|
6191
|
+
}
|
|
6192
|
+
for (const key of Object.keys(obj)) {
|
|
6193
|
+
if (key === "sizes" || key === "_status") continue;
|
|
6194
|
+
collectMediaUrls(obj[key], mimePrefix, siteUrl, out, depth + 1);
|
|
6195
|
+
}
|
|
6196
|
+
}
|
|
6197
|
+
async function eachPublishedDoc(payload, collections, depth, onDoc) {
|
|
6198
|
+
const BATCH = Math.min(100, Math.max(1, parseInt(process.env.SEO_SITEMAP_BATCH_SIZE || "50", 10) || 50));
|
|
6199
|
+
const MAX = Math.max(1, parseInt(process.env.SEO_SITEMAP_MAX_DOCS || "5000", 10) || 5e3);
|
|
6200
|
+
let count = 0;
|
|
6201
|
+
for (const collection of collections) {
|
|
6202
|
+
try {
|
|
6203
|
+
let page = 1;
|
|
6204
|
+
let hasMore = true;
|
|
6205
|
+
while (hasMore) {
|
|
6206
|
+
const res = await payload.find({ collection, limit: BATCH, page, depth, overrideAccess: true });
|
|
6207
|
+
for (const doc of res.docs) {
|
|
6208
|
+
if (doc._status === "draft") continue;
|
|
6209
|
+
if (count >= MAX) return;
|
|
6210
|
+
onDoc(doc, collection);
|
|
6211
|
+
count++;
|
|
6212
|
+
}
|
|
6213
|
+
hasMore = res.hasNextPage;
|
|
6214
|
+
page++;
|
|
6215
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
6216
|
+
}
|
|
6217
|
+
} catch {
|
|
6218
|
+
}
|
|
6219
|
+
}
|
|
6220
|
+
}
|
|
6221
|
+
function createNewsSitemapHandler(targetCollections, seoConfig) {
|
|
6222
|
+
return async (req) => {
|
|
6223
|
+
try {
|
|
6224
|
+
const siteUrl = resolveSiteUrl3(seoConfig);
|
|
6225
|
+
const language = seoConfig?.locale === "en" ? "en" : "fr";
|
|
6226
|
+
let publication = seoConfig?.siteName || "";
|
|
6227
|
+
if (!publication && siteUrl) {
|
|
6228
|
+
try {
|
|
6229
|
+
publication = new URL(siteUrl).hostname;
|
|
6230
|
+
} catch {
|
|
6231
|
+
}
|
|
6232
|
+
}
|
|
6233
|
+
const cutoff = Date.now() - 48 * 36e5;
|
|
6234
|
+
const entries = [];
|
|
6235
|
+
await eachPublishedDoc(req.payload, targetCollections, 0, (doc) => {
|
|
6236
|
+
const dateStr = typeof doc.publishedAt === "string" && doc.publishedAt || typeof doc.date === "string" && doc.date || typeof doc.createdAt === "string" && doc.createdAt || "";
|
|
6237
|
+
if (!dateStr) return;
|
|
6238
|
+
const t = new Date(dateStr).getTime();
|
|
6239
|
+
if (isNaN(t) || t < cutoff) return;
|
|
6240
|
+
const title = doc.title || doc.meta?.title || "";
|
|
6241
|
+
if (!title) return;
|
|
6242
|
+
const loc = `${siteUrl}${docPath(doc.slug || "")}`;
|
|
6243
|
+
entries.push(
|
|
6244
|
+
` <url>
|
|
6245
|
+
<loc>${escapeXml2(loc)}</loc>
|
|
6246
|
+
<news:news>
|
|
6247
|
+
<news:publication>
|
|
6248
|
+
<news:name>${escapeXml2(publication)}</news:name>
|
|
6249
|
+
<news:language>${language}</news:language>
|
|
6250
|
+
</news:publication>
|
|
6251
|
+
<news:publication_date>${new Date(dateStr).toISOString()}</news:publication_date>
|
|
6252
|
+
<news:title>${escapeXml2(title)}</news:title>
|
|
6253
|
+
</news:news>
|
|
6254
|
+
</url>`
|
|
6255
|
+
);
|
|
6256
|
+
});
|
|
6257
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
6258
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
|
|
6259
|
+
${entries.join("\n")}
|
|
6260
|
+
</urlset>`;
|
|
6261
|
+
return xmlResponse(xml);
|
|
6262
|
+
} catch (error) {
|
|
6263
|
+
req.payload.logger.error(`[seo] sitemap-news error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
6264
|
+
return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
|
|
6265
|
+
}
|
|
6266
|
+
};
|
|
6267
|
+
}
|
|
6268
|
+
function createImageSitemapHandler(targetCollections, seoConfig) {
|
|
6269
|
+
return async (req) => {
|
|
6270
|
+
try {
|
|
6271
|
+
const siteUrl = resolveSiteUrl3(seoConfig);
|
|
6272
|
+
const entries = [];
|
|
6273
|
+
await eachPublishedDoc(req.payload, targetCollections, 1, (doc) => {
|
|
6274
|
+
const urls = /* @__PURE__ */ new Set();
|
|
6275
|
+
collectMediaUrls(doc, "image/", siteUrl, urls);
|
|
6276
|
+
if (urls.size === 0) return;
|
|
6277
|
+
const loc = `${siteUrl}${docPath(doc.slug || "")}`;
|
|
6278
|
+
const imgs = Array.from(urls).slice(0, 1e3).map((u) => ` <image:image><image:loc>${escapeXml2(u)}</image:loc></image:image>`).join("\n");
|
|
6279
|
+
entries.push(` <url>
|
|
6280
|
+
<loc>${escapeXml2(loc)}</loc>
|
|
6281
|
+
${imgs}
|
|
6282
|
+
</url>`);
|
|
6283
|
+
});
|
|
6284
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
6285
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
|
6286
|
+
${entries.join("\n")}
|
|
6287
|
+
</urlset>`;
|
|
6288
|
+
return xmlResponse(xml);
|
|
6289
|
+
} catch (error) {
|
|
6290
|
+
req.payload.logger.error(`[seo] sitemap-images error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
6291
|
+
return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
|
|
6292
|
+
}
|
|
6293
|
+
};
|
|
6294
|
+
}
|
|
6295
|
+
function createVideoSitemapHandler(targetCollections, seoConfig) {
|
|
6296
|
+
return async (req) => {
|
|
6297
|
+
try {
|
|
6298
|
+
const siteUrl = resolveSiteUrl3(seoConfig);
|
|
6299
|
+
const entries = [];
|
|
6300
|
+
await eachPublishedDoc(req.payload, targetCollections, 1, (doc) => {
|
|
6301
|
+
const meta = doc.meta || {};
|
|
6302
|
+
const videoUrls = /* @__PURE__ */ new Set();
|
|
6303
|
+
collectMediaUrls(doc, "video/", siteUrl, videoUrls);
|
|
6304
|
+
for (const k of ["videoUrl", "contentUrl", "playerUrl"]) {
|
|
6305
|
+
if (typeof doc[k] === "string" && doc[k]) videoUrls.add(doc[k]);
|
|
6306
|
+
}
|
|
6307
|
+
if (videoUrls.size === 0) return;
|
|
6308
|
+
const title = doc.title || meta.title || "";
|
|
6309
|
+
const description = meta.description || title;
|
|
6310
|
+
const thumbs = /* @__PURE__ */ new Set();
|
|
6311
|
+
collectMediaUrls(meta.image, "image/", siteUrl, thumbs);
|
|
6312
|
+
if (thumbs.size === 0) collectMediaUrls(doc, "image/", siteUrl, thumbs);
|
|
6313
|
+
const thumbnail = Array.from(thumbs)[0] || "";
|
|
6314
|
+
const loc = `${siteUrl}${docPath(doc.slug || "")}`;
|
|
6315
|
+
const videos = Array.from(videoUrls).slice(0, 100).map(
|
|
6316
|
+
(u) => ` <video:video>
|
|
6317
|
+
${thumbnail ? ` <video:thumbnail_loc>${escapeXml2(thumbnail)}</video:thumbnail_loc>
|
|
6318
|
+
` : ""} <video:title>${escapeXml2(title || "Video")}</video:title>
|
|
6319
|
+
<video:description>${escapeXml2(description || title || "Video")}</video:description>
|
|
6320
|
+
<video:content_loc>${escapeXml2(u)}</video:content_loc>
|
|
6321
|
+
</video:video>`
|
|
6322
|
+
).join("\n");
|
|
6323
|
+
entries.push(` <url>
|
|
6324
|
+
<loc>${escapeXml2(loc)}</loc>
|
|
6325
|
+
${videos}
|
|
6326
|
+
</url>`);
|
|
6327
|
+
});
|
|
6328
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
6329
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
|
6330
|
+
${entries.join("\n")}
|
|
6331
|
+
</urlset>`;
|
|
6332
|
+
return xmlResponse(xml);
|
|
6333
|
+
} catch (error) {
|
|
6334
|
+
req.payload.logger.error(`[seo] sitemap-video error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
6335
|
+
return xmlResponse('<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>', 500);
|
|
5754
6336
|
}
|
|
5755
6337
|
};
|
|
5756
6338
|
}
|
|
@@ -6405,6 +6987,219 @@ function createSeoGscAuthCollection() {
|
|
|
6405
6987
|
};
|
|
6406
6988
|
}
|
|
6407
6989
|
|
|
6990
|
+
// src/collections/SeoRankHistory.ts
|
|
6991
|
+
function createSeoRankHistoryCollection() {
|
|
6992
|
+
return {
|
|
6993
|
+
slug: "seo-rank-history",
|
|
6994
|
+
admin: {
|
|
6995
|
+
custom: { navHidden: true }
|
|
6996
|
+
},
|
|
6997
|
+
access: {
|
|
6998
|
+
read: ({ req }) => !!req.user,
|
|
6999
|
+
create: ({ req }) => req.user?.role === "admin",
|
|
7000
|
+
update: ({ req }) => req.user?.role === "admin",
|
|
7001
|
+
delete: ({ req }) => req.user?.role === "admin"
|
|
7002
|
+
},
|
|
7003
|
+
timestamps: false,
|
|
7004
|
+
fields: [
|
|
7005
|
+
{
|
|
7006
|
+
name: "query",
|
|
7007
|
+
type: "text",
|
|
7008
|
+
required: true,
|
|
7009
|
+
index: true,
|
|
7010
|
+
admin: { description: "Search query (keyword) tracked" }
|
|
7011
|
+
},
|
|
7012
|
+
{
|
|
7013
|
+
name: "page",
|
|
7014
|
+
type: "text",
|
|
7015
|
+
admin: { description: "Landing page URL (when tracked by page)" }
|
|
7016
|
+
},
|
|
7017
|
+
{
|
|
7018
|
+
name: "position",
|
|
7019
|
+
type: "number",
|
|
7020
|
+
required: true,
|
|
7021
|
+
admin: { description: "Average SERP position over the snapshot window (lower is better)" }
|
|
7022
|
+
},
|
|
7023
|
+
{
|
|
7024
|
+
name: "clicks",
|
|
7025
|
+
type: "number",
|
|
7026
|
+
admin: { description: "Clicks over the snapshot window" }
|
|
7027
|
+
},
|
|
7028
|
+
{
|
|
7029
|
+
name: "impressions",
|
|
7030
|
+
type: "number",
|
|
7031
|
+
admin: { description: "Impressions over the snapshot window" }
|
|
7032
|
+
},
|
|
7033
|
+
{
|
|
7034
|
+
name: "ctr",
|
|
7035
|
+
type: "number",
|
|
7036
|
+
admin: { description: "Click-through rate (0-1) over the snapshot window" }
|
|
7037
|
+
},
|
|
7038
|
+
{
|
|
7039
|
+
name: "property",
|
|
7040
|
+
type: "text",
|
|
7041
|
+
admin: { description: "GSC property the snapshot was taken from" }
|
|
7042
|
+
},
|
|
7043
|
+
{
|
|
7044
|
+
// YYYY-MM-DD — used to deduplicate one snapshot per query per day.
|
|
7045
|
+
name: "dateKey",
|
|
7046
|
+
type: "text",
|
|
7047
|
+
required: true,
|
|
7048
|
+
index: true,
|
|
7049
|
+
admin: { description: "Snapshot day (YYYY-MM-DD), one snapshot per query per day" }
|
|
7050
|
+
},
|
|
7051
|
+
{
|
|
7052
|
+
name: "snapshotDate",
|
|
7053
|
+
type: "date",
|
|
7054
|
+
required: true,
|
|
7055
|
+
index: true,
|
|
7056
|
+
admin: { description: "Exact timestamp of the snapshot" }
|
|
7057
|
+
}
|
|
7058
|
+
]
|
|
7059
|
+
};
|
|
7060
|
+
}
|
|
7061
|
+
|
|
7062
|
+
// src/endpoints/rankTracking.ts
|
|
7063
|
+
var RANK_COLLECTION = "seo-rank-history";
|
|
7064
|
+
var round1 = (n) => Math.round(n * 10) / 10;
|
|
7065
|
+
async function runRankSnapshot(payload, basePath, seoConfig, opts) {
|
|
7066
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
7067
|
+
if (!cfg) return { ok: false, reason: "not_configured" };
|
|
7068
|
+
const authDoc = await getOrCreateGscAuthDoc(payload);
|
|
7069
|
+
if (!authDoc.refreshTokenEnc) return { ok: false, reason: "not_connected" };
|
|
7070
|
+
let accessToken;
|
|
7071
|
+
try {
|
|
7072
|
+
accessToken = await getGscAccessToken(payload, cfg, authDoc);
|
|
7073
|
+
} catch (e) {
|
|
7074
|
+
return { ok: false, reason: e instanceof Error ? e.message : "refresh_failed" };
|
|
7075
|
+
}
|
|
7076
|
+
const property = authDoc.propertyUrl || cfg.siteUrl;
|
|
7077
|
+
const windowDays = Math.min(90, Math.max(1, 7));
|
|
7078
|
+
const rowLimit = Math.min(1e3, Math.max(1, 100));
|
|
7079
|
+
const end = new Date(Date.now() - 2 * 864e5);
|
|
7080
|
+
const start = new Date(end.getTime() - (windowDays - 1) * 864e5);
|
|
7081
|
+
const endDate = end.toISOString().slice(0, 10);
|
|
7082
|
+
const startDate = start.toISOString().slice(0, 10);
|
|
7083
|
+
let rows;
|
|
7084
|
+
try {
|
|
7085
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
7086
|
+
startDate,
|
|
7087
|
+
endDate,
|
|
7088
|
+
dimensions: ["query"],
|
|
7089
|
+
rowLimit
|
|
7090
|
+
});
|
|
7091
|
+
} catch (e) {
|
|
7092
|
+
return { ok: false, reason: e instanceof Error ? e.message : "query_failed" };
|
|
7093
|
+
}
|
|
7094
|
+
const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
7095
|
+
const existing = await payload.find({
|
|
7096
|
+
collection: RANK_COLLECTION,
|
|
7097
|
+
where: { dateKey: { equals: todayKey } },
|
|
7098
|
+
limit: 2e3,
|
|
7099
|
+
depth: 0,
|
|
7100
|
+
overrideAccess: true
|
|
7101
|
+
});
|
|
7102
|
+
const already = new Set(existing.docs.map((d) => d.query));
|
|
7103
|
+
let stored = 0;
|
|
7104
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
7105
|
+
for (const r of rows) {
|
|
7106
|
+
const query = r.keys?.[0];
|
|
7107
|
+
if (!query || already.has(query)) continue;
|
|
7108
|
+
try {
|
|
7109
|
+
await payload.create({
|
|
7110
|
+
collection: RANK_COLLECTION,
|
|
7111
|
+
data: {
|
|
7112
|
+
query,
|
|
7113
|
+
position: round1(r.position),
|
|
7114
|
+
clicks: r.clicks,
|
|
7115
|
+
impressions: r.impressions,
|
|
7116
|
+
ctr: r.ctr,
|
|
7117
|
+
property,
|
|
7118
|
+
dateKey: todayKey,
|
|
7119
|
+
snapshotDate: nowIso
|
|
7120
|
+
},
|
|
7121
|
+
overrideAccess: true
|
|
7122
|
+
});
|
|
7123
|
+
stored++;
|
|
7124
|
+
} catch (e) {
|
|
7125
|
+
payload.logger.warn(`[seo] rank-snapshot: skipped "${query}": ${e instanceof Error ? e.message : "error"}`);
|
|
7126
|
+
}
|
|
7127
|
+
}
|
|
7128
|
+
return { ok: true, stored, scanned: rows.length, startDate, endDate };
|
|
7129
|
+
}
|
|
7130
|
+
function createRankSnapshotHandler(basePath, seoConfig) {
|
|
7131
|
+
return async (req) => {
|
|
7132
|
+
try {
|
|
7133
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7134
|
+
const result = await runRankSnapshot(req.payload, basePath, seoConfig);
|
|
7135
|
+
if (!result.ok) {
|
|
7136
|
+
const status = result.reason === "not_connected" || result.reason === "not_configured" ? 409 : 502;
|
|
7137
|
+
return Response.json(result, { status, headers: { "Cache-Control": "no-store" } });
|
|
7138
|
+
}
|
|
7139
|
+
return Response.json(result, { headers: { "Cache-Control": "no-store" } });
|
|
7140
|
+
} catch (error) {
|
|
7141
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7142
|
+
req.payload.logger.error(`[seo] rank-snapshot error: ${message}`);
|
|
7143
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7144
|
+
}
|
|
7145
|
+
};
|
|
7146
|
+
}
|
|
7147
|
+
function createRankHistoryHandler() {
|
|
7148
|
+
return async (req) => {
|
|
7149
|
+
try {
|
|
7150
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7151
|
+
const url = new URL(req.url);
|
|
7152
|
+
const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") || "35", 10)));
|
|
7153
|
+
const since = new Date(Date.now() - days * 864e5).toISOString();
|
|
7154
|
+
const all = await req.payload.find({
|
|
7155
|
+
collection: RANK_COLLECTION,
|
|
7156
|
+
where: { snapshotDate: { greater_than: since } },
|
|
7157
|
+
sort: "-snapshotDate",
|
|
7158
|
+
limit: 5e3,
|
|
7159
|
+
depth: 0,
|
|
7160
|
+
overrideAccess: true
|
|
7161
|
+
});
|
|
7162
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
7163
|
+
for (const d of all.docs) {
|
|
7164
|
+
const q = d.query;
|
|
7165
|
+
const arr = byQuery.get(q);
|
|
7166
|
+
if (arr) arr.push(d);
|
|
7167
|
+
else byQuery.set(q, [d]);
|
|
7168
|
+
}
|
|
7169
|
+
const movers = Array.from(byQuery.entries()).map(([query, snaps]) => {
|
|
7170
|
+
const latest = snaps[0];
|
|
7171
|
+
const previous = snaps.find((s) => s.dateKey !== latest.dateKey) || null;
|
|
7172
|
+
const delta = previous ? round1(previous.position - latest.position) : 0;
|
|
7173
|
+
return {
|
|
7174
|
+
query,
|
|
7175
|
+
page: latest.page || null,
|
|
7176
|
+
position: latest.position,
|
|
7177
|
+
previousPosition: previous ? previous.position : null,
|
|
7178
|
+
delta,
|
|
7179
|
+
clicks: latest.clicks ?? 0,
|
|
7180
|
+
impressions: latest.impressions ?? 0,
|
|
7181
|
+
ctr: latest.ctr ?? 0,
|
|
7182
|
+
snapshotDate: latest.snapshotDate,
|
|
7183
|
+
history: snaps.slice(0, 30).map((s) => ({ date: s.dateKey, position: s.position })).reverse()
|
|
7184
|
+
};
|
|
7185
|
+
});
|
|
7186
|
+
movers.sort((a, b) => (b.impressions || 0) - (a.impressions || 0));
|
|
7187
|
+
return Response.json(
|
|
7188
|
+
{
|
|
7189
|
+
count: movers.length,
|
|
7190
|
+
lastSnapshot: all.docs[0]?.snapshotDate || null,
|
|
7191
|
+
movers
|
|
7192
|
+
},
|
|
7193
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7194
|
+
);
|
|
7195
|
+
} catch (error) {
|
|
7196
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7197
|
+
req.payload.logger.error(`[seo] rank-history error: ${message}`);
|
|
7198
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7199
|
+
}
|
|
7200
|
+
};
|
|
7201
|
+
}
|
|
7202
|
+
|
|
6408
7203
|
// src/rateLimiter.ts
|
|
6409
7204
|
function createRateLimiter(maxRequests, windowMs) {
|
|
6410
7205
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -6749,6 +7544,296 @@ function stopCacheWarmUp() {
|
|
|
6749
7544
|
}
|
|
6750
7545
|
}
|
|
6751
7546
|
|
|
7547
|
+
// src/rankTracker.ts
|
|
7548
|
+
var SNAPSHOT_INTERVAL = 24 * 60 * 60 * 1e3;
|
|
7549
|
+
var STARTUP_DELAY2 = 30 * 1e3;
|
|
7550
|
+
var intervalId2 = null;
|
|
7551
|
+
var listenersAttached2 = false;
|
|
7552
|
+
async function doSnapshot(payload, basePath, seoConfig) {
|
|
7553
|
+
try {
|
|
7554
|
+
const result = await runRankSnapshot(payload, basePath, seoConfig);
|
|
7555
|
+
if (result.ok) {
|
|
7556
|
+
payload.logger.info(`[seo] rank-tracker: snapshot stored ${result.stored}/${result.scanned} queries`);
|
|
7557
|
+
} else if (result.reason !== "not_connected" && result.reason !== "not_configured") {
|
|
7558
|
+
payload.logger.warn(`[seo] rank-tracker: snapshot skipped (${result.reason})`);
|
|
7559
|
+
}
|
|
7560
|
+
} catch (error) {
|
|
7561
|
+
payload.logger.error(`[seo] rank-tracker error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
7562
|
+
}
|
|
7563
|
+
}
|
|
7564
|
+
function startRankTracker(payload, basePath, seoConfig) {
|
|
7565
|
+
setTimeout(() => {
|
|
7566
|
+
void doSnapshot(payload, basePath, seoConfig);
|
|
7567
|
+
}, STARTUP_DELAY2);
|
|
7568
|
+
intervalId2 = setInterval(() => {
|
|
7569
|
+
void doSnapshot(payload, basePath, seoConfig);
|
|
7570
|
+
}, SNAPSHOT_INTERVAL);
|
|
7571
|
+
if (!listenersAttached2) {
|
|
7572
|
+
const cleanup = () => stopRankTracker();
|
|
7573
|
+
process.on("SIGTERM", cleanup);
|
|
7574
|
+
process.on("SIGINT", cleanup);
|
|
7575
|
+
listenersAttached2 = true;
|
|
7576
|
+
}
|
|
7577
|
+
payload.logger.info("[seo] rank-tracker: scheduled startup + every 24h");
|
|
7578
|
+
}
|
|
7579
|
+
function stopRankTracker() {
|
|
7580
|
+
if (intervalId2) {
|
|
7581
|
+
clearInterval(intervalId2);
|
|
7582
|
+
intervalId2 = null;
|
|
7583
|
+
}
|
|
7584
|
+
}
|
|
7585
|
+
|
|
7586
|
+
// src/endpoints/alerts.ts
|
|
7587
|
+
function isAdmin8(user) {
|
|
7588
|
+
if (!user) return false;
|
|
7589
|
+
if (user.role === "admin") return true;
|
|
7590
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
7591
|
+
return false;
|
|
7592
|
+
}
|
|
7593
|
+
function getAlertConfig() {
|
|
7594
|
+
return {
|
|
7595
|
+
webhookUrl: process.env.SEO_ALERT_WEBHOOK_URL || "",
|
|
7596
|
+
emails: (process.env.SEO_ALERT_EMAIL || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
7597
|
+
scoreDrop: parseInt(process.env.SEO_ALERT_SCORE_DROP || "10", 10) || 10,
|
|
7598
|
+
positionDrop: parseInt(process.env.SEO_ALERT_POSITION_DROP || "5", 10) || 5,
|
|
7599
|
+
windowHours: Math.max(1, parseInt(process.env.SEO_ALERT_WINDOW_HOURS || "24", 10) || 24)
|
|
7600
|
+
};
|
|
7601
|
+
}
|
|
7602
|
+
var round12 = (n) => Math.round(n * 10) / 10;
|
|
7603
|
+
async function buildAlertDigest(payload, cfg) {
|
|
7604
|
+
const now = Date.now();
|
|
7605
|
+
const since = new Date(now - cfg.windowHours * 36e5).toISOString();
|
|
7606
|
+
const scoreRegressions = [];
|
|
7607
|
+
try {
|
|
7608
|
+
const hist = await payload.find({
|
|
7609
|
+
collection: "seo-score-history",
|
|
7610
|
+
where: { snapshotDate: { greater_than: new Date(now - 14 * 864e5).toISOString() } },
|
|
7611
|
+
sort: "-snapshotDate",
|
|
7612
|
+
limit: 5e3,
|
|
7613
|
+
depth: 0,
|
|
7614
|
+
overrideAccess: true
|
|
7615
|
+
});
|
|
7616
|
+
const byDoc = /* @__PURE__ */ new Map();
|
|
7617
|
+
for (const h of hist.docs) {
|
|
7618
|
+
const key = `${h.documentId}::${h.collection}`;
|
|
7619
|
+
const arr = byDoc.get(key);
|
|
7620
|
+
if (arr) arr.push(h);
|
|
7621
|
+
else byDoc.set(key, [h]);
|
|
7622
|
+
}
|
|
7623
|
+
for (const [key, snaps] of byDoc) {
|
|
7624
|
+
const latest = snaps[0];
|
|
7625
|
+
const oldest = snaps[snaps.length - 1];
|
|
7626
|
+
const drop = oldest.score - latest.score;
|
|
7627
|
+
if (drop >= cfg.scoreDrop) {
|
|
7628
|
+
const [documentId, collection] = key.split("::");
|
|
7629
|
+
scoreRegressions.push({
|
|
7630
|
+
documentId,
|
|
7631
|
+
collection,
|
|
7632
|
+
from: oldest.score,
|
|
7633
|
+
to: latest.score,
|
|
7634
|
+
drop
|
|
7635
|
+
});
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7638
|
+
scoreRegressions.sort((a, b) => b.drop - a.drop);
|
|
7639
|
+
} catch {
|
|
7640
|
+
}
|
|
7641
|
+
const newNotFound = [];
|
|
7642
|
+
try {
|
|
7643
|
+
const logs = await payload.find({
|
|
7644
|
+
collection: "seo-logs",
|
|
7645
|
+
where: {
|
|
7646
|
+
and: [{ lastSeen: { greater_than: since } }, { ignored: { not_equals: true } }]
|
|
7647
|
+
},
|
|
7648
|
+
sort: "-count",
|
|
7649
|
+
limit: 50,
|
|
7650
|
+
depth: 0,
|
|
7651
|
+
overrideAccess: true
|
|
7652
|
+
});
|
|
7653
|
+
for (const l of logs.docs) {
|
|
7654
|
+
newNotFound.push({
|
|
7655
|
+
url: l.url || "",
|
|
7656
|
+
count: l.count || 1,
|
|
7657
|
+
lastSeen: l.lastSeen || ""
|
|
7658
|
+
});
|
|
7659
|
+
}
|
|
7660
|
+
} catch {
|
|
7661
|
+
}
|
|
7662
|
+
const rankDrops = [];
|
|
7663
|
+
try {
|
|
7664
|
+
const ranks = await payload.find({
|
|
7665
|
+
collection: "seo-rank-history",
|
|
7666
|
+
where: { snapshotDate: { greater_than: new Date(now - 35 * 864e5).toISOString() } },
|
|
7667
|
+
sort: "-snapshotDate",
|
|
7668
|
+
limit: 5e3,
|
|
7669
|
+
depth: 0,
|
|
7670
|
+
overrideAccess: true
|
|
7671
|
+
});
|
|
7672
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
7673
|
+
for (const r of ranks.docs) {
|
|
7674
|
+
const q = r.query;
|
|
7675
|
+
const arr = byQuery.get(q);
|
|
7676
|
+
if (arr) arr.push(r);
|
|
7677
|
+
else byQuery.set(q, [r]);
|
|
7678
|
+
}
|
|
7679
|
+
for (const [query, snaps] of byQuery) {
|
|
7680
|
+
const latest = snaps[0];
|
|
7681
|
+
const previous = snaps.find((s) => s.dateKey !== latest.dateKey);
|
|
7682
|
+
if (!previous) continue;
|
|
7683
|
+
const drop = round12(latest.position - previous.position);
|
|
7684
|
+
if (drop >= cfg.positionDrop) {
|
|
7685
|
+
rankDrops.push({ query, from: previous.position, to: latest.position, drop });
|
|
7686
|
+
}
|
|
7687
|
+
}
|
|
7688
|
+
rankDrops.sort((a, b) => b.drop - a.drop);
|
|
7689
|
+
} catch {
|
|
7690
|
+
}
|
|
7691
|
+
const totalIssues = scoreRegressions.length + newNotFound.length + rankDrops.length;
|
|
7692
|
+
return {
|
|
7693
|
+
since,
|
|
7694
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7695
|
+
scoreRegressions,
|
|
7696
|
+
newNotFound,
|
|
7697
|
+
rankDrops,
|
|
7698
|
+
totalIssues
|
|
7699
|
+
};
|
|
7700
|
+
}
|
|
7701
|
+
function digestToHtml(digest, siteUrl) {
|
|
7702
|
+
const section = (title, rows) => rows.length ? `<h3 style="margin:18px 0 6px">${title}</h3><ul style="margin:0;padding-left:18px">${rows.join("")}</ul>` : "";
|
|
7703
|
+
const reg = digest.scoreRegressions.slice(0, 20).map((r) => `<li>${r.collection}/${r.documentId} \u2014 score ${r.from} \u2192 <b>${r.to}</b> (\u2212${r.drop})</li>`);
|
|
7704
|
+
const nf = digest.newNotFound.slice(0, 20).map((n) => `<li><code>${n.url}</code> \u2014 ${n.count}\xD7</li>`);
|
|
7705
|
+
const rd = digest.rankDrops.slice(0, 20).map((d) => `<li>\u201C${d.query}\u201D \u2014 #${round12(d.from)} \u2192 <b>#${round12(d.to)}</b> (\u25BC${d.drop})</li>`);
|
|
7706
|
+
return `<div style="font-family:system-ui;max-width:640px">
|
|
7707
|
+
<h2>SEO alert digest${siteUrl ? ` \u2014 ${siteUrl}` : ""}</h2>
|
|
7708
|
+
<p style="color:#6b7280;font-size:13px">${digest.totalIssues} issue(s) since ${new Date(digest.since).toLocaleString()}</p>
|
|
7709
|
+
${section("\u{1F4C9} Score regressions", reg)}
|
|
7710
|
+
${section("\u{1F517} New 404s", nf)}
|
|
7711
|
+
${section("\u{1F53B} Ranking drops", rd)}
|
|
7712
|
+
${digest.totalIssues === 0 ? "<p>No issues to report. \u{1F389}</p>" : ""}
|
|
7713
|
+
</div>`;
|
|
7714
|
+
}
|
|
7715
|
+
async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
|
|
7716
|
+
const channels = { webhook: false, email: false };
|
|
7717
|
+
if (digest.totalIssues === 0) {
|
|
7718
|
+
return { sent: false, reason: "nothing_to_report", channels };
|
|
7719
|
+
}
|
|
7720
|
+
if (cfg.webhookUrl) {
|
|
7721
|
+
try {
|
|
7722
|
+
await fetch(cfg.webhookUrl, {
|
|
7723
|
+
method: "POST",
|
|
7724
|
+
headers: { "content-type": "application/json" },
|
|
7725
|
+
body: JSON.stringify({ type: "seo-alert-digest", siteUrl, digest })
|
|
7726
|
+
});
|
|
7727
|
+
channels.webhook = true;
|
|
7728
|
+
} catch (e) {
|
|
7729
|
+
payload.logger.warn(`[seo] alerts: webhook delivery failed: ${e instanceof Error ? e.message : "error"}`);
|
|
7730
|
+
}
|
|
7731
|
+
}
|
|
7732
|
+
if (cfg.emails.length > 0) {
|
|
7733
|
+
const send = payload.sendEmail;
|
|
7734
|
+
if (typeof send === "function") {
|
|
7735
|
+
try {
|
|
7736
|
+
await send({
|
|
7737
|
+
to: cfg.emails,
|
|
7738
|
+
subject: `SEO alert digest \u2014 ${digest.totalIssues} issue(s)`,
|
|
7739
|
+
html: digestToHtml(digest, siteUrl)
|
|
7740
|
+
});
|
|
7741
|
+
channels.email = true;
|
|
7742
|
+
} catch (e) {
|
|
7743
|
+
payload.logger.warn(`[seo] alerts: email delivery failed: ${e instanceof Error ? e.message : "error"}`);
|
|
7744
|
+
}
|
|
7745
|
+
}
|
|
7746
|
+
}
|
|
7747
|
+
const sent = channels.webhook || channels.email;
|
|
7748
|
+
return { sent, reason: sent ? void 0 : "no_channel_configured", channels };
|
|
7749
|
+
}
|
|
7750
|
+
function createAlertsDigestHandler() {
|
|
7751
|
+
return async (req) => {
|
|
7752
|
+
try {
|
|
7753
|
+
if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7754
|
+
const cfg = getAlertConfig();
|
|
7755
|
+
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7756
|
+
return Response.json(
|
|
7757
|
+
{
|
|
7758
|
+
digest,
|
|
7759
|
+
config: {
|
|
7760
|
+
webhookConfigured: !!cfg.webhookUrl,
|
|
7761
|
+
emailConfigured: cfg.emails.length > 0,
|
|
7762
|
+
scoreDrop: cfg.scoreDrop,
|
|
7763
|
+
positionDrop: cfg.positionDrop,
|
|
7764
|
+
windowHours: cfg.windowHours
|
|
7765
|
+
}
|
|
7766
|
+
},
|
|
7767
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7768
|
+
);
|
|
7769
|
+
} catch (error) {
|
|
7770
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7771
|
+
req.payload.logger.error(`[seo] alerts-digest error: ${message}`);
|
|
7772
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7773
|
+
}
|
|
7774
|
+
};
|
|
7775
|
+
}
|
|
7776
|
+
function createAlertsRunHandler(siteUrl) {
|
|
7777
|
+
return async (req) => {
|
|
7778
|
+
try {
|
|
7779
|
+
if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7780
|
+
const cfg = getAlertConfig();
|
|
7781
|
+
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7782
|
+
const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
|
|
7783
|
+
return Response.json({ digest, delivery }, { headers: { "Cache-Control": "no-store" } });
|
|
7784
|
+
} catch (error) {
|
|
7785
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7786
|
+
req.payload.logger.error(`[seo] alerts-run error: ${message}`);
|
|
7787
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7788
|
+
}
|
|
7789
|
+
};
|
|
7790
|
+
}
|
|
7791
|
+
|
|
7792
|
+
// src/alertsScheduler.ts
|
|
7793
|
+
var STARTUP_DELAY3 = 60 * 1e3;
|
|
7794
|
+
var intervalId3 = null;
|
|
7795
|
+
var listenersAttached3 = false;
|
|
7796
|
+
async function runDigest(payload, siteUrl) {
|
|
7797
|
+
try {
|
|
7798
|
+
const cfg = getAlertConfig();
|
|
7799
|
+
if (!cfg.webhookUrl && cfg.emails.length === 0) {
|
|
7800
|
+
return;
|
|
7801
|
+
}
|
|
7802
|
+
const digest = await buildAlertDigest(payload, cfg);
|
|
7803
|
+
const delivery = await deliverAlertDigest(payload, digest, cfg, siteUrl);
|
|
7804
|
+
if (delivery.sent) {
|
|
7805
|
+
payload.logger.info(
|
|
7806
|
+
`[seo] alerts: digest delivered (${digest.totalIssues} issues; webhook=${delivery.channels.webhook} email=${delivery.channels.email})`
|
|
7807
|
+
);
|
|
7808
|
+
}
|
|
7809
|
+
} catch (error) {
|
|
7810
|
+
payload.logger.error(`[seo] alerts scheduler error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
7811
|
+
}
|
|
7812
|
+
}
|
|
7813
|
+
function startAlertsScheduler(payload, siteUrl) {
|
|
7814
|
+
const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
|
|
7815
|
+
const intervalMs = intervalHours * 60 * 60 * 1e3;
|
|
7816
|
+
setTimeout(() => {
|
|
7817
|
+
void runDigest(payload, siteUrl);
|
|
7818
|
+
}, STARTUP_DELAY3);
|
|
7819
|
+
intervalId3 = setInterval(() => {
|
|
7820
|
+
void runDigest(payload, siteUrl);
|
|
7821
|
+
}, intervalMs);
|
|
7822
|
+
if (!listenersAttached3) {
|
|
7823
|
+
const cleanup = () => stopAlertsScheduler();
|
|
7824
|
+
process.on("SIGTERM", cleanup);
|
|
7825
|
+
process.on("SIGINT", cleanup);
|
|
7826
|
+
listenersAttached3 = true;
|
|
7827
|
+
}
|
|
7828
|
+
payload.logger.info(`[seo] alerts: scheduled startup + every ${intervalHours}h`);
|
|
7829
|
+
}
|
|
7830
|
+
function stopAlertsScheduler() {
|
|
7831
|
+
if (intervalId3) {
|
|
7832
|
+
clearInterval(intervalId3);
|
|
7833
|
+
intervalId3 = null;
|
|
7834
|
+
}
|
|
7835
|
+
}
|
|
7836
|
+
|
|
6752
7837
|
// src/endpoints/generate.ts
|
|
6753
7838
|
var TYPE_TO_CONFIG_KEY = {
|
|
6754
7839
|
title: "generateTitle",
|
|
@@ -7740,6 +8825,9 @@ var fr = {
|
|
|
7740
8825
|
pagesAnalyzed: "pages analys\xE9es",
|
|
7741
8826
|
markCornerstone: "Marquer pilier",
|
|
7742
8827
|
unmarkCornerstone: "D\xE9marquer pilier",
|
|
8828
|
+
bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
|
|
8829
|
+
bulkOptimizing: "Optimisation\u2026",
|
|
8830
|
+
bulkConfirm: "Confirmer ?",
|
|
7743
8831
|
searchPlaceholder: "Rechercher (titre, slug, keyword)...",
|
|
7744
8832
|
allCollections: "Toutes les collections",
|
|
7745
8833
|
allScores: "Tous les scores",
|
|
@@ -8332,6 +9420,9 @@ var en = {
|
|
|
8332
9420
|
pagesAnalyzed: "pages analyzed",
|
|
8333
9421
|
markCornerstone: "Mark as cornerstone",
|
|
8334
9422
|
unmarkCornerstone: "Unmark cornerstone",
|
|
9423
|
+
bulkOptimizeMeta: "Optimize meta (AI)",
|
|
9424
|
+
bulkOptimizing: "Optimizing\u2026",
|
|
9425
|
+
bulkConfirm: "Confirm?",
|
|
8335
9426
|
searchPlaceholder: "Search (title, slug, keyword)...",
|
|
8336
9427
|
allCollections: "All collections",
|
|
8337
9428
|
allScores: "All scores",
|
|
@@ -8903,6 +9994,7 @@ function buildSeoConfig(pluginConfig) {
|
|
|
8903
9994
|
var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
8904
9995
|
const config = { ...incomingConfig };
|
|
8905
9996
|
const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
|
|
9997
|
+
const uploadsCollection = pluginConfig.uploadsCollection ?? "media";
|
|
8906
9998
|
const targetGlobals = pluginConfig.globals ?? [];
|
|
8907
9999
|
const basePath = pluginConfig.endpointBasePath ?? "/seo-plugin";
|
|
8908
10000
|
const seoConfig = buildSeoConfig(pluginConfig);
|
|
@@ -8925,6 +10017,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8925
10017
|
// opt-in — requires Google Cloud OAuth setup + secrets
|
|
8926
10018
|
warmCache: true,
|
|
8927
10019
|
// disable on low-memory hosts to skip startup pre-loading
|
|
10020
|
+
alerts: false,
|
|
10021
|
+
// opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
|
|
8928
10022
|
...pluginConfig.features
|
|
8929
10023
|
};
|
|
8930
10024
|
function hasExistingSeoMeta(fields) {
|
|
@@ -9065,7 +10159,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9065
10159
|
if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
|
|
9066
10160
|
if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
|
|
9067
10161
|
if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
|
|
9068
|
-
if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
|
|
10162
|
+
if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection(), createSeoRankHistoryCollection());
|
|
9069
10163
|
config.collections = [
|
|
9070
10164
|
...config.collections || [],
|
|
9071
10165
|
...pluginCollections
|
|
@@ -9178,7 +10272,10 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9178
10272
|
pluginEndpoints.push(
|
|
9179
10273
|
{ path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
|
|
9180
10274
|
{ path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
|
|
9181
|
-
{ path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) }
|
|
10275
|
+
{ path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
|
|
10276
|
+
{ path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
|
|
10277
|
+
{ path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) },
|
|
10278
|
+
{ path: `${basePath}/ai-content-brief`, method: "post", handler: withRateLimit(createAiContentBriefHandler(targetCollections, seoConfig)) }
|
|
9182
10279
|
);
|
|
9183
10280
|
}
|
|
9184
10281
|
if (features.cannibalization) {
|
|
@@ -9209,7 +10306,15 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9209
10306
|
{ path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
|
|
9210
10307
|
{ path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
|
|
9211
10308
|
{ path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
|
|
9212
|
-
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
|
|
10309
|
+
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
|
|
10310
|
+
{ path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
|
|
10311
|
+
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
|
|
10312
|
+
);
|
|
10313
|
+
}
|
|
10314
|
+
if (features.alerts) {
|
|
10315
|
+
pluginEndpoints.push(
|
|
10316
|
+
{ path: `${basePath}/alerts-digest`, method: "get", handler: createAlertsDigestHandler() },
|
|
10317
|
+
{ path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
|
|
9213
10318
|
);
|
|
9214
10319
|
}
|
|
9215
10320
|
if (features.keywords) {
|
|
@@ -9262,6 +10367,21 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9262
10367
|
path: `${basePath}/sitemap.xml`,
|
|
9263
10368
|
method: "get",
|
|
9264
10369
|
handler: createSitemapHandler(targetCollections)
|
|
10370
|
+
},
|
|
10371
|
+
{
|
|
10372
|
+
path: `${basePath}/sitemap-news.xml`,
|
|
10373
|
+
method: "get",
|
|
10374
|
+
handler: createNewsSitemapHandler(targetCollections, seoConfig)
|
|
10375
|
+
},
|
|
10376
|
+
{
|
|
10377
|
+
path: `${basePath}/sitemap-images.xml`,
|
|
10378
|
+
method: "get",
|
|
10379
|
+
handler: createImageSitemapHandler(targetCollections, seoConfig)
|
|
10380
|
+
},
|
|
10381
|
+
{
|
|
10382
|
+
path: `${basePath}/sitemap-video.xml`,
|
|
10383
|
+
method: "get",
|
|
10384
|
+
handler: createVideoSitemapHandler(targetCollections, seoConfig)
|
|
9265
10385
|
}
|
|
9266
10386
|
);
|
|
9267
10387
|
config.endpoints = [
|
|
@@ -9355,10 +10475,90 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9355
10475
|
if (features.warmCache) {
|
|
9356
10476
|
startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
|
|
9357
10477
|
}
|
|
10478
|
+
if (features.gscApi) {
|
|
10479
|
+
startRankTracker(payload, basePath, seoConfig);
|
|
10480
|
+
}
|
|
10481
|
+
if (features.alerts) {
|
|
10482
|
+
startAlertsScheduler(payload, resolveGscSiteUrl(seoConfig));
|
|
10483
|
+
}
|
|
9358
10484
|
};
|
|
9359
10485
|
return config;
|
|
9360
10486
|
};
|
|
9361
10487
|
|
|
10488
|
+
// src/helpers/buildMetadata.ts
|
|
10489
|
+
function resolveSiteUrl4(explicit) {
|
|
10490
|
+
return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
|
|
10491
|
+
}
|
|
10492
|
+
function parseRobots(doc, meta) {
|
|
10493
|
+
const raw = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
|
|
10494
|
+
let noindex = false;
|
|
10495
|
+
let nofollow = false;
|
|
10496
|
+
if (raw) {
|
|
10497
|
+
const low = raw.toLowerCase();
|
|
10498
|
+
noindex = low.includes("noindex");
|
|
10499
|
+
nofollow = low.includes("nofollow");
|
|
10500
|
+
}
|
|
10501
|
+
if (doc.noindex === true || meta.noindex === true) noindex = true;
|
|
10502
|
+
if (doc.nofollow === true || meta.nofollow === true) nofollow = true;
|
|
10503
|
+
return { index: !noindex, follow: !nofollow };
|
|
10504
|
+
}
|
|
10505
|
+
function buildLanguages(doc) {
|
|
10506
|
+
const raw = doc.localeAlternates || doc.alternates || doc.hreflang;
|
|
10507
|
+
if (!Array.isArray(raw)) return void 0;
|
|
10508
|
+
const out = {};
|
|
10509
|
+
for (const a of raw) {
|
|
10510
|
+
if (!a || typeof a !== "object") continue;
|
|
10511
|
+
const r = a;
|
|
10512
|
+
const lang = String(r.hreflang || r.locale || r.lang || "");
|
|
10513
|
+
const href = String(r.href || r.url || "");
|
|
10514
|
+
if (lang && href) out[lang] = href;
|
|
10515
|
+
}
|
|
10516
|
+
return Object.keys(out).length ? out : void 0;
|
|
10517
|
+
}
|
|
10518
|
+
function absoluteUrl(value, siteUrl) {
|
|
10519
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
10520
|
+
return `${siteUrl}${value.startsWith("/") ? "" : "/"}${value}`;
|
|
10521
|
+
}
|
|
10522
|
+
function buildSeoMetadata(doc, options = {}) {
|
|
10523
|
+
const siteUrl = resolveSiteUrl4(options.siteUrl);
|
|
10524
|
+
const meta = doc.meta || {};
|
|
10525
|
+
const rawTitle = meta.title || doc.title || "";
|
|
10526
|
+
const title = options.titleTemplate && rawTitle ? options.titleTemplate.replace("%s", rawTitle) : rawTitle;
|
|
10527
|
+
const description = meta.description || "";
|
|
10528
|
+
const slug = doc.slug || "";
|
|
10529
|
+
const heroMedia = doc.hero?.media;
|
|
10530
|
+
let image = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
10531
|
+
if (!image && options.defaultImage) image = absoluteUrl(options.defaultImage, siteUrl);
|
|
10532
|
+
const explicitCanonical = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || "";
|
|
10533
|
+
const canonical = explicitCanonical || (siteUrl ? `${siteUrl}${slug ? `/${slug}` : ""}` : void 0);
|
|
10534
|
+
const languages = buildLanguages(doc);
|
|
10535
|
+
const isPost = options.collection === "posts" || doc.isPost === true;
|
|
10536
|
+
const md = {};
|
|
10537
|
+
if (title) md.title = title;
|
|
10538
|
+
if (description) md.description = description;
|
|
10539
|
+
const alternates = {};
|
|
10540
|
+
if (canonical) alternates.canonical = canonical;
|
|
10541
|
+
if (languages) alternates.languages = languages;
|
|
10542
|
+
if (Object.keys(alternates).length) md.alternates = alternates;
|
|
10543
|
+
md.robots = parseRobots(doc, meta);
|
|
10544
|
+
md.openGraph = {
|
|
10545
|
+
...rawTitle ? { title: rawTitle } : {},
|
|
10546
|
+
...description ? { description } : {},
|
|
10547
|
+
...canonical ? { url: canonical } : {},
|
|
10548
|
+
...options.siteName ? { siteName: options.siteName } : {},
|
|
10549
|
+
type: isPost ? "article" : "website",
|
|
10550
|
+
...options.locale ? { locale: options.locale } : {},
|
|
10551
|
+
...image ? { images: [{ url: image }] } : {}
|
|
10552
|
+
};
|
|
10553
|
+
md.twitter = {
|
|
10554
|
+
card: image ? "summary_large_image" : "summary",
|
|
10555
|
+
...rawTitle ? { title: rawTitle } : {},
|
|
10556
|
+
...description ? { description } : {},
|
|
10557
|
+
...image ? { images: [image] } : {}
|
|
10558
|
+
};
|
|
10559
|
+
return md;
|
|
10560
|
+
}
|
|
10561
|
+
|
|
9362
10562
|
// src/i18n.ts
|
|
9363
10563
|
var rulesFr = {
|
|
9364
10564
|
title: {
|
|
@@ -13267,4 +14467,4 @@ function analyzeSeo(data, config) {
|
|
|
13267
14467
|
return { score, level, checks, ...aiReadiness ? { aiReadiness } : {} };
|
|
13268
14468
|
}
|
|
13269
14469
|
|
|
13270
|
-
export { ACTION_VERBS, EVERGREEN_SLUGS, FLESCH_THRESHOLDS, GENERIC_ANCHORS, KEYWORD_DENSITY_MAX, KEYWORD_DENSITY_MIN, KEYWORD_DENSITY_WARN, LEGAL_SLUGS_MAP, MAX_RECURSION_DEPTH, META_DESC_LENGTH_MAX, META_DESC_LENGTH_MIN, MIN_WORDS_FORM, MIN_WORDS_GENERIC, MIN_WORDS_LEGAL, MIN_WORDS_POST, MIN_WORDS_THIN, POWER_WORDS, POWER_WORDS_FR, READABILITY_THRESHOLDS, SCORE_EXCELLENT, SCORE_GOOD, SCORE_OK, STOP_WORDS, STOP_WORD_COMPOUNDS_MAP, TITLE_LENGTH_MAX, TITLE_LENGTH_MIN, UTILITY_SLUGS, WARNING_MULTIPLIER, analyzeSeo, buildSeoInputFromDoc, calculateFlesch, calculateFleschFR, checkHeadingHierarchy, checkImagesInBlocks, countKeywordOccurrences, countLongSections, countSentences, countSyllablesEN, countSyllablesFR, countWords, createAiRewriteHandler, createDuplicateContentHandler, createGenerateHandler, createHistoryHandler, createKeywordResearchHandler, createPerformanceHandler, createRedirectChainsHandler, createSchemaGeneratorHandler, createSeoPerformanceCollection, createSeoScoreHistoryCollection, createSitemapAuditHandler, createTrackSeoScoreHook, detectPageType, detectPassiveVoice, extractHeadingsFromLexical, extractImagesFromLexical, extractLinkUrlsFromLexical, extractLinksFromLexical, extractListsFromLexical, extractTextFromLexical, fetchAllDocs, getActionVerbs, getActionVerbsFR, getDashboardT, getEvergreenSlugs, getGenericAnchors, getLegalSlugs, getPowerWords, getStopWordCompounds, getStopWords, getStopWordsFR, getUtilitySlugs, hasTransitionWord, isStopWordInCompoundExpression, keywordMatchesText, metaFields, normalizeForComparison, registerDashboardTranslations, resolveAnalysisLocale, seoAnalyzerPlugin, seoFields, seoAnalyzerPlugin as seoPlugin, slugifyKeyword };
|
|
14470
|
+
export { ACTION_VERBS, EVERGREEN_SLUGS, FLESCH_THRESHOLDS, GENERIC_ANCHORS, KEYWORD_DENSITY_MAX, KEYWORD_DENSITY_MIN, KEYWORD_DENSITY_WARN, LEGAL_SLUGS_MAP, MAX_RECURSION_DEPTH, META_DESC_LENGTH_MAX, META_DESC_LENGTH_MIN, MIN_WORDS_FORM, MIN_WORDS_GENERIC, MIN_WORDS_LEGAL, MIN_WORDS_POST, MIN_WORDS_THIN, POWER_WORDS, POWER_WORDS_FR, READABILITY_THRESHOLDS, SCHEMA_TYPES, SCORE_EXCELLENT, SCORE_GOOD, SCORE_OK, STOP_WORDS, STOP_WORD_COMPOUNDS_MAP, TITLE_LENGTH_MAX, TITLE_LENGTH_MIN, UTILITY_SLUGS, WARNING_MULTIPLIER, analyzeSeo, buildJsonLd, buildSeoInputFromDoc, buildSeoMetadata, calculateFlesch, calculateFleschFR, checkHeadingHierarchy, checkImagesInBlocks, countKeywordOccurrences, countLongSections, countSentences, countSyllablesEN, countSyllablesFR, countWords, createAiRewriteHandler, createDuplicateContentHandler, createGenerateHandler, createHistoryHandler, createKeywordResearchHandler, createPerformanceHandler, createRedirectChainsHandler, createSchemaGeneratorHandler, createSeoPerformanceCollection, createSeoScoreHistoryCollection, createSitemapAuditHandler, createTrackSeoScoreHook, detectPageType, detectPassiveVoice, detectSchemaType, extractHeadingsFromLexical, extractImagesFromLexical, extractLinkUrlsFromLexical, extractLinksFromLexical, extractListsFromLexical, extractTextFromLexical, fetchAllDocs, getActionVerbs, getActionVerbsFR, getDashboardT, getEvergreenSlugs, getGenericAnchors, getLegalSlugs, getPowerWords, getSchemaImageUrl, getStopWordCompounds, getStopWords, getStopWordsFR, getUtilitySlugs, hasTransitionWord, isStopWordInCompoundExpression, keywordMatchesText, metaFields, normalizeForComparison, registerDashboardTranslations, renderJsonLdScript, resolveAnalysisLocale, seoAnalyzerPlugin, seoFields, seoAnalyzerPlugin as seoPlugin, slugifyKeyword };
|