@consilioweb/payload-seo-analyzer 1.8.0 → 1.9.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 +33 -1
- package/dist/client.cjs +232 -6
- package/dist/client.js +232 -6
- package/dist/index.cjs +393 -120
- package/dist/index.d.cts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +393 -120
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1632,7 +1632,10 @@ function analyzeDoc(doc, collection, seoConfig) {
|
|
|
1632
1632
|
if (seoInput.isGlobal) {
|
|
1633
1633
|
seoInput.slug = "";
|
|
1634
1634
|
}
|
|
1635
|
-
const analysis = analyzeSeo(seoInput,
|
|
1635
|
+
const analysis = analyzeSeo(seoInput, {
|
|
1636
|
+
...seoConfig,
|
|
1637
|
+
disabledRules: [...seoConfig?.disabledRules ?? [], "geo", "eeat", "hreflang"]
|
|
1638
|
+
});
|
|
1636
1639
|
const extracted = extractDocContent(doc);
|
|
1637
1640
|
const fullText = extracted.text;
|
|
1638
1641
|
const allLinks = extracted.links;
|
|
@@ -1679,6 +1682,127 @@ function analyzeDoc(doc, collection, seoConfig) {
|
|
|
1679
1682
|
daysSinceUpdate: doc.updatedAt ? Math.floor((Date.now() - new Date(doc.updatedAt).getTime()) / (1e3 * 60 * 60 * 24)) : null
|
|
1680
1683
|
};
|
|
1681
1684
|
}
|
|
1685
|
+
var CACHE_KEY = "audit";
|
|
1686
|
+
var auditBuildInFlight = false;
|
|
1687
|
+
async function buildAuditCache(payload, collections, globals, seoConfig) {
|
|
1688
|
+
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig);
|
|
1689
|
+
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "15", 10) || 15));
|
|
1690
|
+
const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
|
|
1691
|
+
const allResults = [];
|
|
1692
|
+
let capped = false;
|
|
1693
|
+
collectionsLoop:
|
|
1694
|
+
for (const collectionSlug of collections) {
|
|
1695
|
+
try {
|
|
1696
|
+
let page = 1;
|
|
1697
|
+
let hasMore = true;
|
|
1698
|
+
while (hasMore) {
|
|
1699
|
+
const result = await payload.find({
|
|
1700
|
+
collection: collectionSlug,
|
|
1701
|
+
limit: BATCH_SIZE,
|
|
1702
|
+
page,
|
|
1703
|
+
depth: 1,
|
|
1704
|
+
overrideAccess: true
|
|
1705
|
+
});
|
|
1706
|
+
for (const doc of result.docs) {
|
|
1707
|
+
if (ignoredSlugs.includes(doc.slug)) continue;
|
|
1708
|
+
if (allResults.length >= MAX_DOCS2) {
|
|
1709
|
+
capped = true;
|
|
1710
|
+
break collectionsLoop;
|
|
1711
|
+
}
|
|
1712
|
+
try {
|
|
1713
|
+
allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
|
|
1714
|
+
} catch (e) {
|
|
1715
|
+
payload.logger.warn(
|
|
1716
|
+
`[seo] audit: skipped ${collectionSlug}/${doc.id}: ${e instanceof Error ? e.message : "error"}`
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
hasMore = result.hasNextPage;
|
|
1721
|
+
page++;
|
|
1722
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1723
|
+
}
|
|
1724
|
+
} catch {
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (capped) {
|
|
1728
|
+
payload.logger.warn(
|
|
1729
|
+
`[seo] audit: capped at ${MAX_DOCS2} docs (SEO_AUDIT_MAX_DOCS). Lower SEO_AUDIT_BATCH_SIZE on low-memory hosts, or raise the cap.`
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
for (const globalSlug of globals) {
|
|
1733
|
+
try {
|
|
1734
|
+
const doc = await payload.findGlobal({
|
|
1735
|
+
slug: globalSlug,
|
|
1736
|
+
depth: 1,
|
|
1737
|
+
overrideAccess: true
|
|
1738
|
+
});
|
|
1739
|
+
if (doc) {
|
|
1740
|
+
if (ignoredSlugs.includes(globalSlug)) continue;
|
|
1741
|
+
const result = analyzeDoc(doc, `global:${globalSlug}`, mergedConfig);
|
|
1742
|
+
allResults.push({
|
|
1743
|
+
...result,
|
|
1744
|
+
id: globalSlug,
|
|
1745
|
+
collection: `global:${globalSlug}`,
|
|
1746
|
+
slug: "",
|
|
1747
|
+
title: doc.title || globalSlug
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
} catch {
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
const previousScoreMap = /* @__PURE__ */ new Map();
|
|
1754
|
+
try {
|
|
1755
|
+
const historyResults = await payload.find({
|
|
1756
|
+
collection: "seo-score-history",
|
|
1757
|
+
limit: Math.min(allResults.length * 2, 3e3),
|
|
1758
|
+
sort: "-snapshotDate",
|
|
1759
|
+
depth: 0,
|
|
1760
|
+
overrideAccess: true
|
|
1761
|
+
});
|
|
1762
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1763
|
+
for (const h of historyResults.docs) {
|
|
1764
|
+
const key = `${h.documentId}::${h.collection}`;
|
|
1765
|
+
if (!seen.has(key)) {
|
|
1766
|
+
seen.add(key);
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
if (!previousScoreMap.has(key)) {
|
|
1770
|
+
previousScoreMap.set(key, h.score);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
const enrichedResults = allResults.map((r) => ({
|
|
1776
|
+
...r,
|
|
1777
|
+
previousScore: previousScoreMap.get(`${r.id}::${r.collection}`) ?? null
|
|
1778
|
+
}));
|
|
1779
|
+
enrichedResults.sort((a, b) => a.score - b.score);
|
|
1780
|
+
const totalDocs = enrichedResults.length;
|
|
1781
|
+
const stats = {
|
|
1782
|
+
totalPages: totalDocs,
|
|
1783
|
+
avgScore: totalDocs > 0 ? Math.round(enrichedResults.reduce((s, r) => s + r.score, 0) / totalDocs) : 0,
|
|
1784
|
+
good: enrichedResults.filter((r) => r.score >= 80).length,
|
|
1785
|
+
needsWork: enrichedResults.filter((r) => r.score >= 50 && r.score < 80).length,
|
|
1786
|
+
critical: enrichedResults.filter((r) => r.score < 50).length,
|
|
1787
|
+
noKeyword: enrichedResults.filter((r) => !r.focusKeyword).length,
|
|
1788
|
+
noMetaTitle: enrichedResults.filter((r) => !r.metaTitle).length,
|
|
1789
|
+
noMetaDesc: enrichedResults.filter((r) => !r.metaDescription).length,
|
|
1790
|
+
avgWordCount: totalDocs > 0 ? Math.round(enrichedResults.reduce((s, r) => s + r.wordCount, 0) / totalDocs) : 0,
|
|
1791
|
+
avgReadability: totalDocs > 0 ? Math.round(enrichedResults.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs) : 0
|
|
1792
|
+
};
|
|
1793
|
+
return { enrichedResults, stats, capped };
|
|
1794
|
+
}
|
|
1795
|
+
function ensureAuditBuild(payload, collections, globals, seoConfig) {
|
|
1796
|
+
if (auditBuildInFlight) return;
|
|
1797
|
+
auditBuildInFlight = true;
|
|
1798
|
+
void buildAuditCache(payload, collections, globals, seoConfig).then((result) => {
|
|
1799
|
+
seoCache.set(CACHE_KEY, result);
|
|
1800
|
+
}).catch((e) => {
|
|
1801
|
+
payload.logger.error(`[seo] audit build failed: ${e instanceof Error ? e.message : "unknown"}`);
|
|
1802
|
+
}).finally(() => {
|
|
1803
|
+
auditBuildInFlight = false;
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1682
1806
|
function createAuditHandler(collections, seoConfig, globals = []) {
|
|
1683
1807
|
return async (req) => {
|
|
1684
1808
|
try {
|
|
@@ -1689,98 +1813,18 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1689
1813
|
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
|
|
1690
1814
|
const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
|
|
1691
1815
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
1692
|
-
|
|
1693
|
-
|
|
1816
|
+
if (noCache && !auditBuildInFlight) {
|
|
1817
|
+
seoCache.invalidateKey(CACHE_KEY);
|
|
1818
|
+
}
|
|
1819
|
+
const cached = seoCache.get(CACHE_KEY);
|
|
1694
1820
|
if (!cached) {
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
let hasMore = true;
|
|
1701
|
-
while (hasMore) {
|
|
1702
|
-
const result = await req.payload.find({
|
|
1703
|
-
collection: collectionSlug,
|
|
1704
|
-
limit: 100,
|
|
1705
|
-
page: page2,
|
|
1706
|
-
depth: 1,
|
|
1707
|
-
overrideAccess: true
|
|
1708
|
-
});
|
|
1709
|
-
for (const doc of result.docs) {
|
|
1710
|
-
if (ignoredSlugs.includes(doc.slug)) continue;
|
|
1711
|
-
allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
|
|
1712
|
-
}
|
|
1713
|
-
hasMore = result.hasNextPage;
|
|
1714
|
-
page2++;
|
|
1715
|
-
}
|
|
1716
|
-
} catch {
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
1719
|
-
for (const globalSlug of globals) {
|
|
1720
|
-
try {
|
|
1721
|
-
const doc = await req.payload.findGlobal({
|
|
1722
|
-
slug: globalSlug,
|
|
1723
|
-
depth: 1,
|
|
1724
|
-
overrideAccess: true
|
|
1725
|
-
});
|
|
1726
|
-
if (doc) {
|
|
1727
|
-
if (ignoredSlugs.includes(globalSlug)) continue;
|
|
1728
|
-
const result = analyzeDoc(doc, `global:${globalSlug}`, mergedConfig);
|
|
1729
|
-
allResults.push({
|
|
1730
|
-
...result,
|
|
1731
|
-
id: globalSlug,
|
|
1732
|
-
collection: `global:${globalSlug}`,
|
|
1733
|
-
slug: "",
|
|
1734
|
-
title: doc.title || globalSlug
|
|
1735
|
-
});
|
|
1736
|
-
}
|
|
1737
|
-
} catch {
|
|
1738
|
-
}
|
|
1739
|
-
}
|
|
1740
|
-
const previousScoreMap = /* @__PURE__ */ new Map();
|
|
1741
|
-
try {
|
|
1742
|
-
const historyResults = await req.payload.find({
|
|
1743
|
-
collection: "seo-score-history",
|
|
1744
|
-
limit: allResults.length * 2,
|
|
1745
|
-
sort: "-snapshotDate",
|
|
1746
|
-
depth: 0,
|
|
1747
|
-
overrideAccess: true
|
|
1748
|
-
});
|
|
1749
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1750
|
-
for (const h of historyResults.docs) {
|
|
1751
|
-
const key = `${h.documentId}::${h.collection}`;
|
|
1752
|
-
if (!seen.has(key)) {
|
|
1753
|
-
seen.add(key);
|
|
1754
|
-
continue;
|
|
1755
|
-
}
|
|
1756
|
-
if (!previousScoreMap.has(key)) {
|
|
1757
|
-
previousScoreMap.set(key, h.score);
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
} catch {
|
|
1761
|
-
}
|
|
1762
|
-
const enrichedResults2 = allResults.map((r) => ({
|
|
1763
|
-
...r,
|
|
1764
|
-
previousScore: previousScoreMap.get(`${r.id}::${r.collection}`) ?? null
|
|
1765
|
-
}));
|
|
1766
|
-
enrichedResults2.sort((a, b) => a.score - b.score);
|
|
1767
|
-
const totalDocs2 = enrichedResults2.length;
|
|
1768
|
-
const stats2 = {
|
|
1769
|
-
totalPages: totalDocs2,
|
|
1770
|
-
avgScore: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.score, 0) / totalDocs2) : 0,
|
|
1771
|
-
good: enrichedResults2.filter((r) => r.score >= 80).length,
|
|
1772
|
-
needsWork: enrichedResults2.filter((r) => r.score >= 50 && r.score < 80).length,
|
|
1773
|
-
critical: enrichedResults2.filter((r) => r.score < 50).length,
|
|
1774
|
-
noKeyword: enrichedResults2.filter((r) => !r.focusKeyword).length,
|
|
1775
|
-
noMetaTitle: enrichedResults2.filter((r) => !r.metaTitle).length,
|
|
1776
|
-
noMetaDesc: enrichedResults2.filter((r) => !r.metaDescription).length,
|
|
1777
|
-
avgWordCount: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.wordCount, 0) / totalDocs2) : 0,
|
|
1778
|
-
avgReadability: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs2) : 0
|
|
1779
|
-
};
|
|
1780
|
-
cached = { enrichedResults: enrichedResults2, stats: stats2 };
|
|
1781
|
-
seoCache.set(CACHE_KEY, cached);
|
|
1821
|
+
ensureAuditBuild(req.payload, collections, globals, seoConfig);
|
|
1822
|
+
return Response.json(
|
|
1823
|
+
{ building: true, results: [], stats: null },
|
|
1824
|
+
{ status: 202, headers: { "Cache-Control": "no-store" } }
|
|
1825
|
+
);
|
|
1782
1826
|
}
|
|
1783
|
-
const { enrichedResults, stats } = cached;
|
|
1827
|
+
const { enrichedResults, stats, capped } = cached;
|
|
1784
1828
|
const totalDocs = enrichedResults.length;
|
|
1785
1829
|
const totalPages = Math.ceil(totalDocs / limit);
|
|
1786
1830
|
const startIdx = (page - 1) * limit;
|
|
@@ -1796,7 +1840,8 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1796
1840
|
hasNextPage: page < totalPages,
|
|
1797
1841
|
hasPrevPage: page > 1
|
|
1798
1842
|
},
|
|
1799
|
-
cached:
|
|
1843
|
+
cached: true,
|
|
1844
|
+
capped
|
|
1800
1845
|
}, { headers: { "Cache-Control": "no-store" } });
|
|
1801
1846
|
} catch (error) {
|
|
1802
1847
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -2152,8 +2197,8 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
|
|
|
2152
2197
|
}
|
|
2153
2198
|
const url = new URL(req.url);
|
|
2154
2199
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
2155
|
-
const
|
|
2156
|
-
const cached = noCache ? null : seoCache.get(
|
|
2200
|
+
const CACHE_KEY2 = "sitemap-audit";
|
|
2201
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
2157
2202
|
if (cached) {
|
|
2158
2203
|
return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
|
|
2159
2204
|
}
|
|
@@ -2299,7 +2344,7 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
|
|
|
2299
2344
|
brokenCount: uniqueBrokenLinks.length
|
|
2300
2345
|
}
|
|
2301
2346
|
};
|
|
2302
|
-
seoCache.set(
|
|
2347
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
2303
2348
|
return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
|
|
2304
2349
|
} catch (error) {
|
|
2305
2350
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -2882,6 +2927,201 @@ function createAiGenerateHandler() {
|
|
|
2882
2927
|
};
|
|
2883
2928
|
}
|
|
2884
2929
|
|
|
2930
|
+
// src/endpoints/aiOptimize.ts
|
|
2931
|
+
var DEFAULT_MODEL = "claude-opus-4-8";
|
|
2932
|
+
var TITLE_HARD_MAX = 70;
|
|
2933
|
+
var DESC_HARD_MAX = 160;
|
|
2934
|
+
var KEYWORD_MAX = 60;
|
|
2935
|
+
var RATIONALE_MAX_ITEMS = 4;
|
|
2936
|
+
var RATIONALE_ITEM_MAX = 200;
|
|
2937
|
+
async function callClaudeOptimize(apiKey, model, params) {
|
|
2938
|
+
const systemPrompt = `You are an SEO expert applying June 2026 best practices. You optimize ONLY a page's meta title and meta description (and may suggest a focus keyword). You NEVER rewrite body content.
|
|
2939
|
+
Strict rules (these mirror the site's own SEO engine \u2014 follow them exactly):
|
|
2940
|
+
- Meta title: natural and compelling, hard limit ${TITLE_HARD_MAX} characters, front-load the important words, do NOT keyword-stuff or repeat the brand/keyword.
|
|
2941
|
+
- Meta description: 120-160 characters, one or two sentences, action-oriented, includes the focus keyword ONCE and naturally. No keyword stuffing.
|
|
2942
|
+
- Write in the SAME language as the page content.
|
|
2943
|
+
- Base everything on the ACTUAL content; never invent facts, prices, numbers, or claims not present in the content.
|
|
2944
|
+
- If the focus keyword is missing, you MAY suggest one short keyword (2-4 words) matching the content's main topic; otherwise keep the existing one.
|
|
2945
|
+
Return ONLY a JSON object (no markdown, no prose, no code fences) with EXACTLY this shape:
|
|
2946
|
+
{"metaTitle": string, "metaDescription": string, "focusKeyword": string, "rationale": string[]}
|
|
2947
|
+
"rationale": 2-4 short strings, in the page's language, explaining what you improved and why, referencing the detected issues.`;
|
|
2948
|
+
const issuesText = params.issues.length ? params.issues.map((i) => `- ${i.label}: ${i.message}`).join("\n") : "- (no blocking issues \u2014 focus on making the meta tags more compelling and accurate)";
|
|
2949
|
+
const userPrompt = `Page title: ${params.pageTitle || "(none)"}
|
|
2950
|
+
Slug: ${params.slug || "(none)"}
|
|
2951
|
+
Current focus keyword: ${params.focusKeyword || "(none)"}
|
|
2952
|
+
Current meta title: ${params.currentMetaTitle || "(empty)"}
|
|
2953
|
+
Current meta description: ${params.currentMetaDescription || "(empty)"}
|
|
2954
|
+
|
|
2955
|
+
SEO issues detected by the engine (fix these):
|
|
2956
|
+
${issuesText}
|
|
2957
|
+
|
|
2958
|
+
Page content (first 3000 chars):
|
|
2959
|
+
${params.content.substring(0, 3e3)}
|
|
2960
|
+
|
|
2961
|
+
Return the optimized JSON now:`;
|
|
2962
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
2963
|
+
method: "POST",
|
|
2964
|
+
headers: {
|
|
2965
|
+
"Content-Type": "application/json",
|
|
2966
|
+
"x-api-key": apiKey,
|
|
2967
|
+
"anthropic-version": "2023-06-01"
|
|
2968
|
+
},
|
|
2969
|
+
body: JSON.stringify({
|
|
2970
|
+
model,
|
|
2971
|
+
max_tokens: 1024,
|
|
2972
|
+
system: systemPrompt,
|
|
2973
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
2974
|
+
})
|
|
2975
|
+
});
|
|
2976
|
+
if (!response.ok) {
|
|
2977
|
+
const errorBody = await response.text();
|
|
2978
|
+
throw new Error(`Claude API error ${response.status}: ${errorBody}`);
|
|
2979
|
+
}
|
|
2980
|
+
const data = await response.json();
|
|
2981
|
+
if (data.stop_reason === "refusal") {
|
|
2982
|
+
return null;
|
|
2983
|
+
}
|
|
2984
|
+
const text = (data.content?.find((b) => b.type === "text")?.text || "").trim();
|
|
2985
|
+
if (!text) return null;
|
|
2986
|
+
return parseSuggestions(text);
|
|
2987
|
+
}
|
|
2988
|
+
function parseSuggestions(raw) {
|
|
2989
|
+
let s = raw.trim();
|
|
2990
|
+
if (s.startsWith("```")) {
|
|
2991
|
+
s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
2992
|
+
}
|
|
2993
|
+
if (!s.startsWith("{")) {
|
|
2994
|
+
const start = s.indexOf("{");
|
|
2995
|
+
const end = s.lastIndexOf("}");
|
|
2996
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
2997
|
+
s = s.slice(start, end + 1);
|
|
2998
|
+
}
|
|
2999
|
+
try {
|
|
3000
|
+
const parsed = JSON.parse(s);
|
|
3001
|
+
return {
|
|
3002
|
+
metaTitle: typeof parsed.metaTitle === "string" ? parsed.metaTitle : "",
|
|
3003
|
+
metaDescription: typeof parsed.metaDescription === "string" ? parsed.metaDescription : "",
|
|
3004
|
+
focusKeyword: typeof parsed.focusKeyword === "string" ? parsed.focusKeyword : "",
|
|
3005
|
+
rationale: Array.isArray(parsed.rationale) ? parsed.rationale.filter((r) => typeof r === "string") : []
|
|
3006
|
+
};
|
|
3007
|
+
} catch {
|
|
3008
|
+
return null;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
function sanitizeSuggestions(s, currentFocusKeyword) {
|
|
3012
|
+
const metaTitle = truncateWords(s.metaTitle.trim(), TITLE_HARD_MAX);
|
|
3013
|
+
const metaDescription = truncateWords(s.metaDescription.trim(), DESC_HARD_MAX);
|
|
3014
|
+
let focusKeyword = currentFocusKeyword;
|
|
3015
|
+
if (!currentFocusKeyword.trim()) {
|
|
3016
|
+
const suggested = s.focusKeyword.trim();
|
|
3017
|
+
if (suggested && suggested.length <= KEYWORD_MAX) {
|
|
3018
|
+
focusKeyword = suggested;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
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
|
+
return { metaTitle, metaDescription, focusKeyword, rationale };
|
|
3023
|
+
}
|
|
3024
|
+
function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
|
|
3025
|
+
return async (req) => {
|
|
3026
|
+
try {
|
|
3027
|
+
if (!req.user) {
|
|
3028
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
3029
|
+
}
|
|
3030
|
+
const body = await parseJsonBody(req);
|
|
3031
|
+
const collection = typeof body.collection === "string" ? body.collection.trim() : void 0;
|
|
3032
|
+
const id = typeof body.id === "string" || typeof body.id === "number" ? String(body.id).trim() : void 0;
|
|
3033
|
+
if (!collection || !id) {
|
|
3034
|
+
return Response.json({ error: "Missing required fields: collection, id" }, { status: 400 });
|
|
3035
|
+
}
|
|
3036
|
+
if (targetCollections && !targetCollections.includes(collection)) {
|
|
3037
|
+
return Response.json({ error: "Collection not allowed" }, { status: 403 });
|
|
3038
|
+
}
|
|
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
|
+
const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
|
|
3052
|
+
reqLocale: req.locale,
|
|
3053
|
+
localeMapping
|
|
3054
|
+
});
|
|
3055
|
+
const seoInput = buildSeoInputFromDoc(doc, collection);
|
|
3056
|
+
const analysis = analyzeSeo(seoInput, mergedConfig);
|
|
3057
|
+
const metaGroups = /* @__PURE__ */ new Set(["title", "meta-description", "content", "url", "headings"]);
|
|
3058
|
+
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);
|
|
3059
|
+
const pageTitle = doc.title || "";
|
|
3060
|
+
const slug = doc.slug || "";
|
|
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: []
|
|
3074
|
+
});
|
|
3075
|
+
if (apiKey) {
|
|
3076
|
+
try {
|
|
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";
|
|
3103
|
+
}
|
|
3104
|
+
const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
|
|
3105
|
+
return Response.json({
|
|
3106
|
+
method,
|
|
3107
|
+
...method === "ai" ? { model } : {},
|
|
3108
|
+
score: analysis.score,
|
|
3109
|
+
current: {
|
|
3110
|
+
metaTitle: currentMetaTitle,
|
|
3111
|
+
metaDescription: currentMetaDescription,
|
|
3112
|
+
focusKeyword: currentFocusKeyword
|
|
3113
|
+
},
|
|
3114
|
+
suggestions: sanitized,
|
|
3115
|
+
issues
|
|
3116
|
+
});
|
|
3117
|
+
} catch (error) {
|
|
3118
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3119
|
+
req.payload.logger.error(`[seo] ai-optimize error: ${message}`);
|
|
3120
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3121
|
+
}
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
|
|
2885
3125
|
// src/endpoints/cannibalization.ts
|
|
2886
3126
|
function canonicalIntent(keyword) {
|
|
2887
3127
|
return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
|
|
@@ -2894,8 +3134,8 @@ function createCannibalizationHandler(collections, globals = []) {
|
|
|
2894
3134
|
}
|
|
2895
3135
|
const url = new URL(req.url);
|
|
2896
3136
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
2897
|
-
const
|
|
2898
|
-
const cached = noCache ? null : seoCache.get(
|
|
3137
|
+
const CACHE_KEY2 = "cannibalization";
|
|
3138
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
2899
3139
|
if (cached) {
|
|
2900
3140
|
return Response.json({ ...cached, cached: true });
|
|
2901
3141
|
}
|
|
@@ -2989,7 +3229,7 @@ function createCannibalizationHandler(collections, globals = []) {
|
|
|
2989
3229
|
totalAffectedPages
|
|
2990
3230
|
}
|
|
2991
3231
|
};
|
|
2992
|
-
seoCache.set(
|
|
3232
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
2993
3233
|
return Response.json({ ...responseData, cached: false });
|
|
2994
3234
|
} catch (error) {
|
|
2995
3235
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -3200,8 +3440,8 @@ function createExternalLinksHandler(collections, globals = []) {
|
|
|
3200
3440
|
}
|
|
3201
3441
|
const url = new URL(req.url);
|
|
3202
3442
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
3203
|
-
const
|
|
3204
|
-
const cached = noCache ? null : seoCache.get(
|
|
3443
|
+
const CACHE_KEY2 = "external-links";
|
|
3444
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
3205
3445
|
if (cached) {
|
|
3206
3446
|
return Response.json({ ...cached, cached: true });
|
|
3207
3447
|
}
|
|
@@ -3281,7 +3521,7 @@ function createExternalLinksHandler(collections, globals = []) {
|
|
|
3281
3521
|
return a.ok ? 1 : -1;
|
|
3282
3522
|
});
|
|
3283
3523
|
const responseData = { results, stats };
|
|
3284
|
-
seoCache.set(
|
|
3524
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
3285
3525
|
return Response.json({ ...responseData, cached: false });
|
|
3286
3526
|
} catch (error) {
|
|
3287
3527
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -4275,8 +4515,8 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
|
|
|
4275
4515
|
}
|
|
4276
4516
|
const url = new URL(req.url);
|
|
4277
4517
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
4278
|
-
const
|
|
4279
|
-
const cached = noCache ? null : seoCache.get(
|
|
4518
|
+
const CACHE_KEY2 = "keyword-research";
|
|
4519
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
4280
4520
|
if (cached) {
|
|
4281
4521
|
return Response.json({ ...cached, cached: true });
|
|
4282
4522
|
}
|
|
@@ -4452,7 +4692,7 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
|
|
|
4452
4692
|
suggestionsCount: suggestions.length
|
|
4453
4693
|
}
|
|
4454
4694
|
};
|
|
4455
|
-
seoCache.set(
|
|
4695
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
4456
4696
|
return Response.json({ ...responseData, cached: false });
|
|
4457
4697
|
} catch (error) {
|
|
4458
4698
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -4589,8 +4829,8 @@ function createLinkGraphHandler(targetCollections, globals = []) {
|
|
|
4589
4829
|
}
|
|
4590
4830
|
const url = new URL(req.url);
|
|
4591
4831
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
4592
|
-
const
|
|
4593
|
-
const cached = noCache ? null : seoCache.get(
|
|
4832
|
+
const CACHE_KEY2 = "link-graph";
|
|
4833
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
4594
4834
|
if (cached) {
|
|
4595
4835
|
return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
|
|
4596
4836
|
}
|
|
@@ -4677,7 +4917,7 @@ function createLinkGraphHandler(targetCollections, globals = []) {
|
|
|
4677
4917
|
avgDegree
|
|
4678
4918
|
};
|
|
4679
4919
|
const responseData = { nodes, edges, stats };
|
|
4680
|
-
seoCache.set(
|
|
4920
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
4681
4921
|
return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
|
|
4682
4922
|
} catch (error) {
|
|
4683
4923
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -5036,8 +5276,8 @@ function createRedirectChainsHandler(redirectsCollection) {
|
|
|
5036
5276
|
}
|
|
5037
5277
|
const url = new URL(req.url || "", "http://localhost");
|
|
5038
5278
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
5039
|
-
const
|
|
5040
|
-
const cached = noCache ? null : seoCache.get(
|
|
5279
|
+
const CACHE_KEY2 = "redirect-chains";
|
|
5280
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
5041
5281
|
if (cached) {
|
|
5042
5282
|
return Response.json({ ...cached, cached: true });
|
|
5043
5283
|
}
|
|
@@ -5106,7 +5346,7 @@ function createRedirectChainsHandler(redirectsCollection) {
|
|
|
5106
5346
|
totalRedirects: allRedirects.length
|
|
5107
5347
|
}
|
|
5108
5348
|
};
|
|
5109
|
-
seoCache.set(
|
|
5349
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
5110
5350
|
return Response.json({ ...responseData, cached: false });
|
|
5111
5351
|
} catch (error) {
|
|
5112
5352
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -5149,8 +5389,8 @@ function createDuplicateContentHandler(collections) {
|
|
|
5149
5389
|
const url = new URL(req.url || "", "http://localhost");
|
|
5150
5390
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
5151
5391
|
const threshold = parseFloat(url.searchParams.get("threshold") || "") || SIMILARITY_THRESHOLD;
|
|
5152
|
-
const
|
|
5153
|
-
const cached = noCache ? null : seoCache.get(
|
|
5392
|
+
const CACHE_KEY2 = `duplicate-content-${threshold}`;
|
|
5393
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
5154
5394
|
if (cached) {
|
|
5155
5395
|
return Response.json({ ...cached, cached: true });
|
|
5156
5396
|
}
|
|
@@ -5207,7 +5447,7 @@ function createDuplicateContentHandler(collections) {
|
|
|
5207
5447
|
threshold
|
|
5208
5448
|
}
|
|
5209
5449
|
};
|
|
5210
|
-
seoCache.set(
|
|
5450
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
5211
5451
|
return Response.json({ ...responseData, cached: false });
|
|
5212
5452
|
} catch (error) {
|
|
5213
5453
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -6463,8 +6703,8 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
|
|
|
6463
6703
|
try {
|
|
6464
6704
|
await payload.find({
|
|
6465
6705
|
collection: collectionSlug,
|
|
6466
|
-
limit:
|
|
6467
|
-
depth:
|
|
6706
|
+
limit: 100,
|
|
6707
|
+
depth: 0,
|
|
6468
6708
|
overrideAccess: true
|
|
6469
6709
|
});
|
|
6470
6710
|
payload.logger.info(`[seo] warm-cache: pre-loaded ${collectionSlug}`);
|
|
@@ -6475,7 +6715,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
|
|
|
6475
6715
|
try {
|
|
6476
6716
|
await payload.findGlobal({
|
|
6477
6717
|
slug: globalSlug,
|
|
6478
|
-
depth:
|
|
6718
|
+
depth: 0,
|
|
6479
6719
|
overrideAccess: true
|
|
6480
6720
|
});
|
|
6481
6721
|
payload.logger.info(`[seo] warm-cache: pre-loaded global ${globalSlug}`);
|
|
@@ -7495,6 +7735,8 @@ var fr = {
|
|
|
7495
7735
|
},
|
|
7496
7736
|
seoView: {
|
|
7497
7737
|
loadingAudit: "Chargement de l'audit SEO...",
|
|
7738
|
+
buildingAudit: "G\xE9n\xE9ration de l'audit SEO en cours\u2026 (calcul\xE9 en arri\xE8re-plan pour ne pas surcharger le serveur, cela peut prendre un moment sur un gros site)",
|
|
7739
|
+
buildTimeout: "La g\xE9n\xE9ration de l'audit prend plus de temps que pr\xE9vu. R\xE9essayez dans quelques instants.",
|
|
7498
7740
|
errorSaving: "Erreur lors de la sauvegarde",
|
|
7499
7741
|
auditTitle: "Audit SEO",
|
|
7500
7742
|
pagesAnalyzed: "pages analys\xE9es",
|
|
@@ -7937,7 +8179,19 @@ var fr = {
|
|
|
7937
8179
|
generateMeta: "G\xE9n\xE9rer les meta",
|
|
7938
8180
|
metaTitle: "Meta Title",
|
|
7939
8181
|
metaDescription: "Meta Description",
|
|
7940
|
-
emptyValue: "(vide)"
|
|
8182
|
+
emptyValue: "(vide)",
|
|
8183
|
+
optimizeWithAi: "Optimiser avec l'IA",
|
|
8184
|
+
optimizeIntro: "L'IA analyse la page et propose des meta optimis\xE9es (titre, description, mot-cl\xE9). V\xE9rifiez puis appliquez.",
|
|
8185
|
+
optimizeRunning: "Analyse en cours\u2026",
|
|
8186
|
+
applyAll: "Appliquer",
|
|
8187
|
+
applied: "Appliqu\xE9",
|
|
8188
|
+
whyChanges: "Pourquoi ces changements",
|
|
8189
|
+
labelCurrent: "Actuel",
|
|
8190
|
+
labelSuggested: "Sugg\xE9r\xE9",
|
|
8191
|
+
labelFocusKeyword: "Mot-cl\xE9 cible",
|
|
8192
|
+
heuristicNote: "Suggestions heuristiques (cl\xE9 API Claude non configur\xE9e).",
|
|
8193
|
+
applySaveHint: "Champs remplis \u2014 pensez \xE0 enregistrer le document.",
|
|
8194
|
+
noMetaChange: "Aucun changement propos\xE9."
|
|
7941
8195
|
},
|
|
7942
8196
|
scoreHistory: {
|
|
7943
8197
|
loading: "Chargement de l'historique...",
|
|
@@ -8073,6 +8327,8 @@ var en = {
|
|
|
8073
8327
|
},
|
|
8074
8328
|
seoView: {
|
|
8075
8329
|
loadingAudit: "Loading SEO audit...",
|
|
8330
|
+
buildingAudit: "Building the SEO audit\u2026 (computed in the background to avoid overloading the server \u2014 this can take a moment on a large site)",
|
|
8331
|
+
buildTimeout: "The audit is taking longer than expected. Please try again in a moment.",
|
|
8076
8332
|
errorSaving: "Error during save",
|
|
8077
8333
|
auditTitle: "SEO Audit",
|
|
8078
8334
|
pagesAnalyzed: "pages analyzed",
|
|
@@ -8515,7 +8771,19 @@ var en = {
|
|
|
8515
8771
|
generateMeta: "Generate meta",
|
|
8516
8772
|
metaTitle: "Meta Title",
|
|
8517
8773
|
metaDescription: "Meta Description",
|
|
8518
|
-
emptyValue: "(empty)"
|
|
8774
|
+
emptyValue: "(empty)",
|
|
8775
|
+
optimizeWithAi: "Optimize with AI",
|
|
8776
|
+
optimizeIntro: "AI analyzes the page and proposes optimized meta tags (title, description, keyword). Review, then apply.",
|
|
8777
|
+
optimizeRunning: "Analyzing\u2026",
|
|
8778
|
+
applyAll: "Apply",
|
|
8779
|
+
applied: "Applied",
|
|
8780
|
+
whyChanges: "Why these changes",
|
|
8781
|
+
labelCurrent: "Current",
|
|
8782
|
+
labelSuggested: "Suggested",
|
|
8783
|
+
labelFocusKeyword: "Focus keyword",
|
|
8784
|
+
heuristicNote: "Heuristic suggestions (Claude API key not configured).",
|
|
8785
|
+
applySaveHint: "Fields filled \u2014 remember to save the document.",
|
|
8786
|
+
noMetaChange: "No changes proposed."
|
|
8519
8787
|
},
|
|
8520
8788
|
scoreHistory: {
|
|
8521
8789
|
loading: "Loading history...",
|
|
@@ -8657,6 +8925,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8657
8925
|
settings: true,
|
|
8658
8926
|
gscApi: false,
|
|
8659
8927
|
// opt-in — requires Google Cloud OAuth setup + secrets
|
|
8928
|
+
warmCache: true,
|
|
8929
|
+
// disable on low-memory hosts to skip startup pre-loading
|
|
8660
8930
|
...pluginConfig.features
|
|
8661
8931
|
};
|
|
8662
8932
|
function hasExistingSeoMeta(fields) {
|
|
@@ -8909,7 +9179,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8909
9179
|
if (features.aiFeatures) {
|
|
8910
9180
|
pluginEndpoints.push(
|
|
8911
9181
|
{ path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
|
|
8912
|
-
{ path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) }
|
|
9182
|
+
{ path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
|
|
9183
|
+
{ path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) }
|
|
8913
9184
|
);
|
|
8914
9185
|
}
|
|
8915
9186
|
if (features.cannibalization) {
|
|
@@ -9083,7 +9354,9 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9083
9354
|
const existingOnInit = config.onInit;
|
|
9084
9355
|
config.onInit = async (payload) => {
|
|
9085
9356
|
if (existingOnInit) await existingOnInit(payload);
|
|
9086
|
-
|
|
9357
|
+
if (features.warmCache) {
|
|
9358
|
+
startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
|
|
9359
|
+
}
|
|
9087
9360
|
};
|
|
9088
9361
|
return config;
|
|
9089
9362
|
};
|