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