@consilioweb/payload-seo-analyzer 1.8.1 → 1.10.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 +75 -1
- package/dist/client.cjs +887 -91
- package/dist/client.js +887 -91
- package/dist/index.cjs +1445 -347
- package/dist/index.d.cts +125 -3
- package/dist/index.d.ts +125 -3
- package/dist/index.js +1440 -348
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var dns = require('dns');
|
|
4
3
|
var crypto = require('crypto');
|
|
4
|
+
var dns = require('dns');
|
|
5
5
|
|
|
6
6
|
// src/constants.ts
|
|
7
7
|
var TITLE_LENGTH_MIN = 30;
|
|
@@ -1682,6 +1682,127 @@ function analyzeDoc(doc, collection, seoConfig) {
|
|
|
1682
1682
|
daysSinceUpdate: doc.updatedAt ? Math.floor((Date.now() - new Date(doc.updatedAt).getTime()) / (1e3 * 60 * 60 * 24)) : null
|
|
1683
1683
|
};
|
|
1684
1684
|
}
|
|
1685
|
+
var CACHE_KEY = "audit";
|
|
1686
|
+
var auditBuildInFlight = false;
|
|
1687
|
+
async function buildAuditCache(payload, collections, globals, seoConfig) {
|
|
1688
|
+
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(payload, seoConfig);
|
|
1689
|
+
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "15", 10) || 15));
|
|
1690
|
+
const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
|
|
1691
|
+
const allResults = [];
|
|
1692
|
+
let capped = false;
|
|
1693
|
+
collectionsLoop:
|
|
1694
|
+
for (const collectionSlug of collections) {
|
|
1695
|
+
try {
|
|
1696
|
+
let page = 1;
|
|
1697
|
+
let hasMore = true;
|
|
1698
|
+
while (hasMore) {
|
|
1699
|
+
const result = await payload.find({
|
|
1700
|
+
collection: collectionSlug,
|
|
1701
|
+
limit: BATCH_SIZE,
|
|
1702
|
+
page,
|
|
1703
|
+
depth: 1,
|
|
1704
|
+
overrideAccess: true
|
|
1705
|
+
});
|
|
1706
|
+
for (const doc of result.docs) {
|
|
1707
|
+
if (ignoredSlugs.includes(doc.slug)) continue;
|
|
1708
|
+
if (allResults.length >= MAX_DOCS2) {
|
|
1709
|
+
capped = true;
|
|
1710
|
+
break collectionsLoop;
|
|
1711
|
+
}
|
|
1712
|
+
try {
|
|
1713
|
+
allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
|
|
1714
|
+
} catch (e) {
|
|
1715
|
+
payload.logger.warn(
|
|
1716
|
+
`[seo] audit: skipped ${collectionSlug}/${doc.id}: ${e instanceof Error ? e.message : "error"}`
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
hasMore = result.hasNextPage;
|
|
1721
|
+
page++;
|
|
1722
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1723
|
+
}
|
|
1724
|
+
} catch {
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (capped) {
|
|
1728
|
+
payload.logger.warn(
|
|
1729
|
+
`[seo] audit: capped at ${MAX_DOCS2} docs (SEO_AUDIT_MAX_DOCS). Lower SEO_AUDIT_BATCH_SIZE on low-memory hosts, or raise the cap.`
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
for (const globalSlug of globals) {
|
|
1733
|
+
try {
|
|
1734
|
+
const doc = await payload.findGlobal({
|
|
1735
|
+
slug: globalSlug,
|
|
1736
|
+
depth: 1,
|
|
1737
|
+
overrideAccess: true
|
|
1738
|
+
});
|
|
1739
|
+
if (doc) {
|
|
1740
|
+
if (ignoredSlugs.includes(globalSlug)) continue;
|
|
1741
|
+
const result = analyzeDoc(doc, `global:${globalSlug}`, mergedConfig);
|
|
1742
|
+
allResults.push({
|
|
1743
|
+
...result,
|
|
1744
|
+
id: globalSlug,
|
|
1745
|
+
collection: `global:${globalSlug}`,
|
|
1746
|
+
slug: "",
|
|
1747
|
+
title: doc.title || globalSlug
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
} catch {
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
const previousScoreMap = /* @__PURE__ */ new Map();
|
|
1754
|
+
try {
|
|
1755
|
+
const historyResults = await payload.find({
|
|
1756
|
+
collection: "seo-score-history",
|
|
1757
|
+
limit: Math.min(allResults.length * 2, 3e3),
|
|
1758
|
+
sort: "-snapshotDate",
|
|
1759
|
+
depth: 0,
|
|
1760
|
+
overrideAccess: true
|
|
1761
|
+
});
|
|
1762
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1763
|
+
for (const h of historyResults.docs) {
|
|
1764
|
+
const key = `${h.documentId}::${h.collection}`;
|
|
1765
|
+
if (!seen.has(key)) {
|
|
1766
|
+
seen.add(key);
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
if (!previousScoreMap.has(key)) {
|
|
1770
|
+
previousScoreMap.set(key, h.score);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
const enrichedResults = allResults.map((r) => ({
|
|
1776
|
+
...r,
|
|
1777
|
+
previousScore: previousScoreMap.get(`${r.id}::${r.collection}`) ?? null
|
|
1778
|
+
}));
|
|
1779
|
+
enrichedResults.sort((a, b) => a.score - b.score);
|
|
1780
|
+
const totalDocs = enrichedResults.length;
|
|
1781
|
+
const stats = {
|
|
1782
|
+
totalPages: totalDocs,
|
|
1783
|
+
avgScore: totalDocs > 0 ? Math.round(enrichedResults.reduce((s, r) => s + r.score, 0) / totalDocs) : 0,
|
|
1784
|
+
good: enrichedResults.filter((r) => r.score >= 80).length,
|
|
1785
|
+
needsWork: enrichedResults.filter((r) => r.score >= 50 && r.score < 80).length,
|
|
1786
|
+
critical: enrichedResults.filter((r) => r.score < 50).length,
|
|
1787
|
+
noKeyword: enrichedResults.filter((r) => !r.focusKeyword).length,
|
|
1788
|
+
noMetaTitle: enrichedResults.filter((r) => !r.metaTitle).length,
|
|
1789
|
+
noMetaDesc: enrichedResults.filter((r) => !r.metaDescription).length,
|
|
1790
|
+
avgWordCount: totalDocs > 0 ? Math.round(enrichedResults.reduce((s, r) => s + r.wordCount, 0) / totalDocs) : 0,
|
|
1791
|
+
avgReadability: totalDocs > 0 ? Math.round(enrichedResults.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs) : 0
|
|
1792
|
+
};
|
|
1793
|
+
return { enrichedResults, stats, capped };
|
|
1794
|
+
}
|
|
1795
|
+
function ensureAuditBuild(payload, collections, globals, seoConfig) {
|
|
1796
|
+
if (auditBuildInFlight) return;
|
|
1797
|
+
auditBuildInFlight = true;
|
|
1798
|
+
void buildAuditCache(payload, collections, globals, seoConfig).then((result) => {
|
|
1799
|
+
seoCache.set(CACHE_KEY, result);
|
|
1800
|
+
}).catch((e) => {
|
|
1801
|
+
payload.logger.error(`[seo] audit build failed: ${e instanceof Error ? e.message : "unknown"}`);
|
|
1802
|
+
}).finally(() => {
|
|
1803
|
+
auditBuildInFlight = false;
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1685
1806
|
function createAuditHandler(collections, seoConfig, globals = []) {
|
|
1686
1807
|
return async (req) => {
|
|
1687
1808
|
try {
|
|
@@ -1692,116 +1813,16 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1692
1813
|
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
|
|
1693
1814
|
const limit = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "300", 10)));
|
|
1694
1815
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
1695
|
-
|
|
1696
|
-
|
|
1816
|
+
if (noCache && !auditBuildInFlight) {
|
|
1817
|
+
seoCache.invalidateKey(CACHE_KEY);
|
|
1818
|
+
}
|
|
1819
|
+
const cached = seoCache.get(CACHE_KEY);
|
|
1697
1820
|
if (!cached) {
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
collectionsLoop:
|
|
1704
|
-
for (const collectionSlug of collections) {
|
|
1705
|
-
try {
|
|
1706
|
-
let page2 = 1;
|
|
1707
|
-
let hasMore = true;
|
|
1708
|
-
while (hasMore) {
|
|
1709
|
-
const result = await req.payload.find({
|
|
1710
|
-
collection: collectionSlug,
|
|
1711
|
-
limit: BATCH_SIZE,
|
|
1712
|
-
page: page2,
|
|
1713
|
-
depth: 1,
|
|
1714
|
-
overrideAccess: true
|
|
1715
|
-
});
|
|
1716
|
-
for (const doc of result.docs) {
|
|
1717
|
-
if (ignoredSlugs.includes(doc.slug)) continue;
|
|
1718
|
-
if (allResults.length >= MAX_DOCS2) {
|
|
1719
|
-
capped2 = true;
|
|
1720
|
-
break collectionsLoop;
|
|
1721
|
-
}
|
|
1722
|
-
try {
|
|
1723
|
-
allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
|
|
1724
|
-
} catch (e) {
|
|
1725
|
-
req.payload.logger.warn(
|
|
1726
|
-
`[seo] audit: skipped ${collectionSlug}/${doc.id}: ${e instanceof Error ? e.message : "error"}`
|
|
1727
|
-
);
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
hasMore = result.hasNextPage;
|
|
1731
|
-
page2++;
|
|
1732
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
1733
|
-
}
|
|
1734
|
-
} catch {
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
if (capped2) {
|
|
1738
|
-
req.payload.logger.warn(
|
|
1739
|
-
`[seo] audit: capped at ${MAX_DOCS2} docs (SEO_AUDIT_MAX_DOCS). Lower SEO_AUDIT_BATCH_SIZE on low-memory hosts, or raise the cap.`
|
|
1740
|
-
);
|
|
1741
|
-
}
|
|
1742
|
-
for (const globalSlug of globals) {
|
|
1743
|
-
try {
|
|
1744
|
-
const doc = await req.payload.findGlobal({
|
|
1745
|
-
slug: globalSlug,
|
|
1746
|
-
depth: 1,
|
|
1747
|
-
overrideAccess: true
|
|
1748
|
-
});
|
|
1749
|
-
if (doc) {
|
|
1750
|
-
if (ignoredSlugs.includes(globalSlug)) continue;
|
|
1751
|
-
const result = analyzeDoc(doc, `global:${globalSlug}`, mergedConfig);
|
|
1752
|
-
allResults.push({
|
|
1753
|
-
...result,
|
|
1754
|
-
id: globalSlug,
|
|
1755
|
-
collection: `global:${globalSlug}`,
|
|
1756
|
-
slug: "",
|
|
1757
|
-
title: doc.title || globalSlug
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
1760
|
-
} catch {
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
const previousScoreMap = /* @__PURE__ */ new Map();
|
|
1764
|
-
try {
|
|
1765
|
-
const historyResults = await req.payload.find({
|
|
1766
|
-
collection: "seo-score-history",
|
|
1767
|
-
limit: allResults.length * 2,
|
|
1768
|
-
sort: "-snapshotDate",
|
|
1769
|
-
depth: 0,
|
|
1770
|
-
overrideAccess: true
|
|
1771
|
-
});
|
|
1772
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1773
|
-
for (const h of historyResults.docs) {
|
|
1774
|
-
const key = `${h.documentId}::${h.collection}`;
|
|
1775
|
-
if (!seen.has(key)) {
|
|
1776
|
-
seen.add(key);
|
|
1777
|
-
continue;
|
|
1778
|
-
}
|
|
1779
|
-
if (!previousScoreMap.has(key)) {
|
|
1780
|
-
previousScoreMap.set(key, h.score);
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
} catch {
|
|
1784
|
-
}
|
|
1785
|
-
const enrichedResults2 = allResults.map((r) => ({
|
|
1786
|
-
...r,
|
|
1787
|
-
previousScore: previousScoreMap.get(`${r.id}::${r.collection}`) ?? null
|
|
1788
|
-
}));
|
|
1789
|
-
enrichedResults2.sort((a, b) => a.score - b.score);
|
|
1790
|
-
const totalDocs2 = enrichedResults2.length;
|
|
1791
|
-
const stats2 = {
|
|
1792
|
-
totalPages: totalDocs2,
|
|
1793
|
-
avgScore: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.score, 0) / totalDocs2) : 0,
|
|
1794
|
-
good: enrichedResults2.filter((r) => r.score >= 80).length,
|
|
1795
|
-
needsWork: enrichedResults2.filter((r) => r.score >= 50 && r.score < 80).length,
|
|
1796
|
-
critical: enrichedResults2.filter((r) => r.score < 50).length,
|
|
1797
|
-
noKeyword: enrichedResults2.filter((r) => !r.focusKeyword).length,
|
|
1798
|
-
noMetaTitle: enrichedResults2.filter((r) => !r.metaTitle).length,
|
|
1799
|
-
noMetaDesc: enrichedResults2.filter((r) => !r.metaDescription).length,
|
|
1800
|
-
avgWordCount: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.wordCount, 0) / totalDocs2) : 0,
|
|
1801
|
-
avgReadability: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs2) : 0
|
|
1802
|
-
};
|
|
1803
|
-
cached = { enrichedResults: enrichedResults2, stats: stats2, capped: capped2 };
|
|
1804
|
-
seoCache.set(CACHE_KEY, cached);
|
|
1821
|
+
ensureAuditBuild(req.payload, collections, globals, seoConfig);
|
|
1822
|
+
return Response.json(
|
|
1823
|
+
{ building: true, results: [], stats: null },
|
|
1824
|
+
{ status: 202, headers: { "Cache-Control": "no-store" } }
|
|
1825
|
+
);
|
|
1805
1826
|
}
|
|
1806
1827
|
const { enrichedResults, stats, capped } = cached;
|
|
1807
1828
|
const totalDocs = enrichedResults.length;
|
|
@@ -1819,7 +1840,7 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1819
1840
|
hasNextPage: page < totalPages,
|
|
1820
1841
|
hasPrevPage: page > 1
|
|
1821
1842
|
},
|
|
1822
|
-
cached:
|
|
1843
|
+
cached: true,
|
|
1823
1844
|
capped
|
|
1824
1845
|
}, { headers: { "Cache-Control": "no-store" } });
|
|
1825
1846
|
} catch (error) {
|
|
@@ -2176,8 +2197,8 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
|
|
|
2176
2197
|
}
|
|
2177
2198
|
const url = new URL(req.url);
|
|
2178
2199
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
2179
|
-
const
|
|
2180
|
-
const cached = noCache ? null : seoCache.get(
|
|
2200
|
+
const CACHE_KEY2 = "sitemap-audit";
|
|
2201
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
2181
2202
|
if (cached) {
|
|
2182
2203
|
return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
|
|
2183
2204
|
}
|
|
@@ -2323,7 +2344,7 @@ function createSitemapAuditHandler(collections, redirectsCollection = "seo-redir
|
|
|
2323
2344
|
brokenCount: uniqueBrokenLinks.length
|
|
2324
2345
|
}
|
|
2325
2346
|
};
|
|
2326
|
-
seoCache.set(
|
|
2347
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
2327
2348
|
return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
|
|
2328
2349
|
} catch (error) {
|
|
2329
2350
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -2906,6 +2927,522 @@ function createAiGenerateHandler() {
|
|
|
2906
2927
|
};
|
|
2907
2928
|
}
|
|
2908
2929
|
|
|
2930
|
+
// src/endpoints/aiOptimize.ts
|
|
2931
|
+
var DEFAULT_MODEL = "claude-opus-4-8";
|
|
2932
|
+
var TITLE_HARD_MAX = 70;
|
|
2933
|
+
var DESC_HARD_MAX = 160;
|
|
2934
|
+
var KEYWORD_MAX = 60;
|
|
2935
|
+
var RATIONALE_MAX_ITEMS = 4;
|
|
2936
|
+
var RATIONALE_ITEM_MAX = 200;
|
|
2937
|
+
async function callClaudeOptimize(apiKey, model, params) {
|
|
2938
|
+
const systemPrompt = `You are an SEO expert applying June 2026 best practices. You optimize ONLY a page's meta title and meta description (and may suggest a focus keyword). You NEVER rewrite body content.
|
|
2939
|
+
Strict rules (these mirror the site's own SEO engine \u2014 follow them exactly):
|
|
2940
|
+
- Meta title: natural and compelling, hard limit ${TITLE_HARD_MAX} characters, front-load the important words, do NOT keyword-stuff or repeat the brand/keyword.
|
|
2941
|
+
- Meta description: 120-160 characters, one or two sentences, action-oriented, includes the focus keyword ONCE and naturally. No keyword stuffing.
|
|
2942
|
+
- Write in the SAME language as the page content.
|
|
2943
|
+
- Base everything on the ACTUAL content; never invent facts, prices, numbers, or claims not present in the content.
|
|
2944
|
+
- If the focus keyword is missing, you MAY suggest one short keyword (2-4 words) matching the content's main topic; otherwise keep the existing one.
|
|
2945
|
+
Return ONLY a JSON object (no markdown, no prose, no code fences) with EXACTLY this shape:
|
|
2946
|
+
{"metaTitle": string, "metaDescription": string, "focusKeyword": string, "rationale": string[]}
|
|
2947
|
+
"rationale": 2-4 short strings, in the page's language, explaining what you improved and why, referencing the detected issues.`;
|
|
2948
|
+
const issuesText = params.issues.length ? params.issues.map((i) => `- ${i.label}: ${i.message}`).join("\n") : "- (no blocking issues \u2014 focus on making the meta tags more compelling and accurate)";
|
|
2949
|
+
const userPrompt = `Page title: ${params.pageTitle || "(none)"}
|
|
2950
|
+
Slug: ${params.slug || "(none)"}
|
|
2951
|
+
Current focus keyword: ${params.focusKeyword || "(none)"}
|
|
2952
|
+
Current meta title: ${params.currentMetaTitle || "(empty)"}
|
|
2953
|
+
Current meta description: ${params.currentMetaDescription || "(empty)"}
|
|
2954
|
+
|
|
2955
|
+
SEO issues detected by the engine (fix these):
|
|
2956
|
+
${issuesText}
|
|
2957
|
+
|
|
2958
|
+
Page content (first 3000 chars):
|
|
2959
|
+
${params.content.substring(0, 3e3)}
|
|
2960
|
+
|
|
2961
|
+
Return the optimized JSON now:`;
|
|
2962
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
2963
|
+
method: "POST",
|
|
2964
|
+
headers: {
|
|
2965
|
+
"Content-Type": "application/json",
|
|
2966
|
+
"x-api-key": apiKey,
|
|
2967
|
+
"anthropic-version": "2023-06-01"
|
|
2968
|
+
},
|
|
2969
|
+
body: JSON.stringify({
|
|
2970
|
+
model,
|
|
2971
|
+
max_tokens: 1024,
|
|
2972
|
+
system: systemPrompt,
|
|
2973
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
2974
|
+
})
|
|
2975
|
+
});
|
|
2976
|
+
if (!response.ok) {
|
|
2977
|
+
const errorBody = await response.text();
|
|
2978
|
+
throw new Error(`Claude API error ${response.status}: ${errorBody}`);
|
|
2979
|
+
}
|
|
2980
|
+
const data = await response.json();
|
|
2981
|
+
if (data.stop_reason === "refusal") {
|
|
2982
|
+
return null;
|
|
2983
|
+
}
|
|
2984
|
+
const text = (data.content?.find((b) => b.type === "text")?.text || "").trim();
|
|
2985
|
+
if (!text) return null;
|
|
2986
|
+
return parseSuggestions(text);
|
|
2987
|
+
}
|
|
2988
|
+
function parseSuggestions(raw) {
|
|
2989
|
+
let s = raw.trim();
|
|
2990
|
+
if (s.startsWith("```")) {
|
|
2991
|
+
s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
2992
|
+
}
|
|
2993
|
+
if (!s.startsWith("{")) {
|
|
2994
|
+
const start = s.indexOf("{");
|
|
2995
|
+
const end = s.lastIndexOf("}");
|
|
2996
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
2997
|
+
s = s.slice(start, end + 1);
|
|
2998
|
+
}
|
|
2999
|
+
try {
|
|
3000
|
+
const parsed = JSON.parse(s);
|
|
3001
|
+
return {
|
|
3002
|
+
metaTitle: typeof parsed.metaTitle === "string" ? parsed.metaTitle : "",
|
|
3003
|
+
metaDescription: typeof parsed.metaDescription === "string" ? parsed.metaDescription : "",
|
|
3004
|
+
focusKeyword: typeof parsed.focusKeyword === "string" ? parsed.focusKeyword : "",
|
|
3005
|
+
rationale: Array.isArray(parsed.rationale) ? parsed.rationale.filter((r) => typeof r === "string") : []
|
|
3006
|
+
};
|
|
3007
|
+
} catch {
|
|
3008
|
+
return null;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
function sanitizeSuggestions(s, currentFocusKeyword) {
|
|
3012
|
+
const metaTitle = truncateWords(s.metaTitle.trim(), TITLE_HARD_MAX);
|
|
3013
|
+
const metaDescription = truncateWords(s.metaDescription.trim(), DESC_HARD_MAX);
|
|
3014
|
+
let focusKeyword = currentFocusKeyword;
|
|
3015
|
+
if (!currentFocusKeyword.trim()) {
|
|
3016
|
+
const suggested = s.focusKeyword.trim();
|
|
3017
|
+
if (suggested && suggested.length <= KEYWORD_MAX) {
|
|
3018
|
+
focusKeyword = suggested;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
const rationale = s.rationale.map((r) => r.trim()).filter(Boolean).slice(0, RATIONALE_MAX_ITEMS).map((r) => r.length > RATIONALE_ITEM_MAX ? `${r.slice(0, RATIONALE_ITEM_MAX - 1)}\u2026` : r);
|
|
3022
|
+
return { metaTitle, metaDescription, focusKeyword, rationale };
|
|
3023
|
+
}
|
|
3024
|
+
function createAiOptimizeHandler(targetCollections, seoConfig, localeMapping) {
|
|
3025
|
+
return async (req) => {
|
|
3026
|
+
try {
|
|
3027
|
+
if (!req.user) {
|
|
3028
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
3029
|
+
}
|
|
3030
|
+
const body = await parseJsonBody(req);
|
|
3031
|
+
const collection = typeof body.collection === "string" ? body.collection.trim() : void 0;
|
|
3032
|
+
const id = typeof body.id === "string" || typeof body.id === "number" ? String(body.id).trim() : void 0;
|
|
3033
|
+
if (!collection || !id) {
|
|
3034
|
+
return Response.json({ error: "Missing required fields: collection, id" }, { status: 400 });
|
|
3035
|
+
}
|
|
3036
|
+
if (targetCollections && !targetCollections.includes(collection)) {
|
|
3037
|
+
return Response.json({ error: "Collection not allowed" }, { status: 403 });
|
|
3038
|
+
}
|
|
3039
|
+
let doc;
|
|
3040
|
+
try {
|
|
3041
|
+
const result = await req.payload.findByID({
|
|
3042
|
+
collection,
|
|
3043
|
+
id,
|
|
3044
|
+
depth: 1,
|
|
3045
|
+
overrideAccess: true
|
|
3046
|
+
});
|
|
3047
|
+
doc = result;
|
|
3048
|
+
} catch {
|
|
3049
|
+
return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
|
|
3050
|
+
}
|
|
3051
|
+
const { config: mergedConfig } = await loadMergedConfig(req.payload, seoConfig, {
|
|
3052
|
+
reqLocale: req.locale,
|
|
3053
|
+
localeMapping
|
|
3054
|
+
});
|
|
3055
|
+
const seoInput = buildSeoInputFromDoc(doc, collection);
|
|
3056
|
+
const analysis = analyzeSeo(seoInput, mergedConfig);
|
|
3057
|
+
const metaGroups = /* @__PURE__ */ new Set(["title", "meta-description", "content", "url", "headings"]);
|
|
3058
|
+
const issues = analysis.checks.filter((c) => (c.status === "fail" || c.status === "warning") && metaGroups.has(c.group)).map((c) => ({ label: c.label, message: c.message })).slice(0, 12);
|
|
3059
|
+
const pageTitle = doc.title || "";
|
|
3060
|
+
const slug = doc.slug || "";
|
|
3061
|
+
const currentFocusKeyword = doc.focusKeyword || "";
|
|
3062
|
+
const currentMetaTitle = seoInput.metaTitle || "";
|
|
3063
|
+
const currentMetaDescription = seoInput.metaDescription || "";
|
|
3064
|
+
const content = extractDocContent(doc).text;
|
|
3065
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3066
|
+
const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL;
|
|
3067
|
+
let suggestions;
|
|
3068
|
+
let method;
|
|
3069
|
+
const heuristicFallback = () => ({
|
|
3070
|
+
metaTitle: generateMetaTitle(pageTitle, currentFocusKeyword, slug),
|
|
3071
|
+
metaDescription: generateMetaDescription(content, currentFocusKeyword, slug),
|
|
3072
|
+
focusKeyword: currentFocusKeyword,
|
|
3073
|
+
rationale: []
|
|
3074
|
+
});
|
|
3075
|
+
if (apiKey) {
|
|
3076
|
+
try {
|
|
3077
|
+
const aiResult = await callClaudeOptimize(apiKey, model, {
|
|
3078
|
+
pageTitle,
|
|
3079
|
+
slug,
|
|
3080
|
+
focusKeyword: currentFocusKeyword,
|
|
3081
|
+
currentMetaTitle,
|
|
3082
|
+
currentMetaDescription,
|
|
3083
|
+
issues,
|
|
3084
|
+
content
|
|
3085
|
+
});
|
|
3086
|
+
if (aiResult) {
|
|
3087
|
+
suggestions = aiResult;
|
|
3088
|
+
method = "ai";
|
|
3089
|
+
} else {
|
|
3090
|
+
suggestions = heuristicFallback();
|
|
3091
|
+
method = "heuristic";
|
|
3092
|
+
}
|
|
3093
|
+
} catch (error) {
|
|
3094
|
+
req.payload.logger.error(
|
|
3095
|
+
`[seo] ai-optimize Claude API error: ${error instanceof Error ? error.message : "unknown"}`
|
|
3096
|
+
);
|
|
3097
|
+
suggestions = heuristicFallback();
|
|
3098
|
+
method = "heuristic";
|
|
3099
|
+
}
|
|
3100
|
+
} else {
|
|
3101
|
+
suggestions = heuristicFallback();
|
|
3102
|
+
method = "heuristic";
|
|
3103
|
+
}
|
|
3104
|
+
const sanitized = sanitizeSuggestions(suggestions, currentFocusKeyword);
|
|
3105
|
+
return Response.json({
|
|
3106
|
+
method,
|
|
3107
|
+
...method === "ai" ? { model } : {},
|
|
3108
|
+
score: analysis.score,
|
|
3109
|
+
current: {
|
|
3110
|
+
metaTitle: currentMetaTitle,
|
|
3111
|
+
metaDescription: currentMetaDescription,
|
|
3112
|
+
focusKeyword: currentFocusKeyword
|
|
3113
|
+
},
|
|
3114
|
+
suggestions: sanitized,
|
|
3115
|
+
issues
|
|
3116
|
+
});
|
|
3117
|
+
} catch (error) {
|
|
3118
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3119
|
+
req.payload.logger.error(`[seo] ai-optimize error: ${message}`);
|
|
3120
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3121
|
+
}
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
var ALGO = "aes-256-gcm";
|
|
3125
|
+
var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
|
|
3126
|
+
var FORMAT_VERSION = "v1";
|
|
3127
|
+
function deriveKey(secret) {
|
|
3128
|
+
const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
|
|
3129
|
+
if (explicit) {
|
|
3130
|
+
const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
|
|
3131
|
+
if (buf.length === 32) return buf;
|
|
3132
|
+
throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
|
|
3133
|
+
}
|
|
3134
|
+
if (!secret) {
|
|
3135
|
+
throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
|
|
3136
|
+
}
|
|
3137
|
+
return crypto.scryptSync(secret, KEY_NAMESPACE, 32);
|
|
3138
|
+
}
|
|
3139
|
+
function encryptToken(plaintext, secret) {
|
|
3140
|
+
const key = deriveKey(secret);
|
|
3141
|
+
const iv = crypto.randomBytes(12);
|
|
3142
|
+
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
3143
|
+
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
3144
|
+
const tag = cipher.getAuthTag();
|
|
3145
|
+
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
|
|
3146
|
+
}
|
|
3147
|
+
function decryptToken(payload, secret) {
|
|
3148
|
+
const parts = payload.split(":");
|
|
3149
|
+
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
3150
|
+
throw new Error("Invalid encrypted token format.");
|
|
3151
|
+
}
|
|
3152
|
+
const key = deriveKey(secret);
|
|
3153
|
+
const iv = Buffer.from(parts[1], "base64");
|
|
3154
|
+
const tag = Buffer.from(parts[2], "base64");
|
|
3155
|
+
const enc = Buffer.from(parts[3], "base64");
|
|
3156
|
+
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
3157
|
+
decipher.setAuthTag(tag);
|
|
3158
|
+
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
3159
|
+
return dec.toString("utf8");
|
|
3160
|
+
}
|
|
3161
|
+
function safeEqual(a, b) {
|
|
3162
|
+
const ba = Buffer.from(a);
|
|
3163
|
+
const bb = Buffer.from(b);
|
|
3164
|
+
if (ba.length !== bb.length) return false;
|
|
3165
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// src/helpers/gscClient.ts
|
|
3169
|
+
var GSC_AUTH_COLLECTION = "seo-gsc-auth";
|
|
3170
|
+
var GSC_SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
|
|
3171
|
+
function isGscAdmin(user) {
|
|
3172
|
+
if (!user) return false;
|
|
3173
|
+
if (user.role === "admin") return true;
|
|
3174
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3175
|
+
return false;
|
|
3176
|
+
}
|
|
3177
|
+
function resolveGscSiteUrl(seoConfig) {
|
|
3178
|
+
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
3179
|
+
}
|
|
3180
|
+
function getGscOAuthConfig(basePath, seoConfig) {
|
|
3181
|
+
const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
|
|
3182
|
+
const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
|
|
3183
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
3184
|
+
if (!clientId || !clientSecret || !siteUrl) return null;
|
|
3185
|
+
return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
|
|
3186
|
+
}
|
|
3187
|
+
async function getOrCreateGscAuthDoc(payload) {
|
|
3188
|
+
const found = await payload.find({ collection: GSC_AUTH_COLLECTION, limit: 1, overrideAccess: true });
|
|
3189
|
+
if (found.docs.length > 0) return found.docs[0];
|
|
3190
|
+
return payload.create({ collection: GSC_AUTH_COLLECTION, data: {}, overrideAccess: true });
|
|
3191
|
+
}
|
|
3192
|
+
async function gscTokenRequest(cfg, body) {
|
|
3193
|
+
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
3194
|
+
method: "POST",
|
|
3195
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3196
|
+
body: new URLSearchParams({
|
|
3197
|
+
client_id: cfg.clientId,
|
|
3198
|
+
client_secret: cfg.clientSecret,
|
|
3199
|
+
...body
|
|
3200
|
+
}).toString()
|
|
3201
|
+
});
|
|
3202
|
+
const json = await resp.json();
|
|
3203
|
+
if (!resp.ok) {
|
|
3204
|
+
throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
|
|
3205
|
+
}
|
|
3206
|
+
return json;
|
|
3207
|
+
}
|
|
3208
|
+
async function getGscAccessToken(payload, cfg, authDoc) {
|
|
3209
|
+
if (!authDoc?.refreshTokenEnc) throw new Error("not_connected");
|
|
3210
|
+
const secret = payload.secret || "";
|
|
3211
|
+
let refreshToken;
|
|
3212
|
+
try {
|
|
3213
|
+
refreshToken = decryptToken(authDoc.refreshTokenEnc, secret);
|
|
3214
|
+
} catch {
|
|
3215
|
+
throw new Error("decrypt_failed");
|
|
3216
|
+
}
|
|
3217
|
+
const tokens = await gscTokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
|
|
3218
|
+
const accessToken = tokens.access_token;
|
|
3219
|
+
if (!accessToken) throw new Error("refresh_failed");
|
|
3220
|
+
return accessToken;
|
|
3221
|
+
}
|
|
3222
|
+
async function queryGscSearchAnalytics(accessToken, property, body) {
|
|
3223
|
+
const resp = await fetch(
|
|
3224
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
|
|
3225
|
+
{
|
|
3226
|
+
method: "POST",
|
|
3227
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
3228
|
+
body: JSON.stringify(body)
|
|
3229
|
+
}
|
|
3230
|
+
);
|
|
3231
|
+
const json = await resp.json();
|
|
3232
|
+
if (!resp.ok) {
|
|
3233
|
+
const err = json.error?.message || resp.status;
|
|
3234
|
+
throw new Error(`GSC query failed: ${err}`);
|
|
3235
|
+
}
|
|
3236
|
+
return json.rows || [];
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
// src/endpoints/aiAltText.ts
|
|
3240
|
+
var DEFAULT_MODEL2 = "claude-opus-4-8";
|
|
3241
|
+
var ALT_MAX = 125;
|
|
3242
|
+
var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
3243
|
+
var SUPPORTED_MIME = {
|
|
3244
|
+
"image/jpeg": "image/jpeg",
|
|
3245
|
+
"image/jpg": "image/jpeg",
|
|
3246
|
+
"image/png": "image/png",
|
|
3247
|
+
"image/gif": "image/gif",
|
|
3248
|
+
"image/webp": "image/webp"
|
|
3249
|
+
};
|
|
3250
|
+
function isAdmin4(user) {
|
|
3251
|
+
if (!user) return false;
|
|
3252
|
+
if (user.role === "admin") return true;
|
|
3253
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3254
|
+
return false;
|
|
3255
|
+
}
|
|
3256
|
+
function resolveImageUrl(media, siteUrl) {
|
|
3257
|
+
const raw = typeof media.url === "string" && media.url || (typeof media.filename === "string" ? `/media/${media.filename}` : "");
|
|
3258
|
+
if (!raw) return null;
|
|
3259
|
+
let absolute;
|
|
3260
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
3261
|
+
absolute = raw;
|
|
3262
|
+
} else if (siteUrl) {
|
|
3263
|
+
absolute = `${siteUrl.replace(/\/$/, "")}${raw.startsWith("/") ? "" : "/"}${raw}`;
|
|
3264
|
+
} else {
|
|
3265
|
+
return null;
|
|
3266
|
+
}
|
|
3267
|
+
try {
|
|
3268
|
+
const target = new URL(absolute);
|
|
3269
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") return null;
|
|
3270
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
3271
|
+
if (siteUrl) allowed.add(new URL(siteUrl).origin);
|
|
3272
|
+
if (process.env.SEO_MEDIA_ORIGIN) allowed.add(new URL(process.env.SEO_MEDIA_ORIGIN).origin);
|
|
3273
|
+
if (allowed.size > 0 && !allowed.has(target.origin)) return null;
|
|
3274
|
+
if (allowed.size === 0) return null;
|
|
3275
|
+
return target.toString();
|
|
3276
|
+
} catch {
|
|
3277
|
+
return null;
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
3280
|
+
async function generateAltText(apiKey, model, base64, mediaType, language, context) {
|
|
3281
|
+
const systemPrompt = `You write concise, descriptive image ALT text for accessibility and SEO.
|
|
3282
|
+
Rules:
|
|
3283
|
+
- Describe what is actually visible in the image.
|
|
3284
|
+
- Maximum ${ALT_MAX} characters.
|
|
3285
|
+
- Write in ${language === "en" ? "English" : "French"}.
|
|
3286
|
+
- Do NOT start with "image of", "photo of", "picture of" or similar.
|
|
3287
|
+
- No quotes around the result. Return ONLY the alt text, nothing else.`;
|
|
3288
|
+
const userText = `Filename: ${context.filename}${context.title ? `
|
|
3289
|
+
Page/context: ${context.title}` : ""}
|
|
3290
|
+
Write the alt text for this image:`;
|
|
3291
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
3292
|
+
method: "POST",
|
|
3293
|
+
headers: {
|
|
3294
|
+
"Content-Type": "application/json",
|
|
3295
|
+
"x-api-key": apiKey,
|
|
3296
|
+
"anthropic-version": "2023-06-01"
|
|
3297
|
+
},
|
|
3298
|
+
body: JSON.stringify({
|
|
3299
|
+
model,
|
|
3300
|
+
max_tokens: 150,
|
|
3301
|
+
system: systemPrompt,
|
|
3302
|
+
messages: [
|
|
3303
|
+
{
|
|
3304
|
+
role: "user",
|
|
3305
|
+
content: [
|
|
3306
|
+
{ type: "image", source: { type: "base64", media_type: mediaType, data: base64 } },
|
|
3307
|
+
{ type: "text", text: userText }
|
|
3308
|
+
]
|
|
3309
|
+
}
|
|
3310
|
+
]
|
|
3311
|
+
})
|
|
3312
|
+
});
|
|
3313
|
+
if (!response.ok) {
|
|
3314
|
+
const body = await response.text();
|
|
3315
|
+
throw new Error(`Claude API error ${response.status}: ${body}`);
|
|
3316
|
+
}
|
|
3317
|
+
const data = await response.json();
|
|
3318
|
+
if (data.stop_reason === "refusal") return null;
|
|
3319
|
+
const text = (data.content?.find((b) => b.type === "text")?.text || "").trim().replace(/^["']|["']$/g, "");
|
|
3320
|
+
if (!text) return null;
|
|
3321
|
+
return text.length > ALT_MAX ? text.slice(0, ALT_MAX).trim() : text;
|
|
3322
|
+
}
|
|
3323
|
+
function createAltTextAuditHandler(uploadsCollection) {
|
|
3324
|
+
return async (req) => {
|
|
3325
|
+
try {
|
|
3326
|
+
if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
3327
|
+
const url = new URL(req.url);
|
|
3328
|
+
const limit = Math.min(200, Math.max(1, parseInt(url.searchParams.get("limit") || "50", 10)));
|
|
3329
|
+
try {
|
|
3330
|
+
const missing = await req.payload.find({
|
|
3331
|
+
collection: uploadsCollection,
|
|
3332
|
+
where: { or: [{ alt: { exists: false } }, { alt: { equals: "" } }] },
|
|
3333
|
+
limit,
|
|
3334
|
+
depth: 0,
|
|
3335
|
+
overrideAccess: true
|
|
3336
|
+
});
|
|
3337
|
+
const items = missing.docs.map((d) => ({
|
|
3338
|
+
id: d.id,
|
|
3339
|
+
filename: d.filename || "",
|
|
3340
|
+
url: d.url || "",
|
|
3341
|
+
mimeType: d.mimeType || "",
|
|
3342
|
+
alt: d.alt || ""
|
|
3343
|
+
}));
|
|
3344
|
+
return Response.json(
|
|
3345
|
+
{ collection: uploadsCollection, missingCount: missing.totalDocs, items },
|
|
3346
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3347
|
+
);
|
|
3348
|
+
} catch {
|
|
3349
|
+
return Response.json(
|
|
3350
|
+
{ collection: uploadsCollection, missingCount: 0, items: [], note: "no_alt_field" },
|
|
3351
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3352
|
+
);
|
|
3353
|
+
}
|
|
3354
|
+
} catch (error) {
|
|
3355
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3356
|
+
req.payload.logger.error(`[seo] alt-text-audit error: ${message}`);
|
|
3357
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
function createAiAltTextHandler(uploadsCollection, seoConfig) {
|
|
3362
|
+
return async (req) => {
|
|
3363
|
+
try {
|
|
3364
|
+
if (!isAdmin4(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
3365
|
+
const body = await parseJsonBody(req);
|
|
3366
|
+
const collection = typeof body.collection === "string" ? body.collection : uploadsCollection;
|
|
3367
|
+
const id = body.id != null ? String(body.id) : void 0;
|
|
3368
|
+
const apply = body.apply === true;
|
|
3369
|
+
const providedAlt = typeof body.altText === "string" ? body.altText.trim() : void 0;
|
|
3370
|
+
if (!id) return Response.json({ error: "Missing required field: id" }, { status: 400 });
|
|
3371
|
+
if (apply && providedAlt) {
|
|
3372
|
+
const alt2 = providedAlt.slice(0, ALT_MAX);
|
|
3373
|
+
await req.payload.update({ collection, id, data: { alt: alt2 }, overrideAccess: true });
|
|
3374
|
+
return Response.json({ alt: alt2, applied: true, method: "manual" });
|
|
3375
|
+
}
|
|
3376
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
3377
|
+
if (!apiKey) {
|
|
3378
|
+
return Response.json(
|
|
3379
|
+
{ error: "AI not configured. Set ANTHROPIC_API_KEY to generate alt text.", code: "no_api_key" },
|
|
3380
|
+
{ status: 400 }
|
|
3381
|
+
);
|
|
3382
|
+
}
|
|
3383
|
+
let media;
|
|
3384
|
+
try {
|
|
3385
|
+
media = await req.payload.findByID({ collection, id, depth: 0, overrideAccess: true });
|
|
3386
|
+
} catch {
|
|
3387
|
+
return Response.json({ error: `Media not found: ${collection}/${id}` }, { status: 404 });
|
|
3388
|
+
}
|
|
3389
|
+
const mime = media.mimeType || "";
|
|
3390
|
+
const mediaType = SUPPORTED_MIME[mime.toLowerCase()];
|
|
3391
|
+
if (!mediaType) {
|
|
3392
|
+
return Response.json(
|
|
3393
|
+
{ error: `Unsupported image type for vision: ${mime || "unknown"} (use JPEG, PNG, GIF or WebP).` },
|
|
3394
|
+
{ status: 422 }
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
const siteUrl = resolveGscSiteUrl(seoConfig);
|
|
3398
|
+
const imageUrl = resolveImageUrl(media, siteUrl);
|
|
3399
|
+
if (!imageUrl) {
|
|
3400
|
+
return Response.json(
|
|
3401
|
+
{ error: "Could not resolve a safe image URL (must be on the site origin or SEO_MEDIA_ORIGIN)." },
|
|
3402
|
+
{ status: 422 }
|
|
3403
|
+
);
|
|
3404
|
+
}
|
|
3405
|
+
let base64;
|
|
3406
|
+
try {
|
|
3407
|
+
const imgResp = await fetch(imageUrl);
|
|
3408
|
+
if (!imgResp.ok) throw new Error(`fetch ${imgResp.status}`);
|
|
3409
|
+
const buf = Buffer.from(await imgResp.arrayBuffer());
|
|
3410
|
+
if (buf.byteLength > MAX_IMAGE_BYTES) {
|
|
3411
|
+
return Response.json({ error: "Image too large for vision (max 5 MB)." }, { status: 413 });
|
|
3412
|
+
}
|
|
3413
|
+
base64 = buf.toString("base64");
|
|
3414
|
+
} catch (e) {
|
|
3415
|
+
return Response.json({ error: `Could not fetch image: ${e instanceof Error ? e.message : "error"}` }, { status: 502 });
|
|
3416
|
+
}
|
|
3417
|
+
const model = process.env.SEO_AI_MODEL || DEFAULT_MODEL2;
|
|
3418
|
+
const language = seoConfig?.locale === "en" ? "en" : "fr";
|
|
3419
|
+
let alt;
|
|
3420
|
+
try {
|
|
3421
|
+
alt = await generateAltText(apiKey, model, base64, mediaType, language, {
|
|
3422
|
+
filename: media.filename || "",
|
|
3423
|
+
title: typeof body.context === "string" ? body.context : void 0
|
|
3424
|
+
});
|
|
3425
|
+
} catch (e) {
|
|
3426
|
+
req.payload.logger.error(`[seo] ai-alt-text Claude error: ${e instanceof Error ? e.message : "unknown"}`);
|
|
3427
|
+
return Response.json({ error: "Alt-text generation failed." }, { status: 502 });
|
|
3428
|
+
}
|
|
3429
|
+
if (!alt) {
|
|
3430
|
+
return Response.json({ error: "The model did not return alt text (possibly declined)." }, { status: 502 });
|
|
3431
|
+
}
|
|
3432
|
+
let applied = false;
|
|
3433
|
+
if (apply) {
|
|
3434
|
+
await req.payload.update({ collection, id, data: { alt }, overrideAccess: true });
|
|
3435
|
+
applied = true;
|
|
3436
|
+
}
|
|
3437
|
+
return Response.json({ alt, applied, method: "ai", model });
|
|
3438
|
+
} catch (error) {
|
|
3439
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3440
|
+
req.payload.logger.error(`[seo] ai-alt-text error: ${message}`);
|
|
3441
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3445
|
+
|
|
2909
3446
|
// src/endpoints/cannibalization.ts
|
|
2910
3447
|
function canonicalIntent(keyword) {
|
|
2911
3448
|
return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
|
|
@@ -2918,8 +3455,8 @@ function createCannibalizationHandler(collections, globals = []) {
|
|
|
2918
3455
|
}
|
|
2919
3456
|
const url = new URL(req.url);
|
|
2920
3457
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
2921
|
-
const
|
|
2922
|
-
const cached = noCache ? null : seoCache.get(
|
|
3458
|
+
const CACHE_KEY2 = "cannibalization";
|
|
3459
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
2923
3460
|
if (cached) {
|
|
2924
3461
|
return Response.json({ ...cached, cached: true });
|
|
2925
3462
|
}
|
|
@@ -3013,7 +3550,7 @@ function createCannibalizationHandler(collections, globals = []) {
|
|
|
3013
3550
|
totalAffectedPages
|
|
3014
3551
|
}
|
|
3015
3552
|
};
|
|
3016
|
-
seoCache.set(
|
|
3553
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
3017
3554
|
return Response.json({ ...responseData, cached: false });
|
|
3018
3555
|
} catch (error) {
|
|
3019
3556
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -3224,8 +3761,8 @@ function createExternalLinksHandler(collections, globals = []) {
|
|
|
3224
3761
|
}
|
|
3225
3762
|
const url = new URL(req.url);
|
|
3226
3763
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
3227
|
-
const
|
|
3228
|
-
const cached = noCache ? null : seoCache.get(
|
|
3764
|
+
const CACHE_KEY2 = "external-links";
|
|
3765
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
3229
3766
|
if (cached) {
|
|
3230
3767
|
return Response.json({ ...cached, cached: true });
|
|
3231
3768
|
}
|
|
@@ -3305,7 +3842,7 @@ function createExternalLinksHandler(collections, globals = []) {
|
|
|
3305
3842
|
return a.ok ? 1 : -1;
|
|
3306
3843
|
});
|
|
3307
3844
|
const responseData = { results, stats };
|
|
3308
|
-
seoCache.set(
|
|
3845
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
3309
3846
|
return Response.json({ ...responseData, cached: false });
|
|
3310
3847
|
} catch (error) {
|
|
3311
3848
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -3490,7 +4027,7 @@ function getDateThreshold(period) {
|
|
|
3490
4027
|
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
3491
4028
|
}
|
|
3492
4029
|
}
|
|
3493
|
-
function
|
|
4030
|
+
function isAdmin5(user) {
|
|
3494
4031
|
if (!user) return false;
|
|
3495
4032
|
if (user.role === "admin") return true;
|
|
3496
4033
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -3595,7 +4132,7 @@ function createPerformanceHandler() {
|
|
|
3595
4132
|
});
|
|
3596
4133
|
}
|
|
3597
4134
|
if (method === "POST") {
|
|
3598
|
-
if (!
|
|
4135
|
+
if (!isAdmin5(req.user)) {
|
|
3599
4136
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
3600
4137
|
}
|
|
3601
4138
|
const body = await parseJsonBody(req);
|
|
@@ -3893,96 +4430,12 @@ function createCoreWebVitalsHandler(seoConfig) {
|
|
|
3893
4430
|
}
|
|
3894
4431
|
};
|
|
3895
4432
|
}
|
|
3896
|
-
var ALGO = "aes-256-gcm";
|
|
3897
|
-
var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
|
|
3898
|
-
var FORMAT_VERSION = "v1";
|
|
3899
|
-
function deriveKey(secret) {
|
|
3900
|
-
const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
|
|
3901
|
-
if (explicit) {
|
|
3902
|
-
const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
|
|
3903
|
-
if (buf.length === 32) return buf;
|
|
3904
|
-
throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
|
|
3905
|
-
}
|
|
3906
|
-
if (!secret) {
|
|
3907
|
-
throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
|
|
3908
|
-
}
|
|
3909
|
-
return crypto.scryptSync(secret, KEY_NAMESPACE, 32);
|
|
3910
|
-
}
|
|
3911
|
-
function encryptToken(plaintext, secret) {
|
|
3912
|
-
const key = deriveKey(secret);
|
|
3913
|
-
const iv = crypto.randomBytes(12);
|
|
3914
|
-
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
3915
|
-
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
3916
|
-
const tag = cipher.getAuthTag();
|
|
3917
|
-
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
|
|
3918
|
-
}
|
|
3919
|
-
function decryptToken(payload, secret) {
|
|
3920
|
-
const parts = payload.split(":");
|
|
3921
|
-
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
3922
|
-
throw new Error("Invalid encrypted token format.");
|
|
3923
|
-
}
|
|
3924
|
-
const key = deriveKey(secret);
|
|
3925
|
-
const iv = Buffer.from(parts[1], "base64");
|
|
3926
|
-
const tag = Buffer.from(parts[2], "base64");
|
|
3927
|
-
const enc = Buffer.from(parts[3], "base64");
|
|
3928
|
-
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
3929
|
-
decipher.setAuthTag(tag);
|
|
3930
|
-
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
3931
|
-
return dec.toString("utf8");
|
|
3932
|
-
}
|
|
3933
|
-
function safeEqual(a, b) {
|
|
3934
|
-
const ba = Buffer.from(a);
|
|
3935
|
-
const bb = Buffer.from(b);
|
|
3936
|
-
if (ba.length !== bb.length) return false;
|
|
3937
|
-
return crypto.timingSafeEqual(ba, bb);
|
|
3938
|
-
}
|
|
3939
|
-
|
|
3940
|
-
// src/endpoints/gscOAuth.ts
|
|
3941
|
-
var AUTH_COLLECTION = "seo-gsc-auth";
|
|
3942
|
-
var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
|
|
3943
|
-
function isAdmin5(user) {
|
|
3944
|
-
if (!user) return false;
|
|
3945
|
-
if (user.role === "admin") return true;
|
|
3946
|
-
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3947
|
-
return false;
|
|
3948
|
-
}
|
|
3949
|
-
function resolveSiteUrl2(seoConfig) {
|
|
3950
|
-
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
3951
|
-
}
|
|
3952
|
-
function getOAuthConfig(basePath, seoConfig) {
|
|
3953
|
-
const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
|
|
3954
|
-
const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
|
|
3955
|
-
const siteUrl = resolveSiteUrl2(seoConfig);
|
|
3956
|
-
if (!clientId || !clientSecret || !siteUrl) return null;
|
|
3957
|
-
return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
|
|
3958
|
-
}
|
|
3959
|
-
async function getOrCreateAuthDoc(payload) {
|
|
3960
|
-
const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
|
|
3961
|
-
if (found.docs.length > 0) return found.docs[0];
|
|
3962
|
-
return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
|
|
3963
|
-
}
|
|
3964
|
-
async function tokenRequest(cfg, body) {
|
|
3965
|
-
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
3966
|
-
method: "POST",
|
|
3967
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3968
|
-
body: new URLSearchParams({
|
|
3969
|
-
client_id: cfg.clientId,
|
|
3970
|
-
client_secret: cfg.clientSecret,
|
|
3971
|
-
...body
|
|
3972
|
-
}).toString()
|
|
3973
|
-
});
|
|
3974
|
-
const json = await resp.json();
|
|
3975
|
-
if (!resp.ok) {
|
|
3976
|
-
throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
|
|
3977
|
-
}
|
|
3978
|
-
return json;
|
|
3979
|
-
}
|
|
3980
4433
|
function createGscStatusHandler(basePath, seoConfig) {
|
|
3981
4434
|
return async (req) => {
|
|
3982
4435
|
try {
|
|
3983
4436
|
if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
3984
|
-
const cfg =
|
|
3985
|
-
const doc = await
|
|
4437
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4438
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
3986
4439
|
return Response.json(
|
|
3987
4440
|
{
|
|
3988
4441
|
configured: !!cfg,
|
|
@@ -4004,8 +4457,8 @@ function createGscStatusHandler(basePath, seoConfig) {
|
|
|
4004
4457
|
function createGscAuthStartHandler(basePath, seoConfig) {
|
|
4005
4458
|
return async (req) => {
|
|
4006
4459
|
try {
|
|
4007
|
-
if (!
|
|
4008
|
-
const cfg =
|
|
4460
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4461
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4009
4462
|
if (!cfg) {
|
|
4010
4463
|
return Response.json(
|
|
4011
4464
|
{ error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
|
|
@@ -4013,9 +4466,9 @@ function createGscAuthStartHandler(basePath, seoConfig) {
|
|
|
4013
4466
|
);
|
|
4014
4467
|
}
|
|
4015
4468
|
const state = crypto.randomBytes(24).toString("hex");
|
|
4016
|
-
const doc = await
|
|
4469
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4017
4470
|
await req.payload.update({
|
|
4018
|
-
collection:
|
|
4471
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4019
4472
|
id: doc.id,
|
|
4020
4473
|
data: { pendingState: state },
|
|
4021
4474
|
overrideAccess: true
|
|
@@ -4024,7 +4477,7 @@ function createGscAuthStartHandler(basePath, seoConfig) {
|
|
|
4024
4477
|
authUrl.searchParams.set("client_id", cfg.clientId);
|
|
4025
4478
|
authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
|
|
4026
4479
|
authUrl.searchParams.set("response_type", "code");
|
|
4027
|
-
authUrl.searchParams.set("scope",
|
|
4480
|
+
authUrl.searchParams.set("scope", GSC_SCOPES);
|
|
4028
4481
|
authUrl.searchParams.set("access_type", "offline");
|
|
4029
4482
|
authUrl.searchParams.set("prompt", "consent");
|
|
4030
4483
|
authUrl.searchParams.set("state", state);
|
|
@@ -4043,10 +4496,10 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4043
4496
|
{ status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
|
|
4044
4497
|
);
|
|
4045
4498
|
try {
|
|
4046
|
-
if (!
|
|
4499
|
+
if (!isGscAdmin(req.user)) {
|
|
4047
4500
|
return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
|
|
4048
4501
|
}
|
|
4049
|
-
const cfg =
|
|
4502
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4050
4503
|
if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
|
|
4051
4504
|
const url = new URL(req.url);
|
|
4052
4505
|
const code = url.searchParams.get("code");
|
|
@@ -4054,11 +4507,11 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4054
4507
|
const oauthError = url.searchParams.get("error");
|
|
4055
4508
|
if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
|
|
4056
4509
|
if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
|
|
4057
|
-
const doc = await
|
|
4510
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4058
4511
|
if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
|
|
4059
4512
|
return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
|
|
4060
4513
|
}
|
|
4061
|
-
const tokens = await
|
|
4514
|
+
const tokens = await gscTokenRequest(cfg, {
|
|
4062
4515
|
code,
|
|
4063
4516
|
redirect_uri: cfg.redirectUri,
|
|
4064
4517
|
grant_type: "authorization_code"
|
|
@@ -4084,14 +4537,14 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4084
4537
|
const secret = req.payload.secret || "";
|
|
4085
4538
|
const refreshTokenEnc = encryptToken(refreshToken, secret);
|
|
4086
4539
|
await req.payload.update({
|
|
4087
|
-
collection:
|
|
4540
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4088
4541
|
id: doc.id,
|
|
4089
4542
|
data: {
|
|
4090
4543
|
refreshTokenEnc,
|
|
4091
4544
|
pendingState: null,
|
|
4092
4545
|
connectedEmail: email,
|
|
4093
4546
|
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4094
|
-
scope: tokens.scope ||
|
|
4547
|
+
scope: tokens.scope || GSC_SCOPES,
|
|
4095
4548
|
propertyUrl: doc.propertyUrl || cfg.siteUrl
|
|
4096
4549
|
},
|
|
4097
4550
|
overrideAccess: true
|
|
@@ -4107,26 +4560,26 @@ function createGscCallbackHandler(basePath, seoConfig) {
|
|
|
4107
4560
|
function createGscDataHandler(basePath, seoConfig) {
|
|
4108
4561
|
return async (req) => {
|
|
4109
4562
|
try {
|
|
4110
|
-
if (!
|
|
4111
|
-
const cfg =
|
|
4563
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4564
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
4112
4565
|
if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
|
|
4113
|
-
const doc = await
|
|
4566
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4114
4567
|
if (!doc.refreshTokenEnc) {
|
|
4115
4568
|
return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
|
|
4116
4569
|
}
|
|
4117
|
-
|
|
4118
|
-
let refreshToken;
|
|
4570
|
+
let accessToken;
|
|
4119
4571
|
try {
|
|
4120
|
-
|
|
4121
|
-
} catch {
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4572
|
+
accessToken = await getGscAccessToken(req.payload, cfg, doc);
|
|
4573
|
+
} catch (e) {
|
|
4574
|
+
const code = e instanceof Error ? e.message : "refresh_failed";
|
|
4575
|
+
if (code === "decrypt_failed") {
|
|
4576
|
+
return Response.json(
|
|
4577
|
+
{ error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
|
|
4578
|
+
{ status: 409 }
|
|
4579
|
+
);
|
|
4580
|
+
}
|
|
4581
|
+
return Response.json({ error: "Could not refresh access token." }, { status: 502 });
|
|
4126
4582
|
}
|
|
4127
|
-
const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
|
|
4128
|
-
const accessToken = tokens.access_token;
|
|
4129
|
-
if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
|
|
4130
4583
|
const url = new URL(req.url);
|
|
4131
4584
|
const today = /* @__PURE__ */ new Date();
|
|
4132
4585
|
const defaultEnd = today.toISOString().slice(0, 10);
|
|
@@ -4136,21 +4589,19 @@ function createGscDataHandler(basePath, seoConfig) {
|
|
|
4136
4589
|
const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
|
|
4137
4590
|
const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
|
|
4138
4591
|
const property = doc.propertyUrl || cfg.siteUrl;
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
{
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
const err = gscJson.error?.message || gscResp.status;
|
|
4150
|
-
return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
|
|
4592
|
+
let rows;
|
|
4593
|
+
try {
|
|
4594
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
4595
|
+
startDate,
|
|
4596
|
+
endDate,
|
|
4597
|
+
dimensions: [dimension],
|
|
4598
|
+
rowLimit
|
|
4599
|
+
});
|
|
4600
|
+
} catch (e) {
|
|
4601
|
+
return Response.json({ error: e instanceof Error ? e.message : "GSC query failed" }, { status: 502 });
|
|
4151
4602
|
}
|
|
4152
4603
|
return Response.json(
|
|
4153
|
-
{ property, startDate, endDate, dimension, rows
|
|
4604
|
+
{ property, startDate, endDate, dimension, rows },
|
|
4154
4605
|
{ headers: { "Cache-Control": "no-store" } }
|
|
4155
4606
|
);
|
|
4156
4607
|
} catch (error) {
|
|
@@ -4163,10 +4614,10 @@ function createGscDataHandler(basePath, seoConfig) {
|
|
|
4163
4614
|
function createGscDisconnectHandler() {
|
|
4164
4615
|
return async (req) => {
|
|
4165
4616
|
try {
|
|
4166
|
-
if (!
|
|
4167
|
-
const doc = await
|
|
4617
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4618
|
+
const doc = await getOrCreateGscAuthDoc(req.payload);
|
|
4168
4619
|
await req.payload.update({
|
|
4169
|
-
collection:
|
|
4620
|
+
collection: GSC_AUTH_COLLECTION,
|
|
4170
4621
|
id: doc.id,
|
|
4171
4622
|
data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
|
|
4172
4623
|
overrideAccess: true
|
|
@@ -4299,8 +4750,8 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
|
|
|
4299
4750
|
}
|
|
4300
4751
|
const url = new URL(req.url);
|
|
4301
4752
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
4302
|
-
const
|
|
4303
|
-
const cached = noCache ? null : seoCache.get(
|
|
4753
|
+
const CACHE_KEY2 = "keyword-research";
|
|
4754
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
4304
4755
|
if (cached) {
|
|
4305
4756
|
return Response.json({ ...cached, cached: true });
|
|
4306
4757
|
}
|
|
@@ -4476,7 +4927,7 @@ function createKeywordResearchHandler(targetCollections, globals = []) {
|
|
|
4476
4927
|
suggestionsCount: suggestions.length
|
|
4477
4928
|
}
|
|
4478
4929
|
};
|
|
4479
|
-
seoCache.set(
|
|
4930
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
4480
4931
|
return Response.json({ ...responseData, cached: false });
|
|
4481
4932
|
} catch (error) {
|
|
4482
4933
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -4613,8 +5064,8 @@ function createLinkGraphHandler(targetCollections, globals = []) {
|
|
|
4613
5064
|
}
|
|
4614
5065
|
const url = new URL(req.url);
|
|
4615
5066
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
4616
|
-
const
|
|
4617
|
-
const cached = noCache ? null : seoCache.get(
|
|
5067
|
+
const CACHE_KEY2 = "link-graph";
|
|
5068
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
4618
5069
|
if (cached) {
|
|
4619
5070
|
return Response.json({ ...cached, cached: true }, { headers: { "Cache-Control": "no-store" } });
|
|
4620
5071
|
}
|
|
@@ -4701,7 +5152,7 @@ function createLinkGraphHandler(targetCollections, globals = []) {
|
|
|
4701
5152
|
avgDegree
|
|
4702
5153
|
};
|
|
4703
5154
|
const responseData = { nodes, edges, stats };
|
|
4704
|
-
seoCache.set(
|
|
5155
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
4705
5156
|
return Response.json({ ...responseData, cached: false }, { headers: { "Cache-Control": "no-store" } });
|
|
4706
5157
|
} catch (error) {
|
|
4707
5158
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -4711,7 +5162,33 @@ function createLinkGraphHandler(targetCollections, globals = []) {
|
|
|
4711
5162
|
};
|
|
4712
5163
|
}
|
|
4713
5164
|
|
|
4714
|
-
// src/
|
|
5165
|
+
// src/helpers/buildSchema.ts
|
|
5166
|
+
var SCHEMA_TYPES = [
|
|
5167
|
+
"Article",
|
|
5168
|
+
"LocalBusiness",
|
|
5169
|
+
"BreadcrumbList",
|
|
5170
|
+
"FAQPage",
|
|
5171
|
+
"Product",
|
|
5172
|
+
"Organization",
|
|
5173
|
+
"Person",
|
|
5174
|
+
"Event",
|
|
5175
|
+
"Recipe",
|
|
5176
|
+
"Video"
|
|
5177
|
+
];
|
|
5178
|
+
function resolveSiteUrl2(explicit) {
|
|
5179
|
+
return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "http://localhost:3000").replace(/\/$/, "");
|
|
5180
|
+
}
|
|
5181
|
+
function getSchemaImageUrl(metaImage, heroMedia, siteUrl) {
|
|
5182
|
+
const img = metaImage || heroMedia;
|
|
5183
|
+
if (!img) return void 0;
|
|
5184
|
+
if (typeof img.url === "string") {
|
|
5185
|
+
return img.url.startsWith("http") ? img.url : `${siteUrl}${img.url}`;
|
|
5186
|
+
}
|
|
5187
|
+
if (typeof img.filename === "string") {
|
|
5188
|
+
return `${siteUrl}/media/${img.filename}`;
|
|
5189
|
+
}
|
|
5190
|
+
return void 0;
|
|
5191
|
+
}
|
|
4715
5192
|
function detectSchemaType(collection, doc) {
|
|
4716
5193
|
if (collection === "posts") return "Article";
|
|
4717
5194
|
const layout = doc.layout;
|
|
@@ -4732,16 +5209,25 @@ function detectSchemaType(collection, doc) {
|
|
|
4732
5209
|
}
|
|
4733
5210
|
return "Article";
|
|
4734
5211
|
}
|
|
5212
|
+
function buildAuthors(authors) {
|
|
5213
|
+
return authors.filter((a) => a && typeof a === "object").map((a) => {
|
|
5214
|
+
const author = a;
|
|
5215
|
+
return {
|
|
5216
|
+
"@type": "Person",
|
|
5217
|
+
name: author.name || author.firstName || "Author"
|
|
5218
|
+
};
|
|
5219
|
+
});
|
|
5220
|
+
}
|
|
4735
5221
|
function buildArticleSchema(doc, siteUrl) {
|
|
4736
5222
|
const meta = doc.meta || {};
|
|
4737
5223
|
const heroMedia = doc.hero?.media;
|
|
4738
|
-
const imageUrl =
|
|
5224
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
4739
5225
|
const schema = {
|
|
4740
5226
|
"@context": "https://schema.org",
|
|
4741
5227
|
"@type": "Article",
|
|
4742
5228
|
headline: meta.title || doc.title || "",
|
|
4743
5229
|
description: meta.description || "",
|
|
4744
|
-
datePublished: doc.createdAt || void 0,
|
|
5230
|
+
datePublished: doc.publishedAt || doc.createdAt || void 0,
|
|
4745
5231
|
dateModified: doc.updatedAt || void 0,
|
|
4746
5232
|
mainEntityOfPage: {
|
|
4747
5233
|
"@type": "WebPage",
|
|
@@ -4840,7 +5326,7 @@ function buildFAQSchema(doc) {
|
|
|
4840
5326
|
function buildProductSchema(doc, siteUrl) {
|
|
4841
5327
|
const meta = doc.meta || {};
|
|
4842
5328
|
const heroMedia = doc.hero?.media;
|
|
4843
|
-
const imageUrl =
|
|
5329
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
4844
5330
|
const schema = {
|
|
4845
5331
|
"@context": "https://schema.org",
|
|
4846
5332
|
"@type": "Product",
|
|
@@ -4913,7 +5399,7 @@ function buildEventSchema(doc, siteUrl) {
|
|
|
4913
5399
|
function buildRecipeSchema(doc, siteUrl) {
|
|
4914
5400
|
const meta = doc.meta || {};
|
|
4915
5401
|
const heroMedia = doc.hero?.media;
|
|
4916
|
-
const imageUrl =
|
|
5402
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
4917
5403
|
const schema = {
|
|
4918
5404
|
"@context": "https://schema.org",
|
|
4919
5405
|
"@type": "Recipe",
|
|
@@ -4930,7 +5416,7 @@ function buildRecipeSchema(doc, siteUrl) {
|
|
|
4930
5416
|
function buildVideoSchema(doc, siteUrl) {
|
|
4931
5417
|
const meta = doc.meta || {};
|
|
4932
5418
|
const heroMedia = doc.hero?.media;
|
|
4933
|
-
const imageUrl =
|
|
5419
|
+
const imageUrl = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
4934
5420
|
const schema = {
|
|
4935
5421
|
"@context": "https://schema.org",
|
|
4936
5422
|
"@type": "VideoObject",
|
|
@@ -4943,26 +5429,51 @@ function buildVideoSchema(doc, siteUrl) {
|
|
|
4943
5429
|
if (doc.duration) schema.duration = doc.duration;
|
|
4944
5430
|
return schema;
|
|
4945
5431
|
}
|
|
4946
|
-
function
|
|
4947
|
-
const
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
5432
|
+
function buildJsonLd(doc, options = {}) {
|
|
5433
|
+
const siteUrl = resolveSiteUrl2(options.siteUrl);
|
|
5434
|
+
const schemaType = options.type || detectSchemaType(options.collection || "", doc);
|
|
5435
|
+
let jsonLd;
|
|
5436
|
+
switch (schemaType) {
|
|
5437
|
+
case "Article":
|
|
5438
|
+
jsonLd = buildArticleSchema(doc, siteUrl);
|
|
5439
|
+
break;
|
|
5440
|
+
case "LocalBusiness":
|
|
5441
|
+
jsonLd = buildLocalBusinessSchema(doc, siteUrl);
|
|
5442
|
+
break;
|
|
5443
|
+
case "BreadcrumbList":
|
|
5444
|
+
jsonLd = buildBreadcrumbSchema(doc, siteUrl);
|
|
5445
|
+
break;
|
|
5446
|
+
case "FAQPage":
|
|
5447
|
+
jsonLd = buildFAQSchema(doc);
|
|
5448
|
+
break;
|
|
5449
|
+
case "Product":
|
|
5450
|
+
jsonLd = buildProductSchema(doc, siteUrl);
|
|
5451
|
+
break;
|
|
5452
|
+
case "Organization":
|
|
5453
|
+
jsonLd = buildOrganizationSchema(doc, siteUrl);
|
|
5454
|
+
break;
|
|
5455
|
+
case "Person":
|
|
5456
|
+
jsonLd = buildPersonSchema(doc, siteUrl);
|
|
5457
|
+
break;
|
|
5458
|
+
case "Event":
|
|
5459
|
+
jsonLd = buildEventSchema(doc, siteUrl);
|
|
5460
|
+
break;
|
|
5461
|
+
case "Recipe":
|
|
5462
|
+
jsonLd = buildRecipeSchema(doc, siteUrl);
|
|
5463
|
+
break;
|
|
5464
|
+
case "Video":
|
|
5465
|
+
jsonLd = buildVideoSchema(doc, siteUrl);
|
|
5466
|
+
break;
|
|
5467
|
+
}
|
|
5468
|
+
const cleaned = JSON.parse(JSON.stringify(jsonLd));
|
|
5469
|
+
return { type: schemaType, jsonLd: cleaned };
|
|
5470
|
+
}
|
|
5471
|
+
function renderJsonLdScript(doc, options = {}) {
|
|
5472
|
+
const { jsonLd } = buildJsonLd(doc, options);
|
|
5473
|
+
return `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
|
|
4965
5474
|
}
|
|
5475
|
+
|
|
5476
|
+
// src/endpoints/schemaGenerator.ts
|
|
4966
5477
|
function createSchemaGeneratorHandler(targetCollections) {
|
|
4967
5478
|
return async (req) => {
|
|
4968
5479
|
try {
|
|
@@ -4974,74 +5485,30 @@ function createSchemaGeneratorHandler(targetCollections) {
|
|
|
4974
5485
|
const id = url.searchParams.get("id");
|
|
4975
5486
|
const typeOverrideRaw = url.searchParams.get("type");
|
|
4976
5487
|
if (!collection || !id) {
|
|
4977
|
-
return Response.json(
|
|
4978
|
-
{ error: "Missing required query params: collection, id" },
|
|
4979
|
-
{ status: 400 }
|
|
4980
|
-
);
|
|
5488
|
+
return Response.json({ error: "Missing required query params: collection, id" }, { status: 400 });
|
|
4981
5489
|
}
|
|
4982
|
-
|
|
4983
|
-
if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
|
|
5490
|
+
if (typeOverrideRaw !== null && !SCHEMA_TYPES.includes(typeOverrideRaw)) {
|
|
4984
5491
|
return Response.json(
|
|
4985
|
-
{ error: `Invalid schema type. Valid types: ${
|
|
5492
|
+
{ error: `Invalid schema type. Valid types: ${SCHEMA_TYPES.join(", ")}` },
|
|
4986
5493
|
{ status: 400 }
|
|
4987
5494
|
);
|
|
4988
5495
|
}
|
|
4989
|
-
const typeOverride = typeOverrideRaw;
|
|
5496
|
+
const typeOverride = typeOverrideRaw || void 0;
|
|
4990
5497
|
if (targetCollections && !targetCollections.includes(collection)) {
|
|
4991
5498
|
return Response.json({ error: "Collection not allowed" }, { status: 403 });
|
|
4992
5499
|
}
|
|
4993
5500
|
let doc;
|
|
4994
5501
|
try {
|
|
4995
|
-
const result = await req.payload.findByID({
|
|
4996
|
-
collection,
|
|
4997
|
-
id,
|
|
4998
|
-
depth: 1,
|
|
4999
|
-
overrideAccess: true
|
|
5000
|
-
});
|
|
5502
|
+
const result = await req.payload.findByID({ collection, id, depth: 1, overrideAccess: true });
|
|
5001
5503
|
doc = result;
|
|
5002
5504
|
} catch {
|
|
5003
5505
|
return Response.json({ error: `Document not found: ${collection}/${id}` }, { status: 404 });
|
|
5004
5506
|
}
|
|
5005
|
-
const
|
|
5006
|
-
const schemaType = typeOverride || detectSchemaType(collection, doc);
|
|
5007
|
-
let jsonLd;
|
|
5008
|
-
switch (schemaType) {
|
|
5009
|
-
case "Article":
|
|
5010
|
-
jsonLd = buildArticleSchema(doc, siteUrl);
|
|
5011
|
-
break;
|
|
5012
|
-
case "LocalBusiness":
|
|
5013
|
-
jsonLd = buildLocalBusinessSchema(doc, siteUrl);
|
|
5014
|
-
break;
|
|
5015
|
-
case "BreadcrumbList":
|
|
5016
|
-
jsonLd = buildBreadcrumbSchema(doc, siteUrl);
|
|
5017
|
-
break;
|
|
5018
|
-
case "FAQPage":
|
|
5019
|
-
jsonLd = buildFAQSchema(doc);
|
|
5020
|
-
break;
|
|
5021
|
-
case "Product":
|
|
5022
|
-
jsonLd = buildProductSchema(doc, siteUrl);
|
|
5023
|
-
break;
|
|
5024
|
-
case "Organization":
|
|
5025
|
-
jsonLd = buildOrganizationSchema(doc, siteUrl);
|
|
5026
|
-
break;
|
|
5027
|
-
case "Person":
|
|
5028
|
-
jsonLd = buildPersonSchema(doc, siteUrl);
|
|
5029
|
-
break;
|
|
5030
|
-
case "Event":
|
|
5031
|
-
jsonLd = buildEventSchema(doc, siteUrl);
|
|
5032
|
-
break;
|
|
5033
|
-
case "Recipe":
|
|
5034
|
-
jsonLd = buildRecipeSchema(doc, siteUrl);
|
|
5035
|
-
break;
|
|
5036
|
-
case "Video":
|
|
5037
|
-
jsonLd = buildVideoSchema(doc, siteUrl);
|
|
5038
|
-
break;
|
|
5039
|
-
}
|
|
5040
|
-
const cleaned = JSON.parse(JSON.stringify(jsonLd));
|
|
5507
|
+
const { type, jsonLd } = buildJsonLd(doc, { collection, type: typeOverride });
|
|
5041
5508
|
return Response.json({
|
|
5042
|
-
type
|
|
5043
|
-
jsonLd
|
|
5044
|
-
html: `<script type="application/ld+json">${JSON.stringify(
|
|
5509
|
+
type,
|
|
5510
|
+
jsonLd,
|
|
5511
|
+
html: `<script type="application/ld+json">${JSON.stringify(jsonLd, null, 2)}</script>`
|
|
5045
5512
|
});
|
|
5046
5513
|
} catch (error) {
|
|
5047
5514
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -5060,8 +5527,8 @@ function createRedirectChainsHandler(redirectsCollection) {
|
|
|
5060
5527
|
}
|
|
5061
5528
|
const url = new URL(req.url || "", "http://localhost");
|
|
5062
5529
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
5063
|
-
const
|
|
5064
|
-
const cached = noCache ? null : seoCache.get(
|
|
5530
|
+
const CACHE_KEY2 = "redirect-chains";
|
|
5531
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
5065
5532
|
if (cached) {
|
|
5066
5533
|
return Response.json({ ...cached, cached: true });
|
|
5067
5534
|
}
|
|
@@ -5130,7 +5597,7 @@ function createRedirectChainsHandler(redirectsCollection) {
|
|
|
5130
5597
|
totalRedirects: allRedirects.length
|
|
5131
5598
|
}
|
|
5132
5599
|
};
|
|
5133
|
-
seoCache.set(
|
|
5600
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
5134
5601
|
return Response.json({ ...responseData, cached: false });
|
|
5135
5602
|
} catch (error) {
|
|
5136
5603
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -5173,8 +5640,8 @@ function createDuplicateContentHandler(collections) {
|
|
|
5173
5640
|
const url = new URL(req.url || "", "http://localhost");
|
|
5174
5641
|
const noCache = url.searchParams.get("nocache") === "1";
|
|
5175
5642
|
const threshold = parseFloat(url.searchParams.get("threshold") || "") || SIMILARITY_THRESHOLD;
|
|
5176
|
-
const
|
|
5177
|
-
const cached = noCache ? null : seoCache.get(
|
|
5643
|
+
const CACHE_KEY2 = `duplicate-content-${threshold}`;
|
|
5644
|
+
const cached = noCache ? null : seoCache.get(CACHE_KEY2);
|
|
5178
5645
|
if (cached) {
|
|
5179
5646
|
return Response.json({ ...cached, cached: true });
|
|
5180
5647
|
}
|
|
@@ -5231,7 +5698,7 @@ function createDuplicateContentHandler(collections) {
|
|
|
5231
5698
|
threshold
|
|
5232
5699
|
}
|
|
5233
5700
|
};
|
|
5234
|
-
seoCache.set(
|
|
5701
|
+
seoCache.set(CACHE_KEY2, responseData);
|
|
5235
5702
|
return Response.json({ ...responseData, cached: false });
|
|
5236
5703
|
} catch (error) {
|
|
5237
5704
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -6191,6 +6658,219 @@ function createSeoGscAuthCollection() {
|
|
|
6191
6658
|
};
|
|
6192
6659
|
}
|
|
6193
6660
|
|
|
6661
|
+
// src/collections/SeoRankHistory.ts
|
|
6662
|
+
function createSeoRankHistoryCollection() {
|
|
6663
|
+
return {
|
|
6664
|
+
slug: "seo-rank-history",
|
|
6665
|
+
admin: {
|
|
6666
|
+
custom: { navHidden: true }
|
|
6667
|
+
},
|
|
6668
|
+
access: {
|
|
6669
|
+
read: ({ req }) => !!req.user,
|
|
6670
|
+
create: ({ req }) => req.user?.role === "admin",
|
|
6671
|
+
update: ({ req }) => req.user?.role === "admin",
|
|
6672
|
+
delete: ({ req }) => req.user?.role === "admin"
|
|
6673
|
+
},
|
|
6674
|
+
timestamps: false,
|
|
6675
|
+
fields: [
|
|
6676
|
+
{
|
|
6677
|
+
name: "query",
|
|
6678
|
+
type: "text",
|
|
6679
|
+
required: true,
|
|
6680
|
+
index: true,
|
|
6681
|
+
admin: { description: "Search query (keyword) tracked" }
|
|
6682
|
+
},
|
|
6683
|
+
{
|
|
6684
|
+
name: "page",
|
|
6685
|
+
type: "text",
|
|
6686
|
+
admin: { description: "Landing page URL (when tracked by page)" }
|
|
6687
|
+
},
|
|
6688
|
+
{
|
|
6689
|
+
name: "position",
|
|
6690
|
+
type: "number",
|
|
6691
|
+
required: true,
|
|
6692
|
+
admin: { description: "Average SERP position over the snapshot window (lower is better)" }
|
|
6693
|
+
},
|
|
6694
|
+
{
|
|
6695
|
+
name: "clicks",
|
|
6696
|
+
type: "number",
|
|
6697
|
+
admin: { description: "Clicks over the snapshot window" }
|
|
6698
|
+
},
|
|
6699
|
+
{
|
|
6700
|
+
name: "impressions",
|
|
6701
|
+
type: "number",
|
|
6702
|
+
admin: { description: "Impressions over the snapshot window" }
|
|
6703
|
+
},
|
|
6704
|
+
{
|
|
6705
|
+
name: "ctr",
|
|
6706
|
+
type: "number",
|
|
6707
|
+
admin: { description: "Click-through rate (0-1) over the snapshot window" }
|
|
6708
|
+
},
|
|
6709
|
+
{
|
|
6710
|
+
name: "property",
|
|
6711
|
+
type: "text",
|
|
6712
|
+
admin: { description: "GSC property the snapshot was taken from" }
|
|
6713
|
+
},
|
|
6714
|
+
{
|
|
6715
|
+
// YYYY-MM-DD — used to deduplicate one snapshot per query per day.
|
|
6716
|
+
name: "dateKey",
|
|
6717
|
+
type: "text",
|
|
6718
|
+
required: true,
|
|
6719
|
+
index: true,
|
|
6720
|
+
admin: { description: "Snapshot day (YYYY-MM-DD), one snapshot per query per day" }
|
|
6721
|
+
},
|
|
6722
|
+
{
|
|
6723
|
+
name: "snapshotDate",
|
|
6724
|
+
type: "date",
|
|
6725
|
+
required: true,
|
|
6726
|
+
index: true,
|
|
6727
|
+
admin: { description: "Exact timestamp of the snapshot" }
|
|
6728
|
+
}
|
|
6729
|
+
]
|
|
6730
|
+
};
|
|
6731
|
+
}
|
|
6732
|
+
|
|
6733
|
+
// src/endpoints/rankTracking.ts
|
|
6734
|
+
var RANK_COLLECTION = "seo-rank-history";
|
|
6735
|
+
var round1 = (n) => Math.round(n * 10) / 10;
|
|
6736
|
+
async function runRankSnapshot(payload, basePath, seoConfig, opts) {
|
|
6737
|
+
const cfg = getGscOAuthConfig(basePath, seoConfig);
|
|
6738
|
+
if (!cfg) return { ok: false, reason: "not_configured" };
|
|
6739
|
+
const authDoc = await getOrCreateGscAuthDoc(payload);
|
|
6740
|
+
if (!authDoc.refreshTokenEnc) return { ok: false, reason: "not_connected" };
|
|
6741
|
+
let accessToken;
|
|
6742
|
+
try {
|
|
6743
|
+
accessToken = await getGscAccessToken(payload, cfg, authDoc);
|
|
6744
|
+
} catch (e) {
|
|
6745
|
+
return { ok: false, reason: e instanceof Error ? e.message : "refresh_failed" };
|
|
6746
|
+
}
|
|
6747
|
+
const property = authDoc.propertyUrl || cfg.siteUrl;
|
|
6748
|
+
const windowDays = Math.min(90, Math.max(1, 7));
|
|
6749
|
+
const rowLimit = Math.min(1e3, Math.max(1, 100));
|
|
6750
|
+
const end = new Date(Date.now() - 2 * 864e5);
|
|
6751
|
+
const start = new Date(end.getTime() - (windowDays - 1) * 864e5);
|
|
6752
|
+
const endDate = end.toISOString().slice(0, 10);
|
|
6753
|
+
const startDate = start.toISOString().slice(0, 10);
|
|
6754
|
+
let rows;
|
|
6755
|
+
try {
|
|
6756
|
+
rows = await queryGscSearchAnalytics(accessToken, property, {
|
|
6757
|
+
startDate,
|
|
6758
|
+
endDate,
|
|
6759
|
+
dimensions: ["query"],
|
|
6760
|
+
rowLimit
|
|
6761
|
+
});
|
|
6762
|
+
} catch (e) {
|
|
6763
|
+
return { ok: false, reason: e instanceof Error ? e.message : "query_failed" };
|
|
6764
|
+
}
|
|
6765
|
+
const todayKey = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
6766
|
+
const existing = await payload.find({
|
|
6767
|
+
collection: RANK_COLLECTION,
|
|
6768
|
+
where: { dateKey: { equals: todayKey } },
|
|
6769
|
+
limit: 2e3,
|
|
6770
|
+
depth: 0,
|
|
6771
|
+
overrideAccess: true
|
|
6772
|
+
});
|
|
6773
|
+
const already = new Set(existing.docs.map((d) => d.query));
|
|
6774
|
+
let stored = 0;
|
|
6775
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
6776
|
+
for (const r of rows) {
|
|
6777
|
+
const query = r.keys?.[0];
|
|
6778
|
+
if (!query || already.has(query)) continue;
|
|
6779
|
+
try {
|
|
6780
|
+
await payload.create({
|
|
6781
|
+
collection: RANK_COLLECTION,
|
|
6782
|
+
data: {
|
|
6783
|
+
query,
|
|
6784
|
+
position: round1(r.position),
|
|
6785
|
+
clicks: r.clicks,
|
|
6786
|
+
impressions: r.impressions,
|
|
6787
|
+
ctr: r.ctr,
|
|
6788
|
+
property,
|
|
6789
|
+
dateKey: todayKey,
|
|
6790
|
+
snapshotDate: nowIso
|
|
6791
|
+
},
|
|
6792
|
+
overrideAccess: true
|
|
6793
|
+
});
|
|
6794
|
+
stored++;
|
|
6795
|
+
} catch (e) {
|
|
6796
|
+
payload.logger.warn(`[seo] rank-snapshot: skipped "${query}": ${e instanceof Error ? e.message : "error"}`);
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
return { ok: true, stored, scanned: rows.length, startDate, endDate };
|
|
6800
|
+
}
|
|
6801
|
+
function createRankSnapshotHandler(basePath, seoConfig) {
|
|
6802
|
+
return async (req) => {
|
|
6803
|
+
try {
|
|
6804
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6805
|
+
const result = await runRankSnapshot(req.payload, basePath, seoConfig);
|
|
6806
|
+
if (!result.ok) {
|
|
6807
|
+
const status = result.reason === "not_connected" || result.reason === "not_configured" ? 409 : 502;
|
|
6808
|
+
return Response.json(result, { status, headers: { "Cache-Control": "no-store" } });
|
|
6809
|
+
}
|
|
6810
|
+
return Response.json(result, { headers: { "Cache-Control": "no-store" } });
|
|
6811
|
+
} catch (error) {
|
|
6812
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
6813
|
+
req.payload.logger.error(`[seo] rank-snapshot error: ${message}`);
|
|
6814
|
+
return Response.json({ error: message }, { status: 500 });
|
|
6815
|
+
}
|
|
6816
|
+
};
|
|
6817
|
+
}
|
|
6818
|
+
function createRankHistoryHandler() {
|
|
6819
|
+
return async (req) => {
|
|
6820
|
+
try {
|
|
6821
|
+
if (!isGscAdmin(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6822
|
+
const url = new URL(req.url);
|
|
6823
|
+
const days = Math.min(180, Math.max(7, parseInt(url.searchParams.get("days") || "35", 10)));
|
|
6824
|
+
const since = new Date(Date.now() - days * 864e5).toISOString();
|
|
6825
|
+
const all = await req.payload.find({
|
|
6826
|
+
collection: RANK_COLLECTION,
|
|
6827
|
+
where: { snapshotDate: { greater_than: since } },
|
|
6828
|
+
sort: "-snapshotDate",
|
|
6829
|
+
limit: 5e3,
|
|
6830
|
+
depth: 0,
|
|
6831
|
+
overrideAccess: true
|
|
6832
|
+
});
|
|
6833
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
6834
|
+
for (const d of all.docs) {
|
|
6835
|
+
const q = d.query;
|
|
6836
|
+
const arr = byQuery.get(q);
|
|
6837
|
+
if (arr) arr.push(d);
|
|
6838
|
+
else byQuery.set(q, [d]);
|
|
6839
|
+
}
|
|
6840
|
+
const movers = Array.from(byQuery.entries()).map(([query, snaps]) => {
|
|
6841
|
+
const latest = snaps[0];
|
|
6842
|
+
const previous = snaps.find((s) => s.dateKey !== latest.dateKey) || null;
|
|
6843
|
+
const delta = previous ? round1(previous.position - latest.position) : 0;
|
|
6844
|
+
return {
|
|
6845
|
+
query,
|
|
6846
|
+
page: latest.page || null,
|
|
6847
|
+
position: latest.position,
|
|
6848
|
+
previousPosition: previous ? previous.position : null,
|
|
6849
|
+
delta,
|
|
6850
|
+
clicks: latest.clicks ?? 0,
|
|
6851
|
+
impressions: latest.impressions ?? 0,
|
|
6852
|
+
ctr: latest.ctr ?? 0,
|
|
6853
|
+
snapshotDate: latest.snapshotDate,
|
|
6854
|
+
history: snaps.slice(0, 30).map((s) => ({ date: s.dateKey, position: s.position })).reverse()
|
|
6855
|
+
};
|
|
6856
|
+
});
|
|
6857
|
+
movers.sort((a, b) => (b.impressions || 0) - (a.impressions || 0));
|
|
6858
|
+
return Response.json(
|
|
6859
|
+
{
|
|
6860
|
+
count: movers.length,
|
|
6861
|
+
lastSnapshot: all.docs[0]?.snapshotDate || null,
|
|
6862
|
+
movers
|
|
6863
|
+
},
|
|
6864
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
6865
|
+
);
|
|
6866
|
+
} catch (error) {
|
|
6867
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
6868
|
+
req.payload.logger.error(`[seo] rank-history error: ${message}`);
|
|
6869
|
+
return Response.json({ error: message }, { status: 500 });
|
|
6870
|
+
}
|
|
6871
|
+
};
|
|
6872
|
+
}
|
|
6873
|
+
|
|
6194
6874
|
// src/rateLimiter.ts
|
|
6195
6875
|
function createRateLimiter(maxRequests, windowMs) {
|
|
6196
6876
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -6487,8 +7167,8 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
|
|
|
6487
7167
|
try {
|
|
6488
7168
|
await payload.find({
|
|
6489
7169
|
collection: collectionSlug,
|
|
6490
|
-
limit:
|
|
6491
|
-
depth:
|
|
7170
|
+
limit: 100,
|
|
7171
|
+
depth: 0,
|
|
6492
7172
|
overrideAccess: true
|
|
6493
7173
|
});
|
|
6494
7174
|
payload.logger.info(`[seo] warm-cache: pre-loaded ${collectionSlug}`);
|
|
@@ -6499,7 +7179,7 @@ async function doWarmUp(payload, collections = ["pages", "posts"], globals = [])
|
|
|
6499
7179
|
try {
|
|
6500
7180
|
await payload.findGlobal({
|
|
6501
7181
|
slug: globalSlug,
|
|
6502
|
-
depth:
|
|
7182
|
+
depth: 0,
|
|
6503
7183
|
overrideAccess: true
|
|
6504
7184
|
});
|
|
6505
7185
|
payload.logger.info(`[seo] warm-cache: pre-loaded global ${globalSlug}`);
|
|
@@ -6535,6 +7215,296 @@ function stopCacheWarmUp() {
|
|
|
6535
7215
|
}
|
|
6536
7216
|
}
|
|
6537
7217
|
|
|
7218
|
+
// src/rankTracker.ts
|
|
7219
|
+
var SNAPSHOT_INTERVAL = 24 * 60 * 60 * 1e3;
|
|
7220
|
+
var STARTUP_DELAY2 = 30 * 1e3;
|
|
7221
|
+
var intervalId2 = null;
|
|
7222
|
+
var listenersAttached2 = false;
|
|
7223
|
+
async function doSnapshot(payload, basePath, seoConfig) {
|
|
7224
|
+
try {
|
|
7225
|
+
const result = await runRankSnapshot(payload, basePath, seoConfig);
|
|
7226
|
+
if (result.ok) {
|
|
7227
|
+
payload.logger.info(`[seo] rank-tracker: snapshot stored ${result.stored}/${result.scanned} queries`);
|
|
7228
|
+
} else if (result.reason !== "not_connected" && result.reason !== "not_configured") {
|
|
7229
|
+
payload.logger.warn(`[seo] rank-tracker: snapshot skipped (${result.reason})`);
|
|
7230
|
+
}
|
|
7231
|
+
} catch (error) {
|
|
7232
|
+
payload.logger.error(`[seo] rank-tracker error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
7233
|
+
}
|
|
7234
|
+
}
|
|
7235
|
+
function startRankTracker(payload, basePath, seoConfig) {
|
|
7236
|
+
setTimeout(() => {
|
|
7237
|
+
void doSnapshot(payload, basePath, seoConfig);
|
|
7238
|
+
}, STARTUP_DELAY2);
|
|
7239
|
+
intervalId2 = setInterval(() => {
|
|
7240
|
+
void doSnapshot(payload, basePath, seoConfig);
|
|
7241
|
+
}, SNAPSHOT_INTERVAL);
|
|
7242
|
+
if (!listenersAttached2) {
|
|
7243
|
+
const cleanup = () => stopRankTracker();
|
|
7244
|
+
process.on("SIGTERM", cleanup);
|
|
7245
|
+
process.on("SIGINT", cleanup);
|
|
7246
|
+
listenersAttached2 = true;
|
|
7247
|
+
}
|
|
7248
|
+
payload.logger.info("[seo] rank-tracker: scheduled startup + every 24h");
|
|
7249
|
+
}
|
|
7250
|
+
function stopRankTracker() {
|
|
7251
|
+
if (intervalId2) {
|
|
7252
|
+
clearInterval(intervalId2);
|
|
7253
|
+
intervalId2 = null;
|
|
7254
|
+
}
|
|
7255
|
+
}
|
|
7256
|
+
|
|
7257
|
+
// src/endpoints/alerts.ts
|
|
7258
|
+
function isAdmin8(user) {
|
|
7259
|
+
if (!user) return false;
|
|
7260
|
+
if (user.role === "admin") return true;
|
|
7261
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
7262
|
+
return false;
|
|
7263
|
+
}
|
|
7264
|
+
function getAlertConfig() {
|
|
7265
|
+
return {
|
|
7266
|
+
webhookUrl: process.env.SEO_ALERT_WEBHOOK_URL || "",
|
|
7267
|
+
emails: (process.env.SEO_ALERT_EMAIL || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
7268
|
+
scoreDrop: parseInt(process.env.SEO_ALERT_SCORE_DROP || "10", 10) || 10,
|
|
7269
|
+
positionDrop: parseInt(process.env.SEO_ALERT_POSITION_DROP || "5", 10) || 5,
|
|
7270
|
+
windowHours: Math.max(1, parseInt(process.env.SEO_ALERT_WINDOW_HOURS || "24", 10) || 24)
|
|
7271
|
+
};
|
|
7272
|
+
}
|
|
7273
|
+
var round12 = (n) => Math.round(n * 10) / 10;
|
|
7274
|
+
async function buildAlertDigest(payload, cfg) {
|
|
7275
|
+
const now = Date.now();
|
|
7276
|
+
const since = new Date(now - cfg.windowHours * 36e5).toISOString();
|
|
7277
|
+
const scoreRegressions = [];
|
|
7278
|
+
try {
|
|
7279
|
+
const hist = await payload.find({
|
|
7280
|
+
collection: "seo-score-history",
|
|
7281
|
+
where: { snapshotDate: { greater_than: new Date(now - 14 * 864e5).toISOString() } },
|
|
7282
|
+
sort: "-snapshotDate",
|
|
7283
|
+
limit: 5e3,
|
|
7284
|
+
depth: 0,
|
|
7285
|
+
overrideAccess: true
|
|
7286
|
+
});
|
|
7287
|
+
const byDoc = /* @__PURE__ */ new Map();
|
|
7288
|
+
for (const h of hist.docs) {
|
|
7289
|
+
const key = `${h.documentId}::${h.collection}`;
|
|
7290
|
+
const arr = byDoc.get(key);
|
|
7291
|
+
if (arr) arr.push(h);
|
|
7292
|
+
else byDoc.set(key, [h]);
|
|
7293
|
+
}
|
|
7294
|
+
for (const [key, snaps] of byDoc) {
|
|
7295
|
+
const latest = snaps[0];
|
|
7296
|
+
const oldest = snaps[snaps.length - 1];
|
|
7297
|
+
const drop = oldest.score - latest.score;
|
|
7298
|
+
if (drop >= cfg.scoreDrop) {
|
|
7299
|
+
const [documentId, collection] = key.split("::");
|
|
7300
|
+
scoreRegressions.push({
|
|
7301
|
+
documentId,
|
|
7302
|
+
collection,
|
|
7303
|
+
from: oldest.score,
|
|
7304
|
+
to: latest.score,
|
|
7305
|
+
drop
|
|
7306
|
+
});
|
|
7307
|
+
}
|
|
7308
|
+
}
|
|
7309
|
+
scoreRegressions.sort((a, b) => b.drop - a.drop);
|
|
7310
|
+
} catch {
|
|
7311
|
+
}
|
|
7312
|
+
const newNotFound = [];
|
|
7313
|
+
try {
|
|
7314
|
+
const logs = await payload.find({
|
|
7315
|
+
collection: "seo-logs",
|
|
7316
|
+
where: {
|
|
7317
|
+
and: [{ lastSeen: { greater_than: since } }, { ignored: { not_equals: true } }]
|
|
7318
|
+
},
|
|
7319
|
+
sort: "-count",
|
|
7320
|
+
limit: 50,
|
|
7321
|
+
depth: 0,
|
|
7322
|
+
overrideAccess: true
|
|
7323
|
+
});
|
|
7324
|
+
for (const l of logs.docs) {
|
|
7325
|
+
newNotFound.push({
|
|
7326
|
+
url: l.url || "",
|
|
7327
|
+
count: l.count || 1,
|
|
7328
|
+
lastSeen: l.lastSeen || ""
|
|
7329
|
+
});
|
|
7330
|
+
}
|
|
7331
|
+
} catch {
|
|
7332
|
+
}
|
|
7333
|
+
const rankDrops = [];
|
|
7334
|
+
try {
|
|
7335
|
+
const ranks = await payload.find({
|
|
7336
|
+
collection: "seo-rank-history",
|
|
7337
|
+
where: { snapshotDate: { greater_than: new Date(now - 35 * 864e5).toISOString() } },
|
|
7338
|
+
sort: "-snapshotDate",
|
|
7339
|
+
limit: 5e3,
|
|
7340
|
+
depth: 0,
|
|
7341
|
+
overrideAccess: true
|
|
7342
|
+
});
|
|
7343
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
7344
|
+
for (const r of ranks.docs) {
|
|
7345
|
+
const q = r.query;
|
|
7346
|
+
const arr = byQuery.get(q);
|
|
7347
|
+
if (arr) arr.push(r);
|
|
7348
|
+
else byQuery.set(q, [r]);
|
|
7349
|
+
}
|
|
7350
|
+
for (const [query, snaps] of byQuery) {
|
|
7351
|
+
const latest = snaps[0];
|
|
7352
|
+
const previous = snaps.find((s) => s.dateKey !== latest.dateKey);
|
|
7353
|
+
if (!previous) continue;
|
|
7354
|
+
const drop = round12(latest.position - previous.position);
|
|
7355
|
+
if (drop >= cfg.positionDrop) {
|
|
7356
|
+
rankDrops.push({ query, from: previous.position, to: latest.position, drop });
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
7359
|
+
rankDrops.sort((a, b) => b.drop - a.drop);
|
|
7360
|
+
} catch {
|
|
7361
|
+
}
|
|
7362
|
+
const totalIssues = scoreRegressions.length + newNotFound.length + rankDrops.length;
|
|
7363
|
+
return {
|
|
7364
|
+
since,
|
|
7365
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7366
|
+
scoreRegressions,
|
|
7367
|
+
newNotFound,
|
|
7368
|
+
rankDrops,
|
|
7369
|
+
totalIssues
|
|
7370
|
+
};
|
|
7371
|
+
}
|
|
7372
|
+
function digestToHtml(digest, siteUrl) {
|
|
7373
|
+
const section = (title, rows) => rows.length ? `<h3 style="margin:18px 0 6px">${title}</h3><ul style="margin:0;padding-left:18px">${rows.join("")}</ul>` : "";
|
|
7374
|
+
const reg = digest.scoreRegressions.slice(0, 20).map((r) => `<li>${r.collection}/${r.documentId} \u2014 score ${r.from} \u2192 <b>${r.to}</b> (\u2212${r.drop})</li>`);
|
|
7375
|
+
const nf = digest.newNotFound.slice(0, 20).map((n) => `<li><code>${n.url}</code> \u2014 ${n.count}\xD7</li>`);
|
|
7376
|
+
const rd = digest.rankDrops.slice(0, 20).map((d) => `<li>\u201C${d.query}\u201D \u2014 #${round12(d.from)} \u2192 <b>#${round12(d.to)}</b> (\u25BC${d.drop})</li>`);
|
|
7377
|
+
return `<div style="font-family:system-ui;max-width:640px">
|
|
7378
|
+
<h2>SEO alert digest${siteUrl ? ` \u2014 ${siteUrl}` : ""}</h2>
|
|
7379
|
+
<p style="color:#6b7280;font-size:13px">${digest.totalIssues} issue(s) since ${new Date(digest.since).toLocaleString()}</p>
|
|
7380
|
+
${section("\u{1F4C9} Score regressions", reg)}
|
|
7381
|
+
${section("\u{1F517} New 404s", nf)}
|
|
7382
|
+
${section("\u{1F53B} Ranking drops", rd)}
|
|
7383
|
+
${digest.totalIssues === 0 ? "<p>No issues to report. \u{1F389}</p>" : ""}
|
|
7384
|
+
</div>`;
|
|
7385
|
+
}
|
|
7386
|
+
async function deliverAlertDigest(payload, digest, cfg, siteUrl) {
|
|
7387
|
+
const channels = { webhook: false, email: false };
|
|
7388
|
+
if (digest.totalIssues === 0) {
|
|
7389
|
+
return { sent: false, reason: "nothing_to_report", channels };
|
|
7390
|
+
}
|
|
7391
|
+
if (cfg.webhookUrl) {
|
|
7392
|
+
try {
|
|
7393
|
+
await fetch(cfg.webhookUrl, {
|
|
7394
|
+
method: "POST",
|
|
7395
|
+
headers: { "content-type": "application/json" },
|
|
7396
|
+
body: JSON.stringify({ type: "seo-alert-digest", siteUrl, digest })
|
|
7397
|
+
});
|
|
7398
|
+
channels.webhook = true;
|
|
7399
|
+
} catch (e) {
|
|
7400
|
+
payload.logger.warn(`[seo] alerts: webhook delivery failed: ${e instanceof Error ? e.message : "error"}`);
|
|
7401
|
+
}
|
|
7402
|
+
}
|
|
7403
|
+
if (cfg.emails.length > 0) {
|
|
7404
|
+
const send = payload.sendEmail;
|
|
7405
|
+
if (typeof send === "function") {
|
|
7406
|
+
try {
|
|
7407
|
+
await send({
|
|
7408
|
+
to: cfg.emails,
|
|
7409
|
+
subject: `SEO alert digest \u2014 ${digest.totalIssues} issue(s)`,
|
|
7410
|
+
html: digestToHtml(digest, siteUrl)
|
|
7411
|
+
});
|
|
7412
|
+
channels.email = true;
|
|
7413
|
+
} catch (e) {
|
|
7414
|
+
payload.logger.warn(`[seo] alerts: email delivery failed: ${e instanceof Error ? e.message : "error"}`);
|
|
7415
|
+
}
|
|
7416
|
+
}
|
|
7417
|
+
}
|
|
7418
|
+
const sent = channels.webhook || channels.email;
|
|
7419
|
+
return { sent, reason: sent ? void 0 : "no_channel_configured", channels };
|
|
7420
|
+
}
|
|
7421
|
+
function createAlertsDigestHandler() {
|
|
7422
|
+
return async (req) => {
|
|
7423
|
+
try {
|
|
7424
|
+
if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7425
|
+
const cfg = getAlertConfig();
|
|
7426
|
+
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7427
|
+
return Response.json(
|
|
7428
|
+
{
|
|
7429
|
+
digest,
|
|
7430
|
+
config: {
|
|
7431
|
+
webhookConfigured: !!cfg.webhookUrl,
|
|
7432
|
+
emailConfigured: cfg.emails.length > 0,
|
|
7433
|
+
scoreDrop: cfg.scoreDrop,
|
|
7434
|
+
positionDrop: cfg.positionDrop,
|
|
7435
|
+
windowHours: cfg.windowHours
|
|
7436
|
+
}
|
|
7437
|
+
},
|
|
7438
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
7439
|
+
);
|
|
7440
|
+
} catch (error) {
|
|
7441
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7442
|
+
req.payload.logger.error(`[seo] alerts-digest error: ${message}`);
|
|
7443
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7444
|
+
}
|
|
7445
|
+
};
|
|
7446
|
+
}
|
|
7447
|
+
function createAlertsRunHandler(siteUrl) {
|
|
7448
|
+
return async (req) => {
|
|
7449
|
+
try {
|
|
7450
|
+
if (!isAdmin8(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
7451
|
+
const cfg = getAlertConfig();
|
|
7452
|
+
const digest = await buildAlertDigest(req.payload, cfg);
|
|
7453
|
+
const delivery = await deliverAlertDigest(req.payload, digest, cfg, siteUrl);
|
|
7454
|
+
return Response.json({ digest, delivery }, { headers: { "Cache-Control": "no-store" } });
|
|
7455
|
+
} catch (error) {
|
|
7456
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
7457
|
+
req.payload.logger.error(`[seo] alerts-run error: ${message}`);
|
|
7458
|
+
return Response.json({ error: message }, { status: 500 });
|
|
7459
|
+
}
|
|
7460
|
+
};
|
|
7461
|
+
}
|
|
7462
|
+
|
|
7463
|
+
// src/alertsScheduler.ts
|
|
7464
|
+
var STARTUP_DELAY3 = 60 * 1e3;
|
|
7465
|
+
var intervalId3 = null;
|
|
7466
|
+
var listenersAttached3 = false;
|
|
7467
|
+
async function runDigest(payload, siteUrl) {
|
|
7468
|
+
try {
|
|
7469
|
+
const cfg = getAlertConfig();
|
|
7470
|
+
if (!cfg.webhookUrl && cfg.emails.length === 0) {
|
|
7471
|
+
return;
|
|
7472
|
+
}
|
|
7473
|
+
const digest = await buildAlertDigest(payload, cfg);
|
|
7474
|
+
const delivery = await deliverAlertDigest(payload, digest, cfg, siteUrl);
|
|
7475
|
+
if (delivery.sent) {
|
|
7476
|
+
payload.logger.info(
|
|
7477
|
+
`[seo] alerts: digest delivered (${digest.totalIssues} issues; webhook=${delivery.channels.webhook} email=${delivery.channels.email})`
|
|
7478
|
+
);
|
|
7479
|
+
}
|
|
7480
|
+
} catch (error) {
|
|
7481
|
+
payload.logger.error(`[seo] alerts scheduler error: ${error instanceof Error ? error.message : "unknown"}`);
|
|
7482
|
+
}
|
|
7483
|
+
}
|
|
7484
|
+
function startAlertsScheduler(payload, siteUrl) {
|
|
7485
|
+
const intervalHours = Math.max(1, parseInt(process.env.SEO_ALERT_INTERVAL_HOURS || "24", 10) || 24);
|
|
7486
|
+
const intervalMs = intervalHours * 60 * 60 * 1e3;
|
|
7487
|
+
setTimeout(() => {
|
|
7488
|
+
void runDigest(payload, siteUrl);
|
|
7489
|
+
}, STARTUP_DELAY3);
|
|
7490
|
+
intervalId3 = setInterval(() => {
|
|
7491
|
+
void runDigest(payload, siteUrl);
|
|
7492
|
+
}, intervalMs);
|
|
7493
|
+
if (!listenersAttached3) {
|
|
7494
|
+
const cleanup = () => stopAlertsScheduler();
|
|
7495
|
+
process.on("SIGTERM", cleanup);
|
|
7496
|
+
process.on("SIGINT", cleanup);
|
|
7497
|
+
listenersAttached3 = true;
|
|
7498
|
+
}
|
|
7499
|
+
payload.logger.info(`[seo] alerts: scheduled startup + every ${intervalHours}h`);
|
|
7500
|
+
}
|
|
7501
|
+
function stopAlertsScheduler() {
|
|
7502
|
+
if (intervalId3) {
|
|
7503
|
+
clearInterval(intervalId3);
|
|
7504
|
+
intervalId3 = null;
|
|
7505
|
+
}
|
|
7506
|
+
}
|
|
7507
|
+
|
|
6538
7508
|
// src/endpoints/generate.ts
|
|
6539
7509
|
var TYPE_TO_CONFIG_KEY = {
|
|
6540
7510
|
title: "generateTitle",
|
|
@@ -7519,6 +8489,8 @@ var fr = {
|
|
|
7519
8489
|
},
|
|
7520
8490
|
seoView: {
|
|
7521
8491
|
loadingAudit: "Chargement de l'audit SEO...",
|
|
8492
|
+
buildingAudit: "G\xE9n\xE9ration de l'audit SEO en cours\u2026 (calcul\xE9 en arri\xE8re-plan pour ne pas surcharger le serveur, cela peut prendre un moment sur un gros site)",
|
|
8493
|
+
buildTimeout: "La g\xE9n\xE9ration de l'audit prend plus de temps que pr\xE9vu. R\xE9essayez dans quelques instants.",
|
|
7522
8494
|
errorSaving: "Erreur lors de la sauvegarde",
|
|
7523
8495
|
auditTitle: "Audit SEO",
|
|
7524
8496
|
pagesAnalyzed: "pages analys\xE9es",
|
|
@@ -7961,7 +8933,19 @@ var fr = {
|
|
|
7961
8933
|
generateMeta: "G\xE9n\xE9rer les meta",
|
|
7962
8934
|
metaTitle: "Meta Title",
|
|
7963
8935
|
metaDescription: "Meta Description",
|
|
7964
|
-
emptyValue: "(vide)"
|
|
8936
|
+
emptyValue: "(vide)",
|
|
8937
|
+
optimizeWithAi: "Optimiser avec l'IA",
|
|
8938
|
+
optimizeIntro: "L'IA analyse la page et propose des meta optimis\xE9es (titre, description, mot-cl\xE9). V\xE9rifiez puis appliquez.",
|
|
8939
|
+
optimizeRunning: "Analyse en cours\u2026",
|
|
8940
|
+
applyAll: "Appliquer",
|
|
8941
|
+
applied: "Appliqu\xE9",
|
|
8942
|
+
whyChanges: "Pourquoi ces changements",
|
|
8943
|
+
labelCurrent: "Actuel",
|
|
8944
|
+
labelSuggested: "Sugg\xE9r\xE9",
|
|
8945
|
+
labelFocusKeyword: "Mot-cl\xE9 cible",
|
|
8946
|
+
heuristicNote: "Suggestions heuristiques (cl\xE9 API Claude non configur\xE9e).",
|
|
8947
|
+
applySaveHint: "Champs remplis \u2014 pensez \xE0 enregistrer le document.",
|
|
8948
|
+
noMetaChange: "Aucun changement propos\xE9."
|
|
7965
8949
|
},
|
|
7966
8950
|
scoreHistory: {
|
|
7967
8951
|
loading: "Chargement de l'historique...",
|
|
@@ -8097,6 +9081,8 @@ var en = {
|
|
|
8097
9081
|
},
|
|
8098
9082
|
seoView: {
|
|
8099
9083
|
loadingAudit: "Loading SEO audit...",
|
|
9084
|
+
buildingAudit: "Building the SEO audit\u2026 (computed in the background to avoid overloading the server \u2014 this can take a moment on a large site)",
|
|
9085
|
+
buildTimeout: "The audit is taking longer than expected. Please try again in a moment.",
|
|
8100
9086
|
errorSaving: "Error during save",
|
|
8101
9087
|
auditTitle: "SEO Audit",
|
|
8102
9088
|
pagesAnalyzed: "pages analyzed",
|
|
@@ -8539,7 +9525,19 @@ var en = {
|
|
|
8539
9525
|
generateMeta: "Generate meta",
|
|
8540
9526
|
metaTitle: "Meta Title",
|
|
8541
9527
|
metaDescription: "Meta Description",
|
|
8542
|
-
emptyValue: "(empty)"
|
|
9528
|
+
emptyValue: "(empty)",
|
|
9529
|
+
optimizeWithAi: "Optimize with AI",
|
|
9530
|
+
optimizeIntro: "AI analyzes the page and proposes optimized meta tags (title, description, keyword). Review, then apply.",
|
|
9531
|
+
optimizeRunning: "Analyzing\u2026",
|
|
9532
|
+
applyAll: "Apply",
|
|
9533
|
+
applied: "Applied",
|
|
9534
|
+
whyChanges: "Why these changes",
|
|
9535
|
+
labelCurrent: "Current",
|
|
9536
|
+
labelSuggested: "Suggested",
|
|
9537
|
+
labelFocusKeyword: "Focus keyword",
|
|
9538
|
+
heuristicNote: "Heuristic suggestions (Claude API key not configured).",
|
|
9539
|
+
applySaveHint: "Fields filled \u2014 remember to save the document.",
|
|
9540
|
+
noMetaChange: "No changes proposed."
|
|
8543
9541
|
},
|
|
8544
9542
|
scoreHistory: {
|
|
8545
9543
|
loading: "Loading history...",
|
|
@@ -8661,6 +9659,7 @@ function buildSeoConfig(pluginConfig) {
|
|
|
8661
9659
|
var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
8662
9660
|
const config = { ...incomingConfig };
|
|
8663
9661
|
const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
|
|
9662
|
+
const uploadsCollection = pluginConfig.uploadsCollection ?? "media";
|
|
8664
9663
|
const targetGlobals = pluginConfig.globals ?? [];
|
|
8665
9664
|
const basePath = pluginConfig.endpointBasePath ?? "/seo-plugin";
|
|
8666
9665
|
const seoConfig = buildSeoConfig(pluginConfig);
|
|
@@ -8683,6 +9682,8 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8683
9682
|
// opt-in — requires Google Cloud OAuth setup + secrets
|
|
8684
9683
|
warmCache: true,
|
|
8685
9684
|
// disable on low-memory hosts to skip startup pre-loading
|
|
9685
|
+
alerts: false,
|
|
9686
|
+
// opt-in — requires SEO_ALERT_WEBHOOK_URL and/or SEO_ALERT_EMAIL
|
|
8686
9687
|
...pluginConfig.features
|
|
8687
9688
|
};
|
|
8688
9689
|
function hasExistingSeoMeta(fields) {
|
|
@@ -8823,7 +9824,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8823
9824
|
if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
|
|
8824
9825
|
if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
|
|
8825
9826
|
if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
|
|
8826
|
-
if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
|
|
9827
|
+
if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection(), createSeoRankHistoryCollection());
|
|
8827
9828
|
config.collections = [
|
|
8828
9829
|
...config.collections || [],
|
|
8829
9830
|
...pluginCollections
|
|
@@ -8935,7 +9936,10 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8935
9936
|
if (features.aiFeatures) {
|
|
8936
9937
|
pluginEndpoints.push(
|
|
8937
9938
|
{ path: `${basePath}/ai-generate`, method: "post", handler: createAiGenerateHandler() },
|
|
8938
|
-
{ path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) }
|
|
9939
|
+
{ path: `${basePath}/ai-rewrite`, method: "post", handler: createAiRewriteHandler(targetCollections) },
|
|
9940
|
+
{ path: `${basePath}/ai-optimize`, method: "post", handler: createAiOptimizeHandler(targetCollections, seoConfig) },
|
|
9941
|
+
{ path: `${basePath}/alt-text-audit`, method: "get", handler: createAltTextAuditHandler(uploadsCollection) },
|
|
9942
|
+
{ path: `${basePath}/ai-alt-text`, method: "post", handler: withRateLimit(createAiAltTextHandler(uploadsCollection, seoConfig)) }
|
|
8939
9943
|
);
|
|
8940
9944
|
}
|
|
8941
9945
|
if (features.cannibalization) {
|
|
@@ -8966,7 +9970,15 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8966
9970
|
{ path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
|
|
8967
9971
|
{ path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
|
|
8968
9972
|
{ path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
|
|
8969
|
-
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
|
|
9973
|
+
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() },
|
|
9974
|
+
{ path: `${basePath}/rank-snapshot`, method: "post", handler: withRateLimit(createRankSnapshotHandler(basePath, seoConfig)) },
|
|
9975
|
+
{ path: `${basePath}/rank-history`, method: "get", handler: createRankHistoryHandler() }
|
|
9976
|
+
);
|
|
9977
|
+
}
|
|
9978
|
+
if (features.alerts) {
|
|
9979
|
+
pluginEndpoints.push(
|
|
9980
|
+
{ path: `${basePath}/alerts-digest`, method: "get", handler: createAlertsDigestHandler() },
|
|
9981
|
+
{ path: `${basePath}/alerts-run`, method: "post", handler: withRateLimit(createAlertsRunHandler(resolveGscSiteUrl(seoConfig))) }
|
|
8970
9982
|
);
|
|
8971
9983
|
}
|
|
8972
9984
|
if (features.keywords) {
|
|
@@ -9112,10 +10124,90 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
9112
10124
|
if (features.warmCache) {
|
|
9113
10125
|
startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
|
|
9114
10126
|
}
|
|
10127
|
+
if (features.gscApi) {
|
|
10128
|
+
startRankTracker(payload, basePath, seoConfig);
|
|
10129
|
+
}
|
|
10130
|
+
if (features.alerts) {
|
|
10131
|
+
startAlertsScheduler(payload, resolveGscSiteUrl(seoConfig));
|
|
10132
|
+
}
|
|
9115
10133
|
};
|
|
9116
10134
|
return config;
|
|
9117
10135
|
};
|
|
9118
10136
|
|
|
10137
|
+
// src/helpers/buildMetadata.ts
|
|
10138
|
+
function resolveSiteUrl3(explicit) {
|
|
10139
|
+
return (explicit || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || "").replace(/\/$/, "");
|
|
10140
|
+
}
|
|
10141
|
+
function parseRobots(doc, meta) {
|
|
10142
|
+
const raw = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
|
|
10143
|
+
let noindex = false;
|
|
10144
|
+
let nofollow = false;
|
|
10145
|
+
if (raw) {
|
|
10146
|
+
const low = raw.toLowerCase();
|
|
10147
|
+
noindex = low.includes("noindex");
|
|
10148
|
+
nofollow = low.includes("nofollow");
|
|
10149
|
+
}
|
|
10150
|
+
if (doc.noindex === true || meta.noindex === true) noindex = true;
|
|
10151
|
+
if (doc.nofollow === true || meta.nofollow === true) nofollow = true;
|
|
10152
|
+
return { index: !noindex, follow: !nofollow };
|
|
10153
|
+
}
|
|
10154
|
+
function buildLanguages(doc) {
|
|
10155
|
+
const raw = doc.localeAlternates || doc.alternates || doc.hreflang;
|
|
10156
|
+
if (!Array.isArray(raw)) return void 0;
|
|
10157
|
+
const out = {};
|
|
10158
|
+
for (const a of raw) {
|
|
10159
|
+
if (!a || typeof a !== "object") continue;
|
|
10160
|
+
const r = a;
|
|
10161
|
+
const lang = String(r.hreflang || r.locale || r.lang || "");
|
|
10162
|
+
const href = String(r.href || r.url || "");
|
|
10163
|
+
if (lang && href) out[lang] = href;
|
|
10164
|
+
}
|
|
10165
|
+
return Object.keys(out).length ? out : void 0;
|
|
10166
|
+
}
|
|
10167
|
+
function absoluteUrl(value, siteUrl) {
|
|
10168
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
10169
|
+
return `${siteUrl}${value.startsWith("/") ? "" : "/"}${value}`;
|
|
10170
|
+
}
|
|
10171
|
+
function buildSeoMetadata(doc, options = {}) {
|
|
10172
|
+
const siteUrl = resolveSiteUrl3(options.siteUrl);
|
|
10173
|
+
const meta = doc.meta || {};
|
|
10174
|
+
const rawTitle = meta.title || doc.title || "";
|
|
10175
|
+
const title = options.titleTemplate && rawTitle ? options.titleTemplate.replace("%s", rawTitle) : rawTitle;
|
|
10176
|
+
const description = meta.description || "";
|
|
10177
|
+
const slug = doc.slug || "";
|
|
10178
|
+
const heroMedia = doc.hero?.media;
|
|
10179
|
+
let image = getSchemaImageUrl(meta.image, heroMedia, siteUrl);
|
|
10180
|
+
if (!image && options.defaultImage) image = absoluteUrl(options.defaultImage, siteUrl);
|
|
10181
|
+
const explicitCanonical = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || "";
|
|
10182
|
+
const canonical = explicitCanonical || (siteUrl ? `${siteUrl}${slug ? `/${slug}` : ""}` : void 0);
|
|
10183
|
+
const languages = buildLanguages(doc);
|
|
10184
|
+
const isPost = options.collection === "posts" || doc.isPost === true;
|
|
10185
|
+
const md = {};
|
|
10186
|
+
if (title) md.title = title;
|
|
10187
|
+
if (description) md.description = description;
|
|
10188
|
+
const alternates = {};
|
|
10189
|
+
if (canonical) alternates.canonical = canonical;
|
|
10190
|
+
if (languages) alternates.languages = languages;
|
|
10191
|
+
if (Object.keys(alternates).length) md.alternates = alternates;
|
|
10192
|
+
md.robots = parseRobots(doc, meta);
|
|
10193
|
+
md.openGraph = {
|
|
10194
|
+
...rawTitle ? { title: rawTitle } : {},
|
|
10195
|
+
...description ? { description } : {},
|
|
10196
|
+
...canonical ? { url: canonical } : {},
|
|
10197
|
+
...options.siteName ? { siteName: options.siteName } : {},
|
|
10198
|
+
type: isPost ? "article" : "website",
|
|
10199
|
+
...options.locale ? { locale: options.locale } : {},
|
|
10200
|
+
...image ? { images: [{ url: image }] } : {}
|
|
10201
|
+
};
|
|
10202
|
+
md.twitter = {
|
|
10203
|
+
card: image ? "summary_large_image" : "summary",
|
|
10204
|
+
...rawTitle ? { title: rawTitle } : {},
|
|
10205
|
+
...description ? { description } : {},
|
|
10206
|
+
...image ? { images: [image] } : {}
|
|
10207
|
+
};
|
|
10208
|
+
return md;
|
|
10209
|
+
}
|
|
10210
|
+
|
|
9119
10211
|
// src/i18n.ts
|
|
9120
10212
|
var rulesFr = {
|
|
9121
10213
|
title: {
|
|
@@ -13043,6 +14135,7 @@ exports.MIN_WORDS_THIN = MIN_WORDS_THIN;
|
|
|
13043
14135
|
exports.POWER_WORDS = POWER_WORDS;
|
|
13044
14136
|
exports.POWER_WORDS_FR = POWER_WORDS_FR;
|
|
13045
14137
|
exports.READABILITY_THRESHOLDS = READABILITY_THRESHOLDS;
|
|
14138
|
+
exports.SCHEMA_TYPES = SCHEMA_TYPES;
|
|
13046
14139
|
exports.SCORE_EXCELLENT = SCORE_EXCELLENT;
|
|
13047
14140
|
exports.SCORE_GOOD = SCORE_GOOD;
|
|
13048
14141
|
exports.SCORE_OK = SCORE_OK;
|
|
@@ -13053,7 +14146,9 @@ exports.TITLE_LENGTH_MIN = TITLE_LENGTH_MIN;
|
|
|
13053
14146
|
exports.UTILITY_SLUGS = UTILITY_SLUGS;
|
|
13054
14147
|
exports.WARNING_MULTIPLIER = WARNING_MULTIPLIER;
|
|
13055
14148
|
exports.analyzeSeo = analyzeSeo;
|
|
14149
|
+
exports.buildJsonLd = buildJsonLd;
|
|
13056
14150
|
exports.buildSeoInputFromDoc = buildSeoInputFromDoc;
|
|
14151
|
+
exports.buildSeoMetadata = buildSeoMetadata;
|
|
13057
14152
|
exports.calculateFlesch = calculateFlesch;
|
|
13058
14153
|
exports.calculateFleschFR = calculateFleschFR;
|
|
13059
14154
|
exports.checkHeadingHierarchy = checkHeadingHierarchy;
|
|
@@ -13078,6 +14173,7 @@ exports.createSitemapAuditHandler = createSitemapAuditHandler;
|
|
|
13078
14173
|
exports.createTrackSeoScoreHook = createTrackSeoScoreHook;
|
|
13079
14174
|
exports.detectPageType = detectPageType;
|
|
13080
14175
|
exports.detectPassiveVoice = detectPassiveVoice;
|
|
14176
|
+
exports.detectSchemaType = detectSchemaType;
|
|
13081
14177
|
exports.extractHeadingsFromLexical = extractHeadingsFromLexical;
|
|
13082
14178
|
exports.extractImagesFromLexical = extractImagesFromLexical;
|
|
13083
14179
|
exports.extractLinkUrlsFromLexical = extractLinkUrlsFromLexical;
|
|
@@ -13092,6 +14188,7 @@ exports.getEvergreenSlugs = getEvergreenSlugs;
|
|
|
13092
14188
|
exports.getGenericAnchors = getGenericAnchors;
|
|
13093
14189
|
exports.getLegalSlugs = getLegalSlugs;
|
|
13094
14190
|
exports.getPowerWords = getPowerWords;
|
|
14191
|
+
exports.getSchemaImageUrl = getSchemaImageUrl;
|
|
13095
14192
|
exports.getStopWordCompounds = getStopWordCompounds;
|
|
13096
14193
|
exports.getStopWords = getStopWords;
|
|
13097
14194
|
exports.getStopWordsFR = getStopWordsFR;
|
|
@@ -13102,6 +14199,7 @@ exports.keywordMatchesText = keywordMatchesText;
|
|
|
13102
14199
|
exports.metaFields = metaFields;
|
|
13103
14200
|
exports.normalizeForComparison = normalizeForComparison;
|
|
13104
14201
|
exports.registerDashboardTranslations = registerDashboardTranslations;
|
|
14202
|
+
exports.renderJsonLdScript = renderJsonLdScript;
|
|
13105
14203
|
exports.resolveAnalysisLocale = resolveAnalysisLocale;
|
|
13106
14204
|
exports.seoAnalyzerPlugin = seoAnalyzerPlugin;
|
|
13107
14205
|
exports.seoFields = seoFields;
|