@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.js CHANGED
@@ -1680,6 +1680,127 @@ function analyzeDoc(doc, collection, seoConfig) {
1680
1680
  daysSinceUpdate: doc.updatedAt ? Math.floor((Date.now() - new Date(doc.updatedAt).getTime()) / (1e3 * 60 * 60 * 24)) : null
1681
1681
  };
1682
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
+ }
1683
1804
  function createAuditHandler(collections, seoConfig, globals = []) {
1684
1805
  return async (req) => {
1685
1806
  try {
@@ -1690,116 +1811,16 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1690
1811
  const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
1691
1812
  const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
1692
1813
  const noCache = url.searchParams.get("nocache") === "1";
1693
- const CACHE_KEY = "audit";
1694
- let cached = noCache ? null : seoCache.get(CACHE_KEY);
1814
+ if (noCache && !auditBuildInFlight) {
1815
+ seoCache.invalidateKey(CACHE_KEY);
1816
+ }
1817
+ const cached = seoCache.get(CACHE_KEY);
1695
1818
  if (!cached) {
1696
- const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(req.payload, seoConfig);
1697
- const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "25", 10) || 25));
1698
- const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
1699
- const allResults = [];
1700
- let capped2 = false;
1701
- collectionsLoop:
1702
- for (const collectionSlug of collections) {
1703
- try {
1704
- let page2 = 1;
1705
- let hasMore = true;
1706
- while (hasMore) {
1707
- const result = await req.payload.find({
1708
- collection: collectionSlug,
1709
- limit: BATCH_SIZE,
1710
- page: page2,
1711
- depth: 1,
1712
- overrideAccess: true
1713
- });
1714
- for (const doc of result.docs) {
1715
- if (ignoredSlugs.includes(doc.slug)) continue;
1716
- if (allResults.length >= MAX_DOCS2) {
1717
- capped2 = true;
1718
- break collectionsLoop;
1719
- }
1720
- try {
1721
- allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
1722
- } catch (e) {
1723
- req.payload.logger.warn(
1724
- `[seo] audit: skipped ${collectionSlug}/${doc.id}: ${e instanceof Error ? e.message : "error"}`
1725
- );
1726
- }
1727
- }
1728
- hasMore = result.hasNextPage;
1729
- page2++;
1730
- await new Promise((resolve) => setImmediate(resolve));
1731
- }
1732
- } catch {
1733
- }
1734
- }
1735
- if (capped2) {
1736
- req.payload.logger.warn(
1737
- `[seo] audit: capped at ${MAX_DOCS2} docs (SEO_AUDIT_MAX_DOCS). Lower SEO_AUDIT_BATCH_SIZE on low-memory hosts, or raise the cap.`
1738
- );
1739
- }
1740
- for (const globalSlug of globals) {
1741
- try {
1742
- const doc = await req.payload.findGlobal({
1743
- slug: globalSlug,
1744
- depth: 1,
1745
- overrideAccess: true
1746
- });
1747
- if (doc) {
1748
- if (ignoredSlugs.includes(globalSlug)) continue;
1749
- const result = analyzeDoc(doc, `global:${globalSlug}`, mergedConfig);
1750
- allResults.push({
1751
- ...result,
1752
- id: globalSlug,
1753
- collection: `global:${globalSlug}`,
1754
- slug: "",
1755
- title: doc.title || globalSlug
1756
- });
1757
- }
1758
- } catch {
1759
- }
1760
- }
1761
- const previousScoreMap = /* @__PURE__ */ new Map();
1762
- try {
1763
- const historyResults = await req.payload.find({
1764
- collection: "seo-score-history",
1765
- limit: allResults.length * 2,
1766
- sort: "-snapshotDate",
1767
- depth: 0,
1768
- overrideAccess: true
1769
- });
1770
- const seen = /* @__PURE__ */ new Set();
1771
- for (const h of historyResults.docs) {
1772
- const key = `${h.documentId}::${h.collection}`;
1773
- if (!seen.has(key)) {
1774
- seen.add(key);
1775
- continue;
1776
- }
1777
- if (!previousScoreMap.has(key)) {
1778
- previousScoreMap.set(key, h.score);
1779
- }
1780
- }
1781
- } catch {
1782
- }
1783
- const enrichedResults2 = allResults.map((r) => ({
1784
- ...r,
1785
- previousScore: previousScoreMap.get(`${r.id}::${r.collection}`) ?? null
1786
- }));
1787
- enrichedResults2.sort((a, b) => a.score - b.score);
1788
- const totalDocs2 = enrichedResults2.length;
1789
- const stats2 = {
1790
- totalPages: totalDocs2,
1791
- avgScore: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.score, 0) / totalDocs2) : 0,
1792
- good: enrichedResults2.filter((r) => r.score >= 80).length,
1793
- needsWork: enrichedResults2.filter((r) => r.score >= 50 && r.score < 80).length,
1794
- critical: enrichedResults2.filter((r) => r.score < 50).length,
1795
- noKeyword: enrichedResults2.filter((r) => !r.focusKeyword).length,
1796
- noMetaTitle: enrichedResults2.filter((r) => !r.metaTitle).length,
1797
- noMetaDesc: enrichedResults2.filter((r) => !r.metaDescription).length,
1798
- avgWordCount: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.wordCount, 0) / totalDocs2) : 0,
1799
- avgReadability: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs2) : 0
1800
- };
1801
- cached = { enrichedResults: enrichedResults2, stats: stats2, capped: capped2 };
1802
- 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
+ );
1803
1824
  }
1804
1825
  const { enrichedResults, stats, capped } = cached;
1805
1826
  const totalDocs = enrichedResults.length;
@@ -1817,7 +1838,7 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1817
1838
  hasNextPage: page < totalPages,
1818
1839
  hasPrevPage: page > 1
1819
1840
  },
1820
- cached: !noCache && seoCache.get(CACHE_KEY) !== null,
1841
+ cached: true,
1821
1842
  capped
1822
1843
  }, { headers: { "Cache-Control": "no-store" } });
1823
1844
  } catch (error) {
@@ -2174,8 +2195,8 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
2174
2195
  }
2175
2196
  const url = new URL(req.url);
2176
2197
  const noCache = url.searchParams.get("nocache") === "1";
2177
- const CACHE_KEY = "sitemap-audit";
2178
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
2198
+ const CACHE_KEY2 = "sitemap-audit";
2199
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
2179
2200
  if (cached) {
2180
2201
  return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
2181
2202
  }
@@ -2321,7 +2342,7 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
2321
2342
  brokenCount: uniqueBrokenLinks.length
2322
2343
  }
2323
2344
  };
2324
- seoCache.set(CACHE_KEY, responseData);
2345
+ seoCache.set(CACHE_KEY2, responseData);
2325
2346
  return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
2326
2347
  } catch (error) {
2327
2348
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -2904,6 +2925,201 @@ function createAiGenerateHandler() {
2904
2925
  };
2905
2926
  }
2906
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
+
2907
3123
  // src/endpoints/cannibalization.ts
2908
3124
  function canonicalIntent(keyword) {
2909
3125
  return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
@@ -2916,8 +3132,8 @@ function createCannibalizationHandler(collections, globals = []) {
2916
3132
  }
2917
3133
  const url = new URL(req.url);
2918
3134
  const noCache = url.searchParams.get("nocache") === "1";
2919
- const CACHE_KEY = "cannibalization";
2920
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
3135
+ const CACHE_KEY2 = "cannibalization";
3136
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
2921
3137
  if (cached) {
2922
3138
  return Response.json({ ...cached, cached: true });
2923
3139
  }
@@ -3011,7 +3227,7 @@ function createCannibalizationHandler(collections, globals = []) {
3011
3227
  totalAffectedPages
3012
3228
  }
3013
3229
  };
3014
- seoCache.set(CACHE_KEY, responseData);
3230
+ seoCache.set(CACHE_KEY2, responseData);
3015
3231
  return Response.json({ ...responseData, cached: false });
3016
3232
  } catch (error) {
3017
3233
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -3222,8 +3438,8 @@ function createExternalLinksHandler(collections, globals = []) {
3222
3438
  }
3223
3439
  const url = new URL(req.url);
3224
3440
  const noCache = url.searchParams.get("nocache") === "1";
3225
- const CACHE_KEY = "external-links";
3226
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
3441
+ const CACHE_KEY2 = "external-links";
3442
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
3227
3443
  if (cached) {
3228
3444
  return Response.json({ ...cached, cached: true });
3229
3445
  }
@@ -3303,7 +3519,7 @@ function createExternalLinksHandler(collections, globals = []) {
3303
3519
  return a.ok ? 1 : -1;
3304
3520
  });
3305
3521
  const responseData = { results, stats };
3306
- seoCache.set(CACHE_KEY, responseData);
3522
+ seoCache.set(CACHE_KEY2, responseData);
3307
3523
  return Response.json({ ...responseData, cached: false });
3308
3524
  } catch (error) {
3309
3525
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -4297,8 +4513,8 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
4297
4513
  }
4298
4514
  const url = new URL(req.url);
4299
4515
  const noCache = url.searchParams.get("nocache") === "1";
4300
- const CACHE_KEY = "keyword-research";
4301
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
4516
+ const CACHE_KEY2 = "keyword-research";
4517
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
4302
4518
  if (cached) {
4303
4519
  return Response.json({ ...cached, cached: true });
4304
4520
  }
@@ -4474,7 +4690,7 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
4474
4690
  suggestionsCount: suggestions.length
4475
4691
  }
4476
4692
  };
4477
- seoCache.set(CACHE_KEY, responseData);
4693
+ seoCache.set(CACHE_KEY2, responseData);
4478
4694
  return Response.json({ ...responseData, cached: false });
4479
4695
  } catch (error) {
4480
4696
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -4611,8 +4827,8 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4611
4827
  }
4612
4828
  const url = new URL(req.url);
4613
4829
  const noCache = url.searchParams.get("nocache") === "1";
4614
- const CACHE_KEY = "link-graph";
4615
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
4830
+ const CACHE_KEY2 = "link-graph";
4831
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
4616
4832
  if (cached) {
4617
4833
  return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
4618
4834
  }
@@ -4699,7 +4915,7 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4699
4915
  avgDegree
4700
4916
  };
4701
4917
  const responseData = { nodes, edges, stats };
4702
- seoCache.set(CACHE_KEY, responseData);
4918
+ seoCache.set(CACHE_KEY2, responseData);
4703
4919
  return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
4704
4920
  } catch (error) {
4705
4921
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -5058,8 +5274,8 @@ function createRedirectChainsHandler(redirectsCollection) {
5058
5274
  }
5059
5275
  const url = new URL(req.url || "", "http://localhost");
5060
5276
  const noCache = url.searchParams.get("nocache") === "1";
5061
- const CACHE_KEY = "redirect-chains";
5062
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
5277
+ const CACHE_KEY2 = "redirect-chains";
5278
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
5063
5279
  if (cached) {
5064
5280
  return Response.json({ ...cached, cached: true });
5065
5281
  }
@@ -5128,7 +5344,7 @@ function createRedirectChainsHandler(redirectsCollection) {
5128
5344
  totalRedirects: allRedirects.length
5129
5345
  }
5130
5346
  };
5131
- seoCache.set(CACHE_KEY, responseData);
5347
+ seoCache.set(CACHE_KEY2, responseData);
5132
5348
  return Response.json({ ...responseData, cached: false });
5133
5349
  } catch (error) {
5134
5350
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -5171,8 +5387,8 @@ function createDuplicateContentHandler(collections) {
5171
5387
  const url = new URL(req.url || "", "http://localhost");
5172
5388
  const noCache = url.searchParams.get("nocache") === "1";
5173
5389
  const threshold = parseFloat(url.searchParams.get("threshold") || "") || SIMILARITY_THRESHOLD;
5174
- const CACHE_KEY = `duplicate-content-${threshold}`;
5175
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
5390
+ const CACHE_KEY2 = `duplicate-content-${threshold}`;
5391
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
5176
5392
  if (cached) {
5177
5393
  return Response.json({ ...cached, cached: true });
5178
5394
  }
@@ -5229,7 +5445,7 @@ function createDuplicateContentHandler(collections) {
5229
5445
  threshold
5230
5446
  }
5231
5447
  };
5232
- seoCache.set(CACHE_KEY, responseData);
5448
+ seoCache.set(CACHE_KEY2, responseData);
5233
5449
  return Response.json({ ...responseData, cached: false });
5234
5450
  } catch (error) {
5235
5451
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -6485,8 +6701,8 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
6485
6701
  try {
6486
6702
  await payload.find({
6487
6703
  collection: collectionSlug,
6488
- limit: 500,
6489
- depth: 1,
6704
+ limit: 100,
6705
+ depth: 0,
6490
6706
  overrideAccess: true
6491
6707
  });
6492
6708
  payload.logger.info(`[seo] warm-cache: pre-loaded ${collectionSlug}`);
@@ -6497,7 +6713,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
6497
6713
  try {
6498
6714
  await payload.findGlobal({
6499
6715
  slug: globalSlug,
6500
- depth: 1,
6716
+ depth: 0,
6501
6717
  overrideAccess: true
6502
6718
  });
6503
6719
  payload.logger.info(`[seo] warm-cache: pre-loaded global ${globalSlug}`);
@@ -7517,6 +7733,8 @@ var fr = {
7517
7733
  },
7518
7734
  seoView: {
7519
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.",
7520
7738
  errorSaving: "Erreur lors de la sauvegarde",
7521
7739
  auditTitle: "Audit SEO",
7522
7740
  pagesAnalyzed: "pages analys\xE9es",
@@ -7959,7 +8177,19 @@ var fr = {
7959
8177
  generateMeta: "G\xE9n\xE9rer les meta",
7960
8178
  metaTitle: "Meta Title",
7961
8179
  metaDescription: "Meta Description",
7962
- 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."
7963
8193
  },
7964
8194
  scoreHistory: {
7965
8195
  loading: "Chargement de l'historique...",
@@ -8095,6 +8325,8 @@ var en = {
8095
8325
  },
8096
8326
  seoView: {
8097
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.",
8098
8330
  errorSaving: "Error during save",
8099
8331
  auditTitle: "SEO Audit",
8100
8332
  pagesAnalyzed: "pages analyzed",
@@ -8537,7 +8769,19 @@ var en = {
8537
8769
  generateMeta: "Generate meta",
8538
8770
  metaTitle: "Meta Title",
8539
8771
  metaDescription: "Meta Description",
8540
- 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."
8541
8785
  },
8542
8786
  scoreHistory: {
8543
8787
  loading: "Loading history...",
@@ -8933,7 +9177,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8933
9177
  if (features.aiFeatures) {
8934
9178
  pluginEndpoints.push(
8935
9179
  { path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
8936
- { 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) }
8937
9182
  );
8938
9183
  }
8939
9184
  if (features.cannibalization) {