@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/README.md CHANGED
@@ -637,7 +637,9 @@ All endpoints are prefixed with the configured `endpointBasePath` (default: `/se
637
637
  | `POST` | `/suggest-links` | Internal link suggestions for a page |
638
638
  | `POST` | `/create-redirect` | Create a single redirect entry |
639
639
  | `GET` `POST` `PATCH` `DELETE` | `/redirects` | Full CRUD for redirect management |
640
- | `POST` | `/ai-generate` | AI-powered meta title/description generation |
640
+ | `POST` | `/ai-generate` | Heuristic meta title/description generation (no API key needed) |
641
+ | `POST` | `/ai-rewrite` | Rewrite a single meta field (Claude if `ANTHROPIC_API_KEY` set, else heuristic) |
642
+ | `POST` | `/ai-optimize` | **AI SEO Optimize** — scan a page, propose optimized meta (Claude Opus 4.8 by default), server-validated; applied in one click from the sidebar |
641
643
  | `GET` | `/cannibalization` | Detect keyword cannibalization |
642
644
  | `POST` | `/external-links` | Check external link status (live HTTP checks with SSRF protection) |
643
645
  | `GET` | `/sitemap-config` | Sitemap configuration data |
@@ -647,6 +649,36 @@ All endpoints are prefixed with the configured `endpointBasePath` (default: `/se
647
649
  | `GET` | `/link-graph` | Internal link graph data |
648
650
  | `GET` `POST` `DELETE` | `/seo-logs` | 404 log management (POST supports secret-header auth) |
649
651
 
652
+ ### AI SEO Optimize (`/ai-optimize`)
653
+
654
+ One-click **"scan → propose → apply"** assistant in the editor sidebar (button **“Optimiser avec l'IA”**):
655
+
656
+ 1. Runs the real SEO engine on the current page and collects the failing/warning meta checks.
657
+ 2. Sends the content + detected issues to Claude, which proposes an optimized **meta title**, **meta description** and (only when missing) a **focus keyword**, applying the same SEO 2026 rules the engine enforces.
658
+ 3. The editor reviews the *current → suggested* diff + rationale and **applies in one click** — the fields are filled, then saved as usual.
659
+
660
+ Scope is **meta only** by design: the SEO 2026 analysis classifies mass AI-generated body content as a spam/penalty risk, so the feature never rewrites page content.
661
+
662
+ **Setup (opt-in, billed to your own Anthropic key):**
663
+
664
+ | Env var | Default | Purpose |
665
+ |---|---|---|
666
+ | `ANTHROPIC_API_KEY` | — | Your Anthropic API key, read **only** server-side (never from the client). **Without it, the feature falls back to the built-in heuristic generators.** |
667
+ | `SEO_AI_MODEL` | `claude-opus-4-8` | Override the Claude model (e.g. `claude-haiku-4-5` for a cheaper/faster option). |
668
+
669
+ The endpoint is gated behind `features.aiFeatures` (default `true`). Suggestions are **validated/clamped server-side** (title ≤ 70, description ≤ 160, focus keyword only filled when empty) so what gets applied is always rule-compliant, regardless of the model's output.
670
+
671
+ ### Low-memory hosting (e.g. Infomaniak)
672
+
673
+ The site-wide audit (`/admin/seo` dashboard) is the heaviest operation. On constrained shared Node hosting it is built **single-flight, in the background**: the first uncached load returns `202` and the dashboard shows a “generating…” state while it computes, then polls until ready — it never blocks or OOM-kills the process. Tune it via env vars if needed:
674
+
675
+ | Env var | Default | Purpose |
676
+ |---|---|---|
677
+ | `SEO_AUDIT_BATCH_SIZE` | `15` | Documents processed per batch. Lower it on very small hosts. |
678
+ | `SEO_AUDIT_MAX_DOCS` | `1500` | Hard cap on documents audited. |
679
+
680
+ For the lowest-memory tiers you can also set `features: { warmCache: false }` to skip the startup/hourly pre-load entirely.
681
+
650
682
  <img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="line">
651
683
 
652
684
  ## SEO Rules Reference
package/dist/client.cjs CHANGED
@@ -1058,6 +1058,8 @@ var fr = {
1058
1058
  },
1059
1059
  seoView: {
1060
1060
  loadingAudit: "Chargement de l'audit SEO...",
1061
+ 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)",
1062
+ buildTimeout: "La g\xE9n\xE9ration de l'audit prend plus de temps que pr\xE9vu. R\xE9essayez dans quelques instants.",
1061
1063
  errorSaving: "Erreur lors de la sauvegarde",
1062
1064
  auditTitle: "Audit SEO",
1063
1065
  pagesAnalyzed: "pages analys\xE9es",
@@ -1500,7 +1502,19 @@ var fr = {
1500
1502
  generateMeta: "G\xE9n\xE9rer les meta",
1501
1503
  metaTitle: "Meta Title",
1502
1504
  metaDescription: "Meta Description",
1503
- emptyValue: "(vide)"
1505
+ emptyValue: "(vide)",
1506
+ optimizeWithAi: "Optimiser avec l'IA",
1507
+ optimizeIntro: "L'IA analyse la page et propose des meta optimis\xE9es (titre, description, mot-cl\xE9). V\xE9rifiez puis appliquez.",
1508
+ optimizeRunning: "Analyse en cours\u2026",
1509
+ applyAll: "Appliquer",
1510
+ applied: "Appliqu\xE9",
1511
+ whyChanges: "Pourquoi ces changements",
1512
+ labelCurrent: "Actuel",
1513
+ labelSuggested: "Sugg\xE9r\xE9",
1514
+ labelFocusKeyword: "Mot-cl\xE9 cible",
1515
+ heuristicNote: "Suggestions heuristiques (cl\xE9 API Claude non configur\xE9e).",
1516
+ applySaveHint: "Champs remplis \u2014 pensez \xE0 enregistrer le document.",
1517
+ noMetaChange: "Aucun changement propos\xE9."
1504
1518
  },
1505
1519
  scoreHistory: {
1506
1520
  loading: "Chargement de l'historique...",
@@ -1636,6 +1650,8 @@ var en = {
1636
1650
  },
1637
1651
  seoView: {
1638
1652
  loadingAudit: "Loading SEO audit...",
1653
+ 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)",
1654
+ buildTimeout: "The audit is taking longer than expected. Please try again in a moment.",
1639
1655
  errorSaving: "Error during save",
1640
1656
  auditTitle: "SEO Audit",
1641
1657
  pagesAnalyzed: "pages analyzed",
@@ -2078,7 +2094,19 @@ var en = {
2078
2094
  generateMeta: "Generate meta",
2079
2095
  metaTitle: "Meta Title",
2080
2096
  metaDescription: "Meta Description",
2081
- emptyValue: "(empty)"
2097
+ emptyValue: "(empty)",
2098
+ optimizeWithAi: "Optimize with AI",
2099
+ optimizeIntro: "AI analyzes the page and proposes optimized meta tags (title, description, keyword). Review, then apply.",
2100
+ optimizeRunning: "Analyzing\u2026",
2101
+ applyAll: "Apply",
2102
+ applied: "Applied",
2103
+ whyChanges: "Why these changes",
2104
+ labelCurrent: "Current",
2105
+ labelSuggested: "Suggested",
2106
+ labelFocusKeyword: "Focus keyword",
2107
+ heuristicNote: "Heuristic suggestions (Claude API key not configured).",
2108
+ applySaveHint: "Fields filled \u2014 remember to save the document.",
2109
+ noMetaChange: "No changes proposed."
2082
2110
  },
2083
2111
  scoreHistory: {
2084
2112
  loading: "Loading history...",
@@ -6484,9 +6512,11 @@ var C2 = {
6484
6512
  white: "#fff",
6485
6513
  green: "#22c55e",
6486
6514
  red: "#ef4444",
6515
+ bg: "#fafafa",
6487
6516
  textPrimary: "var(--theme-text, #1a1a1a)",
6488
6517
  textSecondary: "var(--theme-elevation-600, #6b7280)",
6489
6518
  border: "var(--theme-border-color, #000)",
6519
+ inputBg: "var(--theme-input-bg, #fff)",
6490
6520
  surfaceBg: "var(--theme-elevation-0, #fff)",
6491
6521
  surface50: "var(--theme-elevation-50, #f9fafb)"
6492
6522
  };
@@ -6986,10 +7016,48 @@ function useInternalLinkSuggestions(documentId, collection, textContent) {
6986
7016
  }, [textContent, documentId, collection]);
6987
7017
  return { suggestions, loading };
6988
7018
  }
7019
+ function AiDiffRow({
7020
+ label,
7021
+ current,
7022
+ suggested,
7023
+ labelCurrent,
7024
+ labelSuggested,
7025
+ emptyValue,
7026
+ C: colors
7027
+ }) {
7028
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginBottom: 10 }, children: [
7029
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 10, fontWeight: 700, color: colors.textSecondary, textTransform: "uppercase", marginBottom: 4 }, children: label }),
7030
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { fontSize: 11, color: colors.textSecondary, marginBottom: 3 }, children: [
7031
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { style: { fontWeight: 700 }, children: [
7032
+ labelCurrent,
7033
+ ": "
7034
+ ] }),
7035
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { textDecoration: current ? "line-through" : "none", opacity: 0.7 }, children: current || emptyValue })
7036
+ ] }),
7037
+ /* @__PURE__ */ jsxRuntime.jsxs(
7038
+ "div",
7039
+ {
7040
+ style: {
7041
+ padding: "6px 10px",
7042
+ borderRadius: 6,
7043
+ border: `1px solid var(--theme-elevation-200, #e5e7eb)`,
7044
+ backgroundColor: colors.surface50,
7045
+ fontSize: 12,
7046
+ color: colors.textPrimary,
7047
+ lineHeight: 1.5
7048
+ },
7049
+ children: [
7050
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: 10, fontWeight: 700, color: colors.green, textTransform: "uppercase", marginRight: 6 }, children: labelSuggested }),
7051
+ suggested || emptyValue
7052
+ ]
7053
+ }
7054
+ )
7055
+ ] });
7056
+ }
6989
7057
  var SeoAnalyzer = () => {
6990
7058
  const locale = useSeoLocale();
6991
7059
  const t = getDashboardT(locale);
6992
- const [formFields] = ui.useAllFormFields();
7060
+ const [formFields, dispatchFields] = ui.useAllFormFields();
6993
7061
  const initialScoreRef = React4.useRef(null);
6994
7062
  const [suggestionsOpen, setSuggestionsOpen] = React4.useState(true);
6995
7063
  const [cannibalizationExpanded, setCannibalizationExpanded] = React4.useState(false);
@@ -6998,6 +7066,10 @@ var SeoAnalyzer = () => {
6998
7066
  const [aiGenerating, setAiGenerating] = React4.useState(false);
6999
7067
  const [aiResult, setAiResult] = React4.useState(null);
7000
7068
  const [aiCopied, setAiCopied] = React4.useState(null);
7069
+ const [aiOptimizing, setAiOptimizing] = React4.useState(false);
7070
+ const [aiOptimizeResult, setAiOptimizeResult] = React4.useState(null);
7071
+ const [aiOptimizeApplied, setAiOptimizeApplied] = React4.useState(false);
7072
+ const [aiOptimizeError, setAiOptimizeError] = React4.useState(false);
7001
7073
  const getFieldValue = React4.useCallback(
7002
7074
  (path) => {
7003
7075
  if (!formFields) return void 0;
@@ -7725,6 +7797,145 @@ var SeoAnalyzer = () => {
7725
7797
  }
7726
7798
  )
7727
7799
  ] }),
7800
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginBottom: 12 }, children: [
7801
+ /* @__PURE__ */ jsxRuntime.jsx(
7802
+ "button",
7803
+ {
7804
+ type: "button",
7805
+ disabled: aiOptimizing || !documentId,
7806
+ title: !documentId ? t.seoAnalyzer.applySaveHint : void 0,
7807
+ onClick: async () => {
7808
+ setAiOptimizing(true);
7809
+ setAiOptimizeResult(null);
7810
+ setAiOptimizeApplied(false);
7811
+ setAiOptimizeError(false);
7812
+ try {
7813
+ const res = await fetch("/api/seo-plugin/ai-optimize", {
7814
+ method: "POST",
7815
+ credentials: "include",
7816
+ headers: { "Content-Type": "application/json" },
7817
+ body: JSON.stringify({ collection: currentCollection, id: documentId })
7818
+ });
7819
+ if (res.ok) {
7820
+ setAiOptimizeResult(await res.json());
7821
+ } else {
7822
+ setAiOptimizeError(true);
7823
+ }
7824
+ } catch {
7825
+ setAiOptimizeError(true);
7826
+ }
7827
+ setAiOptimizing(false);
7828
+ },
7829
+ style: {
7830
+ display: "flex",
7831
+ alignItems: "center",
7832
+ gap: 6,
7833
+ width: "100%",
7834
+ padding: "10px 14px",
7835
+ borderRadius: 8,
7836
+ border: `2px solid ${C2.border}`,
7837
+ backgroundColor: "#7c3aed",
7838
+ color: "#fff",
7839
+ fontWeight: 800,
7840
+ fontSize: 12,
7841
+ cursor: aiOptimizing || !documentId ? "not-allowed" : "pointer",
7842
+ opacity: aiOptimizing || !documentId ? 0.6 : 1,
7843
+ textTransform: "uppercase",
7844
+ letterSpacing: "0.04em",
7845
+ justifyContent: "center",
7846
+ boxShadow: "2px 2px 0 0 var(--theme-border-color, rgba(0,0,0,1))"
7847
+ },
7848
+ children: aiOptimizing ? t.seoAnalyzer.optimizeRunning : `\u2728 ${t.seoAnalyzer.optimizeWithAi}`
7849
+ }
7850
+ ),
7851
+ aiOptimizeError && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { marginTop: 8, padding: "8px 12px", borderRadius: 6, fontSize: 11, color: C2.white, backgroundColor: C2.red }, children: t.common.loadingError }),
7852
+ aiOptimizeResult && /* @__PURE__ */ jsxRuntime.jsxs(
7853
+ "div",
7854
+ {
7855
+ style: {
7856
+ marginTop: 8,
7857
+ padding: "12px 14px",
7858
+ borderRadius: 8,
7859
+ border: `2px solid ${C2.border}`,
7860
+ backgroundColor: C2.surfaceBg
7861
+ },
7862
+ children: [
7863
+ aiOptimizeResult.method === "heuristic" && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 10, color: C2.textSecondary, marginBottom: 8, fontStyle: "italic" }, children: t.seoAnalyzer.heuristicNote }),
7864
+ /* @__PURE__ */ jsxRuntime.jsx(
7865
+ AiDiffRow,
7866
+ {
7867
+ label: t.seoAnalyzer.metaTitle,
7868
+ current: aiOptimizeResult.current.metaTitle,
7869
+ suggested: aiOptimizeResult.suggestions.metaTitle,
7870
+ labelCurrent: t.seoAnalyzer.labelCurrent,
7871
+ labelSuggested: t.seoAnalyzer.labelSuggested,
7872
+ emptyValue: t.seoAnalyzer.emptyValue,
7873
+ C: C2
7874
+ }
7875
+ ),
7876
+ /* @__PURE__ */ jsxRuntime.jsx(
7877
+ AiDiffRow,
7878
+ {
7879
+ label: t.seoAnalyzer.metaDescription,
7880
+ current: aiOptimizeResult.current.metaDescription,
7881
+ suggested: aiOptimizeResult.suggestions.metaDescription,
7882
+ labelCurrent: t.seoAnalyzer.labelCurrent,
7883
+ labelSuggested: t.seoAnalyzer.labelSuggested,
7884
+ emptyValue: t.seoAnalyzer.emptyValue,
7885
+ C: C2
7886
+ }
7887
+ ),
7888
+ aiOptimizeResult.suggestions.focusKeyword && aiOptimizeResult.suggestions.focusKeyword !== aiOptimizeResult.current.focusKeyword && /* @__PURE__ */ jsxRuntime.jsx(
7889
+ AiDiffRow,
7890
+ {
7891
+ label: t.seoAnalyzer.labelFocusKeyword,
7892
+ current: aiOptimizeResult.current.focusKeyword,
7893
+ suggested: aiOptimizeResult.suggestions.focusKeyword,
7894
+ labelCurrent: t.seoAnalyzer.labelCurrent,
7895
+ labelSuggested: t.seoAnalyzer.labelSuggested,
7896
+ emptyValue: t.seoAnalyzer.emptyValue,
7897
+ C: C2
7898
+ }
7899
+ ),
7900
+ aiOptimizeResult.suggestions.rationale.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: { marginTop: 4, marginBottom: 10 }, children: [
7901
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { fontSize: 10, fontWeight: 700, color: C2.textSecondary, textTransform: "uppercase", marginBottom: 4 }, children: t.seoAnalyzer.whyChanges }),
7902
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { style: { margin: 0, paddingLeft: 16, fontSize: 11, color: C2.textPrimary, lineHeight: 1.5 }, children: aiOptimizeResult.suggestions.rationale.map((r, i) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: r }, i)) })
7903
+ ] }),
7904
+ /* @__PURE__ */ jsxRuntime.jsx(
7905
+ "button",
7906
+ {
7907
+ type: "button",
7908
+ disabled: aiOptimizeApplied,
7909
+ onClick: () => {
7910
+ const sug = aiOptimizeResult.suggestions;
7911
+ if (sug.metaTitle) dispatchFields({ type: "UPDATE", path: "meta.title", value: sug.metaTitle });
7912
+ if (sug.metaDescription) dispatchFields({ type: "UPDATE", path: "meta.description", value: sug.metaDescription });
7913
+ if (sug.focusKeyword && sug.focusKeyword !== aiOptimizeResult.current.focusKeyword) {
7914
+ dispatchFields({ type: "UPDATE", path: "focusKeyword", value: sug.focusKeyword });
7915
+ }
7916
+ setAiOptimizeApplied(true);
7917
+ },
7918
+ style: {
7919
+ width: "100%",
7920
+ padding: "9px 14px",
7921
+ borderRadius: 6,
7922
+ border: `2px solid ${C2.border}`,
7923
+ backgroundColor: aiOptimizeApplied ? C2.green : C2.cyan,
7924
+ color: aiOptimizeApplied ? C2.white : C2.black,
7925
+ fontWeight: 800,
7926
+ fontSize: 11,
7927
+ textTransform: "uppercase",
7928
+ letterSpacing: "0.04em",
7929
+ cursor: aiOptimizeApplied ? "default" : "pointer"
7930
+ },
7931
+ children: aiOptimizeApplied ? `\u2713 ${t.seoAnalyzer.applied}` : t.seoAnalyzer.applyAll
7932
+ }
7933
+ ),
7934
+ aiOptimizeApplied && /* @__PURE__ */ jsxRuntime.jsx("div", { style: { marginTop: 6, fontSize: 10, color: C2.textSecondary, textAlign: "center" }, children: t.seoAnalyzer.applySaveHint })
7935
+ ]
7936
+ }
7937
+ )
7938
+ ] }),
7728
7939
  suggestions.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(
7729
7940
  "div",
7730
7941
  {
@@ -9064,6 +9275,7 @@ function SeoView() {
9064
9275
  const [items, setItems] = React4.useState([]);
9065
9276
  const [stats, setStats] = React4.useState(null);
9066
9277
  const [loading, setLoading] = React4.useState(true);
9278
+ const [building, setBuilding] = React4.useState(false);
9067
9279
  const [error, setError] = React4.useState(null);
9068
9280
  const [filter, setFilter] = React4.useState("all");
9069
9281
  const [scoreFilter, setScoreFilter] = React4.useState("all");
@@ -9080,9 +9292,22 @@ function SeoView() {
9080
9292
  const fetchAudit = React4.useCallback(async (forceRefresh = false) => {
9081
9293
  setLoading(true);
9082
9294
  setError(null);
9295
+ setBuilding(false);
9083
9296
  try {
9084
- const url = forceRefresh ? "/api/seo-plugin/audit?nocache=1" : "/api/seo-plugin/audit";
9085
- const res = await fetch(url, { credentials: "include", cache: "no-store" });
9297
+ const base = "/api/seo-plugin/audit";
9298
+ let res = await fetch(forceRefresh ? `${base}?nocache=1` : base, {
9299
+ credentials: "include",
9300
+ cache: "no-store"
9301
+ });
9302
+ let attempts = 0;
9303
+ const MAX_POLLS = 90;
9304
+ while (res.status === 202 && attempts < MAX_POLLS) {
9305
+ setBuilding(true);
9306
+ await new Promise((r) => setTimeout(r, 3e3));
9307
+ res = await fetch(base, { credentials: "include", cache: "no-store" });
9308
+ attempts++;
9309
+ }
9310
+ if (res.status === 202) throw new Error(t.seoView.buildTimeout);
9086
9311
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
9087
9312
  const data = await res.json();
9088
9313
  setItems(data.results || []);
@@ -9090,6 +9315,7 @@ function SeoView() {
9090
9315
  } catch (e) {
9091
9316
  setError(e instanceof Error ? e.message : t.common.loadingError);
9092
9317
  }
9318
+ setBuilding(false);
9093
9319
  setLoading(false);
9094
9320
  }, [t]);
9095
9321
  React4.useEffect(() => {
@@ -9531,7 +9757,7 @@ function SeoView() {
9531
9757
  fontSize: 14,
9532
9758
  fontFamily: "var(--font-body, system-ui)"
9533
9759
  },
9534
- children: t.seoView.loadingAudit
9760
+ children: building ? t.seoView.buildingAudit : t.seoView.loadingAudit
9535
9761
  }
9536
9762
  );
9537
9763
  }