From c1ee107bc313d3cee3b0f72c4df58d63c9de7702 Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Wed, 24 Sep 2025 16:01:46 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha6=20-=20=E9=87=8D=E6=9E=84=20s?= =?UTF-8?q?crapers=20=E5=B7=A5=E5=85=B7=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=B8=AD=E8=8B=B1=E5=8F=8C=E8=AF=AD=E6=B3=A8=E9=87=8A=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...android-studio-agp-compatibility-table.mjs | 48 +- ...etch-and-parse-android-studio-archives.mjs | 142 +++--- ...e-android-studio-latest-stable-version.mjs | 136 +++--- ...e-autojs6-merged-pr-commits-statistics.mjs | 431 ++++++++++-------- .utils/fetch-and-parse-gradle-releases.mjs | 12 +- .utils/run-scrapers.bat | 1 - .utils/run-scrapers.mjs | 223 +++++---- ...d-inject-agp-gradle-compatibility-list.mjs | 12 +- .utils/scrape-and-inject-agp-releases.mjs | 14 +- ...-inject-android-studio-agp-version-map.mjs | 17 +- ...scrape-and-inject-embedded-kotlin-list.mjs | 98 ---- ...nject-gradle-kotlin-compatibility-list.mjs | 60 +++ ...-inject-java-gradle-compatibility-list.mjs | 22 +- .utils/scrape-and-inject-ksp-releases.mjs | 223 ++++----- ...crape-and-inject-latest-gradle-wrapper.mjs | 31 +- .../scrape-and-inject-rhino-engine-data.mjs | 32 +- ...ape-and-update-foojay-resolver-version.mjs | 99 ++++ ...ate-readme-template-contributors-table.mjs | 18 +- .utils/utils/anchors.mjs | 133 +++--- .utils/utils/date.mjs | 25 +- .utils/utils/fetch.mjs | 80 +++- .utils/utils/format.mjs | 34 +- .utils/utils/properties.mjs | 270 ++++++----- .utils/utils/puppeteer-helpers.mjs | 196 +++++--- .utils/utils/versioning.mjs | 227 +++++---- 25 files changed, 1512 insertions(+), 1072 deletions(-) delete mode 100644 .utils/scrape-and-inject-embedded-kotlin-list.mjs create mode 100644 .utils/scrape-and-inject-gradle-kotlin-compatibility-list.mjs create mode 100644 .utils/scrape-and-update-foojay-resolver-version.mjs diff --git a/.utils/fetch-and-parse-android-studio-agp-compatibility-table.mjs b/.utils/fetch-and-parse-android-studio-agp-compatibility-table.mjs index f199d60a..58f1ee06 100644 --- a/.utils/fetch-and-parse-android-studio-agp-compatibility-table.mjs +++ b/.utils/fetch-and-parse-android-studio-agp-compatibility-table.mjs @@ -1,48 +1,42 @@ // 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>} + * @param {string} s + * @returns {string} + */ +const normalize = (s) => s.trim().replace(/\s+/g, ' '); + +/** + * @returns {Promise>} */ 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); process.exitCode = 1; }); -} \ No newline at end of file +} diff --git a/.utils/fetch-and-parse-android-studio-archives.mjs b/.utils/fetch-and-parse-android-studio-archives.mjs index f2cb6446..032cd580 100644 --- a/.utils/fetch-and-parse-android-studio-archives.mjs +++ b/.utils/fetch-and-parse-android-studio-archives.mjs @@ -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).
+ * zh-CN: 在所有 frame (含主文档) 中查找 "同意" 按钮. * * @param {Page} page * @param {number} [timeoutMs=30000] - * @returns {Promise<{handle: ElementHandle, frame: Frame}>} + * @returns {Promise<{ handle: ElementHandle, 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} */ - 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.
+ * 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} + * @returns {Promise} */ 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 = {}; - // 行格式: 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,10 +218,11 @@ 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); process.exitCode = 1; }); -} \ No newline at end of file +} diff --git a/.utils/fetch-and-parse-android-studio-latest-stable-version.mjs b/.utils/fetch-and-parse-android-studio-latest-stable-version.mjs index 429ce05e..85b945ff 100644 --- a/.utils/fetch-and-parse-android-studio-latest-stable-version.mjs +++ b/.utils/fetch-and-parse-android-studio-latest-stable-version.mjs @@ -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} + * @returns {Promise} */ 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} */ -function parseWindowsRowsFromHtml(html) { - const $ = load(html); +async function parseItemsFromHtml(html) { + const $ = cheerio.load(html); const rows = []; /** @type {import('cheerio').Cheerio} */ 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)
+ * Returns array like: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ]
+ * zh-CN:
+ * 导出: 获取最新稳定版档案的下载信息 (exe, zip, tar)
+ * 返回形如: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ] * * @param {string} [sourceUrl=URL] - * @returns {Promise} + * @returns {Promise} */ -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); diff --git a/.utils/fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs b/.utils/fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs index 5add5cfb..99d54fea 100644 --- a/.utils/fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs +++ b/.utils/fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs @@ -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} + * @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} - */ -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} - */ -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} */ +const userProfileCache = new Map(); + +/** + * @param {string} login + * @returns {Promise} + */ +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} + */ +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.
+ * 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.
+ * 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.
+ * 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.
+ * zh-CN:
+ * 并发受限的异步映射: 以不超过 `limit` 的并发度执行 `mapper`, 并按输入顺序返回结果. + * - 在需要控制请求/任务并发量 (如网络请求/IO 操作) 时使用, 避免压垮服务或触发限流. + * - 保证输出数组与输入 items 的顺序一致, 即使各任务完成时间不同. + * + * @example Promise + * // 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 + * // 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.
+ * zh-CN: 要处理的输入项列表. * @param {number} limit + * Maximum concurrency (>=1).
+ * zh=CN: 最大并发数 (>=1). * @param {(item: Item, idx: number) => Promise} mapper - * @return {Promise} + * Async function to process one item.
+ * zh-CN: 处理单个项的异步函数. + * @returns {Promise} + * Results in the same order as input.
+ * 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. + * Bulk fetch author profiles (with concurrency limit).
+ * zh-CN: 批量获取作者资料 (并发受限). * * @param {string[]} logins * @param {number} [concurrency=6] - * @return {Promise>} + * @returns {Promise>} */ 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} + * @param {number} [concurrency=6] + * @returns {Promise} */ -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} + */ 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 `${text}`; + return style ? `${text}` : `${text}`; } 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,16 +417,20 @@ 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); }); -} \ No newline at end of file +} diff --git a/.utils/fetch-and-parse-gradle-releases.mjs b/.utils/fetch-and-parse-gradle-releases.mjs index 6fdf506b..0f0ef295 100644 --- a/.utils/fetch-and-parse-gradle-releases.mjs +++ b/.utils/fetch-and-parse-gradle-releases.mjs @@ -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} + * @returns {Promise} */ 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,10 +65,11 @@ 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); process.exitCode = 1; }); -} \ No newline at end of file +} diff --git a/.utils/run-scrapers.bat b/.utils/run-scrapers.bat index a08b2b8d..6d75ef7d 100644 --- a/.utils/run-scrapers.bat +++ b/.utils/run-scrapers.bat @@ -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 } }" diff --git a/.utils/run-scrapers.mjs b/.utils/run-scrapers.mjs index 3abdaa47..53b0fad2 100644 --- a/.utils/run-scrapers.mjs +++ b/.utils/run-scrapers.mjs @@ -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.
+ * 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} + * @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); } } @@ -173,4 +228,4 @@ async function main() { main().catch(err => { console.error(err); process.exit(1); -}); \ No newline at end of file +}); diff --git a/.utils/scrape-and-inject-agp-gradle-compatibility-list.mjs b/.utils/scrape-and-inject-agp-gradle-compatibility-list.mjs index 5ae9764b..0783c8ad 100644 --- a/.utils/scrape-and-inject-agp-gradle-compatibility-list.mjs +++ b/.utils/scrape-and-inject-agp-gradle-compatibility-list.mjs @@ -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,9 +39,9 @@ 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); process.exitCode = 1; -}); \ No newline at end of file +}); diff --git a/.utils/scrape-and-inject-agp-releases.mjs b/.utils/scrape-and-inject-agp-releases.mjs index ce15dfe0..ec230fd7 100644 --- a/.utils/scrape-and-inject-agp-releases.mjs +++ b/.utils/scrape-and-inject-agp-releases.mjs @@ -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); -}) \ No newline at end of file +}); diff --git a/.utils/scrape-and-inject-android-studio-agp-version-map.mjs b/.utils/scrape-and-inject-android-studio-agp-version-map.mjs index dbdf0344..8871bdd5 100644 --- a/.utils/scrape-and-inject-android-studio-agp-version-map.mjs +++ b/.utils/scrape-and-inject-android-studio-agp-version-map.mjs @@ -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,9 +33,9 @@ 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); process.exitCode = 1; -}); \ No newline at end of file +}); diff --git a/.utils/scrape-and-inject-embedded-kotlin-list.mjs b/.utils/scrape-and-inject-embedded-kotlin-list.mjs deleted file mode 100644 index f6853fd4..00000000 --- a/.utils/scrape-and-inject-embedded-kotlin-list.mjs +++ /dev/null @@ -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} - */ -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; -}); \ No newline at end of file diff --git a/.utils/scrape-and-inject-gradle-kotlin-compatibility-list.mjs b/.utils/scrape-and-inject-gradle-kotlin-compatibility-list.mjs new file mode 100644 index 00000000..a980a75d --- /dev/null +++ b/.utils/scrape-and-inject-gradle-kotlin-compatibility-list.mjs @@ -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; +}); diff --git a/.utils/scrape-and-inject-java-gradle-compatibility-list.mjs b/.utils/scrape-and-inject-java-gradle-compatibility-list.mjs index c4fb4463..61288dd5 100644 --- a/.utils/scrape-and-inject-java-gradle-compatibility-list.mjs +++ b/.utils/scrape-and-inject-java-gradle-compatibility-list.mjs @@ -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,9 +52,9 @@ const unofficialGradleCompatibilityList = { } return `${java} to "${gradle}",`; }), - updatedLabel: 'Java 与 Gradle 兼容性映射', + updatedLabel: 'Java and Gradle compatibility list', }); })().catch(err => { console.error(err); process.exitCode = 1; -}); \ No newline at end of file +}); diff --git a/.utils/scrape-and-inject-ksp-releases.mjs b/.utils/scrape-and-inject-ksp-releases.mjs index d1b4e206..ad5cfff0 100644 --- a/.utils/scrape-and-inject-ksp-releases.mjs +++ b/.utils/scrape-and-inject-ksp-releases.mjs @@ -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} - */ -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} + * @returns {Promise} */ 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} */ - const latestByKsp = new Map(); // kspVer -> { kotlinVer, date } + // De-duplicate by Kotlin version, keep the latest one by release date. + // zh-CN: 根据 Kotlin 版本去重, 保留发布时间最新的一条. + + /** @type {Map} */ + 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.
+ * 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)); \ No newline at end of file +(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; +}); diff --git a/.utils/scrape-and-inject-latest-gradle-wrapper.mjs b/.utils/scrape-and-inject-latest-gradle-wrapper.mjs index 0a001f15..3794f3c1 100644 --- a/.utils/scrape-and-inject-latest-gradle-wrapper.mjs +++ b/.utils/scrape-and-inject-latest-gradle-wrapper.mjs @@ -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} + * @returns {Promise} */ 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); -}); \ No newline at end of file +}); diff --git a/.utils/scrape-and-inject-rhino-engine-data.mjs b/.utils/scrape-and-inject-rhino-engine-data.mjs index e1583bf2..9a59983e 100644 --- a/.utils/scrape-and-inject-rhino-engine-data.mjs +++ b/.utils/scrape-and-inject-rhino-engine-data.mjs @@ -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} + * @returns {Promise} */ 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} + * @returns {Promise} */ 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; -}); \ No newline at end of file +}); diff --git a/.utils/scrape-and-update-foojay-resolver-version.mjs b/.utils/scrape-and-update-foojay-resolver-version.mjs new file mode 100644 index 00000000..c0ae0a68 --- /dev/null +++ b/.utils/scrape-and-update-foojay-resolver-version.mjs @@ -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.
+ * 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} + */ +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} + */ +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; +}); diff --git a/.utils/scrape-and-update-readme-template-contributors-table.mjs b/.utils/scrape-and-update-readme-template-contributors-table.mjs index 65d6c75f..406f8229 100644 --- a/.utils/scrape-and-update-readme-template-contributors-table.mjs +++ b/.utils/scrape-and-update-readme-template-contributors-table.mjs @@ -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,19 +28,19 @@ 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* + * 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.
+ * zh-CN:
* 替换锚点块中的某个 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).
+ * 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})`); } } diff --git a/.utils/utils/date.mjs b/.utils/utils/date.mjs index 1d8a3d23..0cd67629 100644 --- a/.utils/utils/date.mjs +++ b/.utils/utils/date.mjs @@ -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.
+ * Note: The timezone abbreviation depends on runtime environment, may display as GMT+08/PDT etc.
+ * zh-CN:
+ * 生成 .properties 文件头部注释时间戳.
* 注: 时区缩写依赖运行环境, 可能显示为 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} */ ({})); - // 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}`; } diff --git a/.utils/utils/fetch.mjs b/.utils/utils/fetch.mjs index da2e6850..4a80d2b4 100644 --- a/.utils/utils/fetch.mjs +++ b/.utils/utils/fetch.mjs @@ -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.
+ * zh-CN: 获取远程文件真实大小. * * @param {string} url - * @param {{timeout?: number}} [options] + * @param {Object} [options] + * @param {number} [options.timeout=30000] * @returns {Promise} */ 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} + * @returns {Promise} */ 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} [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(); + }); +} diff --git a/.utils/utils/format.mjs b/.utils/utils/format.mjs index 99adac25..694f8d48 100644 --- a/.utils/utils/format.mjs +++ b/.utils/utils/format.mjs @@ -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`; -} \ No newline at end of file +} + +/** + * Constructs a URL with query parameters.
+ * 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, '\\$&'); +} diff --git a/.utils/utils/properties.mjs b/.utils/utils/properties.mjs index 614b6bcb..bfc6ea62 100644 --- a/.utils/utils/properties.mjs +++ b/.utils/utils/properties.mjs @@ -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)
+ * zh-CN:
+ * 将 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} */ -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} [props={}] * @param {Object} options * @param {BufferEncoding} [options.encoding='utf8'] - * @return {Promise} + * @returns {Promise} */ 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} [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); } }); - return { - currentJavaVersionInt: currentVersion, - minSuggestedJavaVersionInt: minSuggestedVersion, - minSupportedJavaVersionInt: minSupportedVersion, - maxSupportedJavaVersionInt: maxSupportedVersion, + + /** + * @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`); }; -} \ No newline at end of file + + validate(minSuggestedVer, 'minSuggestedJavaVersionInt'); + validate(minSupportedVer, 'minSupportedJavaVersionInt'); + validate(maxSupportedVer, 'maxSupportedJavaVersionInt'); + + return { + minSuggestedJavaVersionInt: minSuggestedVer, + minSupportedJavaVersionInt: minSupportedVer, + maxSupportedJavaVersionInt: maxSupportedVer, + }; +} diff --git a/.utils/utils/puppeteer-helpers.mjs b/.utils/utils/puppeteer-helpers.mjs index b1213368..e98544c3 100644 --- a/.utils/utils/puppeteer-helpers.mjs +++ b/.utils/utils/puppeteer-helpers.mjs @@ -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} @@ -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>} */ 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>} + * @returns {Promise>} */ 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) { @@ -157,4 +249,4 @@ export async function findTargetRows(options) { } finally { await browser.close(); } -} \ No newline at end of file +} diff --git a/.utils/utils/versioning.mjs b/.utils/utils/versioning.mjs index 6e809a44..51c6bbb9 100644 --- a/.utils/utils/versioning.mjs +++ b/.utils/utils/versioning.mjs @@ -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.
+ * 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); -}