JS邊角料: NodeJS+AutoJS+WebSocket+TamperMonkey實現局域網多端文字互傳

—閱讀時間約 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=//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( ) 的接口服務,對應請求路徑應為 ‘//IPv4 Address:端口號/getString’,IPv4可以通過cmd中鍵入ipconfig查詢,下文不再贅述。

行27 – 函數括號內兩個形參 req 接收請求體,res 接收響應體。

行29 – http.get 請求通過query傳參,例如請求路徑’//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,則我的油猴腳本請求路徑應為’//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服務,也就是上述中請求路徑的 ‘//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    //tampermonkey.net/
// @license     GPL version 3
// @encoding    utf-8
// @description  try to take over the world!
// @author       97z4moon
// @include      *
// @icon         //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("//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    //tampermonkey.net/
// @license     GPL version 3
// @encoding    utf-8
// @description  try to take over the world!
// @author       97z4moon
// @include      *
// @icon         //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 –

Tags: