├── .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 | 
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 |
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 |
64 |
转发日志(最近20条)
65 | {/*
66 |
69 |
*/}
70 |
71 |
72 |
73 | {logs.map((log) => (
74 |
75 | |
76 | {log.date}
77 | |
78 |
79 | {log.seq}
80 | |
81 |
82 | {log.decoded_type}
83 | |
84 |
85 | {log.user_id}
86 | |
87 |
88 | {log.content}
89 | |
90 |
91 | ))}
92 |
93 |
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 | | 状态数据API: |
75 |
76 |
77 | {liveStatusUrl}
78 |
79 | |
80 |
81 |
82 | | 主播ID: |
83 | {formData.hostID} |
84 |
85 |
86 | | 直播间ID: |
87 | {formData.roomID} |
88 |
89 |
90 | | 开播时间: |
91 | {formData.startTime} |
92 |
93 |
94 | | 在线人数: |
95 | {formData.onlineNumber} |
96 |
97 |
98 | | 点赞数: |
99 | {formData.likeCount} |
100 |
101 |
102 | | 微信币: |
103 | {formData.rewardAmount} |
104 |
105 |
106 |
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 |
33 |
--------------------------------------------------------------------------------
/.erb/img/palette-sponsor-banner.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------