@consilioweb/payload-seo-analyzer 1.8.1 → 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/dist/index.cjs CHANGED
@@ -1682,6 +1682,127 @@ function analyzeDoc(doc, collection, seoConfig) {
1682
1682
  daysSinceUpdate: doc.updatedAt ? Math.floor((Date.now() - new Date(doc.updatedAt).getTime()) / (1e3 * 60 * 60 * 24)) : null
1683
1683
  };
1684
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
+ }
1685
1806
  function createAuditHandler(collections, seoConfig, globals = []) {
1686
1807
  return async (req) => {
1687
1808
  try {
@@ -1692,116 +1813,16 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1692
1813
  const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
1693
1814
  const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
1694
1815
  const noCache = url.searchParams.get("nocache") === "1";
1695
- const CACHE_KEY = "audit";
1696
- let cached = noCache ? null : seoCache.get(CACHE_KEY);
1816
+ if (noCache && !auditBuildInFlight) {
1817
+ seoCache.invalidateKey(CACHE_KEY);
1818
+ }
1819
+ const cached = seoCache.get(CACHE_KEY);
1697
1820
  if (!cached) {
1698
- const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(req.payload, seoConfig);
1699
- const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "25", 10) || 25));
1700
- const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
1701
- const allResults = [];
1702
- let capped2 = false;
1703
- collectionsLoop:
1704
- for (const collectionSlug of collections) {
1705
- try {
1706
- let page2 = 1;
1707
- let hasMore = true;
1708
- while (hasMore) {
1709
- const result = await req.payload.find({
1710
- collection: collectionSlug,
1711
- limit: BATCH_SIZE,
1712
- page: page2,
1713
- depth: 1,
1714
- overrideAccess: true
1715
- });
1716
- for (const doc of result.docs) {
1717
- if (ignoredSlugs.includes(doc.slug)) continue;
1718
- if (allResults.length >= MAX_DOCS2) {
1719
- capped2 = true;
1720
- break collectionsLoop;
1721
- }
1722
- try {
1723
- allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
1724
- } catch (e) {
1725
- req.payload.logger.warn(
1726
- `[seo] audit: skipped ${collectionSlug}/${doc.id}: ${e instanceof Error ? e.message : "error"}`
1727
- );
1728
- }
1729
- }
1730
- hasMore = result.hasNextPage;
1731
- page2++;
1732
- await new Promise((resolve) => setImmediate(resolve));
1733
- }
1734
- } catch {
1735
- }
1736
- }
1737
- if (capped2) {
1738
- req.payload.logger.warn(
1739
- `[seo] audit: capped at ${MAX_DOCS2} docs (SEO_AUDIT_MAX_DOCS). Lower SEO_AUDIT_BATCH_SIZE on low-memory hosts, or raise the cap.`
1740
- );
1741
- }
1742
- for (const globalSlug of globals) {
1743
- try {
1744
- const doc = await req.payload.findGlobal({
1745
- slug: globalSlug,
1746
- depth: 1,
1747
- overrideAccess: true
1748
- });
1749
- if (doc) {
1750
- if (ignoredSlugs.includes(globalSlug)) continue;
1751
- const result = analyzeDoc(doc, `global:${globalSlug}`, mergedConfig);
1752
- allResults.push({
1753
- ...result,
1754
- id: globalSlug,
1755
- collection: `global:${globalSlug}`,
1756
- slug: "",
1757
- title: doc.title || globalSlug
1758
- });
1759
- }
1760
- } catch {
1761
- }
1762
- }
1763
- const previousScoreMap = /* @__PURE__ */ new Map();
1764
- try {
1765
- const historyResults = await req.payload.find({
1766
- collection: "seo-score-history",
1767
- limit: allResults.length * 2,
1768
- sort: "-snapshotDate",
1769
- depth: 0,
1770
- overrideAccess: true
1771
- });
1772
- const seen = /* @__PURE__ */ new Set();
1773
- for (const h of historyResults.docs) {
1774
- const key = `${h.documentId}::${h.collection}`;
1775
- if (!seen.has(key)) {
1776
- seen.add(key);
1777
- continue;
1778
- }
1779
- if (!previousScoreMap.has(key)) {
1780
- previousScoreMap.set(key, h.score);
1781
- }
1782
- }
1783
- } catch {
1784
- }
1785
- const enrichedResults2 = allResults.map((r) => ({
1786
- ...r,
1787
- previousScore: previousScoreMap.get(`${r.id}::${r.collection}`) ?? null
1788
- }));
1789
- enrichedResults2.sort((a, b) => a.score - b.score);
1790
- const totalDocs2 = enrichedResults2.length;
1791
- const stats2 = {
1792
- totalPages: totalDocs2,
1793
- avgScore: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.score, 0) / totalDocs2) : 0,
1794
- good: enrichedResults2.filter((r) => r.score >= 80).length,
1795
- needsWork: enrichedResults2.filter((r) => r.score >= 50 && r.score < 80).length,
1796
- critical: enrichedResults2.filter((r) => r.score < 50).length,
1797
- noKeyword: enrichedResults2.filter((r) => !r.focusKeyword).length,
1798
- noMetaTitle: enrichedResults2.filter((r) => !r.metaTitle).length,
1799
- noMetaDesc: enrichedResults2.filter((r) => !r.metaDescription).length,
1800
- avgWordCount: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.wordCount, 0) / totalDocs2) : 0,
1801
- avgReadability: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs2) : 0
1802
- };
1803
- cached = { enrichedResults: enrichedResults2, stats: stats2, capped: capped2 };
1804
- 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
+ );
1805
1826
  }
1806
1827
  const { enrichedResults, stats, capped } = cached;
1807
1828
  const totalDocs = enrichedResults.length;
@@ -1819,7 +1840,7 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1819
1840
  hasNextPage: page < totalPages,
1820
1841
  hasPrevPage: page > 1
1821
1842
  },
1822
- cached: !noCache && seoCache.get(CACHE_KEY) !== null,
1843
+ cached: true,
1823
1844
  capped
1824
1845
  }, { headers: { "Cache-Control": "no-store" } });
1825
1846
  } catch (error) {
@@ -2176,8 +2197,8 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
2176
2197
  }
2177
2198
  const url = new URL(req.url);
2178
2199
  const noCache = url.searchParams.get("nocache") === "1";
2179
- const CACHE_KEY = "sitemap-audit";
2180
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
2200
+ const CACHE_KEY2 = "sitemap-audit";
2201
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
2181
2202
  if (cached) {
2182
2203
  return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
2183
2204
  }
@@ -2323,7 +2344,7 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
2323
2344
  brokenCount: uniqueBrokenLinks.length
2324
2345
  }
2325
2346
  };
2326
- seoCache.set(CACHE_KEY, responseData);
2347
+ seoCache.set(CACHE_KEY2, responseData);
2327
2348
  return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
2328
2349
  } catch (error) {
2329
2350
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -2906,6 +2927,201 @@ function createAiGenerateHandler() {
2906
2927
  };
2907
2928
  }
2908
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
+
2909
3125
  // src/endpoints/cannibalization.ts
2910
3126
  function canonicalIntent(keyword) {
2911
3127
  return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
@@ -2918,8 +3134,8 @@ function createCannibalizationHandler(collections, globals = []) {
2918
3134
  }
2919
3135
  const url = new URL(req.url);
2920
3136
  const noCache = url.searchParams.get("nocache") === "1";
2921
- const CACHE_KEY = "cannibalization";
2922
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
3137
+ const CACHE_KEY2 = "cannibalization";
3138
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
2923
3139
  if (cached) {
2924
3140
  return Response.json({ ...cached, cached: true });
2925
3141
  }
@@ -3013,7 +3229,7 @@ function createCannibalizationHandler(collections, globals = []) {
3013
3229
  totalAffectedPages
3014
3230
  }
3015
3231
  };
3016
- seoCache.set(CACHE_KEY, responseData);
3232
+ seoCache.set(CACHE_KEY2, responseData);
3017
3233
  return Response.json({ ...responseData, cached: false });
3018
3234
  } catch (error) {
3019
3235
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -3224,8 +3440,8 @@ function createExternalLinksHandler(collections, globals = []) {
3224
3440
  }
3225
3441
  const url = new URL(req.url);
3226
3442
  const noCache = url.searchParams.get("nocache") === "1";
3227
- const CACHE_KEY = "external-links";
3228
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
3443
+ const CACHE_KEY2 = "external-links";
3444
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
3229
3445
  if (cached) {
3230
3446
  return Response.json({ ...cached, cached: true });
3231
3447
  }
@@ -3305,7 +3521,7 @@ function createExternalLinksHandler(collections, globals = []) {
3305
3521
  return a.ok ? 1 : -1;
3306
3522
  });
3307
3523
  const responseData = { results, stats };
3308
- seoCache.set(CACHE_KEY, responseData);
3524
+ seoCache.set(CACHE_KEY2, responseData);
3309
3525
  return Response.json({ ...responseData, cached: false });
3310
3526
  } catch (error) {
3311
3527
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -4299,8 +4515,8 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
4299
4515
  }
4300
4516
  const url = new URL(req.url);
4301
4517
  const noCache = url.searchParams.get("nocache") === "1";
4302
- const CACHE_KEY = "keyword-research";
4303
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
4518
+ const CACHE_KEY2 = "keyword-research";
4519
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
4304
4520
  if (cached) {
4305
4521
  return Response.json({ ...cached, cached: true });
4306
4522
  }
@@ -4476,7 +4692,7 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
4476
4692
  suggestionsCount: suggestions.length
4477
4693
  }
4478
4694
  };
4479
- seoCache.set(CACHE_KEY, responseData);
4695
+ seoCache.set(CACHE_KEY2, responseData);
4480
4696
  return Response.json({ ...responseData, cached: false });
4481
4697
  } catch (error) {
4482
4698
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -4613,8 +4829,8 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4613
4829
  }
4614
4830
  const url = new URL(req.url);
4615
4831
  const noCache = url.searchParams.get("nocache") === "1";
4616
- const CACHE_KEY = "link-graph";
4617
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
4832
+ const CACHE_KEY2 = "link-graph";
4833
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
4618
4834
  if (cached) {
4619
4835
  return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
4620
4836
  }
@@ -4701,7 +4917,7 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4701
4917
  avgDegree
4702
4918
  };
4703
4919
  const responseData = { nodes, edges, stats };
4704
- seoCache.set(CACHE_KEY, responseData);
4920
+ seoCache.set(CACHE_KEY2, responseData);
4705
4921
  return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
4706
4922
  } catch (error) {
4707
4923
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -5060,8 +5276,8 @@ function createRedirectChainsHandler(redirectsCollection) {
5060
5276
  }
5061
5277
  const url = new URL(req.url || "", "http://localhost");
5062
5278
  const noCache = url.searchParams.get("nocache") === "1";
5063
- const CACHE_KEY = "redirect-chains";
5064
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
5279
+ const CACHE_KEY2 = "redirect-chains";
5280
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
5065
5281
  if (cached) {
5066
5282
  return Response.json({ ...cached, cached: true });
5067
5283
  }
@@ -5130,7 +5346,7 @@ function createRedirectChainsHandler(redirectsCollection) {
5130
5346
  totalRedirects: allRedirects.length
5131
5347
  }
5132
5348
  };
5133
- seoCache.set(CACHE_KEY, responseData);
5349
+ seoCache.set(CACHE_KEY2, responseData);
5134
5350
  return Response.json({ ...responseData, cached: false });
5135
5351
  } catch (error) {
5136
5352
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -5173,8 +5389,8 @@ function createDuplicateContentHandler(collections) {
5173
5389
  const url = new URL(req.url || "", "http://localhost");
5174
5390
  const noCache = url.searchParams.get("nocache") === "1";
5175
5391
  const threshold = parseFloat(url.searchParams.get("threshold") || "") || SIMILARITY_THRESHOLD;
5176
- const CACHE_KEY = `duplicate-content-${threshold}`;
5177
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
5392
+ const CACHE_KEY2 = `duplicate-content-${threshold}`;
5393
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
5178
5394
  if (cached) {
5179
5395
  return Response.json({ ...cached, cached: true });
5180
5396
  }
@@ -5231,7 +5447,7 @@ function createDuplicateContentHandler(collections) {
5231
5447
  threshold
5232
5448
  }
5233
5449
  };
5234
- seoCache.set(CACHE_KEY, responseData);
5450
+ seoCache.set(CACHE_KEY2, responseData);
5235
5451
  return Response.json({ ...responseData, cached: false });
5236
5452
  } catch (error) {
5237
5453
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -6487,8 +6703,8 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
6487
6703
  try {
6488
6704
  await payload.find({
6489
6705
  collection: collectionSlug,
6490
- limit: 500,
6491
- depth: 1,
6706
+ limit: 100,
6707
+ depth: 0,
6492
6708
  overrideAccess: true
6493
6709
  });
6494
6710
  payload.logger.info(`[seo] warm-cache: pre-loaded ${collectionSlug}`);
@@ -6499,7 +6715,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
6499
6715
  try {
6500
6716
  await payload.findGlobal({
6501
6717
  slug: globalSlug,
6502
- depth: 1,
6718
+ depth: 0,
6503
6719
  overrideAccess: true
6504
6720
  });
6505
6721
  payload.logger.info(`[seo] warm-cache: pre-loaded global ${globalSlug}`);
@@ -7519,6 +7735,8 @@ var fr = {
7519
7735
  },
7520
7736
  seoView: {
7521
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.",
7522
7740
  errorSaving: "Erreur lors de la sauvegarde",
7523
7741
  auditTitle: "Audit SEO",
7524
7742
  pagesAnalyzed: "pages analys\xE9es",
@@ -7961,7 +8179,19 @@ var fr = {
7961
8179
  generateMeta: "G\xE9n\xE9rer les meta",
7962
8180
  metaTitle: "Meta Title",
7963
8181
  metaDescription: "Meta Description",
7964
- 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."
7965
8195
  },
7966
8196
  scoreHistory: {
7967
8197
  loading: "Chargement de l'historique...",
@@ -8097,6 +8327,8 @@ var en = {
8097
8327
  },
8098
8328
  seoView: {
8099
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.",
8100
8332
  errorSaving: "Error during save",
8101
8333
  auditTitle: "SEO Audit",
8102
8334
  pagesAnalyzed: "pages analyzed",
@@ -8539,7 +8771,19 @@ var en = {
8539
8771
  generateMeta: "Generate meta",
8540
8772
  metaTitle: "Meta Title",
8541
8773
  metaDescription: "Meta Description",
8542
- 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."
8543
8787
  },
8544
8788
  scoreHistory: {
8545
8789
  loading: "Loading history...",
@@ -8935,7 +9179,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8935
9179
  if (features.aiFeatures) {
8936
9180
  pluginEndpoints.push(
8937
9181
  { path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
8938
- { 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) }
8939
9184
  );
8940
9185
  }
8941
9186
  if (features.cannibalization) {