@consilioweb/payload-seo-analyzer 1.11.0 → 1.13.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
@@ -1703,6 +1703,7 @@ async function buildAuditCache(payload, collections, globals, seoConfig) {
1703
1703
  });
1704
1704
  for (const doc of result.docs) {
1705
1705
  if (ignoredSlugs.includes(doc.slug)) continue;
1706
+ if (doc._status === "draft") continue;
1706
1707
  if (allResults.length >= MAX_DOCS2) {
1707
1708
  capped = true;
1708
1709
  break collectionsLoop;
@@ -2678,6 +2679,9 @@ function createRedirectsHandler(redirectsCollection) {
2678
2679
  return Response.json({ error: "Missing id or ids" }, { status: 400 });
2679
2680
  }
2680
2681
  if (method === "PATCH") {
2682
+ if (!isAdmin3(req.user)) {
2683
+ return Response.json({ error: "Admin access required" }, { status: 403 });
2684
+ }
2681
2685
  const patchBody = await parseJsonBody(req);
2682
2686
  const id = typeof patchBody.id === "string" ? patchBody.id.trim() : void 0;
2683
2687
  const from = typeof patchBody.from === "string" ? patchBody.from.trim() : void 0;
@@ -3019,6 +3023,77 @@ function sanitizeSuggestions(s, currentFocusKeyword) {
3019
3023
  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
3024
  return { metaTitle, metaDescription, focusKeyword, rationale };
3021
3025
  }
3026
+ async function optimizeDocMeta(payload, opts) {
3027
+ const { collection, id, mergedConfig } = opts;
3028
+ let doc;
3029
+ try {
3030
+ doc = await payload.findByID({ collection, id, depth: 1, overrideAccess: true });
3031
+ } catch {
3032
+ return { ok: false, error: `Document not found: ${collection}/${id}`, status: 404 };
3033
+ }
3034
+ const seoInput = buildSeoInputFromDoc(doc, collection);
3035
+ const analysis = analyzeSeo(seoInput, mergedConfig);
3036
+ const metaGroups = /* @__PURE__ */ new Set(["title", "meta-description", "content", "url", "headings"]);
3037
+ 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);
3038
+ const pageTitle = doc.title || "";
3039
+ const slug = doc.slug || "";
3040
+ const currentFocusKeyword = doc.focusKeyword || "";
3041
+ const currentMetaTitle = seoInput.metaTitle || "";
3042
+ const currentMetaDescription = seoInput.metaDescription || "";
3043
+ const content = extractDocContent(doc).text;
3044
+ const apiKey = opts.apiKey;
3045
+ const model = opts.model || DEFAULT_MODEL;
3046
+ let suggestions;
3047
+ let method;
3048
+ const heuristicFallback = () => ({
3049
+ metaTitle: generateMetaTitle(pageTitle, currentFocusKeyword, slug),
3050
+ metaDescription: generateMetaDescription(content, currentFocusKeyword, slug),
3051
+ focusKeyword: currentFocusKeyword,
3052
+ rationale: []
3053
+ });
3054
+ if (apiKey) {
3055
+ try {
3056
+ const aiResult = await callClaudeOptimize(apiKey, model, {
3057
+ pageTitle,
3058
+ slug,
3059
+ focusKeyword: currentFocusKeyword,
3060
+ currentMetaTitle,
3061
+ currentMetaDescription,
3062
+ issues,
3063
+ content
3064
+ });
3065
+ if (aiResult) {
3066
+ suggestions = aiResult;
3067
+ method = "ai";
3068
+ } else {
3069
+ suggestions = heuristicFallback();
3070
+ method = "heuristic";
3071
+ }
3072
+ } catch (error) {
3073
+ payload.logger.error(`[seo] ai-optimize Claude API error: ${error instanceof Error ? error.message : "unknown"}`);
3074
+ suggestions = heuristicFallback();
3075
+ method = "heuristic";
3076
+ }
3077
+ } else {
3078
+ suggestions = heuristicFallback();
3079
+ method = "heuristic";
3080
+ }
3081
+ const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
3082
+ return {
3083
+ ok: true,
3084
+ title: pageTitle,
3085
+ method,
3086
+ ...method === "ai" ? { model } : {},
3087
+ score: analysis.score,
3088
+ current: {
3089
+ metaTitle: currentMetaTitle,
3090
+ metaDescription: currentMetaDescription,
3091
+ focusKeyword: currentFocusKeyword
3092
+ },
3093
+ suggestions: sanitized,
3094
+ issues
3095
+ };
3096
+ }
3022
3097
  function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
3023
3098
  return async (req) => {
3024
3099
  try {
@@ -3034,83 +3109,27 @@ function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
3034
3109
  if (targetCollections && !targetCollections.includes(collection)) {
3035
3110
  return Response.json({ error: "Collection not allowed" }, { status: 403 });
3036
3111
  }
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
3112
  const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
3050
3113
  reqLocale: req.locale,
3051
3114
  localeMapping
3052
3115
  });
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: []
3116
+ const r = await optimizeDocMeta(req.payload, {
3117
+ collection,
3118
+ id,
3119
+ mergedConfig,
3120
+ apiKey: process.env.ANTHROPIC_API_KEY,
3121
+ model: process.env.SEO_AI_MODEL
3072
3122
  });
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";
3123
+ if (!r.ok) {
3124
+ return Response.json({ error: r.error }, { status: r.status || 500 });
3101
3125
  }
3102
- const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
3103
3126
  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
3127
+ method: r.method,
3128
+ ...r.method === "ai" ? { model: r.model } : {},
3129
+ score: r.score,
3130
+ current: r.current,
3131
+ suggestions: r.suggestions,
3132
+ issues: r.issues
3114
3133
  });
3115
3134
  } catch (error) {
3116
3135
  const message = error instanceof Error ? error.message : "Internal server error";
@@ -3565,6 +3584,177 @@ function createAiContentBriefHandler(targetCollections, seoConfig) {
3565
3584
  };
3566
3585
  }
3567
3586
 
3587
+ // src/endpoints/aiOptimizeBulk.ts
3588
+ var TITLE_MAX = 70;
3589
+ var DESC_MAX = 160;
3590
+ var KEYWORD_MAX2 = 60;
3591
+ function isAdmin5(user) {
3592
+ if (!user) return false;
3593
+ if (user.role === "admin") return true;
3594
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
3595
+ return false;
3596
+ }
3597
+ function createAiOptimizeBulkHandler(targetCollections, seoConfig, localeMapping) {
3598
+ return async (req) => {
3599
+ try {
3600
+ if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
3601
+ const body = await parseJsonBody(req);
3602
+ const rawIds = Array.isArray(body.ids) ? body.ids.map(String) : [];
3603
+ const defaultCollection = typeof body.collection === "string" ? body.collection : void 0;
3604
+ const apply = body.apply === true;
3605
+ const MAX = 100;
3606
+ const limit = Math.min(MAX, Math.max(1, parseInt(String(body.limit ?? 50), 10) || 50));
3607
+ const corrections = Array.isArray(body.corrections) ? body.corrections : null;
3608
+ if (apply && corrections && corrections.length > 0) {
3609
+ let applied2 = 0;
3610
+ let i2 = 0;
3611
+ const rows = [];
3612
+ for (const c of corrections.slice(0, MAX)) {
3613
+ i2++;
3614
+ const collection = typeof c.collection === "string" ? c.collection : defaultCollection;
3615
+ const id = c.id != null ? String(c.id) : void 0;
3616
+ if (!collection || !id || collection.startsWith("global:")) continue;
3617
+ if (targetCollections && !targetCollections.includes(collection)) continue;
3618
+ const metaTitle = typeof c.metaTitle === "string" ? truncateWords(c.metaTitle.trim(), TITLE_MAX) : "";
3619
+ const metaDescription = typeof c.metaDescription === "string" ? truncateWords(c.metaDescription.trim(), DESC_MAX) : "";
3620
+ const focusKeyword = typeof c.focusKeyword === "string" ? c.focusKeyword.trim().slice(0, KEYWORD_MAX2) : "";
3621
+ const patch = {};
3622
+ if (metaTitle || metaDescription) patch.meta = { title: metaTitle, description: metaDescription };
3623
+ if (focusKeyword) patch.focusKeyword = focusKeyword;
3624
+ try {
3625
+ if (Object.keys(patch).length > 0) {
3626
+ await req.payload.update({ collection, id, data: patch, overrideAccess: true });
3627
+ applied2++;
3628
+ rows.push({ collection, id, applied: true });
3629
+ }
3630
+ } catch (e) {
3631
+ rows.push({ collection, id, applied: false, error: e instanceof Error ? e.message : "error" });
3632
+ }
3633
+ if (i2 % 5 === 0) await new Promise((resolve) => setImmediate(resolve));
3634
+ }
3635
+ return Response.json(
3636
+ { processed: rows.length, applied: applied2, mode: "corrections", results: rows },
3637
+ { headers: { "Cache-Control": "no-store" } }
3638
+ );
3639
+ }
3640
+ if (rawIds.length === 0) {
3641
+ return Response.json({ error: 'Provide a non-empty "ids" array' }, { status: 400 });
3642
+ }
3643
+ const targets = [];
3644
+ for (const raw of rawIds) {
3645
+ let collection;
3646
+ let id;
3647
+ if (raw.includes("::")) {
3648
+ const [c, i2] = raw.split("::");
3649
+ collection = c;
3650
+ id = i2;
3651
+ } else {
3652
+ collection = defaultCollection;
3653
+ id = raw;
3654
+ }
3655
+ if (!collection || !id) continue;
3656
+ if (collection.startsWith("global:")) continue;
3657
+ if (targetCollections && !targetCollections.includes(collection)) continue;
3658
+ targets.push({ collection, id });
3659
+ }
3660
+ const capped = targets.length > limit;
3661
+ const slice = targets.slice(0, limit);
3662
+ const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
3663
+ reqLocale: req.locale,
3664
+ localeMapping
3665
+ });
3666
+ const apiKey = process.env.ANTHROPIC_API_KEY;
3667
+ const model = process.env.SEO_AI_MODEL;
3668
+ const results = [];
3669
+ let applied = 0;
3670
+ let i = 0;
3671
+ for (const target of slice) {
3672
+ i++;
3673
+ try {
3674
+ const r = await optimizeDocMeta(req.payload, {
3675
+ collection: target.collection,
3676
+ id: target.id,
3677
+ mergedConfig,
3678
+ apiKey,
3679
+ model
3680
+ });
3681
+ if (!r.ok || !r.current || !r.suggestions) {
3682
+ results.push({
3683
+ collection: target.collection,
3684
+ id: target.id,
3685
+ title: "",
3686
+ before: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3687
+ after: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3688
+ changed: false,
3689
+ applied: false,
3690
+ error: r.error || "optimize_failed"
3691
+ });
3692
+ continue;
3693
+ }
3694
+ const before = r.current;
3695
+ const after = {
3696
+ metaTitle: r.suggestions.metaTitle,
3697
+ metaDescription: r.suggestions.metaDescription,
3698
+ focusKeyword: r.suggestions.focusKeyword
3699
+ };
3700
+ const changed = after.metaTitle !== before.metaTitle || after.metaDescription !== before.metaDescription || after.focusKeyword !== before.focusKeyword;
3701
+ let didApply = false;
3702
+ if (apply && changed) {
3703
+ const patch = {};
3704
+ if (after.metaTitle || after.metaDescription) {
3705
+ patch.meta = { title: after.metaTitle, description: after.metaDescription };
3706
+ }
3707
+ if (after.focusKeyword && after.focusKeyword !== before.focusKeyword) {
3708
+ patch.focusKeyword = after.focusKeyword;
3709
+ }
3710
+ if (Object.keys(patch).length > 0) {
3711
+ await req.payload.update({ collection: target.collection, id: target.id, data: patch, overrideAccess: true });
3712
+ didApply = true;
3713
+ applied++;
3714
+ }
3715
+ }
3716
+ results.push({
3717
+ collection: target.collection,
3718
+ id: target.id,
3719
+ title: r.title || "",
3720
+ before,
3721
+ after,
3722
+ changed,
3723
+ applied: didApply,
3724
+ method: r.method
3725
+ });
3726
+ } catch (e) {
3727
+ results.push({
3728
+ collection: target.collection,
3729
+ id: target.id,
3730
+ title: "",
3731
+ before: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3732
+ after: { metaTitle: "", metaDescription: "", focusKeyword: "" },
3733
+ changed: false,
3734
+ applied: false,
3735
+ error: e instanceof Error ? e.message : "error"
3736
+ });
3737
+ }
3738
+ if (i % 3 === 0) await new Promise((resolve) => setImmediate(resolve));
3739
+ }
3740
+ return Response.json(
3741
+ {
3742
+ processed: results.length,
3743
+ changedCount: results.filter((r) => r.changed).length,
3744
+ applied,
3745
+ capped,
3746
+ results
3747
+ },
3748
+ { headers: { "Cache-Control": "no-store" } }
3749
+ );
3750
+ } catch (error) {
3751
+ const message = error instanceof Error ? error.message : "Internal server error";
3752
+ req.payload.logger.error(`[seo] ai-optimize-bulk error: ${message}`);
3753
+ return Response.json({ error: message }, { status: 500 });
3754
+ }
3755
+ };
3756
+ }
3757
+
3568
3758
  // src/endpoints/cannibalization.ts
3569
3759
  function canonicalIntent(keyword) {
3570
3760
  return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
@@ -4149,7 +4339,7 @@ function getDateThreshold(period) {
4149
4339
  return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
4150
4340
  }
4151
4341
  }
4152
- function isAdmin5(user) {
4342
+ function isAdmin6(user) {
4153
4343
  if (!user) return false;
4154
4344
  if (user.role === "admin") return true;
4155
4345
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -4254,7 +4444,7 @@ function createPerformanceHandler() {
4254
4444
  });
4255
4445
  }
4256
4446
  if (method === "POST") {
4257
- if (!isAdmin5(req.user)) {
4447
+ if (!isAdmin6(req.user)) {
4258
4448
  return Response.json({ error: "Admin access required" }, { status: 403 });
4259
4449
  }
4260
4450
  const body = await parseJsonBody(req);
@@ -5968,7 +6158,7 @@ function createAiRewriteHandler(targetCollections) {
5968
6158
  }
5969
6159
 
5970
6160
  // src/endpoints/robots.ts
5971
- function isAdmin6(user) {
6161
+ function isAdmin7(user) {
5972
6162
  if (!user) return false;
5973
6163
  if (user.role === "admin") return true;
5974
6164
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -6017,7 +6207,7 @@ function createRobotsUpdateHandler() {
6017
6207
  if (!req.user) {
6018
6208
  return Response.json({ error: "Unauthorized" }, { status: 401 });
6019
6209
  }
6020
- if (!isAdmin6(req.user)) {
6210
+ if (!isAdmin7(req.user)) {
6021
6211
  return Response.json({ error: "Admin access required" }, { status: 403 });
6022
6212
  }
6023
6213
  const body = await parseJsonBody(req);
@@ -7200,6 +7390,137 @@ function createRankHistoryHandler() {
7200
7390
  };
7201
7391
  }
7202
7392
 
7393
+ // src/endpoints/ctrOpportunities.ts
7394
+ function expectedCtrForPosition(pos) {
7395
+ const table = {
7396
+ 1: 0.28,
7397
+ 2: 0.15,
7398
+ 3: 0.1,
7399
+ 4: 0.07,
7400
+ 5: 0.055,
7401
+ 6: 0.045,
7402
+ 7: 0.035,
7403
+ 8: 0.03,
7404
+ 9: 0.025,
7405
+ 10: 0.022
7406
+ };
7407
+ if (pos <= 1) return table[1];
7408
+ if (pos <= 10) {
7409
+ const lo = Math.floor(pos);
7410
+ const hi = Math.ceil(pos);
7411
+ const a = table[lo] ?? 0.02;
7412
+ const b = table[hi] ?? 0.02;
7413
+ return a + (b - a) * (pos - lo);
7414
+ }
7415
+ if (pos <= 20) return 0.012;
7416
+ return 5e-3;
7417
+ }
7418
+ function rankCtrOpportunities(rows, opts = {}) {
7419
+ const minImpressions = opts.minImpressions ?? 50;
7420
+ const maxPosition = opts.maxPosition ?? 20;
7421
+ const out = [];
7422
+ for (const r of rows) {
7423
+ const url = r.keys?.[0];
7424
+ if (!url) continue;
7425
+ if (r.impressions < minImpressions) continue;
7426
+ if (r.position > maxPosition) continue;
7427
+ const expectedCtr = expectedCtrForPosition(r.position);
7428
+ const gap = expectedCtr - r.ctr;
7429
+ if (gap <= 0) continue;
7430
+ const potentialClicks = Math.round(r.impressions * gap);
7431
+ if (potentialClicks < 1) continue;
7432
+ out.push({
7433
+ url,
7434
+ impressions: r.impressions,
7435
+ clicks: r.clicks,
7436
+ ctr: r.ctr,
7437
+ position: Math.round(r.position * 10) / 10,
7438
+ expectedCtr: Math.round(expectedCtr * 1e3) / 1e3,
7439
+ potentialClicks
7440
+ });
7441
+ }
7442
+ out.sort((a, b) => b.potentialClicks - a.potentialClicks);
7443
+ return out;
7444
+ }
7445
+ async function resolveDoc(payload, url, targetCollections) {
7446
+ let path;
7447
+ try {
7448
+ path = new URL(url).pathname;
7449
+ } catch {
7450
+ return null;
7451
+ }
7452
+ const segments = path.split("/").filter(Boolean);
7453
+ const slug = segments.length === 0 ? "home" : segments[segments.length - 1];
7454
+ for (const collection of targetCollections) {
7455
+ try {
7456
+ const res = await payload.find({
7457
+ collection,
7458
+ where: { slug: { equals: slug } },
7459
+ limit: 1,
7460
+ depth: 0,
7461
+ overrideAccess: true
7462
+ });
7463
+ if (res.docs.length > 0) {
7464
+ return { collection, id: String(res.docs[0].id) };
7465
+ }
7466
+ } catch {
7467
+ }
7468
+ }
7469
+ return null;
7470
+ }
7471
+ function createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) {
7472
+ return async (req) => {
7473
+ try {
7474
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7475
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
7476
+ if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
7477
+ const authDoc = await getOrCreateGscAuthDoc(req.payload);
7478
+ if (!authDoc.refreshTokenEnc) {
7479
+ return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
7480
+ }
7481
+ let accessToken;
7482
+ try {
7483
+ accessToken = await getGscAccessToken(req.payload, cfg, authDoc);
7484
+ } catch (e) {
7485
+ const code = e instanceof Error ? e.message : "refresh_failed";
7486
+ const status = code === "decrypt_failed" ? 409 : 502;
7487
+ return Response.json({ error: "Could not refresh GSC access token." }, { status });
7488
+ }
7489
+ const url = new URL(req.url);
7490
+ const minImpressions = Math.max(1, parseInt(url.searchParams.get("minImpressions") || "50", 10) || 50);
7491
+ const property = authDoc.propertyUrl || cfg.siteUrl;
7492
+ const end = new Date(Date.now() - 2 * 864e5);
7493
+ const start = new Date(end.getTime() - 27 * 864e5);
7494
+ const endDate = end.toISOString().slice(0, 10);
7495
+ const startDate = start.toISOString().slice(0, 10);
7496
+ let rows;
7497
+ try {
7498
+ rows = await queryGscSearchAnalytics(accessToken, property, {
7499
+ startDate,
7500
+ endDate,
7501
+ dimensions: ["page"],
7502
+ rowLimit: 250
7503
+ });
7504
+ } catch (e) {
7505
+ return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
7506
+ }
7507
+ const opportunities = rankCtrOpportunities(rows, { minImpressions });
7508
+ const top = opportunities.slice(0, 50);
7509
+ const resolved = await Promise.all(
7510
+ top.map(async (o) => ({ ...o, doc: await resolveDoc(req.payload, o.url, targetCollections) }))
7511
+ );
7512
+ return Response.json(
7513
+ { property, startDate, endDate, count: opportunities.length, opportunities: resolved },
7514
+ { headers: { "Cache-Control": "no-store" } }
7515
+ );
7516
+ } catch (error) {
7517
+ const message = error instanceof Error ? error.message : "Internal server error";
7518
+ req.payload.logger.error(`[seo] ctr-opportunities error: ${message}`);
7519
+ return Response.json({ error: message }, { status: 500 });
7520
+ }
7521
+ };
7522
+ }
7523
+
7203
7524
  // src/rateLimiter.ts
7204
7525
  function createRateLimiter(maxRequests, windowMs) {
7205
7526
  const store = /* @__PURE__ */ new Map();
@@ -7247,7 +7568,7 @@ function getClientIp(req) {
7247
7568
 
7248
7569
  // src/endpoints/seoLogs.ts
7249
7570
  var VALID_LOG_TYPES = ["404", "redirect", "error"];
7250
- function isAdmin7(user) {
7571
+ function isAdmin8(user) {
7251
7572
  if (!user) return false;
7252
7573
  if (user.role === "admin") return true;
7253
7574
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7344,7 +7665,7 @@ function createSeoLogsHandler(seoLogsSecret) {
7344
7665
  return Response.json({ error: "Unauthorized" }, { status: 401 });
7345
7666
  }
7346
7667
  if (method === "DELETE") {
7347
- if (!isAdmin7(req.user)) {
7668
+ if (!isAdmin8(req.user)) {
7348
7669
  return Response.json({ error: "Admin access required" }, { status: 403 });
7349
7670
  }
7350
7671
  try {
@@ -7521,6 +7842,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
7521
7842
  }
7522
7843
  }
7523
7844
  function startCacheWarmUp(payload, _basePath, globals = [], collections = ["pages", "posts"]) {
7845
+ stopCacheWarmUp();
7524
7846
  setTimeout(() => {
7525
7847
  void doWarmUp(payload, collections, globals);
7526
7848
  }, STARTUP_DELAY);
@@ -7562,6 +7884,7 @@ async function doSnapshot(payload, basePath, seoConfig) {
7562
7884
  }
7563
7885
  }
7564
7886
  function startRankTracker(payload, basePath, seoConfig) {
7887
+ stopRankTracker();
7565
7888
  setTimeout(() => {
7566
7889
  void doSnapshot(payload, basePath, seoConfig);
7567
7890
  }, STARTUP_DELAY2);
@@ -7584,7 +7907,7 @@ function stopRankTracker() {
7584
7907
  }
7585
7908
 
7586
7909
  // src/endpoints/alerts.ts
7587
- function isAdmin8(user) {
7910
+ function isAdmin9(user) {
7588
7911
  if (!user) return false;
7589
7912
  if (user.role === "admin") return true;
7590
7913
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7750,7 +8073,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
7750
8073
  function createAlertsDigestHandler() {
7751
8074
  return async (req) => {
7752
8075
  try {
7753
- if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8076
+ if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7754
8077
  const cfg = getAlertConfig();
7755
8078
  const digest = await buildAlertDigest(req.payload, cfg);
7756
8079
  return Response.json(
@@ -7776,7 +8099,7 @@ function createAlertsDigestHandler() {
7776
8099
  function createAlertsRunHandler(siteUrl) {
7777
8100
  return async (req) => {
7778
8101
  try {
7779
- if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8102
+ if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7780
8103
  const cfg = getAlertConfig();
7781
8104
  const digest = await buildAlertDigest(req.payload, cfg);
7782
8105
  const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
@@ -7811,6 +8134,7 @@ async function runDigest(payload, siteUrl) {
7811
8134
  }
7812
8135
  }
7813
8136
  function startAlertsScheduler(payload, siteUrl) {
8137
+ stopAlertsScheduler();
7814
8138
  const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
7815
8139
  const intervalMs = intervalHours * 60 * 60 * 1e3;
7816
8140
  setTimeout(() => {
@@ -8826,8 +9150,15 @@ var fr = {
8826
9150
  markCornerstone: "Marquer pilier",
8827
9151
  unmarkCornerstone: "D\xE9marquer pilier",
8828
9152
  bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
8829
- bulkOptimizing: "Optimisation\u2026",
9153
+ bulkOptimizing: "Analyse\u2026",
8830
9154
  bulkConfirm: "Confirmer ?",
9155
+ bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
9156
+ bulkApply: "Appliquer",
9157
+ bulkApplying: "Application\u2026",
9158
+ bulkCancel: "Annuler",
9159
+ bulkExport: "Exporter CSV",
9160
+ bulkNoChanges: "Aucune correction n\xE9cessaire sur la s\xE9lection.",
9161
+ bulkCappedNote: "limite atteinte (affine la s\xE9lection)",
8831
9162
  searchPlaceholder: "Rechercher (titre, slug, keyword)...",
8832
9163
  allCollections: "Toutes les collections",
8833
9164
  allScores: "Tous les scores",
@@ -9421,8 +9752,15 @@ var en = {
9421
9752
  markCornerstone: "Mark as cornerstone",
9422
9753
  unmarkCornerstone: "Unmark cornerstone",
9423
9754
  bulkOptimizeMeta: "Optimize meta (AI)",
9424
- bulkOptimizing: "Optimizing\u2026",
9755
+ bulkOptimizing: "Analyzing\u2026",
9425
9756
  bulkConfirm: "Confirm?",
9757
+ bulkPreviewTitle: "Proposed meta corrections",
9758
+ bulkApply: "Apply",
9759
+ bulkApplying: "Applying\u2026",
9760
+ bulkCancel: "Cancel",
9761
+ bulkExport: "Export CSV",
9762
+ bulkNoChanges: "No corrections needed on the selection.",
9763
+ bulkCappedNote: "limit reached (narrow the selection)",
9426
9764
  searchPlaceholder: "Search (title, slug, keyword)...",
9427
9765
  allCollections: "All collections",
9428
9766
  allScores: "All scores",
@@ -10275,7 +10613,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10275
10613
  { path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
10276
10614
  { path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
10277
10615
  { path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) },
10278
- { path: `${basePath}/ai-content-brief`, method: "post", handler: withRateLimit(createAiContentBriefHandler(targetCollections, seoConfig)) }
10616
+ { path: `${basePath}/ai-content-brief`, method: "post", handler: withRateLimit(createAiContentBriefHandler(targetCollections, seoConfig)) },
10617
+ { path: `${basePath}/ai-optimize-bulk`, method: "post", handler: withRateLimit(createAiOptimizeBulkHandler(targetCollections, seoConfig)) }
10279
10618
  );
10280
10619
  }
10281
10620
  if (features.cannibalization) {
@@ -10308,7 +10647,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10308
10647
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
10309
10648
  { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
10310
10649
  { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
10311
- { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
10650
+ { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() },
10651
+ { path: `${basePath}/ctr-opportunities`, method: "get", handler: createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) }
10312
10652
  );
10313
10653
  }
10314
10654
  if (features.alerts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-seo-analyzer",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Payload CMS SEO plugin — 50+ checks, dashboard, Lexical JSON support, Flesch FR/EN readability, i18n",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",