@consilioweb/payload-seo-analyzer 1.8.1 → 1.10.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
@@ -1,5 +1,5 @@
1
+ import { randomBytes, timingSafeEqual, createCipheriv, scryptSync, createDecipheriv } from 'crypto';
1
2
  import { promises } from 'dns';
2
- import { randomBytes, timingSafeEqual, createCipheriv, createDecipheriv, scryptSync } from 'crypto';
3
3
 
4
4
  // src/constants.ts
5
5
  var TITLE_LENGTH_MIN = 30;
@@ -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,522 @@ 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
+ var ALGO = "aes-256-gcm";
3123
+ var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
3124
+ var FORMAT_VERSION = "v1";
3125
+ function deriveKey(secret) {
3126
+ const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
3127
+ if (explicit) {
3128
+ const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
3129
+ if (buf.length === 32) return buf;
3130
+ throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
3131
+ }
3132
+ if (!secret) {
3133
+ throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
3134
+ }
3135
+ return scryptSync(secret, KEY_NAMESPACE, 32);
3136
+ }
3137
+ function encryptToken(plaintext, secret) {
3138
+ const key = deriveKey(secret);
3139
+ const iv = randomBytes(12);
3140
+ const cipher = createCipheriv(ALGO, key, iv);
3141
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
3142
+ const tag = cipher.getAuthTag();
3143
+ return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
3144
+ }
3145
+ function decryptToken(payload, secret) {
3146
+ const parts = payload.split(":");
3147
+ if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
3148
+ throw new Error("Invalid encrypted token format.");
3149
+ }
3150
+ const key = deriveKey(secret);
3151
+ const iv = Buffer.from(parts[1], "base64");
3152
+ const tag = Buffer.from(parts[2], "base64");
3153
+ const enc = Buffer.from(parts[3], "base64");
3154
+ const decipher = createDecipheriv(ALGO, key, iv);
3155
+ decipher.setAuthTag(tag);
3156
+ const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
3157
+ return dec.toString("utf8");
3158
+ }
3159
+ function safeEqual(a, b) {
3160
+ const ba = Buffer.from(a);
3161
+ const bb = Buffer.from(b);
3162
+ if (ba.length !== bb.length) return false;
3163
+ return timingSafeEqual(ba, bb);
3164
+ }
3165
+
3166
+ // src/helpers/gscClient.ts
3167
+ var GSC_AUTH_COLLECTION = "seo-gsc-auth";
3168
+ var GSC_SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
3169
+ function isGscAdmin(user) {
3170
+ if (!user) return false;
3171
+ if (user.role === "admin") return true;
3172
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3173
+ return false;
3174
+ }
3175
+ function resolveGscSiteUrl(seoConfig) {
3176
+ return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
3177
+ }
3178
+ function getGscOAuthConfig(basePath, seoConfig) {
3179
+ const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
3180
+ const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
3181
+ const siteUrl = resolveGscSiteUrl(seoConfig);
3182
+ if (!clientId || !clientSecret || !siteUrl) return null;
3183
+ return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
3184
+ }
3185
+ async function getOrCreateGscAuthDoc(payload) {
3186
+ const found = await payload.find({ collection: GSC_AUTH_COLLECTION, limit: 1, overrideAccess: true });
3187
+ if (found.docs.length > 0) return found.docs[0];
3188
+ return payload.create({ collection: GSC_AUTH_COLLECTION, data: {}, overrideAccess: true });
3189
+ }
3190
+ async function gscTokenRequest(cfg, body) {
3191
+ const resp = await fetch("https://oauth2.googleapis.com/token", {
3192
+ method: "POST",
3193
+ headers: { "content-type": "application/x-www-form-urlencoded" },
3194
+ body: new URLSearchParams({
3195
+ client_id: cfg.clientId,
3196
+ client_secret: cfg.clientSecret,
3197
+ ...body
3198
+ }).toString()
3199
+ });
3200
+ const json = await resp.json();
3201
+ if (!resp.ok) {
3202
+ throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
3203
+ }
3204
+ return json;
3205
+ }
3206
+ async function getGscAccessToken(payload, cfg, authDoc) {
3207
+ if (!authDoc?.refreshTokenEnc) throw new Error("not_connected");
3208
+ const secret = payload.secret || "";
3209
+ let refreshToken;
3210
+ try {
3211
+ refreshToken = decryptToken(authDoc.refreshTokenEnc, secret);
3212
+ } catch {
3213
+ throw new Error("decrypt_failed");
3214
+ }
3215
+ const tokens = await gscTokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
3216
+ const accessToken = tokens.access_token;
3217
+ if (!accessToken) throw new Error("refresh_failed");
3218
+ return accessToken;
3219
+ }
3220
+ async function queryGscSearchAnalytics(accessToken, property, body) {
3221
+ const resp = await fetch(
3222
+ `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
3223
+ {
3224
+ method: "POST",
3225
+ headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
3226
+ body: JSON.stringify(body)
3227
+ }
3228
+ );
3229
+ const json = await resp.json();
3230
+ if (!resp.ok) {
3231
+ const err = json.error?.message || resp.status;
3232
+ throw new Error(`GSC query failed: ${err}`);
3233
+ }
3234
+ return json.rows || [];
3235
+ }
3236
+
3237
+ // src/endpoints/aiAltText.ts
3238
+ var DEFAULT_MODEL2 = "claude-opus-4-8";
3239
+ var ALT_MAX = 125;
3240
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3241
+ var SUPPORTED_MIME = {
3242
+ "image/jpeg": "image/jpeg",
3243
+ "image/jpg": "image/jpeg",
3244
+ "image/png": "image/png",
3245
+ "image/gif": "image/gif",
3246
+ "image/webp": "image/webp"
3247
+ };
3248
+ function isAdmin4(user) {
3249
+ if (!user) return false;
3250
+ if (user.role === "admin") return true;
3251
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3252
+ return false;
3253
+ }
3254
+ function resolveImageUrl(media, siteUrl) {
3255
+ const raw = typeof media.url === "string" && media.url || (typeof media.filename === "string" ? `/media/${media.filename}` : "");
3256
+ if (!raw) return null;
3257
+ let absolute;
3258
+ if (/^https?:\/\//i.test(raw)) {
3259
+ absolute = raw;
3260
+ } else if (siteUrl) {
3261
+ absolute = `${siteUrl.replace(/\/$/, "")}${raw.startsWith("/") ? "" : "/"}${raw}`;
3262
+ } else {
3263
+ return null;
3264
+ }
3265
+ try {
3266
+ const target = new URL(absolute);
3267
+ if (target.protocol !== "http:" && target.protocol !== "https:") return null;
3268
+ const allowed = /* @__PURE__ */ new Set();
3269
+ if (siteUrl) allowed.add(new URL(siteUrl).origin);
3270
+ if (process.env.SEO_MEDIA_ORIGIN) allowed.add(new URL(process.env.SEO_MEDIA_ORIGIN).origin);
3271
+ if (allowed.size > 0 && !allowed.has(target.origin)) return null;
3272
+ if (allowed.size === 0) return null;
3273
+ return target.toString();
3274
+ } catch {
3275
+ return null;
3276
+ }
3277
+ }
3278
+ async function generateAltText(apiKey, model, base64, mediaType, language, context) {
3279
+ const systemPrompt = `You write concise, descriptive image ALT text for accessibility and SEO.
3280
+ Rules:
3281
+ - Describe what is actually visible in the image.
3282
+ - Maximum ${ALT_MAX} characters.
3283
+ - Write in ${language === "en" ? "English" : "French"}.
3284
+ - Do NOT start with "image of", "photo of", "picture of" or similar.
3285
+ - No quotes around the result. Return ONLY the alt text, nothing else.`;
3286
+ const userText = `Filename: ${context.filename}${context.title ? `
3287
+ Page/context: ${context.title}` : ""}
3288
+ Write the alt text for this image:`;
3289
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
3290
+ method: "POST",
3291
+ headers: {
3292
+ "Content-Type": "application/json",
3293
+ "x-api-key": apiKey,
3294
+ "anthropic-version": "2023-06-01"
3295
+ },
3296
+ body: JSON.stringify({
3297
+ model,
3298
+ max_tokens: 150,
3299
+ system: systemPrompt,
3300
+ messages: [
3301
+ {
3302
+ role: "user",
3303
+ content: [
3304
+ { type: "image", source: { type: "base64", media_type: mediaType, data: base64 } },
3305
+ { type: "text", text: userText }
3306
+ ]
3307
+ }
3308
+ ]
3309
+ })
3310
+ });
3311
+ if (!response.ok) {
3312
+ const body = await response.text();
3313
+ throw new Error(`Claude API error ${response.status}: ${body}`);
3314
+ }
3315
+ const data = await response.json();
3316
+ if (data.stop_reason === "refusal") return null;
3317
+ const text = (data.content?.find((b) => b.type === "text")?.text || "").trim().replace(/^["']|["']$/g, "");
3318
+ if (!text) return null;
3319
+ return text.length > ALT_MAX ? text.slice(0, ALT_MAX).trim() : text;
3320
+ }
3321
+ function createAltTextAuditHandler(uploadsCollection) {
3322
+ return async (req) => {
3323
+ try {
3324
+ if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3325
+ const url = new URL(req.url);
3326
+ const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get("limit") || "50", 10)));
3327
+ try {
3328
+ const missing = await req.payload.find({
3329
+ collection: uploadsCollection,
3330
+ where: { or: [{ alt: { exists: false } }, { alt: { equals: "" } }] },
3331
+ limit,
3332
+ depth: 0,
3333
+ overrideAccess: true
3334
+ });
3335
+ const items = missing.docs.map((d) => ({
3336
+ id: d.id,
3337
+ filename: d.filename || "",
3338
+ url: d.url || "",
3339
+ mimeType: d.mimeType || "",
3340
+ alt: d.alt || ""
3341
+ }));
3342
+ return Response.json(
3343
+ { collection: uploadsCollection, missingCount: missing.totalDocs, items },
3344
+ { headers: { "Cache-Control": "no-store" } }
3345
+ );
3346
+ } catch {
3347
+ return Response.json(
3348
+ { collection: uploadsCollection, missingCount: 0, items: [], note: "no_alt_field" },
3349
+ { headers: { "Cache-Control": "no-store" } }
3350
+ );
3351
+ }
3352
+ } catch (error) {
3353
+ const message = error instanceof Error ? error.message : "Internal server error";
3354
+ req.payload.logger.error(`[seo] alt-text-audit error: ${message}`);
3355
+ return Response.json({ error: message }, { status: 500 });
3356
+ }
3357
+ };
3358
+ }
3359
+ function createAiAltTextHandler(uploadsCollection, seoConfig) {
3360
+ return async (req) => {
3361
+ try {
3362
+ if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3363
+ const body = await parseJsonBody(req);
3364
+ const collection = typeof body.collection === "string" ? body.collection : uploadsCollection;
3365
+ const id = body.id != null ? String(body.id) : void 0;
3366
+ const apply = body.apply === true;
3367
+ const providedAlt = typeof body.altText === "string" ? body.altText.trim() : void 0;
3368
+ if (!id) return Response.json({ error: "Missing required field: id" }, { status: 400 });
3369
+ if (apply && providedAlt) {
3370
+ const alt2 = providedAlt.slice(0, ALT_MAX);
3371
+ await req.payload.update({ collection, id, data: { alt: alt2 }, overrideAccess: true });
3372
+ return Response.json({ alt: alt2, applied: true, method: "manual" });
3373
+ }
3374
+ const apiKey = process.env.ANTHROPIC_API_KEY;
3375
+ if (!apiKey) {
3376
+ return Response.json(
3377
+ { error: "AI not configured. Set ANTHROPIC_API_KEY to generate alt text.", code: "no_api_key" },
3378
+ { status: 400 }
3379
+ );
3380
+ }
3381
+ let media;
3382
+ try {
3383
+ media = await req.payload.findByID({ collection, id, depth: 0, overrideAccess: true });
3384
+ } catch {
3385
+ return Response.json({ error: `Media not found: ${collection}/${id}` }, { status: 404 });
3386
+ }
3387
+ const mime = media.mimeType || "";
3388
+ const mediaType = SUPPORTED_MIME[mime.toLowerCase()];
3389
+ if (!mediaType) {
3390
+ return Response.json(
3391
+ { error: `Unsupported image type for vision: ${mime || "unknown"} (use JPEG, PNG, GIF or WebP).` },
3392
+ { status: 422 }
3393
+ );
3394
+ }
3395
+ const siteUrl = resolveGscSiteUrl(seoConfig);
3396
+ const imageUrl = resolveImageUrl(media, siteUrl);
3397
+ if (!imageUrl) {
3398
+ return Response.json(
3399
+ { error: "Could not resolve a safe image URL (must be on the site origin or SEO_MEDIA_ORIGIN)." },
3400
+ { status: 422 }
3401
+ );
3402
+ }
3403
+ let base64;
3404
+ try {
3405
+ const imgResp = await fetch(imageUrl);
3406
+ if (!imgResp.ok) throw new Error(`fetch ${imgResp.status}`);
3407
+ const buf = Buffer.from(await imgResp.arrayBuffer());
3408
+ if (buf.byteLength > MAX_IMAGE_BYTES) {
3409
+ return Response.json({ error: "Image too large for vision (max 5 MB)." }, { status: 413 });
3410
+ }
3411
+ base64 = buf.toString("base64");
3412
+ } catch (e) {
3413
+ return Response.json({ error: `Could not fetch image: ${e instanceof Error ? e.message : "error"}` }, { status: 502 });
3414
+ }
3415
+ const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL2;
3416
+ const language = seoConfig?.locale === "en" ? "en" : "fr";
3417
+ let alt;
3418
+ try {
3419
+ alt = await generateAltText(apiKey, model, base64, mediaType, language, {
3420
+ filename: media.filename || "",
3421
+ title: typeof body.context === "string" ? body.context : void 0
3422
+ });
3423
+ } catch (e) {
3424
+ req.payload.logger.error(`[seo] ai-alt-text Claude error: ${e instanceof Error ? e.message : "unknown"}`);
3425
+ return Response.json({ error: "Alt-text generation failed." }, { status: 502 });
3426
+ }
3427
+ if (!alt) {
3428
+ return Response.json({ error: "The model did not return alt text (possibly declined)." }, { status: 502 });
3429
+ }
3430
+ let applied = false;
3431
+ if (apply) {
3432
+ await req.payload.update({ collection, id, data: { alt }, overrideAccess: true });
3433
+ applied = true;
3434
+ }
3435
+ return Response.json({ alt, applied, method: "ai", model });
3436
+ } catch (error) {
3437
+ const message = error instanceof Error ? error.message : "Internal server error";
3438
+ req.payload.logger.error(`[seo] ai-alt-text error: ${message}`);
3439
+ return Response.json({ error: message }, { status: 500 });
3440
+ }
3441
+ };
3442
+ }
3443
+
2907
3444
  // src/endpoints/cannibalization.ts
2908
3445
  function canonicalIntent(keyword) {
2909
3446
  return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
@@ -2916,8 +3453,8 @@ function createCannibalizationHandler(collections, globals = []) {
2916
3453
  }
2917
3454
  const url = new URL(req.url);
2918
3455
  const noCache = url.searchParams.get("nocache") === "1";
2919
- const CACHE_KEY = "cannibalization";
2920
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
3456
+ const CACHE_KEY2 = "cannibalization";
3457
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
2921
3458
  if (cached) {
2922
3459
  return Response.json({ ...cached, cached: true });
2923
3460
  }
@@ -3011,7 +3548,7 @@ function createCannibalizationHandler(collections, globals = []) {
3011
3548
  totalAffectedPages
3012
3549
  }
3013
3550
  };
3014
- seoCache.set(CACHE_KEY, responseData);
3551
+ seoCache.set(CACHE_KEY2, responseData);
3015
3552
  return Response.json({ ...responseData, cached: false });
3016
3553
  } catch (error) {
3017
3554
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -3222,8 +3759,8 @@ function createExternalLinksHandler(collections, globals = []) {
3222
3759
  }
3223
3760
  const url = new URL(req.url);
3224
3761
  const noCache = url.searchParams.get("nocache") === "1";
3225
- const CACHE_KEY = "external-links";
3226
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
3762
+ const CACHE_KEY2 = "external-links";
3763
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
3227
3764
  if (cached) {
3228
3765
  return Response.json({ ...cached, cached: true });
3229
3766
  }
@@ -3303,7 +3840,7 @@ function createExternalLinksHandler(collections, globals = []) {
3303
3840
  return a.ok ? 1 : -1;
3304
3841
  });
3305
3842
  const responseData = { results, stats };
3306
- seoCache.set(CACHE_KEY, responseData);
3843
+ seoCache.set(CACHE_KEY2, responseData);
3307
3844
  return Response.json({ ...responseData, cached: false });
3308
3845
  } catch (error) {
3309
3846
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -3488,7 +4025,7 @@ function getDateThreshold(period) {
3488
4025
  return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
3489
4026
  }
3490
4027
  }
3491
- function isAdmin4(user) {
4028
+ function isAdmin5(user) {
3492
4029
  if (!user) return false;
3493
4030
  if (user.role === "admin") return true;
3494
4031
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -3593,7 +4130,7 @@ function createPerformanceHandler() {
3593
4130
  });
3594
4131
  }
3595
4132
  if (method === "POST") {
3596
- if (!isAdmin4(req.user)) {
4133
+ if (!isAdmin5(req.user)) {
3597
4134
  return Response.json({ error: "Admin access required" }, { status: 403 });
3598
4135
  }
3599
4136
  const body = await parseJsonBody(req);
@@ -3891,96 +4428,12 @@ function createCoreWebVitalsHandler(seoConfig) {
3891
4428
  }
3892
4429
  };
3893
4430
  }
3894
- var ALGO = "aes-256-gcm";
3895
- var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
3896
- var FORMAT_VERSION = "v1";
3897
- function deriveKey(secret) {
3898
- const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
3899
- if (explicit) {
3900
- const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
3901
- if (buf.length === 32) return buf;
3902
- throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
3903
- }
3904
- if (!secret) {
3905
- throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
3906
- }
3907
- return scryptSync(secret, KEY_NAMESPACE, 32);
3908
- }
3909
- function encryptToken(plaintext, secret) {
3910
- const key = deriveKey(secret);
3911
- const iv = randomBytes(12);
3912
- const cipher = createCipheriv(ALGO, key, iv);
3913
- const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
3914
- const tag = cipher.getAuthTag();
3915
- return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
3916
- }
3917
- function decryptToken(payload, secret) {
3918
- const parts = payload.split(":");
3919
- if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
3920
- throw new Error("Invalid encrypted token format.");
3921
- }
3922
- const key = deriveKey(secret);
3923
- const iv = Buffer.from(parts[1], "base64");
3924
- const tag = Buffer.from(parts[2], "base64");
3925
- const enc = Buffer.from(parts[3], "base64");
3926
- const decipher = createDecipheriv(ALGO, key, iv);
3927
- decipher.setAuthTag(tag);
3928
- const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
3929
- return dec.toString("utf8");
3930
- }
3931
- function safeEqual(a, b) {
3932
- const ba = Buffer.from(a);
3933
- const bb = Buffer.from(b);
3934
- if (ba.length !== bb.length) return false;
3935
- return timingSafeEqual(ba, bb);
3936
- }
3937
-
3938
- // src/endpoints/gscOAuth.ts
3939
- var AUTH_COLLECTION = "seo-gsc-auth";
3940
- var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
3941
- function isAdmin5(user) {
3942
- if (!user) return false;
3943
- if (user.role === "admin") return true;
3944
- if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3945
- return false;
3946
- }
3947
- function resolveSiteUrl2(seoConfig) {
3948
- return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
3949
- }
3950
- function getOAuthConfig(basePath, seoConfig) {
3951
- const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
3952
- const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
3953
- const siteUrl = resolveSiteUrl2(seoConfig);
3954
- if (!clientId || !clientSecret || !siteUrl) return null;
3955
- return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
3956
- }
3957
- async function getOrCreateAuthDoc(payload) {
3958
- const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
3959
- if (found.docs.length > 0) return found.docs[0];
3960
- return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
3961
- }
3962
- async function tokenRequest(cfg, body) {
3963
- const resp = await fetch("https://oauth2.googleapis.com/token", {
3964
- method: "POST",
3965
- headers: { "content-type": "application/x-www-form-urlencoded" },
3966
- body: new URLSearchParams({
3967
- client_id: cfg.clientId,
3968
- client_secret: cfg.clientSecret,
3969
- ...body
3970
- }).toString()
3971
- });
3972
- const json = await resp.json();
3973
- if (!resp.ok) {
3974
- throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
3975
- }
3976
- return json;
3977
- }
3978
4431
  function createGscStatusHandler(basePath, seoConfig) {
3979
4432
  return async (req) => {
3980
4433
  try {
3981
4434
  if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
3982
- const cfg = getOAuthConfig(basePath, seoConfig);
3983
- const doc = await getOrCreateAuthDoc(req.payload);
4435
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4436
+ const doc = await getOrCreateGscAuthDoc(req.payload);
3984
4437
  return Response.json(
3985
4438
  {
3986
4439
  configured: !!cfg,
@@ -4002,8 +4455,8 @@ function createGscStatusHandler(basePath, seoConfig) {
4002
4455
  function createGscAuthStartHandler(basePath, seoConfig) {
4003
4456
  return async (req) => {
4004
4457
  try {
4005
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4006
- const cfg = getOAuthConfig(basePath, seoConfig);
4458
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4459
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4007
4460
  if (!cfg) {
4008
4461
  return Response.json(
4009
4462
  { error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
@@ -4011,9 +4464,9 @@ function createGscAuthStartHandler(basePath, seoConfig) {
4011
4464
  );
4012
4465
  }
4013
4466
  const state = randomBytes(24).toString("hex");
4014
- const doc = await getOrCreateAuthDoc(req.payload);
4467
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4015
4468
  await req.payload.update({
4016
- collection: AUTH_COLLECTION,
4469
+ collection: GSC_AUTH_COLLECTION,
4017
4470
  id: doc.id,
4018
4471
  data: { pendingState: state },
4019
4472
  overrideAccess: true
@@ -4022,7 +4475,7 @@ function createGscAuthStartHandler(basePath, seoConfig) {
4022
4475
  authUrl.searchParams.set("client_id", cfg.clientId);
4023
4476
  authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
4024
4477
  authUrl.searchParams.set("response_type", "code");
4025
- authUrl.searchParams.set("scope", SCOPES);
4478
+ authUrl.searchParams.set("scope", GSC_SCOPES);
4026
4479
  authUrl.searchParams.set("access_type", "offline");
4027
4480
  authUrl.searchParams.set("prompt", "consent");
4028
4481
  authUrl.searchParams.set("state", state);
@@ -4041,10 +4494,10 @@ function createGscCallbackHandler(basePath, seoConfig) {
4041
4494
  { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
4042
4495
  );
4043
4496
  try {
4044
- if (!isAdmin5(req.user)) {
4497
+ if (!isGscAdmin(req.user)) {
4045
4498
  return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
4046
4499
  }
4047
- const cfg = getOAuthConfig(basePath, seoConfig);
4500
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4048
4501
  if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
4049
4502
  const url = new URL(req.url);
4050
4503
  const code = url.searchParams.get("code");
@@ -4052,11 +4505,11 @@ function createGscCallbackHandler(basePath, seoConfig) {
4052
4505
  const oauthError = url.searchParams.get("error");
4053
4506
  if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
4054
4507
  if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
4055
- const doc = await getOrCreateAuthDoc(req.payload);
4508
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4056
4509
  if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
4057
4510
  return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
4058
4511
  }
4059
- const tokens = await tokenRequest(cfg, {
4512
+ const tokens = await gscTokenRequest(cfg, {
4060
4513
  code,
4061
4514
  redirect_uri: cfg.redirectUri,
4062
4515
  grant_type: "authorization_code"
@@ -4082,14 +4535,14 @@ function createGscCallbackHandler(basePath, seoConfig) {
4082
4535
  const secret = req.payload.secret || "";
4083
4536
  const refreshTokenEnc = encryptToken(refreshToken, secret);
4084
4537
  await req.payload.update({
4085
- collection: AUTH_COLLECTION,
4538
+ collection: GSC_AUTH_COLLECTION,
4086
4539
  id: doc.id,
4087
4540
  data: {
4088
4541
  refreshTokenEnc,
4089
4542
  pendingState: null,
4090
4543
  connectedEmail: email,
4091
4544
  connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
4092
- scope: tokens.scope || SCOPES,
4545
+ scope: tokens.scope || GSC_SCOPES,
4093
4546
  propertyUrl: doc.propertyUrl || cfg.siteUrl
4094
4547
  },
4095
4548
  overrideAccess: true
@@ -4105,26 +4558,26 @@ function createGscCallbackHandler(basePath, seoConfig) {
4105
4558
  function createGscDataHandler(basePath, seoConfig) {
4106
4559
  return async (req) => {
4107
4560
  try {
4108
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4109
- const cfg = getOAuthConfig(basePath, seoConfig);
4561
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4562
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
4110
4563
  if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
4111
- const doc = await getOrCreateAuthDoc(req.payload);
4564
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4112
4565
  if (!doc.refreshTokenEnc) {
4113
4566
  return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
4114
4567
  }
4115
- const secret = req.payload.secret || "";
4116
- let refreshToken;
4568
+ let accessToken;
4117
4569
  try {
4118
- refreshToken = decryptToken(doc.refreshTokenEnc, secret);
4119
- } catch {
4120
- return Response.json(
4121
- { error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
4122
- { status: 409 }
4123
- );
4570
+ accessToken = await getGscAccessToken(req.payload, cfg, doc);
4571
+ } catch (e) {
4572
+ const code = e instanceof Error ? e.message : "refresh_failed";
4573
+ if (code === "decrypt_failed") {
4574
+ return Response.json(
4575
+ { error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
4576
+ { status: 409 }
4577
+ );
4578
+ }
4579
+ return Response.json({ error: "Could not refresh access token." }, { status: 502 });
4124
4580
  }
4125
- const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
4126
- const accessToken = tokens.access_token;
4127
- if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
4128
4581
  const url = new URL(req.url);
4129
4582
  const today = /* @__PURE__ */ new Date();
4130
4583
  const defaultEnd = today.toISOString().slice(0, 10);
@@ -4134,21 +4587,19 @@ function createGscDataHandler(basePath, seoConfig) {
4134
4587
  const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
4135
4588
  const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
4136
4589
  const property = doc.propertyUrl || cfg.siteUrl;
4137
- const gscResp = await fetch(
4138
- `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
4139
- {
4140
- method: "POST",
4141
- headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
4142
- body: JSON.stringify({ startDate, endDate, dimensions: [dimension], rowLimit })
4143
- }
4144
- );
4145
- const gscJson = await gscResp.json();
4146
- if (!gscResp.ok) {
4147
- const err = gscJson.error?.message || gscResp.status;
4148
- return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
4590
+ let rows;
4591
+ try {
4592
+ rows = await queryGscSearchAnalytics(accessToken, property, {
4593
+ startDate,
4594
+ endDate,
4595
+ dimensions: [dimension],
4596
+ rowLimit
4597
+ });
4598
+ } catch (e) {
4599
+ return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
4149
4600
  }
4150
4601
  return Response.json(
4151
- { property, startDate, endDate, dimension, rows: gscJson.rows || [] },
4602
+ { property, startDate, endDate, dimension, rows },
4152
4603
  { headers: { "Cache-Control": "no-store" } }
4153
4604
  );
4154
4605
  } catch (error) {
@@ -4161,10 +4612,10 @@ function createGscDataHandler(basePath, seoConfig) {
4161
4612
  function createGscDisconnectHandler() {
4162
4613
  return async (req) => {
4163
4614
  try {
4164
- if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4165
- const doc = await getOrCreateAuthDoc(req.payload);
4615
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
4616
+ const doc = await getOrCreateGscAuthDoc(req.payload);
4166
4617
  await req.payload.update({
4167
- collection: AUTH_COLLECTION,
4618
+ collection: GSC_AUTH_COLLECTION,
4168
4619
  id: doc.id,
4169
4620
  data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
4170
4621
  overrideAccess: true
@@ -4297,8 +4748,8 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
4297
4748
  }
4298
4749
  const url = new URL(req.url);
4299
4750
  const noCache = url.searchParams.get("nocache") === "1";
4300
- const CACHE_KEY = "keyword-research";
4301
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
4751
+ const CACHE_KEY2 = "keyword-research";
4752
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
4302
4753
  if (cached) {
4303
4754
  return Response.json({ ...cached, cached: true });
4304
4755
  }
@@ -4474,7 +4925,7 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
4474
4925
  suggestionsCount: suggestions.length
4475
4926
  }
4476
4927
  };
4477
- seoCache.set(CACHE_KEY, responseData);
4928
+ seoCache.set(CACHE_KEY2, responseData);
4478
4929
  return Response.json({ ...responseData, cached: false });
4479
4930
  } catch (error) {
4480
4931
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -4611,8 +5062,8 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4611
5062
  }
4612
5063
  const url = new URL(req.url);
4613
5064
  const noCache = url.searchParams.get("nocache") === "1";
4614
- const CACHE_KEY = "link-graph";
4615
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
5065
+ const CACHE_KEY2 = "link-graph";
5066
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
4616
5067
  if (cached) {
4617
5068
  return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
4618
5069
  }
@@ -4699,7 +5150,7 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4699
5150
  avgDegree
4700
5151
  };
4701
5152
  const responseData = { nodes, edges, stats };
4702
- seoCache.set(CACHE_KEY, responseData);
5153
+ seoCache.set(CACHE_KEY2, responseData);
4703
5154
  return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
4704
5155
  } catch (error) {
4705
5156
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -4709,7 +5160,33 @@ function createLinkGraphHandler(targetCollections, globals = []) {
4709
5160
  };
4710
5161
  }
4711
5162
 
4712
- // src/endpoints/schemaGenerator.ts
5163
+ // src/helpers/buildSchema.ts
5164
+ var SCHEMA_TYPES = [
5165
+ "Article",
5166
+ "LocalBusiness",
5167
+ "BreadcrumbList",
5168
+ "FAQPage",
5169
+ "Product",
5170
+ "Organization",
5171
+ "Person",
5172
+ "Event",
5173
+ "Recipe",
5174
+ "Video"
5175
+ ];
5176
+ function resolveSiteUrl2(explicit) {
5177
+ return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
5178
+ }
5179
+ function getSchemaImageUrl(metaImage, heroMedia, siteUrl) {
5180
+ const img = metaImage || heroMedia;
5181
+ if (!img) return void 0;
5182
+ if (typeof img.url === "string") {
5183
+ return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
5184
+ }
5185
+ if (typeof img.filename === "string") {
5186
+ return `${siteUrl}/media/${img.filename}`;
5187
+ }
5188
+ return void 0;
5189
+ }
4713
5190
  function detectSchemaType(collection, doc) {
4714
5191
  if (collection === "posts") return "Article";
4715
5192
  const layout = doc.layout;
@@ -4730,16 +5207,25 @@ function detectSchemaType(collection, doc) {
4730
5207
  }
4731
5208
  return "Article";
4732
5209
  }
5210
+ function buildAuthors(authors) {
5211
+ return authors.filter((a) => a && typeof a === "object").map((a) => {
5212
+ const author = a;
5213
+ return {
5214
+ "@type": "Person",
5215
+ name: author.name || author.firstName || "Author"
5216
+ };
5217
+ });
5218
+ }
4733
5219
  function buildArticleSchema(doc, siteUrl) {
4734
5220
  const meta = doc.meta || {};
4735
5221
  const heroMedia = doc.hero?.media;
4736
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5222
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
4737
5223
  const schema = {
4738
5224
  "@context": "https://schema.org",
4739
5225
  "@type": "Article",
4740
5226
  headline: meta.title || doc.title || "",
4741
5227
  description: meta.description || "",
4742
- datePublished: doc.createdAt || void 0,
5228
+ datePublished: doc.publishedAt || doc.createdAt || void 0,
4743
5229
  dateModified: doc.updatedAt || void 0,
4744
5230
  mainEntityOfPage: {
4745
5231
  "@type": "WebPage",
@@ -4838,7 +5324,7 @@ function buildFAQSchema(doc) {
4838
5324
  function buildProductSchema(doc, siteUrl) {
4839
5325
  const meta = doc.meta || {};
4840
5326
  const heroMedia = doc.hero?.media;
4841
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5327
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
4842
5328
  const schema = {
4843
5329
  "@context": "https://schema.org",
4844
5330
  "@type": "Product",
@@ -4911,7 +5397,7 @@ function buildEventSchema(doc, siteUrl) {
4911
5397
  function buildRecipeSchema(doc, siteUrl) {
4912
5398
  const meta = doc.meta || {};
4913
5399
  const heroMedia = doc.hero?.media;
4914
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5400
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
4915
5401
  const schema = {
4916
5402
  "@context": "https://schema.org",
4917
5403
  "@type": "Recipe",
@@ -4928,7 +5414,7 @@ function buildRecipeSchema(doc, siteUrl) {
4928
5414
  function buildVideoSchema(doc, siteUrl) {
4929
5415
  const meta = doc.meta || {};
4930
5416
  const heroMedia = doc.hero?.media;
4931
- const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
5417
+ const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
4932
5418
  const schema = {
4933
5419
  "@context": "https://schema.org",
4934
5420
  "@type": "VideoObject",
@@ -4941,26 +5427,51 @@ function buildVideoSchema(doc, siteUrl) {
4941
5427
  if (doc.duration) schema.duration = doc.duration;
4942
5428
  return schema;
4943
5429
  }
4944
- function getImageUrl(metaImage, heroMedia, siteUrl) {
4945
- const img = metaImage || heroMedia;
4946
- if (!img) return void 0;
4947
- if (typeof img.url === "string") {
4948
- return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
4949
- }
4950
- if (typeof img.filename === "string") {
4951
- return `${siteUrl}/media/${img.filename}`;
4952
- }
4953
- return void 0;
4954
- }
4955
- function buildAuthors(authors) {
4956
- return authors.filter((a) => a && typeof a === "object").map((a) => {
4957
- const author = a;
4958
- return {
4959
- "@type": "Person",
4960
- name: author.name || author.firstName || "Author"
4961
- };
4962
- });
5430
+ function buildJsonLd(doc, options = {}) {
5431
+ const siteUrl = resolveSiteUrl2(options.siteUrl);
5432
+ const schemaType = options.type || detectSchemaType(options.collection || "", doc);
5433
+ let jsonLd;
5434
+ switch (schemaType) {
5435
+ case "Article":
5436
+ jsonLd = buildArticleSchema(doc, siteUrl);
5437
+ break;
5438
+ case "LocalBusiness":
5439
+ jsonLd = buildLocalBusinessSchema(doc, siteUrl);
5440
+ break;
5441
+ case "BreadcrumbList":
5442
+ jsonLd = buildBreadcrumbSchema(doc, siteUrl);
5443
+ break;
5444
+ case "FAQPage":
5445
+ jsonLd = buildFAQSchema(doc);
5446
+ break;
5447
+ case "Product":
5448
+ jsonLd = buildProductSchema(doc, siteUrl);
5449
+ break;
5450
+ case "Organization":
5451
+ jsonLd = buildOrganizationSchema(doc, siteUrl);
5452
+ break;
5453
+ case "Person":
5454
+ jsonLd = buildPersonSchema(doc, siteUrl);
5455
+ break;
5456
+ case "Event":
5457
+ jsonLd = buildEventSchema(doc, siteUrl);
5458
+ break;
5459
+ case "Recipe":
5460
+ jsonLd = buildRecipeSchema(doc, siteUrl);
5461
+ break;
5462
+ case "Video":
5463
+ jsonLd = buildVideoSchema(doc, siteUrl);
5464
+ break;
5465
+ }
5466
+ const cleaned = JSON.parse(JSON.stringify(jsonLd));
5467
+ return { type: schemaType, jsonLd: cleaned };
5468
+ }
5469
+ function renderJsonLdScript(doc, options = {}) {
5470
+ const { jsonLd } = buildJsonLd(doc, options);
5471
+ return `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
4963
5472
  }
5473
+
5474
+ // src/endpoints/schemaGenerator.ts
4964
5475
  function createSchemaGeneratorHandler(targetCollections) {
4965
5476
  return async (req) => {
4966
5477
  try {
@@ -4972,74 +5483,30 @@ function createSchemaGeneratorHandler(targetCollections) {
4972
5483
  const id = url.searchParams.get("id");
4973
5484
  const typeOverrideRaw = url.searchParams.get("type");
4974
5485
  if (!collection || !id) {
4975
- return Response.json(
4976
- { error: "Missing required query params: collection, id" },
4977
- { status: 400 }
4978
- );
5486
+ return Response.json({ error: "Missing required query params: collection, id" }, { status: 400 });
4979
5487
  }
4980
- const validTypes = ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization", "Person", "Event", "Recipe", "Video"];
4981
- if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
5488
+ if (typeOverrideRaw !== null && !SCHEMA_TYPES.includes(typeOverrideRaw)) {
4982
5489
  return Response.json(
4983
- { error: `Invalid schema type. Valid types: ${validTypes.join(", ")}` },
5490
+ { error: `Invalid schema type. Valid types: ${SCHEMA_TYPES.join(", ")}` },
4984
5491
  { status: 400 }
4985
5492
  );
4986
5493
  }
4987
- const typeOverride = typeOverrideRaw;
5494
+ const typeOverride = typeOverrideRaw || void 0;
4988
5495
  if (targetCollections && !targetCollections.includes(collection)) {
4989
5496
  return Response.json({ error: "Collection not allowed" }, { status: 403 });
4990
5497
  }
4991
5498
  let doc;
4992
5499
  try {
4993
- const result = await req.payload.findByID({
4994
- collection,
4995
- id,
4996
- depth: 1,
4997
- overrideAccess: true
4998
- });
5500
+ const result = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
4999
5501
  doc = result;
5000
5502
  } catch {
5001
5503
  return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
5002
5504
  }
5003
- const siteUrl = (process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
5004
- const schemaType = typeOverride || detectSchemaType(collection, doc);
5005
- let jsonLd;
5006
- switch (schemaType) {
5007
- case "Article":
5008
- jsonLd = buildArticleSchema(doc, siteUrl);
5009
- break;
5010
- case "LocalBusiness":
5011
- jsonLd = buildLocalBusinessSchema(doc, siteUrl);
5012
- break;
5013
- case "BreadcrumbList":
5014
- jsonLd = buildBreadcrumbSchema(doc, siteUrl);
5015
- break;
5016
- case "FAQPage":
5017
- jsonLd = buildFAQSchema(doc);
5018
- break;
5019
- case "Product":
5020
- jsonLd = buildProductSchema(doc, siteUrl);
5021
- break;
5022
- case "Organization":
5023
- jsonLd = buildOrganizationSchema(doc, siteUrl);
5024
- break;
5025
- case "Person":
5026
- jsonLd = buildPersonSchema(doc, siteUrl);
5027
- break;
5028
- case "Event":
5029
- jsonLd = buildEventSchema(doc, siteUrl);
5030
- break;
5031
- case "Recipe":
5032
- jsonLd = buildRecipeSchema(doc, siteUrl);
5033
- break;
5034
- case "Video":
5035
- jsonLd = buildVideoSchema(doc, siteUrl);
5036
- break;
5037
- }
5038
- const cleaned = JSON.parse(JSON.stringify(jsonLd));
5505
+ const { type, jsonLd } = buildJsonLd(doc, { collection, type: typeOverride });
5039
5506
  return Response.json({
5040
- type: schemaType,
5041
- jsonLd: cleaned,
5042
- html: `<script type="application/ld+json">${JSON.stringify(cleaned, null, 2)}</script>`
5507
+ type,
5508
+ jsonLd,
5509
+ html: `<script type="application/ld+json">${JSON.stringify(jsonLd, null, 2)}</script>`
5043
5510
  });
5044
5511
  } catch (error) {
5045
5512
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -5058,8 +5525,8 @@ function createRedirectChainsHandler(redirectsCollection) {
5058
5525
  }
5059
5526
  const url = new URL(req.url || "", "http://localhost");
5060
5527
  const noCache = url.searchParams.get("nocache") === "1";
5061
- const CACHE_KEY = "redirect-chains";
5062
- const cached = noCache ? null : seoCache.get(CACHE_KEY);
5528
+ const CACHE_KEY2 = "redirect-chains";
5529
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
5063
5530
  if (cached) {
5064
5531
  return Response.json({ ...cached, cached: true });
5065
5532
  }
@@ -5128,7 +5595,7 @@ function createRedirectChainsHandler(redirectsCollection) {
5128
5595
  totalRedirects: allRedirects.length
5129
5596
  }
5130
5597
  };
5131
- seoCache.set(CACHE_KEY, responseData);
5598
+ seoCache.set(CACHE_KEY2, responseData);
5132
5599
  return Response.json({ ...responseData, cached: false });
5133
5600
  } catch (error) {
5134
5601
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -5171,8 +5638,8 @@ function createDuplicateContentHandler(collections) {
5171
5638
  const url = new URL(req.url || "", "http://localhost");
5172
5639
  const noCache = url.searchParams.get("nocache") === "1";
5173
5640
  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);
5641
+ const CACHE_KEY2 = `duplicate-content-${threshold}`;
5642
+ const cached = noCache ? null : seoCache.get(CACHE_KEY2);
5176
5643
  if (cached) {
5177
5644
  return Response.json({ ...cached, cached: true });
5178
5645
  }
@@ -5229,7 +5696,7 @@ function createDuplicateContentHandler(collections) {
5229
5696
  threshold
5230
5697
  }
5231
5698
  };
5232
- seoCache.set(CACHE_KEY, responseData);
5699
+ seoCache.set(CACHE_KEY2, responseData);
5233
5700
  return Response.json({ ...responseData, cached: false });
5234
5701
  } catch (error) {
5235
5702
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -6189,6 +6656,219 @@ function createSeoGscAuthCollection() {
6189
6656
  };
6190
6657
  }
6191
6658
 
6659
+ // src/collections/SeoRankHistory.ts
6660
+ function createSeoRankHistoryCollection() {
6661
+ return {
6662
+ slug: "seo-rank-history",
6663
+ admin: {
6664
+ custom: { navHidden: true }
6665
+ },
6666
+ access: {
6667
+ read: ({ req }) => !!req.user,
6668
+ create: ({ req }) => req.user?.role === "admin",
6669
+ update: ({ req }) => req.user?.role === "admin",
6670
+ delete: ({ req }) => req.user?.role === "admin"
6671
+ },
6672
+ timestamps: false,
6673
+ fields: [
6674
+ {
6675
+ name: "query",
6676
+ type: "text",
6677
+ required: true,
6678
+ index: true,
6679
+ admin: { description: "Search query (keyword) tracked" }
6680
+ },
6681
+ {
6682
+ name: "page",
6683
+ type: "text",
6684
+ admin: { description: "Landing page URL (when tracked by page)" }
6685
+ },
6686
+ {
6687
+ name: "position",
6688
+ type: "number",
6689
+ required: true,
6690
+ admin: { description: "Average SERP position over the snapshot window (lower is better)" }
6691
+ },
6692
+ {
6693
+ name: "clicks",
6694
+ type: "number",
6695
+ admin: { description: "Clicks over the snapshot window" }
6696
+ },
6697
+ {
6698
+ name: "impressions",
6699
+ type: "number",
6700
+ admin: { description: "Impressions over the snapshot window" }
6701
+ },
6702
+ {
6703
+ name: "ctr",
6704
+ type: "number",
6705
+ admin: { description: "Click-through rate (0-1) over the snapshot window" }
6706
+ },
6707
+ {
6708
+ name: "property",
6709
+ type: "text",
6710
+ admin: { description: "GSC property the snapshot was taken from" }
6711
+ },
6712
+ {
6713
+ // YYYY-MM-DD — used to deduplicate one snapshot per query per day.
6714
+ name: "dateKey",
6715
+ type: "text",
6716
+ required: true,
6717
+ index: true,
6718
+ admin: { description: "Snapshot day (YYYY-MM-DD), one snapshot per query per day" }
6719
+ },
6720
+ {
6721
+ name: "snapshotDate",
6722
+ type: "date",
6723
+ required: true,
6724
+ index: true,
6725
+ admin: { description: "Exact timestamp of the snapshot" }
6726
+ }
6727
+ ]
6728
+ };
6729
+ }
6730
+
6731
+ // src/endpoints/rankTracking.ts
6732
+ var RANK_COLLECTION = "seo-rank-history";
6733
+ var round1 = (n) => Math.round(n * 10) / 10;
6734
+ async function runRankSnapshot(payload, basePath, seoConfig, opts) {
6735
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
6736
+ if (!cfg) return { ok: false, reason: "not_configured" };
6737
+ const authDoc = await getOrCreateGscAuthDoc(payload);
6738
+ if (!authDoc.refreshTokenEnc) return { ok: false, reason: "not_connected" };
6739
+ let accessToken;
6740
+ try {
6741
+ accessToken = await getGscAccessToken(payload, cfg, authDoc);
6742
+ } catch (e) {
6743
+ return { ok: false, reason: e instanceof Error ? e.message : "refresh_failed" };
6744
+ }
6745
+ const property = authDoc.propertyUrl || cfg.siteUrl;
6746
+ const windowDays = Math.min(90, Math.max(1, 7));
6747
+ const rowLimit = Math.min(1e3, Math.max(1, 100));
6748
+ const end = new Date(Date.now() - 2 * 864e5);
6749
+ const start = new Date(end.getTime() - (windowDays - 1) * 864e5);
6750
+ const endDate = end.toISOString().slice(0, 10);
6751
+ const startDate = start.toISOString().slice(0, 10);
6752
+ let rows;
6753
+ try {
6754
+ rows = await queryGscSearchAnalytics(accessToken, property, {
6755
+ startDate,
6756
+ endDate,
6757
+ dimensions: ["query"],
6758
+ rowLimit
6759
+ });
6760
+ } catch (e) {
6761
+ return { ok: false, reason: e instanceof Error ? e.message : "query_failed" };
6762
+ }
6763
+ const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
6764
+ const existing = await payload.find({
6765
+ collection: RANK_COLLECTION,
6766
+ where: { dateKey: { equals: todayKey } },
6767
+ limit: 2e3,
6768
+ depth: 0,
6769
+ overrideAccess: true
6770
+ });
6771
+ const already = new Set(existing.docs.map((d) => d.query));
6772
+ let stored = 0;
6773
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
6774
+ for (const r of rows) {
6775
+ const query = r.keys?.[0];
6776
+ if (!query || already.has(query)) continue;
6777
+ try {
6778
+ await payload.create({
6779
+ collection: RANK_COLLECTION,
6780
+ data: {
6781
+ query,
6782
+ position: round1(r.position),
6783
+ clicks: r.clicks,
6784
+ impressions: r.impressions,
6785
+ ctr: r.ctr,
6786
+ property,
6787
+ dateKey: todayKey,
6788
+ snapshotDate: nowIso
6789
+ },
6790
+ overrideAccess: true
6791
+ });
6792
+ stored++;
6793
+ } catch (e) {
6794
+ payload.logger.warn(`[seo] rank-snapshot: skipped "${query}": ${e instanceof Error ? e.message : "error"}`);
6795
+ }
6796
+ }
6797
+ return { ok: true, stored, scanned: rows.length, startDate, endDate };
6798
+ }
6799
+ function createRankSnapshotHandler(basePath, seoConfig) {
6800
+ return async (req) => {
6801
+ try {
6802
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
6803
+ const result = await runRankSnapshot(req.payload, basePath, seoConfig);
6804
+ if (!result.ok) {
6805
+ const status = result.reason === "not_connected" || result.reason === "not_configured" ? 409 : 502;
6806
+ return Response.json(result, { status, headers: { "Cache-Control": "no-store" } });
6807
+ }
6808
+ return Response.json(result, { headers: { "Cache-Control": "no-store" } });
6809
+ } catch (error) {
6810
+ const message = error instanceof Error ? error.message : "Internal server error";
6811
+ req.payload.logger.error(`[seo] rank-snapshot error: ${message}`);
6812
+ return Response.json({ error: message }, { status: 500 });
6813
+ }
6814
+ };
6815
+ }
6816
+ function createRankHistoryHandler() {
6817
+ return async (req) => {
6818
+ try {
6819
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
6820
+ const url = new URL(req.url);
6821
+ const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") || "35", 10)));
6822
+ const since = new Date(Date.now() - days * 864e5).toISOString();
6823
+ const all = await req.payload.find({
6824
+ collection: RANK_COLLECTION,
6825
+ where: { snapshotDate: { greater_than: since } },
6826
+ sort: "-snapshotDate",
6827
+ limit: 5e3,
6828
+ depth: 0,
6829
+ overrideAccess: true
6830
+ });
6831
+ const byQuery = /* @__PURE__ */ new Map();
6832
+ for (const d of all.docs) {
6833
+ const q = d.query;
6834
+ const arr = byQuery.get(q);
6835
+ if (arr) arr.push(d);
6836
+ else byQuery.set(q, [d]);
6837
+ }
6838
+ const movers = Array.from(byQuery.entries()).map(([query, snaps]) => {
6839
+ const latest = snaps[0];
6840
+ const previous = snaps.find((s) => s.dateKey !== latest.dateKey) || null;
6841
+ const delta = previous ? round1(previous.position - latest.position) : 0;
6842
+ return {
6843
+ query,
6844
+ page: latest.page || null,
6845
+ position: latest.position,
6846
+ previousPosition: previous ? previous.position : null,
6847
+ delta,
6848
+ clicks: latest.clicks ?? 0,
6849
+ impressions: latest.impressions ?? 0,
6850
+ ctr: latest.ctr ?? 0,
6851
+ snapshotDate: latest.snapshotDate,
6852
+ history: snaps.slice(0, 30).map((s) => ({ date: s.dateKey, position: s.position })).reverse()
6853
+ };
6854
+ });
6855
+ movers.sort((a, b) => (b.impressions || 0) - (a.impressions || 0));
6856
+ return Response.json(
6857
+ {
6858
+ count: movers.length,
6859
+ lastSnapshot: all.docs[0]?.snapshotDate || null,
6860
+ movers
6861
+ },
6862
+ { headers: { "Cache-Control": "no-store" } }
6863
+ );
6864
+ } catch (error) {
6865
+ const message = error instanceof Error ? error.message : "Internal server error";
6866
+ req.payload.logger.error(`[seo] rank-history error: ${message}`);
6867
+ return Response.json({ error: message }, { status: 500 });
6868
+ }
6869
+ };
6870
+ }
6871
+
6192
6872
  // src/rateLimiter.ts
6193
6873
  function createRateLimiter(maxRequests, windowMs) {
6194
6874
  const store = /* @__PURE__ */ new Map();
@@ -6485,8 +7165,8 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
6485
7165
  try {
6486
7166
  await payload.find({
6487
7167
  collection: collectionSlug,
6488
- limit: 500,
6489
- depth: 1,
7168
+ limit: 100,
7169
+ depth: 0,
6490
7170
  overrideAccess: true
6491
7171
  });
6492
7172
  payload.logger.info(`[seo] warm-cache: pre-loaded ${collectionSlug}`);
@@ -6497,7 +7177,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
6497
7177
  try {
6498
7178
  await payload.findGlobal({
6499
7179
  slug: globalSlug,
6500
- depth: 1,
7180
+ depth: 0,
6501
7181
  overrideAccess: true
6502
7182
  });
6503
7183
  payload.logger.info(`[seo] warm-cache: pre-loaded global ${globalSlug}`);
@@ -6533,6 +7213,296 @@ function stopCacheWarmUp() {
6533
7213
  }
6534
7214
  }
6535
7215
 
7216
+ // src/rankTracker.ts
7217
+ var SNAPSHOT_INTERVAL = 24 * 60 * 60 * 1e3;
7218
+ var STARTUP_DELAY2 = 30 * 1e3;
7219
+ var intervalId2 = null;
7220
+ var listenersAttached2 = false;
7221
+ async function doSnapshot(payload, basePath, seoConfig) {
7222
+ try {
7223
+ const result = await runRankSnapshot(payload, basePath, seoConfig);
7224
+ if (result.ok) {
7225
+ payload.logger.info(`[seo] rank-tracker: snapshot stored ${result.stored}/${result.scanned} queries`);
7226
+ } else if (result.reason !== "not_connected" && result.reason !== "not_configured") {
7227
+ payload.logger.warn(`[seo] rank-tracker: snapshot skipped (${result.reason})`);
7228
+ }
7229
+ } catch (error) {
7230
+ payload.logger.error(`[seo] rank-tracker error: ${error instanceof Error ? error.message : "unknown"}`);
7231
+ }
7232
+ }
7233
+ function startRankTracker(payload, basePath, seoConfig) {
7234
+ setTimeout(() => {
7235
+ void doSnapshot(payload, basePath, seoConfig);
7236
+ }, STARTUP_DELAY2);
7237
+ intervalId2 = setInterval(() => {
7238
+ void doSnapshot(payload, basePath, seoConfig);
7239
+ }, SNAPSHOT_INTERVAL);
7240
+ if (!listenersAttached2) {
7241
+ const cleanup = () => stopRankTracker();
7242
+ process.on("SIGTERM", cleanup);
7243
+ process.on("SIGINT", cleanup);
7244
+ listenersAttached2 = true;
7245
+ }
7246
+ payload.logger.info("[seo] rank-tracker: scheduled startup + every 24h");
7247
+ }
7248
+ function stopRankTracker() {
7249
+ if (intervalId2) {
7250
+ clearInterval(intervalId2);
7251
+ intervalId2 = null;
7252
+ }
7253
+ }
7254
+
7255
+ // src/endpoints/alerts.ts
7256
+ function isAdmin8(user) {
7257
+ if (!user) return false;
7258
+ if (user.role === "admin") return true;
7259
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
7260
+ return false;
7261
+ }
7262
+ function getAlertConfig() {
7263
+ return {
7264
+ webhookUrl: process.env.SEO_ALERT_WEBHOOK_URL || "",
7265
+ emails: (process.env.SEO_ALERT_EMAIL || "").split(",").map((s) => s.trim()).filter(Boolean),
7266
+ scoreDrop: parseInt(process.env.SEO_ALERT_SCORE_DROP || "10", 10) || 10,
7267
+ positionDrop: parseInt(process.env.SEO_ALERT_POSITION_DROP || "5", 10) || 5,
7268
+ windowHours: Math.max(1, parseInt(process.env.SEO_ALERT_WINDOW_HOURS || "24", 10) || 24)
7269
+ };
7270
+ }
7271
+ var round12 = (n) => Math.round(n * 10) / 10;
7272
+ async function buildAlertDigest(payload, cfg) {
7273
+ const now = Date.now();
7274
+ const since = new Date(now - cfg.windowHours * 36e5).toISOString();
7275
+ const scoreRegressions = [];
7276
+ try {
7277
+ const hist = await payload.find({
7278
+ collection: "seo-score-history",
7279
+ where: { snapshotDate: { greater_than: new Date(now - 14 * 864e5).toISOString() } },
7280
+ sort: "-snapshotDate",
7281
+ limit: 5e3,
7282
+ depth: 0,
7283
+ overrideAccess: true
7284
+ });
7285
+ const byDoc = /* @__PURE__ */ new Map();
7286
+ for (const h of hist.docs) {
7287
+ const key = `${h.documentId}::${h.collection}`;
7288
+ const arr = byDoc.get(key);
7289
+ if (arr) arr.push(h);
7290
+ else byDoc.set(key, [h]);
7291
+ }
7292
+ for (const [key, snaps] of byDoc) {
7293
+ const latest = snaps[0];
7294
+ const oldest = snaps[snaps.length - 1];
7295
+ const drop = oldest.score - latest.score;
7296
+ if (drop >= cfg.scoreDrop) {
7297
+ const [documentId, collection] = key.split("::");
7298
+ scoreRegressions.push({
7299
+ documentId,
7300
+ collection,
7301
+ from: oldest.score,
7302
+ to: latest.score,
7303
+ drop
7304
+ });
7305
+ }
7306
+ }
7307
+ scoreRegressions.sort((a, b) => b.drop - a.drop);
7308
+ } catch {
7309
+ }
7310
+ const newNotFound = [];
7311
+ try {
7312
+ const logs = await payload.find({
7313
+ collection: "seo-logs",
7314
+ where: {
7315
+ and: [{ lastSeen: { greater_than: since } }, { ignored: { not_equals: true } }]
7316
+ },
7317
+ sort: "-count",
7318
+ limit: 50,
7319
+ depth: 0,
7320
+ overrideAccess: true
7321
+ });
7322
+ for (const l of logs.docs) {
7323
+ newNotFound.push({
7324
+ url: l.url || "",
7325
+ count: l.count || 1,
7326
+ lastSeen: l.lastSeen || ""
7327
+ });
7328
+ }
7329
+ } catch {
7330
+ }
7331
+ const rankDrops = [];
7332
+ try {
7333
+ const ranks = await payload.find({
7334
+ collection: "seo-rank-history",
7335
+ where: { snapshotDate: { greater_than: new Date(now - 35 * 864e5).toISOString() } },
7336
+ sort: "-snapshotDate",
7337
+ limit: 5e3,
7338
+ depth: 0,
7339
+ overrideAccess: true
7340
+ });
7341
+ const byQuery = /* @__PURE__ */ new Map();
7342
+ for (const r of ranks.docs) {
7343
+ const q = r.query;
7344
+ const arr = byQuery.get(q);
7345
+ if (arr) arr.push(r);
7346
+ else byQuery.set(q, [r]);
7347
+ }
7348
+ for (const [query, snaps] of byQuery) {
7349
+ const latest = snaps[0];
7350
+ const previous = snaps.find((s) => s.dateKey !== latest.dateKey);
7351
+ if (!previous) continue;
7352
+ const drop = round12(latest.position - previous.position);
7353
+ if (drop >= cfg.positionDrop) {
7354
+ rankDrops.push({ query, from: previous.position, to: latest.position, drop });
7355
+ }
7356
+ }
7357
+ rankDrops.sort((a, b) => b.drop - a.drop);
7358
+ } catch {
7359
+ }
7360
+ const totalIssues = scoreRegressions.length + newNotFound.length + rankDrops.length;
7361
+ return {
7362
+ since,
7363
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7364
+ scoreRegressions,
7365
+ newNotFound,
7366
+ rankDrops,
7367
+ totalIssues
7368
+ };
7369
+ }
7370
+ function digestToHtml(digest, siteUrl) {
7371
+ const section = (title, rows) => rows.length ? `<h3 style="margin:18px 0 6px">${title}</h3><ul style="margin:0;padding-left:18px">${rows.join("")}</ul>` : "";
7372
+ const reg = digest.scoreRegressions.slice(0, 20).map((r) => `<li>${r.collection}/${r.documentId} \u2014 score ${r.from} \u2192 <b>${r.to}</b> (\u2212${r.drop})</li>`);
7373
+ const nf = digest.newNotFound.slice(0, 20).map((n) => `<li><code>${n.url}</code> \u2014 ${n.count}\xD7</li>`);
7374
+ const rd = digest.rankDrops.slice(0, 20).map((d) => `<li>\u201C${d.query}\u201D \u2014 #${round12(d.from)} \u2192 <b>#${round12(d.to)}</b> (\u25BC${d.drop})</li>`);
7375
+ return `<div style="font-family:system-ui;max-width:640px">
7376
+ <h2>SEO alert digest${siteUrl ? ` \u2014 ${siteUrl}` : ""}</h2>
7377
+ <p style="color:#6b7280;font-size:13px">${digest.totalIssues} issue(s) since ${new Date(digest.since).toLocaleString()}</p>
7378
+ ${section("\u{1F4C9} Score regressions", reg)}
7379
+ ${section("\u{1F517} New 404s", nf)}
7380
+ ${section("\u{1F53B} Ranking drops", rd)}
7381
+ ${digest.totalIssues === 0 ? "<p>No issues to report. \u{1F389}</p>" : ""}
7382
+ </div>`;
7383
+ }
7384
+ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
7385
+ const channels = { webhook: false, email: false };
7386
+ if (digest.totalIssues === 0) {
7387
+ return { sent: false, reason: "nothing_to_report", channels };
7388
+ }
7389
+ if (cfg.webhookUrl) {
7390
+ try {
7391
+ await fetch(cfg.webhookUrl, {
7392
+ method: "POST",
7393
+ headers: { "content-type": "application/json" },
7394
+ body: JSON.stringify({ type: "seo-alert-digest", siteUrl, digest })
7395
+ });
7396
+ channels.webhook = true;
7397
+ } catch (e) {
7398
+ payload.logger.warn(`[seo] alerts: webhook delivery failed: ${e instanceof Error ? e.message : "error"}`);
7399
+ }
7400
+ }
7401
+ if (cfg.emails.length > 0) {
7402
+ const send = payload.sendEmail;
7403
+ if (typeof send === "function") {
7404
+ try {
7405
+ await send({
7406
+ to: cfg.emails,
7407
+ subject: `SEO alert digest \u2014 ${digest.totalIssues} issue(s)`,
7408
+ html: digestToHtml(digest, siteUrl)
7409
+ });
7410
+ channels.email = true;
7411
+ } catch (e) {
7412
+ payload.logger.warn(`[seo] alerts: email delivery failed: ${e instanceof Error ? e.message : "error"}`);
7413
+ }
7414
+ }
7415
+ }
7416
+ const sent = channels.webhook || channels.email;
7417
+ return { sent, reason: sent ? void 0 : "no_channel_configured", channels };
7418
+ }
7419
+ function createAlertsDigestHandler() {
7420
+ return async (req) => {
7421
+ try {
7422
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7423
+ const cfg = getAlertConfig();
7424
+ const digest = await buildAlertDigest(req.payload, cfg);
7425
+ return Response.json(
7426
+ {
7427
+ digest,
7428
+ config: {
7429
+ webhookConfigured: !!cfg.webhookUrl,
7430
+ emailConfigured: cfg.emails.length > 0,
7431
+ scoreDrop: cfg.scoreDrop,
7432
+ positionDrop: cfg.positionDrop,
7433
+ windowHours: cfg.windowHours
7434
+ }
7435
+ },
7436
+ { headers: { "Cache-Control": "no-store" } }
7437
+ );
7438
+ } catch (error) {
7439
+ const message = error instanceof Error ? error.message : "Internal server error";
7440
+ req.payload.logger.error(`[seo] alerts-digest error: ${message}`);
7441
+ return Response.json({ error: message }, { status: 500 });
7442
+ }
7443
+ };
7444
+ }
7445
+ function createAlertsRunHandler(siteUrl) {
7446
+ return async (req) => {
7447
+ try {
7448
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7449
+ const cfg = getAlertConfig();
7450
+ const digest = await buildAlertDigest(req.payload, cfg);
7451
+ const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
7452
+ return Response.json({ digest, delivery }, { headers: { "Cache-Control": "no-store" } });
7453
+ } catch (error) {
7454
+ const message = error instanceof Error ? error.message : "Internal server error";
7455
+ req.payload.logger.error(`[seo] alerts-run error: ${message}`);
7456
+ return Response.json({ error: message }, { status: 500 });
7457
+ }
7458
+ };
7459
+ }
7460
+
7461
+ // src/alertsScheduler.ts
7462
+ var STARTUP_DELAY3 = 60 * 1e3;
7463
+ var intervalId3 = null;
7464
+ var listenersAttached3 = false;
7465
+ async function runDigest(payload, siteUrl) {
7466
+ try {
7467
+ const cfg = getAlertConfig();
7468
+ if (!cfg.webhookUrl && cfg.emails.length === 0) {
7469
+ return;
7470
+ }
7471
+ const digest = await buildAlertDigest(payload, cfg);
7472
+ const delivery = await deliverAlertDigest(payload, digest, cfg, siteUrl);
7473
+ if (delivery.sent) {
7474
+ payload.logger.info(
7475
+ `[seo] alerts: digest delivered (${digest.totalIssues} issues; webhook=${delivery.channels.webhook} email=${delivery.channels.email})`
7476
+ );
7477
+ }
7478
+ } catch (error) {
7479
+ payload.logger.error(`[seo] alerts scheduler error: ${error instanceof Error ? error.message : "unknown"}`);
7480
+ }
7481
+ }
7482
+ function startAlertsScheduler(payload, siteUrl) {
7483
+ const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
7484
+ const intervalMs = intervalHours * 60 * 60 * 1e3;
7485
+ setTimeout(() => {
7486
+ void runDigest(payload, siteUrl);
7487
+ }, STARTUP_DELAY3);
7488
+ intervalId3 = setInterval(() => {
7489
+ void runDigest(payload, siteUrl);
7490
+ }, intervalMs);
7491
+ if (!listenersAttached3) {
7492
+ const cleanup = () => stopAlertsScheduler();
7493
+ process.on("SIGTERM", cleanup);
7494
+ process.on("SIGINT", cleanup);
7495
+ listenersAttached3 = true;
7496
+ }
7497
+ payload.logger.info(`[seo] alerts: scheduled startup + every ${intervalHours}h`);
7498
+ }
7499
+ function stopAlertsScheduler() {
7500
+ if (intervalId3) {
7501
+ clearInterval(intervalId3);
7502
+ intervalId3 = null;
7503
+ }
7504
+ }
7505
+
6536
7506
  // src/endpoints/generate.ts
6537
7507
  var TYPE_TO_CONFIG_KEY = {
6538
7508
  title: "generateTitle",
@@ -7517,6 +8487,8 @@ var fr = {
7517
8487
  },
7518
8488
  seoView: {
7519
8489
  loadingAudit: "Chargement de l'audit SEO...",
8490
+ 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)",
8491
+ buildTimeout: "La g\xE9n\xE9ration de l'audit prend plus de temps que pr\xE9vu. R\xE9essayez dans quelques instants.",
7520
8492
  errorSaving: "Erreur lors de la sauvegarde",
7521
8493
  auditTitle: "Audit SEO",
7522
8494
  pagesAnalyzed: "pages analys\xE9es",
@@ -7959,7 +8931,19 @@ var fr = {
7959
8931
  generateMeta: "G\xE9n\xE9rer les meta",
7960
8932
  metaTitle: "Meta Title",
7961
8933
  metaDescription: "Meta Description",
7962
- emptyValue: "(vide)"
8934
+ emptyValue: "(vide)",
8935
+ optimizeWithAi: "Optimiser avec l'IA",
8936
+ optimizeIntro: "L'IA analyse la page et propose des meta optimis\xE9es (titre, description, mot-cl\xE9). V\xE9rifiez puis appliquez.",
8937
+ optimizeRunning: "Analyse en cours\u2026",
8938
+ applyAll: "Appliquer",
8939
+ applied: "Appliqu\xE9",
8940
+ whyChanges: "Pourquoi ces changements",
8941
+ labelCurrent: "Actuel",
8942
+ labelSuggested: "Sugg\xE9r\xE9",
8943
+ labelFocusKeyword: "Mot-cl\xE9 cible",
8944
+ heuristicNote: "Suggestions heuristiques (cl\xE9 API Claude non configur\xE9e).",
8945
+ applySaveHint: "Champs remplis \u2014 pensez \xE0 enregistrer le document.",
8946
+ noMetaChange: "Aucun changement propos\xE9."
7963
8947
  },
7964
8948
  scoreHistory: {
7965
8949
  loading: "Chargement de l'historique...",
@@ -8095,6 +9079,8 @@ var en = {
8095
9079
  },
8096
9080
  seoView: {
8097
9081
  loadingAudit: "Loading SEO audit...",
9082
+ 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)",
9083
+ buildTimeout: "The audit is taking longer than expected. Please try again in a moment.",
8098
9084
  errorSaving: "Error during save",
8099
9085
  auditTitle: "SEO Audit",
8100
9086
  pagesAnalyzed: "pages analyzed",
@@ -8537,7 +9523,19 @@ var en = {
8537
9523
  generateMeta: "Generate meta",
8538
9524
  metaTitle: "Meta Title",
8539
9525
  metaDescription: "Meta Description",
8540
- emptyValue: "(empty)"
9526
+ emptyValue: "(empty)",
9527
+ optimizeWithAi: "Optimize with AI",
9528
+ optimizeIntro: "AI analyzes the page and proposes optimized meta tags (title, description, keyword). Review, then apply.",
9529
+ optimizeRunning: "Analyzing\u2026",
9530
+ applyAll: "Apply",
9531
+ applied: "Applied",
9532
+ whyChanges: "Why these changes",
9533
+ labelCurrent: "Current",
9534
+ labelSuggested: "Suggested",
9535
+ labelFocusKeyword: "Focus keyword",
9536
+ heuristicNote: "Heuristic suggestions (Claude API key not configured).",
9537
+ applySaveHint: "Fields filled \u2014 remember to save the document.",
9538
+ noMetaChange: "No changes proposed."
8541
9539
  },
8542
9540
  scoreHistory: {
8543
9541
  loading: "Loading history...",
@@ -8659,6 +9657,7 @@ function buildSeoConfig(pluginConfig) {
8659
9657
  var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8660
9658
  const config = { ...incomingConfig };
8661
9659
  const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
9660
+ const uploadsCollection = pluginConfig.uploadsCollection ?? "media";
8662
9661
  const targetGlobals = pluginConfig.globals ?? [];
8663
9662
  const basePath = pluginConfig.endpointBasePath ?? "/seo-plugin";
8664
9663
  const seoConfig = buildSeoConfig(pluginConfig);
@@ -8681,6 +9680,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8681
9680
  // opt-in — requires Google Cloud OAuth setup + secrets
8682
9681
  warmCache: true,
8683
9682
  // disable on low-memory hosts to skip startup pre-loading
9683
+ alerts: false,
9684
+ // opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
8684
9685
  ...pluginConfig.features
8685
9686
  };
8686
9687
  function hasExistingSeoMeta(fields) {
@@ -8821,7 +9822,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8821
9822
  if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
8822
9823
  if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
8823
9824
  if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
8824
- if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
9825
+ if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection(), createSeoRankHistoryCollection());
8825
9826
  config.collections = [
8826
9827
  ...config.collections || [],
8827
9828
  ...pluginCollections
@@ -8933,7 +9934,10 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8933
9934
  if (features.aiFeatures) {
8934
9935
  pluginEndpoints.push(
8935
9936
  { path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
8936
- { path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) }
9937
+ { path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
9938
+ { path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
9939
+ { path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
9940
+ { path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) }
8937
9941
  );
8938
9942
  }
8939
9943
  if (features.cannibalization) {
@@ -8964,7 +9968,15 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
8964
9968
  { path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
8965
9969
  { path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
8966
9970
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
8967
- { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
9971
+ { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
9972
+ { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
9973
+ { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
9974
+ );
9975
+ }
9976
+ if (features.alerts) {
9977
+ pluginEndpoints.push(
9978
+ { path: `${basePath}/alerts-digest`, method: "get", handler: createAlertsDigestHandler() },
9979
+ { path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
8968
9980
  );
8969
9981
  }
8970
9982
  if (features.keywords) {
@@ -9110,10 +10122,90 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
9110
10122
  if (features.warmCache) {
9111
10123
  startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
9112
10124
  }
10125
+ if (features.gscApi) {
10126
+ startRankTracker(payload, basePath, seoConfig);
10127
+ }
10128
+ if (features.alerts) {
10129
+ startAlertsScheduler(payload, resolveGscSiteUrl(seoConfig));
10130
+ }
9113
10131
  };
9114
10132
  return config;
9115
10133
  };
9116
10134
 
10135
+ // src/helpers/buildMetadata.ts
10136
+ function resolveSiteUrl3(explicit) {
10137
+ return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
10138
+ }
10139
+ function parseRobots(doc, meta) {
10140
+ const raw = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
10141
+ let noindex = false;
10142
+ let nofollow = false;
10143
+ if (raw) {
10144
+ const low = raw.toLowerCase();
10145
+ noindex = low.includes("noindex");
10146
+ nofollow = low.includes("nofollow");
10147
+ }
10148
+ if (doc.noindex === true || meta.noindex === true) noindex = true;
10149
+ if (doc.nofollow === true || meta.nofollow === true) nofollow = true;
10150
+ return { index: !noindex, follow: !nofollow };
10151
+ }
10152
+ function buildLanguages(doc) {
10153
+ const raw = doc.localeAlternates || doc.alternates || doc.hreflang;
10154
+ if (!Array.isArray(raw)) return void 0;
10155
+ const out = {};
10156
+ for (const a of raw) {
10157
+ if (!a || typeof a !== "object") continue;
10158
+ const r = a;
10159
+ const lang = String(r.hreflang || r.locale || r.lang || "");
10160
+ const href = String(r.href || r.url || "");
10161
+ if (lang && href) out[lang] = href;
10162
+ }
10163
+ return Object.keys(out).length ? out : void 0;
10164
+ }
10165
+ function absoluteUrl(value, siteUrl) {
10166
+ if (/^https?:\/\//i.test(value)) return value;
10167
+ return `${siteUrl}${value.startsWith("/") ? "" : "/"}${value}`;
10168
+ }
10169
+ function buildSeoMetadata(doc, options = {}) {
10170
+ const siteUrl = resolveSiteUrl3(options.siteUrl);
10171
+ const meta = doc.meta || {};
10172
+ const rawTitle = meta.title || doc.title || "";
10173
+ const title = options.titleTemplate && rawTitle ? options.titleTemplate.replace("%s", rawTitle) : rawTitle;
10174
+ const description = meta.description || "";
10175
+ const slug = doc.slug || "";
10176
+ const heroMedia = doc.hero?.media;
10177
+ let image = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
10178
+ if (!image && options.defaultImage) image = absoluteUrl(options.defaultImage, siteUrl);
10179
+ const explicitCanonical = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || "";
10180
+ const canonical = explicitCanonical || (siteUrl ? `${siteUrl}${slug ? `/${slug}` : ""}` : void 0);
10181
+ const languages = buildLanguages(doc);
10182
+ const isPost = options.collection === "posts" || doc.isPost === true;
10183
+ const md = {};
10184
+ if (title) md.title = title;
10185
+ if (description) md.description = description;
10186
+ const alternates = {};
10187
+ if (canonical) alternates.canonical = canonical;
10188
+ if (languages) alternates.languages = languages;
10189
+ if (Object.keys(alternates).length) md.alternates = alternates;
10190
+ md.robots = parseRobots(doc, meta);
10191
+ md.openGraph = {
10192
+ ...rawTitle ? { title: rawTitle } : {},
10193
+ ...description ? { description } : {},
10194
+ ...canonical ? { url: canonical } : {},
10195
+ ...options.siteName ? { siteName: options.siteName } : {},
10196
+ type: isPost ? "article" : "website",
10197
+ ...options.locale ? { locale: options.locale } : {},
10198
+ ...image ? { images: [{ url: image }] } : {}
10199
+ };
10200
+ md.twitter = {
10201
+ card: image ? "summary_large_image" : "summary",
10202
+ ...rawTitle ? { title: rawTitle } : {},
10203
+ ...description ? { description } : {},
10204
+ ...image ? { images: [image] } : {}
10205
+ };
10206
+ return md;
10207
+ }
10208
+
9117
10209
  // src/i18n.ts
9118
10210
  var rulesFr = {
9119
10211
  title: {
@@ -13022,4 +14114,4 @@ function analyzeSeo(data, config) {
13022
14114
  return { score, level, checks, ...aiReadiness ? { aiReadiness } : {} };
13023
14115
  }
13024
14116
 
13025
- export { ACTION_VERBS, EVERGREEN_SLUGS, FLESCH_THRESHOLDS, GENERIC_ANCHORS, KEYWORD_DENSITY_MAX, KEYWORD_DENSITY_MIN, KEYWORD_DENSITY_WARN, LEGAL_SLUGS_MAP, MAX_RECURSION_DEPTH, META_DESC_LENGTH_MAX, META_DESC_LENGTH_MIN, MIN_WORDS_FORM, MIN_WORDS_GENERIC, MIN_WORDS_LEGAL, MIN_WORDS_POST, MIN_WORDS_THIN, POWER_WORDS, POWER_WORDS_FR, READABILITY_THRESHOLDS, SCORE_EXCELLENT, SCORE_GOOD, SCORE_OK, STOP_WORDS, STOP_WORD_COMPOUNDS_MAP, TITLE_LENGTH_MAX, TITLE_LENGTH_MIN, UTILITY_SLUGS, WARNING_MULTIPLIER, analyzeSeo, buildSeoInputFromDoc, calculateFlesch, calculateFleschFR, checkHeadingHierarchy, checkImagesInBlocks, countKeywordOccurrences, countLongSections, countSentences, countSyllablesEN, countSyllablesFR, countWords, createAiRewriteHandler, createDuplicateContentHandler, createGenerateHandler, createHistoryHandler, createKeywordResearchHandler, createPerformanceHandler, createRedirectChainsHandler, createSchemaGeneratorHandler, createSeoPerformanceCollection, createSeoScoreHistoryCollection, createSitemapAuditHandler, createTrackSeoScoreHook, detectPageType, detectPassiveVoice, extractHeadingsFromLexical, extractImagesFromLexical, extractLinkUrlsFromLexical, extractLinksFromLexical, extractListsFromLexical, extractTextFromLexical, fetchAllDocs, getActionVerbs, getActionVerbsFR, getDashboardT, getEvergreenSlugs, getGenericAnchors, getLegalSlugs, getPowerWords, getStopWordCompounds, getStopWords, getStopWordsFR, getUtilitySlugs, hasTransitionWord, isStopWordInCompoundExpression, keywordMatchesText, metaFields, normalizeForComparison, registerDashboardTranslations, resolveAnalysisLocale, seoAnalyzerPlugin, seoFields, seoAnalyzerPlugin as seoPlugin, slugifyKeyword };
14117
+ export { ACTION_VERBS, EVERGREEN_SLUGS, FLESCH_THRESHOLDS, GENERIC_ANCHORS, KEYWORD_DENSITY_MAX, KEYWORD_DENSITY_MIN, KEYWORD_DENSITY_WARN, LEGAL_SLUGS_MAP, MAX_RECURSION_DEPTH, META_DESC_LENGTH_MAX, META_DESC_LENGTH_MIN, MIN_WORDS_FORM, MIN_WORDS_GENERIC, MIN_WORDS_LEGAL, MIN_WORDS_POST, MIN_WORDS_THIN, POWER_WORDS, POWER_WORDS_FR, READABILITY_THRESHOLDS, SCHEMA_TYPES, SCORE_EXCELLENT, SCORE_GOOD, SCORE_OK, STOP_WORDS, STOP_WORD_COMPOUNDS_MAP, TITLE_LENGTH_MAX, TITLE_LENGTH_MIN, UTILITY_SLUGS, WARNING_MULTIPLIER, analyzeSeo, buildJsonLd, buildSeoInputFromDoc, buildSeoMetadata, calculateFlesch, calculateFleschFR, checkHeadingHierarchy, checkImagesInBlocks, countKeywordOccurrences, countLongSections, countSentences, countSyllablesEN, countSyllablesFR, countWords, createAiRewriteHandler, createDuplicateContentHandler, createGenerateHandler, createHistoryHandler, createKeywordResearchHandler, createPerformanceHandler, createRedirectChainsHandler, createSchemaGeneratorHandler, createSeoPerformanceCollection, createSeoScoreHistoryCollection, createSitemapAuditHandler, createTrackSeoScoreHook, detectPageType, detectPassiveVoice, detectSchemaType, extractHeadingsFromLexical, extractImagesFromLexical, extractLinkUrlsFromLexical, extractLinksFromLexical, extractListsFromLexical, extractTextFromLexical, fetchAllDocs, getActionVerbs, getActionVerbsFR, getDashboardT, getEvergreenSlugs, getGenericAnchors, getLegalSlugs, getPowerWords, getSchemaImageUrl, getStopWordCompounds, getStopWords, getStopWordsFR, getUtilitySlugs, hasTransitionWord, isStopWordInCompoundExpression, keywordMatchesText, metaFields, normalizeForComparison, registerDashboardTranslations, renderJsonLdScript, resolveAnalysisLocale, seoAnalyzerPlugin, seoFields, seoAnalyzerPlugin as seoPlugin, slugifyKeyword };