@lynxflow/seo-engine 1.4.1 โ 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -339
- package/TUTORIEL_INTEGRATION_SITE.md +283 -0
- package/connectors/wordpress/lynxseo-connector.zip +0 -0
- package/dist/ad-intelligence-cro.d.ts +58 -0
- package/dist/ai-bots-log-analyzer.d.ts +36 -0
- package/dist/analytics-client.d.ts +48 -11
- package/dist/auth-key.d.ts +9 -0
- package/dist/brand-dna-calendar.d.ts +35 -0
- package/dist/copy-frameworks-master.d.ts +56 -0
- package/dist/cro-copywriting-engine.d.ts +46 -0
- package/dist/crosslink-scorer.d.ts +33 -0
- package/dist/embeddable-seo-widget.d.ts +12 -0
- package/dist/engine.d.ts +29 -1
- package/dist/engine.test.d.ts +1 -0
- package/dist/extended-schemas.d.ts +106 -0
- package/dist/geo-mesh-linking.d.ts +33 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +4162 -128
- package/dist/index.mjs +4090 -98
- package/dist/instant-matrix-search.d.ts +19 -0
- package/dist/isr-cache-manager.d.ts +35 -0
- package/dist/knowledge-graph-linker.d.ts +29 -0
- package/dist/legal-disclaimers.d.ts +46 -0
- package/dist/llm-content-cleaner.d.ts +16 -0
- package/dist/llm-prompt.d.ts +13 -0
- package/dist/master-marketing-engine.d.ts +114 -0
- package/dist/matrix-engine.d.ts +201 -0
- package/dist/mcp-seo-server.d.ts +21 -0
- package/dist/ngram-density-analyzer.d.ts +35 -0
- package/dist/rank-math-parity.d.ts +58 -0
- package/dist/real-reviews-sync.d.ts +73 -0
- package/dist/rss-syndication-feed.d.ts +25 -0
- package/dist/schema-builder.d.ts +6 -0
- package/dist/seo-opportunities-decay.d.ts +54 -0
- package/dist/serp-history-alerts.d.ts +27 -0
- package/dist/slug-engine.d.ts +32 -0
- package/dist/social-ads-seo.d.ts +77 -0
- package/dist/social-growth-suite.d.ts +125 -0
- package/dist/social-trend-seo.d.ts +38 -0
- package/dist/social-video-seo.d.ts +62 -0
- package/dist/team-rbac.d.ts +17 -0
- package/dist/technical-rules-auditor.d.ts +50 -0
- package/dist/types.d.ts +4 -44
- package/dist/yoast-parity.d.ts +45 -0
- package/package.json +8 -5
- package/src/ad-intelligence-cro.ts +140 -0
- package/src/ai-bots-log-analyzer.ts +127 -0
- package/src/analytics-client.ts +222 -48
- package/src/auth-key.ts +59 -0
- package/src/brand-dna-calendar.ts +120 -0
- package/src/copy-frameworks-master.ts +89 -0
- package/src/cro-copywriting-engine.ts +105 -0
- package/src/crosslink-scorer.ts +101 -0
- package/src/embeddable-seo-widget.ts +57 -0
- package/src/engine.test.ts +136 -0
- package/src/engine.ts +218 -27
- package/src/extended-schemas.ts +279 -0
- package/src/geo-mesh-linking.ts +93 -0
- package/src/index.ts +89 -0
- package/src/instant-matrix-search.ts +48 -0
- package/src/isr-cache-manager.ts +78 -0
- package/src/knowledge-graph-linker.ts +81 -0
- package/src/legal-disclaimers.ts +407 -0
- package/src/llm-content-cleaner.ts +64 -0
- package/src/llm-prompt.ts +70 -0
- package/src/master-marketing-engine.ts +314 -0
- package/src/matrix-engine.ts +818 -0
- package/src/mcp-seo-server.ts +108 -0
- package/src/ngram-density-analyzer.ts +108 -0
- package/src/rank-math-parity.ts +235 -0
- package/src/real-reviews-sync.ts +207 -0
- package/src/rss-syndication-feed.ts +56 -0
- package/src/schema-builder.ts +71 -24
- package/src/seo-opportunities-decay.ts +154 -0
- package/src/serp-history-alerts.ts +75 -0
- package/src/slug-engine.ts +183 -0
- package/src/social-ads-seo.ts +211 -0
- package/src/social-growth-suite.ts +310 -0
- package/src/social-trend-seo.ts +103 -0
- package/src/social-video-seo.ts +100 -0
- package/src/team-rbac.ts +61 -0
- package/src/technical-rules-auditor.ts +171 -0
- package/src/types.ts +22 -47
- package/src/yoast-parity.ts +161 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ฐ RSS 2.0 & Atom Syndication Feed Generator (Inspiration: pages-seo/feed.xml.js)
|
|
3
|
+
*
|
|
4
|
+
* Automatically generates standards-compliant RSS 2.0 and Atom feeds for published
|
|
5
|
+
* programmatic landing pages and articles to accelerate syndication and indexing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FeedItem {
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
description: string;
|
|
12
|
+
publishedAt: string; // ISO date
|
|
13
|
+
author?: string;
|
|
14
|
+
category?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class RssSyndicationFeedGenerator {
|
|
18
|
+
/**
|
|
19
|
+
* Generates a valid RSS 2.0 XML string.
|
|
20
|
+
*/
|
|
21
|
+
static generateRss2(opts: {
|
|
22
|
+
siteTitle: string;
|
|
23
|
+
siteUrl: string;
|
|
24
|
+
description: string;
|
|
25
|
+
items: FeedItem[];
|
|
26
|
+
}): string {
|
|
27
|
+
const itemsXml = opts.items
|
|
28
|
+
.map(
|
|
29
|
+
(item) => `
|
|
30
|
+
<item>
|
|
31
|
+
<title><![CDATA[${item.title}]]></title>
|
|
32
|
+
<link>${item.url}</link>
|
|
33
|
+
<guid isPermaLink="true">${item.url}</guid>
|
|
34
|
+
<description><![CDATA[${item.description}]]></description>
|
|
35
|
+
<pubDate>${new Date(item.publishedAt).toUTCString()}</pubDate>
|
|
36
|
+
${item.category ? `<category>${item.category}</category>` : ""}
|
|
37
|
+
${item.author ? `<author>${item.author}</author>` : ""}
|
|
38
|
+
</item>
|
|
39
|
+
`.trim(),
|
|
40
|
+
)
|
|
41
|
+
.join("\n");
|
|
42
|
+
|
|
43
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
45
|
+
<channel>
|
|
46
|
+
<title><![CDATA[${opts.siteTitle}]]></title>
|
|
47
|
+
<link>${opts.siteUrl}</link>
|
|
48
|
+
<description><![CDATA[${opts.description}]]></description>
|
|
49
|
+
<atom:link href="${opts.siteUrl}/feed.xml" rel="self" type="application/rss+xml" />
|
|
50
|
+
<language>fr</language>
|
|
51
|
+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
|
52
|
+
${itemsXml}
|
|
53
|
+
</channel>
|
|
54
|
+
</rss>`.trim();
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/schema-builder.ts
CHANGED
|
@@ -21,35 +21,82 @@ export class SchemaGraphBuilder {
|
|
|
21
21
|
ratingValue?: string;
|
|
22
22
|
reviewCount?: string;
|
|
23
23
|
locale?: string;
|
|
24
|
+
reviews?: Array<{
|
|
25
|
+
authorName: string;
|
|
26
|
+
ratingValue: number;
|
|
27
|
+
reviewText: string;
|
|
28
|
+
datePublished?: string;
|
|
29
|
+
}>;
|
|
24
30
|
}): Record<string, unknown> {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
const productNode: Record<string, unknown> = {
|
|
32
|
+
"@type": "Product",
|
|
33
|
+
"@id": `${opts.url}#product`,
|
|
34
|
+
name: opts.name,
|
|
35
|
+
description: opts.description,
|
|
36
|
+
image: opts.image || `${opts.url}/api/og`,
|
|
37
|
+
brand: {
|
|
38
|
+
"@type": "Brand",
|
|
39
|
+
name: opts.brandName,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (opts.locale) {
|
|
44
|
+
productNode.inLanguage = opts.locale;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Automatically compute or attach real AggregateRating
|
|
48
|
+
if (opts.ratingValue && opts.reviewCount) {
|
|
49
|
+
productNode.aggregateRating = {
|
|
50
|
+
"@type": "AggregateRating",
|
|
51
|
+
ratingValue: opts.ratingValue,
|
|
52
|
+
reviewCount: opts.reviewCount,
|
|
53
|
+
bestRating: "5",
|
|
54
|
+
worstRating: "1",
|
|
55
|
+
};
|
|
56
|
+
} else if (opts.reviews && opts.reviews.length > 0) {
|
|
57
|
+
const avg = (
|
|
58
|
+
opts.reviews.reduce((acc, r) => acc + r.ratingValue, 0) / opts.reviews.length
|
|
59
|
+
).toFixed(1);
|
|
60
|
+
productNode.aggregateRating = {
|
|
61
|
+
"@type": "AggregateRating",
|
|
62
|
+
ratingValue: avg,
|
|
63
|
+
reviewCount: opts.reviews.length.toString(),
|
|
64
|
+
bestRating: "5",
|
|
65
|
+
worstRating: "1",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Attach individual authentic reviews if provided
|
|
70
|
+
if (opts.reviews && opts.reviews.length > 0) {
|
|
71
|
+
productNode.review = opts.reviews.map((r) => ({
|
|
72
|
+
"@type": "Review",
|
|
73
|
+
author: {
|
|
74
|
+
"@type": "Person",
|
|
75
|
+
name: r.authorName,
|
|
36
76
|
},
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
77
|
+
datePublished: r.datePublished || new Date().toISOString().split("T")[0],
|
|
78
|
+
reviewBody: r.reviewText,
|
|
79
|
+
reviewRating: {
|
|
80
|
+
"@type": "Rating",
|
|
81
|
+
ratingValue: r.ratingValue.toString(),
|
|
41
82
|
bestRating: "5",
|
|
42
83
|
worstRating: "1",
|
|
43
84
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Only emit Offer if real price is provided
|
|
89
|
+
if (opts.price !== undefined) {
|
|
90
|
+
productNode.offers = {
|
|
91
|
+
"@type": "Offer",
|
|
92
|
+
price: opts.price.toString(),
|
|
93
|
+
priceCurrency: opts.currency || "EUR",
|
|
94
|
+
availability: "https://schema.org/InStock",
|
|
95
|
+
url: opts.url,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const graph: Record<string, unknown>[] = [productNode];
|
|
53
100
|
|
|
54
101
|
if (opts.faqs && opts.faqs.length > 0) {
|
|
55
102
|
graph.push({
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ฏ Advanced SEO Opportunities & Content Decay Detector (Inspiration: crawlseo/seo-opportunities.ts)
|
|
3
|
+
*
|
|
4
|
+
* Implements mathematical models to detect:
|
|
5
|
+
* 1. Striking Distance Keywords (Positions 4 to 20 with high impressions)
|
|
6
|
+
* 2. Low CTR Gaps (Real CTR < Expected Industry Curve)
|
|
7
|
+
* 3. Content Decay (Pages losing > 30% traffic over 28-day windows)
|
|
8
|
+
* 4. Keyword Cannibalization (Multiple internal URLs splitting Google rankings)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface KeywordMetric {
|
|
12
|
+
query: string;
|
|
13
|
+
url: string;
|
|
14
|
+
position: number;
|
|
15
|
+
clicks: number;
|
|
16
|
+
impressions: number;
|
|
17
|
+
ctr: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ContentDecayInput {
|
|
21
|
+
url: string;
|
|
22
|
+
previous28DaysClicks: number;
|
|
23
|
+
last28DaysClicks: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OpportunityFinding {
|
|
27
|
+
type: "striking_distance" | "low_ctr" | "content_decay" | "cannibalization";
|
|
28
|
+
severity: "critical" | "high" | "medium" | "low";
|
|
29
|
+
title: string;
|
|
30
|
+
detail: string;
|
|
31
|
+
query?: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
potentialTrafficGain?: number;
|
|
34
|
+
suggestedAction: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SeoOpportunitiesDecayDetector {
|
|
38
|
+
/**
|
|
39
|
+
* Expected organic CTR curve by Google SERP rank position.
|
|
40
|
+
*/
|
|
41
|
+
static getExpectedCtr(position: number): number {
|
|
42
|
+
if (position <= 1) return 0.28;
|
|
43
|
+
if (position <= 2) return 0.15;
|
|
44
|
+
if (position <= 3) return 0.11;
|
|
45
|
+
if (position <= 5) return 0.07;
|
|
46
|
+
if (position <= 10) return 0.03;
|
|
47
|
+
if (position <= 20) return 0.01;
|
|
48
|
+
return 0.005;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detects Striking Distance opportunities (Keywords ranked between #4 and #20).
|
|
53
|
+
*/
|
|
54
|
+
static detectStrikingDistance(keywords: KeywordMetric[], minImpressions = 30): OpportunityFinding[] {
|
|
55
|
+
return keywords
|
|
56
|
+
.filter((k) => k.position >= 4 && k.position <= 20 && k.impressions >= minImpressions)
|
|
57
|
+
.sort((a, b) => b.impressions - a.impressions)
|
|
58
|
+
.map((k) => {
|
|
59
|
+
const targetPos = Math.max(1, k.position - 3);
|
|
60
|
+
const potentialClicks = Math.round(k.impressions * this.getExpectedCtr(targetPos)) - k.clicks;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
type: "striking_distance",
|
|
64
|
+
severity: k.position <= 10 ? "high" : "medium",
|
|
65
|
+
title: `๐ฏ Mot-clรฉ ร portรฉe de main : "${k.query}" (#${k.position.toFixed(1)})`,
|
|
66
|
+
detail: `${k.impressions} impressions mensuelles actuelles. Atteindre le top 3 gรฉnรฉrerait environ +${Math.max(1, potentialClicks)} clics/mois.`,
|
|
67
|
+
query: k.query,
|
|
68
|
+
url: k.url,
|
|
69
|
+
potentialTrafficGain: Math.max(1, potentialClicks),
|
|
70
|
+
suggestedAction: "Ajoutez 1 lien interne contextualisรฉ vers cette page et enrichissez la section FAQ avec ce terme.",
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detects Low CTR Gaps (Ranked in top 15 but underperforming in clicks).
|
|
77
|
+
*/
|
|
78
|
+
static detectLowCtrGaps(keywords: KeywordMetric[], minImpressions = 50): OpportunityFinding[] {
|
|
79
|
+
return keywords
|
|
80
|
+
.filter((k) => k.impressions >= minImpressions && k.position <= 15)
|
|
81
|
+
.map((k) => {
|
|
82
|
+
const expected = this.getExpectedCtr(k.position);
|
|
83
|
+
const gap = expected - k.ctr;
|
|
84
|
+
return { ...k, expected, gap };
|
|
85
|
+
})
|
|
86
|
+
.filter((k) => k.gap > 0.02)
|
|
87
|
+
.sort((a, b) => b.impressions * b.gap - a.impressions * a.gap)
|
|
88
|
+
.map((k) => ({
|
|
89
|
+
type: "low_ctr" as const,
|
|
90
|
+
severity: "high" as const,
|
|
91
|
+
title: `โก CTR anormalement bas sur "${k.query}" (${(k.ctr * 100).toFixed(1)}% vs ${(k.expected * 100).toFixed(1)}% attendu)`,
|
|
92
|
+
detail: `Classรฉ #${k.position.toFixed(1)} avec ${k.impressions} impressions, mais un taux de clic infรฉrieur aux moyennes du secteur.`,
|
|
93
|
+
query: k.query,
|
|
94
|
+
url: k.url,
|
|
95
|
+
potentialTrafficGain: Math.round(k.impressions * k.gap),
|
|
96
|
+
suggestedAction: "Rรฉรฉcrivez la balise <title> et la mรฉta-description avec des mots d'action, des chiffres ou un crochet d'actualitรฉ.",
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detects Content Decay (Pages losing more than 30% of traffic).
|
|
102
|
+
*/
|
|
103
|
+
static detectContentDecay(pages: ContentDecayInput[]): OpportunityFinding[] {
|
|
104
|
+
return pages
|
|
105
|
+
.filter((p) => p.previous28DaysClicks >= 20)
|
|
106
|
+
.map((p) => {
|
|
107
|
+
const dropRatio = (p.previous28DaysClicks - p.last28DaysClicks) / p.previous28DaysClicks;
|
|
108
|
+
return { ...p, dropRatio };
|
|
109
|
+
})
|
|
110
|
+
.filter((p) => p.dropRatio >= 0.3)
|
|
111
|
+
.sort((a, b) => b.dropRatio - a.dropRatio)
|
|
112
|
+
.map((p) => ({
|
|
113
|
+
type: "content_decay" as const,
|
|
114
|
+
severity: "critical" as const,
|
|
115
|
+
title: `๐ Dรฉclin de contenu dรฉtectรฉ (-${Math.round(p.dropRatio * 100)}% de trafic)`,
|
|
116
|
+
detail: `Le trafic est passรฉ de ${p.previous28DaysClicks} ร ${p.last28DaysClicks} clics sur les 28 derniers jours.`,
|
|
117
|
+
url: p.url,
|
|
118
|
+
potentialTrafficGain: p.previous28DaysClicks - p.last28DaysClicks,
|
|
119
|
+
suggestedAction: "Mettez ร jour les donnรฉes datรฉes, ajoutez une section 2026 et vรฉrifiez si des concurrents ont publiรฉ un meilleur guide.",
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Detects Keyword Cannibalization across internal URLs.
|
|
125
|
+
*/
|
|
126
|
+
static detectCannibalization(keywords: KeywordMetric[]): OpportunityFinding[] {
|
|
127
|
+
const queryMap: Record<string, KeywordMetric[]> = {};
|
|
128
|
+
|
|
129
|
+
for (const k of keywords) {
|
|
130
|
+
if (!queryMap[k.query]) queryMap[k.query] = [];
|
|
131
|
+
queryMap[k.query].push(k);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const cannibalized: OpportunityFinding[] = [];
|
|
135
|
+
|
|
136
|
+
for (const [query, urls] of Object.entries(queryMap)) {
|
|
137
|
+
if (urls.length > 1) {
|
|
138
|
+
const totalImpressions = urls.reduce((acc, curr) => acc + curr.impressions, 0);
|
|
139
|
+
if (totalImpressions >= 20) {
|
|
140
|
+
cannibalized.push({
|
|
141
|
+
type: "cannibalization",
|
|
142
|
+
severity: "high",
|
|
143
|
+
title: `โ๏ธ Cannibalisation dรฉtectรฉe sur "${query}" (${urls.length} URLs en compรฉtition)`,
|
|
144
|
+
detail: `Les URLs suivantes se partagent les clics : ${urls.map((u) => u.url).join(", ")}.`,
|
|
145
|
+
query,
|
|
146
|
+
suggestedAction: "Fusionnez les deux contenus en une seule page maรฎtresse ou placez une balise canonique vers la plus performante.",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return cannibalized;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ๐ SERP Chronological History & Webhook Alert Engine (Pole 5)
|
|
3
|
+
*
|
|
4
|
+
* Tracks keyword rank history over time, detects ranking shifts,
|
|
5
|
+
* and triggers automated alerts (Top 3, Top 10, rank drop).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface KeywordRankSnapshot {
|
|
9
|
+
keyword: string;
|
|
10
|
+
date: string; // ISO format: YYYY-MM-DD
|
|
11
|
+
position: number;
|
|
12
|
+
url: string;
|
|
13
|
+
searchEngine: "google-mobile" | "google-desktop" | "bing";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RankShiftAlert {
|
|
17
|
+
keyword: string;
|
|
18
|
+
previousPosition: number;
|
|
19
|
+
newPosition: number;
|
|
20
|
+
shiftType: "entered-top-3" | "entered-top-10" | "critical-drop" | "improved";
|
|
21
|
+
message: string;
|
|
22
|
+
timestamp: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SerpRankHistoryEngine {
|
|
26
|
+
/**
|
|
27
|
+
* Evaluates position shifts between two snapshots and returns alerts if thresholds are breached.
|
|
28
|
+
*/
|
|
29
|
+
static analyzeRankShift(
|
|
30
|
+
previousSnapshot: KeywordRankSnapshot,
|
|
31
|
+
currentSnapshot: KeywordRankSnapshot,
|
|
32
|
+
): RankShiftAlert | null {
|
|
33
|
+
const prev = previousSnapshot.position;
|
|
34
|
+
const curr = currentSnapshot.position;
|
|
35
|
+
|
|
36
|
+
if (prev === curr) return null;
|
|
37
|
+
|
|
38
|
+
const diff = prev - curr; // Positive means gained positions, negative means dropped
|
|
39
|
+
|
|
40
|
+
if (curr <= 3 && prev > 3) {
|
|
41
|
+
return {
|
|
42
|
+
keyword: currentSnapshot.keyword,
|
|
43
|
+
previousPosition: prev,
|
|
44
|
+
newPosition: curr,
|
|
45
|
+
shiftType: "entered-top-3",
|
|
46
|
+
message: `๐ Le mot-clรฉ "${currentSnapshot.keyword}" a pรฉnรฉtrรฉ le TOP 3 (#${curr}) !`,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (curr <= 10 && prev > 10) {
|
|
52
|
+
return {
|
|
53
|
+
keyword: currentSnapshot.keyword,
|
|
54
|
+
previousPosition: prev,
|
|
55
|
+
newPosition: curr,
|
|
56
|
+
shiftType: "entered-top-10",
|
|
57
|
+
message: `๐ฏ Le mot-clรฉ "${currentSnapshot.keyword}" est dรฉsormais en Premiรจre Page (#${curr}) !`,
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (diff <= -5) {
|
|
63
|
+
return {
|
|
64
|
+
keyword: currentSnapshot.keyword,
|
|
65
|
+
previousPosition: prev,
|
|
66
|
+
newPosition: curr,
|
|
67
|
+
shiftType: "critical-drop",
|
|
68
|
+
message: `โ ๏ธ Chute de position dรฉtectรฉe sur "${currentSnapshot.keyword}" (${prev} โ ${curr}).`,
|
|
69
|
+
timestamp: new Date().toISOString(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global Multilingual Programmatic SEO Slug Engine
|
|
3
|
+
* Enforces the 6 golden rules of SEO-optimized URL paths across 10+ languages:
|
|
4
|
+
* English, French, German, Spanish, Italian, Portuguese, Dutch, Russian, Swedish, Polish.
|
|
5
|
+
*
|
|
6
|
+
* 1. Short & focused (3-5 target keywords)
|
|
7
|
+
* 2. Hyphen (-) as the sole delimiter
|
|
8
|
+
* 3. 100% lowercase, diacritics/accents stripped
|
|
9
|
+
* 4. Stop words removed for 10+ international languages
|
|
10
|
+
* 5. Zero dates/numbers for evergreen content
|
|
11
|
+
* 6. Primary keyword positioned first
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MULTILINGUAL_STOP_WORDS: Record<string, Set<string>> = {
|
|
15
|
+
// ๐ฌ๐ง English
|
|
16
|
+
en: new Set([
|
|
17
|
+
"the", "a", "an", "and", "or", "for", "with", "without", "in", "on", "at",
|
|
18
|
+
"by", "to", "of", "from", "as", "is", "are", "be", "this", "that", "these",
|
|
19
|
+
"those", "how", "what", "which", "who", "whom", "when", "where", "why",
|
|
20
|
+
"your", "my", "our", "their", "its", "into", "over", "after", "before",
|
|
21
|
+
]),
|
|
22
|
+
|
|
23
|
+
// ๐ซ๐ท French
|
|
24
|
+
fr: new Set([
|
|
25
|
+
"le", "la", "les", "un", "une", "des", "du", "de", "d", "l", "pour", "avec",
|
|
26
|
+
"sans", "sur", "sous", "dans", "par", "et", "ou", "a", "au", "aux", "en",
|
|
27
|
+
"ce", "cet", "cette", "ces", "son", "sa", "ses", "leur", "leurs", "notre",
|
|
28
|
+
"votre", "nos", "vos", "qui", "que", "quoi", "dont", "comment", "pourquoi",
|
|
29
|
+
]),
|
|
30
|
+
|
|
31
|
+
// ๐ฉ๐ช German
|
|
32
|
+
de: new Set([
|
|
33
|
+
"der", "die", "das", "ein", "eine", "eines", "einer", "einem", "einen",
|
|
34
|
+
"und", "oder", "fur", "fรผr", "mit", "ohne", "auf", "unter", "in", "im", "von",
|
|
35
|
+
"vom", "zu", "zum", "zur", "bei", "beim", "nach", "aus", "uber", "รผber", "vor",
|
|
36
|
+
"wie", "was", "wer", "warum", "ihr", "ihre", "sein", "seine", "mein",
|
|
37
|
+
]),
|
|
38
|
+
|
|
39
|
+
// ๐ช๐ธ Spanish
|
|
40
|
+
es: new Set([
|
|
41
|
+
"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "para",
|
|
42
|
+
"con", "sin", "en", "por", "y", "o", "a", "al", "este", "esta", "estos",
|
|
43
|
+
"estas", "su", "sus", "mi", "mis", "tu", "tus", "como", "que", "cual",
|
|
44
|
+
]),
|
|
45
|
+
|
|
46
|
+
// ๐ฎ๐น Italian
|
|
47
|
+
it: new Set([
|
|
48
|
+
"il", "lo", "la", "i", "gli", "le", "un", "uno", "una", "un'", "di", "del",
|
|
49
|
+
"dello", "della", "dei", "degli", "delle", "a", "al", "allo", "alla", "ai",
|
|
50
|
+
"agli", "alle", "da", "dal", "in", "con", "su", "per", "tra", "fra", "e", "o",
|
|
51
|
+
"come", "cosa", "perche", "questo", "questa",
|
|
52
|
+
]),
|
|
53
|
+
|
|
54
|
+
// ๐ต๐น Portuguese
|
|
55
|
+
pt: new Set([
|
|
56
|
+
"o", "a", "os", "as", "um", "uma", "uns", "umas", "de", "do", "da", "dos",
|
|
57
|
+
"das", "em", "no", "na", "nos", "nas", "para", "por", "com", "sem", "e",
|
|
58
|
+
"ou", "como", "que", "qual", "seu", "sua", "seus", "suas", "este", "esta",
|
|
59
|
+
]),
|
|
60
|
+
|
|
61
|
+
// ๐ณ๐ฑ Dutch
|
|
62
|
+
nl: new Set([
|
|
63
|
+
"de", "het", "een", "en", "of", "voor", "met", "zonder", "in", "op", "bij",
|
|
64
|
+
"van", "naar", "door", "over", "onder", "als", "hoe", "wat", "wie", "waarom",
|
|
65
|
+
"zijn", "haar", "hun", "onze", "uw",
|
|
66
|
+
]),
|
|
67
|
+
|
|
68
|
+
// ๐ท๐บ Russian (Transliterated / Latinized)
|
|
69
|
+
ru: new Set([
|
|
70
|
+
"i", "v", "na", "s", "po", "dlya", "ot", "iz", "k", "o", "za", "kak",
|
|
71
|
+
"chto", "gde", "kogda", "pochemu", "eto", "etot", "eta", "eti", "ili",
|
|
72
|
+
]),
|
|
73
|
+
|
|
74
|
+
// ๐ธ๐ช Swedish
|
|
75
|
+
sv: new Set([
|
|
76
|
+
"en", "ett", "den", "det", "de", "och", "eller", "for", "med", "utan",
|
|
77
|
+
"pa", "under", "i", "av", "till", "fran", "om", "hur", "vad", "varfor",
|
|
78
|
+
]),
|
|
79
|
+
|
|
80
|
+
// ๐ต๐ฑ Polish
|
|
81
|
+
pl: new Set([
|
|
82
|
+
"i", "w", "we", "z", "ze", "na", "do", "dla", "od", "o", "po", "za", "jak",
|
|
83
|
+
"co", "gdzie", "dlaczego", "to", "ten", "ta", "te", "lub", "albo",
|
|
84
|
+
]),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Global merged stop words set
|
|
88
|
+
const ALL_STOP_WORDS = new Set<string>();
|
|
89
|
+
for (const langSet of Object.values(MULTILINGUAL_STOP_WORDS)) {
|
|
90
|
+
for (const word of langSet) {
|
|
91
|
+
ALL_STOP_WORDS.add(word);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SlugOptions {
|
|
96
|
+
language?: string; // e.g. "en", "fr", "de", "es", "all"
|
|
97
|
+
removeStopWords?: boolean;
|
|
98
|
+
stripDates?: boolean;
|
|
99
|
+
maxWords?: number;
|
|
100
|
+
maxLength?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Normalizes any text into a clean, canonical SEO slug with multilingual stop word filtering.
|
|
105
|
+
*/
|
|
106
|
+
export function cleanSeoSlug(input: string, options: SlugOptions = {}): string {
|
|
107
|
+
const {
|
|
108
|
+
language = "en",
|
|
109
|
+
removeStopWords = true,
|
|
110
|
+
stripDates = true,
|
|
111
|
+
maxWords = 5,
|
|
112
|
+
maxLength = 75,
|
|
113
|
+
} = options;
|
|
114
|
+
|
|
115
|
+
if (!input || typeof input !== "string") {
|
|
116
|
+
return "page";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let text = input
|
|
120
|
+
.normalize("NFKD")
|
|
121
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
122
|
+
.toLowerCase()
|
|
123
|
+
.trim();
|
|
124
|
+
|
|
125
|
+
if (stripDates) {
|
|
126
|
+
// Strip 4-digit years (2010-2039) and transient top lists (e.g. "top 10")
|
|
127
|
+
text = text.replace(/\b(20[1-3][0-9])\b/g, "");
|
|
128
|
+
text = text.replace(/\btop\s*\d+\s*/g, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let tokens = text
|
|
132
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
133
|
+
.trim()
|
|
134
|
+
.split(/\s+/)
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
|
|
137
|
+
if (removeStopWords) {
|
|
138
|
+
const activeSet = language === "all" || !MULTILINGUAL_STOP_WORDS[language]
|
|
139
|
+
? ALL_STOP_WORDS
|
|
140
|
+
: MULTILINGUAL_STOP_WORDS[language];
|
|
141
|
+
|
|
142
|
+
tokens = tokens.filter((token) => !activeSet.has(token) && token.length > 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
tokens = tokens.slice(0, maxWords);
|
|
146
|
+
let result = tokens.join("-");
|
|
147
|
+
|
|
148
|
+
if (result.length > maxLength) {
|
|
149
|
+
result = result.substring(0, maxLength).replace(/-[^-]*$/, "");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return result || "page";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface SlugValidationResult {
|
|
156
|
+
isValid: boolean;
|
|
157
|
+
errors: string[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validates a slug against universal SEO golden rules.
|
|
162
|
+
*/
|
|
163
|
+
export function validateSeoSlug(slug: string): SlugValidationResult {
|
|
164
|
+
const errors: string[] = [];
|
|
165
|
+
|
|
166
|
+
if (!slug || slug.length < 2) {
|
|
167
|
+
errors.push("Slug must contain at least 2 characters");
|
|
168
|
+
}
|
|
169
|
+
if (slug.length > 75) {
|
|
170
|
+
errors.push("Slug should not exceed 75 characters for optimal CTR");
|
|
171
|
+
}
|
|
172
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
|
173
|
+
errors.push("Slug must be 100% lowercase alphanumeric with single hyphen (-) separators");
|
|
174
|
+
}
|
|
175
|
+
if (/(?:20[1-3][0-9])/.test(slug)) {
|
|
176
|
+
errors.push("Evergreen slug should not contain yearly date stamps");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
isValid: errors.length === 0,
|
|
181
|
+
errors,
|
|
182
|
+
};
|
|
183
|
+
}
|