add google translate fallback

This commit is contained in:
artie 2025-02-12 17:58:49 +01:00
parent 6d10dc2a00
commit ea40e13891
9 changed files with 368 additions and 52 deletions

3
.gitignore vendored
View File

@ -174,4 +174,5 @@ dist
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
data/temp/* data/temp/*
src/scripts/sandbox.ts

251
data/gtrans-langcodes.json Normal file
View File

@ -0,0 +1,251 @@
[
"aa",
"ab",
"ace",
"ach",
"af",
"ak",
"alz",
"am",
"ar",
"as",
"av",
"awa",
"ay",
"az",
"ba",
"bal",
"ban",
"bbc",
"bci",
"be",
"bem",
"ber",
"ber-Latn",
"bew",
"bg",
"bho",
"bik",
"bm",
"bm-Nkoo",
"bn",
"bo",
"br",
"bs",
"bts",
"btx",
"bua",
"ca",
"ce",
"ceb",
"cgg",
"ch",
"chk",
"chm",
"ckb",
"cnh",
"co",
"crh",
"crh-Latn",
"crs",
"cs",
"cv",
"cy",
"da",
"de",
"din",
"doi",
"dov",
"dv",
"dyu",
"dz",
"ee",
"el",
"en",
"eo",
"es",
"et",
"eu",
"fa",
"fa-AF",
"ff",
"fi",
"fj",
"fo",
"fon",
"fr",
"fr-CA",
"fur",
"fy",
"ga",
"gaa",
"gd",
"gl",
"gn",
"gom",
"gu",
"gv",
"ha",
"haw",
"hi",
"hil",
"hmn",
"hr",
"hrx",
"ht",
"hu",
"hy",
"iba",
"id",
"ig",
"ilo",
"is",
"it",
"iu",
"iu-Latn",
"iw",
"ja",
"jam",
"jw",
"ka",
"kac",
"kek",
"kg",
"kha",
"kk",
"kl",
"km",
"kn",
"ko",
"kr",
"kri",
"ktu",
"ku",
"kv",
"ky",
"la",
"lb",
"lg",
"li",
"lij",
"lmo",
"ln",
"lo",
"lt",
"ltg",
"lua",
"luo",
"lus",
"lv",
"mad",
"mai",
"mak",
"mam",
"mfe",
"mg",
"mh",
"mi",
"min",
"mk",
"ml",
"mn",
"mni-Mtei",
"mr",
"ms",
"ms-Arab",
"mt",
"mwr",
"my",
"ndc-ZW",
"ne",
"new",
"nhe",
"nl",
"no",
"nr",
"nso",
"nus",
"ny",
"oc",
"om",
"or",
"os",
"pa",
"pa-Arab",
"pag",
"pam",
"pap",
"pl",
"ps",
"pt",
"pt-PT",
"qu",
"rn",
"ro",
"rom",
"ru",
"rw",
"sa",
"sah",
"sat",
"sat-Latn",
"scn",
"sd",
"se",
"sg",
"shn",
"si",
"sk",
"sl",
"sm",
"sn",
"so",
"sq",
"sr",
"ss",
"st",
"su",
"sus",
"sv",
"sw",
"szl",
"ta",
"tcy",
"te",
"tet",
"tg",
"th",
"ti",
"tiv",
"tk",
"tl",
"tn",
"to",
"tpi",
"tr",
"trp",
"ts",
"tt",
"tum",
"ty",
"tyv",
"udm",
"ug",
"uk",
"ur",
"uz",
"ve",
"vec",
"vi",
"war",
"wo",
"xh",
"yi",
"yo",
"yua",
"yue",
"zap",
"zh-CN",
"zh-TW",
"zu"
]

View File

@ -9,13 +9,14 @@ import { defineCommand } from "..";
import { import {
getSourceLanguages, getSourceLanguages,
getTargetLanguages, getTargetLanguages,
isSourceLanguageSupported, isSourceLanguage,
isTargetLanguageSupported, isTargetLanguage,
translate, translate as translateDeepl,
} from "../../utils/deepl"; } from "../../utils/deepl";
import { abort } from "../../utils/error"; import { abort } from "../../utils/error";
import type { OCRResult } from "../../types/ocr"; import type { OCRResult } from "../../types/ocr";
import { capitalize, languageCodeToName } from "../../utils/functions"; import { capitalize, languageCodeToName } from "../../utils/functions";
import { translate as translateGoogle } from "../../utils/gtrans";
export async function translateAutocompleteImpl( export async function translateAutocompleteImpl(
interaction: AutocompleteInteraction interaction: AutocompleteInteraction
@ -44,15 +45,18 @@ export async function translateImpl(
ocrModel?: OCRResult["model"], ocrModel?: OCRResult["model"],
imageUrl?: string imageUrl?: string
): Promise<InteractionEditReplyOptions> { ): Promise<InteractionEditReplyOptions> {
const { let { translatedText, detectedSourceLang, model } = await translateDeepl(
text: translatedText,
detectedSourceLang,
billedCharacters,
} = await translate({
text, text,
source, source,
target, target
}); ).catch(() => translateGoogle(text, "auto", "en"));
if (translatedText.trim() === text.trim() && model === "deepl") {
const result = await translateGoogle(text, "auto", "en");
translatedText = result.translatedText;
detectedSourceLang = result.detectedSourceLang;
model = result.model;
}
const displaySource = languageCodeToName(detectedSourceLang); const displaySource = languageCodeToName(detectedSourceLang);
const displayTarget = languageCodeToName(target); const displayTarget = languageCodeToName(target);
@ -78,20 +82,20 @@ export async function translateImpl(
{ {
title: `From ${displaySource} to ${displayTarget}`, title: `From ${displaySource} to ${displayTarget}`,
description: translatedText, description: translatedText,
color: 0x0f2b46, color: model === "deepl" ? 0x0f2b46 : 0x4285f4,
...(imageUrl ? { image: { url: imageUrl } } : {}), ...(imageUrl ? { image: { url: imageUrl } } : {}),
author: { author: {
name: "DeepL", name: model === "deepl" ? "DeepL" : "Google Translate",
icon_url: "https://www.google.com/s2/favicons?domain=deepl.com&sz=64", icon_url: `https://www.google.com/s2/favicons?domain=${model}.com&sz=64`,
},
footer: {
text: ocrModel
? `OCR: ${capitalize(ocrModel)}`
: `Billed characters: ${billedCharacters}`,
icon_url: ocrModel
? `https://www.google.com/s2/favicons?domain=${ocrModel}.com&sz=64`
: undefined,
}, },
...(ocrModel
? {
footer: {
text: `OCR: ${capitalize(ocrModel)}`,
icon_url: `https://www.google.com/s2/favicons?domain=${ocrModel}.com&sz=64`,
},
}
: {}),
}, },
], ],
}; };
@ -100,7 +104,9 @@ export async function translateImpl(
export default defineCommand({ export default defineCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("translate") .setName("translate")
.setDescription("Translates text using DeepL") .setDescription(
"Translates text using DeepL or Google Translate as fallback"
)
.addStringOption((option) => .addStringOption((option) =>
option option
.setName("text") .setName("text")
@ -129,10 +135,10 @@ export default defineCommand({
await interaction.deferReply(); await interaction.deferReply();
if (source && !(await isSourceLanguageSupported(source))) { if (source && !(await isSourceLanguage(source))) {
abort("Source language not supported"); abort("Source language not supported");
} }
if (target && !(await isTargetLanguageSupported(target))) { if (target && !(await isTargetLanguage(target))) {
abort("Target language not supported"); abort("Target language not supported");
} }

View File

@ -97,7 +97,7 @@ export async function ocrImpl(url: string) {
export default defineCommand({ export default defineCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("ocr") .setName("ocr")
.setDescription("OCR an image using Yandex") .setDescription("OCR an image using Google Lens or Yandex as fallback")
.addAttachmentOption((option) => .addAttachmentOption((option) =>
option.setName("image").setDescription("The image to OCR") option.setName("image").setDescription("The image to OCR")
) )

View File

@ -1,10 +1,7 @@
import { SlashCommandBuilder } from "discord.js"; import { SlashCommandBuilder } from "discord.js";
import { defineCommand } from ".."; import { defineCommand } from "..";
import { abort } from "../../utils/error"; import { abort } from "../../utils/error";
import { import { isSourceLanguage, isTargetLanguage } from "../../utils/deepl";
isSourceLanguageSupported,
isTargetLanguageSupported,
} from "../../utils/deepl";
import { import {
translateAutocompleteImpl, translateAutocompleteImpl,
translateImpl, translateImpl,
@ -16,7 +13,7 @@ export default defineCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("ocrtranslate") .setName("ocrtranslate")
.setDescription( .setDescription(
"OCR an image using Yandex and translate the result using DeepL" "OCR an image using Google Lens or Yandex and translate the result using DeepL or Google Translate"
) )
.addAttachmentOption((option) => .addAttachmentOption((option) =>
option.setName("image").setDescription("The image to OCR") option.setName("image").setDescription("The image to OCR")
@ -50,10 +47,10 @@ export default defineCommand({
await interaction.deferReply(); await interaction.deferReply();
if (source && !(await isSourceLanguageSupported(source))) { if (source && !(await isSourceLanguage(source))) {
abort("Source language not supported"); abort("Source language not supported");
} }
if (target && !(await isTargetLanguageSupported(target))) { if (target && !(await isTargetLanguage(target))) {
abort("Target language not supported"); abort("Target language not supported");
} }

View File

5
src/types/translate.ts Normal file
View File

@ -0,0 +1,5 @@
export type TranslateResult = {
translatedText: string;
detectedSourceLang: string;
model: "deepl" | "google";
};

View File

@ -5,38 +5,34 @@ import {
} from "deepl-node"; } from "deepl-node";
import { env } from "../env"; import { env } from "../env";
import { lazy } from "./functions"; import { lazy } from "./functions";
import type { TranslateResult } from "../types/translate";
const translator = new Translator(env.DEEPL_API_KEY); const translator = new Translator(env.DEEPL_API_KEY);
export const getSourceLanguages = lazy(() => translator.getSourceLanguages()); export const getSourceLanguages = lazy(() => translator.getSourceLanguages());
export const getTargetLanguages = lazy(() => translator.getTargetLanguages()); export const getTargetLanguages = lazy(() => translator.getTargetLanguages());
type TranslateOptions = { export async function translate(
text: string; text: string,
source?: string | null; source: string | null = null,
target?: string; target = "en-US"
}; ): Promise<TranslateResult> {
const result = await translator.translateText(
export async function translate({
text,
source = null,
target = "en-US",
}: TranslateOptions) {
return translator.translateText(
text, text,
source as SourceLanguageCode, source as SourceLanguageCode | null,
target as TargetLanguageCode target as TargetLanguageCode
); );
return {
translatedText: result.text,
detectedSourceLang: result.detectedSourceLang,
model: "deepl",
};
} }
export async function getUsage() { export async function getUsage() {
return translator.getUsage(); return translator.getUsage();
} }
export async function getLanguages() { export async function isSourceLanguage(code: string) {
return (await getSourceLanguages()).concat(await getTargetLanguages());
}
export async function isSourceLanguageSupported(code: string) {
const sourceLanguages = await getSourceLanguages(); const sourceLanguages = await getSourceLanguages();
return ( return (
sourceLanguages.find((l) => l.code.toLowerCase() === code.toLowerCase()) !== sourceLanguages.find((l) => l.code.toLowerCase() === code.toLowerCase()) !==
@ -44,7 +40,7 @@ export async function isSourceLanguageSupported(code: string) {
); );
} }
export async function isTargetLanguageSupported(code: string) { export async function isTargetLanguage(code: string) {
const targetLanguages = await getTargetLanguages(); const targetLanguages = await getTargetLanguages();
return ( return (
targetLanguages.find((l) => l.code.toLowerCase() === code.toLowerCase()) !== targetLanguages.find((l) => l.code.toLowerCase() === code.toLowerCase()) !==

60
src/utils/gtrans.ts Normal file
View File

@ -0,0 +1,60 @@
import ky from "ky";
import { languageCodeToName, lazy } from "./functions";
import { readFileSync } from "node:fs";
import type { TranslateResult } from "../types/translate";
type TranslationResponse = {
src: string;
sentences: {
trans: string;
}[];
};
const client = ky.create({
prefixUrl: "https://translate.googleapis.com/translate_a",
});
const languageCodes = lazy(
() =>
JSON.parse(readFileSync("data/gtrans-langcodes.json", "utf8")) as string[]
);
export const getLanguages = lazy(() =>
languageCodes().map((code) => ({
code,
name: languageCodeToName(code),
}))
);
export function isLanguage(code: string) {
return languageCodes().includes(code);
}
export async function translate(
text: string,
source = "auto",
target = "en"
): Promise<TranslateResult> {
const res = await client.get("single", {
searchParams: {
sl: source,
tl: target,
q: text,
client: "gtx",
dt: "t",
dj: "1",
source: "input",
},
});
const { sentences, src } = await res.json<TranslationResponse>();
return {
translatedText: sentences
.map((s) => s?.trans)
.filter(Boolean)
.join(""),
detectedSourceLang: src,
model: "google",
};
}