6.7.0 - Alpha4 - 新增 JS 脚本工具 (run-scrapers.mjs)

This commit is contained in:
SuperMonster003
2025-09-09 17:24:48 +08:00
parent a997c7bbec
commit 9991ec3c28
56 changed files with 5167 additions and 752 deletions

263
.utils/utils/anchors.mjs Normal file
View File

@@ -0,0 +1,263 @@
// utils/anchors.mjs
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import { toUpdatedStamp } from './date.mjs';
/**
* @param {string} s
* @returns {string}
*/
const normalize = s => String(s).replace(/\s+/g, '');
/**
* 在指定 Anchor 块中, 用给定的替换函数生成新块内容.
*
* @param {string} src
* @param {string} anchorTag
* @param {(block: string) => { newBlock: string, changed: boolean }} replaceBlockFn
* @returns {{ src: string, changed: boolean }} - 返回 { src: 新源码, changed: 是否发生变更 }. 若找不到锚点, 原样返回.
*/
export function replaceInAnchoredBlock(src, anchorTag, replaceBlockFn) {
const beginTag = `// @AnchorBegin ${anchorTag}`;
const endTag = `// @AnchorEnd ${anchorTag}`;
const beginIdx = src.indexOf(beginTag);
if (beginIdx === -1) return { src, changed: false };
const endIdx = src.indexOf(endTag, beginIdx + beginTag.length);
if (endIdx === -1) return { src, changed: false };
const before = src.slice(0, beginIdx);
const block = src.slice(beginIdx, endIdx); // 不包含 endTag
const after = src.slice(endIdx);
const { newBlock, changed } = replaceBlockFn(block) || {};
if (!changed || !newBlock) return { src, changed: false };
return { src: before + newBlock + after, changed };
}
/**
* 替换锚点块中的某个 map 声明 (如 mapOf(...)), 并在变更时自动刷新 @Updated 日期.
*
* @param {string} src
* @param {Object} options
* @param {string} options.anchorTag - 块的锚点名
* @param {string} options.mapName - 变量名, 如 agpVersionMap
* @param {string[]} options.lines - map 体内的每行 (不含缩进, 由函数自动缩进)
* @param {number} [options.linesIndent=4]
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
* @returns {{ src: string, changed: boolean }}}
*/
export function replaceAnchoredMapBlock(src, {
anchorTag,
mapName,
lines,
linesIndent = 4,
toUpdatedStamp: toStamp = toUpdatedStamp,
}) {
return replaceInAnchoredBlock(src, anchorTag, (block) => {
let changed = false;
const re = new RegExp(`([\\t\\x20]*)(va[lr]\\s+)?${mapName}\\s*=\\s*mapOf\\([\\s\\S]*?\\)(,?)`, 'm');
let updatedBlock = block.replace(re, (/** @type {string} */ original, /** @type {string} */ indent, /** @type {string} */ keyword, /** @type {string} */ comma) => {
const kw = keyword ?? '';
const body = lines.map(l => `${' '.repeat(linesIndent)}${indent}${l}`).join('\n');
const next = `${indent}${kw}${mapName} = mapOf(\n${body}\n${indent})${comma}`;
if (normalize(original) !== normalize(next)) changed = true;
return next;
});
if (changed) {
updatedBlock = updatedBlock.replace(
/(@Updated[^\n]*?\son\s)([A-Z][a-z]{2}\s\d{1,2},\s\d{4})(\.?)/,
(_, p1, _old, p3) => `${p1}${(toStamp())}${p3}`,
);
}
return { newBlock: updatedBlock, changed };
});
}
/**
* 替换锚点块中的某个 list 声明 (如 listOf(...)), 并在变更时自动刷新 @Updated 日期.
*
* @param {string} src
* @param {Object} options
* @param {string} options.anchorTag - 块的锚点名
* @param {string} options.listName - 变量名, 如 modules 或 libs
* @param {string[]} options.lines - list 体内的每行 (不含缩进, 由函数自动缩进)
* @param {number} [options.linesIndent=4]
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
* @returns {{ src: string, changed: boolean }}}
*/
export function replaceAnchoredListBlock(src, {
anchorTag,
listName,
lines,
linesIndent = 4,
toUpdatedStamp: toStamp = toUpdatedStamp,
}) {
return replaceInAnchoredBlock(src, anchorTag, (block) => {
let changed = false;
const re = new RegExp(`([\\t\\x20]*)(va[lr]\\s+)?${listName}\\s*=\\s*listOf\\([\\s\\S]*?\\)(,?)`, 'm');
let updatedBlock = block.replace(re, (original, indent, keyword, comma) => {
const kw = keyword ?? '';
const body = lines.map(l => `${' '.repeat(linesIndent)}${indent}${l}`).join('\n');
const next = `${indent}${kw}${listName} = listOf(\n${body}\n${indent})${comma}`;
if (normalize(original) !== normalize(next)) changed = true;
return next;
});
if (changed) {
updatedBlock = updatedBlock.replace(
/(@Updated[^\n]*?\son\s)([A-Z][a-z]{2}\s\d{1,2},\s\d{4})(\.?)/,
(_, p1, _old, p3) => `${p1}${(toStamp())}${p3}`,
);
}
return { newBlock: updatedBlock, changed };
});
}
/**
* 高层封装: 读取文件 -> 替换锚点 map -> 若有变更则写回 -> 打印日志.
*
* @param {string} filePath
* @param {Object} options
* @param {string} options.anchorTag - 块的锚点名
* @param {string} options.mapName - 变量名, 如 agpVersionMap
* @param {string[]} options.lines - map 体内的每行 (不含缩进, 由函数自动缩进)
* @param {number} [options.linesIndent=4]
* @param {string} [options.updatedLabel='']
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
* @param {Console} [options.logger=console]
* @returns {Promise<{ changed: boolean, content: string }>}
*/
export async function updateAnchoredMapInFile(filePath, {
anchorTag,
mapName,
lines,
linesIndent = 4,
updatedLabel = '',
toUpdatedStamp: toStamp = toUpdatedStamp,
logger = console,
}) {
const filename = path.basename(filePath);
const raw = await fsp.readFile(filePath, 'utf8');
const { src: updated, changed } = replaceAnchoredMapBlock(raw, { anchorTag, mapName, lines, linesIndent, toUpdatedStamp: toStamp });
if (changed) {
await fsp.writeFile(filePath, updated, 'utf8');
logger.log(`[${filename}] 已更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
} else {
// logger.log(`[${filename}] 无需更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
}
return { changed, content: updated };
}
/**
* 高层封装: 读取文件 -> 替换锚点 list -> 若有变更则写回 -> 打印日志.
*
* @param {string} filePath
* @param {Object} options
* @param {string} options.anchorTag - 块的锚点名
* @param {string} options.listName - 变量名, 如 modules 或 libs
* @param {string[]} options.lines - list 体内的每行 (不含缩进, 由函数自动缩进)
* @param {number} [options.linesIndent=4]
* @param {string} [options.updatedLabel='']
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
* @param {Console} [options.logger=console]
* @returns {Promise<{ changed: boolean, content: string }>}
*/
export async function updateAnchoredListInFile(filePath, {
anchorTag,
listName,
lines,
linesIndent = 4,
updatedLabel = '',
toUpdatedStamp: toStamp = toUpdatedStamp,
logger = console,
}) {
const filename = path.basename(filePath);
const raw = await fsp.readFile(filePath, 'utf8');
const { src: updated, changed } = replaceAnchoredListBlock(raw, { anchorTag, listName, lines, linesIndent, toUpdatedStamp: toStamp });
if (changed) {
await fsp.writeFile(filePath, updated, 'utf8');
logger.log(`[${filename}] 已更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
} else {
// logger.log(`[${filename}] 无需更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
}
return { changed, content: updated };
}
/**
* @typedef {Object} AnchoredBlockUpdateOption
* @property {'map' | 'list' | 'custom'} type
* @property {string} anchorTag
* @property {string} [mapName]
* @property {string} [listName]
* @property {string[]} lines
* @property {number} [linesIndent=4]
* @property {string} [updatedLabel]
* @property {(srcInBlock: string, options: { toUpdatedStamp?: (date?: Date) => string }) => { newBlock: string, changed: boolean }} [replacer]
*/
/**
* 批量在同一文件内进行多锚点替换 (map 与 list 都支持, 读一次/写一次).
*
* @param {string} filePath
* @param {AnchoredBlockUpdateOption[]} optionList
* @param {Object} [extraOptions={}]
* @param {(date?: Date) => string} [extraOptions.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
* @param {Console} [extraOptions.logger=console]
* @returns {Promise<{ changed: boolean, content: string }>}
*/
export async function batchUpdateAnchoredBlocks(filePath, optionList, {
toUpdatedStamp: toStamp = toUpdatedStamp,
logger = console,
} = {}) {
const filename = path.basename(filePath);
let raw = await fsp.readFile(filePath, 'utf8');
let changedAny = false;
for (const op of optionList) {
let res = { src: raw, changed: false };
if (op.type === 'map') {
res = replaceAnchoredMapBlock(raw, {
anchorTag: op.anchorTag,
mapName: op.mapName,
lines: op.lines,
linesIndent: op.linesIndent,
toUpdatedStamp: toStamp,
});
} else if (op.type === 'list') {
res = replaceAnchoredListBlock(raw, {
anchorTag: op.anchorTag,
listName: op.listName,
lines: op.lines,
linesIndent: op.linesIndent,
toUpdatedStamp: toStamp,
});
} else if (op.type === 'custom' && typeof op.replacer === 'function') {
res = replaceInAnchoredBlock(raw, op.anchorTag, (block) => op.replacer(block, { toUpdatedStamp: toStamp }));
} else {
logger.warn(`[${filename}] 未知操作类型或缺少参数:`, op);
continue;
}
if (res.changed) {
changedAny = true;
raw = res.src;
logger.log(`[${filename}] 已更新 (${op['updatedLabel'] ?? op.anchorTag})`);
} else {
// logger.log(`[${filename}] 无需更新 (${op['updatedLabel'] ?? op.anchorTag})`);
}
}
if (changedAny) {
await fsp.writeFile(filePath, raw, 'utf8');
}
return { changed: changedAny, content: raw };
}

9
.utils/utils/async.mjs Normal file
View File

@@ -0,0 +1,9 @@
// utils/async.mjs
/**
* @param {number} ms
* @returns {Promise<NodeJS.Timeout>}
*/
export async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}

23
.utils/utils/date.mjs Normal file
View File

@@ -0,0 +1,23 @@
// utils/date.mjs
/**
* @param {Date} [date=new Date()]
* @returns {string}
*/
export function toUpdatedStamp(date = new Date()) {
/* e.g. "Aug 23, 2025". */
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
/**
* @param {string} [dateText='']
* @returns {string | null}
*/
export function toYYYYMMDD(dateText = '') {
const d = dateText ? new Date(dateText) : new Date();
if (Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}/${m}/${day}`;
}

100
.utils/utils/fetch.mjs Normal file
View File

@@ -0,0 +1,100 @@
// utils/fetch.mjs
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/commits']['response']['data']} CommitsData */
import fetch from 'node-fetch';
import * as dotenv from 'dotenv';
import { toYYYYMMDD } from './date.mjs';
dotenv.config({ path: '../.env', quiet: true });
/**
* 获取远程文件真实大小.
*
* @param {string} url
* @param {{timeout?: number}} [options]
* @returns {Promise<number | null>}
*/
export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
const headers = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36',
'accept': '*/*',
};
// 1) 尝试 HEAD
try {
const res = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
headers,
});
if (res.ok) {
const len = res.headers.get('content-length');
if (len && /^\d+$/.test(len)) return Number(len);
}
} catch (_) {
/* Ignored. */
}
// 2) 尝试 Range GET (bytes=0-0), 从 Content-Range 解析总长度
try {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeout);
const res = await fetch(url, {
method: 'GET',
redirect: 'follow',
headers: { ...headers, range: 'bytes=0-0' },
signal: ac.signal,
}).finally(() => clearTimeout(t));
if (res.ok || res.status === 206) {
// Content-Range: bytes 0-0/123456789
const cr = res.headers.get('content-range');
if (cr) {
const m = /bytes\s+\d+-\d+\/(\d+)/i.exec(cr);
if (m) return Number(m[1]);
}
// 退化: 仍然尝试 content-length
const len = res.headers.get('content-length');
if (len && /^\d+$/.test(len)) return Number(len);
}
} catch (_) {
/* Ignored. */
}
return null;
}
/**
* @param {string} owner
* @param {string} repo
* @return {Promise<string>}
*/
export async function getLatestCommitDate(owner, repo) {
const token = process.env.GITHUB_TOKEN; // 可选:避免频繁请求受限
const url = `https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`;
/** @type {import('node-fetch').HeadersInit} */
const headers = {
accept: 'application/vnd.github+json',
'user-agent': 'repo-last-commit-script',
...(token ? { authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(url, { headers });
if (!res.ok) {
throw new Error(`GitHub API 请求失败: ${res.status} ${res.statusText}`);
}
const data = /** @type {CommitsData} */ await res.json();
const latest = Array.isArray(data) ? data[0] : null;
if (!latest?.commit) throw new Error('未获取到最新提交');
// 优先使用 committer 的提交时间fallback 到 author
const iso = latest.commit.committer?.date ?? latest.commit.author?.date;
if (!iso) throw new Error('提交对象缺少日期字段');
return toYYYYMMDD(iso);
}

11
.utils/utils/format.mjs Normal file
View File

@@ -0,0 +1,11 @@
// utils/format.mjs
/**
* @param {number | null} bytes
* @param {number} [fractionDigits=2]
*/
export function bytes2GiB(bytes, fractionDigits = 2) {
if (bytes == null) return null;
const gib = bytes / 1024 ** 3;
return `${gib.toFixed(fractionDigits)} GiB`;
}

234
.utils/utils/properties.mjs Normal file
View File

@@ -0,0 +1,234 @@
// utils/properties.mjs
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import { compareVersionStrings } from './versioning.mjs';
/**
* @param {string} str
* @returns {string}
*/
function unescapeProperty(str) {
let i = 0, out = '';
while (i < str.length) {
const ch = str[i++];
if (ch !== '\\') {
out += ch;
continue;
}
const next = str[i++];
switch (next) {
case 't':
out += '\t';
break;
case 'n':
out += '\n';
break;
case 'r':
out += '\r';
break;
case 'f':
out += '\f';
break;
case 'u': {
const hex = str.slice(i, i + 4);
if (/^[0-9a-fA-F] {4}$/.test(hex)) {
out += String.fromCharCode(parseInt(hex, 16));
i += 4;
} else {
// 非法 \u 序列, 按字面量保留
out += '\\u';
}
break;
}
case ':':
case '=':
case ' ':
case '\\':
out += next;
break;
default:
// 未知转义, 保留第二个字符
out += next;
}
}
return out;
}
/**
* @param {string} text
* @returns {Object<string, string>}
*/
export function parseProperties(text) {
const props = Object.create(null);
if (!text) return props;
const lines = [];
const rawLines = text.split(/\r?\n/);
// 合并续行 (以反斜杠结尾且反斜杠未被转义)
for (let i = 0; i < rawLines.length; i++) {
let line = rawLines[i];
if (line == null) continue;
// 去除行尾 CR (兼容 \r\n 已 split 的情况, 一般无需此步)
line = line.replace(/\r$/, '');
// 合并续行
while (true) {
// 统计结尾连续反斜杠数量, 奇数表示续行
let backslashes = 0;
for (let j = line.length - 1; j >= 0 && line[j] === '\\'; j--) backslashes++;
const isContinuation = backslashes % 2 === 1;
if (!isContinuation) break;
const next = rawLines[++i];
if (next == null) break;
// 去掉一个续行用的反斜杠, 再拼接后续行, 续行处按规范会吞掉换行
line = line.slice(0, -1) + next;
}
lines.push(line);
}
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith('#') || line.startsWith('!')) continue;
// 键值分隔: 第一个 =/: 或未转义空白
let key = '';
let value = '';
let sepIdx = -1;
// 逐字符扫描, 识别未转义的分隔符
let escaped = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (!escaped && (ch === '=' || ch === ':')) {
sepIdx = i;
break;
}
if (!escaped && /\s/.test(ch)) {
sepIdx = i;
break;
}
escaped = ch === '\\' && !escaped;
if (!escaped && ch !== '\\') escaped = false;
}
if (sepIdx === -1) {
key = line;
value = '';
} else {
key = line.slice(0, sepIdx);
value = line.slice(sepIdx + 1);
// 如果分隔符是空白, value 应该从第一个非空白处开始
if (/^\s$/.test(line[sepIdx])) {
value = value.replace(/^\s+/, '');
}
}
key = key.replace(/\s+$/, ''); // 规范里 key 前部空白可作为分隔符, 末尾空白需要去掉
const k = unescapeProperty(key);
const v = unescapeProperty(value.trim());
if (k) props[k] = v;
}
return props;
}
/**
* @param {string} [filePath='../version.properties']
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @returns {Promise<Object<string, string>>}
*/
export async function readProperties(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
const text = await fsp.readFile(filePath, { encoding });
return parseProperties(text);
}
/**
* @param {string} [filePath='../version.properties']
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @returns {Object<string, string>}
*/
export function readPropertiesSync(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
const text = fs.readFileSync(filePath, { encoding });
return parseProperties(text);
}
/**
* @param {string} [filePath='../version.properties']
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @returns {string}
*/
export function getMinSupportedAgpVersion(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
let minSupportedVersion = null;
Object.entries(readPropertiesSync(filePath, { encoding })).forEach(([ key, value ]) => {
if (!/agp.version.*min.supported|min.supported.*agp.version/i.test(key)) return;
if (minSupportedVersion === null || compareVersionStrings(value, minSupportedVersion) < 0) {
minSupportedVersion = value;
}
});
return minSupportedVersion ?? '8.0';
}
/**
* @param {string} [filePath='../version.properties']
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @returns {string}
*/
export function getMinSupportedGradleVersion(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
let minSupportedVersion = null;
Object.entries(readPropertiesSync(filePath, { encoding })).forEach(([ key, value ]) => {
if (!/gradle.version.*min.supported|min.supported.*gradle.version/i.test(key)) return;
if (minSupportedVersion === null || compareVersionStrings(value, minSupportedVersion) < 0) {
minSupportedVersion = value;
}
});
return minSupportedVersion ?? '8.0';
}
/**
* @param {string} [filePath='../version.properties']
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @returns {number}
*/
export function getMinSupportedJavaVersionInt(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
return getJavaVersionInfo(filePath, { encoding }).minSupportedJavaVersionInt;
}
/**
* @param {string} [filePath='../version.properties']
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @returns {{ currentJavaVersionInt: number, minSupportedJavaVersionInt: number, minSuggestedJavaVersionInt: number, maxSupportedJavaVersionInt: number }}
*/
export function getJavaVersionInfo(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
let currentVersion = 0;
let minSuggestedVersion = 19;
let minSupportedVersion = 17;
let maxSupportedVersion = 0;
Object.entries(readPropertiesSync(filePath, { encoding })).forEach(([ key, value ]) => {
const versionNumber = parseInt(value, 10);
if (/^java.version$/i.test(key)) {
currentVersion = Math.max(currentVersion, versionNumber);
} else if (/java.version.*min.suggested|min.suggested.*java.version/i.test(key)) {
minSuggestedVersion = Math.min(minSuggestedVersion, versionNumber);
} else if (/java.version.*min.supported|min.supported.*java.version/i.test(key)) {
minSupportedVersion = Math.min(minSupportedVersion, versionNumber);
} else if (/java.version.*max.supported|max.supported.*java.version/i.test(key)) {
maxSupportedVersion = Math.max(maxSupportedVersion, versionNumber);
}
});
return {
currentJavaVersionInt: currentVersion,
minSuggestedJavaVersionInt: minSuggestedVersion,
minSupportedJavaVersionInt: minSupportedVersion,
maxSupportedJavaVersionInt: maxSupportedVersion,
};
}

View File

@@ -0,0 +1,160 @@
// utils/puppeteer-helpers.mjs
/** @typedef {import('puppeteer').Page} Page */
import puppeteer from 'puppeteer';
import { sleep } from './async.mjs';
/**
* @param {Page} page
* @returns {Promise<void>}
*/
export async function autoScroll(page) {
await page.evaluate(async () => {
await new Promise(resolve => {
let total = 0;
const step = 400;
const timer = setInterval(() => {
window.scrollBy(0, step);
total += step;
if (total >= document.body.scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});
}
/**
* @typedef {object} FindTargetRowsOptions
* @property {string} [tableSelector='table']
* @property {{ [selector: string]: string | string[] }}[tableFilter={}]
* @property {string} [tableRowSelector='tbody tr']
* @property {string} [tableDataSelector='td']
* @property {Array<{ [dataItemName: string]: string } | string>} [tableDataStructure=[]]
*/
/**
* @param {Page} page
* @param {FindTargetRowsOptions} [options={}]
* @returns {Promise<Array<{ [dataItemName: string]: (string | null) }>>}
*/
async function findTargetRowsWithPage(page, options = {}) {
return await page.evaluate((options) => {
const targets = Array.from(document.querySelectorAll(options.tableSelector ?? 'table'));
const target = targets.find(t => {
for (const [ selector, filter ] of Object.entries(options.tableFilter ?? {})) {
const elements = Array.from(t.querySelectorAll(selector));
if (Array.isArray(filter)) {
if (!filter.some(f => elements.some(e => {
if (typeof f === 'string') {
if (!f.startsWith(':RegExp:')) {
return e.textContent.trim() === f;
}
const [ _, flags, pattern ] = f.match(/:RegExp:(?:(\w+):)?(.+)/);
const re = new RegExp(pattern, flags);
return re.test(e.textContent.trim());
}
throw TypeError(`Unknown type of filter (${f})`);
}))) {
return false;
}
} else {
if (!elements.some(e => {
if (typeof filter === 'string') {
if (!filter.startsWith(':RegExp:')) {
return e.textContent.trim() === filter;
}
const [ _, flags, pattern ] = filter.match(/:RegExp:(?:(\w+):)?(.+)/);
const re = new RegExp(pattern, flags);
return re.test(e.textContent.trim());
}
throw TypeError(`Unknown type of filter (${filter})`);
})) {
return false;
}
}
}
return true;
});
if (!target) {
throw Error('No target table found');
}
const tableRows = Array.from(target.querySelectorAll(options.tableRowSelector ?? 'tbody tr'));
const tableDataList = [];
tableRows.forEach((tr) => {
const tableData = {};
const tds = Array.from(tr.querySelectorAll(options.tableDataSelector ?? 'td'));
const tableDataStructure = options.tableDataStructure ?? [];
if (tds.length === 0 || tableDataStructure.length === 0) return null;
if (options.tableDataStructure.length > tds.length) {
throw Error(`Table data size (${tds.length}) is less than table data structure (${options.tableDataStructure.length})`);
}
for (let i = 0; i < options.tableDataStructure.length; i++) {
const o = options.tableDataStructure[i];
let dataItemName = null;
let dataItemFilter = null;
if (typeof o === 'string') {
dataItemName = o;
} else if (typeof o === 'object' && o !== null) {
if (Object.keys(o).length !== 1) {
throw Error(`Table data structure (${options.tableDataStructure}) must be a string or an object with only one key`);
}
dataItemName = Object.keys(o)[0];
dataItemFilter = o[dataItemName];
} else {
throw Error(`Unknown type of table data structure (${options.tableDataStructure})`);
}
const dataItemValueRaw = tds[i].textContent.trim();
if (dataItemFilter == null) {
tableData[dataItemName] = dataItemValueRaw;
} else if (typeof dataItemFilter === 'string') {
if (!dataItemFilter.startsWith(':RegExp:')) {
tableData[dataItemName] = dataItemValueRaw === dataItemFilter ? dataItemValueRaw : null;
} else {
const [ _, flags, pattern ] = dataItemFilter.match(/:RegExp:(?:(\w+):)?(.+)/);
const re = new RegExp(pattern, flags);
tableData[dataItemName] = dataItemValueRaw.match(re)?.[0] ?? null;
}
}
}
tableDataList.push(tableData);
});
return tableDataList;
}, options);
}
/**
* @typedef {object} PuppeteerOptions
* @property {string} url
* @property {number} [pageGoToTimeout=120000]
* @property {number} [findTargetRowsTimeout=30000]
*/
/**
* @param {FindTargetRowsOptions & PuppeteerOptions} options
* @returns {Promise<Array<{[dataItemName: string]: string | null}>>}
*/
export async function findTargetRows(options) {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
try {
await page.goto(options.url, { waitUntil: 'networkidle0', timeout: options.pageGoToTimeout ?? 120000 });
// 页面为懒加载: 滚动并多次尝试, 直到目标表格出现或超时
let rows = null;
const deadline = Date.now() + (options.findTargetRowsTimeout ?? 30000);
while (Date.now() < deadline) {
rows = await findTargetRowsWithPage(page, options);
if (rows && rows.length) break;
await autoScroll(page);
await sleep(300);
}
if (rows && rows.length > 0) {
return rows;
}
throw new Error('Unable to locate target table rows (lazy-loaded content not found in time)');
} finally {
await browser.close();
}
}

View File

@@ -0,0 +1,97 @@
// utils/versioning.mjs
const SUFFIX_PRIORITY = { '': 10, 'alpha': 1, 'beta': 2, 'canary': 3, 'rc': 5 };
/**
* @param {string} version
* @return { [number[], [string, number]]}
*/
export function toVersionParts(version) {
const parts = version.split(/[\s+-]/);
const numberParts = parts[0].split('.').map(part => {
const num = parseInt(String(part), 10);
if (Number.isNaN(num)) {
throw new Error(`Invalid version part: '${part}' in version: '${version}'`);
}
return num;
});
// 解析后缀, 如 Alpha2 / Beta / RC1 等; 默认数字为 1
const suffixPattern = /([A-Za-z]+)\s*(\d*)|([A-Za-z]*)\s*(\d+)/;
const suffixStr = parts[1] || '';
const m = suffixStr.match(suffixPattern);
if (!m) return [ numberParts, [ '', 0 ] ];
const suffixName = m[1] || '';
const suffixNumber = parseInt(m[2] || '1', 10);
return [ numberParts, [ suffixName, Number.isNaN(suffixNumber) ? 1 : suffixNumber ] ];
}
/**
* @param {number[]} a
* @param {number[]} b
* @return {number}
*/
export function compareVersionParts(a, b) {
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i++) {
const x = a[i] ?? 0;
const y = b[i] ?? 0;
if (x !== y) return x > y ? 1 : -1;
}
return 0;
}
/**
* @param {[string, number]} s1
* @param {[string, number]} s2
* @return {number}
*/
export function compareVersionSuffix(s1, s2) {
const [ name1Raw, num1 ] = s1;
const [ name2Raw, num2 ] = s2;
const name1 = name1Raw.toLowerCase();
const name2 = name2Raw.toLowerCase();
const p1 = SUFFIX_PRIORITY[name1] ?? Number.MAX_SAFE_INTEGER;
const p2 = SUFFIX_PRIORITY[name2] ?? Number.MAX_SAFE_INTEGER;
if (p1 !== p2) return p1 > p2 ? 1 : -1;
if (num1 !== num2) return num1 > num2 ? 1 : -1;
return 0;
}
/**
* @param {string} v1
* @param {string} v2
* @return {number}
*/
export function compareVersionStrings(v1, v2) {
const [ n1, s1 ] = toVersionParts(v1);
const [ n2, s2 ] = toVersionParts(v2);
const cmp = compareVersionParts(n1, n2);
return cmp !== 0 ? cmp : compareVersionSuffix(s1, s2);
}
/**
* @param {string} v1
* @param {string} v2
* @return {number}
*/
export function compareVersionStringsDescending(v1, v2) {
const [ n1, s1 ] = toVersionParts(v1);
const [ n2, s2 ] = toVersionParts(v2);
const cmp = compareVersionParts(n2, n1);
return cmp !== 0 ? cmp : compareVersionSuffix(s2, s1);
}
/**
* @param {string} v
* @param {Object} options
* @param {string} [options.min]
* @param {string} [options.max]
* @return {boolean}
*/
export function isVersionInRange(v, { min, max } = {}) {
return (min !== null && compareVersionStrings(v, min) >= 0)
&& (max !== null && compareVersionStrings(v, max) <= 0);
}