diff --git a/README.md b/README.md index f6eb1cb10..a7e0c3110 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ ## 細かい変更点 +- 翻訳機能にて、投稿言語が指定されていない場合にのみ言語の自動検出を用いるように変更 - アップデート時に更新内容を確認できる機能を追加 - 英語のメッセージしか出ないけど、日本語に翻訳する機能も欲しいですか? - 依存ライブラリを最新版にアップデート diff --git a/neko/pnpm-lock.yaml b/neko/pnpm-lock.yaml index 41df01a4f..1babda43d 100644 --- a/neko/pnpm-lock.yaml +++ b/neko/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: deep-email-validator: specifier: 0.1.21 version: 0.1.21 + deepl-node: + specifier: 1.11.0 + version: 1.11.0 escape-regexp: specifier: 0.0.1 version: 0.0.1 @@ -7283,6 +7286,18 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepl-node@1.11.0: + resolution: {integrity: sha512-PLz38WIfWzVScbz4S+32vI9yCUAXy3cpkKSMPC+IDpvrwKrQ2TSzwkoBh7CJOkPjaX8ZMvjwAc+C5eTCPamQTg==} + engines: {node: '>=12.0'} + dependencies: + '@types/node': 20.10.5 + axios: 1.6.3 + form-data: 3.0.1 + loglevel: 1.8.1 + transitivePeerDependencies: + - debug + dev: false + /deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -9231,6 +9246,15 @@ packages: mime-types: 2.1.35 dev: false + /form-data@3.0.1: + resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -12093,6 +12117,11 @@ packages: is-unicode-supported: 0.1.0 dev: true + /loglevel@1.8.1: + resolution: {integrity: sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==} + engines: {node: '>= 0.6.0'} + dev: false + /long@4.0.0: resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} dev: false diff --git a/packages/backend/package.json b/packages/backend/package.json index 319437b20..945f03f9a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -57,6 +57,7 @@ "date-fns": "3.0.6", "decompress": "4.2.1", "deep-email-validator": "0.1.21", + "deepl-node": "1.11.0", "escape-regexp": "0.0.1", "feed": "4.2.2", "file-type": "18.7.0", diff --git a/packages/backend/src/misc/langmap.ts b/packages/backend/src/misc/langmap.ts index c4d4f0beb..092272d44 100644 --- a/packages/backend/src/misc/langmap.ts +++ b/packages/backend/src/misc/langmap.ts @@ -379,3 +379,4 @@ export const iso639Regional = { }; export const langmap = Object.assign({}, langmapNoRegion, iso639Regional); +export type Language = keyof typeof langmap; diff --git a/packages/backend/src/misc/translate.ts b/packages/backend/src/misc/translate.ts new file mode 100644 index 000000000..e0a75bbcc --- /dev/null +++ b/packages/backend/src/misc/translate.ts @@ -0,0 +1,88 @@ +import fetch from "node-fetch"; +import { Converter } from "opencc-js"; +import { getAgentByUrl } from "@/misc/fetch.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import type { Language } from "@/misc/langmap"; +import * as deepl from "deepl-node"; + +function convertChinese(convert: boolean, src: string) { + if (!convert) return src; + const converter = Converter({ from: "cn", to: "twp" }); + return converter(src); +} + +function stem(lang: Language): string { + let toReturn = lang as string; + if (toReturn.includes("-")) toReturn = toReturn.split("-")[0]; + if (toReturn.includes("_")) toReturn = toReturn.split("_")[0]; + return toReturn; +} + +export default async function ( + text: string, + from: Language | null, + to: Language, +) { + const instance = await fetchMeta(); + + if (instance.deeplAuthKey == null && instance.libreTranslateApiUrl == null) { + throw Error("No translator is set up on this server."); + } + + const source = from == null ? null : stem(from); + const target = stem(to); + + if (instance.libreTranslateApiUrl != null) { + const jsonBody = { + q: text, + source: source ?? "auto", + target, + format: "text", + api_key: instance.libreTranslateApiKey ?? "", + }; + + const url = new URL(instance.libreTranslateApiUrl); + if (url.pathname.endsWith("/")) { + url.pathname = url.pathname.slice(0, -1); + } + if (!url.pathname.endsWith("/translate")) { + url.pathname += "/translate"; + } + const res = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(jsonBody), + agent: getAgentByUrl, + }); + + const json = (await res.json()) as { + detectedLanguage?: { + confidence: number; + language: string; + }; + translatedText: string; + }; + + return { + sourceLang: source ?? json.detectedLanguage?.language, + text: convertChinese( + ["zh-hant", "zh-TW"].includes(to), + json.translatedText, + ), + }; + } + + const deeplTranslator = new deepl.Translator(instance.deeplAuthKey ?? ""); + const result = await deeplTranslator.translateText( + text, + source as deepl.SourceLanguageCode | null, + target as deepl.TargetLanguageCode, + ); + + return { + sourceLang: source ?? result.detectedSourceLang, + text: convertChinese(["zh-hant", "zh-TW"].includes(to), result.text), + }; +} diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index d1de39993..7f1f90cf2 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -1,12 +1,8 @@ -import { URLSearchParams } from "node:url"; -import fetch from "node-fetch"; -import config from "@/config/index.js"; -import { Converter } from "opencc-js"; -import { getAgentByUrl } from "@/misc/fetch.js"; -import { fetchMeta } from "@/misc/fetch-meta.js"; import { ApiError } from "@/server/api/error.js"; import { getNote } from "@/server/api/common/getters.js"; import define from "@/server/api/define.js"; +import translate from "@/misc/translate.js"; +import type { Language } from "@/misc/langmap"; export const meta = { tags: ["notes"], @@ -26,6 +22,11 @@ export const meta = { code: "NO_SUCH_NOTE", id: "bea9b03f-36e0-49c5-a4db-627a029f8971", }, + noteTextIsNull: { + message: "The text of this note is null.", + code: "NOTE_TEXT_IS_NULL", + id: "c2794117-1a8d-4fe5-8925-0eca24ba47d0", + }, }, } as const; @@ -38,13 +39,6 @@ export const paramDef = { required: ["noteId", "targetLang"], } as const; -function convertChinese(convert: boolean, src: string) { - if (!convert) return src; - - const converter = Converter({ from: "cn", to: "twp" }); - return converter(src); -} - export default define(meta, paramDef, async (ps, user) => { const note = await getNote(ps.noteId, user).catch((err) => { if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") @@ -53,89 +47,12 @@ export default define(meta, paramDef, async (ps, user) => { }); if (note.text == null) { - return 204; + throw new ApiError(meta.errors.noteTextIsNull); } - const instance = await fetchMeta(); - - if (instance.deeplAuthKey == null && instance.libreTranslateApiUrl == null) { - return 204; // TODO: 良い感じのエラー返す - } - - let targetLang = ps.targetLang; - if (targetLang.includes("-")) targetLang = targetLang.split("-")[0]; - if (targetLang.includes("_")) targetLang = targetLang.split("_")[0]; - - if (instance.libreTranslateApiUrl != null) { - const jsonBody = { - q: note.text, - source: "auto", - target: targetLang, - format: "text", - api_key: instance.libreTranslateApiKey ?? "", - }; - - const url = new URL(instance.libreTranslateApiUrl); - if (url.pathname.endsWith("/")) { - url.pathname = url.pathname.slice(0, -1); - } - if (!url.pathname.endsWith("/translate")) { - url.pathname += "/translate"; - } - const res = await fetch(url.toString(), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(jsonBody), - agent: getAgentByUrl, - }); - - const json = (await res.json()) as { - detectedLanguage?: { - confidence: number; - language: string; - }; - translatedText: string; - }; - - return { - sourceLang: json.detectedLanguage?.language, - text: convertChinese(ps.targetLang === "zh-TW", json.translatedText), - }; - } - - const params = new URLSearchParams(); - params.append("auth_key", instance.deeplAuthKey ?? ""); - params.append("text", note.text); - params.append("target_lang", targetLang); - - const endpoint = instance.deeplIsPro - ? "https://api.deepl.com/v2/translate" - : "https://api-free.deepl.com/v2/translate"; - - const res = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": config.userAgent, - Accept: "application/json, */*", - }, - body: params, - // TODO - //timeout: 10000, - agent: getAgentByUrl, - }); - - const json = (await res.json()) as { - translations: { - detected_source_language: string; - text: string; - }[]; - }; - - return { - sourceLang: json.translations[0].detected_source_language, - text: convertChinese(ps.targetLang === "zh-TW", json.translations[0].text), - }; + return translate( + note.text, + note.lang as Language | null, + ps.targetLang as Language, + ); });