Site Overlay

用Cloudflare Workers提供一个翻译API后端

引言

大概是半年前吧,我得知了一个很好用的翻译用的浏览器插件,叫做沉浸式翻译。相比网页翻译,这个插件有一个非常不错的特点,就是它并不会直接替换翻译前的原文,而是将译文与原文进行并排比较。在很多场景下,机翻的结果由于缺乏上下文或者多义词,通常都需要参考原文才能让人理解,这样并排比较的插件极大提升了阅读的效率。

于是在使用了几天后,我加入了这个项目的Github Sponsor,一方面是它确实给我带来了巨大的方便,另一方面则是Sponsor可以得到作者给的一个DeepL的API key,用于插件内部的DeepL翻译。DeepL的API可能是我有史以来遇到的最难搞的API,对于IP、账单地址、信用卡BIN具有极其恶心的要求,我至今没能自主注册一个可用的DeepL API账户,而即使注册成功了,一个月也仅有50万字符的免费调用,对于我的阅读量而言甚至不足以支撑一个星期。而沉浸式翻译的Sponsor方案提供的API key有1000万的调用(而且开发者说只要不是太离谱甚至可以超),对于我来说是非常划算的。

但是在几个月前,有人在论坛上指出,沉浸式翻译的其实有一个更老的、开源的版本,并且这个开源的版本曾经被各种科技媒体作为“开源插件”报道推荐,即使是在插件已经转为闭源之后。另外,项目的Github repo曾经是属于开源版插件的,但是在转为闭源后,开源repo被迁移到了另一个地址并归档,将repo地址腾给了闭源项目。

其实这件事情与我没有什么关系,我发现这款插件的时候,这款插件已经转向了闭源,并且我一开始就知道这款插件不是开源的,只是通过Github发布而已。但是经过深思熟虑之后,我仍然决定放弃使用这款插件,我的想法是这样的:

赞成作者的部分举动,因为:

  • Github上发布的不必须是一款开源软件的源码,事实上是,很多闭源软件乃至各种和软件没有半点关系的二进制文件都是通过Github进行分发的,如果这一点存在问题,那也是违反了Github的EULA,而不是开源社区的什么规定。Github本身提供了一套非常便利的对项目进行管理、维护、交流的工具,发布在Github上显然不仅仅是出于“假装开源”的目的。
  • 通过闭源插件进行盈利是正当的,也是无可指摘的,而且开通Sponsor与被收购在我看来与项目本身转向闭源没有足够的因果关系,即使有,我认为也不能因此对项目进行批评。

理解作者的部分举动,因为:

  • 使用闭源项目直接替代老repo,从某种意义上情有可原。即使使用初号大字将项目迁移的通告写在README里面,也肯定会有相当大的一部分人看不见,这可能也是作者之所以进行替代的最大的原因。就我在群内对作者的了解而言,我认为作者对于直接替代的行为给闭源项目带来的好处的这一件事实充其量是“放任”,而远没有达到“有意利用”的程度。
  • 科技媒体对于插件是否开源,应当自己进行验证,作者对于媒体错误宣传的“开源”并没有义务更没有能力去逐个纠正,以此批评作者是欠妥的。

反对作者的部分举动,因为:

  • 新老项目的更替导致的找不到新repo的问题本质上是一种信息差的问题,而直接替换repo的行为不是在抹除信息差,而是在以一种负面的方式利用信息差,这是我一贯以来坚决反对的。直接替代的行为从根本上是对可追溯性的破坏,也是对用户信任的背叛。

对于项目是否是“假开源”是否应该被谴责,您可以看一看开发者自己的解释:对“假开源”事件的反省,或者看一看V2EX上的讨论:10k+ star 的项目也搞假开源,这里我就不再继续讨论了。我唯一一点想吐槽的是,V2EX或者说整个互联网的讨论环境确实在飞速地恶化,在讨论帖中我能看到不少比较有条理、有价值的意见,然而更多的人则是纯粹的动机质疑与人身攻击。要求反对者在道德上完美是一件非常荒谬的思维方式,一方面这体现了一种赤裸裸的自负与拒绝理性交流的流氓,另一方面这本质上是对整个社会道德水平的向下拉平,因为他们根本上拒绝相信道德层面的更优,并以此揣测他人。

让话题回到技术上来,放弃了沉浸式翻译后,我找到了另一款目前(2023年10月9日)还是开源的类似的翻译插件KISS Translator,除了没有翻译PDF的功能之外,这款插件能够满足我绝大多数的翻译需求,在我过去两个月的使用中,除了有一些段落不识别的bug之外,几乎可以说是完美。

除了一个问题,我之前提到过,我加入了沉浸式翻译的Sponsor很大一个目的就是DeepL的API key,虽然我目前还没有退出Sponsor,但是这个API key是只能在沉浸式翻译里面使用的(这倒也合理,否则作者不得被薅秃了)。KISS Translator支持谷歌、微软、DeepL和OpenAI的API接口,也支持自定义接口,谷歌和微软在我的实际体验中,翻译质量确实不如后两者,OpenAI的翻译确实厉害,但是以我的阅读量,一个月破产不再是梦想,因此看起来只能从DeepL或者自定义入手了。

使用Cloudflare Workers AI搭建翻译API后端

我对KISS Translator颇有好感,一部分原因大概是作者也是一个CF Worker的忠实用户,KISS Translator的配置同步功能就是使用CF Workers实现的。Cloudflare Workers AI是一个CF刚推出的能够访问其AI模型的Workers,由于CF带了个meta的翻译的model,所以我心血来潮想到能不能用它配合KISS Translator进行翻译。

我写了一个简单的CF Worker作为概念验证,CF目前支持的语言和语言的代码与KISS Translator的定义不同,由于个人水平限制,我在验证中写死了目标语言为中文,但是这个Worker代码我测试了能够配合KISS Translator正常翻译网页。

import { Ai } from './vendor/@cloudflare/ai';

export default {
  async fetch(request, env) {

    /**
     * readRequestBody reads in the incoming request body
     * Use await readRequestBody(..) in an async function to get the string
     * @param {Request} request the incoming request to read from
     */
    async function readRequestBody(request) {
      const contentType = request.headers.get("content-type");
      const Authorization = request.headers.get("Authorization");
      const expected_token = env.BEARER_TOKEN;
      if (Authorization === `Bearer ${expected_token}`) {
        if (contentType.includes("application/json")) {
          return await request.json();
        } else {
          throw new Error("Request's content-type is not supported. Please use application/json.");
        }
      } else {
        throw new Error("Authentication failed. Please check your access token.");
      }
    }

    if (request.method === "POST") {
      try {
        const reqBody = await readRequestBody(request);

        const ai = new Ai(env.AI);
        const inputs = {
          text: reqBody['text'],
          source_lang: '',
          target_lang: 'chinese'
        };
        const response = await ai.run('@cf/meta/m2m100-1.2b', inputs);

        const translated_text = response['translated_text'];
        const ret = {
          text: translated_text,
          from: "", // 识别的源语言,Cloudflare目前似乎并不支持
          to: "zh-CN"
        };
        return Response.json(ret);

      } catch (e) {
        return new Response(e.message);
      }
    } else if (request.method === "GET") {
      return new Response("Please use POST.");
    }

  }
};

在CF Worker的dashboard配置好BEARER_TOKEN的环境变量,并将它的值填入KISS Translator的Custom接口,接口URL填CF Worker的URL,即可使用Cloudflare Workers AI配合KISS Translator进行翻译了。

参考:Translation · Cloudflare Workers AI docs

当然这个方案毕竟只是一个尝鲜的小测试,还是有不少问题,比如Workers AI还在Beta不知道会不会有大的变动、Pricing会不会不够合理、语言代码和模型会不会变化什么的,因此这个方法仅供参考。我在issue区域(年轻人第一个issue)问了作者,作者也表示当前还不够合适,会考虑在Workers AI稳定后加入官方支持。

DeepL API负载均衡

我在淘宝上购买了几个DeepL Free API的账号,但是就如上面所说,单个DeepL账号的免费额度远远不够,而目前似乎KISS Translator并不能输入多个API key进行负载均衡(也有可能是我单纯不知道),因此我自己搭建了一个Cloudflare Worker,用于将目前的几个API key进行负载均衡。无论怎样,使用自己搭建的代理都有一定的好处,一方面可以用自己喜欢的方式管理API key,另一方面也可以用CF避开DeepL的莫名其妙的风控。

我首先在我已经使用在了很多地方的一段反向代理代码(参考:CloudFlare Workers 反代任意网站和挂载单页代码 – SunPma’Blog)上进行修改,增加了认证的步骤,但是使用这段代码并不能连接到DeepL服务器,反而会报SSL握手错误(525)。

经过简单的查找,我发现这居然是CF自己的bug:🐛 BUG: Error: Origin SSL Handshake Error (525) when making requests to deepl API · Issue #776 · cloudflare/workerd。CF的工作人员说应该能够在年底进行修复,那在此之前,看起来只能找一些替代的方案。

Issue中有人提到了可以使用其他的平台代理DeepL的API,比如qin-guan/deepl-proxy: Proxy for DeepL API。我尝试将这个代理部署在了Vercel上,确实可以成功访问DeepL的API了,但是这个项目是使用typescript写的,而我对TS和JS都是一窍不通,我的水平只能支持我修改一些逻辑代码,没有办法成功在这个代理中加入一些中间件,完成API key负载均衡的工作。

因此我决定使用Cloudflare Workers进行API负载均衡的管理,通过CF连接到Vercel,再通过Vercel完成与DeepL的通信。这样一旦CF解决自己的SSL握手问题,我就可以很快的切换回CF only的原计划。

然后我遇到了最后一个问题,在测试API可用性的时候,我一直使用的是查询余额的方法,而查询余额是一个GET,而实际使用中,翻译的API接口是一个POST,因此Workers报错:TypeError: A request with a one-time-use body (it was initialized from a stream, not a buffer) encountered a redirect requiring the body to be retransmitted. To avoid this error in the future, construct this request from a buffer-like body initializer.

好在Stack Overflow上已经有耐心的大佬详细解释了这个问题的原因:javascript – Cloudflare Worker TypeError: One-time-use body – Stack Overflow。在我的理解中,简而言之,就是POST的内容是流式传输的,因此它不会在任何的中间环节进行保存,而API在访问中会产生重定向,在重定向后,中间环节已经没有了可以发送的内容,因此报错。理论上而言,重定向应当发回给客户机,因此按照大佬的说法,fetch()不需要关注重定向,只需要原样转发请求就行。但是由于我需要进行API的负载均衡,因此我需要对请求进行修改,所以最简单的方法是在Workers上用一个缓冲区暂存POST的内容。我曾担心这样的缓冲区可能会带来过高的开销,但是就目前的测试与使用经历而言,开销在可以接受的范围之内。

最终修改完成可用的代码如下:

// Website you intended to retrieve for users.
const upstream = self["DEEPL_UPSTREAM"];

const deepl_keys = self["DEEPL_KEYS"].split(",");

// Custom pathname for the upstream website.
const upstream_path = '/'

// Website you intended to retrieve for users using mobile devices.
const upstream_mobile = upstream

// Countries and regions where you wish to suspend your service.
const blocked_region = []

// IP addresses which you wish to block from using your service.
const blocked_ip_address = ['0.0.0.0', '127.0.0.1']

// Whether to use HTTPS protocol for upstream address.
const https = true

// Whether to disable cache.
const disable_cache = false

// Replace texts.
const replace_dict = {
    '$upstream': '$custom_domain',
}

addEventListener('fetch', event => {
    event.respondWith(fetchAndApply(event));
})

async function fetchAndApply(event) {
    let request = event.request;

    const region = request.headers.get('cf-ipcountry').toUpperCase();
    const ip_address = request.headers.get('cf-connecting-ip');
    const user_agent = request.headers.get('user-agent');

    let response = null;
    let url = new URL(request.url);
    let url_hostname = url.hostname;

    if (https == true) {
        url.protocol = 'https:';
    } else {
        url.protocol = 'http:';
    }

    if (await device_status(user_agent)) {
        var upstream_domain = upstream;
    } else {
        var upstream_domain = upstream_mobile;
    }

    url.host = upstream_domain;
    if (url.pathname == '/') {
        url.pathname = upstream_path;
    } else {
        url.pathname = upstream_path + url.pathname;
    }

    if (blocked_region.includes(region)) {
        response = new Response('Access denied: WorkersProxy is not available in your region yet.', {
            status: 403
        });
    } else if (blocked_ip_address.includes(ip_address)) {
        response = new Response('Access denied: Your IP address is blocked by WorkersProxy.', {
            status: 403
        });
    } else {
        let method = request.method;
        let request_headers = request.headers;
        let new_request_headers = new Headers(request_headers);

        new_request_headers.set('Host', upstream_domain);
        new_request_headers.set('Referer', url.protocol + '//' + url_hostname);

        // AUTH part
        const Authorization = request.headers.get("Authorization");
        const expected_key = self["PSEUDO_DEEPL_KEY"];
        if (Authorization === `DeepL-Auth-Key ${expected_key}`) {
          let rand = ~~(Math.random() * deepl_keys.length);
          let deepl_key = deepl_keys[rand];
          new_request_headers.set('Authorization', `DeepL-Auth-Key ${deepl_key}`);
        }

        // https://stackoverflow.com/questions/55920957/cloudflare-worker-typeerror-one-time-use-body
        let original_response_body = request.body;
        if (method === "POST") {
            original_response_body = await event.request.arrayBuffer();
        }

        let original_response = await fetch(url.href, {
            method: method,
            headers: new_request_headers,
            body: original_response_body,
        })

        connection_upgrade = new_request_headers.get("Upgrade");
        if (connection_upgrade && connection_upgrade.toLowerCase() == "websocket") {
            return original_response;
        }

        let original_response_clone = original_response.clone();
        let original_text = null;
        let response_headers = original_response.headers;
        let new_response_headers = new Headers(response_headers);
        let status = original_response.status;

        if (disable_cache) {
            new_response_headers.set('Cache-Control', 'no-store');
        }

        new_response_headers.set('access-control-allow-origin', '*');
        new_response_headers.set('access-control-allow-credentials', true);
        new_response_headers.delete('content-security-policy');
        new_response_headers.delete('content-security-policy-report-only');
        new_response_headers.delete('clear-site-data');

        if (new_response_headers.get("x-pjax-url")) {
            new_response_headers.set("x-pjax-url", response_headers.get("x-pjax-url").replace("//" + upstream_domain, "//" + url_hostname));
        }

        const content_type = new_response_headers.get('content-type');
        if (content_type != null && content_type.includes('text/html') && content_type.includes('UTF-8')) {
            original_text = await replace_response_text(original_response_clone, upstream_domain, url_hostname);
        } else {
            original_text = original_response_clone.body
        }

        response = new Response(original_text, {
            status,
            headers: new_response_headers
        })
    }
    return response;
}

async function replace_response_text(response, upstream_domain, host_name) {
    let text = await response.text()

    var i, j;
    for (i in replace_dict) {
        j = replace_dict[i]
        if (i == '$upstream') {
            i = upstream_domain
        } else if (i == '$custom_domain') {
            i = host_name
        }

        if (j == '$upstream') {
            j = upstream_domain
        } else if (j == '$custom_domain') {
            j = host_name
        }

        let re = new RegExp(i, 'g')
        text = text.replace(re, j);
    }
    return text;
}

async function device_status(user_agent_info) {
    var agents = ["Android", "iPhone", "SymbianOS", "Windows Phone", "iPad", "iPod"];
    var flag = true;
    for (var v = 0; v < agents.length; v++) {
        if (user_agent_info.indexOf(agents[v]) > 0) {
            flag = false;
            break;
        }
    }
    return flag;
}

同样的,在CF Worker的dashboard配置DEEPL_UPSTREAM为上游域名(即Vercel的域名),DEEPL_KEYS为需要负载均衡的DeepL API key,用英文逗号分割,PSEUDO_DEEPL_KEY为你自己设置的访问密钥,并将它的值填入KISS Translator的DeepL接口,接口URL填CF Worker的URL,这样就能够通过Cloudflare Workers对DeepL的API key进行负载均衡了。

不过这个代码有一点小小的隐患,我没有对已经用完的API key进行特殊处理,因为就我目前的测算,应该不会出现API配额用完的情况,但是如果确实用完了,那么就有可能会出现部分翻译结果为空的问题,而KISS Translator暂时还不支持对单个的翻译结果进行重试的操作,因此也许应当在之后研究一下如何对配额用尽的API key进行管理。

2 thoughts on “用Cloudflare Workers提供一个翻译API后端

  1. 比想象中的要复杂不少啊
    感觉如果在KISS Translator上下功夫可能要更“优雅”些?特别是客户端处持久化状态更容易,因此可以在客户端上加上配额使用情况的管理等。
    但如果更新不能并入主线就尴尬了,因为要改动的地方也不算少

    1. 如果需要统计用量,确实在客户端管理要更加容易一点,但是我觉得在客户端或者中间环节统计用量大概是不如DeepL的统计那样精确的(毕竟服务器说了算),所以我的想法是用量应该定时(?)从API接口获取,然后筛选可用的key放在CF Worker的KV里面,这样就不需要关心用量统计的问题了。而且CF Worker的KV存储也不算麻烦。
      而且不在客户端做改动一方面是我想把尽可能多的环节掌握在自己这边,另一方面是我JS水平本来就依托,就不去人家脸上献丑了。
      其实也有一个更简单的方法,就是在CF Worker这边实现一个自动重试的机制,如果一个key超配额了就换一个,但是我还不知道如果超出API配额了DeepL会返回一个什么样的响应(

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据