├── .erb ├── mocks │ └── fileMock.js ├── img │ ├── erb-logo.png │ ├── erb-banner.svg │ └── palette-sponsor-banner.svg ├── configs │ ├── .eslintrc │ ├── webpack.config.eslint.ts │ ├── webpack.paths.ts │ ├── webpack.config.base.ts │ ├── webpack.config.renderer.dev.dll.ts │ ├── webpack.config.preload.dev.ts │ ├── webpack.config.main.prod.ts │ ├── webpack.config.renderer.prod.ts │ └── webpack.config.renderer.dev.ts └── scripts │ ├── .eslintrc │ ├── link-modules.ts │ ├── clean.js │ ├── check-node-env.js │ ├── check-port-in-use.js │ ├── delete-source-maps.js │ ├── electron-rebuild.js │ ├── check-build-exists.ts │ ├── notarize.js │ └── check-native-dep.js ├── coffee.jpg ├── gif2sc.gif ├── assets ├── ant.png ├── icon.ico ├── icon.png ├── icon.icns ├── fire4nt.png ├── icons │ ├── 16x16.png │ ├── 24x24.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 64x64.png │ ├── 96x96.png │ ├── 128x128.png │ ├── 256x256.png │ ├── 512x512.png │ └── 1024x1024.png ├── entitlements.mac.plist ├── assets.d.ts └── icon.svg ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── Makefile ├── src ├── renderer │ ├── preload.d.ts │ ├── index.ejs │ ├── App.tsx │ ├── App.css │ ├── index.tsx │ ├── EventPanel.tsx │ └── StatusPanel.tsx ├── __tests__ │ └── App.test.tsx ├── main │ ├── interface.ts │ ├── idcache.ts │ ├── util.ts │ ├── httpserver.ts │ ├── preload.ts │ ├── config.ts │ ├── EventForwarder.ts │ ├── listener.ts │ ├── main.ts │ ├── app.ts │ ├── WXDataDecoder.ts │ ├── service.ts │ └── menu.ts ├── CommonUtil.ts ├── schema.json └── CustomTypes.ts ├── .gitattributes ├── .editorconfig ├── release └── app │ ├── package-lock.json │ └── package.json ├── webpack.config.js ├── tsconfig.json ├── .eslintignore ├── .gitignore ├── README.md ├── .eslintrc.js ├── LICENSE └── package.json /.erb/mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | export default 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /coffee.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/coffee.jpg -------------------------------------------------------------------------------- /gif2sc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/gif2sc.gif -------------------------------------------------------------------------------- /assets/ant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/ant.png -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icon.icns -------------------------------------------------------------------------------- /assets/fire4nt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/fire4nt.png -------------------------------------------------------------------------------- /.erb/img/erb-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/.erb/img/erb-logo.png -------------------------------------------------------------------------------- /assets/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/16x16.png -------------------------------------------------------------------------------- /assets/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/24x24.png -------------------------------------------------------------------------------- /assets/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/32x32.png -------------------------------------------------------------------------------- /assets/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/48x48.png -------------------------------------------------------------------------------- /assets/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/64x64.png -------------------------------------------------------------------------------- /assets/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/96x96.png -------------------------------------------------------------------------------- /assets/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/128x128.png -------------------------------------------------------------------------------- /assets/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/256x256.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/512x512.png -------------------------------------------------------------------------------- /assets/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fire4nt/wxlivespy/HEAD/assets/icons/1024x1024.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"] 3 | } 4 | -------------------------------------------------------------------------------- /.erb/configs/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "global-require": "off", 5 | "import/no-dynamic-require": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.eslint.ts: -------------------------------------------------------------------------------- 1 | /* eslint import/no-unresolved: off, import/no-self-import: off */ 2 | 3 | module.exports = require('./webpack.config.renderer.dev').default; 4 | -------------------------------------------------------------------------------- /.erb/scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "global-require": "off", 5 | "import/no-dynamic-require": "off", 6 | "import/no-extraneous-dependencies": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | type: 2 | cmd.exe /c '.\node_modules\.bin\quicktype --src src\schema.json --src-lang schema -l ts -o src\CustomTypes.ts' 3 | sed -i '1i /* eslint-disable */' src/CustomTypes.ts 4 | 5 | build: 6 | powershell .\build.ps1 7 | -------------------------------------------------------------------------------- /src/renderer/preload.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronHandler } from '../main/preload'; 2 | 3 | declare global { 4 | // eslint-disable-next-line no-unused-vars 5 | interface Window { 6 | electron: ElectronHandler; 7 | } 8 | } 9 | 10 | export {}; 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.exe binary 3 | *.png binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.ico binary 7 | *.icns binary 8 | *.eot binary 9 | *.otf binary 10 | *.ttf binary 11 | *.woff binary 12 | *.woff2 binary 13 | *.gif binary 14 | -------------------------------------------------------------------------------- /src/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render } from '@testing-library/react'; 3 | import App from '../renderer/App'; 4 | 5 | describe('App', () => { 6 | it('should render', () => { 7 | expect(render()).toBeTruthy(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 120 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [Makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /release/app/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wxlive-spy", 3 | "version": "2024.0202.1638", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "wxlive-spy", 9 | "version": "2024.0202.1638", 10 | "hasInstallScript": true, 11 | "license": "MIT" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/interface.ts: -------------------------------------------------------------------------------- 1 | import { DecodedData, LiveInfo } from '../CustomTypes'; 2 | 3 | interface WXLiveEventHandler { 4 | onStatusUpdate: (res: LiveInfo) => void; 5 | /** 6 | * 将解析出来的数据传递给前端,并进行转发 7 | * 8 | * @param res 解析出来的数据 9 | */ 10 | onEvents: (res: DecodedData) => void; 11 | } 12 | 13 | export default WXLiveEventHandler; 14 | -------------------------------------------------------------------------------- /src/renderer/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 视频号弹幕抓取工具 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /.erb/scripts/link-modules.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import webpackPaths from '../configs/webpack.paths'; 3 | 4 | const { srcNodeModulesPath } = webpackPaths; 5 | const { appNodeModulesPath } = webpackPaths; 6 | 7 | if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) { 8 | fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction'); 9 | } 10 | -------------------------------------------------------------------------------- /.erb/scripts/clean.js: -------------------------------------------------------------------------------- 1 | import { rimrafSync } from 'rimraf'; 2 | import fs from 'fs'; 3 | import webpackPaths from '../configs/webpack.paths'; 4 | 5 | const foldersToRemove = [ 6 | webpackPaths.distPath, 7 | webpackPaths.buildPath, 8 | webpackPaths.dllPath, 9 | ]; 10 | 11 | foldersToRemove.forEach((folder) => { 12 | if (fs.existsSync(folder)) rimrafSync(folder); 13 | }); 14 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); 2 | 3 | module.exports = { 4 | // ... other options 5 | devtool: 'source-map', // Source map generation must be turned on 6 | plugins: [ 7 | // Put the Sentry Webpack plugin after all other plugins 8 | sentryWebpackPlugin({ 9 | authToken: process.env.SENTRY_AUTH_TOKEN, 10 | org: 'fire-ant-studio', 11 | project: 'wxlivespy', 12 | }), 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /.erb/scripts/check-node-env.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export default function checkNodeEnv(expectedEnv) { 4 | if (!expectedEnv) { 5 | throw new Error('"expectedEnv" not set'); 6 | } 7 | 8 | if (process.env.NODE_ENV !== expectedEnv) { 9 | console.log( 10 | chalk.whiteBright.bgRed.bold( 11 | `"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`, 12 | ), 13 | ); 14 | process.exit(2); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/idcache.ts: -------------------------------------------------------------------------------- 1 | class IDCache { 2 | private cache: Map = new Map(); 3 | 4 | public set(liveId: string, secOpenId: string, decodedOpenId: string) { 5 | const key = `${liveId}-${secOpenId}`; 6 | this.cache.set(key, decodedOpenId); 7 | } 8 | 9 | public get(liveId: string, secOpenId: string): string | null { 10 | const key = `${liveId}-${secOpenId}`; 11 | return this.cache.get(key) ?? null; 12 | } 13 | } 14 | 15 | export default IDCache; 16 | -------------------------------------------------------------------------------- /.erb/scripts/check-port-in-use.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import detectPort from 'detect-port'; 3 | 4 | const port = process.env.PORT || '1212'; 5 | 6 | detectPort(port, (_err, availablePort) => { 7 | if (port !== String(availablePort)) { 8 | throw new Error( 9 | chalk.whiteBright.bgRed.bold( 10 | `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`, 11 | ), 12 | ); 13 | } else { 14 | process.exit(0); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2022", 5 | "module": "commonjs", 6 | "lib": ["dom", "es2022"], 7 | "jsx": "react-jsx", 8 | "strict": true, 9 | "sourceMap": true, 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "allowJs": true, 15 | "outDir": ".erb/dll" 16 | }, 17 | "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"] 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/App.tsx: -------------------------------------------------------------------------------- 1 | import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; 2 | import StatusPanel from './StatusPanel'; 3 | import './App.css'; 4 | import EventPanel from './EventPanel'; 5 | 6 | function Hello() { 7 | return ( 8 |
9 | 10 | 11 |
12 | ); 13 | } 14 | 15 | export default function App() { 16 | return ( 17 | 18 | 19 | } /> 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /.erb/scripts/delete-source-maps.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { rimrafSync } from 'rimraf'; 4 | import webpackPaths from '../configs/webpack.paths'; 5 | 6 | export default function deleteSourceMaps() { 7 | if (fs.existsSync(webpackPaths.distMainPath)) 8 | rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), { 9 | glob: true, 10 | }); 11 | if (fs.existsSync(webpackPaths.distRendererPath)) 12 | rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), { 13 | glob: true, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /release/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wxlive-spy", 3 | "version": "2024.0202.1638", 4 | "description": "微信视频号直播间弹幕信息抓取工具", 5 | "license": "MIT", 6 | "author": { 7 | "name": "fire4nt", 8 | "email": "fire4nt.soft@gmail.com", 9 | "url": "https://github.com/fire4nt" 10 | }, 11 | "main": "./dist/main/main.js", 12 | "scripts": { 13 | "rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js", 14 | "postinstall": "npm run rebuild && npm run link-modules", 15 | "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts" 16 | }, 17 | "dependencies": {} 18 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | .eslintcache 13 | 14 | # Dependency directory 15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 16 | node_modules 17 | 18 | # OSX 19 | .DS_Store 20 | 21 | release/app/dist 22 | release/build 23 | .erb/dll 24 | 25 | .idea 26 | npm-debug.log.* 27 | *.css.d.ts 28 | *.sass.d.ts 29 | *.scss.d.ts 30 | 31 | # eslint ignores hidden directories by default: 32 | # https://github.com/eslint/eslint/issues/8429 33 | !.erb 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | userdata2/ 2 | assets/puppeteer_chrome 3 | assets/puppeteer_chrome/ 4 | .github/ 5 | *.swp 6 | 7 | # Logs 8 | logs 9 | *.log 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | .eslintcache 19 | 20 | # Dependency directory 21 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 22 | node_modules 23 | 24 | # OSX 25 | .DS_Store 26 | 27 | release/app/dist 28 | release/build 29 | .erb/dll 30 | 31 | .idea 32 | npm-debug.log.* 33 | *.css.d.ts 34 | *.sass.d.ts 35 | *.scss.d.ts 36 | 37 | # Sentry Config File 38 | .env.sentry-build-plugin 39 | -------------------------------------------------------------------------------- /.erb/scripts/electron-rebuild.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | import { dependencies } from '../../release/app/package.json'; 4 | import webpackPaths from '../configs/webpack.paths'; 5 | 6 | if ( 7 | Object.keys(dependencies || {}).length > 0 && 8 | fs.existsSync(webpackPaths.appNodeModulesPath) 9 | ) { 10 | const electronRebuildCmd = 11 | '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .'; 12 | const cmd = 13 | process.platform === 'win32' 14 | ? electronRebuildCmd.replace(/\//g, '\\') 15 | : electronRebuildCmd; 16 | execSync(cmd, { 17 | cwd: webpackPaths.appPath, 18 | stdio: 'inherit', 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | ".eslintrc": "jsonc", 4 | ".prettierrc": "jsonc", 5 | ".eslintignore": "ignore" 6 | }, 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "html", 11 | "typescriptreact" 12 | ], 13 | "javascript.validate.enable": false, 14 | "javascript.format.enable": false, 15 | "typescript.format.enable": true, 16 | "search.exclude": { 17 | ".git": true, 18 | ".eslintcache": true, 19 | ".erb/dll": true, 20 | "release/{build,app/dist}": true, 21 | "node_modules": true, 22 | "npm-debug.log.*": true, 23 | "test/**/__snapshots__": true, 24 | "package-lock.json": true, 25 | "*.{css,sass,scss}.d.ts": true 26 | } 27 | } -------------------------------------------------------------------------------- /assets/assets.d.ts: -------------------------------------------------------------------------------- 1 | type Styles = Record; 2 | 3 | declare module '*.svg' { 4 | import React = require('react'); 5 | 6 | export const ReactComponent: React.FC>; 7 | 8 | const content: string; 9 | export default content; 10 | } 11 | 12 | declare module '*.png' { 13 | const content: string; 14 | export default content; 15 | } 16 | 17 | declare module '*.jpg' { 18 | const content: string; 19 | export default content; 20 | } 21 | 22 | declare module '*.scss' { 23 | const content: Styles; 24 | export default content; 25 | } 26 | 27 | declare module '*.sass' { 28 | const content: Styles; 29 | export default content; 30 | } 31 | 32 | declare module '*.css' { 33 | const content: Styles; 34 | export default content; 35 | } 36 | -------------------------------------------------------------------------------- /src/main/util.ts: -------------------------------------------------------------------------------- 1 | /* eslint import/prefer-default-export: off */ 2 | import { URL } from 'url'; 3 | import path from 'path'; 4 | import crypto from 'crypto'; 5 | import fs from 'fs'; 6 | 7 | export function resolveHtmlPath(htmlFileName: string) { 8 | if (process.env.NODE_ENV === 'development') { 9 | const port = process.env.PORT || 1212; 10 | const url = new URL(`http://localhost:${port}`); 11 | url.pathname = htmlFileName; 12 | return url.href; 13 | } 14 | return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; 15 | } 16 | 17 | export function md5OfFile(filename: string) { 18 | const hash = crypto.createHash('md5'); 19 | const fileContent = fs.readFileSync(filename); 20 | hash.update(fileContent); 21 | const md5 = hash.digest('hex'); 22 | return md5; 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Electron: Main", 6 | "type": "node", 7 | "request": "launch", 8 | "protocol": "inspector", 9 | "runtimeExecutable": "npm", 10 | "runtimeArgs": ["run", "start"], 11 | "env": { 12 | "MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223 --trace-warnings" 13 | } 14 | }, 15 | { 16 | "name": "Electron: Renderer", 17 | "type": "chrome", 18 | "request": "attach", 19 | "port": 9223, 20 | "webRoot": "${workspaceFolder}", 21 | "timeout": 15000 22 | } 23 | ], 24 | "compounds": [ 25 | { 26 | "name": "Electron: All", 27 | "configurations": ["Electron: Main", "Electron: Renderer"] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.erb/scripts/check-build-exists.ts: -------------------------------------------------------------------------------- 1 | // Check if the renderer and main bundles are built 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import fs from 'fs'; 5 | import webpackPaths from '../configs/webpack.paths'; 6 | 7 | const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); 8 | const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); 9 | 10 | if (!fs.existsSync(mainPath)) { 11 | throw new Error( 12 | chalk.whiteBright.bgRed.bold( 13 | 'The main process is not built yet. Build it by running "npm run build:main"', 14 | ), 15 | ); 16 | } 17 | 18 | if (!fs.existsSync(rendererPath)) { 19 | throw new Error( 20 | chalk.whiteBright.bgRed.bold( 21 | 'The renderer process is not built yet. Build it by running "npm run build:renderer"', 22 | ), 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.erb/scripts/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require('@electron/notarize'); 2 | const { build } = require('../../package.json'); 3 | 4 | exports.default = async function notarizeMacos(context) { 5 | const { electronPlatformName, appOutDir } = context; 6 | if (electronPlatformName !== 'darwin') { 7 | return; 8 | } 9 | 10 | if (process.env.CI !== 'true') { 11 | console.warn('Skipping notarizing step. Packaging is not running in CI'); 12 | return; 13 | } 14 | 15 | if ( 16 | !('APPLE_ID' in process.env && 'APPLE_APP_SPECIFIC_PASSWORD' in process.env) 17 | ) { 18 | console.warn( 19 | 'Skipping notarizing step. APPLE_ID and APPLE_APP_SPECIFIC_PASSWORD env variables must be set', 20 | ); 21 | return; 22 | } 23 | 24 | const appName = context.packager.appInfo.productFilename; 25 | 26 | await notarize({ 27 | appBundleId: build.appId, 28 | appPath: `${appOutDir}/${appName}.app`, 29 | appleId: process.env.APPLE_ID, 30 | appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 功能 2 | 3 | 本工具可以监听微信视频号直播间的弹幕、礼物信息,并转发到指定的http地址。 4 | 5 | - 工具只在Win64系统上发布并测试过。其他系统未测试。 6 | - ~~同一个用户在不同的直播场次中,用户ID会变化~~。(2024-02-02 已更新,使用数据中的`decoded_openid`,它在同一个主播的不同直播场次中是不变的) 7 | - 可以获取到用户的点赞行为(长按直播界面的点赞按钮),以及直播间的点赞总数,但是无法获取单个用户精确的点赞次数。 8 | 9 | ## 使用方式 10 | 11 | ![gif2sc.gif](gif2sc.gif) 12 | 13 | 1. 点击“开始监听”按钮。 14 | 2. 浏览器会打开的视频号管理后台,用微信扫码登录。 15 | 3. 本工具上会展示出直播间的状态以及弹幕、礼物信息。 16 | 4. 设置http转发地址,将弹幕、礼物信息转发到指定地址。 17 | 18 | ## 开发说明 19 | 20 | ### Install 21 | 22 | Clone the repo and install dependencies: 23 | 24 | ```bash 25 | npm install 26 | ``` 27 | 28 | 安装完毕后,在 `C:\Users\\.cache\puppeteer\chrome` 目录下,会有安装好的chrome 29 | 比如我机器上是 `C:\Users\fire4nt\.cache\puppeteer\chrome\win64-117.0.5938.149\chrome-win64`。 30 | 把这个目录复制为项目目录下的 `assets\puppeteer_chrome` 目录。 31 | 32 | ### Starting Development 33 | 34 | Start the app in the `dev` environment: 35 | 36 | ```bash 37 | npm start 38 | ``` 39 | 40 | ### Packaging for Production 41 | 42 | To package apps for the local platform: 43 | 44 | ```bash 45 | npm run package 46 | ``` 47 | 48 | ## License 49 | 50 | MIT 51 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'erb', 3 | plugins: ['@typescript-eslint'], 4 | rules: { 5 | // A temporary hack related to IDE not resolving correct package.json 6 | 'import/no-extraneous-dependencies': 'off', 7 | 'react/react-in-jsx-scope': 'off', 8 | 'react/jsx-filename-extension': 'off', 9 | 'import/extensions': 'off', 10 | 'import/no-unresolved': 'off', 11 | 'import/no-import-module-exports': 'off', 12 | 'no-shadow': 'off', 13 | '@typescript-eslint/no-shadow': 'error', 14 | 'no-unused-vars': 'off', 15 | '@typescript-eslint/no-unused-vars': 'error', 16 | }, 17 | parserOptions: { 18 | ecmaVersion: 2022, 19 | sourceType: 'module', 20 | }, 21 | settings: { 22 | 'import/resolver': { 23 | // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below 24 | node: {}, 25 | webpack: { 26 | config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), 27 | }, 28 | typescript: {}, 29 | }, 30 | 'import/parsers': { 31 | '@typescript-eslint/parser': ['.ts', '.tsx'], 32 | }, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present Electron React Boilerplate 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 | -------------------------------------------------------------------------------- /.erb/configs/webpack.paths.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const rootPath = path.join(__dirname, '../..'); 4 | 5 | const dllPath = path.join(__dirname, '../dll'); 6 | 7 | const srcPath = path.join(rootPath, 'src'); 8 | const srcMainPath = path.join(srcPath, 'main'); 9 | const srcRendererPath = path.join(srcPath, 'renderer'); 10 | 11 | const releasePath = path.join(rootPath, 'release'); 12 | const appPath = path.join(releasePath, 'app'); 13 | const appPackagePath = path.join(appPath, 'package.json'); 14 | const appNodeModulesPath = path.join(appPath, 'node_modules'); 15 | const srcNodeModulesPath = path.join(srcPath, 'node_modules'); 16 | 17 | const distPath = path.join(appPath, 'dist'); 18 | const distMainPath = path.join(distPath, 'main'); 19 | const distRendererPath = path.join(distPath, 'renderer'); 20 | 21 | const buildPath = path.join(releasePath, 'build'); 22 | 23 | export default { 24 | rootPath, 25 | dllPath, 26 | srcPath, 27 | srcMainPath, 28 | srcRendererPath, 29 | releasePath, 30 | appPath, 31 | appPackagePath, 32 | appNodeModulesPath, 33 | srcNodeModulesPath, 34 | distPath, 35 | distMainPath, 36 | distRendererPath, 37 | buildPath, 38 | }; 39 | -------------------------------------------------------------------------------- /src/renderer/App.css: -------------------------------------------------------------------------------- 1 | /* 2 | * @NOTE: Prepend a `~` to css file paths that are in your node_modules 3 | * See https://github.com/webpack-contrib/sass-loader#imports 4 | */ 5 | body { 6 | position: relative; 7 | color: white; 8 | height: 100vh; 9 | background: linear-gradient( 10 | 200.96deg, 11 | #fedc2a -29.09%, 12 | #dd5789 51.77%, 13 | #7a2c9e 129.35% 14 | ); 15 | font-family: sans-serif; 16 | overflow-y: hidden; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | } 21 | 22 | button { 23 | background-color: white; 24 | /* padding: 10px 20px; 25 | border-radius: 10px; */ 26 | border: none; 27 | appearance: none; 28 | /* font-size: 1.3rem; */ 29 | box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12), 30 | 0px 18px 88px -4px rgba(24, 39, 75, 0.14); 31 | transition: all ease-in 0.1s; 32 | cursor: pointer; 33 | opacity: 0.9; 34 | } 35 | 36 | button:hover { 37 | transform: scale(1.05); 38 | opacity: 1; 39 | } 40 | 41 | li { 42 | list-style: none; 43 | } 44 | 45 | a { 46 | text-decoration: none; 47 | height: fit-content; 48 | width: fit-content; 49 | margin: 10px; 50 | } 51 | 52 | a:hover { 53 | opacity: 1; 54 | text-decoration: none; 55 | } 56 | 57 | .Hello { 58 | display: flex; 59 | justify-content: center; 60 | align-items: center; 61 | margin: 20px 0; 62 | } 63 | -------------------------------------------------------------------------------- /src/main/httpserver.ts: -------------------------------------------------------------------------------- 1 | import log from 'electron-log'; 2 | import * as http from 'http'; 3 | import * as url from 'url'; 4 | 5 | class SpyHttpServer { 6 | private port: number; 7 | 8 | private server: http.Server | null; 9 | 10 | private getLiveStatus: Function; 11 | 12 | constructor(port: number, getLiveStatus: Function) { 13 | this.port = port; 14 | this.getLiveStatus = getLiveStatus; 15 | this.server = null; 16 | } 17 | 18 | public start() { 19 | this.server = http.createServer((req, res) => { 20 | const reqUrl = url.parse(req.url || '', true); 21 | 22 | if (reqUrl.pathname === '/getLiveStatus' && req.method === 'GET') { 23 | res.writeHead(200, { 'Content-Type': 'application/json' }); 24 | const status = this.getLiveStatus(); 25 | log.debug(status === null); 26 | if (status === null) { 27 | res.write('{}'); 28 | } else { 29 | res.write(JSON.stringify(this.getLiveStatus())); 30 | } 31 | res.end(); 32 | } else { 33 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 34 | res.write('404 Not Found\n'); 35 | res.end(); 36 | } 37 | }); 38 | 39 | this.server.listen(this.port, () => { 40 | log.info(`Server listening on port ${this.port}`); 41 | }); 42 | } 43 | 44 | public stop() { 45 | this.server?.close(() => { 46 | log.info('server closed'); 47 | }); 48 | } 49 | } 50 | export default SpyHttpServer; 51 | -------------------------------------------------------------------------------- /src/main/preload.ts: -------------------------------------------------------------------------------- 1 | // Disable no-unused-vars, broken for spread args 2 | /* eslint no-unused-vars: off */ 3 | import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; 4 | 5 | export type Channels = 6 | | 'ipc-example' 7 | | 'wxlive-event' 8 | | 'wxlive-status' 9 | | 'wxlive-set-all-config' 10 | | 'wxlive-set-config' 11 | | 'electron-baidu-tongji-reply' 12 | | 'electron-baidu-tongji-message' 13 | | 'wxlive-debug'; 14 | 15 | const electronHandler = { 16 | ipcRenderer: { 17 | sendMessage(channel: Channels, ...args: unknown[]) { 18 | ipcRenderer.send(channel, ...args); 19 | }, 20 | on(channel: Channels, func: (...args: unknown[]) => void) { 21 | const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => func(...args); 22 | ipcRenderer.on(channel, subscription); 23 | 24 | return () => { 25 | ipcRenderer.removeListener(channel, subscription); 26 | }; 27 | }, 28 | once(channel: Channels, func: (...args: unknown[]) => void) { 29 | ipcRenderer.once(channel, (_event, ...args) => func(...args)); 30 | }, 31 | getConfig: (key: string) => ipcRenderer.invoke('wxlive-get-config', key), 32 | getForwardUrl: () => ipcRenderer.invoke('wxlive-get-forward-url'), 33 | openExternalLink: (url: string) => ipcRenderer.invoke('wxlive-open-external-link', url), 34 | }, 35 | }; 36 | 37 | contextBridge.exposeInMainWorld('electron', electronHandler); 38 | 39 | export type ElectronHandler = typeof electronHandler; 40 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import * as Sentry from '@sentry/electron/renderer'; 3 | import log from 'electron-log'; 4 | import App from './App'; 5 | 6 | Sentry.init({ 7 | dsn: 'https://fcbeb5b02f692b5f81995d7f07dbd4d5@o4506054030721024.ingest.sentry.io/4506054034784256', 8 | }); 9 | const container = document.getElementById('root') as HTMLElement; 10 | const root = createRoot(container); 11 | root.render(); 12 | log.info('Log from the renderer process'); 13 | // calling IPC exposed from preload script 14 | window.electron.ipcRenderer.once('ipc-example', (arg) => { 15 | log.info(arg); 16 | }); 17 | 18 | const BAIDU_SITE_ID = '9cbc91a7ca7ce2180c60fb203a9b3bdf'; 19 | window.electron.ipcRenderer.on('electron-baidu-tongji-reply', (text) => { 20 | const textStr = text as string; 21 | log.info(`electron-baidu-tongji-reply, ${`${textStr.slice(0, 10)}...`}}`); 22 | 23 | const hm = document.createElement('script'); 24 | hm.text = textStr; 25 | 26 | const head = document.getElementsByTagName('head')[0]; 27 | log.info('append script to head', head); 28 | head.appendChild(hm); 29 | }); 30 | window.electron.ipcRenderer.sendMessage('electron-baidu-tongji-message', BAIDU_SITE_ID); 31 | 32 | window.electron.ipcRenderer.on('wxlive-debug', (action) => { 33 | if (action === 'crash') { 34 | process.crash(); 35 | } 36 | if (action === 'undefined-function') { 37 | // eslint-disable-next-line no-undef 38 | undefinedFunction(); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base webpack config used across other specific configs 3 | */ 4 | 5 | import webpack from 'webpack'; 6 | import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; 7 | import webpackPaths from './webpack.paths'; 8 | import { dependencies as externals } from '../../release/app/package.json'; 9 | 10 | const configuration: webpack.Configuration = { 11 | externals: [...Object.keys(externals || {})], 12 | 13 | stats: 'errors-only', 14 | 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.[jt]sx?$/, 19 | exclude: /node_modules/, 20 | use: { 21 | loader: 'ts-loader', 22 | options: { 23 | // Remove this line to enable type checking in webpack builds 24 | transpileOnly: true, 25 | compilerOptions: { 26 | module: 'esnext', 27 | }, 28 | }, 29 | }, 30 | }, 31 | ], 32 | }, 33 | 34 | output: { 35 | path: webpackPaths.srcPath, 36 | // https://github.com/webpack/webpack/issues/1114 37 | library: { 38 | type: 'commonjs2', 39 | }, 40 | }, 41 | 42 | /** 43 | * Determine the array of extensions that should be used to resolve modules. 44 | */ 45 | resolve: { 46 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], 47 | modules: [webpackPaths.srcPath, 'node_modules'], 48 | // There is no need to add aliases here, the paths in tsconfig get mirrored 49 | plugins: [new TsconfigPathsPlugins()], 50 | }, 51 | 52 | plugins: [ 53 | new webpack.EnvironmentPlugin({ 54 | NODE_ENV: 'production', 55 | }), 56 | ], 57 | }; 58 | 59 | export default configuration; 60 | -------------------------------------------------------------------------------- /src/main/config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | // Step 1: Define an interface for your configuration 4 | export interface ConfigProps { 5 | debug: boolean; 6 | spy_url: string; 7 | forward_url?: string; 8 | gzip_forward_data: boolean; 9 | chrome_path?: string; 10 | chrome_userdata_path?: string; 11 | log_path: string; 12 | gift_and_comments_only: boolean; 13 | http_server_port: number; 14 | // Add other properties as needed 15 | } 16 | 17 | class SpyConfig { 18 | private configFilePath: string; 19 | 20 | private defaultConfig: ConfigProps = { 21 | debug: false, 22 | spy_url: 'https://channels.weixin.qq.com/platform/live/liveBuild', 23 | forward_url: 'http://127.0.0.1:8000/forward', 24 | gzip_forward_data: false, 25 | log_path: './logs', 26 | gift_and_comments_only: false, 27 | http_server_port: 21201, 28 | }; 29 | 30 | private config: ConfigProps; 31 | 32 | constructor(configFilePath: string) { 33 | this.configFilePath = configFilePath; 34 | this.config = { ...this.defaultConfig }; 35 | } 36 | 37 | public load(): void { 38 | if (fs.existsSync(this.configFilePath)) { 39 | const data = fs.readFileSync(this.configFilePath, 'utf8'); 40 | const loadedConfig = JSON.parse(data); 41 | this.config = { ...this.defaultConfig, ...loadedConfig }; 42 | } 43 | } 44 | 45 | public save(): void { 46 | const data = JSON.stringify(this.config, null, 2); 47 | fs.writeFileSync(this.configFilePath, data); 48 | } 49 | 50 | // Step 2: Implement generic getProp and setProp methods 51 | public getProp(key: K): ConfigProps[K] { 52 | return this.config[key]; 53 | } 54 | 55 | public setProp(key: K, value: ConfigProps[K]): void { 56 | this.config[key] = value; 57 | } 58 | 59 | public getAllConfigs(): ConfigProps { 60 | return this.config; 61 | } 62 | } 63 | 64 | export default SpyConfig; 65 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.dll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Builds the DLL for development electron renderer process 3 | */ 4 | 5 | import webpack from 'webpack'; 6 | import path from 'path'; 7 | import { merge } from 'webpack-merge'; 8 | import baseConfig from './webpack.config.base'; 9 | import webpackPaths from './webpack.paths'; 10 | import { dependencies } from '../../package.json'; 11 | import checkNodeEnv from '../scripts/check-node-env'; 12 | 13 | checkNodeEnv('development'); 14 | 15 | const dist = webpackPaths.dllPath; 16 | 17 | const configuration: webpack.Configuration = { 18 | context: webpackPaths.rootPath, 19 | 20 | devtool: 'eval', 21 | 22 | mode: 'development', 23 | 24 | target: 'electron-renderer', 25 | 26 | externals: ['fsevents', 'crypto-browserify'], 27 | 28 | /** 29 | * Use `module` from `webpack.config.renderer.dev.js` 30 | */ 31 | module: require('./webpack.config.renderer.dev').default.module, 32 | 33 | entry: { 34 | renderer: Object.keys(dependencies || {}), 35 | }, 36 | 37 | output: { 38 | path: dist, 39 | filename: '[name].dev.dll.js', 40 | library: { 41 | name: 'renderer', 42 | type: 'var', 43 | }, 44 | }, 45 | 46 | plugins: [ 47 | new webpack.DllPlugin({ 48 | path: path.join(dist, '[name].json'), 49 | name: '[name]', 50 | }), 51 | 52 | /** 53 | * Create global constants which can be configured at compile time. 54 | * 55 | * Useful for allowing different behaviour between development builds and 56 | * release builds 57 | * 58 | * NODE_ENV should be production so that modules do not perform certain 59 | * development checks 60 | */ 61 | new webpack.EnvironmentPlugin({ 62 | NODE_ENV: 'development', 63 | }), 64 | 65 | new webpack.LoaderOptionsPlugin({ 66 | debug: true, 67 | options: { 68 | context: webpackPaths.srcPath, 69 | output: { 70 | path: webpackPaths.dllPath, 71 | }, 72 | }, 73 | }), 74 | ], 75 | }; 76 | 77 | export default merge(baseConfig, configuration); 78 | -------------------------------------------------------------------------------- /src/main/EventForwarder.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import zlib from 'zlib'; 3 | import log from 'electron-log'; 4 | import SpyConfig from './config'; 5 | 6 | class EventForwarder { 7 | private config: SpyConfig; 8 | 9 | constructor(config: SpyConfig) { 10 | this.config = config; 11 | } 12 | 13 | static async postGzippedData(url: string, data: any) { 14 | // Gzip the data 15 | return new Promise((resolve, reject) => { 16 | zlib.gzip(JSON.stringify(data), async (err, gzippedData) => { 17 | if (err) { 18 | return reject(err); 19 | } 20 | 21 | try { 22 | // Make the axios POST request with gzipped data 23 | const response = await axios.post(url, gzippedData, { 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | 'Content-Encoding': 'gzip', 27 | }, 28 | }); 29 | 30 | return resolve(response.data); 31 | } catch (error) { 32 | return reject(error); 33 | } 34 | }); 35 | }); 36 | } 37 | 38 | static async PostOriginalData(url: string, data: any) { 39 | return new Promise((resolve, reject) => { 40 | try { 41 | // Make the axios POST request with gzipped data 42 | axios 43 | .post(url, data, { 44 | headers: { 45 | 'Content-Type': 'application/json', 46 | }, 47 | }) 48 | .then((rr) => { 49 | // return INFO(`server response: ${JSON.stringify(rr)}`); 50 | return resolve(rr.data); 51 | }) 52 | .catch((error) => { 53 | reject(error); 54 | }); 55 | } catch (error) { 56 | reject(error); 57 | } 58 | }); 59 | } 60 | 61 | async forwardData(data: any): Promise { 62 | const url = this.config.getProp('forward_url'); 63 | if (url === undefined || url === '') { 64 | log.warn('forward url is not set'); 65 | return undefined; 66 | } 67 | if (this.config.getProp('gzip_forward_data')) { 68 | return EventForwarder.postGzippedData(url, data); 69 | } 70 | return EventForwarder.PostOriginalData(url, data); 71 | } 72 | } 73 | 74 | export default EventForwarder; 75 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.preload.dev.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import { merge } from 'webpack-merge'; 4 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 5 | import baseConfig from './webpack.config.base'; 6 | import webpackPaths from './webpack.paths'; 7 | import checkNodeEnv from '../scripts/check-node-env'; 8 | 9 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 10 | // at the dev webpack config is not accidentally run in a production environment 11 | if (process.env.NODE_ENV === 'production') { 12 | checkNodeEnv('development'); 13 | } 14 | 15 | const configuration: webpack.Configuration = { 16 | devtool: 'inline-source-map', 17 | 18 | mode: 'development', 19 | 20 | target: 'electron-preload', 21 | 22 | entry: path.join(webpackPaths.srcMainPath, 'preload.ts'), 23 | 24 | output: { 25 | path: webpackPaths.dllPath, 26 | filename: 'preload.js', 27 | library: { 28 | type: 'umd', 29 | }, 30 | }, 31 | 32 | plugins: [ 33 | new BundleAnalyzerPlugin({ 34 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 35 | }), 36 | 37 | /** 38 | * Create global constants which can be configured at compile time. 39 | * 40 | * Useful for allowing different behaviour between development builds and 41 | * release builds 42 | * 43 | * NODE_ENV should be production so that modules do not perform certain 44 | * development checks 45 | * 46 | * By default, use 'development' as NODE_ENV. This can be overriden with 47 | * 'staging', for example, by changing the ENV variables in the npm scripts 48 | */ 49 | new webpack.EnvironmentPlugin({ 50 | NODE_ENV: 'development', 51 | }), 52 | 53 | new webpack.LoaderOptionsPlugin({ 54 | debug: true, 55 | }), 56 | ], 57 | 58 | /** 59 | * Disables webpack processing of __dirname and __filename. 60 | * If you run the bundle in node.js it falls back to these values of node.js. 61 | * https://github.com/webpack/webpack/issues/2010 62 | */ 63 | node: { 64 | __dirname: false, 65 | __filename: false, 66 | }, 67 | 68 | watch: true, 69 | }; 70 | 71 | export default merge(baseConfig, configuration); 72 | -------------------------------------------------------------------------------- /.erb/scripts/check-native-dep.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import chalk from 'chalk'; 3 | import { execSync } from 'child_process'; 4 | import { dependencies } from '../../package.json'; 5 | 6 | if (dependencies) { 7 | const dependenciesKeys = Object.keys(dependencies); 8 | const nativeDeps = fs 9 | .readdirSync('node_modules') 10 | .filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`)); 11 | if (nativeDeps.length === 0) { 12 | process.exit(0); 13 | } 14 | try { 15 | // Find the reason for why the dependency is installed. If it is installed 16 | // because of a devDependency then that is okay. Warn when it is installed 17 | // because of a dependency 18 | const { dependencies: dependenciesObject } = JSON.parse( 19 | execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(), 20 | ); 21 | const rootDependencies = Object.keys(dependenciesObject); 22 | const filteredRootDependencies = rootDependencies.filter((rootDependency) => 23 | dependenciesKeys.includes(rootDependency), 24 | ); 25 | if (filteredRootDependencies.length > 0) { 26 | const plural = filteredRootDependencies.length > 1; 27 | console.log(` 28 | ${chalk.whiteBright.bgYellow.bold( 29 | 'Webpack does not work with native dependencies.', 30 | )} 31 | ${chalk.bold(filteredRootDependencies.join(', '))} ${ 32 | plural ? 'are native dependencies' : 'is a native dependency' 33 | } and should be installed inside of the "./release/app" folder. 34 | First, uninstall the packages from "./package.json": 35 | ${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')} 36 | ${chalk.bold( 37 | 'Then, instead of installing the package to the root "./package.json":', 38 | )} 39 | ${chalk.whiteBright.bgRed.bold('npm install your-package')} 40 | ${chalk.bold('Install the package to "./release/app/package.json"')} 41 | ${chalk.whiteBright.bgGreen.bold( 42 | 'cd ./release/app && npm install your-package', 43 | )} 44 | Read more about native dependencies at: 45 | ${chalk.bold( 46 | 'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure', 47 | )} 48 | `); 49 | process.exit(1); 50 | } 51 | } catch (e) { 52 | console.log('Native dependencies could not be checked'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.main.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack config for production electron main process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import { merge } from 'webpack-merge'; 8 | import TerserPlugin from 'terser-webpack-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import baseConfig from './webpack.config.base'; 11 | import webpackPaths from './webpack.paths'; 12 | import checkNodeEnv from '../scripts/check-node-env'; 13 | import deleteSourceMaps from '../scripts/delete-source-maps'; 14 | 15 | checkNodeEnv('production'); 16 | deleteSourceMaps(); 17 | 18 | const configuration: webpack.Configuration = { 19 | devtool: 'source-map', 20 | 21 | mode: 'production', 22 | 23 | target: 'electron-main', 24 | 25 | entry: { 26 | main: path.join(webpackPaths.srcMainPath, 'main.ts'), 27 | preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), 28 | }, 29 | 30 | output: { 31 | path: webpackPaths.distMainPath, 32 | filename: '[name].js', 33 | library: { 34 | type: 'umd', 35 | }, 36 | }, 37 | 38 | optimization: { 39 | minimizer: [ 40 | new TerserPlugin({ 41 | parallel: true, 42 | }), 43 | ], 44 | }, 45 | 46 | plugins: [ 47 | new BundleAnalyzerPlugin({ 48 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 49 | analyzerPort: 8888, 50 | }), 51 | 52 | /** 53 | * Create global constants which can be configured at compile time. 54 | * 55 | * Useful for allowing different behaviour between development builds and 56 | * release builds 57 | * 58 | * NODE_ENV should be production so that modules do not perform certain 59 | * development checks 60 | */ 61 | new webpack.EnvironmentPlugin({ 62 | NODE_ENV: 'production', 63 | DEBUG_PROD: false, 64 | START_MINIMIZED: false, 65 | }), 66 | 67 | new webpack.DefinePlugin({ 68 | 'process.type': '"browser"', 69 | }), 70 | ], 71 | 72 | /** 73 | * Disables webpack processing of __dirname and __filename. 74 | * If you run the bundle in node.js it falls back to these values of node.js. 75 | * https://github.com/webpack/webpack/issues/2010 76 | */ 77 | node: { 78 | __dirname: false, 79 | __filename: false, 80 | }, 81 | }; 82 | 83 | export default merge(baseConfig, configuration); 84 | -------------------------------------------------------------------------------- /src/CommonUtil.ts: -------------------------------------------------------------------------------- 1 | export function pad2(n: number) { 2 | return n < 10 ? `0${n}` : n; 3 | } 4 | export function pad3(n: number) { 5 | if (n < 10) { 6 | return `00${n}`; 7 | } 8 | if (n < 100) { 9 | return `0${n}`; 10 | } 11 | return n; 12 | } 13 | function MY_LOG(level: string, msg: string) { 14 | const date = new Date(); 15 | const year = date.getFullYear().toString(); 16 | const mouth = pad2(date.getMonth() + 1); 17 | const day = pad2(date.getDate()); 18 | const hour = pad2(date.getHours()); 19 | const minute = pad2(date.getMinutes()); 20 | const second = pad2(date.getSeconds()); 21 | const datestr = `${year}-${mouth}-${day} ${hour}:${minute}:${second}`; 22 | // eslint-disable-next-line no-console 23 | console.log(`${datestr} [${level}] ${msg}`); 24 | } 25 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 26 | export function DEBUG(msg: string) { 27 | // MY_LOG('DEBUG', msg); 28 | } 29 | export function INFO(msg: string) { 30 | MY_LOG('INFO', msg); 31 | } 32 | export function WARNING(msg: string) { 33 | MY_LOG('WARN', msg); 34 | } 35 | export function CONSOLELOG(msg: any) { 36 | // eslint-disable-next-line no-console 37 | console.log(msg); 38 | } 39 | export function timestamp() { 40 | const date = new Date(); 41 | const year = date.getFullYear().toString(); 42 | const mouth = pad2(date.getMonth() + 1); 43 | const day = pad2(date.getDate()); 44 | const hour = pad2(date.getHours()); 45 | const minute = pad2(date.getMinutes()); 46 | const second = pad2(date.getSeconds()); 47 | const milliSeconds = pad3(date.getMilliseconds()); 48 | const datestr = `${year}${mouth}${day}_${hour}${minute}${second}${milliSeconds}`; 49 | return datestr; 50 | } 51 | export function url2filename(url: string) { 52 | // remove url parameters 53 | const url2 = url.replace(/\?.*$/, ''); 54 | const filename = url2.replace(/https?:\/\//, '').replace(/\//g, '_'); 55 | return filename; 56 | } 57 | export function date2str(date: Date): string { 58 | const year = date.getFullYear().toString(); 59 | const mouth = pad2(date.getMonth() + 1); 60 | const day = pad2(date.getDate()); 61 | const hour = pad2(date.getHours()); 62 | const minute = pad2(date.getMinutes()); 63 | const second = pad2(date.getSeconds()); 64 | const milliSeconds = pad3(date.getMilliseconds()); 65 | return `${year}-${mouth}-${day} ${hour}:${minute}:${second}.${milliSeconds}`; 66 | } 67 | 68 | export function textToPrettyJson(text: string | null | undefined): string { 69 | if (text == null || text === undefined) { 70 | return ''; 71 | } 72 | if (!text.startsWith('{')) { 73 | return text; 74 | } 75 | const obj = JSON.parse(text); 76 | return JSON.stringify(obj, null, 2); 77 | } 78 | export function stringmask(v: string, head: number, tail: number): string { 79 | return `${v.slice(0, head)}****${v.slice(-tail)}`; 80 | } 81 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Build config for electron renderer process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 8 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; 11 | import { merge } from 'webpack-merge'; 12 | import TerserPlugin from 'terser-webpack-plugin'; 13 | import baseConfig from './webpack.config.base'; 14 | import webpackPaths from './webpack.paths'; 15 | import checkNodeEnv from '../scripts/check-node-env'; 16 | import deleteSourceMaps from '../scripts/delete-source-maps'; 17 | 18 | checkNodeEnv('production'); 19 | deleteSourceMaps(); 20 | 21 | const configuration: webpack.Configuration = { 22 | devtool: 'source-map', 23 | 24 | mode: 'production', 25 | 26 | target: ['web', 'electron-renderer'], 27 | 28 | entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], 29 | 30 | output: { 31 | path: webpackPaths.distRendererPath, 32 | publicPath: './', 33 | filename: 'renderer.js', 34 | library: { 35 | type: 'umd', 36 | }, 37 | }, 38 | 39 | module: { 40 | rules: [ 41 | { 42 | test: /\.s?(a|c)ss$/, 43 | use: [ 44 | MiniCssExtractPlugin.loader, 45 | { 46 | loader: 'css-loader', 47 | options: { 48 | modules: true, 49 | sourceMap: true, 50 | importLoaders: 1, 51 | }, 52 | }, 53 | 'sass-loader', 54 | ], 55 | include: /\.module\.s?(c|a)ss$/, 56 | }, 57 | { 58 | test: /\.s?(a|c)ss$/, 59 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], 60 | exclude: /\.module\.s?(c|a)ss$/, 61 | }, 62 | // Fonts 63 | { 64 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 65 | type: 'asset/resource', 66 | }, 67 | // Images 68 | { 69 | test: /\.(png|jpg|jpeg|gif)$/i, 70 | type: 'asset/resource', 71 | }, 72 | // SVG 73 | { 74 | test: /\.svg$/, 75 | use: [ 76 | { 77 | loader: '@svgr/webpack', 78 | options: { 79 | prettier: false, 80 | svgo: false, 81 | svgoConfig: { 82 | plugins: [{ removeViewBox: false }], 83 | }, 84 | titleProp: true, 85 | ref: true, 86 | }, 87 | }, 88 | 'file-loader', 89 | ], 90 | }, 91 | ], 92 | }, 93 | 94 | optimization: { 95 | minimize: true, 96 | minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], 97 | }, 98 | 99 | plugins: [ 100 | /** 101 | * Create global constants which can be configured at compile time. 102 | * 103 | * Useful for allowing different behaviour between development builds and 104 | * release builds 105 | * 106 | * NODE_ENV should be production so that modules do not perform certain 107 | * development checks 108 | */ 109 | new webpack.EnvironmentPlugin({ 110 | NODE_ENV: 'production', 111 | DEBUG_PROD: false, 112 | }), 113 | 114 | new MiniCssExtractPlugin({ 115 | filename: 'style.css', 116 | }), 117 | 118 | new BundleAnalyzerPlugin({ 119 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 120 | analyzerPort: 8889, 121 | }), 122 | 123 | new HtmlWebpackPlugin({ 124 | filename: 'index.html', 125 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), 126 | minify: { 127 | collapseWhitespace: true, 128 | removeAttributeQuotes: true, 129 | removeComments: true, 130 | }, 131 | isBrowser: false, 132 | isDevelopment: false, 133 | }), 134 | 135 | new webpack.DefinePlugin({ 136 | 'process.type': '"renderer"', 137 | }), 138 | ], 139 | }; 140 | 141 | export default merge(baseConfig, configuration); 142 | -------------------------------------------------------------------------------- /src/renderer/EventPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | 3 | import { LiveMessage } from '../CustomTypes'; 4 | import { date2str, stringmask } from '../CommonUtil'; 5 | 6 | let setLogsFunc: Function | undefined; 7 | 8 | function EventPanel() { 9 | const [logs, setLogs] = useState([ 10 | { date: '时间', seq: '序号', decoded_type: '类型', content: '内容', user_id: '用户ID' }, 11 | // ... (initial logs here) 12 | ]); 13 | setLogsFunc = setLogs; 14 | const [forwardUrl, setForwardURL] = useState(''); 15 | 16 | const logTableRef = useRef(null); // Ref for the table container 17 | 18 | // Use an effect to scroll the table container to the bottom whenever logs change 19 | useEffect(() => { 20 | if (logTableRef.current) { 21 | logTableRef.current.scrollTop = logTableRef.current.scrollHeight; 22 | } 23 | // Fetch config from main process when component is mounted 24 | const getForwardURL = async () => { 25 | const url = await window.electron.ipcRenderer.getForwardUrl(); 26 | // setFormData(configFromMain); 27 | setForwardURL(url); 28 | }; 29 | getForwardURL(); 30 | }, [logs]); 31 | 32 | return ( 33 |
34 |

转发

35 | 36 | 37 | 38 | 51 | 61 | 62 | 63 |
39 | { 47 | setForwardURL(e.target.value); 48 | }} 49 | /> 50 | 52 | 60 |
64 |

转发日志(最近20条)

65 | {/*
66 | 69 |
*/} 70 |
71 | 72 | 73 | {logs.map((log) => ( 74 | 75 | 78 | 81 | 84 | 87 | 90 | 91 | ))} 92 | 93 |
76 | {log.date} 77 | 79 | {log.seq} 80 | 82 | {log.decoded_type} 83 | 85 | {log.user_id} 86 | 88 | {log.content} 89 |
94 |
95 |
96 | ); 97 | } 98 | 99 | export default EventPanel; 100 | window.electron.ipcRenderer.on('wxlive-event', (arg) => { 101 | // eslint-disable-next-line no-console 102 | console.log('this is a wxlive event'); 103 | // eslint-disable-next-line no-console 104 | console.log(arg); 105 | // cast arg to EventData 106 | const castedEventData = arg as LiveMessage; 107 | const newLog = { 108 | date: date2str(new Date(castedEventData.msg_time)), 109 | decoded_type: castedEventData.decoded_type, 110 | content: castedEventData.content, 111 | seq: castedEventData.seq.toString(), 112 | user_id: stringmask(castedEventData.decoded_openid, 0, 16), 113 | }; 114 | if (setLogsFunc !== undefined) { 115 | setLogsFunc((prevLogs: any) => [...prevLogs, newLog].slice(-20)); 116 | } 117 | }); 118 | -------------------------------------------------------------------------------- /src/main/listener.ts: -------------------------------------------------------------------------------- 1 | // import SpyService from './service'; 2 | import log from 'electron-log'; 3 | import puppeteer, { HTTPResponse } from 'puppeteer'; 4 | import SpyConfig from './config'; 5 | import WXLiveEventHandler from './interface'; 6 | import WXDataDecoder from './WXDataDecoder'; 7 | 8 | class WXLiveEventListener { 9 | private config: SpyConfig; 10 | 11 | private eventHandler: WXLiveEventHandler; 12 | 13 | constructor(config: SpyConfig, handler: WXLiveEventHandler) { 14 | this.config = config; 15 | this.eventHandler = handler; 16 | } 17 | 18 | private static getContentType(response: HTTPResponse): string { 19 | const contentType = response.headers()['content-type']; 20 | if (!contentType) { 21 | return ''; 22 | } 23 | // remove charset 24 | return contentType.replace(/;.*$/, ''); 25 | } 26 | 27 | private static skipContentType(response: HTTPResponse): boolean { 28 | const contentType = this.getContentType(response); 29 | if (contentType === '') { 30 | log.warn(`no content type for url:${response.url()}`); 31 | return true; 32 | } 33 | if (contentType.indexOf('video') >= 0 || contentType.indexOf('image') >= 0 || contentType.indexOf('audio') >= 0) { 34 | return true; 35 | } 36 | const excludeList = [ 37 | 'text/css', 38 | 'application/javascript', 39 | 'application/x-javascript', 40 | 'text/html', 41 | 'text/javascript', 42 | 'application/font-woff', 43 | 'font/ttf', 44 | 'text/plain', 45 | ]; 46 | if (excludeList.indexOf(contentType) >= 0) { 47 | return true; 48 | } 49 | return false; 50 | } 51 | 52 | private static skipURL(response: HTTPResponse): boolean { 53 | const url = response.url(); 54 | if (!response.url().includes('mmfinderassistant-bin/live/msg')) { 55 | // TODO 目前的实现,只处理 live/msg 开头的请求,其他请求不处理。 56 | // 后续的if分支并没有实际意义。 57 | return true; 58 | } 59 | if (url.includes('mmfinderassistant-bin/helper/hepler_merlin_mmdata')) { 60 | return true; 61 | } 62 | if (url.includes('mmfinderassistant-bin/live/finder_live_get_promote_info_list')) { 63 | return true; 64 | } 65 | if (url.includes('mmfinderassistant-bin/live/get_live_info')) { 66 | // TODO 参考 sample/live_get_live_info_20230920_124943663.log 67 | // 这里面有直播间的收入信息,可能需要上报。 68 | return true; 69 | } 70 | return false; 71 | } 72 | 73 | private static skip(response: HTTPResponse): boolean { 74 | return this.skipContentType(response) || this.skipURL(response); 75 | } 76 | 77 | private async handleResponse(response: HTTPResponse) { 78 | if (WXLiveEventListener.skip(response)) { 79 | return; 80 | } 81 | // log.debug(`handle response: ${response.url()}`); 82 | // log.debug(`forward url: ${this.config.getProp('forward_url')}`); 83 | const responseData = await response.json(); 84 | const requestHeaders = response.request().headers(); 85 | const requestText = response.request().postData(); 86 | if (requestText === undefined) { 87 | log.warn('request text is undefined'); 88 | return; 89 | } 90 | const requestData = JSON.parse(requestText); 91 | const decodedData = WXDataDecoder.decodeDataFromResponse(requestHeaders, requestData, responseData); 92 | this.eventHandler.onStatusUpdate(decodedData.live_info); 93 | if (decodedData.events.length > 0) { 94 | this.eventHandler.onEvents(decodedData); 95 | } 96 | } 97 | 98 | public async start() { 99 | log.info(`start listener on ${this.config.getProp('spy_url')}`); 100 | 101 | const windowSize = '--window-size=1024,1024'; 102 | 103 | const options = { 104 | defaultViewport: null, 105 | headless: false, 106 | args: ['--disable-setuid-sandbox', windowSize, '--hide-crash-restore-bubble', '--disable-gpu'], 107 | executablePath: this.config.getProp('chrome_path'), 108 | ignoreHTTPSErrors: true, 109 | userDataDir: this.config.getProp('chrome_userdata_path'), 110 | }; 111 | 112 | const browser = await puppeteer.launch(options); 113 | const page = await browser.newPage(); 114 | browser.on('disconnected', () => { 115 | log.info('disconnected'); 116 | }); 117 | await page.setRequestInterception(true); 118 | page.on('request', (request) => { 119 | request.continue(); 120 | }); 121 | 122 | page.on('response', (response) => { 123 | this.handleResponse(response); 124 | }); 125 | await page.goto(this.config.getProp('spy_url'), { waitUntil: 'networkidle2' }); 126 | } 127 | } 128 | 129 | export default WXLiveEventListener; 130 | -------------------------------------------------------------------------------- /src/renderer/StatusPanel.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/label-has-associated-control */ 2 | import React, { useState } from 'react'; 3 | 4 | import log from 'electron-log'; 5 | // import { shell } from 'electron'; 6 | import { LiveInfo } from '../CustomTypes'; 7 | import { date2str, stringmask } from '../CommonUtil'; 8 | 9 | interface FormData { 10 | hostID: string; 11 | roomID: string; 12 | startTime: string; 13 | onlineNumber: number; 14 | likeCount: number; 15 | rewardAmount: number; 16 | } 17 | const initialFormData: FormData = { 18 | hostID: '', 19 | roomID: '', 20 | startTime: '', 21 | onlineNumber: 0, 22 | likeCount: 0, 23 | rewardAmount: 0, 24 | }; 25 | let setDisplay: Function; 26 | let formData: FormData; 27 | let setFormData: Function; 28 | let display: string; 29 | let liveStatusUrl: string; 30 | 31 | function StatusPanel() { 32 | [display, setDisplay] = useState('none'); 33 | 34 | [formData, setFormData] = useState(initialFormData); 35 | // window.electron.ipcRenderer.once('wxlive-status', (arg) => { 36 | // // eslint-disable-next-line no-console 37 | // console.log('2this is a wxlive event2'); 38 | // // eslint-disable-next-line no-console 39 | // console.log(arg); 40 | // }); 41 | 42 | const getLiveStatusURL = async () => { 43 | const httpServerPort = await window.electron.ipcRenderer.getConfig('http_server_port'); 44 | liveStatusUrl = `http://localhost:${httpServerPort}/getLiveStatus`; 45 | log.info(liveStatusUrl); 46 | }; 47 | getLiveStatusURL(); 48 | 49 | const openLink = (event: any) => { 50 | event.preventDefault(); 51 | const target = event.target as HTMLAnchorElement; 52 | log.info(`clicked ${target.href}`); 53 | window.electron.ipcRenderer.openExternalLink(target.href); 54 | }; 55 | 56 | return ( 57 |
58 |

监听

59 | 69 |
70 |

直播间信息

71 | 72 | 73 | 74 | 75 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
状态数据API: 76 | 77 | {liveStatusUrl} 78 | 79 |
主播ID:{formData.hostID}
直播间ID:{formData.roomID}
开播时间:{formData.startTime}
在线人数:{formData.onlineNumber}
点赞数:{formData.likeCount}
微信币:{formData.rewardAmount}
107 |
108 |
109 | ); 110 | } 111 | 112 | export default StatusPanel; 113 | 114 | window.electron.ipcRenderer.on('wxlive-status', (arg) => { 115 | // eslint-disable-next-line no-console 116 | console.log('this is a wxlive status'); 117 | // eslint-disable-next-line no-console 118 | console.log(arg); 119 | // cast arg to StatusData 120 | const castedStatusData = arg as LiveInfo; 121 | if (formData !== undefined) { 122 | formData.hostID = stringmask(castedStatusData.wechat_uin, 3, 3); 123 | formData.roomID = stringmask(castedStatusData.live_id, 3, 3); 124 | formData.startTime = date2str(new Date(castedStatusData.start_time * 1000)); 125 | formData.onlineNumber = castedStatusData.online_count; 126 | formData.likeCount = castedStatusData.like_count; 127 | formData.rewardAmount = castedStatusData.reward_total_amount_in_wecoin; 128 | // eslint-disable-next-line no-console 129 | console.log(formData); 130 | setFormData({ 131 | ...formData, 132 | }); 133 | setDisplay('block'); 134 | // eslint-disable-next-line no-console 135 | console.log('after setFormData'); 136 | } 137 | }); 138 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint global-require: off, no-console: off, promise/always-return: off */ 2 | 3 | /** 4 | * This module executes inside of electron's main process. You can start 5 | * electron renderer process from here and communicate with the other processes 6 | * through IPC. 7 | * 8 | * When running `npm run build` or `npm run build:main`, this file is compiled to 9 | * `./src/main.js` using webpack. This gives us some performance wins. 10 | */ 11 | import path from 'path'; 12 | import { app, BrowserWindow, shell } from 'electron'; 13 | import { autoUpdater } from 'electron-updater'; 14 | import * as Sentry from '@sentry/electron/main'; 15 | import log from 'electron-log'; 16 | import MenuBuilder from './menu'; 17 | import { resolveHtmlPath } from './util'; 18 | import SpyService from './service'; 19 | 20 | log.transports.file.resolvePathFn = () => path.join(app.getPath('userData'), 'logs/main.log'); 21 | log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{processType}] {text}'; 22 | log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{processType}] {text}'; 23 | log.initialize({ preload: true }); 24 | Sentry.init({ 25 | dsn: 'https://fcbeb5b02f692b5f81995d7f07dbd4d5@o4506054030721024.ingest.sentry.io/4506054034784256', 26 | }); 27 | class AppUpdater { 28 | constructor() { 29 | log.transports.file.level = 'info'; 30 | autoUpdater.logger = log; 31 | autoUpdater.checkForUpdatesAndNotify(); 32 | } 33 | } 34 | 35 | let mainWindow: BrowserWindow | null = null; 36 | let spyService: SpyService | null = null; 37 | 38 | if (process.env.NODE_ENV === 'production') { 39 | const sourceMapSupport = require('source-map-support'); 40 | sourceMapSupport.install(); 41 | } 42 | 43 | const isDebug = process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; 44 | 45 | if (isDebug) { 46 | require('electron-debug')(); 47 | } 48 | 49 | const installExtensions = async () => { 50 | const installer = require('electron-devtools-installer'); 51 | const forceDownload = !!process.env.UPGRADE_EXTENSIONS; 52 | const extensions = ['REACT_DEVELOPER_TOOLS']; 53 | 54 | return installer 55 | .default( 56 | extensions.map((name) => installer[name]), 57 | forceDownload, 58 | ) 59 | .catch(console.log); 60 | }; 61 | 62 | const createWindow = async () => { 63 | if (isDebug) { 64 | await installExtensions(); 65 | } 66 | 67 | const RESOURCES_PATH = app.isPackaged 68 | ? path.join(process.resourcesPath, 'assets') 69 | : path.join(__dirname, '../../assets'); 70 | 71 | const getAssetPath = (...paths: string[]): string => { 72 | return path.join(RESOURCES_PATH, ...paths); 73 | }; 74 | 75 | mainWindow = new BrowserWindow({ 76 | show: false, 77 | width: 1024, 78 | height: 728, 79 | icon: getAssetPath('ant.png'), 80 | webPreferences: { 81 | preload: app.isPackaged ? path.join(__dirname, 'preload.js') : path.join(__dirname, '../../.erb/dll/preload.js'), 82 | }, 83 | }); 84 | const userDataPath = app.getPath('userData'); 85 | const configFilePath = path.join(userDataPath, 'config.json'); 86 | log.debug(`config file path: ${configFilePath}`); 87 | spyService = new SpyService(configFilePath, mainWindow); 88 | spyService.setChromePath(getAssetPath('puppeteer_chrome', 'chrome.exe')); 89 | spyService.start(); 90 | 91 | mainWindow.loadURL(resolveHtmlPath('index.html')); 92 | 93 | mainWindow.on('ready-to-show', () => { 94 | if (!mainWindow) { 95 | throw new Error('"mainWindow" is not defined'); 96 | } 97 | if (process.env.START_MINIMIZED) { 98 | mainWindow.minimize(); 99 | } else { 100 | mainWindow.show(); 101 | } 102 | }); 103 | 104 | mainWindow.on('closed', () => { 105 | log.info('main window closed'); 106 | spyService?.stop(); 107 | spyService = null; 108 | mainWindow = null; 109 | }); 110 | 111 | if (isDebug) { 112 | const menuBuilder = new MenuBuilder(mainWindow); 113 | menuBuilder.buildMenu(); 114 | } else { 115 | mainWindow.setMenu(null); 116 | } 117 | 118 | // Open urls in the user's browser 119 | mainWindow.webContents.setWindowOpenHandler((edata) => { 120 | shell.openExternal(edata.url); 121 | return { action: 'deny' }; 122 | }); 123 | 124 | // Remove this if your app does not use auto updates 125 | // eslint-disable-next-line 126 | new AppUpdater(); 127 | }; 128 | 129 | /** 130 | * Add event listeners... 131 | */ 132 | 133 | app.on('window-all-closed', () => { 134 | // Respect the OSX convention of having the application in memory even 135 | // after all windows have been closed 136 | log.info('all windows closed'); 137 | if (process.platform !== 'darwin') { 138 | app.quit(); 139 | log.info('app quit'); 140 | } 141 | }); 142 | app.on('before-quit', () => { 143 | console.log('App is about to quit'); 144 | }); 145 | 146 | app.on('will-quit', () => { 147 | console.log('App will quit now'); 148 | }); 149 | 150 | app 151 | .whenReady() 152 | .then(() => { 153 | createWindow(); 154 | app.on('activate', () => { 155 | // On macOS it's common to re-create a window in the app when the 156 | // dock icon is clicked and there are no other windows open. 157 | if (mainWindow === null) createWindow(); 158 | }); 159 | }) 160 | .catch(console.log); 161 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.ts: -------------------------------------------------------------------------------- 1 | import 'webpack-dev-server'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import webpack from 'webpack'; 5 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 6 | import chalk from 'chalk'; 7 | import { merge } from 'webpack-merge'; 8 | import { execSync, spawn } from 'child_process'; 9 | import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; 10 | import baseConfig from './webpack.config.base'; 11 | import webpackPaths from './webpack.paths'; 12 | import checkNodeEnv from '../scripts/check-node-env'; 13 | 14 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 15 | // at the dev webpack config is not accidentally run in a production environment 16 | if (process.env.NODE_ENV === 'production') { 17 | checkNodeEnv('development'); 18 | } 19 | 20 | const port = process.env.PORT || 1212; 21 | const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json'); 22 | const skipDLLs = 23 | module.parent?.filename.includes('webpack.config.renderer.dev.dll') || 24 | module.parent?.filename.includes('webpack.config.eslint'); 25 | 26 | /** 27 | * Warn if the DLL is not built 28 | */ 29 | if ( 30 | !skipDLLs && 31 | !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest)) 32 | ) { 33 | console.log( 34 | chalk.black.bgYellow.bold( 35 | 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', 36 | ), 37 | ); 38 | execSync('npm run postinstall'); 39 | } 40 | 41 | const configuration: webpack.Configuration = { 42 | devtool: 'inline-source-map', 43 | 44 | mode: 'development', 45 | 46 | target: ['web', 'electron-renderer'], 47 | 48 | entry: [ 49 | `webpack-dev-server/client?http://localhost:${port}/dist`, 50 | 'webpack/hot/only-dev-server', 51 | path.join(webpackPaths.srcRendererPath, 'index.tsx'), 52 | ], 53 | 54 | output: { 55 | path: webpackPaths.distRendererPath, 56 | publicPath: '/', 57 | filename: 'renderer.dev.js', 58 | library: { 59 | type: 'umd', 60 | }, 61 | }, 62 | 63 | module: { 64 | rules: [ 65 | { 66 | test: /\.s?(c|a)ss$/, 67 | use: [ 68 | 'style-loader', 69 | { 70 | loader: 'css-loader', 71 | options: { 72 | modules: true, 73 | sourceMap: true, 74 | importLoaders: 1, 75 | }, 76 | }, 77 | 'sass-loader', 78 | ], 79 | include: /\.module\.s?(c|a)ss$/, 80 | }, 81 | { 82 | test: /\.s?css$/, 83 | use: ['style-loader', 'css-loader', 'sass-loader'], 84 | exclude: /\.module\.s?(c|a)ss$/, 85 | }, 86 | // Fonts 87 | { 88 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 89 | type: 'asset/resource', 90 | }, 91 | // Images 92 | { 93 | test: /\.(png|jpg|jpeg|gif)$/i, 94 | type: 'asset/resource', 95 | }, 96 | // SVG 97 | { 98 | test: /\.svg$/, 99 | use: [ 100 | { 101 | loader: '@svgr/webpack', 102 | options: { 103 | prettier: false, 104 | svgo: false, 105 | svgoConfig: { 106 | plugins: [{ removeViewBox: false }], 107 | }, 108 | titleProp: true, 109 | ref: true, 110 | }, 111 | }, 112 | 'file-loader', 113 | ], 114 | }, 115 | ], 116 | }, 117 | plugins: [ 118 | ...(skipDLLs 119 | ? [] 120 | : [ 121 | new webpack.DllReferencePlugin({ 122 | context: webpackPaths.dllPath, 123 | manifest: require(manifest), 124 | sourceType: 'var', 125 | }), 126 | ]), 127 | 128 | new webpack.NoEmitOnErrorsPlugin(), 129 | 130 | /** 131 | * Create global constants which can be configured at compile time. 132 | * 133 | * Useful for allowing different behaviour between development builds and 134 | * release builds 135 | * 136 | * NODE_ENV should be production so that modules do not perform certain 137 | * development checks 138 | * 139 | * By default, use 'development' as NODE_ENV. This can be overriden with 140 | * 'staging', for example, by changing the ENV variables in the npm scripts 141 | */ 142 | new webpack.EnvironmentPlugin({ 143 | NODE_ENV: 'development', 144 | }), 145 | 146 | new webpack.LoaderOptionsPlugin({ 147 | debug: true, 148 | }), 149 | 150 | new ReactRefreshWebpackPlugin(), 151 | 152 | new HtmlWebpackPlugin({ 153 | filename: path.join('index.html'), 154 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), 155 | minify: { 156 | collapseWhitespace: true, 157 | removeAttributeQuotes: true, 158 | removeComments: true, 159 | }, 160 | isBrowser: false, 161 | env: process.env.NODE_ENV, 162 | isDevelopment: process.env.NODE_ENV !== 'production', 163 | nodeModules: webpackPaths.appNodeModulesPath, 164 | }), 165 | ], 166 | 167 | node: { 168 | __dirname: false, 169 | __filename: false, 170 | }, 171 | 172 | devServer: { 173 | port, 174 | compress: true, 175 | hot: true, 176 | headers: { 'Access-Control-Allow-Origin': '*' }, 177 | static: { 178 | publicPath: '/', 179 | }, 180 | historyApiFallback: { 181 | verbose: true, 182 | }, 183 | setupMiddlewares(middlewares) { 184 | console.log('Starting preload.js builder...'); 185 | const preloadProcess = spawn('npm', ['run', 'start:preload'], { 186 | shell: true, 187 | stdio: 'inherit', 188 | }) 189 | .on('close', (code: number) => process.exit(code!)) 190 | .on('error', (spawnError) => console.error(spawnError)); 191 | 192 | console.log('Starting Main Process...'); 193 | let args = ['run', 'start:main']; 194 | if (process.env.MAIN_ARGS) { 195 | args = args.concat( 196 | ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(), 197 | ); 198 | } 199 | spawn('npm', args, { 200 | shell: true, 201 | stdio: 'inherit', 202 | }) 203 | .on('close', (code: number) => { 204 | preloadProcess.kill(); 205 | process.exit(code!); 206 | }) 207 | .on('error', (spawnError) => console.error(spawnError)); 208 | return middlewares; 209 | }, 210 | }, 211 | }; 212 | 213 | export default merge(baseConfig, configuration); 214 | -------------------------------------------------------------------------------- /src/main/app.ts: -------------------------------------------------------------------------------- 1 | // /* eslint-disable */ 2 | // /* eslint-disable no-restricted-syntax */ 3 | // /* eslint-disable guard-for-in */ 4 | // import axios from 'axios'; 5 | // import fs from 'fs'; 6 | // import puppeteer, { HTTPResponse } from 'puppeteer'; 7 | // import zlib from 'zlib'; 8 | // import { LiveMessage, HostInfo, LiveInfo, DecodedData } from '../CustomTypes'; 9 | 10 | // import { 11 | // DEBUG, 12 | // INFO, 13 | // WARNING, 14 | // CONSOLELOG, 15 | // pad3, 16 | // textToPrettyJson, 17 | // timestamp, 18 | // url2filename, 19 | // } from './util'; 20 | 21 | // async function saveResponseToLog(response: HTTPResponse, filename: string) { 22 | // try { 23 | // let txtContent = `${response.request().method()} ${response.url()}\n`; 24 | // for (const key in response.request().headers()) { 25 | // txtContent += `${key}: ${response.request().headers()[key]}\n`; 26 | // } 27 | // txtContent += '\n\n'; 28 | // txtContent += textToPrettyJson(response.request().postData()); 29 | // txtContent += '\n\n'; 30 | // txtContent += `${response.status()} ${response.statusText()}\n`; 31 | // for (const key in response.headers()) { 32 | // txtContent += `${key}: ${response.headers()[key]}\n`; 33 | // } 34 | // txtContent += '\n\n'; 35 | // if (response.url().includes('mmfinderassistant-bin/live/msg')) { 36 | // const jsonData = await response.json(); 37 | // if (jsonData.data.appMsgList.length > 0) { 38 | // for (const idx in jsonData.data.appMsgList) { 39 | // const o = jsonData.data.appMsgList[idx]; 40 | // const decodedPayload = Buffer.from(o.payload, 'base64').toString(); 41 | // const giftPayload = JSON.parse(decodedPayload); 42 | // jsonData.data.appMsgList[idx].payload = giftPayload; 43 | // } 44 | // } 45 | // txtContent += JSON.stringify(jsonData, null, 2); 46 | // } else { 47 | // txtContent += textToPrettyJson(await response.text()); 48 | // } 49 | // fs.writeFile(filename, txtContent, function cb(err: any) { 50 | // if (err) { 51 | // return CONSOLELOG(err); 52 | // } 53 | // return true; 54 | // }); 55 | // } catch (error) { 56 | // WARNING('error when get response data'); 57 | // WARNING(response.url()); 58 | // WARNING(response.headers()['content-type']); 59 | // CONSOLELOG(error); 60 | // } 61 | // } 62 | 63 | // function saveData(o: any, m: LiveMessage, logPath: string) { 64 | // const seq = pad3(m.seq); 65 | // const tt = new Date().getTime(); 66 | // const filename = `${logPath}/${seq}_${m.decoded_type}_${m.msg_sub_type}_${m.content}_${tt}.json`; 67 | // fs.writeFile(filename, JSON.stringify(o, null, 2), (err) => { 68 | // if (err) { 69 | // WARNING(`failed to save data${filename}`); 70 | // CONSOLELOG(err); 71 | // } 72 | // }); 73 | // } 74 | 75 | 76 | // /** 77 | // *@returns true if there is unknown event in decoded_data.events 78 | // */ 79 | // async function forwardResponse( 80 | // response: HTTPResponse, 81 | // config: Config, 82 | // liveInfoCallback: (arg0: LiveInfo) => void, 83 | // liveMessageCallback: (arg0: LiveMessage) => void, 84 | // ): Promise { 85 | // const forwardURL = config.forward_url; 86 | // const zipPostData = config.gzip_forward_data; 87 | // const contentType = getContentType(response); 88 | // if (contentType !== 'application/json') { 89 | // WARNING(`content type is not application/json: ${response.url()}`); 90 | // return false; 91 | // } 92 | // try { 93 | // if (!response.url().includes('mmfinderassistant-bin/live/msg')) { 94 | // if (config.forward_all_json_data) { 95 | // const data = { 96 | // original_url: response.url(), 97 | // original_body: await response.json(), 98 | // }; 99 | // PostData(forwardURL, data, zipPostData) 100 | // .then((rr) => { 101 | // return INFO(`server response: ${JSON.stringify(rr)}`); 102 | // }) 103 | // .catch((error) => { 104 | // WARNING('failed to forward message'); 105 | // CONSOLELOG(error); 106 | // }); 107 | // } 108 | // return false; 109 | // } 110 | // const responseData = await response.json(); 111 | // const requestHeaders = response.request().headers(); 112 | // const requestText = response.request().postData(); 113 | // if (requestText === undefined) { 114 | // WARNING('request text is undefined'); 115 | // return false; 116 | // } 117 | // const requestData = JSON.parse(requestText); 118 | 119 | // const hostInfo = {} as HostInfo; 120 | // hostInfo.wechat_uin = requestHeaders['x-wechat-uin']; 121 | // hostInfo.finder_username = requestData.finderUsername; 122 | 123 | // const decodedData = decodeDataFromResponse(responseData, hostInfo, config); 124 | 125 | // // use callback to update UI 126 | // liveInfoCallback(decodedData.live_info); 127 | 128 | // if (!Array.isArray(decodedData.events) || decodedData.events.length === 0) { 129 | // if (!config.forward_all_json_data) { 130 | // return false; 131 | // } 132 | // } 133 | 134 | // for (const idx in decodedData.events) { 135 | // const msg = decodedData.events[idx]; 136 | // liveMessageCallback(msg); 137 | // } 138 | 139 | // decodedData.host_info = hostInfo; 140 | // DEBUG(`forward msg to ${forwardURL}with gzip:${zipPostData}`); 141 | // const data = { 142 | // decoded_data: decodedData, 143 | // original_url: response.url(), 144 | // original_body: responseData, 145 | // }; 146 | // PostData(forwardURL, data, zipPostData) 147 | // .then((rr) => { 148 | // return INFO(`server response: ${JSON.stringify(rr)}`); 149 | // }) 150 | // .catch((error) => { 151 | // WARNING('failed to forward message'); 152 | // CONSOLELOG(error); 153 | // }); 154 | 155 | // // check if there is unknown event in decoded_data.events 156 | // for (const idx in decodedData.events) { 157 | // const msg = decodedData.events[idx]; 158 | // if (msg.decoded_type === 'unknown') { 159 | // return true; 160 | // } 161 | // } 162 | // } catch (error) { 163 | // WARNING('failed to forward message'); 164 | // CONSOLELOG(error); 165 | // } 166 | // return false; 167 | // } 168 | // async function handleResponse( 169 | // response: HTTPResponse, 170 | // config: Config, 171 | // liveInfoCallback: (arg0: LiveInfo) => void, 172 | // liveMessageCallback: (arg0: LiveMessage) => void, 173 | // ) { 174 | // const url = response.url(); 175 | // const logPath = config.log_path; 176 | // // const forwardUrl = config.forward_url; 177 | // if (skip(response)) { 178 | // DEBUG(`skip url: ${url}`); 179 | // return; 180 | // } 181 | // DEBUG(`url:${url}`); 182 | // let filename = `${logPath}/${url2filename(url)}_${timestamp()}.log`; 183 | // const prefix = 'mmfinderassistant-bin/'; 184 | // const idx = url.indexOf(prefix); 185 | // if (idx >= 0) { 186 | // filename = `${logPath}/${url2filename( 187 | // url.substring(idx + prefix.length), 188 | // )}_${timestamp()}.log`; 189 | // } 190 | // await saveResponseToLog(response, filename); 191 | // const hasUnknownType = await forwardResponse( 192 | // response, 193 | // config, 194 | // liveInfoCallback, 195 | // liveMessageCallback, 196 | // ); 197 | // if (hasUnknownType) { 198 | // // 单独列出来需要后续分析。 199 | // filename = `${logPath}/unknown_${timestamp()}.log`; 200 | // await saveResponseToLog(response, filename); 201 | // } 202 | // } 203 | -------------------------------------------------------------------------------- /src/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "ForwardData", 5 | "properties": { 6 | "original_url": { 7 | "type": "string", 8 | "description": "The original url of the request" 9 | }, 10 | "original_body": { 11 | "type": "string", 12 | "description": "The original body of the response" 13 | }, 14 | "decoded_data": { 15 | "type": "object", 16 | "description": "从 original_body 中解析出来的业务数据", 17 | "properties": { 18 | "host_info": { 19 | "$ref": "#/definitions/HostInfo", 20 | "description": "主播信息" 21 | }, 22 | "live_info": { 23 | "$ref": "#/definitions/LiveInfo", 24 | "description": "直播间信息" 25 | }, 26 | "events": { 27 | "type": "array", 28 | "description": "直播间消息", 29 | "items": { 30 | "$ref": "#/definitions/LiveMessage" 31 | } 32 | } 33 | }, 34 | "required": [ 35 | "host_info", 36 | "live_info", 37 | "events" 38 | ] 39 | } 40 | }, 41 | "required": [ 42 | "original_url", 43 | "original_body", 44 | "decoded_data" 45 | ], 46 | "definitions": { 47 | "HostInfo": { 48 | "type": "object", 49 | "properties": { 50 | "wechat_uin": { 51 | "type": "string", 52 | "description": "从原始请求的 header中获取,可能是主播的微信号(用这个ID作为直播间的唯一标识)", 53 | "default": "" 54 | }, 55 | "finder_username": { 56 | "type": "string", 57 | "description": "从原始请求的 body 中获取", 58 | "default": "" 59 | } 60 | }, 61 | "required": [ 62 | "wechat_uin", 63 | "finder_username" 64 | ] 65 | }, 66 | "LiveInfo": { 67 | "type": "object", 68 | "properties": { 69 | "wechat_uin": { 70 | "type": "string", 71 | "description": "从原始请求的 header中获取,可能是主播的微信号(用这个ID作为直播间的唯一标识)", 72 | "default": "" 73 | }, 74 | "live_status": { 75 | "type": "number", 76 | "description": "直播间状态,1 表示直播中,2 表示直播结束", 77 | "default": "" 78 | }, 79 | "live_id": { 80 | "type": "string", 81 | "description": "直播间ID", 82 | "default": "" 83 | }, 84 | "online_count": { 85 | "type": "number", 86 | "description": "直播间在线人数", 87 | "default": 0 88 | }, 89 | "start_time": { 90 | "type": "number", 91 | "description": "直播间开始时间,unix时间戳", 92 | "default": 0 93 | }, 94 | "like_count": { 95 | "type": "number", 96 | "description": "直播间点赞数", 97 | "default": 0 98 | }, 99 | "reward_total_amount_in_wecoin": { 100 | "type": "number", 101 | "description": "直播间打赏总金额,单位为微信币", 102 | "default": 0 103 | }, 104 | "nickname": { 105 | "type": "string", 106 | "description": "主播昵称", 107 | "default": "" 108 | }, 109 | "head_url": { 110 | "type": "string", 111 | "description": "主播头像", 112 | "default": "" 113 | } 114 | }, 115 | "required": [ 116 | "wechat_uin", 117 | "live_id", 118 | "live_status", 119 | "online_count", 120 | "start_time", 121 | "like_count", 122 | "reward_total_amount_in_wecoin", 123 | "nickname", 124 | "head_url" 125 | ] 126 | }, 127 | "LiveMessage": { 128 | "type": "object", 129 | "properties": { 130 | "msg_time": { 131 | "type": "number", 132 | "description": "收到消息的unix时间戳", 133 | "default": 0 134 | }, 135 | "decoded_type": { 136 | "type": "string", 137 | "description": "解析出来的消息类型: comment, enter, gift, like, enter, levelup, unknown", 138 | "default": "" 139 | }, 140 | "msg_id": { 141 | "type": "string", 142 | "default": "" 143 | }, 144 | "sec_openid": { 145 | "type": "string", 146 | "description": "经过加密的用户的微信openid,同一个用户在同一个主播的不同直播场次会变化", 147 | "default": "" 148 | }, 149 | "decoded_openid": { 150 | "type": "string", 151 | "description": "解密后的用户的微信openid,同一个用户在同一个主播的不同直播场次不会变化", 152 | "default": "" 153 | }, 154 | "nickname": { 155 | "type": "string", 156 | "default": "" 157 | }, 158 | "seq": { 159 | "type": "number", 160 | "description": "事件在直播间发生的消息序号,从1开始,递增。可能会重复发送,服务器收到之后要自己去重。", 161 | "default": 0 162 | }, 163 | "content": { 164 | "type": "string", 165 | "default": "xxxx" 166 | }, 167 | "msg_sub_type": { 168 | "type": "string", 169 | "description": "从原始请求的 body 中获取, data.msgList[].type 或者 data.appMsgList[].msgType", 170 | "default": "" 171 | }, 172 | "sec_gift_id": { 173 | "type": "string", 174 | "description": "可选, decoded_type 是 gift 或 combo_gift 时会有", 175 | "default": "" 176 | }, 177 | "gift_num": { 178 | "type": "number", 179 | "description": "可选, decoded_type 是 gift 时会有", 180 | "default": 0 181 | }, 182 | "gift_value": { 183 | "type": "number", 184 | "description": "可选, decoded_type 是 gift 时会有,单位为微信币。是本次送礼物的总价值,不是单价。", 185 | "default": 0 186 | }, 187 | "combo_product_count": { 188 | "type": "number", 189 | "description": "可选, decoded_type 是 combo_gift 时会有", 190 | "default": 0 191 | }, 192 | "from_level": { 193 | "type": "number", 194 | "description": "可选, decoded_type 是 levelup 时会有", 195 | "default": 0 196 | }, 197 | "to_level": { 198 | "type": "number", 199 | "description": "可选, decoded_type 是 levelup 时会有", 200 | "default": 0 201 | }, 202 | "original_data": { 203 | "type": "object", 204 | "description": "可选,类型是 unknown 时会有,原始的消息内容", 205 | "default": {} 206 | } 207 | }, 208 | "required": [ 209 | "msg_time", 210 | "decoded_type", 211 | "decoded_openid", 212 | "msg_id", 213 | "sec_openid", 214 | "nickname", 215 | "seq", 216 | "content", 217 | "msg_sub_type" 218 | ] 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wxlive-spy", 3 | "description": "微信视频号直播间弹幕信息抓取工具", 4 | "keywords": [ 5 | "electron", 6 | "react", 7 | "puppeteer", 8 | "typescript", 9 | "wechat", 10 | "微信", 11 | "视频号", 12 | "直播", 13 | "弹幕游戏" 14 | ], 15 | "homepage": "https://github.com/fire4nt/wxlivespy#readme", 16 | "bugs": { 17 | "url": "https://github.com/fire4nt/wxlivespy/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/fire4nt/wxlivespy.git" 22 | }, 23 | "license": "MIT", 24 | "author": { 25 | "name": "fire4nt", 26 | "email": "fire4nt.soft@gmail.com", 27 | "url": "https://github.com/fire4nt" 28 | }, 29 | "main": "./src/main/main.ts", 30 | "scripts": { 31 | "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", 32 | "build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", 33 | "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", 34 | "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", 35 | "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", 36 | "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", 37 | "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", 38 | "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", 39 | "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", 40 | "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .", 41 | "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", 42 | "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", 43 | "test": "jest" 44 | }, 45 | "browserslist": [], 46 | "prettier": { 47 | "singleQuote": true, 48 | "overrides": [ 49 | { 50 | "files": [ 51 | ".prettierrc", 52 | ".eslintrc" 53 | ], 54 | "options": { 55 | "parser": "json" 56 | } 57 | } 58 | ] 59 | }, 60 | "jest": { 61 | "moduleDirectories": [ 62 | "node_modules", 63 | "release/app/node_modules", 64 | "src" 65 | ], 66 | "moduleFileExtensions": [ 67 | "js", 68 | "jsx", 69 | "ts", 70 | "tsx", 71 | "json" 72 | ], 73 | "moduleNameMapper": { 74 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js", 75 | "\\.(css|less|sass|scss)$": "identity-obj-proxy" 76 | }, 77 | "setupFiles": [ 78 | "./.erb/scripts/check-build-exists.ts" 79 | ], 80 | "testEnvironment": "jsdom", 81 | "testEnvironmentOptions": { 82 | "url": "http://localhost/" 83 | }, 84 | "testPathIgnorePatterns": [ 85 | "release/app/dist", 86 | ".erb/dll" 87 | ], 88 | "transform": { 89 | "\\.(ts|tsx|js|jsx)$": "ts-jest" 90 | } 91 | }, 92 | "dependencies": { 93 | "@sentry/electron": "^4.13.0", 94 | "axios": "^1.5.1", 95 | "electron-debug": "^3.2.0", 96 | "electron-log": "^5.0.0-rc.1", 97 | "electron-updater": "^6.1.4", 98 | "puppeteer": "^21.3.6", 99 | "react": "^18.2.0", 100 | "react-dom": "^18.2.0", 101 | "react-router-dom": "^6.16.0" 102 | }, 103 | "devDependencies": { 104 | "@electron/notarize": "^2.1.0", 105 | "@electron/rebuild": "^3.3.0", 106 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", 107 | "@sentry/cli": "^2.21.2", 108 | "@sentry/webpack-plugin": "^2.8.0", 109 | "@svgr/webpack": "^8.1.0", 110 | "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", 111 | "@testing-library/jest-dom": "^6.1.3", 112 | "@testing-library/react": "^14.0.0", 113 | "@types/jest": "^29.5.5", 114 | "@types/node": "20.6.2", 115 | "@types/react": "^18.2.21", 116 | "@types/react-dom": "^18.2.7", 117 | "@types/react-test-renderer": "^18.0.1", 118 | "@types/terser-webpack-plugin": "^5.0.4", 119 | "@types/webpack-bundle-analyzer": "^4.6.0", 120 | "@typescript-eslint/eslint-plugin": "^6.7.0", 121 | "@typescript-eslint/parser": "^6.7.0", 122 | "browserslist-config-erb": "^0.0.3", 123 | "chalk": "^4.1.2", 124 | "concurrently": "^8.2.1", 125 | "core-js": "^3.32.2", 126 | "cross-env": "^7.0.3", 127 | "css-loader": "^6.8.1", 128 | "css-minimizer-webpack-plugin": "^5.0.1", 129 | "detect-port": "^1.5.1", 130 | "electron": "^26.2.3", 131 | "electron-builder": "^24.6.4", 132 | "electron-devtools-installer": "^3.2.0", 133 | "electronmon": "^2.0.2", 134 | "eslint": "^8.49.0", 135 | "eslint-config-airbnb-base": "^15.0.0", 136 | "eslint-config-erb": "^4.1.0-0", 137 | "eslint-import-resolver-typescript": "^3.6.0", 138 | "eslint-import-resolver-webpack": "^0.13.7", 139 | "eslint-plugin-compat": "^4.2.0", 140 | "eslint-plugin-import": "^2.28.1", 141 | "eslint-plugin-jest": "^27.4.0", 142 | "eslint-plugin-jsx-a11y": "^6.7.1", 143 | "eslint-plugin-promise": "^6.1.1", 144 | "eslint-plugin-react": "^7.33.2", 145 | "eslint-plugin-react-hooks": "^4.6.0", 146 | "file-loader": "^6.2.0", 147 | "html-webpack-plugin": "^5.5.3", 148 | "identity-obj-proxy": "^3.0.0", 149 | "jest": "^29.7.0", 150 | "jest-environment-jsdom": "^29.7.0", 151 | "mini-css-extract-plugin": "^2.7.6", 152 | "prettier": "^3.0.3", 153 | "quicktype": "^23.0.76", 154 | "react-refresh": "^0.14.0", 155 | "react-test-renderer": "^18.2.0", 156 | "rimraf": "^5.0.1", 157 | "sass": "^1.67.0", 158 | "sass-loader": "^13.3.2", 159 | "style-loader": "^3.3.3", 160 | "terser-webpack-plugin": "^5.3.9", 161 | "ts-jest": "^29.1.1", 162 | "ts-loader": "^9.4.4", 163 | "ts-node": "^10.9.1", 164 | "tsconfig-paths-webpack-plugin": "^4.1.0", 165 | "typescript": "^5.2.2", 166 | "url-loader": "^4.1.1", 167 | "webpack": "^5.88.2", 168 | "webpack-bundle-analyzer": "^4.9.1", 169 | "webpack-cli": "^5.1.4", 170 | "webpack-dev-server": "^4.15.1", 171 | "webpack-merge": "^5.9.0" 172 | }, 173 | "build": { 174 | "productName": "WXLiveSpy", 175 | "appId": "com.fire4nt.wxlivespy", 176 | "asar": true, 177 | "asarUnpack": "**\\*.{node,dll}", 178 | "files": [ 179 | "dist", 180 | "node_modules", 181 | "package.json" 182 | ], 183 | "afterSign": ".erb/scripts/notarize.js", 184 | "mac": { 185 | "target": { 186 | "target": "default", 187 | "arch": [ 188 | "arm64", 189 | "x64" 190 | ] 191 | }, 192 | "type": "distribution", 193 | "hardenedRuntime": true, 194 | "entitlements": "assets/entitlements.mac.plist", 195 | "entitlementsInherit": "assets/entitlements.mac.plist", 196 | "gatekeeperAssess": false 197 | }, 198 | "dmg": { 199 | "contents": [ 200 | { 201 | "x": 130, 202 | "y": 220 203 | }, 204 | { 205 | "x": 410, 206 | "y": 220, 207 | "type": "link", 208 | "path": "/Applications" 209 | } 210 | ] 211 | }, 212 | "win": { 213 | "target": [ 214 | "nsis" 215 | ] 216 | }, 217 | "linux": { 218 | "target": [ 219 | "AppImage" 220 | ], 221 | "category": "Development" 222 | }, 223 | "directories": { 224 | "app": "release/app", 225 | "buildResources": "assets", 226 | "output": "release/build" 227 | }, 228 | "extraResources": [ 229 | "./assets/**" 230 | ], 231 | "publish": { 232 | "provider": "github", 233 | "owner": "fire4nt", 234 | "repo": "wxlivespy" 235 | } 236 | }, 237 | "devEngines": { 238 | "node": ">=14.x", 239 | "npm": ">=7.x" 240 | }, 241 | "electronmon": { 242 | "patterns": [ 243 | "!**/**", 244 | "src/main/**" 245 | ], 246 | "logLevel": "quiet" 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/main/WXDataDecoder.ts: -------------------------------------------------------------------------------- 1 | import { LiveMessage, HostInfo, LiveInfo, DecodedData } from '../CustomTypes'; 2 | 3 | class WXDataDecoder { 4 | static liveInfoFromObject(o: any, hostInfo: HostInfo): LiveInfo { 5 | const li = {} as LiveInfo; 6 | li.wechat_uin = hostInfo.wechat_uin; 7 | // TODO 这个值可能为 undefined 8 | li.live_id = o.liveId; 9 | li.live_status = o.liveStatus; 10 | li.online_count = o.onlineCnt; 11 | li.start_time = o.startTime; 12 | li.like_count = o.likeCnt; 13 | li.reward_total_amount_in_wecoin = o.rewardTotalAmountInWecoin; 14 | return li; 15 | } 16 | 17 | static liveInfoToString(li: LiveInfo): string { 18 | return `${li.live_id} online[${li.online_count}] like[${li.like_count}] wecoin[${li.reward_total_amount_in_wecoin}]`; 19 | } 20 | 21 | static liveMessageFromMsg(o: any): LiveMessage { 22 | const messageInstance = {} as LiveMessage; 23 | messageInstance.msg_time = new Date().getTime(); 24 | messageInstance.msg_sub_type = o.type; 25 | if (o.type === 1) { 26 | messageInstance.decoded_type = 'comment'; 27 | } else if (o.type === 10005) { 28 | messageInstance.decoded_type = 'enter'; 29 | } else { 30 | messageInstance.decoded_type = 'unknown'; 31 | messageInstance.original_data = o; 32 | } 33 | messageInstance.msg_id = o.clientMsgId; 34 | messageInstance.sec_openid = o.username; 35 | messageInstance.content = o.content; 36 | messageInstance.nickname = o.nickname; 37 | messageInstance.seq = o.seq; 38 | return messageInstance; 39 | } 40 | 41 | static liveMessageFromAppMsg(o: any): LiveMessage { 42 | const messageInstance = {} as LiveMessage; 43 | messageInstance.msg_time = new Date().getTime(); 44 | messageInstance.msg_sub_type = o.msgType; 45 | // 还有一个 localClientMsgId 比 clientMsgId要短一点,clientMsgId完全包含localClientMsgId 46 | messageInstance.msg_id = o.clientMsgId; 47 | // TODO 不同直播场次,username这个值会变化? 48 | messageInstance.sec_openid = o.fromUserContact.contact.username; 49 | messageInstance.nickname = o.fromUserContact.contact.nickname; // o.fromUserContact.displayNickname 50 | messageInstance.seq = o.seq; 51 | // DEBUG("msgType" + o.msgType); 52 | // DEBUG("check: " + (o.msgType === 20006)); 53 | if (o.msgType === 20009) { 54 | messageInstance.decoded_type = 'gift'; 55 | const decodedPayload = Buffer.from(o.payload, 'base64').toString(); 56 | const giftPayload = JSON.parse(decodedPayload); 57 | messageInstance.sec_gift_id = giftPayload.reward_product_id; 58 | messageInstance.gift_num = giftPayload.reward_product_count; 59 | messageInstance.gift_value = giftPayload.reward_amount_in_wecoin; 60 | messageInstance.content = giftPayload.content; 61 | } else if (o.msgType === 20013) { 62 | messageInstance.decoded_type = 'combogift'; 63 | const decodedPayload = Buffer.from(o.payload, 'base64').toString(); 64 | const giftPayload = JSON.parse(decodedPayload); 65 | messageInstance.sec_gift_id = giftPayload.reward_product_id; 66 | messageInstance.combo_product_count = giftPayload.combo_product_count; 67 | messageInstance.content = giftPayload.content; 68 | } else if (o.msgType === 20006) { 69 | messageInstance.decoded_type = 'like'; 70 | } else if (o.msgType === 20031) { 71 | messageInstance.decoded_type = 'levelup'; 72 | const decodedPayload = Buffer.from(o.payload, 'base64').toString(); 73 | const giftPayload = JSON.parse(decodedPayload); 74 | messageInstance.from_level = giftPayload.from_level; 75 | messageInstance.to_level = giftPayload.to_level; 76 | } else { 77 | messageInstance.decoded_type = 'unknown'; 78 | messageInstance.original_data = o; 79 | } 80 | return messageInstance; 81 | } 82 | 83 | static liveMessageToString(msg: LiveMessage): string { 84 | if (msg.decoded_type === 'comment') { 85 | return `seq[${msg.seq}] ${msg.decoded_type} ${msg.msg_sub_type} ${msg.nickname} ${msg.content}`; 86 | } 87 | if (msg.decoded_type === 'enter') { 88 | return `seq[${msg.seq}] ${msg.decoded_type} ${msg.msg_sub_type} ${msg.nickname} ${msg.content}`; 89 | } 90 | if (msg.decoded_type === 'gift') { 91 | return `seq[${msg.seq}] ${msg.decoded_type} ${msg.msg_sub_type} ${msg.nickname} ${msg.content}`; 92 | } 93 | if (msg.decoded_type === 'combogift') { 94 | return `seq[${msg.seq}] ${msg.decoded_type} ${msg.msg_sub_type} ${msg.nickname} ${msg.content}`; 95 | } 96 | if (msg.decoded_type === 'like') { 97 | return `seq[${msg.seq}] ${msg.decoded_type} ${msg.msg_sub_type} ${msg.nickname}`; 98 | } 99 | if (msg.decoded_type === 'levelup') { 100 | return `seq[${msg.seq}] ${msg.decoded_type} ${msg.msg_sub_type} ${msg.nickname} ${msg.from_level} ${msg.to_level}`; 101 | } 102 | return `seq[${msg.seq}] ${msg.decoded_type} ${msg.msg_sub_type} ${msg.nickname}`; 103 | } 104 | 105 | // static *decodeMessage(responseData: any): IterableIterator { 106 | // const hasComments = Array.isArray(responseData.data.msgList) && responseData.data.msgList.length > 0; 107 | // const hasGifts = Array.isArray(responseData.data.appMsgList) && responseData.data.appMsgList.length > 0; 108 | // if (hasComments) { 109 | // const comments = responseData.data.msgList; 110 | // const decodedMessages = comments.map((o: any) => WXDataDecoder.liveMessageFromMsg(o)); 111 | // yield* decodedMessages; 112 | // } 113 | // if (hasGifts) { 114 | // const gifts = responseData.data.appMsgList; 115 | // const decodedMessages = gifts.map((o: any) => { 116 | // const gm = WXDataDecoder.liveMessageFromAppMsg(o); 117 | // const decodedPayload = Buffer.from(o.payload, 'base64').toString(); 118 | // const giftPayload = JSON.parse(decodedPayload); 119 | // o.payload = giftPayload; 120 | // return gm; 121 | // }); 122 | // yield* decodedMessages; 123 | // } 124 | // } 125 | 126 | static decodeDataFromResponse( 127 | requestHeaders: Record, 128 | requestData: any, 129 | responseData: any, 130 | ): DecodedData | null { 131 | const decodedMessages = {} as DecodedData; 132 | decodedMessages.host_info = {} as HostInfo; 133 | decodedMessages.host_info.wechat_uin = requestHeaders['x-wechat-uin']; 134 | decodedMessages.host_info.finder_username = requestData.finderUsername; 135 | 136 | // 可能出现 responseData.data.liveInfo 为 undefined 的情况 137 | if (responseData.data.liveInfo === undefined) { 138 | if (responseData.data.msgList.length === 0 && responseData.data.appMsgList.length === 0) { 139 | return null; 140 | } 141 | throw new Error('liveInfo is undefined, but msgList or appMsgList is not empty'); 142 | } 143 | 144 | decodedMessages.live_info = WXDataDecoder.liveInfoFromObject(responseData.data.liveInfo, decodedMessages.host_info); 145 | 146 | decodedMessages.events = responseData.data.msgList.reduce((acc: LiveMessage[], o: any) => { 147 | acc.push(WXDataDecoder.liveMessageFromMsg(o)); 148 | // todo save message to log file. 149 | // saveData(o, gm, config.log_path); 150 | return acc; 151 | }, []); 152 | decodedMessages.events = responseData.data.appMsgList.reduce((acc: LiveMessage[], o: any) => { 153 | const gm = WXDataDecoder.liveMessageFromAppMsg(o); 154 | const decodedPayload = Buffer.from(o.payload, 'base64').toString(); 155 | const giftPayload = JSON.parse(decodedPayload); 156 | o.payload = giftPayload; 157 | acc.push(gm); 158 | // todo save message to log file. 159 | // saveData(o, gm, config.log_path); 160 | return acc; 161 | }, decodedMessages.events); 162 | return decodedMessages; 163 | } 164 | 165 | static getOpenIDFromMsgId(msgId: string): string | null { 166 | // "msg_id": "finderlive_usermsg_comment_1F4EF489-B2CE-4B26-9002-3C7421EF8E78_o9hHn5apfwHL-RYrxochETS7NyDM", 167 | // return 'o9hHn5apfwHL-RYrxochETS7NyDM' 168 | // This id will not change between different live sessions 169 | const idx = msgId.indexOf('_o9h'); 170 | if (idx >= 0) { 171 | return msgId.substring(idx + 1, msgId.length); 172 | } 173 | return null; 174 | } 175 | 176 | static getSecOpenIDFromMsgId(msgId: string): string | null { 177 | // "finderlive_appmsg_finderlive_commcommentnotify_d5addee68407c6d78fe2cc115ef3bcbf_2042584726460027863_1698657150_b2afac411cfad2d6f5fe69e1c2ec3901", 178 | // return 'b2afac411cfad2d6f5fe69e1c2ec3901' 179 | // split by '_' and return the last one, this is the sec_openid 180 | const parts = msgId.split('_'); 181 | if (parts.length >= 1) { 182 | return parts[parts.length - 1]; 183 | } 184 | return null; 185 | } 186 | } 187 | 188 | export default WXDataDecoder; 189 | -------------------------------------------------------------------------------- /src/main/service.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, BrowserWindow, shell } from 'electron'; 2 | import log from 'electron-log'; 3 | import path from 'path'; 4 | import axios from 'axios'; 5 | import SpyConfig, { ConfigProps } from './config'; 6 | import WXLiveEventListener from './listener'; 7 | import WXLiveEventHandler from './interface'; 8 | import { DecodedData, LiveInfo, LiveMessage } from '../CustomTypes'; 9 | import EventForwarder from './EventForwarder'; 10 | import SpyHttpServer from './httpserver'; 11 | import IDCache from './idcache'; 12 | import WXDataDecoder from './WXDataDecoder'; 13 | 14 | class SpyService implements WXLiveEventHandler { 15 | private config: SpyConfig | null; 16 | 17 | private listener: WXLiveEventListener | null; 18 | 19 | private forwarder: EventForwarder | null; 20 | 21 | private mainWindow: BrowserWindow | null; 22 | 23 | private receivedSeqs: number[] = []; 24 | 25 | private currentStatus: LiveInfo | null; 26 | 27 | private httpServer: SpyHttpServer | null; 28 | 29 | private idCache: IDCache; 30 | 31 | constructor(configFilePath: string, mainWindow: BrowserWindow) { 32 | this.config = new SpyConfig(configFilePath); 33 | this.config.load(); 34 | this.mainWindow = mainWindow; 35 | const userDataPath = app.getPath('userData'); 36 | this.config.setProp('chrome_userdata_path', path.join(userDataPath, './chromeuserdata')); 37 | this.config.setProp('log_path', path.join(userDataPath, './logs')); 38 | this.listener = new WXLiveEventListener(this.config, this); 39 | this.forwarder = new EventForwarder(this.config); 40 | this.currentStatus = null; 41 | this.httpServer = null; 42 | this.idCache = new IDCache(); 43 | } 44 | 45 | public setChromePath(chromePath: string) { 46 | this.config?.setProp('chrome_path', chromePath); 47 | } 48 | 49 | public onStatusUpdate(liveInfo: LiveInfo) { 50 | // log.info(`forward status: ${liveInfo.wechat_uin} to ${this.config?.getProp('forward_url')}`); 51 | this.currentStatus = liveInfo; 52 | this.mainWindow?.webContents.send('wxlive-status', liveInfo); 53 | } 54 | 55 | public onEvent(liveMessage: LiveMessage) { 56 | log.debug(`get event: ${liveMessage.seq} ${liveMessage.decoded_type} ${liveMessage.content}`); 57 | // 根据seq去重 58 | if (this.receivedSeqs.indexOf(liveMessage.seq) >= 0) { 59 | log.debug(`ignore duplicated event: ${liveMessage.seq}`); 60 | return; 61 | } 62 | log.debug(`show event ${liveMessage.seq}`); 63 | this.mainWindow?.webContents.send('wxlive-event', liveMessage); 64 | this.receivedSeqs.push(liveMessage.seq); 65 | } 66 | 67 | public onEvents(decodedData: DecodedData) { 68 | // TODO 这里可能会发送重复的数据,服务器要自己根据seq去重。 69 | const dataWithDecodedOpenID = this.decodeOpenIDInEvents(decodedData); 70 | 71 | this.forwarder 72 | ?.forwardData(dataWithDecodedOpenID) 73 | .then((response: any) => { 74 | log.info(`forward response: ${JSON.stringify(response)}`); 75 | return response; 76 | }) 77 | .catch((error: any) => { 78 | log.error(`forward error: ${error}`); 79 | // TODO should retry when error occurs. 80 | }); 81 | decodedData.events.forEach((o) => { 82 | this.onEvent(o); 83 | }); 84 | } 85 | 86 | public stop(): void { 87 | log.info('stop spy service'); 88 | this.httpServer?.stop(); 89 | this.listener = null; 90 | this.forwarder = null; 91 | this.mainWindow = null; 92 | this.config = null; 93 | } 94 | 95 | public start(): void { 96 | log.info('start spy service'); 97 | log.debug(`debug mode: ${this.config?.getProp('debug')}`); 98 | log.debug(`forward url: ${this.config?.getProp('forward_url')}`); 99 | log.debug(`gzip forward data: ${this.config?.getProp('gzip_forward_data')}`); 100 | log.debug(`local chrome path: ${this.config?.getProp('chrome_path')}`); 101 | 102 | ipcMain.on('electron-baidu-tongji-message', (event, arg) => { 103 | log.info(`get baidu tongji message: ${arg}`); 104 | const config = { 105 | headers: { 106 | Referer: 'https://hm.baidu.com/', 107 | }, 108 | }; 109 | axios 110 | .get(`https://hm.baidu.com/hm.js?${arg}`, config) 111 | .then((res) => { 112 | log.debug(res.status); 113 | log.debug(`get baidu tongji response: ${res.data.slice(0, 20)}`); 114 | let { data } = res; 115 | data = data.replace(/document.location.href/g, '"https://wxlivespy.fire4nt.com/app.html"'); 116 | data = data.replace(/document.location.protocol/g, '"https:"'); 117 | if (this.mainWindow?.webContents) { 118 | log.debug(`send baidu tongji response: ${data.slice(0, 20)}}`); 119 | this.mainWindow?.webContents.send('electron-baidu-tongji-reply', data); 120 | } else { 121 | log.error('no main window'); 122 | } 123 | return res; 124 | }) 125 | .catch((err) => { 126 | log.error(err); 127 | }); 128 | }); 129 | 130 | ipcMain.on('ipc-example', async (event, arg) => { 131 | const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`; 132 | log.debug(msgTemplate(arg)); 133 | event.reply('ipc-example', msgTemplate('pong')); 134 | this.startListener(); 135 | }); 136 | ipcMain.handle('wxlive-get-config', (event, arg) => { 137 | return this.config?.getProp(arg as keyof ConfigProps); 138 | }); 139 | ipcMain.handle('wxlive-get-forward-url', () => { 140 | return this.config?.getProp('forward_url'); 141 | }); 142 | ipcMain.handle('wxlive-open-external-link', (event, link) => { 143 | shell.openExternal(link); 144 | }); 145 | ipcMain.on('wxlive-set-all-config', (event, arg) => { 146 | log.debug(`set config: ${JSON.stringify(arg)}`); 147 | // iterate over all properties of ConfigProps 148 | // and set them one by one 149 | Object.keys(arg).forEach((key) => { 150 | this.config?.setProp(key as keyof ConfigProps, arg[key]); 151 | }); 152 | // this.config.setProp('forward_url', arg.forwardURL); 153 | this.config?.save(); 154 | event.reply('wxlive-set-all-config', 'ok'); 155 | }); 156 | ipcMain.on('wxlive-set-config', (event, arg) => { 157 | log.debug(`set config: ${JSON.stringify(arg)}`); 158 | // iterate over all properties of ConfigProps 159 | // and set them one by one 160 | Object.keys(arg).forEach((key) => { 161 | log.debug(`set config: ${key} = ${arg[key]}`); 162 | this.config?.setProp(key as keyof ConfigProps, arg[key]); 163 | }); 164 | this.config?.save(); 165 | event.reply('wxlive-set-config', 'ok'); 166 | }); 167 | this.httpServer = new SpyHttpServer(21201, () => this.currentStatus); 168 | this.httpServer.start(); 169 | 170 | ipcMain.on('undefined-function', () => { 171 | // eslint-disable-next-line no-undef 172 | undefinedFunction(); 173 | }); 174 | } 175 | 176 | private startListener(): void { 177 | this.listener?.start(); 178 | } 179 | 180 | private decodeOpenIDInEvents(decodedData: DecodedData): DecodedData { 181 | const data = {} as DecodedData; 182 | data.host_info = decodedData.host_info; 183 | data.live_info = decodedData.live_info; 184 | data.live_info.nickname = this.idCache.get('auth', 'nickname') ?? 'unknown'; 185 | data.live_info.head_url = this.idCache.get('auth', 'avatar') ?? ''; 186 | data.events = []; 187 | // 这里对于解析出来的events,需要进一步做数据解析,写入 decoded_openid 188 | decodedData.events.forEach((o) => { 189 | log.debug(`get event: ${o.seq} ${o.decoded_type} ${o.content}`); 190 | if (o.decoded_type === 'enter' || o.decoded_type === 'comment') { 191 | // 对于 enter 和 comment,需要解析出 _o9h openid,并缓存下来。 192 | const decodedOpenID = WXDataDecoder.getOpenIDFromMsgId(o.msg_id); 193 | if (decodedOpenID === null) { 194 | log.error(`getOpenIDFromMsgId failed, msg_id: ${o.msg_id}`); 195 | return; 196 | } 197 | o.decoded_openid = decodedOpenID; 198 | 199 | const savedOpenID = this.idCache.get(decodedData.live_info.live_id, o.sec_openid); 200 | if (savedOpenID === null) { 201 | // 把 decodedData.live_info.live_id , o.sec_openid, o.decoded_openid 数据缓存下来。 202 | this.idCache.set(decodedData.live_info.live_id, o.sec_openid, o.decoded_openid); 203 | } else if (savedOpenID !== o.decoded_openid) { 204 | // 如果缓存已经存在,要不要对比,如果不一致,要不要报警? 205 | log.warn(`sec_openid ${o.sec_openid} has two decoded_openid: ${savedOpenID} and ${o.decoded_openid}`); 206 | return; 207 | } 208 | data.events.push(o); 209 | } else if (o.decoded_type === 'gift' || o.decoded_type === 'combogift') { 210 | const decodedOpenID = this.idCache.get(decodedData.live_info.live_id, o.sec_openid); 211 | if (decodedOpenID === null) { 212 | // TODO 如果用户上来就发礼物,这里会找不到对应的 decodedOpenID,要怎么处理? 213 | log.warn(`getOpenIDFromMsgId failed, msg_id: ${o.msg_id}`); 214 | return; 215 | } 216 | o.decoded_openid = decodedOpenID; 217 | 218 | const hexID = WXDataDecoder.getSecOpenIDFromMsgId(o.msg_id); 219 | if (hexID === null) { 220 | log.error(`getHexIDFromMsgId failed, msg_id: ${o.msg_id}`); 221 | return; 222 | } 223 | 224 | const savedOpenID = this.idCache.get(decodedData.live_info.live_id, hexID); 225 | if (savedOpenID === null) { 226 | this.idCache.set(decodedData.live_info.live_id, hexID, o.decoded_openid); 227 | } else if (savedOpenID !== o.decoded_openid) { 228 | log.warn(`hexid ${hexID} has two decoded_openid: ${savedOpenID} and ${o.decoded_openid}`); 229 | return; 230 | } 231 | data.events.push(o); 232 | } else { 233 | const decodedOpenID = this.idCache.get(decodedData.live_info.live_id, o.sec_openid); 234 | if (decodedOpenID !== null) { 235 | o.decoded_openid = decodedOpenID; 236 | data.events.push(o); 237 | return; 238 | } 239 | // 根据 sec_openid 找不到 decoded_openid,那么根据 msg_id 找一下。 240 | const hexID = WXDataDecoder.getSecOpenIDFromMsgId(o.msg_id); 241 | if (hexID === null) { 242 | log.error(`getHexIDFromMsgId failed, msg_id: ${o.msg_id}`); 243 | return; 244 | } 245 | const savedOpenID = this.idCache.get(decodedData.live_info.live_id, hexID); 246 | if (savedOpenID === null) { 247 | log.warn(`getOpenIDFromMsgId failed, msg_id: ${o.msg_id}`); 248 | return; 249 | } 250 | o.decoded_openid = savedOpenID; 251 | data.events.push(o); 252 | } 253 | }); 254 | return data; 255 | } 256 | } 257 | 258 | export default SpyService; 259 | -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions, ipcMain } from 'electron'; 2 | 3 | import { LiveInfo, LiveMessage } from '../CustomTypes'; 4 | 5 | interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { 6 | selector?: string; 7 | submenu?: DarwinMenuItemConstructorOptions[] | Menu; 8 | } 9 | 10 | export default class MenuBuilder { 11 | mainWindow: BrowserWindow; 12 | 13 | constructor(mainWindow: BrowserWindow) { 14 | this.mainWindow = mainWindow; 15 | } 16 | 17 | buildMenu(): Menu { 18 | if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') { 19 | this.setupDevelopmentEnvironment(); 20 | } 21 | 22 | const template = process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate(); 23 | 24 | const menu = Menu.buildFromTemplate(template); 25 | Menu.setApplicationMenu(menu); 26 | 27 | return menu; 28 | } 29 | 30 | setupDevelopmentEnvironment(): void { 31 | this.mainWindow.webContents.on('context-menu', (_, props) => { 32 | const { x, y } = props; 33 | 34 | Menu.buildFromTemplate([ 35 | { 36 | label: 'Inspect element', 37 | click: () => { 38 | this.mainWindow.webContents.inspectElement(x, y); 39 | }, 40 | }, 41 | ]).popup({ window: this.mainWindow }); 42 | }); 43 | } 44 | 45 | buildDarwinTemplate(): MenuItemConstructorOptions[] { 46 | const subMenuAbout: DarwinMenuItemConstructorOptions = { 47 | label: 'Electron', 48 | submenu: [ 49 | { 50 | label: 'About ElectronReact', 51 | selector: 'orderFrontStandardAboutPanel:', 52 | }, 53 | { type: 'separator' }, 54 | { label: 'Services', submenu: [] }, 55 | { type: 'separator' }, 56 | { 57 | label: 'Hide ElectronReact', 58 | accelerator: 'Command+H', 59 | selector: 'hide:', 60 | }, 61 | { 62 | label: 'Hide Others', 63 | accelerator: 'Command+Shift+H', 64 | selector: 'hideOtherApplications:', 65 | }, 66 | { label: 'Show All', selector: 'unhideAllApplications:' }, 67 | { type: 'separator' }, 68 | { 69 | label: 'Quit', 70 | accelerator: 'Command+Q', 71 | click: () => { 72 | app.quit(); 73 | }, 74 | }, 75 | ], 76 | }; 77 | const subMenuEdit: DarwinMenuItemConstructorOptions = { 78 | label: 'Edit', 79 | submenu: [ 80 | { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' }, 81 | { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' }, 82 | { type: 'separator' }, 83 | { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' }, 84 | { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' }, 85 | { label: 'Paste', accelerator: 'Command+V', selector: 'paste:' }, 86 | { 87 | label: 'Select All', 88 | accelerator: 'Command+A', 89 | selector: 'selectAll:', 90 | }, 91 | ], 92 | }; 93 | const subMenuViewDev: MenuItemConstructorOptions = { 94 | label: 'View', 95 | submenu: [ 96 | { 97 | label: 'Reload', 98 | accelerator: 'Command+R', 99 | click: () => { 100 | this.mainWindow.webContents.reload(); 101 | }, 102 | }, 103 | { 104 | label: 'Toggle Full Screen', 105 | accelerator: 'Ctrl+Command+F', 106 | click: () => { 107 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); 108 | }, 109 | }, 110 | { 111 | label: 'Toggle Developer Tools', 112 | accelerator: 'Alt+Command+I', 113 | click: () => { 114 | this.mainWindow.webContents.toggleDevTools(); 115 | }, 116 | }, 117 | ], 118 | }; 119 | const subMenuViewProd: MenuItemConstructorOptions = { 120 | label: 'View', 121 | submenu: [ 122 | { 123 | label: 'Toggle Full Screen', 124 | accelerator: 'Ctrl+Command+F', 125 | click: () => { 126 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); 127 | }, 128 | }, 129 | ], 130 | }; 131 | const subMenuWindow: DarwinMenuItemConstructorOptions = { 132 | label: 'Window', 133 | submenu: [ 134 | { 135 | label: 'Minimize', 136 | accelerator: 'Command+M', 137 | selector: 'performMiniaturize:', 138 | }, 139 | { label: 'Close', accelerator: 'Command+W', selector: 'performClose:' }, 140 | { type: 'separator' }, 141 | { label: 'Bring All to Front', selector: 'arrangeInFront:' }, 142 | ], 143 | }; 144 | const subMenuHelp: MenuItemConstructorOptions = { 145 | label: 'Help', 146 | submenu: [ 147 | { 148 | label: 'Learn More', 149 | click() { 150 | shell.openExternal('https://electronjs.org'); 151 | }, 152 | }, 153 | { 154 | label: 'Documentation', 155 | click() { 156 | shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme'); 157 | }, 158 | }, 159 | { 160 | label: 'Community Discussions', 161 | click() { 162 | shell.openExternal('https://www.electronjs.org/community'); 163 | }, 164 | }, 165 | { 166 | label: 'Search Issues', 167 | click() { 168 | shell.openExternal('https://github.com/electron/electron/issues'); 169 | }, 170 | }, 171 | ], 172 | }; 173 | 174 | const subMenuView = 175 | process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' ? subMenuViewDev : subMenuViewProd; 176 | 177 | return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp]; 178 | } 179 | 180 | buildDefaultTemplate() { 181 | const templateDefault = [ 182 | { 183 | label: '&File', 184 | submenu: [ 185 | { 186 | label: '&Open', 187 | accelerator: 'Ctrl+O', 188 | }, 189 | { 190 | label: '&Close', 191 | accelerator: 'Ctrl+W', 192 | click: () => { 193 | this.mainWindow.close(); 194 | }, 195 | }, 196 | ], 197 | }, 198 | { 199 | label: '&View', 200 | submenu: 201 | process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true' 202 | ? [ 203 | { 204 | label: '&Reload', 205 | accelerator: 'Ctrl+R', 206 | click: () => { 207 | this.mainWindow.webContents.reload(); 208 | }, 209 | }, 210 | { 211 | label: 'Toggle &Full Screen', 212 | accelerator: 'F11', 213 | click: () => { 214 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); 215 | }, 216 | }, 217 | { 218 | label: 'Toggle &Developer Tools', 219 | accelerator: 'Alt+Ctrl+I', 220 | click: () => { 221 | this.mainWindow.webContents.toggleDevTools(); 222 | }, 223 | }, 224 | ] 225 | : [ 226 | { 227 | label: 'Toggle &Full Screen', 228 | accelerator: 'F11', 229 | click: () => { 230 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); 231 | }, 232 | }, 233 | ], 234 | }, 235 | { 236 | label: 'Actions', 237 | submenu: [ 238 | { 239 | label: 'Refresh room status', 240 | click: () => { 241 | const now = new Date(); 242 | const onlineCount = Math.floor(Math.random() * 100); 243 | const likeCount = Math.floor(Math.random() * 100); 244 | const rewardAmount = Math.floor(Math.random() * 100); 245 | const data = {} as LiveInfo; 246 | data.start_time = now.getTime(); 247 | data.online_count = onlineCount; 248 | data.like_count = likeCount; 249 | data.live_id = '1234567890'; 250 | data.wechat_uin = '1234567890'; 251 | data.reward_total_amount_in_wecoin = rewardAmount; 252 | this.mainWindow.webContents.send('wxlive-status', data); 253 | }, 254 | }, 255 | { 256 | label: 'Add log', 257 | click: () => { 258 | const now = new Date(); 259 | const r = Math.random(); 260 | let eventType = 'gift'; 261 | if (r > 0.5) { 262 | eventType = 'comment'; 263 | } 264 | const data = {} as LiveMessage; 265 | data.msg_time = now.getTime(); 266 | data.decoded_type = eventType; 267 | data.content = 'test'; 268 | data.sec_openid = '1234567890abcdefghijk'; 269 | data.seq = Math.floor(Math.random() * 100000); 270 | 271 | this.mainWindow.webContents.send('wxlive-event', data); 272 | }, 273 | }, 274 | { 275 | label: 'crash in main process', 276 | click: () => { 277 | // this.mainWindow.webContents.send('wxlive-main-crash'); 278 | process.crash(); 279 | }, 280 | }, 281 | { 282 | label: 'undefined in main process', 283 | click: () => { 284 | // eslint-disable-next-line no-undef 285 | // undefinedFunction(); 286 | ipcMain.emit('undefined-function'); 287 | }, 288 | }, 289 | { 290 | label: 'crash in render process', 291 | click: () => { 292 | this.mainWindow.webContents.send('wxlive-debug', 'crash'); 293 | }, 294 | }, 295 | { 296 | label: 'undefined in render process', 297 | click: () => { 298 | this.mainWindow.webContents.send('wxlive-debug', 'undefined-function'); 299 | }, 300 | }, 301 | ], 302 | }, 303 | { 304 | label: 'Help', 305 | submenu: [ 306 | { 307 | label: 'Learn More', 308 | click() { 309 | shell.openExternal('https://electronjs.org'); 310 | }, 311 | }, 312 | { 313 | label: 'Documentation', 314 | click() { 315 | shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme'); 316 | }, 317 | }, 318 | { 319 | label: 'Community Discussions', 320 | click() { 321 | shell.openExternal('https://www.electronjs.org/community'); 322 | }, 323 | }, 324 | { 325 | label: 'Search Issues', 326 | click() { 327 | shell.openExternal('https://github.com/electron/electron/issues'); 328 | }, 329 | }, 330 | ], 331 | }, 332 | ]; 333 | 334 | return templateDefault; 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/CustomTypes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // To parse this data: 3 | // 4 | // import { Convert, CustomTypes } from "./file"; 5 | // 6 | // const customTypes = Convert.toCustomTypes(json); 7 | // 8 | // These functions will throw an error if the JSON doesn't 9 | // match the expected interface, even if the JSON is valid. 10 | 11 | export interface CustomTypes { 12 | /** 13 | * 从 original_body 中解析出来的业务数据 14 | */ 15 | decoded_data: DecodedData; 16 | /** 17 | * The original body of the response 18 | */ 19 | original_body: string; 20 | /** 21 | * The original url of the request 22 | */ 23 | original_url: string; 24 | [property: string]: any; 25 | } 26 | 27 | /** 28 | * 从 original_body 中解析出来的业务数据 29 | */ 30 | export interface DecodedData { 31 | /** 32 | * 直播间消息 33 | */ 34 | events: LiveMessage[]; 35 | /** 36 | * 主播信息 37 | */ 38 | host_info: HostInfo; 39 | /** 40 | * 直播间信息 41 | */ 42 | live_info: LiveInfo; 43 | [property: string]: any; 44 | } 45 | 46 | export interface LiveMessage { 47 | /** 48 | * 可选, decoded_type 是 combo_gift 时会有 49 | */ 50 | combo_product_count?: number; 51 | content: string; 52 | /** 53 | * 解密后的用户的微信openid,同一个用户在同一个主播的不同直播场次不会变化 54 | */ 55 | decoded_openid: string; 56 | /** 57 | * 解析出来的消息类型: comment, enter, gift, like, enter, levelup, unknown 58 | */ 59 | decoded_type: string; 60 | /** 61 | * 可选, decoded_type 是 levelup 时会有 62 | */ 63 | from_level?: number; 64 | /** 65 | * 可选, decoded_type 是 gift 时会有 66 | */ 67 | gift_num?: number; 68 | /** 69 | * 可选, decoded_type 是 gift 时会有,单位为微信币。是本次送礼物的总价值,不是单价。 70 | */ 71 | gift_value?: number; 72 | msg_id: string; 73 | /** 74 | * 从原始请求的 body 中获取, data.msgList[].type 或者 data.appMsgList[].msgType 75 | */ 76 | msg_sub_type: string; 77 | /** 78 | * 收到消息的unix时间戳 79 | */ 80 | msg_time: number; 81 | nickname: string; 82 | /** 83 | * 可选,类型是 unknown 时会有,原始的消息内容 84 | */ 85 | original_data?: { [key: string]: any }; 86 | /** 87 | * 可选, decoded_type 是 gift 或 combo_gift 时会有 88 | */ 89 | sec_gift_id?: string; 90 | /** 91 | * 经过加密的用户的微信openid,同一个用户在同一个主播的不同直播场次会变化 92 | */ 93 | sec_openid: string; 94 | /** 95 | * 事件在直播间发生的消息序号,从1开始,递增。可能会重复发送,服务器收到之后要自己去重。 96 | */ 97 | seq: number; 98 | /** 99 | * 可选, decoded_type 是 levelup 时会有 100 | */ 101 | to_level?: number; 102 | [property: string]: any; 103 | } 104 | 105 | /** 106 | * 主播信息 107 | */ 108 | export interface HostInfo { 109 | /** 110 | * 从原始请求的 body 中获取 111 | */ 112 | finder_username: string; 113 | /** 114 | * 从原始请求的 header中获取,可能是主播的微信号(用这个ID作为直播间的唯一标识) 115 | */ 116 | wechat_uin: string; 117 | [property: string]: any; 118 | } 119 | 120 | /** 121 | * 直播间信息 122 | */ 123 | export interface LiveInfo { 124 | /** 125 | * 主播头像 126 | */ 127 | head_url: string; 128 | /** 129 | * 直播间点赞数 130 | */ 131 | like_count: number; 132 | /** 133 | * 直播间ID 134 | */ 135 | live_id: string; 136 | /** 137 | * 直播间状态,1 表示直播中,2 表示直播结束 138 | */ 139 | live_status: number; 140 | /** 141 | * 主播昵称 142 | */ 143 | nickname: string; 144 | /** 145 | * 直播间在线人数 146 | */ 147 | online_count: number; 148 | /** 149 | * 直播间打赏总金额,单位为微信币 150 | */ 151 | reward_total_amount_in_wecoin: number; 152 | /** 153 | * 直播间开始时间,unix时间戳 154 | */ 155 | start_time: number; 156 | /** 157 | * 从原始请求的 header中获取,可能是主播的微信号(用这个ID作为直播间的唯一标识) 158 | */ 159 | wechat_uin: string; 160 | [property: string]: any; 161 | } 162 | 163 | // Converts JSON strings to/from your types 164 | // and asserts the results of JSON.parse at runtime 165 | export class Convert { 166 | public static toCustomTypes(json: string): CustomTypes { 167 | return cast(JSON.parse(json), r("CustomTypes")); 168 | } 169 | 170 | public static customTypesToJson(value: CustomTypes): string { 171 | return JSON.stringify(uncast(value, r("CustomTypes")), null, 2); 172 | } 173 | } 174 | 175 | function invalidValue(typ: any, val: any, key: any, parent: any = ''): never { 176 | const prettyTyp = prettyTypeName(typ); 177 | const parentText = parent ? ` on ${parent}` : ''; 178 | const keyText = key ? ` for key "${key}"` : ''; 179 | throw Error(`Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify(val)}`); 180 | } 181 | 182 | function prettyTypeName(typ: any): string { 183 | if (Array.isArray(typ)) { 184 | if (typ.length === 2 && typ[0] === undefined) { 185 | return `an optional ${prettyTypeName(typ[1])}`; 186 | } else { 187 | return `one of [${typ.map(a => { return prettyTypeName(a); }).join(", ")}]`; 188 | } 189 | } else if (typeof typ === "object" && typ.literal !== undefined) { 190 | return typ.literal; 191 | } else { 192 | return typeof typ; 193 | } 194 | } 195 | 196 | function jsonToJSProps(typ: any): any { 197 | if (typ.jsonToJS === undefined) { 198 | const map: any = {}; 199 | typ.props.forEach((p: any) => map[p.json] = { key: p.js, typ: p.typ }); 200 | typ.jsonToJS = map; 201 | } 202 | return typ.jsonToJS; 203 | } 204 | 205 | function jsToJSONProps(typ: any): any { 206 | if (typ.jsToJSON === undefined) { 207 | const map: any = {}; 208 | typ.props.forEach((p: any) => map[p.js] = { key: p.json, typ: p.typ }); 209 | typ.jsToJSON = map; 210 | } 211 | return typ.jsToJSON; 212 | } 213 | 214 | function transform(val: any, typ: any, getProps: any, key: any = '', parent: any = ''): any { 215 | function transformPrimitive(typ: string, val: any): any { 216 | if (typeof typ === typeof val) return val; 217 | return invalidValue(typ, val, key, parent); 218 | } 219 | 220 | function transformUnion(typs: any[], val: any): any { 221 | // val must validate against one typ in typs 222 | const l = typs.length; 223 | for (let i = 0; i < l; i++) { 224 | const typ = typs[i]; 225 | try { 226 | return transform(val, typ, getProps); 227 | } catch (_) {} 228 | } 229 | return invalidValue(typs, val, key, parent); 230 | } 231 | 232 | function transformEnum(cases: string[], val: any): any { 233 | if (cases.indexOf(val) !== -1) return val; 234 | return invalidValue(cases.map(a => { return l(a); }), val, key, parent); 235 | } 236 | 237 | function transformArray(typ: any, val: any): any { 238 | // val must be an array with no invalid elements 239 | if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); 240 | return val.map(el => transform(el, typ, getProps)); 241 | } 242 | 243 | function transformDate(val: any): any { 244 | if (val === null) { 245 | return null; 246 | } 247 | const d = new Date(val); 248 | if (isNaN(d.valueOf())) { 249 | return invalidValue(l("Date"), val, key, parent); 250 | } 251 | return d; 252 | } 253 | 254 | function transformObject(props: { [k: string]: any }, additional: any, val: any): any { 255 | if (val === null || typeof val !== "object" || Array.isArray(val)) { 256 | return invalidValue(l(ref || "object"), val, key, parent); 257 | } 258 | const result: any = {}; 259 | Object.getOwnPropertyNames(props).forEach(key => { 260 | const prop = props[key]; 261 | const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; 262 | result[prop.key] = transform(v, prop.typ, getProps, key, ref); 263 | }); 264 | Object.getOwnPropertyNames(val).forEach(key => { 265 | if (!Object.prototype.hasOwnProperty.call(props, key)) { 266 | result[key] = transform(val[key], additional, getProps, key, ref); 267 | } 268 | }); 269 | return result; 270 | } 271 | 272 | if (typ === "any") return val; 273 | if (typ === null) { 274 | if (val === null) return val; 275 | return invalidValue(typ, val, key, parent); 276 | } 277 | if (typ === false) return invalidValue(typ, val, key, parent); 278 | let ref: any = undefined; 279 | while (typeof typ === "object" && typ.ref !== undefined) { 280 | ref = typ.ref; 281 | typ = typeMap[typ.ref]; 282 | } 283 | if (Array.isArray(typ)) return transformEnum(typ, val); 284 | if (typeof typ === "object") { 285 | return typ.hasOwnProperty("unionMembers") ? transformUnion(typ.unionMembers, val) 286 | : typ.hasOwnProperty("arrayItems") ? transformArray(typ.arrayItems, val) 287 | : typ.hasOwnProperty("props") ? transformObject(getProps(typ), typ.additional, val) 288 | : invalidValue(typ, val, key, parent); 289 | } 290 | // Numbers can be parsed by Date but shouldn't be. 291 | if (typ === Date && typeof val !== "number") return transformDate(val); 292 | return transformPrimitive(typ, val); 293 | } 294 | 295 | function cast(val: any, typ: any): T { 296 | return transform(val, typ, jsonToJSProps); 297 | } 298 | 299 | function uncast(val: T, typ: any): any { 300 | return transform(val, typ, jsToJSONProps); 301 | } 302 | 303 | function l(typ: any) { 304 | return { literal: typ }; 305 | } 306 | 307 | function a(typ: any) { 308 | return { arrayItems: typ }; 309 | } 310 | 311 | function u(...typs: any[]) { 312 | return { unionMembers: typs }; 313 | } 314 | 315 | function o(props: any[], additional: any) { 316 | return { props, additional }; 317 | } 318 | 319 | function m(additional: any) { 320 | return { props: [], additional }; 321 | } 322 | 323 | function r(name: string) { 324 | return { ref: name }; 325 | } 326 | 327 | const typeMap: any = { 328 | "CustomTypes": o([ 329 | { json: "decoded_data", js: "decoded_data", typ: r("DecodedData") }, 330 | { json: "original_body", js: "original_body", typ: "" }, 331 | { json: "original_url", js: "original_url", typ: "" }, 332 | ], "any"), 333 | "DecodedData": o([ 334 | { json: "events", js: "events", typ: a(r("LiveMessage")) }, 335 | { json: "host_info", js: "host_info", typ: r("HostInfo") }, 336 | { json: "live_info", js: "live_info", typ: r("LiveInfo") }, 337 | ], "any"), 338 | "LiveMessage": o([ 339 | { json: "combo_product_count", js: "combo_product_count", typ: u(undefined, 3.14) }, 340 | { json: "content", js: "content", typ: "" }, 341 | { json: "decoded_openid", js: "decoded_openid", typ: "" }, 342 | { json: "decoded_type", js: "decoded_type", typ: "" }, 343 | { json: "from_level", js: "from_level", typ: u(undefined, 3.14) }, 344 | { json: "gift_num", js: "gift_num", typ: u(undefined, 3.14) }, 345 | { json: "gift_value", js: "gift_value", typ: u(undefined, 3.14) }, 346 | { json: "msg_id", js: "msg_id", typ: "" }, 347 | { json: "msg_sub_type", js: "msg_sub_type", typ: "" }, 348 | { json: "msg_time", js: "msg_time", typ: 3.14 }, 349 | { json: "nickname", js: "nickname", typ: "" }, 350 | { json: "original_data", js: "original_data", typ: u(undefined, m("any")) }, 351 | { json: "sec_gift_id", js: "sec_gift_id", typ: u(undefined, "") }, 352 | { json: "sec_openid", js: "sec_openid", typ: "" }, 353 | { json: "seq", js: "seq", typ: 3.14 }, 354 | { json: "to_level", js: "to_level", typ: u(undefined, 3.14) }, 355 | ], "any"), 356 | "HostInfo": o([ 357 | { json: "finder_username", js: "finder_username", typ: "" }, 358 | { json: "wechat_uin", js: "wechat_uin", typ: "" }, 359 | ], "any"), 360 | "LiveInfo": o([ 361 | { json: "head_url", js: "head_url", typ: "" }, 362 | { json: "like_count", js: "like_count", typ: 3.14 }, 363 | { json: "live_id", js: "live_id", typ: "" }, 364 | { json: "live_status", js: "live_status", typ: 3.14 }, 365 | { json: "nickname", js: "nickname", typ: "" }, 366 | { json: "online_count", js: "online_count", typ: 3.14 }, 367 | { json: "reward_total_amount_in_wecoin", js: "reward_total_amount_in_wecoin", typ: 3.14 }, 368 | { json: "start_time", js: "start_time", typ: 3.14 }, 369 | { json: "wechat_uin", js: "wechat_uin", typ: "" }, 370 | ], "any"), 371 | }; 372 | -------------------------------------------------------------------------------- /.erb/img/erb-banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.erb/img/palette-sponsor-banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | --------------------------------------------------------------------------------