Files
api.pawol.nu/src/api/parole/services/parole.js
T

286 lines
9.8 KiB
JavaScript
Raw Normal View History

2022-05-12 03:33:24 +04:00
'use strict';
const Diff = require('diff');
2022-05-12 03:33:24 +04:00
const { createCoreService } = require('@strapi/strapi').factories;
const { ApplicationError } = require('@strapi/utils').errors;
2022-05-12 03:33:24 +04:00
const LANG_MAP = {
2026-07-03 13:40:22 +04:00
fr: { field: 'francais', targetLang: 'fr', userPrompt: 'Tradui an fransé' },
en: { field: 'anglais', targetLang: 'en', userPrompt: 'Translate to English', deeplTarget: 'EN', suffix: '\n\n (Translated by DeepL)' },
es: { field: 'espagnol', targetLang: 'es', userPrompt: 'Traduce al español', deeplTarget: 'ES', suffix: '\n\n (Traducido por DeepL)' },
de: { field: 'allemand', targetLang: 'de', userPrompt: 'Übersetze auf Deutsch', deeplTarget: 'DE', suffix: '\n\n (Übersetzt von DeepL)' },
it: { field: 'italien', targetLang: 'it', userPrompt: 'Traduci in italiano', deeplTarget: 'IT', suffix: '\n\n (Tradotto da DeepL)' },
pt: { field: 'portugais', targetLang: 'pt', userPrompt: 'Traduza para o português', deeplTarget: 'PT-BR', suffix: '\n\n (Traduzido pela DeepL)' },
ja: { field: 'japonais', targetLang: 'ja', userPrompt: '日本語に翻訳して', deeplTarget: 'JA', suffix: '\n\n (DeepLによる翻訳)' },
ko: { field: 'coreen', targetLang: 'ko', userPrompt: '한국어로 번역해줘', deeplTarget: 'KO', suffix: '\n\n (DeepL 번역)' },
};
const ALL_LANGS = Object.keys(LANG_MAP);
function stripMarkdown(text) {
if (!text) return '';
return text
.replace(/#{1,6}\s+/g, '')
.replace(/\*\*(.*?)\*\*/gs, '$1')
.replace(/\*(.*?)\*/gs, '$1')
.replace(/__(.*?)__/gs, '$1')
.replace(/_(.*?)_/gs, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^[>\-*+]\s+/gm, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// Détecte si une transcription est probablement en français plutôt qu'en KA.
// Heuristique : si les pronoms personnels français représentent > 4 % des mots.
const FR_PRONOUNS = new Set(['je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles']);
function suspectFrench(text) {
if (!text) return false;
const words = text.toLowerCase().match(/\b[a-zàâäéèêëîïôöùûüç]+\b/g) || [];
if (words.length < 10) return false;
const frCount = words.filter(w => FR_PRONOUNS.has(w)).length;
return frCount / words.length > 0.04;
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
2026-06-15 20:19:30 +04:00
2022-05-20 00:05:34 +04:00
class Translator {
constructor() {
this.deeplApi = process.env.DEEPL_URL || 'api-free.deepl.com';
this.deeplKey = process.env.DEEPL_KEY;
this.urlRequest = `https://${this.deeplApi}/v2/translate`;
2022-05-20 00:05:34 +04:00
}
async get(origin, target, text) {
const response = await fetch(this.urlRequest, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${this.deeplKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
2026-04-28 12:30:46 +04:00
text: Array.isArray(text) ? text : [text],
source_lang: origin,
target_lang: target,
}),
signal: AbortSignal.timeout(15_000)
});
2022-05-20 00:05:34 +04:00
if (!response.ok) {
const body = await response.text();
console.error('DeepL error:', body);
throw new Error(`DeepL ${response.status}: ${body}`);
2022-05-20 00:05:34 +04:00
}
return await response.json();
2022-05-20 00:05:34 +04:00
}
}
const translator = new Translator();
2022-05-20 00:05:34 +04:00
module.exports = createCoreService('api::parole.parole', ({strapi}) => ({
async translate(origin, target, text) {
const data = await translator.get(origin, target, text);
return data.translations[0].text;
},
async translateLyrics(parolesFR) {
const result = { francais: parolesFR };
2026-07-03 13:40:22 +04:00
for (const lang of ALL_LANGS) {
if (lang === 'fr') continue;
const { field, deeplTarget, suffix } = LANG_MAP[lang];
try {
const translated = await this.translate('FR', deeplTarget, parolesFR);
result[field] = translated + suffix;
} catch (err) {
strapi.log.error(`DeepL (${deeplTarget}): ${err.message}`);
}
}
2026-07-03 13:40:22 +04:00
return result;
},
validateParoles(titre, transcription) {
if (!titre || titre.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez choisir un titre.');
}
2022-12-09 04:42:27 +04:00
if (!transcription || transcription.trim().length === 0) {
throw new ApplicationError('Champ obligatoire. Veuillez renseigner la transcription.');
}
2022-12-09 04:42:27 +04:00
if (transcription.trim().length < 10) {
throw new ApplicationError('La transcription doit contenir au moins 10 caractères.');
}
2022-12-09 04:42:27 +04:00
},
async fetchAllParoles() {
const pageSize = 100;
let start = 0;
const all = [];
for (;;) {
const batch = await strapi.documents('api::parole.parole').findMany({
status: 'published',
populate: ['artistes', 'traductions'],
2026-06-12 14:10:05 +04:00
fields: ['documentId', 'titre', 'slug', 'transcription', 'annee', 'langueSource'],
limit: pageSize,
start,
});
all.push(...batch);
if (batch.length < pageSize) break;
start += pageSize;
}
return all;
},
buildExport(paroles, type, langs) {
const targetLangs = langs && langs.length ? langs : ALL_LANGS;
const pairs = [];
const missing = [];
const nonKa = [];
const langCounts = {};
for (const parole of paroles) {
const source = stripMarkdown(parole.transcription);
const sourceLang = parole.langueSource || 'ka';
const artists = (parole.artistes || []).map(a => a.alias);
const paroleMeta = { title: parole.titre, artists };
2026-06-12 14:10:05 +04:00
if (sourceLang !== 'ka') {
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: sourceLang });
2026-06-12 14:10:05 +04:00
} else if (suspectFrench(source)) {
nonKa.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, suspected_lang: 'fr' });
}
const missingLangs = ALL_LANGS.filter(lang => !parole.traductions?.[LANG_MAP[lang].field]);
if (missingLangs.length > 0) {
missing.push({ documentId: parole.documentId, slug: parole.slug, ...paroleMeta, missing: missingLangs });
}
for (const lang of targetLangs) {
const { field, targetLang, userPrompt } = LANG_MAP[lang];
if (lang === sourceLang) continue;
const target = stripMarkdown(parole.traductions?.[field]);
if (!target) continue;
langCounts[lang] = (langCounts[lang] || 0) + 1;
if (type === 'instruct') {
2026-06-12 14:10:05 +04:00
const systemPrompt = sourceLang === 'ka'
? 'Tu es un expert en langue KA (créole guadeloupéen/martiniquais). Traduis le texte KA suivant.'
: `Tu es un expert en traduction. Traduis le texte suivant (langue source : ${sourceLang}).`;
pairs.push({
messages: [
2026-06-12 14:10:05 +04:00
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `${userPrompt} :\n\n${source}` },
{ role: 'assistant', content: target },
],
});
} else {
pairs.push({
2026-06-12 14:10:05 +04:00
source_lang: sourceLang,
target_lang: targetLang,
source,
target,
...paroleMeta,
});
}
}
}
const metadata = {
exported_at: new Date().toISOString(),
total_paroles: paroles.length,
total_pairs: pairs.length,
languages: langCounts,
missing_translations: missing,
non_ka_transcriptions: nonKa,
};
return { metadata, pairs };
},
2026-06-15 20:19:30 +04:00
async bulkTranslateMissing() {
2026-07-03 13:40:22 +04:00
const TARGET_LANGS = ALL_LANGS
.filter(lang => lang !== 'fr')
.map(lang => ({ lang, ...LANG_MAP[lang] }));
2026-06-15 20:19:30 +04:00
const pageSize = 100;
let start = 0;
const all = [];
for (;;) {
2026-06-15 20:19:30 +04:00
const batch = await strapi.documents('api::parole.parole').findMany({
status: 'published',
populate: ['traductions'],
fields: ['documentId', 'slug', 'titre', 'transcription', 'langueSource'],
limit: pageSize,
start,
});
all.push(...batch);
if (batch.length < pageSize) break;
start += pageSize;
2026-06-15 20:19:30 +04:00
}
const translated = [];
const skipped = [];
const errors = [];
2026-06-15 20:19:30 +04:00
for (const parole of all) {
const sourceFR = parole.traductions?.francais
|| (parole.langueSource === 'fr' ? parole.transcription : null);
2026-06-15 20:19:30 +04:00
if (!sourceFR) { skipped.push(parole.slug); continue; }
2026-06-15 20:19:30 +04:00
const missing = TARGET_LANGS.filter(({ field }) => !parole.traductions?.[field]);
if (missing.length === 0) { skipped.push(parole.slug); continue; }
2026-06-15 20:19:30 +04:00
// eslint-disable-next-line no-unused-vars -- id exclu intentionnellement du spread
const { id: _id, ...tradData } = parole.traductions || {};
const updatedTrad = { ...tradData };
const addedLangs = [];
2026-06-15 20:19:30 +04:00
for (const { lang, field, deeplTarget, suffix } of missing) {
try {
await sleep(700);
const result = await translator.get('FR', deeplTarget, sourceFR);
const text = result?.translations?.[0]?.text;
2026-06-15 20:19:30 +04:00
if (text) {
updatedTrad[field] = text + suffix;
addedLangs.push(lang);
2026-06-15 20:19:30 +04:00
}
} catch (err) {
errors.push({ slug: parole.slug, lang: deeplTarget, error: err.message });
2026-06-15 20:19:30 +04:00
}
}
if (addedLangs.length > 0) {
await strapi.documents('api::parole.parole').update({
documentId: parole.documentId,
data: { traductions: updatedTrad },
});
2026-06-15 20:19:30 +04:00
await strapi.documents('api::parole.parole').publish({
documentId: parole.documentId,
});
translated.push({ slug: parole.slug, langs: addedLangs });
2026-06-15 20:19:30 +04:00
}
}
return { translated, skipped, errors };
2026-06-15 20:19:30 +04:00
},
2022-12-09 04:42:27 +04:00
parolesDiff(titre = '', oldString, newString) {
const patch = Diff.createPatch(titre, oldString, newString, 'supprimée', 'ajoutée');
const parsePatch = Diff.parsePatch(patch);
2022-12-09 04:42:27 +04:00
if (parsePatch[0].hunks.length > 0) {
const jsonDiff = Diff.diffWords(oldString, newString);
2022-12-12 22:19:59 +04:00
return {
patch,
jsonDiff
};
2022-12-09 04:42:27 +04:00
}
2022-05-20 00:05:34 +04:00
}
}));