@consilioweb/payload-seo-analyzer 1.11.0 → 1.13.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 +2 -0
- package/dist/client.cjs +527 -188
- package/dist/client.js +527 -188
- package/dist/index.cjs +423 -83
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +423 -83
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1703,6 +1703,7 @@ async function buildAuditCache(payload, collections, globals, seoConfig) {
|
|
|
1703
1703
|
});
|
|
1704
1704
|
for (const doc of result.docs) {
|
|
1705
1705
|
if (ignoredSlugs.includes(doc.slug)) continue;
|
|
1706
|
+
if (doc._status === "draft") continue;
|
|
1706
1707
|
if (allResults.length >= MAX_DOCS2) {
|
|
1707
1708
|
capped = true;
|
|
1708
1709
|
break collectionsLoop;
|
|
@@ -2678,6 +2679,9 @@ function createRedirectsHandler(redirectsCollection) {
|
|
|
2678
2679
|
return Response.json({ error: "Missing id or ids" }, { status: 400 });
|
|
2679
2680
|
}
|
|
2680
2681
|
if (method === "PATCH") {
|
|
2682
|
+
if (!isAdmin3(req.user)) {
|
|
2683
|
+
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
2684
|
+
}
|
|
2681
2685
|
const patchBody = await parseJsonBody(req);
|
|
2682
2686
|
const id = typeof patchBody.id === "string" ? patchBody.id.trim() : void 0;
|
|
2683
2687
|
const from = typeof patchBody.from === "string" ? patchBody.from.trim() : void 0;
|
|
@@ -3019,6 +3023,77 @@ function sanitizeSuggestions(s, currentFocusKeyword) {
|
|
|
3019
3023
|
const rationale = s.rationale.map((r) => r.trim()).filter(Boolean).slice(0, RATIONALE_MAX_ITEMS).map((r) => r.length > RATIONALE_ITEM_MAX ? `${r.slice(0, RATIONALE_ITEM_MAX - 1)}\u2026` : r);
|
|
3020
3024
|
return { metaTitle, metaDescription, focusKeyword, rationale };
|
|
3021
3025
|
}
|
|
3026
|
+
async function optimizeDocMeta(payload, opts) {
|
|
3027
|
+
const { collection, id, mergedConfig } = opts;
|
|
3028
|
+
let doc;
|
|
3029
|
+
try {
|
|
3030
|
+
doc = await payload.findByID({ collection, id, depth: 1, overrideAccess: true });
|
|
3031
|
+
} catch {
|
|
3032
|
+
return { ok: false, error: `Document not found: ${collection}/${id}`, status: 404 };
|
|
3033
|
+
}
|
|
3034
|
+
const seoInput = buildSeoInputFromDoc(doc, collection);
|
|
3035
|
+
const analysis = analyzeSeo(seoInput, mergedConfig);
|
|
3036
|
+
const metaGroups = /* @__PURE__ */ new Set(["title", "meta-description", "content", "url", "headings"]);
|
|
3037
|
+
const issues = analysis.checks.filter((c) => (c.status === "fail" || c.status === "warning") && metaGroups.has(c.group)).map((c) => ({ label: c.label, message: c.message })).slice(0, 12);
|
|
3038
|
+
const pageTitle = doc.title || "";
|
|
3039
|
+
const slug = doc.slug || "";
|
|
3040
|
+
const currentFocusKeyword = doc.focusKeyword || "";
|
|
3041
|
+
const currentMetaTitle = seoInput.metaTitle || "";
|
|
3042
|
+
const currentMetaDescription = seoInput.metaDescription || "";
|
|
3043
|
+
const content = extractDocContent(doc).text;
|
|
3044
|
+
const apiKey = opts.apiKey;
|
|
3045
|
+
const model = opts.model || DEFAULT_MODEL;
|
|
3046
|
+
let suggestions;
|
|
3047
|
+
let method;
|
|
3048
|
+
const heuristicFallback = () => ({
|
|
3049
|
+
metaTitle: generateMetaTitle(pageTitle, currentFocusKeyword, slug),
|
|
3050
|
+
metaDescription: generateMetaDescription(content, currentFocusKeyword, slug),
|
|
3051
|
+
focusKeyword: currentFocusKeyword,
|
|
3052
|
+
rationale: []
|
|
3053
|
+
});
|
|
3054
|
+
if (apiKey) {
|
|
3055
|
+
try {
|
|
3056
|
+
const aiResult = await callClaudeOptimize(apiKey, model, {
|
|
3057
|
+
pageTitle,
|
|
3058
|
+
slug,
|
|
3059
|
+
focusKeyword: currentFocusKeyword,
|
|
3060
|
+
currentMetaTitle,
|
|
3061
|
+
currentMetaDescription,
|
|
3062
|
+
issues,
|
|
3063
|
+
content
|
|
3064
|
+
});
|
|
3065
|
+
if (aiResult) {
|
|
3066
|
+
suggestions = aiResult;
|
|
3067
|
+
method = "ai";
|
|
3068
|
+
} else {
|
|
3069
|
+
suggestions = heuristicFallback();
|
|
3070
|
+
method = "heuristic";
|
|
3071
|
+
}
|
|
3072
|
+
} catch (error) {
|
|
3073
|
+
payload.logger.error(`[seo] ai-optimize Claude API error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
3074
|
+
suggestions = heuristicFallback();
|
|
3075
|
+
method = "heuristic";
|
|
3076
|
+
}
|
|
3077
|
+
} else {
|
|
3078
|
+
suggestions = heuristicFallback();
|
|
3079
|
+
method = "heuristic";
|
|
3080
|
+
}
|
|
3081
|
+
const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
|
|
3082
|
+
return {
|
|
3083
|
+
ok: true,
|
|
3084
|
+
title: pageTitle,
|
|
3085
|
+
method,
|
|
3086
|
+
...method === "ai" ? { model } : {},
|
|
3087
|
+
score: analysis.score,
|
|
3088
|
+
current: {
|
|
3089
|
+
metaTitle: currentMetaTitle,
|
|
3090
|
+
metaDescription: currentMetaDescription,
|
|
3091
|
+
focusKeyword: currentFocusKeyword
|
|
3092
|
+
},
|
|
3093
|
+
suggestions: sanitized,
|
|
3094
|
+
issues
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3022
3097
|
function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
|
|
3023
3098
|
return async (req) => {
|
|
3024
3099
|
try {
|
|
@@ -3034,83 +3109,27 @@ function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
|
|
|
3034
3109
|
if (targetCollections && !targetCollections.includes(collection)) {
|
|
3035
3110
|
return Response.json({ error: "Collection not allowed" }, { status: 403 });
|
|
3036
3111
|
}
|
|
3037
|
-
let doc;
|
|
3038
|
-
try {
|
|
3039
|
-
const result = await req.payload.findByID({
|
|
3040
|
-
collection,
|
|
3041
|
-
id,
|
|
3042
|
-
depth: 1,
|
|
3043
|
-
overrideAccess: true
|
|
3044
|
-
});
|
|
3045
|
-
doc = result;
|
|
3046
|
-
} catch {
|
|
3047
|
-
return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
|
|
3048
|
-
}
|
|
3049
3112
|
const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
|
|
3050
3113
|
reqLocale: req.locale,
|
|
3051
3114
|
localeMapping
|
|
3052
3115
|
});
|
|
3053
|
-
const
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
const currentFocusKeyword = doc.focusKeyword || "";
|
|
3060
|
-
const currentMetaTitle = seoInput.metaTitle || "";
|
|
3061
|
-
const currentMetaDescription = seoInput.metaDescription || "";
|
|
3062
|
-
const content = extractDocContent(doc).text;
|
|
3063
|
-
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3064
|
-
const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL;
|
|
3065
|
-
let suggestions;
|
|
3066
|
-
let method;
|
|
3067
|
-
const heuristicFallback = () => ({
|
|
3068
|
-
metaTitle: generateMetaTitle(pageTitle, currentFocusKeyword, slug),
|
|
3069
|
-
metaDescription: generateMetaDescription(content, currentFocusKeyword, slug),
|
|
3070
|
-
focusKeyword: currentFocusKeyword,
|
|
3071
|
-
rationale: []
|
|
3116
|
+
const r = await optimizeDocMeta(req.payload, {
|
|
3117
|
+
collection,
|
|
3118
|
+
id,
|
|
3119
|
+
mergedConfig,
|
|
3120
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
3121
|
+
model: process.env.SEO_AI_MODEL
|
|
3072
3122
|
});
|
|
3073
|
-
if (
|
|
3074
|
-
|
|
3075
|
-
const aiResult = await callClaudeOptimize(apiKey, model, {
|
|
3076
|
-
pageTitle,
|
|
3077
|
-
slug,
|
|
3078
|
-
focusKeyword: currentFocusKeyword,
|
|
3079
|
-
currentMetaTitle,
|
|
3080
|
-
currentMetaDescription,
|
|
3081
|
-
issues,
|
|
3082
|
-
content
|
|
3083
|
-
});
|
|
3084
|
-
if (aiResult) {
|
|
3085
|
-
suggestions = aiResult;
|
|
3086
|
-
method = "ai";
|
|
3087
|
-
} else {
|
|
3088
|
-
suggestions = heuristicFallback();
|
|
3089
|
-
method = "heuristic";
|
|
3090
|
-
}
|
|
3091
|
-
} catch (error) {
|
|
3092
|
-
req.payload.logger.error(
|
|
3093
|
-
`[seo] ai-optimize Claude API error: ${error instanceof Error ? error.message : "unknown"}`
|
|
3094
|
-
);
|
|
3095
|
-
suggestions = heuristicFallback();
|
|
3096
|
-
method = "heuristic";
|
|
3097
|
-
}
|
|
3098
|
-
} else {
|
|
3099
|
-
suggestions = heuristicFallback();
|
|
3100
|
-
method = "heuristic";
|
|
3123
|
+
if (!r.ok) {
|
|
3124
|
+
return Response.json({ error: r.error }, { status: r.status || 500 });
|
|
3101
3125
|
}
|
|
3102
|
-
const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
|
|
3103
3126
|
return Response.json({
|
|
3104
|
-
method,
|
|
3105
|
-
...method === "ai" ? { model } : {},
|
|
3106
|
-
score:
|
|
3107
|
-
current:
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
focusKeyword: currentFocusKeyword
|
|
3111
|
-
},
|
|
3112
|
-
suggestions: sanitized,
|
|
3113
|
-
issues
|
|
3127
|
+
method: r.method,
|
|
3128
|
+
...r.method === "ai" ? { model: r.model } : {},
|
|
3129
|
+
score: r.score,
|
|
3130
|
+
current: r.current,
|
|
3131
|
+
suggestions: r.suggestions,
|
|
3132
|
+
issues: r.issues
|
|
3114
3133
|
});
|
|
3115
3134
|
} catch (error) {
|
|
3116
3135
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -3565,6 +3584,177 @@ function createAiContentBriefHandler(targetCollections, seoConfig) {
|
|
|
3565
3584
|
};
|
|
3566
3585
|
}
|
|
3567
3586
|
|
|
3587
|
+
// src/endpoints/aiOptimizeBulk.ts
|
|
3588
|
+
var TITLE_MAX = 70;
|
|
3589
|
+
var DESC_MAX = 160;
|
|
3590
|
+
var KEYWORD_MAX2 = 60;
|
|
3591
|
+
function isAdmin5(user) {
|
|
3592
|
+
if (!user) return false;
|
|
3593
|
+
if (user.role === "admin") return true;
|
|
3594
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3595
|
+
return false;
|
|
3596
|
+
}
|
|
3597
|
+
function createAiOptimizeBulkHandler(targetCollections, seoConfig, localeMapping) {
|
|
3598
|
+
return async (req) => {
|
|
3599
|
+
try {
|
|
3600
|
+
if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
3601
|
+
const body = await parseJsonBody(req);
|
|
3602
|
+
const rawIds = Array.isArray(body.ids) ? body.ids.map(String) : [];
|
|
3603
|
+
const defaultCollection = typeof body.collection === "string" ? body.collection : void 0;
|
|
3604
|
+
const apply = body.apply === true;
|
|
3605
|
+
const MAX = 100;
|
|
3606
|
+
const limit = Math.min(MAX, Math.max(1, parseInt(String(body.limit ?? 50), 10) || 50));
|
|
3607
|
+
const corrections = Array.isArray(body.corrections) ? body.corrections : null;
|
|
3608
|
+
if (apply && corrections && corrections.length > 0) {
|
|
3609
|
+
let applied2 = 0;
|
|
3610
|
+
let i2 = 0;
|
|
3611
|
+
const rows = [];
|
|
3612
|
+
for (const c of corrections.slice(0, MAX)) {
|
|
3613
|
+
i2++;
|
|
3614
|
+
const collection = typeof c.collection === "string" ? c.collection : defaultCollection;
|
|
3615
|
+
const id = c.id != null ? String(c.id) : void 0;
|
|
3616
|
+
if (!collection || !id || collection.startsWith("global:")) continue;
|
|
3617
|
+
if (targetCollections && !targetCollections.includes(collection)) continue;
|
|
3618
|
+
const metaTitle = typeof c.metaTitle === "string" ? truncateWords(c.metaTitle.trim(), TITLE_MAX) : "";
|
|
3619
|
+
const metaDescription = typeof c.metaDescription === "string" ? truncateWords(c.metaDescription.trim(), DESC_MAX) : "";
|
|
3620
|
+
const focusKeyword = typeof c.focusKeyword === "string" ? c.focusKeyword.trim().slice(0, KEYWORD_MAX2) : "";
|
|
3621
|
+
const patch = {};
|
|
3622
|
+
if (metaTitle || metaDescription) patch.meta = { title: metaTitle, description: metaDescription };
|
|
3623
|
+
if (focusKeyword) patch.focusKeyword = focusKeyword;
|
|
3624
|
+
try {
|
|
3625
|
+
if (Object.keys(patch).length > 0) {
|
|
3626
|
+
await req.payload.update({ collection, id, data: patch, overrideAccess: true });
|
|
3627
|
+
applied2++;
|
|
3628
|
+
rows.push({ collection, id, applied: true });
|
|
3629
|
+
}
|
|
3630
|
+
} catch (e) {
|
|
3631
|
+
rows.push({ collection, id, applied: false, error: e instanceof Error ? e.message : "error" });
|
|
3632
|
+
}
|
|
3633
|
+
if (i2 % 5 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
3634
|
+
}
|
|
3635
|
+
return Response.json(
|
|
3636
|
+
{ processed: rows.length, applied: applied2, mode: "corrections", results: rows },
|
|
3637
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
3640
|
+
if (rawIds.length === 0) {
|
|
3641
|
+
return Response.json({ error: 'Provide a non-empty "ids" array' }, { status: 400 });
|
|
3642
|
+
}
|
|
3643
|
+
const targets = [];
|
|
3644
|
+
for (const raw of rawIds) {
|
|
3645
|
+
let collection;
|
|
3646
|
+
let id;
|
|
3647
|
+
if (raw.includes("::")) {
|
|
3648
|
+
const [c, i2] = raw.split("::");
|
|
3649
|
+
collection = c;
|
|
3650
|
+
id = i2;
|
|
3651
|
+
} else {
|
|
3652
|
+
collection = defaultCollection;
|
|
3653
|
+
id = raw;
|
|
3654
|
+
}
|
|
3655
|
+
if (!collection || !id) continue;
|
|
3656
|
+
if (collection.startsWith("global:")) continue;
|
|
3657
|
+
if (targetCollections && !targetCollections.includes(collection)) continue;
|
|
3658
|
+
targets.push({ collection, id });
|
|
3659
|
+
}
|
|
3660
|
+
const capped = targets.length > limit;
|
|
3661
|
+
const slice = targets.slice(0, limit);
|
|
3662
|
+
const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
|
|
3663
|
+
reqLocale: req.locale,
|
|
3664
|
+
localeMapping
|
|
3665
|
+
});
|
|
3666
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3667
|
+
const model = process.env.SEO_AI_MODEL;
|
|
3668
|
+
const results = [];
|
|
3669
|
+
let applied = 0;
|
|
3670
|
+
let i = 0;
|
|
3671
|
+
for (const target of slice) {
|
|
3672
|
+
i++;
|
|
3673
|
+
try {
|
|
3674
|
+
const r = await optimizeDocMeta(req.payload, {
|
|
3675
|
+
collection: target.collection,
|
|
3676
|
+
id: target.id,
|
|
3677
|
+
mergedConfig,
|
|
3678
|
+
apiKey,
|
|
3679
|
+
model
|
|
3680
|
+
});
|
|
3681
|
+
if (!r.ok || !r.current || !r.suggestions) {
|
|
3682
|
+
results.push({
|
|
3683
|
+
collection: target.collection,
|
|
3684
|
+
id: target.id,
|
|
3685
|
+
title: "",
|
|
3686
|
+
before: { metaTitle: "", metaDescription: "", focusKeyword: "" },
|
|
3687
|
+
after: { metaTitle: "", metaDescription: "", focusKeyword: "" },
|
|
3688
|
+
changed: false,
|
|
3689
|
+
applied: false,
|
|
3690
|
+
error: r.error || "optimize_failed"
|
|
3691
|
+
});
|
|
3692
|
+
continue;
|
|
3693
|
+
}
|
|
3694
|
+
const before = r.current;
|
|
3695
|
+
const after = {
|
|
3696
|
+
metaTitle: r.suggestions.metaTitle,
|
|
3697
|
+
metaDescription: r.suggestions.metaDescription,
|
|
3698
|
+
focusKeyword: r.suggestions.focusKeyword
|
|
3699
|
+
};
|
|
3700
|
+
const changed = after.metaTitle !== before.metaTitle || after.metaDescription !== before.metaDescription || after.focusKeyword !== before.focusKeyword;
|
|
3701
|
+
let didApply = false;
|
|
3702
|
+
if (apply && changed) {
|
|
3703
|
+
const patch = {};
|
|
3704
|
+
if (after.metaTitle || after.metaDescription) {
|
|
3705
|
+
patch.meta = { title: after.metaTitle, description: after.metaDescription };
|
|
3706
|
+
}
|
|
3707
|
+
if (after.focusKeyword && after.focusKeyword !== before.focusKeyword) {
|
|
3708
|
+
patch.focusKeyword = after.focusKeyword;
|
|
3709
|
+
}
|
|
3710
|
+
if (Object.keys(patch).length > 0) {
|
|
3711
|
+
await req.payload.update({ collection: target.collection, id: target.id, data: patch, overrideAccess: true });
|
|
3712
|
+
didApply = true;
|
|
3713
|
+
applied++;
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
results.push({
|
|
3717
|
+
collection: target.collection,
|
|
3718
|
+
id: target.id,
|
|
3719
|
+
title: r.title || "",
|
|
3720
|
+
before,
|
|
3721
|
+
after,
|
|
3722
|
+
changed,
|
|
3723
|
+
applied: didApply,
|
|
3724
|
+
method: r.method
|
|
3725
|
+
});
|
|
3726
|
+
} catch (e) {
|
|
3727
|
+
results.push({
|
|
3728
|
+
collection: target.collection,
|
|
3729
|
+
id: target.id,
|
|
3730
|
+
title: "",
|
|
3731
|
+
before: { metaTitle: "", metaDescription: "", focusKeyword: "" },
|
|
3732
|
+
after: { metaTitle: "", metaDescription: "", focusKeyword: "" },
|
|
3733
|
+
changed: false,
|
|
3734
|
+
applied: false,
|
|
3735
|
+
error: e instanceof Error ? e.message : "error"
|
|
3736
|
+
});
|
|
3737
|
+
}
|
|
3738
|
+
if (i % 3 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
3739
|
+
}
|
|
3740
|
+
return Response.json(
|
|
3741
|
+
{
|
|
3742
|
+
processed: results.length,
|
|
3743
|
+
changedCount: results.filter((r) => r.changed).length,
|
|
3744
|
+
applied,
|
|
3745
|
+
capped,
|
|
3746
|
+
results
|
|
3747
|
+
},
|
|
3748
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3749
|
+
);
|
|
3750
|
+
} catch (error) {
|
|
3751
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3752
|
+
req.payload.logger.error(`[seo] ai-optimize-bulk error: ${message}`);
|
|
3753
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3568
3758
|
// src/endpoints/cannibalization.ts
|
|
3569
3759
|
function canonicalIntent(keyword) {
|
|
3570
3760
|
return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
|
|
@@ -4149,7 +4339,7 @@ function getDateThreshold(period) {
|
|
|
4149
4339
|
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
4150
4340
|
}
|
|
4151
4341
|
}
|
|
4152
|
-
function
|
|
4342
|
+
function isAdmin6(user) {
|
|
4153
4343
|
if (!user) return false;
|
|
4154
4344
|
if (user.role === "admin") return true;
|
|
4155
4345
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -4254,7 +4444,7 @@ function createPerformanceHandler() {
|
|
|
4254
4444
|
});
|
|
4255
4445
|
}
|
|
4256
4446
|
if (method === "POST") {
|
|
4257
|
-
if (!
|
|
4447
|
+
if (!isAdmin6(req.user)) {
|
|
4258
4448
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
4259
4449
|
}
|
|
4260
4450
|
const body = await parseJsonBody(req);
|
|
@@ -5968,7 +6158,7 @@ function createAiRewriteHandler(targetCollections) {
|
|
|
5968
6158
|
}
|
|
5969
6159
|
|
|
5970
6160
|
// src/endpoints/robots.ts
|
|
5971
|
-
function
|
|
6161
|
+
function isAdmin7(user) {
|
|
5972
6162
|
if (!user) return false;
|
|
5973
6163
|
if (user.role === "admin") return true;
|
|
5974
6164
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -6017,7 +6207,7 @@ function createRobotsUpdateHandler() {
|
|
|
6017
6207
|
if (!req.user) {
|
|
6018
6208
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
6019
6209
|
}
|
|
6020
|
-
if (!
|
|
6210
|
+
if (!isAdmin7(req.user)) {
|
|
6021
6211
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
6022
6212
|
}
|
|
6023
6213
|
const body = await parseJsonBody(req);
|
|
@@ -7200,6 +7390,137 @@ function createRankHistoryHandler() {
|
|
|
7200
7390
|
};
|
|
7201
7391
|
}
|
|
7202
7392
|
|
|
7393
|
+
// src/endpoints/ctrOpportunities.ts
|
|
7394
|
+
function expectedCtrForPosition(pos) {
|
|
7395
|
+
const table = {
|
|
7396
|
+
1: 0.28,
|
|
7397
|
+
2: 0.15,
|
|
7398
|
+
3: 0.1,
|
|
7399
|
+
4: 0.07,
|
|
7400
|
+
5: 0.055,
|
|
7401
|
+
6: 0.045,
|
|
7402
|
+
7: 0.035,
|
|
7403
|
+
8: 0.03,
|
|
7404
|
+
9: 0.025,
|
|
7405
|
+
10: 0.022
|
|
7406
|
+
};
|
|
7407
|
+
if (pos <= 1) return table[1];
|
|
7408
|
+
if (pos <= 10) {
|
|
7409
|
+
const lo = Math.floor(pos);
|
|
7410
|
+
const hi = Math.ceil(pos);
|
|
7411
|
+
const a = table[lo] ?? 0.02;
|
|
7412
|
+
const b = table[hi] ?? 0.02;
|
|
7413
|
+
return a + (b - a) * (pos - lo);
|
|
7414
|
+
}
|
|
7415
|
+
if (pos <= 20) return 0.012;
|
|
7416
|
+
return 5e-3;
|
|
7417
|
+
}
|
|
7418
|
+
function rankCtrOpportunities(rows, opts = {}) {
|
|
7419
|
+
const minImpressions = opts.minImpressions ?? 50;
|
|
7420
|
+
const maxPosition = opts.maxPosition ?? 20;
|
|
7421
|
+
const out = [];
|
|
7422
|
+
for (const r of rows) {
|
|
7423
|
+
const url = r.keys?.[0];
|
|
7424
|
+
if (!url) continue;
|
|
7425
|
+
if (r.impressions < minImpressions) continue;
|
|
7426
|
+
if (r.position > maxPosition) continue;
|
|
7427
|
+
const expectedCtr = expectedCtrForPosition(r.position);
|
|
7428
|
+
const gap = expectedCtr - r.ctr;
|
|
7429
|
+
if (gap <= 0) continue;
|
|
7430
|
+
const potentialClicks = Math.round(r.impressions * gap);
|
|
7431
|
+
if (potentialClicks < 1) continue;
|
|
7432
|
+
out.push({
|
|
7433
|
+
url,
|
|
7434
|
+
impressions: r.impressions,
|
|
7435
|
+
clicks: r.clicks,
|
|
7436
|
+
ctr: r.ctr,
|
|
7437
|
+
position: Math.round(r.position * 10) / 10,
|
|
7438
|
+
expectedCtr: Math.round(expectedCtr * 1e3) / 1e3,
|
|
7439
|
+
potentialClicks
|
|
7440
|
+
});
|
|
7441
|
+
}
|
|
7442
|
+
out.sort((a, b) => b.potentialClicks - a.potentialClicks);
|
|
7443
|
+
return out;
|
|
7444
|
+
}
|
|
7445
|
+
async function resolveDoc(payload, url, targetCollections) {
|
|
7446
|
+
let path;
|
|
7447
|
+
try {
|
|
7448
|
+
path = new URL(url).pathname;
|
|
7449
|
+
} catch {
|
|
7450
|
+
return null;
|
|
7451
|
+
}
|
|
7452
|
+
const segments = path.split("/").filter(Boolean);
|
|
7453
|
+
const slug = segments.length === 0 ? "home" : segments[segments.length - 1];
|
|
7454
|
+
for (const collection of targetCollections) {
|
|
7455
|
+
try {
|
|
7456
|
+
const res = await payload.find({
|
|
7457
|
+
collection,
|
|
7458
|
+
where: { slug: { equals: slug } },
|
|
7459
|
+
limit: 1,
|
|
7460
|
+
depth: 0,
|
|
7461
|
+
overrideAccess: true
|
|
7462
|
+
});
|
|
7463
|
+
if (res.docs.length > 0) {
|
|
7464
|
+
return { collection, id: String(res.docs[0].id) };
|
|
7465
|
+
}
|
|
7466
|
+
} catch {
|
|
7467
|
+
}
|
|
7468
|
+
}
|
|
7469
|
+
return null;
|
|
7470
|
+
}
|
|
7471
|
+
function createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) {
|
|
7472
|
+
return async (req) => {
|
|
7473
|
+
try {
|
|
7474
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7475
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
7476
|
+
if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
|
|
7477
|
+
const authDoc = await getOrCreateGscAuthDoc(req.payload);
|
|
7478
|
+
if (!authDoc.refreshTokenEnc) {
|
|
7479
|
+
return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
|
|
7480
|
+
}
|
|
7481
|
+
let accessToken;
|
|
7482
|
+
try {
|
|
7483
|
+
accessToken = await getGscAccessToken(req.payload, cfg, authDoc);
|
|
7484
|
+
} catch (e) {
|
|
7485
|
+
const code = e instanceof Error ? e.message : "refresh_failed";
|
|
7486
|
+
const status = code === "decrypt_failed" ? 409 : 502;
|
|
7487
|
+
return Response.json({ error: "Could not refresh GSC access token." }, { status });
|
|
7488
|
+
}
|
|
7489
|
+
const url = new URL(req.url);
|
|
7490
|
+
const minImpressions = Math.max(1, parseInt(url.searchParams.get("minImpressions") || "50", 10) || 50);
|
|
7491
|
+
const property = authDoc.propertyUrl || cfg.siteUrl;
|
|
7492
|
+
const end = new Date(Date.now() - 2 * 864e5);
|
|
7493
|
+
const start = new Date(end.getTime() - 27 * 864e5);
|
|
7494
|
+
const endDate = end.toISOString().slice(0, 10);
|
|
7495
|
+
const startDate = start.toISOString().slice(0, 10);
|
|
7496
|
+
let rows;
|
|
7497
|
+
try {
|
|
7498
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
7499
|
+
startDate,
|
|
7500
|
+
endDate,
|
|
7501
|
+
dimensions: ["page"],
|
|
7502
|
+
rowLimit: 250
|
|
7503
|
+
});
|
|
7504
|
+
} catch (e) {
|
|
7505
|
+
return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
|
|
7506
|
+
}
|
|
7507
|
+
const opportunities = rankCtrOpportunities(rows, { minImpressions });
|
|
7508
|
+
const top = opportunities.slice(0, 50);
|
|
7509
|
+
const resolved = await Promise.all(
|
|
7510
|
+
top.map(async (o) => ({ ...o, doc: await resolveDoc(req.payload, o.url, targetCollections) }))
|
|
7511
|
+
);
|
|
7512
|
+
return Response.json(
|
|
7513
|
+
{ property, startDate, endDate, count: opportunities.length, opportunities: resolved },
|
|
7514
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7515
|
+
);
|
|
7516
|
+
} catch (error) {
|
|
7517
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7518
|
+
req.payload.logger.error(`[seo] ctr-opportunities error: ${message}`);
|
|
7519
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7520
|
+
}
|
|
7521
|
+
};
|
|
7522
|
+
}
|
|
7523
|
+
|
|
7203
7524
|
// src/rateLimiter.ts
|
|
7204
7525
|
function createRateLimiter(maxRequests, windowMs) {
|
|
7205
7526
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -7247,7 +7568,7 @@ function getClientIp(req) {
|
|
|
7247
7568
|
|
|
7248
7569
|
// src/endpoints/seoLogs.ts
|
|
7249
7570
|
var VALID_LOG_TYPES = ["404", "redirect", "error"];
|
|
7250
|
-
function
|
|
7571
|
+
function isAdmin8(user) {
|
|
7251
7572
|
if (!user) return false;
|
|
7252
7573
|
if (user.role === "admin") return true;
|
|
7253
7574
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -7344,7 +7665,7 @@ function createSeoLogsHandler(seoLogsSecret) {
|
|
|
7344
7665
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
7345
7666
|
}
|
|
7346
7667
|
if (method === "DELETE") {
|
|
7347
|
-
if (!
|
|
7668
|
+
if (!isAdmin8(req.user)) {
|
|
7348
7669
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
7349
7670
|
}
|
|
7350
7671
|
try {
|
|
@@ -7521,6 +7842,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
|
|
|
7521
7842
|
}
|
|
7522
7843
|
}
|
|
7523
7844
|
function startCacheWarmUp(payload, _basePath, globals = [], collections = ["pages", "posts"]) {
|
|
7845
|
+
stopCacheWarmUp();
|
|
7524
7846
|
setTimeout(() => {
|
|
7525
7847
|
void doWarmUp(payload, collections, globals);
|
|
7526
7848
|
}, STARTUP_DELAY);
|
|
@@ -7562,6 +7884,7 @@ async function doSnapshot(payload, basePath, seoConfig) {
|
|
|
7562
7884
|
}
|
|
7563
7885
|
}
|
|
7564
7886
|
function startRankTracker(payload, basePath, seoConfig) {
|
|
7887
|
+
stopRankTracker();
|
|
7565
7888
|
setTimeout(() => {
|
|
7566
7889
|
void doSnapshot(payload, basePath, seoConfig);
|
|
7567
7890
|
}, STARTUP_DELAY2);
|
|
@@ -7584,7 +7907,7 @@ function stopRankTracker() {
|
|
|
7584
7907
|
}
|
|
7585
7908
|
|
|
7586
7909
|
// src/endpoints/alerts.ts
|
|
7587
|
-
function
|
|
7910
|
+
function isAdmin9(user) {
|
|
7588
7911
|
if (!user) return false;
|
|
7589
7912
|
if (user.role === "admin") return true;
|
|
7590
7913
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -7750,7 +8073,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
|
|
|
7750
8073
|
function createAlertsDigestHandler() {
|
|
7751
8074
|
return async (req) => {
|
|
7752
8075
|
try {
|
|
7753
|
-
if (!
|
|
8076
|
+
if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7754
8077
|
const cfg = getAlertConfig();
|
|
7755
8078
|
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7756
8079
|
return Response.json(
|
|
@@ -7776,7 +8099,7 @@ function createAlertsDigestHandler() {
|
|
|
7776
8099
|
function createAlertsRunHandler(siteUrl) {
|
|
7777
8100
|
return async (req) => {
|
|
7778
8101
|
try {
|
|
7779
|
-
if (!
|
|
8102
|
+
if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7780
8103
|
const cfg = getAlertConfig();
|
|
7781
8104
|
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7782
8105
|
const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
|
|
@@ -7811,6 +8134,7 @@ async function runDigest(payload, siteUrl) {
|
|
|
7811
8134
|
}
|
|
7812
8135
|
}
|
|
7813
8136
|
function startAlertsScheduler(payload, siteUrl) {
|
|
8137
|
+
stopAlertsScheduler();
|
|
7814
8138
|
const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
|
|
7815
8139
|
const intervalMs = intervalHours * 60 * 60 * 1e3;
|
|
7816
8140
|
setTimeout(() => {
|
|
@@ -8826,8 +9150,15 @@ var fr = {
|
|
|
8826
9150
|
markCornerstone: "Marquer pilier",
|
|
8827
9151
|
unmarkCornerstone: "D\xE9marquer pilier",
|
|
8828
9152
|
bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
|
|
8829
|
-
bulkOptimizing: "
|
|
9153
|
+
bulkOptimizing: "Analyse\u2026",
|
|
8830
9154
|
bulkConfirm: "Confirmer ?",
|
|
9155
|
+
bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
|
|
9156
|
+
bulkApply: "Appliquer",
|
|
9157
|
+
bulkApplying: "Application\u2026",
|
|
9158
|
+
bulkCancel: "Annuler",
|
|
9159
|
+
bulkExport: "Exporter CSV",
|
|
9160
|
+
bulkNoChanges: "Aucune correction n\xE9cessaire sur la s\xE9lection.",
|
|
9161
|
+
bulkCappedNote: "limite atteinte (affine la s\xE9lection)",
|
|
8831
9162
|
searchPlaceholder: "Rechercher (titre, slug, keyword)...",
|
|
8832
9163
|
allCollections: "Toutes les collections",
|
|
8833
9164
|
allScores: "Tous les scores",
|
|
@@ -9421,8 +9752,15 @@ var en = {
|
|
|
9421
9752
|
markCornerstone: "Mark as cornerstone",
|
|
9422
9753
|
unmarkCornerstone: "Unmark cornerstone",
|
|
9423
9754
|
bulkOptimizeMeta: "Optimize meta (AI)",
|
|
9424
|
-
bulkOptimizing: "
|
|
9755
|
+
bulkOptimizing: "Analyzing\u2026",
|
|
9425
9756
|
bulkConfirm: "Confirm?",
|
|
9757
|
+
bulkPreviewTitle: "Proposed meta corrections",
|
|
9758
|
+
bulkApply: "Apply",
|
|
9759
|
+
bulkApplying: "Applying\u2026",
|
|
9760
|
+
bulkCancel: "Cancel",
|
|
9761
|
+
bulkExport: "Export CSV",
|
|
9762
|
+
bulkNoChanges: "No corrections needed on the selection.",
|
|
9763
|
+
bulkCappedNote: "limit reached (narrow the selection)",
|
|
9426
9764
|
searchPlaceholder: "Search (title, slug, keyword)...",
|
|
9427
9765
|
allCollections: "All collections",
|
|
9428
9766
|
allScores: "All scores",
|
|
@@ -10275,7 +10613,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10275
10613
|
{ path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
|
|
10276
10614
|
{ path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
|
|
10277
10615
|
{ 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)) }
|
|
10616
|
+
{ path: `${basePath}/ai-content-brief`, method: "post", handler: withRateLimit(createAiContentBriefHandler(targetCollections, seoConfig)) },
|
|
10617
|
+
{ path: `${basePath}/ai-optimize-bulk`, method: "post", handler: withRateLimit(createAiOptimizeBulkHandler(targetCollections, seoConfig)) }
|
|
10279
10618
|
);
|
|
10280
10619
|
}
|
|
10281
10620
|
if (features.cannibalization) {
|
|
@@ -10308,7 +10647,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10308
10647
|
{ path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
|
|
10309
10648
|
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
|
|
10310
10649
|
{ path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
|
|
10311
|
-
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
|
|
10650
|
+
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() },
|
|
10651
|
+
{ path: `${basePath}/ctr-opportunities`, method: "get", handler: createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) }
|
|
10312
10652
|
);
|
|
10313
10653
|
}
|
|
10314
10654
|
if (features.alerts) {
|
package/package.json
CHANGED