@consilioweb/payload-seo-analyzer 1.15.0 → 1.17.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 +6 -2
- package/dist/index.cjs +126 -8
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +126 -8
- package/package.json +1 -1
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 |
|
|
@@ -722,10 +724,12 @@ The site-wide audit (`/admin/seo` dashboard) is the heaviest operation. On const
|
|
|
722
724
|
|
|
723
725
|
| Env var | Default | Purpose |
|
|
724
726
|
|---|---|---|
|
|
725
|
-
| `SEO_AUDIT_BATCH_SIZE` | `
|
|
727
|
+
| `SEO_AUDIT_BATCH_SIZE` | `10` | Documents processed per batch. Lower it on very small hosts. |
|
|
728
|
+
| `SEO_AUDIT_BATCH_DELAY_MS` | `100` | **Pause between batches** — throttles CPU so the build never saturates the site. Raise it (e.g. `300`) on the tiniest shared hosts; lower it (`0`) for speed on a beefy server. |
|
|
729
|
+
| `SEO_AUDIT_DEPTH` | `1` | Relationship depth when loading docs. Set `0` to cut memory/CPU (image-dimension sub-checks then see IDs, a minor score difference). |
|
|
726
730
|
| `SEO_AUDIT_MAX_DOCS` | `1500` | Hard cap on documents audited. |
|
|
727
731
|
|
|
728
|
-
For the lowest-memory tiers you can also set `features: { warmCache: false }` to skip the startup/hourly pre-load entirely.
|
|
732
|
+
The audit runs **single-flight in the background and throttled** — a constrained host (Infomaniak) stays responsive while it builds, then the dashboard polls until ready. If it still feels heavy, raise `SEO_AUDIT_BATCH_DELAY_MS` and/or set `SEO_AUDIT_DEPTH=0`. For the lowest-memory tiers you can also set `features: { warmCache: false }` to skip the startup/hourly pre-load entirely.
|
|
729
733
|
|
|
730
734
|
<img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="line">
|
|
731
735
|
|
package/dist/index.cjs
CHANGED
|
@@ -1686,8 +1686,11 @@ var CACHE_KEY = "audit";
|
|
|
1686
1686
|
var auditBuildsInFlight = /* @__PURE__ */ new Set();
|
|
1687
1687
|
async function buildAuditCache(payload, collections, globals, seoConfig, reqLocale) {
|
|
1688
1688
|
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig, { reqLocale });
|
|
1689
|
-
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "
|
|
1689
|
+
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "10", 10) || 10));
|
|
1690
1690
|
const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
|
|
1691
|
+
const BATCH_DELAY_MS = Math.min(5e3, Math.max(0, parseInt(process.env.SEO_AUDIT_BATCH_DELAY_MS || "100", 10) || 0));
|
|
1692
|
+
const rawDepth = parseInt(process.env.SEO_AUDIT_DEPTH ?? "1", 10);
|
|
1693
|
+
const DEPTH = Number.isNaN(rawDepth) ? 1 : Math.min(2, Math.max(0, rawDepth));
|
|
1691
1694
|
const allResults = [];
|
|
1692
1695
|
let capped = false;
|
|
1693
1696
|
collectionsLoop:
|
|
@@ -1700,7 +1703,7 @@ async function buildAuditCache(payload, collections, globals, seoConfig, reqLoca
|
|
|
1700
1703
|
collection: collectionSlug,
|
|
1701
1704
|
limit: BATCH_SIZE,
|
|
1702
1705
|
page,
|
|
1703
|
-
depth:
|
|
1706
|
+
depth: DEPTH,
|
|
1704
1707
|
overrideAccess: true
|
|
1705
1708
|
});
|
|
1706
1709
|
for (const doc of result.docs) {
|
|
@@ -1720,7 +1723,7 @@ async function buildAuditCache(payload, collections, globals, seoConfig, reqLoca
|
|
|
1720
1723
|
}
|
|
1721
1724
|
hasMore = result.hasNextPage;
|
|
1722
1725
|
page++;
|
|
1723
|
-
await new Promise((resolve) =>
|
|
1726
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
1724
1727
|
}
|
|
1725
1728
|
} catch {
|
|
1726
1729
|
}
|
|
@@ -7544,6 +7547,7 @@ function createSeoHealthHandler(basePath, seoConfig) {
|
|
|
7544
7547
|
gscEncryptionKey: !!process.env.SEO_GSC_ENCRYPTION_KEY,
|
|
7545
7548
|
alertWebhook: !!process.env.SEO_ALERT_WEBHOOK_URL,
|
|
7546
7549
|
alertEmail: !!process.env.SEO_ALERT_EMAIL,
|
|
7550
|
+
indexNowKey: !!process.env.SEO_INDEXNOW_KEY,
|
|
7547
7551
|
siteUrl: seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || null
|
|
7548
7552
|
};
|
|
7549
7553
|
const cacheStats = seoCache.stats();
|
|
@@ -7596,6 +7600,102 @@ function createSeoHealthHandler(basePath, seoConfig) {
|
|
|
7596
7600
|
};
|
|
7597
7601
|
}
|
|
7598
7602
|
|
|
7603
|
+
// src/endpoints/indexNow.ts
|
|
7604
|
+
function isAdmin9(user) {
|
|
7605
|
+
if (!user) return false;
|
|
7606
|
+
if (user.role === "admin") return true;
|
|
7607
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
7608
|
+
return false;
|
|
7609
|
+
}
|
|
7610
|
+
function docToUrl(slug, siteUrl) {
|
|
7611
|
+
const base = siteUrl.replace(/\/$/, "");
|
|
7612
|
+
if (!slug || slug === "home") return base;
|
|
7613
|
+
return `${base}/${slug}`;
|
|
7614
|
+
}
|
|
7615
|
+
async function submitToIndexNow(siteUrl, key, keyLocation, urls) {
|
|
7616
|
+
if (!key || urls.length === 0) return { ok: false, reason: "no_key_or_urls" };
|
|
7617
|
+
let host;
|
|
7618
|
+
try {
|
|
7619
|
+
host = new URL(siteUrl).host;
|
|
7620
|
+
} catch {
|
|
7621
|
+
return { ok: false, reason: "bad_site_url" };
|
|
7622
|
+
}
|
|
7623
|
+
try {
|
|
7624
|
+
const resp = await fetch("https://api.indexnow.org/indexnow", {
|
|
7625
|
+
method: "POST",
|
|
7626
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
7627
|
+
body: JSON.stringify({ host, key, keyLocation, urlList: urls.slice(0, 1e4) })
|
|
7628
|
+
});
|
|
7629
|
+
return { ok: resp.ok, status: resp.status };
|
|
7630
|
+
} catch (e) {
|
|
7631
|
+
return { ok: false, reason: e instanceof Error ? e.message : "fetch_error" };
|
|
7632
|
+
}
|
|
7633
|
+
}
|
|
7634
|
+
function keyLocationFor(siteUrl, basePath) {
|
|
7635
|
+
return `${siteUrl.replace(/\/$/, "")}/api${basePath}/indexnow-key.txt`;
|
|
7636
|
+
}
|
|
7637
|
+
function createIndexNowKeyHandler() {
|
|
7638
|
+
return async () => {
|
|
7639
|
+
const key = process.env.SEO_INDEXNOW_KEY || "";
|
|
7640
|
+
if (!key) return new Response("IndexNow key not configured", { status: 404 });
|
|
7641
|
+
return new Response(key, {
|
|
7642
|
+
status: 200,
|
|
7643
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=86400" }
|
|
7644
|
+
});
|
|
7645
|
+
};
|
|
7646
|
+
}
|
|
7647
|
+
function createIndexNowSubmitHandler(basePath, targetCollections, seoConfig) {
|
|
7648
|
+
return async (req) => {
|
|
7649
|
+
try {
|
|
7650
|
+
if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7651
|
+
const key = process.env.SEO_INDEXNOW_KEY;
|
|
7652
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
7653
|
+
if (!key) return Response.json({ error: "SEO_INDEXNOW_KEY not configured." }, { status: 400 });
|
|
7654
|
+
if (!siteUrl) return Response.json({ error: "siteUrl not configured." }, { status: 400 });
|
|
7655
|
+
const urls = [];
|
|
7656
|
+
for (const collection of targetCollections) {
|
|
7657
|
+
try {
|
|
7658
|
+
const res = await req.payload.find({ collection, limit: 1e3, depth: 0, overrideAccess: true });
|
|
7659
|
+
for (const d of res.docs) {
|
|
7660
|
+
if (d._status === "draft") continue;
|
|
7661
|
+
urls.push(docToUrl(d.slug || "", siteUrl));
|
|
7662
|
+
}
|
|
7663
|
+
} catch {
|
|
7664
|
+
}
|
|
7665
|
+
}
|
|
7666
|
+
const result = await submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), urls);
|
|
7667
|
+
return Response.json(
|
|
7668
|
+
{ submitted: urls.length, ...result },
|
|
7669
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7670
|
+
);
|
|
7671
|
+
} catch (error) {
|
|
7672
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7673
|
+
req.payload.logger.error(`[seo] indexnow-submit error: ${message}`);
|
|
7674
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7675
|
+
}
|
|
7676
|
+
};
|
|
7677
|
+
}
|
|
7678
|
+
function createIndexNowHook(basePath, seoConfig) {
|
|
7679
|
+
return ({ doc, req }) => {
|
|
7680
|
+
try {
|
|
7681
|
+
const key = process.env.SEO_INDEXNOW_KEY;
|
|
7682
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
7683
|
+
if (!key || !siteUrl) return doc;
|
|
7684
|
+
const status = doc?._status;
|
|
7685
|
+
if (status && status !== "published") return doc;
|
|
7686
|
+
const slug = doc?.slug || "";
|
|
7687
|
+
const url = docToUrl(slug, siteUrl);
|
|
7688
|
+
void submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), [url]).then((r) => {
|
|
7689
|
+
if (!r.ok && r.reason !== "no_key_or_urls") {
|
|
7690
|
+
req?.payload?.logger?.warn(`[seo] IndexNow submit failed (${r.status || r.reason})`);
|
|
7691
|
+
}
|
|
7692
|
+
});
|
|
7693
|
+
} catch {
|
|
7694
|
+
}
|
|
7695
|
+
return doc;
|
|
7696
|
+
};
|
|
7697
|
+
}
|
|
7698
|
+
|
|
7599
7699
|
// src/rateLimiter.ts
|
|
7600
7700
|
function createRateLimiter(maxRequests, windowMs) {
|
|
7601
7701
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -7643,7 +7743,7 @@ function getClientIp(req) {
|
|
|
7643
7743
|
|
|
7644
7744
|
// src/endpoints/seoLogs.ts
|
|
7645
7745
|
var VALID_LOG_TYPES = ["404", "redirect", "error"];
|
|
7646
|
-
function
|
|
7746
|
+
function isAdmin10(user) {
|
|
7647
7747
|
if (!user) return false;
|
|
7648
7748
|
if (user.role === "admin") return true;
|
|
7649
7749
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -7740,7 +7840,7 @@ function createSeoLogsHandler(seoLogsSecret) {
|
|
|
7740
7840
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
7741
7841
|
}
|
|
7742
7842
|
if (method === "DELETE") {
|
|
7743
|
-
if (!
|
|
7843
|
+
if (!isAdmin10(req.user)) {
|
|
7744
7844
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
7745
7845
|
}
|
|
7746
7846
|
try {
|
|
@@ -7982,7 +8082,7 @@ function stopRankTracker() {
|
|
|
7982
8082
|
}
|
|
7983
8083
|
|
|
7984
8084
|
// src/endpoints/alerts.ts
|
|
7985
|
-
function
|
|
8085
|
+
function isAdmin11(user) {
|
|
7986
8086
|
if (!user) return false;
|
|
7987
8087
|
if (user.role === "admin") return true;
|
|
7988
8088
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -8148,7 +8248,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
|
|
|
8148
8248
|
function createAlertsDigestHandler() {
|
|
8149
8249
|
return async (req) => {
|
|
8150
8250
|
try {
|
|
8151
|
-
if (!
|
|
8251
|
+
if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
8152
8252
|
const cfg = getAlertConfig();
|
|
8153
8253
|
const digest = await buildAlertDigest(req.payload, cfg);
|
|
8154
8254
|
return Response.json(
|
|
@@ -8174,7 +8274,7 @@ function createAlertsDigestHandler() {
|
|
|
8174
8274
|
function createAlertsRunHandler(siteUrl) {
|
|
8175
8275
|
return async (req) => {
|
|
8176
8276
|
try {
|
|
8177
|
-
if (!
|
|
8277
|
+
if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
8178
8278
|
const cfg = getAlertConfig();
|
|
8179
8279
|
const digest = await buildAlertDigest(req.payload, cfg);
|
|
8180
8280
|
const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
|
|
@@ -10436,6 +10536,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10436
10536
|
// disable on low-memory hosts to skip startup pre-loading
|
|
10437
10537
|
alerts: false,
|
|
10438
10538
|
// opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
|
|
10539
|
+
indexNow: false,
|
|
10540
|
+
// opt-in — requires SEO_INDEXNOW_KEY
|
|
10439
10541
|
...pluginConfig.features
|
|
10440
10542
|
};
|
|
10441
10543
|
function hasExistingSeoMeta(fields) {
|
|
@@ -10536,6 +10638,16 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10536
10638
|
]
|
|
10537
10639
|
};
|
|
10538
10640
|
}
|
|
10641
|
+
if (features.indexNow) {
|
|
10642
|
+
const existingHooks = updated.hooks?.afterChange || [];
|
|
10643
|
+
updated.hooks = {
|
|
10644
|
+
...updated.hooks,
|
|
10645
|
+
afterChange: [
|
|
10646
|
+
...Array.isArray(existingHooks) ? existingHooks : [existingHooks],
|
|
10647
|
+
createIndexNowHook(basePath, seoConfig)
|
|
10648
|
+
]
|
|
10649
|
+
};
|
|
10650
|
+
}
|
|
10539
10651
|
return updated;
|
|
10540
10652
|
}
|
|
10541
10653
|
return collection;
|
|
@@ -10736,6 +10848,12 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10736
10848
|
{ path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
|
|
10737
10849
|
);
|
|
10738
10850
|
}
|
|
10851
|
+
if (features.indexNow) {
|
|
10852
|
+
pluginEndpoints.push(
|
|
10853
|
+
{ path: `${basePath}/indexnow-key.txt`, method: "get", handler: createIndexNowKeyHandler() },
|
|
10854
|
+
{ path: `${basePath}/indexnow-submit`, method: "post", handler: withRateLimit(createIndexNowSubmitHandler(basePath, targetCollections, seoConfig)) }
|
|
10855
|
+
);
|
|
10856
|
+
}
|
|
10739
10857
|
if (features.keywords) {
|
|
10740
10858
|
pluginEndpoints.push({
|
|
10741
10859
|
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
|
@@ -1684,8 +1684,11 @@ var CACHE_KEY = "audit";
|
|
|
1684
1684
|
var auditBuildsInFlight = /* @__PURE__ */ new Set();
|
|
1685
1685
|
async function buildAuditCache(payload, collections, globals, seoConfig, reqLocale) {
|
|
1686
1686
|
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig, { reqLocale });
|
|
1687
|
-
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "
|
|
1687
|
+
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "10", 10) || 10));
|
|
1688
1688
|
const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
|
|
1689
|
+
const BATCH_DELAY_MS = Math.min(5e3, Math.max(0, parseInt(process.env.SEO_AUDIT_BATCH_DELAY_MS || "100", 10) || 0));
|
|
1690
|
+
const rawDepth = parseInt(process.env.SEO_AUDIT_DEPTH ?? "1", 10);
|
|
1691
|
+
const DEPTH = Number.isNaN(rawDepth) ? 1 : Math.min(2, Math.max(0, rawDepth));
|
|
1689
1692
|
const allResults = [];
|
|
1690
1693
|
let capped = false;
|
|
1691
1694
|
collectionsLoop:
|
|
@@ -1698,7 +1701,7 @@ async function buildAuditCache(payload, collections, globals, seoConfig, reqLoca
|
|
|
1698
1701
|
collection: collectionSlug,
|
|
1699
1702
|
limit: BATCH_SIZE,
|
|
1700
1703
|
page,
|
|
1701
|
-
depth:
|
|
1704
|
+
depth: DEPTH,
|
|
1702
1705
|
overrideAccess: true
|
|
1703
1706
|
});
|
|
1704
1707
|
for (const doc of result.docs) {
|
|
@@ -1718,7 +1721,7 @@ async function buildAuditCache(payload, collections, globals, seoConfig, reqLoca
|
|
|
1718
1721
|
}
|
|
1719
1722
|
hasMore = result.hasNextPage;
|
|
1720
1723
|
page++;
|
|
1721
|
-
await new Promise((resolve) =>
|
|
1724
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
1722
1725
|
}
|
|
1723
1726
|
} catch {
|
|
1724
1727
|
}
|
|
@@ -7542,6 +7545,7 @@ function createSeoHealthHandler(basePath, seoConfig) {
|
|
|
7542
7545
|
gscEncryptionKey: !!process.env.SEO_GSC_ENCRYPTION_KEY,
|
|
7543
7546
|
alertWebhook: !!process.env.SEO_ALERT_WEBHOOK_URL,
|
|
7544
7547
|
alertEmail: !!process.env.SEO_ALERT_EMAIL,
|
|
7548
|
+
indexNowKey: !!process.env.SEO_INDEXNOW_KEY,
|
|
7545
7549
|
siteUrl: seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || null
|
|
7546
7550
|
};
|
|
7547
7551
|
const cacheStats = seoCache.stats();
|
|
@@ -7594,6 +7598,102 @@ function createSeoHealthHandler(basePath, seoConfig) {
|
|
|
7594
7598
|
};
|
|
7595
7599
|
}
|
|
7596
7600
|
|
|
7601
|
+
// src/endpoints/indexNow.ts
|
|
7602
|
+
function isAdmin9(user) {
|
|
7603
|
+
if (!user) return false;
|
|
7604
|
+
if (user.role === "admin") return true;
|
|
7605
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
7606
|
+
return false;
|
|
7607
|
+
}
|
|
7608
|
+
function docToUrl(slug, siteUrl) {
|
|
7609
|
+
const base = siteUrl.replace(/\/$/, "");
|
|
7610
|
+
if (!slug || slug === "home") return base;
|
|
7611
|
+
return `${base}/${slug}`;
|
|
7612
|
+
}
|
|
7613
|
+
async function submitToIndexNow(siteUrl, key, keyLocation, urls) {
|
|
7614
|
+
if (!key || urls.length === 0) return { ok: false, reason: "no_key_or_urls" };
|
|
7615
|
+
let host;
|
|
7616
|
+
try {
|
|
7617
|
+
host = new URL(siteUrl).host;
|
|
7618
|
+
} catch {
|
|
7619
|
+
return { ok: false, reason: "bad_site_url" };
|
|
7620
|
+
}
|
|
7621
|
+
try {
|
|
7622
|
+
const resp = await fetch("https://api.indexnow.org/indexnow", {
|
|
7623
|
+
method: "POST",
|
|
7624
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
7625
|
+
body: JSON.stringify({ host, key, keyLocation, urlList: urls.slice(0, 1e4) })
|
|
7626
|
+
});
|
|
7627
|
+
return { ok: resp.ok, status: resp.status };
|
|
7628
|
+
} catch (e) {
|
|
7629
|
+
return { ok: false, reason: e instanceof Error ? e.message : "fetch_error" };
|
|
7630
|
+
}
|
|
7631
|
+
}
|
|
7632
|
+
function keyLocationFor(siteUrl, basePath) {
|
|
7633
|
+
return `${siteUrl.replace(/\/$/, "")}/api${basePath}/indexnow-key.txt`;
|
|
7634
|
+
}
|
|
7635
|
+
function createIndexNowKeyHandler() {
|
|
7636
|
+
return async () => {
|
|
7637
|
+
const key = process.env.SEO_INDEXNOW_KEY || "";
|
|
7638
|
+
if (!key) return new Response("IndexNow key not configured", { status: 404 });
|
|
7639
|
+
return new Response(key, {
|
|
7640
|
+
status: 200,
|
|
7641
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=86400" }
|
|
7642
|
+
});
|
|
7643
|
+
};
|
|
7644
|
+
}
|
|
7645
|
+
function createIndexNowSubmitHandler(basePath, targetCollections, seoConfig) {
|
|
7646
|
+
return async (req) => {
|
|
7647
|
+
try {
|
|
7648
|
+
if (!isAdmin9(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7649
|
+
const key = process.env.SEO_INDEXNOW_KEY;
|
|
7650
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
7651
|
+
if (!key) return Response.json({ error: "SEO_INDEXNOW_KEY not configured." }, { status: 400 });
|
|
7652
|
+
if (!siteUrl) return Response.json({ error: "siteUrl not configured." }, { status: 400 });
|
|
7653
|
+
const urls = [];
|
|
7654
|
+
for (const collection of targetCollections) {
|
|
7655
|
+
try {
|
|
7656
|
+
const res = await req.payload.find({ collection, limit: 1e3, depth: 0, overrideAccess: true });
|
|
7657
|
+
for (const d of res.docs) {
|
|
7658
|
+
if (d._status === "draft") continue;
|
|
7659
|
+
urls.push(docToUrl(d.slug || "", siteUrl));
|
|
7660
|
+
}
|
|
7661
|
+
} catch {
|
|
7662
|
+
}
|
|
7663
|
+
}
|
|
7664
|
+
const result = await submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), urls);
|
|
7665
|
+
return Response.json(
|
|
7666
|
+
{ submitted: urls.length, ...result },
|
|
7667
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7668
|
+
);
|
|
7669
|
+
} catch (error) {
|
|
7670
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7671
|
+
req.payload.logger.error(`[seo] indexnow-submit error: ${message}`);
|
|
7672
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7673
|
+
}
|
|
7674
|
+
};
|
|
7675
|
+
}
|
|
7676
|
+
function createIndexNowHook(basePath, seoConfig) {
|
|
7677
|
+
return ({ doc, req }) => {
|
|
7678
|
+
try {
|
|
7679
|
+
const key = process.env.SEO_INDEXNOW_KEY;
|
|
7680
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
7681
|
+
if (!key || !siteUrl) return doc;
|
|
7682
|
+
const status = doc?._status;
|
|
7683
|
+
if (status && status !== "published") return doc;
|
|
7684
|
+
const slug = doc?.slug || "";
|
|
7685
|
+
const url = docToUrl(slug, siteUrl);
|
|
7686
|
+
void submitToIndexNow(siteUrl, key, keyLocationFor(siteUrl, basePath), [url]).then((r) => {
|
|
7687
|
+
if (!r.ok && r.reason !== "no_key_or_urls") {
|
|
7688
|
+
req?.payload?.logger?.warn(`[seo] IndexNow submit failed (${r.status || r.reason})`);
|
|
7689
|
+
}
|
|
7690
|
+
});
|
|
7691
|
+
} catch {
|
|
7692
|
+
}
|
|
7693
|
+
return doc;
|
|
7694
|
+
};
|
|
7695
|
+
}
|
|
7696
|
+
|
|
7597
7697
|
// src/rateLimiter.ts
|
|
7598
7698
|
function createRateLimiter(maxRequests, windowMs) {
|
|
7599
7699
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -7641,7 +7741,7 @@ function getClientIp(req) {
|
|
|
7641
7741
|
|
|
7642
7742
|
// src/endpoints/seoLogs.ts
|
|
7643
7743
|
var VALID_LOG_TYPES = ["404", "redirect", "error"];
|
|
7644
|
-
function
|
|
7744
|
+
function isAdmin10(user) {
|
|
7645
7745
|
if (!user) return false;
|
|
7646
7746
|
if (user.role === "admin") return true;
|
|
7647
7747
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -7738,7 +7838,7 @@ function createSeoLogsHandler(seoLogsSecret) {
|
|
|
7738
7838
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
7739
7839
|
}
|
|
7740
7840
|
if (method === "DELETE") {
|
|
7741
|
-
if (!
|
|
7841
|
+
if (!isAdmin10(req.user)) {
|
|
7742
7842
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
7743
7843
|
}
|
|
7744
7844
|
try {
|
|
@@ -7980,7 +8080,7 @@ function stopRankTracker() {
|
|
|
7980
8080
|
}
|
|
7981
8081
|
|
|
7982
8082
|
// src/endpoints/alerts.ts
|
|
7983
|
-
function
|
|
8083
|
+
function isAdmin11(user) {
|
|
7984
8084
|
if (!user) return false;
|
|
7985
8085
|
if (user.role === "admin") return true;
|
|
7986
8086
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -8146,7 +8246,7 @@ async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
|
|
|
8146
8246
|
function createAlertsDigestHandler() {
|
|
8147
8247
|
return async (req) => {
|
|
8148
8248
|
try {
|
|
8149
|
-
if (!
|
|
8249
|
+
if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
8150
8250
|
const cfg = getAlertConfig();
|
|
8151
8251
|
const digest = await buildAlertDigest(req.payload, cfg);
|
|
8152
8252
|
return Response.json(
|
|
@@ -8172,7 +8272,7 @@ function createAlertsDigestHandler() {
|
|
|
8172
8272
|
function createAlertsRunHandler(siteUrl) {
|
|
8173
8273
|
return async (req) => {
|
|
8174
8274
|
try {
|
|
8175
|
-
if (!
|
|
8275
|
+
if (!isAdmin11(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
8176
8276
|
const cfg = getAlertConfig();
|
|
8177
8277
|
const digest = await buildAlertDigest(req.payload, cfg);
|
|
8178
8278
|
const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
|
|
@@ -10434,6 +10534,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10434
10534
|
// disable on low-memory hosts to skip startup pre-loading
|
|
10435
10535
|
alerts: false,
|
|
10436
10536
|
// opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
|
|
10537
|
+
indexNow: false,
|
|
10538
|
+
// opt-in — requires SEO_INDEXNOW_KEY
|
|
10437
10539
|
...pluginConfig.features
|
|
10438
10540
|
};
|
|
10439
10541
|
function hasExistingSeoMeta(fields) {
|
|
@@ -10534,6 +10636,16 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10534
10636
|
]
|
|
10535
10637
|
};
|
|
10536
10638
|
}
|
|
10639
|
+
if (features.indexNow) {
|
|
10640
|
+
const existingHooks = updated.hooks?.afterChange || [];
|
|
10641
|
+
updated.hooks = {
|
|
10642
|
+
...updated.hooks,
|
|
10643
|
+
afterChange: [
|
|
10644
|
+
...Array.isArray(existingHooks) ? existingHooks : [existingHooks],
|
|
10645
|
+
createIndexNowHook(basePath, seoConfig)
|
|
10646
|
+
]
|
|
10647
|
+
};
|
|
10648
|
+
}
|
|
10537
10649
|
return updated;
|
|
10538
10650
|
}
|
|
10539
10651
|
return collection;
|
|
@@ -10734,6 +10846,12 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
10734
10846
|
{ path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
|
|
10735
10847
|
);
|
|
10736
10848
|
}
|
|
10849
|
+
if (features.indexNow) {
|
|
10850
|
+
pluginEndpoints.push(
|
|
10851
|
+
{ path: `${basePath}/indexnow-key.txt`, method: "get", handler: createIndexNowKeyHandler() },
|
|
10852
|
+
{ path: `${basePath}/indexnow-submit`, method: "post", handler: withRateLimit(createIndexNowSubmitHandler(basePath, targetCollections, seoConfig)) }
|
|
10853
|
+
);
|
|
10854
|
+
}
|
|
10737
10855
|
if (features.keywords) {
|
|
10738
10856
|
pluginEndpoints.push({
|
|
10739
10857
|
path: `${basePath}/keyword-research`,
|
package/package.json
CHANGED