---閱讀時間約 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,或者依賴使用者行為的 HTTP,結合自身應用場景而定;由瀏覽器的 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 :
1 //匯入express框架
2 var express = require("express");
3 var app = express();
4 //解決跨域問題
5 const cors = require('cors');
6 // 中介軟體 獲取引數的
7 const bodyParser = require('body-parser');
8 //讀寫檔案流
9 var fs = require("fs")
10 //引入websocket
11 const ws = require('nodejs-websocket');
12
13 app.use(bodyParser.json());
14 app.use(bodyParser.urlencoded({extended: true}));
15 app.use(cors());
16
17 app.all("*", function(req, res, next) {
18 res.header("Access-Control-Allow-Origin", "*");
19 res.header("Access-Control-Allow-Headers", "X-Requested-With");
20 res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OP0TIONS");
21 res.header("X-Powered-By", "3.2.1");
22 res.header("Content-Type", "application/json;charset=utf-8");
23 next();
24 });
25
26
27 app.get('/getString', function(req, res) {
28 // console.log(5555,req.query,666,req.params,888,req.body)
29 console.log(req.query)
30 res.status(200)
31 //json格式
32 // res.json(data)
33 //獲取json
34 fs.readFile('./data.json','utf-8',function(err,data) {
35 console.log(data)
36 let params = {}
37 if(err) {
38 console.error()
39 params = {
40 code:500,
41 message:"讀取失敗"
42 }
43 } else {
44 params = {
45 code:200,
46 message:"成功",
47 data:data
48 }
49 }
50 //傳入頁面
51 res.send(params)
52 })
53
54 });
55
56 app.get('/setString', function(req, res) {
57 // console.log(5555,req.query,666,req.params,888,req.body)
58 console.log(req.query)
59 res.status(200)
60 //json格式
61 // res.json(data)
62 //傳入頁面
63 fs.readFile("./data.json",function(err,data){
64 if(err) {
65 return console.error(err)
66 }
67 let obj = {
68 clips: req.query
69 }
70 let str = JSON.stringify(obj)
71 fs.writeFile("./data.json",str,function(err){
72 if(err) {
73 console.error(err)
74 }
75 console.log('-------修改成功-------')
76 })
77 })
78 let params = {
79 code:200,
80 message:"成功"
81 }
82 res.send(params)
83 });
84
85 let padKey = '';
86 const webServer = ws.createServer(conn => {
87 // console.log('有一名使用者連線進來了...')
88 conn.on("text", function (res) {
89 let resa = JSON.parse(res);
90 if(resa.msg && resa.msg === 'Request connection.') {
91 console.log(`${resa.role} 請求連線...`)
92 console.log('key: ', conn.key)
93 conn.sendText(JSON.stringify({
94 "sid": conn.key,
95 "msg": "伺服器連線成功!"
96 }));//返回給客戶端的資料
97 setTimeout(() => {
98 conn.sendText(JSON.stringify({
99 "sid": conn.key,
100 "msg": `Hi, ${resa.role}.`
101 }))
102 }, 800)
103 if(resa.role === 'Pad') {
104 padKey = conn.key
105 }
106 }
107 if(resa.clips && resa.role === 'Borwser') {
108 console.log(`剪貼簿更新: ${resa.clips}`)
109 webServer.connections.forEach(function (conn) {
110 if(conn.key == padKey) {
111 conn.sendText(JSON.stringify(resa))//返回給所有客戶端的資料(相當於公告、通知)
112 }
113 })
114 }
115 })
116 //監聽關閉
117 conn.on("close", function (code, reason) {
118 console.log("連線斷開...")
119 })
120 //監聽異常
121 conn.on("error",() => {
122 console.log('服務異常關閉...')
123 })
124 }).listen(8088)
125
126 var server = app.listen(3000, function() {
127 var host = server.address().address;
128 var port = server.address().port;
129
130 console.log("服務啟動: ", port);
131 })
----------------- 必要部分 ----------------
行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()將內容設定到裝置剪貼簿。
1 // ==UserScript==
2 // @name setClipString
3 // @namespace http://tampermonkey.net/
4 // @license GPL version 3
5 // @encoding utf-8
6 // @description try to take over the world!
7 // @author 97z4moon
8 // @include *
9 // @icon https://www.google.com/s2/favicons?domain=tampermonkey.net
10 // @grant GM_xmlhttpRequest
11 // @grant GM_download
12 // @run-at document-end
13 // @version 1.0.0
14 // ==/UserScript==
15
16 (function() {
17 // Your code here...
18 let urls = document.location.href
19 document.addEventListener("copy",function(e){
20 fetch("http://localhost:3000/setString?str="+window.getSelection(0).toString()+"&url="+urls,{
21 "headers":{
22 "accept": "application/json, text/plain, */*",
23 "accept-language": "zh-CN,zh;q=0.9",
24 "authorization":"Basic " + btoa(JSON.stringify({
25 "li":"administrator","pd":"superadmin"
26 })),
27 "referrer": urls,
28 "referrerPolicy": "no-referrer-when-downgrade",
29 "body": null,
30 "method": "GET",
31 "mode": "cors",
32 "credentials": "include"
33 }}).then(response=>response.json()).then(data=>{
34 console.log(data)
35 }).catch(e=>{
36 console.log(e)
37 })
38 })
39 })();
行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將其設為剪貼簿。
1 // ==UserScript==
2 // @name setClipString2
3 // @namespace http://tampermonkey.net/
4 // @license GPL version 3
5 // @encoding utf-8
6 // @description try to take over the world!
7 // @author 97z4moon
8 // @include *
9 // @icon https://www.google.com/s2/favicons?domain=tampermonkey.net
10 // @grant GM_xmlhttpRequest
11 // @grant GM_download
12 // @run-at document-end
13 // @version 2.0.0
14 // ==/UserScript==
15
16 (function() {
17 // Your code here...
18 let ws = new WebSocket('ws://localhost:8088');//例項化websocket
19 let obj = {
20 role: 'Borwser',
21 msg: 'Request connection.'
22 }
23 ws.onopen = function () {
24 console.log("socket has been opend")
25 ws.send(JSON.stringify(obj))
26 }
27 document.addEventListener("copy",function(e){
28 console.log("data: ", window.getSelection(0).toString())
29 obj.clips = window.getSelection(0).toString()
30 obj.msg = 'ClipBoard has been updated.'
31 ws.send(JSON.stringify(obj))
32 })
33 })();
行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
1 let ws = $web.newWebSocket("ws://192.168.31.109:8088", {
2 eventThread: 'this'
3 });
4 console.show();
5
6 let padSid = '';
7 ws.on("open",(res,ws)=>{
8 log("WebSocket has been ready...")
9 }).on("failure",(err,res,ws)=>{
10 log("Connect fail...")
11 ws.close(1000,null)
12 console.hide()
13 }).on("closing",(code,reason,ws)=>{
14 log("WebSocket is closing...")
15 }).on("text",(text, ws)=>{
16 let res = JSON.parse(text)
17 if(res.sid) {
18 padSid = res.sid
19 }
20 console.info("Receive msg: ", res.msg)
21 if(res.clips) {
22 setClip(res.clips)
23 }
24 }).on("binary",(bytes,ws)=>{
25 console.info("Receive binary:")
26 console.info("hex: ",bytes.hex())
27 console.info("base64: ",bytes.base64())
28 console.info("md5: ",bytes.md5())
29 console.info("size: ",bytes.size())
30 console.info("bytes: ",bytes.toByteArray())
31 }).on("closed",(code,reason,ws)=>{
32 log("WebSocket closed: code = %d, reason = %s")
33 })
34
35 let params = {
36 role: 'Pad',
37 msg: 'Request connection.'
38 }
39 ws.send(JSON.stringify(params));
40 setTimeout(()=>{
41 log("connect not WebSocket...")
42 ws.close(1000,null)
43 console.hide()
44 },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通訊較高的延遲。