@consilioweb/payload-seo-analyzer 1.14.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/dist/index.cjs CHANGED
@@ -7525,6 +7525,174 @@ function createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) {
7525
7525
  };
7526
7526
  }
7527
7527
 
7528
+ // src/endpoints/health.ts
7529
+ function isAdmin8(user) {
7530
+ if (!user) return false;
7531
+ if (user.role === "admin") return true;
7532
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
7533
+ return false;
7534
+ }
7535
+ function createSeoHealthHandler(basePath, seoConfig) {
7536
+ return async (req) => {
7537
+ try {
7538
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7539
+ const config = {
7540
+ aiKey: !!process.env.ANTHROPIC_API_KEY,
7541
+ aiModel: process.env.SEO_AI_MODEL || "claude-opus-4-8",
7542
+ pageSpeedKey: !!(process.env.PAGESPEED_API_KEY || process.env.GOOGLE_PAGESPEED_API_KEY),
7543
+ gscConfigured: !!getGscOAuthConfig(basePath, seoConfig),
7544
+ gscEncryptionKey: !!process.env.SEO_GSC_ENCRYPTION_KEY,
7545
+ alertWebhook: !!process.env.SEO_ALERT_WEBHOOK_URL,
7546
+ alertEmail: !!process.env.SEO_ALERT_EMAIL,
7547
+ indexNowKey: !!process.env.SEO_INDEXNOW_KEY,
7548
+ siteUrl: seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || null
7549
+ };
7550
+ const cacheStats = seoCache.stats();
7551
+ const auditCached = cacheStats.keys.some((k) => k === "audit" || k.startsWith("audit:"));
7552
+ let gscConnected = false;
7553
+ let gscEmail = null;
7554
+ let lastRankSnapshot = null;
7555
+ try {
7556
+ const authDoc = await getOrCreateGscAuthDoc(req.payload);
7557
+ gscConnected = !!authDoc.refreshTokenEnc;
7558
+ gscEmail = authDoc.connectedEmail || null;
7559
+ } catch {
7560
+ }
7561
+ try {
7562
+ const latest = await req.payload.find({
7563
+ collection: "seo-rank-history",
7564
+ sort: "-snapshotDate",
7565
+ limit: 1,
7566
+ depth: 0,
7567
+ overrideAccess: true
7568
+ });
7569
+ lastRankSnapshot = latest.docs[0]?.snapshotDate || null;
7570
+ } catch {
7571
+ }
7572
+ const warnings = [];
7573
+ if (!config.aiKey) warnings.push("ANTHROPIC_API_KEY not set \u2014 AI features fall back to heuristics.");
7574
+ if (config.gscConfigured && !gscConnected) warnings.push("GSC configured but not connected \u2014 rank tracking & CTR opportunities inactive.");
7575
+ if (config.gscConfigured && !config.gscEncryptionKey) warnings.push("SEO_GSC_ENCRYPTION_KEY not set \u2014 GSC token encrypted with a derived key (set an explicit key for stability).");
7576
+ if ((config.alertWebhook || config.alertEmail) === false) warnings.push("No alert channel configured (SEO_ALERT_WEBHOOK_URL / SEO_ALERT_EMAIL) \u2014 monitoring digest will not be delivered.");
7577
+ return Response.json(
7578
+ {
7579
+ ok: warnings.length === 0,
7580
+ config,
7581
+ runtime: {
7582
+ auditCached,
7583
+ cacheKeys: cacheStats.size,
7584
+ gscConnected,
7585
+ gscEmail,
7586
+ lastRankSnapshot
7587
+ },
7588
+ warnings
7589
+ },
7590
+ { headers: { "Cache-Control": "no-store" } }
7591
+ );
7592
+ } catch (error) {
7593
+ const message = error instanceof Error ? error.message : "Internal server error";
7594
+ req.payload.logger.error(`[seo] health error: ${message}`);
7595
+ return Response.json({ error: message }, { status: 500 });
7596
+ }
7597
+ };
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
+
7528
7696
  // src/rateLimiter.ts
7529
7697
  function createRateLimiter(maxRequests, windowMs) {
7530
7698
  const store = /* @__PURE__ */ new Map();
@@ -7572,7 +7740,7 @@ function getClientIp(req) {
7572
7740
 
7573
7741
  // src/endpoints/seoLogs.ts
7574
7742
  var VALID_LOG_TYPES = ["404", "redirect", "error"];
7575
- function isAdmin8(user) {
7743
+ function isAdmin10(user) {
7576
7744
  if (!user) return false;
7577
7745
  if (user.role === "admin") return true;
7578
7746
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7669,7 +7837,7 @@ function createSeoLogsHandler(seoLogsSecret) {
7669
7837
  return Response.json({ error: "Unauthorized" }, { status: 401 });
7670
7838
  }
7671
7839
  if (method === "DELETE") {
7672
- if (!isAdmin8(req.user)) {
7840
+ if (!isAdmin10(req.user)) {
7673
7841
  return Response.json({ error: "Admin access required" }, { status: 403 });
7674
7842
  }
7675
7843
  try {
@@ -7911,7 +8079,7 @@ function stopRankTracker() {
7911
8079
  }
7912
8080
 
7913
8081
  // src/endpoints/alerts.ts
7914
- function isAdmin9(user) {
8082
+ function isAdmin11(user) {
7915
8083
  if (!user) return false;
7916
8084
  if (user.role === "admin") return true;
7917
8085
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -8077,7 +8245,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
8077
8245
  function createAlertsDigestHandler() {
8078
8246
  return async (req) => {
8079
8247
  try {
8080
- if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8248
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8081
8249
  const cfg = getAlertConfig();
8082
8250
  const digest = await buildAlertDigest(req.payload, cfg);
8083
8251
  return Response.json(
@@ -8103,7 +8271,7 @@ function createAlertsDigestHandler() {
8103
8271
  function createAlertsRunHandler(siteUrl) {
8104
8272
  return async (req) => {
8105
8273
  try {
8106
- if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8274
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8107
8275
  const cfg = getAlertConfig();
8108
8276
  const digest = await buildAlertDigest(req.payload, cfg);
8109
8277
  const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
@@ -10365,6 +10533,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10365
10533
  // disable on low-memory hosts to skip startup pre-loading
10366
10534
  alerts: false,
10367
10535
  // opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
10536
+ indexNow: false,
10537
+ // opt-in — requires SEO_INDEXNOW_KEY
10368
10538
  ...pluginConfig.features
10369
10539
  };
10370
10540
  function hasExistingSeoMeta(fields) {
@@ -10465,6 +10635,16 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10465
10635
  ]
10466
10636
  };
10467
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
+ }
10468
10648
  return updated;
10469
10649
  }
10470
10650
  return collection;
@@ -10665,6 +10845,12 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10665
10845
  { path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
10666
10846
  );
10667
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
+ }
10668
10854
  if (features.keywords) {
10669
10855
  pluginEndpoints.push({
10670
10856
  path: `${basePath}/keyword-research`,
@@ -10700,6 +10886,11 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10700
10886
  handler: withRateLimit(createDuplicateContentHandler(targetCollections))
10701
10887
  });
10702
10888
  }
10889
+ pluginEndpoints.push({
10890
+ path: `${basePath}/health`,
10891
+ method: "get",
10892
+ handler: createSeoHealthHandler(basePath, seoConfig)
10893
+ });
10703
10894
  pluginEndpoints.push(
10704
10895
  {
10705
10896
  path: `${basePath}/robots.txt`,
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
@@ -7523,6 +7523,174 @@ function createCtrOpportunitiesHandler(basePath, targetCollections, seoConfig) {
7523
7523
  };
7524
7524
  }
7525
7525
 
7526
+ // src/endpoints/health.ts
7527
+ function isAdmin8(user) {
7528
+ if (!user) return false;
7529
+ if (user.role === "admin") return true;
7530
+ if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
7531
+ return false;
7532
+ }
7533
+ function createSeoHealthHandler(basePath, seoConfig) {
7534
+ return async (req) => {
7535
+ try {
7536
+ if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
7537
+ const config = {
7538
+ aiKey: !!process.env.ANTHROPIC_API_KEY,
7539
+ aiModel: process.env.SEO_AI_MODEL || "claude-opus-4-8",
7540
+ pageSpeedKey: !!(process.env.PAGESPEED_API_KEY || process.env.GOOGLE_PAGESPEED_API_KEY),
7541
+ gscConfigured: !!getGscOAuthConfig(basePath, seoConfig),
7542
+ gscEncryptionKey: !!process.env.SEO_GSC_ENCRYPTION_KEY,
7543
+ alertWebhook: !!process.env.SEO_ALERT_WEBHOOK_URL,
7544
+ alertEmail: !!process.env.SEO_ALERT_EMAIL,
7545
+ indexNowKey: !!process.env.SEO_INDEXNOW_KEY,
7546
+ siteUrl: seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || null
7547
+ };
7548
+ const cacheStats = seoCache.stats();
7549
+ const auditCached = cacheStats.keys.some((k) => k === "audit" || k.startsWith("audit:"));
7550
+ let gscConnected = false;
7551
+ let gscEmail = null;
7552
+ let lastRankSnapshot = null;
7553
+ try {
7554
+ const authDoc = await getOrCreateGscAuthDoc(req.payload);
7555
+ gscConnected = !!authDoc.refreshTokenEnc;
7556
+ gscEmail = authDoc.connectedEmail || null;
7557
+ } catch {
7558
+ }
7559
+ try {
7560
+ const latest = await req.payload.find({
7561
+ collection: "seo-rank-history",
7562
+ sort: "-snapshotDate",
7563
+ limit: 1,
7564
+ depth: 0,
7565
+ overrideAccess: true
7566
+ });
7567
+ lastRankSnapshot = latest.docs[0]?.snapshotDate || null;
7568
+ } catch {
7569
+ }
7570
+ const warnings = [];
7571
+ if (!config.aiKey) warnings.push("ANTHROPIC_API_KEY not set \u2014 AI features fall back to heuristics.");
7572
+ if (config.gscConfigured && !gscConnected) warnings.push("GSC configured but not connected \u2014 rank tracking & CTR opportunities inactive.");
7573
+ if (config.gscConfigured && !config.gscEncryptionKey) warnings.push("SEO_GSC_ENCRYPTION_KEY not set \u2014 GSC token encrypted with a derived key (set an explicit key for stability).");
7574
+ if ((config.alertWebhook || config.alertEmail) === false) warnings.push("No alert channel configured (SEO_ALERT_WEBHOOK_URL / SEO_ALERT_EMAIL) \u2014 monitoring digest will not be delivered.");
7575
+ return Response.json(
7576
+ {
7577
+ ok: warnings.length === 0,
7578
+ config,
7579
+ runtime: {
7580
+ auditCached,
7581
+ cacheKeys: cacheStats.size,
7582
+ gscConnected,
7583
+ gscEmail,
7584
+ lastRankSnapshot
7585
+ },
7586
+ warnings
7587
+ },
7588
+ { headers: { "Cache-Control": "no-store" } }
7589
+ );
7590
+ } catch (error) {
7591
+ const message = error instanceof Error ? error.message : "Internal server error";
7592
+ req.payload.logger.error(`[seo] health error: ${message}`);
7593
+ return Response.json({ error: message }, { status: 500 });
7594
+ }
7595
+ };
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
+
7526
7694
  // src/rateLimiter.ts
7527
7695
  function createRateLimiter(maxRequests, windowMs) {
7528
7696
  const store = /* @__PURE__ */ new Map();
@@ -7570,7 +7738,7 @@ function getClientIp(req) {
7570
7738
 
7571
7739
  // src/endpoints/seoLogs.ts
7572
7740
  var VALID_LOG_TYPES = ["404", "redirect", "error"];
7573
- function isAdmin8(user) {
7741
+ function isAdmin10(user) {
7574
7742
  if (!user) return false;
7575
7743
  if (user.role === "admin") return true;
7576
7744
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -7667,7 +7835,7 @@ function createSeoLogsHandler(seoLogsSecret) {
7667
7835
  return Response.json({ error: "Unauthorized" }, { status: 401 });
7668
7836
  }
7669
7837
  if (method === "DELETE") {
7670
- if (!isAdmin8(req.user)) {
7838
+ if (!isAdmin10(req.user)) {
7671
7839
  return Response.json({ error: "Admin access required" }, { status: 403 });
7672
7840
  }
7673
7841
  try {
@@ -7909,7 +8077,7 @@ function stopRankTracker() {
7909
8077
  }
7910
8078
 
7911
8079
  // src/endpoints/alerts.ts
7912
- function isAdmin9(user) {
8080
+ function isAdmin11(user) {
7913
8081
  if (!user) return false;
7914
8082
  if (user.role === "admin") return true;
7915
8083
  if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
@@ -8075,7 +8243,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
8075
8243
  function createAlertsDigestHandler() {
8076
8244
  return async (req) => {
8077
8245
  try {
8078
- if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8246
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8079
8247
  const cfg = getAlertConfig();
8080
8248
  const digest = await buildAlertDigest(req.payload, cfg);
8081
8249
  return Response.json(
@@ -8101,7 +8269,7 @@ function createAlertsDigestHandler() {
8101
8269
  function createAlertsRunHandler(siteUrl) {
8102
8270
  return async (req) => {
8103
8271
  try {
8104
- if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8272
+ if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
8105
8273
  const cfg = getAlertConfig();
8106
8274
  const digest = await buildAlertDigest(req.payload, cfg);
8107
8275
  const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
@@ -10363,6 +10531,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10363
10531
  // disable on low-memory hosts to skip startup pre-loading
10364
10532
  alerts: false,
10365
10533
  // opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
10534
+ indexNow: false,
10535
+ // opt-in — requires SEO_INDEXNOW_KEY
10366
10536
  ...pluginConfig.features
10367
10537
  };
10368
10538
  function hasExistingSeoMeta(fields) {
@@ -10463,6 +10633,16 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10463
10633
  ]
10464
10634
  };
10465
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
+ }
10466
10646
  return updated;
10467
10647
  }
10468
10648
  return collection;
@@ -10663,6 +10843,12 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10663
10843
  { path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
10664
10844
  );
10665
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
+ }
10666
10852
  if (features.keywords) {
10667
10853
  pluginEndpoints.push({
10668
10854
  path: `${basePath}/keyword-research`,
@@ -10698,6 +10884,11 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
10698
10884
  handler: withRateLimit(createDuplicateContentHandler(targetCollections))
10699
10885
  });
10700
10886
  }
10887
+ pluginEndpoints.push({
10888
+ path: `${basePath}/health`,
10889
+ method: "get",
10890
+ handler: createSeoHealthHandler(basePath, seoConfig)
10891
+ });
10701
10892
  pluginEndpoints.push(
10702
10893
  {
10703
10894
  path: `${basePath}/robots.txt`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-seo-analyzer",
3
- "version": "1.14.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",