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