@consilioweb/payload-seo-analyzer 1.15.0 → 1.16.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/README.md CHANGED
@@ -634,6 +634,8 @@ All endpoints are prefixed with the configured `endpointBasePath` (default: `/se
634
634
  | `POST` `GET` | `/rank-snapshot` `/rank-history` | Rank tracking (opt-in `features.gscApi`) — daily GSC position snapshots + movement over time |
635
635
  | `GET` `POST` | `/alerts-digest` `/alerts-run` | Monitoring digest (opt-in `features.alerts`) — score regressions, new 404s, ranking drops via webhook/email |
636
636
  | `GET` | `/ctr-opportunities` | CTR opportunities (opt-in `features.gscApi`) — well-ranked pages with low CTR → targeted meta rewrite, one-click AI |
637
+ | `GET` | `/health` | Module health (admin) — integration/job status + actionable warnings |
638
+ | `GET` `POST` | `/indexnow-key.txt` `/indexnow-submit` | IndexNow (opt-in `features.indexNow`) — proactive indexing on publish + manual batch submit |
637
639
  | `GET` | `/history` | Score history data for trend charts |
638
640
  | `GET` | `/sitemap-audit` | Sitemap structure audit |
639
641
  | `GET` | `/sitemap.xml` `/sitemap-news.xml` `/sitemap-images.xml` `/sitemap-video.xml` | XML sitemaps (public) — standard + Google News (48h) + image + video |
package/dist/index.cjs CHANGED
@@ -7544,6 +7544,7 @@ function createSeoHealthHandler(basePath, seoConfig) {
7544
7544
  gscEncryptionKey: !!process.env.SEO_GSC_ENCRYPTION_KEY,
7545
7545
  alertWebhook: !!process.env.SEO_ALERT_WEBHOOK_URL,
7546
7546
  alertEmail: !!process.env.SEO_ALERT_EMAIL,
7547
+ indexNowKey: !!process.env.SEO_INDEXNOW_KEY,
7547
7548
  siteUrl: seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || null
7548
7549
  };
7549
7550
  const cacheStats = seoCache.stats();
@@ -7596,6 +7597,102 @@ function createSeoHealthHandler(basePath, seoConfig) {
7596
7597
  };
7597
7598
  }
7598
7599
 
7600
+ // src/endpoints/indexNow.ts
7601
+ function isAdmin9(user) {
7602
+ if (!user) return false;
7603
+ if (user.role === "admin") return true;
7604
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
7605
+ return false;
7606
+ }
7607
+ function docToUrl(slug, siteUrl) {
7608
+ const base = siteUrl.replace(/\/$/, "");
7609
+ if (!slug || slug === "home") return base;
7610
+ return `${base}/${slug}`;
7611
+ }
7612
+ async function submitToIndexNow(siteUrl, key, keyLocation, urls) {
7613
+ if (!key || urls.length === 0) return { ok: false, reason: "no_key_or_urls" };
7614
+ let host;
7615
+ try {
7616
+ host = new URL(siteUrl).host;
7617
+ } catch {
7618
+ return { ok: false, reason: "bad_site_url" };
7619
+ }
7620
+ try {
7621
+ const resp = await fetch("https://api.indexnow.org/indexnow", {
7622
+ method: "POST",
7623
+ headers: { "Content-Type": "application/json; charset=utf-8" },
7624
+ body: JSON.stringify({ host, key, keyLocation, urlList: urls.slice(0, 1e4) })
7625
+ });
7626
+ return { ok: resp.ok, status: resp.status };
7627
+ } catch (e) {
7628
+ return { ok: false, reason: e instanceof Error ? e.message : "fetch_error" };
7629
+ }
7630
+ }
7631
+ function keyLocationFor(siteUrl, basePath) {
7632
+ return `${siteUrl.replace(/\/$/, "")}/api${basePath}/indexnow-key.txt`;
7633
+ }
7634
+ function createIndexNowKeyHandler() {
7635
+ return async () => {
7636
+ const key = process.env.SEO_INDEXNOW_KEY || "";
7637
+ if (!key) return new Response("IndexNow key not configured", { status: 404 });
7638
+ return new Response(key, {
7639
+ status: 200,
7640
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=86400" }
7641
+ });
7642
+ };
7643
+ }
7644
+ function createIndexNowSubmitHandler(basePath, targetCollections, seoConfig) {
7645
+ return async (req) => {
7646
+ try {
7647
+ if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7648
+ const key = process.env.SEO_INDEXNOW_KEY;
7649
+ const siteUrl = resolveGscSiteUrl(seoConfig);
7650
+ if (!key) return Response.json({ error: "SEO_INDEXNOW_KEY not configured." }, { status: 400 });
7651
+ if (!siteUrl) return Response.json({ error: "siteUrl not configured." }, { status: 400 });
7652
+ const urls = [];
7653
+ for (const collection of targetCollections) {
7654
+ try {
7655
+ const res = await req.payload.find({ collection, limit: 1e3, depth: 0, overrideAccess: true });
7656
+ for (const d of res.docs) {
7657
+ if (d._status === "draft") continue;
7658
+ urls.push(docToUrl(d.slug || "", siteUrl));
7659
+ }
7660
+ } catch {
7661
+ }
7662
+ }
7663
+ const result = await submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), urls);
7664
+ return Response.json(
7665
+ { submitted: urls.length, ...result },
7666
+ { headers: { "Cache-Control": "no-store" } }
7667
+ );
7668
+ } catch (error) {
7669
+ const message = error instanceof Error ? error.message : "Internal server error";
7670
+ req.payload.logger.error(`[seo] indexnow-submit error: ${message}`);
7671
+ return Response.json({ error: message }, { status: 500 });
7672
+ }
7673
+ };
7674
+ }
7675
+ function createIndexNowHook(basePath, seoConfig) {
7676
+ return ({ doc, req }) => {
7677
+ try {
7678
+ const key = process.env.SEO_INDEXNOW_KEY;
7679
+ const siteUrl = resolveGscSiteUrl(seoConfig);
7680
+ if (!key || !siteUrl) return doc;
7681
+ const status = doc?._status;
7682
+ if (status && status !== "published") return doc;
7683
+ const slug = doc?.slug || "";
7684
+ const url = docToUrl(slug, siteUrl);
7685
+ void submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), [url]).then((r) => {
7686
+ if (!r.ok && r.reason !== "no_key_or_urls") {
7687
+ req?.payload?.logger?.warn(`[seo] IndexNow submit failed (${r.status || r.reason})`);
7688
+ }
7689
+ });
7690
+ } catch {
7691
+ }
7692
+ return doc;
7693
+ };
7694
+ }
7695
+
7599
7696
  // src/rateLimiter.ts
7600
7697
  function createRateLimiter(maxRequests, windowMs) {
7601
7698
  const store = /* @__PURE__ */ new Map();
@@ -7643,7 +7740,7 @@ function getClientIp(req) {
7643
7740
 
7644
7741
  // src/endpoints/seoLogs.ts
7645
7742
  var VALID_LOG_TYPES = ["404", "redirect", "error"];
7646
- function isAdmin9(user) {
7743
+ function isAdmin10(user) {
7647
7744
  if (!user) return false;
7648
7745
  if (user.role === "admin") return true;
7649
7746
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7740,7 +7837,7 @@ function createSeoLogsHandler(seoLogsSecret) {
7740
7837
  return Response.json({ error: "Unauthorized" }, { status: 401 });
7741
7838
  }
7742
7839
  if (method === "DELETE") {
7743
- if (!isAdmin9(req.user)) {
7840
+ if (!isAdmin10(req.user)) {
7744
7841
  return Response.json({ error: "Admin access required" }, { status: 403 });
7745
7842
  }
7746
7843
  try {
@@ -7982,7 +8079,7 @@ function stopRankTracker() {
7982
8079
  }
7983
8080
 
7984
8081
  // src/endpoints/alerts.ts
7985
- function isAdmin10(user) {
8082
+ function isAdmin11(user) {
7986
8083
  if (!user) return false;
7987
8084
  if (user.role === "admin") return true;
7988
8085
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -8148,7 +8245,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
8148
8245
  function createAlertsDigestHandler() {
8149
8246
  return async (req) => {
8150
8247
  try {
8151
- if (!isAdmin10(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8248
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8152
8249
  const cfg = getAlertConfig();
8153
8250
  const digest = await buildAlertDigest(req.payload, cfg);
8154
8251
  return Response.json(
@@ -8174,7 +8271,7 @@ function createAlertsDigestHandler() {
8174
8271
  function createAlertsRunHandler(siteUrl) {
8175
8272
  return async (req) => {
8176
8273
  try {
8177
- if (!isAdmin10(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8274
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8178
8275
  const cfg = getAlertConfig();
8179
8276
  const digest = await buildAlertDigest(req.payload, cfg);
8180
8277
  const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
@@ -10436,6 +10533,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10436
10533
  // disable on low-memory hosts to skip startup pre-loading
10437
10534
  alerts: false,
10438
10535
  // opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
10536
+ indexNow: false,
10537
+ // opt-in — requires SEO_INDEXNOW_KEY
10439
10538
  ...pluginConfig.features
10440
10539
  };
10441
10540
  function hasExistingSeoMeta(fields) {
@@ -10536,6 +10635,16 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10536
10635
  ]
10537
10636
  };
10538
10637
  }
10638
+ if (features.indexNow) {
10639
+ const existingHooks = updated.hooks?.afterChange || [];
10640
+ updated.hooks = {
10641
+ ...updated.hooks,
10642
+ afterChange: [
10643
+ ...Array.isArray(existingHooks) ? existingHooks : [existingHooks],
10644
+ createIndexNowHook(basePath, seoConfig)
10645
+ ]
10646
+ };
10647
+ }
10539
10648
  return updated;
10540
10649
  }
10541
10650
  return collection;
@@ -10736,6 +10845,12 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10736
10845
  { path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
10737
10846
  );
10738
10847
  }
10848
+ if (features.indexNow) {
10849
+ pluginEndpoints.push(
10850
+ { path: `${basePath}/indexnow-key.txt`, method: "get", handler: createIndexNowKeyHandler() },
10851
+ { path: `${basePath}/indexnow-submit`, method: "post", handler: withRateLimit(createIndexNowSubmitHandler(basePath, targetCollections, seoConfig)) }
10852
+ );
10853
+ }
10739
10854
  if (features.keywords) {
10740
10855
  pluginEndpoints.push({
10741
10856
  path: `${basePath}/keyword-research`,
package/dist/index.d.cts CHANGED
@@ -184,6 +184,12 @@ interface SeoFeatures {
184
184
  * SEO_ALERT_POSITION_DROP, SEO_ALERT_INTERVAL_HOURS). Email uses Payload's email adapter.
185
185
  */
186
186
  alerts?: boolean;
187
+ /**
188
+ * IndexNow proactive indexing (opt-in, default false). Pings Bing/Yandex/etc. when content is
189
+ * published. Requires `SEO_INDEXNOW_KEY` (a key you generate once) — the plugin serves the
190
+ * verification file at `<basePath>/indexnow-key.txt`.
191
+ */
192
+ indexNow?: boolean;
187
193
  }
188
194
  /** Pre-computed context shared across all rule modules to avoid redundant work */
189
195
  interface AnalysisContext {
package/dist/index.d.ts CHANGED
@@ -184,6 +184,12 @@ interface SeoFeatures {
184
184
  * SEO_ALERT_POSITION_DROP, SEO_ALERT_INTERVAL_HOURS). Email uses Payload's email adapter.
185
185
  */
186
186
  alerts?: boolean;
187
+ /**
188
+ * IndexNow proactive indexing (opt-in, default false). Pings Bing/Yandex/etc. when content is
189
+ * published. Requires `SEO_INDEXNOW_KEY` (a key you generate once) — the plugin serves the
190
+ * verification file at `<basePath>/indexnow-key.txt`.
191
+ */
192
+ indexNow?: boolean;
187
193
  }
188
194
  /** Pre-computed context shared across all rule modules to avoid redundant work */
189
195
  interface AnalysisContext {
package/dist/index.js CHANGED
@@ -7542,6 +7542,7 @@ function createSeoHealthHandler(basePath, seoConfig) {
7542
7542
  gscEncryptionKey: !!process.env.SEO_GSC_ENCRYPTION_KEY,
7543
7543
  alertWebhook: !!process.env.SEO_ALERT_WEBHOOK_URL,
7544
7544
  alertEmail: !!process.env.SEO_ALERT_EMAIL,
7545
+ indexNowKey: !!process.env.SEO_INDEXNOW_KEY,
7545
7546
  siteUrl: seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || null
7546
7547
  };
7547
7548
  const cacheStats = seoCache.stats();
@@ -7594,6 +7595,102 @@ function createSeoHealthHandler(basePath, seoConfig) {
7594
7595
  };
7595
7596
  }
7596
7597
 
7598
+ // src/endpoints/indexNow.ts
7599
+ function isAdmin9(user) {
7600
+ if (!user) return false;
7601
+ if (user.role === "admin") return true;
7602
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
7603
+ return false;
7604
+ }
7605
+ function docToUrl(slug, siteUrl) {
7606
+ const base = siteUrl.replace(/\/$/, "");
7607
+ if (!slug || slug === "home") return base;
7608
+ return `${base}/${slug}`;
7609
+ }
7610
+ async function submitToIndexNow(siteUrl, key, keyLocation, urls) {
7611
+ if (!key || urls.length === 0) return { ok: false, reason: "no_key_or_urls" };
7612
+ let host;
7613
+ try {
7614
+ host = new URL(siteUrl).host;
7615
+ } catch {
7616
+ return { ok: false, reason: "bad_site_url" };
7617
+ }
7618
+ try {
7619
+ const resp = await fetch("https://api.indexnow.org/indexnow", {
7620
+ method: "POST",
7621
+ headers: { "Content-Type": "application/json; charset=utf-8" },
7622
+ body: JSON.stringify({ host, key, keyLocation, urlList: urls.slice(0, 1e4) })
7623
+ });
7624
+ return { ok: resp.ok, status: resp.status };
7625
+ } catch (e) {
7626
+ return { ok: false, reason: e instanceof Error ? e.message : "fetch_error" };
7627
+ }
7628
+ }
7629
+ function keyLocationFor(siteUrl, basePath) {
7630
+ return `${siteUrl.replace(/\/$/, "")}/api${basePath}/indexnow-key.txt`;
7631
+ }
7632
+ function createIndexNowKeyHandler() {
7633
+ return async () => {
7634
+ const key = process.env.SEO_INDEXNOW_KEY || "";
7635
+ if (!key) return new Response("IndexNow key not configured", { status: 404 });
7636
+ return new Response(key, {
7637
+ status: 200,
7638
+ headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=86400" }
7639
+ });
7640
+ };
7641
+ }
7642
+ function createIndexNowSubmitHandler(basePath, targetCollections, seoConfig) {
7643
+ return async (req) => {
7644
+ try {
7645
+ if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7646
+ const key = process.env.SEO_INDEXNOW_KEY;
7647
+ const siteUrl = resolveGscSiteUrl(seoConfig);
7648
+ if (!key) return Response.json({ error: "SEO_INDEXNOW_KEY not configured." }, { status: 400 });
7649
+ if (!siteUrl) return Response.json({ error: "siteUrl not configured." }, { status: 400 });
7650
+ const urls = [];
7651
+ for (const collection of targetCollections) {
7652
+ try {
7653
+ const res = await req.payload.find({ collection, limit: 1e3, depth: 0, overrideAccess: true });
7654
+ for (const d of res.docs) {
7655
+ if (d._status === "draft") continue;
7656
+ urls.push(docToUrl(d.slug || "", siteUrl));
7657
+ }
7658
+ } catch {
7659
+ }
7660
+ }
7661
+ const result = await submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), urls);
7662
+ return Response.json(
7663
+ { submitted: urls.length, ...result },
7664
+ { headers: { "Cache-Control": "no-store" } }
7665
+ );
7666
+ } catch (error) {
7667
+ const message = error instanceof Error ? error.message : "Internal server error";
7668
+ req.payload.logger.error(`[seo] indexnow-submit error: ${message}`);
7669
+ return Response.json({ error: message }, { status: 500 });
7670
+ }
7671
+ };
7672
+ }
7673
+ function createIndexNowHook(basePath, seoConfig) {
7674
+ return ({ doc, req }) => {
7675
+ try {
7676
+ const key = process.env.SEO_INDEXNOW_KEY;
7677
+ const siteUrl = resolveGscSiteUrl(seoConfig);
7678
+ if (!key || !siteUrl) return doc;
7679
+ const status = doc?._status;
7680
+ if (status && status !== "published") return doc;
7681
+ const slug = doc?.slug || "";
7682
+ const url = docToUrl(slug, siteUrl);
7683
+ void submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), [url]).then((r) => {
7684
+ if (!r.ok && r.reason !== "no_key_or_urls") {
7685
+ req?.payload?.logger?.warn(`[seo] IndexNow submit failed (${r.status || r.reason})`);
7686
+ }
7687
+ });
7688
+ } catch {
7689
+ }
7690
+ return doc;
7691
+ };
7692
+ }
7693
+
7597
7694
  // src/rateLimiter.ts
7598
7695
  function createRateLimiter(maxRequests, windowMs) {
7599
7696
  const store = /* @__PURE__ */ new Map();
@@ -7641,7 +7738,7 @@ function getClientIp(req) {
7641
7738
 
7642
7739
  // src/endpoints/seoLogs.ts
7643
7740
  var VALID_LOG_TYPES = ["404", "redirect", "error"];
7644
- function isAdmin9(user) {
7741
+ function isAdmin10(user) {
7645
7742
  if (!user) return false;
7646
7743
  if (user.role === "admin") return true;
7647
7744
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7738,7 +7835,7 @@ function createSeoLogsHandler(seoLogsSecret) {
7738
7835
  return Response.json({ error: "Unauthorized" }, { status: 401 });
7739
7836
  }
7740
7837
  if (method === "DELETE") {
7741
- if (!isAdmin9(req.user)) {
7838
+ if (!isAdmin10(req.user)) {
7742
7839
  return Response.json({ error: "Admin access required" }, { status: 403 });
7743
7840
  }
7744
7841
  try {
@@ -7980,7 +8077,7 @@ function stopRankTracker() {
7980
8077
  }
7981
8078
 
7982
8079
  // src/endpoints/alerts.ts
7983
- function isAdmin10(user) {
8080
+ function isAdmin11(user) {
7984
8081
  if (!user) return false;
7985
8082
  if (user.role === "admin") return true;
7986
8083
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -8146,7 +8243,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
8146
8243
  function createAlertsDigestHandler() {
8147
8244
  return async (req) => {
8148
8245
  try {
8149
- if (!isAdmin10(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8246
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8150
8247
  const cfg = getAlertConfig();
8151
8248
  const digest = await buildAlertDigest(req.payload, cfg);
8152
8249
  return Response.json(
@@ -8172,7 +8269,7 @@ function createAlertsDigestHandler() {
8172
8269
  function createAlertsRunHandler(siteUrl) {
8173
8270
  return async (req) => {
8174
8271
  try {
8175
- if (!isAdmin10(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8272
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8176
8273
  const cfg = getAlertConfig();
8177
8274
  const digest = await buildAlertDigest(req.payload, cfg);
8178
8275
  const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
@@ -10434,6 +10531,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10434
10531
  // disable on low-memory hosts to skip startup pre-loading
10435
10532
  alerts: false,
10436
10533
  // opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
10534
+ indexNow: false,
10535
+ // opt-in — requires SEO_INDEXNOW_KEY
10437
10536
  ...pluginConfig.features
10438
10537
  };
10439
10538
  function hasExistingSeoMeta(fields) {
@@ -10534,6 +10633,16 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10534
10633
  ]
10535
10634
  };
10536
10635
  }
10636
+ if (features.indexNow) {
10637
+ const existingHooks = updated.hooks?.afterChange || [];
10638
+ updated.hooks = {
10639
+ ...updated.hooks,
10640
+ afterChange: [
10641
+ ...Array.isArray(existingHooks) ? existingHooks : [existingHooks],
10642
+ createIndexNowHook(basePath, seoConfig)
10643
+ ]
10644
+ };
10645
+ }
10537
10646
  return updated;
10538
10647
  }
10539
10648
  return collection;
@@ -10734,6 +10843,12 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10734
10843
  { path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
10735
10844
  );
10736
10845
  }
10846
+ if (features.indexNow) {
10847
+ pluginEndpoints.push(
10848
+ { path: `${basePath}/indexnow-key.txt`, method: "get", handler: createIndexNowKeyHandler() },
10849
+ { path: `${basePath}/indexnow-submit`, method: "post", handler: withRateLimit(createIndexNowSubmitHandler(basePath, targetCollections, seoConfig)) }
10850
+ );
10851
+ }
10737
10852
  if (features.keywords) {
10738
10853
  pluginEndpoints.push({
10739
10854
  path: `${basePath}/keyword-research`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-seo-analyzer",
3
- "version": "1.15.0",
3
+ "version": "1.16.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",