@consilioweb/payload-seo-analyzer 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/client.cjs +655 -85
- package/dist/client.js +655 -85
- package/dist/index.cjs +1064 -211
- package/dist/index.d.cts +111 -3
- package/dist/index.d.ts +111 -3
- package/dist/index.js +1059 -212
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var dns = require('dns');
|
|
4
3
|
var crypto = require('crypto');
|
|
4
|
+
var dns = require('dns');
|
|
5
5
|
|
|
6
6
|
// src/constants.ts
|
|
7
7
|
var TITLE_LENGTH_MIN = 30;
|
|
@@ -3121,6 +3121,327 @@ function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
|
|
|
3121
3121
|
}
|
|
3122
3122
|
};
|
|
3123
3123
|
}
|
|
3124
|
+
var ALGO = "aes-256-gcm";
|
|
3125
|
+
var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
|
|
3126
|
+
var FORMAT_VERSION = "v1";
|
|
3127
|
+
function deriveKey(secret) {
|
|
3128
|
+
const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
|
|
3129
|
+
if (explicit) {
|
|
3130
|
+
const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
|
|
3131
|
+
if (buf.length === 32) return buf;
|
|
3132
|
+
throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
|
|
3133
|
+
}
|
|
3134
|
+
if (!secret) {
|
|
3135
|
+
throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
|
|
3136
|
+
}
|
|
3137
|
+
return crypto.scryptSync(secret, KEY_NAMESPACE, 32);
|
|
3138
|
+
}
|
|
3139
|
+
function encryptToken(plaintext, secret) {
|
|
3140
|
+
const key = deriveKey(secret);
|
|
3141
|
+
const iv = crypto.randomBytes(12);
|
|
3142
|
+
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
3143
|
+
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
3144
|
+
const tag = cipher.getAuthTag();
|
|
3145
|
+
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
|
|
3146
|
+
}
|
|
3147
|
+
function decryptToken(payload, secret) {
|
|
3148
|
+
const parts = payload.split(":");
|
|
3149
|
+
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
3150
|
+
throw new Error("Invalid encrypted token format.");
|
|
3151
|
+
}
|
|
3152
|
+
const key = deriveKey(secret);
|
|
3153
|
+
const iv = Buffer.from(parts[1], "base64");
|
|
3154
|
+
const tag = Buffer.from(parts[2], "base64");
|
|
3155
|
+
const enc = Buffer.from(parts[3], "base64");
|
|
3156
|
+
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
3157
|
+
decipher.setAuthTag(tag);
|
|
3158
|
+
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
3159
|
+
return dec.toString("utf8");
|
|
3160
|
+
}
|
|
3161
|
+
function safeEqual(a, b) {
|
|
3162
|
+
const ba = Buffer.from(a);
|
|
3163
|
+
const bb = Buffer.from(b);
|
|
3164
|
+
if (ba.length !== bb.length) return false;
|
|
3165
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// src/helpers/gscClient.ts
|
|
3169
|
+
var GSC_AUTH_COLLECTION = "seo-gsc-auth";
|
|
3170
|
+
var GSC_SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
|
|
3171
|
+
function isGscAdmin(user) {
|
|
3172
|
+
if (!user) return false;
|
|
3173
|
+
if (user.role === "admin") return true;
|
|
3174
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3175
|
+
return false;
|
|
3176
|
+
}
|
|
3177
|
+
function resolveGscSiteUrl(seoConfig) {
|
|
3178
|
+
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
3179
|
+
}
|
|
3180
|
+
function getGscOAuthConfig(basePath, seoConfig) {
|
|
3181
|
+
const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
|
|
3182
|
+
const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
|
|
3183
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
3184
|
+
if (!clientId || !clientSecret || !siteUrl) return null;
|
|
3185
|
+
return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
|
|
3186
|
+
}
|
|
3187
|
+
async function getOrCreateGscAuthDoc(payload) {
|
|
3188
|
+
const found = await payload.find({ collection: GSC_AUTH_COLLECTION, limit: 1, overrideAccess: true });
|
|
3189
|
+
if (found.docs.length > 0) return found.docs[0];
|
|
3190
|
+
return payload.create({ collection: GSC_AUTH_COLLECTION, data: {}, overrideAccess: true });
|
|
3191
|
+
}
|
|
3192
|
+
async function gscTokenRequest(cfg, body) {
|
|
3193
|
+
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
3194
|
+
method: "POST",
|
|
3195
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3196
|
+
body: new URLSearchParams({
|
|
3197
|
+
client_id: cfg.clientId,
|
|
3198
|
+
client_secret: cfg.clientSecret,
|
|
3199
|
+
...body
|
|
3200
|
+
}).toString()
|
|
3201
|
+
});
|
|
3202
|
+
const json = await resp.json();
|
|
3203
|
+
if (!resp.ok) {
|
|
3204
|
+
throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
|
|
3205
|
+
}
|
|
3206
|
+
return json;
|
|
3207
|
+
}
|
|
3208
|
+
async function getGscAccessToken(payload, cfg, authDoc) {
|
|
3209
|
+
if (!authDoc?.refreshTokenEnc) throw new Error("not_connected");
|
|
3210
|
+
const secret = payload.secret || "";
|
|
3211
|
+
let refreshToken;
|
|
3212
|
+
try {
|
|
3213
|
+
refreshToken = decryptToken(authDoc.refreshTokenEnc, secret);
|
|
3214
|
+
} catch {
|
|
3215
|
+
throw new Error("decrypt_failed");
|
|
3216
|
+
}
|
|
3217
|
+
const tokens = await gscTokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
|
|
3218
|
+
const accessToken = tokens.access_token;
|
|
3219
|
+
if (!accessToken) throw new Error("refresh_failed");
|
|
3220
|
+
return accessToken;
|
|
3221
|
+
}
|
|
3222
|
+
async function queryGscSearchAnalytics(accessToken, property, body) {
|
|
3223
|
+
const resp = await fetch(
|
|
3224
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
|
|
3225
|
+
{
|
|
3226
|
+
method: "POST",
|
|
3227
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
3228
|
+
body: JSON.stringify(body)
|
|
3229
|
+
}
|
|
3230
|
+
);
|
|
3231
|
+
const json = await resp.json();
|
|
3232
|
+
if (!resp.ok) {
|
|
3233
|
+
const err = json.error?.message || resp.status;
|
|
3234
|
+
throw new Error(`GSC query failed: ${err}`);
|
|
3235
|
+
}
|
|
3236
|
+
return json.rows || [];
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
// src/endpoints/aiAltText.ts
|
|
3240
|
+
var DEFAULT_MODEL2 = "claude-opus-4-8";
|
|
3241
|
+
var ALT_MAX = 125;
|
|
3242
|
+
var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
3243
|
+
var SUPPORTED_MIME = {
|
|
3244
|
+
"image/jpeg": "image/jpeg",
|
|
3245
|
+
"image/jpg": "image/jpeg",
|
|
3246
|
+
"image/png": "image/png",
|
|
3247
|
+
"image/gif": "image/gif",
|
|
3248
|
+
"image/webp": "image/webp"
|
|
3249
|
+
};
|
|
3250
|
+
function isAdmin4(user) {
|
|
3251
|
+
if (!user) return false;
|
|
3252
|
+
if (user.role === "admin") return true;
|
|
3253
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3254
|
+
return false;
|
|
3255
|
+
}
|
|
3256
|
+
function resolveImageUrl(media, siteUrl) {
|
|
3257
|
+
const raw = typeof media.url === "string" && media.url || (typeof media.filename === "string" ? `/media/${media.filename}` : "");
|
|
3258
|
+
if (!raw) return null;
|
|
3259
|
+
let absolute;
|
|
3260
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
3261
|
+
absolute = raw;
|
|
3262
|
+
} else if (siteUrl) {
|
|
3263
|
+
absolute = `${siteUrl.replace(/\/$/, "")}${raw.startsWith("/") ? "" : "/"}${raw}`;
|
|
3264
|
+
} else {
|
|
3265
|
+
return null;
|
|
3266
|
+
}
|
|
3267
|
+
try {
|
|
3268
|
+
const target = new URL(absolute);
|
|
3269
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return null;
|
|
3270
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
3271
|
+
if (siteUrl) allowed.add(new URL(siteUrl).origin);
|
|
3272
|
+
if (process.env.SEO_MEDIA_ORIGIN) allowed.add(new URL(process.env.SEO_MEDIA_ORIGIN).origin);
|
|
3273
|
+
if (allowed.size > 0 && !allowed.has(target.origin)) return null;
|
|
3274
|
+
if (allowed.size === 0) return null;
|
|
3275
|
+
return target.toString();
|
|
3276
|
+
} catch {
|
|
3277
|
+
return null;
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
async function generateAltText(apiKey, model, base64, mediaType, language, context) {
|
|
3281
|
+
const systemPrompt = `You write concise, descriptive image ALT text for accessibility and SEO.
|
|
3282
|
+
Rules:
|
|
3283
|
+
- Describe what is actually visible in the image.
|
|
3284
|
+
- Maximum ${ALT_MAX} characters.
|
|
3285
|
+
- Write in ${language === "en" ? "English" : "French"}.
|
|
3286
|
+
- Do NOT start with "image of", "photo of", "picture of" or similar.
|
|
3287
|
+
- No quotes around the result. Return ONLY the alt text, nothing else.`;
|
|
3288
|
+
const userText = `Filename: ${context.filename}${context.title ? `
|
|
3289
|
+
Page/context: ${context.title}` : ""}
|
|
3290
|
+
Write the alt text for this image:`;
|
|
3291
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
3292
|
+
method: "POST",
|
|
3293
|
+
headers: {
|
|
3294
|
+
"Content-Type": "application/json",
|
|
3295
|
+
"x-api-key": apiKey,
|
|
3296
|
+
"anthropic-version": "2023-06-01"
|
|
3297
|
+
},
|
|
3298
|
+
body: JSON.stringify({
|
|
3299
|
+
model,
|
|
3300
|
+
max_tokens: 150,
|
|
3301
|
+
system: systemPrompt,
|
|
3302
|
+
messages: [
|
|
3303
|
+
{
|
|
3304
|
+
role: "user",
|
|
3305
|
+
content: [
|
|
3306
|
+
{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } },
|
|
3307
|
+
{ type: "text", text: userText }
|
|
3308
|
+
]
|
|
3309
|
+
}
|
|
3310
|
+
]
|
|
3311
|
+
})
|
|
3312
|
+
});
|
|
3313
|
+
if (!response.ok) {
|
|
3314
|
+
const body = await response.text();
|
|
3315
|
+
throw new Error(`Claude API error ${response.status}: ${body}`);
|
|
3316
|
+
}
|
|
3317
|
+
const data = await response.json();
|
|
3318
|
+
if (data.stop_reason === "refusal") return null;
|
|
3319
|
+
const text = (data.content?.find((b) => b.type === "text")?.text || "").trim().replace(/^["']|["']$/g, "");
|
|
3320
|
+
if (!text) return null;
|
|
3321
|
+
return text.length > ALT_MAX ? text.slice(0, ALT_MAX).trim() : text;
|
|
3322
|
+
}
|
|
3323
|
+
function createAltTextAuditHandler(uploadsCollection) {
|
|
3324
|
+
return async (req) => {
|
|
3325
|
+
try {
|
|
3326
|
+
if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
3327
|
+
const url = new URL(req.url);
|
|
3328
|
+
const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get("limit") || "50", 10)));
|
|
3329
|
+
try {
|
|
3330
|
+
const missing = await req.payload.find({
|
|
3331
|
+
collection: uploadsCollection,
|
|
3332
|
+
where: { or: [{ alt: { exists: false } }, { alt: { equals: "" } }] },
|
|
3333
|
+
limit,
|
|
3334
|
+
depth: 0,
|
|
3335
|
+
overrideAccess: true
|
|
3336
|
+
});
|
|
3337
|
+
const items = missing.docs.map((d) => ({
|
|
3338
|
+
id: d.id,
|
|
3339
|
+
filename: d.filename || "",
|
|
3340
|
+
url: d.url || "",
|
|
3341
|
+
mimeType: d.mimeType || "",
|
|
3342
|
+
alt: d.alt || ""
|
|
3343
|
+
}));
|
|
3344
|
+
return Response.json(
|
|
3345
|
+
{ collection: uploadsCollection, missingCount: missing.totalDocs, items },
|
|
3346
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3347
|
+
);
|
|
3348
|
+
} catch {
|
|
3349
|
+
return Response.json(
|
|
3350
|
+
{ collection: uploadsCollection, missingCount: 0, items: [], note: "no_alt_field" },
|
|
3351
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3352
|
+
);
|
|
3353
|
+
}
|
|
3354
|
+
} catch (error) {
|
|
3355
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3356
|
+
req.payload.logger.error(`[seo] alt-text-audit error: ${message}`);
|
|
3357
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
function createAiAltTextHandler(uploadsCollection, seoConfig) {
|
|
3362
|
+
return async (req) => {
|
|
3363
|
+
try {
|
|
3364
|
+
if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
3365
|
+
const body = await parseJsonBody(req);
|
|
3366
|
+
const collection = typeof body.collection === "string" ? body.collection : uploadsCollection;
|
|
3367
|
+
const id = body.id != null ? String(body.id) : void 0;
|
|
3368
|
+
const apply = body.apply === true;
|
|
3369
|
+
const providedAlt = typeof body.altText === "string" ? body.altText.trim() : void 0;
|
|
3370
|
+
if (!id) return Response.json({ error: "Missing required field: id" }, { status: 400 });
|
|
3371
|
+
if (apply && providedAlt) {
|
|
3372
|
+
const alt2 = providedAlt.slice(0, ALT_MAX);
|
|
3373
|
+
await req.payload.update({ collection, id, data: { alt: alt2 }, overrideAccess: true });
|
|
3374
|
+
return Response.json({ alt: alt2, applied: true, method: "manual" });
|
|
3375
|
+
}
|
|
3376
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3377
|
+
if (!apiKey) {
|
|
3378
|
+
return Response.json(
|
|
3379
|
+
{ error: "AI not configured. Set ANTHROPIC_API_KEY to generate alt text.", code: "no_api_key" },
|
|
3380
|
+
{ status: 400 }
|
|
3381
|
+
);
|
|
3382
|
+
}
|
|
3383
|
+
let media;
|
|
3384
|
+
try {
|
|
3385
|
+
media = await req.payload.findByID({ collection, id, depth: 0, overrideAccess: true });
|
|
3386
|
+
} catch {
|
|
3387
|
+
return Response.json({ error: `Media not found: ${collection}/${id}` }, { status: 404 });
|
|
3388
|
+
}
|
|
3389
|
+
const mime = media.mimeType || "";
|
|
3390
|
+
const mediaType = SUPPORTED_MIME[mime.toLowerCase()];
|
|
3391
|
+
if (!mediaType) {
|
|
3392
|
+
return Response.json(
|
|
3393
|
+
{ error: `Unsupported image type for vision: ${mime || "unknown"} (use JPEG, PNG, GIF or WebP).` },
|
|
3394
|
+
{ status: 422 }
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
3398
|
+
const imageUrl = resolveImageUrl(media, siteUrl);
|
|
3399
|
+
if (!imageUrl) {
|
|
3400
|
+
return Response.json(
|
|
3401
|
+
{ error: "Could not resolve a safe image URL (must be on the site origin or SEO_MEDIA_ORIGIN)." },
|
|
3402
|
+
{ status: 422 }
|
|
3403
|
+
);
|
|
3404
|
+
}
|
|
3405
|
+
let base64;
|
|
3406
|
+
try {
|
|
3407
|
+
const imgResp = await fetch(imageUrl);
|
|
3408
|
+
if (!imgResp.ok) throw new Error(`fetch ${imgResp.status}`);
|
|
3409
|
+
const buf = Buffer.from(await imgResp.arrayBuffer());
|
|
3410
|
+
if (buf.byteLength > MAX_IMAGE_BYTES) {
|
|
3411
|
+
return Response.json({ error: "Image too large for vision (max 5 MB)." }, { status: 413 });
|
|
3412
|
+
}
|
|
3413
|
+
base64 = buf.toString("base64");
|
|
3414
|
+
} catch (e) {
|
|
3415
|
+
return Response.json({ error: `Could not fetch image: ${e instanceof Error ? e.message : "error"}` }, { status: 502 });
|
|
3416
|
+
}
|
|
3417
|
+
const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL2;
|
|
3418
|
+
const language = seoConfig?.locale === "en" ? "en" : "fr";
|
|
3419
|
+
let alt;
|
|
3420
|
+
try {
|
|
3421
|
+
alt = await generateAltText(apiKey, model, base64, mediaType, language, {
|
|
3422
|
+
filename: media.filename || "",
|
|
3423
|
+
title: typeof body.context === "string" ? body.context : void 0
|
|
3424
|
+
});
|
|
3425
|
+
} catch (e) {
|
|
3426
|
+
req.payload.logger.error(`[seo] ai-alt-text Claude error: ${e instanceof Error ? e.message : "unknown"}`);
|
|
3427
|
+
return Response.json({ error: "Alt-text generation failed." }, { status: 502 });
|
|
3428
|
+
}
|
|
3429
|
+
if (!alt) {
|
|
3430
|
+
return Response.json({ error: "The model did not return alt text (possibly declined)." }, { status: 502 });
|
|
3431
|
+
}
|
|
3432
|
+
let applied = false;
|
|
3433
|
+
if (apply) {
|
|
3434
|
+
await req.payload.update({ collection, id, data: { alt }, overrideAccess: true });
|
|
3435
|
+
applied = true;
|
|
3436
|
+
}
|
|
3437
|
+
return Response.json({ alt, applied, method: "ai", model });
|
|
3438
|
+
} catch (error) {
|
|
3439
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3440
|
+
req.payload.logger.error(`[seo] ai-alt-text error: ${message}`);
|
|
3441
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3124
3445
|
|
|
3125
3446
|
// src/endpoints/cannibalization.ts
|
|
3126
3447
|
function canonicalIntent(keyword) {
|
|
@@ -3706,7 +4027,7 @@ function getDateThreshold(period) {
|
|
|
3706
4027
|
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
3707
4028
|
}
|
|
3708
4029
|
}
|
|
3709
|
-
function
|
|
4030
|
+
function isAdmin5(user) {
|
|
3710
4031
|
if (!user) return false;
|
|
3711
4032
|
if (user.role === "admin") return true;
|
|
3712
4033
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -3811,7 +4132,7 @@ function createPerformanceHandler() {
|
|
|
3811
4132
|
});
|
|
3812
4133
|
}
|
|
3813
4134
|
if (method === "POST") {
|
|
3814
|
-
if (!
|
|
4135
|
+
if (!isAdmin5(req.user)) {
|
|
3815
4136
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
3816
4137
|
}
|
|
3817
4138
|
const body = await parseJsonBody(req);
|
|
@@ -4109,96 +4430,12 @@ function createCoreWebVitalsHandler(seoConfig) {
|
|
|
4109
4430
|
}
|
|
4110
4431
|
};
|
|
4111
4432
|
}
|
|
4112
|
-
var ALGO = "aes-256-gcm";
|
|
4113
|
-
var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
|
|
4114
|
-
var FORMAT_VERSION = "v1";
|
|
4115
|
-
function deriveKey(secret) {
|
|
4116
|
-
const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
|
|
4117
|
-
if (explicit) {
|
|
4118
|
-
const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
|
|
4119
|
-
if (buf.length === 32) return buf;
|
|
4120
|
-
throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
|
|
4121
|
-
}
|
|
4122
|
-
if (!secret) {
|
|
4123
|
-
throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
|
|
4124
|
-
}
|
|
4125
|
-
return crypto.scryptSync(secret, KEY_NAMESPACE, 32);
|
|
4126
|
-
}
|
|
4127
|
-
function encryptToken(plaintext, secret) {
|
|
4128
|
-
const key = deriveKey(secret);
|
|
4129
|
-
const iv = crypto.randomBytes(12);
|
|
4130
|
-
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
4131
|
-
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
4132
|
-
const tag = cipher.getAuthTag();
|
|
4133
|
-
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
|
|
4134
|
-
}
|
|
4135
|
-
function decryptToken(payload, secret) {
|
|
4136
|
-
const parts = payload.split(":");
|
|
4137
|
-
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
4138
|
-
throw new Error("Invalid encrypted token format.");
|
|
4139
|
-
}
|
|
4140
|
-
const key = deriveKey(secret);
|
|
4141
|
-
const iv = Buffer.from(parts[1], "base64");
|
|
4142
|
-
const tag = Buffer.from(parts[2], "base64");
|
|
4143
|
-
const enc = Buffer.from(parts[3], "base64");
|
|
4144
|
-
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
4145
|
-
decipher.setAuthTag(tag);
|
|
4146
|
-
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
4147
|
-
return dec.toString("utf8");
|
|
4148
|
-
}
|
|
4149
|
-
function safeEqual(a, b) {
|
|
4150
|
-
const ba = Buffer.from(a);
|
|
4151
|
-
const bb = Buffer.from(b);
|
|
4152
|
-
if (ba.length !== bb.length) return false;
|
|
4153
|
-
return crypto.timingSafeEqual(ba, bb);
|
|
4154
|
-
}
|
|
4155
|
-
|
|
4156
|
-
// src/endpoints/gscOAuth.ts
|
|
4157
|
-
var AUTH_COLLECTION = "seo-gsc-auth";
|
|
4158
|
-
var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
|
|
4159
|
-
function isAdmin5(user) {
|
|
4160
|
-
if (!user) return false;
|
|
4161
|
-
if (user.role === "admin") return true;
|
|
4162
|
-
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
4163
|
-
return false;
|
|
4164
|
-
}
|
|
4165
|
-
function resolveSiteUrl2(seoConfig) {
|
|
4166
|
-
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
4167
|
-
}
|
|
4168
|
-
function getOAuthConfig(basePath, seoConfig) {
|
|
4169
|
-
const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
|
|
4170
|
-
const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
|
|
4171
|
-
const siteUrl = resolveSiteUrl2(seoConfig);
|
|
4172
|
-
if (!clientId || !clientSecret || !siteUrl) return null;
|
|
4173
|
-
return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
|
|
4174
|
-
}
|
|
4175
|
-
async function getOrCreateAuthDoc(payload) {
|
|
4176
|
-
const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
|
|
4177
|
-
if (found.docs.length > 0) return found.docs[0];
|
|
4178
|
-
return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
|
|
4179
|
-
}
|
|
4180
|
-
async function tokenRequest(cfg, body) {
|
|
4181
|
-
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
4182
|
-
method: "POST",
|
|
4183
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4184
|
-
body: new URLSearchParams({
|
|
4185
|
-
client_id: cfg.clientId,
|
|
4186
|
-
client_secret: cfg.clientSecret,
|
|
4187
|
-
...body
|
|
4188
|
-
}).toString()
|
|
4189
|
-
});
|
|
4190
|
-
const json = await resp.json();
|
|
4191
|
-
if (!resp.ok) {
|
|
4192
|
-
throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
|
|
4193
|
-
}
|
|
4194
|
-
return json;
|
|
4195
|
-
}
|
|
4196
4433
|
function createGscStatusHandler(basePath, seoConfig) {
|
|
4197
4434
|
return async (req) => {
|
|
4198
4435
|
try {
|
|
4199
4436
|
if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
4200
|
-
const cfg =
|
|
4201
|
-
const doc = await
|
|
4437
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4438
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4202
4439
|
return Response.json(
|
|
4203
4440
|
{
|
|
4204
4441
|
configured: !!cfg,
|
|
@@ -4220,8 +4457,8 @@ function createGscStatusHandler(basePath, seoConfig) {
|
|
|
4220
4457
|
function createGscAuthStartHandler(basePath, seoConfig) {
|
|
4221
4458
|
return async (req) => {
|
|
4222
4459
|
try {
|
|
4223
|
-
if (!
|
|
4224
|
-
const cfg =
|
|
4460
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4461
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4225
4462
|
if (!cfg) {
|
|
4226
4463
|
return Response.json(
|
|
4227
4464
|
{ error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
|
|
@@ -4229,9 +4466,9 @@ function createGscAuthStartHandler(basePath, seoConfig) {
|
|
|
4229
4466
|
);
|
|
4230
4467
|
}
|
|
4231
4468
|
const state = crypto.randomBytes(24).toString("hex");
|
|
4232
|
-
const doc = await
|
|
4469
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4233
4470
|
await req.payload.update({
|
|
4234
|
-
collection:
|
|
4471
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4235
4472
|
id: doc.id,
|
|
4236
4473
|
data: { pendingState: state },
|
|
4237
4474
|
overrideAccess: true
|
|
@@ -4240,7 +4477,7 @@ function createGscAuthStartHandler(basePath, seoConfig) {
|
|
|
4240
4477
|
authUrl.searchParams.set("client_id", cfg.clientId);
|
|
4241
4478
|
authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
|
|
4242
4479
|
authUrl.searchParams.set("response_type", "code");
|
|
4243
|
-
authUrl.searchParams.set("scope",
|
|
4480
|
+
authUrl.searchParams.set("scope", GSC_SCOPES);
|
|
4244
4481
|
authUrl.searchParams.set("access_type", "offline");
|
|
4245
4482
|
authUrl.searchParams.set("prompt", "consent");
|
|
4246
4483
|
authUrl.searchParams.set("state", state);
|
|
@@ -4259,10 +4496,10 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4259
4496
|
{ status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
|
|
4260
4497
|
);
|
|
4261
4498
|
try {
|
|
4262
|
-
if (!
|
|
4499
|
+
if (!isGscAdmin(req.user)) {
|
|
4263
4500
|
return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
|
|
4264
4501
|
}
|
|
4265
|
-
const cfg =
|
|
4502
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4266
4503
|
if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
|
|
4267
4504
|
const url = new URL(req.url);
|
|
4268
4505
|
const code = url.searchParams.get("code");
|
|
@@ -4270,11 +4507,11 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4270
4507
|
const oauthError = url.searchParams.get("error");
|
|
4271
4508
|
if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
|
|
4272
4509
|
if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
|
|
4273
|
-
const doc = await
|
|
4510
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4274
4511
|
if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
|
|
4275
4512
|
return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
|
|
4276
4513
|
}
|
|
4277
|
-
const tokens = await
|
|
4514
|
+
const tokens = await gscTokenRequest(cfg, {
|
|
4278
4515
|
code,
|
|
4279
4516
|
redirect_uri: cfg.redirectUri,
|
|
4280
4517
|
grant_type: "authorization_code"
|
|
@@ -4300,14 +4537,14 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4300
4537
|
const secret = req.payload.secret || "";
|
|
4301
4538
|
const refreshTokenEnc = encryptToken(refreshToken, secret);
|
|
4302
4539
|
await req.payload.update({
|
|
4303
|
-
collection:
|
|
4540
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4304
4541
|
id: doc.id,
|
|
4305
4542
|
data: {
|
|
4306
4543
|
refreshTokenEnc,
|
|
4307
4544
|
pendingState: null,
|
|
4308
4545
|
connectedEmail: email,
|
|
4309
4546
|
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4310
|
-
scope: tokens.scope ||
|
|
4547
|
+
scope: tokens.scope || GSC_SCOPES,
|
|
4311
4548
|
propertyUrl: doc.propertyUrl || cfg.siteUrl
|
|
4312
4549
|
},
|
|
4313
4550
|
overrideAccess: true
|
|
@@ -4323,26 +4560,26 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4323
4560
|
function createGscDataHandler(basePath, seoConfig) {
|
|
4324
4561
|
return async (req) => {
|
|
4325
4562
|
try {
|
|
4326
|
-
if (!
|
|
4327
|
-
const cfg =
|
|
4563
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4564
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4328
4565
|
if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
|
|
4329
|
-
const doc = await
|
|
4566
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4330
4567
|
if (!doc.refreshTokenEnc) {
|
|
4331
4568
|
return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
|
|
4332
4569
|
}
|
|
4333
|
-
|
|
4334
|
-
let refreshToken;
|
|
4570
|
+
let accessToken;
|
|
4335
4571
|
try {
|
|
4336
|
-
|
|
4337
|
-
} catch {
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4572
|
+
accessToken = await getGscAccessToken(req.payload, cfg, doc);
|
|
4573
|
+
} catch (e) {
|
|
4574
|
+
const code = e instanceof Error ? e.message : "refresh_failed";
|
|
4575
|
+
if (code === "decrypt_failed") {
|
|
4576
|
+
return Response.json(
|
|
4577
|
+
{ error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
|
|
4578
|
+
{ status: 409 }
|
|
4579
|
+
);
|
|
4580
|
+
}
|
|
4581
|
+
return Response.json({ error: "Could not refresh access token." }, { status: 502 });
|
|
4342
4582
|
}
|
|
4343
|
-
const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
|
|
4344
|
-
const accessToken = tokens.access_token;
|
|
4345
|
-
if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
|
|
4346
4583
|
const url = new URL(req.url);
|
|
4347
4584
|
const today = /* @__PURE__ */ new Date();
|
|
4348
4585
|
const defaultEnd = today.toISOString().slice(0, 10);
|
|
@@ -4352,21 +4589,19 @@ function createGscDataHandler(basePath, seoConfig) {
|
|
|
4352
4589
|
const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
|
|
4353
4590
|
const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
|
|
4354
4591
|
const property = doc.propertyUrl || cfg.siteUrl;
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
{
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
const err = gscJson.error?.message || gscResp.status;
|
|
4366
|
-
return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
|
|
4592
|
+
let rows;
|
|
4593
|
+
try {
|
|
4594
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
4595
|
+
startDate,
|
|
4596
|
+
endDate,
|
|
4597
|
+
dimensions: [dimension],
|
|
4598
|
+
rowLimit
|
|
4599
|
+
});
|
|
4600
|
+
} catch (e) {
|
|
4601
|
+
return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
|
|
4367
4602
|
}
|
|
4368
4603
|
return Response.json(
|
|
4369
|
-
{ property, startDate, endDate, dimension, rows
|
|
4604
|
+
{ property, startDate, endDate, dimension, rows },
|
|
4370
4605
|
{ headers: { "Cache-Control": "no-store" } }
|
|
4371
4606
|
);
|
|
4372
4607
|
} catch (error) {
|
|
@@ -4379,10 +4614,10 @@ function createGscDataHandler(basePath, seoConfig) {
|
|
|
4379
4614
|
function createGscDisconnectHandler() {
|
|
4380
4615
|
return async (req) => {
|
|
4381
4616
|
try {
|
|
4382
|
-
if (!
|
|
4383
|
-
const doc = await
|
|
4617
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4618
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4384
4619
|
await req.payload.update({
|
|
4385
|
-
collection:
|
|
4620
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4386
4621
|
id: doc.id,
|
|
4387
4622
|
data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
|
|
4388
4623
|
overrideAccess: true
|
|
@@ -4927,7 +5162,33 @@ function createLinkGraphHandler(targetCollections, globals = []) {
|
|
|
4927
5162
|
};
|
|
4928
5163
|
}
|
|
4929
5164
|
|
|
4930
|
-
// src/
|
|
5165
|
+
// src/helpers/buildSchema.ts
|
|
5166
|
+
var SCHEMA_TYPES = [
|
|
5167
|
+
"Article",
|
|
5168
|
+
"LocalBusiness",
|
|
5169
|
+
"BreadcrumbList",
|
|
5170
|
+
"FAQPage",
|
|
5171
|
+
"Product",
|
|
5172
|
+
"Organization",
|
|
5173
|
+
"Person",
|
|
5174
|
+
"Event",
|
|
5175
|
+
"Recipe",
|
|
5176
|
+
"Video"
|
|
5177
|
+
];
|
|
5178
|
+
function resolveSiteUrl2(explicit) {
|
|
5179
|
+
return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
|
|
5180
|
+
}
|
|
5181
|
+
function getSchemaImageUrl(metaImage, heroMedia, siteUrl) {
|
|
5182
|
+
const img = metaImage || heroMedia;
|
|
5183
|
+
if (!img) return void 0;
|
|
5184
|
+
if (typeof img.url === "string") {
|
|
5185
|
+
return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
|
|
5186
|
+
}
|
|
5187
|
+
if (typeof img.filename === "string") {
|
|
5188
|
+
return `${siteUrl}/media/${img.filename}`;
|
|
5189
|
+
}
|
|
5190
|
+
return void 0;
|
|
5191
|
+
}
|
|
4931
5192
|
function detectSchemaType(collection, doc) {
|
|
4932
5193
|
if (collection === "posts") return "Article";
|
|
4933
5194
|
const layout = doc.layout;
|
|
@@ -4948,16 +5209,25 @@ function detectSchemaType(collection, doc) {
|
|
|
4948
5209
|
}
|
|
4949
5210
|
return "Article";
|
|
4950
5211
|
}
|
|
5212
|
+
function buildAuthors(authors) {
|
|
5213
|
+
return authors.filter((a) => a && typeof a === "object").map((a) => {
|
|
5214
|
+
const author = a;
|
|
5215
|
+
return {
|
|
5216
|
+
"@type": "Person",
|
|
5217
|
+
name: author.name || author.firstName || "Author"
|
|
5218
|
+
};
|
|
5219
|
+
});
|
|
5220
|
+
}
|
|
4951
5221
|
function buildArticleSchema(doc, siteUrl) {
|
|
4952
5222
|
const meta = doc.meta || {};
|
|
4953
5223
|
const heroMedia = doc.hero?.media;
|
|
4954
|
-
const imageUrl =
|
|
5224
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
4955
5225
|
const schema = {
|
|
4956
5226
|
"@context": "https://schema.org",
|
|
4957
5227
|
"@type": "Article",
|
|
4958
5228
|
headline: meta.title || doc.title || "",
|
|
4959
5229
|
description: meta.description || "",
|
|
4960
|
-
datePublished: doc.createdAt || void 0,
|
|
5230
|
+
datePublished: doc.publishedAt || doc.createdAt || void 0,
|
|
4961
5231
|
dateModified: doc.updatedAt || void 0,
|
|
4962
5232
|
mainEntityOfPage: {
|
|
4963
5233
|
"@type": "WebPage",
|
|
@@ -5056,7 +5326,7 @@ function buildFAQSchema(doc) {
|
|
|
5056
5326
|
function buildProductSchema(doc, siteUrl) {
|
|
5057
5327
|
const meta = doc.meta || {};
|
|
5058
5328
|
const heroMedia = doc.hero?.media;
|
|
5059
|
-
const imageUrl =
|
|
5329
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
5060
5330
|
const schema = {
|
|
5061
5331
|
"@context": "https://schema.org",
|
|
5062
5332
|
"@type": "Product",
|
|
@@ -5129,7 +5399,7 @@ function buildEventSchema(doc, siteUrl) {
|
|
|
5129
5399
|
function buildRecipeSchema(doc, siteUrl) {
|
|
5130
5400
|
const meta = doc.meta || {};
|
|
5131
5401
|
const heroMedia = doc.hero?.media;
|
|
5132
|
-
const imageUrl =
|
|
5402
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
5133
5403
|
const schema = {
|
|
5134
5404
|
"@context": "https://schema.org",
|
|
5135
5405
|
"@type": "Recipe",
|
|
@@ -5146,7 +5416,7 @@ function buildRecipeSchema(doc, siteUrl) {
|
|
|
5146
5416
|
function buildVideoSchema(doc, siteUrl) {
|
|
5147
5417
|
const meta = doc.meta || {};
|
|
5148
5418
|
const heroMedia = doc.hero?.media;
|
|
5149
|
-
const imageUrl =
|
|
5419
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
5150
5420
|
const schema = {
|
|
5151
5421
|
"@context": "https://schema.org",
|
|
5152
5422
|
"@type": "VideoObject",
|
|
@@ -5159,26 +5429,51 @@ function buildVideoSchema(doc, siteUrl) {
|
|
|
5159
5429
|
if (doc.duration) schema.duration = doc.duration;
|
|
5160
5430
|
return schema;
|
|
5161
5431
|
}
|
|
5162
|
-
function
|
|
5163
|
-
const
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5432
|
+
function buildJsonLd(doc, options = {}) {
|
|
5433
|
+
const siteUrl = resolveSiteUrl2(options.siteUrl);
|
|
5434
|
+
const schemaType = options.type || detectSchemaType(options.collection || "", doc);
|
|
5435
|
+
let jsonLd;
|
|
5436
|
+
switch (schemaType) {
|
|
5437
|
+
case "Article":
|
|
5438
|
+
jsonLd = buildArticleSchema(doc, siteUrl);
|
|
5439
|
+
break;
|
|
5440
|
+
case "LocalBusiness":
|
|
5441
|
+
jsonLd = buildLocalBusinessSchema(doc, siteUrl);
|
|
5442
|
+
break;
|
|
5443
|
+
case "BreadcrumbList":
|
|
5444
|
+
jsonLd = buildBreadcrumbSchema(doc, siteUrl);
|
|
5445
|
+
break;
|
|
5446
|
+
case "FAQPage":
|
|
5447
|
+
jsonLd = buildFAQSchema(doc);
|
|
5448
|
+
break;
|
|
5449
|
+
case "Product":
|
|
5450
|
+
jsonLd = buildProductSchema(doc, siteUrl);
|
|
5451
|
+
break;
|
|
5452
|
+
case "Organization":
|
|
5453
|
+
jsonLd = buildOrganizationSchema(doc, siteUrl);
|
|
5454
|
+
break;
|
|
5455
|
+
case "Person":
|
|
5456
|
+
jsonLd = buildPersonSchema(doc, siteUrl);
|
|
5457
|
+
break;
|
|
5458
|
+
case "Event":
|
|
5459
|
+
jsonLd = buildEventSchema(doc, siteUrl);
|
|
5460
|
+
break;
|
|
5461
|
+
case "Recipe":
|
|
5462
|
+
jsonLd = buildRecipeSchema(doc, siteUrl);
|
|
5463
|
+
break;
|
|
5464
|
+
case "Video":
|
|
5465
|
+
jsonLd = buildVideoSchema(doc, siteUrl);
|
|
5466
|
+
break;
|
|
5467
|
+
}
|
|
5468
|
+
const cleaned = JSON.parse(JSON.stringify(jsonLd));
|
|
5469
|
+
return { type: schemaType, jsonLd: cleaned };
|
|
5470
|
+
}
|
|
5471
|
+
function renderJsonLdScript(doc, options = {}) {
|
|
5472
|
+
const { jsonLd } = buildJsonLd(doc, options);
|
|
5473
|
+
return `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
|
|
5181
5474
|
}
|
|
5475
|
+
|
|
5476
|
+
// src/endpoints/schemaGenerator.ts
|
|
5182
5477
|
function createSchemaGeneratorHandler(targetCollections) {
|
|
5183
5478
|
return async (req) => {
|
|
5184
5479
|
try {
|
|
@@ -5190,74 +5485,30 @@ function createSchemaGeneratorHandler(targetCollections) {
|
|
|
5190
5485
|
const id = url.searchParams.get("id");
|
|
5191
5486
|
const typeOverrideRaw = url.searchParams.get("type");
|
|
5192
5487
|
if (!collection || !id) {
|
|
5193
|
-
return Response.json(
|
|
5194
|
-
{ error: "Missing required query params: collection, id" },
|
|
5195
|
-
{ status: 400 }
|
|
5196
|
-
);
|
|
5488
|
+
return Response.json({ error: "Missing required query params: collection, id" }, { status: 400 });
|
|
5197
5489
|
}
|
|
5198
|
-
|
|
5199
|
-
if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
|
|
5490
|
+
if (typeOverrideRaw !== null && !SCHEMA_TYPES.includes(typeOverrideRaw)) {
|
|
5200
5491
|
return Response.json(
|
|
5201
|
-
{ error: `Invalid schema type. Valid types: ${
|
|
5492
|
+
{ error: `Invalid schema type. Valid types: ${SCHEMA_TYPES.join(", ")}` },
|
|
5202
5493
|
{ status: 400 }
|
|
5203
5494
|
);
|
|
5204
5495
|
}
|
|
5205
|
-
const typeOverride = typeOverrideRaw;
|
|
5496
|
+
const typeOverride = typeOverrideRaw || void 0;
|
|
5206
5497
|
if (targetCollections && !targetCollections.includes(collection)) {
|
|
5207
5498
|
return Response.json({ error: "Collection not allowed" }, { status: 403 });
|
|
5208
5499
|
}
|
|
5209
5500
|
let doc;
|
|
5210
5501
|
try {
|
|
5211
|
-
const result = await req.payload.findByID({
|
|
5212
|
-
collection,
|
|
5213
|
-
id,
|
|
5214
|
-
depth: 1,
|
|
5215
|
-
overrideAccess: true
|
|
5216
|
-
});
|
|
5502
|
+
const result = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
|
|
5217
5503
|
doc = result;
|
|
5218
5504
|
} catch {
|
|
5219
5505
|
return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
|
|
5220
5506
|
}
|
|
5221
|
-
const
|
|
5222
|
-
const schemaType = typeOverride || detectSchemaType(collection, doc);
|
|
5223
|
-
let jsonLd;
|
|
5224
|
-
switch (schemaType) {
|
|
5225
|
-
case "Article":
|
|
5226
|
-
jsonLd = buildArticleSchema(doc, siteUrl);
|
|
5227
|
-
break;
|
|
5228
|
-
case "LocalBusiness":
|
|
5229
|
-
jsonLd = buildLocalBusinessSchema(doc, siteUrl);
|
|
5230
|
-
break;
|
|
5231
|
-
case "BreadcrumbList":
|
|
5232
|
-
jsonLd = buildBreadcrumbSchema(doc, siteUrl);
|
|
5233
|
-
break;
|
|
5234
|
-
case "FAQPage":
|
|
5235
|
-
jsonLd = buildFAQSchema(doc);
|
|
5236
|
-
break;
|
|
5237
|
-
case "Product":
|
|
5238
|
-
jsonLd = buildProductSchema(doc, siteUrl);
|
|
5239
|
-
break;
|
|
5240
|
-
case "Organization":
|
|
5241
|
-
jsonLd = buildOrganizationSchema(doc, siteUrl);
|
|
5242
|
-
break;
|
|
5243
|
-
case "Person":
|
|
5244
|
-
jsonLd = buildPersonSchema(doc, siteUrl);
|
|
5245
|
-
break;
|
|
5246
|
-
case "Event":
|
|
5247
|
-
jsonLd = buildEventSchema(doc, siteUrl);
|
|
5248
|
-
break;
|
|
5249
|
-
case "Recipe":
|
|
5250
|
-
jsonLd = buildRecipeSchema(doc, siteUrl);
|
|
5251
|
-
break;
|
|
5252
|
-
case "Video":
|
|
5253
|
-
jsonLd = buildVideoSchema(doc, siteUrl);
|
|
5254
|
-
break;
|
|
5255
|
-
}
|
|
5256
|
-
const cleaned = JSON.parse(JSON.stringify(jsonLd));
|
|
5507
|
+
const { type, jsonLd } = buildJsonLd(doc, { collection, type: typeOverride });
|
|
5257
5508
|
return Response.json({
|
|
5258
|
-
type
|
|
5259
|
-
jsonLd
|
|
5260
|
-
html: `<script type="application/ld+json">${JSON.stringify(
|
|
5509
|
+
type,
|
|
5510
|
+
jsonLd,
|
|
5511
|
+
html: `<script type="application/ld+json">${JSON.stringify(jsonLd, null, 2)}</script>`
|
|
5261
5512
|
});
|
|
5262
5513
|
} catch (error) {
|
|
5263
5514
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -6407,6 +6658,219 @@ function createSeoGscAuthCollection() {
|
|
|
6407
6658
|
};
|
|
6408
6659
|
}
|
|
6409
6660
|
|
|
6661
|
+
// src/collections/SeoRankHistory.ts
|
|
6662
|
+
function createSeoRankHistoryCollection() {
|
|
6663
|
+
return {
|
|
6664
|
+
slug: "seo-rank-history",
|
|
6665
|
+
admin: {
|
|
6666
|
+
custom: { navHidden: true }
|
|
6667
|
+
},
|
|
6668
|
+
access: {
|
|
6669
|
+
read: ({ req }) => !!req.user,
|
|
6670
|
+
create: ({ req }) => req.user?.role === "admin",
|
|
6671
|
+
update: ({ req }) => req.user?.role === "admin",
|
|
6672
|
+
delete: ({ req }) => req.user?.role === "admin"
|
|
6673
|
+
},
|
|
6674
|
+
timestamps: false,
|
|
6675
|
+
fields: [
|
|
6676
|
+
{
|
|
6677
|
+
name: "query",
|
|
6678
|
+
type: "text",
|
|
6679
|
+
required: true,
|
|
6680
|
+
index: true,
|
|
6681
|
+
admin: { description: "Search query (keyword) tracked" }
|
|
6682
|
+
},
|
|
6683
|
+
{
|
|
6684
|
+
name: "page",
|
|
6685
|
+
type: "text",
|
|
6686
|
+
admin: { description: "Landing page URL (when tracked by page)" }
|
|
6687
|
+
},
|
|
6688
|
+
{
|
|
6689
|
+
name: "position",
|
|
6690
|
+
type: "number",
|
|
6691
|
+
required: true,
|
|
6692
|
+
admin: { description: "Average SERP position over the snapshot window (lower is better)" }
|
|
6693
|
+
},
|
|
6694
|
+
{
|
|
6695
|
+
name: "clicks",
|
|
6696
|
+
type: "number",
|
|
6697
|
+
admin: { description: "Clicks over the snapshot window" }
|
|
6698
|
+
},
|
|
6699
|
+
{
|
|
6700
|
+
name: "impressions",
|
|
6701
|
+
type: "number",
|
|
6702
|
+
admin: { description: "Impressions over the snapshot window" }
|
|
6703
|
+
},
|
|
6704
|
+
{
|
|
6705
|
+
name: "ctr",
|
|
6706
|
+
type: "number",
|
|
6707
|
+
admin: { description: "Click-through rate (0-1) over the snapshot window" }
|
|
6708
|
+
},
|
|
6709
|
+
{
|
|
6710
|
+
name: "property",
|
|
6711
|
+
type: "text",
|
|
6712
|
+
admin: { description: "GSC property the snapshot was taken from" }
|
|
6713
|
+
},
|
|
6714
|
+
{
|
|
6715
|
+
// YYYY-MM-DD — used to deduplicate one snapshot per query per day.
|
|
6716
|
+
name: "dateKey",
|
|
6717
|
+
type: "text",
|
|
6718
|
+
required: true,
|
|
6719
|
+
index: true,
|
|
6720
|
+
admin: { description: "Snapshot day (YYYY-MM-DD), one snapshot per query per day" }
|
|
6721
|
+
},
|
|
6722
|
+
{
|
|
6723
|
+
name: "snapshotDate",
|
|
6724
|
+
type: "date",
|
|
6725
|
+
required: true,
|
|
6726
|
+
index: true,
|
|
6727
|
+
admin: { description: "Exact timestamp of the snapshot" }
|
|
6728
|
+
}
|
|
6729
|
+
]
|
|
6730
|
+
};
|
|
6731
|
+
}
|
|
6732
|
+
|
|
6733
|
+
// src/endpoints/rankTracking.ts
|
|
6734
|
+
var RANK_COLLECTION = "seo-rank-history";
|
|
6735
|
+
var round1 = (n) => Math.round(n * 10) / 10;
|
|
6736
|
+
async function runRankSnapshot(payload, basePath, seoConfig, opts) {
|
|
6737
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
6738
|
+
if (!cfg) return { ok: false, reason: "not_configured" };
|
|
6739
|
+
const authDoc = await getOrCreateGscAuthDoc(payload);
|
|
6740
|
+
if (!authDoc.refreshTokenEnc) return { ok: false, reason: "not_connected" };
|
|
6741
|
+
let accessToken;
|
|
6742
|
+
try {
|
|
6743
|
+
accessToken = await getGscAccessToken(payload, cfg, authDoc);
|
|
6744
|
+
} catch (e) {
|
|
6745
|
+
return { ok: false, reason: e instanceof Error ? e.message : "refresh_failed" };
|
|
6746
|
+
}
|
|
6747
|
+
const property = authDoc.propertyUrl || cfg.siteUrl;
|
|
6748
|
+
const windowDays = Math.min(90, Math.max(1, 7));
|
|
6749
|
+
const rowLimit = Math.min(1e3, Math.max(1, 100));
|
|
6750
|
+
const end = new Date(Date.now() - 2 * 864e5);
|
|
6751
|
+
const start = new Date(end.getTime() - (windowDays - 1) * 864e5);
|
|
6752
|
+
const endDate = end.toISOString().slice(0, 10);
|
|
6753
|
+
const startDate = start.toISOString().slice(0, 10);
|
|
6754
|
+
let rows;
|
|
6755
|
+
try {
|
|
6756
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
6757
|
+
startDate,
|
|
6758
|
+
endDate,
|
|
6759
|
+
dimensions: ["query"],
|
|
6760
|
+
rowLimit
|
|
6761
|
+
});
|
|
6762
|
+
} catch (e) {
|
|
6763
|
+
return { ok: false, reason: e instanceof Error ? e.message : "query_failed" };
|
|
6764
|
+
}
|
|
6765
|
+
const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
6766
|
+
const existing = await payload.find({
|
|
6767
|
+
collection: RANK_COLLECTION,
|
|
6768
|
+
where: { dateKey: { equals: todayKey } },
|
|
6769
|
+
limit: 2e3,
|
|
6770
|
+
depth: 0,
|
|
6771
|
+
overrideAccess: true
|
|
6772
|
+
});
|
|
6773
|
+
const already = new Set(existing.docs.map((d) => d.query));
|
|
6774
|
+
let stored = 0;
|
|
6775
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
6776
|
+
for (const r of rows) {
|
|
6777
|
+
const query = r.keys?.[0];
|
|
6778
|
+
if (!query || already.has(query)) continue;
|
|
6779
|
+
try {
|
|
6780
|
+
await payload.create({
|
|
6781
|
+
collection: RANK_COLLECTION,
|
|
6782
|
+
data: {
|
|
6783
|
+
query,
|
|
6784
|
+
position: round1(r.position),
|
|
6785
|
+
clicks: r.clicks,
|
|
6786
|
+
impressions: r.impressions,
|
|
6787
|
+
ctr: r.ctr,
|
|
6788
|
+
property,
|
|
6789
|
+
dateKey: todayKey,
|
|
6790
|
+
snapshotDate: nowIso
|
|
6791
|
+
},
|
|
6792
|
+
overrideAccess: true
|
|
6793
|
+
});
|
|
6794
|
+
stored++;
|
|
6795
|
+
} catch (e) {
|
|
6796
|
+
payload.logger.warn(`[seo] rank-snapshot: skipped "${query}": ${e instanceof Error ? e.message : "error"}`);
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
return { ok: true, stored, scanned: rows.length, startDate, endDate };
|
|
6800
|
+
}
|
|
6801
|
+
function createRankSnapshotHandler(basePath, seoConfig) {
|
|
6802
|
+
return async (req) => {
|
|
6803
|
+
try {
|
|
6804
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6805
|
+
const result = await runRankSnapshot(req.payload, basePath, seoConfig);
|
|
6806
|
+
if (!result.ok) {
|
|
6807
|
+
const status = result.reason === "not_connected" || result.reason === "not_configured" ? 409 : 502;
|
|
6808
|
+
return Response.json(result, { status, headers: { "Cache-Control": "no-store" } });
|
|
6809
|
+
}
|
|
6810
|
+
return Response.json(result, { headers: { "Cache-Control": "no-store" } });
|
|
6811
|
+
} catch (error) {
|
|
6812
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
6813
|
+
req.payload.logger.error(`[seo] rank-snapshot error: ${message}`);
|
|
6814
|
+
return Response.json({ error: message }, { status: 500 });
|
|
6815
|
+
}
|
|
6816
|
+
};
|
|
6817
|
+
}
|
|
6818
|
+
function createRankHistoryHandler() {
|
|
6819
|
+
return async (req) => {
|
|
6820
|
+
try {
|
|
6821
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6822
|
+
const url = new URL(req.url);
|
|
6823
|
+
const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") || "35", 10)));
|
|
6824
|
+
const since = new Date(Date.now() - days * 864e5).toISOString();
|
|
6825
|
+
const all = await req.payload.find({
|
|
6826
|
+
collection: RANK_COLLECTION,
|
|
6827
|
+
where: { snapshotDate: { greater_than: since } },
|
|
6828
|
+
sort: "-snapshotDate",
|
|
6829
|
+
limit: 5e3,
|
|
6830
|
+
depth: 0,
|
|
6831
|
+
overrideAccess: true
|
|
6832
|
+
});
|
|
6833
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
6834
|
+
for (const d of all.docs) {
|
|
6835
|
+
const q = d.query;
|
|
6836
|
+
const arr = byQuery.get(q);
|
|
6837
|
+
if (arr) arr.push(d);
|
|
6838
|
+
else byQuery.set(q, [d]);
|
|
6839
|
+
}
|
|
6840
|
+
const movers = Array.from(byQuery.entries()).map(([query, snaps]) => {
|
|
6841
|
+
const latest = snaps[0];
|
|
6842
|
+
const previous = snaps.find((s) => s.dateKey !== latest.dateKey) || null;
|
|
6843
|
+
const delta = previous ? round1(previous.position - latest.position) : 0;
|
|
6844
|
+
return {
|
|
6845
|
+
query,
|
|
6846
|
+
page: latest.page || null,
|
|
6847
|
+
position: latest.position,
|
|
6848
|
+
previousPosition: previous ? previous.position : null,
|
|
6849
|
+
delta,
|
|
6850
|
+
clicks: latest.clicks ?? 0,
|
|
6851
|
+
impressions: latest.impressions ?? 0,
|
|
6852
|
+
ctr: latest.ctr ?? 0,
|
|
6853
|
+
snapshotDate: latest.snapshotDate,
|
|
6854
|
+
history: snaps.slice(0, 30).map((s) => ({ date: s.dateKey, position: s.position })).reverse()
|
|
6855
|
+
};
|
|
6856
|
+
});
|
|
6857
|
+
movers.sort((a, b) => (b.impressions || 0) - (a.impressions || 0));
|
|
6858
|
+
return Response.json(
|
|
6859
|
+
{
|
|
6860
|
+
count: movers.length,
|
|
6861
|
+
lastSnapshot: all.docs[0]?.snapshotDate || null,
|
|
6862
|
+
movers
|
|
6863
|
+
},
|
|
6864
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
6865
|
+
);
|
|
6866
|
+
} catch (error) {
|
|
6867
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
6868
|
+
req.payload.logger.error(`[seo] rank-history error: ${message}`);
|
|
6869
|
+
return Response.json({ error: message }, { status: 500 });
|
|
6870
|
+
}
|
|
6871
|
+
};
|
|
6872
|
+
}
|
|
6873
|
+
|
|
6410
6874
|
// src/rateLimiter.ts
|
|
6411
6875
|
function createRateLimiter(maxRequests, windowMs) {
|
|
6412
6876
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -6751,6 +7215,296 @@ function stopCacheWarmUp() {
|
|
|
6751
7215
|
}
|
|
6752
7216
|
}
|
|
6753
7217
|
|
|
7218
|
+
// src/rankTracker.ts
|
|
7219
|
+
var SNAPSHOT_INTERVAL = 24 * 60 * 60 * 1e3;
|
|
7220
|
+
var STARTUP_DELAY2 = 30 * 1e3;
|
|
7221
|
+
var intervalId2 = null;
|
|
7222
|
+
var listenersAttached2 = false;
|
|
7223
|
+
async function doSnapshot(payload, basePath, seoConfig) {
|
|
7224
|
+
try {
|
|
7225
|
+
const result = await runRankSnapshot(payload, basePath, seoConfig);
|
|
7226
|
+
if (result.ok) {
|
|
7227
|
+
payload.logger.info(`[seo] rank-tracker: snapshot stored ${result.stored}/${result.scanned} queries`);
|
|
7228
|
+
} else if (result.reason !== "not_connected" && result.reason !== "not_configured") {
|
|
7229
|
+
payload.logger.warn(`[seo] rank-tracker: snapshot skipped (${result.reason})`);
|
|
7230
|
+
}
|
|
7231
|
+
} catch (error) {
|
|
7232
|
+
payload.logger.error(`[seo] rank-tracker error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
7233
|
+
}
|
|
7234
|
+
}
|
|
7235
|
+
function startRankTracker(payload, basePath, seoConfig) {
|
|
7236
|
+
setTimeout(() => {
|
|
7237
|
+
void doSnapshot(payload, basePath, seoConfig);
|
|
7238
|
+
}, STARTUP_DELAY2);
|
|
7239
|
+
intervalId2 = setInterval(() => {
|
|
7240
|
+
void doSnapshot(payload, basePath, seoConfig);
|
|
7241
|
+
}, SNAPSHOT_INTERVAL);
|
|
7242
|
+
if (!listenersAttached2) {
|
|
7243
|
+
const cleanup = () => stopRankTracker();
|
|
7244
|
+
process.on("SIGTERM", cleanup);
|
|
7245
|
+
process.on("SIGINT", cleanup);
|
|
7246
|
+
listenersAttached2 = true;
|
|
7247
|
+
}
|
|
7248
|
+
payload.logger.info("[seo] rank-tracker: scheduled startup + every 24h");
|
|
7249
|
+
}
|
|
7250
|
+
function stopRankTracker() {
|
|
7251
|
+
if (intervalId2) {
|
|
7252
|
+
clearInterval(intervalId2);
|
|
7253
|
+
intervalId2 = null;
|
|
7254
|
+
}
|
|
7255
|
+
}
|
|
7256
|
+
|
|
7257
|
+
// src/endpoints/alerts.ts
|
|
7258
|
+
function isAdmin8(user) {
|
|
7259
|
+
if (!user) return false;
|
|
7260
|
+
if (user.role === "admin") return true;
|
|
7261
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
7262
|
+
return false;
|
|
7263
|
+
}
|
|
7264
|
+
function getAlertConfig() {
|
|
7265
|
+
return {
|
|
7266
|
+
webhookUrl: process.env.SEO_ALERT_WEBHOOK_URL || "",
|
|
7267
|
+
emails: (process.env.SEO_ALERT_EMAIL || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
7268
|
+
scoreDrop: parseInt(process.env.SEO_ALERT_SCORE_DROP || "10", 10) || 10,
|
|
7269
|
+
positionDrop: parseInt(process.env.SEO_ALERT_POSITION_DROP || "5", 10) || 5,
|
|
7270
|
+
windowHours: Math.max(1, parseInt(process.env.SEO_ALERT_WINDOW_HOURS || "24", 10) || 24)
|
|
7271
|
+
};
|
|
7272
|
+
}
|
|
7273
|
+
var round12 = (n) => Math.round(n * 10) / 10;
|
|
7274
|
+
async function buildAlertDigest(payload, cfg) {
|
|
7275
|
+
const now = Date.now();
|
|
7276
|
+
const since = new Date(now - cfg.windowHours * 36e5).toISOString();
|
|
7277
|
+
const scoreRegressions = [];
|
|
7278
|
+
try {
|
|
7279
|
+
const hist = await payload.find({
|
|
7280
|
+
collection: "seo-score-history",
|
|
7281
|
+
where: { snapshotDate: { greater_than: new Date(now - 14 * 864e5).toISOString() } },
|
|
7282
|
+
sort: "-snapshotDate",
|
|
7283
|
+
limit: 5e3,
|
|
7284
|
+
depth: 0,
|
|
7285
|
+
overrideAccess: true
|
|
7286
|
+
});
|
|
7287
|
+
const byDoc = /* @__PURE__ */ new Map();
|
|
7288
|
+
for (const h of hist.docs) {
|
|
7289
|
+
const key = `${h.documentId}::${h.collection}`;
|
|
7290
|
+
const arr = byDoc.get(key);
|
|
7291
|
+
if (arr) arr.push(h);
|
|
7292
|
+
else byDoc.set(key, [h]);
|
|
7293
|
+
}
|
|
7294
|
+
for (const [key, snaps] of byDoc) {
|
|
7295
|
+
const latest = snaps[0];
|
|
7296
|
+
const oldest = snaps[snaps.length - 1];
|
|
7297
|
+
const drop = oldest.score - latest.score;
|
|
7298
|
+
if (drop >= cfg.scoreDrop) {
|
|
7299
|
+
const [documentId, collection] = key.split("::");
|
|
7300
|
+
scoreRegressions.push({
|
|
7301
|
+
documentId,
|
|
7302
|
+
collection,
|
|
7303
|
+
from: oldest.score,
|
|
7304
|
+
to: latest.score,
|
|
7305
|
+
drop
|
|
7306
|
+
});
|
|
7307
|
+
}
|
|
7308
|
+
}
|
|
7309
|
+
scoreRegressions.sort((a, b) => b.drop - a.drop);
|
|
7310
|
+
} catch {
|
|
7311
|
+
}
|
|
7312
|
+
const newNotFound = [];
|
|
7313
|
+
try {
|
|
7314
|
+
const logs = await payload.find({
|
|
7315
|
+
collection: "seo-logs",
|
|
7316
|
+
where: {
|
|
7317
|
+
and: [{ lastSeen: { greater_than: since } }, { ignored: { not_equals: true } }]
|
|
7318
|
+
},
|
|
7319
|
+
sort: "-count",
|
|
7320
|
+
limit: 50,
|
|
7321
|
+
depth: 0,
|
|
7322
|
+
overrideAccess: true
|
|
7323
|
+
});
|
|
7324
|
+
for (const l of logs.docs) {
|
|
7325
|
+
newNotFound.push({
|
|
7326
|
+
url: l.url || "",
|
|
7327
|
+
count: l.count || 1,
|
|
7328
|
+
lastSeen: l.lastSeen || ""
|
|
7329
|
+
});
|
|
7330
|
+
}
|
|
7331
|
+
} catch {
|
|
7332
|
+
}
|
|
7333
|
+
const rankDrops = [];
|
|
7334
|
+
try {
|
|
7335
|
+
const ranks = await payload.find({
|
|
7336
|
+
collection: "seo-rank-history",
|
|
7337
|
+
where: { snapshotDate: { greater_than: new Date(now - 35 * 864e5).toISOString() } },
|
|
7338
|
+
sort: "-snapshotDate",
|
|
7339
|
+
limit: 5e3,
|
|
7340
|
+
depth: 0,
|
|
7341
|
+
overrideAccess: true
|
|
7342
|
+
});
|
|
7343
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
7344
|
+
for (const r of ranks.docs) {
|
|
7345
|
+
const q = r.query;
|
|
7346
|
+
const arr = byQuery.get(q);
|
|
7347
|
+
if (arr) arr.push(r);
|
|
7348
|
+
else byQuery.set(q, [r]);
|
|
7349
|
+
}
|
|
7350
|
+
for (const [query, snaps] of byQuery) {
|
|
7351
|
+
const latest = snaps[0];
|
|
7352
|
+
const previous = snaps.find((s) => s.dateKey !== latest.dateKey);
|
|
7353
|
+
if (!previous) continue;
|
|
7354
|
+
const drop = round12(latest.position - previous.position);
|
|
7355
|
+
if (drop >= cfg.positionDrop) {
|
|
7356
|
+
rankDrops.push({ query, from: previous.position, to: latest.position, drop });
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
7359
|
+
rankDrops.sort((a, b) => b.drop - a.drop);
|
|
7360
|
+
} catch {
|
|
7361
|
+
}
|
|
7362
|
+
const totalIssues = scoreRegressions.length + newNotFound.length + rankDrops.length;
|
|
7363
|
+
return {
|
|
7364
|
+
since,
|
|
7365
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7366
|
+
scoreRegressions,
|
|
7367
|
+
newNotFound,
|
|
7368
|
+
rankDrops,
|
|
7369
|
+
totalIssues
|
|
7370
|
+
};
|
|
7371
|
+
}
|
|
7372
|
+
function digestToHtml(digest, siteUrl) {
|
|
7373
|
+
const section = (title, rows) => rows.length ? `<h3 style="margin:18px 0 6px">${title}</h3><ul style="margin:0;padding-left:18px">${rows.join("")}</ul>` : "";
|
|
7374
|
+
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>`);
|
|
7375
|
+
const nf = digest.newNotFound.slice(0, 20).map((n) => `<li><code>${n.url}</code> \u2014 ${n.count}\xD7</li>`);
|
|
7376
|
+
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>`);
|
|
7377
|
+
return `<div style="font-family:system-ui;max-width:640px">
|
|
7378
|
+
<h2>SEO alert digest${siteUrl ? ` \u2014 ${siteUrl}` : ""}</h2>
|
|
7379
|
+
<p style="color:#6b7280;font-size:13px">${digest.totalIssues} issue(s) since ${new Date(digest.since).toLocaleString()}</p>
|
|
7380
|
+
${section("\u{1F4C9} Score regressions", reg)}
|
|
7381
|
+
${section("\u{1F517} New 404s", nf)}
|
|
7382
|
+
${section("\u{1F53B} Ranking drops", rd)}
|
|
7383
|
+
${digest.totalIssues === 0 ? "<p>No issues to report. \u{1F389}</p>" : ""}
|
|
7384
|
+
</div>`;
|
|
7385
|
+
}
|
|
7386
|
+
async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
|
|
7387
|
+
const channels = { webhook: false, email: false };
|
|
7388
|
+
if (digest.totalIssues === 0) {
|
|
7389
|
+
return { sent: false, reason: "nothing_to_report", channels };
|
|
7390
|
+
}
|
|
7391
|
+
if (cfg.webhookUrl) {
|
|
7392
|
+
try {
|
|
7393
|
+
await fetch(cfg.webhookUrl, {
|
|
7394
|
+
method: "POST",
|
|
7395
|
+
headers: { "content-type": "application/json" },
|
|
7396
|
+
body: JSON.stringify({ type: "seo-alert-digest", siteUrl, digest })
|
|
7397
|
+
});
|
|
7398
|
+
channels.webhook = true;
|
|
7399
|
+
} catch (e) {
|
|
7400
|
+
payload.logger.warn(`[seo] alerts: webhook delivery failed: ${e instanceof Error ? e.message : "error"}`);
|
|
7401
|
+
}
|
|
7402
|
+
}
|
|
7403
|
+
if (cfg.emails.length > 0) {
|
|
7404
|
+
const send = payload.sendEmail;
|
|
7405
|
+
if (typeof send === "function") {
|
|
7406
|
+
try {
|
|
7407
|
+
await send({
|
|
7408
|
+
to: cfg.emails,
|
|
7409
|
+
subject: `SEO alert digest \u2014 ${digest.totalIssues} issue(s)`,
|
|
7410
|
+
html: digestToHtml(digest, siteUrl)
|
|
7411
|
+
});
|
|
7412
|
+
channels.email = true;
|
|
7413
|
+
} catch (e) {
|
|
7414
|
+
payload.logger.warn(`[seo] alerts: email delivery failed: ${e instanceof Error ? e.message : "error"}`);
|
|
7415
|
+
}
|
|
7416
|
+
}
|
|
7417
|
+
}
|
|
7418
|
+
const sent = channels.webhook || channels.email;
|
|
7419
|
+
return { sent, reason: sent ? void 0 : "no_channel_configured", channels };
|
|
7420
|
+
}
|
|
7421
|
+
function createAlertsDigestHandler() {
|
|
7422
|
+
return async (req) => {
|
|
7423
|
+
try {
|
|
7424
|
+
if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7425
|
+
const cfg = getAlertConfig();
|
|
7426
|
+
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7427
|
+
return Response.json(
|
|
7428
|
+
{
|
|
7429
|
+
digest,
|
|
7430
|
+
config: {
|
|
7431
|
+
webhookConfigured: !!cfg.webhookUrl,
|
|
7432
|
+
emailConfigured: cfg.emails.length > 0,
|
|
7433
|
+
scoreDrop: cfg.scoreDrop,
|
|
7434
|
+
positionDrop: cfg.positionDrop,
|
|
7435
|
+
windowHours: cfg.windowHours
|
|
7436
|
+
}
|
|
7437
|
+
},
|
|
7438
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7439
|
+
);
|
|
7440
|
+
} catch (error) {
|
|
7441
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7442
|
+
req.payload.logger.error(`[seo] alerts-digest error: ${message}`);
|
|
7443
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7444
|
+
}
|
|
7445
|
+
};
|
|
7446
|
+
}
|
|
7447
|
+
function createAlertsRunHandler(siteUrl) {
|
|
7448
|
+
return async (req) => {
|
|
7449
|
+
try {
|
|
7450
|
+
if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7451
|
+
const cfg = getAlertConfig();
|
|
7452
|
+
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7453
|
+
const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
|
|
7454
|
+
return Response.json({ digest, delivery }, { headers: { "Cache-Control": "no-store" } });
|
|
7455
|
+
} catch (error) {
|
|
7456
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7457
|
+
req.payload.logger.error(`[seo] alerts-run error: ${message}`);
|
|
7458
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7459
|
+
}
|
|
7460
|
+
};
|
|
7461
|
+
}
|
|
7462
|
+
|
|
7463
|
+
// src/alertsScheduler.ts
|
|
7464
|
+
var STARTUP_DELAY3 = 60 * 1e3;
|
|
7465
|
+
var intervalId3 = null;
|
|
7466
|
+
var listenersAttached3 = false;
|
|
7467
|
+
async function runDigest(payload, siteUrl) {
|
|
7468
|
+
try {
|
|
7469
|
+
const cfg = getAlertConfig();
|
|
7470
|
+
if (!cfg.webhookUrl && cfg.emails.length === 0) {
|
|
7471
|
+
return;
|
|
7472
|
+
}
|
|
7473
|
+
const digest = await buildAlertDigest(payload, cfg);
|
|
7474
|
+
const delivery = await deliverAlertDigest(payload, digest, cfg, siteUrl);
|
|
7475
|
+
if (delivery.sent) {
|
|
7476
|
+
payload.logger.info(
|
|
7477
|
+
`[seo] alerts: digest delivered (${digest.totalIssues} issues; webhook=${delivery.channels.webhook} email=${delivery.channels.email})`
|
|
7478
|
+
);
|
|
7479
|
+
}
|
|
7480
|
+
} catch (error) {
|
|
7481
|
+
payload.logger.error(`[seo] alerts scheduler error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
7482
|
+
}
|
|
7483
|
+
}
|
|
7484
|
+
function startAlertsScheduler(payload, siteUrl) {
|
|
7485
|
+
const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
|
|
7486
|
+
const intervalMs = intervalHours * 60 * 60 * 1e3;
|
|
7487
|
+
setTimeout(() => {
|
|
7488
|
+
void runDigest(payload, siteUrl);
|
|
7489
|
+
}, STARTUP_DELAY3);
|
|
7490
|
+
intervalId3 = setInterval(() => {
|
|
7491
|
+
void runDigest(payload, siteUrl);
|
|
7492
|
+
}, intervalMs);
|
|
7493
|
+
if (!listenersAttached3) {
|
|
7494
|
+
const cleanup = () => stopAlertsScheduler();
|
|
7495
|
+
process.on("SIGTERM", cleanup);
|
|
7496
|
+
process.on("SIGINT", cleanup);
|
|
7497
|
+
listenersAttached3 = true;
|
|
7498
|
+
}
|
|
7499
|
+
payload.logger.info(`[seo] alerts: scheduled startup + every ${intervalHours}h`);
|
|
7500
|
+
}
|
|
7501
|
+
function stopAlertsScheduler() {
|
|
7502
|
+
if (intervalId3) {
|
|
7503
|
+
clearInterval(intervalId3);
|
|
7504
|
+
intervalId3 = null;
|
|
7505
|
+
}
|
|
7506
|
+
}
|
|
7507
|
+
|
|
6754
7508
|
// src/endpoints/generate.ts
|
|
6755
7509
|
var TYPE_TO_CONFIG_KEY = {
|
|
6756
7510
|
title: "generateTitle",
|
|
@@ -8905,6 +9659,7 @@ function buildSeoConfig(pluginConfig) {
|
|
|
8905
9659
|
var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
8906
9660
|
const config = { ...incomingConfig };
|
|
8907
9661
|
const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
|
|
9662
|
+
const uploadsCollection = pluginConfig.uploadsCollection ?? "media";
|
|
8908
9663
|
const targetGlobals = pluginConfig.globals ?? [];
|
|
8909
9664
|
const basePath = pluginConfig.endpointBasePath ?? "/seo-plugin";
|
|
8910
9665
|
const seoConfig = buildSeoConfig(pluginConfig);
|
|
@@ -8927,6 +9682,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8927
9682
|
// opt-in — requires Google Cloud OAuth setup + secrets
|
|
8928
9683
|
warmCache: true,
|
|
8929
9684
|
// disable on low-memory hosts to skip startup pre-loading
|
|
9685
|
+
alerts: false,
|
|
9686
|
+
// opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
|
|
8930
9687
|
...pluginConfig.features
|
|
8931
9688
|
};
|
|
8932
9689
|
function hasExistingSeoMeta(fields) {
|
|
@@ -9067,7 +9824,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9067
9824
|
if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
|
|
9068
9825
|
if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
|
|
9069
9826
|
if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
|
|
9070
|
-
if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
|
|
9827
|
+
if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection(), createSeoRankHistoryCollection());
|
|
9071
9828
|
config.collections = [
|
|
9072
9829
|
...config.collections || [],
|
|
9073
9830
|
...pluginCollections
|
|
@@ -9180,7 +9937,9 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9180
9937
|
pluginEndpoints.push(
|
|
9181
9938
|
{ path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
|
|
9182
9939
|
{ path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
|
|
9183
|
-
{ path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) }
|
|
9940
|
+
{ path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
|
|
9941
|
+
{ path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
|
|
9942
|
+
{ path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) }
|
|
9184
9943
|
);
|
|
9185
9944
|
}
|
|
9186
9945
|
if (features.cannibalization) {
|
|
@@ -9211,7 +9970,15 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9211
9970
|
{ path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
|
|
9212
9971
|
{ path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
|
|
9213
9972
|
{ path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
|
|
9214
|
-
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
|
|
9973
|
+
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
|
|
9974
|
+
{ path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
|
|
9975
|
+
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
|
|
9976
|
+
);
|
|
9977
|
+
}
|
|
9978
|
+
if (features.alerts) {
|
|
9979
|
+
pluginEndpoints.push(
|
|
9980
|
+
{ path: `${basePath}/alerts-digest`, method: "get", handler: createAlertsDigestHandler() },
|
|
9981
|
+
{ path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
|
|
9215
9982
|
);
|
|
9216
9983
|
}
|
|
9217
9984
|
if (features.keywords) {
|
|
@@ -9357,10 +10124,90 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9357
10124
|
if (features.warmCache) {
|
|
9358
10125
|
startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
|
|
9359
10126
|
}
|
|
10127
|
+
if (features.gscApi) {
|
|
10128
|
+
startRankTracker(payload, basePath, seoConfig);
|
|
10129
|
+
}
|
|
10130
|
+
if (features.alerts) {
|
|
10131
|
+
startAlertsScheduler(payload, resolveGscSiteUrl(seoConfig));
|
|
10132
|
+
}
|
|
9360
10133
|
};
|
|
9361
10134
|
return config;
|
|
9362
10135
|
};
|
|
9363
10136
|
|
|
10137
|
+
// src/helpers/buildMetadata.ts
|
|
10138
|
+
function resolveSiteUrl3(explicit) {
|
|
10139
|
+
return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
|
|
10140
|
+
}
|
|
10141
|
+
function parseRobots(doc, meta) {
|
|
10142
|
+
const raw = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
|
|
10143
|
+
let noindex = false;
|
|
10144
|
+
let nofollow = false;
|
|
10145
|
+
if (raw) {
|
|
10146
|
+
const low = raw.toLowerCase();
|
|
10147
|
+
noindex = low.includes("noindex");
|
|
10148
|
+
nofollow = low.includes("nofollow");
|
|
10149
|
+
}
|
|
10150
|
+
if (doc.noindex === true || meta.noindex === true) noindex = true;
|
|
10151
|
+
if (doc.nofollow === true || meta.nofollow === true) nofollow = true;
|
|
10152
|
+
return { index: !noindex, follow: !nofollow };
|
|
10153
|
+
}
|
|
10154
|
+
function buildLanguages(doc) {
|
|
10155
|
+
const raw = doc.localeAlternates || doc.alternates || doc.hreflang;
|
|
10156
|
+
if (!Array.isArray(raw)) return void 0;
|
|
10157
|
+
const out = {};
|
|
10158
|
+
for (const a of raw) {
|
|
10159
|
+
if (!a || typeof a !== "object") continue;
|
|
10160
|
+
const r = a;
|
|
10161
|
+
const lang = String(r.hreflang || r.locale || r.lang || "");
|
|
10162
|
+
const href = String(r.href || r.url || "");
|
|
10163
|
+
if (lang && href) out[lang] = href;
|
|
10164
|
+
}
|
|
10165
|
+
return Object.keys(out).length ? out : void 0;
|
|
10166
|
+
}
|
|
10167
|
+
function absoluteUrl(value, siteUrl) {
|
|
10168
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
10169
|
+
return `${siteUrl}${value.startsWith("/") ? "" : "/"}${value}`;
|
|
10170
|
+
}
|
|
10171
|
+
function buildSeoMetadata(doc, options = {}) {
|
|
10172
|
+
const siteUrl = resolveSiteUrl3(options.siteUrl);
|
|
10173
|
+
const meta = doc.meta || {};
|
|
10174
|
+
const rawTitle = meta.title || doc.title || "";
|
|
10175
|
+
const title = options.titleTemplate && rawTitle ? options.titleTemplate.replace("%s", rawTitle) : rawTitle;
|
|
10176
|
+
const description = meta.description || "";
|
|
10177
|
+
const slug = doc.slug || "";
|
|
10178
|
+
const heroMedia = doc.hero?.media;
|
|
10179
|
+
let image = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
10180
|
+
if (!image && options.defaultImage) image = absoluteUrl(options.defaultImage, siteUrl);
|
|
10181
|
+
const explicitCanonical = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || "";
|
|
10182
|
+
const canonical = explicitCanonical || (siteUrl ? `${siteUrl}${slug ? `/${slug}` : ""}` : void 0);
|
|
10183
|
+
const languages = buildLanguages(doc);
|
|
10184
|
+
const isPost = options.collection === "posts" || doc.isPost === true;
|
|
10185
|
+
const md = {};
|
|
10186
|
+
if (title) md.title = title;
|
|
10187
|
+
if (description) md.description = description;
|
|
10188
|
+
const alternates = {};
|
|
10189
|
+
if (canonical) alternates.canonical = canonical;
|
|
10190
|
+
if (languages) alternates.languages = languages;
|
|
10191
|
+
if (Object.keys(alternates).length) md.alternates = alternates;
|
|
10192
|
+
md.robots = parseRobots(doc, meta);
|
|
10193
|
+
md.openGraph = {
|
|
10194
|
+
...rawTitle ? { title: rawTitle } : {},
|
|
10195
|
+
...description ? { description } : {},
|
|
10196
|
+
...canonical ? { url: canonical } : {},
|
|
10197
|
+
...options.siteName ? { siteName: options.siteName } : {},
|
|
10198
|
+
type: isPost ? "article" : "website",
|
|
10199
|
+
...options.locale ? { locale: options.locale } : {},
|
|
10200
|
+
...image ? { images: [{ url: image }] } : {}
|
|
10201
|
+
};
|
|
10202
|
+
md.twitter = {
|
|
10203
|
+
card: image ? "summary_large_image" : "summary",
|
|
10204
|
+
...rawTitle ? { title: rawTitle } : {},
|
|
10205
|
+
...description ? { description } : {},
|
|
10206
|
+
...image ? { images: [image] } : {}
|
|
10207
|
+
};
|
|
10208
|
+
return md;
|
|
10209
|
+
}
|
|
10210
|
+
|
|
9364
10211
|
// src/i18n.ts
|
|
9365
10212
|
var rulesFr = {
|
|
9366
10213
|
title: {
|
|
@@ -13288,6 +14135,7 @@ exports.MIN_WORDS_THIN = MIN_WORDS_THIN;
|
|
|
13288
14135
|
exports.POWER_WORDS = POWER_WORDS;
|
|
13289
14136
|
exports.POWER_WORDS_FR = POWER_WORDS_FR;
|
|
13290
14137
|
exports.READABILITY_THRESHOLDS = READABILITY_THRESHOLDS;
|
|
14138
|
+
exports.SCHEMA_TYPES = SCHEMA_TYPES;
|
|
13291
14139
|
exports.SCORE_EXCELLENT = SCORE_EXCELLENT;
|
|
13292
14140
|
exports.SCORE_GOOD = SCORE_GOOD;
|
|
13293
14141
|
exports.SCORE_OK = SCORE_OK;
|
|
@@ -13298,7 +14146,9 @@ exports.TITLE_LENGTH_MIN = TITLE_LENGTH_MIN;
|
|
|
13298
14146
|
exports.UTILITY_SLUGS = UTILITY_SLUGS;
|
|
13299
14147
|
exports.WARNING_MULTIPLIER = WARNING_MULTIPLIER;
|
|
13300
14148
|
exports.analyzeSeo = analyzeSeo;
|
|
14149
|
+
exports.buildJsonLd = buildJsonLd;
|
|
13301
14150
|
exports.buildSeoInputFromDoc = buildSeoInputFromDoc;
|
|
14151
|
+
exports.buildSeoMetadata = buildSeoMetadata;
|
|
13302
14152
|
exports.calculateFlesch = calculateFlesch;
|
|
13303
14153
|
exports.calculateFleschFR = calculateFleschFR;
|
|
13304
14154
|
exports.checkHeadingHierarchy = checkHeadingHierarchy;
|
|
@@ -13323,6 +14173,7 @@ exports.createSitemapAuditHandler = createSitemapAuditHandler;
|
|
|
13323
14173
|
exports.createTrackSeoScoreHook = createTrackSeoScoreHook;
|
|
13324
14174
|
exports.detectPageType = detectPageType;
|
|
13325
14175
|
exports.detectPassiveVoice = detectPassiveVoice;
|
|
14176
|
+
exports.detectSchemaType = detectSchemaType;
|
|
13326
14177
|
exports.extractHeadingsFromLexical = extractHeadingsFromLexical;
|
|
13327
14178
|
exports.extractImagesFromLexical = extractImagesFromLexical;
|
|
13328
14179
|
exports.extractLinkUrlsFromLexical = extractLinkUrlsFromLexical;
|
|
@@ -13337,6 +14188,7 @@ exports.getEvergreenSlugs = getEvergreenSlugs;
|
|
|
13337
14188
|
exports.getGenericAnchors = getGenericAnchors;
|
|
13338
14189
|
exports.getLegalSlugs = getLegalSlugs;
|
|
13339
14190
|
exports.getPowerWords = getPowerWords;
|
|
14191
|
+
exports.getSchemaImageUrl = getSchemaImageUrl;
|
|
13340
14192
|
exports.getStopWordCompounds = getStopWordCompounds;
|
|
13341
14193
|
exports.getStopWords = getStopWords;
|
|
13342
14194
|
exports.getStopWordsFR = getStopWordsFR;
|
|
@@ -13347,6 +14199,7 @@ exports.keywordMatchesText = keywordMatchesText;
|
|
|
13347
14199
|
exports.metaFields = metaFields;
|
|
13348
14200
|
exports.normalizeForComparison = normalizeForComparison;
|
|
13349
14201
|
exports.registerDashboardTranslations = registerDashboardTranslations;
|
|
14202
|
+
exports.renderJsonLdScript = renderJsonLdScript;
|
|
13350
14203
|
exports.resolveAnalysisLocale = resolveAnalysisLocale;
|
|
13351
14204
|
exports.seoAnalyzerPlugin = seoAnalyzerPlugin;
|
|
13352
14205
|
exports.seoFields = seoFields;
|