@consilioweb/payload-seo-analyzer 1.12.0 → 1.14.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
@@ -1683,9 +1683,9 @@ function analyzeDoc(doc, collection, seoConfig) {
1683
1683
  };
1684
1684
  }
1685
1685
  var CACHE_KEY = "audit";
1686
- var auditBuildInFlight = false;
1687
- async function buildAuditCache(payload, collections, globals, seoConfig) {
1688
- const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig);
1686
+ var auditBuildsInFlight = /* @__PURE__ */ new Set();
1687
+ async function buildAuditCache(payload, collections, globals, seoConfig, reqLocale) {
1688
+ const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig, { reqLocale });
1689
1689
  const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "15", 10) || 15));
1690
1690
  const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
1691
1691
  const allResults = [];
@@ -1793,15 +1793,15 @@ async function buildAuditCache(payload, collections, globals, seoConfig) {
1793
1793
  };
1794
1794
  return { enrichedResults, stats, capped };
1795
1795
  }
1796
- function ensureAuditBuild(payload, collections, globals, seoConfig) {
1797
- if (auditBuildInFlight) return;
1798
- auditBuildInFlight = true;
1799
- void buildAuditCache(payload, collections, globals, seoConfig).then((result) => {
1800
- seoCache.set(CACHE_KEY, result);
1796
+ function ensureAuditBuild(payload, collections, globals, seoConfig, cacheKey, reqLocale) {
1797
+ if (auditBuildsInFlight.has(cacheKey)) return;
1798
+ auditBuildsInFlight.add(cacheKey);
1799
+ void buildAuditCache(payload, collections, globals, seoConfig, reqLocale).then((result) => {
1800
+ seoCache.set(cacheKey, result);
1801
1801
  }).catch((e) => {
1802
1802
  payload.logger.error(`[seo] audit build failed: ${e instanceof Error ? e.message : "unknown"}`);
1803
1803
  }).finally(() => {
1804
- auditBuildInFlight = false;
1804
+ auditBuildsInFlight.delete(cacheKey);
1805
1805
  });
1806
1806
  }
1807
1807
  function createAuditHandler(collections, seoConfig, globals = []) {
@@ -1814,12 +1814,14 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1814
1814
  const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
1815
1815
  const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
1816
1816
  const noCache = url.searchParams.get("nocache") === "1";
1817
- if (noCache && !auditBuildInFlight) {
1818
- seoCache.invalidateKey(CACHE_KEY);
1817
+ const reqLocale = typeof req.locale === "string" && req.locale ? req.locale : void 0;
1818
+ const cacheKey = reqLocale ? `${CACHE_KEY}:${reqLocale}` : CACHE_KEY;
1819
+ if (noCache && !auditBuildsInFlight.has(cacheKey)) {
1820
+ seoCache.invalidateKey(cacheKey);
1819
1821
  }
1820
- const cached = seoCache.get(CACHE_KEY);
1822
+ const cached = seoCache.get(cacheKey);
1821
1823
  if (!cached) {
1822
- ensureAuditBuild(req.payload, collections, globals, seoConfig);
1824
+ ensureAuditBuild(req.payload, collections, globals, seoConfig, cacheKey, reqLocale);
1823
1825
  return Response.json(
1824
1826
  { building: true, results: [], stats: null },
1825
1827
  { status: 202, headers: { "Cache-Control": "no-store" } }
@@ -7392,6 +7394,137 @@ function createRankHistoryHandler() {
7392
7394
  };
7393
7395
  }
7394
7396
 
7397
+ // src/endpoints/ctrOpportunities.ts
7398
+ function expectedCtrForPosition(pos) {
7399
+ const table = {
7400
+ 1: 0.28,
7401
+ 2: 0.15,
7402
+ 3: 0.1,
7403
+ 4: 0.07,
7404
+ 5: 0.055,
7405
+ 6: 0.045,
7406
+ 7: 0.035,
7407
+ 8: 0.03,
7408
+ 9: 0.025,
7409
+ 10: 0.022
7410
+ };
7411
+ if (pos <= 1) return table[1];
7412
+ if (pos <= 10) {
7413
+ const lo = Math.floor(pos);
7414
+ const hi = Math.ceil(pos);
7415
+ const a = table[lo] ?? 0.02;
7416
+ const b = table[hi] ?? 0.02;
7417
+ return a + (b - a) * (pos - lo);
7418
+ }
7419
+ if (pos <= 20) return 0.012;
7420
+ return 5e-3;
7421
+ }
7422
+ function rankCtrOpportunities(rows, opts = {}) {
7423
+ const minImpressions = opts.minImpressions ?? 50;
7424
+ const maxPosition = opts.maxPosition ?? 20;
7425
+ const out = [];
7426
+ for (const r of rows) {
7427
+ const url = r.keys?.[0];
7428
+ if (!url) continue;
7429
+ if (r.impressions < minImpressions) continue;
7430
+ if (r.position > maxPosition) continue;
7431
+ const expectedCtr = expectedCtrForPosition(r.position);
7432
+ const gap = expectedCtr - r.ctr;
7433
+ if (gap <= 0) continue;
7434
+ const potentialClicks = Math.round(r.impressions * gap);
7435
+ if (potentialClicks < 1) continue;
7436
+ out.push({
7437
+ url,
7438
+ impressions: r.impressions,
7439
+ clicks: r.clicks,
7440
+ ctr: r.ctr,
7441
+ position: Math.round(r.position * 10) / 10,
7442
+ expectedCtr: Math.round(expectedCtr * 1e3) / 1e3,
7443
+ potentialClicks
7444
+ });
7445
+ }
7446
+ out.sort((a, b) => b.potentialClicks - a.potentialClicks);
7447
+ return out;
7448
+ }
7449
+ async function resolveDoc(payload, url, targetCollections) {
7450
+ let path;
7451
+ try {
7452
+ path = new URL(url).pathname;
7453
+ } catch {
7454
+ return null;
7455
+ }
7456
+ const segments = path.split("/").filter(Boolean);
7457
+ const slug = segments.length === 0 ? "home" : segments[segments.length - 1];
7458
+ for (const collection of targetCollections) {
7459
+ try {
7460
+ const res = await payload.find({
7461
+ collection,
7462
+ where: { slug: { equals: slug } },
7463
+ limit: 1,
7464
+ depth: 0,
7465
+ overrideAccess: true
7466
+ });
7467
+ if (res.docs.length > 0) {
7468
+ return { collection, id: String(res.docs[0].id) };
7469
+ }
7470
+ } catch {
7471
+ }
7472
+ }
7473
+ return null;
7474
+ }
7475
+ function createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) {
7476
+ return async (req) => {
7477
+ try {
7478
+ if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7479
+ const cfg = getGscOAuthConfig(basePath, seoConfig);
7480
+ if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
7481
+ const authDoc = await getOrCreateGscAuthDoc(req.payload);
7482
+ if (!authDoc.refreshTokenEnc) {
7483
+ return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
7484
+ }
7485
+ let accessToken;
7486
+ try {
7487
+ accessToken = await getGscAccessToken(req.payload, cfg, authDoc);
7488
+ } catch (e) {
7489
+ const code = e instanceof Error ? e.message : "refresh_failed";
7490
+ const status = code === "decrypt_failed" ? 409 : 502;
7491
+ return Response.json({ error: "Could not refresh GSC access token." }, { status });
7492
+ }
7493
+ const url = new URL(req.url);
7494
+ const minImpressions = Math.max(1, parseInt(url.searchParams.get("minImpressions") || "50", 10) || 50);
7495
+ const property = authDoc.propertyUrl || cfg.siteUrl;
7496
+ const end = new Date(Date.now() - 2 * 864e5);
7497
+ const start = new Date(end.getTime() - 27 * 864e5);
7498
+ const endDate = end.toISOString().slice(0, 10);
7499
+ const startDate = start.toISOString().slice(0, 10);
7500
+ let rows;
7501
+ try {
7502
+ rows = await queryGscSearchAnalytics(accessToken, property, {
7503
+ startDate,
7504
+ endDate,
7505
+ dimensions: ["page"],
7506
+ rowLimit: 250
7507
+ });
7508
+ } catch (e) {
7509
+ return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
7510
+ }
7511
+ const opportunities = rankCtrOpportunities(rows, { minImpressions });
7512
+ const top = opportunities.slice(0, 50);
7513
+ const resolved = await Promise.all(
7514
+ top.map(async (o) => ({ ...o, doc: await resolveDoc(req.payload, o.url, targetCollections) }))
7515
+ );
7516
+ return Response.json(
7517
+ { property, startDate, endDate, count: opportunities.length, opportunities: resolved },
7518
+ { headers: { "Cache-Control": "no-store" } }
7519
+ );
7520
+ } catch (error) {
7521
+ const message = error instanceof Error ? error.message : "Internal server error";
7522
+ req.payload.logger.error(`[seo] ctr-opportunities error: ${message}`);
7523
+ return Response.json({ error: message }, { status: 500 });
7524
+ }
7525
+ };
7526
+ }
7527
+
7395
7528
  // src/rateLimiter.ts
7396
7529
  function createRateLimiter(maxRequests, windowMs) {
7397
7530
  const store = /* @__PURE__ */ new Map();
@@ -9023,6 +9156,8 @@ var fr = {
9023
9156
  bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
9024
9157
  bulkOptimizing: "Analyse\u2026",
9025
9158
  bulkConfirm: "Confirmer ?",
9159
+ optimizeSite: "Optimiser le site",
9160
+ optimizeSiteHint: "Cible automatiquement les pages \xE0 probl\xE8me (m\xE9ta manquante, sans mot-cl\xE9, score faible) \u2192 aper\xE7u \u2192 appliquer",
9026
9161
  bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
9027
9162
  bulkApply: "Appliquer",
9028
9163
  bulkApplying: "Application\u2026",
@@ -9625,6 +9760,8 @@ var en = {
9625
9760
  bulkOptimizeMeta: "Optimize meta (AI)",
9626
9761
  bulkOptimizing: "Analyzing\u2026",
9627
9762
  bulkConfirm: "Confirm?",
9763
+ optimizeSite: "Optimize site",
9764
+ optimizeSiteHint: "Auto-targets pages that need work (missing meta, no keyword, low score) \u2192 preview \u2192 apply",
9628
9765
  bulkPreviewTitle: "Proposed meta corrections",
9629
9766
  bulkApply: "Apply",
9630
9767
  bulkApplying: "Applying\u2026",
@@ -10518,7 +10655,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10518
10655
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
10519
10656
  { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
10520
10657
  { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
10521
- { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
10658
+ { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() },
10659
+ { path: `${basePath}/ctr-opportunities`, method: "get", handler: createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) }
10522
10660
  );
10523
10661
  }
10524
10662
  if (features.alerts) {
package/dist/index.d.cts CHANGED
@@ -470,6 +470,8 @@ interface DashboardTranslations {
470
470
  bulkOptimizeMeta: string;
471
471
  bulkOptimizing: string;
472
472
  bulkConfirm: string;
473
+ optimizeSite: string;
474
+ optimizeSiteHint: string;
473
475
  bulkPreviewTitle: string;
474
476
  bulkApply: string;
475
477
  bulkApplying: string;
package/dist/index.d.ts CHANGED
@@ -470,6 +470,8 @@ interface DashboardTranslations {
470
470
  bulkOptimizeMeta: string;
471
471
  bulkOptimizing: string;
472
472
  bulkConfirm: string;
473
+ optimizeSite: string;
474
+ optimizeSiteHint: string;
473
475
  bulkPreviewTitle: string;
474
476
  bulkApply: string;
475
477
  bulkApplying: string;
package/dist/index.js CHANGED
@@ -1681,9 +1681,9 @@ function analyzeDoc(doc, collection, seoConfig) {
1681
1681
  };
1682
1682
  }
1683
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);
1684
+ var auditBuildsInFlight = /* @__PURE__ */ new Set();
1685
+ async function buildAuditCache(payload, collections, globals, seoConfig, reqLocale) {
1686
+ const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig, { reqLocale });
1687
1687
  const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "15", 10) || 15));
1688
1688
  const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
1689
1689
  const allResults = [];
@@ -1791,15 +1791,15 @@ async function buildAuditCache(payload, collections, globals, seoConfig) {
1791
1791
  };
1792
1792
  return { enrichedResults, stats, capped };
1793
1793
  }
1794
- function ensureAuditBuild(payload, collections, globals, seoConfig) {
1795
- if (auditBuildInFlight) return;
1796
- auditBuildInFlight = true;
1797
- void buildAuditCache(payload, collections, globals, seoConfig).then((result) => {
1798
- seoCache.set(CACHE_KEY, result);
1794
+ function ensureAuditBuild(payload, collections, globals, seoConfig, cacheKey, reqLocale) {
1795
+ if (auditBuildsInFlight.has(cacheKey)) return;
1796
+ auditBuildsInFlight.add(cacheKey);
1797
+ void buildAuditCache(payload, collections, globals, seoConfig, reqLocale).then((result) => {
1798
+ seoCache.set(cacheKey, result);
1799
1799
  }).catch((e) => {
1800
1800
  payload.logger.error(`[seo] audit build failed: ${e instanceof Error ? e.message : "unknown"}`);
1801
1801
  }).finally(() => {
1802
- auditBuildInFlight = false;
1802
+ auditBuildsInFlight.delete(cacheKey);
1803
1803
  });
1804
1804
  }
1805
1805
  function createAuditHandler(collections, seoConfig, globals = []) {
@@ -1812,12 +1812,14 @@ function createAuditHandler(collections, seoConfig, globals = []) {
1812
1812
  const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
1813
1813
  const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
1814
1814
  const noCache = url.searchParams.get("nocache") === "1";
1815
- if (noCache && !auditBuildInFlight) {
1816
- seoCache.invalidateKey(CACHE_KEY);
1815
+ const reqLocale = typeof req.locale === "string" && req.locale ? req.locale : void 0;
1816
+ const cacheKey = reqLocale ? `${CACHE_KEY}:${reqLocale}` : CACHE_KEY;
1817
+ if (noCache && !auditBuildsInFlight.has(cacheKey)) {
1818
+ seoCache.invalidateKey(cacheKey);
1817
1819
  }
1818
- const cached = seoCache.get(CACHE_KEY);
1820
+ const cached = seoCache.get(cacheKey);
1819
1821
  if (!cached) {
1820
- ensureAuditBuild(req.payload, collections, globals, seoConfig);
1822
+ ensureAuditBuild(req.payload, collections, globals, seoConfig, cacheKey, reqLocale);
1821
1823
  return Response.json(
1822
1824
  { building: true, results: [], stats: null },
1823
1825
  { status: 202, headers: { "Cache-Control": "no-store" } }
@@ -7390,6 +7392,137 @@ function createRankHistoryHandler() {
7390
7392
  };
7391
7393
  }
7392
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
+
7393
7526
  // src/rateLimiter.ts
7394
7527
  function createRateLimiter(maxRequests, windowMs) {
7395
7528
  const store = /* @__PURE__ */ new Map();
@@ -9021,6 +9154,8 @@ var fr = {
9021
9154
  bulkOptimizeMeta: "Optimiser m\xE9ta (IA)",
9022
9155
  bulkOptimizing: "Analyse\u2026",
9023
9156
  bulkConfirm: "Confirmer ?",
9157
+ optimizeSite: "Optimiser le site",
9158
+ optimizeSiteHint: "Cible automatiquement les pages \xE0 probl\xE8me (m\xE9ta manquante, sans mot-cl\xE9, score faible) \u2192 aper\xE7u \u2192 appliquer",
9024
9159
  bulkPreviewTitle: "Corrections m\xE9ta propos\xE9es",
9025
9160
  bulkApply: "Appliquer",
9026
9161
  bulkApplying: "Application\u2026",
@@ -9623,6 +9758,8 @@ var en = {
9623
9758
  bulkOptimizeMeta: "Optimize meta (AI)",
9624
9759
  bulkOptimizing: "Analyzing\u2026",
9625
9760
  bulkConfirm: "Confirm?",
9761
+ optimizeSite: "Optimize site",
9762
+ optimizeSiteHint: "Auto-targets pages that need work (missing meta, no keyword, low score) \u2192 preview \u2192 apply",
9626
9763
  bulkPreviewTitle: "Proposed meta corrections",
9627
9764
  bulkApply: "Apply",
9628
9765
  bulkApplying: "Applying\u2026",
@@ -10516,7 +10653,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10516
10653
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
10517
10654
  { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
10518
10655
  { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
10519
- { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
10656
+ { path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() },
10657
+ { path: `${basePath}/ctr-opportunities`, method: "get", handler: createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) }
10520
10658
  );
10521
10659
  }
10522
10660
  if (features.alerts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-seo-analyzer",
3
- "version": "1.12.0",
3
+ "version": "1.14.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",