diff --git a/README.md b/README.md index f6eb1cb1..a7e0c311 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ ## 細かい変更点 +- 翻訳機能にて、投稿言語が指定されていない場合にのみ言語の自動検出を用いるように変更 - アップデート時に更新内容を確認できる機能を追加 - 英語のメッセージしか出ないけど、日本語に翻訳する機能も欲しいですか? - 依存ライブラリを最新版にアップデート diff --git a/neko/pnpm-lock.yaml b/neko/pnpm-lock.yaml index 41df01a4..1babda43 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 319437b2..945f03f9 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 c4d4f0be..092272d4 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 00000000..e0a75bbc --- /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 d1de3999..7f1f90cf 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, + ); });