@consilioweb/payload-seo-analyzer 1.12.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
@@ -7392,6 +7392,137 @@ function createRankHistoryHandler() {
7392
7392
  };
7393
7393
  }
7394
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
+
7395
7526
  // src/rateLimiter.ts
7396
7527
  function createRateLimiter(maxRequests, windowMs) {
7397
7528
  const store = /* @__PURE__ */ new Map();
@@ -10518,7 +10649,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10518
10649
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
10519
10650
  { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
10520
10651
  { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
10521
- { 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) }
10522
10654
  );
10523
10655
  }
10524
10656
  if (features.alerts) {
package/dist/index.js CHANGED
@@ -7390,6 +7390,137 @@ function createRankHistoryHandler() {
7390
7390
  };
7391
7391
  }
7392
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
+
7393
7524
  // src/rateLimiter.ts
7394
7525
  function createRateLimiter(maxRequests, windowMs) {
7395
7526
  const store = /* @__PURE__ */ new Map();
@@ -10516,7 +10647,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10516
10647
  { path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
10517
10648
  { path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
10518
10649
  { path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
10519
- { 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) }
10520
10652
  );
10521
10653
  }
10522
10654
  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.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",