JS边角料: NodeJS+AutoJS+WebSocket+TamperMonkey实现局域网多端文字互传
阅读原文时间:2021年09月13日阅读:3

---阅读时间约 7 分钟,复现时间约 15 分钟---

由于之前一直在用的扩展 QPush 停止服务了,苦于一人凑齐了 Window, Android, Mac, ios 四种系统的设备,Apple与其他厂商提供的互传又无法协同,有时只是需要在多设备使用同一串文字就在通讯App之间辗转登录非常影响当下如火如荼的状态,甚至当复制长文字时微信还会偷偷的剪裁,而且从 QPush 以后市面竟然没有找到任何一款既不打广又这样轻量的文字协同App,一怒之下自己写了这样一套基于浏览器的简易工具。

本文从配置到代码,小白可入内容较多,老司机们直接菜单索引到代码吧。


粮草先行

- Node.js

Node.js 是一个跨平台 JavaScript 运行环境,使开发者可以搭建服务器端的JavaScript应用程序。 [ MDN ]

如果你的项目够健全,WebServer 是由许多模块构成的,但对于非职业选手来讲,只要理解为  视图层  和  服务层  就成,服务层提供数据,视图层负责渲染,js 一直曾作为一个仅实现视图层的脚本语言,必须基于浏览器且 Web API 贫瘠、浏览器厂商特立独行的割据时代已经过了,现在的 js 可以写 浏览器视图层 / 服务层、App、小程序、游戏、PC客户端、3D动画 等等等,Node.js 可以称得上是改变前端命运的神话之一了。如MDN所述它可以使用js语言,为  视图层  提供服务、数据。

Download: [ 官网 ]

- Auto.js

一个在Android、鸿蒙平台编写、运行JavaScript代码的集成开发环境,包括代码补全的编辑器、单步调试、图形化设计,可构建为独立apk应用,也可连接电脑开发。 [ 官方文档 ]

如果你会JS,ios 的 workflow 都得往后稍稍,如果你会其他语言,那么你大抵能找到更顺手的替代品。能跟 Auto.js 生态和稳定比较的,国内应该没有几家。

Download: Android / 鸿蒙 应用商店(Apple Store未提供)

- WebSocket

WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。  [ MDN ]

讲人话就是  即时通讯  ,使服务器与多个客户端能高并发地保持通讯状态,我们日常生活中的大部分操作都基于  HTTP  请求,比如点击外卖App的某家店铺发出了请求,而App公司的服务器将这家店铺的每个菜单的文字和图片返回到手机并展示出来;又比如我们刷短视频时每次下滑下一条视频,服务器将下条视频通过  HTTP  返回给我们。再直白点就是我们的每个操作都像是网购,只不过流量成为这次交易的货币,而卖家把商品交给你也要承担包邮的运费。

可是当我们观看直播、网络通话等操作时  HTTP  就不那么适用了,在  WebSocket  正式普及以前大家只能通过  轮询  来实现此方法,也就是在一秒内不停地买 60 次,就可以观看一秒 60 帧的视频直播了,虽然 Web API 不需要我们拥有1秒60下的手速,但对浏览器的性能是一个很大的困扰,再者网络或服务器波动造成的失败概率,也会随着请求基数的增加而倍数增长,不是 1*60*0.01%,看直播的人不仅仅有一个,这些人会不断地给服务器造成压力,好比2009.11.11的阿里巴巴,2019的暴雪娱乐,和每一年的新浪微博。这也是以前网电和直播不普及的原因,真不是有头脑的人少,英雄也要倚靠时势。

Download: 项目依赖包,不需要手动安装,下文会详细说明。

- TamperMonkey

俗称油猴,也是基于浏览器扩展程序的 JS 语言,淘系0点秒杀、自动挂网课、脚本去广告基本用的都是它,网上已经有非常多资源这里就不介绍了。

Download: Chrome等各大浏览器商店,没有梯子的可以试试 [ 扩展迷 ]。

采用这几个工具的重点在于,它们的生态都很好且稳定,团队保持更新,技术领先至少五年内不会被淘汰。


代码部分

上文提到数据由  服务层  提供,我们可以通过 Node.js 实现中转; 通讯协议  作为媒介,可以使用 WebSocket;由浏览器的 TamperMonkey  监听剪贴板  ;  协同设备  通过 Auto.js 接收复制好的文本流。

整个思路已经理清了,服务层 作为本业务的中枢,所以由 Node.js 的开发先开始。

- Node.js

由上文提到官网入口下载,安装包会附带一个  npm 插件  ,它是一个包管理器,直接作用是通过在 cmd 输入 URI 的方式将网络上的资源下载到你的电脑,从这点来讲可以理解为一个全世界在用的大号云网盘。

安装好后打开环境变量:

找到系统变量中的 path 编辑,将 npm 和 nodejs 的路径 copy 至末尾:

然后  window + R  ,键入 cmd,回车

在命令行窗口输入 npm -v 和 node -v 检查安装与环境变量是否配置成功:

如图返回版本号即为成功,接着输入下行代码安装 cnpm:

npm install -g cnpm --registry=https://registry.npm.taobao.org

npm 是我们刚刚配置变量索引到的程序,install 是 安装 关键字,-g 是 安装 到全局(global),cnpm 是淘宝镜像,由于 npm 起于墙外,无论是服务器支持还是其内数据远在天边,都导致下载速度缓慢且大概率会 fail,后面一长串是下载路径。

还是 cnpm -v 检查,出现版本号就是安装成功,由于是基于npm的镜像,不需要配置环境变量。

随便找个盘新建文件夹,名字不能随意否则可能会造成不可预知错误,起码中文是绝对不行的,也不建议驼峰式,我开发此项目过程中因此报过错,建议小写"a-z"与"_"组合:

直接在文件夹管理器的地址栏中键入 cmd 回车(下图中文字选中高亮处),省的一直cd找URI了:

在命令行窗口中输入 npm init,回车,紧跟着一连串配置(图中黄字备注):

初始化后在根目录生成一个package.json文件,该文件除了声明项目描述,还注明了引入的依赖包和对应版本,不可删除。

命令行保持这个文件夹路径,依次键入安装依赖包,项目相当于一台手机,依赖包是里面的App,提供各种功能:

  • cnpm install express --save 这个包作用是nodeJS基于此框架创建服务层业务
  • cnpm install cors --save 作用是解决跨域问题(想了解跨域可以阅读我的另一篇文章:浏览器:深度理解浏览器的同源策略
  • cnpm install body-parser --save 以此包获取前台传参的参数
  • cnpm install mysql --save 帮助连接MySQL数据库
  • cnpm install multer --save 中间件上传文件处理formdata类型的表单数据
  • cnpm install cookie-parser --save 该包提供cookie的使用

安装后根目录会多出一个 node_modules 文件夹存放这些依赖包

package.json 也自动写入了相应的注明:

里面的文件不要改不要删,也不用去看,否则会掉很多头发。

在根目录新建一个文件 app.js,用代码编辑器打开,VSCode 提供了wifi 局域网连接手机 Auto.js 软件调试的插件,小白的话找个秒开级的轻量编辑器就完全没问题了:Sublime Text 3 官网

直接  Ctrl+C  和  Ctrl+V :

//导入express框架
var express = require("express");
var app = express();
//解决跨域问题
const cors = require('cors');
// 中间件 获取参数的
const bodyParser = require('body-parser');
//读写文件流
var fs = require("fs")
//引入websocket
const ws = require('nodejs-websocket');

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
app.use(cors());

app.all("*", function(req, res, next) {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "X-Requested-With");
    res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OP0TIONS");
    res.header("X-Powered-By", "3.2.1");
    res.header("Content-Type", "application/json;charset=utf-8");
    next();
});

app.get('/getString', function(req, res) {
    // console.log(5555,req.query,666,req.params,888,req.body)
    console.log(req.query)
    res.status(200)
    //json格式
    // res.json(data)
    //获取json
    fs.readFile('./data.json','utf-8',function(err,data) {
        console.log(data)
        let params = {}
        if(err) {
            console.error()
            params = {
                code:500,
                message:"读取失败"
            }
        } else {
            params = {
                code:200,
                message:"成功",
                data:data
            }
        }
        //传入页面
        res.send(params)
    })

});

app.get('/setString', function(req, res) {
    // console.log(5555,req.query,666,req.params,888,req.body)
    console.log(req.query)
    res.status(200)
    //json格式
    // res.json(data)
    //传入页面
    fs.readFile("./data.json",function(err,data){
        if(err) {
            return console.error(err)
        }
        let obj = {
            clips: req.query
        }
        let str = JSON.stringify(obj)
        fs.writeFile("./data.json",str,function(err){
            if(err) {
                console.error(err)
            }
            console.log('-------修改成功-------')
        })
    })
    let params = {
        code:200,
        message:"成功"
    }
    res.send(params)
});

let padKey = '';
const webServer = ws.createServer(conn => {
    // console.log('有一名用户连接进来了...')
    conn.on("text", function (res) {
        let resa = JSON.parse(res);
        if(resa.msg && resa.msg === 'Request connection.') {
            console.log(`${resa.role} 请求连接...`)
            console.log('key: ', conn.key)
            conn.sendText(JSON.stringify({
                "sid": conn.key,
                "msg": "服务器连接成功!"
            }));//返回给客户端的数据
            setTimeout(() => {
                conn.sendText(JSON.stringify({
                "sid": conn.key,
                "msg": `Hi, ${resa.role}.`
            }))
            }, 800)
            if(resa.role === 'Pad') {
                padKey = conn.key
            }
        }
        if(resa.clips && resa.role === 'Borwser') {
            console.log(`剪贴板更新: ${resa.clips}`)
            webServer.connections.forEach(function (conn) {
                if(conn.key == padKey) {
                    conn.sendText(JSON.stringify(resa))//返回给所有客户端的数据(相当于公告、通知)
                }
            })
        }
    })
    //监听关闭
    conn.on("close", function (code, reason) {
        console.log("连接断开...")
    })
    //监听异常
    conn.on("error",() => {
        console.log('服务异常关闭...')
    })
}).listen(8088)

var server = app.listen(3000, function() {
    var host = server.address().address;
    var port = server.address().port;

    console.log("服务启动: ", port);
})

----------------- 必要部分 ----------------

行2、3 - 引入express框架,定义变量app接收将API实例化。

行5 - 引入cors,使得浏览器与其他设备可以跨域请求该服务。

---------- 手动http部分(可选) ----------

行7 - 引入body-parser,以获取  HTTP  请求的参数(仅使用  WebSocket  时可略)。

行9 - 引入node.js的fs模块,以读写文件流内容(仅使用  WebSocket  时可略)。

------- 自动websocket部分(可选) ------

行11 - 引入websocket,作为网络交互协议。

---------- 手动http部分(可选) ----------

行13、14、15 - 对该服务激活跨域插件与中间件,变量app为行2引入express框架的实例化实现,下文不再赘述。

行17~14 - 设置所有  httpResponse  的响应头。

行27~54 - 响应 http get( ) 的接口服务,对应请求路径应为 'http://IPv4 Address:端口号/getString',IPv4可以通过cmd中键入ipconfig查询,下文不再赘述。

行27 - 函数括号内两个形参 req 接收请求体,res 接收响应体。

行29 - http.get 请求通过query传参,例如请求路径'http://192.168.0.1/getString?id=1&name=97z4moon',服务就可通过上述引入的中间件依赖包获取到两个参数 { id: '1', name: '97z4moon'}。想传不同参数时,只需要改变路径'?'后面跟的值即可,多个参数以'&'连接。

行34 - 通过fs模块读操作,'./data.json','./'为同目录下,'../'为上一级,比如我的app.js文件路径为 'C:\clipboard_project\app.js','./data.json' 即为 'C:\clipboard_project\data.json','../data.json' 为 'C:\data.json',它们都是相对路径,字面意思就是比较代码所处文件app.js位置的对应路径。'utf-8' 是以该编码接收,参数err接收错误时实参,data接收读取文件流的内容。

行51 - 将响应体发送至客户端,也就是接收的人,该角色在本业务中对应的是持有Auto.js软件的移动设备,实参params将所期待的剪贴板数据返回给请求者,假设一直不执行send()方法,请求者会将该进程挂起,直到网络请求超时。

行56~83 - 与getString同理,思路是油猴监听浏览器剪贴板事件,在键盘键入复制操作时将剪贴板的内容写入data.json文件中,以便移动端获取。假设我的局域网ip为192.168.31.109,则我的油猴脚本请求路径应为'http://192.168.31.109:3000/setString?str=剪贴板文本'。

行71 - 通过fs模块写操作,在行67~69定义一个对象obj,在obj的堆中增加一个键值对,如上所说,形参req接收的是请求体,req.query即为上述请求路径中最后'?'紧跟的'str=剪贴板文本'。

------- 自动websocket部分(可选) ------

行85~124 - websocket通讯自动同步到移动设备部分。

行85 - 定义变量padKey存储本次通讯接收者的唯一key,该key由node的websocket插件自动分配,如果需要多设备,则将行104改为:padKey+=conn.key,行110改为:if(padKey.indexOf(conn.key)>-1){ 。

行86 - ws已由行11部分实例化了websocket包,通过该包提供的API - createServer创建一个socket通讯,定义常量webServer接收,因为该服务保持通讯,仅随着项目关闭或服务器维护而关闭,所以定义为常量为最优,通过形参conn接收每一次建立起的socket通讯。

行88 - 通过某次连接的原型函数on(),监听 'text' 事件,并定义一个function在监听到事件时执行,以形参res接收。

行93~96 - 将一个JSON字符串处理后的对象传入给本次通讯连接的发起者。

行97~102 - 同上,通过定时器setTimeout延迟 800ms 执行。

行109 - webServer是本次socket服务实例,其[key]connections对应的是当前socket服务下所有的连接用户,通过forEach遍历找到需要接收的用户,通过API - sendText() 向其发送剪贴板内容。

行117~119 - 监听本次socket服务中所有成功连接的用户的退出连接事件。

行121~123 - 监听本次socket服务中所有成功连接的用户的异常错误事件。

行124 - 以第一个实参8088为端口启动socket服务 。

行126 - 以第一个实参3000为端口启动http服务,也就是上述中请求路径的 'http://192.168.31.109:3000/getString' 。

- TamperMonkey

手动版思路:监听浏览器剪贴板事件 -> 将剪贴板内容通过http发送给服务层,node.js接收到query将其保存至data.json文件中,移动设备执行auto.js的代码向服务层发起请求,node.js拿到data.json中的剪贴板内容放进响应体返回给移动设备,移动设备通过auto.js API - setClip()将内容设置到设备剪贴板。

// ==UserScript==
// @name         setClipString
// @namespace    http://tampermonkey.net/
// @license     GPL version 3
// @encoding    utf-8
// @description  try to take over the world!
// @author       97z4moon
// @include      *
// @icon         https://www.google.com/s2/favicons?domain=tampermonkey.net
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @run-at      document-end
// @version     1.0.0
// ==/UserScript==

(function() {
    // Your code here...
    let urls = document.location.href
    document.addEventListener("copy",function(e){
        fetch("http://localhost:3000/setString?str="+window.getSelection(0).toString()+"&url="+urls,{
            "headers":{
                "accept": "application/json, text/plain, */*",
                "accept-language": "zh-CN,zh;q=0.9",
                "authorization":"Basic " + btoa(JSON.stringify({
                    "li":"administrator","pd":"superadmin"
                })),
                "referrer": urls,
                "referrerPolicy": "no-referrer-when-downgrade",
                "body": null,
                "method": "GET",
                "mode": "cors",
                "credentials": "include"
            }}).then(response=>response.json()).then(data=>{
                console.log(data)
            }).catch(e=>{
                console.log(e)
            })
    })
})();

行1~14 - 脚本声明与配置。

行16~39 - IIFE函数。

行18 - 通过DOM的location对象获取到复制操作的网站链接,保存在定义的字符串变量urls中。

行19 - 对整个DOM设置监听器,第一个参数定义监听器监听'copy'事件,第二个参数监听到时执行函数。

行20 - 通过Fetch API对服务层发起请求,该方法提供了一种简单,合理的方式来跨网络异步获取资源。fetch() 可以接受跨域cookie,也可以建立起跨域对话,fetch() 不会发送 cookie。如果需要在 IE11 及以下版本中使用 fetch,通过 Fetch Polyfill 来实现。[MDN]

行21~32 - 设置请求头。

-------

自动版思路:在浏览器打开的页面中建立websocket通讯,连接到node.js启动在8088端口的socket服务,TamperMonkey监听到浏览器复制操作时,将剪贴板内容发送至服务层node.js处理,node.js再将该内容下发到key值对应的移动设备中。只需将移动设备socket通讯时发送的参数role改变为预设值即可,如我在node.js代码中设置的条件是:if(resa.role === 'Pad') 。当移动设备接收到剪贴板内容时,使用auto.js将其设为剪贴板。

// ==UserScript==
// @name         setClipString2
// @namespace    http://tampermonkey.net/
// @license     GPL version 3
// @encoding    utf-8
// @description  try to take over the world!
// @author       97z4moon
// @include      *
// @icon         https://www.google.com/s2/favicons?domain=tampermonkey.net
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @run-at      document-end
// @version     2.0.0
// ==/UserScript==

(function() {
    // Your code here...
    let ws = new WebSocket('ws://localhost:8088');//实例化websocket
    let obj = {
        role: 'Borwser',
        msg: 'Request connection.'
    }
    ws.onopen = function () {
        console.log("socket has been opend")
        ws.send(JSON.stringify(obj))
    }
    document.addEventListener("copy",function(e){
        console.log("data: ", window.getSelection(0).toString())
        obj.clips = window.getSelection(0).toString()
        obj.msg = 'ClipBoard has been updated.'
        ws.send(JSON.stringify(obj))
    })
})();

行18 - 实例化websocket,路径'ws://……'为关键字,'localhost'不可替换为IPv4,否则会报错,8088为node.js设置的socket服务端口。(自行扩展可在node.js可以启动多个socket服务,分别对应不同功能)

行23~26 - 在socket连接成功后在浏览器控制台输出提示,并向node.js发送实参obj表明身份与来意。send()事件不可在连接成功前执行,否则会导致该页面生命周期下的所有socket连接失败。

行29 - window对象API - window.getSelection(0) 获取剪贴板信息,使用toString将其格式化为剪贴板内容。

- Auto.js

let ws = $web.newWebSocket("ws://192.168.31.109:8088", {
    eventThread: 'this'
});
console.show();

let padSid = '';
ws.on("open",(res,ws)=>{
    log("WebSocket has been ready...")
}).on("failure",(err,res,ws)=>{
    log("Connect fail...")
    ws.close(1000,null)
    console.hide()
}).on("closing",(code,reason,ws)=>{
    log("WebSocket is closing...")
}).on("text",(text, ws)=>{
    let res = JSON.parse(text)
    if(res.sid) {
        padSid = res.sid
    }
    console.info("Receive msg: ", res.msg)
    if(res.clips) {
        setClip(res.clips)
    }
}).on("binary",(bytes,ws)=>{
    console.info("Receive binary:")
    console.info("hex: ",bytes.hex())
    console.info("base64: ",bytes.base64())
    console.info("md5: ",bytes.md5())
    console.info("size: ",bytes.size())
    console.info("bytes: ",bytes.toByteArray())
}).on("closed",(code,reason,ws)=>{
    log("WebSocket closed: code = %d, reason = %s")
})

let params = {
    role: 'Pad',
    msg: 'Request connection.'
}
ws.send(JSON.stringify(params));
setTimeout(()=>{
    log("connect not WebSocket...")
    ws.close(1000,null)
    console.hide()
},600000)

行1 - 定义变量ws实例化一个socket服务,请求地址为 'ws://192.168.31.109:8088' 。

行2 - eventThread定义为this事件将在创建WebSocket的线程触发,如果该线程被阻塞,则事件也无法被及时派发。

行4 - 打开控制台悬浮窗。

行6 - 定义字符串变量padSid接收node.js中socket服务分配的本次通讯设备唯一key。

行7 - 监听socket包服务的启动事件。

行9 - 监听与socket服务层断线的事件。

行11 - 关闭本次socket通讯。

行12 - 隐藏控制台悬浮窗。

行13 - 监听socket通讯关闭中事件。

行15 - 监听socket通讯接收到文本事件。

行22 - Auto.js API - setClip() 设置剪贴板内容。

行24 - 监听socket通讯接收到二进制信息事件。

行31 - 监听socket通讯关闭完成的生命周期。

行39 - 向服务层发送socket讯息表明身份和来意。

行40 - 定时器 10 分钟后关闭本次socket通讯服务,如果不设则通讯会在执行完js后立即结束,如果想永久挂起,可以将行40~44改为:setInterval(()=>{}),需要注意的是这样做会占用许多不必要的性能资源,时间长了以后可能造成内存溢出,宏队列拥挤造成socket通讯较高的延迟。


WebSocket版演示

- END -