mv3
This commit is contained in:
343
src/pkg/utils/script.ts
Normal file
343
src/pkg/utils/script.ts
Normal file
@ -0,0 +1,343 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Metadata,
|
||||
Script,
|
||||
SCRIPT_RUN_STATUS_COMPLETE,
|
||||
SCRIPT_STATUS_DISABLE,
|
||||
SCRIPT_STATUS_ENABLE,
|
||||
SCRIPT_TYPE_BACKGROUND,
|
||||
SCRIPT_TYPE_CRONTAB,
|
||||
SCRIPT_TYPE_NORMAL,
|
||||
ScriptDAO,
|
||||
UserConfig,
|
||||
} from "@App/app/repo/scripts";
|
||||
import { InstallSource } from "@App/app/service/manager";
|
||||
import YAML from "yaml";
|
||||
import { Subscribe, SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe";
|
||||
import { nextTime } from "./utils";
|
||||
|
||||
export function getMetadataStr(code: string): string | null {
|
||||
const start = code.indexOf("==UserScript==");
|
||||
const end = code.indexOf("==/UserScript==");
|
||||
if (start === -1 || end === -1) {
|
||||
return null;
|
||||
}
|
||||
return `// ${code.substring(start, end + 15)}`;
|
||||
}
|
||||
|
||||
export function getUserConfigStr(code: string): string | null {
|
||||
const start = code.indexOf("==UserConfig==");
|
||||
const end = code.indexOf("==/UserConfig==");
|
||||
if (start === -1 || end === -1) {
|
||||
return null;
|
||||
}
|
||||
return `/* ${code.substring(start, end + 15)} */`;
|
||||
}
|
||||
|
||||
export function parseMetadata(code: string): Metadata | null {
|
||||
let issub = false;
|
||||
let regex = /\/\/\s*==UserScript==([\s\S]+?)\/\/\s*==\/UserScript==/m;
|
||||
let header = regex.exec(code);
|
||||
if (!header) {
|
||||
regex = /\/\/\s*==UserSubscribe==([\s\S]+?)\/\/\s*==\/UserSubscribe==/m;
|
||||
header = regex.exec(code);
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
issub = true;
|
||||
}
|
||||
regex = /\/\/\s*@([\S]+)((.+?)$|$)/gm;
|
||||
const ret: Metadata = {};
|
||||
let meta: RegExpExecArray | null = regex.exec(header[1]);
|
||||
while (meta !== null) {
|
||||
const [key, val] = [meta[1].toLowerCase().trim(), meta[2].trim()];
|
||||
let values = ret[key];
|
||||
if (values == null) {
|
||||
values = [];
|
||||
}
|
||||
values.push(val);
|
||||
ret[key] = values;
|
||||
meta = regex.exec(header[1]);
|
||||
}
|
||||
if (ret.name === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (Object.keys(ret).length < 3) {
|
||||
return null;
|
||||
}
|
||||
if (!ret.namespace) {
|
||||
ret.namespace = [""];
|
||||
}
|
||||
if (issub) {
|
||||
ret.usersubscribe = [];
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function parseUserConfig(code: string): UserConfig | undefined {
|
||||
const regex = /\/\*\s*==UserConfig==([\s\S]+?)\s*==\/UserConfig==\s*\*\//m;
|
||||
const config = regex.exec(code);
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const configs = config[1].trim().split(/[-]{3,}/);
|
||||
const ret: UserConfig = {};
|
||||
configs.forEach((val) => {
|
||||
const obj: UserConfig = YAML.parse(val);
|
||||
Object.keys(obj).forEach((key) => {
|
||||
ret[key] = obj[key];
|
||||
});
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
export type ScriptInfo = {
|
||||
url: string;
|
||||
code: string;
|
||||
uuid: string;
|
||||
isSubscribe: boolean;
|
||||
isUpdate: boolean;
|
||||
metadata: Metadata;
|
||||
source: InstallSource;
|
||||
};
|
||||
|
||||
export async function fetchScriptInfo(
|
||||
url: string,
|
||||
source: InstallSource,
|
||||
isUpdate: boolean,
|
||||
uuid: string
|
||||
): Promise<ScriptInfo> {
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
throw new Error("fetch script info failed");
|
||||
}
|
||||
if (resp.headers.get("content-type")?.indexOf("text/html") !== -1) {
|
||||
throw new Error("url is html");
|
||||
}
|
||||
|
||||
const body = await resp.text();
|
||||
const parse = parseMetadata(body);
|
||||
if (!parse) {
|
||||
throw new Error("parse script info failed");
|
||||
}
|
||||
const ret: ScriptInfo = {
|
||||
url,
|
||||
code: body,
|
||||
uuid,
|
||||
isSubscribe: false,
|
||||
isUpdate,
|
||||
metadata: parse,
|
||||
source,
|
||||
};
|
||||
if (parse.usersubscribe) {
|
||||
ret.isSubscribe = true;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function copyScript(script: Script, old: Script): Script {
|
||||
const ret = script;
|
||||
ret.id = old.id;
|
||||
ret.uuid = old.uuid;
|
||||
ret.createtime = old.createtime;
|
||||
ret.lastruntime = old.lastruntime;
|
||||
// ret.delayruntime = old.delayruntime;
|
||||
ret.error = old.error;
|
||||
ret.sort = old.sort;
|
||||
if (!ret.selfMetadata) {
|
||||
ret.selfMetadata = old.selfMetadata || {};
|
||||
}
|
||||
ret.subscribeUrl = old.subscribeUrl;
|
||||
ret.status = old.status;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function copySubscribe(sub: Subscribe, old: Subscribe): Subscribe {
|
||||
const ret = sub;
|
||||
ret.id = old.id;
|
||||
ret.scripts = old.scripts;
|
||||
ret.createtime = old.createtime;
|
||||
ret.status = old.status;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(<string>reader.result);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function blobToText(blob: Blob): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(<string | null>reader.result);
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function base64ToBlob(dataURI: string) {
|
||||
const mimeString = dataURI.split(",")[0].split(":")[1].split(";")[0];
|
||||
const byteString = atob(dataURI.split(",")[1]);
|
||||
const arrayBuffer = new ArrayBuffer(byteString.length);
|
||||
const intArray = new Uint8Array(arrayBuffer);
|
||||
|
||||
for (let i = 0; i < byteString.length; i += 1) {
|
||||
intArray[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([intArray], { type: mimeString });
|
||||
}
|
||||
|
||||
export function strToBase64(str: string): string {
|
||||
return btoa(
|
||||
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1: string) => {
|
||||
return String.fromCharCode(parseInt(`0x${p1}`, 16));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 通过代码解析出脚本信息
|
||||
export function prepareScriptByCode(
|
||||
code: string,
|
||||
url: string,
|
||||
uuid?: string,
|
||||
override?: boolean
|
||||
): Promise<{ script: Script; oldScript?: Script }> {
|
||||
const dao = new ScriptDAO();
|
||||
return new Promise((resolve, reject) => {
|
||||
const metadata = parseMetadata(code);
|
||||
if (metadata == null) {
|
||||
throw new Error("MetaData信息错误");
|
||||
}
|
||||
if (metadata.name === undefined) {
|
||||
throw new Error("脚本名不能为空");
|
||||
}
|
||||
if (metadata.version === undefined) {
|
||||
throw new Error("脚本@version版本不能为空");
|
||||
}
|
||||
if (metadata.namespace === undefined) {
|
||||
throw new Error("脚本@namespace命名空间不能为空");
|
||||
}
|
||||
let type = SCRIPT_TYPE_NORMAL;
|
||||
if (metadata.crontab !== undefined) {
|
||||
type = SCRIPT_TYPE_CRONTAB;
|
||||
try {
|
||||
nextTime(metadata.crontab[0]);
|
||||
} catch {
|
||||
throw new Error(`错误的定时表达式,请检查: ${metadata.crontab[0]}`);
|
||||
}
|
||||
} else if (metadata.background !== undefined) {
|
||||
type = SCRIPT_TYPE_BACKGROUND;
|
||||
}
|
||||
let urlSplit: string[];
|
||||
let domain = "";
|
||||
let checkUpdateUrl = "";
|
||||
let downloadUrl = url;
|
||||
if (metadata.updateurl && metadata.downloadurl) {
|
||||
[checkUpdateUrl] = metadata.updateurl;
|
||||
[downloadUrl] = metadata.downloadurl;
|
||||
} else {
|
||||
checkUpdateUrl = url.replace("user.js", "meta.js");
|
||||
}
|
||||
if (url.indexOf("/") !== -1) {
|
||||
urlSplit = url.split("/");
|
||||
if (urlSplit[2]) {
|
||||
[, domain] = urlSplit;
|
||||
}
|
||||
}
|
||||
let newUUID = "";
|
||||
if (uuid) {
|
||||
newUUID = uuid;
|
||||
} else {
|
||||
newUUID = uuidv4();
|
||||
}
|
||||
let script: Script = {
|
||||
id: 0,
|
||||
uuid: newUUID,
|
||||
name: metadata.name[0],
|
||||
code,
|
||||
author: metadata.author && metadata.author[0],
|
||||
namespace: metadata.namespace && metadata.namespace[0],
|
||||
originDomain: domain,
|
||||
origin: url,
|
||||
checkUpdateUrl,
|
||||
downloadUrl,
|
||||
config: parseUserConfig(code),
|
||||
metadata,
|
||||
selfMetadata: {},
|
||||
sort: -1,
|
||||
type,
|
||||
status: SCRIPT_STATUS_DISABLE,
|
||||
runStatus: SCRIPT_RUN_STATUS_COMPLETE,
|
||||
createtime: Date.now(),
|
||||
updatetime: Date.now(),
|
||||
checktime: Date.now(),
|
||||
};
|
||||
const handler = async () => {
|
||||
let old: Script | undefined;
|
||||
if (uuid) {
|
||||
old = await dao.findByUUID(uuid);
|
||||
if (!old && override) {
|
||||
old = await dao.findByNameAndNamespace(script.name, script.namespace);
|
||||
}
|
||||
} else {
|
||||
old = await dao.findByNameAndNamespace(script.name, script.namespace);
|
||||
}
|
||||
if (old) {
|
||||
if (
|
||||
(old.type === SCRIPT_TYPE_NORMAL && script.type !== SCRIPT_TYPE_NORMAL) ||
|
||||
(script.type === SCRIPT_TYPE_NORMAL && old.type !== SCRIPT_TYPE_NORMAL)
|
||||
) {
|
||||
reject(new Error("脚本类型不匹配,普通脚本与后台脚本不能互相转变"));
|
||||
return;
|
||||
}
|
||||
script = copyScript(script, old);
|
||||
} else {
|
||||
// 前台脚本默认开启
|
||||
if (script.type === SCRIPT_TYPE_NORMAL) {
|
||||
script.status = SCRIPT_STATUS_ENABLE;
|
||||
}
|
||||
script.checktime = new Date().getTime();
|
||||
}
|
||||
resolve({ script, oldScript: old });
|
||||
};
|
||||
handler();
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareSubscribeByCode(
|
||||
code: string,
|
||||
url: string
|
||||
): Promise<{ subscribe: Subscribe; oldSubscribe?: Subscribe }> {
|
||||
const dao = new SubscribeDAO();
|
||||
const metadata = parseMetadata(code);
|
||||
if (metadata == null) {
|
||||
throw new Error("MetaData信息错误");
|
||||
}
|
||||
if (metadata.name === undefined) {
|
||||
throw new Error("订阅名不能为空");
|
||||
}
|
||||
let subscribe: Subscribe = {
|
||||
id: 0,
|
||||
url,
|
||||
name: metadata.name[0],
|
||||
code,
|
||||
author: metadata.author && metadata.author[0],
|
||||
scripts: {},
|
||||
metadata,
|
||||
status: SUBSCRIBE_STATUS_ENABLE,
|
||||
createtime: Date.now(),
|
||||
updatetime: Date.now(),
|
||||
checktime: Date.now(),
|
||||
};
|
||||
const old = await dao.findByUrl(url);
|
||||
if (old) {
|
||||
subscribe = copySubscribe(subscribe, old);
|
||||
}
|
||||
return Promise.resolve({ subscribe, oldSubscribe: old });
|
||||
}
|
96
src/pkg/utils/utils.test.ts
Normal file
96
src/pkg/utils/utils.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { formatTime, nextTime, ltever, checkSilenceUpdate } from "./utils";
|
||||
import dayjs from "dayjs";
|
||||
describe("nextTime", () => {
|
||||
test("每分钟表达式", () => {
|
||||
expect(nextTime("* * * * *")).toEqual(
|
||||
dayjs(new Date()).add(1, "minute").format("YYYY-MM-DD HH:mm:00")
|
||||
);
|
||||
});
|
||||
test("每分钟一次表达式", () => {
|
||||
expect(nextTime("once * * * *")).toEqual(
|
||||
dayjs(new Date())
|
||||
.add(1, "minute")
|
||||
.format("YYYY-MM-DD HH:mm 每分钟运行一次")
|
||||
);
|
||||
});
|
||||
test("每小时一次表达式", () => {
|
||||
expect(nextTime("* once * * *")).toEqual(
|
||||
dayjs(new Date()).add(1, "hour").format("YYYY-MM-DD HH 每小时运行一次")
|
||||
);
|
||||
});
|
||||
test("每天一次表达式", () => {
|
||||
expect(nextTime("* * once * *")).toEqual(
|
||||
dayjs(new Date()).add(1, "day").format("YYYY-MM-DD 每天运行一次")
|
||||
);
|
||||
});
|
||||
test("每月一次表达式", () => {
|
||||
expect(nextTime("* * * once *")).toEqual(
|
||||
dayjs(new Date()).add(1, "month").format("YYYY-MM 每月运行一次")
|
||||
);
|
||||
});
|
||||
test("每星期一次表达式", () => {
|
||||
expect(nextTime("* * * * once")).toEqual(
|
||||
dayjs(new Date()).add(1, "week").format("YYYY-MM-DD 每星期运行一次")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ltever", () => {
|
||||
it("semver", () => {
|
||||
expect(ltever("1.0.0", "1.0.1")).toBe(true);
|
||||
expect(ltever("1.0.0", "1.0.0")).toBe(true);
|
||||
expect(ltever("1.0.1", "1.0.0")).toBe(false);
|
||||
});
|
||||
it("any", () => {
|
||||
expect(ltever("1.2.3.4", "1.2.3.4")).toBe(true);
|
||||
expect(ltever("1.2.3.4", "1.2.3.5")).toBe(true);
|
||||
expect(ltever("1.2.3.4", "1.2.3.3")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSilenceUpdate", () => {
|
||||
it("true", () => {
|
||||
expect(
|
||||
checkSilenceUpdate(
|
||||
{
|
||||
connect: ["www.baidu.com"],
|
||||
},
|
||||
{
|
||||
connect: ["www.baidu.com"],
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkSilenceUpdate(
|
||||
{
|
||||
connect: ["www.baidu.com", "scriptcat.org"],
|
||||
},
|
||||
{
|
||||
connect: ["scriptcat.org"],
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
it("false", () => {
|
||||
expect(
|
||||
checkSilenceUpdate(
|
||||
{
|
||||
connect: ["www.baidu.com"],
|
||||
},
|
||||
{
|
||||
connect: ["www.google.com"],
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
checkSilenceUpdate(
|
||||
{
|
||||
connect: ["www.baidu.com"],
|
||||
},
|
||||
{
|
||||
connect: ["www.baidu.com", "scriptcat.org"],
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
184
src/pkg/utils/utils.ts
Normal file
184
src/pkg/utils/utils.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { CronTime } from "cron";
|
||||
import dayjs from "dayjs";
|
||||
import semver from "semver";
|
||||
|
||||
export function nextTime(crontab: string): string {
|
||||
let oncePos = 0;
|
||||
if (crontab.indexOf("once") !== -1) {
|
||||
const vals = crontab.split(" ");
|
||||
vals.forEach((val, index) => {
|
||||
if (val === "once") {
|
||||
oncePos = index;
|
||||
}
|
||||
});
|
||||
if (vals.length === 5) {
|
||||
oncePos += 1;
|
||||
}
|
||||
}
|
||||
let cron: CronTime;
|
||||
try {
|
||||
cron = new CronTime(crontab.replace(/once/g, "*"));
|
||||
} catch {
|
||||
throw new Error("错误的定时表达式");
|
||||
}
|
||||
if (oncePos) {
|
||||
switch (oncePos) {
|
||||
case 1: // 每分钟
|
||||
return cron.sendAt().toFormat("yyyy-MM-dd HH:mm 每分钟运行一次");
|
||||
case 2: // 每小时
|
||||
return cron.sendAt().plus({ hour: 1 }).toFormat("yyyy-MM-dd HH 每小时运行一次");
|
||||
case 3: // 每天
|
||||
return cron.sendAt().plus({ day: 1 }).toFormat("yyyy-MM-dd 每天运行一次");
|
||||
case 4: // 每月
|
||||
return cron.sendAt().plus({ month: 1 }).toFormat("yyyy-MM 每月运行一次");
|
||||
case 5: // 每星期
|
||||
return cron.sendAt().plus({ week: 1 }).toFormat("yyyy-MM-dd 每星期运行一次");
|
||||
}
|
||||
throw new Error("错误表达式");
|
||||
}
|
||||
return cron.sendAt().toFormat("yyyy-MM-dd HH:mm:ss");
|
||||
}
|
||||
|
||||
export function formatTime(time: Date) {
|
||||
return dayjs(time).format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
|
||||
export function formatUnixTime(time: number) {
|
||||
return dayjs.unix(time).format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
|
||||
export function semTime(time: Date) {
|
||||
return dayjs().to(dayjs(time));
|
||||
}
|
||||
|
||||
export function randomString(e: number) {
|
||||
e = e || 32;
|
||||
const t = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz";
|
||||
const a = t.length;
|
||||
let n = "";
|
||||
for (let i = 0; i < e; i += 1) {
|
||||
n += t.charAt(Math.floor(Math.random() * a));
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function dealSymbol(source: string): string {
|
||||
source = source.replace(/("|\\)/g, "\\$1");
|
||||
source = source.replace(/(\r\n|\n)/g, "\\n");
|
||||
return source;
|
||||
}
|
||||
|
||||
export function dealScript(source: string): string {
|
||||
return dealSymbol(source);
|
||||
}
|
||||
|
||||
export function isFirefox() {
|
||||
if (navigator.userAgent.indexOf("Firefox") >= 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function InfoNotification(title: string, msg: string) {
|
||||
chrome.notifications.create({
|
||||
type: "basic",
|
||||
title,
|
||||
message: msg,
|
||||
iconUrl: chrome.runtime.getURL("assets/logo.png"),
|
||||
});
|
||||
}
|
||||
|
||||
export function valueType(val: unknown) {
|
||||
switch (typeof val) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "object":
|
||||
return typeof val;
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function toStorageValueStr(val: unknown): string {
|
||||
switch (typeof val) {
|
||||
case "string":
|
||||
return `s${val}`;
|
||||
case "number":
|
||||
return `n${val.toString()}`;
|
||||
case "boolean":
|
||||
return `b${val ? "true" : "false"}`;
|
||||
default:
|
||||
try {
|
||||
return `o${JSON.stringify(val)}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStorageValue(str: string): unknown {
|
||||
if (str === "") {
|
||||
return undefined;
|
||||
}
|
||||
const t = str[0];
|
||||
const s = str.substring(1);
|
||||
switch (t) {
|
||||
case "b":
|
||||
return s === "true";
|
||||
case "n":
|
||||
return parseFloat(s);
|
||||
case "o":
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
case "s":
|
||||
return s;
|
||||
default:
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
// 对比版本大小
|
||||
export function ltever(newVersion: string, oldVersion: string) {
|
||||
// 先验证符不符合语义化版本规范
|
||||
try {
|
||||
return semver.lte(newVersion, oldVersion);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const newVer = newVersion.split(".");
|
||||
const oldVer = oldVersion.split(".");
|
||||
for (let i = 0; i < newVer.length; i += 1) {
|
||||
if (Number(newVer[i]) > Number(oldVer[i])) {
|
||||
return false;
|
||||
}
|
||||
if (Number(newVer[i]) < Number(oldVer[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 在当前页后打开一个新页面
|
||||
export function openInCurrentTab(url: string) {
|
||||
chrome.tabs.query(
|
||||
{
|
||||
active: true,
|
||||
},
|
||||
(tabs) => {
|
||||
if (tabs.length) {
|
||||
chrome.tabs.create({
|
||||
url,
|
||||
index: tabs[0].index + 1,
|
||||
});
|
||||
} else {
|
||||
chrome.tabs.create({
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user