This commit is contained in:
2024-11-23 21:13:05 +08:00
parent 6693de3f35
commit 82e2c29937
29 changed files with 1819 additions and 27 deletions

343
src/pkg/utils/script.ts Normal file
View 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 });
}

View 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
View 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,
});
}
}
);
}