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