@lynxflow/seo-engine 1.0.0 → 1.3.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 +333 -82
- package/connectors/cloudflare-worker/worker.js +54 -26
- package/connectors/laravel/LynxSeoController.php +8 -8
- package/connectors/wordpress/lynxseo-connector.php +296 -53
- package/dist/ai-copilot-client.d.ts +36 -0
- package/dist/analytics-client.d.ts +53 -0
- package/dist/auth-key.d.ts +57 -0
- package/dist/backlinks-client.d.ts +31 -0
- package/dist/engine.d.ts +79 -0
- package/dist/i18n-dictionary.d.ts +30 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +1561 -169
- package/dist/index.mjs +1759 -0
- package/dist/indexnow-client.d.ts +25 -0
- package/dist/lago-token-meter.d.ts +36 -0
- package/dist/schema-builder.d.ts +27 -0
- package/dist/serp-client.d.ts +42 -0
- package/dist/site-auditor.d.ts +29 -0
- package/dist/site-crawler.d.ts +76 -0
- package/dist/src/ai-copilot-client.d.ts +36 -0
- package/dist/src/analytics-client.d.ts +53 -0
- package/dist/src/auth-key.d.ts +57 -0
- package/dist/src/backlinks-client.d.ts +31 -0
- package/dist/src/engine.d.ts +72 -0
- package/dist/src/i18n-dictionary.d.ts +30 -0
- package/dist/src/index.d.ts +42 -0
- package/dist/src/indexnow-client.d.ts +25 -0
- package/dist/src/lago-token-meter.d.ts +36 -0
- package/dist/src/schema-builder.d.ts +27 -0
- package/dist/src/serp-client.d.ts +42 -0
- package/dist/src/site-auditor.d.ts +29 -0
- package/dist/src/token-quota-manager.d.ts +37 -0
- package/dist/src/types.d.ts +145 -0
- package/dist/token-quota-manager.d.ts +37 -0
- package/dist/types.d.ts +162 -0
- package/lynxflow-seo-engine-1.2.0.tgz +0 -0
- package/package.json +1 -1
- package/src/ai-copilot-client.ts +84 -0
- package/src/analytics-client.ts +141 -0
- package/src/auth-key.ts +203 -0
- package/src/backlinks-client.ts +85 -0
- package/src/engine.ts +508 -85
- package/src/i18n-dictionary.ts +362 -0
- package/src/index.ts +22 -4
- package/src/indexnow-client.ts +94 -0
- package/src/lago-token-meter.ts +7 -2
- package/src/schema-builder.ts +87 -0
- package/src/serp-client.ts +89 -0
- package/src/site-auditor.ts +83 -0
- package/src/site-crawler.ts +406 -0
- package/src/types.ts +148 -28
- package/tsconfig.json +14 -0
- package/lynxflow-seo-engine-1.0.0.tgz +0 -0
- package/src/licensing.ts +0 -138
package/src/engine.ts
CHANGED
|
@@ -1,148 +1,571 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ⚡ LynxSEO High-Speed Dynamic Page Engine
|
|
3
|
-
*
|
|
2
|
+
* ⚡ LynxSEO High-Speed Universal Dynamic Page Engine (Internationalized)
|
|
3
|
+
*
|
|
4
|
+
* Computes 8 Rich Programmatic Matrix Types on-demand in < 0.05ms with
|
|
5
|
+
* automatic multi-language i18n support & Google Hreflang alternates:
|
|
6
|
+
* 1. 📍 Local / GEO : `/solutions/{service}/{country}/{city}`
|
|
7
|
+
* 2. 🥊 VS Comparisons : `/comparatif/{competitor}` (ex: `/comparatif/hubspot`)
|
|
8
|
+
* 3. 🔄 Alternatives : `/alternatives/alternative-a-{competitor}`
|
|
9
|
+
* 4. 🔌 Integrations : `/integrations/{software}` (ex: `/integrations/shopify`)
|
|
10
|
+
* 5. 🏢 Industry / Verticals : `/secteurs/{industry}` (ex: `/secteurs/avocats`)
|
|
11
|
+
* 6. 👤 Personas / Roles : `/metiers/{role}` (ex: `/metiers/directeur-commercial`)
|
|
12
|
+
* 7. 🎯 Use Cases : `/cas-usage/{useCase}` (ex: `/cas-usage/relance-devis`)
|
|
13
|
+
* 8. 🧮 Calculators / ROI Tools : `/outils/simulateur-roi`
|
|
4
14
|
*/
|
|
5
15
|
|
|
6
|
-
import type { LynxSeoConfig, LynxResolvedPage } from "./types";
|
|
7
|
-
import {
|
|
16
|
+
import type { LynxSeoConfig, LynxResolvedPage, LynxServiceDefinition } from "./types";
|
|
17
|
+
import { ApiKeyGuardian, type ApiKeyValidationResult } from "./auth-key";
|
|
18
|
+
import { getDictionary, isSupportedLanguage } from "./i18n-dictionary";
|
|
19
|
+
import { IndexNowClient, type IndexNowResponse } from "./indexnow-client";
|
|
20
|
+
import { LynxAnalyticsClient } from "./analytics-client";
|
|
21
|
+
import { SerpClient } from "./serp-client";
|
|
22
|
+
import { BacklinksClient } from "./backlinks-client";
|
|
23
|
+
import { AiCopilotClient } from "./ai-copilot-client";
|
|
24
|
+
import { LagoTokenMeter } from "./lago-token-meter";
|
|
25
|
+
import { SiteAuditor } from "./site-auditor";
|
|
26
|
+
import { SchemaGraphBuilder } from "./schema-builder";
|
|
27
|
+
import { DeepCrawlerAuditor, type SiteAuditSummary, type CrawlOptions } from "./site-crawler";
|
|
28
|
+
|
|
29
|
+
export type EngineConfig = LynxSeoConfig;
|
|
8
30
|
|
|
9
31
|
export class LynxSeoEngine {
|
|
10
32
|
private config: LynxSeoConfig;
|
|
11
|
-
private servicesMap: Map<string,
|
|
12
|
-
private
|
|
33
|
+
private servicesMap: Map<string, LynxServiceDefinition>;
|
|
34
|
+
private authStatus: ApiKeyValidationResult;
|
|
35
|
+
|
|
36
|
+
// Embedded Sub-Clients for complete in-app autonomy
|
|
37
|
+
public readonly analytics: LynxAnalyticsClient;
|
|
38
|
+
public readonly serp: SerpClient;
|
|
39
|
+
public readonly backlinks: BacklinksClient;
|
|
40
|
+
public readonly ai: AiCopilotClient;
|
|
41
|
+
public readonly tokenMeter: LagoTokenMeter;
|
|
42
|
+
public readonly auditor = SiteAuditor;
|
|
43
|
+
public readonly crawler = DeepCrawlerAuditor;
|
|
44
|
+
public readonly schema = SchemaGraphBuilder;
|
|
45
|
+
public readonly indexNow = IndexNowClient;
|
|
13
46
|
|
|
14
47
|
constructor(config: LynxSeoConfig) {
|
|
15
48
|
this.config = {
|
|
16
49
|
currency: "EUR",
|
|
17
50
|
currencySymbol: "€",
|
|
51
|
+
defaultLocale: "fr",
|
|
52
|
+
supportedLocales: ["fr", "en", "es", "de", "ar"],
|
|
18
53
|
...config,
|
|
19
54
|
};
|
|
20
|
-
this.servicesMap = new Map(config.services.map((s) => [s.slug, s]));
|
|
21
|
-
|
|
55
|
+
this.servicesMap = new Map((config.services || []).map((s) => [s.slug, s]));
|
|
56
|
+
const rawKey = config.apiKey || config.licenseKey || "";
|
|
57
|
+
this.authStatus = ApiKeyGuardian.validate(rawKey, config.domain);
|
|
58
|
+
|
|
59
|
+
this.analytics = new LynxAnalyticsClient(rawKey);
|
|
60
|
+
this.serp = new SerpClient(rawKey);
|
|
61
|
+
this.backlinks = new BacklinksClient(rawKey);
|
|
62
|
+
this.ai = new AiCopilotClient(rawKey);
|
|
63
|
+
this.tokenMeter = new LagoTokenMeter();
|
|
22
64
|
}
|
|
23
65
|
|
|
24
66
|
/**
|
|
25
|
-
*
|
|
26
|
-
* e.g. engine.resolve("/solutions/droit-immobilier/fr/lyon")
|
|
67
|
+
* Submits generated URLs to Bing, Yandex, Seznam, and Naver via IndexNow.
|
|
27
68
|
*/
|
|
28
|
-
|
|
69
|
+
async submitToIndexNow(urls: string[], indexNowKey = this.config.apiKey || this.config.licenseKey || ""): Promise<IndexNowResponse> {
|
|
70
|
+
const host = this.config.domain.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
71
|
+
return IndexNowClient.submitUrls({
|
|
72
|
+
host,
|
|
73
|
+
key: indexNowKey,
|
|
74
|
+
urlList: urls,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Universal resolver: supports both path strings e.g. "/comparatif/hubspot"
|
|
80
|
+
* and structured object params { slug, language, country, city, type, competitor, industry }.
|
|
81
|
+
*/
|
|
82
|
+
resolve(input: string | { slug?: string; language?: string; locale?: string; country?: string; city?: string; type?: string; competitor?: string; industry?: string; role?: string }): LynxResolvedPage | null {
|
|
29
83
|
const t0 = performance.now();
|
|
30
84
|
|
|
31
|
-
if (!this.
|
|
32
|
-
console.warn(`[@lynxflow/seo-engine]
|
|
85
|
+
if (!this.authStatus.isValid) {
|
|
86
|
+
console.warn(`[@lynxflow/seo-engine] Authentication / Domain Limit Error: ${this.authStatus.errorMessage}`);
|
|
33
87
|
return null;
|
|
34
88
|
}
|
|
35
89
|
|
|
36
|
-
const
|
|
90
|
+
const rawKey = this.config.apiKey || this.config.licenseKey || "";
|
|
91
|
+
const targetPath = typeof input === "string" ? input.replace(/^\/+/, "").replace(/\/+$/, "") : `${input.type || "solutions"}/${input.slug || ""}`;
|
|
92
|
+
const quota = ApiKeyGuardian.trackAndCheckUniquePage(rawKey, targetPath, this.authStatus.maxPages);
|
|
93
|
+
if (!quota.allowed) {
|
|
94
|
+
console.warn(`[@lynxflow/seo-engine] Unique Page Quota Exceeded: Your plan allows up to ${this.authStatus.maxPages.toLocaleString()} unique pages (Current active catalog: ${quota.uniqueCount}). Please upgrade to a higher plan.`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const domain = this.config.domain.replace(/\/$/, "");
|
|
99
|
+
const defaultService = this.config.services?.[0] || {
|
|
100
|
+
slug: "crm-pipeline",
|
|
101
|
+
name: "CRM Pipeline Commercial",
|
|
102
|
+
pricePerMonth: 49,
|
|
103
|
+
category: "Automatisation Commerciale",
|
|
104
|
+
features: ["Pipeline Kanban", "Relances IA 24/7", "WhatsApp Sync"],
|
|
105
|
+
faqs: [
|
|
106
|
+
{ question: "Combien de temps prend la mise en place ?", answer: "Déploiement immédiat en moins de 10 minutes." },
|
|
107
|
+
{ question: "Est-ce sans engagement ?", answer: "Oui, vous pouvez résilier à tout moment sans frais." }
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// -------------------------------------------------------------
|
|
112
|
+
// CASE A : Structured Object Input (e.g. Next.js Route Params)
|
|
113
|
+
// -------------------------------------------------------------
|
|
114
|
+
if (typeof input === "object") {
|
|
115
|
+
const locale = (input.locale || input.language || this.config.defaultLocale || "fr").toLowerCase();
|
|
116
|
+
const type = input.type || (input.competitor ? "comparison" : input.city ? "geo" : "industry");
|
|
117
|
+
const service = (input.slug ? this.servicesMap.get(input.slug) : null) || defaultService;
|
|
118
|
+
|
|
119
|
+
if (type === "comparison" && input.competitor) {
|
|
120
|
+
return this.buildComparisonPage(service, input.competitor, domain, t0, locale);
|
|
121
|
+
}
|
|
122
|
+
if (type === "industry" && input.industry) {
|
|
123
|
+
return this.buildIndustryPage(service, input.industry, domain, t0, locale);
|
|
124
|
+
}
|
|
125
|
+
if (type === "role" && input.role) {
|
|
126
|
+
return this.buildRolePage(service, input.role, domain, t0, locale);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Default to Local GEO
|
|
130
|
+
const country = (input.country || locale.toUpperCase() || "FR").toUpperCase();
|
|
131
|
+
const city = input.city || "Paris";
|
|
132
|
+
return this.buildGeoPage(service, country, city, domain, t0, locale);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -------------------------------------------------------------
|
|
136
|
+
// CASE B : Raw URL Path String (e.g. WordPress, Cloudflare Worker, Laravel)
|
|
137
|
+
// -------------------------------------------------------------
|
|
138
|
+
const cleanPath = input.toLowerCase().replace(/^\//, "").replace(/\/$/, "");
|
|
37
139
|
const parts = cleanPath.split("/");
|
|
38
140
|
|
|
39
|
-
|
|
40
|
-
|
|
141
|
+
// Detect language prefix if present (e.g. /en/solutions/..., /ja/solutions/..., etc.)
|
|
142
|
+
let locale = this.config.defaultLocale || "fr";
|
|
143
|
+
let pathParts = parts;
|
|
144
|
+
|
|
145
|
+
if (parts.length > 1 && isSupportedLanguage(parts[0])) {
|
|
146
|
+
locale = parts[0];
|
|
147
|
+
pathParts = parts.slice(1);
|
|
41
148
|
}
|
|
42
149
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
150
|
+
// 1. GEO & Multi-Slug Combined Matrix:
|
|
151
|
+
// Pattern A : /solutions/{service}/{country}/{city} (3-4 parts)
|
|
152
|
+
// Pattern B : /solutions/{service}/{industry}/{competitor}/{software}/{role}/{city}/{tool} (5-8 parts)
|
|
153
|
+
if (pathParts[0] === "solutions") {
|
|
154
|
+
if (pathParts.length >= 5) {
|
|
155
|
+
// Hyper-combined 8-slug page
|
|
156
|
+
const service = this.servicesMap.get(pathParts[1]) || defaultService;
|
|
157
|
+
const industry = (pathParts[2] || "").replace(/-/g, " ");
|
|
158
|
+
const competitor = (pathParts[3] || "").replace(/-/g, " ");
|
|
159
|
+
const software = (pathParts[4] || "").replace(/-/g, " ");
|
|
160
|
+
const role = (pathParts[5] || "").replace(/-/g, " ");
|
|
161
|
+
const city = (pathParts[6] || "Paris").replace(/-/g, " ");
|
|
162
|
+
const tool = (pathParts[7] || "").replace(/-/g, " ");
|
|
47
163
|
|
|
48
|
-
|
|
49
|
-
|
|
164
|
+
return this.buildHyperCombinedPage({
|
|
165
|
+
service,
|
|
166
|
+
industry,
|
|
167
|
+
competitor,
|
|
168
|
+
software,
|
|
169
|
+
role,
|
|
170
|
+
city,
|
|
171
|
+
tool,
|
|
172
|
+
domain,
|
|
173
|
+
t0,
|
|
174
|
+
locale,
|
|
175
|
+
path: cleanPath,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
50
178
|
|
|
51
|
-
|
|
52
|
-
|
|
179
|
+
// 4-part: /solutions/{service}/{country}/{city} (e.g. /solutions/crm/us/new-york)
|
|
180
|
+
if (pathParts.length >= 4) {
|
|
181
|
+
const service = this.servicesMap.get(pathParts[1]) || defaultService;
|
|
182
|
+
const country = pathParts[2].toUpperCase();
|
|
183
|
+
const cityName = pathParts[3].charAt(0).toUpperCase() + pathParts[3].slice(1);
|
|
184
|
+
return this.buildGeoPage(service, country, cityName, domain, t0, locale, cleanPath);
|
|
185
|
+
}
|
|
53
186
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
187
|
+
// 3-part (Clean standard URL): /solutions/{service}/{city} (e.g. /solutions/crm/lyon, /fr/solutions/crm/paris)
|
|
188
|
+
if (pathParts.length >= 3) {
|
|
189
|
+
const service = this.servicesMap.get(pathParts[1]) || defaultService;
|
|
190
|
+
const defaultCountry = locale.toUpperCase();
|
|
191
|
+
const cityName = pathParts[2].charAt(0).toUpperCase() + pathParts[2].slice(1);
|
|
192
|
+
return this.buildGeoPage(service, defaultCountry, cityName, domain, t0, locale, cleanPath);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
57
195
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
196
|
+
// Helper: Find service mentioned in slug
|
|
197
|
+
const detectServiceFromSlug = (slug: string): { service: LynxServiceDefinition; remainder: string } => {
|
|
198
|
+
for (const [sSlug, sDef] of this.servicesMap.entries()) {
|
|
199
|
+
if (slug.startsWith(`${sSlug}-`)) {
|
|
200
|
+
return { service: sDef, remainder: slug.substring(sSlug.length + 1) };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { service: defaultService, remainder: slug };
|
|
204
|
+
};
|
|
63
205
|
|
|
64
|
-
|
|
65
|
-
|
|
206
|
+
// 2. VS Comparison Matrix: /comparatif/{service}-vs-{competitor} OR /comparatif/{competitor}
|
|
207
|
+
if ((pathParts[0] === "comparatif" || pathParts[0] === "comparison" || pathParts[0] === "vs") && pathParts.length >= 2) {
|
|
208
|
+
const raw = pathParts[1];
|
|
209
|
+
const vsMatch = raw.match(/^(.*?)-vs-(.*?)$/);
|
|
210
|
+
let targetService = defaultService;
|
|
211
|
+
let competitor = raw;
|
|
66
212
|
|
|
67
|
-
|
|
213
|
+
if (vsMatch) {
|
|
214
|
+
targetService = this.servicesMap.get(vsMatch[1]) || defaultService;
|
|
215
|
+
competitor = vsMatch[2];
|
|
216
|
+
} else {
|
|
217
|
+
const detected = detectServiceFromSlug(raw);
|
|
218
|
+
targetService = detected.service;
|
|
219
|
+
competitor = detected.remainder;
|
|
220
|
+
}
|
|
68
221
|
|
|
69
|
-
|
|
222
|
+
return this.buildComparisonPage(targetService, competitor.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
223
|
+
}
|
|
70
224
|
|
|
71
|
-
|
|
225
|
+
// 3. Alternative To Matrix: /alternatives/alternative-a-{competitor}
|
|
226
|
+
if (pathParts[0] === "alternatives" && pathParts.length >= 2) {
|
|
227
|
+
const raw = pathParts[1].replace(/^alternative-(a-|to-)?/i, "");
|
|
228
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
229
|
+
return this.buildAlternativePage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
230
|
+
}
|
|
72
231
|
|
|
73
|
-
|
|
74
|
-
|
|
232
|
+
// 4. Industry / Vertical Matrix: /secteurs/{service}-pour-{industry} OR /secteurs/{industry}
|
|
233
|
+
if ((pathParts[0] === "secteurs" || pathParts[0] === "industries") && pathParts.length >= 2) {
|
|
234
|
+
const raw = pathParts[1].replace(/-(pour|for)-/i, "-");
|
|
235
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
236
|
+
return this.buildIndustryPage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
237
|
+
}
|
|
75
238
|
|
|
76
|
-
|
|
239
|
+
// 5. Integrations Matrix: /integrations/{service}-avec-{software} OR /integrations/{software}
|
|
240
|
+
if (pathParts[0] === "integrations" && pathParts.length >= 2) {
|
|
241
|
+
const raw = pathParts[1].replace(/-(avec|with)-/i, "-");
|
|
242
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
243
|
+
return this.buildIntegrationPage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
244
|
+
}
|
|
77
245
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
246
|
+
// 6. Personas / Roles: /metiers/{service}-pour-{role} OR /metiers/{role}
|
|
247
|
+
if ((pathParts[0] === "metiers" || pathParts[0] === "roles") && pathParts.length >= 2) {
|
|
248
|
+
const raw = pathParts[1].replace(/-(pour|for)-/i, "-");
|
|
249
|
+
const { service, remainder } = detectServiceFromSlug(raw);
|
|
250
|
+
return this.buildRolePage(service, remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
251
|
+
}
|
|
82
252
|
|
|
83
|
-
|
|
253
|
+
// 7. Use Cases: /cas-usage/{topic}
|
|
254
|
+
if ((pathParts[0] === "cas-usage" || pathParts[0] === "use-cases") && pathParts.length >= 2) {
|
|
255
|
+
const { service, remainder } = detectServiceFromSlug(pathParts[1]);
|
|
256
|
+
return this.buildGeoPage(service, "FR", remainder.replace(/-/g, " "), domain, t0, locale, cleanPath);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 8. Tools / Calculators: /outils/{tool}
|
|
260
|
+
if ((pathParts[0] === "outils" || pathParts[0] === "tools") && pathParts.length >= 2) {
|
|
261
|
+
const { service } = detectServiceFromSlug(pathParts[1]);
|
|
262
|
+
return this.buildGeoPage(service, "FR", "Calculateur ROI", domain, t0, locale, cleanPath);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Fallback: local geo
|
|
266
|
+
return this.buildGeoPage(defaultService, "FR", "Paris", domain, t0, locale, cleanPath);
|
|
267
|
+
}
|
|
84
268
|
|
|
85
|
-
|
|
86
|
-
1. **Étape 1** : Activez votre compte en 30 secondes.
|
|
87
|
-
2. **Étape 2** : Configurez vos préférences et règles métier.
|
|
88
|
-
3. **Étape 3** : Vos premières demandes sont traitées 24/7.
|
|
89
|
-
`.trim();
|
|
269
|
+
// ---------------- Localized Builder Helpers ----------------
|
|
90
270
|
|
|
91
|
-
|
|
271
|
+
private buildHyperCombinedPage(opts: {
|
|
272
|
+
service: LynxServiceDefinition;
|
|
273
|
+
industry: string;
|
|
274
|
+
competitor: string;
|
|
275
|
+
software: string;
|
|
276
|
+
role: string;
|
|
277
|
+
city: string;
|
|
278
|
+
tool: string;
|
|
279
|
+
domain: string;
|
|
280
|
+
t0: number;
|
|
281
|
+
locale: string;
|
|
282
|
+
path: string;
|
|
283
|
+
}): LynxResolvedPage {
|
|
284
|
+
const { service, industry, competitor, software, role, city, tool, domain, t0, locale, path } = opts;
|
|
285
|
+
const currentYear = new Date().getFullYear();
|
|
286
|
+
|
|
287
|
+
const indStr = industry ? ` pour ${industry}` : "";
|
|
288
|
+
const compStr = competitor ? ` (Alternative à ${competitor})` : "";
|
|
289
|
+
const softStr = software ? ` connecté avec ${software}` : "";
|
|
290
|
+
const roleStr = role ? ` pour ${role}` : "";
|
|
291
|
+
const cityStr = city ? ` à ${city}` : "";
|
|
292
|
+
const toolStr = tool ? ` | Simulateur ROI` : "";
|
|
293
|
+
|
|
294
|
+
const title = `${service.name}${indStr}${roleStr}${cityStr}${compStr}${toolStr} — ${this.config.brandName}`;
|
|
295
|
+
const description = `Découvrez la solution de ${service.name}${indStr}${cityStr}. Automatisez vos flux, gagnez 10h/semaine${softStr}. Déploiement en 10 minutes. Note 4.9/5★.`;
|
|
296
|
+
const h1 = `${service.name}${indStr}${cityStr}`;
|
|
297
|
+
const directAnswer = `${this.config.brandName} propose le meilleur ${service.name}${indStr}${roleStr}${cityStr}${compStr}${softStr}. Mise en service immédiate dès ${service.pricePerMonth} ${this.config.currencySymbol || "€"}/mois sans engagement.`;
|
|
298
|
+
|
|
299
|
+
return this.assemblePage({
|
|
300
|
+
url: `${domain}/${path}`,
|
|
301
|
+
title,
|
|
302
|
+
description,
|
|
303
|
+
h1,
|
|
304
|
+
directAnswer,
|
|
305
|
+
service,
|
|
306
|
+
locale,
|
|
307
|
+
path,
|
|
308
|
+
t0,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private buildGeoPage(service: LynxServiceDefinition, country: string, city: string, domain: string, t0: number, locale = "fr", path = `solutions/${service.slug}/${country.toLowerCase()}/${city.toLowerCase()}`): LynxResolvedPage {
|
|
313
|
+
const dict = getDictionary(locale);
|
|
314
|
+
const title = dict.geoTitle(service.name, city, country, this.config.brandName);
|
|
315
|
+
const description = dict.geoDesc(service.name, city, this.config.brandName);
|
|
316
|
+
const h1 = `${service.name} à ${city}`;
|
|
317
|
+
const directAnswer = dict.geoDirectAnswer(this.config.brandName, service.name, city, country, service.pricePerMonth, this.config.currencySymbol || "€");
|
|
318
|
+
|
|
319
|
+
return this.assemblePage({
|
|
320
|
+
url: `${domain}/${path}`,
|
|
321
|
+
title,
|
|
322
|
+
description,
|
|
323
|
+
h1,
|
|
324
|
+
directAnswer,
|
|
325
|
+
service,
|
|
326
|
+
locale,
|
|
327
|
+
path,
|
|
328
|
+
t0,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private buildComparisonPage(service: LynxServiceDefinition, competitor: string, domain: string, t0: number, locale = "fr", path = `comparatif/${competitor.toLowerCase().replace(/\s+/g, "-")}`): LynxResolvedPage {
|
|
333
|
+
const dict = getDictionary(locale);
|
|
334
|
+
const compName = competitor.charAt(0).toUpperCase() + competitor.slice(1);
|
|
335
|
+
const currentYear = new Date().getFullYear();
|
|
336
|
+
const title = `${service.name} : ${this.config.brandName} vs ${compName} (${currentYear})`;
|
|
337
|
+
const description = dict.vsDesc(this.config.brandName, compName);
|
|
338
|
+
const h1 = `${service.name} : ${this.config.brandName} vs ${compName}`;
|
|
339
|
+
const directAnswer = dict.vsDirectAnswer(this.config.brandName, compName);
|
|
340
|
+
|
|
341
|
+
return this.assemblePage({
|
|
342
|
+
url: `${domain}/${path}`,
|
|
343
|
+
title,
|
|
344
|
+
description,
|
|
345
|
+
h1,
|
|
346
|
+
directAnswer,
|
|
347
|
+
service,
|
|
348
|
+
locale,
|
|
349
|
+
path,
|
|
350
|
+
t0,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private buildAlternativePage(service: LynxServiceDefinition, competitor: string, domain: string, t0: number, locale = "fr", path = `alternatives/alternative-a-${competitor.toLowerCase().replace(/\s+/g, "-")}`): LynxResolvedPage {
|
|
355
|
+
const dict = getDictionary(locale);
|
|
356
|
+
const compName = competitor.charAt(0).toUpperCase() + competitor.slice(1);
|
|
357
|
+
const currentYear = new Date().getFullYear();
|
|
358
|
+
const title = `Meilleure Alternative à ${compName} (${service.name}) en ${currentYear} — ${this.config.brandName}`;
|
|
359
|
+
const description = dict.altDesc(compName, this.config.brandName);
|
|
360
|
+
const h1 = `Alternative à ${compName} pour votre ${service.name}`;
|
|
361
|
+
const directAnswer = dict.altDirectAnswer(this.config.brandName, compName);
|
|
362
|
+
|
|
363
|
+
return this.assemblePage({
|
|
364
|
+
url: `${domain}/${path}`,
|
|
365
|
+
title,
|
|
366
|
+
description,
|
|
367
|
+
h1,
|
|
368
|
+
directAnswer,
|
|
369
|
+
service,
|
|
370
|
+
locale,
|
|
371
|
+
path,
|
|
372
|
+
t0,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private buildIndustryPage(service: LynxServiceDefinition, industry: string, domain: string, t0: number, locale = "fr", path = `secteurs/${industry.toLowerCase().replace(/\s+/g, "-")}`): LynxResolvedPage {
|
|
377
|
+
const dict = getDictionary(locale);
|
|
378
|
+
const indName = industry.charAt(0).toUpperCase() + industry.slice(1);
|
|
379
|
+
const title = dict.industryTitle(service.name, indName, this.config.brandName);
|
|
380
|
+
const description = dict.industryDesc(service.name, indName);
|
|
381
|
+
const h1 = `${service.name} pour ${indName}`;
|
|
382
|
+
const directAnswer = `${this.config.brandName} propose une solution de ${service.name} conçue sur mesure pour les ${indName}, avec processus métiers et automatisations optimisés.`;
|
|
383
|
+
|
|
384
|
+
return this.assemblePage({
|
|
385
|
+
url: `${domain}/${path}`,
|
|
386
|
+
title,
|
|
387
|
+
description,
|
|
388
|
+
h1,
|
|
389
|
+
directAnswer,
|
|
390
|
+
service,
|
|
391
|
+
locale,
|
|
392
|
+
path,
|
|
393
|
+
t0,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private buildRolePage(service: LynxServiceDefinition, role: string, domain: string, t0: number, locale = "fr", path = `metiers/${role.toLowerCase().replace(/\s+/g, "-")}`): LynxResolvedPage {
|
|
398
|
+
const roleName = role.charAt(0).toUpperCase() + role.slice(1);
|
|
399
|
+
const title = `${service.name} pour ${roleName} — ${this.config.brandName}`;
|
|
400
|
+
const description = `Optimisez votre quotidien de ${roleName} grâce à notre solution de ${service.name}. Gagnez 10h par semaine.`;
|
|
401
|
+
const h1 = `${service.name} pour ${roleName}`;
|
|
402
|
+
const directAnswer = `En tant que ${roleName}, ${this.config.brandName} (${service.name}) vous libère des tâches manuelles grâce à des relances intelligentes et un suivi d'activité en temps réel.`;
|
|
403
|
+
|
|
404
|
+
return this.assemblePage({
|
|
405
|
+
url: `${domain}/${path}`,
|
|
406
|
+
title,
|
|
407
|
+
description,
|
|
408
|
+
h1,
|
|
409
|
+
directAnswer,
|
|
410
|
+
service,
|
|
411
|
+
locale,
|
|
412
|
+
path,
|
|
413
|
+
t0,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private buildIntegrationPage(service: LynxServiceDefinition, software: string, domain: string, t0: number, locale = "fr", path = `integrations/${software.toLowerCase().replace(/\s+/g, "-")}`): LynxResolvedPage {
|
|
418
|
+
const softName = software.charAt(0).toUpperCase() + software.slice(1);
|
|
419
|
+
const title = `Intégration ${softName} & ${service.name} — ${this.config.brandName}`;
|
|
420
|
+
const description = `Connectez facilement votre compte ${softName} à notre ${service.name}. Synchronisation bidirectionnelle instantanée.`;
|
|
421
|
+
const h1 = `Intégration ${softName} & ${service.name}`;
|
|
422
|
+
const directAnswer = `L'intégration native entre ${softName} et ${this.config.brandName} (${service.name}) permet de synchroniser contacts, événements et transactions en temps réel sans code.`;
|
|
423
|
+
|
|
424
|
+
return this.assemblePage({
|
|
425
|
+
url: `${domain}/${path}`,
|
|
426
|
+
title,
|
|
427
|
+
description,
|
|
428
|
+
h1,
|
|
429
|
+
directAnswer,
|
|
430
|
+
service,
|
|
431
|
+
locale,
|
|
432
|
+
path,
|
|
433
|
+
t0,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private assemblePage(opts: { url: string; title: string; description: string; h1: string; directAnswer: string; service: LynxServiceDefinition; locale: string; path: string; t0: number }): LynxResolvedPage {
|
|
438
|
+
const dict = getDictionary(opts.locale);
|
|
439
|
+
const domain = this.config.domain.replace(/\/$/, "");
|
|
440
|
+
|
|
441
|
+
// 🌐 Google Hreflang alternates for International SEO Indexing
|
|
442
|
+
const supportedLocales = this.config.supportedLocales || ["fr", "en", "es", "de", "ar"];
|
|
443
|
+
const hreflangs = supportedLocales.map((l) => ({
|
|
444
|
+
lang: l,
|
|
445
|
+
url: l === this.config.defaultLocale ? `${domain}/${opts.path}` : `${domain}/${l}/${opts.path}`,
|
|
446
|
+
}));
|
|
447
|
+
|
|
448
|
+
const localizedFaqs = opts.service.faqs?.length ? opts.service.faqs : [
|
|
449
|
+
{ question: dict.faqDeploymentQ, answer: dict.faqDeploymentA },
|
|
450
|
+
{ question: dict.faqCommitmentQ, answer: dict.faqCommitmentA },
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
const ogImageUrl = `${domain}/api/og?title=${encodeURIComponent(opts.h1)}&brand=${encodeURIComponent(this.config.brandName)}&service=${encodeURIComponent(opts.service.name)}`;
|
|
454
|
+
|
|
455
|
+
const jsonLd = {
|
|
92
456
|
"@context": "https://schema.org",
|
|
93
457
|
"@graph": [
|
|
94
458
|
{
|
|
95
|
-
"@type": "
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
},
|
|
102
|
-
areaServed: {
|
|
103
|
-
"@type": "City",
|
|
104
|
-
name: cityName,
|
|
105
|
-
},
|
|
459
|
+
"@type": "Product",
|
|
460
|
+
"@id": `${opts.url}#product`,
|
|
461
|
+
name: opts.title,
|
|
462
|
+
description: opts.description,
|
|
463
|
+
inLanguage: opts.locale,
|
|
464
|
+
image: ogImageUrl,
|
|
465
|
+
brand: { "@type": "Brand", name: this.config.brandName },
|
|
106
466
|
aggregateRating: {
|
|
107
467
|
"@type": "AggregateRating",
|
|
108
468
|
ratingValue: "4.9",
|
|
109
469
|
reviewCount: "1280",
|
|
470
|
+
bestRating: "5",
|
|
471
|
+
worstRating: "1",
|
|
472
|
+
},
|
|
473
|
+
offers: {
|
|
474
|
+
"@type": "Offer",
|
|
475
|
+
price: opts.service.pricePerMonth.toString(),
|
|
476
|
+
priceCurrency: this.config.currency,
|
|
477
|
+
availability: "https://schema.org/InStock",
|
|
478
|
+
url: opts.url,
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
"@type": "FAQPage",
|
|
483
|
+
"@id": `${opts.url}#faq`,
|
|
484
|
+
inLanguage: opts.locale,
|
|
485
|
+
mainEntity: localizedFaqs.map((faq) => ({
|
|
486
|
+
"@type": "Question",
|
|
487
|
+
name: faq.question,
|
|
488
|
+
acceptedAnswer: {
|
|
489
|
+
"@type": "Answer",
|
|
490
|
+
text: faq.answer,
|
|
491
|
+
},
|
|
492
|
+
})),
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
"@type": "WebPage",
|
|
496
|
+
"@id": `${opts.url}#webpage`,
|
|
497
|
+
url: opts.url,
|
|
498
|
+
name: opts.title,
|
|
499
|
+
description: opts.description,
|
|
500
|
+
speakable: {
|
|
501
|
+
"@type": "SpeakableSpecification",
|
|
502
|
+
cssSelector: [".geo-direct-answer", "h1"],
|
|
110
503
|
},
|
|
111
504
|
},
|
|
112
505
|
],
|
|
113
506
|
};
|
|
114
507
|
|
|
115
|
-
const
|
|
116
|
-
{ question: `Pourquoi choisir ${service.name} à ${cityName} ?`, answer: `Pour notre proximité, notre rapidité d'exécution et notre satisfaction client de 4.9/5.` },
|
|
117
|
-
{ question: `Comment démarrer ?`, answer: `Inscrivez-vous en 30 secondes sans engagement pour tester la solution.` },
|
|
118
|
-
];
|
|
119
|
-
|
|
120
|
-
const executionTimeMs = parseFloat((performance.now() - t0).toFixed(3));
|
|
508
|
+
const directAnswerHtml = `<div class="geo-direct-answer" data-geo-extract="true"><p>${opts.directAnswer}</p></div>`;
|
|
121
509
|
|
|
122
510
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
511
|
+
url: opts.url,
|
|
512
|
+
title: opts.title,
|
|
513
|
+
description: opts.description,
|
|
514
|
+
h1: opts.h1,
|
|
515
|
+
directAnswer: opts.directAnswer,
|
|
516
|
+
service: opts.service,
|
|
517
|
+
locale: opts.locale,
|
|
518
|
+
hreflangs,
|
|
519
|
+
faqs: localizedFaqs,
|
|
520
|
+
jsonLd,
|
|
521
|
+
htmlBody: `<h1>${opts.h1}</h1><p>${opts.description}</p>${directAnswerHtml}`,
|
|
522
|
+
markdownBody: `# ${opts.h1}\n\n${opts.description}\n\n> ${opts.directAnswer}`,
|
|
523
|
+
executionTimeMs: parseFloat((performance.now() - opts.t0).toFixed(3)),
|
|
524
|
+
|
|
525
|
+
// Backward compatibility bindings
|
|
526
|
+
fullUrl: opts.url,
|
|
125
527
|
meta: {
|
|
126
|
-
title,
|
|
127
|
-
description,
|
|
128
|
-
h1,
|
|
129
|
-
canonical:
|
|
130
|
-
openGraphImageUrl: `${domain}/api/og?service=${encodeURIComponent(service.name)}&city=${encodeURIComponent(cityName)}`,
|
|
528
|
+
title: opts.title,
|
|
529
|
+
description: opts.description,
|
|
530
|
+
h1: opts.h1,
|
|
531
|
+
canonical: opts.url,
|
|
131
532
|
},
|
|
132
|
-
schemaJsonLd,
|
|
133
533
|
content: {
|
|
134
|
-
directAnswerGeoHtml,
|
|
135
|
-
heroHeadline: h1,
|
|
136
|
-
heroSubheadline: description,
|
|
137
|
-
markdownBody
|
|
138
|
-
faqList,
|
|
139
|
-
neighboringLinks: [],
|
|
534
|
+
directAnswerGeoHtml: directAnswerHtml,
|
|
535
|
+
heroHeadline: opts.h1,
|
|
536
|
+
heroSubheadline: opts.description,
|
|
537
|
+
markdownBody: `# ${opts.h1}\n\n${opts.description}\n\n> ${opts.directAnswer}`,
|
|
538
|
+
faqList: localizedFaqs,
|
|
140
539
|
},
|
|
540
|
+
schemaJsonLd: jsonLd,
|
|
141
541
|
pricing: {
|
|
142
|
-
priceNumber: service.pricePerMonth,
|
|
143
|
-
priceFormatted: `${service.pricePerMonth} ${this.config.currencySymbol}
|
|
542
|
+
priceNumber: opts.service.pricePerMonth,
|
|
543
|
+
priceFormatted: `${opts.service.pricePerMonth} ${this.config.currencySymbol}`,
|
|
544
|
+
currency: this.config.currency || "EUR",
|
|
144
545
|
},
|
|
145
|
-
executionTimeMs,
|
|
146
546
|
};
|
|
147
547
|
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Returns the exact count of distinct unique programmatic pages currently active.
|
|
551
|
+
*/
|
|
552
|
+
getUniquePageCount(): number {
|
|
553
|
+
const rawKey = this.config.apiKey || this.config.licenseKey || "";
|
|
554
|
+
return ApiKeyGuardian.getUniquePageCount(rawKey);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Returns the list of all distinct unique URL paths generated so far.
|
|
559
|
+
*/
|
|
560
|
+
getUniquePageList(): string[] {
|
|
561
|
+
const rawKey = this.config.apiKey || this.config.licenseKey || "";
|
|
562
|
+
return ApiKeyGuardian.getUniquePageList(rawKey);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Performs an automated deep crawl and technical SEO audit of the configured domain.
|
|
567
|
+
*/
|
|
568
|
+
async crawlSite(options?: CrawlOptions): Promise<SiteAuditSummary> {
|
|
569
|
+
return DeepCrawlerAuditor.crawlAndAuditDomain(this.config.domain, options);
|
|
570
|
+
}
|
|
148
571
|
}
|