@consilioweb/payload-seo-analyzer 1.7.1 → 1.8.1
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 +47 -8
- package/dist/client.cjs +1179 -193
- package/dist/client.js +1179 -193
- package/dist/index.cjs +1429 -156
- package/dist/index.d.cts +48 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.js +1430 -157
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { promises } from 'dns';
|
|
2
|
-
import { timingSafeEqual } from 'crypto';
|
|
2
|
+
import { randomBytes, timingSafeEqual, createCipheriv, createDecipheriv, scryptSync } from 'crypto';
|
|
3
3
|
|
|
4
4
|
// src/constants.ts
|
|
5
5
|
var TITLE_LENGTH_MIN = 30;
|
|
@@ -13,7 +13,7 @@ var MIN_WORDS_GENERIC = 300;
|
|
|
13
13
|
var MIN_WORDS_THIN = 100;
|
|
14
14
|
var MIN_WORDS_QUALITY_FAIL = 50;
|
|
15
15
|
var MIN_WORDS_QUALITY_WARN = 200;
|
|
16
|
-
var CORNERSTONE_MIN_WORDS =
|
|
16
|
+
var CORNERSTONE_MIN_WORDS = 600;
|
|
17
17
|
var THIN_AGING_MIN_WORDS = 500;
|
|
18
18
|
var KEYWORD_DENSITY_MAX = 3;
|
|
19
19
|
var KEYWORD_DENSITY_WARN = 2.5;
|
|
@@ -1226,6 +1226,44 @@ function buildSeoInputFromDoc(doc, collection, options) {
|
|
|
1226
1226
|
const meta = doc.meta || {};
|
|
1227
1227
|
const hero = doc.hero || {};
|
|
1228
1228
|
const isPost = collection === "posts";
|
|
1229
|
+
const canonicalUrl = typeof meta.canonicalUrl === "string" && meta.canonicalUrl || typeof doc.canonicalUrl === "string" && doc.canonicalUrl || void 0;
|
|
1230
|
+
let robotsMeta;
|
|
1231
|
+
const rawRobots = typeof meta.robots === "string" && meta.robots || typeof doc.robots === "string" && doc.robots || "";
|
|
1232
|
+
if (rawRobots) {
|
|
1233
|
+
robotsMeta = rawRobots;
|
|
1234
|
+
} else {
|
|
1235
|
+
const directives = [];
|
|
1236
|
+
if (doc.noindex === true || meta.noindex === true) directives.push("noindex");
|
|
1237
|
+
if (doc.nofollow === true || meta.nofollow === true) directives.push("nofollow");
|
|
1238
|
+
if (directives.length > 0) robotsMeta = directives.join(", ");
|
|
1239
|
+
}
|
|
1240
|
+
let author;
|
|
1241
|
+
let authorUrl;
|
|
1242
|
+
const populatedAuthors = doc.populatedAuthors;
|
|
1243
|
+
if (Array.isArray(populatedAuthors) && populatedAuthors.length > 0) {
|
|
1244
|
+
const a = populatedAuthors[0] || {};
|
|
1245
|
+
author = typeof a.name === "string" && a.name || typeof a.firstName === "string" && a.firstName || void 0;
|
|
1246
|
+
if (typeof a.url === "string") authorUrl = a.url;
|
|
1247
|
+
} else if (Array.isArray(doc.authors) && doc.authors.length > 0) {
|
|
1248
|
+
const a = doc.authors[0];
|
|
1249
|
+
if (a && typeof a === "object" && typeof a.name === "string") author = a.name;
|
|
1250
|
+
} else if (typeof doc.author === "string" && doc.author) {
|
|
1251
|
+
author = doc.author;
|
|
1252
|
+
} else if (doc.author && typeof doc.author === "object" && typeof doc.author.name === "string") {
|
|
1253
|
+
author = doc.author.name;
|
|
1254
|
+
}
|
|
1255
|
+
if (!authorUrl && typeof doc.authorUrl === "string") authorUrl = doc.authorUrl;
|
|
1256
|
+
const publishedAt = typeof doc.publishedAt === "string" && doc.publishedAt || typeof doc.createdAt === "string" && doc.createdAt || void 0;
|
|
1257
|
+
const displayedDate = typeof doc.publishedAt === "string" && doc.publishedAt || typeof doc.date === "string" && doc.date || void 0;
|
|
1258
|
+
let localeAlternates;
|
|
1259
|
+
const rawAlts = doc.localeAlternates || doc.alternates || doc.hreflang;
|
|
1260
|
+
if (Array.isArray(rawAlts)) {
|
|
1261
|
+
const mapped = rawAlts.filter((a) => !!a && typeof a === "object").map((a) => ({
|
|
1262
|
+
hreflang: String(a.hreflang || a.locale || a.lang || ""),
|
|
1263
|
+
href: String(a.href || a.url || "")
|
|
1264
|
+
})).filter((a) => a.hreflang && a.href);
|
|
1265
|
+
if (mapped.length > 0) localeAlternates = mapped;
|
|
1266
|
+
}
|
|
1229
1267
|
return {
|
|
1230
1268
|
metaTitle: meta.title || "",
|
|
1231
1269
|
metaDescription: meta.description || "",
|
|
@@ -1245,7 +1283,14 @@ function buildSeoInputFromDoc(doc, collection, options) {
|
|
|
1245
1283
|
isPost,
|
|
1246
1284
|
isCornerstone: !!doc.isCornerstone,
|
|
1247
1285
|
updatedAt: doc.updatedAt || void 0,
|
|
1286
|
+
publishedAt,
|
|
1248
1287
|
contentLastReviewed: doc.contentLastReviewed || void 0,
|
|
1288
|
+
displayedDate,
|
|
1289
|
+
author,
|
|
1290
|
+
authorUrl,
|
|
1291
|
+
localeAlternates,
|
|
1292
|
+
canonicalUrl,
|
|
1293
|
+
robotsMeta,
|
|
1249
1294
|
isGlobal: options?.isGlobal ?? false
|
|
1250
1295
|
};
|
|
1251
1296
|
}
|
|
@@ -1585,7 +1630,10 @@ function analyzeDoc(doc, collection, seoConfig) {
|
|
|
1585
1630
|
if (seoInput.isGlobal) {
|
|
1586
1631
|
seoInput.slug = "";
|
|
1587
1632
|
}
|
|
1588
|
-
const analysis = analyzeSeo(seoInput,
|
|
1633
|
+
const analysis = analyzeSeo(seoInput, {
|
|
1634
|
+
...seoConfig,
|
|
1635
|
+
disabledRules: [...seoConfig?.disabledRules ?? [], "geo", "eeat", "hreflang"]
|
|
1636
|
+
});
|
|
1589
1637
|
const extracted = extractDocContent(doc);
|
|
1590
1638
|
const fullText = extracted.text;
|
|
1591
1639
|
const allLinks = extracted.links;
|
|
@@ -1623,6 +1671,7 @@ function analyzeDoc(doc, collection, seoConfig) {
|
|
|
1623
1671
|
hasH1: h1Count > 0,
|
|
1624
1672
|
h1Count,
|
|
1625
1673
|
score: analysis.score,
|
|
1674
|
+
aiReadiness: analysis.aiReadiness ? analysis.aiReadiness.score : null,
|
|
1626
1675
|
level: analysis.level,
|
|
1627
1676
|
status: doc._status || "published",
|
|
1628
1677
|
updatedAt: doc.updatedAt || "",
|
|
@@ -1645,28 +1694,48 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1645
1694
|
let cached = noCache ? null : seoCache.get(CACHE_KEY);
|
|
1646
1695
|
if (!cached) {
|
|
1647
1696
|
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(req.payload, seoConfig);
|
|
1697
|
+
const BATCH_SIZE = Math.min(100, Math.max(1, parseInt(process.env.SEO_AUDIT_BATCH_SIZE || "25", 10) || 25));
|
|
1698
|
+
const MAX_DOCS2 = Math.max(1, parseInt(process.env.SEO_AUDIT_MAX_DOCS || "1500", 10) || 1500);
|
|
1648
1699
|
const allResults = [];
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1700
|
+
let capped2 = false;
|
|
1701
|
+
collectionsLoop:
|
|
1702
|
+
for (const collectionSlug of collections) {
|
|
1703
|
+
try {
|
|
1704
|
+
let page2 = 1;
|
|
1705
|
+
let hasMore = true;
|
|
1706
|
+
while (hasMore) {
|
|
1707
|
+
const result = await req.payload.find({
|
|
1708
|
+
collection: collectionSlug,
|
|
1709
|
+
limit: BATCH_SIZE,
|
|
1710
|
+
page: page2,
|
|
1711
|
+
depth: 1,
|
|
1712
|
+
overrideAccess: true
|
|
1713
|
+
});
|
|
1714
|
+
for (const doc of result.docs) {
|
|
1715
|
+
if (ignoredSlugs.includes(doc.slug)) continue;
|
|
1716
|
+
if (allResults.length >= MAX_DOCS2) {
|
|
1717
|
+
capped2 = true;
|
|
1718
|
+
break collectionsLoop;
|
|
1719
|
+
}
|
|
1720
|
+
try {
|
|
1721
|
+
allResults.push(analyzeDoc(doc, collectionSlug, mergedConfig));
|
|
1722
|
+
} catch (e) {
|
|
1723
|
+
req.payload.logger.warn(
|
|
1724
|
+
`[seo] audit: skipped ${collectionSlug}/${doc.id}: ${e instanceof Error ? e.message : "error"}`
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
hasMore = result.hasNextPage;
|
|
1729
|
+
page2++;
|
|
1730
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1664
1731
|
}
|
|
1665
|
-
|
|
1666
|
-
page2++;
|
|
1732
|
+
} catch {
|
|
1667
1733
|
}
|
|
1668
|
-
} catch {
|
|
1669
1734
|
}
|
|
1735
|
+
if (capped2) {
|
|
1736
|
+
req.payload.logger.warn(
|
|
1737
|
+
`[seo] audit: capped at ${MAX_DOCS2} docs (SEO_AUDIT_MAX_DOCS). Lower SEO_AUDIT_BATCH_SIZE on low-memory hosts, or raise the cap.`
|
|
1738
|
+
);
|
|
1670
1739
|
}
|
|
1671
1740
|
for (const globalSlug of globals) {
|
|
1672
1741
|
try {
|
|
@@ -1729,10 +1798,10 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1729
1798
|
avgWordCount: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.wordCount, 0) / totalDocs2) : 0,
|
|
1730
1799
|
avgReadability: totalDocs2 > 0 ? Math.round(enrichedResults2.reduce((s, r) => s + r.readabilityScore, 0) / totalDocs2) : 0
|
|
1731
1800
|
};
|
|
1732
|
-
cached = { enrichedResults: enrichedResults2, stats: stats2 };
|
|
1801
|
+
cached = { enrichedResults: enrichedResults2, stats: stats2, capped: capped2 };
|
|
1733
1802
|
seoCache.set(CACHE_KEY, cached);
|
|
1734
1803
|
}
|
|
1735
|
-
const { enrichedResults, stats } = cached;
|
|
1804
|
+
const { enrichedResults, stats, capped } = cached;
|
|
1736
1805
|
const totalDocs = enrichedResults.length;
|
|
1737
1806
|
const totalPages = Math.ceil(totalDocs / limit);
|
|
1738
1807
|
const startIdx = (page - 1) * limit;
|
|
@@ -1748,7 +1817,8 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1748
1817
|
hasNextPage: page < totalPages,
|
|
1749
1818
|
hasPrevPage: page > 1
|
|
1750
1819
|
},
|
|
1751
|
-
cached: !noCache && seoCache.get(CACHE_KEY) !== null
|
|
1820
|
+
cached: !noCache && seoCache.get(CACHE_KEY) !== null,
|
|
1821
|
+
capped
|
|
1752
1822
|
}, { headers: { "Cache-Control": "no-store" } });
|
|
1753
1823
|
} catch (error) {
|
|
1754
1824
|
const message = error instanceof Error ? error.message : "Internal server error";
|
|
@@ -1758,6 +1828,97 @@ function createAuditHandler(collections, seoConfig, globals = []) {
|
|
|
1758
1828
|
};
|
|
1759
1829
|
}
|
|
1760
1830
|
|
|
1831
|
+
// src/endpoints/indexationAudit.ts
|
|
1832
|
+
var INDEXATION_CHECK_IDS = /* @__PURE__ */ new Set([
|
|
1833
|
+
"robots-noindex",
|
|
1834
|
+
"robots-nofollow",
|
|
1835
|
+
"canonical-cross",
|
|
1836
|
+
"canonical-external",
|
|
1837
|
+
"canonical-invalid",
|
|
1838
|
+
"canonical-missing"
|
|
1839
|
+
]);
|
|
1840
|
+
function createIndexationAuditHandler(collections, seoConfig, globals = []) {
|
|
1841
|
+
return async (req) => {
|
|
1842
|
+
try {
|
|
1843
|
+
if (!req.user) {
|
|
1844
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1845
|
+
}
|
|
1846
|
+
const { config: mergedConfig, ignoredSlugs } = await loadMergedConfig(req.payload, seoConfig);
|
|
1847
|
+
const entries = [];
|
|
1848
|
+
const inspect = (doc, collection, isGlobal = false) => {
|
|
1849
|
+
const input = buildSeoInputFromDoc(doc, collection, { isGlobal });
|
|
1850
|
+
const analysis = analyzeSeo(input, mergedConfig);
|
|
1851
|
+
const issues = analysis.checks.filter(
|
|
1852
|
+
(c) => c.group === "technical" && INDEXATION_CHECK_IDS.has(c.id) && c.status !== "pass"
|
|
1853
|
+
).map((c) => ({ id: c.id, status: c.status, message: c.message }));
|
|
1854
|
+
const noindex = (input.robotsMeta || "").toLowerCase().includes("noindex");
|
|
1855
|
+
if (issues.length > 0 || noindex) {
|
|
1856
|
+
entries.push({
|
|
1857
|
+
collection,
|
|
1858
|
+
id: doc.id ?? (isGlobal ? collection : ""),
|
|
1859
|
+
slug: doc.slug || "",
|
|
1860
|
+
title: doc.title || doc.slug || String(doc.id ?? collection),
|
|
1861
|
+
noindex,
|
|
1862
|
+
issues
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
for (const collectionSlug of collections) {
|
|
1867
|
+
try {
|
|
1868
|
+
let page = 1;
|
|
1869
|
+
let hasMore = true;
|
|
1870
|
+
while (hasMore) {
|
|
1871
|
+
const result = await req.payload.find({
|
|
1872
|
+
collection: collectionSlug,
|
|
1873
|
+
limit: 100,
|
|
1874
|
+
page,
|
|
1875
|
+
depth: 1,
|
|
1876
|
+
overrideAccess: true
|
|
1877
|
+
});
|
|
1878
|
+
for (const doc of result.docs) {
|
|
1879
|
+
if (ignoredSlugs.includes(doc.slug)) continue;
|
|
1880
|
+
inspect(doc, collectionSlug);
|
|
1881
|
+
}
|
|
1882
|
+
hasMore = result.hasNextPage;
|
|
1883
|
+
page++;
|
|
1884
|
+
}
|
|
1885
|
+
} catch {
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
for (const globalSlug of globals) {
|
|
1889
|
+
try {
|
|
1890
|
+
const doc = await req.payload.findGlobal({
|
|
1891
|
+
slug: globalSlug,
|
|
1892
|
+
depth: 1,
|
|
1893
|
+
overrideAccess: true
|
|
1894
|
+
});
|
|
1895
|
+
if (doc) inspect(doc, `global:${globalSlug}`, true);
|
|
1896
|
+
} catch {
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
const noindexCount = entries.filter((e) => e.noindex).length;
|
|
1900
|
+
const canonicalIssueCount = entries.filter(
|
|
1901
|
+
(e) => e.issues.some((i) => i.id.startsWith("canonical"))
|
|
1902
|
+
).length;
|
|
1903
|
+
return Response.json(
|
|
1904
|
+
{
|
|
1905
|
+
entries,
|
|
1906
|
+
summary: {
|
|
1907
|
+
totalFlagged: entries.length,
|
|
1908
|
+
noindexCount,
|
|
1909
|
+
canonicalIssueCount
|
|
1910
|
+
}
|
|
1911
|
+
},
|
|
1912
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
1913
|
+
);
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
1916
|
+
req.payload.logger.error(`[seo] indexation-audit error: ${message}`);
|
|
1917
|
+
return Response.json({ error: message }, { status: 500 });
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1761
1922
|
// src/endpoints/history.ts
|
|
1762
1923
|
var TREND_THRESHOLD = 3;
|
|
1763
1924
|
function createHistoryHandler() {
|
|
@@ -2744,6 +2905,9 @@ function createAiGenerateHandler() {
|
|
|
2744
2905
|
}
|
|
2745
2906
|
|
|
2746
2907
|
// src/endpoints/cannibalization.ts
|
|
2908
|
+
function canonicalIntent(keyword) {
|
|
2909
|
+
return keyword.toLowerCase().normalize("NFD").replace(/\p{Diacritic}/gu, "").replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean).sort().join(" ");
|
|
2910
|
+
}
|
|
2747
2911
|
function createCannibalizationHandler(collections, globals = []) {
|
|
2748
2912
|
return async (req) => {
|
|
2749
2913
|
try {
|
|
@@ -2773,26 +2937,28 @@ function createCannibalizationHandler(collections, globals = []) {
|
|
|
2773
2937
|
collection: collectionLabel,
|
|
2774
2938
|
score: 0
|
|
2775
2939
|
};
|
|
2776
|
-
const
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2940
|
+
const seenCanon = /* @__PURE__ */ new Set();
|
|
2941
|
+
const docKeywords = [];
|
|
2942
|
+
const addKeyword = (raw) => {
|
|
2943
|
+
if (typeof raw !== "string") return;
|
|
2944
|
+
const display = raw.trim();
|
|
2945
|
+
if (!display) return;
|
|
2946
|
+
const key = canonicalIntent(display);
|
|
2947
|
+
if (!key || seenCanon.has(key)) return;
|
|
2948
|
+
seenCanon.add(key);
|
|
2949
|
+
docKeywords.push({ display, key });
|
|
2950
|
+
};
|
|
2951
|
+
addKeyword(d.focusKeyword);
|
|
2780
2952
|
if (Array.isArray(d.focusKeywords)) {
|
|
2781
2953
|
for (const kw of d.focusKeywords) {
|
|
2782
|
-
|
|
2783
|
-
if (keyword && typeof keyword === "string" && keyword.trim()) {
|
|
2784
|
-
const normalized = keyword.trim().toLowerCase();
|
|
2785
|
-
if (!keywords.includes(normalized)) {
|
|
2786
|
-
keywords.push(normalized);
|
|
2787
|
-
}
|
|
2788
|
-
}
|
|
2954
|
+
addKeyword(typeof kw === "string" ? kw : kw?.keyword);
|
|
2789
2955
|
}
|
|
2790
2956
|
}
|
|
2791
|
-
for (const
|
|
2792
|
-
if (!keywordMap.has(
|
|
2793
|
-
keywordMap.set(
|
|
2957
|
+
for (const { display, key } of docKeywords) {
|
|
2958
|
+
if (!keywordMap.has(key)) {
|
|
2959
|
+
keywordMap.set(key, { display, pages: [] });
|
|
2794
2960
|
}
|
|
2795
|
-
keywordMap.get(
|
|
2961
|
+
keywordMap.get(key).pages.push(pageEntry);
|
|
2796
2962
|
}
|
|
2797
2963
|
}
|
|
2798
2964
|
const scoreMap = /* @__PURE__ */ new Map();
|
|
@@ -2818,13 +2984,13 @@ function createCannibalizationHandler(collections, globals = []) {
|
|
|
2818
2984
|
const conflicts = [];
|
|
2819
2985
|
let totalAffectedPages = 0;
|
|
2820
2986
|
const affectedPageIds = /* @__PURE__ */ new Set();
|
|
2821
|
-
for (const
|
|
2987
|
+
for (const { display, pages } of keywordMap.values()) {
|
|
2822
2988
|
if (pages.length < 2) continue;
|
|
2823
2989
|
const enrichedPages = pages.map((p) => ({
|
|
2824
2990
|
...p,
|
|
2825
2991
|
score: scoreMap.get(`${p.collection}::${p.id}`) || 0
|
|
2826
2992
|
}));
|
|
2827
|
-
conflicts.push({ keyword, pages: enrichedPages });
|
|
2993
|
+
conflicts.push({ keyword: display, pages: enrichedPages });
|
|
2828
2994
|
for (const p of enrichedPages) {
|
|
2829
2995
|
const pageKey = `${p.collection}::${p.id}`;
|
|
2830
2996
|
if (!affectedPageIds.has(pageKey)) {
|
|
@@ -3598,6 +3764,420 @@ function createPerformanceHandler() {
|
|
|
3598
3764
|
};
|
|
3599
3765
|
}
|
|
3600
3766
|
|
|
3767
|
+
// src/endpoints/coreWebVitals.ts
|
|
3768
|
+
var CWV_THRESHOLDS = {
|
|
3769
|
+
lcp: { good: 2500, poor: 4e3 },
|
|
3770
|
+
// ms
|
|
3771
|
+
inp: { good: 200, poor: 500 },
|
|
3772
|
+
// ms
|
|
3773
|
+
cls: { good: 0.1, poor: 0.25 }
|
|
3774
|
+
// unitless
|
|
3775
|
+
};
|
|
3776
|
+
function rate(value, t) {
|
|
3777
|
+
if (value === null || Number.isNaN(value)) return "unknown";
|
|
3778
|
+
if (value <= t.good) return "good";
|
|
3779
|
+
if (value <= t.poor) return "needs-improvement";
|
|
3780
|
+
return "poor";
|
|
3781
|
+
}
|
|
3782
|
+
function resolveSiteUrl(seoConfig) {
|
|
3783
|
+
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
3784
|
+
}
|
|
3785
|
+
function createCoreWebVitalsHandler(seoConfig) {
|
|
3786
|
+
return async (req) => {
|
|
3787
|
+
try {
|
|
3788
|
+
if (!req.user) {
|
|
3789
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
3790
|
+
}
|
|
3791
|
+
const reqUrl = new URL(req.url);
|
|
3792
|
+
const target = reqUrl.searchParams.get("url");
|
|
3793
|
+
const strategy = reqUrl.searchParams.get("strategy") === "desktop" ? "desktop" : "mobile";
|
|
3794
|
+
if (!target) {
|
|
3795
|
+
return Response.json({ error: "Missing required query param: url" }, { status: 400 });
|
|
3796
|
+
}
|
|
3797
|
+
const siteUrl = resolveSiteUrl(seoConfig);
|
|
3798
|
+
if (!siteUrl) {
|
|
3799
|
+
return Response.json(
|
|
3800
|
+
{ error: "siteUrl is not configured \u2014 set it in the plugin config or NEXT_PUBLIC_SERVER_URL." },
|
|
3801
|
+
{ status: 400 }
|
|
3802
|
+
);
|
|
3803
|
+
}
|
|
3804
|
+
let targetOrigin;
|
|
3805
|
+
let siteOrigin;
|
|
3806
|
+
try {
|
|
3807
|
+
targetOrigin = new URL(target).origin;
|
|
3808
|
+
siteOrigin = new URL(siteUrl).origin;
|
|
3809
|
+
} catch {
|
|
3810
|
+
return Response.json({ error: "Invalid url." }, { status: 400 });
|
|
3811
|
+
}
|
|
3812
|
+
if (targetOrigin !== siteOrigin) {
|
|
3813
|
+
return Response.json(
|
|
3814
|
+
{ error: "Only the configured site origin can be tested." },
|
|
3815
|
+
{ status: 403 }
|
|
3816
|
+
);
|
|
3817
|
+
}
|
|
3818
|
+
const apiKey = process.env.PAGESPEED_API_KEY || process.env.GOOGLE_PAGESPEED_API_KEY || "";
|
|
3819
|
+
const psi = new URL("https://www.googleapis.com/pagespeedonline/v5/runPagespeed");
|
|
3820
|
+
psi.searchParams.set("url", target);
|
|
3821
|
+
psi.searchParams.set("category", "performance");
|
|
3822
|
+
psi.searchParams.set("strategy", strategy);
|
|
3823
|
+
if (apiKey) psi.searchParams.set("key", apiKey);
|
|
3824
|
+
const controller = new AbortController();
|
|
3825
|
+
const timeout = setTimeout(() => controller.abort(), 3e4);
|
|
3826
|
+
let psiData;
|
|
3827
|
+
try {
|
|
3828
|
+
const resp = await fetch(psi.toString(), { signal: controller.signal });
|
|
3829
|
+
if (!resp.ok) {
|
|
3830
|
+
const detail = resp.status === 429 ? " (PSI quota exceeded \u2014 add a PAGESPEED_API_KEY)" : "";
|
|
3831
|
+
return Response.json(
|
|
3832
|
+
{ error: `PageSpeed Insights request failed: ${resp.status}${detail}` },
|
|
3833
|
+
{ status: 502 }
|
|
3834
|
+
);
|
|
3835
|
+
}
|
|
3836
|
+
psiData = await resp.json();
|
|
3837
|
+
} finally {
|
|
3838
|
+
clearTimeout(timeout);
|
|
3839
|
+
}
|
|
3840
|
+
const loadingExperience = psiData.loadingExperience || {};
|
|
3841
|
+
const metrics = loadingExperience.metrics || {};
|
|
3842
|
+
const fieldMetric = (key) => {
|
|
3843
|
+
const m = metrics[key];
|
|
3844
|
+
const p = m?.percentile;
|
|
3845
|
+
return typeof p === "number" ? p : null;
|
|
3846
|
+
};
|
|
3847
|
+
const fieldLcp = fieldMetric("LARGEST_CONTENTFUL_PAINT_MS");
|
|
3848
|
+
const fieldInp = fieldMetric("INTERACTION_TO_NEXT_PAINT");
|
|
3849
|
+
const fieldClsRaw = fieldMetric("CUMULATIVE_LAYOUT_SHIFT_SCORE");
|
|
3850
|
+
const fieldCls = fieldClsRaw !== null ? fieldClsRaw / 100 : null;
|
|
3851
|
+
const hasFieldData = fieldLcp !== null || fieldInp !== null || fieldCls !== null;
|
|
3852
|
+
const lighthouse = psiData.lighthouseResult || {};
|
|
3853
|
+
const audits = lighthouse.audits || {};
|
|
3854
|
+
const labNumeric = (id) => {
|
|
3855
|
+
const v = audits[id]?.numericValue;
|
|
3856
|
+
return typeof v === "number" ? v : null;
|
|
3857
|
+
};
|
|
3858
|
+
const labLcp = labNumeric("largest-contentful-paint");
|
|
3859
|
+
const labCls = labNumeric("cumulative-layout-shift");
|
|
3860
|
+
const labTbt = labNumeric("total-blocking-time");
|
|
3861
|
+
const lcp = fieldLcp ?? labLcp;
|
|
3862
|
+
const inp = fieldInp;
|
|
3863
|
+
const cls = fieldCls ?? labCls;
|
|
3864
|
+
return Response.json(
|
|
3865
|
+
{
|
|
3866
|
+
url: target,
|
|
3867
|
+
strategy,
|
|
3868
|
+
source: hasFieldData ? "field" : "lab",
|
|
3869
|
+
hasFieldData,
|
|
3870
|
+
metrics: {
|
|
3871
|
+
lcp: { value: lcp, unit: "ms", rating: rate(lcp, CWV_THRESHOLDS.lcp) },
|
|
3872
|
+
inp: {
|
|
3873
|
+
value: inp,
|
|
3874
|
+
unit: "ms",
|
|
3875
|
+
rating: rate(inp, CWV_THRESHOLDS.inp),
|
|
3876
|
+
note: inp === null ? "INP needs real-user (field) data; not available in lab." : void 0
|
|
3877
|
+
},
|
|
3878
|
+
cls: { value: cls, unit: "score", rating: rate(cls, CWV_THRESHOLDS.cls) },
|
|
3879
|
+
tbt: { value: labTbt, unit: "ms", source: "lab" }
|
|
3880
|
+
},
|
|
3881
|
+
thresholds: CWV_THRESHOLDS,
|
|
3882
|
+
keyConfigured: !!apiKey,
|
|
3883
|
+
note: "Informational only \u2014 Core Web Vitals are a ranking tie-breaker, not part of the on-page SEO score."
|
|
3884
|
+
},
|
|
3885
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3886
|
+
);
|
|
3887
|
+
} catch (error) {
|
|
3888
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3889
|
+
req.payload.logger.error(`[seo] core-web-vitals error: ${message}`);
|
|
3890
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3891
|
+
}
|
|
3892
|
+
};
|
|
3893
|
+
}
|
|
3894
|
+
var ALGO = "aes-256-gcm";
|
|
3895
|
+
var KEY_NAMESPACE = "seo-analyzer:gsc:v1";
|
|
3896
|
+
var FORMAT_VERSION = "v1";
|
|
3897
|
+
function deriveKey(secret) {
|
|
3898
|
+
const explicit = process.env.SEO_GSC_ENCRYPTION_KEY;
|
|
3899
|
+
if (explicit) {
|
|
3900
|
+
const buf = explicit.length === 64 ? Buffer.from(explicit, "hex") : Buffer.from(explicit, "base64");
|
|
3901
|
+
if (buf.length === 32) return buf;
|
|
3902
|
+
throw new Error("SEO_GSC_ENCRYPTION_KEY must decode to exactly 32 bytes (hex64 or base64).");
|
|
3903
|
+
}
|
|
3904
|
+
if (!secret) {
|
|
3905
|
+
throw new Error("No encryption secret available (set SEO_GSC_ENCRYPTION_KEY or Payload secret).");
|
|
3906
|
+
}
|
|
3907
|
+
return scryptSync(secret, KEY_NAMESPACE, 32);
|
|
3908
|
+
}
|
|
3909
|
+
function encryptToken(plaintext, secret) {
|
|
3910
|
+
const key = deriveKey(secret);
|
|
3911
|
+
const iv = randomBytes(12);
|
|
3912
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
3913
|
+
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
3914
|
+
const tag = cipher.getAuthTag();
|
|
3915
|
+
return [FORMAT_VERSION, iv.toString("base64"), tag.toString("base64"), enc.toString("base64")].join(":");
|
|
3916
|
+
}
|
|
3917
|
+
function decryptToken(payload, secret) {
|
|
3918
|
+
const parts = payload.split(":");
|
|
3919
|
+
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) {
|
|
3920
|
+
throw new Error("Invalid encrypted token format.");
|
|
3921
|
+
}
|
|
3922
|
+
const key = deriveKey(secret);
|
|
3923
|
+
const iv = Buffer.from(parts[1], "base64");
|
|
3924
|
+
const tag = Buffer.from(parts[2], "base64");
|
|
3925
|
+
const enc = Buffer.from(parts[3], "base64");
|
|
3926
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
3927
|
+
decipher.setAuthTag(tag);
|
|
3928
|
+
const dec = Buffer.concat([decipher.update(enc), decipher.final()]);
|
|
3929
|
+
return dec.toString("utf8");
|
|
3930
|
+
}
|
|
3931
|
+
function safeEqual(a, b) {
|
|
3932
|
+
const ba = Buffer.from(a);
|
|
3933
|
+
const bb = Buffer.from(b);
|
|
3934
|
+
if (ba.length !== bb.length) return false;
|
|
3935
|
+
return timingSafeEqual(ba, bb);
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
// src/endpoints/gscOAuth.ts
|
|
3939
|
+
var AUTH_COLLECTION = "seo-gsc-auth";
|
|
3940
|
+
var SCOPES = "https://www.googleapis.com/auth/webmasters.readonly openid email";
|
|
3941
|
+
function isAdmin5(user) {
|
|
3942
|
+
if (!user) return false;
|
|
3943
|
+
if (user.role === "admin") return true;
|
|
3944
|
+
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
3945
|
+
return false;
|
|
3946
|
+
}
|
|
3947
|
+
function resolveSiteUrl2(seoConfig) {
|
|
3948
|
+
return (seoConfig?.siteUrl || process.env.NEXT_PUBLIC_SERVER_URL || process.env.PAYLOAD_PUBLIC_SERVER_URL || void 0)?.replace(/\/$/, "");
|
|
3949
|
+
}
|
|
3950
|
+
function getOAuthConfig(basePath, seoConfig) {
|
|
3951
|
+
const clientId = process.env.GSC_OAUTH_CLIENT_ID || "";
|
|
3952
|
+
const clientSecret = process.env.GSC_OAUTH_CLIENT_SECRET || "";
|
|
3953
|
+
const siteUrl = resolveSiteUrl2(seoConfig);
|
|
3954
|
+
if (!clientId || !clientSecret || !siteUrl) return null;
|
|
3955
|
+
return { clientId, clientSecret, siteUrl, redirectUri: `${siteUrl}/api${basePath}/gsc/callback` };
|
|
3956
|
+
}
|
|
3957
|
+
async function getOrCreateAuthDoc(payload) {
|
|
3958
|
+
const found = await payload.find({ collection: AUTH_COLLECTION, limit: 1, overrideAccess: true });
|
|
3959
|
+
if (found.docs.length > 0) return found.docs[0];
|
|
3960
|
+
return payload.create({ collection: AUTH_COLLECTION, data: {}, overrideAccess: true });
|
|
3961
|
+
}
|
|
3962
|
+
async function tokenRequest(cfg, body) {
|
|
3963
|
+
const resp = await fetch("https://oauth2.googleapis.com/token", {
|
|
3964
|
+
method: "POST",
|
|
3965
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3966
|
+
body: new URLSearchParams({
|
|
3967
|
+
client_id: cfg.clientId,
|
|
3968
|
+
client_secret: cfg.clientSecret,
|
|
3969
|
+
...body
|
|
3970
|
+
}).toString()
|
|
3971
|
+
});
|
|
3972
|
+
const json = await resp.json();
|
|
3973
|
+
if (!resp.ok) {
|
|
3974
|
+
throw new Error(`Token endpoint error: ${resp.status} ${json.error || ""}`);
|
|
3975
|
+
}
|
|
3976
|
+
return json;
|
|
3977
|
+
}
|
|
3978
|
+
function createGscStatusHandler(basePath, seoConfig) {
|
|
3979
|
+
return async (req) => {
|
|
3980
|
+
try {
|
|
3981
|
+
if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
3982
|
+
const cfg = getOAuthConfig(basePath, seoConfig);
|
|
3983
|
+
const doc = await getOrCreateAuthDoc(req.payload);
|
|
3984
|
+
return Response.json(
|
|
3985
|
+
{
|
|
3986
|
+
configured: !!cfg,
|
|
3987
|
+
connected: !!doc.refreshTokenEnc,
|
|
3988
|
+
connectedEmail: doc.connectedEmail || null,
|
|
3989
|
+
connectedAt: doc.connectedAt || null,
|
|
3990
|
+
propertyUrl: doc.propertyUrl || cfg?.siteUrl || null,
|
|
3991
|
+
redirectUri: cfg?.redirectUri || null
|
|
3992
|
+
},
|
|
3993
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
3994
|
+
);
|
|
3995
|
+
} catch (error) {
|
|
3996
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
3997
|
+
req.payload.logger.error(`[seo] gsc-status error: ${message}`);
|
|
3998
|
+
return Response.json({ error: message }, { status: 500 });
|
|
3999
|
+
}
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
function createGscAuthStartHandler(basePath, seoConfig) {
|
|
4003
|
+
return async (req) => {
|
|
4004
|
+
try {
|
|
4005
|
+
if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4006
|
+
const cfg = getOAuthConfig(basePath, seoConfig);
|
|
4007
|
+
if (!cfg) {
|
|
4008
|
+
return Response.json(
|
|
4009
|
+
{ error: "GSC OAuth not configured. Set GSC_OAUTH_CLIENT_ID, GSC_OAUTH_CLIENT_SECRET and siteUrl." },
|
|
4010
|
+
{ status: 400 }
|
|
4011
|
+
);
|
|
4012
|
+
}
|
|
4013
|
+
const state = randomBytes(24).toString("hex");
|
|
4014
|
+
const doc = await getOrCreateAuthDoc(req.payload);
|
|
4015
|
+
await req.payload.update({
|
|
4016
|
+
collection: AUTH_COLLECTION,
|
|
4017
|
+
id: doc.id,
|
|
4018
|
+
data: { pendingState: state },
|
|
4019
|
+
overrideAccess: true
|
|
4020
|
+
});
|
|
4021
|
+
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
4022
|
+
authUrl.searchParams.set("client_id", cfg.clientId);
|
|
4023
|
+
authUrl.searchParams.set("redirect_uri", cfg.redirectUri);
|
|
4024
|
+
authUrl.searchParams.set("response_type", "code");
|
|
4025
|
+
authUrl.searchParams.set("scope", SCOPES);
|
|
4026
|
+
authUrl.searchParams.set("access_type", "offline");
|
|
4027
|
+
authUrl.searchParams.set("prompt", "consent");
|
|
4028
|
+
authUrl.searchParams.set("state", state);
|
|
4029
|
+
return Response.json({ authUrl: authUrl.toString() }, { headers: { "Cache-Control": "no-store" } });
|
|
4030
|
+
} catch (error) {
|
|
4031
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
4032
|
+
req.payload.logger.error(`[seo] gsc-auth error: ${message}`);
|
|
4033
|
+
return Response.json({ error: message }, { status: 500 });
|
|
4034
|
+
}
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
function createGscCallbackHandler(basePath, seoConfig) {
|
|
4038
|
+
return async (req) => {
|
|
4039
|
+
const htmlPage = (title, body) => new Response(
|
|
4040
|
+
`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family:system-ui;padding:2rem;max-width:40rem;margin:auto"><h1>${title}</h1><p>${body}</p><p><a href="/admin/performance">\u2190 Back to the SEO dashboard</a></p></body></html>`,
|
|
4041
|
+
{ status: 200, headers: { "content-type": "text/html; charset=utf-8" } }
|
|
4042
|
+
);
|
|
4043
|
+
try {
|
|
4044
|
+
if (!isAdmin5(req.user)) {
|
|
4045
|
+
return htmlPage("Connection failed", "You must be signed in as an admin to connect Google Search Console.");
|
|
4046
|
+
}
|
|
4047
|
+
const cfg = getOAuthConfig(basePath, seoConfig);
|
|
4048
|
+
if (!cfg) return htmlPage("Connection failed", "GSC OAuth is not configured on the server.");
|
|
4049
|
+
const url = new URL(req.url);
|
|
4050
|
+
const code = url.searchParams.get("code");
|
|
4051
|
+
const state = url.searchParams.get("state");
|
|
4052
|
+
const oauthError = url.searchParams.get("error");
|
|
4053
|
+
if (oauthError) return htmlPage("Connection cancelled", `Google returned: ${oauthError}`);
|
|
4054
|
+
if (!code || !state) return htmlPage("Connection failed", "Missing code or state.");
|
|
4055
|
+
const doc = await getOrCreateAuthDoc(req.payload);
|
|
4056
|
+
if (!doc.pendingState || !safeEqual(state, doc.pendingState)) {
|
|
4057
|
+
return htmlPage("Connection failed", "Invalid state (possible CSRF). Please restart the connection.");
|
|
4058
|
+
}
|
|
4059
|
+
const tokens = await tokenRequest(cfg, {
|
|
4060
|
+
code,
|
|
4061
|
+
redirect_uri: cfg.redirectUri,
|
|
4062
|
+
grant_type: "authorization_code"
|
|
4063
|
+
});
|
|
4064
|
+
const refreshToken = tokens.refresh_token;
|
|
4065
|
+
const accessToken = tokens.access_token;
|
|
4066
|
+
if (!refreshToken) {
|
|
4067
|
+
return htmlPage(
|
|
4068
|
+
"Connection failed",
|
|
4069
|
+
"Google did not return a refresh token. Revoke the app access in your Google account and try again (the consent screen must show)."
|
|
4070
|
+
);
|
|
4071
|
+
}
|
|
4072
|
+
let email = null;
|
|
4073
|
+
if (accessToken) {
|
|
4074
|
+
try {
|
|
4075
|
+
const ui = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
|
4076
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
4077
|
+
});
|
|
4078
|
+
if (ui.ok) email = (await ui.json()).email;
|
|
4079
|
+
} catch {
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
const secret = req.payload.secret || "";
|
|
4083
|
+
const refreshTokenEnc = encryptToken(refreshToken, secret);
|
|
4084
|
+
await req.payload.update({
|
|
4085
|
+
collection: AUTH_COLLECTION,
|
|
4086
|
+
id: doc.id,
|
|
4087
|
+
data: {
|
|
4088
|
+
refreshTokenEnc,
|
|
4089
|
+
pendingState: null,
|
|
4090
|
+
connectedEmail: email,
|
|
4091
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4092
|
+
scope: tokens.scope || SCOPES,
|
|
4093
|
+
propertyUrl: doc.propertyUrl || cfg.siteUrl
|
|
4094
|
+
},
|
|
4095
|
+
overrideAccess: true
|
|
4096
|
+
});
|
|
4097
|
+
return htmlPage("Google Search Console connected \u2705", "You can close this tab and return to the SEO dashboard.");
|
|
4098
|
+
} catch (error) {
|
|
4099
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
4100
|
+
req.payload.logger.error(`[seo] gsc-callback error: ${message}`);
|
|
4101
|
+
return htmlPage("Connection failed", "An unexpected error occurred. Check the server logs.");
|
|
4102
|
+
}
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
function createGscDataHandler(basePath, seoConfig) {
|
|
4106
|
+
return async (req) => {
|
|
4107
|
+
try {
|
|
4108
|
+
if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4109
|
+
const cfg = getOAuthConfig(basePath, seoConfig);
|
|
4110
|
+
if (!cfg) return Response.json({ error: "GSC OAuth not configured." }, { status: 400 });
|
|
4111
|
+
const doc = await getOrCreateAuthDoc(req.payload);
|
|
4112
|
+
if (!doc.refreshTokenEnc) {
|
|
4113
|
+
return Response.json({ error: "Not connected to Google Search Console." }, { status: 409 });
|
|
4114
|
+
}
|
|
4115
|
+
const secret = req.payload.secret || "";
|
|
4116
|
+
let refreshToken;
|
|
4117
|
+
try {
|
|
4118
|
+
refreshToken = decryptToken(doc.refreshTokenEnc, secret);
|
|
4119
|
+
} catch {
|
|
4120
|
+
return Response.json(
|
|
4121
|
+
{ error: "Stored token could not be decrypted (encryption key changed?). Reconnect GSC." },
|
|
4122
|
+
{ status: 409 }
|
|
4123
|
+
);
|
|
4124
|
+
}
|
|
4125
|
+
const tokens = await tokenRequest(cfg, { refresh_token: refreshToken, grant_type: "refresh_token" });
|
|
4126
|
+
const accessToken = tokens.access_token;
|
|
4127
|
+
if (!accessToken) return Response.json({ error: "Could not refresh access token." }, { status: 502 });
|
|
4128
|
+
const url = new URL(req.url);
|
|
4129
|
+
const today = /* @__PURE__ */ new Date();
|
|
4130
|
+
const defaultEnd = today.toISOString().slice(0, 10);
|
|
4131
|
+
const defaultStart = new Date(today.getTime() - 28 * 864e5).toISOString().slice(0, 10);
|
|
4132
|
+
const startDate = url.searchParams.get("startDate") || defaultStart;
|
|
4133
|
+
const endDate = url.searchParams.get("endDate") || defaultEnd;
|
|
4134
|
+
const dimension = url.searchParams.get("dimension") === "page" ? "page" : "query";
|
|
4135
|
+
const rowLimit = Math.min(1e3, Math.max(1, parseInt(url.searchParams.get("rowLimit") || "100", 10)));
|
|
4136
|
+
const property = doc.propertyUrl || cfg.siteUrl;
|
|
4137
|
+
const gscResp = await fetch(
|
|
4138
|
+
`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
|
|
4139
|
+
{
|
|
4140
|
+
method: "POST",
|
|
4141
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
4142
|
+
body: JSON.stringify({ startDate, endDate, dimensions: [dimension], rowLimit })
|
|
4143
|
+
}
|
|
4144
|
+
);
|
|
4145
|
+
const gscJson = await gscResp.json();
|
|
4146
|
+
if (!gscResp.ok) {
|
|
4147
|
+
const err = gscJson.error?.message || gscResp.status;
|
|
4148
|
+
return Response.json({ error: `GSC query failed: ${err}` }, { status: 502 });
|
|
4149
|
+
}
|
|
4150
|
+
return Response.json(
|
|
4151
|
+
{ property, startDate, endDate, dimension, rows: gscJson.rows || [] },
|
|
4152
|
+
{ headers: { "Cache-Control": "no-store" } }
|
|
4153
|
+
);
|
|
4154
|
+
} catch (error) {
|
|
4155
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
4156
|
+
req.payload.logger.error(`[seo] gsc-data error: ${message}`);
|
|
4157
|
+
return Response.json({ error: message }, { status: 500 });
|
|
4158
|
+
}
|
|
4159
|
+
};
|
|
4160
|
+
}
|
|
4161
|
+
function createGscDisconnectHandler() {
|
|
4162
|
+
return async (req) => {
|
|
4163
|
+
try {
|
|
4164
|
+
if (!isAdmin5(req.user)) return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
4165
|
+
const doc = await getOrCreateAuthDoc(req.payload);
|
|
4166
|
+
await req.payload.update({
|
|
4167
|
+
collection: AUTH_COLLECTION,
|
|
4168
|
+
id: doc.id,
|
|
4169
|
+
data: { refreshTokenEnc: null, pendingState: null, connectedEmail: null, connectedAt: null, scope: null },
|
|
4170
|
+
overrideAccess: true
|
|
4171
|
+
});
|
|
4172
|
+
return Response.json({ disconnected: true }, { headers: { "Cache-Control": "no-store" } });
|
|
4173
|
+
} catch (error) {
|
|
4174
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
4175
|
+
req.payload.logger.error(`[seo] gsc-disconnect error: ${message}`);
|
|
4176
|
+
return Response.json({ error: message }, { status: 500 });
|
|
4177
|
+
}
|
|
4178
|
+
};
|
|
4179
|
+
}
|
|
4180
|
+
|
|
3601
4181
|
// src/endpoints/keywordResearch.ts
|
|
3602
4182
|
var STOP_WORDS_SET = /* @__PURE__ */ new Set();
|
|
3603
4183
|
function getStopWords2() {
|
|
@@ -4134,12 +4714,12 @@ function detectSchemaType(collection, doc) {
|
|
|
4134
4714
|
if (collection === "posts") return "Article";
|
|
4135
4715
|
const layout = doc.layout;
|
|
4136
4716
|
if (layout && Array.isArray(layout)) {
|
|
4137
|
-
const
|
|
4717
|
+
const hasFaqBlock2 = layout.some((block) => {
|
|
4138
4718
|
if (!block || typeof block !== "object") return false;
|
|
4139
4719
|
const b = block;
|
|
4140
4720
|
return b.blockType === "faq" || b.blockType === "FAQ" || b.blockType === "faqBlock";
|
|
4141
4721
|
});
|
|
4142
|
-
if (
|
|
4722
|
+
if (hasFaqBlock2) return "FAQPage";
|
|
4143
4723
|
}
|
|
4144
4724
|
if (doc.price !== void 0 || doc.sku !== void 0 || collection === "products") {
|
|
4145
4725
|
return "Product";
|
|
@@ -4290,6 +4870,75 @@ function buildOrganizationSchema(doc, siteUrl) {
|
|
|
4290
4870
|
if (doc.logo) {
|
|
4291
4871
|
schema.logo = typeof doc.logo === "string" ? doc.logo : doc.logo?.url;
|
|
4292
4872
|
}
|
|
4873
|
+
if (Array.isArray(doc.sameAs)) {
|
|
4874
|
+
const sameAs = doc.sameAs.filter((s) => typeof s === "string");
|
|
4875
|
+
if (sameAs.length > 0) schema.sameAs = sameAs;
|
|
4876
|
+
}
|
|
4877
|
+
return schema;
|
|
4878
|
+
}
|
|
4879
|
+
function buildPersonSchema(doc, siteUrl) {
|
|
4880
|
+
const meta = doc.meta || {};
|
|
4881
|
+
const schema = {
|
|
4882
|
+
"@context": "https://schema.org",
|
|
4883
|
+
"@type": "Person",
|
|
4884
|
+
name: doc.name || doc.title || meta.title || ""
|
|
4885
|
+
};
|
|
4886
|
+
if (doc.jobTitle) schema.jobTitle = doc.jobTitle;
|
|
4887
|
+
if (doc.description || meta.description) schema.description = doc.description || meta.description;
|
|
4888
|
+
schema.url = typeof doc.url === "string" && doc.url || `${siteUrl}/${doc.slug || ""}`;
|
|
4889
|
+
if (Array.isArray(doc.sameAs)) {
|
|
4890
|
+
const sameAs = doc.sameAs.filter((s) => typeof s === "string");
|
|
4891
|
+
if (sameAs.length > 0) schema.sameAs = sameAs;
|
|
4892
|
+
}
|
|
4893
|
+
return schema;
|
|
4894
|
+
}
|
|
4895
|
+
function buildEventSchema(doc, siteUrl) {
|
|
4896
|
+
const meta = doc.meta || {};
|
|
4897
|
+
const schema = {
|
|
4898
|
+
"@context": "https://schema.org",
|
|
4899
|
+
"@type": "Event",
|
|
4900
|
+
name: doc.title || meta.title || "",
|
|
4901
|
+
description: meta.description || "",
|
|
4902
|
+
startDate: doc.startDate || doc.eventStart || void 0,
|
|
4903
|
+
endDate: doc.endDate || doc.eventEnd || void 0,
|
|
4904
|
+
url: `${siteUrl}/${doc.slug || ""}`
|
|
4905
|
+
};
|
|
4906
|
+
if (doc.location) {
|
|
4907
|
+
schema.location = typeof doc.location === "string" ? { "@type": "Place", name: doc.location } : { "@type": "Place", ...doc.location };
|
|
4908
|
+
}
|
|
4909
|
+
return schema;
|
|
4910
|
+
}
|
|
4911
|
+
function buildRecipeSchema(doc, siteUrl) {
|
|
4912
|
+
const meta = doc.meta || {};
|
|
4913
|
+
const heroMedia = doc.hero?.media;
|
|
4914
|
+
const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
|
|
4915
|
+
const schema = {
|
|
4916
|
+
"@context": "https://schema.org",
|
|
4917
|
+
"@type": "Recipe",
|
|
4918
|
+
name: doc.title || meta.title || "",
|
|
4919
|
+
description: meta.description || ""
|
|
4920
|
+
};
|
|
4921
|
+
if (imageUrl) schema.image = imageUrl;
|
|
4922
|
+
if (Array.isArray(doc.recipeIngredient)) schema.recipeIngredient = doc.recipeIngredient;
|
|
4923
|
+
else if (Array.isArray(doc.ingredients)) schema.recipeIngredient = doc.ingredients;
|
|
4924
|
+
if (doc.recipeInstructions) schema.recipeInstructions = doc.recipeInstructions;
|
|
4925
|
+
else if (doc.instructions) schema.recipeInstructions = doc.instructions;
|
|
4926
|
+
return schema;
|
|
4927
|
+
}
|
|
4928
|
+
function buildVideoSchema(doc, siteUrl) {
|
|
4929
|
+
const meta = doc.meta || {};
|
|
4930
|
+
const heroMedia = doc.hero?.media;
|
|
4931
|
+
const imageUrl = getImageUrl(meta.image, heroMedia, siteUrl);
|
|
4932
|
+
const schema = {
|
|
4933
|
+
"@context": "https://schema.org",
|
|
4934
|
+
"@type": "VideoObject",
|
|
4935
|
+
name: doc.title || meta.title || "",
|
|
4936
|
+
description: meta.description || "",
|
|
4937
|
+
uploadDate: doc.uploadDate || doc.createdAt || void 0
|
|
4938
|
+
};
|
|
4939
|
+
if (imageUrl) schema.thumbnailUrl = imageUrl;
|
|
4940
|
+
if (doc.videoUrl || doc.contentUrl) schema.contentUrl = doc.videoUrl || doc.contentUrl;
|
|
4941
|
+
if (doc.duration) schema.duration = doc.duration;
|
|
4293
4942
|
return schema;
|
|
4294
4943
|
}
|
|
4295
4944
|
function getImageUrl(metaImage, heroMedia, siteUrl) {
|
|
@@ -4328,7 +4977,7 @@ function createSchemaGeneratorHandler(targetCollections) {
|
|
|
4328
4977
|
{ status: 400 }
|
|
4329
4978
|
);
|
|
4330
4979
|
}
|
|
4331
|
-
const validTypes = ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization"];
|
|
4980
|
+
const validTypes = ["Article", "LocalBusiness", "BreadcrumbList", "FAQPage", "Product", "Organization", "Person", "Event", "Recipe", "Video"];
|
|
4332
4981
|
if (typeOverrideRaw !== null && !validTypes.includes(typeOverrideRaw)) {
|
|
4333
4982
|
return Response.json(
|
|
4334
4983
|
{ error: `Invalid schema type. Valid types: ${validTypes.join(", ")}` },
|
|
@@ -4373,6 +5022,18 @@ function createSchemaGeneratorHandler(targetCollections) {
|
|
|
4373
5022
|
case "Organization":
|
|
4374
5023
|
jsonLd = buildOrganizationSchema(doc, siteUrl);
|
|
4375
5024
|
break;
|
|
5025
|
+
case "Person":
|
|
5026
|
+
jsonLd = buildPersonSchema(doc, siteUrl);
|
|
5027
|
+
break;
|
|
5028
|
+
case "Event":
|
|
5029
|
+
jsonLd = buildEventSchema(doc, siteUrl);
|
|
5030
|
+
break;
|
|
5031
|
+
case "Recipe":
|
|
5032
|
+
jsonLd = buildRecipeSchema(doc, siteUrl);
|
|
5033
|
+
break;
|
|
5034
|
+
case "Video":
|
|
5035
|
+
jsonLd = buildVideoSchema(doc, siteUrl);
|
|
5036
|
+
break;
|
|
4376
5037
|
}
|
|
4377
5038
|
const cleaned = JSON.parse(JSON.stringify(jsonLd));
|
|
4378
5039
|
return Response.json({
|
|
@@ -4694,7 +5355,7 @@ function createAiRewriteHandler(targetCollections) {
|
|
|
4694
5355
|
}
|
|
4695
5356
|
|
|
4696
5357
|
// src/endpoints/robots.ts
|
|
4697
|
-
function
|
|
5358
|
+
function isAdmin6(user) {
|
|
4698
5359
|
if (!user) return false;
|
|
4699
5360
|
if (user.role === "admin") return true;
|
|
4700
5361
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -4743,7 +5404,7 @@ function createRobotsUpdateHandler() {
|
|
|
4743
5404
|
if (!req.user) {
|
|
4744
5405
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
4745
5406
|
}
|
|
4746
|
-
if (!
|
|
5407
|
+
if (!isAdmin6(req.user)) {
|
|
4747
5408
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
4748
5409
|
}
|
|
4749
5410
|
const body = await parseJsonBody(req);
|
|
@@ -5479,6 +6140,55 @@ function createSeoLogsCollection() {
|
|
|
5479
6140
|
};
|
|
5480
6141
|
}
|
|
5481
6142
|
|
|
6143
|
+
// src/collections/SeoGscAuth.ts
|
|
6144
|
+
function createSeoGscAuthCollection() {
|
|
6145
|
+
return {
|
|
6146
|
+
slug: "seo-gsc-auth",
|
|
6147
|
+
admin: {
|
|
6148
|
+
hidden: true,
|
|
6149
|
+
custom: { navHidden: true }
|
|
6150
|
+
},
|
|
6151
|
+
access: {
|
|
6152
|
+
read: ({ req }) => !!req.user,
|
|
6153
|
+
update: ({ req }) => !!req.user,
|
|
6154
|
+
create: ({ req }) => !!req.user,
|
|
6155
|
+
delete: ({ req }) => !!req.user
|
|
6156
|
+
},
|
|
6157
|
+
fields: [
|
|
6158
|
+
{
|
|
6159
|
+
name: "refreshTokenEnc",
|
|
6160
|
+
type: "text",
|
|
6161
|
+
// The encrypted refresh-token blob must never leave the server.
|
|
6162
|
+
access: {
|
|
6163
|
+
read: () => false,
|
|
6164
|
+
create: () => false,
|
|
6165
|
+
update: () => false
|
|
6166
|
+
},
|
|
6167
|
+
admin: { hidden: true }
|
|
6168
|
+
},
|
|
6169
|
+
{
|
|
6170
|
+
name: "pendingState",
|
|
6171
|
+
type: "text",
|
|
6172
|
+
// CSRF state for the in-flight OAuth handshake — also server-only.
|
|
6173
|
+
access: {
|
|
6174
|
+
read: () => false,
|
|
6175
|
+
create: () => false,
|
|
6176
|
+
update: () => false
|
|
6177
|
+
},
|
|
6178
|
+
admin: { hidden: true }
|
|
6179
|
+
},
|
|
6180
|
+
{ name: "connectedEmail", type: "text", admin: { readOnly: true } },
|
|
6181
|
+
{ name: "connectedAt", type: "date", admin: { readOnly: true } },
|
|
6182
|
+
{
|
|
6183
|
+
name: "propertyUrl",
|
|
6184
|
+
type: "text",
|
|
6185
|
+
admin: { description: "GSC property (e.g. sc-domain:example.com or https://example.com/)" }
|
|
6186
|
+
},
|
|
6187
|
+
{ name: "scope", type: "text", admin: { readOnly: true } }
|
|
6188
|
+
]
|
|
6189
|
+
};
|
|
6190
|
+
}
|
|
6191
|
+
|
|
5482
6192
|
// src/rateLimiter.ts
|
|
5483
6193
|
function createRateLimiter(maxRequests, windowMs) {
|
|
5484
6194
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -5526,7 +6236,7 @@ function getClientIp(req) {
|
|
|
5526
6236
|
|
|
5527
6237
|
// src/endpoints/seoLogs.ts
|
|
5528
6238
|
var VALID_LOG_TYPES = ["404", "redirect", "error"];
|
|
5529
|
-
function
|
|
6239
|
+
function isAdmin7(user) {
|
|
5530
6240
|
if (!user) return false;
|
|
5531
6241
|
if (user.role === "admin") return true;
|
|
5532
6242
|
if (Array.isArray(user.roles) && user.roles.includes("admin")) return true;
|
|
@@ -5623,7 +6333,7 @@ function createSeoLogsHandler(seoLogsSecret) {
|
|
|
5623
6333
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
5624
6334
|
}
|
|
5625
6335
|
if (method === "DELETE") {
|
|
5626
|
-
if (!
|
|
6336
|
+
if (!isAdmin7(req.user)) {
|
|
5627
6337
|
return Response.json({ error: "Admin access required" }, { status: 403 });
|
|
5628
6338
|
}
|
|
5629
6339
|
try {
|
|
@@ -7207,6 +7917,9 @@ var fr = {
|
|
|
7207
7917
|
groupTechnical: "Technique",
|
|
7208
7918
|
groupAccessibility: "Accessibilit\xE9",
|
|
7209
7919
|
groupEcommerce: "E-commerce",
|
|
7920
|
+
groupEeat: "E-E-A-T",
|
|
7921
|
+
groupGeo: "GEO (IA)",
|
|
7922
|
+
groupHreflang: "Hreflang",
|
|
7210
7923
|
levelExcellent: "Excellent",
|
|
7211
7924
|
levelGood: "Bon",
|
|
7212
7925
|
levelFair: "Acceptable",
|
|
@@ -7215,6 +7928,8 @@ var fr = {
|
|
|
7215
7928
|
categoryImportant: "Important",
|
|
7216
7929
|
categoryBonus: "Bonus",
|
|
7217
7930
|
seoScore: "Score SEO",
|
|
7931
|
+
aiReadiness: "IA",
|
|
7932
|
+
aiReadinessTooltip: "Pr\xEAt pour l'IA \u2014 qualit\xE9 de structuration pour \xEAtre cit\xE9 par les moteurs g\xE9n\xE9ratifs (AI Overviews, ChatGPT, Perplexity). Distinct du score SEO.",
|
|
7218
7933
|
outOf100: "/ 100",
|
|
7219
7934
|
cornerstoneLabel: "PILIER",
|
|
7220
7935
|
checksPassed: "crit\xE8res valid\xE9s",
|
|
@@ -7780,6 +8495,9 @@ var en = {
|
|
|
7780
8495
|
groupTechnical: "Technical",
|
|
7781
8496
|
groupAccessibility: "Accessibility",
|
|
7782
8497
|
groupEcommerce: "E-commerce",
|
|
8498
|
+
groupEeat: "E-E-A-T",
|
|
8499
|
+
groupGeo: "GEO (AI)",
|
|
8500
|
+
groupHreflang: "Hreflang",
|
|
7783
8501
|
levelExcellent: "Excellent",
|
|
7784
8502
|
levelGood: "Good",
|
|
7785
8503
|
levelFair: "Fair",
|
|
@@ -7788,6 +8506,8 @@ var en = {
|
|
|
7788
8506
|
categoryImportant: "Important",
|
|
7789
8507
|
categoryBonus: "Bonus",
|
|
7790
8508
|
seoScore: "SEO Score",
|
|
8509
|
+
aiReadiness: "AI",
|
|
8510
|
+
aiReadinessTooltip: "AI-readiness \u2014 how well the page is structured to be cited by generative engines (AI Overviews, ChatGPT, Perplexity). Separate from the SEO score.",
|
|
7791
8511
|
outOf100: "/ 100",
|
|
7792
8512
|
cornerstoneLabel: "CORNERSTONE",
|
|
7793
8513
|
checksPassed: "checks passed",
|
|
@@ -7957,6 +8677,10 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
7957
8677
|
aiFeatures: true,
|
|
7958
8678
|
duplicateContent: true,
|
|
7959
8679
|
settings: true,
|
|
8680
|
+
gscApi: false,
|
|
8681
|
+
// opt-in — requires Google Cloud OAuth setup + secrets
|
|
8682
|
+
warmCache: true,
|
|
8683
|
+
// disable on low-memory hosts to skip startup pre-loading
|
|
7960
8684
|
...pluginConfig.features
|
|
7961
8685
|
};
|
|
7962
8686
|
function hasExistingSeoMeta(fields) {
|
|
@@ -8097,6 +8821,7 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8097
8821
|
if (features.redirects && !hasExistingRedirects) pluginCollections.push(createSeoRedirectsCollection(redirectsSlug));
|
|
8098
8822
|
if (features.performance) pluginCollections.push(createSeoPerformanceCollection());
|
|
8099
8823
|
if (features.seoLogs) pluginCollections.push(createSeoLogsCollection());
|
|
8824
|
+
if (features.gscApi) pluginCollections.push(createSeoGscAuthCollection());
|
|
8100
8825
|
config.collections = [
|
|
8101
8826
|
...config.collections || [],
|
|
8102
8827
|
...pluginCollections
|
|
@@ -8149,6 +8874,11 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8149
8874
|
method: "get",
|
|
8150
8875
|
handler: withRateLimit(createAuditHandler(targetCollections, seoConfig, targetGlobals))
|
|
8151
8876
|
});
|
|
8877
|
+
pluginEndpoints.push({
|
|
8878
|
+
path: `${basePath}/indexation-audit`,
|
|
8879
|
+
method: "get",
|
|
8880
|
+
handler: withRateLimit(createIndexationAuditHandler(targetCollections, seoConfig, targetGlobals))
|
|
8881
|
+
});
|
|
8152
8882
|
}
|
|
8153
8883
|
if (features.scoreHistory) {
|
|
8154
8884
|
pluginEndpoints.push({
|
|
@@ -8223,7 +8953,18 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8223
8953
|
if (features.performance) {
|
|
8224
8954
|
pluginEndpoints.push(
|
|
8225
8955
|
{ path: `${basePath}/performance`, method: "get", handler: createPerformanceHandler() },
|
|
8226
|
-
{ path: `${basePath}/performance`, method: "post", handler: createPerformanceHandler() }
|
|
8956
|
+
{ path: `${basePath}/performance`, method: "post", handler: createPerformanceHandler() },
|
|
8957
|
+
// Core Web Vitals via PageSpeed Insights — informational, on-demand, SSRF-safe
|
|
8958
|
+
{ path: `${basePath}/core-web-vitals`, method: "get", handler: withRateLimit(createCoreWebVitalsHandler(seoConfig)) }
|
|
8959
|
+
);
|
|
8960
|
+
}
|
|
8961
|
+
if (features.gscApi) {
|
|
8962
|
+
pluginEndpoints.push(
|
|
8963
|
+
{ path: `${basePath}/gsc/status`, method: "get", handler: createGscStatusHandler(basePath, seoConfig) },
|
|
8964
|
+
{ path: `${basePath}/gsc/auth`, method: "get", handler: createGscAuthStartHandler(basePath, seoConfig) },
|
|
8965
|
+
{ path: `${basePath}/gsc/callback`, method: "get", handler: createGscCallbackHandler(basePath, seoConfig) },
|
|
8966
|
+
{ path: `${basePath}/gsc/data`, method: "get", handler: withRateLimit(createGscDataHandler(basePath, seoConfig)) },
|
|
8967
|
+
{ path: `${basePath}/gsc/disconnect`, method: "post", handler: createGscDisconnectHandler() }
|
|
8227
8968
|
);
|
|
8228
8969
|
}
|
|
8229
8970
|
if (features.keywords) {
|
|
@@ -8366,7 +9107,9 @@ var seoAnalyzerPlugin = (pluginConfig = {}) => (incomingConfig) => {
|
|
|
8366
9107
|
const existingOnInit = config.onInit;
|
|
8367
9108
|
config.onInit = async (payload) => {
|
|
8368
9109
|
if (existingOnInit) await existingOnInit(payload);
|
|
8369
|
-
|
|
9110
|
+
if (features.warmCache) {
|
|
9111
|
+
startCacheWarmUp(payload, basePath, targetGlobals, targetCollections);
|
|
9112
|
+
}
|
|
8370
9113
|
};
|
|
8371
9114
|
return config;
|
|
8372
9115
|
};
|
|
@@ -8397,8 +9140,11 @@ var rulesFr = {
|
|
|
8397
9140
|
powerWordsPass: (count, words) => `Le title contient ${count} mot(s) puissant(s) : ${words}`,
|
|
8398
9141
|
powerWordsFail: 'Le title ne contient aucun mot puissant \u2014 Ajoutez un mot comme "gratuit", "guide", "complet" pour booster le CTR.',
|
|
8399
9142
|
powerWordsTip: "Les mots puissants (gratuit, exclusif, guide, complet, essentiel...) attirent l'attention dans les resultats de recherche.",
|
|
9143
|
+
pixelWidthLabel: "Largeur du title (pixels)",
|
|
9144
|
+
pixelWidthPass: (px) => `Largeur estimee ${px}px \u2014 sous le seuil de troncature SERP (~600px).`,
|
|
9145
|
+
pixelWidthWarn: (px) => `Largeur estimee ${px}px \u2014 risque de troncature dans Google (~600px). Informatif.`,
|
|
8400
9146
|
hasNumberLabel: "Nombre dans le title",
|
|
8401
|
-
hasNumberPass: "Le title contient un nombre \u2014
|
|
9147
|
+
hasNumberPass: "Le title contient un nombre \u2014 format type liste, souvent plus engageant.",
|
|
8402
9148
|
hasNumberFail: 'Aucun nombre dans le title \u2014 Les titres avec chiffres (ex: "5 astuces", "Top 10") attirent plus de clics.',
|
|
8403
9149
|
hasNumberTip: 'Ajoutez un nombre pour creer un titre de type liste (ex: "7 conseils pour...", "Les 3 erreurs a eviter").',
|
|
8404
9150
|
isQuestionLabel: "Title interrogatif",
|
|
@@ -8482,12 +9228,12 @@ var rulesFr = {
|
|
|
8482
9228
|
keywordIntroFail: (kw) => `Ajoutez le mot-cle "${kw}" dans les premieres phrases du contenu.`,
|
|
8483
9229
|
densityLabel: "Densite du mot-cle",
|
|
8484
9230
|
densityOverstuffed: (density) => `Densite du mot-cle : ${density}% \u2014 Trop eleve (>3%), risque de suroptimisation (keyword stuffing).`,
|
|
8485
|
-
densityHigh: (density) => `Densite du mot-cle : ${density}% \u2014 Legerement elevee
|
|
8486
|
-
densityPass: (density) => `Densite du mot-cle : ${density}% \u2014
|
|
9231
|
+
densityHigh: (density) => `Densite du mot-cle : ${density}% \u2014 Legerement elevee, restez naturel pour eviter la sur-optimisation.`,
|
|
9232
|
+
densityPass: (density) => `Densite du mot-cle : ${density}% \u2014 Usage naturel, aucun bourrage detecte.`,
|
|
8487
9233
|
densityPassWordLevel: (density) => `Les composants du mot-cle sont presents dans le contenu (densite estimee : ${density}%).`,
|
|
8488
9234
|
densityLowWordLevel: (density) => `Les composants du mot-cle sont presents mais peu frequents (densite estimee : ${density}%). Renforcez leur presence.`,
|
|
8489
9235
|
densityLow: (density) => `Densite du mot-cle : ${density}% \u2014 Trop faible. Visez 0,5% a 2,5%.`,
|
|
8490
|
-
densityMissing: (kw) => `Le mot-cle "${kw}" n'apparait
|
|
9236
|
+
densityMissing: (kw) => `Le mot-cle "${kw}" n'apparait pas dans le corps \u2014 privilegiez une couverture naturelle du sujet plutot que la repetition.`,
|
|
8491
9237
|
placeholderLabel: "Contenu placeholder",
|
|
8492
9238
|
placeholderFail: "Du contenu placeholder a ete detecte (lorem ipsum, TODO, etc.) \u2014 Remplacez par du vrai contenu.",
|
|
8493
9239
|
placeholderPass: "Aucun contenu placeholder detecte.",
|
|
@@ -8573,7 +9319,7 @@ var rulesFr = {
|
|
|
8573
9319
|
cornerstone: {
|
|
8574
9320
|
wordcountLabel: "Longueur du contenu pilier",
|
|
8575
9321
|
wordcountPass: (count) => `${count} mots \u2014 Le contenu pilier est suffisamment complet.`,
|
|
8576
|
-
wordcountFail: (count) => `${count} mots \u2014
|
|
9322
|
+
wordcountFail: (count) => `${count} mots \u2014 Contenu pilier trop leger ; developpez la couverture du sujet (sans viser un nombre de mots arbitraire).`,
|
|
8577
9323
|
internalLinksLabel: "Maillage interne du contenu pilier",
|
|
8578
9324
|
internalLinksPass: (count) => `${count} liens internes \u2014 Bon maillage pour un contenu pilier.`,
|
|
8579
9325
|
internalLinksFail: (count) => `${count} lien(s) interne(s) \u2014 Un contenu pilier devrait avoir au moins 5 liens internes vers du contenu associe.`,
|
|
@@ -8627,12 +9373,23 @@ var rulesFr = {
|
|
|
8627
9373
|
yearRefWarn: (oldest, current, last) => `Le contenu mentionne l'annee ${oldest} sans reference a ${current} ou ${last} \u2014 Contenu potentiellement obsolete.`,
|
|
8628
9374
|
yearRefPass: (year) => `Le contenu fait reference a l'annee en cours (${year}).`,
|
|
8629
9375
|
thinAgingLabel: "Contenu leger et ancien",
|
|
8630
|
-
thinAgingFail: (words, days) => `Seulement ${words} mots et non mis a jour depuis ${days} jours \u2014 Un contenu leger ancien perd rapidement en pertinence
|
|
9376
|
+
thinAgingFail: (words, days) => `Seulement ${words} mots et non mis a jour depuis ${days} jours \u2014 Un contenu leger ancien perd rapidement en pertinence.`,
|
|
9377
|
+
fakeRefreshLabel: "Faux rafraichissement",
|
|
9378
|
+
fakeRefreshWarn: (displayedDays, updatedDays) => `La date affichee (il y a ${displayedDays} j) est plus recente que la derniere modification reelle (il y a ${updatedDays} j) \u2014 evitez de rajeunir la date sans vraie mise a jour du contenu.`,
|
|
9379
|
+
fakeRefreshTip: "Google detecte la vraie date de modification. Mettez a jour le fond du contenu, pas seulement la date affichee."
|
|
8631
9380
|
},
|
|
8632
9381
|
schema: {
|
|
8633
9382
|
readinessLabel: "Donnees structurees",
|
|
8634
9383
|
readinessPass: "La page a suffisamment de metadonnees pour generer du JSON-LD (title, description, image).",
|
|
8635
|
-
readinessFail: "Completez le title, la description et ajoutez une image pour exploiter pleinement les donnees structurees."
|
|
9384
|
+
readinessFail: "Completez le title, la description et ajoutez une image pour exploiter pleinement les donnees structurees.",
|
|
9385
|
+
coverageLabel: "Couverture des donnees structurees",
|
|
9386
|
+
coverageOptional: "Les donnees structurees sont optionnelles pour ce type de page.",
|
|
9387
|
+
coveragePass: (type) => `Donnees CMS suffisantes pour generer un schema ${type} valide.`,
|
|
9388
|
+
coverageMissing: (type, fields) => `Schema ${type} attendu pour cette page \u2014 champ(s) requis manquant(s) dans le CMS : ${fields}.`,
|
|
9389
|
+
coverageMissingTip: "Completez ces champs, ou assurez-vous que le JSON-LD correspondant est injecte au rendu (frontend).",
|
|
9390
|
+
coverageRemind: (type, fields) => `Schema ${type} : champs CMS requis presents. Verifiez aussi ces champs requis non detectables automatiquement : ${fields}.`,
|
|
9391
|
+
faqNoRichResultLabel: "FAQ / donnees structurees",
|
|
9392
|
+
faqNoRichResult: "FAQPage detecte \u2014 markup valide et toujours lu par les moteurs et l'IA, mais Google ne genere plus de rich result FAQ (2026). Conservez le markup, n'attendez pas d'extrait enrichi en SERP."
|
|
8636
9393
|
},
|
|
8637
9394
|
technical: {
|
|
8638
9395
|
canonicalMissingLabel: "URL canonique",
|
|
@@ -8641,6 +9398,8 @@ var rulesFr = {
|
|
|
8641
9398
|
canonicalInvalidMessage: (url) => `URL canonique "${url}" invalide \u2014 Utilisez une URL absolue (https://...).`,
|
|
8642
9399
|
canonicalExternalLabel: "URL canonique",
|
|
8643
9400
|
canonicalExternalMessage: "URL canonique pointe vers un domaine externe \u2014 Verifiez que c'est intentionnel.",
|
|
9401
|
+
canonicalCrossLabel: "URL canonique",
|
|
9402
|
+
canonicalCrossMessage: (target) => `URL canonique pointe vers une AUTRE page (${target}) \u2014 cette page se desindexe au profit de cette URL. Verifiez que c'est intentionnel.`,
|
|
8644
9403
|
canonicalOkLabel: "URL canonique",
|
|
8645
9404
|
canonicalOkMessage: "URL canonique correctement definie.",
|
|
8646
9405
|
robotsNoindexLabel: "Robots noindex",
|
|
@@ -8765,8 +9524,11 @@ var rulesEn = {
|
|
|
8765
9524
|
powerWordsPass: (count, words) => `The title contains ${count} power word(s): ${words}`,
|
|
8766
9525
|
powerWordsFail: 'The title contains no power words \u2014 Add a word like "free", "guide", "ultimate" to boost CTR.',
|
|
8767
9526
|
powerWordsTip: "Power words (free, exclusive, guide, ultimate, essential...) attract attention in search results.",
|
|
9527
|
+
pixelWidthLabel: "Title pixel width",
|
|
9528
|
+
pixelWidthPass: (px) => `Estimated width ${px}px \u2014 under the SERP truncation threshold (~600px).`,
|
|
9529
|
+
pixelWidthWarn: (px) => `Estimated width ${px}px \u2014 may be truncated in Google (~600px). Informational.`,
|
|
8768
9530
|
hasNumberLabel: "Number in title",
|
|
8769
|
-
hasNumberPass: "The title contains a number \u2014
|
|
9531
|
+
hasNumberPass: "The title contains a number \u2014 list-style format, often more engaging.",
|
|
8770
9532
|
hasNumberFail: 'No number in the title \u2014 Titles with numbers (e.g. "5 tips", "Top 10") attract more clicks.',
|
|
8771
9533
|
hasNumberTip: 'Add a number to create a list-type title (e.g. "7 tips for...", "The 3 mistakes to avoid").',
|
|
8772
9534
|
isQuestionLabel: "Question title",
|
|
@@ -8850,12 +9612,12 @@ var rulesEn = {
|
|
|
8850
9612
|
keywordIntroFail: (kw) => `Add the keyword "${kw}" in the first sentences of the content.`,
|
|
8851
9613
|
densityLabel: "Keyword density",
|
|
8852
9614
|
densityOverstuffed: (density) => `Keyword density: ${density}% \u2014 Too high (>3%), risk of keyword stuffing.`,
|
|
8853
|
-
densityHigh: (density) => `Keyword density: ${density}% \u2014 Slightly high
|
|
8854
|
-
densityPass: (density) => `Keyword density: ${density}% \u2014
|
|
9615
|
+
densityHigh: (density) => `Keyword density: ${density}% \u2014 Slightly high; keep it natural to avoid over-optimisation.`,
|
|
9616
|
+
densityPass: (density) => `Keyword density: ${density}% \u2014 Natural usage, no stuffing detected.`,
|
|
8855
9617
|
densityPassWordLevel: (density) => `Keyword components are present in the content (estimated density: ${density}%).`,
|
|
8856
9618
|
densityLowWordLevel: (density) => `Keyword components are present but infrequent (estimated density: ${density}%). Strengthen their presence.`,
|
|
8857
9619
|
densityLow: (density) => `Keyword density: ${density}% \u2014 Too low. Aim for 0.5% to 2.5%.`,
|
|
8858
|
-
densityMissing: (kw) => `The keyword "${kw}"
|
|
9620
|
+
densityMissing: (kw) => `The keyword "${kw}" does not appear in the body \u2014 focus on covering the topic naturally rather than repeating it.`,
|
|
8859
9621
|
placeholderLabel: "Placeholder content",
|
|
8860
9622
|
placeholderFail: "Placeholder content detected (lorem ipsum, TODO, etc.) \u2014 Replace with real content.",
|
|
8861
9623
|
placeholderPass: "No placeholder content detected.",
|
|
@@ -8941,7 +9703,7 @@ var rulesEn = {
|
|
|
8941
9703
|
cornerstone: {
|
|
8942
9704
|
wordcountLabel: "Pillar content length",
|
|
8943
9705
|
wordcountPass: (count) => `${count} words \u2014 The pillar content is comprehensive enough.`,
|
|
8944
|
-
wordcountFail: (count) => `${count} words \u2014 Pillar content
|
|
9706
|
+
wordcountFail: (count) => `${count} words \u2014 Pillar content seems thin; expand topical coverage (without targeting an arbitrary word count).`,
|
|
8945
9707
|
internalLinksLabel: "Pillar content internal linking",
|
|
8946
9708
|
internalLinksPass: (count) => `${count} internal links \u2014 Good linking for pillar content.`,
|
|
8947
9709
|
internalLinksFail: (count) => `${count} internal link(s) \u2014 Pillar content should have at least 5 internal links to related content.`,
|
|
@@ -8995,12 +9757,23 @@ var rulesEn = {
|
|
|
8995
9757
|
yearRefWarn: (oldest, current, last) => `The content mentions year ${oldest} without reference to ${current} or ${last} \u2014 Potentially outdated.`,
|
|
8996
9758
|
yearRefPass: (year) => `The content references the current year (${year}).`,
|
|
8997
9759
|
thinAgingLabel: "Thin and old content",
|
|
8998
|
-
thinAgingFail: (words, days) => `Only ${words} words and not updated for ${days} days \u2014 Thin old content loses relevance quickly
|
|
9760
|
+
thinAgingFail: (words, days) => `Only ${words} words and not updated for ${days} days \u2014 Thin old content loses relevance quickly.`,
|
|
9761
|
+
fakeRefreshLabel: "Fake refresh",
|
|
9762
|
+
fakeRefreshWarn: (displayedDays, updatedDays) => `The displayed date (${displayedDays} days ago) is newer than the last real modification (${updatedDays} days ago) \u2014 avoid bumping the date without a real content update.`,
|
|
9763
|
+
fakeRefreshTip: "Google detects the real modification date. Update the actual content, not just the displayed date."
|
|
8999
9764
|
},
|
|
9000
9765
|
schema: {
|
|
9001
9766
|
readinessLabel: "Structured data",
|
|
9002
9767
|
readinessPass: "The page has sufficient metadata to generate JSON-LD (title, description, image).",
|
|
9003
|
-
readinessFail: "Complete the title, description and add an image to fully leverage structured data."
|
|
9768
|
+
readinessFail: "Complete the title, description and add an image to fully leverage structured data.",
|
|
9769
|
+
coverageLabel: "Structured data coverage",
|
|
9770
|
+
coverageOptional: "Structured data is optional for this page type.",
|
|
9771
|
+
coveragePass: (type) => `Enough CMS data to generate a valid ${type} schema.`,
|
|
9772
|
+
coverageMissing: (type, fields) => `${type} schema expected for this page \u2014 required field(s) missing in the CMS: ${fields}.`,
|
|
9773
|
+
coverageMissingTip: "Complete these fields, or make sure the matching JSON-LD is injected at render time (frontend).",
|
|
9774
|
+
coverageRemind: (type, fields) => `${type} schema: required CMS fields are present. Also confirm these required fields the analyzer cannot detect: ${fields}.`,
|
|
9775
|
+
faqNoRichResultLabel: "FAQ / structured data",
|
|
9776
|
+
faqNoRichResult: "FAQPage detected \u2014 markup is valid and still read by search/AI engines, but Google no longer renders FAQ rich results (2026). Keep the markup; do not expect an enhanced SERP snippet."
|
|
9004
9777
|
},
|
|
9005
9778
|
technical: {
|
|
9006
9779
|
canonicalMissingLabel: "Canonical URL",
|
|
@@ -9009,6 +9782,8 @@ var rulesEn = {
|
|
|
9009
9782
|
canonicalInvalidMessage: (url) => `Canonical URL "${url}" is invalid \u2014 Use an absolute URL (https://...).`,
|
|
9010
9783
|
canonicalExternalLabel: "Canonical URL",
|
|
9011
9784
|
canonicalExternalMessage: "Canonical URL points to an external domain \u2014 Verify this is intentional.",
|
|
9785
|
+
canonicalCrossLabel: "Canonical URL",
|
|
9786
|
+
canonicalCrossMessage: (target) => `Canonical URL points to a DIFFERENT page (${target}) \u2014 this page de-indexes itself in favor of that URL. Verify this is intentional.`,
|
|
9012
9787
|
canonicalOkLabel: "Canonical URL",
|
|
9013
9788
|
canonicalOkMessage: "Canonical URL is correctly defined.",
|
|
9014
9789
|
robotsNoindexLabel: "Robots noindex",
|
|
@@ -9361,6 +10136,21 @@ function getTranslations(locale) {
|
|
|
9361
10136
|
}
|
|
9362
10137
|
|
|
9363
10138
|
// src/rules/title.ts
|
|
10139
|
+
var TITLE_PIXEL_MAX = 600;
|
|
10140
|
+
var NARROW_CHARS = new Set("iIl.,:;|!'`jft()[]{}/\\".split(""));
|
|
10141
|
+
var WIDE_CHARS = new Set("mwMW@%".split(""));
|
|
10142
|
+
function estimateTitlePixelWidth(title) {
|
|
10143
|
+
let px = 0;
|
|
10144
|
+
for (const ch of title) {
|
|
10145
|
+
if (ch === " ") px += 5;
|
|
10146
|
+
else if (NARROW_CHARS.has(ch)) px += 5;
|
|
10147
|
+
else if (WIDE_CHARS.has(ch)) px += 15;
|
|
10148
|
+
else if (ch >= "A" && ch <= "Z") px += 12;
|
|
10149
|
+
else if (ch >= "0" && ch <= "9") px += 10;
|
|
10150
|
+
else px += 9;
|
|
10151
|
+
}
|
|
10152
|
+
return Math.round(px);
|
|
10153
|
+
}
|
|
9364
10154
|
function checkTitle(input, ctx) {
|
|
9365
10155
|
const checks = [];
|
|
9366
10156
|
const r = getTranslations(ctx.locale).rules.title;
|
|
@@ -9386,8 +10176,10 @@ function checkTitle(input, ctx) {
|
|
|
9386
10176
|
label: r.lengthLabel,
|
|
9387
10177
|
status: "warning",
|
|
9388
10178
|
message: r.lengthShort(titleLen),
|
|
9389
|
-
|
|
9390
|
-
weight
|
|
10179
|
+
// SEO desintox: title length is a SERP-display hint, NOT a ranking factor
|
|
10180
|
+
// (Google rewrites 60%+ of titles). Informational weight, never critical.
|
|
10181
|
+
category: "bonus",
|
|
10182
|
+
weight: 1,
|
|
9391
10183
|
group: "title",
|
|
9392
10184
|
tip: r.lengthShortTip
|
|
9393
10185
|
});
|
|
@@ -9397,8 +10189,8 @@ function checkTitle(input, ctx) {
|
|
|
9397
10189
|
label: r.lengthLabel,
|
|
9398
10190
|
status: "warning",
|
|
9399
10191
|
message: r.lengthLong(titleLen),
|
|
9400
|
-
category: "
|
|
9401
|
-
weight:
|
|
10192
|
+
category: "bonus",
|
|
10193
|
+
weight: 1,
|
|
9402
10194
|
group: "title",
|
|
9403
10195
|
tip: r.lengthLongTip
|
|
9404
10196
|
});
|
|
@@ -9408,11 +10200,21 @@ function checkTitle(input, ctx) {
|
|
|
9408
10200
|
label: r.lengthLabel,
|
|
9409
10201
|
status: "pass",
|
|
9410
10202
|
message: r.lengthPass(titleLen),
|
|
9411
|
-
category: "
|
|
9412
|
-
weight:
|
|
10203
|
+
category: "bonus",
|
|
10204
|
+
weight: 1,
|
|
9413
10205
|
group: "title"
|
|
9414
10206
|
});
|
|
9415
10207
|
}
|
|
10208
|
+
const titlePx = estimateTitlePixelWidth(title);
|
|
10209
|
+
checks.push({
|
|
10210
|
+
id: "title-pixel-width",
|
|
10211
|
+
label: r.pixelWidthLabel,
|
|
10212
|
+
status: titlePx > TITLE_PIXEL_MAX ? "warning" : "pass",
|
|
10213
|
+
message: titlePx > TITLE_PIXEL_MAX ? r.pixelWidthWarn(titlePx) : r.pixelWidthPass(titlePx),
|
|
10214
|
+
category: "bonus",
|
|
10215
|
+
weight: 0,
|
|
10216
|
+
group: "title"
|
|
10217
|
+
});
|
|
9416
10218
|
if (kw) {
|
|
9417
10219
|
const titleNorm = normalizeForComparison(title);
|
|
9418
10220
|
const kwPresent = keywordMatchesText(kw, titleNorm);
|
|
@@ -9948,44 +10750,15 @@ function checkContent(input, ctx) {
|
|
|
9948
10750
|
weight: 2,
|
|
9949
10751
|
group: "content"
|
|
9950
10752
|
});
|
|
9951
|
-
} else if (density >= KEYWORD_DENSITY_MIN) {
|
|
9952
|
-
checks.push({
|
|
9953
|
-
id: "content-keyword-density",
|
|
9954
|
-
label: r.densityLabel,
|
|
9955
|
-
status: "pass",
|
|
9956
|
-
message: exactCount > 0 ? r.densityPass(density.toFixed(1)) : r.densityPassWordLevel(density.toFixed(1)),
|
|
9957
|
-
category: "important",
|
|
9958
|
-
weight: 2,
|
|
9959
|
-
group: "content"
|
|
9960
|
-
});
|
|
9961
|
-
} else if (wordLevelMatch) {
|
|
9962
|
-
checks.push({
|
|
9963
|
-
id: "content-keyword-density",
|
|
9964
|
-
label: r.densityLabel,
|
|
9965
|
-
status: "warning",
|
|
9966
|
-
message: r.densityLowWordLevel(density.toFixed(1)),
|
|
9967
|
-
category: "important",
|
|
9968
|
-
weight: 2,
|
|
9969
|
-
group: "content"
|
|
9970
|
-
});
|
|
9971
|
-
} else if (exactCount > 0) {
|
|
9972
|
-
checks.push({
|
|
9973
|
-
id: "content-keyword-density",
|
|
9974
|
-
label: r.densityLabel,
|
|
9975
|
-
status: "warning",
|
|
9976
|
-
message: r.densityLow(density.toFixed(1)),
|
|
9977
|
-
category: "important",
|
|
9978
|
-
weight: 2,
|
|
9979
|
-
group: "content"
|
|
9980
|
-
});
|
|
9981
10753
|
} else {
|
|
10754
|
+
const present = exactCount > 0 || wordLevelMatch;
|
|
9982
10755
|
checks.push({
|
|
9983
10756
|
id: "content-keyword-density",
|
|
9984
10757
|
label: r.densityLabel,
|
|
9985
|
-
status: "
|
|
9986
|
-
message: r.densityMissing(input.focusKeyword || normalizedKeyword),
|
|
9987
|
-
category: "
|
|
9988
|
-
weight:
|
|
10758
|
+
status: "pass",
|
|
10759
|
+
message: present ? r.densityPass(density.toFixed(1)) : r.densityMissing(input.focusKeyword || normalizedKeyword),
|
|
10760
|
+
category: "bonus",
|
|
10761
|
+
weight: 0,
|
|
9989
10762
|
group: "content"
|
|
9990
10763
|
});
|
|
9991
10764
|
}
|
|
@@ -10032,39 +10805,17 @@ function checkContent(input, ctx) {
|
|
|
10032
10805
|
const tiersWithKw = [tier1, tier2, tier3].filter(
|
|
10033
10806
|
(t) => keywordMatchesText(normalizedKeyword, t)
|
|
10034
10807
|
).length;
|
|
10035
|
-
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
10040
|
-
|
|
10041
|
-
|
|
10042
|
-
|
|
10043
|
-
|
|
10044
|
-
}
|
|
10045
|
-
}
|
|
10046
|
-
checks.push({
|
|
10047
|
-
id: "content-keyword-distribution",
|
|
10048
|
-
label: r.distributionLabel,
|
|
10049
|
-
status: "warning",
|
|
10050
|
-
message: r.distributionWarn,
|
|
10051
|
-
category: "important",
|
|
10052
|
-
weight: 2,
|
|
10053
|
-
group: "content",
|
|
10054
|
-
tip: r.distributionWarnTip
|
|
10055
|
-
});
|
|
10056
|
-
} else {
|
|
10057
|
-
checks.push({
|
|
10058
|
-
id: "content-keyword-distribution",
|
|
10059
|
-
label: r.distributionLabel,
|
|
10060
|
-
status: "fail",
|
|
10061
|
-
message: r.distributionFail,
|
|
10062
|
-
category: "important",
|
|
10063
|
-
weight: 2,
|
|
10064
|
-
group: "content",
|
|
10065
|
-
tip: r.distributionFailTip
|
|
10066
|
-
});
|
|
10067
|
-
}
|
|
10808
|
+
const wellDistributed = tiersWithKw >= 2;
|
|
10809
|
+
checks.push({
|
|
10810
|
+
id: "content-keyword-distribution",
|
|
10811
|
+
label: r.distributionLabel,
|
|
10812
|
+
status: wellDistributed ? "pass" : "warning",
|
|
10813
|
+
message: wellDistributed ? r.distributionPass(tiersWithKw) : r.distributionWarn,
|
|
10814
|
+
category: "bonus",
|
|
10815
|
+
weight: 0,
|
|
10816
|
+
group: "content",
|
|
10817
|
+
...wellDistributed ? {} : { tip: r.distributionWarnTip }
|
|
10818
|
+
});
|
|
10068
10819
|
}
|
|
10069
10820
|
if (wordCount > 500) {
|
|
10070
10821
|
const allLists = [];
|
|
@@ -10556,23 +11307,161 @@ function checkSocial(input, ctx) {
|
|
|
10556
11307
|
return checks;
|
|
10557
11308
|
}
|
|
10558
11309
|
|
|
11310
|
+
// src/rules/schema-requirements.ts
|
|
11311
|
+
var SCHEMA_REQUIREMENTS = {
|
|
11312
|
+
Article: {
|
|
11313
|
+
required: ["headline", "image"],
|
|
11314
|
+
recommended: ["author", "datePublished", "dateModified", "publisher"],
|
|
11315
|
+
richResult: true
|
|
11316
|
+
},
|
|
11317
|
+
Product: {
|
|
11318
|
+
// Google needs name + at least one of offers / review / aggregateRating
|
|
11319
|
+
required: ["name", "offers"],
|
|
11320
|
+
recommended: ["image", "brand", "aggregateRating", "review", "sku"],
|
|
11321
|
+
richResult: true
|
|
11322
|
+
},
|
|
11323
|
+
LocalBusiness: {
|
|
11324
|
+
required: ["name", "address"],
|
|
11325
|
+
recommended: ["telephone", "openingHours", "geo", "priceRange"],
|
|
11326
|
+
richResult: true
|
|
11327
|
+
},
|
|
11328
|
+
BreadcrumbList: {
|
|
11329
|
+
required: ["itemListElement"],
|
|
11330
|
+
recommended: [],
|
|
11331
|
+
richResult: true
|
|
11332
|
+
},
|
|
11333
|
+
Organization: {
|
|
11334
|
+
required: ["name", "url"],
|
|
11335
|
+
recommended: ["logo", "sameAs"],
|
|
11336
|
+
richResult: false
|
|
11337
|
+
},
|
|
11338
|
+
Person: {
|
|
11339
|
+
required: ["name"],
|
|
11340
|
+
recommended: ["sameAs", "jobTitle", "image", "url"],
|
|
11341
|
+
richResult: false
|
|
11342
|
+
},
|
|
11343
|
+
FAQPage: {
|
|
11344
|
+
required: ["mainEntity"],
|
|
11345
|
+
recommended: [],
|
|
11346
|
+
// FAQ rich results removed by Google (May 2026). Markup still useful for AI/search understanding.
|
|
11347
|
+
richResult: false
|
|
11348
|
+
},
|
|
11349
|
+
Event: {
|
|
11350
|
+
required: ["name", "startDate", "location"],
|
|
11351
|
+
recommended: ["endDate", "offers", "image", "performer"],
|
|
11352
|
+
richResult: true
|
|
11353
|
+
},
|
|
11354
|
+
Recipe: {
|
|
11355
|
+
required: ["name", "image", "recipeIngredient", "recipeInstructions"],
|
|
11356
|
+
recommended: ["nutrition", "aggregateRating", "totalTime", "recipeYield"],
|
|
11357
|
+
richResult: true
|
|
11358
|
+
},
|
|
11359
|
+
Video: {
|
|
11360
|
+
required: ["name", "thumbnailUrl", "uploadDate"],
|
|
11361
|
+
recommended: ["duration", "contentUrl", "description"],
|
|
11362
|
+
richResult: true
|
|
11363
|
+
}
|
|
11364
|
+
};
|
|
11365
|
+
var CMS_VERIFIABLE_SCHEMA_FIELDS = /* @__PURE__ */ new Set([
|
|
11366
|
+
"headline",
|
|
11367
|
+
"name",
|
|
11368
|
+
"image",
|
|
11369
|
+
"url",
|
|
11370
|
+
"itemListElement",
|
|
11371
|
+
"mainEntity"
|
|
11372
|
+
]);
|
|
11373
|
+
|
|
10559
11374
|
// src/rules/schema.ts
|
|
11375
|
+
var FAQ_BLOCK_TYPES = /* @__PURE__ */ new Set(["faq", "FAQ", "faqBlock", "faqs"]);
|
|
11376
|
+
function hasFaqBlock(blocks) {
|
|
11377
|
+
if (!Array.isArray(blocks)) return false;
|
|
11378
|
+
return blocks.some((block) => {
|
|
11379
|
+
if (!block || typeof block !== "object") return false;
|
|
11380
|
+
const t = block.blockType;
|
|
11381
|
+
return typeof t === "string" && FAQ_BLOCK_TYPES.has(t);
|
|
11382
|
+
});
|
|
11383
|
+
}
|
|
11384
|
+
function detectExpectedSchemaType(input, ctx) {
|
|
11385
|
+
if (input.isProduct) return "Product";
|
|
11386
|
+
if (input.isPost || ctx.pageType === "blog") return "Article";
|
|
11387
|
+
if (ctx.pageType === "local-seo") return "LocalBusiness";
|
|
11388
|
+
if (ctx.pageType === "agency") return "Organization";
|
|
11389
|
+
if (ctx.pageType === "legal" || ctx.pageType === "contact" || ctx.pageType === "form") return null;
|
|
11390
|
+
return "Article";
|
|
11391
|
+
}
|
|
10560
11392
|
function checkSchema(input, ctx) {
|
|
10561
11393
|
const checks = [];
|
|
10562
11394
|
const r = getTranslations(ctx.locale).rules.schema;
|
|
10563
|
-
const
|
|
10564
|
-
const
|
|
10565
|
-
|
|
10566
|
-
|
|
10567
|
-
|
|
10568
|
-
|
|
10569
|
-
|
|
10570
|
-
|
|
10571
|
-
|
|
10572
|
-
|
|
10573
|
-
|
|
10574
|
-
|
|
10575
|
-
|
|
11395
|
+
const faqPresent = hasFaqBlock(input.blocks);
|
|
11396
|
+
const present = {
|
|
11397
|
+
headline: !!(input.metaTitle || input.heroTitle) || ctx.allHeadings.some((h) => h.tag === "h1"),
|
|
11398
|
+
name: !!(input.metaTitle || input.heroTitle),
|
|
11399
|
+
image: ctx.imageStats.total > 0 || !!input.metaImage,
|
|
11400
|
+
url: true,
|
|
11401
|
+
itemListElement: !!input.slug,
|
|
11402
|
+
mainEntity: faqPresent
|
|
11403
|
+
};
|
|
11404
|
+
const expected = detectExpectedSchemaType(input, ctx);
|
|
11405
|
+
if (expected === null) {
|
|
11406
|
+
checks.push({
|
|
11407
|
+
id: "schema-coverage",
|
|
11408
|
+
label: r.coverageLabel,
|
|
11409
|
+
status: "pass",
|
|
11410
|
+
message: r.coverageOptional,
|
|
11411
|
+
category: "bonus",
|
|
11412
|
+
weight: 0,
|
|
11413
|
+
group: "schema"
|
|
11414
|
+
});
|
|
11415
|
+
} else {
|
|
11416
|
+
const reqDef = SCHEMA_REQUIREMENTS[expected];
|
|
11417
|
+
const knownMissing = reqDef.required.filter(
|
|
11418
|
+
(f) => CMS_VERIFIABLE_SCHEMA_FIELDS.has(f) && !present[f]
|
|
11419
|
+
);
|
|
11420
|
+
const unverifiable = reqDef.required.filter((f) => !CMS_VERIFIABLE_SCHEMA_FIELDS.has(f));
|
|
11421
|
+
if (knownMissing.length > 0) {
|
|
11422
|
+
checks.push({
|
|
11423
|
+
id: "schema-coverage",
|
|
11424
|
+
label: r.coverageLabel,
|
|
11425
|
+
status: "warning",
|
|
11426
|
+
message: r.coverageMissing(expected, knownMissing.join(", ")),
|
|
11427
|
+
category: "bonus",
|
|
11428
|
+
weight: 1,
|
|
11429
|
+
group: "schema",
|
|
11430
|
+
tip: r.coverageMissingTip
|
|
11431
|
+
});
|
|
11432
|
+
} else if (unverifiable.length > 0) {
|
|
11433
|
+
checks.push({
|
|
11434
|
+
id: "schema-coverage",
|
|
11435
|
+
label: r.coverageLabel,
|
|
11436
|
+
status: "pass",
|
|
11437
|
+
message: r.coverageRemind(expected, unverifiable.join(", ")),
|
|
11438
|
+
category: "bonus",
|
|
11439
|
+
weight: 0,
|
|
11440
|
+
group: "schema"
|
|
11441
|
+
});
|
|
11442
|
+
} else {
|
|
11443
|
+
checks.push({
|
|
11444
|
+
id: "schema-coverage",
|
|
11445
|
+
label: r.coverageLabel,
|
|
11446
|
+
status: "pass",
|
|
11447
|
+
message: r.coveragePass(expected),
|
|
11448
|
+
category: "bonus",
|
|
11449
|
+
weight: 1,
|
|
11450
|
+
group: "schema"
|
|
11451
|
+
});
|
|
11452
|
+
}
|
|
11453
|
+
}
|
|
11454
|
+
if (faqPresent && true) {
|
|
11455
|
+
checks.push({
|
|
11456
|
+
id: "schema-faq-no-rich-result",
|
|
11457
|
+
label: r.faqNoRichResultLabel,
|
|
11458
|
+
status: "pass",
|
|
11459
|
+
message: r.faqNoRichResult,
|
|
11460
|
+
category: "bonus",
|
|
11461
|
+
weight: 0,
|
|
11462
|
+
group: "schema"
|
|
11463
|
+
});
|
|
11464
|
+
}
|
|
10576
11465
|
return checks;
|
|
10577
11466
|
}
|
|
10578
11467
|
|
|
@@ -10694,8 +11583,8 @@ function checkReadability(input, ctx) {
|
|
|
10694
11583
|
label: r.passiveLabelFail,
|
|
10695
11584
|
status: "warning",
|
|
10696
11585
|
message: r.passiveFail(passiveSentences.length, sentences.length, Math.round(passiveRatio * 100)),
|
|
10697
|
-
category: "
|
|
10698
|
-
weight:
|
|
11586
|
+
category: "bonus",
|
|
11587
|
+
weight: 0,
|
|
10699
11588
|
group: "readability"
|
|
10700
11589
|
});
|
|
10701
11590
|
} else {
|
|
@@ -10704,8 +11593,8 @@ function checkReadability(input, ctx) {
|
|
|
10704
11593
|
label: r.passiveLabelPass,
|
|
10705
11594
|
status: "pass",
|
|
10706
11595
|
message: r.passivePass(Math.round(passiveRatio * 100)),
|
|
10707
|
-
category: "
|
|
10708
|
-
weight:
|
|
11596
|
+
category: "bonus",
|
|
11597
|
+
weight: 0,
|
|
10709
11598
|
group: "readability"
|
|
10710
11599
|
});
|
|
10711
11600
|
}
|
|
@@ -10720,7 +11609,7 @@ function checkReadability(input, ctx) {
|
|
|
10720
11609
|
status: "warning",
|
|
10721
11610
|
message: r.transitionsFail(Math.round(transitionRatio * 100)),
|
|
10722
11611
|
category: "bonus",
|
|
10723
|
-
weight:
|
|
11612
|
+
weight: 0,
|
|
10724
11613
|
group: "readability"
|
|
10725
11614
|
});
|
|
10726
11615
|
} else {
|
|
@@ -10730,7 +11619,7 @@ function checkReadability(input, ctx) {
|
|
|
10730
11619
|
status: "pass",
|
|
10731
11620
|
message: r.transitionsPass(Math.round(transitionRatio * 100)),
|
|
10732
11621
|
category: "bonus",
|
|
10733
|
-
weight:
|
|
11622
|
+
weight: 0,
|
|
10734
11623
|
group: "readability"
|
|
10735
11624
|
});
|
|
10736
11625
|
}
|
|
@@ -11178,10 +12067,34 @@ function checkFreshness(input, ctx) {
|
|
|
11178
12067
|
group: "freshness"
|
|
11179
12068
|
});
|
|
11180
12069
|
}
|
|
12070
|
+
if (input.displayedDate && input.updatedAt) {
|
|
12071
|
+
const displayedDays = daysSince(input.displayedDate);
|
|
12072
|
+
const updatedDays = daysSince(input.updatedAt);
|
|
12073
|
+
if (displayedDays !== Infinity && updatedDays !== Infinity && updatedDays - displayedDays > 60) {
|
|
12074
|
+
checks.push({
|
|
12075
|
+
id: "freshness-fake-refresh",
|
|
12076
|
+
label: r.fakeRefreshLabel,
|
|
12077
|
+
status: "warning",
|
|
12078
|
+
message: r.fakeRefreshWarn(displayedDays, updatedDays),
|
|
12079
|
+
category: "bonus",
|
|
12080
|
+
weight: 0,
|
|
12081
|
+
group: "freshness",
|
|
12082
|
+
tip: r.fakeRefreshTip
|
|
12083
|
+
});
|
|
12084
|
+
}
|
|
12085
|
+
}
|
|
11181
12086
|
return checks;
|
|
11182
12087
|
}
|
|
11183
12088
|
|
|
11184
12089
|
// src/rules/technical.ts
|
|
12090
|
+
function isCrossCanonical(canonical, siteUrl, slug) {
|
|
12091
|
+
const norm = (p) => p.replace(/[?#].*$/, "").replace(/^\/+/, "").replace(/\/+$/, "").toLowerCase();
|
|
12092
|
+
const canonicalPath = norm(canonical.slice(siteUrl.length));
|
|
12093
|
+
const selfPath = norm(slug);
|
|
12094
|
+
const selfIsHome = selfPath === "" || selfPath === "home";
|
|
12095
|
+
if (selfIsHome) return canonicalPath !== "" && canonicalPath !== "home";
|
|
12096
|
+
return canonicalPath !== selfPath;
|
|
12097
|
+
}
|
|
11185
12098
|
function checkTechnical(input, ctx) {
|
|
11186
12099
|
const checks = [];
|
|
11187
12100
|
const r = getTranslations(ctx.locale).rules.technical;
|
|
@@ -11219,6 +12132,16 @@ function checkTechnical(input, ctx) {
|
|
|
11219
12132
|
weight: 2,
|
|
11220
12133
|
group: "technical"
|
|
11221
12134
|
});
|
|
12135
|
+
} else if (siteUrl && input.slug && !input.isGlobal && isCrossCanonical(canonical, siteUrl, input.slug)) {
|
|
12136
|
+
checks.push({
|
|
12137
|
+
id: "canonical-cross",
|
|
12138
|
+
label: r.canonicalCrossLabel,
|
|
12139
|
+
status: "warning",
|
|
12140
|
+
message: r.canonicalCrossMessage(canonical),
|
|
12141
|
+
category: "important",
|
|
12142
|
+
weight: 2,
|
|
12143
|
+
group: "technical"
|
|
12144
|
+
});
|
|
11222
12145
|
} else {
|
|
11223
12146
|
checks.push({
|
|
11224
12147
|
id: "canonical-ok",
|
|
@@ -11511,6 +12434,336 @@ function checkEcommerce(input, ctx) {
|
|
|
11511
12434
|
return checks;
|
|
11512
12435
|
}
|
|
11513
12436
|
|
|
12437
|
+
// src/rules/eeat.ts
|
|
12438
|
+
var STRINGS = {
|
|
12439
|
+
fr: {
|
|
12440
|
+
authorLabel: "Auteur attribu\xE9 (E-E-A-T)",
|
|
12441
|
+
authorPass: "Un auteur est attribu\xE9 \u2014 bon signal de transparence E-E-A-T.",
|
|
12442
|
+
authorFail: "Aucun auteur identifi\xE9 \u2014 attribuez un auteur r\xE9el pour renforcer la confiance (E-E-A-T).",
|
|
12443
|
+
authorTip: "Ajoutez un auteur avec une courte bio et un lien vers son profil (Person schema + sameAs).",
|
|
12444
|
+
authorEntityLabel: "Entit\xE9 auteur (profil / sameAs)",
|
|
12445
|
+
authorEntityPass: "L'auteur a un lien de profil \u2014 renforce l'entit\xE9 (sameAs) pour les moteurs et l'IA.",
|
|
12446
|
+
authorEntityFail: "L'auteur n'a pas de lien de profil \u2014 ajoutez une URL (LinkedIn, page auteur) comme sameAs.",
|
|
12447
|
+
datesLabel: "Dates de publication / mise \xE0 jour",
|
|
12448
|
+
datesPass: "Dates de publication et de mise \xE0 jour disponibles \u2014 bon pour la fra\xEEcheur et la confiance.",
|
|
12449
|
+
datesFail: "Date de publication ou de mise \xE0 jour manquante \u2014 exposez datePublished et dateModified.",
|
|
12450
|
+
sourcesLabel: "Sources externes cit\xE9es",
|
|
12451
|
+
sourcesPass: (n) => `${n} lien(s) vers des sources externes \u2014 renforce la cr\xE9dibilit\xE9 et l'E-E-A-T.`,
|
|
12452
|
+
sourcesFail: "Aucune source externe cit\xE9e \u2014 liez des sources fiables pour appuyer vos affirmations.",
|
|
12453
|
+
sourcesTip: "Citez des \xE9tudes, donn\xE9es officielles ou r\xE9f\xE9rences sectorielles avec des liens sortants.",
|
|
12454
|
+
dataLabel: "Donn\xE9es originales / chiffr\xE9es",
|
|
12455
|
+
dataPass: "Le contenu pr\xE9sente des donn\xE9es chiffr\xE9es \u2014 signal d'expertise et de contenu original.",
|
|
12456
|
+
dataFail: "Peu de donn\xE9es chiffr\xE9es d\xE9tect\xE9es \u2014 ajoutez des chiffres, statistiques ou r\xE9sultats concrets.",
|
|
12457
|
+
dataTip: "Le contenu d\xE9montrant une exp\xE9rience de premi\xE8re main (donn\xE9es, chiffres, exemples v\xE9cus) est le levier de mont\xE9e n\xB01 en 2026."
|
|
12458
|
+
},
|
|
12459
|
+
en: {
|
|
12460
|
+
authorLabel: "Attributed author (E-E-A-T)",
|
|
12461
|
+
authorPass: "An author is attributed \u2014 good E-E-A-T transparency signal.",
|
|
12462
|
+
authorFail: "No identified author \u2014 attribute a real author to strengthen trust (E-E-A-T).",
|
|
12463
|
+
authorTip: "Add an author with a short bio and a link to their profile (Person schema + sameAs).",
|
|
12464
|
+
authorEntityLabel: "Author entity (profile / sameAs)",
|
|
12465
|
+
authorEntityPass: "The author has a profile link \u2014 strengthens the entity (sameAs) for search and AI.",
|
|
12466
|
+
authorEntityFail: "The author has no profile link \u2014 add a URL (LinkedIn, author page) as sameAs.",
|
|
12467
|
+
datesLabel: "Published / updated dates",
|
|
12468
|
+
datesPass: "Published and modified dates available \u2014 good for freshness and trust.",
|
|
12469
|
+
datesFail: "Published or modified date missing \u2014 expose datePublished and dateModified.",
|
|
12470
|
+
sourcesLabel: "External sources cited",
|
|
12471
|
+
sourcesPass: (n) => `${n} link(s) to external sources \u2014 strengthens credibility and E-E-A-T.`,
|
|
12472
|
+
sourcesFail: "No external source cited \u2014 link reliable sources to back your claims.",
|
|
12473
|
+
sourcesTip: "Cite studies, official data or industry references with outbound links.",
|
|
12474
|
+
dataLabel: "Original / quantitative data",
|
|
12475
|
+
dataPass: "The content includes quantitative data \u2014 a signal of expertise and original content.",
|
|
12476
|
+
dataFail: "Little quantitative data detected \u2014 add figures, statistics or concrete results.",
|
|
12477
|
+
dataTip: "First-hand-experience content (data, figures, lived examples) is the #1 visibility lever in 2026."
|
|
12478
|
+
}
|
|
12479
|
+
};
|
|
12480
|
+
var EEAT_SKIP_PAGE_TYPES = /* @__PURE__ */ new Set(["legal", "contact", "form", "home"]);
|
|
12481
|
+
function checkEeat(input, ctx) {
|
|
12482
|
+
const checks = [];
|
|
12483
|
+
if (EEAT_SKIP_PAGE_TYPES.has(ctx.pageType) || ctx.wordCount < 100) return checks;
|
|
12484
|
+
const s = STRINGS[ctx.locale] ?? STRINGS.fr;
|
|
12485
|
+
const hasAuthor = !!(input.author && input.author.trim());
|
|
12486
|
+
checks.push({
|
|
12487
|
+
id: "eeat-author",
|
|
12488
|
+
label: s.authorLabel,
|
|
12489
|
+
status: hasAuthor ? "pass" : "warning",
|
|
12490
|
+
message: hasAuthor ? s.authorPass : s.authorFail,
|
|
12491
|
+
category: "important",
|
|
12492
|
+
weight: 0,
|
|
12493
|
+
group: "eeat",
|
|
12494
|
+
...hasAuthor ? {} : { tip: s.authorTip }
|
|
12495
|
+
});
|
|
12496
|
+
if (hasAuthor) {
|
|
12497
|
+
const hasLink = !!(input.authorUrl && input.authorUrl.trim());
|
|
12498
|
+
checks.push({
|
|
12499
|
+
id: "eeat-author-entity",
|
|
12500
|
+
label: s.authorEntityLabel,
|
|
12501
|
+
status: hasLink ? "pass" : "warning",
|
|
12502
|
+
message: hasLink ? s.authorEntityPass : s.authorEntityFail,
|
|
12503
|
+
category: "bonus",
|
|
12504
|
+
weight: 0,
|
|
12505
|
+
group: "eeat"
|
|
12506
|
+
});
|
|
12507
|
+
}
|
|
12508
|
+
const datesOk = !!input.publishedAt && !!input.updatedAt;
|
|
12509
|
+
checks.push({
|
|
12510
|
+
id: "eeat-dates",
|
|
12511
|
+
label: s.datesLabel,
|
|
12512
|
+
status: datesOk ? "pass" : "warning",
|
|
12513
|
+
message: datesOk ? s.datesPass : s.datesFail,
|
|
12514
|
+
category: "bonus",
|
|
12515
|
+
weight: 0,
|
|
12516
|
+
group: "eeat"
|
|
12517
|
+
});
|
|
12518
|
+
const externalLinks = ctx.allLinks.filter((l) => /^https?:\/\//i.test(l.url));
|
|
12519
|
+
const hasSources = externalLinks.length > 0;
|
|
12520
|
+
checks.push({
|
|
12521
|
+
id: "eeat-sources",
|
|
12522
|
+
label: s.sourcesLabel,
|
|
12523
|
+
status: hasSources ? "pass" : "warning",
|
|
12524
|
+
message: hasSources ? s.sourcesPass(externalLinks.length) : s.sourcesFail,
|
|
12525
|
+
category: "bonus",
|
|
12526
|
+
weight: 0,
|
|
12527
|
+
group: "eeat",
|
|
12528
|
+
...hasSources ? {} : { tip: s.sourcesTip }
|
|
12529
|
+
});
|
|
12530
|
+
const hasPercent = /\d+([.,]\d+)?\s?%/.test(ctx.fullText);
|
|
12531
|
+
const numberCount = (ctx.fullText.match(/\b\d{2,}\b/g) || []).length;
|
|
12532
|
+
const hasData = hasPercent || numberCount >= 3;
|
|
12533
|
+
checks.push({
|
|
12534
|
+
id: "eeat-original-data",
|
|
12535
|
+
label: s.dataLabel,
|
|
12536
|
+
status: hasData ? "pass" : "warning",
|
|
12537
|
+
message: hasData ? s.dataPass : s.dataFail,
|
|
12538
|
+
category: "bonus",
|
|
12539
|
+
weight: 0,
|
|
12540
|
+
group: "eeat",
|
|
12541
|
+
...hasData ? {} : { tip: s.dataTip }
|
|
12542
|
+
});
|
|
12543
|
+
return checks;
|
|
12544
|
+
}
|
|
12545
|
+
|
|
12546
|
+
// src/rules/geo.ts
|
|
12547
|
+
var QUESTION_WORDS = {
|
|
12548
|
+
fr: ["comment", "pourquoi", "quand", "quel", "quelle", "quels", "quelles", "combien", "ou", "o\xF9", "qui", "que", "quoi", "est-ce"],
|
|
12549
|
+
en: ["how", "why", "when", "what", "which", "where", "who", "can", "do", "does", "is", "are", "should"]
|
|
12550
|
+
};
|
|
12551
|
+
var STRINGS2 = {
|
|
12552
|
+
fr: {
|
|
12553
|
+
answerLabel: "R\xE9ponse en t\xEAte (answer-first)",
|
|
12554
|
+
answerPass: "Le contenu d\xE9marre par une accroche concise \u2014 favorise l'extraction par l'IA.",
|
|
12555
|
+
answerFail: "Le contenu ne d\xE9marre pas par une r\xE9ponse concise \u2014 placez une r\xE9ponse directe en t\xEAte de page/section.",
|
|
12556
|
+
answerTip: "Format BLUF (Bottom Line Up Front) : r\xE9pondez \xE0 l'intention en 1-2 phrases avant de d\xE9velopper.",
|
|
12557
|
+
questionsLabel: "Titres en question",
|
|
12558
|
+
questionsPass: (n) => `${n} sous-titre(s) formul\xE9(s) en question \u2014 structure Q\u2192R id\xE9ale pour l'IA.`,
|
|
12559
|
+
questionsFail: "Aucun titre en question \u2014 formulez certains H2/H3 en questions (les moteurs IA citent les paires question/r\xE9ponse).",
|
|
12560
|
+
structureLabel: "Contenu extractible (listes / tableaux)",
|
|
12561
|
+
structurePass: "Listes ou tableaux d\xE9tect\xE9s \u2014 unit\xE9s facilement extraites et cit\xE9es par l'IA.",
|
|
12562
|
+
structureFail: "Aucune liste ni tableau \u2014 structurez les \xE9num\xE9rations et comparaisons en listes/tableaux.",
|
|
12563
|
+
chunkLabel: "Contenu d\xE9coup\xE9 (scannable)",
|
|
12564
|
+
chunkPass: "Contenu bien d\xE9coup\xE9 en sections \u2014 facilite l'extraction de passages.",
|
|
12565
|
+
chunkFail: "Contenu peu d\xE9coup\xE9 \u2014 ajoutez des sous-titres pour cr\xE9er des passages auto-suffisants."
|
|
12566
|
+
},
|
|
12567
|
+
en: {
|
|
12568
|
+
answerLabel: "Answer-first lead",
|
|
12569
|
+
answerPass: "Content opens with a concise lead \u2014 helps AI extraction.",
|
|
12570
|
+
answerFail: "Content does not open with a concise answer \u2014 put a direct answer at the top of the page/section.",
|
|
12571
|
+
answerTip: "BLUF (Bottom Line Up Front): answer the intent in 1-2 sentences before expanding.",
|
|
12572
|
+
questionsLabel: "Question-style headings",
|
|
12573
|
+
questionsPass: (n) => `${n} heading(s) phrased as questions \u2014 ideal Q\u2192A structure for AI.`,
|
|
12574
|
+
questionsFail: "No question headings \u2014 phrase some H2/H3 as questions (AI engines cite question/answer pairs).",
|
|
12575
|
+
structureLabel: "Extractable content (lists / tables)",
|
|
12576
|
+
structurePass: "Lists or tables detected \u2014 units easily extracted and cited by AI.",
|
|
12577
|
+
structureFail: "No list or table \u2014 structure enumerations and comparisons as lists/tables.",
|
|
12578
|
+
chunkLabel: "Chunked content (scannable)",
|
|
12579
|
+
chunkPass: "Content is well chunked into sections \u2014 helps passage extraction.",
|
|
12580
|
+
chunkFail: "Content is barely chunked \u2014 add subheadings to create self-contained passages."
|
|
12581
|
+
}
|
|
12582
|
+
};
|
|
12583
|
+
var GEO_SKIP_PAGE_TYPES = /* @__PURE__ */ new Set(["legal", "contact", "form", "home"]);
|
|
12584
|
+
function isQuestionHeading(text, locale) {
|
|
12585
|
+
const t = text.trim().toLowerCase();
|
|
12586
|
+
if (t.endsWith("?")) return true;
|
|
12587
|
+
const words = QUESTION_WORDS[locale] ?? QUESTION_WORDS.fr;
|
|
12588
|
+
return words.some((w) => t.startsWith(w + " ") || t.startsWith(w + "-"));
|
|
12589
|
+
}
|
|
12590
|
+
function collectLexicalSources(input) {
|
|
12591
|
+
const sources = [];
|
|
12592
|
+
if (input.heroRichText) sources.push(input.heroRichText);
|
|
12593
|
+
if (input.content) sources.push(input.content);
|
|
12594
|
+
if (Array.isArray(input.blocks)) {
|
|
12595
|
+
for (const b of input.blocks) {
|
|
12596
|
+
if (!b || typeof b !== "object") continue;
|
|
12597
|
+
const blk = b;
|
|
12598
|
+
if (blk.richText) sources.push(blk.richText);
|
|
12599
|
+
if (Array.isArray(blk.columns)) {
|
|
12600
|
+
for (const c of blk.columns) {
|
|
12601
|
+
if (c && typeof c === "object" && c.richText) {
|
|
12602
|
+
sources.push(c.richText);
|
|
12603
|
+
}
|
|
12604
|
+
}
|
|
12605
|
+
}
|
|
12606
|
+
}
|
|
12607
|
+
}
|
|
12608
|
+
return sources;
|
|
12609
|
+
}
|
|
12610
|
+
function containsLexicalType(node, type, depth = 0) {
|
|
12611
|
+
if (depth > 50 || !node || typeof node !== "object") return false;
|
|
12612
|
+
const n = node;
|
|
12613
|
+
if (n.type === type) return true;
|
|
12614
|
+
const root = n.root || n;
|
|
12615
|
+
const children = root.children || n.children;
|
|
12616
|
+
if (Array.isArray(children)) {
|
|
12617
|
+
for (const c of children) {
|
|
12618
|
+
if (containsLexicalType(c, type, depth + 1)) return true;
|
|
12619
|
+
}
|
|
12620
|
+
}
|
|
12621
|
+
return false;
|
|
12622
|
+
}
|
|
12623
|
+
function checkGeo(input, ctx) {
|
|
12624
|
+
const checks = [];
|
|
12625
|
+
if (GEO_SKIP_PAGE_TYPES.has(ctx.pageType) || ctx.wordCount < 150) return checks;
|
|
12626
|
+
const s = STRINGS2[ctx.locale] ?? STRINGS2.fr;
|
|
12627
|
+
const locale = ctx.locale;
|
|
12628
|
+
const firstSentence = (ctx.sentences[0] || "").trim();
|
|
12629
|
+
const firstSentenceWords = firstSentence ? firstSentence.split(/\s+/).length : 0;
|
|
12630
|
+
const answerFirst = firstSentenceWords > 0 && firstSentenceWords <= 30;
|
|
12631
|
+
checks.push({
|
|
12632
|
+
id: "geo-answer-first",
|
|
12633
|
+
label: s.answerLabel,
|
|
12634
|
+
status: answerFirst ? "pass" : "warning",
|
|
12635
|
+
message: answerFirst ? s.answerPass : s.answerFail,
|
|
12636
|
+
category: "bonus",
|
|
12637
|
+
weight: 0,
|
|
12638
|
+
group: "geo",
|
|
12639
|
+
...answerFirst ? {} : { tip: s.answerTip }
|
|
12640
|
+
});
|
|
12641
|
+
const questionHeadings = ctx.allHeadings.filter(
|
|
12642
|
+
(h) => h.tag !== "h1" && isQuestionHeading(h.text, locale)
|
|
12643
|
+
).length;
|
|
12644
|
+
checks.push({
|
|
12645
|
+
id: "geo-question-headings",
|
|
12646
|
+
label: s.questionsLabel,
|
|
12647
|
+
status: questionHeadings > 0 ? "pass" : "warning",
|
|
12648
|
+
message: questionHeadings > 0 ? s.questionsPass(questionHeadings) : s.questionsFail,
|
|
12649
|
+
category: "bonus",
|
|
12650
|
+
weight: 0,
|
|
12651
|
+
group: "geo"
|
|
12652
|
+
});
|
|
12653
|
+
const sources = collectLexicalSources(input);
|
|
12654
|
+
const hasList = sources.some((src) => extractListsFromLexical(src).length > 0);
|
|
12655
|
+
const hasTable = sources.some((src) => containsLexicalType(src, "table"));
|
|
12656
|
+
const structured = hasList || hasTable;
|
|
12657
|
+
checks.push({
|
|
12658
|
+
id: "geo-extractable-structure",
|
|
12659
|
+
label: s.structureLabel,
|
|
12660
|
+
status: structured ? "pass" : "warning",
|
|
12661
|
+
message: structured ? s.structurePass : s.structureFail,
|
|
12662
|
+
category: "bonus",
|
|
12663
|
+
weight: 0,
|
|
12664
|
+
group: "geo"
|
|
12665
|
+
});
|
|
12666
|
+
const subheadings = ctx.allHeadings.filter((h) => h.tag !== "h1").length;
|
|
12667
|
+
const expected = Math.max(1, Math.floor(ctx.wordCount / 300));
|
|
12668
|
+
const chunked = subheadings >= expected;
|
|
12669
|
+
checks.push({
|
|
12670
|
+
id: "geo-chunked",
|
|
12671
|
+
label: s.chunkLabel,
|
|
12672
|
+
status: chunked ? "pass" : "warning",
|
|
12673
|
+
message: chunked ? s.chunkPass : s.chunkFail,
|
|
12674
|
+
category: "bonus",
|
|
12675
|
+
weight: 0,
|
|
12676
|
+
group: "geo"
|
|
12677
|
+
});
|
|
12678
|
+
return checks;
|
|
12679
|
+
}
|
|
12680
|
+
|
|
12681
|
+
// src/rules/hreflang.ts
|
|
12682
|
+
var HREFLANG_RE = /^[a-z]{2,3}(-[a-z]{2,4})?$/i;
|
|
12683
|
+
var STRINGS3 = {
|
|
12684
|
+
fr: {
|
|
12685
|
+
codesLabel: "Codes hreflang valides",
|
|
12686
|
+
codesPass: "Tous les codes hreflang sont au format valide (langue ISO 639-1, r\xE9gion ISO 3166-1 optionnelle).",
|
|
12687
|
+
codesFail: (bad) => `Code(s) hreflang invalide(s) : ${bad} \u2014 un seul code erron\xE9 fait ignorer tout le cluster par Google.`,
|
|
12688
|
+
dupLabel: "Doublons hreflang",
|
|
12689
|
+
dupPass: "Aucun doublon de code hreflang.",
|
|
12690
|
+
dupFail: (dup) => `Code(s) hreflang en double : ${dup} \u2014 chaque locale doit \xEAtre d\xE9clar\xE9e une seule fois.`,
|
|
12691
|
+
absLabel: "URLs hreflang absolues",
|
|
12692
|
+
absPass: "Toutes les URLs hreflang sont absolues.",
|
|
12693
|
+
absFail: "Certaines URLs hreflang ne sont pas absolues \u2014 utilisez des URLs compl\xE8tes (https://...).",
|
|
12694
|
+
xdefLabel: "hreflang x-default",
|
|
12695
|
+
xdefPass: "Un x-default est d\xE9fini \u2014 bonne pratique pour les visiteurs hors locales cibl\xE9es.",
|
|
12696
|
+
xdefFail: 'Aucun x-default \u2014 ajoutez un hreflang="x-default" pour les locales non couvertes.'
|
|
12697
|
+
},
|
|
12698
|
+
en: {
|
|
12699
|
+
codesLabel: "Valid hreflang codes",
|
|
12700
|
+
codesPass: "All hreflang codes are well-formed (ISO 639-1 language, optional ISO 3166-1 region).",
|
|
12701
|
+
codesFail: (bad) => `Invalid hreflang code(s): ${bad} \u2014 a single bad code makes Google ignore the whole cluster.`,
|
|
12702
|
+
dupLabel: "Duplicate hreflang",
|
|
12703
|
+
dupPass: "No duplicate hreflang code.",
|
|
12704
|
+
dupFail: (dup) => `Duplicate hreflang code(s): ${dup} \u2014 each locale must be declared once.`,
|
|
12705
|
+
absLabel: "Absolute hreflang URLs",
|
|
12706
|
+
absPass: "All hreflang URLs are absolute.",
|
|
12707
|
+
absFail: "Some hreflang URLs are not absolute \u2014 use full URLs (https://...).",
|
|
12708
|
+
xdefLabel: "hreflang x-default",
|
|
12709
|
+
xdefPass: "An x-default is defined \u2014 good practice for visitors outside targeted locales.",
|
|
12710
|
+
xdefFail: 'No x-default \u2014 add hreflang="x-default" for locales you do not cover.'
|
|
12711
|
+
}
|
|
12712
|
+
};
|
|
12713
|
+
function checkHreflang(input, ctx) {
|
|
12714
|
+
const alts = input.localeAlternates;
|
|
12715
|
+
if (!Array.isArray(alts) || alts.length === 0) return [];
|
|
12716
|
+
const s = STRINGS3[ctx.locale] ?? STRINGS3.fr;
|
|
12717
|
+
const checks = [];
|
|
12718
|
+
const codes = alts.map((a) => (a.hreflang || "").trim()).filter(Boolean);
|
|
12719
|
+
const invalid = codes.filter((c) => c.toLowerCase() !== "x-default" && !HREFLANG_RE.test(c));
|
|
12720
|
+
checks.push({
|
|
12721
|
+
id: "hreflang-codes",
|
|
12722
|
+
label: s.codesLabel,
|
|
12723
|
+
status: invalid.length === 0 ? "pass" : "fail",
|
|
12724
|
+
message: invalid.length === 0 ? s.codesPass : s.codesFail(invalid.join(", ")),
|
|
12725
|
+
category: "important",
|
|
12726
|
+
weight: 2,
|
|
12727
|
+
group: "hreflang"
|
|
12728
|
+
});
|
|
12729
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12730
|
+
const dups = /* @__PURE__ */ new Set();
|
|
12731
|
+
for (const c of codes.map((c2) => c2.toLowerCase())) {
|
|
12732
|
+
if (seen.has(c)) dups.add(c);
|
|
12733
|
+
seen.add(c);
|
|
12734
|
+
}
|
|
12735
|
+
checks.push({
|
|
12736
|
+
id: "hreflang-duplicates",
|
|
12737
|
+
label: s.dupLabel,
|
|
12738
|
+
status: dups.size === 0 ? "pass" : "warning",
|
|
12739
|
+
message: dups.size === 0 ? s.dupPass : s.dupFail([...dups].join(", ")),
|
|
12740
|
+
category: "important",
|
|
12741
|
+
weight: 1,
|
|
12742
|
+
group: "hreflang"
|
|
12743
|
+
});
|
|
12744
|
+
const allAbsolute = alts.every((a) => /^https?:\/\//i.test((a.href || "").trim()));
|
|
12745
|
+
checks.push({
|
|
12746
|
+
id: "hreflang-absolute",
|
|
12747
|
+
label: s.absLabel,
|
|
12748
|
+
status: allAbsolute ? "pass" : "warning",
|
|
12749
|
+
message: allAbsolute ? s.absPass : s.absFail,
|
|
12750
|
+
category: "important",
|
|
12751
|
+
weight: 1,
|
|
12752
|
+
group: "hreflang"
|
|
12753
|
+
});
|
|
12754
|
+
const hasXDefault = codes.some((c) => c.toLowerCase() === "x-default");
|
|
12755
|
+
checks.push({
|
|
12756
|
+
id: "hreflang-x-default",
|
|
12757
|
+
label: s.xdefLabel,
|
|
12758
|
+
status: hasXDefault ? "pass" : "warning",
|
|
12759
|
+
message: hasXDefault ? s.xdefPass : s.xdefFail,
|
|
12760
|
+
category: "bonus",
|
|
12761
|
+
weight: 1,
|
|
12762
|
+
group: "hreflang"
|
|
12763
|
+
});
|
|
12764
|
+
return checks;
|
|
12765
|
+
}
|
|
12766
|
+
|
|
11514
12767
|
// src/index.ts
|
|
11515
12768
|
function buildContext(data, config) {
|
|
11516
12769
|
const {
|
|
@@ -11715,6 +12968,9 @@ function analyzeSeo(data, config) {
|
|
|
11715
12968
|
{ group: "freshness", fn: checkFreshness },
|
|
11716
12969
|
{ group: "technical", fn: checkTechnical },
|
|
11717
12970
|
{ group: "accessibility", fn: checkAccessibility },
|
|
12971
|
+
{ group: "eeat", fn: checkEeat },
|
|
12972
|
+
{ group: "geo", fn: checkGeo },
|
|
12973
|
+
{ group: "hreflang", fn: checkHreflang },
|
|
11718
12974
|
// E-commerce rules only run for product pages
|
|
11719
12975
|
...data.isProduct ? [{ group: "ecommerce", fn: checkEcommerce }] : []
|
|
11720
12976
|
];
|
|
@@ -11746,7 +13002,24 @@ function analyzeSeo(data, config) {
|
|
|
11746
13002
|
if (score >= SCORE_EXCELLENT) level = "excellent";
|
|
11747
13003
|
else if (score >= SCORE_GOOD) level = "good";
|
|
11748
13004
|
else if (score >= SCORE_OK) level = "ok";
|
|
11749
|
-
|
|
13005
|
+
const aiChecks = checks.filter(
|
|
13006
|
+
(c) => c.group === "geo" || c.group === "eeat" || c.id === "schema-coverage"
|
|
13007
|
+
);
|
|
13008
|
+
let aiReadiness;
|
|
13009
|
+
if (aiChecks.length > 0) {
|
|
13010
|
+
let aiEarned = 0;
|
|
13011
|
+
for (const c of aiChecks) {
|
|
13012
|
+
if (c.status === "pass") aiEarned += 1;
|
|
13013
|
+
else if (c.status === "warning") aiEarned += WARNING_MULTIPLIER;
|
|
13014
|
+
}
|
|
13015
|
+
const aiScore = Math.round(aiEarned / aiChecks.length * 100);
|
|
13016
|
+
let aiLevel = "poor";
|
|
13017
|
+
if (aiScore >= SCORE_EXCELLENT) aiLevel = "excellent";
|
|
13018
|
+
else if (aiScore >= SCORE_GOOD) aiLevel = "good";
|
|
13019
|
+
else if (aiScore >= SCORE_OK) aiLevel = "ok";
|
|
13020
|
+
aiReadiness = { score: aiScore, level: aiLevel, checkCount: aiChecks.length };
|
|
13021
|
+
}
|
|
13022
|
+
return { score, level, checks, ...aiReadiness ? { aiReadiness } : {} };
|
|
11750
13023
|
}
|
|
11751
13024
|
|
|
11752
13025
|
export { ACTION_VERBS, EVERGREEN_SLUGS, FLESCH_THRESHOLDS, GENERIC_ANCHORS, KEYWORD_DENSITY_MAX, KEYWORD_DENSITY_MIN, KEYWORD_DENSITY_WARN, LEGAL_SLUGS_MAP, MAX_RECURSION_DEPTH, META_DESC_LENGTH_MAX, META_DESC_LENGTH_MIN, MIN_WORDS_FORM, MIN_WORDS_GENERIC, MIN_WORDS_LEGAL, MIN_WORDS_POST, MIN_WORDS_THIN, POWER_WORDS, POWER_WORDS_FR, READABILITY_THRESHOLDS, SCORE_EXCELLENT, SCORE_GOOD, SCORE_OK, STOP_WORDS, STOP_WORD_COMPOUNDS_MAP, TITLE_LENGTH_MAX, TITLE_LENGTH_MIN, UTILITY_SLUGS, WARNING_MULTIPLIER, analyzeSeo, buildSeoInputFromDoc, calculateFlesch, calculateFleschFR, checkHeadingHierarchy, checkImagesInBlocks, countKeywordOccurrences, countLongSections, countSentences, countSyllablesEN, countSyllablesFR, countWords, createAiRewriteHandler, createDuplicateContentHandler, createGenerateHandler, createHistoryHandler, createKeywordResearchHandler, createPerformanceHandler, createRedirectChainsHandler, createSchemaGeneratorHandler, createSeoPerformanceCollection, createSeoScoreHistoryCollection, createSitemapAuditHandler, createTrackSeoScoreHook, detectPageType, detectPassiveVoice, extractHeadingsFromLexical, extractImagesFromLexical, extractLinkUrlsFromLexical, extractLinksFromLexical, extractListsFromLexical, extractTextFromLexical, fetchAllDocs, getActionVerbs, getActionVerbsFR, getDashboardT, getEvergreenSlugs, getGenericAnchors, getLegalSlugs, getPowerWords, getStopWordCompounds, getStopWords, getStopWordsFR, getUtilitySlugs, hasTransitionWord, isStopWordInCompoundExpression, keywordMatchesText, metaFields, normalizeForComparison, registerDashboardTranslations, resolveAnalysisLocale, seoAnalyzerPlugin, seoFields, seoAnalyzerPlugin as seoPlugin, slugifyKeyword };
|