├── .editorconfig ├── .github └── workflows │ ├── node-ci.yml │ └── npm-publish.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierrc ├── CHANGELOG.md ├── README.md ├── example ├── rules │ ├── bing-save-image.js │ ├── modify-baidu-index.js │ ├── print-res-body.js │ └── req-header.js └── scripts │ └── lzkj-getTopAndNewActInfo.js ├── index.js ├── initial.js ├── package.json ├── rules.txt ├── src ├── auth.ts ├── index.ts ├── init.ts ├── lib │ ├── QingLong.ts │ ├── TOTP.ts │ ├── X.ts │ ├── getConfig.ts │ ├── helper.ts │ ├── ruleHandler.ts │ ├── rulesManager.ts │ ├── update.ts │ ├── w2RulesManage.ts │ └── watch.ts ├── reqRead.ts ├── reqWrite.ts ├── resRead.ts ├── resRulesServer.ts ├── resStatsServer.ts ├── resWrite.ts ├── rulesServer.ts ├── server.ts ├── sniCallback.ts ├── statsServer.ts ├── tunnelReqRead.ts ├── tunnelReqWrite.ts ├── tunnelResRead.ts ├── tunnelResWrite.ts ├── tunnelRulesServer.ts ├── util │ ├── dataSource.ts │ └── util.ts ├── wsReqRead.ts ├── wsReqWrite.ts ├── wsResRead.ts └── wsResWrite.ts ├── template └── qx-tpl.js ├── tsconfig.cjs.json ├── tsconfig.json ├── typings ├── base.d.ts ├── global.d.ts └── index.d.ts └── w2.x-scripts.config.sample.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 140 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: 'Tests' 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | paths-ignore: 7 | - README.md 8 | - CONTRIBUTING.md 9 | pull_request: 10 | branches: 11 | - '**' 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | continue-on-error: ${{ matrix.os == 'windows-latest' }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: 20 | - ubuntu-latest 21 | - macos-latest 22 | # - windows-latest 23 | node-version: 24 | - 18 25 | include: 26 | - node-version: 20 27 | os: ubuntu-latest 28 | name: Node ${{ matrix.node-version }} on ${{ matrix.os }} 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | with: 33 | submodules: 'recursive' 34 | 35 | - name: Install Node.js 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | # cache: 'pnpm' 40 | 41 | - uses: pnpm/action-setup@v4 42 | with: 43 | version: 9 44 | 45 | - name: Install dependencies 46 | run: pnpm install --ignore-scripts 47 | 48 | - name: Test 49 | run: pnpm test 50 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 2 | 3 | name: Publish Package to npmjs 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v*.*.*" 9 | # release: 10 | # types: [created] 11 | 12 | jobs: 13 | npm-publish: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.ref }} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | with: 23 | submodules: 'recursive' 24 | 25 | - name: Install Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 18 29 | registry-url: https://registry.npmjs.com 30 | 31 | - uses: pnpm/action-setup@v4 32 | name: Install pnpm 33 | id: pnpm-install 34 | with: 35 | version: 9 36 | run_install: false 37 | 38 | - name: Get pnpm store directory 39 | id: pnpm-cache 40 | shell: bash 41 | run: | 42 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 43 | 44 | - uses: actions/cache@v3 45 | name: Setup pnpm cache 46 | with: 47 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 48 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 49 | restore-keys: | 50 | ${{ runner.os }}-pnpm-store- 51 | 52 | - name: Install dependencies 53 | run: pnpm install --no-frozen-lockfile --ignore-scripts 54 | 55 | - name: Build and npm-publish 56 | run: npm run release 57 | 58 | - run: npm publish 59 | env: 60 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 61 | 62 | # - name: GitHub Pages action 63 | # uses: peaceiris/actions-gh-pages@v3 64 | # with: 65 | # github_token: ${{ secrets.GITHUB_TOKEN }} 66 | # publish_dir: ./docs 67 | 68 | - name: Github Release 69 | uses: softprops/action-gh-release@v1 70 | if: startsWith(github.ref, 'refs/tags/') 71 | with: 72 | draft: false 73 | prerelease: false 74 | # tag_name: ${{ github.ref }} 75 | # name: Release ${{ github.ref }} 76 | # body: TODO New Release. 77 | # files: | 78 | # ${{ secrets.ReleaseZipName }}.zip 79 | # LICENSE 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # build dist 5 | .nyc_output 6 | *.log 7 | /dist/ 8 | /cjs/ 9 | /esm/ 10 | /release/ 11 | /debug/ 12 | /coverage/ 13 | /docs/ 14 | /tmp 15 | /cache 16 | /logs 17 | 18 | # manager 19 | npm-debug.log 20 | package-lock.json 21 | yarn.lock 22 | yarn-error.log 23 | pnpm-lock.yaml 24 | .pnpm-debug.log 25 | 26 | # config 27 | # .flh.config.js 28 | .grs.config.js 29 | /env-config.sh 30 | /w2.x-scripts.config.js 31 | /w2.x-scripts.cache.json 32 | /test-* 33 | /local-x-scripts-rules 34 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm exec flh --commitlint 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm test 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "proseWrap": "preserve", 6 | "semi": true, 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "lf", 10 | "overrides": [ 11 | { 12 | "files": ".prettierrc", 13 | "options": { 14 | "parser": "json" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.0.6](https://github.com/lzwme/whistle.x-scripts/compare/v0.0.5...v0.0.6) (2024-11-11) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * 修复ql获取的单账号ck中包含&符号默认会被处理为多账号的问题 ([536ff0f](https://github.com/lzwme/whistle.x-scripts/commit/536ff0fd2ab45234c1237b933bbb5e1ab63e81dc)) 11 | 12 | ### [0.0.5](https://github.com/lzwme/whistle.x-scripts/compare/v0.0.4...v0.0.5) (2024-06-10) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * **update:** 修复 update 本地 envConfig 文件时多行匹配处理错误的问题 ([379d938](https://github.com/lzwme/whistle.x-scripts/commit/379d9385018940901aefd6d8108e8a128876632f)) 18 | 19 | ### [0.0.4](https://github.com/lzwme/whistle.x-scripts/compare/v0.0.3...v0.0.4) (2024-05-05) 20 | 21 | 22 | ### Features 23 | 24 | * 新增 rulesServer,支持配置自定义的 whistle 原生支持规则,支持从远程加载 ([1305910](https://github.com/lzwme/whistle.x-scripts/commit/130591067738f67b19a280fee305fa0877dca8a6)) 25 | * 新增支持 MITM 配置,支持部分的启用 https 拦截以改善 w2 代理性能 ([a2d3e56](https://github.com/lzwme/whistle.x-scripts/commit/a2d3e56f2b9bfed0a2548515f776b85bdd44159c)) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * 修复 updateToQL 多账号会丢失旧数据的问题 ([0db59c1](https://github.com/lzwme/whistle.x-scripts/commit/0db59c191f89bb6443db2d168dba3a785e8e96a1)) 31 | 32 | ### [0.0.3](https://github.com/lzwme/whistle.x-scripts/compare/v0.0.2...v0.0.3) (2024-02-29) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * 修复规则热更新不生效的问题 ([93ca442](https://github.com/lzwme/whistle.x-scripts/commit/93ca44242723741fe949fb9c8093567afb7b6734)) 38 | 39 | ### [0.0.2](https://github.com/lzwme/whistle.x-scripts/compare/v0.0.1...v0.0.2) (2024-02-18) 40 | 41 | 42 | ### Features 43 | 44 | * 新增配置项 ruleInclude 和 ruleExclude,支持仅开启或禁用部分规则 ([9947663](https://github.com/lzwme/whistle.x-scripts/commit/994766379363f1b24e6329a83485ccfdbb478baf)) 45 | * 支持热更新。新增 watch 参数,支持配置文件与规则文件的监听模式 ([5458476](https://github.com/lzwme/whistle.x-scripts/commit/54584761a0d929fce0d9746791bb6481873f4ae0)) 46 | 47 | ### 0.0.1 (2024-01-23) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![@lzwme/whistle.x-scripts](https://nodei.co/npm/@lzwme/whistle.x-scripts.png)][download-url] 2 | 3 | @lzwme/whistle.x-scripts 4 | ======== 5 | 6 | [![NPM version][npm-badge]][npm-url] 7 | [![node version][node-badge]][node-url] 8 | [![npm download][download-badge]][download-url] 9 | [![GitHub issues][issues-badge]][issues-url] 10 | [![GitHub forks][forks-badge]][forks-url] 11 | [![GitHub stars][stars-badge]][stars-url] 12 | ![license MIT](https://img.shields.io/github/license/lzwme/whistle.x-scripts) 13 | 14 | 15 | 一个基于 [whistle](https://wproxy.org) 的代理脚本插件,用于辅助 web 程序调试、逆向学习等目的。 16 | 17 | 常见的流行代理工具软件如 WireShark、fiddler、charles、whistle、burpsuite、mitmproxy 等本身自带的能力已相当强大,但是在实现较为复杂的自定义逻辑目的时,要么配置规则相当复杂,拥有较高的规则编写学习成本,要么需要开发对应的插件实现。 18 | 19 | 本插件基于代理工具 `whistle` 的插件开发能力,提供了一套简易的脚本编写规范。基于该规范,对于拥有简单的 JavaScript 开发能力的同学来说,只需针对常用网站或软件以自由编码的形式编写少量的代码规则,即可实现绝大多数场景需求。如实现自动保存登录认证 cookie、拦截、模拟、修改、和保存接口数据等功能,从而可以以较低的成本实现:认证信息同步、广告过滤、数据修改、数据缓存、文件替换调试等目的。 20 | 21 | **功能特性:** 22 | 23 | - `自定义脚本` 设计了一套简易的脚本编写规范,基于该规范只需编写少量的脚本代码规则,即可实现绝大多数场景需求。 24 | - `保存 cookie` 可以基于配置规则提取 cookie 并保存至环境变量配置文件。也支持更新至青龙脚本的环境变量配置中。 25 | - `接口数据模拟 mock` 匹配某些接口并 `mock` 它,返回构造模拟的结果。 26 | - `接口数据修改 modify` 根据匹配规则修改请求返回的数据体。 27 | - more... 28 | 29 | **可以做什么?** 30 | 31 | 基于以上基础特性,可以实现许多有意思的能力,为我们分析学习三方系统提供有效的帮助,例如: 32 | 33 | 34 | - `应用调试` 通过拦截请求、修改参数、模拟数据等,可以实现应用的调试、逆向分析、数据模拟等目的。 35 | - `功能增强` 通过程序化的修改返回结果,解锁客户端应用的隐藏功能 36 | - `VIP 破解` 通过拦截请求,修改请求参数,模拟请求,从而实现破解 vip 功能。 37 | - `广告过滤` 通过修改或移除请求返回结果,屏蔽客户端应用广告。 38 | - `认证信息同步` 通过拦截请求,提取 cookie 等信息并同步保存至文件或其他系统重,从而实现认证信息同步、登录状态同步等功能。 39 | - `数据缓存` 通过解码、修改、保存数据,可以实现数据缓存、数据修改等功能。 40 | - ...... 41 | 42 | ## 安装与更新 43 | 44 | 首先需全局安装 `whistle`: 45 | 46 | ```bash 47 | npm i -g whistle 48 | ``` 49 | 50 | 然后使用 npm 全局安装,即可被 whistle 自动识别和加载: 51 | 52 | ```bash 53 | # 使用 npm 54 | npm i -g @lzwme/whistle.x-scripts 55 | # 也可以使用 whistle 命令 56 | w2 install @lzwme/whistle.x-scripts 57 | ``` 58 | 59 | ## 使用 60 | 61 | ### 快速开始 62 | 63 | 首先,在当前工作目录下新建配置文件 `w2.x-scripts.config.js`。内容参考:[w2.x-scripts.config.sample.js](./w2.x-scripts.config.sample.js) 64 | 65 | 然后在配置文件中的 `rules` 或 `ruleDirs` 配置项中,添加配置自定义的规则。 66 | 67 | 最后启动 `whistle`: 68 | 69 | ```bash 70 | # 调试方式启动:可以从控制台看到日志,适用于自编写调试规则时 71 | w2 run 72 | 73 | # 守护进程方式启动:适用于服务器运行时 74 | w2 start 75 | ``` 76 | 77 | 可以从控制台日志看到具体启动的代理地址。应当为:`http://[本机IP]:8899` 78 | 79 | **提示:** 80 | 81 | 若希望可以在任意位置启动并加载配置文件,可将配置文件放置在 `Home` 目录下,即: `~/w2.x-scripts.config.js`。 82 | 83 | ### 设备设置代理 84 | 85 | - 当前运行 `whistle` 的设备设置代理 86 | 87 | ```bash 88 | # 安装 根证书 89 | w2 ca 90 | 91 | # 设置代理 92 | w2 proxy 93 | 94 | # 取消代理 95 | w2 proxy off 96 | ``` 97 | 98 | 详情可参考 `whistle` 文档:[代理与证书](https://wproxy.org/whistle/proxy.html) 99 | 100 | - 移动设备设置代理 101 | 102 | 移动设备设置代理,需先在 `whistle` 运行时安装 `whistle` 根证书。具体配置方法不同的设备稍有差异,具体可根据你的设备搜索查找其对应详细的代理设置方法。`whistle` 文档相关参考:[安装启动](https://wproxy.org/whistle/install.html) 103 | 104 | ### 青龙面板中使用参考 105 | 106 | 以下以基于 `docker` 方式安装的青龙面板为例简要介绍。 107 | 108 | 1. 编辑 `配置文件 -> extra.sh`,增加如下内容: 109 | 110 | ```bash 111 | npm i -g whistle @lzwme/whistle.x-scripts 112 | mkdir -p /ql/data/scripts/whistle 113 | cd /ql/data/scripts/whistle 114 | 115 | if [ ! -e w2.x-scripts.config.js ]; then 116 | cp /usr/local/lib/node_modules/@lzwme/whistle.x-scripts/w2.x-scripts.config.sample.js w2.x-scripts.config.js 117 | fi 118 | 119 | w2 start # -M capture 120 | ``` 121 | 122 | 2. 进入 `/ql/data/scripts/whistle` 目录(若为 docker 方式安装,则进入对应映射目录下),参考 [w2.x-scripts.config.sample.js](./w2.x-scripts.config.sample.js) 新建/修改配置文件 `w2.x-scripts.config.js`。 123 | 124 | 3. 可在该目录下新建 `local-x-scripts-rules` 目录,然后将编写的规则脚本放入其中。 125 | 126 | ## 配置文件 127 | 128 | 配置文件名称为 `w2.x-scripts.config.js`,默认会在当前目录和用户 Home 目录下查找。 129 | 130 | 各配置项及详细说明请参考类型定义[W2XScriptsConfig]('./index.d.ts')。 131 | 132 | ## 脚本规则的编写 133 | 134 | 你可以在配置文件 `w2.x-scripts.config.js` 中的 `rules` 字段中直接编写规则,也可以在其他目录下新建 `.js` 文件并编写具体的脚本规则,然后在 `ruleDirs` 字段中引入。 135 | 136 | 一个极其简单的脚本规则示例: 137 | 138 | ```js 139 | /** @type {import('@lzwme/whistle.x-scripts').RuleItem} */ 140 | const rule = { 141 | disabled: false, // 是否禁用该规则 142 | on: 'res-body', // 规则执行的时机,res-body 表示在收到响应体后触发 143 | ruleId: 'print-response-body', // 规则唯一标记,必须设置且唯一 144 | desc: '打印接口返回内容', // 规则描述 145 | url: '**', // ** 表示匹配所有 url 地址 146 | handler({ url, req, reqBody, resHeaders, resBody, X }) { 147 | // 只处理文本类型的请求 148 | if (X.isText(req.headers) && !/\.(js|css)/.test(url)) { 149 | // X 是提供的工具类对象,方便简化脚本编写逻辑调用 150 | const { magenta, gray, cyan } = X.FeUtils.color; 151 | 152 | console.log(`\n\n[${magenta('handler')}][${cyan(req.method)}] -`, gray(url)); 153 | console.log(cyan('\req headers:'), req.headers); 154 | console.log(cyan('\res headers:'), resHeaders); 155 | if (reqBody) console.log(cyan('请求参数:'), reqBody.toString()); 156 | if (resBody) console.log(cyan('返回内容:'), resBody); 157 | } 158 | 159 | // body: 若返回 body 字段,则会以该内容返回; 160 | // envConfig: 若返回 envConfig 字段,则会根据其内容及配置写入本地环境变量文件、上传至青龙面板等 161 | // return { body: modifyedResBody, envConfig }; 162 | }, 163 | }; 164 | module.exports = rule; 165 | ``` 166 | 167 | 具体编写方法请参考配置示例和类型定义文件: 168 | 169 | - [x-scripts-rules](https://github.com/lzwme/x-scripts-rules) 提供了一些示例脚本,可作为脚本规则的编写参考示例。 170 | - [w2.x-scripts.config.sample.js](./w2.x-scripts.config.sample.js) 171 | - [index.d.ts](./index.d.ts) 172 | 173 | **提示:** 174 | 175 | - 您可以设置环境变量 `DEBUG=1` 以开启调试模式。 176 | - 当前工作路径下所有名称包含 `x-scripts-rules` 的目录下的 `.js` 文件都会被自动加载。 177 | - **⚠️警告:自定义脚本会真实的在服务器上执行,且拥有较高的安全风险,请不要在工作用机上部署。建议基于 docker 等虚拟化技术使用。** 178 | 179 | ### 脚本规则示例 180 | 181 | 可直接下载使用的脚本规则: 182 | 183 | - [https://github.com/lzwme/x-scripts-rules](https://github.com/lzwme/x-scripts-rules) 184 | - [jd.js](https://github.com/lzwme/x-scripts-rules/blob/main/src/jd.js) **自动保存某东 cookie** 至本地环境变量文件,并上传至青龙面板 185 | - [imaotai.js](https://github.com/lzwme/x-scripts-rules/blob/main/src/imaotai.js) **自动保存i茅台 token** 并上传至青龙面板 186 | - 更多实用示例脚本持续添加中... 187 | 188 | 模板示例脚本规则: 189 | 190 | - [example/rules](./example/rules/) 目录下包含了常用场景及示例脚本,可作为模板示例参考使用。 191 | - [**打印**所有接口请求与返回信息](./example/rules/print-res-body.js) 192 | - [**保存**在访问bing首页时加载的每日背景图至本地](./example/rules/bing-save-images.js) 193 | - [**修改**百度首页网盘入口为自定义链接地址](./example/rules/modify-baidu-index.js) 194 | - more... 195 | 196 | ## 二次开发 197 | 198 | ```bash 199 | # 全局安装 whistle 200 | npm i -g whistle 201 | 202 | # 拉取本插件仓库代码 203 | git clone https://github.com/lzwme/whistle.x-scripts.git 204 | cd whistle.x-scripts 205 | # 安装依赖。也可以使用 npm、yarn 等包管理器 206 | pnpm install 207 | 208 | # 监控模式编译项目 209 | pnpm dev 210 | # 链接至全局,效果等同于 npm i -g @lzwme/whistle.x-scripts 211 | npm link . 212 | 213 | # 安装根 ca 214 | w2 ca 215 | # 设置本机代理 216 | w2 proxy 217 | # 关闭本机代理 218 | # w2 proxy off 219 | 220 | # 调试模式启动 221 | w2 run 222 | ``` 223 | 224 | ## 扩展参考 225 | 226 | - [whistle 插件开发](https://wproxy.org/whistle/plugins.html) 227 | - [Custom extension script for whistle](https://github.com/whistle-plugins/whistle.script) 228 | 229 | ## 免责说明 230 | 231 | - 本插件项目仅用于个人对 web 程序逆向的兴趣研究学习,请勿用于商业用途、任何恶意目的,否则后果自负。 232 | - 由于插件引入自定义脚本会真实的在服务器上执行,使用第三方编写的脚本时请谨慎甄别安全风险,请尽可能的在虚拟化容器内使用。 233 | - 请自行评估使用本插件及基于本插件规范开发的第三方脚本的安全风险。本人对使用本插件或插件涉及的任何脚本引发的问题概不负责,包括但不限于由脚本错误引起的任何损失或损害。 234 | 235 | ## License 236 | 237 | `@lzwme/whistle.x-scripts` is released under the MIT license. 238 | 239 | 该插件由[志文工作室](https://lzw.me)开发和维护。 240 | 241 | 242 | [stars-badge]: https://img.shields.io/github/stars/lzwme/whistle.x-scripts.svg 243 | [stars-url]: https://github.com/lzwme/whistle.x-scripts/stargazers 244 | [forks-badge]: https://img.shields.io/github/forks/lzwme/whistle.x-scripts.svg 245 | [forks-url]: https://github.com/lzwme/whistle.x-scripts/network 246 | [issues-badge]: https://img.shields.io/github/issues/lzwme/whistle.x-scripts.svg 247 | [issues-url]: https://github.com/lzwme/whistle.x-scripts/issues 248 | [npm-badge]: https://img.shields.io/npm/v/@lzwme/whistle.x-scripts.svg?style=flat-square 249 | [npm-url]: https://npmjs.org/package/@lzwme/whistle.x-scripts 250 | [node-badge]: https://img.shields.io/badge/node.js-%3E=_16.15.0-green.svg?style=flat-square 251 | [node-url]: https://nodejs.org/download/ 252 | [download-badge]: https://img.shields.io/npm/dm/@lzwme/whistle.x-scripts.svg?style=flat-square 253 | [download-url]: https://npmjs.org/package/@lzwme/whistle.x-scripts 254 | [bundlephobia-url]: https://bundlephobia.com/result?p=@lzwme/whistle.x-scripts@latest 255 | [bundlephobia-badge]: https://badgen.net/bundlephobia/minzip/@lzwme/whistle.x-scripts@latest 256 | -------------------------------------------------------------------------------- /example/rules/bing-save-image.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | /** @type {import('@lzwme/whistle.x-scripts').RuleItem} */ 4 | module.exports = { 5 | on: 'res-body', 6 | ruleId: 'bing-save-image', 7 | desc: '保存 bing 大图', 8 | url: 'https://s.cn.bing.net/th?id=*.jpg&w=3840&h=2160*', 9 | handler({ url, resBody, X }) { 10 | const filename = /id=(.*\.jpg)/.exec(url)?.[1]; 11 | 12 | if (filename && Buffer.isBuffer(resBody)) { 13 | const filepath = `cache/bing-images/${filename}`; 14 | const { mkdirp, color, formatByteSize } = X.FeUtils; 15 | 16 | if (!fs.existsSync(filepath)) { 17 | mkdirp('cache/bing-images'); 18 | fs.promises.writeFile(filepath, resBody); 19 | X.logger.info(`图片已保存:[${color.green(formatByteSize(resBody.byteLength))}]`, color.cyan(filepath)); 20 | } 21 | } 22 | }, 23 | }; 24 | 25 | // const micromatch = require('micromatch'); 26 | // let url = 'https://s.cn.bing.net/th?id=OHR.SquirrelNetherlands_ZH-CN0757138587_UHD.jpg&w=3840&h=2160&c=8&rs=1&o=3&r=0' 27 | // let p = 'https://s.cn.bing.net/th?id=*.jpg&w=3840&h=2160*'; 28 | // console.log(micromatch.isMatch(url, p)); 29 | -------------------------------------------------------------------------------- /example/rules/modify-baidu-index.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@lzwme/whistle.x-scripts').RuleItem} */ 2 | module.exports = { 3 | on: 'res-body', 4 | ruleId: 'modify-baidu-index-pan', 5 | desc: '修改百度首页网盘入口为自定义的链接地址', 6 | url: 'https://www.baidu.com/', 7 | handler({ resHeaders, resBody }) { 8 | if (!String(resHeaders['content-type']).includes('text/html')) return; 9 | 10 | const body = resBody.toString() 11 | .replace(/https:\/\/pan.baidu.com?\w+/, 'https://lzw.me?from=baiduindex') 12 | .replace('>网盘', '>志文工作室'); 13 | return { body }; 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /example/rules/print-res-body.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-22 14:00:13 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-01-29 15:45:36 6 | * @Description: 7 | */ 8 | /** @type {import('@lzwme/whistle.x-scripts').RuleItem} */ 9 | module.exports = { 10 | // disabled: true, // 是否禁用该规则 11 | on: 'res-body', // 规则执行的时机,res-body 表示在收到响应体后触发 12 | ruleId: 'print-response-body', // 规则唯一标记,必须设置且唯一 13 | desc: '打印接口返回内容', // 规则描述 14 | method: '*', 15 | url: '**', // ** 表示匹配所有 url 地址 16 | handler({ url, req, reqBody, resHeaders, resBody, X }) { 17 | // 只处理文本类型的请求 18 | if (X.isText(req.headers) && !/\.(js|css)/.test(url)) { 19 | // X 是提供的工具类对象,方便简化脚本编写逻辑调用 20 | const { magenta, gray, cyan } = X.FeUtils.color; 21 | 22 | console.log(`\n\n[${magenta('handler')}][${cyan(req.method)}] -`, gray(url)); 23 | console.log(cyan('req headers:'), req.headers); 24 | console.log(cyan('res headers:'), resHeaders); 25 | if (reqBody) console.log(cyan('请求参数:'), reqBody); 26 | if (resBody) console.log(cyan('返回内容:'), resBody); 27 | } 28 | 29 | // 若返回 body 参数则会以该内容返回 30 | // 若返回 envConfig 参数则会以该内容写入环境变量文件 31 | // return { body: modifyedResBody, envConfig }; 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /example/rules/req-header.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@lzwme/whistle.x-scripts').RuleItem} */ 2 | module.exports = { 3 | on: 'req-header', 4 | ruleId: 'hdl_data', 5 | method: '**', 6 | url: 'https://superapp-public.kiwa-tech.com/activity/wxapp/**', 7 | desc: '海底捞小程序签到 token', 8 | handler({ headers }) { 9 | if (headers['_haidilao_app_token']) return { envConfig: { value: headers['_haidilao_app_token'] } }; 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /example/scripts/lzkj-getTopAndNewActInfo.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-17 11:10:55 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-01-17 12:10:19 6 | * @Description: 7 | */ 8 | 9 | async function getTopAndNewActInfo(cookie = process.env.LZKJ_COOKIE) { 10 | const url = 'https://lzkj-isv.isvjd.com/wxAssemblePage/getTopAndNewActInfo'; 11 | const r = await fetch(url, { 12 | method: 'post', 13 | headers: { 14 | 'content-type': 'application/x-www-form-urlencoded', 15 | cookie, 16 | }, 17 | body: `pin=a%2BuYEDziWNPOwbIg6yOnhlw0qxi8AhQQPw1iwI2bhThO0zLXWT5XGufTchohGid1&aggrateActType=5&topNewType=1&pageNo=1&pageSize=200`, 18 | }).then(d => d.json()); 19 | console.log(r, '\n Total:', r.data?.homeInfoResultVOList?.length); 20 | 21 | const ids = {}; 22 | r.data?.homeInfoResultVOList?.forEach(d => { 23 | const key = /:\/\/(\w+)-isv/.exec(d.activityUrl)?.[1]; 24 | if (key) { 25 | if (!ids[key]) ids[key] = []; 26 | ids[key].push(d.activityId); 27 | } 28 | }); 29 | 30 | Object.entries(ids).forEach(([k, v]) => console.log(`[${k}][count: ${v.length}]`, v.join(','))); 31 | return ids; 32 | } 33 | 34 | const cookie = process.env.LZKJ_COOKIE || ''; 35 | getTopAndNewActInfo(cookie); 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-10 16:58:26 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-02-29 20:06:35 6 | * @Description: 一个 whistle 插件, 用于自动抓取 cookie 并保存 7 | */ 8 | 9 | module.exports = require('./dist/index'); 10 | 11 | // exports.server = require('./dist/server.js').default; 12 | // exports.rulesServer = require('./dist/rulesServer.js').rulesServer; 13 | // exports.reqRead = require('./dist/reqRead').default; 14 | // exports.reqWrite = require('./dist/reqWrite').default; 15 | // exports.resRead = require('./dist/resRead').default; 16 | // exports.resWrite = require('./dist/resWrite').default; 17 | -------------------------------------------------------------------------------- /initial.js: -------------------------------------------------------------------------------- 1 | module.exports = function(_options) { 2 | require('./dist/init').init(); 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lzwme/whistle.x-scripts", 3 | "version": "0.0.6", 4 | "description": "一个基于 whistle 的代理脚本插件。用于辅助 web 程序调试、逆向学习等目的。让用户可以用自由编码开发的方式编写简单的规则脚本,即可实现自动保存登录认证 cookie、模拟接口数据、修改接口数据等功能。", 5 | "main": "index.js", 6 | "typings": "./typings/index.d.ts", 7 | "license": "MIT", 8 | "repository": "https://github.com/lzwme/whistle.x-scripts.git", 9 | "author": { 10 | "name": "renxia", 11 | "email": "lzwy0820@qq.com", 12 | "url": "https://lzw.me" 13 | }, 14 | "scripts": { 15 | "prepare": "husky || true", 16 | "dev": "tsc -p tsconfig.cjs.json -w", 17 | "lint": "flh --prettier --tscheck", 18 | "build": "npm run clean && tsc -p tsconfig.cjs.json", 19 | "version": "standard-version", 20 | "release": "npm run test && npm run build", 21 | "clean": "flh rm -f dist", 22 | "test": "npm run lint" 23 | }, 24 | "keywords": [ 25 | "whistle", 26 | "proxy", 27 | "cookie", 28 | "env" 29 | ], 30 | "engines": { 31 | "node": ">=16" 32 | }, 33 | "publishConfig": { 34 | "access": "public", 35 | "registry": "https://registry.npmjs.com" 36 | }, 37 | "files": [ 38 | "index.js", 39 | "initial.js", 40 | "rules.txt", 41 | "w2.x-scripts.config.sample.js", 42 | "./dist", 43 | "./typings", 44 | "./template" 45 | ], 46 | "dependencies": { 47 | "@lzwme/fe-utils": "^1.7.5", 48 | "micromatch": "^4.0.8" 49 | }, 50 | "devDependencies": { 51 | "@lzwme/fed-lint-helper": "^2.6.4", 52 | "@types/node": "^22.9.0", 53 | "base64-js": "^1.5.1", 54 | "crypto-js": "^4.2.0", 55 | "husky": "^9.1.6", 56 | "prettier": "^3.3.3", 57 | "standard-version": "^9.5.0", 58 | "typescript": "^5.6.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /rules.txt: -------------------------------------------------------------------------------- 1 | # 默认启用插件 2 | @whistle.x-scripts 3 | 4 | # 适用所有请求 5 | * x-scripts:// 6 | 7 | # 强制开启 https 拦截 8 | # * enable://capture 9 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | export default async (req: Whistle.PluginAuthRequest, options: Whistle.PluginOptions) => { 2 | req.setHeader('x-whistle-custom-header', 'lack'); 3 | return true; // false 直接返回 403 4 | }; 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as server } from './server'; 2 | export { rulesServer } from './rulesServer'; 3 | export { tunnelRulesServer } from './tunnelRulesServer'; 4 | -------------------------------------------------------------------------------- /src/init.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-15 08:51:18 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-02-07 15:32:36 6 | * @Description: 7 | */ 8 | import { color } from '@lzwme/fe-utils'; 9 | import { getConfig } from './lib/getConfig'; 10 | import { getCacheStorage, logger } from './lib/helper'; 11 | 12 | export async function init() { 13 | const config = getConfig(); 14 | const storage = getCacheStorage(config.cacheFile); 15 | logger.log('Cache File:', color.cyan(storage.config.filepath)); 16 | logger.debug('config:', config); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/QingLong.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-18 10:12:52 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-03-27 09:46:52 6 | * @Description: 青龙面板 API 简易封装 7 | */ 8 | import { existsSync } from 'node:fs'; 9 | import { Request, readJsonFileSync, color, TOTP } from '@lzwme/fe-utils'; 10 | import { logger } from './helper'; 11 | 12 | export class QingLoing { 13 | private static inc: QingLoing; 14 | static getInstance(config?: QLOptions) { 15 | if (!QingLoing.inc) QingLoing.inc = new QingLoing(config); 16 | return QingLoing.inc; 17 | } 18 | private isOpenApi = false; 19 | private loginStatus: number = 0; 20 | private req = new Request('', { 'content-type': 'application/json' }); 21 | public async request(method: 'POST' | 'GET' | 'PUT' | 'DELETE', url: string, params?: any): Promise> { 22 | if (!url.startsWith('http')) url = this.config.host + (url.startsWith('/') ? '' : '/') + url; 23 | if (this.isOpenApi) url = url.replace('/api/', '/open/'); 24 | const { data } = await this.req.request>(method, url, params); 25 | 26 | if (data.code === 401 && this.loginStatus === 1) { 27 | this.loginStatus = 0; 28 | this.config.token = ''; 29 | if (await this.login()) return this.request(params, url, params); 30 | } 31 | if (this.config.debug) logger.info(`[QL][req][${color.cyan(method)}]`, color.gray(url), '\n req:', params, '\n res:', data); 32 | return data; 33 | } 34 | constructor(private config: QLOptions = {}) {} 35 | async login(config?: QLOptions) { 36 | if (this.loginStatus === 1) return true; 37 | 38 | if (config || this.loginStatus >= -3) { 39 | config = Object.assign(this.config, config || {}); 40 | if (!this.config.host) this.config.host = 'http://127.0.0.1:5700'; 41 | if (this.config.host.endsWith('/')) this.config.host = this.config.host.slice(0, -1); 42 | 43 | // 尝试从配置文件中读取 44 | for (const filepath of ['/ql/data/config/auth.json', '/ql/config/auth.json']) { 45 | if (existsSync(filepath)) { 46 | const r = readJsonFileSync<{ token: string; username: string; password: string; twoFactorSecret: string }>(filepath); 47 | if (r.token && r.username) { 48 | Object.assign(this.config, r); 49 | break; 50 | } 51 | } 52 | } 53 | 54 | if (config.clientId && config.clientSecret) { 55 | const data = await this.request<{ token: string }>( 56 | 'GET', 57 | `/open/auth/token?client_id=${config.clientId}&client_secret=${config.clientSecret}` 58 | ); 59 | if (data.data?.token) { 60 | config.token = data.data.token; 61 | this.isOpenApi = true; 62 | logger.log(`[QL]OpenApi 获取 token 成功!`); 63 | } else logger.error(`[QL]OpenApi 获取 token 异常: ${data.message}`); 64 | } 65 | 66 | if (!config.token && !config.username) { 67 | this.loginStatus = -100; 68 | if (this.config.debug) logger.info('未设置青龙面板相关相关信息'); 69 | return false; 70 | } 71 | 72 | // 验证 token 有效性 73 | if (this.config.token) { 74 | this.req.setHeaders({ Authorization: `Bearer ${this.config.token}` }); 75 | const r = await this.request('GET', `/api/envs?searchValue=&t=${Date.now()}`); 76 | if (r.code !== 200) { 77 | logger.warn('token 已失效:', r); 78 | this.config.token = ''; 79 | } 80 | } 81 | 82 | if (!this.config.token) this.config.token = await this.getQLToken(this.config); 83 | 84 | if (this.config.token) { 85 | this.req.setHeaders({ Authorization: `Bearer ${this.config.token}` }); 86 | this.loginStatus = 1; 87 | } else { 88 | logger.warn('请在配置文件中配置 ql 相关变量'); 89 | this.loginStatus--; 90 | } 91 | } 92 | 93 | return this.loginStatus === 1; 94 | } 95 | private async getQLToken({ username, password, twoFactorSecret = '' } = this.config) { 96 | let token = ''; 97 | if (!username && !password) return ''; 98 | const params: Record = { username, password }; 99 | const r1 = await this.request<{ token: string }>('POST', `/api/user/login?t=${Date.now()}`, params); 100 | 101 | if (r1.data?.token) token = r1.data?.token; 102 | if (r1.code === 420) { 103 | // 需要两步验证 104 | if (twoFactorSecret) { 105 | params.code = TOTP.generate(twoFactorSecret).otp; 106 | const r = await this.request<{ token: string }>('PUT', `/api/user/two-factor/login?t=${Date.now()}`, params); 107 | if (r.data?.token) token = r1.data?.token; 108 | else logger.warn('青龙登陆失败!', color.red(r.message)); 109 | } else { 110 | logger.error('开启了两步验证,请配置参数 twoFactorSecret'); 111 | } 112 | } 113 | 114 | return token; 115 | } 116 | /** 查询环境变量列表 */ 117 | async getEnvList(searchValue = '') { 118 | const data = await this.request('GET', `/api/envs?searchValue=${searchValue}&t=${Date.now()}`); 119 | if (data.code !== 200) { 120 | logger.error('[updateToQL]获取环境变量列表失败!', data); 121 | return []; 122 | } 123 | logger.log('[QL]查询环境变量列表:', data.data?.length); 124 | 125 | return data.data || []; 126 | } 127 | /** 新增环境变量 */ 128 | addEnv(params: QLEnvItem[]) { 129 | return this.request('POST', `/api/envs?t=${Date.now()}`, params); 130 | } 131 | /** 更新环境变量 */ 132 | updateEnv(params: QLEnvItem | QLEnvItem[]) { 133 | return this.request('PUT', `/api/envs?t=${Date.now()}`, params); 134 | } 135 | /** 删除环境变量 */ 136 | delEnv(ids: number | number[]) { 137 | return this.request('DELETE', `/api/envs?t=${Date.now()}`, Array.isArray(ids) ? ids : [ids]); 138 | } 139 | enableEnv(ids: string | string[]) { 140 | return this.request('PUT', `/api/envs/enable?t=${Date.now()}'`, Array.isArray(ids) ? ids : [ids]); 141 | } 142 | disableEnv(ids: string | string[]) { 143 | return this.request('PUT', `/api/envs/disable?t=${Date.now()}'`, Array.isArray(ids) ? ids : [ids]); 144 | } 145 | /** 查询任务列表 */ 146 | async getTaskList(searchValue = '') { 147 | const data = await this.request('GET', `/api/crons?searchValue=${searchValue}&t=${Date.now()}`); 148 | return data.data || []; 149 | } 150 | /** 禁用任务 */ 151 | async disableTask(tasks: QLTaskItem[]) { 152 | const ids = tasks.map(d => d.id); 153 | const data = await this.request('PUT', `/api/crons/disable`, ids); 154 | return data.code === 200 ? '🎉成功禁用重复任务~' : `❌出错!!!错误信息为:${JSON.stringify(data)}`; 155 | } 156 | } 157 | 158 | export type QLOptions = { 159 | debug?: boolean; 160 | /** 青龙服务地址。用于上传环境变量,若设置为空则不上传 */ 161 | host?: string; 162 | /** 青龙服务 token。用于创建或更新 QL 环境变量配置。会自动尝试从 /ql/data/config/auth.json 文件中获取 */ 163 | token?: string; 164 | /** 登录用户名 */ 165 | username?: string; 166 | /** 登录密码 */ 167 | password?: string; 168 | /** 两步验证秘钥。若开启了两步验证则需设置 */ 169 | twoFactorSecret?: string; 170 | /** open app client_id: 应用设置-创建应用,权限选择 环境变量 */ 171 | clientId?: string; 172 | /** open app client_secret */ 173 | clientSecret?: string; 174 | }; 175 | export type QLEnvItem = { name: string; value: string; id?: string; remarks: string }; 176 | export type QLResponse = { code: number; message: string; data: T }; 177 | export interface QLTaskItem { 178 | id: number; 179 | name: string; 180 | command: string; 181 | schedule: string; 182 | timestamp: string; 183 | saved: boolean; 184 | status: number; 185 | isSystem: number; 186 | pid: number; 187 | isDisabled: number; 188 | isPinned: number; 189 | log_path: string; 190 | labels: any[]; 191 | last_running_time: number; 192 | last_execution_time: number; 193 | sub_id: number; 194 | extra_schedules?: string; 195 | task_before?: string; 196 | task_after?: string; 197 | createdAt: string; 198 | updatedAt: string; 199 | _disable: boolean; 200 | } 201 | -------------------------------------------------------------------------------- /src/lib/TOTP.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: bellstrand 3 | * @Date: 2024-02-05 11:03:02 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-02-05 13:55:59 6 | * @Description: 7 | * @see https://github.com/bellstrand/totp-generator/blob/master/src/index.ts 8 | */ 9 | 10 | import { createHmac } from 'crypto'; 11 | 12 | /** 13 | * @param {string} [digits=6] 14 | * @param {string} [algorithm="SHA-1"] 15 | * @param {string} [period=30] 16 | * @param {string} [timestamp=Date.now()] 17 | */ 18 | export type TOTPOptions = { 19 | digits?: number; 20 | algorithm?: 'SHA-1' | 'SHA-224' | 'SHA-256' | 'SHA-384' | 'SHA-512' | 'SHA3-224' | 'SHA3-256' | 'SHA3-384' | 'SHA3-512'; 21 | period?: number; 22 | timestamp?: number; 23 | }; 24 | 25 | /** generate TOTP tokens from a TOTP key */ 26 | export class TOTP { 27 | static generate(key: string, options?: TOTPOptions) { 28 | const _options: Required = { digits: 6, algorithm: 'SHA-1', period: 30, timestamp: Date.now(), ...options }; 29 | const epoch = Math.floor(_options.timestamp / 1000); 30 | const time = this.leftpad(this.dec2hex(Math.floor(epoch / _options.period)), 16, '0'); 31 | const hmac = createHmac(_options.algorithm, Buffer.from(this.base32tohex(key), 'hex')) 32 | .update(Buffer.from(time, 'hex')) 33 | .digest('hex'); 34 | const offset = this.hex2dec(hmac.slice(-1)) * 2; 35 | let otp = (this.hex2dec(hmac.slice(offset, offset + 8)) & this.hex2dec('7fffffff')) + ''; 36 | const start = Math.max(otp.length - _options.digits, 0); 37 | otp = otp.slice(start, start + _options.digits); 38 | const expires = Math.ceil((_options.timestamp + 1) / (_options.period * 1000)) * _options.period * 1000; 39 | return { otp, expires }; 40 | } 41 | private static hex2dec(hex: string) { 42 | return Number.parseInt(hex, 16); 43 | } 44 | private static dec2hex(dec: number) { 45 | return (dec < 15.5 ? '0' : '') + Math.round(dec).toString(16); 46 | } 47 | private static base32tohex(base32: string) { 48 | const base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; 49 | let bits = ''; 50 | let hex = ''; 51 | 52 | const _base32 = base32.replace(/=+$/, ''); 53 | 54 | for (let i = 0; i < _base32.length; i++) { 55 | const val = base32chars.indexOf(base32.charAt(i).toUpperCase()); 56 | if (val === -1) throw new Error('Invalid base32 character in key'); 57 | bits += this.leftpad(val.toString(2), 5, '0'); 58 | } 59 | 60 | for (let i = 0; i + 8 <= bits.length; i += 8) { 61 | const chunk = bits.slice(i, i + 8); 62 | hex = hex + this.leftpad(Number.parseInt(chunk, 2).toString(16), 2, '0'); 63 | } 64 | return hex; 65 | } 66 | 67 | private static leftpad(str: string, len: number, pad: string) { 68 | if (len + 1 >= str.length) { 69 | str = Array.from({ length: len + 1 - str.length }).join(pad) + str; 70 | } 71 | return str; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/X.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-16 09:30:50 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-02-28 13:59:37 6 | * @Description: 用于导出给 ruleHandler 使用的工具变量 7 | */ 8 | import * as FeUtils from '@lzwme/fe-utils'; 9 | import * as util from '../util/util'; 10 | import { logger } from './helper'; 11 | 12 | export const X = { 13 | FeUtils, 14 | logger, 15 | cookieParse: FeUtils.cookieParse, 16 | cookieStringfiy: FeUtils.cookieStringfiy, 17 | objectFilterByKeys: FeUtils.objectFilterByKeys, 18 | ...util, 19 | }; 20 | -------------------------------------------------------------------------------- /src/lib/getConfig.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-11 13:17:00 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-03-01 16:38:28 6 | * @Description: 7 | */ 8 | import type { W2XScriptsConfig } from '../../typings'; 9 | import { existsSync, readdirSync, statSync } from 'node:fs'; 10 | import { basename, resolve } from 'node:path'; 11 | import { homedir } from 'node:os'; 12 | import { assign, color } from '@lzwme/fe-utils'; 13 | import { logger } from './helper'; 14 | import { rulesManager } from './rulesManager'; 15 | import { Watcher } from './watch'; 16 | import { loadW2Rules } from './w2RulesManage'; 17 | 18 | const config: W2XScriptsConfig = { 19 | debug: Boolean(process.env.DEBUG), 20 | watch: 30000, 21 | logType: process.env.LOG_TYPE as never, 22 | ql: { 23 | host: process.env.QL_HOST || 'http://127.0.0.1:5700', 24 | token: process.env.QL_TOKEN || '', 25 | username: process.env.QL_USERNAME || '', 26 | password: process.env.QL_PASSWORD || '', 27 | twoFactorSecret: process.env.QL_2FA_SECRET || '', 28 | }, 29 | envConfFile: 'env-config.sh', 30 | cacheFile: 'w2.x-scripts.cache.json', 31 | cacheDuration: 60 * 60 * 12, 32 | rules: [], 33 | whistleRules: [], 34 | }; 35 | let isLoaded = false; 36 | 37 | export function getConfig(useCache = true) { 38 | if (!useCache || !isLoaded) { 39 | const configFileName = 'w2.x-scripts.config.js'; 40 | const defaultConfigFile = resolve(homedir(), configFileName); 41 | const dirList = new Set( 42 | ['.', process.env.W2_COOKIES_CONFIG || '.', '..', '...', defaultConfigFile].map(d => resolve(process.cwd(), d)) 43 | ); 44 | 45 | const isLoadUserConfig = [...dirList].some(configFilePath => { 46 | try { 47 | if (!existsSync(configFilePath)) return; 48 | if (statSync(configFilePath).isDirectory()) configFilePath = resolve(configFilePath, configFileName); 49 | if (!existsSync(configFilePath)) return; 50 | 51 | assign(config, require(configFilePath)); 52 | config.rules.forEach(d => (d._source = configFilePath)); 53 | config.ruleDirs = config.ruleDirs.map(d => resolve(d.trim())); 54 | 55 | Watcher.add(configFilePath, type => { 56 | if (type === 'update') { 57 | delete require.cache[require.resolve(configFilePath)]; 58 | getConfig(false); 59 | } 60 | }); 61 | 62 | logger.info('配置文件加载成功', color.cyan(configFilePath)); 63 | return true; 64 | } catch (e) { 65 | logger.error('配置文件加载失败', color.red(configFilePath)); 66 | console.log(e); 67 | } 68 | }); 69 | 70 | if (!config.logType) config.logType = config.debug ? 'debug' : 'info'; 71 | logger.updateOptions({ levelType: config.logType, debug: config.debug }); 72 | if (!isLoadUserConfig) { 73 | logger.info( 74 | `请创建配置文件: ${color.yellow(defaultConfigFile)}`, 75 | `\n参考: ${color.cyan(resolve(__dirname, '../../w2.x-scripts.config.sample.js'))}` 76 | ); 77 | } 78 | 79 | config.throttleTime = Math.max(1, +config.throttleTime || 3); 80 | 81 | if (!Array.isArray(config.ruleDirs)) config.ruleDirs = [config.ruleDirs as never as string]; 82 | 83 | if (basename(process.cwd()).includes('x-scripts-rules')) config.ruleDirs.push(process.cwd()); 84 | if (basename(process.cwd()).includes('whistle-rules')) config.whistleRules.push({ path: process.cwd() }); 85 | 86 | readdirSync(process.cwd()).forEach(d => { 87 | if (d.includes('x-scripts-rules')) { 88 | config.ruleDirs.push(d); 89 | 90 | if (statSync(d).isDirectory()) { 91 | readdirSync(d).forEach(filename => filename.includes('whistle-rule') && config.whistleRules.push({ path: resolve(d, filename) })); 92 | } 93 | } 94 | }); 95 | 96 | const allRules = rulesManager.loadRules(config.ruleDirs, true); 97 | config.rules.forEach(d => { 98 | if (Array.isArray(d)) d.forEach(d => d.ruleId && allRules.set(d.ruleId, d)); 99 | else if (d?.ruleId) allRules.set(d.ruleId, d); 100 | }); 101 | rulesManager.classifyRules([...allRules.values()], config, true); 102 | 103 | loadW2Rules(config.whistleRules, 'clear'); 104 | 105 | isLoaded = true; 106 | config.watch ? Watcher.start() : Watcher.stop(); 107 | } 108 | 109 | return config; 110 | } 111 | -------------------------------------------------------------------------------- /src/lib/helper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-11 13:15:52 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-01-16 09:05:19 6 | * @Description: 7 | */ 8 | 9 | import { resolve } from 'node:path'; 10 | import { LiteStorage, NLogger, color } from '@lzwme/fe-utils'; 11 | import type { IncomingHttpHeaders } from '../../typings'; 12 | 13 | export const logger = new NLogger('[X-SCRIPTS]', { logDir: './logs', color }); 14 | 15 | export type CacheStorItem = { 16 | [uid: string]: { 17 | /** 最近一次更新缓存的时间 */ 18 | update: number; 19 | /** 缓存数据体 */ 20 | data: { 21 | /** req headers */ 22 | headers: IncomingHttpHeaders; 23 | /** 提取的数据唯一标记 ID */ 24 | uid: string; 25 | /** getUserId 处理返回的用户自定义数据 */ 26 | data: any; 27 | }; 28 | }; 29 | }; 30 | 31 | let stor: LiteStorage<{ [ruleId: string]: CacheStorItem }>; 32 | export function getCacheStorage(filepath?: string) { 33 | if (!stor) { 34 | filepath = resolve(filepath || 'w2.x-scripts.cache.json'); 35 | stor = new LiteStorage<{ [ruleId: string]: CacheStorItem }>({ filepath, uuid: 'w2_xsctipts_cache' }); 36 | } 37 | return stor; 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/ruleHandler.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-10 16:58:26 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-05-08 15:19:51 6 | * @Description: 基于 whistle 的 cookie 自动抓取插件 7 | */ 8 | 9 | import { assign, color, cookieParse } from '@lzwme/fe-utils'; 10 | import micromatch from 'micromatch'; 11 | import type { EnvConfig, RuleHandlerParams, RuleHandlerResult, RuleItem } from '../../typings'; 12 | import { getConfig } from './getConfig'; 13 | import { type CacheStorItem, getCacheStorage, logger } from './helper'; 14 | import { updateEnvConfigFile, updateToQlEnvConfig } from './update'; 15 | import { X } from './X'; 16 | import * as util from '../util/util'; 17 | 18 | type RuleHandlerOptions = { 19 | rule: RuleItem; 20 | req: (Whistle.PluginServerRequest | Whistle.PluginReqCtx) & { _reqBody?: Buffer | Record }; 21 | res: (Whistle.PluginResponse | Whistle.PluginResCtx) & { _resBody?: Buffer | Record | string }; 22 | reqBody?: Buffer | Record | string; 23 | resBody?: Buffer; 24 | }; 25 | 26 | export function ruleMatcher({ rule, req }: RuleHandlerOptions) { 27 | let errmsg = ''; 28 | 29 | // method 30 | const { method } = req; 31 | if (rule.method && rule.method.toLowerCase() !== method.toLowerCase() && !micromatch.isMatch(method, rule.method)) { 32 | errmsg = `[method] ${method} not match rule.method ${rule.method}`; 33 | } 34 | 35 | // url match 36 | if (!errmsg) { 37 | const { url } = req.originalReq; 38 | const urlMatchList = Array.isArray(rule.url) ? rule.url : [rule.url]; 39 | const isUrlMatch = urlMatchList.some(ruleUrl => { 40 | if (typeof ruleUrl === 'function') { 41 | return ruleUrl(url, method, req.headers); 42 | } else if (ruleUrl instanceof RegExp) { 43 | return ruleUrl.test(url); 44 | } else if (typeof ruleUrl === 'string') { 45 | return url == ruleUrl || micromatch.isMatch(url, ruleUrl); 46 | } 47 | }); 48 | 49 | if (!isUrlMatch) errmsg = `[url] ${url} not match rule.url`; 50 | } 51 | 52 | if (errmsg) { 53 | errmsg = `[ruleMatcher][${rule.ruleId}] ${errmsg}`; 54 | logger.trace(errmsg); 55 | } 56 | return errmsg; 57 | } 58 | 59 | export async function ruleHandler({ rule, req, res, reqBody, resBody }: RuleHandlerOptions) { 60 | const result: RuleHandlerResult = { errmsg: ruleMatcher({ rule, req, res }), envConfig: null, body: null }; 61 | if (result.errmsg) return result; 62 | 63 | const config = getConfig(); 64 | const { headers, fullUrl: url } = req; 65 | const cookieObj = cookieParse(headers.cookie); 66 | const resHeaders = (res as Whistle.PluginResCtx).headers; 67 | 68 | // decode reqBody 69 | if (reqBody && !req._reqBody) { 70 | if ('getJson' in req) { 71 | req._reqBody = await new Promise(rs => req.getJson((err, json) => (err ? rs(util.jsonParse(reqBody) || reqBody) : rs(json)))); 72 | } else { 73 | req._reqBody = util.jsonParse(reqBody) || reqBody; 74 | } 75 | } 76 | 77 | // decode resBody 78 | if (resBody && !res._resBody) { 79 | if ('getJson' in res) { 80 | res._resBody = await new Promise(rs => res.getJson((_err, json) => rs(json))); 81 | } 82 | 83 | if (!res._resBody) { 84 | let ubody = await new Promise(rs => util.unzipBody(resHeaders, resBody, (_e, d) => rs(d))); 85 | if (ubody && util.isText(resHeaders)) ubody = String(ubody || ''); 86 | res._resBody = util.isJSON(resHeaders) || util.isJSON(headers) ? util.jsonParse(ubody) || ubody : ubody; 87 | } 88 | } 89 | 90 | const params: RuleHandlerParams = { req, reqBody: req._reqBody, resBody: res._resBody, headers, url, cookieObj, cacheData: [], X }; 91 | if (resHeaders) params.resHeaders = resHeaders; 92 | 93 | if (rule.getCacheUid) { 94 | let uidData; 95 | let uid = typeof rule.getCacheUid === 'function' ? rule.getCacheUid(params) : rule.getCacheUid; 96 | 97 | if (typeof uid === 'object') { 98 | uidData = uid.data; 99 | uid = uid.uid; 100 | } 101 | 102 | if (uid) { 103 | const storage = getCacheStorage(); 104 | const now = Date.now(); 105 | const cacheData: CacheStorItem = rule.ruleId ? storage.getItem(rule.ruleId) || {} : {}; 106 | 107 | if (cacheData[uid]) { 108 | if (now - cacheData[uid].update < 1000 * config.throttleTime!) { 109 | result.errmsg = `[throttle][${Date.now() - cacheData[uid].update}ms][${rule.ruleId}][${uid}] cache hit`; 110 | return result; 111 | } 112 | 113 | if (uidData && rule.mergeCache && typeof uidData === 'object') uidData = assign({}, cacheData[uid].data.data, uidData); 114 | } 115 | 116 | cacheData[uid] = { update: now, data: { uid, headers: req.originalReq.headers, data: uidData } }; 117 | 118 | params.cacheData = []; 119 | const cacheDuration = 1000 * (Number(rule.cacheDuration || config.cacheDuration) || (rule.updateEnvValue ? 12 : 24 * 10) * 3600); 120 | for (const [key, value] of Object.entries(cacheData)) { 121 | if (cacheDuration && now - value.update > cacheDuration) delete cacheData[key]; 122 | else params.cacheData.push(value.data); 123 | } 124 | params.allCacheData = params.cacheData; 125 | 126 | storage.setItem(rule.ruleId, cacheData); 127 | } else { 128 | result.errmsg = '[rule.getCacheUid] `uid` is required'; 129 | return result; 130 | } 131 | } 132 | 133 | const r = await rule.handler(params); 134 | if (r) { 135 | if (typeof r === 'string' || Buffer.isBuffer(r)) result.body = r; 136 | else if ('envConfig' in r || 'errmsg' in r || 'body' in r) Object.assign(result, r); 137 | else if (rule.getCacheUid) { 138 | if (Array.isArray(r)) result.envConfig = r; 139 | else if ('name' in r && 'value' in r) result.envConfig = r as EnvConfig; 140 | else result.body = JSON.stringify(r); 141 | } else { 142 | result.body = JSON.stringify(r); 143 | } 144 | 145 | if (result.envConfig) { 146 | (Array.isArray(result.envConfig) ? result.envConfig : [result.envConfig]).forEach(async envConfig => { 147 | if (!envConfig || !('value' in envConfig)) return; 148 | 149 | if (!envConfig.name) envConfig.name = rule.ruleId; 150 | if (!envConfig.desc) envConfig.desc = rule.desc; 151 | if (config.ql?.enable !== false && rule.toQL !== false) await updateToQlEnvConfig(envConfig, rule.updateEnvValue); 152 | if (config.envConfFile && rule.toEnvFile !== false) updateEnvConfigFile(envConfig, rule.updateEnvValue, config.envConfFile); 153 | }); 154 | } 155 | } else logger.trace(`[rule.handler][${rule.on}]未返回有效数据格式`, rule.ruleId, rule.desc, req.fullUrl); 156 | 157 | return result; 158 | } 159 | -------------------------------------------------------------------------------- /src/lib/rulesManager.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readdirSync, statSync } from 'node:fs'; 2 | import { resolve } from 'node:path'; 3 | import { clearRequireCache, color } from '@lzwme/fe-utils'; 4 | import { logger } from './helper'; 5 | import type { RuleRunOnType, RuleItem, W2XScriptsConfig } from '../../typings'; 6 | import { getConfig } from './getConfig'; 7 | import { Watcher, WatcherOnChange } from './watch'; 8 | 9 | const { green, cyan, magenta, magentaBright, greenBright } = color; 10 | const RulesCache: Partial>> = { all: new Map() }; 11 | const RuleOnList = ['req-header', 'req-body', 'res-body'] as const; 12 | 13 | function ruleFormat(rule: RuleItem) { 14 | if (!rule || (!rule.ruleId && !rule.url)) return false; 15 | 16 | if (!rule.handler) { 17 | logger.error(`[${rule.on}]未设置处理方法,忽略该规则:`, rule._source || rule.ruleId || rule); 18 | return false; 19 | } 20 | 21 | if (rule.method == null) rule.method = 'post'; 22 | 23 | if (!rule.ruleId) { 24 | rule.ruleId = `${rule.desc || rule.url}_${rule.method || ''}`; 25 | logger.log(`未设置 ruleId 参数,采用 desc/url + method。[${rule.ruleId}]`); 26 | } 27 | 28 | if (!rule.on || !RuleOnList.includes(rule.on)) { 29 | const ruleOn = rule.getCacheUid ? 'res-body' : 'req-header'; 30 | if (rule.on) logger.warn(`[${rule.on}] 参数错误,自动设置为[${ruleOn}]!只能取值为: ${RuleOnList.join(', ')}`, rule.ruleId); 31 | rule.on = ruleOn; 32 | } 33 | 34 | if (!rule.desc) rule.desc = `${rule.ruleId}_${rule.url || rule.method}`; 35 | 36 | if (rule.mitm) { 37 | if (!Array.isArray(rule.mitm)) rule.mitm = [rule.mitm]; 38 | rule.mitm = rule.mitm.filter(d => d && (typeof d === 'string' || d instanceof RegExp)); 39 | } 40 | 41 | return rule; 42 | } 43 | 44 | function classifyRules(rules: RuleItem[], config: W2XScriptsConfig, isInit = false) { 45 | if (isInit) Object.values(RulesCache).forEach(cache => cache.clear()); 46 | 47 | const { ruleInclude = [], ruleExclude = [] } = config; 48 | rules.forEach((rule, idx) => { 49 | if (!rule || !ruleFormat(rule)) return; 50 | RulesCache.all.set(rule.ruleId, rule); 51 | 52 | let disabled = rule.disabled; 53 | if (disabled == null) disabled = ruleExclude.includes(rule.ruleId) || (ruleInclude.length && !ruleInclude.includes(rule.ruleId)); 54 | 55 | if (disabled) { 56 | logger.log(`规则已${rule.disabled ? color.red('禁用') : color.gray('过滤')}: [${rule.on}][${rule.ruleId}][${rule.desc}]`); 57 | if (RulesCache[rule.on]?.has(rule.ruleId)) RulesCache[rule.on]!.delete(rule.ruleId); 58 | return; 59 | } 60 | 61 | if (!RulesCache[rule.on]) RulesCache[rule.on] = new Map(); 62 | 63 | if (isInit && RulesCache[rule.on]!.has(rule.ruleId)) { 64 | const preRule = RulesCache[rule.on]!.get(rule.ruleId); 65 | logger.warn(`[${idx}]存在ruleId重复的规则:[${magenta(rule.ruleId)}]\n - ${cyan(preRule._source)}\n - ${cyan(rule._source)}`); 66 | } 67 | 68 | RulesCache[rule.on]!.set(rule.ruleId, rule); 69 | logger.info(`Load Rule:[${rule.on}][${cyan(rule.ruleId)}]`, green(rule.desc)); 70 | }); 71 | 72 | const s = [...Object.entries(RulesCache)].filter(d => d[0] !== 'all').map(d => `${cyan(d[0])}: ${green(d[1].size)}`); 73 | logger.info(`脚本规则总数:${green(RulesCache.all.size)} 本次加载:${magentaBright(rules.length)} 已启用:[${s.join(', ')}]`); 74 | } 75 | 76 | function loadRules(filepaths: string[] = [], isInit = false) { 77 | if (!Array.isArray(filepaths)) filepaths = [filepaths]; 78 | 79 | const filesSet = new Set(); 80 | const findRuleFiles = (filepath: string) => { 81 | if (!filepath || typeof filepath !== 'string' || !existsSync(filepath)) return; 82 | 83 | filepath = resolve(process.cwd(), filepath); 84 | 85 | if (statSync(filepath).isDirectory()) { 86 | readdirSync(filepath) 87 | .filter(d => /rules|src|vip|active|(\.c?js)$/.test(d)) 88 | .forEach(d => findRuleFiles(resolve(filepath, d))); 89 | } else if (/\.c?js/.test(filepath)) { 90 | filesSet.add(filepath); 91 | Watcher.add(filepath, onRuleFileChange, false); 92 | } 93 | }; 94 | 95 | // 合入当前目录下名称包含 `x-scripts-rule` 的文件或目录 96 | new Set(filepaths.filter(Boolean).map(d => resolve(d))).forEach(d => { 97 | findRuleFiles(d); 98 | Watcher.add(d, onRuleFileChange, false); 99 | }); 100 | 101 | const rules = new Map(); 102 | for (let filepath of filesSet) { 103 | try { 104 | clearRequireCache(filepath); 105 | const rule = require(filepath); 106 | if (!rule) continue; 107 | if (Array.isArray(rule)) 108 | rule.forEach((d: RuleItem) => { 109 | if (ruleFormat(d) && !rules.has(d.ruleId)) { 110 | if (!d._source) d._source = filepath; 111 | rules.set(d.ruleId, d); 112 | } 113 | }); 114 | else if (typeof rule === 'object' && ruleFormat(rule) && !rules.has(rule.ruleId)) { 115 | if (!rule._source) rule._source = filepath; 116 | rules.set(rule.ruleId, rule); 117 | } 118 | } catch (e) { 119 | console.error(e); 120 | logger.warn('从文件加载规则失败:', color.yellow(filepath), color.red(e.message)); 121 | } 122 | } 123 | 124 | if (isInit && rules.size) { 125 | logger.debug( 126 | `加载的规则:\n - ${[...rules].map(([ruleId, d]) => `[${green(ruleId)}]${d.desc || d.url}`).join('\n - ')}`, 127 | `\n加载的文件:\n - ${[...filesSet].join('\n - ')}` 128 | ); 129 | logger.info(`从 ${cyan(filesSet.size)} 个文件中发现了 ${greenBright(rules.size)} 个自定义规则`); 130 | } 131 | 132 | return rules; 133 | } 134 | 135 | function changeRuleStatus(rule: RuleItem, status: boolean, config = getConfig()) { 136 | if (typeof rule === 'string') rule = RulesCache.all.get(rule); 137 | if (!rule?.ruleId || !RulesCache.all.has(rule.ruleId)) return false; 138 | 139 | rule.disabled = status; 140 | classifyRules([rule], config, false); 141 | return true; 142 | } 143 | 144 | function removeRule(ruleId?: string[], filepath?: string[]) { 145 | let count = 0; 146 | if (!ruleId && !filepath) return count; 147 | const ruleSet = new Set(ruleId ? ruleId : []); 148 | const fileSet = new Set(filepath ? filepath : []); 149 | 150 | for (const [type, item] of Object.entries(rulesManager.rules)) { 151 | for (const [ruleId, rule] of item) { 152 | if (ruleSet.has(ruleId) || fileSet.has(rule._source)) { 153 | item.delete(ruleId); 154 | if (type === 'all') count++; 155 | } 156 | } 157 | } 158 | 159 | return count; 160 | } 161 | 162 | const onRuleFileChange: WatcherOnChange = (type, filepath) => { 163 | if (type === 'del') { 164 | const count = removeRule(void 0, [filepath]); 165 | logger.info(`文件已删除: ${color.yellow(filepath)},删除了 ${color.cyan(count)} 条规则。`); 166 | } else { 167 | const rules = loadRules([filepath], false); 168 | if (rules.size) classifyRules([...rules.values()], getConfig(), false); 169 | logger.info(`文件${type === 'add' ? '新增' : '修改'}: ${color.yellow(filepath)},更新了条 ${color.cyan(rules.size)} 规则`); 170 | } 171 | }; 172 | 173 | export const rulesManager = { 174 | rules: RulesCache, 175 | ruleFormat, 176 | classifyRules, 177 | loadRules, 178 | changeRuleStatus, 179 | removeRule, 180 | // onRuleFileChange, 181 | }; 182 | -------------------------------------------------------------------------------- /src/lib/update.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-11 13:38:34 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-08-09 09:01:16 6 | * @Description: 7 | */ 8 | import fs from 'node:fs'; 9 | import { color } from '@lzwme/fe-utils'; 10 | import type { EnvConfig, RuleItem } from '../../typings'; 11 | import { getConfig } from './getConfig'; 12 | import { logger } from './helper'; 13 | import { X } from './X'; 14 | import { QingLoing, type QLResponse, type QLEnvItem } from './QingLong'; 15 | 16 | const { green, magenta, gray, cyan } = color; 17 | const updateCache = { qlEnvList: [] as QLEnvItem[], updateTime: 0 }; 18 | 19 | export async function updateToQlEnvConfig(envConfig: EnvConfig, updateEnvValue?: RuleItem['updateEnvValue']) { 20 | const config = getConfig(); 21 | const ql = QingLoing.getInstance({ ...config.ql, debug: config.debug }); 22 | if (!(await ql.login())) return; 23 | 24 | if (Date.now() - updateCache.updateTime > 1000 * 60 * 60 * 1) updateCache.qlEnvList = []; 25 | const { name, value, desc, sep = '\n' } = envConfig; 26 | let item = updateCache.qlEnvList.find(d => d.name === name); 27 | if (!item) { 28 | updateCache.qlEnvList = await ql.getEnvList(); 29 | updateCache.updateTime = Date.now(); 30 | item = updateCache.qlEnvList.find(d => d.name === name); 31 | } 32 | 33 | let r: QLResponse; 34 | const params: Partial = { name, value }; 35 | if (desc) params.remarks = desc; 36 | 37 | if (item) { 38 | if (item.value.includes(value)) { 39 | logger.log(`[${magenta('updateToQL')}]${cyan(name)} is already ${gray(value)}`); 40 | return; 41 | } 42 | 43 | if (updateEnvValue) { 44 | if (updateEnvValue instanceof RegExp) params.value = updateEnvValueByRegExp(updateEnvValue, envConfig, item.value); 45 | else params.value = await updateEnvValue(envConfig, item.value, X); 46 | } else if (value.includes('##') && item.value.includes('##')) { 47 | // 支持配置以 ## 隔离 uid 48 | params.value = updateEnvValueByRegExp(/##([a-z0-9_\-*]+)/i, envConfig, item.value); 49 | } 50 | 51 | if (!params.value) return; 52 | 53 | if (params.value.length + 10 < item.value.length) { 54 | logger.warn(`[QL]更新值长度小于原始值!\nOLD: ${item.value}\nNEW: ${params.value}`); 55 | } 56 | 57 | params.id = item.id; 58 | params.remarks = desc || item.remarks || ''; 59 | item.value = params.value; 60 | r = await ql.updateEnv(params as QLEnvItem); 61 | } else { 62 | r = await ql.addEnv([params as QLEnvItem]); 63 | } 64 | 65 | const isSuccess = r.code === 200; 66 | const count = params.value.includes(sep) ? params.value.trim().split(sep).length : 1; 67 | logger.info(`${item ? green('更新') : magenta('新增')}QL环境变量[${green(name)}][${count}]`, isSuccess ? '成功' : r); 68 | 69 | return value; 70 | } 71 | 72 | export async function updateEnvConfigFile(envConfig: EnvConfig, updateEnvValue: RuleItem['updateEnvValue'], filePath: string) { 73 | if (!filePath) return; 74 | 75 | let { name, value, desc } = envConfig; 76 | let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; 77 | const isExist = content.includes(`export ${name}=`); 78 | 79 | if (isExist) { 80 | const oldValue = content.match(new RegExp(`export ${name}="([^"]+)"`))?.[1] || ''; 81 | 82 | if (oldValue.includes(value)) { 83 | logger.log(`[UpdateEnv]${color.cyan(name)} 已存在`, color.gray(value)); 84 | return; 85 | } 86 | 87 | if (updateEnvValue) { 88 | if (updateEnvValue instanceof RegExp) value = updateEnvValueByRegExp(updateEnvValue, envConfig, oldValue); 89 | else value = (await updateEnvValue(envConfig, oldValue, X)) as string; 90 | if (!value) return; 91 | } else if (value.includes('##') && value.includes('##')) { 92 | // 支持配置以 ## 隔离 uid 93 | value = updateEnvValueByRegExp(/##([a-z0-9_\-*]+)/i, envConfig, value); 94 | } 95 | 96 | content = content.replace(`export ${name}="${oldValue}"`, `export ${name}="${value}"`); 97 | } else { 98 | if (desc) content += `\n# ${desc}`; 99 | content += `\nexport ${name}="${value}"`; 100 | } 101 | await fs.promises.writeFile(filePath, content, 'utf8'); 102 | logger.info(`[${cyan(filePath)}]${isExist ? green('更新') : magenta('新增')}环境变量`, `${green(name)}="${gray(value)}"`); 103 | return value; 104 | } 105 | 106 | /** 更新处理已存在的环境变量,返回合并后的结果。若无需修改则可返回空 */ 107 | function updateEnvValueByRegExp(re: RegExp, { name, value, sep }: EnvConfig, oldValue: string) { 108 | if (!(re instanceof RegExp)) throw Error(`[${name}]updateEnvValue 应为一个正则匹配表达式`); 109 | 110 | const sepList = ['\n', '&']; 111 | const oldSep = sep || sepList.find(d => oldValue.includes(d)); 112 | const curSep = sep || sepList.find(d => value.includes(d)); 113 | if (!sep) sep = oldSep || curSep || '\n'; 114 | // if (sep !== '&') value = value.replaceAll('&', sep); 115 | 116 | const val: string[] = []; 117 | const values = value.split(curSep || sep).map(d => ({ value: d, id: d.match(re)?.[0] })); 118 | 119 | oldValue.split(oldSep || sep).forEach(cookie => { 120 | if (!cookie) return; 121 | 122 | const uidValue = cookie.match(re)?.[0]; 123 | if (uidValue) { 124 | const item = values.find(d => d.id === uidValue); 125 | val.push(item ? item.value : cookie); 126 | } else { 127 | logger.warn(`[${name}][updateEnvValueByRegExp]oldValue未匹配到uid`, re.toString(), cookie); 128 | val.push(cookie); 129 | } 130 | }); 131 | 132 | values.forEach(d => !val.includes(d.value) && val.push(d.value)); 133 | 134 | return val.join(sep); 135 | } 136 | -------------------------------------------------------------------------------- /src/lib/w2RulesManage.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-22 14:00:13 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-05-08 13:59:33 6 | * @Description: 7 | */ 8 | import { existsSync, readFileSync } from 'node:fs'; 9 | import { resolve } from 'node:path'; 10 | import { color } from '@lzwme/fe-utils'; 11 | import { Watcher } from './watch'; 12 | import { logger } from './helper'; 13 | import type { WhistleRuleItem } from '../../typings'; 14 | import { isMatch } from 'micromatch'; 15 | 16 | type W2RuleMapItem = { rules: string[]; values?: Record; config?: WhistleRuleItem }; 17 | 18 | const rulesMap = new Map(); 19 | 20 | function formatToWhistleRules(content: string, config?: WhistleRuleItem) { 21 | const data: W2RuleMapItem = { rules: [], values: {}, config }; 22 | if (!content?.trim()) return data; 23 | 24 | try { 25 | const cfg = JSON.parse(content.trim()); 26 | // todo: 支持远程加载完整的配置 27 | if (Array.isArray(cfg) && Array.isArray(cfg[0].rules)) { 28 | handlerW2RuleFiles(cfg); 29 | } else { 30 | if (Array.isArray(cfg.rules)) data.rules.push(...cfg.rules); 31 | if (cfg.values && typeof cfg.values === 'object') Object.assign(data.values, cfg.values); 32 | } 33 | } catch { 34 | data.rules.push(content.trim()); 35 | } 36 | 37 | return data; 38 | } 39 | 40 | function getW2RuleKey(ruleConfig: WhistleRuleItem) { 41 | if (!ruleConfig) return ''; 42 | if (ruleConfig.path) ruleConfig.path = resolve(ruleConfig.path); 43 | if (!ruleConfig.url?.startsWith('http')) ruleConfig.url = ''; 44 | 45 | return ruleConfig.path || ruleConfig.url || ruleConfig; 46 | } 47 | 48 | export async function handlerW2RuleFiles(ruleConfig: WhistleRuleItem): Promise { 49 | if (typeof ruleConfig === 'string') ruleConfig = (ruleConfig as string).startsWith('http') ? { url: ruleConfig } : { path: ruleConfig }; 50 | 51 | const ruleKey = getW2RuleKey(ruleConfig); 52 | 53 | try { 54 | if (!ruleKey || rulesMap.has(ruleKey)) return false; 55 | // if (typeof ruleConfig === 'function') return rulesMap.set(ruleKey, await ruleConfig()); 56 | 57 | rulesMap.set(ruleKey, { rules: [], values: {}, config: ruleConfig }); 58 | const data = rulesMap.get(ruleKey); 59 | 60 | if (ruleConfig.rules) data.rules.push(...ruleConfig.rules); 61 | if (ruleConfig.values) data.values = { ...ruleConfig.values }; 62 | 63 | if (ruleConfig.url) { 64 | if (ruleConfig.type === 'pac' || ruleConfig.url.endsWith('.pac.js')) { 65 | data.rules.push(`* pac://${ruleConfig.url}`); 66 | } else { 67 | const content = await fetch(ruleConfig.url).then(d => d.text()); 68 | 69 | const d = formatToWhistleRules(content); 70 | if (d.rules.length) data.rules.push(...d.rules); 71 | if (d.rules) Object.assign(data.values, d.values); 72 | } 73 | } 74 | 75 | if (ruleConfig.path) { 76 | if (existsSync(ruleConfig.path)) { 77 | Watcher.remove(ruleConfig.path, false); 78 | Watcher.add( 79 | ruleConfig.path, 80 | (type, f) => { 81 | if (type === 'del' && rulesMap.has(f)) rulesMap.delete(f); 82 | else { 83 | logger.log(`[${type === 'add' ? '新增' : '更新'}]W2规则文件:`, color.cyan(f)); 84 | const d = formatToWhistleRules(readFileSync(f, 'utf8'), ruleConfig); 85 | if (d.rules.length) rulesMap.set(f, d); 86 | } 87 | }, 88 | true, 89 | ['txt', 'conf', 'yaml', 'json'] 90 | ); 91 | logger.debug('加载 w2 规则文件:', color.cyan(ruleConfig.path)); 92 | } else { 93 | logger.warn('whistle rule 规则文件不存在', color.yellow(ruleConfig.path)); 94 | } 95 | } 96 | 97 | if (!data.rules.length) rulesMap.delete(ruleKey); 98 | } catch (error) { 99 | logger.error('whistle rule 规则解析失败', color.red(ruleKey), error); 100 | } 101 | } 102 | 103 | function removeW2Rules(whistleRules: WhistleRuleItem[]) { 104 | whistleRules.forEach(d => { 105 | const ruleKey = getW2RuleKey(d); 106 | if (ruleKey) { 107 | rulesMap.delete(ruleKey); 108 | if (d.path) Watcher.remove(d.path, false); 109 | } 110 | }); 111 | } 112 | 113 | export function loadW2Rules(whistleRules: WhistleRuleItem[], action: 'clear' | 'purge' = 'purge') { 114 | if (action === 'clear') rulesMap.clear(); 115 | else if (action === 'purge') removeW2Rules([...rulesMap.values()].filter(d => !whistleRules.includes(d.config))); 116 | 117 | if (whistleRules.length) { 118 | whistleRules.forEach(d => handlerW2RuleFiles(d)); 119 | logger.info('w2 规则已加载:', rulesMap.size); 120 | } 121 | } 122 | 123 | const cache = { 124 | w2Rules: { 125 | value: {} as Record, 126 | update: 0, 127 | }, 128 | }; 129 | 130 | export function getW2Rules(req: Whistle.PluginRequest) { 131 | const now = Date.now(); 132 | 133 | // 30s 缓存 134 | if (now - cache.w2Rules.update > 30_000) { 135 | const rulesSet = new Set(); 136 | const values = {} as W2RuleMapItem['values']; 137 | 138 | rulesMap.forEach(item => { 139 | item.rules.forEach(r => rulesSet.add(r)); 140 | Object.assign(values, item.values); 141 | }); 142 | 143 | const rules = [...rulesSet] 144 | .join('\n') 145 | .split('\n') 146 | .map(d => d.trim()) 147 | .filter(d => d && d.startsWith('#') && d.includes(' ') && d.includes('//')); 148 | cache.w2Rules.value = { rules, values }; 149 | } 150 | 151 | const { rules, values } = cache.w2Rules.value; 152 | const { fullUrl } = req; 153 | 154 | for (const line of rules) { 155 | let isMatched = false; 156 | let url: string | RegExp = line.split(' ')[0]; 157 | if ((url as string).startsWith('^http')) url = new RegExp(url); 158 | 159 | if (url instanceof RegExp) isMatched = url.test(fullUrl); 160 | else isMatched = fullUrl.includes(url) || isMatch(fullUrl, url); 161 | 162 | // 性能考虑,暂只支持一个规则 163 | if (isMatched) return { rules: [line], values }; 164 | } 165 | 166 | return; 167 | } 168 | -------------------------------------------------------------------------------- /src/lib/watch.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-02-07 13:38:29 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-03-01 13:30:04 6 | * @Description: 7 | */ 8 | import { existsSync, readdirSync, statSync } from 'node:fs'; 9 | import { extname, resolve } from 'node:path'; 10 | import { color } from '@lzwme/fe-utils'; 11 | import { logger } from './helper'; 12 | 13 | export type WatcherOnChange = (type: 'update' | 'del' | 'add', filepath: string) => void; 14 | 15 | const watcherCache = new Map(); 16 | let timer: NodeJS.Timeout; 17 | 18 | function checkFile(filepath: string, config = watcherCache.get(filepath)) { 19 | if (existsSync(filepath)) { 20 | const stat = statSync(filepath); 21 | 22 | if (!config.ext) config.ext = ['js', 'cjs']; 23 | 24 | if (stat.isFile()) { 25 | if (config.mtime !== stat.mtimeMs) { 26 | const type = config.mtime ? 'update' : 'add'; 27 | logger.log(`[Watcher]${type}:`, color.cyan(filepath)); 28 | config.onchange(type, filepath); 29 | watcherCache.set(filepath, { ...config, mtime: stat.mtimeMs }); 30 | } 31 | } else if (stat.isDirectory()) { 32 | readdirSync(filepath).forEach(filename => { 33 | const fpath = resolve(filepath, filename); 34 | 35 | if (!watcherCache.has(fpath)) { 36 | const ext = extname(filename).slice(1); 37 | 38 | if (config.ext.includes(ext)) { 39 | Watcher.add(fpath, config.onchange, true); 40 | } else if (statSync(fpath).isDirectory() && /rules|src|vip/.test(filename)) { 41 | checkFile(fpath, { ...config, mtime: 0 }); 42 | } 43 | } 44 | }); 45 | } 46 | } else { 47 | Watcher.remove(filepath); 48 | } 49 | } 50 | 51 | function checkWatch(interval: number) { 52 | watcherCache.forEach((config, filepath) => checkFile(filepath, config)); 53 | clearTimeout(timer); 54 | timer = setTimeout(() => checkWatch(interval), interval); 55 | } 56 | 57 | export const Watcher = { 58 | add(filepath: string, onchange: WatcherOnChange, initRun = false, ext?: string[]) { 59 | if (existsSync(filepath) && !watcherCache.has(filepath)) { 60 | watcherCache.set(filepath, { mtime: initRun ? 0 : statSync(filepath).mtimeMs, onchange, ext }); 61 | if (initRun) checkFile(filepath, watcherCache.get(filepath)); 62 | logger.debug(`Watcher file added: [${filepath}]. cache size:`, watcherCache.size); 63 | } 64 | }, 65 | remove(filepath: string, emitEvent = true) { 66 | if (watcherCache.has(filepath)) { 67 | if (emitEvent) { 68 | const config = watcherCache.get(filepath); 69 | config.onchange('del', filepath); 70 | } 71 | 72 | watcherCache.delete(filepath); 73 | if (statSync(filepath).isDirectory()) { 74 | watcherCache.forEach((_config, key) => { 75 | if (key.startsWith(filepath)) Watcher.remove(key, emitEvent); 76 | }); 77 | } 78 | 79 | logger.log('[Watcher]removed:', color.yellow(filepath), `. cache size:`, watcherCache.size); 80 | } 81 | }, 82 | clear(emitEvent = true) { 83 | logger.info('Watcher cleaning. cache size:', watcherCache.size); 84 | if (emitEvent) watcherCache.forEach((config, key) => config.onchange('del', key)); 85 | watcherCache.clear(); 86 | }, 87 | start: (interval: number | boolean = 3000) => { 88 | if (timer) return; 89 | checkWatch(Math.max(1000, +interval || 0)); 90 | logger.info('Watcher started. cache size:', watcherCache.size); 91 | }, 92 | stop: (clear = false) => { 93 | if (timer) { 94 | clearTimeout(timer); 95 | if (clear) Watcher.clear(); 96 | logger.info('Watcher stoped. cache size:', watcherCache.size); 97 | timer = null; 98 | } 99 | }, 100 | }; 101 | -------------------------------------------------------------------------------- /src/reqRead.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 3 | req.pipe(res); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/reqWrite.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 3 | req.pipe(res); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/resRead.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 3 | req.pipe(res); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/resRulesServer.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 3 | res.end(); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/resStatsServer.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 3 | // do something 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/resWrite.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 3 | req.pipe(res); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/rulesServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-22 14:00:13 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-05-08 14:01:31 6 | * @Description: 7 | */ 8 | import { handlerW2RuleFiles, getW2Rules } from './lib/w2RulesManage'; 9 | 10 | export function rulesServer(server: Whistle.PluginServer, options: Whistle.PluginOptions) { 11 | server.on('request', async (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 12 | const rulePath = req.originalReq.ruleValue; 13 | if (rulePath) { 14 | const isUrl = rulePath.startsWith('http'); 15 | await handlerW2RuleFiles({ path: isUrl ? '' : rulePath, url: isUrl ? rulePath : '' }); 16 | } 17 | 18 | const rules = getW2Rules(req); 19 | rules ? res.end(rules) : res.end(); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-22 14:00:13 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-09-19 11:05:28 6 | * @Description: 7 | */ 8 | import { color } from '@lzwme/fe-utils'; 9 | import { toQueryString } from '@lzwme/fe-utils/cjs/common/url'; 10 | import { logger } from './lib/helper'; 11 | import { ruleHandler } from './lib/ruleHandler'; 12 | import { rulesManager } from './lib/rulesManager'; 13 | import * as util from './util/util'; 14 | 15 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 16 | logger.info(color.green(options.name), '插件已加载'); 17 | 18 | // handle http request 19 | server.on('request', async (req: Whistle.PluginServerRequest, res: Whistle.PluginServerResponse) => { 20 | try { 21 | if (util.isRemote(req)) return req.passThrough(); 22 | 23 | const { method, headers, fullUrl: url } = req; 24 | const reqHeaderRules = rulesManager.rules['req-header']; 25 | logger.trace('[request]', color.cyan(method), color.gray(url)); 26 | 27 | if (reqHeaderRules?.size > 0) { 28 | for (const rule of reqHeaderRules.values()) { 29 | try { 30 | const r = await ruleHandler({ req, rule, res }); 31 | if (r.body != null) return res.end(util.toBuffer(r.body) || ''); 32 | } catch (e) { 33 | console.error(e); 34 | logger.error(`[ruleHandler][${color.red(rule.ruleId)}]err`, (e as Error).message || e); 35 | } 36 | } 37 | } 38 | 39 | let reqBody: Buffer | Record; 40 | req.passThrough( 41 | async (body, next, ctx) => { 42 | reqBody = body; 43 | 44 | const mockRules = rulesManager.rules['req-body']; 45 | if (mockRules?.size > 0) { 46 | for (const rule of mockRules.values()) { 47 | try { 48 | const r = await ruleHandler({ req: ctx, rule, res, reqBody: body }); 49 | if (r.body != null) return res.end(util.toBuffer(r.body) || ''); 50 | if (r.reqBody) { 51 | if (typeof r.reqBody === 'object' && !Buffer.isBuffer(r.reqBody)) { 52 | r.reqBody = util.isJSON(headers, true) ? JSON.stringify(r.reqBody) : toQueryString(r.reqBody); 53 | } 54 | body = util.toBuffer(r.reqBody); 55 | // req.writeHead(0, { 'content-length': Buffer.from(r.reqBody).byteLength }); 56 | } 57 | 58 | reqBody = r.reqBody || (ctx as any)._reqBody || body; 59 | } catch (e) { 60 | logger.error('[ruleHandler]err', rule.ruleId, (e as Error).message); 61 | console.error(e); 62 | } 63 | } 64 | } 65 | 66 | next({ body }); 67 | }, 68 | async (body, next, ctx) => { 69 | const resBodyRules = rulesManager.rules['res-body']; 70 | 71 | if (resBodyRules?.size > 0) { 72 | for (const rule of resBodyRules.values()) { 73 | try { 74 | const r = await ruleHandler({ rule, req, res: ctx, resBody: body, reqBody }); 75 | if (r.body != null) { 76 | return next({ body: Buffer.isBuffer(r.body) || typeof r.body === 'string' ? r.body : JSON.stringify(r.body) }); 77 | } 78 | } catch (e) { 79 | logger.error('[ruleHandler]err', rule.ruleId, (e as Error).message); 80 | console.error(e); 81 | } 82 | } 83 | } 84 | 85 | next({ body }); 86 | } 87 | ); 88 | } catch (error) { 89 | logger.log(error.toString()); 90 | res.setHeader('Content-Type', 'text/plain; charset=utf-8'); 91 | res.end(error.toString()); 92 | } 93 | }); 94 | 95 | // // handle websocket request 96 | // server.on('upgrade', (req: Whistle.PluginServerRequest, socket: Whistle.PluginServerSocket) => { 97 | // // do something 98 | // req.passThrough(); 99 | // }); 100 | // // handle tunnel request 101 | // server.on('connect', (req: Whistle.PluginServerRequest, socket: Whistle.PluginServerSocket) => { 102 | // // do something 103 | // req.passThrough(); 104 | // }); 105 | // server.on('error', err => logger.error(err)); 106 | }; 107 | -------------------------------------------------------------------------------- /src/sniCallback.ts: -------------------------------------------------------------------------------- 1 | export default async (req: Whistle.PluginSNIRequest, options: Whistle.PluginOptions) => { 2 | // return { key, cert }; // 可以返回 false、证书 { key, cert }、及其它 3 | }; 4 | -------------------------------------------------------------------------------- /src/statsServer.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 3 | // do something 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/tunnelReqRead.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/tunnelReqWrite.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/tunnelResRead.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/tunnelResWrite.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/tunnelRulesServer.ts: -------------------------------------------------------------------------------- 1 | import { isMatch } from 'micromatch'; 2 | import { getConfig } from './lib/getConfig'; 3 | import { ruleMatcher } from './lib/ruleHandler'; 4 | import { rulesManager } from './lib/rulesManager'; 5 | 6 | function mitmMatch(req: Whistle.PluginRequest) { 7 | if (rulesManager.rules['res-body'].size === 0) return; 8 | 9 | const host = (req.headers.host || new URL(req.originalReq.fullUrl).host).split(':')[0]; 10 | const resBodyRules = rulesManager.rules['res-body'].values(); 11 | 12 | for (const rule of resBodyRules) { 13 | if (rule.mitm) { 14 | const ok = (rule.mitm as (string | RegExp)[]).some(d => (d instanceof RegExp ? d.test(host) : isMatch(host, d))); 15 | if (ok) return host; 16 | } 17 | 18 | const msg = ruleMatcher({ rule, req: req as unknown as Whistle.PluginServerRequest, res: null }); 19 | if (!msg) return host; 20 | } 21 | } 22 | 23 | export function tunnelRulesServer(server: Whistle.PluginServer, _options: Whistle.PluginOptions) { 24 | server.on('request', (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 25 | const { isSNI, enableCapture } = req.originalReq; 26 | if (enableCapture || !isSNI || getConfig().enableMITM === false) return res.end(); 27 | 28 | const host = mitmMatch(req); 29 | host ? res.end(`${host} enable://intercept`) : res.end(); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/util/dataSource.ts: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require('events'); 2 | 3 | const dataSource = new EventEmitter(); 4 | dataSource.setMaxListeners(1000); 5 | 6 | export default dataSource; 7 | -------------------------------------------------------------------------------- /src/util/util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * 通用工具类方法 3 | * from https://github.com/whistle-plugins/whistle.script/blob/master/lib/util.js 4 | */ 5 | 6 | import zlib from 'node:zlib'; 7 | import { EventEmitter } from 'node:events'; 8 | import { parse as parseUrl } from 'node:url'; 9 | import http from 'node:http'; 10 | import https from 'node:https'; 11 | import dataSource from './dataSource'; 12 | 13 | export const AUTH_URL = 'x-whistle-.script-auth-url'; 14 | export const SNI_URL = 'x-whistle-.script-sni-url'; 15 | export const REQ_RULES_URL = 'x-whistle-.script-req-rules-url'; 16 | export const RES_RULES_URL = 'x-whistle-.script-res-rules-url'; 17 | export const STATS_URL = 'x-whistle-.script-stats-url'; 18 | export const DATA_URL = 'x-whistle-.script-data-url'; 19 | export const noop = () => {}; 20 | 21 | const PREFIX_LEN = 'x-whistle-.script-'.length; 22 | const POLICY = 'x-whistle-.script-policy'; 23 | export const isFunction = (fn: unknown) => typeof fn === 'function'; 24 | const URL_RE = /^https?:(?:\/\/|%3A%2F%2F)[\w.-]/; 25 | 26 | export const getCharset = (headers: http.IncomingHttpHeaders) => { 27 | if (/charset=([^\s]+)/.test(headers['content-type'])) { 28 | return RegExp.$1; 29 | } 30 | return 'utf8'; 31 | }; 32 | 33 | export const isText = (headers: http.IncomingHttpHeaders) => { 34 | const type = headers['content-type']; 35 | return !type || (!isBinary(headers) && /javascript|css|html|json|xml|application\/x-www-form-urlencoded|text\//i.test(type)); 36 | }; 37 | 38 | export const isJSON = (headers: http.IncomingHttpHeaders, isStrict = false) => { 39 | const type = headers['content-type']; 40 | if (!type && isStrict) return false; 41 | return !type || type.includes('json'); 42 | }; 43 | 44 | export const isBinary = (headers: http.IncomingHttpHeaders) => { 45 | const type = headers['content-type']; 46 | return type && /image|stream|video\//.test(type); 47 | }; 48 | 49 | export const unzipBody = (headers: http.IncomingHttpHeaders, body: Buffer, callback: (err: Error | null, d: any) => void) => { 50 | let unzip; 51 | let encoding = headers['content-encoding']; 52 | if (body && typeof encoding === 'string') { 53 | encoding = encoding.trim().toLowerCase(); 54 | if (encoding === 'gzip') { 55 | unzip = zlib.gunzip.bind(zlib); 56 | } else if (encoding === 'deflate') { 57 | unzip = zlib.inflate.bind(zlib); 58 | } 59 | } 60 | if (!unzip) { 61 | return callback(null, body); 62 | } 63 | unzip(body, (err: Error, data: string) => { 64 | if (err) { 65 | return zlib.inflateRaw(body, callback); 66 | } 67 | callback(null, data); 68 | }); 69 | }; 70 | 71 | export const getStreamBuffer = (stream: any) => { 72 | return new Promise((resolve, reject) => { 73 | let buffer: Buffer; 74 | stream.on('data', (data: Buffer) => { 75 | buffer = buffer ? Buffer.concat([buffer, data]) : data; 76 | }); 77 | stream.on('end', () => { 78 | unzipBody(stream.headers, buffer, (err, data) => { 79 | if (err) { 80 | reject(err); 81 | } else { 82 | resolve(data || null); 83 | } 84 | }); 85 | }); 86 | stream.on('error', reject); 87 | }); 88 | }; 89 | 90 | export const setupContext = (ctx: any, options: any) => { 91 | ctx.options = options; 92 | ctx.fullUrl = ctx.req.originalReq.url; 93 | }; 94 | 95 | export const formateRules = (ctx: any) => { 96 | if (ctx.rules || ctx.values) { 97 | return { 98 | rules: Array.isArray(ctx.rules) ? ctx.rules.join('\n') : `${ctx.rules}`, 99 | values: ctx.values, 100 | }; 101 | } 102 | }; 103 | 104 | export const responseRules = (ctx: any) => { 105 | if (!ctx.body) { 106 | ctx.body = formateRules(ctx); 107 | } 108 | }; 109 | 110 | export const getDataSource = () => { 111 | const ds = new EventEmitter(); 112 | const handleData = (type: string, args: any[]) => { 113 | ds.emit(type, ...args); 114 | }; 115 | dataSource.on('data', handleData); 116 | return { 117 | dataSource: ds, 118 | clearup: () => { 119 | dataSource.removeListener('data', handleData); 120 | ds.removeAllListeners(); 121 | }, 122 | }; 123 | }; 124 | 125 | export const getContext = (req: Whistle.PluginRequest, res: Whistle.PluginResponse) => { 126 | const fullUrl = req.originalReq.url; 127 | return { 128 | req, 129 | res, 130 | fullUrl, 131 | url: fullUrl, 132 | headers: req.headers, 133 | method: req.method, 134 | }; 135 | }; 136 | 137 | export const getFn = (f1: F1, f2?: F2) => { 138 | if (isFunction(f1)) { 139 | return f1; 140 | } 141 | if (isFunction(f2)) { 142 | return f2; 143 | } 144 | }; 145 | 146 | const req = (url: string, headers: http.IncomingHttpHeaders, data: any) => { 147 | return new Promise((resolve, reject) => { 148 | if (!url) { 149 | resolve(void 0); 150 | return; 151 | } 152 | 153 | const options: https.RequestOptions = parseUrl(url); 154 | options.headers = Object.assign({}, headers); 155 | delete options.headers.host; 156 | if (data) { 157 | data = Buffer.from(JSON.stringify(data)); 158 | options.method = 'POST'; 159 | options.headers['content-type'] = 'application/json'; 160 | } 161 | 162 | const httpModule = options.protocol === 'https:' ? https : http; 163 | options.rejectUnauthorized = false; 164 | const client = httpModule.request(options, res => { 165 | res.on('error', handleError); // eslint-disable-line 166 | let body: Buffer; 167 | res.on('data', chunk => { 168 | body = body ? Buffer.concat([body, chunk]) : chunk; 169 | }); 170 | res.on('end', () => { 171 | clearTimeout(timer); // eslint-disable-line 172 | resolve(jsonParse(body) || ''); 173 | }); 174 | }); 175 | const handleError = (err: Error) => { 176 | clearTimeout(timer); // eslint-disable-line 177 | client.destroy(); 178 | reject(err); 179 | }; 180 | const timer = setTimeout(() => handleError(new Error('Timeout')), 12000); 181 | client.on('error', handleError); 182 | client.end(data); 183 | }); 184 | }; 185 | 186 | export const request = async (url: string, headers: http.IncomingHttpHeaders, data: string) => { 187 | try { 188 | return req(url, headers, data); 189 | } catch (e) { 190 | if (!data) { 191 | return req(url, headers, data); // get 请求异常则重试一次 192 | } 193 | } 194 | }; 195 | 196 | const hasPolicy = ({ headers, originalReq: { ruleValue } }: Whistle.PluginRequest, name: string) => { 197 | const policy = headers[POLICY]; 198 | if (typeof policy === 'string') { 199 | return policy.toLowerCase().indexOf(name) !== -1; 200 | } 201 | if (typeof ruleValue === 'string') { 202 | return ruleValue.indexOf(`=${name}`) !== -1 || ruleValue.indexOf(`&${name}`) !== -1; 203 | } 204 | }; 205 | 206 | export const isRemote = (req: Whistle.PluginRequest) => { 207 | return hasPolicy(req, 'remote'); 208 | }; 209 | 210 | export const isSni = (req: Whistle.PluginRequest) => { 211 | return hasPolicy(req, 'sni'); 212 | }; 213 | 214 | const getValue = ({ originalReq: req }: Whistle.PluginRequest, name: string) => { 215 | const { pluginVars, globalPluginVars } = req; 216 | const vars = globalPluginVars ? pluginVars.concat(globalPluginVars) : pluginVars; 217 | const len = vars && vars.length; 218 | if (!len) { 219 | return; 220 | } 221 | for (let i = 0; i < len; i++) { 222 | const item = vars[i]; 223 | const index = item.indexOf('='); 224 | if (index !== -1 && item.substring(0, index) === name) { 225 | return item.substring(index + 1); 226 | } 227 | } 228 | }; 229 | 230 | const getVarName = (name: string) => name.substring(PREFIX_LEN).replace(/-(.)/g, (_, ch) => ch.toUpperCase()); 231 | 232 | export const getRemoteUrl = (req: Whistle.PluginRequest, name: string) => { 233 | let url = req.headers[name]; 234 | if (url && typeof url === 'string') { 235 | url = decodeURIComponent(url); 236 | } else { 237 | url = getValue(req, getVarName(name)); 238 | } 239 | if (URL_RE.test(url as string)) { 240 | return url as string; 241 | } 242 | }; 243 | 244 | export function jsonParse(body: any) { 245 | try { 246 | if (body) { 247 | if (!Buffer.isBuffer(body) && typeof body === 'object') return body; 248 | const text = body.toString().trim(); 249 | if (text[0] === '[' || text[0] === '{') return JSON.parse(text); 250 | } 251 | } catch (e) {} 252 | } 253 | 254 | export function toBuffer(body: unknown) { 255 | if (!body) return; 256 | if (Buffer.isBuffer(body)) return body; 257 | return Buffer.from(typeof body !== 'string' ? JSON.stringify(body) : body); 258 | } 259 | -------------------------------------------------------------------------------- /src/wsReqRead.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/wsReqWrite.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/wsResRead.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/wsResWrite.ts: -------------------------------------------------------------------------------- 1 | export default (server: Whistle.PluginServer, options: Whistle.PluginOptions) => { 2 | server.on('connect', (req: Whistle.PluginRequest, socket: Whistle.PluginSocket) => { 3 | socket.pipe(socket); 4 | }); 5 | }; 6 | -------------------------------------------------------------------------------- /template/qx-tpl.js: -------------------------------------------------------------------------------- 1 | async function handler({ resBody, resHeader, reqBody, url, method, headers, X }) { 2 | return new Promise((resolve) => { 3 | const $done = (obj) => resolve(mock$Done(obj)); 4 | const $response = { 5 | body: typeof resBody === 'string' ? resBody : JSON.stringify(resBody), 6 | headers: resHeader, 7 | url, 8 | method, 9 | }; // mock response 10 | const $request = { url, method, headers, body: reqBody ? JSON.stringify(body) : '' }; 11 | // env mock 12 | const $environment = { 'stash-version': '1.0.0', 'surge-version': '1.0.0' }; 13 | // const $task = {}; // Quantumult X 14 | // const $rocket = {}; // Shadowrocket 15 | 16 | // 17 | }); 18 | } 19 | 20 | module.exports = handler; 21 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "module": "commonjs", 5 | "target": "ES2021" 6 | }, 7 | "extends": "./tsconfig.json", 8 | "include": ["src/index.ts", "src/init.ts", "typings"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "removeComments": true, 6 | "module": "commonjs", 7 | "target": "ES2021", 8 | "allowJs": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "skipLibCheck": true, 12 | "strictNullChecks": false, 13 | "moduleResolution": "node", 14 | "lib": ["DOM", "ESNext"], 15 | "typeRoots": ["./node_modules/@types", "./typings"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /typings/base.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { IncomingMessage, ServerResponse, Server } from 'http'; 4 | import { ParsedUrlQuery } from 'querystring'; 5 | import { Socket } from 'net'; 6 | import type * as FeUtils from '@lzwme/fe-utils'; 7 | 8 | declare global { 9 | namespace WhistleBase { 10 | class Request extends IncomingMessage {} 11 | class Response extends ServerResponse {} 12 | class HttpServer extends Server {} 13 | class Socks extends Socket {} 14 | type UrlQuery = ParsedUrlQuery; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/triple-slash-reference, @typescript-eslint/naming-convention */ 2 | 3 | /// 4 | 5 | declare module 'koa-onerror'; 6 | 7 | declare module 'micromatch' { 8 | export function isMatch(str: any, pattern: any): boolean; 9 | } 10 | 11 | declare namespace Whistle { 12 | type Body = string | false; 13 | 14 | interface LRUOptions { 15 | max?: number; 16 | maxAge?: number; 17 | length?(value: V, key?: K): number; 18 | dispose?(key: K, value: V): void; 19 | stale?: boolean; 20 | noDisposeOnSet?: boolean; 21 | } 22 | 23 | interface LRUEntry { 24 | k: K; 25 | v: V; 26 | e: number; 27 | } 28 | 29 | interface LRUCache { 30 | new (options?: LRUOptions): this; 31 | readonly length: number; 32 | readonly itemCount: number; 33 | allowStale: boolean; 34 | lengthCalculator(value: V): number; 35 | max: number; 36 | maxAge: number; 37 | set(key: K, value: V, maxAge?: number): boolean; 38 | get(key: K): V; 39 | peek(key: K): V; 40 | has(key: K): boolean; 41 | del(key: K): void; 42 | reset(): void; 43 | prune(): void; 44 | forEach(callbackFn: (this: T, value: V, key: K, cache: this) => void, thisArg?: T): void; 45 | rforEach(callbackFn: (this: T, value: V, key: K, cache: this) => void, thisArg?: T): void; 46 | keys(): K[]; 47 | values(): V[]; 48 | dump(): Array>; 49 | load(cacheEntries: ReadonlyArray>): void; 50 | } 51 | 52 | interface Frame { 53 | reqId: string; 54 | frameId: string; 55 | base64?: string; 56 | bin?: '' | Buffer; 57 | text?: string; 58 | mask?: boolean; 59 | compressed?: boolean; 60 | length?: number; 61 | opcode?: number; 62 | isClient?: boolean; 63 | err?: string; 64 | closed?: true; 65 | code?: string | number; 66 | [propName: string]: any; 67 | } 68 | 69 | interface Session { 70 | id: string; 71 | url: string; 72 | useH2?: boolean; 73 | isHttps?: boolean; 74 | startTime: number; 75 | dnsTime?: number; 76 | requestTime: number; 77 | responseTime: number; 78 | endTime?: number; 79 | req: { 80 | method?: string; 81 | httpVersion?: string; 82 | ip?: string; 83 | port?: string | number; 84 | rawHeaderNames?: object; 85 | headers: object; 86 | size?: number; 87 | body?: Body; 88 | base64?: Body; 89 | rawHeaders?: object; 90 | [propName: string]: any; 91 | }; 92 | res: { 93 | ip?: string; 94 | port?: string | number; 95 | rawHeaderNames?: object; 96 | statusCode?: number | string; 97 | statusMessage?: string; 98 | headers?: object; 99 | size?: number; 100 | body?: Body; 101 | base64?: Body; 102 | rawHeaders?: object; 103 | [propName: string]: any; 104 | }; 105 | rules: object; 106 | rulesHeaders?: object; 107 | frames?: Frame[]; 108 | [propName: string]: any; 109 | } 110 | 111 | interface File { 112 | index: number; 113 | name: string; 114 | data: string; 115 | selected: boolean; 116 | } 117 | 118 | interface Storage { 119 | new (dir: string, filters?: object, disabled?: boolean): this; 120 | count(): number; 121 | existsFile(file: string): false | File; 122 | getFileList(origin: boolean): File[]; 123 | writeFile(file: string, data: string): boolean; 124 | updateFile(file: string, data: string): boolean; 125 | readFile(file: string): string; 126 | removeFile(file: string): boolean; 127 | renameFile(file: string, newFile: string): boolean; 128 | moveTo(fromName: string, toName: string): boolean; 129 | setProperty(name: string, value: string): void; 130 | hasProperty(file: string): boolean; 131 | setProperties(obj: object): boolean; 132 | getProperty(name: string): any; 133 | removeProperty(name: string): void; 134 | } 135 | 136 | interface SharedStorage { 137 | getAll: () => Promise; 138 | setItem: (key: any, value?: any) => Promise; 139 | getItem: (key: string) => any; 140 | removeItem: (key: string) => any; 141 | } 142 | 143 | interface PluginOptions { 144 | name: string; 145 | version: string; 146 | debugMode?: boolean; 147 | CUSTOM_CERT_HEADER: string; 148 | ENABLE_CAPTURE_HEADER: string; 149 | RULE_VALUE_HEADER: string; 150 | RULE_PROTO_HEADER: string; 151 | SNI_VALUE_HEADER: string; 152 | RULE_URL_HEADER: string; 153 | MAX_AGE_HEADER: string; 154 | ETAG_HEADER: string; 155 | FULL_URL_HEADER: string; 156 | REAL_URL_HEADER: string; 157 | RELATIVE_URL_HEADER: string; 158 | REQ_ID_HEADER: string; 159 | PIPE_VALUE_HEADER: string; 160 | CUSTOM_PARSER_HEADER: string; 161 | STATUS_CODE_HEADER: string; 162 | PLUGIN_REQUEST_HEADER: string; 163 | LOCAL_HOST_HEADER: string; 164 | HOST_VALUE_HEADER: string; 165 | PROXY_VALUE_HEADER: string; 166 | PAC_VALUE_HEADER: string; 167 | METHOD_HEADER: string; 168 | CLIENT_IP_HEADER: string; 169 | CLIENT_PORT_HEAD: string; 170 | UI_REQUEST_HEADER: string; 171 | GLOBAL_VALUE_HEAD: string; 172 | SERVER_NAME_HEAD: string; 173 | COMMON_NAME_HEAD: string; 174 | CERT_CACHE_INFO: string; 175 | HOST_IP_HEADER: string; 176 | REQ_FROM_HEADER: string; 177 | config: { 178 | name: string; 179 | version: string; 180 | localUIHost: string; 181 | port: number; 182 | sockets: number; 183 | timeout: number; 184 | baseDir: string; 185 | uiport: number; 186 | clientId: string; 187 | uiHostList: string[]; 188 | pluginHosts: object; 189 | host: string; 190 | [propName: string]: any; 191 | }; 192 | parseUrl(url: string): WhistleBase.UrlQuery; 193 | wsParser: { 194 | getExtensions(res: any, isServer?: boolean): any; 195 | getSender(socket: any, toServer?: boolean): any; 196 | getReceiver(res: any, fromServer?: boolean, maxPayload?: number): any; 197 | }; 198 | wrapWsReader(socket?: any, maxPayload?: number): any; 199 | wrapWsWriter(socket?: any): any; 200 | shortName: string; 201 | Storage: Storage; 202 | localStorage: Storage; 203 | storage: Storage; 204 | sharedStorage: SharedStorage; 205 | baseUrl: string; 206 | LRU: LRUCache; 207 | getValue(key: string, cb: (value: string) => void): void; 208 | getCert(domain: string, cb: (cert: any) => void): void; 209 | getRootCA(cb: (cert: any) => void): void; 210 | getHttpsStatus(cb: (status: any) => void): void; 211 | getRuntimeInfo(cb: (info: any) => void): void; 212 | updateRules(): void; 213 | compose(options: any, cb: (err: any, data?: any) => void): void; 214 | getRules(cb: (rules: any) => void): void; 215 | getValues(cb: (values: any) => void): void; 216 | getPlugins(cb: (plugins: any) => void): void; 217 | getCustomCertsInfo(cb: (certs: any) => void): void; 218 | isActive(cb: (active: boolean) => void): void; 219 | ctx: any; 220 | connect(opts: any, cb?: Function): any; 221 | request(opts: any, cb?: Function): any; 222 | generateSaz(sessions: Session[]): Buffer; 223 | extractSaz(saz: Buffer, cb: (sessions: Session[]) => void): void; 224 | getTempFilePath(ruleValue: string): string | undefined; 225 | [propName: string]: any; 226 | } 227 | 228 | type GetSession = (cb: (session: Session | '') => void) => void; 229 | type GetFrame = (cb: (Frames: Frame[] | '') => void) => void; 230 | type SetRules = (rules: string) => boolean; 231 | interface PluginDecoder { 232 | getBuffer: (cb: (err: any, buf?: Buffer | null) => void) => void; 233 | getText: (cb: (err: any, text?: string) => void, encoding?: string) => void; 234 | getJson: (cb: (err: any, json?: any) => void, encoding?: string) => void; 235 | } 236 | 237 | type PluginReqCtx = PluginDecoder & PluginRequest; 238 | type PluginResCtx = PluginDecoder & WhistleBase.Request; 239 | type PluginNextResult = { 240 | rules?: string | null | undefined; 241 | body?: any; 242 | }; 243 | type PluginReqHandler = (buffer: Buffer | null, next: (result?: PluginNextResult) => void, ctx?: PluginReqCtx) => void; 244 | type PluginResHandler = (buffer: Buffer | null, next: (result?: PluginNextResult) => void, ctx?: PluginResCtx) => void; 245 | type PassThroughReq = PluginReqHandler | { [key: string]: any } | string | null | undefined; 246 | type PassThroughRes = PluginResHandler | { [key: string]: any } | null | undefined; 247 | type PassThrough = (uri?: PassThroughReq, trailers?: PassThroughRes) => void; 248 | 249 | interface WriteHead { 250 | (code: string | number, msg?: string, headers?: any): void; 251 | (code: string | number, headers?: any): void; 252 | } 253 | 254 | interface RequestFn { 255 | (uri: any, cb?: (res: any) => void, opts?: any): any; 256 | (uri: any, opts?: any, cb?: (res: any) => void): any; 257 | } 258 | 259 | class PluginRequest extends WhistleBase.Request { 260 | clientIp: string; 261 | fullUrl: string; 262 | isHttps: boolean; 263 | fromTunnel: boolean; 264 | fromComposer: boolean; 265 | isHttpsServer?: boolean; 266 | getReqSession: GetSession; 267 | getSession: GetSession; 268 | getFrames: GetFrame; 269 | Storage: Storage; 270 | localStorage: Storage; 271 | sharedStorage: SharedStorage; 272 | sessionStorage: { 273 | set(key: string, value: any): any; 274 | get(key: string): any; 275 | remove(key: string): any; 276 | }; 277 | originalReq: { 278 | id: string; 279 | clientIp: string; 280 | isH2: boolean; 281 | existsCustomCert: boolean; 282 | isUIRequest: boolean; 283 | enableCapture: boolean; 284 | isFromPlugin: boolean; 285 | ruleValue: string; 286 | ruleUrl: string; 287 | pipeValue: string; 288 | sniValue: string; 289 | hostValue: string; 290 | fullUrl: string; 291 | url: string; 292 | isHttps: boolean; 293 | remoteAddress: string; 294 | remotePort: number; 295 | fromTunnel: boolean; 296 | fromComposer: boolean; 297 | servername: string; 298 | certCacheName: string; 299 | certCacheTime: number; 300 | isSNI: boolean; 301 | commonName: string; 302 | realUrl: string; 303 | relativeUrl: string; 304 | extraUrl: string; 305 | method: string; 306 | clientPort: string; 307 | globalValue: string; 308 | proxyValue: string; 309 | pacValue: string; 310 | pluginVars: string[]; 311 | globalPluginVars: string[]; 312 | headers: any; 313 | isRexExp?: boolean; 314 | pattern?: string; 315 | customParser?: boolean | ''; 316 | }; 317 | originalRes: { 318 | serverIp: string; 319 | statusCode: string; 320 | }; 321 | } 322 | 323 | type PluginResponse = WhistleBase.Response; 324 | type PluginSocket = WhistleBase.Socks; 325 | type PluginServer = WhistleBase.HttpServer; 326 | class PluginServerRequest extends PluginRequest { 327 | setReqRules: SetRules; 328 | setResRules: SetRules; 329 | writeHead: WriteHead; 330 | request: RequestFn; 331 | connect: RequestFn; 332 | passThrough: PassThrough; 333 | } 334 | 335 | class PluginServerResponse extends WhistleBase.Response { 336 | setReqRules: SetRules; 337 | setResRules: SetRules; 338 | disableTrailers?: boolean; 339 | } 340 | 341 | class PluginServerSocket extends WhistleBase.Socks { 342 | setReqRules: SetRules; 343 | setResRules: SetRules; 344 | disableTrailers?: boolean; 345 | } 346 | class PluginUIRequest extends WhistleBase.Request { 347 | clientIp: string; 348 | Storage: Storage; 349 | localStorage: Storage; 350 | sharedStorage: SharedStorage; 351 | } 352 | 353 | type PluginUIResponse = WhistleBase.Response; 354 | 355 | class PluginAuthRequest extends PluginRequest { 356 | isUIRequest: boolean; 357 | setHtml(html: string): void; 358 | setUrl(url: string): void; 359 | setFile(url: string): void; 360 | setHeader(key: string, value: string): void; 361 | set(key: string, value: string): void; 362 | setRedirect(url: string): void; 363 | setLogin(login: boolean): void; 364 | } 365 | 366 | class PluginSNIRequest extends PluginRequest { 367 | isSNI: boolean; 368 | } 369 | 370 | type PluginSNIResult = boolean | { 371 | key: string; 372 | cert: string; 373 | mtime?: number; 374 | }; 375 | 376 | type Result = T | Promise; 377 | 378 | type PluginAuthHook = (req: PluginAuthRequest, options?: PluginOptions) => Result; 379 | type PluginSNIHook = (req: PluginSNIRequest, options?: PluginOptions) => Result; 380 | type PluginHook = (server: PluginServer, options?: PluginOptions) => Result; 381 | type PluginUIHook = (server: PluginServer, options?: PluginOptions) => Result; 382 | } 383 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: renxia 3 | * @Date: 2024-01-11 16:53:50 4 | * @LastEditors: renxia 5 | * @LastEditTime: 2024-05-08 15:19:58 6 | * @Description: 7 | */ 8 | /// 9 | 10 | import type * as FeUtils from '@lzwme/fe-utils'; 11 | type IncomingHttpHeaders = import('http').IncomingHttpHeaders; 12 | 13 | export interface W2XScriptsConfig { 14 | /** 是否开启调试模式。默认读取环境变量 DEBUG */ 15 | debug?: boolean; 16 | /** 是否开启监听模式。若为 number 则设置监听定时器的时间间隔,单位为 ms。默认为 30000 */ 17 | watch?: boolean | number; 18 | /** 日志级别。默认为 info */ 19 | logType?: import('@lzwme/fe-utils').LogLevelType; 20 | /** 青龙脚本相关配置 */ 21 | ql?: { 22 | /** 是否开启青龙面板上传。默认为 true */ 23 | enable?: boolean; 24 | /** 青龙服务地址。用于上传环境变量,若设置为空则不上传 */ 25 | host?: string; 26 | /** 青龙服务 token。用于创建或更新 QL 环境变量配置。会自动尝试从 /ql/data/config/auth.json 文件中获取 */ 27 | token?: string; 28 | /** 登录用户名 */ 29 | username?: string; 30 | /** 登录密码 */ 31 | password?: string; 32 | /** 两步验证秘钥。若开启了两步验证则需设置 */ 33 | twoFactorSecret?: string; 34 | /** open app client_id: 应用设置-创建应用,权限选择 环境变量 */ 35 | clientId?: string; 36 | /** open app client_secret */ 37 | clientSecret?: string; 38 | }; 39 | /** 写入环境变量信息到本地文件的路径。若设置为空则不写入 */ 40 | envConfFile?: string; 41 | /** 缓存数据保存路径 */ 42 | cacheFile?: string; 43 | /** 数据处理防抖时间间隔。单位为秒,默认为 3 (s) */ 44 | throttleTime?: number; 45 | /** 缓存数据有效时长。单位秒。默认为 12 小时(12 * 60 * 60) */ 46 | cacheDuration?: number; 47 | /** 是否启用 rule.mitm 配置。默认为 true。当无法在某些环境下安装 CA 证书时,可设置为 false 以禁用由 mitm 开启的 https 拦截 */ 48 | enableMITM?: boolean; 49 | /** 自定义脚本规则 */ 50 | rules?: RuleItem[]; 51 | /** 指定规则集文件路径或所在目录,尝试从该列表加载自定义的规则集 */ 52 | ruleDirs?: string[]; 53 | /** 启用的 ruleId。若设置,则仅在该列表中的 ruleId 会启用 */ 54 | ruleInclude?: string[]; 55 | /** 排除/禁用的 ruleId。若设置,则在该列表中的 ruleId 会被过滤 */ 56 | ruleExclude?: string[]; 57 | /** whistle rules 规则列表。可以是本地文件、远程 url、返回规则的自定义函数(仅初始化时执行一次) */ 58 | whistleRules?: WhistleRuleItem[]; 59 | } 60 | 61 | type WhistleRuleItem = { 62 | /** 规则类型 */ 63 | type?: 'rules' | 'pac' | 'file'; 64 | /** url,远程加载 */ 65 | url?: string; 66 | /** 为本地文件或目录路径 */ 67 | path?: string; 68 | /** whistle Rules 规则列表 */ 69 | rules?: string[]; 70 | /** whistle Values,可在 rules 中引用规则 */ 71 | values?: Record; 72 | } 73 | 74 | // export type RuleType = 'saveCookie' | 'mock' | 'modify'; 75 | 76 | /** 规则执行的阶段类型 */ 77 | export type RuleRunOnType = 'req-header' | 'req-body' | 'res-body'; 78 | 79 | type PromiseMaybe = T | Promise; 80 | 81 | type RuleUrlItem = string | RegExp | ((url: string, method: string, headers: IncomingHttpHeaders) => boolean); 82 | 83 | export interface RuleItem { 84 | /** 规则 id,唯一标记 */ 85 | ruleId: string; 86 | /** 规则描述 */ 87 | desc?: string; 88 | // /** 89 | // * 规则类型。默认为 saveCookie 90 | // * @var saveCookie 从 req headers 及 cookie 提取数据并保存。为默认值 91 | // * @var mock 模拟请求直接返回模拟的数据。也可以拦截后重修改数据新请求 92 | // * @var modify 修改服务端返回的数据结果 93 | // */ 94 | // type?: RuleType; 95 | /** 96 | * 规则执行的阶段类型。 97 | * @var req-header 获取到 headers 时即执行。可以更早的返回 98 | * @var req-body 获取到 request body 时执行。例如针对于 post 请求 mock 的场景 99 | * @var res-body 获取到远程接口返回内容后执行。例如可以保存 body、修改 body 等 100 | */ 101 | on?: RuleRunOnType; 102 | /** 禁用该规则 */ 103 | disabled?: boolean; 104 | /** 105 | * MITM 域名匹配配置。 106 | * 当 res-body 类型的规则命中时会主动启用 https 解析拦截(whistle 未启用 https 拦截时)。 107 | */ 108 | mitm?: string | RegExp | (string | RegExp)[]; 109 | /** url 匹配规则 */ 110 | url?: RuleUrlItem | RuleUrlItem[]; 111 | /** 方法匹配。可选: post、get、put 等。设置为空或 ** 表示全部匹配。若不设置,默认为 post */ 112 | method?: string; 113 | /** [envConfig]是否上传至 青龙 环境变量配置。需配置 qlToken */ 114 | toQL?: boolean; 115 | /** [envConfig]是否写入到环境变量配置文件中。默认是 */ 116 | toEnvFile?: boolean; 117 | /** [getCacheUid]是否合并不同请求的缓存数据(多个请求抓取的数据合并一起)。默认为 false 覆盖 */ 118 | mergeCache?: boolean; 119 | /** 缓存数据有效时长。单位秒。默认为一天。优先级大于全局设置 */ 120 | cacheDuration?: number; 121 | /** 文件来源。内置赋值参数,主要用于调试 */ 122 | _source?: string; 123 | /** 获取当前用户唯一性的 uid,及自定义需缓存的数据 data(可选) */ 124 | getCacheUid?: string | ((ctx: RuleHandlerParams) => string | { uid: string; data: any } | undefined); 125 | /** [envConfig]更新处理已存在的环境变量,返回合并后的结果。若无需修改则可返回空 */ 126 | updateEnvValue?: ((envConfig: EnvConfig, oldValue: string, X: RuleHandlerParams['X']) => string | undefined) | RegExp; 127 | /** <${type}>handler 简写。根据 type 类型自动识别 */ 128 | handler?: (ctx: RuleHandlerParams) => PromiseMaybe; 129 | // /** 规则处理并返回环境变量配置。可以数组的形式返回多个 */ 130 | // saveCookieHandler?: (ctx: RuleHandlerParams & { cacheData: CacheData[] }) => PromiseMaybe; 131 | // /** [mock] 接口模拟处理,返回需 mock 的结果。若返回为空则表示忽略 */ 132 | // mockHandler?: (ctx: RuleHandlerParams) => PromiseMaybe | Buffer | string | object>; 133 | // /** [modify] 接收到请求返回数据后修改或保存数据的处理 */ 134 | // modifyHandler?: ( 135 | // ctx: RuleHandlerParams & { resBody: string | Record | Buffer } 136 | // ) => PromiseMaybe; 137 | } 138 | 139 | export type RuleGetUUidCtx = { 140 | headers: IncomingHttpHeaders; 141 | url: string; 142 | cookieObj: Record; 143 | req: Whistle.PluginServerRequest; 144 | }; 145 | 146 | export type RuleHandlerParams = { 147 | req: Whistle.PluginServerRequest | Whistle.PluginReqCtx; 148 | /** 封装的工具方法 */ 149 | X: Record & { 150 | FeUtils: typeof FeUtils; 151 | logger: FeUtils.NLogger; 152 | cookieParse: (cookie?: string, filterNilValue?: boolean) => Record; 153 | isText: (headers: IncomingHttpHeaders) => boolean; 154 | isBinary: (headers: IncomingHttpHeaders) => boolean; 155 | isJSON: (headers: IncomingHttpHeaders, isStrict?: boolean) => boolean; 156 | toBuffer(body: unknown): Buffer; 157 | }; 158 | /** 请求 url 完整地址 */ 159 | url: string; 160 | /** req.headers */ 161 | headers: IncomingHttpHeaders; 162 | /** req.headers.cookie 格式化为的对象格式 */ 163 | cookieObj: Record; 164 | /** 当设置了 getCacheUid 时,返回同一规则缓存的所有数据(包含由 getCacheUid 格式化返回的 data 数据) */ 165 | cacheData: CacheData[]; 166 | /** 167 | * 同 cacheData 168 | * @deprecated 将在后续版本废除,请使用 cacheData 169 | */ 170 | allCacheData?: CacheData[]; 171 | /** [on=req-body, res-body] 请求参数 body */ 172 | reqBody?: Record | Buffer; 173 | /** [on=res-body] 远程接口返回的 body */ 174 | resBody?: string | Record | Buffer; 175 | /** [on=res-body] 远程接口返回的 headers */ 176 | resHeaders?: IncomingHttpHeaders; 177 | }; 178 | 179 | export type RuleHandlerResult = { 180 | errmsg?: string; 181 | /** 返回环境变量的置。返回空则忽略,否则会根据 toQL 和 toEnvFile 更新环境变量 */ 182 | envConfig?: EnvConfig[] | EnvConfig | undefined; 183 | /** 返回值。若返回为空表示忽略,否则则以该值返回给接口调用 */ 184 | body?: T; 185 | /** [req-body] 请求参数消息体。若存在则以该值替换原请求参数 */ 186 | reqBody?: string | Buffer | Record; 187 | }; 188 | 189 | export interface EnvConfig { 190 | /** 环境变量名称。默认取值 ruleId 参数 */ 191 | name?: string; 192 | /** 环境变量值 */ 193 | value: string; 194 | /** 描述信息 */ 195 | desc?: string; 196 | /** 多账号分隔符 */ 197 | sep?: string; 198 | } 199 | 200 | interface CacheData { 201 | /** 数据唯一标记,尽量以用户ID等可识别唯一性的参数值。getCacheUid 方法返回 */ 202 | uid: string; 203 | /** getCacheUid 方法返回,并保存至缓存中的自定义数据。是可选的,主要用于需组合多个请求数据的复杂场景 */ 204 | data: T; 205 | headers: IncomingHttpHeaders; 206 | } 207 | -------------------------------------------------------------------------------- /w2.x-scripts.config.sample.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@lzwme/whistle.x-scripts').W2XScriptsConfig} */ 2 | const config = { 3 | // debug: false, 4 | // logType: 'info', 5 | // ql: { 6 | // enable: false, 7 | // token: '', 8 | // }, 9 | /** 写入环境变量信息到本地文件 */ 10 | // envConfFile: 'env-config.sh', 11 | /** 写入环境变量信息到本地文件的路径。若设置为空则不写入 */ 12 | // cacheFile: 'w2.x-scripts.cache.json', 13 | /** 数据处理防抖时间间隔。单位为秒,默认为 3 (s) */ 14 | throttleTime: 3, 15 | /** 指定规则集文件路径或所在目录,尝试从该列表加载自定义的规则集 */ 16 | ruleDirs: [ 17 | // require.resolve('@lzwme/x-scripts-rules', { paths: require.main.paths }), 18 | './local-x-scripts-rules', 19 | ], 20 | /** 自定义脚本规则 */ 21 | rules: [ 22 | // ...rules, 23 | { 24 | on: 'req-body', 25 | ruleId: 'rule-test', 26 | desc: '这是一条测试规则示例', 27 | method: '*', 28 | url: '**', 29 | toQL: false, 30 | toEnvFile: true, 31 | handler({ url, req, reqBody, resHeaders, resBody, X }) { 32 | // 只处理文本类型的请求 33 | if (X.isText(req.headers) && !/\.(js|css)/.test(url)) { 34 | // X 是提供的工具类对象,方便简化脚本编写逻辑调用 35 | const { magenta, gray, cyan } = X.FeUtils.color; 36 | 37 | console.log(`\n\n[${magenta('handler')}][${cyan(req.method)}] -`, gray(url)); 38 | console.log(cyan('\req headers:'), req.headers); 39 | console.log(cyan('\res headers:'), resHeaders); 40 | if (reqBody) console.log(cyan('请求参数:'), reqBody.toString()); 41 | if (resBody) console.log(cyan('返回内容:'), resBody); 42 | } 43 | 44 | // 若返回 body 参数则会以该内容返回 45 | // 若返回 envConfig 参数则会以该内容写入环境变量文件 46 | // return { body: modifyedResBody, envConfig }; 47 | }, 48 | }, 49 | ], 50 | }; 51 | 52 | module.exports = config; 53 | --------------------------------------------------------------------------------