misskey也可以有翻译功能

好久之前@mashiro@hello.2heng.xin大佬就给mastodon添加了翻译功能,都是混fediverse的misskey也可以有翻译功能。

与mastodon不同,misskey有着其自身的特点与规律,而且我也没有大佬那样的技术能力,所以没办法像大佬一样进行源代码层面上的修改,况且misskey更新快,变化大,这样进一步限制了在源代码层面上改动的空间,至少我的能力做不到……

不改动源代码就不能加翻译了吗?答案是否定的,至少浏览器上还安装着Tampermonkey这个插件,完全用用户端脚本就可以做到这件事……但手机上没有Tampermonkey,可以通过引入外部的JavaScript来完成这项工作。如果用jQuery这样的库会使得整个操作变得更简单,但这有潜在的与原有js冲突的风险,所以还是用"纯粹的"js来安排。

给misskey引入外部js脚本可以有两种途径,第一种是在编译完成的misskey里面找到前端web服务器的渲染模板,misskey前端是用的koa框架,通过pug模板进行渲染,可以到/misskey/buillt/目录下找到base.pug在head标签里进行添加。这样有个缺点,更新misskey编译之后所以的改动会消失(废话,不改源代码不就这样),而且更新完重启misskey还是要花一点时间的。第二种方法是在nginx更改,利用nginx的文本替换ngx_http_sub_module模块在头部插入js文件,具体可以这样写:

  location / {
    sub_filter  '<script>'  '<script src="https://www.dogcraft.top/misskey.js"></script><script>';
        proxy_pass http://127.0.0.1:3003;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header Accept-Encoding "";
        proxy_http_version 1.1;
        proxy_redirect off;

        # For WebSocket
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Cache settings
        proxy_cache cache1;
        proxy_cache_lock on;
        proxy_cache_use_stale updating;
        add_header X-Cache $upstream_cache_status;
    }

只加一行sub_filter '<script>' '<script src="https://www.dogcraft.top/misskey.js"></script><script>';就可以,其余的就是默认的只替换一次和只替换html文件,由于misskey的前后端分离相当彻底,完全没有理由担心头部没有<script>标签。

经过实践,这样工作正常,完全没有问题。

剩下的问题就是安排脚本,在正确的位置添加翻译按钮以及翻译文本显示区域,并把涉及到翻译行为的函数与按键绑定。这就需要弄清楚misskey的前端的层次结构了,用getElementsByClassName及其线性组合对html的dom节点及元素进行正确定位,这个可以用浏览器F12来搞……

misskey的前后端分离以及单页应用的特性决定了前端看上去的换页实际上是HTML文档的局部刷新,而且还有一些实时信息会从html中涌现出来,这就需要对整个文档,至少是绝大部分文档(比较高级的DOM节点)监听DOM树的变化,并对新增元素进行识别与审查,判断是否满足添加翻译按键以及翻译区域的条件。这是整个脚本能够正常生效的重要基础以及可以在前端架构没有重大变化的前提下无视版本升级的根本保障。

监听网页中的元素动态变化,现代浏览器已经有了一套成熟的API可以利用,可以用MutationObserver观察器来监听DOM树的变化,通过回调函数对新增节点进行审查与调整。

    var observer = new MutationObserver(callback);
    ar = document.getElementsByClassName("content")[0];
    observer.observe(ar, config);

由于前后端分离的特性,大部分元素是JavaScript通过DOM操作写进去的,所以一些函数要等页面加载完成之后才可以执行。对于这些可以安排到window.onload里面。

前端的脚本基本上就这些注意事项,剩下的就是后端的问题了。翻译后端,无非是一种代理或者针对各大免费翻译网页的爬虫并封装成API。之前有过flask搞restful API的经验,所以后端采用了translators这个多后端的python包,然后和flask_restful组合起来就可以了。前后端的通信最好还是用post json的形式,如果用get可能会出现奇奇怪怪的问题……

translators有多个后端,Google, Yandex, Microsoft(Bing), Baidu, Alibaba, Tencent, NetEase(Youdao), Sogou, Deepl,可以通过随机数的方式均匀分散到不同的后端。同时为了减少对后端的重复请求(延缓ip被封的那一天的到来),可以用redis来缓存翻译结果,目前设置过期时间10小时。

安排完成之后用nginx+uwsgi+flask那一套标准方法就可以。

效果图:

翻译
翻译

前端js:

console.log('Misskey Translate Script');
ApiUrl = 'https://api.dogcraft.top/ts/';
var lang_dog = navigator.language || navigator.userLanguage;//获取浏览器的语言
lang_dog = lang_dog.substr(0, 2);

function dog_add_fy(eldog) {

    //添加翻译按钮、区域以及绑定点击事件
    if (eldog.fanyi == 1) {
        console.log('已经添加过了,重复添加。')
    } else {
        var cl = document.createElement('div');
        cl.className = '.clear';
        var cl2 = document.createElement('div');
        cl2.className = '.clear';
        var dogfy = document.createElement('span');
        dogfy.className = 'fanyi';
        dogfy.ct = 0;
        var dogbt = document.createElement('button');
        dogbt.innerText = '翻译';
        dogbt.className = 'button _button';
        dogbt.style.backgroundColor="rgba(0,0,200,0.5)";
        dogbt.addEventListener('click', dog_fy);//绑定翻译函数
        eldog.appendChild(cl);
        eldog.appendChild(dogfy);
        eldog.appendChild(cl2);
        eldog.appendChild(dogbt);
        eldog.fanyi = 1;
    }
}

async function dog_fy() {
    //从后端获得翻译文本并写入到html中
    pdog = this.parentElement;
    ldog = pdog.getElementsByClassName('fanyi');
    if (ldog.length > 0) {
        dog_fy_el = ldog[0];
        if (dog_fy_el.ct == 0) {
            // console.log('还没有翻译');
            hdog = pdog.getElementsByClassName('havbbuyv')[0].innerText;
            post_dog = { 'c': hdog, 't': lang_dog };
            dog_fy_el.innerText='正在翻译中……';
            uiy = await fetch(ApiUrl, {
                method: 'POST',
                body: JSON.stringify(post_dog),
                headers: new Headers({
                    'Content-Type': 'application/json'
                })
            });
            if (uiy.status == 200) {
                rt = await uiy.json();
                res_dog = rt.r;
            } else {
                res_dog = '接口不对劲';
            }
            dog_fy_el.innerText = `\n${res_dog}`;
            dog_fy_el.ct = 1
            this.innerText='收起翻译';
        } else {
            if (dog_fy_el.ct == 2) {
                console.log(dog_fy_el.style.display)
                dog_fy_el.style.display="";
                dog_fy_el.ct = 1;
                this.innerText='收起翻译';
            } else if (dog_fy_el.ct == 1) {
                dog_fy_el.style.display="none";
                dog_fy_el.ct = 2;
                this.innerText='展开翻译';
            }
        }
    } else {
        console.log('有地方不对劲');
    }
}

var config = { attributes: false, childList: true, subtree: true };
// 当观察到突变时执行的回调函数
var callback = function (mutationsList) {
    mutationsList.forEach(function (item, index) {
        if (item.type == 'childList') {
            for (let iy_dog = 0; iy_dog < item.addedNodes.length; iy_dog++) {
                const iadog = item.addedNodes[iy_dog];
                if (iadog.getElementsByClassName == undefined) {
                    // console.log('这是啥???')
                } else {
                    sld = iadog.getElementsByClassName('content');
                    if (sld.length > 0) {
                        tty = sld[0].getElementsByClassName('text');
                        if (tty.length > 0) {
                            dog_add_fy(tty[0]);
                        }
                    }
                }


            }
        } 

    });
};


window.onload = function () {
    //code
    console.log('页面加载完毕')
    var observer = new MutationObserver(callback);
    sl = document.getElementsByClassName('article');

    // ar=document.getElementsByClassName("sqadhkmv _list_")[0];
    ar = document.getElementsByClassName("content")[0];
    // observer.observe(document.body, config);
    observer.observe(ar, config);
    for (let si = 0; si < sl.length; si++) {
        const sl_dog = sl[si];
        dog_add_fy(sl_dog.getElementsByClassName('content')[0].getElementsByClassName('text')[0]);
    }
}

后端python

from flask_restful import reqparse, abort, Api, Resource
from flask import Flask, request
from flask import jsonify
from flask_redis import FlaskRedis
import translators as ts
import random

dog_lan=['ar', 'auto', 'de', 'en', 'es', 'fr', 'hi', 'id', 'it', 'jp', 'kr', 'ms', 'pt', 'ru', 'th', 'tr', 'vi', 'zh']
parser = reqparse.RequestParser()
parser.add_argument('c', type=str, help='内容')
parser.add_argument('t', type=str, help='目标')

app = Flask(__name__)
app.config.update(RESTFUL_JSON=dict(ensure_ascii=False))
api = Api(app)

app.config['REDIS_URL'] = "redis://:passwd@127.0.0.1:3006/0"
app.config['JSON_AS_ASCII'] = False
rc = FlaskRedis(app, decode_responses=True)
DOGTIME = 43200

def dog_rd_ts(indog,todog):
    """
    随机翻译
    """
    rd=random.randint(0,3)
    if todog in dog_lan:
        if rd==0:
            res=ts.google(indog,to_language=todog)
            outdog=res+' | 由google翻译'
        if rd==1:
            res=ts.bing(indog,to_language=todog)
            outdog=res+' | 由bing翻译'
        if rd==2:
            res=ts.youdao(indog,to_language=todog)
            outdog=res+' | 由youdao翻译'
        if rd==3:
            res=ts.alibaba(indog,to_language=todog)
            outdog=res+' | 由alibaba翻译'
    else:
        outdog = '#&$*#@!@*@&#!'
    
    return outdog

def dog_rs_fy(c, t, clinfo):
    """
    带有缓存的翻译控制
    """
    if len(c) > 30:
        index_dog = c[:90:3]
    else:
        index_dog = c
    r_dog = 's_{}_{}'.format(t, index_dog)
    rs_dog = rc.get(r_dog)
    if rs_dog == None:
        rr_dog = dog_rd_ts(c, t)
        rc.set(r_dog, rr_dog, ex=DOGTIME)
        rs_dog = rr_dog
        rc.incr("n1")
    else:
        rc.incr("n2")
    return str(rs_dog)


class tsdog(Resource):
    def get(self):
        c = request.args.get("c")
        t = request.args.get("t")
        if c == None:
            r = "@$$%$%^%&&^"
        else:
            r = dog_rs_fy(c, t, 0)
        return r

    def post(self):
        # dog_json=request.get_json()
        args = parser.parse_args()
        # print(args)
        c = args["c"]
        t = args["t"]
        # print(c)
        if c == None:
            r = "@$$%$%^%&&^"
        else:
            r = dog_rs_fy(c, t, 0)
        # print(r)
        # print(dog_json)
        return {"r": r}


api.add_resource(tsdog, '/')

if __name__ == '__main__':
    app.run(debug=True)