6.7.0 - Alpha6 - 重构 scrapers 工具并增加中英双语注释内容
This commit is contained in:
@@ -1,45 +1,39 @@
|
||||
// fetch-and-parse-android-studio-agp-compatibility-table.mjs
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import { load } from 'cheerio';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/studio/releases#android_gradle_plugin_and_android_studio_compatibility';
|
||||
|
||||
/**
|
||||
* @return {Promise<Array<{ studioVersion: string, agpRange: string }>>}
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalize = (s) => s.trim().replace(/\s+/g, ' ');
|
||||
|
||||
/**
|
||||
* @returns {Promise<Array<{ studioVersion: string, agpRange: string }>>}
|
||||
*/
|
||||
export async function fetchStudioAgpTable() {
|
||||
const html = await fetch(URL).then(r => r.text());
|
||||
const $ = load(html);
|
||||
|
||||
const targetTable = $('table').filter((_, el) => {
|
||||
let text = $(el).find('th').first().text();
|
||||
return /Android Studio version/i.test(text);
|
||||
// @ts-ignore
|
||||
return findTargetRows({
|
||||
url: URL,
|
||||
tableFilter: {
|
||||
th: /Android Studio version/i,
|
||||
},
|
||||
tableDataStructure: [
|
||||
{ 'studioVersion': normalize },
|
||||
{ 'agpRange': normalize },
|
||||
],
|
||||
});
|
||||
|
||||
if (!targetTable.length) {
|
||||
throw new Error('未找到目标表格, 页面结构可能已变更');
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
targetTable.find('tbody tr').each((_, tr) => {
|
||||
const cells = $(tr).find('td').map((_, td) => {
|
||||
return $(td).text().trim().replace(/\s+/g, ' ');
|
||||
}).get();
|
||||
if (cells.length < 2) return;
|
||||
const [ studioVersion, agpRange ] = cells;
|
||||
rows.push({ studioVersion, agpRange });
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.table(await fetchStudioAgpTable());
|
||||
}
|
||||
|
||||
// 判断是否为直接执行该文件
|
||||
// Determine if this file is being run directly.
|
||||
// zh-CN: 判断是否为直接执行该文件.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
// fetch-and-parse-android-studio-archives.mjs
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { sleep } from './utils/async.mjs';
|
||||
import { compareVersionStrings, isVersionStable } from './utils/versioning.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readPropertiesSync } from './utils/properties.mjs';
|
||||
import { sleep } from './utils/async.mjs';
|
||||
|
||||
/** @typedef {import('puppeteer').Page} Page */
|
||||
/** @typedef {import('puppeteer').Frame} Frame */
|
||||
@@ -20,51 +22,64 @@ import { readPropertiesSync } from './utils/properties.mjs';
|
||||
*/
|
||||
|
||||
const URL = 'https://developer.android.com/studio/archive?hl=en';
|
||||
const SELECTOR_PRIMARY_BUTTON = 'button.button-primary';
|
||||
const SELECTOR_DEVSITE_EXPANDABLE = 'devsite-expandable';
|
||||
|
||||
/**
|
||||
* 在所有 frame (含主文档) 中查找 "同意" 按钮.
|
||||
* Find "agree" button in frame (including main document).<br>
|
||||
* zh-CN: 在所有 frame (含主文档) 中查找 "同意" 按钮.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {number} [timeoutMs=30000]
|
||||
* @returns {Promise<{handle: ElementHandle<HTMLButtonElement>, frame: Frame}>}
|
||||
* @returns {Promise<{ handle: ElementHandle<HTMLButtonElement>, frame: Frame }>}
|
||||
*/
|
||||
async function waitAndFindAgreeButton(page, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const selector = 'button.button-primary';
|
||||
/**
|
||||
* @param {string | null} s
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const containsAgreementText = (s) => s && /\b(?:i\s*agree|agree\s*to\s*the\s*terms|^agree$)/i.test(s.trim());
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
// 1) 先尝试主文档
|
||||
// Attempt main document.
|
||||
// zh-CN: 尝试主文档.
|
||||
|
||||
const mainBtn = await page.$$(selector);
|
||||
for (const h of mainBtn) {
|
||||
const txt = await page.evaluate(el => (el.textContent || '').trim().toLowerCase(), h);
|
||||
if (txt.includes('agree')) return { handle: h, frame: page.mainFrame() };
|
||||
const btnList = await page.$$(SELECTOR_PRIMARY_BUTTON);
|
||||
for (const btn of btnList) {
|
||||
const txt = await page.evaluate(el => el.textContent, btn);
|
||||
if (containsAgreementText(txt)) {
|
||||
return { handle: btn, frame: page.mainFrame() };
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 再查所有子 frame
|
||||
// Check all sub-frame.
|
||||
// zh-CN: 检查所有子 frame.
|
||||
|
||||
const frames = page.frames();
|
||||
for (const f of frames) {
|
||||
/** @type {ElementHandle<HTMLButtonElement>} */
|
||||
const btn = await f.$(selector);
|
||||
const btn = await f.$(SELECTOR_PRIMARY_BUTTON);
|
||||
if (!btn) continue;
|
||||
const txt = await f.evaluate(el => (el.textContent || '').trim().toLowerCase(), btn);
|
||||
if (txt.includes('i agree') || txt.includes('agree to the terms') || txt === 'agree') {
|
||||
const txt = await f.evaluate(el => el.textContent, btn);
|
||||
if (containsAgreementText(txt)) {
|
||||
return { handle: btn, frame: f };
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 触发懒加载: 轻微滚动几次
|
||||
// Trigger lazy-loading: scroll slightly several times.
|
||||
// zh-CN: 触发懒加载, 轻微滚动几次.
|
||||
|
||||
await page.evaluate(() => window.scrollBy(0, 600));
|
||||
await sleep(250);
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error('未在任何文档中找到 "同意" 按钮 (超时)');
|
||||
throw new Error('Unable to find "agree" button in any document (timeout)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 在所有 frame 中等待某个选择器出现, 并返回该 frame.
|
||||
* Wait for a selector to appear in any frame and return that frame.<br>
|
||||
* zh-CN: 在所有 frame 中等待某个选择器出现, 并返回该 frame.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {string} selector
|
||||
@@ -75,16 +90,17 @@ async function waitForFrameWithSelector(page, selector, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const f of page.frames()) {
|
||||
const el = await f.$(selector);
|
||||
if (el) return f;
|
||||
if (await f.$(selector)) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
await sleep(250);
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error(` 未在任何 frame 中找到选择器: ${selector}`);
|
||||
throw new Error(`Could not find selector "${selector}" in any frame`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<ArchiveItem[]>}
|
||||
* @returns {Promise<ArchiveItem[]>}
|
||||
*/
|
||||
export async function getAndroidStudioArchives() {
|
||||
const browser = await puppeteer.launch({
|
||||
@@ -99,74 +115,68 @@ export async function getAndroidStudioArchives() {
|
||||
await page.setUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36');
|
||||
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
// 滚动到下载区域, 促发懒加载 (有助于注入承载 "同意" 按钮的 iframe)
|
||||
// Scroll to download area, trigger lazy loading (helpful for injecting iframe containing "agree" button).
|
||||
// zh-CN: 滚动到下载区域, 触发懒加载 (有助于注入承载 "同意" 按钮的 iframe).
|
||||
await page.evaluate(() => {
|
||||
const anchor = document.querySelector('#downloads')
|
||||
|| Array.from(document.querySelectorAll('h2, h3'))
|
||||
.find(h => /download|archive/i.test(h.textContent || ''));
|
||||
.find(h => /download|archive/i.test(h.textContent ?? ''));
|
||||
if (anchor) anchor.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await sleep(500);
|
||||
await sleep(300);
|
||||
|
||||
// 等待并点击 "同意" 按钮
|
||||
try {
|
||||
const { handle, frame } = await waitAndFindAgreeButton(page, 30000);
|
||||
await frame.waitForSelector('button.button-primary', { visible: true, timeout: 15000 }).catch(() => {
|
||||
});
|
||||
await frame.waitForSelector(SELECTOR_PRIMARY_BUTTON, { visible: true, timeout: 15000 }).catch(_ => null);
|
||||
await handle.click();
|
||||
} catch (e) {
|
||||
console.log('未检测到协议或已同意, 继续解析...');
|
||||
console.log('No protocol detected or already agreed, continuing parsing...');
|
||||
}
|
||||
|
||||
// 同意后不要在主文档等待; 改为在包含内容的 frame 里等待 devsite-expandable
|
||||
// 若首次未出现, 尝试轻微滚动以触发懒加载, 再次检查
|
||||
// Don't wait in main document after agreeing; wait for devsite-expandable in content frame instead.
|
||||
// If not found initially, try slightly scrolling to trigger lazy loading and check again.
|
||||
// zh-CN:
|
||||
// 同意后不要在主文档等待; 改为在包含内容的 frame 里等待 devsite-expandable.
|
||||
// 若首次未出现, 尝试轻微滚动以触发懒加载, 再次检查.
|
||||
|
||||
/** @type {Frame} */
|
||||
let contentFrame;
|
||||
try {
|
||||
contentFrame = await waitForFrameWithSelector(page, 'devsite-expandable', 20000);
|
||||
contentFrame = await waitForFrameWithSelector(page, SELECTOR_DEVSITE_EXPANDABLE, 20000);
|
||||
} catch {
|
||||
// 尝试滚动触发
|
||||
// Attempt to trigger loading by scrolling. (zh-CN: 尝试滚动触发.)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => window.scrollBy(0, 800));
|
||||
await sleep(250);
|
||||
await sleep(300);
|
||||
}
|
||||
// 再次寻找
|
||||
contentFrame = await waitForFrameWithSelector(page, 'devsite-expandable', 20000);
|
||||
// Check again. (zh-CN: 再次检查.)
|
||||
contentFrame = await waitForFrameWithSelector(page, SELECTOR_DEVSITE_EXPANDABLE, 20000);
|
||||
}
|
||||
if (!contentFrame) {
|
||||
throw new Error('Failed to find content frame');
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {ArchiveItem[]}
|
||||
*/
|
||||
const archives = await contentFrame.$$eval('devsite-expandable', nodes => {
|
||||
|
||||
// 从内容 frame 中直接抽取 devsite-expandable 数据
|
||||
|
||||
/**
|
||||
* @param {Node | null} el
|
||||
* @returns {string}
|
||||
*/
|
||||
const pickText = el => (el?.textContent || '').trim();
|
||||
|
||||
/** @type {ArchiveItem[]} */
|
||||
const archives = await contentFrame.$$eval(SELECTOR_DEVSITE_EXPANDABLE, nodes => {
|
||||
const pickText = (/** @type {Node | null} */ el) => String(el?.textContent ?? '').trim();
|
||||
return nodes.map(n => {
|
||||
/** @type {Node} */
|
||||
const titleEl = n.querySelector('.expand-control');
|
||||
const title = pickText(titleEl?.childNodes?.[0]); // 不含日期的主标题
|
||||
const titleElement = n.querySelector('.expand-control');
|
||||
const title = pickText(titleElement?.childNodes?.[0] ?? null);
|
||||
const date = pickText(n.querySelector('.expand-control span'))
|
||||
.replace(/^([A-Z][a-z]{2})[a-z]*( \d+, \d+)$/, '$1$2');
|
||||
/** @type {Element[]} */
|
||||
const linkEls = Array.from(n.querySelectorAll('.downloads a[href]'));
|
||||
const links = linkEls.map(a => ({
|
||||
const linkElements = Array.from(n.querySelectorAll('.downloads a[href]'));
|
||||
const links = linkElements.map(a => ({
|
||||
text: pickText(a),
|
||||
href: a.getAttribute('href') || '',
|
||||
href: a.getAttribute('href') ?? '',
|
||||
}));
|
||||
|
||||
// 收集 checksums (在 .downloads 文本中)
|
||||
/** @type {{ [filename: string]: string }} */
|
||||
const checksums = {};
|
||||
/** @type {HTMLElement} */
|
||||
const downloadsElement = n.querySelector('.downloads');
|
||||
const bodyText = (downloadsElement?.innerText || '').trim();
|
||||
/** @type {{[filename: string]: string}} */
|
||||
const checksums = {};
|
||||
// 行格式: <sha256> <filename>
|
||||
bodyText.split('\n').forEach(line => {
|
||||
const m = /^\s*([a-f0-9]{64})\s+(.+?)\s*$/.exec(line);
|
||||
if (m) {
|
||||
@@ -175,11 +185,8 @@ export async function getAndroidStudioArchives() {
|
||||
}
|
||||
});
|
||||
|
||||
// 解析版本号 (2025.1.2 等), 优先从标题中提取
|
||||
let version = null;
|
||||
const vm = title.match(/\d{2,}\.\d+(?:\.\d+)?/);
|
||||
if (vm) version = vm[0];
|
||||
|
||||
/* e.g. "2025.1.3". */
|
||||
const version = title.match(/\d{2,}\.\d+(?:\.\d+)?/)?.[0] ?? null;
|
||||
return { title, date, version, links, checksums };
|
||||
});
|
||||
});
|
||||
@@ -211,7 +218,8 @@ async function main() {
|
||||
console.table(results);
|
||||
}
|
||||
|
||||
// 判断是否为直接执行该文件
|
||||
// Determine if this file is being run directly.
|
||||
// zh-CN: 判断是否为直接执行该文件.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await main().catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
// fetch-and-parse-android-studio-latest-stable-version.mjs
|
||||
|
||||
import { load } from 'cheerio';
|
||||
/** @typedef {'exe' | 'zip' | 'tar' | 'other'} StableArchiveItemKind */
|
||||
/**
|
||||
* @typedef {Object} StableArchiveItem
|
||||
* @property {string} platform
|
||||
* @property {string} filename
|
||||
* @property {string} size
|
||||
* @property {string} sha256
|
||||
* @property {string | null} url
|
||||
* @property {StableArchiveItemKind} kind
|
||||
*/
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import { bytes2GiB } from './utils/format.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getRemoteFileSizeBytes } from './utils/fetch.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/studio?hl=en';
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @return {string}
|
||||
* @returns {string}
|
||||
*/
|
||||
function norm(s) {
|
||||
return (s ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
const normalize = (s) => (s ?? '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @return {string | null}
|
||||
* @param {string} kind
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function buildDownloadUrlFromFilename(filename) {
|
||||
// 例: android-studio-2025.1.2.13-windows.exe
|
||||
function buildDownloadUrlFromFilename(filename, kind) {
|
||||
const m = /android-studio-([\d.]+)-/.exec(filename);
|
||||
if (!m) return null;
|
||||
const version = m[1];
|
||||
return `https://redirector.gvt1.com/edgedl/android/studio/install/${version}/${filename}`;
|
||||
const urlType = {
|
||||
'exe': 'install',
|
||||
'zip': 'ide-zips',
|
||||
'tar': 'ide-zips',
|
||||
}[kind];
|
||||
if (!urlType) {
|
||||
throw new Error(`Unsupported kind: ${kind}`);
|
||||
}
|
||||
return `https://redirector.gvt1.com/edgedl/android/studio/${urlType}/${version}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @return {Promise<string>}
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function fetchHtml(url) {
|
||||
const res = await fetch(url, {
|
||||
@@ -40,96 +58,72 @@ async function fetchHtml(url) {
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {'exe' | 'zip' | 'other'} RowKind
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Row
|
||||
* @property {string} platform
|
||||
* @property {string} filename
|
||||
* @property {string} size
|
||||
* @property {string} sha256
|
||||
* @property {string | null} url
|
||||
* @property {RowKind} kind
|
||||
*/
|
||||
/**
|
||||
* @param {string} html
|
||||
* @return {Row[]}
|
||||
* @returns {Promise<StableArchiveItem[]>}
|
||||
*/
|
||||
function parseWindowsRowsFromHtml(html) {
|
||||
const $ = load(html);
|
||||
async function parseItemsFromHtml(html) {
|
||||
const $ = cheerio.load(html);
|
||||
const rows = [];
|
||||
|
||||
/** @type {import('cheerio').Cheerio<import('domhandler').Element>} */
|
||||
const tableRows = $('table.download tbody tr');
|
||||
tableRows.each((_, tr) => {
|
||||
await Promise.all(Array.from(tableRows).map(async tr => {
|
||||
const tds = $(tr).find('td');
|
||||
if (tds.length !== 4) return;
|
||||
|
||||
const platform = norm($(tds[0]).text());
|
||||
if (!/windows/i.test(platform)) return;
|
||||
const platform = normalize($(tds[0]).text());
|
||||
if (!/windows|linux/i.test(platform)) return;
|
||||
|
||||
const btn = $(tds[1]).find('button.devsite-dialog-button').first();
|
||||
const filename = norm(btn.text());
|
||||
const filename = normalize(btn.text());
|
||||
if (!filename || !filename.includes('android-studio')) return;
|
||||
|
||||
const size = norm($(tds[2]).text());
|
||||
const sha256 = norm($(tds[3]).text());
|
||||
const url = buildDownloadUrlFromFilename(filename);
|
||||
const sha256 = normalize($(tds[3]).text());
|
||||
|
||||
rows.push({
|
||||
platform,
|
||||
filename,
|
||||
size,
|
||||
sha256,
|
||||
url,
|
||||
kind: filename.endsWith('.exe')
|
||||
? 'exe'
|
||||
: filename.endsWith('.zip')
|
||||
? 'zip'
|
||||
: 'other',
|
||||
});
|
||||
});
|
||||
// @formatter:off
|
||||
const kind = filename.match(/\.exe$/) ? 'exe'
|
||||
: filename.match(/\.zip$/) ? 'zip'
|
||||
: filename.match(/\.tar(\.gz)?$/) ? 'tar'
|
||||
: 'other';
|
||||
// @formatter:on
|
||||
|
||||
const url = buildDownloadUrlFromFilename(filename, kind);
|
||||
const bytes = await getRemoteFileSizeBytes(url);
|
||||
const size = bytes2GiB(bytes);
|
||||
rows.push({ platform, filename, size, sha256, url, kind });
|
||||
}))
|
||||
return rows
|
||||
.filter(r => r.kind === 'exe' || r.kind === 'zip')
|
||||
.sort((a) => a.kind === 'exe' ? -1 : 1);
|
||||
.filter(r => r.kind === 'exe' || r.kind === 'zip' || r.kind === 'tar')
|
||||
.sort((_, b) => b.kind === 'exe' ? 1 : b.kind === 'zip' ? 1 : -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出: 获取最新稳定版 Windows 的下载信息 (exe 与 zip)
|
||||
* 返回形如:
|
||||
* [
|
||||
* { platform, filename, size, sha256, url, kind: 'exe' },
|
||||
* { platform, filename, size, sha256, url, kind: 'zip' }
|
||||
* ]
|
||||
* Export: Get download information for the latest stable archives (exe, zip, tar)<br>
|
||||
* Returns array like: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ]<br>
|
||||
* zh-CN:<br>
|
||||
* 导出: 获取最新稳定版档案的下载信息 (exe, zip, tar)<br>
|
||||
* 返回形如: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ]
|
||||
*
|
||||
* @param {string} [sourceUrl=URL]
|
||||
* @returns {Promise<Row[]>}
|
||||
* @returns {Promise<StableArchiveItem[]>}
|
||||
*/
|
||||
export async function getLatestStableWindows(sourceUrl = URL) {
|
||||
export async function getLatestStableArchives(sourceUrl = URL) {
|
||||
const html = await fetchHtml(sourceUrl);
|
||||
const rows = parseWindowsRowsFromHtml(html);
|
||||
if (!rows.length) {
|
||||
throw new Error('未在页面中找到 Windows 稳定版下载条目');
|
||||
const items = await parseItemsFromHtml(html);
|
||||
if (!items.length) {
|
||||
throw new Error('No latest stable archives found in the page');
|
||||
}
|
||||
return rows;
|
||||
return items;
|
||||
}
|
||||
|
||||
// CLI 模式: 直接运行则打印结果; 被 import 时不执行
|
||||
async function main() {
|
||||
const rows = await getLatestStableWindows(URL);
|
||||
for (const r of rows) {
|
||||
console.log(`${r.kind.toUpperCase()}:`);
|
||||
console.log(` filename : ${r.filename}`);
|
||||
console.log(` size : ${r.size}`);
|
||||
console.log(` sha256 : ${r.sha256}`);
|
||||
console.log(` url : ${r.url}`);
|
||||
}
|
||||
const items = await getLatestStableArchives(URL);
|
||||
console.table(items.map(({ filename, size, url, sha256 }) => ({ filename, size, url, sha256 })));
|
||||
}
|
||||
|
||||
// 判断是否为直接执行该文件
|
||||
// Determine if this file is being run directly.
|
||||
// zh-CN: 判断是否为直接执行该文件.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
@@ -2,137 +2,19 @@
|
||||
|
||||
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/pulls']['response']['data']} PullsData */
|
||||
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data']} PullCommitsData */
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { toYYYYMMDD } from './utils/date.mjs';
|
||||
|
||||
dotenv.config({ path: '.env', quiet: true });
|
||||
|
||||
const REPO = 'AutoJs6';
|
||||
const OWNER = 'SuperMonster003';
|
||||
const BASE = `https://api.github.com/repos/${OWNER}/${REPO}`;
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
|
||||
|
||||
// 调试: 设置为某个作者的登录名 (login) 以打印该作者的匹配细节
|
||||
const DEBUG_LOGIN = process.env.DEBUG_LOGIN || '';
|
||||
const DEBUG_VERBOSE = process.env.DEBUG_VERBOSE === '1';
|
||||
|
||||
// 可排除的登录名列表 (默认排除仓库维护者); 可用 EXCLUDED_LOGINS 环境变量覆盖, 逗号分隔
|
||||
const EXCLUDED_LOGINS = (process.env.EXCLUDED_LOGINS || OWNER)
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* @return {import('node-fetch').HeadersInit}
|
||||
*/
|
||||
function headers() {
|
||||
return {
|
||||
accept: 'application/vnd.github+json',
|
||||
...(GITHUB_TOKEN ? { authorization: `Bearer ${GITHUB_TOKEN}` } : {}),
|
||||
'user-agent': 'pr-commit-contributions',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserProfile
|
||||
* @property {string} login
|
||||
* @property {string | null} name
|
||||
*/
|
||||
/**
|
||||
* 简单内存缓存, 避免重复请求.
|
||||
*
|
||||
* @type {Map<string, UserProfile>}
|
||||
* @typedef {Object} StatisticsEntry
|
||||
* @property {string} login
|
||||
* @property {string | null} name
|
||||
* @property {string} htmlUrl
|
||||
* @property {number} totalCommitsInMergedPRs
|
||||
* @property {string | null} latestCommitAt
|
||||
*/
|
||||
const userProfileCache = new Map();
|
||||
|
||||
/**
|
||||
* @param {string} login
|
||||
* @return {Promise<UserProfile>}
|
||||
*/
|
||||
async function getUserProfile(login) {
|
||||
if (userProfileCache.has(login)) return userProfileCache.get(login);
|
||||
const url = `https://api.github.com/users/${login}`;
|
||||
const res = await fetch(url, { headers: headers() });
|
||||
if (!res.ok) {
|
||||
userProfileCache.set(login, { login, name: null });
|
||||
return { login, name: null };
|
||||
}
|
||||
const data = await res.json();
|
||||
const profile = {
|
||||
login: data?.['login'] || login,
|
||||
name: data?.['name'] || null,
|
||||
};
|
||||
userProfileCache.set(login, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<PullsData>}
|
||||
*/
|
||||
async function fetchAllMergedPRs() {
|
||||
const perPage = 100;
|
||||
let page = 1;
|
||||
const merged = [];
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE}/pulls?state=closed&per_page=${perPage}&page=${page}&sort=created&direction=asc`;
|
||||
const res = await fetch(url, { headers: headers() });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`获取 PR 列表失败: ${res.status} ${res.statusText} - ${text}`);
|
||||
}
|
||||
const prs = /** @type {PullsData} */ await res.json();
|
||||
if (!Array.isArray(prs) || prs.length === 0) break;
|
||||
|
||||
for (const pr of prs) {
|
||||
if (pr.merged_at) merged.push(pr);
|
||||
}
|
||||
|
||||
if (prs.length < perPage) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @return {boolean}
|
||||
*/
|
||||
function equalsIgnoreCase(a, b) {
|
||||
return typeof a === 'string'
|
||||
&& typeof b === 'string'
|
||||
&& a.toLowerCase() === b.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PullCommitsData[number]} c
|
||||
* @return {string | null}
|
||||
*/
|
||||
function commitTimeFrom(c) {
|
||||
return c.commit?.committer?.date
|
||||
|| c.commit?.author?.date
|
||||
|| null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则: 排除 EXCLUDED_LOGINS (author.login 或 committer.login 命中即排除); 其余一律计入.
|
||||
*
|
||||
* @param {PullCommitsData[number]} c
|
||||
* @return {boolean}
|
||||
*/
|
||||
function isCommitBelongsToLogin(c) {
|
||||
const authorLogin = c.author?.login || null;
|
||||
const committerLogin = c.committer?.login || null;
|
||||
|
||||
return !(authorLogin && EXCLUDED_LOGINS.some(x => equalsIgnoreCase(x, authorLogin)))
|
||||
&& !(committerLogin && EXCLUDED_LOGINS.some(x => equalsIgnoreCase(x, committerLogin)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} DebugRecord
|
||||
* @property {string} sha
|
||||
@@ -143,9 +25,140 @@ function isCommitBelongsToLogin(c) {
|
||||
* @property {string | null} author_email
|
||||
* @property {string | null} time
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { httpFetch } from './utils/fetch.mjs';
|
||||
import { toYYYYMMDD } from './utils/date.mjs';
|
||||
|
||||
dotenv.config({ path: '.env', quiet: true });
|
||||
|
||||
const REPO = 'AutoJs6';
|
||||
const OWNER = 'SuperMonster003';
|
||||
const BASE = `https://api.github.com/repos/${OWNER}/${REPO}`;
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
|
||||
|
||||
// Debug: Set to an author's login name to print matching details for that author.
|
||||
// zh-CN: 调试: 设置为某个作者的登录名 (login) 以打印该作者的匹配细节.
|
||||
const DEBUG_LOGIN = process.env.DEBUG_LOGIN || '';
|
||||
const DEBUG_VERBOSE = process.env.DEBUG_VERBOSE === '1';
|
||||
|
||||
// List of logins to exclude (defaults to repository maintainer);
|
||||
// can be overridden with EXCLUDED_LOGINS env var, comma-separated.
|
||||
// zh-CN: 可排除的登录名列表 (默认排除仓库维护者); 可用 EXCLUDED_LOGINS 环境变量覆盖, 逗号分隔.
|
||||
const EXCLUDED_LOGINS = (process.env.EXCLUDED_LOGINS || OWNER)
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** @type {import('http').OutgoingHttpHeaders} */
|
||||
const headers = {
|
||||
'accept': 'application/vnd.github+json',
|
||||
'user-agent': 'pr-commit-contributions',
|
||||
...(GITHUB_TOKEN ? { 'authorization': `Bearer ${GITHUB_TOKEN}` } : {}),
|
||||
};
|
||||
|
||||
/** @type {Map<string, UserProfile>} */
|
||||
const userProfileCache = new Map();
|
||||
|
||||
/**
|
||||
* @param {string} login
|
||||
* @returns {Promise<UserProfile>}
|
||||
*/
|
||||
async function getUserProfile(login) {
|
||||
if (userProfileCache.has(login)) {
|
||||
return userProfileCache.get(login);
|
||||
}
|
||||
const url = `https://api.github.com/users/${login}`;
|
||||
/** @type {UserProfile} */
|
||||
const data = await httpFetch(url, { headers });
|
||||
const profile = {
|
||||
login: data?.login || login,
|
||||
name: data?.name || null,
|
||||
};
|
||||
userProfileCache.set(login, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<PullsData>}
|
||||
*/
|
||||
async function fetchAllMergedPRs() {
|
||||
const perPage = 100;
|
||||
let page = 1;
|
||||
const merged = [];
|
||||
|
||||
while (true) {
|
||||
/** @type {PullsData} */
|
||||
const prs = await httpFetch(`${BASE}/pulls`, {
|
||||
headers,
|
||||
query: {
|
||||
state: 'closed',
|
||||
per_page: perPage,
|
||||
page: page,
|
||||
sort: 'created',
|
||||
direction: 'asc',
|
||||
},
|
||||
});
|
||||
if (!prs) {
|
||||
throw new Error('Failed to fetch PR list');
|
||||
}
|
||||
if (!Array.isArray(prs) || prs.length === 0) {
|
||||
break;
|
||||
}
|
||||
for (const pr of prs) {
|
||||
if (pr.merged_at) merged.push(pr);
|
||||
}
|
||||
if (prs.length < perPage) {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function equalsIgnoreCase(a, b) {
|
||||
return typeof a === 'string'
|
||||
&& typeof b === 'string'
|
||||
&& a.toLowerCase() === b.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PullCommitsData[number]} c
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function commitTimeFrom(c) {
|
||||
return c.commit?.committer?.date
|
||||
|| c.commit?.author?.date
|
||||
|| null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule: exclude `EXCLUDED_LOGINS` (when `author.login` or `committer.login` matches); include all others.<br>
|
||||
* zh-CN: 规则: 排除 `EXCLUDED_LOGINS` (`author.login` 或 `committer.login` 命中即排除); 其余一律计入.
|
||||
*
|
||||
* @param {PullCommitsData[number]} c
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isCommitBelongsToLogin(c) {
|
||||
const authorLogin = c.author?.login || null;
|
||||
const committerLogin = c.committer?.login || null;
|
||||
|
||||
return !(authorLogin && EXCLUDED_LOGINS.some(x => equalsIgnoreCase(x, authorLogin)))
|
||||
&& !(committerLogin && EXCLUDED_LOGINS.some(x => equalsIgnoreCase(x, committerLogin)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PullsData[number]} pr
|
||||
* @return {Promise<{count: number, latestCommitAt: string | null}>} - 该 PR 中属于 PR 作者本人的提交计数与最新提交时间.
|
||||
* @returns {Promise<{commitCount: number, latestCommitAt: string | null}>}
|
||||
* The count of commits belonging to the PR author and the latest commit time in this PR.<br>
|
||||
* zh-CN: 该 PR 中属于 PR 作者本人的提交计数与最新提交时间.
|
||||
*/
|
||||
async function getPRCommitStatsByAuthor(pr) {
|
||||
const perPage = 100;
|
||||
@@ -154,20 +167,22 @@ async function getPRCommitStatsByAuthor(pr) {
|
||||
let latestCommitAt = null;
|
||||
|
||||
const login = pr.user?.login;
|
||||
if (!login) return { count: 0, latestCommitAt: null };
|
||||
if (!login) return { commitCount: 0, latestCommitAt: null };
|
||||
|
||||
// 仅在调试该作者时收集详细信息
|
||||
/** @type {DebugRecord[]} */
|
||||
/**
|
||||
* Only collect detailed information when debugging this author.<br>
|
||||
* zh-CN: 仅在调试该作者时收集详细信息.
|
||||
* @type {DebugRecord[]}
|
||||
*/
|
||||
const debugRecords = [];
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE}/pulls/${pr.number}/commits?per_page=${perPage}&page=${page}`;
|
||||
const res = await fetch(url, { headers: headers() });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`获取 PR #${pr.number} 的 commits 失败: ${res.status} ${res.statusText} - ${text}`);
|
||||
/** @type {PullCommitsData} */
|
||||
const commits = await httpFetch(url, { headers });
|
||||
if (!commits) {
|
||||
throw new Error('获取 PR #${pr.number} 的 commits 失败');
|
||||
}
|
||||
const commits = /** @type {PullCommitsData} */ await res.json();
|
||||
if (!Array.isArray(commits) || commits.length === 0) break;
|
||||
|
||||
for (const c of commits) {
|
||||
@@ -196,28 +211,76 @@ async function getPRCommitStatsByAuthor(pr) {
|
||||
page += 1;
|
||||
}
|
||||
|
||||
// 打印调试: 仅针对目标作者; 默认仅在该 PR 统计结果为 0 时打印, 或开启 DEBUG_VERBOSE 时总是打印
|
||||
// Print debug info: only for target author;
|
||||
// print when PR statistics result is 0 or DEBUG_VERBOSE is enabled.
|
||||
// zh-CN: 打印调试: 仅针对目标作者; 在该 PR 统计结果为 0 或开启 DEBUG_VERBOSE 时打印.
|
||||
if (DEBUG_LOGIN && equalsIgnoreCase(login, DEBUG_LOGIN) && (DEBUG_VERBOSE || total === 0)) {
|
||||
const matched = debugRecords.filter(r => r.belongs).length;
|
||||
const unmatched = debugRecords.length - matched;
|
||||
console.log(`\n[DEBUG] PR #${pr.number} by ${login}: commits=${debugRecords.length}, included=${matched}, excluded=${unmatched}`);
|
||||
for (const r of debugRecords) {
|
||||
if (DEBUG_VERBOSE || !r.belongs) {
|
||||
console.log(`[DEBUG] ${r.sha} | included=${r.belongs} | author_login=${r.author_login} | committer_login=${r.committer_login} | author_name=${r.author_name} | author_email=${r.author_email} | time=${r.time}`);
|
||||
console.log('[DEBUG] ' + [
|
||||
r.sha,
|
||||
`included=${r.belongs}`,
|
||||
`author_login=${r.author_login}`,
|
||||
`committer_login=${r.committer_login}`,
|
||||
`author_name=${r.author_name}`,
|
||||
`author_email=${r.author_email}`,
|
||||
`time=${r.time}`,
|
||||
].join(' | '));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { count: total, latestCommitAt };
|
||||
return { commitCount: total, latestCommitAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* A concurrency-limited async map: runs `mapper`
|
||||
* with at most `limit` concurrent tasks and preserves input order.
|
||||
* - Use when you need to control request/task concurrency
|
||||
* (e.g. network requests/IO operations)
|
||||
* to avoid overwhelming services or triggering rate limits.
|
||||
* - Guarantees output array order matches input items order,
|
||||
* even if task completion times differ.<br>
|
||||
* zh-CN:<br>
|
||||
* 并发受限的异步映射: 以不超过 `limit` 的并发度执行 `mapper`, 并按输入顺序返回结果.
|
||||
* - 在需要控制请求/任务并发量 (如网络请求/IO 操作) 时使用, 避免压垮服务或触发限流.
|
||||
* - 保证输出数组与输入 items 的顺序一致, 即使各任务完成时间不同.
|
||||
*
|
||||
* @example Promise<MapperResult[]>
|
||||
* // Throttled fetching. (zh-CN: 限流抓取.)
|
||||
* const urls = [ 'https://a.com', 'https://b.com', 'https://c.com' ];
|
||||
* const res = await mapWithLimit(urls, 2, async (url, i) => {
|
||||
* const r = await fetch(url);
|
||||
* return { i, url, status: r.status };
|
||||
* });
|
||||
* console.log(res);
|
||||
*
|
||||
* @example Promise<MapperResult[]>
|
||||
* // Throttled processing. (zh-CN: 限流处理.)
|
||||
* const tasks = [ 1, 2, 3, 4, 5 ];
|
||||
* const out = await mapWithLimit(tasks, 3, async (n) => {
|
||||
* await new Promise(r => setTimeout(r, 100 * n));
|
||||
* return n * 2;
|
||||
* });
|
||||
* console.log(out); // [ 2, 4, 6, 8, 10 ]
|
||||
*
|
||||
* @template Item
|
||||
* @template MapperResult
|
||||
* @param {Item[]} items
|
||||
* The input items to process.<br>
|
||||
* zh-CN: 要处理的输入项列表.
|
||||
* @param {number} limit
|
||||
* Maximum concurrency (>=1).<br>
|
||||
* zh=CN: 最大并发数 (>=1).
|
||||
* @param {(item: Item, idx: number) => Promise<MapperResult>} mapper
|
||||
* @return {Promise<MapperResult[]>}
|
||||
* Async function to process one item.<br>
|
||||
* zh-CN: 处理单个项的异步函数.
|
||||
* @returns {Promise<MapperResult[]>}
|
||||
* Results in the same order as input.<br>
|
||||
* zh-CN: 按输入顺序排列的结果数组.
|
||||
*/
|
||||
async function mapWithLimit(items, limit, mapper) {
|
||||
const results = new Array(items.length);
|
||||
@@ -234,11 +297,12 @@ async function mapWithLimit(items, limit, mapper) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取作者资料 (并发受限), 返回 Map<login, profile>.
|
||||
* Bulk fetch author profiles (with concurrency limit).<br>
|
||||
* zh-CN: 批量获取作者资料 (并发受限).
|
||||
*
|
||||
* @param {string[]} logins
|
||||
* @param {number} [concurrency=6]
|
||||
* @return {Promise<Map<UserProfile['login'], UserProfile>>}
|
||||
* @returns {Promise<Map<UserProfile['login'], UserProfile>>}
|
||||
*/
|
||||
async function fetchProfilesForLogins(logins, concurrency = 6) {
|
||||
const unique = Array.from(new Set(logins)).filter(Boolean);
|
||||
@@ -253,37 +317,43 @@ async function fetchProfilesForLogins(logins, concurrency = 6) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<Statistics[]>}
|
||||
* @param {number} [concurrency=6]
|
||||
* @returns {Promise<Statistics[]>}
|
||||
*/
|
||||
export async function fetchStatistics() {
|
||||
export async function fetchStatistics(concurrency = 6) {
|
||||
const mergedPRs = await fetchAllMergedPRs();
|
||||
const CONCURRENCY = 6;
|
||||
const prWithStats = await mapWithLimit(mergedPRs, CONCURRENCY, async (pr) => {
|
||||
const { count, latestCommitAt } = await getPRCommitStatsByAuthor(pr);
|
||||
return { pr, count, latestCommitAt };
|
||||
const prWithStats = await mapWithLimit(mergedPRs, concurrency, async (pr) => {
|
||||
const { commitCount, latestCommitAt } = await getPRCommitStatsByAuthor(pr);
|
||||
return { pr, commitCount, latestCommitAt };
|
||||
});
|
||||
|
||||
// 先收集所有作者, 再批量获取资料 (并发受限 + 内存缓存)
|
||||
// Collect all authors first, then fetch profiles in bulk (with concurrency limit + memory cache).
|
||||
// zh-CN: 先收集所有作者, 再批量获取资料 (并发受限 + 内存缓存).
|
||||
const allLogins = prWithStats
|
||||
.map(({ pr }) => pr.user?.login)
|
||||
.filter(Boolean);
|
||||
const profileMap = await fetchProfilesForLogins(allLogins, 6);
|
||||
const profileMap = await fetchProfilesForLogins(allLogins, concurrency);
|
||||
|
||||
// 按 PR 发起者聚合
|
||||
|
||||
/**
|
||||
* Group by PR creators. (zh-CN: 按 PR 发起者聚合.)
|
||||
* @type {Map<string, StatisticsEntry>}
|
||||
*/
|
||||
const byAuthor = new Map();
|
||||
for (const { pr, count, latestCommitAt } of prWithStats) {
|
||||
for (const { pr, commitCount, latestCommitAt } of prWithStats) {
|
||||
const user = pr.user;
|
||||
if (!user?.login) continue;
|
||||
|
||||
/** @type {StatisticsEntry} */
|
||||
const entry = byAuthor.get(user.login) || {
|
||||
login: user.login,
|
||||
name: profileMap.get(user.login)?.name || null,
|
||||
html_url: `https://github.com/${user.login}`,
|
||||
htmlUrl: `https://github.com/${user.login}`,
|
||||
totalCommitsInMergedPRs: 0,
|
||||
latestCommitAt: null,
|
||||
};
|
||||
|
||||
entry.totalCommitsInMergedPRs += count;
|
||||
entry.totalCommitsInMergedPRs += commitCount;
|
||||
|
||||
if (latestCommitAt && (!entry.latestCommitAt || new Date(latestCommitAt) > new Date(entry.latestCommitAt))) {
|
||||
entry.latestCommitAt = latestCommitAt;
|
||||
@@ -293,53 +363,42 @@ export async function fetchStatistics() {
|
||||
}
|
||||
|
||||
// 按最近提交倒序
|
||||
const rows = Array.from(byAuthor.values()).sort((a, b) => {
|
||||
const entries = Array.from(byAuthor.values()).sort((a, b) => {
|
||||
const da = a.latestCommitAt ? new Date(a.latestCommitAt).getTime() : 0;
|
||||
const db = b.latestCommitAt ? new Date(b.latestCommitAt).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
|
||||
return rows.map(r => new Statistics(r));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
return await fetchStatistics();
|
||||
return entries.map(e => new Statistics(e));
|
||||
}
|
||||
|
||||
class Statistics {
|
||||
/**
|
||||
* @param row {{
|
||||
* login: string,
|
||||
* name: string | null,
|
||||
* html_url: string,
|
||||
* totalCommitsInMergedPRs: number,
|
||||
* latestCommitAt: string | null,
|
||||
* prListLink: string,
|
||||
* }}
|
||||
* @param {StatisticsEntry} entry
|
||||
*/
|
||||
constructor(row) {
|
||||
this.login = row.login;
|
||||
this.name = row.name;
|
||||
this.html_url = row.html_url;
|
||||
this.totalCommitsInMergedPRs = row.totalCommitsInMergedPRs;
|
||||
this.latestCommitAt = row.latestCommitAt;
|
||||
constructor(entry) {
|
||||
this.login = entry.login;
|
||||
this.name = entry.name;
|
||||
this.htmlUrl = entry.htmlUrl;
|
||||
this.totalCommitsInMergedPRs = entry.totalCommitsInMergedPRs;
|
||||
this.latestCommitAt = entry.latestCommitAt;
|
||||
this.prListLink = `https://github.com/${OWNER}/${REPO}/pulls?q=`
|
||||
+ 'is' + '%3A' + 'pr' + '+'
|
||||
+ 'is' + '%3A' + 'merged' + '+'
|
||||
+ 'author' + '%3A' + encodeURIComponent(row.login);
|
||||
+ 'author' + '%3A' + encodeURIComponent(entry.login);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string} [style='word-break:keep-all;white-space:nowrap']
|
||||
* @param {string | null} [style='word-break:keep-all;white-space:nowrap']
|
||||
* @returns {string}
|
||||
*/
|
||||
#wrapInSpan(text, style = 'word-break:keep-all;white-space:nowrap') {
|
||||
return `<span style="${style}">${text}</span>`;
|
||||
return style ? `<span style="${style}">${text}</span>` : `<span>${text}</span>`;
|
||||
}
|
||||
|
||||
get contributorMarkdown() {
|
||||
let markdownName = `[${this.login.replace(/-/g, '‑')}](${this.html_url})`;
|
||||
let markdownName = `[${this.login.replace(/-/g, '‑')}](${this.htmlUrl})`;
|
||||
if (this.name && this.name !== this.login) {
|
||||
markdownName += ` \`(${this.name})\``;
|
||||
}
|
||||
@@ -358,15 +417,19 @@ class Statistics {
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为直接执行该文件
|
||||
async function main() {
|
||||
const dataList = await fetchStatistics();
|
||||
console.table(dataList.map(e => ({
|
||||
contributor: e.name && e.name !== e.login ? `${e.login} (${e.name})` : e.login,
|
||||
commits: e.totalCommitsInMergedPRs,
|
||||
recent: e.latestCommitAt ? toYYYYMMDD(e.latestCommitAt) : 'N/A',
|
||||
})));
|
||||
}
|
||||
|
||||
// Determine if this file is being run directly.
|
||||
// zh-CN: 判断是否为直接执行该文件.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().then((dataList) => {
|
||||
console.table(dataList.map(r => ({
|
||||
contributor: r.name && r.name !== r.login ? `${r.login} (${r.name})` : r.login,
|
||||
commits: r.totalCommitsInMergedPRs,
|
||||
recent: r.latestCommitAt ? toYYYYMMDD(r.latestCommitAt) : 'N/A',
|
||||
})));
|
||||
}).catch(err => {
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -10,18 +10,17 @@
|
||||
* @property {string} link.checksums
|
||||
*/
|
||||
|
||||
import { load } from 'cheerio';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const LINK_PREFIX = 'https://gradle.org';
|
||||
const URL = `${LINK_PREFIX}/releases`;
|
||||
|
||||
/**
|
||||
* @return {Promise<GradleRelease[]>}
|
||||
* @returns {Promise<GradleRelease[]>}
|
||||
*/
|
||||
export async function fetchGradleReleases() {
|
||||
const html = await fetch(URL).then(r => r.text());
|
||||
const $ = load(html);
|
||||
const $ = cheerio.load(await fetch(URL).then(r => r.text()));
|
||||
|
||||
const contents = $('.resources-contents').filter((_, el) => {
|
||||
return $(el).find('.u-text-with-icon').length > 0;
|
||||
@@ -66,7 +65,8 @@ async function main() {
|
||||
})));
|
||||
}
|
||||
|
||||
// 判断是否为直接执行该文件
|
||||
// Determine if this file is being run directly.
|
||||
// zh-CN: 判断是否为直接执行该文件.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
node "run-scrapers.mjs"
|
||||
ECHO.
|
||||
|
||||
REM 显示提示并等待单键 (无需按回车)
|
||||
ECHO Press [R] to rerun the scrapers, or [ESC]/[Enter]/[Space] to exit...
|
||||
powershell -NoLogo -NoProfile -Command "$ErrorActionPreference='Stop'; while($true){ $k=$Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown'); if($k.VirtualKeyCode -eq 27 -or $k.VirtualKeyCode -eq 13 -or $k.Character -eq ' '){ exit 1 } elseif($k.Character -match '^[Rr]$'){ exit 0 } }"
|
||||
|
||||
|
||||
@@ -1,55 +1,67 @@
|
||||
// run-scrapers.mjs
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
/**
|
||||
* @typedef {Object} ScriptItem
|
||||
* @property {string} name
|
||||
* @property {string} abs
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 待执行脚本 (顺序可按需调整)
|
||||
const SCRIPT_LIST = [
|
||||
'scrape-and-update-readme-template-contributors-table.mjs',
|
||||
'scrape-and-update-foojay-resolver-version.mjs',
|
||||
'scrape-and-inject-latest-gradle-wrapper.mjs',
|
||||
'scrape-and-inject-agp-releases.mjs',
|
||||
'scrape-and-inject-android-studio-agp-version-map.mjs',
|
||||
'scrape-and-inject-android-studio-codename_maps.mjs',
|
||||
'scrape-and-inject-embedded-kotlin-list.mjs',
|
||||
'scrape-and-inject-gradle-kotlin-compatibility-list.mjs',
|
||||
'scrape-and-inject-ksp-releases.mjs',
|
||||
'scrape-and-inject-agp-gradle-compatibility-list.mjs',
|
||||
'scrape-and-inject-java-gradle-compatibility-list.mjs',
|
||||
'scrape-and-inject-rhino-engine-data.mjs',
|
||||
'scrape-and-update-readme-template-contributors-table.mjs',
|
||||
];
|
||||
|
||||
const childProcessOutput = [];
|
||||
|
||||
/**
|
||||
* 解析 CLI 参数.
|
||||
*
|
||||
* @param {string[]} [argv=process.argv.slice(2)]
|
||||
* @return {{ continueOnError: boolean, dryRun: boolean, nodePath: string, filters: string[] }}
|
||||
* @param {ScriptItem[]} scripts
|
||||
* @param {number} idx
|
||||
* @returns {string}
|
||||
*/
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
function getScriptProgressMessage(scripts, idx) {
|
||||
return `[${idx + 1}/${scripts.length}] ${scripts[idx].name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLI arguments.<br>
|
||||
* zh-CN: 解析 CLI 参数.
|
||||
*
|
||||
* @returns {{ nodePath: string }}
|
||||
*/
|
||||
function parseArgs() {
|
||||
const argv = process.argv.slice(2);
|
||||
const opts = {
|
||||
continueOnError: false,
|
||||
dryRun: false,
|
||||
nodePath: process.execPath, // 使用当前 Node 可执行文件, 避免 PATH 问题
|
||||
filters: [],
|
||||
nodePath: process.execPath,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--continue-on-error') opts.continueOnError = true;
|
||||
else if (a === '--dry-run') opts.dryRun = true;
|
||||
else if (a === '--node') opts.nodePath = argv[++i];
|
||||
else if (a === '--filter') opts.filters.push(argv[++i]);
|
||||
else if (a.startsWith('--filter=')) opts.filters.push(a.split('=').slice(1).join('='));
|
||||
else if (a.startsWith('--node=')) opts.nodePath = a.split('=').slice(1).join('=');
|
||||
if (a === '--node') {
|
||||
opts.nodePath = argv[++i];
|
||||
} else if (a.startsWith('--node=')) {
|
||||
opts.nodePath = a.split('=').slice(1).join('=');
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ms
|
||||
* @return {string}
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatDuration(ms) {
|
||||
const sec = Math.floor(ms / 1000);
|
||||
@@ -59,11 +71,11 @@ function formatDuration(ms) {
|
||||
|
||||
/**
|
||||
* @param {import('fs').PathLike} filePath
|
||||
* @return {Promise<boolean>}
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async function ensureExists(filePath) {
|
||||
function ensureExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
fs.accessSync(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -75,23 +87,29 @@ async function ensureExists(filePath) {
|
||||
* @param {string} options.nodePath
|
||||
* @param {string} options.scriptPath
|
||||
* @param {string | URL | undefined} options.cwd
|
||||
* @return {Promise<{ code: number, signal: NodeJS.Signals | null, ms: number, error?: Error }>}
|
||||
* @returns {Promise<{ code: number, signal: NodeJS.Signals | null, ms: number, error?: Error }>}
|
||||
*/
|
||||
async function runOne({ nodePath, scriptPath, cwd }) {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
const child = spawn(nodePath, [ scriptPath ], {
|
||||
cwd,
|
||||
stdio: 'inherit', // 直接把子进程的输出打到当前控制台
|
||||
// Capture child process output.
|
||||
// zh-CN: 捕获子进程输出.
|
||||
stdio: [ 'inherit', 'pipe', 'pipe' ],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
childProcessOutput.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
const end = Date.now();
|
||||
resolve({
|
||||
code: code ?? 0,
|
||||
signal: signal ?? null,
|
||||
ms: end - start,
|
||||
});
|
||||
resolve({ code: code ?? 0, signal: signal ?? null, ms: end - start });
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
const end = Date.now();
|
||||
@@ -103,69 +121,106 @@ async function runOne({ nodePath, scriptPath, cwd }) {
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
const utilsDir = __dirname; // 运行器位于 .utils
|
||||
const scripts = SCRIPT_LIST
|
||||
.map(name => ({ name, abs: path.resolve(utilsDir, name) }))
|
||||
.filter(s => opts.filters.length === 0 || opts.filters.some(f => s.name.includes(f)));
|
||||
const utilsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
/** @type {ScriptItem[]} */
|
||||
const scripts = SCRIPT_LIST.map((name) => {
|
||||
const abs = path.resolve(utilsDir, name);
|
||||
if (!ensureExists(abs)) {
|
||||
throw new Error(`File not found: ${abs}`);
|
||||
}
|
||||
return { name, abs };
|
||||
});
|
||||
|
||||
console.log('\n============================================================');
|
||||
console.log(' Running scrapers in sequence (Node ESM)');
|
||||
console.log(` UTILS_DIR = ${utilsDir}`);
|
||||
console.log(` NODE_EXE = ${opts.nodePath}`);
|
||||
if (opts.filters.length) console.log(` FILTERS = ${opts.filters.join(', ')}`);
|
||||
console.log('============================================================\n');
|
||||
const title = 'Running scrapers in sequence (Node ESM)';
|
||||
const exhibition = {
|
||||
UTILS_DIR: utilsDir,
|
||||
NODE_EXE: opts.nodePath,
|
||||
};
|
||||
const exhibitionItems = (/* @IIFE */ () => {
|
||||
const maxKeyLength = Math.max(...Object.keys(exhibition).map(k => k.length));
|
||||
return Object.entries(exhibition).map(([ key, value ]) => {
|
||||
return ` ${key.padEnd(maxKeyLength)} : ${value}`;
|
||||
});
|
||||
})();
|
||||
|
||||
const lineLength = Math.min(Math.max(
|
||||
title.length,
|
||||
...exhibitionItems.map(s => s.length - 1),
|
||||
) + 2, process.stdout.columns || 80);
|
||||
const lineDouble = '='.repeat(lineLength);
|
||||
const lineSingle = '-'.repeat(lineLength);
|
||||
|
||||
console.log('\n');
|
||||
console.log(lineDouble);
|
||||
console.log(` ${title}`);
|
||||
console.log(lineSingle);
|
||||
console.log(exhibitionItems.join('\n'));
|
||||
console.log(lineDouble);
|
||||
console.log('\n');
|
||||
|
||||
if (scripts.length === 0) {
|
||||
console.log('No scripts to run after filtering.');
|
||||
return process.exit(0);
|
||||
}
|
||||
|
||||
// 检查存在性
|
||||
const finalScripts = [];
|
||||
for (const s of scripts) {
|
||||
if (await ensureExists(s.abs)) {
|
||||
finalScripts.push(s);
|
||||
} else {
|
||||
console.log(`File not found: ${s.name}`);
|
||||
}
|
||||
}
|
||||
if (finalScripts.length === 0) {
|
||||
console.log('No existing scripts to run.');
|
||||
return process.exit(0);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
console.log('The following scripts would run in order:');
|
||||
finalScripts.forEach((s, i) => console.log(` (${i + 1}/${finalScripts.length}) ${s.name}`));
|
||||
return process.exit(0);
|
||||
const results = [];
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
const script = scripts[i];
|
||||
const startLine = getScriptProgressMessage(scripts, i);
|
||||
|
||||
process.stdout.write(`\r${startLine}\n`);
|
||||
|
||||
const res = await runOne({ nodePath: opts.nodePath, scriptPath: script.abs, cwd: utilsDir });
|
||||
const endLine = `${startLine} (${formatDuration(res.ms)})`;
|
||||
|
||||
if (res.code === 0) {
|
||||
childProcessOutput.forEach((chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
readline.moveCursor(process.stdout, 0, -childProcessOutput.length - 1);
|
||||
process.stdout.write(`\r${endLine}`);
|
||||
readline.moveCursor(process.stdout, 0, childProcessOutput.length + 2);
|
||||
process.stdout.write('\r');
|
||||
results.push({ ...res, name: script.name });
|
||||
childProcessOutput.splice(0, childProcessOutput.length);
|
||||
} else {
|
||||
readline.moveCursor(process.stdout, 0, -childProcessOutput.length - 1);
|
||||
process.stdout.write(`\r${endLine} [code: ${res.code}]`);
|
||||
process.stdout.write('\n');
|
||||
results.push({ ...res, name: script.name });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < finalScripts.length; i++) {
|
||||
const s = finalScripts[i];
|
||||
console.log(`[${i + 1}/${finalScripts.length}] ${s.name}`);
|
||||
const res = await runOne({ nodePath: opts.nodePath, scriptPath: s.abs, cwd: utilsDir });
|
||||
if (res.code === 0) {
|
||||
console.log(`[Duration] ${formatDuration(res.ms)}\n`);
|
||||
} else {
|
||||
console.log(`[Duration] ${formatDuration(res.ms)} | [Exit Code] ${res.code}\n`);
|
||||
results.push({ ...res, name: s.name });
|
||||
if (!opts.continueOnError) break;
|
||||
continue;
|
||||
}
|
||||
results.push({ ...res, name: s.name });
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
|
||||
const failed = results.filter(r => r.code !== 0);
|
||||
console.log('============================================================');
|
||||
if (failed.length === 0) {
|
||||
console.log(' All tasks completed successfully.');
|
||||
console.log('============================================================\n');
|
||||
const title = 'All tasks completed successfully';
|
||||
const lineLength = Math.min(Math.max(title.length) + 1, process.stdout.columns || 80);
|
||||
const line = '='.repeat(lineLength);
|
||||
console.log(line);
|
||||
console.log(` ${title}`);
|
||||
console.log(line);
|
||||
process.stdout.write('\n');
|
||||
process.exit(0);
|
||||
} else {
|
||||
const title = `${failed.length} task(s) failed`;
|
||||
const messages = failed.map(r => {
|
||||
return ` - ${r.name} (${formatDuration(r.ms)}) [code: ${r.code}]`;
|
||||
});
|
||||
const lineLength = Math.min(Math.max(
|
||||
title.length,
|
||||
...messages.map(s => s.length - 1),
|
||||
) + 2, process.stdout.columns || 80);
|
||||
const lineDouble = '='.repeat(lineLength);
|
||||
const lineSingle = '-'.repeat(lineLength);
|
||||
console.log(lineDouble);
|
||||
console.log(` ${failed.length} task(s) failed:`);
|
||||
failed.forEach(r => console.log(` - ${r.name} (code ${r.code}, ${formatDuration(r.ms)})`));
|
||||
console.log('============================================================\n');
|
||||
console.log(lineSingle);
|
||||
console.log(messages.join('\n'));
|
||||
console.log(lineDouble);
|
||||
process.stdout.write('\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// scrape-and-inject-agp-gradle-compatibility-list.mjs
|
||||
|
||||
import { getMinSupportedAgpVersion, getMinSupportedGradleVersion } from './utils/properties.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
import { getMinSupportedAgpVersion, getMinSupportedGradleVersion } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/build/releases/gradle-plugin#updating-gradle';
|
||||
|
||||
@@ -13,8 +13,8 @@ const URL = 'https://developer.android.com/build/releases/gradle-plugin#updating
|
||||
tableSelector: '.devsite-table-wrapper table',
|
||||
tableFilter: {
|
||||
'tr th': [
|
||||
`:RegExp:i:${/Plugin version/.source}`,
|
||||
`:RegExp:i:${/Minimum required Gradle version/.source}`,
|
||||
/Plugin version/i,
|
||||
/Minimum required Gradle version/i,
|
||||
],
|
||||
},
|
||||
tableRowSelector: 'tbody tr',
|
||||
@@ -39,7 +39,7 @@ const URL = 'https://developer.android.com/build/releases/gradle-plugin#updating
|
||||
lines: Object.entries(map)
|
||||
.sort((a, b) => compareVersionStrings(b[0], a[0]))
|
||||
.map(([ pluginVersion, gradleVersion ]) => `"${pluginVersion}" to "${gradleVersion}",`),
|
||||
updatedLabel: 'AGP 与 Gradle 兼容性映射',
|
||||
updatedLabel: 'AGP and Gradle compatibility list',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// scrape-and-inject-agp-releases.mjs
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import { getMinSupportedAgpVersion } from './utils/properties.mjs';
|
||||
import { compareVersionStrings, compareVersionStringsDescending } from './utils/versioning.mjs';
|
||||
import { getMinSupportedAgpVersion } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/reference/tools/gradle-api';
|
||||
@@ -19,14 +19,16 @@ const URL = 'https://developer.android.com/reference/tools/gradle-api';
|
||||
}
|
||||
});
|
||||
const minSupportedVersion = getMinSupportedAgpVersion();
|
||||
const agpList = Array.from(results).filter(v => compareVersionStrings(v, minSupportedVersion) >= 0).sort(compareVersionStringsDescending);
|
||||
const agpList = Array.from(results)
|
||||
.filter(v => compareVersionStrings(v, minSupportedVersion) >= 0)
|
||||
.sort(compareVersionStringsDescending);
|
||||
await updateAnchoredListInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'ANDROID_GRADLE_PLUGIN_RELEASES_LIST',
|
||||
listName: 'agpReleases',
|
||||
lines: agpList.map(v => `"${v}",`),
|
||||
updatedLabel: 'AGP 发行版本数据',
|
||||
updatedLabel: 'AGP releases list',
|
||||
});
|
||||
})().catch((e) => {
|
||||
console.error('Failed to fetch AGP releases:', e)
|
||||
})().catch(err => {
|
||||
console.error('Failed to fetch AGP releases:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// scrape-android-studio-agp_version_maps.mjs
|
||||
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { fetchStudioAgpTable } from './fetch-and-parse-android-studio-agp-compatibility-table.mjs';
|
||||
import { readPropertiesSync } from './utils/properties.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { updateAnchoredMapInFile } from './utils/anchors.mjs';
|
||||
|
||||
const props = readPropertiesSync();
|
||||
@@ -18,14 +18,11 @@ const version = {
|
||||
const agpMap = {};
|
||||
|
||||
for (const { studioVersion, agpRange } of agpTable) {
|
||||
const [ _, targetAgpVersion ] = agpRange.split('-');
|
||||
const targetStudioVersion = studioVersion.match(/\d{2,}\.\d+\.\d/)?.[0];
|
||||
const targetStudioVersion = studioVersion.match(/\d{2,}\.\d+\.\d+/)?.[0];
|
||||
if (!targetStudioVersion) continue;
|
||||
if (targetStudioVersion in agpMap) {
|
||||
if (compareVersionStrings(targetAgpVersion, agpMap[targetStudioVersion]) < 0) {
|
||||
agpMap[targetStudioVersion] = targetAgpVersion;
|
||||
}
|
||||
} else {
|
||||
|
||||
const [ _, targetAgpVersion ] = agpRange.split('-');
|
||||
if (!(targetStudioVersion in agpMap) || compareVersionStrings(agpMap[targetStudioVersion], targetAgpVersion) > 0) {
|
||||
agpMap[targetStudioVersion] = targetAgpVersion;
|
||||
}
|
||||
if (compareVersionStrings(targetStudioVersion, version.MIN_IDE) <= 0) break;
|
||||
@@ -36,7 +33,7 @@ const version = {
|
||||
anchorTag: 'ANDROID_STUDIO_AGP_VERSION_MAP',
|
||||
mapName: 'agpVersionMap',
|
||||
lines: Object.entries(agpMap).map(([ studioVer, agpVer ]) => `"${studioVer}" to "${agpVer}",`),
|
||||
updatedLabel: 'AGP 版本映射',
|
||||
updatedLabel: 'AGP version map',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// scrape-and-inject-embedded-kotlin-list.mjs
|
||||
|
||||
/** @typedef {import('puppeteer').Page} Page */
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { getMinSupportedGradleVersion } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { autoScroll } from './utils/puppeteer-helpers.mjs';
|
||||
import { sleep } from './utils/async.mjs';
|
||||
|
||||
const URL = 'https://docs.gradle.org/current/userguide/compatibility.html#kotlin';
|
||||
|
||||
const unofficialKotlinCompatibilityList = {
|
||||
'8.14': '2.1.10',
|
||||
'8.13': '2.1.10',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Page} page
|
||||
* @return {Promise<string[][] | null>}
|
||||
*/
|
||||
async function findTargetRows(page) {
|
||||
return await page.evaluate(() => {
|
||||
/** @type {HTMLTableElement[]} */
|
||||
const targets = Array.from(document.querySelectorAll('table.tableblock'));
|
||||
const target = targets.find(t => {
|
||||
const tableHeadList = t.querySelectorAll('th');
|
||||
return Array.from(tableHeadList).some(th => /Embedded Kotlin version|Minimum Gradle version|Kotlin Language version/i.test(th.textContent));
|
||||
});
|
||||
if (!target) return null;
|
||||
|
||||
return Array.from(target.querySelectorAll('tbody tr'))
|
||||
.map(tr => {
|
||||
const tds = tr.querySelectorAll('td');
|
||||
if (tds.length < 3) return null;
|
||||
const kotlin = tds[0]?.querySelector('p')?.textContent?.trim();
|
||||
const gradle = tds[1]?.querySelector('p')?.textContent?.trim();
|
||||
const ktLanguage = tds[2]?.querySelector('p')?.textContent?.trim();
|
||||
return kotlin && gradle && ktLanguage ? [ kotlin, gradle, ktLanguage ] : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const browser = await puppeteer.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(URL, { waitUntil: 'networkidle0', timeout: 120000 });
|
||||
|
||||
// 页面为懒加载: 滚动并多次尝试, 直到目标表格出现或超时
|
||||
let rows = null;
|
||||
const deadline = Date.now() + 30000; // 30s 总超时
|
||||
while (Date.now() < deadline) {
|
||||
rows = await findTargetRows(page);
|
||||
if (rows && rows.length) break;
|
||||
await autoScroll(page);
|
||||
await sleep(300);
|
||||
}
|
||||
if (!rows || !rows.length) {
|
||||
throw new Error('Unable to locate target table rows (lazy-loaded content not found in time)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {{ [gradle: string]: { kotlin: string, isUnofficial: boolean } }}
|
||||
*/
|
||||
const map = {};
|
||||
const minSupportedGradleVersion = getMinSupportedGradleVersion();
|
||||
|
||||
for (const [ kotlin, gradle ] of rows) {
|
||||
if (compareVersionStrings(gradle, minSupportedGradleVersion) < 0) continue;
|
||||
map[gradle] = { kotlin, isUnofficial: false };
|
||||
}
|
||||
|
||||
Object.entries(unofficialKotlinCompatibilityList).forEach(([ gradle, kotlin ]) => {
|
||||
if (!(gradle in map)) map[gradle] = { kotlin, isUnofficial: true };
|
||||
});
|
||||
|
||||
await updateAnchoredListInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'EMBEDDED_KOTLIN_LIST',
|
||||
listName: 'embeddedKotlin',
|
||||
lines: Object.entries(map)
|
||||
.sort((a, b) => compareVersionStrings(b[1]['kotlin'], a[1]['kotlin']))
|
||||
.map(([ gradle, { kotlin, isUnofficial } ]) => {
|
||||
return isUnofficial
|
||||
? `"${gradle}" to "${kotlin}", /* Unofficial. */`
|
||||
: `"${gradle}" to "${kotlin}",`;
|
||||
}),
|
||||
updatedLabel: 'Java 与 Gradle 兼容性映射',
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// scrape-and-inject-gradle-kotlin-compatibility-list.mjs
|
||||
|
||||
/** @typedef {import('puppeteer').Page} Page */
|
||||
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { getMinSupportedGradleVersion } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
|
||||
const URL = 'https://docs.gradle.org/current/userguide/compatibility.html#kotlin';
|
||||
|
||||
const unofficialKotlinCompatibilityList = {
|
||||
// '8.14': '2.1.10',
|
||||
// '8.13': '2.1.10',
|
||||
};
|
||||
|
||||
(async function main() {
|
||||
const rows = await findTargetRows({
|
||||
url: URL,
|
||||
tableSelector: 'table.tableblock',
|
||||
tableFilter: {
|
||||
th: /Embedded Kotlin version|Minimum Gradle version|Kotlin Language version/i,
|
||||
},
|
||||
tableDataStructure: [
|
||||
'kotlin',
|
||||
'gradle',
|
||||
'ktLanguage',
|
||||
],
|
||||
});
|
||||
/**
|
||||
* @type {{ [gradle: string]: { kotlin: string, isUnofficial: boolean } }}
|
||||
*/
|
||||
const map = {};
|
||||
const minSupportedGradleVersion = getMinSupportedGradleVersion();
|
||||
|
||||
for (const { kotlin, gradle } of rows) {
|
||||
if (compareVersionStrings(gradle, minSupportedGradleVersion) < 0) continue;
|
||||
map[gradle] = { kotlin, isUnofficial: false };
|
||||
}
|
||||
|
||||
Object.entries(unofficialKotlinCompatibilityList).forEach(([ gradle, kotlin ]) => {
|
||||
if (!(gradle in map)) map[gradle] = { kotlin, isUnofficial: true };
|
||||
});
|
||||
|
||||
await updateAnchoredListInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'GRADLE_KOTLIN_COMPATIBILITY_LIST',
|
||||
listName: 'gradleKotlinCompatibility',
|
||||
lines: Object.entries(map)
|
||||
.sort((a, b) => compareVersionStrings(b[1]['kotlin'], a[1]['kotlin']))
|
||||
.map(([ gradle, { kotlin, isUnofficial } ]) => {
|
||||
return isUnofficial
|
||||
? `"${gradle}" to "${kotlin}", /* Unofficial. */`
|
||||
: `"${gradle}" to "${kotlin}",`;
|
||||
}),
|
||||
updatedLabel: 'Gradle and Kotlin compatibility list',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
// scrape-and-inject-java-gradle-compatibility-list.mjs
|
||||
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
import { getMinSupportedJavaVersionInt } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
|
||||
const URL = 'https://docs.gradle.org/current/userguide/compatibility.html#java_runtime';
|
||||
|
||||
const unofficialGradleCompatibilityList = {
|
||||
25: '9.0',
|
||||
// 25: '9.0',
|
||||
};
|
||||
|
||||
(async function main() {
|
||||
@@ -15,25 +15,23 @@ const unofficialGradleCompatibilityList = {
|
||||
url: URL,
|
||||
tableSelector: 'table.tableblock',
|
||||
tableFilter: {
|
||||
'caption': `:RegExp:i:${/java compatibility/.source}`,
|
||||
caption: /java compatibility/i,
|
||||
},
|
||||
tableRowSelector: 'tbody tr',
|
||||
tableDataSelector: 'td',
|
||||
tableDataStructure: [
|
||||
{ 'java': `:RegExp:${/^\d+$/.source}` },
|
||||
{ 'toolchain': `:RegExp:${/^N\/A$|\d+\.\d+/.source}` },
|
||||
{ 'gradle': `:RegExp:${/^N\/A$|\d+\.\d+/.source}` },
|
||||
{ java: /^\d+$/ },
|
||||
{ toolchain: /^N\/A$|\d+\.\d+/ },
|
||||
{ gradle: /^N\/A$|\d+\.\d+/ },
|
||||
],
|
||||
});
|
||||
/**
|
||||
* @type {{ [javaInt: string]: string }}
|
||||
*/
|
||||
/** @type {{ [javaInt: string]: string }} */
|
||||
const map = {};
|
||||
const minSupportedJavaVersionInt = getMinSupportedJavaVersionInt();
|
||||
for (const { java, gradle } of rows) {
|
||||
const javaInt = parseInt(java);
|
||||
if (Number.isNaN(javaInt)) {
|
||||
throw Error(`Invalid java version int: ${java}`);
|
||||
throw Error(`Invalid java version int: "${java}"`);
|
||||
}
|
||||
if (javaInt >= minSupportedJavaVersionInt) {
|
||||
map[javaInt] = gradle;
|
||||
@@ -54,7 +52,7 @@ const unofficialGradleCompatibilityList = {
|
||||
}
|
||||
return `${java} to "${gradle}",`;
|
||||
}),
|
||||
updatedLabel: 'Java 与 Gradle 兼容性映射',
|
||||
updatedLabel: 'Java and Gradle compatibility list',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,107 +1,67 @@
|
||||
// scrape-and-inject-ksp-releases.mjs
|
||||
|
||||
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/releases']['response']['data']} ReleasesData */
|
||||
|
||||
import * as https from 'https';
|
||||
import { updateAnchoredMapInFile } from './utils/anchors.mjs';
|
||||
import { toUpdatedStamp } from './utils/date.mjs';
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {Object} [options={}]
|
||||
* @param {import("http").OutgoingHttpHeaders} [options.headers={}]
|
||||
* @param {number} [options.timeout=15000]
|
||||
* @return {Promise<ReleasesData>}
|
||||
*/
|
||||
function httpsGetJson(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'node',
|
||||
'Accept': 'application/vnd.github+json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
timeout: options.timeout || 15000,
|
||||
},
|
||||
(res) => {
|
||||
const { statusCode } = res;
|
||||
const chunks = [];
|
||||
|
||||
res.on('data', (d) => chunks.push(d));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
return reject(
|
||||
new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const json = /** @type {ReleasesData} */ JSON.parse(body);
|
||||
resolve(json);
|
||||
} catch (e) {
|
||||
reject(new Error(`JSON parse error: ${e.message}`));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('Request timed out'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} KspRelease
|
||||
* @property {string} version
|
||||
* @property {string} name
|
||||
* @property {string} publishedAt
|
||||
*/
|
||||
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import { httpFetch } from './utils/fetch.mjs';
|
||||
import { readProperties } from './utils/properties.mjs';
|
||||
import { toUpdatedStamp } from './utils/date.mjs';
|
||||
import { updateAnchoredMapInFile } from './utils/anchors.mjs';
|
||||
|
||||
const URL = 'https://api.github.com/repos/google/ksp/releases';
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||
|
||||
/**
|
||||
* @return {Promise<KspRelease[]>}
|
||||
* @returns {Promise<KspRelease[]>}
|
||||
*/
|
||||
async function fetchKspReleases() {
|
||||
const base = 'https://api.github.com/repos/google/ksp/releases';
|
||||
const perPage = 100;
|
||||
let page = 1;
|
||||
let reached = false;
|
||||
/** @type {import('http').OutgoingHttpHeaders} */
|
||||
const headers = {
|
||||
'accept': 'application/vnd.github+json',
|
||||
'user-agent': 'node',
|
||||
...(GITHUB_TOKEN ? { 'authorization': `Bearer ${GITHUB_TOKEN}` } : {}),
|
||||
};
|
||||
const minToCheck = await getMinKotlinVersionToCheck();
|
||||
const out = [];
|
||||
|
||||
/** @type {import("http").OutgoingHttpHeaders} */
|
||||
const headers = {};
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
}
|
||||
let page = 1;
|
||||
let reached = false;
|
||||
|
||||
while (!reached) {
|
||||
const url = `${base}?per_page=${perPage}&page=${page}`;
|
||||
const releases = await httpsGetJson(url, { headers });
|
||||
const query = { per_page: 100, page };
|
||||
/** @type {ReleasesData} */
|
||||
const releases = await httpFetch(URL, { query, headers });
|
||||
|
||||
if (!Array.isArray(releases) || releases.length === 0) break;
|
||||
|
||||
for (const release of releases) {
|
||||
const tag = String(release.tag_name || '').trim();
|
||||
// 记录
|
||||
out.push({
|
||||
version: tag,
|
||||
name: release.name,
|
||||
publishedAt: release.published_at,
|
||||
});
|
||||
|
||||
// 判断是否已到达目标最旧版本(含)
|
||||
const parts = tag.split('-');
|
||||
if (parts.length >= 2) {
|
||||
const kspVer = parts.slice(0, -1).join('-');
|
||||
if (kspVer === '1.8.0-RC2') {
|
||||
reached = true;
|
||||
break;
|
||||
}
|
||||
if (parts.length < 2) continue;
|
||||
/**
|
||||
* @example string
|
||||
* "2.2.20-2.0.3" -> "2.2.20"
|
||||
* "1.9.10-1.0.13" -> "1.9.10"
|
||||
* "2.2.20-RC2-2.0.2" -> "2.2.20-RC2"
|
||||
* "1.9.20-RC-1.0.13" -> "1.9.20-RC"
|
||||
* @type {string}
|
||||
*/
|
||||
const kotlinVer = parts.slice(0, -1).join('-');
|
||||
if (kotlinVer === minToCheck) {
|
||||
reached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,41 +72,68 @@ async function fetchKspReleases() {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function getMinKotlinVersionToCheck() {
|
||||
const raw = await fsp.readFile('../settings.gradle.kts', 'utf8');
|
||||
const tag = 'GRADLE_KOTLIN_COMPATIBILITY_LIST';
|
||||
const beginIndex = raw.indexOf(`@AnchorBegin ${tag}`);
|
||||
const endIndex = raw.indexOf(`@AnchorEnd ${tag}`);
|
||||
|
||||
if (beginIndex !== -1 && endIndex !== -1) {
|
||||
const props = await readProperties();
|
||||
const minGradle = props['MIN_SUPPORTED_GRADLE_VERSION'];
|
||||
|
||||
const lines = raw.slice(beginIndex + tag.length + 1, endIndex).split('\n');
|
||||
for (const line of lines) {
|
||||
const m = /^"(\d+(?:\.\d+)+)" to "(\d+(?:\.\d+)+)",/.exec(line.trim());
|
||||
if (m) {
|
||||
const [ _, gradleVersion, kotlinVersion ] = m;
|
||||
if (gradleVersion === minGradle) {
|
||||
return kotlinVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to find the minimum Kotlin version to check in settings.gradle.kts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KspRelease[]} releases
|
||||
* @return {string[]}
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseReleases(releases) {
|
||||
if (!Array.isArray(releases)) return [];
|
||||
|
||||
// 先根据 KSP 版本去重,保留发布时间最新的一条
|
||||
/** @type {Map<string, { kotlinVer: string, date: Date }>} */
|
||||
const latestByKsp = new Map(); // kspVer -> { kotlinVer, date }
|
||||
// De-duplicate by Kotlin version, keep the latest one by release date.
|
||||
// zh-CN: 根据 Kotlin 版本去重, 保留发布时间最新的一条.
|
||||
|
||||
/** @type {Map<string, { kspVer: string, date: Date }>} */
|
||||
const latestByKotlin = new Map();
|
||||
for (const r of releases) {
|
||||
const rawVer = String(r.version || '').trim();
|
||||
const parts = rawVer.split('-');
|
||||
if (parts.length < 2) continue; // 跳过无效项
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const kotlinVer = parts.pop();
|
||||
const kspVer = parts.join('-');
|
||||
const kspVer = parts.pop();
|
||||
const KotlinVer = parts.join('-');
|
||||
|
||||
const d = new Date(r.publishedAt);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
|
||||
const prev = latestByKsp.get(kspVer);
|
||||
const prev = latestByKotlin.get(KotlinVer);
|
||||
if (!prev || d > prev.date) {
|
||||
latestByKsp.set(kspVer, { kotlinVer, date: d });
|
||||
latestByKotlin.set(KotlinVer, { kspVer, date: d });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 KSP 版本用于排序.
|
||||
* Parse Kotlin version for sorting.<br>
|
||||
* zh-CN: 解析 Kotlin 版本用于排序.
|
||||
*
|
||||
* @param {string} ksp
|
||||
* @return {{ baseNums: number[], rank: number, qName: string, qNum: number }}
|
||||
* @param {string} kotlinVer
|
||||
* @returns {{ baseNums: number[], rank: number, qName: string, qNum: number }}
|
||||
*/
|
||||
function parseKspVer(ksp) {
|
||||
const [ base, qualifierRaw = '' ] = ksp.split('-', 2);
|
||||
function parseKotlinVer(kotlinVer) {
|
||||
const [ base, qualifierRaw = '' ] = kotlinVer.split('-', 2);
|
||||
const baseNums = base.split('.').map((n) => parseInt(String(n), 10) || 0);
|
||||
|
||||
let qName = '';
|
||||
@@ -161,7 +148,6 @@ function parseReleases(releases) {
|
||||
}
|
||||
}
|
||||
|
||||
// 等级:稳定版 > RC > Beta > 其他
|
||||
const rankMap = { '': 3, RC: 2, BETA: 1 };
|
||||
const rank = Object.prototype.hasOwnProperty.call(rankMap, qName) ? rankMap[qName] : 0;
|
||||
|
||||
@@ -171,54 +157,45 @@ function parseReleases(releases) {
|
||||
/**
|
||||
* @param {number[]} aNums
|
||||
* @param {number[]} bNums
|
||||
* @return {number}
|
||||
* @returns {number}
|
||||
*/
|
||||
function cmpBaseDesc(aNums, bNums) {
|
||||
const len = Math.max(aNums.length, bNums.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const av = aNums[i] ?? 0;
|
||||
const bv = bNums[i] ?? 0;
|
||||
if (av !== bv) return bv - av; // 降序
|
||||
if (av !== bv) return bv - av;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 转为数组并按 KSP 版本降序排列
|
||||
/**
|
||||
* @type {Array<{ kspVer: string, kotlinVer: string, date: Date, parsed: { baseNums: number[], rank: number, qName: string, qNum: number } }>}
|
||||
*/
|
||||
const items = Array.from(latestByKsp.entries())
|
||||
.map(([ kspVer, v ]) => {
|
||||
const parsed = parseKspVer(kspVer);
|
||||
return { kspVer, kotlinVer: v.kotlinVer, date: v.date, parsed };
|
||||
return Array.from(latestByKotlin.entries())
|
||||
.map(([ kotlinVer, v ]) => {
|
||||
const parsed = parseKotlinVer(kotlinVer);
|
||||
return { kotlinVer, kspVer: v.kspVer, date: v.date, parsed };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 1) 基础版本号降序
|
||||
let c = cmpBaseDesc(a.parsed.baseNums, b.parsed.baseNums);
|
||||
const c = cmpBaseDesc(a.parsed.baseNums, b.parsed.baseNums);
|
||||
if (c !== 0) return c;
|
||||
// 2) 级别降序(稳定版 > RC > Beta > 其他)
|
||||
if (a.parsed.rank !== b.parsed.rank) return b.parsed.rank - a.parsed.rank;
|
||||
// 3) 同级别数字降序(RC2 > RC1;Beta2 > Beta1;无数字视为 0)
|
||||
if (a.parsed.qNum !== b.parsed.qNum) return b.parsed.qNum - a.parsed.qNum;
|
||||
// 4) 兜底,限定词字典序降序(稳定排序用)
|
||||
if (a.parsed.qName !== b.parsed.qName) return a.parsed.qName < b.parsed.qName ? 1 : -1;
|
||||
// 5) 仍然相同则按日期降序(保险)
|
||||
return b.date.getTime() - a.date.getTime();
|
||||
})
|
||||
.map(({ kotlinVer, kspVer, date }) => {
|
||||
return `"${kotlinVer}" to "${kspVer}", /* ${(toUpdatedStamp(date))}. */`;
|
||||
});
|
||||
|
||||
return items.map(({ kspVer, kotlinVer, date }) => {
|
||||
const dateStr = toUpdatedStamp(date);
|
||||
return `"${kspVer}" to "${kotlinVer}", /* ${dateStr}. */`;
|
||||
});
|
||||
}
|
||||
|
||||
fetchKspReleases()
|
||||
.then(async (releases) => {
|
||||
await updateAnchoredMapInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'KSP_VERSION_MAP',
|
||||
mapName: 'kspVersionMap',
|
||||
lines: parseReleases(releases).map(l => `${l}`),
|
||||
updatedLabel: 'KSP 发行版本映射',
|
||||
});
|
||||
})
|
||||
.catch((error) => console.error('Failed to fetch KSP releases:', error));
|
||||
(async function main() {
|
||||
const releases = await fetchKspReleases();
|
||||
await updateAnchoredMapInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'KSP_VERSION_MAP',
|
||||
mapName: 'kspVersionMap',
|
||||
lines: parseReleases(releases),
|
||||
updatedLabel: 'KSP releases version map',
|
||||
});
|
||||
})().catch((err) => {
|
||||
console.error('Failed to fetch KSP releases:', err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// scrape-and-inject-latest-gradle-wrapper.mjs
|
||||
|
||||
/** @typedef {import('./fetch-and-parse-gradle-releases.mjs').GradleRelease} GradleRelease */
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @typedef {Object} GradleReleaseConfig
|
||||
* @property {number | string | null} [majorVersionLimit=null]
|
||||
* @property {'bin' | 'all'} [format='bin']
|
||||
*/
|
||||
/** @typedef {import('./fetch-and-parse-gradle-releases.mjs').GradleRelease} GradleRelease */
|
||||
|
||||
import { fetchGradleReleases } from './fetch-and-parse-gradle-releases.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { fetchGradleReleases } from './fetch-and-parse-gradle-releases.mjs';
|
||||
import { readPropertiesSync, writePropertiesSync } from './utils/properties.mjs';
|
||||
|
||||
const KEY = 'distributionUrl';
|
||||
const URL_PREFIX = 'https://services.gradle.org/distributions';
|
||||
|
||||
/** @type {Config} */
|
||||
/** @type {GradleReleaseConfig} */
|
||||
const config = {
|
||||
// @Hint by SuperMonster003 on Sep 10, 2025.
|
||||
// ! Limit major version to 8.x.x, to:
|
||||
@@ -41,7 +41,7 @@ function isVersionLimited() {
|
||||
/**
|
||||
* @param {GradleRelease[]} releases
|
||||
* @param {string} majorVersionLimit
|
||||
* @return {GradleRelease}
|
||||
* @returns {GradleRelease}
|
||||
*/
|
||||
function getLatestRelease(releases, majorVersionLimit) {
|
||||
const limitedRelease = releases
|
||||
@@ -55,7 +55,7 @@ function getLatestRelease(releases, majorVersionLimit) {
|
||||
|
||||
/**
|
||||
* @param {string} latestGradleVersion
|
||||
* @return {string}
|
||||
* @returns {string}
|
||||
*/
|
||||
function getLatestGradleUrl(latestGradleVersion) {
|
||||
const format = config.format ?? 'bin';
|
||||
@@ -67,10 +67,11 @@ function getLatestGradleUrl(latestGradleVersion) {
|
||||
* @param {string} data.latestGradleVersion
|
||||
* @param {string} data.latestGradleUrl
|
||||
* @param {string} data.majorVersionLimit
|
||||
* @return {Promise<void>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateGradleWrapperFileContent({ latestGradleVersion, latestGradleUrl, majorVersionLimit }) {
|
||||
const path = '../gradle/wrapper/gradle-wrapper.properties';
|
||||
const fileName = 'gradle-wrapper.properties';
|
||||
const path = '../gradle/wrapper/' + fileName;
|
||||
const messages = [];
|
||||
const props = readPropertiesSync(path);
|
||||
let propUrl = props[KEY];
|
||||
@@ -87,21 +88,21 @@ async function updateGradleWrapperFileContent({ latestGradleVersion, latestGradl
|
||||
}
|
||||
|
||||
if (compareVersionStrings(propVersion, majorVersionLimit) > 0) {
|
||||
const suffix = ` (降级, 受限于 "${config.majorVersionLimit}")`;
|
||||
const suffix = ` (downgrade, limited by "${config.majorVersionLimit}")`;
|
||||
messages.push(`-- ${propUrl}\n-> ${latestGradleUrl}${suffix}`);
|
||||
props[KEY] = propUrl.replace(re, `$1${latestGradleVersion}$3`);
|
||||
} else if (compareVersionStrings(propVersion, latestGradleVersion) < 0) {
|
||||
const suffix = isVersionLimited() ? ` (升级, 但受限于 "${config.majorVersionLimit}")` : ` (升级)`;
|
||||
const suffix = isVersionLimited() ? ` (upgrade, but limited by "${config.majorVersionLimit}")` : ` (upgrade)`;
|
||||
messages.push(`-- ${propUrl}\n-> ${latestGradleUrl}${suffix}`);
|
||||
props[KEY] = propUrl.replace(re, `$1${latestGradleVersion}$3`);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
writePropertiesSync(path, props);
|
||||
console.log('[gradle-wrapper.properties] 已更新 (Gradle 版本)');
|
||||
console.log(`[${fileName}] Updated (Gradle version)`);
|
||||
messages.forEach((message) => console.log(message));
|
||||
} else {
|
||||
// console.log('[gradle-wrapper.properties] 无需更新 (Gradle 版本)');
|
||||
// console.log(`[${fileName}] No update needed (Gradle version)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ function parseMajorVersionLimit() {
|
||||
await updateGradleWrapperFileContent({
|
||||
latestGradleVersion, latestGradleUrl, majorVersionLimit,
|
||||
});
|
||||
})().catch((e) => {
|
||||
console.error('Failed to scrape or inject latest Gradle wrapper:', e);
|
||||
})().catch(err => {
|
||||
console.error('Failed to scrape or inject latest Gradle wrapper:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
// scrape-and-inject-rhino-engine-data.mjs
|
||||
|
||||
import { getLatestCommitDate } from './utils/fetch.mjs';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getLatestCommitDate } from './utils/fetch.mjs';
|
||||
|
||||
const URL = 'https://raw.githubusercontent.com/SuperMonster003/Rhino-For-AutoJs6/refs/heads/master/gradle.properties';
|
||||
|
||||
/**
|
||||
* @param {string} latestVersion
|
||||
* @return {Promise<void>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateTemplateReadmeRhinoBadge(latestVersion) {
|
||||
const templateReadmePath = path.resolve(process.cwd(), '../.readme/template_readme.md');
|
||||
@@ -19,18 +19,18 @@ async function updateTemplateReadmeRhinoBadge(latestVersion) {
|
||||
if (oldVersion !== latestVersion) {
|
||||
const updatedFileContent = fileContent.replace(rhinoBadgeRegex, `$1${latestVersion.replaceAll('-', '--')}$3`);
|
||||
fs.writeFileSync(templateReadmePath, updatedFileContent, 'utf8');
|
||||
console.log('[template_readme.md] 已更新 (Rhino 徽标版本)');
|
||||
console.log('[template_readme.md] Updated (Rhino badge version)');
|
||||
console.log(`-- ${oldVersion}`);
|
||||
console.log(`-> ${latestVersion}`);
|
||||
} else {
|
||||
// console.log('[template_readme.md] 无需更新 (Rhino 徽标版本)');
|
||||
// console.log('[template_readme.md] No update needed (Rhino badge version)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} latestVersion
|
||||
* @param {number} linenoOfLatestVersion
|
||||
* @return {Promise<void>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateCommonJsonWithRhinoData(latestVersion, linenoOfLatestVersion) {
|
||||
const commonJsonPath = path.resolve(process.cwd(), '../.readme/common.json');
|
||||
@@ -42,16 +42,20 @@ async function updateCommonJsonWithRhinoData(latestVersion, linenoOfLatestVersio
|
||||
const addressJsonValue = `[v${latestVersion}](${addressPrefix}${addressSuffix})`;
|
||||
const latestCommitValue = await getLatestCommitDate('SuperMonster003', 'Rhino-For-AutoJs6');
|
||||
|
||||
const toUpdateKeys = {
|
||||
address: 'latest_rhino_engine_name_with_github_lineno_address',
|
||||
date: 'var_date_rhino_engine_latest_committed',
|
||||
};
|
||||
const updatedCommon = {
|
||||
...commonObj,
|
||||
latest_rhino_engine_name_with_github_lineno_address: addressJsonValue,
|
||||
var_date_rhino_engine_latest_committed: latestCommitValue,
|
||||
[toUpdateKeys.address]: addressJsonValue,
|
||||
[toUpdateKeys.date]: latestCommitValue,
|
||||
};
|
||||
|
||||
if (JSON.stringify(updatedCommon) !== JSON.stringify(commonObj)) {
|
||||
fs.writeFileSync(commonJsonPath, JSON.stringify(updatedCommon, null, 2), 'utf8');
|
||||
console.log('[common.json] 已更新 (Rhino 数据)');
|
||||
[ 'latest_rhino_engine_name_with_github_lineno_address', 'var_date_rhino_engine_latest_committed' ].forEach(key => {
|
||||
console.log('[common.json] Updated (Rhino information)');
|
||||
Object.values(toUpdateKeys).forEach(key => {
|
||||
if (key in updatedCommon && key in commonObj && updatedCommon[key] !== commonObj[key]) {
|
||||
console.log(`## ${key}`);
|
||||
console.log(`-- ${commonObj[key]}`);
|
||||
@@ -59,11 +63,11 @@ async function updateCommonJsonWithRhinoData(latestVersion, linenoOfLatestVersio
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// console.log('[common.json] 无需更新 (Rhino 数据)');
|
||||
// console.log('[common.json] No update needed (Rhino information)');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
(async function main() {
|
||||
const response = await fetch(URL);
|
||||
const text = await response.text();
|
||||
const lines = text.split('\n');
|
||||
@@ -72,9 +76,7 @@ async function main() {
|
||||
|
||||
await updateTemplateReadmeRhinoBadge(latestVersion);
|
||||
await updateCommonJsonWithRhinoData(latestVersion, linenoOfLatestVersion);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
99
.utils/scrape-and-update-foojay-resolver-version.mjs
Normal file
99
.utils/scrape-and-update-foojay-resolver-version.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
// scrape-and-update-foojay-resolver-version.mjs
|
||||
// noinspection CssInvalidHtmlTagReference
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import fetch from 'node-fetch';
|
||||
import { compareVersionStrings, isVersionStable } from './utils/versioning.mjs';
|
||||
|
||||
const PLUGIN_ID = 'org.gradle.toolchains.foojay-resolver-convention';
|
||||
const ARTIFACT_ID = `foojay-resolver`;
|
||||
|
||||
/**
|
||||
* Convert plugin ID to marker artifact metadata URL on Plugin Portal.<br>
|
||||
* zh-CN: 将插件 ID 转为 Plugin Portal 上的 marker artifact 元数据地址.
|
||||
*
|
||||
* @param {string} pluginId
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildMetadataUrl(pluginId) {
|
||||
const lastDot = pluginId.lastIndexOf('.');
|
||||
if (lastDot < 0) {
|
||||
throw new Error(`Invalid plugin ID: ${pluginId}`);
|
||||
}
|
||||
const groupId = pluginId.slice(0, lastDot); // e.g. org.gradle.toolchains
|
||||
const groupPath = groupId.replace(/\./g, '/'); // e.g. org/gradle/toolchains
|
||||
return `https://plugins.gradle.org/m2/${groupPath}/${ARTIFACT_ID}/maven-metadata.xml`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pluginId
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function fetchLatestVersion(pluginId) {
|
||||
const url = buildMetadataUrl(pluginId);
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch: ${res.status} ${res.statusText} (${url})`);
|
||||
}
|
||||
const xml = await res.text();
|
||||
const $ = cheerio.load(xml, { xmlMode: true });
|
||||
|
||||
return $('metadata > versioning > latest').first().text().trim()
|
||||
|| $('metadata > versioning > release').first().text().trim()
|
||||
|| ( /* @IIFE(getByVersionList) */ () => {
|
||||
const versions = $('metadata > versioning > versions > version')
|
||||
.map((_, el) => $(el).text().trim())
|
||||
.get()
|
||||
.filter(Boolean)
|
||||
.filter(isVersionStable)
|
||||
.sort(compareVersionStrings);
|
||||
if (versions.length === 0) {
|
||||
throw new Error('No version found in metadata');
|
||||
}
|
||||
return versions.at(-1);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} newVersion
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateVersionInGradleSettings(newVersion) {
|
||||
const pluginLabel = ARTIFACT_ID.split(/\W+/).map(s => s[0].toUpperCase() + s.slice(1)).join(' ');
|
||||
const updatedLabel = `${pluginLabel} plugin version`;
|
||||
const filePath = '../gradle/libs.versions.toml';
|
||||
const filename = path.basename(filePath);
|
||||
const raw = await fsp.readFile(filePath, 'utf8');
|
||||
|
||||
// e.g. `foojay-resolver-convention = "0.9.0"`.
|
||||
const re = /(foojay.resolver.convention\s*=\s*")(\d+(?:\.\d+)+)(?=")/;
|
||||
|
||||
const matched = raw.match(re);
|
||||
if (!matched) {
|
||||
throw new Error(`Cannot determine the location of ${pluginLabel} plugin information`);
|
||||
}
|
||||
const oldVersion = matched[2];
|
||||
if (oldVersion !== newVersion) {
|
||||
const updated = raw.replace(re, `$1${newVersion}`);
|
||||
await fsp.writeFile(filePath, updated, 'utf8');
|
||||
console.log(`[${filename}] Updated (${updatedLabel})`);
|
||||
console.log(`-- ${oldVersion}`);
|
||||
console.log(`-> ${newVersion}`);
|
||||
} else {
|
||||
// console.log(`[${filename}] No update needed (${updatedLabel})`);
|
||||
}
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
try {
|
||||
await updateVersionInGradleSettings(await fetchLatestVersion(PLUGIN_ID));
|
||||
} catch (e) {
|
||||
console.error(`Failed to get latest version of Foojay Resolver: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
// scrape-and-update-readme-template-contributors-table.mjs
|
||||
|
||||
import { fetchStatistics } from './fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs';
|
||||
import * as fs from 'node:fs';
|
||||
import { toYYYYMMDD } from './utils/date.mjs';
|
||||
import * as path from 'node:path';
|
||||
import { fetchStatistics } from './fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs';
|
||||
import { toYYYYMMDD } from './utils/date.mjs';
|
||||
|
||||
function updateCommonJsonFile() {
|
||||
const commonJsonPath = path.resolve(process.cwd(), '../.readme/common.json');
|
||||
@@ -17,7 +17,7 @@ function updateCommonJsonFile() {
|
||||
|
||||
if (JSON.stringify(updatedCommon) !== JSON.stringify(commonObj)) {
|
||||
fs.writeFileSync(commonJsonPath, JSON.stringify(updatedCommon, null, 2), 'utf8');
|
||||
console.log('[common.json] 已更新 (贡献参与数据统计日期)');
|
||||
console.log('[common.json] Updated (contribution statistics date)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,17 +28,17 @@ function updateCommonJsonFile() {
|
||||
const newMarkdown = stats.map(stat => `| ${stat.contributorMarkdown} | ${stat.commitsCountMarkdown} | ${stat.latestCommitMarkdown} |`).join('\n');
|
||||
|
||||
const text = fs.readFileSync(path, { encoding: 'utf-8' });
|
||||
const newText = text.replace(/((?:table_header_contribution_contributors|table_header_contribution_number_of_commits|table_header_contribution_recent_submissions).+\r?\n)([\s|:\-]+\r?\n)(\|\s*<span style=".+\r?\n)+/i, ($0, $1, $2, $3) => {
|
||||
const cr = $3.match(/\|\s*<span style=".+(\r?\n)/)[1];
|
||||
return `${$1}${$2}${newMarkdown}${cr}`;
|
||||
const contributionHeaderRegex = /(table_header_contribution_\w+.+\r?\n)([\s|:\-]+\r?\n)(?:\|\s*<span style=".+(\r?\n))+/i;
|
||||
const newText = text.replace(contributionHeaderRegex, (_, headerLine, separatorLine, eol) => {
|
||||
return `${headerLine}${separatorLine}${newMarkdown}${eol}`;
|
||||
});
|
||||
|
||||
if (text.replace(/\s+/g, '') !== newText.replace(/\s+/g, '')) {
|
||||
fs.writeFileSync(path, newText, { encoding: 'utf-8' });
|
||||
console.log('[template_readme.md] 已更新 (贡献参与统计列表)');
|
||||
console.log('[template_readme.md] Updated (contribution statistics list)');
|
||||
updateCommonJsonFile();
|
||||
} else {
|
||||
// console.log('[template_readme.md] 无需更新 (贡献参与统计列表)');
|
||||
// console.log('[template_readme.md] No update needed (contribution statistics list)');
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
// utils/anchors.mjs
|
||||
|
||||
/**
|
||||
* @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]
|
||||
*/
|
||||
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { escapeRegExp } from './format.mjs';
|
||||
import { toUpdatedStamp } from './date.mjs';
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalize = s => String(s).replace(/\s+/g, '');
|
||||
const normalize = (s) => String(s).replace(/\s+/g, '');
|
||||
|
||||
/**
|
||||
* 在指定 Anchor 块中, 用给定的替换函数生成新块内容.
|
||||
* Generate new block content with the given replacement function in the specified Anchor block.<br>
|
||||
* zh-CN: 在指定 Anchor 块中, 用给定的替换函数生成新块内容.
|
||||
*
|
||||
* @param {string} src
|
||||
* @param {string} anchorTag
|
||||
* @param {(block: string) => { newBlock: string, changed: boolean }} replaceBlockFn
|
||||
* @returns {{ src: string, changed: boolean }} - 返回 { src: 新源码, changed: 是否发生变更 }. 若找不到锚点, 原样返回.
|
||||
* @returns {{ src: string, changed: boolean }}
|
||||
*/
|
||||
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 };
|
||||
if (beginIdx === -1) throw new Error(`Anchor tag "${anchorTag}" not found in the source code`);
|
||||
|
||||
const endIdx = src.indexOf(endTag, beginIdx + beginTag.length);
|
||||
if (endIdx === -1) return { src, changed: false };
|
||||
if (endIdx === -1) throw new Error(`Anchor tag "${anchorTag}" not found in the source code`);
|
||||
|
||||
const before = src.slice(0, beginIdx);
|
||||
const block = src.slice(beginIdx, endIdx); // 不包含 endTag
|
||||
const block = src.slice(beginIdx, endIdx);
|
||||
const after = src.slice(endIdx);
|
||||
|
||||
const { newBlock, changed } = replaceBlockFn(block) || {};
|
||||
@@ -39,16 +53,18 @@ export function replaceInAnchoredBlock(src, anchorTag, replaceBlockFn) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换锚点块中的某个 map 声明 (如 mapOf(...)), 并在变更时自动刷新 @Updated 日期.
|
||||
* Replace a map declaration (like mapOf(...)) in an anchor block
|
||||
* and automatically refresh the @Updated date when changed.<br>
|
||||
* zh-CN: 替换锚点块中的某个 map 声明 (如 mapOf(...)), 并在变更时自动刷新 @Updated 日期.
|
||||
*
|
||||
* @param {string} src
|
||||
* @param {Object} options
|
||||
* @param {string} options.anchorTag - 块的锚点名
|
||||
* @param {string} options.mapName - 变量名, 如 agpVersionMap
|
||||
* @param {string[]} options.lines - map 体内的每行 (不含缩进, 由函数自动缩进)
|
||||
* @param {string} options.anchorTag
|
||||
* @param {string} options.mapName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
|
||||
* @returns {{ src: string, changed: boolean }}}
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @returns {{ src: string, changed: boolean }}
|
||||
*/
|
||||
export function replaceAnchoredMapBlock(src, {
|
||||
anchorTag,
|
||||
@@ -60,7 +76,8 @@ export function replaceAnchoredMapBlock(src, {
|
||||
return replaceInAnchoredBlock(src, anchorTag, (block) => {
|
||||
let changed = false;
|
||||
|
||||
const re = new RegExp(`([\\t\\x20]*)(va[lr]\\s+)?${mapName}\\s*=\\s*mapOf\\([\\s\\S]*?\\)(,?)`, 'm');
|
||||
const re = new RegExp(String.raw`([\t ]*)(va[lr]\s+)?${escapeRegExp(mapName)}\s*=\s*mapOf\(.*?\)(,?)`, 's');
|
||||
|
||||
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');
|
||||
@@ -80,16 +97,19 @@ export function replaceAnchoredMapBlock(src, {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a list declaration (like listOf(...)) in an anchor block
|
||||
* and automatically refresh the @Updated date when changed.<br>
|
||||
* zh-CN:<br>
|
||||
* 替换锚点块中的某个 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 {string} options.anchorTag
|
||||
* @param {string} options.listName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
|
||||
* @returns {{ src: string, changed: boolean }}}
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @returns {{ src: string, changed: boolean }}
|
||||
*/
|
||||
export function replaceAnchoredListBlock(src, {
|
||||
anchorTag,
|
||||
@@ -121,16 +141,14 @@ export function replaceAnchoredListBlock(src, {
|
||||
}
|
||||
|
||||
/**
|
||||
* 高层封装: 读取文件 -> 替换锚点 map -> 若有变更则写回 -> 打印日志.
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {Object} options
|
||||
* @param {string} options.anchorTag - 块的锚点名
|
||||
* @param {string} options.mapName - 变量名, 如 agpVersionMap
|
||||
* @param {string[]} options.lines - map 体内的每行 (不含缩进, 由函数自动缩进)
|
||||
* @param {string} options.anchorTag
|
||||
* @param {string} options.mapName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {string} [options.updatedLabel='']
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @param {Console} [options.logger=console]
|
||||
* @returns {Promise<{ changed: boolean, content: string }>}
|
||||
*/
|
||||
@@ -149,24 +167,22 @@ export async function updateAnchoredMapInFile(filePath, {
|
||||
|
||||
if (changed) {
|
||||
await fsp.writeFile(filePath, updated, 'utf8');
|
||||
logger.log(`[${filename}] 已更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
logger.log(`[${filename}] Updated` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
} else {
|
||||
// logger.log(`[${filename}] 无需更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
// logger.log(`[${filename}] No update needed` + (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 {string} options.anchorTag
|
||||
* @param {string} options.listName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {string} [options.updatedLabel='']
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @param {Console} [options.logger=console]
|
||||
* @returns {Promise<{ changed: boolean, content: string }>}
|
||||
*/
|
||||
@@ -185,31 +201,22 @@ export async function updateAnchoredListInFile(filePath, {
|
||||
|
||||
if (changed) {
|
||||
await fsp.writeFile(filePath, updated, 'utf8');
|
||||
logger.log(`[${filename}] 已更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
logger.log(`[${filename}] Updated` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
} else {
|
||||
// logger.log(`[${filename}] 无需更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
// logger.log(`[${filename}] No update needed` + (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 都支持, 读一次/写一次).
|
||||
* Batch replace multiple anchors within the same file
|
||||
* (supports both map and list, requiring only one read/write operation).<br>
|
||||
* zh-CN: 批量在同一文件内进行多锚点替换 (同时支持 map 与 list, 读写仅需一次).
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {AnchoredBlockUpdateOption[]} optionList
|
||||
* @param {Object} [extraOptions={}]
|
||||
* @param {(date?: Date) => string} [extraOptions.toUpdatedStamp=toUpdatedStamp] - 自定义时间戳函数 (可选)
|
||||
* @param {(date?: Date) => string} [extraOptions.toUpdatedStamp=toUpdatedStamp]
|
||||
* @param {Console} [extraOptions.logger=console]
|
||||
* @returns {Promise<{ changed: boolean, content: string }>}
|
||||
*/
|
||||
@@ -221,38 +228,38 @@ export async function batchUpdateAnchoredBlocks(filePath, optionList, {
|
||||
let raw = await fsp.readFile(filePath, 'utf8');
|
||||
let changedAny = false;
|
||||
|
||||
for (const op of optionList) {
|
||||
for (const opt of optionList) {
|
||||
let res = { src: raw, changed: false };
|
||||
|
||||
if (op.type === 'map') {
|
||||
if (opt.type === 'map') {
|
||||
res = replaceAnchoredMapBlock(raw, {
|
||||
anchorTag: op.anchorTag,
|
||||
mapName: op.mapName,
|
||||
lines: op.lines,
|
||||
linesIndent: op.linesIndent,
|
||||
anchorTag: opt.anchorTag,
|
||||
mapName: opt.mapName,
|
||||
lines: opt.lines,
|
||||
linesIndent: opt.linesIndent,
|
||||
toUpdatedStamp: toStamp,
|
||||
});
|
||||
} else if (op.type === 'list') {
|
||||
} else if (opt.type === 'list') {
|
||||
res = replaceAnchoredListBlock(raw, {
|
||||
anchorTag: op.anchorTag,
|
||||
listName: op.listName,
|
||||
lines: op.lines,
|
||||
linesIndent: op.linesIndent,
|
||||
anchorTag: opt.anchorTag,
|
||||
listName: opt.listName,
|
||||
lines: opt.lines,
|
||||
linesIndent: opt.linesIndent,
|
||||
toUpdatedStamp: toStamp,
|
||||
});
|
||||
} else if (op.type === 'custom' && typeof op.replacer === 'function') {
|
||||
res = replaceInAnchoredBlock(raw, op.anchorTag, (block) => op.replacer(block, { toUpdatedStamp: toStamp }));
|
||||
} else if (opt.type === 'custom' && typeof opt.replacer === 'function') {
|
||||
res = replaceInAnchoredBlock(raw, opt.anchorTag, (block) => opt.replacer(block, { toUpdatedStamp: toStamp }));
|
||||
} else {
|
||||
logger.warn(`[${filename}] 未知操作类型或缺少参数:`, op);
|
||||
logger.warn(`[${filename}] Unknown operation type or missing parameters:`, opt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.changed) {
|
||||
changedAny = true;
|
||||
raw = res.src;
|
||||
logger.log(`[${filename}] 已更新 (${op['updatedLabel'] ?? op.anchorTag})`);
|
||||
logger.log(`[${filename}] Updated (${opt.updatedLabel ?? opt.anchorTag})`);
|
||||
} else {
|
||||
// logger.log(`[${filename}] 无需更新 (${op['updatedLabel'] ?? op.anchorTag})`);
|
||||
// logger.log(`[${filename}] No update needed (${op['updatedLabel'] ?? op.anchorTag})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// utils/date.mjs
|
||||
|
||||
/**
|
||||
* @example string
|
||||
* "Aug 23, 2025"
|
||||
* @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' });
|
||||
}
|
||||
|
||||
/**
|
||||
* @example string
|
||||
* "2025/09/20"
|
||||
* @param {string} [dateText='']
|
||||
* @returns {string | null}
|
||||
*/
|
||||
@@ -23,11 +26,17 @@ export function toYYYYMMDD(dateText = '') {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 .properties 文件头部注释时间戳.
|
||||
* 形如: "#Thu Aug 28 12:05:55 CST 2025" (Properties.store 风格, en-US + short tz)
|
||||
* Generate timestamp for .properties file header comment.<br>
|
||||
* Note: The timezone abbreviation depends on runtime environment, may display as GMT+08/PDT etc.<br>
|
||||
* zh-CN:<br>
|
||||
* 生成 .properties 文件头部注释时间戳.<br>
|
||||
* 注: 时区缩写依赖运行环境, 可能显示为 GMT+08/PDT 等.
|
||||
*
|
||||
* @example string
|
||||
* "#ThuAug 28 12:05:55 CST 2025"
|
||||
*
|
||||
* @param {Date} [date=new Date()]
|
||||
* @param {string} [timeZone="Asia/Shanghai"] 可选时区, 如 "Asia/Shanghai"
|
||||
* @param {string} [timeZone="Asia/Shanghai"]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generatePropertiesFileTimestamp(date = new Date(), timeZone) {
|
||||
@@ -43,11 +52,15 @@ export function generatePropertiesFileTimestamp(date = new Date(), timeZone) {
|
||||
timeZoneName: 'short',
|
||||
...(timeZone ? { timeZone } : { timeZone: 'Asia/Shanghai' }),
|
||||
});
|
||||
/**
|
||||
* @example Intl.DateTimeFormatOptions
|
||||
* { weekday: 'Thu', month:'Aug', day:'28', hour:'12', minute:'05', second:'55', timeZoneName:'CST', year:'2025' }
|
||||
* @type {Intl.DateTimeFormatOptions}
|
||||
*/
|
||||
const parts = fmt.formatToParts(date).reduce((acc, p) => {
|
||||
acc[p.type] = p.value;
|
||||
return acc;
|
||||
}, /** @type {Object<string, string>} */ ({}));
|
||||
// parts 示例: { weekday: 'Thu', month:'Aug', day:'28', hour:'12', minute:'05', second:'55', timeZoneName:'CST', year:'2025' }
|
||||
}, {});
|
||||
const stamp = `${parts.weekday} ${parts.month} ${parts.day} ${parts.hour}:${parts.minute}:${parts.second} ${parts.timeZoneName} ${parts.year}`;
|
||||
return `#${stamp}`;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/commits']['response']['data']} CommitsData */
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as https from 'https';
|
||||
import nodeFetch from 'node-fetch';
|
||||
import { buildUrl } from './format.mjs';
|
||||
import { toYYYYMMDD } from './date.mjs';
|
||||
|
||||
dotenv.config({ path: '../.env', quiet: true });
|
||||
|
||||
/**
|
||||
* 获取远程文件真实大小.
|
||||
* Get the actual size of a remote file.<br>
|
||||
* zh-CN: 获取远程文件真实大小.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{timeout?: number}} [options]
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.timeout=30000]
|
||||
* @returns {Promise<number | null>}
|
||||
*/
|
||||
export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
|
||||
@@ -21,10 +25,11 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
|
||||
'accept': '*/*',
|
||||
};
|
||||
|
||||
// 1) 尝试 HEAD
|
||||
// Attempt HEAD.
|
||||
// zh-CN: 尝试 HEAD.
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
const res = await nodeFetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'follow',
|
||||
headers,
|
||||
@@ -37,7 +42,8 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
|
||||
/* Ignored. */
|
||||
}
|
||||
|
||||
// 2) 尝试 Range GET (bytes=0-0), 从 Content-Range 解析总长度
|
||||
// Attempt Range GET (bytes=0-0), parse total length from Content-Range.
|
||||
// zh-CN: 尝试 Range GET (bytes=0-0), 从 Content-Range 解析总长度.
|
||||
|
||||
try {
|
||||
const ac = new AbortController();
|
||||
@@ -56,7 +62,8 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
|
||||
const m = /bytes\s+\d+-\d+\/(\d+)/i.exec(cr);
|
||||
if (m) return Number(m[1]);
|
||||
}
|
||||
// 退化: 仍然尝试 content-length
|
||||
// Fallback, still try content-length.
|
||||
// zh-CN: 退化, 仍然尝试 content-length.
|
||||
const len = res.headers.get('content-length');
|
||||
if (len && /^\d+$/.test(len)) return Number(len);
|
||||
}
|
||||
@@ -70,12 +77,12 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
|
||||
/**
|
||||
* @param {string} owner
|
||||
* @param {string} repo
|
||||
* @return {Promise<string>}
|
||||
* @returns {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`;
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
/** @type {import('node-fetch').HeadersInit} */
|
||||
const headers = {
|
||||
accept: 'application/vnd.github+json',
|
||||
@@ -85,16 +92,63 @@ export async function getLatestCommitDate(owner, repo) {
|
||||
const res = await fetch(url, { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API 请求失败: ${res.status} ${res.statusText}`);
|
||||
throw new Error(`GitHub API request failed: ${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('未获取到最新提交');
|
||||
if (!latest?.commit) throw new Error('Failed to get latest commit');
|
||||
|
||||
// 优先使用 committer 的提交时间,fallback 到 author
|
||||
const iso = latest.commit.committer?.date ?? latest.commit.author?.date;
|
||||
if (!iso) throw new Error('提交对象缺少日期字段');
|
||||
if (!iso) throw new Error('Commit object missing date field');
|
||||
|
||||
return toYYYYMMDD(iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {Object} [options={}]
|
||||
* @param {Object<string, any>} [options.query={}]
|
||||
* @param {import('http').OutgoingHttpHeaders} [options.headers={}]
|
||||
* @param {number} [options.timeout=15000]
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
export function httpFetch(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = {
|
||||
method: 'GET',
|
||||
headers: options.headers ?? {},
|
||||
timeout: options.timeout ?? 15000,
|
||||
};
|
||||
const niceUrl = options.query ? buildUrl(url, options.query) : url;
|
||||
const req = https.request(niceUrl, opts, (res) => {
|
||||
const { statusCode } = res;
|
||||
const chunks = [];
|
||||
res.on('data', (d) => chunks.push(d));
|
||||
res.on('end', () => {
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
return reject(`HTTP ${statusCode}`);
|
||||
}
|
||||
let body = null;
|
||||
try {
|
||||
body = Buffer.concat(chunks).toString('utf8');
|
||||
} catch (_) {
|
||||
/* Ignored. */
|
||||
}
|
||||
if (body == null) {
|
||||
throw new Error('Failed to read response body');
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch {
|
||||
resolve(body);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('Request timed out'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,9 +3,41 @@
|
||||
/**
|
||||
* @param {number | null} bytes
|
||||
* @param {number} [fractionDigits=2]
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function bytes2GiB(bytes, fractionDigits = 2) {
|
||||
if (bytes == null) return null;
|
||||
const gib = bytes / 1024 ** 3;
|
||||
return `${gib.toFixed(fractionDigits)} GiB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a URL with query parameters.<br>
|
||||
* zh-CN: 使用查询参数构造 URL.
|
||||
*
|
||||
* @example
|
||||
* // https://example.com?a=1&b=2
|
||||
* url('https://example.com', { a: 1, b: 2 });
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {Object} query
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildUrl(url, query) {
|
||||
if (!query) return url;
|
||||
const q = Object.entries(query)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
return `${url}?${q}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* "Up | Down ?" -> "Up \| Down \?"
|
||||
*
|
||||
* @param {string} string
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,105 @@ import * as fsp from 'node:fs/promises';
|
||||
import { compareVersionStrings } from './versioning.mjs';
|
||||
import { generatePropertiesFileTimestamp } from './date.mjs';
|
||||
|
||||
/**
|
||||
* Convert JS string to .properties format escaping rules (store format)
|
||||
* - Escape: backslash, whitespace/control chars, delimiters (= :), comment chars (# !)
|
||||
* - All non-ASCII chars are encoded as \uXXXX (aligned with java.util.Properties.store)<br>
|
||||
* zh-CN:<br>
|
||||
* 将 JS 字符串按 .properties 规范转义 (store 格式)
|
||||
* - 转义: backslash, 空白/控制字符, 分隔符 (= :), 注释首字符 (# !)
|
||||
* - 非 ASCII 均编码为 \uXXXX (与 java.util.Properties.store 对齐)
|
||||
*
|
||||
* @example string
|
||||
* escapeProperty('https://www.example.com'); // 'https\://www.example.com'
|
||||
* escapeProperty('a==b'); // 'a\=\=b'
|
||||
* escapeProperty('\n\r\t\f'); // '\n\r\t\f'
|
||||
*
|
||||
* escapeProperty('#comment', true); // \#comment
|
||||
* escapeProperty('#comment', false); // \#comment
|
||||
* escapeProperty('comment#today', true); // comment\#today
|
||||
* escapeProperty('comment#today', false); // comment#today
|
||||
*
|
||||
* escapeProperty(' comment', true); // \ comment
|
||||
* escapeProperty(' comment', false); // \ comment
|
||||
* escapeProperty('comment today', true); // comment\ today
|
||||
* escapeProperty('comment today', false); // comment today
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {boolean} isKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeProperty(str, isKey) {
|
||||
if (!str) return '';
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
const code = ch.codePointAt(0);
|
||||
// Handle non-ASCII or control chars uniformly.
|
||||
// zh-CN: 统一处理非 ASCII 或控制字符.
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
if (ch === '\t') {
|
||||
out += '\\t';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
out += '\\n';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
out += '\\r';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\f') {
|
||||
out += '\\f';
|
||||
continue;
|
||||
}
|
||||
// 其他非 ASCII -> \uXXXX
|
||||
const hex = code.toString(16).padStart(4, '0');
|
||||
out += '\\u' + hex.slice(-4);
|
||||
continue;
|
||||
}
|
||||
switch (ch) {
|
||||
case '\\':
|
||||
out += '\\\\';
|
||||
break;
|
||||
case '=':
|
||||
case ':':
|
||||
// Escape both key and value for compatibility.
|
||||
// zh-CN: 在 key 和 value 中都转义, 保证兼容性.
|
||||
out += '\\' + ch;
|
||||
break;
|
||||
case ' ':
|
||||
// Spaces in key need to be escaped; leading spaces in value need to be escaped.
|
||||
// zh-CN: key 中任意空格需要转义; value 的前导空格需要转义.
|
||||
if (isKey || out === '') out += '\\ ';
|
||||
else out += ' ';
|
||||
break;
|
||||
case '\t':
|
||||
case '\n':
|
||||
case '\r':
|
||||
case '\f':
|
||||
// Redundant protection, control chars have been handled above.
|
||||
// zh-CN: 冗余保护, 已在上方控制字符分支处理.
|
||||
out += ch === '\t' ? '\\t'
|
||||
: ch === '\n' ? '\\n'
|
||||
: ch === '\r' ? '\\r'
|
||||
: '\\f';
|
||||
break;
|
||||
case '#':
|
||||
case '!':
|
||||
// When used as key or as first char of value, need to escape to avoid being parsed as comment.
|
||||
// zh-CN: 作为 key 时或 value 的首字符, 为避免被解析为注释, 需转义.
|
||||
if (isKey || out === '') out += '\\' + ch;
|
||||
else out += ch;
|
||||
break;
|
||||
default:
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
@@ -37,7 +136,8 @@ function unescapeProperty(str) {
|
||||
out += String.fromCharCode(parseInt(hex, 16));
|
||||
i += 4;
|
||||
} else {
|
||||
// 非法 \u 序列, 按字面量保留
|
||||
// Illegal \u sequence, keep as literal.
|
||||
// zh-CN: 非法 \u 序列, 按字面量保留.
|
||||
out += '\\u';
|
||||
}
|
||||
break;
|
||||
@@ -49,109 +149,40 @@ function unescapeProperty(str) {
|
||||
out += next;
|
||||
break;
|
||||
default:
|
||||
// 未知转义, 保留第二个字符
|
||||
// Unknown escape sequence, keep (but without the preceding "\").
|
||||
// zh-CN: 未知转义, 保留 (但不包含前面的 "\").
|
||||
out += next;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JS 字符串按 .properties 规范转义 (store 格式)
|
||||
* - 转义: backslash, 空白/控制字符, 分隔符 (= :), 注释首字符 (# !)
|
||||
* - 非 ASCII 均编码为 \uXXXX (与 java.util.Properties.store 对齐)
|
||||
* @param {string} str
|
||||
* @param {boolean} isKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeProperty(str, isKey) {
|
||||
if (!str) return '';
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
const code = ch.codePointAt(0);
|
||||
// 统一处理非 ASCII 或控制字符
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
if (ch === '\t') {
|
||||
out += '\\t';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
out += '\\n';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
out += '\\r';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\f') {
|
||||
out += '\\f';
|
||||
continue;
|
||||
}
|
||||
// 其他非 ASCII -> \uXXXX
|
||||
const hex = code.toString(16).padStart(4, '0');
|
||||
out += '\\u' + hex.slice(-4);
|
||||
continue;
|
||||
}
|
||||
switch (ch) {
|
||||
case '\\':
|
||||
out += '\\\\';
|
||||
break;
|
||||
case '=':
|
||||
case ':':
|
||||
// 在 key 和 value 中都转义, 保证兼容性
|
||||
out += '\\' + ch;
|
||||
break;
|
||||
case ' ':
|
||||
// key 中任意空格需要转义; value 的前导空格需要转义
|
||||
if (isKey || out === '') out += '\\ ';
|
||||
else out += ' ';
|
||||
break;
|
||||
case '\t':
|
||||
case '\n':
|
||||
case '\r':
|
||||
case '\f':
|
||||
// 已在上方控制字符分支处理, 这里冗余保护
|
||||
out += ch === '\t' ? '\\t'
|
||||
: ch === '\n' ? '\\n'
|
||||
: ch === '\r' ? '\\r'
|
||||
: '\\f';
|
||||
break;
|
||||
case '#':
|
||||
case '!':
|
||||
// 作为 key 时或 value 的首字符, 为避免被解析为注释, 需转义
|
||||
if (isKey || out === '') out += '\\' + ch;
|
||||
else out += ch;
|
||||
break;
|
||||
default:
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {Object<string, string>}
|
||||
*/
|
||||
export function parseProperties(text) {
|
||||
function parseProperties(text) {
|
||||
const props = Object.create(null);
|
||||
if (!text) return props;
|
||||
|
||||
const lines = [];
|
||||
const rawLines = text.split(/\r?\n/);
|
||||
|
||||
// 合并续行 (以反斜杠结尾且反斜杠未被转义)
|
||||
// Merge continuation lines (lines ending with an unescaped backslash).
|
||||
// zh-CN: 合并续行 (以反斜杠结尾且反斜杠未被转义).
|
||||
for (let i = 0; i < rawLines.length; i++) {
|
||||
let line = rawLines[i];
|
||||
if (line == null) continue;
|
||||
|
||||
// 去除行尾 CR (兼容 \r\n 已 split 的情况, 一般无需此步)
|
||||
// Remove trailing CR (for \r\n already split cases, usually not needed).
|
||||
// zh-CN: 去除行尾 CR (兼容 \r\n 已 split 的情况, 一般无需此步).
|
||||
line = line.replace(/\r$/, '');
|
||||
|
||||
// 合并续行
|
||||
// Merge continuation lines.
|
||||
// zh-CN: 合并续行.
|
||||
while (true) {
|
||||
// 统计结尾连续反斜杠数量, 奇数表示续行
|
||||
// Count consecutive backslashes at end, odd number indicates continuation.
|
||||
// zh-CN: 统计结尾连续反斜杠数量, 奇数表示续行.
|
||||
let backslashes = 0;
|
||||
for (let j = line.length - 1; j >= 0 && line[j] === '\\'; j--) backslashes++;
|
||||
const isContinuation = backslashes % 2 === 1;
|
||||
@@ -159,7 +190,8 @@ export function parseProperties(text) {
|
||||
if (!isContinuation) break;
|
||||
const next = rawLines[++i];
|
||||
if (next == null) break;
|
||||
// 去掉一个续行用的反斜杠, 再拼接后续行, 续行处按规范会吞掉换行
|
||||
// Remove one continuation backslash, append next line, newline is discarded at continuation point per spec.
|
||||
// zh-CN: 去掉一个续行用的反斜杠, 再拼接后续行, 续行处按规范会吞掉换行.
|
||||
line = line.slice(0, -1) + next;
|
||||
}
|
||||
lines.push(line);
|
||||
@@ -169,12 +201,14 @@ export function parseProperties(text) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#') || line.startsWith('!')) continue;
|
||||
|
||||
// 键值分隔: 第一个 =/: 或未转义空白
|
||||
// Key-value separator: first =/: or unescaped whitespace.
|
||||
// zh-CN: 键值分隔: 第一个 =/: 或未转义空白.
|
||||
let key = '';
|
||||
let value = '';
|
||||
let sepIdx = -1;
|
||||
|
||||
// 逐字符扫描, 识别未转义的分隔符
|
||||
// Scan character by character to identify unescaped separators.
|
||||
// zh-CN: 逐字符扫描, 识别未转义的分隔符.
|
||||
let escaped = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
@@ -196,13 +230,16 @@ export function parseProperties(text) {
|
||||
} else {
|
||||
key = line.slice(0, sepIdx);
|
||||
value = line.slice(sepIdx + 1);
|
||||
// 如果分隔符是空白, value 应该从第一个非空白处开始
|
||||
// If separator is whitespace, value should start from first non-whitespace.
|
||||
// zh-CN: 如果分隔符是空白, value 应该从第一个非空白处开始.
|
||||
if (/^\s$/.test(line[sepIdx])) {
|
||||
value = value.replace(/^\s+/, '');
|
||||
}
|
||||
}
|
||||
|
||||
key = key.replace(/\s+$/, ''); // 规范里 key 前部空白可作为分隔符, 末尾空白需要去掉
|
||||
// In spec, leading whitespace in key can be separator, trailing whitespace should be removed.
|
||||
// zh-CN: 规范里 key 前部空白可作为分隔符, 末尾空白需要去掉.
|
||||
key = key.replace(/\s+$/, '');
|
||||
const k = unescapeProperty(key);
|
||||
const v = unescapeProperty(value.trim());
|
||||
|
||||
@@ -238,7 +275,7 @@ export function readPropertiesSync(filePath = '../version.properties', { encodin
|
||||
* @param {Object<string,string>} [props={}]
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @return {Promise<void>}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writeProperties(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
|
||||
const lines = [];
|
||||
@@ -259,7 +296,7 @@ export async function writeProperties(filePath = '../version.properties', props
|
||||
* @param {Object<string,string>} [props={}]
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @return {void}
|
||||
* @returns {void}
|
||||
*/
|
||||
export function writePropertiesSync(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
|
||||
const lines = [];
|
||||
@@ -289,7 +326,10 @@ export function getMinSupportedAgpVersion(filePath = '../version.properties', {
|
||||
minSupportedVersion = value;
|
||||
}
|
||||
});
|
||||
return minSupportedVersion ?? '8.0';
|
||||
if (!minSupportedVersion) {
|
||||
throw new Error('Could not determine minSupportedAgpVersion from "version.properties" file');
|
||||
}
|
||||
return minSupportedVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,7 +346,10 @@ export function getMinSupportedGradleVersion(filePath = '../version.properties',
|
||||
minSupportedVersion = value;
|
||||
}
|
||||
});
|
||||
return minSupportedVersion ?? '8.0';
|
||||
if (!minSupportedVersion) {
|
||||
throw new Error('Could not determine minSupportedGradleVersion from "version.properties" file');
|
||||
}
|
||||
return minSupportedVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,29 +366,40 @@ export function getMinSupportedJavaVersionInt(filePath = '../version.properties'
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @returns {{ currentJavaVersionInt: number, minSupportedJavaVersionInt: number, minSuggestedJavaVersionInt: number, maxSupportedJavaVersionInt: number }}
|
||||
* @returns {{ 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;
|
||||
let minSuggestedVer = Infinity;
|
||||
let minSupportedVer = Infinity;
|
||||
let maxSupportedVer = -Infinity;
|
||||
|
||||
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);
|
||||
const currentVer = parseInt(value, 10);
|
||||
if (/java.version.*min.suggested|min.suggested.*java.version/i.test(key)) {
|
||||
minSuggestedVer = Math.min(minSuggestedVer, currentVer);
|
||||
} else if (/java.version.*min.supported|min.supported.*java.version/i.test(key)) {
|
||||
minSupportedVersion = Math.min(minSupportedVersion, versionNumber);
|
||||
minSupportedVer = Math.min(minSupportedVer, currentVer);
|
||||
} else if (/java.version.*max.supported|max.supported.*java.version/i.test(key)) {
|
||||
maxSupportedVersion = Math.max(maxSupportedVersion, versionNumber);
|
||||
maxSupportedVer = Math.max(maxSupportedVer, currentVer);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {number} target
|
||||
* @param {string} variableName
|
||||
* @throws {Error}
|
||||
*/
|
||||
const validate = (target, variableName) => {
|
||||
if (!isFinite(target)) throw new Error(`Could not determine ${variableName} from "version.properties" file`);
|
||||
};
|
||||
|
||||
validate(minSuggestedVer, 'minSuggestedJavaVersionInt');
|
||||
validate(minSupportedVer, 'minSupportedJavaVersionInt');
|
||||
validate(maxSupportedVer, 'maxSupportedJavaVersionInt');
|
||||
|
||||
return {
|
||||
currentJavaVersionInt: currentVersion,
|
||||
minSuggestedJavaVersionInt: minSuggestedVersion,
|
||||
minSupportedJavaVersionInt: minSupportedVersion,
|
||||
maxSupportedJavaVersionInt: maxSupportedVersion,
|
||||
minSuggestedJavaVersionInt: minSuggestedVer,
|
||||
minSupportedJavaVersionInt: minSupportedVer,
|
||||
maxSupportedJavaVersionInt: maxSupportedVer,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,88 @@
|
||||
// utils/puppeteer-helpers.mjs
|
||||
|
||||
/** @typedef {import('puppeteer').Page} Page */
|
||||
/** @typedef {string | RegExp | ((s: string) => boolean)} FindTargetRowsFilter */
|
||||
/** @typedef {string} TableDataStructureItemName */
|
||||
/** @typedef {RegExp | ((s: string) => boolean | string)} TableDataStructureItem */
|
||||
/** @typedef {string | RegExp | ((s: string) => boolean | string)} TableDataStructureItemForPageEvaluate */
|
||||
/**
|
||||
* @typedef {object} FindTargetRowsOptions
|
||||
* @property {string} [tableSelector='table']
|
||||
* @property {{ [selector: string]: FindTargetRowsFilter | FindTargetRowsFilter[] }}[tableFilter={}]
|
||||
* @property {string} [tableRowSelector='tbody tr']
|
||||
* @property {string} [tableDataSelector='td']
|
||||
* @property {Array<{ [dataItemName: TableDataStructureItemName]: TableDataStructureItem } | TableDataStructureItemName>} [tableDataStructure=[]]
|
||||
*/
|
||||
/**
|
||||
* @typedef {object} FindTargetRowsOptionsForPageEvaluate
|
||||
* @property {string} [tableSelector='table']
|
||||
* @property {{ [selector: string]: FindTargetRowsFilter | FindTargetRowsFilter[] }}[tableFilter={}]
|
||||
* @property {string} [tableRowSelector='tbody tr']
|
||||
* @property {string} [tableDataSelector='td']
|
||||
* @property {Array<{ [dataItemName: TableDataStructureItemName]: TableDataStructureItemForPageEvaluate } | TableDataStructureItemName>} [tableDataStructure=[]]
|
||||
*/
|
||||
/**
|
||||
* @typedef {object} PuppeteerOptions
|
||||
* @property {string} url
|
||||
* @property {number} [pageGoToTimeout=120000]
|
||||
* @property {number} [findTargetRowsTimeout=30000]
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { sleep } from './async.mjs';
|
||||
|
||||
const REGEX_ID_FOR_PAGE_EVALUATE = ':RegExp:';
|
||||
const FUNCTION_ID_FOR_PAGE_EVALUATE = ':Function:';
|
||||
|
||||
/**
|
||||
* @param {RegExp} regex
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeRegexTag(regex) {
|
||||
const flags = regex.flags.split('').sort().join('');
|
||||
return `${REGEX_ID_FOR_PAGE_EVALUATE}${flags ? `${flags}:` : ''}${regex.source}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FindTargetRowsOptions} options
|
||||
* @returns {FindTargetRowsOptionsForPageEvaluate}
|
||||
*/
|
||||
function toEncodedRegexTagOptions(options) {
|
||||
/**
|
||||
* @param {Object} obj
|
||||
* @returns {Object}
|
||||
*/
|
||||
function traverse(obj) {
|
||||
if (!obj) return obj;
|
||||
if (typeof obj !== 'object' && typeof obj !== 'function') return obj;
|
||||
|
||||
for (const key in obj) {
|
||||
const value = obj[key];
|
||||
|
||||
if (value instanceof RegExp) {
|
||||
obj[key] = encodeRegexTag(value);
|
||||
} else if (typeof value === 'function') {
|
||||
obj[key] = `${FUNCTION_ID_FOR_PAGE_EVALUATE}${value.toString()}`;
|
||||
} else if (Array.isArray(value)) {
|
||||
obj[key] = value.map(item => {
|
||||
if (item instanceof RegExp) {
|
||||
return encodeRegexTag(item);
|
||||
}
|
||||
if (typeof item === 'function') {
|
||||
return `${FUNCTION_ID_FOR_PAGE_EVALUATE}${item.toString()}`;
|
||||
}
|
||||
return traverse(item);
|
||||
});
|
||||
} else if (typeof value === 'object') {
|
||||
traverse(value);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
return structuredClone(traverse(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Page} page
|
||||
* @returns {Promise<void>}
|
||||
@@ -26,21 +104,16 @@ export async function autoScroll(page) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) => {
|
||||
/** @type {FindTargetRowsOptionsForPageEvaluate} */
|
||||
const opts = toEncodedRegexTagOptions(options);
|
||||
return await page.evaluate((options, consts) => {
|
||||
const regex = new RegExp(consts.regexId + String.raw`(?:(\w+):)?(.+)`);
|
||||
const targets = Array.from(document.querySelectorAll(options.tableSelector ?? 'table'));
|
||||
const target = targets.find(t => {
|
||||
for (const [ selector, filter ] of Object.entries(options.tableFilter ?? {})) {
|
||||
@@ -48,37 +121,45 @@ async function findTargetRowsWithPage(page, options = {}) {
|
||||
if (Array.isArray(filter)) {
|
||||
if (!filter.some(f => elements.some(e => {
|
||||
if (typeof f === 'string') {
|
||||
if (!f.startsWith(':RegExp:')) {
|
||||
return e.textContent.trim() === f;
|
||||
if (f.startsWith(consts.regexId)) {
|
||||
const [ _, flags, pattern ] = f.match(regex);
|
||||
const re = new RegExp(pattern, flags);
|
||||
return re.test(e.textContent.trim());
|
||||
}
|
||||
const [ _, flags, pattern ] = f.match(/:RegExp:(?:(\w+):)?(.+)/);
|
||||
const re = new RegExp(pattern, flags);
|
||||
return re.test(e.textContent.trim());
|
||||
if (f.startsWith(consts.functionId)) {
|
||||
const src = f.replace(consts.functionId, '');
|
||||
const fn = new Function(`return ${src}`)();
|
||||
return Boolean(fn(e.textContent.trim()));
|
||||
}
|
||||
return e.textContent.trim() === f;
|
||||
}
|
||||
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());
|
||||
}
|
||||
} else if (!elements.some(e => {
|
||||
if (typeof filter !== 'string') {
|
||||
throw TypeError(`Unknown type of filter (${filter})`);
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
if (filter.startsWith(consts.regexId)) {
|
||||
const [ _, flags, pattern ] = filter.match(regex);
|
||||
const re = new RegExp(pattern, flags);
|
||||
return re.test(e.textContent.trim());
|
||||
}
|
||||
if (filter.startsWith(consts.functionId)) {
|
||||
const src = filter.replace(consts.functionId, '');
|
||||
const fn = new Function(`return ${src}`)();
|
||||
return Boolean(fn(e.textContent.trim()));
|
||||
}
|
||||
return e.textContent.trim() === filter;
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!target) {
|
||||
throw Error('No target table found');
|
||||
throw new Error('No target table found');
|
||||
}
|
||||
|
||||
const tableRows = Array.from(target.querySelectorAll(options.tableRowSelector ?? 'tbody tr'));
|
||||
@@ -88,60 +169,71 @@ async function findTargetRowsWithPage(page, options = {}) {
|
||||
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})`);
|
||||
if (tableDataStructure.length > tds.length) {
|
||||
throw new Error(`Table data size (${tds.length}) is less than table data structure (${tableDataStructure.length})`);
|
||||
}
|
||||
for (let i = 0; i < options.tableDataStructure.length; i++) {
|
||||
const o = options.tableDataStructure[i];
|
||||
for (let i = 0; i < tableDataStructure.length; i++) {
|
||||
const o = tableDataStructure[i];
|
||||
let dataItemName = null;
|
||||
let dataItemFilter = null;
|
||||
let dataItemChecker = 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`);
|
||||
throw new Error(`Table data structure (${tableDataStructure}) must be a string or an object with only one key`);
|
||||
}
|
||||
dataItemName = Object.keys(o)[0];
|
||||
dataItemFilter = o[dataItemName];
|
||||
dataItemChecker = o[dataItemName];
|
||||
} else {
|
||||
throw Error(`Unknown type of table data structure (${options.tableDataStructure})`);
|
||||
throw new Error(`Unknown type of table data structure (${o})`);
|
||||
}
|
||||
const dataItemValueRaw = tds[i].textContent.trim();
|
||||
if (dataItemFilter == null) {
|
||||
if (!dataItemChecker) {
|
||||
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+):)?(.+)/);
|
||||
continue;
|
||||
}
|
||||
if (typeof dataItemChecker === 'string') {
|
||||
if (dataItemChecker.startsWith(consts.regexId)) {
|
||||
const [ _, flags, pattern ] = dataItemChecker.match(regex);
|
||||
const re = new RegExp(pattern, flags);
|
||||
tableData[dataItemName] = dataItemValueRaw.match(re)?.[0] ?? null;
|
||||
continue;
|
||||
}
|
||||
if (dataItemChecker.startsWith(consts.functionId)) {
|
||||
const src = dataItemChecker.replace(consts.functionId, '');
|
||||
const fn = new Function(`return ${src}`)();
|
||||
const result = fn(dataItemValueRaw);
|
||||
if (typeof result === 'boolean') {
|
||||
tableData[dataItemName] = result ? dataItemValueRaw : null;
|
||||
continue;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
tableData[dataItemName] = result;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Function ${fn.name} must return a boolean or a string`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown type of data item checker (${dataItemChecker})`);
|
||||
}
|
||||
tableDataList.push(tableData);
|
||||
});
|
||||
return tableDataList;
|
||||
}, options);
|
||||
}, opts, {
|
||||
regexId: REGEX_ID_FOR_PAGE_EVALUATE,
|
||||
functionId: FUNCTION_ID_FOR_PAGE_EVALUATE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}>>}
|
||||
* @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) {
|
||||
|
||||
@@ -1,80 +1,165 @@
|
||||
// utils/versioning.mjs
|
||||
|
||||
const SUFFIX_PRIORITY = {
|
||||
// 预发布 (越小越靠前)
|
||||
'canary': 1, 'nightly': 1, 'snapshot': 1, 'dev': 1,
|
||||
'pre-alpha': 2, 'prealpha': 2, 'preview': 2, 'eap': 2, 'milestone': 2,
|
||||
'alpha': 3,
|
||||
'beta': 4,
|
||||
'rc': 5,
|
||||
// 正式/稳定
|
||||
'': 10, 'stable': 10, 'ga': 10, 'final': 10, 'release': 10, 'lts': 10,
|
||||
};
|
||||
const PRIORITY_STABLE = 100;
|
||||
|
||||
const SUFFIX_PRIORITY = Object.fromEntries([
|
||||
|
||||
[ [ 'canary', 'nightly', 'snapshot', 'dev', 'experimental', 'dev-experimental', 'wip', 'prototype' ], 5 ],
|
||||
[ [ 'pre-alpha', 'preview', 'tech-preview', 'eap', 'milestone' ], 10 ],
|
||||
[ [ 'alpha' ], 15 ],
|
||||
[ [ 'beta' ], 20 ],
|
||||
[ [ 'rc', 'ga-candidate' ], 25 ],
|
||||
[ [ '', 'stable', 'ga', 'final', 'release', 'lts', 'rtm', 'sp', 'patch', 'maintenance' ], PRIORITY_STABLE ],
|
||||
|
||||
].map((/** @type {[string[], number]} */ [ suffixes, priority ]) => {
|
||||
return suffixes.map(suffix => [ suffix, priority ]);
|
||||
}).flat());
|
||||
|
||||
/**
|
||||
* 统一后缀别名到规范键.
|
||||
* @param {string} v1
|
||||
* @param {string} v2
|
||||
* @returns {number}
|
||||
*/
|
||||
export function compareVersionStrings(v1, v2) {
|
||||
const [ n1, s1 ] = toVersionParts(v1);
|
||||
const [ n2, s2 ] = toVersionParts(v2);
|
||||
return compareVersionParts(n1, n2) || compareVersionSuffix(s1, s2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} v1
|
||||
* @param {string} v2
|
||||
* @returns {number}
|
||||
*/
|
||||
export function compareVersionStringsDescending(v1, v2) {
|
||||
const [ n1, s1 ] = toVersionParts(v1);
|
||||
const [ n2, s2 ] = toVersionParts(v2);
|
||||
return compareVersionParts(n2, n1) || compareVersionSuffix(s2, s1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isVersionStable(version) {
|
||||
const [ _, [ suffixName ] ] = toVersionParts(version);
|
||||
return getSuffixPriority(suffixName) === PRIORITY_STABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} version
|
||||
* @param {Object} options
|
||||
* @param {string} [options.min]
|
||||
* @param {string} [options.max]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isVersionInRange(version, { min, max } = {}) {
|
||||
return (min == null || compareVersionStrings(version, min) >= 0)
|
||||
&& (max == null || compareVersionStrings(version, max) <= 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} suffix
|
||||
* @returns {number}
|
||||
*/
|
||||
function getSuffixPriority(suffix) {
|
||||
return SUFFIX_PRIORITY[normalizeSuffixName(suffix)] ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize suffix aliases to standard keys.<br>
|
||||
* zh-CN: 统一后缀别名到规范键.
|
||||
*
|
||||
* @param {string} nameRaw
|
||||
* @return {string}
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeSuffixName(nameRaw) {
|
||||
const n = String(nameRaw || '').toLowerCase();
|
||||
const n = String(nameRaw || '').toLowerCase().replaceAll('_', '-');
|
||||
switch (n) {
|
||||
case 'a':
|
||||
return 'alpha';
|
||||
case 'b':
|
||||
return 'beta';
|
||||
case 'exp':
|
||||
return 'experimental';
|
||||
case 'cr':
|
||||
case 'release-candidate':
|
||||
return 'rc';
|
||||
case 'general-availability':
|
||||
return 'ga';
|
||||
case 'm':
|
||||
return 'milestone';
|
||||
case 'pre':
|
||||
case 'prev':
|
||||
return 'preview';
|
||||
case 'canary':
|
||||
case 'nightly':
|
||||
case 'snapshot':
|
||||
case 'dev':
|
||||
case 'pre-alpha':
|
||||
case 'prealpha':
|
||||
case 'preview':
|
||||
case 'eap':
|
||||
case 'milestone':
|
||||
case 'alpha':
|
||||
case 'beta':
|
||||
case 'rc':
|
||||
case 'stable':
|
||||
case 'ga':
|
||||
case 'final':
|
||||
case 'release':
|
||||
case 'lts':
|
||||
return n;
|
||||
return 'pre-alpha';
|
||||
case 'proto':
|
||||
return 'prototype';
|
||||
default:
|
||||
return n; // 未知后缀维持原样, 优先级将落在默认分支
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @example [ numberPart[], [ suffixName, suffixNumber ] ]
|
||||
* // All results below will be: [ [ 4, 1, 1 ], [ 'alpha', 2 ] ].
|
||||
*
|
||||
* toVersionParts('4.1.1 Alpha2');
|
||||
* toVersionParts('4.1.1 alpha2');
|
||||
* toVersionParts('4.1.1alpha2');
|
||||
* toVersionParts('4.1.1alpha 2');
|
||||
* toVersionParts('4.1.1 alpha 2');
|
||||
* toVersionParts('4.1.1-alpha2');
|
||||
* toVersionParts('4.1.1-alpha-2');
|
||||
* toVersionParts('4.1.1 - alpha 2');
|
||||
* toVersionParts('4.1.1_alpha_2');
|
||||
* toVersionParts('4.1.1a2');
|
||||
*
|
||||
* // All results below will be: [ [ 2024, 3, 2 ], [ 'beta', 1 ] ].
|
||||
*
|
||||
* toVersionParts('2024.3.2 Beta');
|
||||
* toVersionParts('2024.3.2 Beta1');
|
||||
* toVersionParts('2024.3.2 Beta01');
|
||||
* toVersionParts('2024.3.2 Beta.1');
|
||||
* toVersionParts('2024.3.2beta1');
|
||||
* toVersionParts('2024.3.2b1');
|
||||
* toVersionParts('2024.3.2b');
|
||||
*
|
||||
* @param {string} version
|
||||
* @return { [number[], [string, number]]}
|
||||
* @returns { [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);
|
||||
function toVersionParts(version) {
|
||||
const ver = version.trim();
|
||||
const parts = ver.split(/[\s_+-]+/);
|
||||
const numberParts = parts[0].split('.').map((partRaw, idx, arr) => {
|
||||
const part = String(partRaw);
|
||||
if (idx === arr.length - 1) {
|
||||
const matched = part.match(/^\d+([A-Za-z]+)(\d*)$/);
|
||||
if (matched) {
|
||||
const suffix = matched[1];
|
||||
const suffixNum = matched[2];
|
||||
if (suffixNum) {
|
||||
parts.splice(1, 0, suffix, suffixNum);
|
||||
} else {
|
||||
parts.splice(1, 0, suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
const num = parseInt(part, 10);
|
||||
if (Number.isNaN(num)) {
|
||||
throw new Error(`Invalid version part: '${part}' in version: '${version}'`);
|
||||
throw new Error(`Invalid version part: '${part}' in version: '${ver}'`);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
|
||||
// 解析后缀, 支持 rc1 / rc 1 / rc.1 / RC1 等; 默认数字为 1
|
||||
const suffixPattern = /([A-Za-z]+)[\s._-]*(\d*)|([A-Za-z]*)[\s._-]*(\d+)/;
|
||||
const suffixStr = parts[1] || '';
|
||||
const suffixStr = parts.slice(1).join('') || '';
|
||||
const m = suffixStr.match(suffixPattern);
|
||||
if (!m) return [ numberParts, [ '', 0 ] ];
|
||||
|
||||
const rawName = (m[1] ?? m[3] ?? '');
|
||||
const rawNum = (m[2] ?? m[4] ?? '');
|
||||
const rawName = m[1] ?? m[3] ?? '';
|
||||
const rawNum = m[2] ?? m[4] ?? '';
|
||||
const suffixName = normalizeSuffixName(rawName);
|
||||
const suffixNumberParsed = parseInt(rawNum || '1', 10);
|
||||
const suffixNumber = Number.isNaN(suffixNumberParsed) ? 1 : suffixNumberParsed;
|
||||
@@ -84,9 +169,9 @@ export function toVersionParts(version) {
|
||||
/**
|
||||
* @param {number[]} a
|
||||
* @param {number[]} b
|
||||
* @return {number}
|
||||
* @returns {number}
|
||||
*/
|
||||
export function compareVersionParts(a, b) {
|
||||
function compareVersionParts(a, b) {
|
||||
const max = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < max; i++) {
|
||||
const x = a[i] ?? 0;
|
||||
@@ -99,62 +184,14 @@ export function compareVersionParts(a, b) {
|
||||
/**
|
||||
* @param {[string, number]} s1
|
||||
* @param {[string, number]} s2
|
||||
* @return {number}
|
||||
* @returns {number}
|
||||
*/
|
||||
export function compareVersionSuffix(s1, s2) {
|
||||
function compareVersionSuffix(s1, s2) {
|
||||
const [ name1Raw, num1 ] = s1;
|
||||
const [ name2Raw, num2 ] = s2;
|
||||
const name1 = normalizeSuffixName(name1Raw);
|
||||
const name2 = normalizeSuffixName(name2Raw);
|
||||
const p1 = SUFFIX_PRIORITY[name1] ?? Number.MAX_SAFE_INTEGER;
|
||||
const p2 = SUFFIX_PRIORITY[name2] ?? Number.MAX_SAFE_INTEGER;
|
||||
const p1 = getSuffixPriority(name1Raw);
|
||||
const p2 = getSuffixPriority(name2Raw);
|
||||
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
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function isVersionStable(v) {
|
||||
const [ _, [ suffixName ] ] = toVersionParts(v);
|
||||
const normalizedName = normalizeSuffixName(suffixName);
|
||||
return (SUFFIX_PRIORITY[normalizedName] ?? Number.MAX_SAFE_INTEGER) === 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user