如何编写Quantumult-X脚本

最近对脚本开发比较感兴趣,通过JavaScript编写脚本,不仅可以加深你对JavaScript使用的熟练程度,更甚可以锻炼你的逻辑能力。

IOS上面有一些可以定时运行js脚本的工具,这些工具可以实现通过js定时京东签到,漫画签到等一系列的功能。Quantumult-X就是其中的一个软件,我知道的还有LoonSurge不过后两者我是完全没有使用过。

奇怪的是Quantumult-X这种软件,我目前没有找到官方的API文档,翻来覆去折腾了好久,还是决定直接看一下别人写的源码,其中我参考的是京东签到这个脚本的源代码。

读这种比较长的源码一定不要在网页上面阅读,因为没有代码定位,你可以将它拷贝到本地,通过vscode这一类的编辑器打开阅读,其中它有一个比较重要的方法就是。

注:本篇文章不适合对JavaScript没有了解的小白阅读,同时阅读本篇文章之前,相信你已经知道Quantumult-X的基础用法,并且已经可以正常使用别人的开源脚本。

1. 工具函数

下面是脚本作者封装的工具函数,里面实现了http请求、消息提醒、警告、数据持久化储存

可以大致过一遍这些源代码,在后面的文章会单独的将上面提到的功能进行讲解。

/**
 * 工具类
 * @return {{read: ((function(*=): (*|null|undefined))|*), isRequest: boolean, isLoon: boolean, isQuanX: boolean, isNode: boolean, done: ((function(*=): (*|undefined))|*), notify: notify, isSurge: boolean, post: post, AnError: (function(*, *=, *=, *=, *): void), get: get, time: (function(): void), isJSBox: boolean, write: ((function(*=, *=): (*|undefined))|*)}}
 */
export function nobyda() {
  const start = Date.now();
  // 判断是否是重写
  const isRequest = typeof $request != "undefined";
  // 判断是否是Surge
  const isSurge = typeof $httpClient != "undefined";
  // 判断是否是QuanX
  const isQuanX = typeof $task != "undefined";
  // 判断是否是Loon
  const isLoon = typeof $loon != "undefined";
  // 判断是否是JSBox
  const isJSBox = typeof $app != "undefined" && typeof $http != "undefined";
  // 判断是否是Node环境
  const isNode = typeof require == "function" && !isJSBox;
  const NodeSet = "CookieSet.json";
  /**
   * 引入Nodejs中的request模块和fs模块
   * @type {{request: *, fs: module:fs}|null}
   */
  const node = (() => {
    if (isNode) {
      const request = require("request");
      const fs = require("fs");
      return ({
        request,
        fs
      });
    } else {
      return null;
    }
  })();
  /**
   * 提示信息
   * @param {string} title 标题
   * @param {string} subtitle 副标题
   * @param {string} message 提示信息
   * @param {*} rawopts 设置
   */
  const notify = (title, subtitle, message, rawopts) => {
    const Opts = (rawopts) => {
      //Modified from https://github.com/chavyleung/scripts/blob/master/Env.js
      if (!rawopts) return rawopts;
      switch (typeof rawopts) {
        case "string":
          return isLoon
            ? rawopts
            : isQuanX
            ? {
                "open-url": rawopts,
              }
            : isSurge
            ? {
                url: rawopts,
              }
            : undefined;
        case "object":
          if (isLoon) {
            let openUrl = rawopts.openUrl || rawopts.url || rawopts["open-url"];
            let mediaUrl = rawopts.mediaUrl || rawopts["media-url"];
            return {
              openUrl,
              mediaUrl,
            };
          } else if (isQuanX) {
            let openUrl = rawopts["open-url"] || rawopts.url || rawopts.openUrl;
            let mediaUrl = rawopts["media-url"] || rawopts.mediaUrl;
            return {
              "open-url": openUrl,
              "media-url": mediaUrl,
            };
          } else if (isSurge) {
            let openUrl = rawopts.url || rawopts.openUrl || rawopts["open-url"];
            return {
              url: openUrl,
            };
          }
          break;
        default:
          return undefined;
      }
    };
    console.log(`${ title }\n${ subtitle }\n${ message }`);
    if (isQuanX) $notify(title, subtitle, message, Opts(rawopts));
    if (isSurge) $notification.post(title, subtitle, message, Opts(rawopts));
    if (isJSBox) $push.schedule({
      title: title,
      body: subtitle ? subtitle + "\n" + message : message
    });
  };
  // 将获得的cookies信息储存起来
  const write = (value, key) => {
    if (isQuanX) return $prefs.setValueForKey(value, key);
    if (isSurge) return $persistentStore.write(value, key);
    if (isNode) {
      try {
        if (!node.fs.existsSync(NodeSet)) node.fs.writeFileSync(NodeSet, JSON.stringify({}));
        const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
        if (value) dataValue[key] = value;
        if (!value) delete dataValue[key];
        return node.fs.writeFileSync(NodeSet, JSON.stringify(dataValue));
      } catch (er) {
        return AnError("Node.js持久化写入", null, er);
      }
    }
    if (isJSBox) {
      if (!value) return $file.delete(`shared://${ key }.txt`);
      return $file.write({
        data: $data({
          string: value
        }),
        path: `shared://${ key }.txt`
      });
    }
  };
  // 将获取的cookies信息读出来
  const read = (key) => {
    if (isQuanX) return $prefs.valueForKey(key);
    if (isSurge) return $persistentStore.read(key);
    if (isNode) {
      try {
        if (!node.fs.existsSync(NodeSet)) return null;
        const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
        return dataValue[key];
      } catch (er) {
        return AnError("Node.js持久化读取", null, er);
      }
    }
    if (isJSBox) {
      if (!$file.exists(`shared://${ key }.txt`)) return null;
      return $file.read(`shared://${ key }.txt`).string;
    }
  };
  const adapterStatus = (response) => {
    if (response) {
      if (response.status) {
        response["statusCode"] = response.status;
      } else if (response.statusCode) {
        response["status"] = response.statusCode;
      }
    }
    return response;
  };
  // get请求
  const get = (options, callback) => {
    options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
    if (isQuanX) {
      if (typeof options == "string") options = {
        url: options
      };
      options["method"] = "GET";
      //options["opts"] = {
      //  "hints": false
      //}
      $task.fetch(options).then(response => {
        callback(null, adapterStatus(response), response.body);
      }, reason => callback(reason.error, null, null));
    }
    if (isSurge) {
      options.headers["X-Surge-Skip-Scripting"] = false;
      $httpClient.get(options, (error, response, body) => {
        callback(error, adapterStatus(response), body);
      });
    }
    if (isNode) {
      node.request(options, (error, response, body) => {
        callback(error, adapterStatus(response), body);
      });
    }
    if (isJSBox) {
      if (typeof options == "string") options = {
        url: options
      };
      options["header"] = options["headers"];
      options["handler"] = function (resp) {
        let error = resp.error;
        if (error) error = JSON.stringify(resp.error);
        let body = resp.data;
        if (typeof body == "object") body = JSON.stringify(resp.data);
        callback(error, adapterStatus(resp.response), body);
      };
      $http.get(options);
    }
  };
  // post请求
  const post = (options, callback) => {
    options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
    if (options.body) options.headers["Content-Type"] = "application/x-www-form-urlencoded";
    if (isQuanX) {
      if (typeof options == "string") options = {
        url: options
      };
      options["method"] = "POST";
      $task.fetch(options).then(response => {
        callback(null, adapterStatus(response), response.body);
      }, reason => callback(reason.error, null, null));
    }
    if (isSurge) {
      options.headers["X-Surge-Skip-Scripting"] = false;
      $httpClient.post(options, (error, response, body) => {
        callback(error, adapterStatus(response), body);
      });
    }
    if (isNode) {
      node.request.post(options, (error, response, body) => {
        callback(error, adapterStatus(response), body);
      });
    }
    if (isJSBox) {
      if (typeof options == "string") options = {
        url: options
      };
      options["header"] = options["headers"];
      options["handler"] = function (resp) {
        let error = resp.error;
        if (error) error = JSON.stringify(resp.error);
        let body = resp.data;
        if (typeof body == "object") body = JSON.stringify(resp.data);
        callback(error, adapterStatus(resp.response), body);
      };
      $http.post(options);
    }
  };
  // 异常信息
  const AnError = (name, keyname, er, resp, body) => {
    if (typeof (merge) != "undefined" && keyname) {
      if (!merge[keyname].notify) {
        merge[keyname].notify = `${ name }: 异常, 已输出日志 ‼️`;
      } else {
        merge[keyname].notify += `\n${ name }: 异常, 已输出日志 ‼️ (2)`;
      }
      merge[keyname].error = 1;
    }
    return console.log(`\n‼️${ name }发生错误\n‼️名称: ${ er.name }\n‼️描述: ${ er.message }${ JSON.stringify(er).match(/"line"/) ? `\n‼️行列: ${ JSON.stringify(er) }` : `` }${ resp && resp.status ? `\n‼️状态: ${ resp.status }` : `` }${ body ? `\n‼️响应: ${ resp && resp.status != 503 ? body : `Omit.` }` : `` }`);
  };
  // 总共用时
  const time = () => {
    const end = ((Date.now() - start) / 1000).toFixed(2);
    return console.log("\n签到用时: " + end + " 秒");
  };
  // 关闭请求
  const done = (value = {}) => {
    if (isQuanX) return $done(value);
    if (isSurge) isRequest ? $done(value) : $done();
  };
  return {
    AnError,
    isRequest,
    isJSBox,
    isSurge,
    isQuanX,
    isLoon,
    isNode,
    notify,
    write,
    read,
    get,
    post,
    time,
    done
  };
}

2. 分析

2.1 判断环境

其实大部分代码我都已经在阅读源码的时候标上了注释,源码不难理解,首先进入代码一开始就是判断各种环境:

// 判断是否是重写
const isRequest = typeof $request != "undefined";
// 判断是否是Surge
const isSurge = typeof $httpClient != "undefined";
// 判断是否是QuanX
const isQuanX = typeof $task != "undefined";
// 判断是否是Loon
const isLoon = typeof $loon != "undefined";
// 判断是否是JSBox
const isJSBox = typeof $app != "undefined" && typeof $http != "undefined";
// 判断是否是Node环境
const isNode = typeof require == "function" && !isJSBox;

该作者的这个脚本不仅仅可以用在QuanX上面,还可以在Surge、Loon、JSBox甚至是Node环境上运行。

2.2 提示信息

该方法封装了消息提示功能,就跟微信来消息的弹框一样,如果你没有给该软件提示权限则收不到相关的消息提示。

/**
 * 提示信息
 * @param {string} title 标题
 * @param {string} subtitle 副标题
 * @param {string} message 提示信息
 * @param {*} rawopts 设置
 */
const notify = (title, subtitle, message, rawopts) => {
  const Opts = (rawopts) => {
    //Modified from https://github.com/chavyleung/scripts/blob/master/Env.js
    if (!rawopts) return rawopts;
    switch (typeof rawopts) {
      case "string":
        return isLoon
          ? rawopts
          : isQuanX
          ? {
              "open-url": rawopts,
            }
          : isSurge
          ? {
              url: rawopts,
            }
          : undefined;
      case "object":
        if (isLoon) {
          let openUrl = rawopts.openUrl || rawopts.url || rawopts["open-url"];
          let mediaUrl = rawopts.mediaUrl || rawopts["media-url"];
          return {
            openUrl,
            mediaUrl,
          };
        } else if (isQuanX) {
          let openUrl = rawopts["open-url"] || rawopts.url || rawopts.openUrl;
          let mediaUrl = rawopts["media-url"] || rawopts.mediaUrl;
          return {
            "open-url": openUrl,
            "media-url": mediaUrl,
          };
        } else if (isSurge) {
          let openUrl = rawopts.url || rawopts.openUrl || rawopts["open-url"];
          return {
            url: openUrl,
          };
        }
        break;
      default:
        return undefined;
    }
  };
  console.log(`${ title }\n${ subtitle }\n${ message }`);
  if (isQuanX) $notify(title, subtitle, message, Opts(rawopts));
  if (isSurge) $notification.post(title, subtitle, message, Opts(rawopts));
  if (isJSBox) $push.schedule({
    title: title,
    body: subtitle ? subtitle + "\n" + message : message
  });
};

2.3 异常信息

脚本中封装的异常信息提示方法。

// 异常信息
const AnError = (name, keyname, er, resp, body) => {
  if (typeof (merge) != "undefined" && keyname) {
    if (!merge[keyname].notify) {
      merge[keyname].notify = `${ name }: 异常, 已输出日志 ‼️`;
    } else {
      merge[keyname].notify += `\n${ name }: 异常, 已输出日志 ‼️ (2)`;
    }
    merge[keyname].error = 1;
  }
  return console.log(`\n‼️${ name }发生错误\n‼️名称: ${ er.name }\n‼️描述: ${ er.message }${ JSON.stringify(er).match(/"line"/) ? `\n‼️行列: ${ JSON.stringify(er) }` : `` }${ resp && resp.status ? `\n‼️状态: ${ resp.status }` : `` }${ body ? `\n‼️响应: ${ resp && resp.status != 503 ? body : `Omit.` }` : `` }`);
};

2.4 请求

在脚本的编写中因为要获取第三方网站的信息,或者模拟请求,所以需要使用到http请求,由于各个软件的请求方式有些许不同,所以该脚本中封装了一个get以及post请求,进行了差异化处理,直接调用这两个方法就可以进行http请求。

// get请求
const get = (options, callback) => {
  options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
  if (isQuanX) {
    if (typeof options == "string") options = {
      url: options
    };
    options["method"] = "GET";
    //options["opts"] = {
    //  "hints": false
    //}
    $task.fetch(options).then(response => {
      callback(null, adapterStatus(response), response.body);
    }, reason => callback(reason.error, null, null));
  }
  if (isSurge) {
    options.headers["X-Surge-Skip-Scripting"] = false;
    $httpClient.get(options, (error, response, body) => {
      callback(error, adapterStatus(response), body);
    });
  }
  if (isNode) {
    node.request(options, (error, response, body) => {
      callback(error, adapterStatus(response), body);
    });
  }
  if (isJSBox) {
    if (typeof options == "string") options = {
      url: options
    };
    options["header"] = options["headers"];
    options["handler"] = function (resp) {
      let error = resp.error;
      if (error) error = JSON.stringify(resp.error);
      let body = resp.data;
      if (typeof body == "object") body = JSON.stringify(resp.data);
      callback(error, adapterStatus(resp.response), body);
    };
    $http.get(options);
  }
};
// post请求
const post = (options, callback) => {
  options.headers["User-Agent"] = "JD4iPhone/167169 (iPhone; iOS 13.4.1; Scale/3.00)";
  if (options.body) options.headers["Content-Type"] = "application/x-www-form-urlencoded";
  if (isQuanX) {
    if (typeof options == "string") options = {
      url: options
    };
    options["method"] = "POST";
    $task.fetch(options).then(response => {
      callback(null, adapterStatus(response), response.body);
    }, reason => callback(reason.error, null, null));
  }
  if (isSurge) {
    options.headers["X-Surge-Skip-Scripting"] = false;
    $httpClient.post(options, (error, response, body) => {
      callback(error, adapterStatus(response), body);
    });
  }
  if (isNode) {
    node.request.post(options, (error, response, body) => {
      callback(error, adapterStatus(response), body);
    });
  }
  if (isJSBox) {
    if (typeof options == "string") options = {
      url: options
    };
    options["header"] = options["headers"];
    options["handler"] = function (resp) {
      let error = resp.error;
      if (error) error = JSON.stringify(resp.error);
      let body = resp.data;
      if (typeof body == "object") body = JSON.stringify(resp.data);
      callback(error, adapterStatus(resp.response), body);
    };
    $http.post(options);
  }
};

// 关闭请求
const done = (value = {}) => {
  if (isQuanX) return $done(value);
  if (isSurge) isRequest ? $done(value) : $done();
};

2.5 数据读取

该脚本针对数据持久化储存进行了封装,可以将你获取到的Cookie信息存储下来,就不用每次运行脚本时都需要填写Cookie信息。

// 将获得的cookies信息储存起来
const write = (value, key) => {
  if (isQuanX) return $prefs.setValueForKey(value, key);
  if (isSurge) return $persistentStore.write(value, key);
  if (isNode) {
    try {
      if (!node.fs.existsSync(NodeSet)) node.fs.writeFileSync(NodeSet, JSON.stringify({}));
      const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
      if (value) dataValue[key] = value;
      if (!value) delete dataValue[key];
      return node.fs.writeFileSync(NodeSet, JSON.stringify(dataValue));
    } catch (er) {
      return AnError("Node.js持久化写入", null, er);
    }
  }
  if (isJSBox) {
    if (!value) return $file.delete(`shared://${ key }.txt`);
    return $file.write({
      data: $data({
        string: value
      }),
      path: `shared://${ key }.txt`
    });
  }
};

// 将获取的cookies信息读出来
const read = (key) => {
  if (isQuanX) return $prefs.valueForKey(key);
  if (isSurge) return $persistentStore.read(key);
  if (isNode) {
    try {
      if (!node.fs.existsSync(NodeSet)) return null;
      const dataValue = JSON.parse(node.fs.readFileSync(NodeSet));
      return dataValue[key];
    } catch (er) {
      return AnError("Node.js持久化读取", null, er);
    }
  }
  if (isJSBox) {
    if (!$file.exists(`shared://${ key }.txt`)) return null;
    return $file.read(`shared://${ key }.txt`).string;
  }
};

3. 重写

写过脚本或者爬虫的人应该都比较清楚,http为无状态请求,也就是后端并不知道请求之前用户进行了什么操作,如果服务器要识别某一请求为哪个用户发出来的,现在最主流的有两种办法,一种是token,一种是Cookie。

而从该脚本中可以得知,京东明显是使用了Cookie判断用户信息,那么如何使用脚本来获取cookie信息呢?

根据源代码,如果const isRequest = typeof $request != "undefined";那么该脚本即为重写。

那么我们只需要着重观察哪儿有调用isRequest

if (DeleteCookie) {
  if ($nobyda.read(EnvInfo) || $nobyda.read(EnvInfo2)) {
    $nobyda.write("", EnvInfo);
    $nobyda.write("", EnvInfo2);
    $nobyda.notify("京东Cookie清除成功 !", "", "请手动关闭脚本内\"DeleteCookie\"选项");
    $nobyda.done();
    return;
  }
  $nobyda.notify("脚本终止", "", "未关闭脚本内\"DeleteCookie\"选项 ‼️");
  $nobyda.done();
  return;
} else if ($nobyda.isRequest) {
  // 如果为重写,那么就执行GetCookie函数
  GetCookie();
  return;
}

可以看到在$nobyda.isRequesttrue时调用了GetCookie()函数,于是我们就着重分析GetCookie()函数。

// 自动获取cookie方法
function GetCookie() {
  try {
    if ($request.headers && $request.url.match(/api\.m\.jd\.com.*=signBean/)) {
      var CV = $request.headers["Cookie"];
      if (CV.match(/pt_key=.+?;/) && CV.match(/pt_pin=.+?;/)) {
        var CookieValue = CV.match(/pt_key=.+?;/)[0] + CV.match(/pt_pin=.+?;/)[0];
        var CK1 = $nobyda.read("CookieJD");
        var CK2 = $nobyda.read("CookieJD2");
        var AccountOne = CK1 ? CK1.match(/pt_pin=.+?;/) ? CK1.match(/pt_pin=(.+?);/)[1] : null : null;
        var AccountTwo = CK2 ? CK2.match(/pt_pin=.+?;/) ? CK2.match(/pt_pin=(.+?);/)[1] : null : null;
        var UserName = CookieValue.match(/pt_pin=(.+?);/)[1];
        var DecodeName = decodeURIComponent(UserName);
        if (!AccountOne || UserName == AccountOne) {
          var CookieName = " [账号一] ";
          var CookieKey = "CookieJD";
        } else if (!AccountTwo || UserName == AccountTwo) {
          var CookieName = " [账号二] ";
          var CookieKey = "CookieJD2";
        } else {
          $nobyda.notify("更新京东Cookie失败", "非历史写入账号 ‼️", "请开启脚本内\"DeleteCookie\"以清空Cookie ‼️");
          return;
        }
      } else {
        $nobyda.notify("写入京东Cookie失败", "", "请查看脚本内说明, 登录网页获取 ‼️");
        return;
      }
      const RA = $nobyda.read(CookieKey);
      if (RA == CookieValue) {
        console.log(`\n用户名: ${ DecodeName }\n与历史京东${ CookieName }Cookie相同, 跳过写入 ⚠️`);
      } else {
        const WT = $nobyda.write(CookieValue, CookieKey);
        $nobyda.notify(`用户名: ${ DecodeName }`, ``, `${ RA ? `更新` : `写入` }京东${ CookieName }Cookie${ WT ? `成功 🎉` : `失败 ‼️` }`);
      }
    } else if ($request.url === "http://www.apple.com/") {
      $nobyda.notify("京东签到", "", "类型错误, 手动运行请选择上下文环境为Cron ⚠️");
    } else {
      $nobyda.notify("京东签到", "写入Cookie失败", "请检查匹配URL或配置内脚本类型 ⚠️");
    }
  } catch (eor) {
    $nobyda.write("", "CookieJD");
    $nobyda.write("", "CookieJD2");
    $nobyda.notify("写入京东Cookie失败", "", "已尝试清空历史Cookie, 请重试 ⚠️");
    console.log(`\n写入京东Cookie出现错误 ‼️\n${ JSON.stringify(eor) }\n\n${ eor }\n\n${ JSON.stringify($request.headers) }\n`);
  } finally {
    $nobyda.done();
  }
}

因为Quantumult-X的重写功能为访问到指定的url就可以触发脚本,根据该脚本来看,几个软件的重写方法没有什么差异化,都是使用的$request对象,而Node环境下无法自动获取Cookie,必须进行手动填写。

4. 最后

我找了好久Quantumult-X都没有提供官方文档,所以我并不清楚它的API,不过从上面的脚本来看,大致分为下面几个API:

  • $prefs:持久化数据存储(读取和写入)。
  • $task:网络请求。
  • $done:请求完毕时需要调用。
  • $request:重写网络请求,用来获取请求中的Cookie等,甚至可以用来篡改响应体
  • $notify:弹框提示信息。

看了一下该脚本封装的还是比较全的,几乎可能用到的方法都封装进去了,同时我尝试将脚本通过webpack进行压缩,事实证明即使经过webpack压缩,该脚本依然是可以正常使用。也就是说,在编写脚本的时候可以通过webpack引入一些第三方工具类。

目前来说通过webpack压缩脚本仅仅只有一点缺陷,就是在分享脚本时,别人无法直接阅读到你的源代码,而无法进行修改(修改难度高),不过如果你不想暴露自己的脚本源代码,通过webpack压缩是一个非常不错的选择。