├── .babelrc.js ├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky └── pre-commit ├── LICENSE ├── README.md ├── dist └── index.user.js ├── package.json ├── src ├── chain.ts ├── index.ts ├── meta.js └── wx.ts ├── test └── contract.test.ts ├── tsup.config.ts └── vitest.config.ts /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: require.resolve('@gera2ld/plaid/config/babelrc-base'), 3 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]], 4 | plugins: [ 5 | ['module-resolver', { 6 | alias: { 7 | '#': './src', 8 | }, 9 | }], 10 | 11 | // JSX 12 | ['@babel/plugin-transform-react-jsx', { 13 | pragma: 'VM.createElement', 14 | pragmaFrag: 'VM.Fragment', 15 | }], 16 | ].filter(Boolean), 17 | }; 18 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 55 2 | Firefox >= 53 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src 3 | !/test 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | require.resolve('@gera2ld/plaid/eslint'), 5 | require.resolve('@gera2ld/plaid-react/eslint/react'), 6 | ], 7 | settings: { 8 | 'import/resolver': { 9 | 'babel-module': {}, 10 | }, 11 | react: { 12 | pragma: 'VM', 13 | }, 14 | }, 15 | globals: { 16 | VM: true, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.lock 4 | /.idea 5 | /.nyc_output 6 | /coverage 7 | /types 8 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn build 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gerald 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 微信公众号上链 WeSync 2 | 3 | WeSync 是一个发布微信公众号的同时把文章上链的浏览器脚本。此脚本需要配合浏览器的脚本管理器插件一起工作。 4 | [![Watch the video](https://user-images.githubusercontent.com/121119893/209360084-5db67c49-5b4e-4638-909d-b6ae66dc7d8c.png)](https://vimeo.com/783948560) 5 | 6 | ## 安装 7 | 8 | 1. 浏览器支持 9 | 10 | - Chrome 11 | - Firefox 12 | 13 | 2. 为浏览器安装的脚本管理器插件 14 | - ViolentMonkey 15 | - TamperMonkey 16 | 17 | 3. 安装[脚本](./dist/index.user.js) 18 | 19 | 4. 准备一个用于发布文章的私钥,把私钥粘贴在脚本里。 20 | 21 | 5. 去[水龙头](https://faucet.crossbell.io/)领取发布文章所需的路费。 22 | 23 | 6. 在发布页面,可以看到插件已经工作了 24 | https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit 25 | 26 | ## 贡献 27 | ### Development 28 | 29 | Development 状态下默认与本地的RPC节点通信。 30 | 31 | ``` sh 32 | $ yarn dev 33 | ``` 34 | 35 | ### Building 36 | 37 | ```sh 38 | $ yarn build 39 | ``` 40 | 41 | ### Lint 42 | 43 | ``` sh 44 | $ yarn lint 45 | ``` 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hook-wxgzh", 3 | "version": "1.0.0", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "type": "module", 8 | "husky": { 9 | "hooks": { 10 | "pre-push": "npm run lint" 11 | } 12 | }, 13 | "browser": { 14 | "crypto": "crypto-browserify", 15 | "stream": "stream-browserify" 16 | }, 17 | "scripts": { 18 | "dev": "export DEBUG=true && tsup --watch", 19 | "clean": "del dist node_modules", 20 | "build": "export DEBUG=false && tsup", 21 | "ci": "npm run lint", 22 | "lint": "eslint .", 23 | "test": "vitest run", 24 | "prepare": "husky install" 25 | }, 26 | "devDependencies": { 27 | "@gera2ld/plaid": "~1.5.0", 28 | "@gera2ld/plaid-react": "~1.5.0", 29 | "@hyrious/esbuild-plugin-commonjs": "^0.2.2", 30 | "babel-jest": "^29.3.1", 31 | "cross-env": "^7.0.3", 32 | "cssnano": "^5.1.14", 33 | "del-cli": "^5.0.0", 34 | "eslint-import-resolver-babel-module": "^5.3.1", 35 | "husky": "^8.0.2", 36 | "jsdom": "^20.0.3", 37 | "node-stdlib-browser": "^1.2.0", 38 | "postcss": "^8.4.20", 39 | "postcss-modules": "^6.0.0", 40 | "tsup": "^6.5.0", 41 | "vitest": "^0.25.8" 42 | }, 43 | "dependencies": { 44 | "@babel/core": "^7.20.5", 45 | "@babel/preset-env": "^7.20.2", 46 | "@babel/runtime": "^7.20.6", 47 | "babel-loader": "^9.1.0", 48 | "crossbell.js": "^0.14.0", 49 | "esbuild-ifdef": "^0.2.0", 50 | "esbuild-plugin-ifdef": "^1.0.1", 51 | "ethers": "^5.7.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/chain.ts: -------------------------------------------------------------------------------- 1 | import { Contract, NoteMetadata } from "crossbell.js"; 2 | import { ethers } from "ethers"; 3 | /** Mock */ 4 | //#ifdef DEBUG 5 | import { Network } from "crossbell.js"; 6 | import { Article } from "./wx"; 7 | 8 | const priKey = 9 | "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; 10 | //0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 11 | 12 | const info = Network.getCrossbellMainnetInfo(); 13 | info.chainId = 31337; 14 | Network.getCrossbellMainnetInfo = () => info; 15 | Network.jsonRpcAddress = "http://127.0.0.1:8545"; 16 | //#endif 17 | 18 | /** Mock End */ 19 | 20 | /** Global vars */ 21 | let bal: string; 22 | let contract: Contract; 23 | let characterId: number; 24 | let noteCount: number; 25 | /** Global vars end */ 26 | 27 | export const setUpContract = async (priKey?: string) => { 28 | const c = new Contract(priKey); 29 | await c.connect(); 30 | return c; 31 | }; 32 | 33 | const getBal = async (user: string) => { 34 | const { data } = await contract.getBalance(user); 35 | return ethers.utils.formatEther(data); 36 | }; 37 | 38 | const hasEnoughBal = async () => { 39 | const user = getUser(); 40 | bal = await getBal(user); 41 | if (Number(bal) < 0.0005) { 42 | // 0.000128251 PostNote 43 | // 0.000327791 CreateCharacter 44 | return false; 45 | } else { 46 | return true; 47 | } 48 | }; 49 | 50 | export const getUser = () => { 51 | const account = new ethers.Wallet(priKey); 52 | const user = account.address; 53 | return user; 54 | }; 55 | 56 | const getNoteCount = async (characterId: number) => { 57 | const { data } = await contract.getCharacter(characterId); 58 | return data.noteCount; 59 | }; 60 | 61 | export const setUpAccount = async () => { 62 | const user = getUser(); 63 | const { data } = await contract.getPrimaryCharacterId(user); 64 | if (data !== 0) { 65 | characterId = data; 66 | } else { 67 | const hashCode = (s: string) => 68 | s.split("").reduce((a, b) => { 69 | a = (a << 5) - a + b.charCodeAt(0); 70 | return a & a; 71 | }, 0); 72 | const token = window.location.toString().split("token=")[1].split("&")[0]; 73 | const handle = "wxgzh-" + hashCode(token); 74 | const { data } = await contract.createCharacter(user, handle, ""); 75 | characterId = data; 76 | } 77 | noteCount = await getNoteCount(characterId); 78 | }; 79 | 80 | export const setUp = async () => { 81 | try { 82 | getUser(); 83 | } catch (e) { 84 | return "InvalidPrivateKey"; 85 | } 86 | 87 | contract = await setUpContract(priKey); 88 | 89 | if (!(await hasEnoughBal())) { 90 | return "BalanceNotEnough"; 91 | } 92 | 93 | await setUpAccount(); 94 | console.log("Contract connected. Logged in as " + characterId); 95 | return null; 96 | }; 97 | 98 | const makeUrl = (characterId: number, noteId: number) => 99 | `https://crossbell.io/notes/${characterId}-${noteId}`; 100 | 101 | export const predictPostUrl = () => makeUrl(characterId, noteCount + 1); 102 | 103 | export const save = async (article: Article) => { 104 | const { data } = await contract.postNote(characterId, { 105 | content: article.content, 106 | title: article.title, 107 | attachments: [ 108 | { 109 | name: "cover", 110 | address: article.cover, 111 | }, 112 | ], 113 | attributes: [ 114 | { 115 | trait_type: "summary", 116 | value: article.summary, 117 | }, 118 | { 119 | trait_type: "author", 120 | value: article.author, 121 | }, 122 | ], 123 | sources: ["WeSync", "公众号: " + article.channel], 124 | // TODO 125 | // sources: 126 | } as NoteMetadata); 127 | return makeUrl(characterId, data.noteId); 128 | }; 129 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { predictPostUrl, save, setUp } from "./chain"; 2 | import { 3 | findMassSendBtn1, 4 | findMassSendBtn2, 5 | findMassSendBtn3, 6 | extractArticle, 7 | Article, 8 | appendOnChainUrl, 9 | makeErrorPanel, 10 | appendSucceedNotice, 11 | appendSyncingNotice, 12 | } from "./wx"; 13 | 14 | const newBtnText = "备份并群发"; 15 | 16 | const main = async () => { 17 | let errMsg: "InvalidPrivateKey" | "BalanceNotEnough" | "UnknownError" | null; 18 | try { 19 | errMsg = await setUp(); 20 | } catch (e) { 21 | errMsg = "UnknownError"; 22 | } 23 | 24 | if (errMsg) { 25 | makeErrorPanel(document, errMsg); 26 | console.log("Fail to initialize."); 27 | return; 28 | } 29 | 30 | let article: Article | null; 31 | const vueApp = window.document.querySelector("#vue_app"); 32 | if (!vueApp) return; 33 | 34 | const changeMassSendBtnsText = (event) => { 35 | const btn2 = findMassSendBtn2(event.target); 36 | const btn3 = findMassSendBtn3(event.target); 37 | if (btn2) { 38 | // removeEventListener should be previous than 39 | // the change of btn.textContent because that 40 | // change would trigger the listener again 41 | vueApp.removeEventListener("DOMNodeInserted", changeMassSendBtnsText); 42 | btn2.textContent = newBtnText; 43 | } 44 | if (btn3) { 45 | btn3.textContent = "继续" + newBtnText; 46 | (btn3 as HTMLElement).onclick = async function () { 47 | if (article) { 48 | appendSyncingNotice(document); 49 | const newUrl = await save(article); 50 | appendSucceedNotice(document, newUrl); 51 | } 52 | }; 53 | } 54 | }; 55 | 56 | vueApp.addEventListener("DOMNodeInserted", changeMassSendBtnsText); 57 | 58 | appendOnChainUrl(document, predictPostUrl()); 59 | 60 | const massSend = findMassSendBtn1(document); 61 | if (!massSend) return; 62 | massSend.textContent = newBtnText; 63 | 64 | (massSend as HTMLElement).onclick = function () { 65 | article = extractArticle(document); 66 | }; 67 | }; 68 | 69 | main(); 70 | -------------------------------------------------------------------------------- /src/meta.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name WeSync——发布微信公众号的同时把文章上链 3 | // @namespace Violentmonkey Scripts 4 | // @description 全网唯一实时备份微信公众号文章的上链解决方案。 5 | // @match https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit* 6 | // @grant none 7 | // @version process.env.VERSION 8 | // @author Saltad 9 | // ==/UserScript== 10 | -------------------------------------------------------------------------------- /src/wx.ts: -------------------------------------------------------------------------------- 1 | export interface Article { 2 | title: string; 3 | author: string; 4 | cover: string; 5 | summary: string; 6 | content: string; 7 | channel: string; 8 | } 9 | 10 | // Buttons Location 11 | export const findMassSendBtn1 = (d: Document) => 12 | d.querySelector && d.querySelector("button.mass_send"); 13 | 14 | export const findMassSendBtn2 = (d: Element) => 15 | d.querySelector && d.querySelector(".mass-send__footer .weui-desktop-btn"); 16 | 17 | export const findMassSendBtn3 = (d: Element) => { 18 | if (d.querySelectorAll) { 19 | const btn = [ 20 | ...d.querySelectorAll(".weui-desktop-dialog .weui-desktop-btn_primary"), 21 | ].find((t) => t.textContent === "继续群发"); 22 | return btn; 23 | } 24 | }; 25 | 26 | // Article Modification 27 | export const getArticleIframe = (d: Document) => { 28 | const iframe = d.querySelector("iframe"); 29 | if (!iframe || !iframe.contentWindow) return; 30 | //TODO : https://stackoverflow.com/questions/17197084/difference-between-contentdocument-and-contentwindow-javascript-iframe-frame-acc 31 | return iframe.contentWindow.document; 32 | }; 33 | const textPrefix = "本文已同步于 "; 34 | 35 | const isHardcodeText = (node: ChildNode) => { 36 | return node.textContent?.startsWith(textPrefix); 37 | }; 38 | 39 | export const appendOnChainUrl = (d: Document, url: string) => { 40 | const iframe = getArticleIframe(d); 41 | if (!iframe) return null; 42 | const appended = [...iframe.querySelectorAll("p")].find((p) => 43 | isHardcodeText(p) 44 | ); 45 | if (appended) { 46 | //TODO: fix url 47 | return; 48 | } 49 | 50 | const span = document.createElement("span"); 51 | span.setAttribute("style", "font-size: 12px;"); 52 | span.textContent = `${textPrefix}${url}`; 53 | const newNode = document.createElement("p"); 54 | newNode.appendChild(span); 55 | iframe.querySelector("body")?.appendChild(newNode); 56 | }; 57 | 58 | export const appendSyncingNotice = (d: Document) => { 59 | const location = [...d.querySelectorAll(".dialog_bd")].find((n) => 60 | n.innerHTML.includes("扫码后") 61 | ); 62 | if (!location) return; 63 | const newNode = d.createElement("p"); 64 | newNode.setAttribute("class", "tc syncing"); 65 | newNode.setAttribute("style", "color: red;"); 66 | newNode.textContent = `备份中......`; 67 | location.appendChild(newNode); 68 | }; 69 | 70 | export const appendSucceedNotice = (d: Document, url: string) => { 71 | const location = [...d.querySelectorAll(".dialog_bd")].find((n) => 72 | n.innerHTML.includes("扫码后") 73 | ); 74 | if (!location) return; 75 | const syncingText = location.querySelector(".syncing"); 76 | if (syncingText) location.removeChild(syncingText); 77 | const newNode = d.createElement("p"); 78 | newNode.setAttribute("class", "tc"); 79 | newNode.setAttribute("style", "color: red;"); 80 | newNode.textContent = `已成功备份于: ${url}`; 81 | location.appendChild(newNode); 82 | }; 83 | 84 | export const extractArticle = (d: Document): Article | null => { 85 | const title = (d.querySelector("#js_title_main textarea") as HTMLInputElement) 86 | .value; 87 | 88 | const author = (d.querySelector("#js_author_area input") as HTMLInputElement) 89 | .value; 90 | 91 | const channel = d.querySelector(".appmsg_account_name")?.textContent || ""; 92 | 93 | const summary = ( 94 | d.querySelector("#js_description_area textarea") as HTMLInputElement 95 | ).value; 96 | 97 | const iframe = getArticleIframe(d)?.querySelector("body"); 98 | if (!iframe) return null; 99 | 100 | // Filter will preserve the original array order 101 | // https://stackoverflow.com/questions/39712160/does-javascript-filter-preserve-order 102 | const sections = [...iframe.childNodes].filter((node) => { 103 | const nStyle = (node as HTMLElement).style; 104 | return !isHardcodeText(node) && (nStyle ? nStyle.display !== "none" : true); 105 | }); 106 | 107 | const content = sections 108 | .map((node) => 109 | (node as HTMLElement)?.outerHTML?.replace( 110 | /]*><\/mpchecktext>/g, 111 | "" 112 | ) 113 | ) 114 | .join("
"); 115 | 116 | let node = d.querySelector(".js_cover_preview"); 117 | if (!node) return null; 118 | const cover = node["style"]["background-image"].split('"')[1].split("?")[0]; 119 | return { 120 | title, 121 | channel, 122 | author, 123 | content, 124 | cover, 125 | summary, 126 | }; 127 | }; 128 | 129 | // Error Panel 130 | const addErrMsg = ( 131 | wrapNode: HTMLDivElement, 132 | errMsg: "InvalidPrivateKey" | "BalanceNotEnough" | "UnknownError" 133 | ) => { 134 | if (errMsg === "BalanceNotEnough") { 135 | const span1 = document.createElement("span"); 136 | span1.textContent = "⚠️ 由于您账户的$CSB不足,备份将不会成功。请先去"; 137 | const aNode = document.createElement("a"); 138 | aNode.setAttribute("href", "https://faucet.crossbell.io"); 139 | aNode.textContent = "水龙头(https://faucet.crossbell.io)"; 140 | aNode.setAttribute("style", "color: #940000;"); 141 | aNode.setAttribute("target", "_blank"); 142 | const span2 = document.createElement("span"); 143 | span2.textContent = "领取。"; 144 | wrapNode.appendChild(span1); 145 | wrapNode.appendChild(aNode); 146 | wrapNode.appendChild(span2); 147 | } else if (errMsg === "InvalidPrivateKey") { 148 | wrapNode.textContent = 149 | "⚠️ 由于您的私钥设置不当,备份将不会成功。请前往脚本源码页面手动添加私钥。"; 150 | } 151 | }; 152 | 153 | export const makeErrorPanel = ( 154 | d: Document, 155 | errMsg: "InvalidPrivateKey" | "BalanceNotEnough" | "UnknownError" 156 | ) => { 157 | const newNode = d.createElement("div"); 158 | newNode.setAttribute( 159 | "style", 160 | `margin-top: -15px; 161 | padding: 0 20px 0 20px; 162 | color: red; 163 | text-align: right;` 164 | ); 165 | d.querySelector("#js_button_area")?.appendChild(newNode); 166 | addErrMsg(newNode, errMsg); 167 | }; 168 | -------------------------------------------------------------------------------- /test/contract.test.ts: -------------------------------------------------------------------------------- 1 | import { getUser, setUpContract, save, setUp } from "../src/chain"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | describe("contract suite", () => { 5 | it("setup contract", async () => { 6 | try { 7 | const c = await setUpContract(); 8 | expect(c).not.toBeNull(); 9 | } catch (e) { 10 | console.log(e.message); 11 | } 12 | }); 13 | 14 | it("get user", async () => { 15 | const user = getUser(); 16 | expect(user.startsWith("0x")).toBe(true); 17 | expect(user.length).toBe(42); 18 | }); 19 | 20 | it("general setup", async () => { 21 | // if already has primary character, return true 22 | // else, return false and create one 23 | const res = await setUp(); 24 | expect(res).toBe(true); 25 | }); 26 | 27 | it("save on chain", async () => { 28 | await save("xxx"); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import fs from "fs"; 3 | import pkg from "./package.json"; 4 | 5 | import plugin from "node-stdlib-browser/helpers/esbuild/plugin"; 6 | import ifdef from "esbuild-plugin-ifdef"; 7 | // import ifdefPlugin from "esbuild-ifdef"; 8 | import stdLibBrowser from "node-stdlib-browser"; 9 | 10 | const BANNER = fs 11 | .readFileSync("src/meta.js", "utf8") 12 | .replace("process.env.VERSION", pkg.version); 13 | 14 | // process.env.DEBUG = "true"; 15 | export default defineConfig({ 16 | entry: ["src/index.ts"], 17 | splitting: false, 18 | clean: true, 19 | format: ["iife"], 20 | outExtension() { 21 | return { 22 | js: `.user.js`, 23 | }; 24 | }, 25 | banner: { 26 | js: `${BANNER} 27 | var priKey = "0xPUT_YOUR_PRIVATE_KEY_HERE"; 28 | //e.g. var priKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";`, 29 | }, 30 | platform: "browser", 31 | //https://github.com/evanw/esbuild/issues/1626 32 | inject: ["node_modules/node-stdlib-browser/helpers/esbuild/shim.js"], 33 | define: { 34 | global: "window", 35 | process: "process", 36 | Buffer: "Buffer", 37 | }, 38 | esbuildPlugins: [ifdef()], 39 | plugins: [plugin(stdLibBrowser)], 40 | }); 41 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | testTimeout: 10 * 60 * 1000, 6 | threads: false, 7 | environment: "jsdom", 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------