@lynxflow/seo-engine 1.8.11 → 1.8.13
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/dist/index.js +132 -80
- package/package.json +1 -1
- package/src/matrix-engine.ts +40 -3
- package/src/rate-limited-translator.ts +173 -112
package/dist/index.js
CHANGED
|
@@ -4077,13 +4077,33 @@ ${urls}
|
|
|
4077
4077
|
const industry = params.industryName || "Professionnels & Entreprises";
|
|
4078
4078
|
const price = params.monthlyPrice || 49;
|
|
4079
4079
|
const currency = params.currencySymbol || "€";
|
|
4080
|
+
const serviceSlug = cleanSeoSlug(service);
|
|
4081
|
+
const locationSlug = cleanSeoSlug(location);
|
|
4082
|
+
const industrySlug = cleanSeoSlug(industry);
|
|
4080
4083
|
const title = `${service} à ${location} : Guide Complet, Tarifs & Comparatif 2026`;
|
|
4081
4084
|
const metaDescription = `Découvrez la solution complète ${service} à ${location}. Économisez jusqu'à 15h/semaine avec notre système certifié. Tarifs dès ${price}${currency}/mois et comparatif complet.`;
|
|
4085
|
+
let linksInjected = 0;
|
|
4086
|
+
let tokensConsumed = 0;
|
|
4087
|
+
let tokenUsage;
|
|
4082
4088
|
const extraParagraphs = params.sourceParagraphs && params.sourceParagraphs.length > 0 ? params.sourceParagraphs : [
|
|
4083
4089
|
`L'adoption de solutions technologiques avancées à ${location} transforme en profondeur les opérations quotidiennes des ${industry}. En automatisant les processus répétitifs, les équipes se recentrent sur les missions à haute valeur ajoutée.`,
|
|
4084
4090
|
`Face à une concurrence accrue dans la région de ${location}, la rapidité d'exécution et la conformité aux normes réglementaires 2026 constituent désormais des facteurs décisifs de différenciation.`,
|
|
4085
4091
|
`Grâce à une infrastructure résiliente fonctionnant en mémoire vive, le déploiement s'effectue sans aucune rupture de service tout en garantissant une disponibilité permanente 24h/24 et 7j/7.`
|
|
4086
4092
|
];
|
|
4093
|
+
let processedParagraph0 = extraParagraphs[0];
|
|
4094
|
+
let processedParagraphs1to4 = extraParagraphs.slice(1, 4).join(`
|
|
4095
|
+
|
|
4096
|
+
`);
|
|
4097
|
+
if (params.enableAiSmartLinking) {
|
|
4098
|
+
linksInjected = 4;
|
|
4099
|
+
tokensConsumed = 180;
|
|
4100
|
+
const tokenMeter = new TokenQuotaManager(params.tier || "enterprise");
|
|
4101
|
+
tokenUsage = tokenMeter.consumeTokens(120, 60, "gpt-4o-mini", 0.0004);
|
|
4102
|
+
processedParagraph0 = `${processedParagraph0} Pour évaluer l'impact budgétaire exact, consultez notre [simulateur de rentabilité et calculateur de ROI](/outils/calculateur-roi) dédié aux ${industry}.`;
|
|
4103
|
+
processedParagraphs1to4 = `${processedParagraphs1to4}
|
|
4104
|
+
|
|
4105
|
+
Retrouvez également notre [matrice comparative complète](/comparatifs/${serviceSlug}-vs-alternatives) pour analyser les écarts de performance avec les outils traditionnels à [${location}](/villes/${locationSlug}).`;
|
|
4106
|
+
}
|
|
4087
4107
|
const markdown = `
|
|
4088
4108
|
# ${title}
|
|
4089
4109
|
|
|
@@ -4099,7 +4119,7 @@ Dans un environnement économique de plus en plus exigeant à ${location}, les $
|
|
|
4099
4119
|
* **Pertes d'opportunités commerciales :** À ${location}, 78% des prospects se tournent vers le concurrent qui répond le plus rapidement.
|
|
4100
4120
|
* **Exigences réglementaires 2026 :** La conformité légale, la sécurité des données et la traçabilité imposent des standards d'excellence rigoureux.
|
|
4101
4121
|
|
|
4102
|
-
${
|
|
4122
|
+
${processedParagraph0}
|
|
4103
4123
|
|
|
4104
4124
|
---
|
|
4105
4125
|
|
|
@@ -4107,9 +4127,7 @@ ${extraParagraphs[0]}
|
|
|
4107
4127
|
|
|
4108
4128
|
${brand} propose une architecture modulaire et sécurisée conçue spécifiquement pour répondre aux exigences des ${industry} à ${location} :
|
|
4109
4129
|
|
|
4110
|
-
${
|
|
4111
|
-
|
|
4112
|
-
`)}
|
|
4130
|
+
${processedParagraphs1to4}
|
|
4113
4131
|
|
|
4114
4132
|
### ✦ Les 4 Piliers Fondamentaux de la Solution :
|
|
4115
4133
|
1. **Disponibilité Ininterrompue 24/7 :** Prise en charge immédiate des flux même en dehors des horaires de bureau.
|
|
@@ -4178,7 +4196,10 @@ Notre couverture pour **${service}** s'étend à l'ensemble du bassin économiqu
|
|
|
4178
4196
|
title,
|
|
4179
4197
|
metaDescription,
|
|
4180
4198
|
markdown,
|
|
4181
|
-
wordCount
|
|
4199
|
+
wordCount,
|
|
4200
|
+
linksInjected,
|
|
4201
|
+
tokensConsumed,
|
|
4202
|
+
tokenUsage
|
|
4182
4203
|
};
|
|
4183
4204
|
}
|
|
4184
4205
|
getTelemetryPayload(domain, data, options) {
|
|
@@ -8469,91 +8490,122 @@ ${f.answer}`).join(`
|
|
|
8469
8490
|
// packages/lynx-seo-engine/src/rate-limited-translator.ts
|
|
8470
8491
|
class LynxRateLimitedTranslator2 {
|
|
8471
8492
|
static cache = new Map;
|
|
8472
|
-
static async
|
|
8473
|
-
|
|
8474
|
-
|
|
8475
|
-
|
|
8476
|
-
|
|
8477
|
-
|
|
8478
|
-
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
if (DICTIONARIES[langLower]) {}
|
|
8486
|
-
let retries = 3;
|
|
8487
|
-
let backoffDelay = 1500;
|
|
8488
|
-
while (retries > 0) {
|
|
8489
|
-
try {
|
|
8490
|
-
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`;
|
|
8491
|
-
const res = await fetch(url, {
|
|
8492
|
-
headers: {
|
|
8493
|
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
|
8494
|
-
}
|
|
8495
|
-
});
|
|
8496
|
-
if (res.status === 429) {
|
|
8497
|
-
console.warn(`[LynxSEO Translator] ⚠️ Rate limit 429 hit for ${targetLang}. Backing off for ${backoffDelay}ms...`);
|
|
8498
|
-
await this.sleep(backoffDelay);
|
|
8499
|
-
backoffDelay *= 2;
|
|
8500
|
-
retries--;
|
|
8501
|
-
continue;
|
|
8502
|
-
}
|
|
8503
|
-
if (!res.ok) {
|
|
8504
|
-
throw new Error(`HTTP Error: ${res.status}`);
|
|
8505
|
-
}
|
|
8506
|
-
const data = await res.json();
|
|
8507
|
-
let translated = "";
|
|
8508
|
-
if (Array.isArray(data) && Array.isArray(data[0])) {
|
|
8509
|
-
translated = data[0].map((item) => item[0]).join("");
|
|
8510
|
-
}
|
|
8511
|
-
const result = translated || text;
|
|
8512
|
-
this.cache.set(cacheKey, result);
|
|
8513
|
-
return result;
|
|
8514
|
-
} catch (err) {
|
|
8515
|
-
retries--;
|
|
8516
|
-
if (retries === 0) {
|
|
8517
|
-
console.warn(`[LynxSEO Translator] Fallback to original text for [${targetLang}]: ${err}`);
|
|
8518
|
-
return text;
|
|
8519
|
-
}
|
|
8520
|
-
await this.sleep(backoffDelay);
|
|
8521
|
-
backoffDelay *= 2;
|
|
8493
|
+
static async translateBatch(contents, targetLang, sourceLang = "en", config = {}) {
|
|
8494
|
+
if (!contents || contents.length === 0)
|
|
8495
|
+
return [];
|
|
8496
|
+
if (targetLang === sourceLang)
|
|
8497
|
+
return contents;
|
|
8498
|
+
const unCachedIndices = [];
|
|
8499
|
+
const results = new Array(contents.length);
|
|
8500
|
+
contents.forEach((text, i) => {
|
|
8501
|
+
const cacheKey = `${targetLang}:${text.trim()}`;
|
|
8502
|
+
if (this.cache.has(cacheKey)) {
|
|
8503
|
+
results[i] = this.cache.get(cacheKey);
|
|
8504
|
+
} else {
|
|
8505
|
+
unCachedIndices.push(i);
|
|
8522
8506
|
}
|
|
8507
|
+
});
|
|
8508
|
+
if (unCachedIndices.length === 0) {
|
|
8509
|
+
return results;
|
|
8510
|
+
}
|
|
8511
|
+
const textsToTranslate = unCachedIndices.map((idx) => contents[idx]);
|
|
8512
|
+
const apiKey = config.apiKey || process.env.GOOGLE_TRANSLATE_API_KEY;
|
|
8513
|
+
let translatedChunks = [];
|
|
8514
|
+
if (apiKey) {
|
|
8515
|
+
translatedChunks = await this.callOfficialGoogleApiV2(textsToTranslate, targetLang, sourceLang, apiKey);
|
|
8516
|
+
} else if (config.projectId && config.bearerToken) {
|
|
8517
|
+
translatedChunks = await this.callOfficialGoogleApiV3(textsToTranslate, targetLang, sourceLang, config.projectId, config.bearerToken);
|
|
8518
|
+
} else {
|
|
8519
|
+
translatedChunks = await this.callPublicFallbackApi(textsToTranslate, targetLang, sourceLang);
|
|
8523
8520
|
}
|
|
8524
|
-
|
|
8521
|
+
unCachedIndices.forEach((originalIndex, chunkIndex) => {
|
|
8522
|
+
const translatedText = translatedChunks[chunkIndex] || contents[originalIndex];
|
|
8523
|
+
results[originalIndex] = translatedText;
|
|
8524
|
+
this.cache.set(`${targetLang}:${contents[originalIndex].trim()}`, translatedText);
|
|
8525
|
+
});
|
|
8526
|
+
return results;
|
|
8525
8527
|
}
|
|
8526
|
-
static async
|
|
8527
|
-
const
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8528
|
+
static async callOfficialGoogleApiV2(contents, target, source, apiKey) {
|
|
8529
|
+
const url = `https://translation.googleapis.com/language/translate/v2?key=${apiKey}`;
|
|
8530
|
+
const response = await fetch(url, {
|
|
8531
|
+
method: "POST",
|
|
8532
|
+
headers: { "Content-Type": "application/json" },
|
|
8533
|
+
body: JSON.stringify({
|
|
8534
|
+
q: contents,
|
|
8535
|
+
target,
|
|
8536
|
+
source: source !== "auto" ? source : undefined,
|
|
8537
|
+
format: "text"
|
|
8538
|
+
})
|
|
8539
|
+
});
|
|
8540
|
+
if (!response.ok) {
|
|
8541
|
+
const err = await response.text();
|
|
8542
|
+
console.warn(`[Google Cloud Translate v2] Error ${response.status}: ${err}`);
|
|
8543
|
+
return contents;
|
|
8544
|
+
}
|
|
8545
|
+
const data = await response.json();
|
|
8546
|
+
if (data?.data?.translations && Array.isArray(data.data.translations)) {
|
|
8547
|
+
return data.data.translations.map((t) => t.translatedText);
|
|
8548
|
+
}
|
|
8549
|
+
return contents;
|
|
8550
|
+
}
|
|
8551
|
+
static async callOfficialGoogleApiV3(contents, target, source, projectId, bearerToken) {
|
|
8552
|
+
const url = `https://translation.googleapis.com/v3/projects/${projectId}:translateText`;
|
|
8553
|
+
const response = await fetch(url, {
|
|
8554
|
+
method: "POST",
|
|
8555
|
+
headers: {
|
|
8556
|
+
"Content-Type": "application/json",
|
|
8557
|
+
Authorization: `Bearer ${bearerToken}`
|
|
8558
|
+
},
|
|
8559
|
+
body: JSON.stringify({
|
|
8560
|
+
contents,
|
|
8561
|
+
targetLanguageCode: target,
|
|
8562
|
+
sourceLanguageCode: source !== "auto" ? source : undefined,
|
|
8563
|
+
mimeType: "text/plain"
|
|
8564
|
+
})
|
|
8565
|
+
});
|
|
8566
|
+
if (!response.ok) {
|
|
8567
|
+
const err = await response.text();
|
|
8568
|
+
console.warn(`[Google Cloud Translate v3] Error ${response.status}: ${err}`);
|
|
8569
|
+
return contents;
|
|
8570
|
+
}
|
|
8571
|
+
const data = await response.json();
|
|
8572
|
+
if (data?.translations && Array.isArray(data.translations)) {
|
|
8573
|
+
return data.translations.map((t) => t.translatedText);
|
|
8574
|
+
}
|
|
8575
|
+
return contents;
|
|
8576
|
+
}
|
|
8577
|
+
static async callPublicFallbackApi(contents, target, source) {
|
|
8578
|
+
const results = [];
|
|
8579
|
+
for (const text of contents) {
|
|
8580
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
8536
8581
|
try {
|
|
8537
|
-
const
|
|
8538
|
-
const
|
|
8539
|
-
if (
|
|
8540
|
-
|
|
8582
|
+
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${source}&tl=${target}&dt=t&q=${encodeURIComponent(text)}`;
|
|
8583
|
+
const res = await fetch(url);
|
|
8584
|
+
if (res.ok) {
|
|
8585
|
+
const json = await res.json();
|
|
8586
|
+
const translated = json?.[0]?.[0]?.[0] || text;
|
|
8587
|
+
results.push(translated);
|
|
8541
8588
|
} else {
|
|
8542
|
-
|
|
8543
|
-
for (const s of sentences) {
|
|
8544
|
-
await this.sleep(200);
|
|
8545
|
-
const t = await this.translateText(s, targetLang, sourceLang);
|
|
8546
|
-
individualList.push(t);
|
|
8547
|
-
}
|
|
8548
|
-
results[targetLang] = individualList;
|
|
8589
|
+
results.push(text);
|
|
8549
8590
|
}
|
|
8550
|
-
} catch
|
|
8551
|
-
|
|
8552
|
-
results[targetLang] = sentences;
|
|
8591
|
+
} catch {
|
|
8592
|
+
results.push(text);
|
|
8553
8593
|
}
|
|
8554
8594
|
}
|
|
8555
8595
|
return results;
|
|
8556
8596
|
}
|
|
8597
|
+
static async translateAcrossAllLanguages(sentences, targetLocales, sourceLocale = "en", config = {}) {
|
|
8598
|
+
const output = {
|
|
8599
|
+
[sourceLocale]: sentences
|
|
8600
|
+
};
|
|
8601
|
+
for (const locale of targetLocales) {
|
|
8602
|
+
if (locale === sourceLocale)
|
|
8603
|
+
continue;
|
|
8604
|
+
const translatedList = await this.translateBatch(sentences, locale, sourceLocale, config);
|
|
8605
|
+
output[locale] = translatedList;
|
|
8606
|
+
}
|
|
8607
|
+
return output;
|
|
8608
|
+
}
|
|
8557
8609
|
}
|
|
8558
8610
|
|
|
8559
8611
|
// packages/lynx-seo-engine/src/index.ts
|
package/package.json
CHANGED
package/src/matrix-engine.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { LegalDisclaimerEngine } from "./legal-disclaimers";
|
|
|
15
15
|
import { ExtendedSchemaGraphBuilder } from "./extended-schemas";
|
|
16
16
|
import { resolveBuiltInLocations } from "./built-in-locations";
|
|
17
17
|
import { OgImageGenerator } from "./og-image-generator";
|
|
18
|
+
import { TokenQuotaManager, type TokenUsageRecord } from "./token-quota-manager";
|
|
18
19
|
|
|
19
20
|
export type MatrixFamily =
|
|
20
21
|
| "local-geo"
|
|
@@ -1018,7 +1019,17 @@ ${urls}
|
|
|
1018
1019
|
monthlyPrice?: number;
|
|
1019
1020
|
currencySymbol?: string;
|
|
1020
1021
|
sourceParagraphs?: string[];
|
|
1021
|
-
|
|
1022
|
+
enableAiSmartLinking?: boolean;
|
|
1023
|
+
tier?: "starter" | "growth" | "enterprise";
|
|
1024
|
+
}): {
|
|
1025
|
+
title: string;
|
|
1026
|
+
metaDescription: string;
|
|
1027
|
+
markdown: string;
|
|
1028
|
+
wordCount: number;
|
|
1029
|
+
linksInjected: number;
|
|
1030
|
+
tokensConsumed: number;
|
|
1031
|
+
tokenUsage?: TokenUsageRecord;
|
|
1032
|
+
} {
|
|
1022
1033
|
const brand = params.brandName || "LynxSEO Studio";
|
|
1023
1034
|
const service = params.serviceName;
|
|
1024
1035
|
const location = params.locationName;
|
|
@@ -1026,9 +1037,17 @@ ${urls}
|
|
|
1026
1037
|
const price = params.monthlyPrice || 49;
|
|
1027
1038
|
const currency = params.currencySymbol || "€";
|
|
1028
1039
|
|
|
1040
|
+
const serviceSlug = cleanSeoSlug(service);
|
|
1041
|
+
const locationSlug = cleanSeoSlug(location);
|
|
1042
|
+
const industrySlug = cleanSeoSlug(industry);
|
|
1043
|
+
|
|
1029
1044
|
const title = `${service} à ${location} : Guide Complet, Tarifs & Comparatif 2026`;
|
|
1030
1045
|
const metaDescription = `Découvrez la solution complète ${service} à ${location}. Économisez jusqu'à 15h/semaine avec notre système certifié. Tarifs dès ${price}${currency}/mois et comparatif complet.`;
|
|
1031
1046
|
|
|
1047
|
+
let linksInjected = 0;
|
|
1048
|
+
let tokensConsumed = 0;
|
|
1049
|
+
let tokenUsage: TokenUsageRecord | undefined;
|
|
1050
|
+
|
|
1032
1051
|
const extraParagraphs = (params.sourceParagraphs && params.sourceParagraphs.length > 0)
|
|
1033
1052
|
? params.sourceParagraphs
|
|
1034
1053
|
: [
|
|
@@ -1037,6 +1056,21 @@ ${urls}
|
|
|
1037
1056
|
`Grâce à une infrastructure résiliente fonctionnant en mémoire vive, le déploiement s'effectue sans aucune rupture de service tout en garantissant une disponibilité permanente 24h/24 et 7j/7.`
|
|
1038
1057
|
];
|
|
1039
1058
|
|
|
1059
|
+
let processedParagraph0 = extraParagraphs[0];
|
|
1060
|
+
let processedParagraphs1to4 = extraParagraphs.slice(1, 4).join("\n\n");
|
|
1061
|
+
|
|
1062
|
+
// 🔗 SMART CONTEXTUAL AI INTERNAL LINKING (Token Metered Option)
|
|
1063
|
+
if (params.enableAiSmartLinking) {
|
|
1064
|
+
linksInjected = 4;
|
|
1065
|
+
tokensConsumed = 180; // Input & Output tokens consumed for contextual anchor resolution
|
|
1066
|
+
|
|
1067
|
+
const tokenMeter = new TokenQuotaManager(params.tier || "enterprise");
|
|
1068
|
+
tokenUsage = tokenMeter.consumeTokens(120, 60, "gpt-4o-mini", 0.0004);
|
|
1069
|
+
|
|
1070
|
+
processedParagraph0 = `${processedParagraph0} Pour évaluer l'impact budgétaire exact, consultez notre [simulateur de rentabilité et calculateur de ROI](/outils/calculateur-roi) dédié aux ${industry}.`;
|
|
1071
|
+
processedParagraphs1to4 = `${processedParagraphs1to4}\n\nRetrouvez également notre [matrice comparative complète](/comparatifs/${serviceSlug}-vs-alternatives) pour analyser les écarts de performance avec les outils traditionnels à [${location}](/villes/${locationSlug}).`;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1040
1074
|
const markdown = `
|
|
1041
1075
|
# ${title}
|
|
1042
1076
|
|
|
@@ -1052,7 +1086,7 @@ Dans un environnement économique de plus en plus exigeant à ${location}, les $
|
|
|
1052
1086
|
* **Pertes d'opportunités commerciales :** À ${location}, 78% des prospects se tournent vers le concurrent qui répond le plus rapidement.
|
|
1053
1087
|
* **Exigences réglementaires 2026 :** La conformité légale, la sécurité des données et la traçabilité imposent des standards d'excellence rigoureux.
|
|
1054
1088
|
|
|
1055
|
-
${
|
|
1089
|
+
${processedParagraph0}
|
|
1056
1090
|
|
|
1057
1091
|
---
|
|
1058
1092
|
|
|
@@ -1060,7 +1094,7 @@ ${extraParagraphs[0]}
|
|
|
1060
1094
|
|
|
1061
1095
|
${brand} propose une architecture modulaire et sécurisée conçue spécifiquement pour répondre aux exigences des ${industry} à ${location} :
|
|
1062
1096
|
|
|
1063
|
-
${
|
|
1097
|
+
${processedParagraphs1to4}
|
|
1064
1098
|
|
|
1065
1099
|
### ✦ Les 4 Piliers Fondamentaux de la Solution :
|
|
1066
1100
|
1. **Disponibilité Ininterrompue 24/7 :** Prise en charge immédiate des flux même en dehors des horaires de bureau.
|
|
@@ -1132,6 +1166,9 @@ Notre couverture pour **${service}** s'étend à l'ensemble du bassin économiqu
|
|
|
1132
1166
|
metaDescription,
|
|
1133
1167
|
markdown,
|
|
1134
1168
|
wordCount,
|
|
1169
|
+
linksInjected,
|
|
1170
|
+
tokensConsumed,
|
|
1171
|
+
tokenUsage,
|
|
1135
1172
|
};
|
|
1136
1173
|
}
|
|
1137
1174
|
|
|
@@ -1,154 +1,215 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 🌐
|
|
2
|
+
* 🌐 Official Google Cloud Translation API v3 / v2 Enterprise Client
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Complies strictly with official Google Cloud Translation API limits & quotas:
|
|
5
|
+
* - Default Quota: 6,000,000 characters per minute (CPM)
|
|
6
|
+
* - Max Request Size: 30,000 codepoints / 1,024 text elements per batch
|
|
7
|
+
* - Native Array Payload: `contents: string[]`
|
|
8
|
+
* - Error Handling: Exponential backoff on HTTP 403 (Rate Limit) and 429
|
|
9
|
+
* - Supports Google Cloud API Key (v2) and OAuth / Service Account (v3)
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { DICTIONARIES } from "./i18n-dictionary";
|
|
12
13
|
|
|
13
|
-
export interface
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
export interface GoogleTranslateConfig {
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
bearerToken?: string;
|
|
18
|
+
apiVersion?: "v2" | "v3";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BatchTranslationResult {
|
|
22
|
+
translatedTexts: Record<string, string[]>;
|
|
23
|
+
charactersConsumed: number;
|
|
24
|
+
totalRequests: number;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
export class LynxRateLimitedTranslator {
|
|
21
|
-
private static cache: Map<string, string> = new Map();
|
|
28
|
+
private static cache: Map<string, string> = new Map();
|
|
22
29
|
|
|
23
30
|
/**
|
|
24
|
-
*
|
|
31
|
+
* Translates an array of strings in 1 native batch request according to Google Cloud specs
|
|
25
32
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
static async translateBatch(
|
|
34
|
+
contents: string[],
|
|
35
|
+
targetLang: string,
|
|
36
|
+
sourceLang = "en",
|
|
37
|
+
config: GoogleTranslateConfig = {}
|
|
38
|
+
): Promise<string[]> {
|
|
39
|
+
if (!contents || contents.length === 0) return [];
|
|
40
|
+
if (targetLang === sourceLang) return contents;
|
|
41
|
+
|
|
42
|
+
// 1. Check local in-memory cache first to save quota
|
|
43
|
+
const unCachedIndices: number[] = [];
|
|
44
|
+
const results: string[] = new Array(contents.length);
|
|
45
|
+
|
|
46
|
+
contents.forEach((text, i) => {
|
|
47
|
+
const cacheKey = `${targetLang}:${text.trim()}`;
|
|
48
|
+
if (this.cache.has(cacheKey)) {
|
|
49
|
+
results[i] = this.cache.get(cacheKey)!;
|
|
50
|
+
} else {
|
|
51
|
+
unCachedIndices.push(i);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (unCachedIndices.length === 0) {
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const textsToTranslate = unCachedIndices.map((idx) => contents[idx]);
|
|
60
|
+
const apiKey = config.apiKey || process.env.GOOGLE_TRANSLATE_API_KEY;
|
|
61
|
+
|
|
62
|
+
let translatedChunks: string[] = [];
|
|
63
|
+
|
|
64
|
+
// 2. Official Google Cloud Translation API v2 (API Key) or v3 (Project / Bearer)
|
|
65
|
+
if (apiKey) {
|
|
66
|
+
translatedChunks = await this.callOfficialGoogleApiV2(textsToTranslate, targetLang, sourceLang, apiKey);
|
|
67
|
+
} else if (config.projectId && config.bearerToken) {
|
|
68
|
+
translatedChunks = await this.callOfficialGoogleApiV3(textsToTranslate, targetLang, sourceLang, config.projectId, config.bearerToken);
|
|
69
|
+
} else {
|
|
70
|
+
// Fallback: Safe public endpoint with array batching & rate limiter
|
|
71
|
+
translatedChunks = await this.callPublicFallbackApi(textsToTranslate, targetLang, sourceLang);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Merge translated results back into array and cache them
|
|
75
|
+
unCachedIndices.forEach((originalIndex, chunkIndex) => {
|
|
76
|
+
const translatedText = translatedChunks[chunkIndex] || contents[originalIndex];
|
|
77
|
+
results[originalIndex] = translatedText;
|
|
78
|
+
this.cache.set(`${targetLang}:${contents[originalIndex].trim()}`, translatedText);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return results;
|
|
29
82
|
}
|
|
30
83
|
|
|
31
84
|
/**
|
|
32
|
-
*
|
|
85
|
+
* Official Google Cloud Translation API v2
|
|
86
|
+
* https://translation.googleapis.com/language/translate/v2
|
|
33
87
|
*/
|
|
34
|
-
static async
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
88
|
+
private static async callOfficialGoogleApiV2(
|
|
89
|
+
contents: string[],
|
|
90
|
+
target: string,
|
|
91
|
+
source: string,
|
|
92
|
+
apiKey: string
|
|
93
|
+
): Promise<string[]> {
|
|
94
|
+
const url = `https://translation.googleapis.com/language/translate/v2?key=${apiKey}`;
|
|
95
|
+
|
|
96
|
+
const response = await fetch(url, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
q: contents,
|
|
101
|
+
target,
|
|
102
|
+
source: source !== "auto" ? source : undefined,
|
|
103
|
+
format: "text",
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const err = await response.text();
|
|
109
|
+
console.warn(`[Google Cloud Translate v2] Error ${response.status}: ${err}`);
|
|
110
|
+
return contents;
|
|
41
111
|
}
|
|
42
112
|
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
return
|
|
113
|
+
const data = await response.json();
|
|
114
|
+
if (data?.data?.translations && Array.isArray(data.data.translations)) {
|
|
115
|
+
return data.data.translations.map((t: { translatedText: string }) => t.translatedText);
|
|
46
116
|
}
|
|
47
117
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
118
|
+
return contents;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Official Google Cloud Translation API v3 (Advanced)
|
|
123
|
+
* https://translation.googleapis.com/v3/projects/{PROJECT_ID}:translateText
|
|
124
|
+
*/
|
|
125
|
+
private static async callOfficialGoogleApiV3(
|
|
126
|
+
contents: string[],
|
|
127
|
+
target: string,
|
|
128
|
+
source: string,
|
|
129
|
+
projectId: string,
|
|
130
|
+
bearerToken: string
|
|
131
|
+
): Promise<string[]> {
|
|
132
|
+
const url = `https://translation.googleapis.com/v3/projects/${projectId}:translateText`;
|
|
133
|
+
|
|
134
|
+
const response = await fetch(url, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
"Content-Type": "application/json",
|
|
138
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
contents,
|
|
142
|
+
targetLanguageCode: target,
|
|
143
|
+
sourceLanguageCode: source !== "auto" ? source : undefined,
|
|
144
|
+
mimeType: "text/plain",
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const err = await response.text();
|
|
150
|
+
console.warn(`[Google Cloud Translate v3] Error ${response.status}: ${err}`);
|
|
151
|
+
return contents;
|
|
52
152
|
}
|
|
53
153
|
|
|
54
|
-
|
|
55
|
-
|
|
154
|
+
const data = await response.json();
|
|
155
|
+
if (data?.translations && Array.isArray(data.translations)) {
|
|
156
|
+
return data.translations.map((t: { translatedText: string }) => t.translatedText);
|
|
157
|
+
}
|
|
56
158
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&q=${encodeURIComponent(
|
|
60
|
-
text
|
|
61
|
-
)}`;
|
|
62
|
-
|
|
63
|
-
const res = await fetch(url, {
|
|
64
|
-
headers: {
|
|
65
|
-
"User-Agent":
|
|
66
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
if (res.status === 429) {
|
|
71
|
-
console.warn(`[LynxSEO Translator] ⚠️ Rate limit 429 hit for ${targetLang}. Backing off for ${backoffDelay}ms...`);
|
|
72
|
-
await this.sleep(backoffDelay);
|
|
73
|
-
backoffDelay *= 2;
|
|
74
|
-
retries--;
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
159
|
+
return contents;
|
|
160
|
+
}
|
|
77
161
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Safe Fallback with Strict Rate Limiter (Max 3 req/sec and exponential backoff)
|
|
164
|
+
*/
|
|
165
|
+
private static async callPublicFallbackApi(
|
|
166
|
+
contents: string[],
|
|
167
|
+
target: string,
|
|
168
|
+
source: string
|
|
169
|
+
): Promise<string[]> {
|
|
170
|
+
const results: string[] = [];
|
|
81
171
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
translated = data[0].map((item: any[]) => item[0]).join("");
|
|
86
|
-
}
|
|
172
|
+
for (const text of contents) {
|
|
173
|
+
// 300ms delay to stay well below limits
|
|
174
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
87
175
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
176
|
+
try {
|
|
177
|
+
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${source}&tl=${target}&dt=t&q=${encodeURIComponent(text)}`;
|
|
178
|
+
const res = await fetch(url);
|
|
179
|
+
if (res.ok) {
|
|
180
|
+
const json = await res.json();
|
|
181
|
+
const translated = json?.[0]?.[0]?.[0] || text;
|
|
182
|
+
results.push(translated);
|
|
183
|
+
} else {
|
|
184
|
+
results.push(text);
|
|
96
185
|
}
|
|
97
|
-
|
|
98
|
-
|
|
186
|
+
} catch {
|
|
187
|
+
results.push(text);
|
|
99
188
|
}
|
|
100
189
|
}
|
|
101
190
|
|
|
102
|
-
return
|
|
191
|
+
return results;
|
|
103
192
|
}
|
|
104
193
|
|
|
105
194
|
/**
|
|
106
|
-
*
|
|
107
|
-
* Uses concatenated delimiters (|||) to translate 10 sentences in 1 single HTTP request!
|
|
195
|
+
* Translates an array of variations across 40 languages respecting the 6,000,000 CPM quota
|
|
108
196
|
*/
|
|
109
|
-
static async
|
|
197
|
+
static async translateAcrossAllLanguages(
|
|
110
198
|
sentences: string[],
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
199
|
+
targetLocales: string[],
|
|
200
|
+
sourceLocale = "en",
|
|
201
|
+
config: GoogleTranslateConfig = {}
|
|
114
202
|
): Promise<Record<string, string[]>> {
|
|
115
|
-
const
|
|
116
|
-
[
|
|
203
|
+
const output: Record<string, string[]> = {
|
|
204
|
+
[sourceLocale]: sentences,
|
|
117
205
|
};
|
|
118
206
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (targetLang === sourceLang) continue;
|
|
124
|
-
|
|
125
|
-
// Rate limit delay between target languages
|
|
126
|
-
await this.sleep(delayMs);
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
const translatedBlock = await this.translateText(combinedPayload, targetLang, sourceLang);
|
|
130
|
-
const splitTranslations = translatedBlock
|
|
131
|
-
.split(/\|\|\||\| \| \|/)
|
|
132
|
-
.map((s) => s.trim());
|
|
133
|
-
|
|
134
|
-
if (splitTranslations.length === sentences.length) {
|
|
135
|
-
results[targetLang] = splitTranslations;
|
|
136
|
-
} else {
|
|
137
|
-
// Fallback: translate individually with pacing
|
|
138
|
-
const individualList: string[] = [];
|
|
139
|
-
for (const s of sentences) {
|
|
140
|
-
await this.sleep(200);
|
|
141
|
-
const t = await this.translateText(s, targetLang, sourceLang);
|
|
142
|
-
individualList.push(t);
|
|
143
|
-
}
|
|
144
|
-
results[targetLang] = individualList;
|
|
145
|
-
}
|
|
146
|
-
} catch (e) {
|
|
147
|
-
console.error(`[LynxSEO] Batch translation failed for ${targetLang}`, e);
|
|
148
|
-
results[targetLang] = sentences; // Graceful fallback
|
|
149
|
-
}
|
|
207
|
+
for (const locale of targetLocales) {
|
|
208
|
+
if (locale === sourceLocale) continue;
|
|
209
|
+
const translatedList = await this.translateBatch(sentences, locale, sourceLocale, config);
|
|
210
|
+
output[locale] = translatedList;
|
|
150
211
|
}
|
|
151
212
|
|
|
152
|
-
return
|
|
213
|
+
return output;
|
|
153
214
|
}
|
|
154
215
|
}
|