@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/README.md +2 -0
- package/dist/client.cjs +293 -206
- package/dist/client.js +293 -206
- package/dist/index.cjs +196 -5
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196 -5
- package/package.json +1 -1
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
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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