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

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

2
.utils/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules
.env

View File

@@ -0,0 +1,48 @@
// fetch-and-parse-android-studio-agp-compatibility-table.mjs
import fetch from 'node-fetch';
import { load } from 'cheerio';
import { fileURLToPath } from 'node:url';
const URL = 'https://developer.android.com/studio/releases#android_gradle_plugin_and_android_studio_compatibility';
/**
* @return {Promise<Array<{ studioVersion: string, agpRange: string }>>}
*/
export async function fetchStudioAgpTable() {
const html = await fetch(URL).then(r => r.text());
const $ = load(html);
const targetTable = $('table').filter((_, el) => {
let text = $(el).find('th').first().text();
return /Android Studio version/i.test(text);
});
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());
}
// 判断是否为直接执行该文件
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch(err => {
console.error(err);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,138 @@
// fetch-and-parse-android-studio-latest-stable-version.mjs
import { load } from 'cheerio';
import { fileURLToPath } from 'node:url';
const URL = 'https://developer.android.com/studio?hl=en';
/**
* @param {string} s
* @return {string}
*/
function norm(s) {
return (s ?? '').replace(/\s+/g, ' ').trim();
}
/**
* @param {string} filename
* @return {string | null}
*/
function buildDownloadUrlFromFilename(filename) {
// 例: android-studio-2025.1.2.13-windows.exe
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}`;
}
/**
* @param {string} url
* @return {Promise<string>}
*/
async function fetchHtml(url) {
const res = await fetch(url, {
headers: {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36',
'accept-language': 'en',
},
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${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[]}
*/
function parseWindowsRowsFromHtml(html) {
const $ = load(html);
const rows = [];
/** @type {import('cheerio').Cheerio<import('domhandler').Element>} */
const tableRows = $('table.download tbody tr');
tableRows.each((_, tr) => {
const tds = $(tr).find('td');
if (tds.length !== 4) return;
const platform = norm($(tds[0]).text());
if (!/windows/i.test(platform)) return;
const btn = $(tds[1]).find('button.devsite-dialog-button').first();
const filename = norm(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);
rows.push({
platform,
filename,
size,
sha256,
url,
kind: filename.endsWith('.exe')
? 'exe'
: filename.endsWith('.zip')
? 'zip'
: 'other',
});
});
return rows
.filter(r => r.kind === 'exe' || r.kind === 'zip')
.sort((a) => a.kind === 'exe' ? -1 : 1);
}
/**
* 导出: 获取最新稳定版 Windows 的下载信息 (exe 与 zip)
* 返回形如:
* [
* { platform, filename, size, sha256, url, kind: 'exe' },
* { platform, filename, size, sha256, url, kind: 'zip' }
* ]
*
* @param {string} [sourceUrl=URL]
* @returns {Promise<Row[]>}
*/
export async function getLatestStableWindows(sourceUrl = URL) {
const html = await fetchHtml(sourceUrl);
const rows = parseWindowsRowsFromHtml(html);
if (!rows.length) {
throw new Error('未在页面中找到 Windows 稳定版下载条目');
}
return rows;
}
// 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}`);
}
}
// 判断是否为直接执行该文件
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch(err => {
console.error(err);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,373 @@
// fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/pulls']['response']['data']} PullsData */
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data']} PullCommitsData */
import fetch from 'node-fetch';
import * as dotenv from 'dotenv';
import { fileURLToPath } from 'node:url';
import { toYYYYMMDD } from './utils/date.mjs';
dotenv.config({ path: '.env', quiet: true });
const REPO = 'AutoJs6';
const OWNER = 'SuperMonster003';
const BASE = `https://api.github.com/repos/${OWNER}/${REPO}`;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || '';
// 调试: 设置为某个作者的登录名 (login) 以打印该作者的匹配细节
const DEBUG_LOGIN = process.env.DEBUG_LOGIN || '';
const DEBUG_VERBOSE = process.env.DEBUG_VERBOSE === '1';
// 可排除的登录名列表 (默认排除仓库维护者); 可用 EXCLUDED_LOGINS 环境变量覆盖, 逗号分隔
const EXCLUDED_LOGINS = (process.env.EXCLUDED_LOGINS || OWNER)
.split(',')
.map(s => s.trim())
.filter(Boolean);
/**
* @return {import('node-fetch').HeadersInit}
*/
function headers() {
return {
accept: 'application/vnd.github+json',
...(GITHUB_TOKEN ? { authorization: `Bearer ${GITHUB_TOKEN}` } : {}),
'user-agent': 'pr-commit-contributions',
};
}
/**
* @typedef {Object} UserProfile
* @property {string} login
* @property {string | null} name
*/
/**
* 简单内存缓存, 避免重复请求.
*
* @type {Map<string, UserProfile>}
*/
const userProfileCache = new Map();
/**
* @param {string} login
* @return {Promise<UserProfile>}
*/
async function getUserProfile(login) {
if (userProfileCache.has(login)) return userProfileCache.get(login);
const url = `https://api.github.com/users/${login}`;
const res = await fetch(url, { headers: headers() });
if (!res.ok) {
userProfileCache.set(login, { login, name: null });
return { login, name: null };
}
const data = await res.json();
const profile = {
login: data?.['login'] || login,
name: data?.['name'] || null,
};
userProfileCache.set(login, profile);
return profile;
}
/**
* @return {Promise<PullsData>}
*/
async function fetchAllMergedPRs() {
const perPage = 100;
let page = 1;
const merged = [];
while (true) {
const url = `${BASE}/pulls?state=closed&per_page=${perPage}&page=${page}&sort=created&direction=asc`;
const res = await fetch(url, { headers: headers() });
if (!res.ok) {
const text = await res.text();
throw new Error(`获取 PR 列表失败: ${res.status} ${res.statusText} - ${text}`);
}
const prs = /** @type {PullsData} */ await res.json();
if (!Array.isArray(prs) || prs.length === 0) break;
for (const pr of prs) {
if (pr.merged_at) merged.push(pr);
}
if (prs.length < perPage) break;
page += 1;
}
return merged;
}
/**
* @param {string} a
* @param {string} b
* @return {boolean}
*/
function equalsIgnoreCase(a, b) {
return typeof a === 'string'
&& typeof b === 'string'
&& a.toLowerCase() === b.toLowerCase();
}
/**
* @param {PullCommitsData[number]} c
* @return {string | null}
*/
function commitTimeFrom(c) {
return c.commit?.committer?.date
|| c.commit?.author?.date
|| null;
}
/**
* 规则: 排除 EXCLUDED_LOGINS (author.login 或 committer.login 命中即排除); 其余一律计入.
*
* @param {PullCommitsData[number]} c
* @return {boolean}
*/
function isCommitBelongsToLogin(c) {
const authorLogin = c.author?.login || null;
const committerLogin = c.committer?.login || null;
return !(authorLogin && EXCLUDED_LOGINS.some(x => equalsIgnoreCase(x, authorLogin)))
&& !(committerLogin && EXCLUDED_LOGINS.some(x => equalsIgnoreCase(x, committerLogin)));
}
/**
* @typedef {Object} DebugRecord
* @property {string} sha
* @property {boolean} belongs
* @property {string | null} author_login
* @property {string | null} committer_login
* @property {string | null} author_name
* @property {string | null} author_email
* @property {string | null} time
*/
/**
* @param {PullsData[number]} pr
* @return {Promise<{count: number, latestCommitAt: string | null}>} - 该 PR 中属于 PR 作者本人的提交计数与最新提交时间.
*/
async function getPRCommitStatsByAuthor(pr) {
const perPage = 100;
let page = 1;
let total = 0;
let latestCommitAt = null;
const login = pr.user?.login;
if (!login) return { count: 0, latestCommitAt: null };
// 仅在调试该作者时收集详细信息
/** @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}`);
}
const commits = /** @type {PullCommitsData} */ await res.json();
if (!Array.isArray(commits) || commits.length === 0) break;
for (const c of commits) {
const included = isCommitBelongsToLogin(c);
if (included) {
total += 1;
const t = commitTimeFrom(c);
if (t && (!latestCommitAt || new Date(t) > new Date(latestCommitAt))) {
latestCommitAt = t;
}
}
if (DEBUG_LOGIN && equalsIgnoreCase(login, DEBUG_LOGIN)) {
debugRecords.push({
sha: c.sha,
belongs: included,
author_login: c.author?.login || null,
committer_login: c.committer?.login || null,
author_name: c.commit?.author?.name || null,
author_email: c.commit?.author?.email || null,
time: c.commit?.committer?.date || c.commit?.author?.date || null,
});
}
}
if (commits.length < perPage) break;
page += 1;
}
// 打印调试: 仅针对目标作者; 默认仅在该 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}`);
}
}
}
return { count: total, latestCommitAt };
}
/**
* @template Item
* @template MapperResult
* @param {Item[]} items
* @param {number} limit
* @param {(item: Item, idx: number) => Promise<MapperResult>} mapper
* @return {Promise<MapperResult[]>}
*/
async function mapWithLimit(items, limit, mapper) {
const results = new Array(items.length);
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const idx = i++;
if (idx >= items.length) break;
results[idx] = await mapper(items[idx], idx);
}
});
await Promise.all(workers);
return results;
}
/**
* 批量获取作者资料 (并发受限), 返回 Map<login, profile>.
*
* @param {string[]} logins
* @param {number} [concurrency=6]
* @return {Promise<Map<UserProfile['login'], UserProfile>>}
*/
async function fetchProfilesForLogins(logins, concurrency = 6) {
const unique = Array.from(new Set(logins)).filter(Boolean);
const profiles = await mapWithLimit(unique, concurrency, async (login) => {
return await getUserProfile(login);
});
const map = new Map();
for (const p of profiles) {
map.set(p.login, p);
}
return map;
}
/**
* @return {Promise<Statistics[]>}
*/
export async function fetchStatistics() {
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 allLogins = prWithStats
.map(({ pr }) => pr.user?.login)
.filter(Boolean);
const profileMap = await fetchProfilesForLogins(allLogins, 6);
// 按 PR 发起者聚合
const byAuthor = new Map();
for (const { pr, count, latestCommitAt } of prWithStats) {
const user = pr.user;
if (!user?.login) continue;
const entry = byAuthor.get(user.login) || {
login: user.login,
name: profileMap.get(user.login)?.name || null,
html_url: `https://github.com/${user.login}`,
totalCommitsInMergedPRs: 0,
latestCommitAt: null,
};
entry.totalCommitsInMergedPRs += count;
if (latestCommitAt && (!entry.latestCommitAt || new Date(latestCommitAt) > new Date(entry.latestCommitAt))) {
entry.latestCommitAt = latestCommitAt;
}
byAuthor.set(user.login, entry);
}
// 按最近提交倒序
const rows = 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();
}
class Statistics {
/**
* @param row {{
* login: string,
* name: string | null,
* html_url: string,
* totalCommitsInMergedPRs: number,
* latestCommitAt: string | null,
* prListLink: string,
* }}
*/
constructor(row) {
this.login = row.login;
this.name = row.name;
this.html_url = row.html_url;
this.totalCommitsInMergedPRs = row.totalCommitsInMergedPRs;
this.latestCommitAt = row.latestCommitAt;
this.prListLink = `https://github.com/${OWNER}/${REPO}/pulls?q=`
+ 'is' + '%3A' + 'pr' + '+'
+ 'is' + '%3A' + 'merged' + '+'
+ 'author' + '%3A' + encodeURIComponent(row.login);
}
/**
* @param {string} text
* @param {string} [style='word-break:keep-all;white-space:nowrap']
* @returns {string}
*/
#wrapInSpan(text, style = 'word-break:keep-all;white-space:nowrap') {
return `<span style="${style}">${text}</span>`;
}
get contributorMarkdown() {
let markdownName = `[${this.login.replace(/-/g, '&#x2011;')}](${this.html_url})`;
if (this.name && this.name !== this.login) {
markdownName += ` \`(${this.name})\``;
}
return this.#wrapInSpan(markdownName);
}
get commitsCountMarkdown() {
return this.#wrapInSpan(`[${this.totalCommitsInMergedPRs}](${this.prListLink})`);
}
get latestCommitMarkdown() {
if (!this.latestCommitAt) {
return this.#wrapInSpan('`N/A`');
}
return this.#wrapInSpan(`\`${toYYYYMMDD(this.latestCommitAt)}\``);
}
}
// 判断是否为直接执行该文件
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 => {
console.error(err);
process.exit(1);
});
}

1505
.utils/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

13
.utils/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"type": "module",
"dependencies": {
"cheerio": "^1.1.2",
"node-fetch": "^3.3.2",
"puppeteer": "^24.17.1",
"domhandler": "^5.0.3",
"dotenv": "^17.2.2"
},
"devDependencies": {
"@octokit/types": "^14.1.0"
}
}

17
.utils/run-scrapers.bat Normal file
View File

@@ -0,0 +1,17 @@
@ECHO OFF
:RUN
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 } }"
IF ERRORLEVEL 1 GOTO EXIT
ECHO.
GOTO RUN
:EXIT
ECHO.
EXIT /B

175
.utils/run-scrapers.mjs Normal file
View File

@@ -0,0 +1,175 @@
// run-scrapers.mjs
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 待执行脚本 (顺序可按需调整)
const SCRIPT_LIST = [
'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-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',
];
/**
* 解析 CLI 参数.
*
* @param {string[]} [argv=process.argv.slice(2)]
* @return {{ continueOnError: boolean, dryRun: boolean, nodePath: string, filters: string[] }}
*/
function parseArgs(argv = process.argv.slice(2)) {
const opts = {
continueOnError: false,
dryRun: false,
nodePath: process.execPath, // 使用当前 Node 可执行文件, 避免 PATH 问题
filters: [],
};
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('=');
}
return opts;
}
/**
* @param {number} ms
* @return {string}
*/
function formatDuration(ms) {
const sec = Math.floor(ms / 1000);
const msPart = ms % 1000;
return `${sec}.${String(msPart).padStart(3, '0')}s`;
}
/**
* @param {import("fs").PathLike} filePath
* @return {Promise<boolean>}
*/
async function ensureExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* @param {Object} options
* @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 }>}
*/
async function runOne({ nodePath, scriptPath, cwd }) {
return new Promise((resolve) => {
const start = Date.now();
const child = spawn(nodePath, [ scriptPath ], {
cwd,
stdio: 'inherit', // 直接把子进程的输出打到当前控制台
env: process.env,
});
child.on('close', (code, signal) => {
const end = Date.now();
resolve({
code: code ?? 0,
signal: signal ?? null,
ms: end - start,
});
});
child.on('error', (err) => {
const end = Date.now();
resolve({ code: 1, signal: null, ms: end - start, error: err });
});
});
}
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)));
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');
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 < 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 });
}
const failed = results.filter(r => r.code !== 0);
console.log('============================================================');
if (failed.length === 0) {
console.log(' All tasks completed successfully.');
console.log('============================================================\n');
process.exit(0);
} else {
console.log(` ${failed.length} task(s) failed:`);
failed.forEach(r => console.log(` - ${r.name} (code ${r.code}, ${formatDuration(r.ms)})`));
console.log('============================================================\n');
process.exit(1);
}
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,47 @@
// 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';
const URL = 'https://developer.android.com/build/releases/gradle-plugin#updating-gradle';
(async function main() {
const rows = await findTargetRows({
url: URL,
tableSelector: '.devsite-table-wrapper table',
tableFilter: {
'tr th': [
`:RegExp:i:${/Plugin version/.source}`,
`:RegExp:i:${/Minimum required Gradle version/.source}`,
],
},
tableRowSelector: 'tbody tr',
tableDataSelector: 'td',
tableDataStructure: [
'pluginVersion',
'gradleVersion',
],
});
const minSupportedAgpVersion = getMinSupportedAgpVersion();
const minSupportedGradleVersion = getMinSupportedGradleVersion();
const map = {};
for (const { pluginVersion, gradleVersion } of rows) {
if (compareVersionStrings(pluginVersion, minSupportedAgpVersion) < 0) continue;
if (compareVersionStrings(gradleVersion, minSupportedGradleVersion) < 0) continue;
map[pluginVersion] = gradleVersion;
}
await updateAnchoredListInFile('../settings.gradle.kts', {
anchorTag: 'AGP_GRADLE_COMPATIBILITY_LIST',
listName: 'agpGradleCompatibility',
lines: Object.entries(map)
.sort((a, b) => compareVersionStrings(b[0], a[0]))
.map(([ pluginVersion, gradleVersion ]) => `"${pluginVersion}" to "${gradleVersion}",`),
updatedLabel: 'AGP 与 Gradle 兼容性映射',
});
})().catch(err => {
console.error(err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,32 @@
// 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 { updateAnchoredListInFile } from './utils/anchors.mjs';
const URL = 'https://developer.android.com/reference/tools/gradle-api';
(async function main() {
const res = await fetch(URL);
const $ = cheerio.load(await res.text());
const results = new Set();
$('table').find('a').each((_, el) => {
const a = $(el);
const href = a.attr('href');
if (href.includes('reference/tools/gradle-api')) {
results.add(a.text());
}
});
const minSupportedVersion = getMinSupportedAgpVersion();
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 发行版本数据',
});
})().catch((e) => {
console.error('Failed to fetch AGP releases:', e)
process.exit(1);
})

View File

@@ -0,0 +1,44 @@
// scrape-android-studio-agp_version_maps.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();
const version = {
MIN_IDE: props['MIN_SUPPORTED_ANDROID_STUDIO_IDE_VERSION'],
MIN_AGP: props['MIN_SUPPORTED_ANDROID_STUDIO_AGP_VERSION'],
};
(async function main() {
const agpTable = await fetchStudioAgpTable();
/** @type {{ [targetStudioVersion: string]: string }} */
const agpMap = {};
for (const { studioVersion, agpRange } of agpTable) {
const [ _, targetAgpVersion ] = agpRange.split('-');
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 {
agpMap[targetStudioVersion] = targetAgpVersion;
}
if (compareVersionStrings(targetStudioVersion, version.MIN_IDE) <= 0) break;
if (compareVersionStrings(targetAgpVersion, version.MIN_AGP) <= 0) break;
}
await updateAnchoredMapInFile('../settings.gradle.kts', {
anchorTag: 'ANDROID_STUDIO_AGP_VERSION_MAP',
mapName: 'agpVersionMap',
lines: Object.entries(agpMap).map(([ studioVer, agpVer ]) => `"${studioVer}" to "${agpVer}",`),
updatedLabel: 'AGP 版本映射',
});
})().catch(err => {
console.error(err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,480 @@
// scrape-and-inject-android-studio-codename_maps.mjs
/** @typedef {import('puppeteer').Page} Page */
/** @typedef {import('puppeteer').Frame} Frame */
/**
* @template {Node} T
* @typedef {import('puppeteer').ElementHandle<T>} ElementHandle
*/
import puppeteer from 'puppeteer';
import { getLatestStableWindows } from './fetch-and-parse-android-studio-latest-stable-version.mjs';
import { batchUpdateAnchoredBlocks } from './utils/anchors.mjs';
import { compareVersionStrings } from './utils/versioning.mjs';
import { toUpdatedStamp, toYYYYMMDD } from './utils/date.mjs';
import { sleep } from './utils/async.mjs';
import { bytes2GiB } from './utils/format.mjs';
import { getRemoteFileSizeBytes } from './utils/fetch.mjs';
const URL = 'https://developer.android.com/studio/archive?hl=en';
/**
* 在所有 frame (含主文档) 中查找 "同意" 按钮.
*
* @param {Page} page
* @param {number} [timeoutMs=30000]
* @returns {Promise<{handle: ElementHandle<HTMLButtonElement>, frame: Frame}>}
*/
async function waitAndFindAgreeButton(page, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
const selector = 'button.button-primary';
while (Date.now() < deadline) {
// 1) 先尝试主文档
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() };
}
// 2) 再查所有子 frame
const frames = page.frames();
for (const f of frames) {
/** @type {ElementHandle<HTMLButtonElement>} */
const btn = await f.$(selector);
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') {
return { handle: btn, frame: f };
}
}
// 3) 触发懒加载: 轻微滚动几次
await page.evaluate(() => window.scrollBy(0, 600));
await sleep(250);
}
throw new Error('未在任何文档中找到 "同意" 按钮 (超时)');
}
/**
* 在所有 frame 中等待某个选择器出现, 并返回该 frame.
*
* @param {Page} page
* @param {string} selector
* @param [timeoutMs=30000]
* @returns {Promise<Frame>}
*/
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;
}
await sleep(250);
}
throw new Error(` 未在任何 frame 中找到选择器: ${selector}`);
}
async function main() {
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
],
});
const page = await browser.newPage();
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)
await page.evaluate(() => {
const anchor = document.querySelector('#downloads')
|| Array.from(document.querySelectorAll('h2, h3'))
.find(h => /download|archive/i.test(h.textContent || ''));
if (anchor) anchor.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await sleep(500);
// 等待并点击 "同意" 按钮
try {
const { handle, frame } = await waitAndFindAgreeButton(page, 30000);
await frame.waitForSelector('button.button-primary', { visible: true, timeout: 15000 }).catch(() => {
});
await handle.click();
} catch (e) {
console.log('未检测到协议或已同意, 继续解析...');
}
// 同意后不要在主文档等待; 改为在包含内容的 frame 里等待 devsite-expandable
// 若首次未出现, 尝试轻微滚动以触发懒加载, 再次检查
/** @type {Frame} */
let contentFrame;
try {
contentFrame = await waitForFrameWithSelector(page, 'devsite-expandable', 20000);
} catch {
// 尝试滚动触发
for (let i = 0; i < 8; i++) {
await page.evaluate(() => window.scrollBy(0, 800));
await sleep(250);
}
// 再次寻找
contentFrame = await waitForFrameWithSelector(page, 'devsite-expandable', 20000);
}
/**
* @typedef {Object} ArchiveItem
* @property {string} title
* @property {string} date
* @property {string | null} version
* @property {{ text: string, href: string }[]} links
* @property {{ [filename: string]: string }} checksums
*/
/**
* @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();
return nodes.map(n => {
/** @type {Node} */
const titleEl = n.querySelector('.expand-control');
const title = pickText(titleEl?.childNodes?.[0]); // 不含日期的主标题
const date = pickText(n.querySelector('.expand-control span')); // 例如 "April 1, 2025"
/** @type {Element[]} */
const linkEls = Array.from(n.querySelectorAll('.downloads a[href]'));
const links = linkEls.map(a => ({
text: pickText(a),
href: a.getAttribute('href') || '',
}));
// 收集 checksums (在 .downloads 文本中)
/** @type {HTMLElement} */
const downloadsElement = n.querySelector('.downloads');
const bodyText = (downloadsElement?.innerText || '').trim();
/** @type {{[filename: string]: string}} */
const checksums = {};
// 行格式: <sha256> <filename>
bodyText.split('\n').forEach(line => {
const m = /^\s*([a-f0-9]{64})\s+(.+?)\s*$/.exec(line);
if (m) {
const [ _, sha256, filename ] = m;
checksums[filename] = sha256;
}
});
// 解析版本号 (2025.1.2 等), 优先从标题中提取
let version = null;
const vm = title.match(/\d{2,}\.\d+(?:\.\d+)?/);
if (vm) version = vm[0];
return { title, date, version, links, checksums };
});
});
// 1) 用 "最新稳定版" 的校验和/文件名, 在归档中定位条目, 补全并更新 common.json
const latestRows = await getLatestStableWindows(); // [{kind, filename, sha256, url, size, ...}]
const latestExe = latestRows.find(x => x.kind === 'exe');
const latestZip = latestRows.find(x => x.kind === 'zip');
if (!latestExe || !latestZip) {
throw new Error('最新稳定版条目缺少 Windows EXE 或 ZIP');
}
/**
* 在归档中查找: 优先用 sha256 命中, 其次用文件名.
*
* @param {ArchiveItem[]} rows
* @param {import('./fetch-and-parse-android-studio-latest-stable-version.mjs').Row} target
* @return {ArchiveItem | null}
*/
const matchArchive = (rows, target) => {
for (const arc of rows) {
const bySha = target.sha256 && arc.checksums[target.filename] === target.sha256;
const byName = arc.links.some(l => l.text === target.filename);
if (bySha || byName) return arc;
}
return null;
};
const matchedArc = matchArchive(archives, latestExe) || matchArchive(archives, latestZip);
if (!matchedArc) {
throw new Error('未能在归档中定位到与最新版本对应的条目 (按 sha256/文件名均未命中)');
}
/**
* 从匹配到的 expandable 中抽取 Windows EXE/ZIP 的链接/文件名.
*
* @param {string} suffix
* @return {{ filename: string, url: string, sizeGiB?: string | null } | null}
*/
const pickWinItem = suffix => {
// suffix: "-windows.exe" | "-windows.zip"
const link = matchedArc.links.find(l => l.text.endsWith(suffix));
if (!link) return null;
return {
filename: link.text,
url: link.href,
};
};
const exeItem = pickWinItem('-windows.exe');
const zipItem = pickWinItem('-windows.zip');
// 查询真实文件大小 (并发获取), 格式化为 GiB
const [ exeBytes, zipBytes ] = await Promise.all([
exeItem ? getRemoteFileSizeBytes(exeItem.url) : Promise.resolve(null),
zipItem ? getRemoteFileSizeBytes(zipItem.url) : Promise.resolve(null),
]);
if (exeItem) exeItem.sizeGiB = bytes2GiB(exeBytes);
if (zipItem) zipItem.sizeGiB = bytes2GiB(zipBytes);
// 准备写回 common.json 所需字段
const latestVersionName = matchedArc.title.trim(); // 例: "Android Studio Narwhal Feature Drop | 2025.1.2"
const latestVersionDate = toYYYYMMDD(matchedArc.date) || ''; // 例: "2025/07/31"
if (!exeItem || !zipItem) {
throw new Error('匹配到的归档条目缺少 Windows EXE 或 ZIP 下载信息');
}
// 读取并更新 common.json (仅更新带有 "android_studio_latest_" 片段的键与版本名日期键)
const fs = await import('node:fs/promises');
const path = await import('node:path');
const commonJsonPath = path.resolve(process.cwd(), '../.readme/common.json');
const commonRaw = await fs.readFile(commonJsonPath, 'utf8');
const commonObj = JSON.parse(commonRaw);
const updatedCommon = {
...commonObj,
android_studio_latest_recommended_version_name: latestVersionName,
var_date_android_studio_latest_recommended_version_name: latestVersionDate,
android_studio_latest_recommended_file_name_of_exe: exeItem.filename,
android_studio_latest_recommended_download_address_of_exe: exeItem.url,
android_studio_latest_recommended_file_size_of_exe: exeItem.sizeGiB ?? commonObj.android_studio_latest_recommended_file_size_of_exe,
android_studio_latest_recommended_file_name_of_zip: zipItem.filename,
android_studio_latest_recommended_download_address_of_zip: zipItem.url,
android_studio_latest_recommended_file_size_of_zip: zipItem.sizeGiB ?? commonObj.android_studio_latest_recommended_file_size_of_zip,
};
if (JSON.stringify(updatedCommon) !== JSON.stringify(commonObj)) {
await fs.writeFile(commonJsonPath, JSON.stringify(updatedCommon, null, 2), 'utf8');
console.log('[common.json] 已更新 (Android Studio 数据)');
console.log(`-- '${commonObj.android_studio_latest_recommended_version_name}'`);
console.log(`-> '${updatedCommon.android_studio_latest_recommended_version_name}'`);
} else {
// console.log('[common.json] 无需更新 (Android Studio 数据)');
}
// 2) 汇总代号 - 版本映射与代号首发日期, 更新 settings.gradle.kts 两个锚点块
// 2.1 从标题中解析 "代号与版本号"
// 标题模式示例: "Android Studio Meerkat Feature Drop | 2024.3.2 RC 1"
/**
* @param {string} t
* @return {string | null}
*/
const codenameFromTitle = t => {
// 捕获 "Android Studio <Codename> [Feature Drop] |"
const m = /Android Studio\s+(.+?)\s*(?:(\s+\d+\s+)?Feature Drop)?\s*\|/i.exec(t);
return m ? m[1].trim() : null;
};
// 用户手动覆盖映射 (可按需填写或从外部读取)
/** @type {Object<string, string>} */
const manualCodenameOverrides = {
/* e.g. 'Meerkat': 'Mkt' */
};
// 生成唯一代码: 按顺序, 出现冲突则对冲突组统一递增长度; 覆盖项优先生效且不可被自动改动
/**
* @param {string[]} names 原始代号 (保留大小写与空格, 顺序与站点一致)
* @param {Object<string, string>} overrides 手动覆盖映射
* @returns {Map<string, string>} name -> code
*/
function buildUniquePrefixes(names, overrides) {
// 代码不包含空格; 按名称去空格后逐字符递增长度
const entries = names.map(n => ({
name: n,
base: n.replace(/\s+/g, ''), // 去空格用于截取
len: Math.max(1, overrides[n] ? overrides[n].replace(/\s+/g, '').length : 1),
code: overrides[n] ? overrides[n].replace(/\s+/g, '') : null,
locked: !!overrides[n],
}));
// 先赋初值
for (const e of entries) {
if (!e.code) e.code = e.base.slice(0, e.len);
}
// 检测并解决冲突
const maxLenByName = new Map(entries.map(e => [ e.name, e.base.length ]));
// 循环上限保护, 防止极端情况下死循环
for (let step = 0; step < 1024; step++) {
// 统计冲突组: code -> indices
/** @type {Map<string, number[]>} */
const bucket = new Map();
entries.forEach((e, idx) => {
const key = e.code;
if (!bucket.has(key)) bucket.set(key, []);
bucket.get(key).push(idx);
});
// 找到有冲突的组 (size >= 2)
const conflicts = Array.from(bucket.entries()).filter(([ , indices ]) => indices.length >= 2);
if (conflicts.length === 0) break; // 已无冲突
// 逐组处理
for (const [ code, indices ] of conflicts) {
// 若组内存在 >=2 个锁定项且 code 相同 -> 直接报错
const lockedIndices = indices.filter(i => entries[i].locked);
if (lockedIndices.length >= 2) {
const letter = code[0]?.toUpperCase() || '?';
const groupNames = indices.map(i => entries[i].name);
throw new Error(`[CodenameMap] 手动覆盖映射发生冲突: "${code}" -> ${groupNames.join(', ')}. 请调整覆盖映射. 冲突首字母组: ${letter}*`);
}
// 让组内所有 "未锁定项" 统一递增长度
for (const i of indices) {
const e = entries[i];
if (e.locked) continue; // 覆盖项不改
const maxLen = maxLenByName.get(e.name);
if (e.len >= maxLen) {
// 已经用到全长仍冲突 -> 无法自动消解
const letter = e.base[0]?.toUpperCase() || '?';
const groupNames = indices.map(ii => entries[ii].name);
throw new Error(`[CodenameMap] 无法自动消解冲突 ("${e.name}" 与同组名称完全相同或为彼此前缀到尽头). 请为该首字母组手动指定覆盖映射.\n- 组首字母: ${letter}\n- 组成员: ${groupNames.join(', ')}`);
}
e.len += 1;
e.code = e.base.slice(0, e.len);
}
}
}
return new Map(entries.map(e => [ e.name, e.code ]));
}
// 提取按页面顺序的代号列表 (去重, 保留第一次出现顺序)
const codenamesOrdered = [];
const seen = new Set();
for (const arc of archives) {
const cname = codenameFromTitle(arc.title);
if (!cname) continue;
if (!seen.has(cname)) {
seen.add(cname);
codenamesOrdered.push(cname);
}
}
// 构建 name->code 映射 (按规则自动消解冲突; 支持手动覆盖)
const nameToCode = buildUniquePrefixes(codenamesOrdered, manualCodenameOverrides);
// 收集每版本 (yyyy.m.patch) 对应的代号集合, 以及每个代号的首发日期
/** @type {Map<string, Set<string>>} */
const versionToLetters = new Map();
/** @type {Map<string, { name:string, born: Date } >} */
const letterBorn = new Map();
for (const arc of archives) {
if (!arc.version) continue; // 版本号 (yyyy.m.patch)
const cname = codenameFromTitle(arc.title);
if (!cname) continue;
const code = nameToCode.get(cname);
if (!code) continue;
// 映射 version -> codes
if (!versionToLetters.has(arc.version)) versionToLetters.set(arc.version, new Set());
versionToLetters.get(arc.version).add(code);
// 记录代号首次出现日期
const d = new Date(arc.date);
const existed = letterBorn.get(code);
if (!existed || d < existed.born) {
letterBorn.set(code, { name: cname, born: d });
}
}
// 生成 codenameVersionMap 文本 (按版本倒序排列, 值用 "A|B" 连接)
const sortedVersions = Array.from(versionToLetters.keys()).sort((a, b) => {
const [ ay, am, ap ] = a.split('.').map(Number);
const [ by, bm, bp ] = b.split('.').map(Number);
return by - ay || bm - am || bp - ap;
});
const versionLettersList = sortedVersions.map(v => [ v, Array.from(versionToLetters.get(v)).sort().join('|') ]);
/* e.g. { "2023.3": {1: "J", 2: "J|K"} }. */
/** @type {Object<string, { [patch: number]: string }>} */
const rawVersionLettersMap = {};
for (let i = 0; i < versionLettersList.length; i++) {
const [ v, letters ] = versionLettersList[i];
const matched = v.match(/(^\d+\.\d+)(?:\.(\d+))?/);
if (!matched) continue;
const [ , major, patch ] = matched;
if (major in rawVersionLettersMap) {
rawVersionLettersMap[major][patch] = letters;
} else {
rawVersionLettersMap[major] = { [patch]: letters };
}
}
/* e.g. { "2024.3": "M", "2024.1.2": "K" }. */
const combinedVersionLettersMap = {};
Object.entries(rawVersionLettersMap).forEach(([ major, patchToLetters ]) => {
const letterValues = Object.values(patchToLetters);
if (new Set(letterValues).size === 1) {
combinedVersionLettersMap[major] = letterValues[0];
} else {
Object.entries(patchToLetters).forEach(([ patch, letters ]) => {
combinedVersionLettersMap[`${major}.${patch}`] = letters;
});
}
});
const versionMapLines = Object.entries(combinedVersionLettersMap)
.sort((a, b) => compareVersionStrings(b[0], a[0]))
.map(([ v, letters ]) => `"${v}" to "${letters}",`);
// 生成 codenameMap 文本 (按 born 日期倒序; 注释: Born on Mon d, yyyy.)
// 这里 key 为自动生成的 code (可能为一到多字符), value 为完整代号
const sortedLetters = Array.from(letterBorn.entries())
.sort((a, b) => b[1].born.getTime() - a[1].born.getTime());
const codenameMapLines = sortedLetters.map(([ code, { name, born } ]) => {
const bornStr = toUpdatedStamp(born);
return `"${code}" to "${name}", /* Born on ${bornStr}. */`;
});
await batchUpdateAnchoredBlocks('../settings.gradle.kts', [ {
type: 'map',
anchorTag: 'ANDROID_STUDIO_CODENAME_VERSION_MAP',
mapName: 'codenameVersionMap',
lines: versionMapLines,
updatedLabel: 'Android Studio 代号版本映射',
}, {
type: 'map',
anchorTag: 'ANDROID_STUDIO_CODENAME_MAP',
mapName: 'codenameMap',
lines: codenameMapLines,
updatedLabel: 'Android Studio 代号名称映射',
} ]);
await browser.close();
}
main().catch(err => {
console.error(err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,98 @@
// scrape-and-inject-embedded-kotlin-list.mjs
/** @typedef {import('puppeteer').Page} Page */
import puppeteer from 'puppeteer';
import { getMinSupportedGradleVersion } from './utils/properties.mjs';
import { updateAnchoredListInFile } from './utils/anchors.mjs';
import { compareVersionStrings } from './utils/versioning.mjs';
import { autoScroll } from './utils/puppeteer-helpers.mjs';
import { sleep } from './utils/async.mjs';
const URL = 'https://docs.gradle.org/current/userguide/compatibility.html#kotlin';
const unofficialKotlinCompatibilityList = {
'8.14': '2.1.10',
'8.13': '2.1.10',
};
/**
* @param {Page} page
* @return {Promise<string[][] | null>}
*/
async function findTargetRows(page) {
return await page.evaluate(() => {
/** @type {HTMLTableElement[]} */
const targets = Array.from(document.querySelectorAll('table.tableblock'));
const target = targets.find(t => {
const tableHeadList = t.querySelectorAll('th');
return Array.from(tableHeadList).some(th => /Embedded Kotlin version|Minimum Gradle version|Kotlin Language version/i.test(th.textContent));
});
if (!target) return null;
return Array.from(target.querySelectorAll('tbody tr'))
.map(tr => {
const tds = tr.querySelectorAll('td');
if (tds.length < 3) return null;
const kotlin = tds[0]?.querySelector('p')?.textContent?.trim();
const gradle = tds[1]?.querySelector('p')?.textContent?.trim();
const ktLanguage = tds[2]?.querySelector('p')?.textContent?.trim();
return kotlin && gradle && ktLanguage ? [ kotlin, gradle, ktLanguage ] : null;
})
.filter(Boolean);
});
}
(async function main() {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
try {
await page.goto(URL, { waitUntil: 'networkidle0', timeout: 120000 });
// 页面为懒加载: 滚动并多次尝试, 直到目标表格出现或超时
let rows = null;
const deadline = Date.now() + 30000; // 30s 总超时
while (Date.now() < deadline) {
rows = await findTargetRows(page);
if (rows && rows.length) break;
await autoScroll(page);
await sleep(300);
}
if (!rows || !rows.length) {
throw new Error('Unable to locate target table rows (lazy-loaded content not found in time)');
}
/**
* @type {{ [gradle: string]: { kotlin: string, isUnofficial: boolean } }}
*/
const map = {};
const minSupportedGradleVersion = getMinSupportedGradleVersion();
for (const [ kotlin, gradle ] of rows) {
if (compareVersionStrings(gradle, minSupportedGradleVersion) < 0) continue;
map[gradle] = { kotlin, isUnofficial: false };
}
Object.entries(unofficialKotlinCompatibilityList).forEach(([ gradle, kotlin ]) => {
if (!(gradle in map)) map[gradle] = { kotlin, isUnofficial: true };
});
await updateAnchoredListInFile('../settings.gradle.kts', {
anchorTag: 'EMBEDDED_KOTLIN_LIST',
listName: 'embeddedKotlin',
lines: Object.entries(map)
.sort((a, b) => compareVersionStrings(b[1]['kotlin'], a[1]['kotlin']))
.map(([ gradle, { kotlin, isUnofficial } ]) => {
return isUnofficial
? `"${gradle}" to "${kotlin}", /* Unofficial. */`
: `"${gradle}" to "${kotlin}",`;
}),
updatedLabel: 'Java 与 Gradle 兼容性映射',
});
} finally {
await browser.close();
}
})().catch(err => {
console.error(err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,62 @@
// scrape-and-inject-java-gradle-compatibility-list.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',
};
(async function main() {
const rows = await findTargetRows({
url: URL,
tableSelector: 'table.tableblock',
tableFilter: {
'caption': `:RegExp:i:${/java compatibility/.source}`,
},
tableRowSelector: 'tbody tr',
tableDataSelector: 'td',
tableDataStructure: [
{ 'java': `:RegExp:${/^\d+$/.source}` },
{ 'toolchain': `:RegExp:${/^N\/A$|\d+\.\d+/.source}` },
{ 'gradle': `:RegExp:${/^N\/A$|\d+\.\d+/.source}` },
],
});
/**
* @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}`);
}
if (javaInt >= minSupportedJavaVersionInt) {
map[javaInt] = gradle;
}
}
await updateAnchoredListInFile('../settings.gradle.kts', {
anchorTag: 'JAVA_GRADLE_COMPATIBILITY_LIST',
listName: 'javaGradleCompatibility',
lines: Object.entries(map)
.sort((a, b) => Number(b[0]) - Number(a[0]))
.map(([ java, gradle ]) => {
if (gradle === 'N/A') {
const unofficialGradleVersion = unofficialGradleCompatibilityList[java];
if (unofficialGradleVersion) {
return `${java} to "${unofficialGradleVersion}", /* Unofficial. */`;
}
}
return `${java} to "${gradle}",`;
}),
updatedLabel: 'Java 与 Gradle 兼容性映射',
});
})().catch(err => {
console.error(err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,224 @@
// scrape-and-inject-ksp-releases.mjs
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/releases']['response']['data']} ReleasesData */
import * as https from 'https';
import { updateAnchoredMapInFile } from './utils/anchors.mjs';
import { toUpdatedStamp } from './utils/date.mjs';
/**
* @param {string} url
* @param {Object} [options={}]
* @param {import("http").OutgoingHttpHeaders} [options.headers={}]
* @param {number} [options.timeout=15000]
* @return {Promise<ReleasesData>}
*/
function httpsGetJson(url, options = {}) {
return new Promise((resolve, reject) => {
const req = https.request(
url,
{
method: 'GET',
headers: {
'User-Agent': 'node',
'Accept': 'application/vnd.github+json',
...(options.headers || {}),
},
timeout: options.timeout || 15000,
},
(res) => {
const { statusCode } = res;
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (statusCode < 200 || statusCode >= 300) {
return reject(
new Error(`HTTP ${statusCode}: ${body.slice(0, 200)}`),
);
}
try {
const json = /** @type {ReleasesData} */ JSON.parse(body);
resolve(json);
} catch (e) {
reject(new Error(`JSON parse error: ${e.message}`));
}
});
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
req.end();
});
}
/**
* @typedef {Object} KspRelease
* @property {string} version
* @property {string} name
* @property {string} publishedAt
*/
/**
* @return {Promise<KspRelease[]>}
*/
async function fetchKspReleases() {
const base = 'https://api.github.com/repos/google/ksp/releases';
const perPage = 100;
let page = 1;
let reached = false;
const out = [];
/** @type {import("http").OutgoingHttpHeaders} */
const headers = {};
if (process.env.GITHUB_TOKEN) {
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
while (!reached) {
const url = `${base}?per_page=${perPage}&page=${page}`;
const releases = await httpsGetJson(url, { 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 (reached) break;
page += 1;
}
return out;
}
/**
* @param {KspRelease[]} releases
* @return {string[]}
*/
function parseReleases(releases) {
if (!Array.isArray(releases)) return [];
// 先根据 KSP 版本去重,保留发布时间最新的一条
/** @type {Map<string, { kotlinVer: string, date: Date }>} */
const latestByKsp = new Map(); // kspVer -> { kotlinVer, date }
for (const r of releases) {
const rawVer = String(r.version || '').trim();
const parts = rawVer.split('-');
if (parts.length < 2) continue; // 跳过无效项
const kotlinVer = parts.pop();
const kspVer = parts.join('-');
const d = new Date(r.publishedAt);
if (Number.isNaN(d.getTime())) continue;
const prev = latestByKsp.get(kspVer);
if (!prev || d > prev.date) {
latestByKsp.set(kspVer, { kotlinVer, date: d });
}
}
/**
* 解析 KSP 版本用于排序.
*
* @param {string} ksp
* @return {{ baseNums: number[], rank: number, qName: string, qNum: number }}
*/
function parseKspVer(ksp) {
const [ base, qualifierRaw = '' ] = ksp.split('-', 2);
const baseNums = base.split('.').map((n) => parseInt(String(n), 10) || 0);
let qName = '';
let qNum = 0;
if (qualifierRaw) {
const m = /^([A-Za-z]+)(\d+)?$/i.exec(qualifierRaw.trim());
if (m) {
qName = m[1].toUpperCase();
qNum = m[2] ? parseInt(m[2], 10) : 0;
} else {
qName = qualifierRaw.toUpperCase();
}
}
// 等级:稳定版 > RC > Beta > 其他
const rankMap = { '': 3, RC: 2, BETA: 1 };
const rank = Object.prototype.hasOwnProperty.call(rankMap, qName) ? rankMap[qName] : 0;
return { baseNums, rank, qName, qNum };
}
/**
* @param {number[]} aNums
* @param {number[]} bNums
* @return {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; // 降序
}
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 };
})
.sort((a, b) => {
// 1) 基础版本号降序
let 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();
});
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));

View File

@@ -0,0 +1,80 @@
// 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';
const URL = 'https://raw.githubusercontent.com/SuperMonster003/Rhino-For-AutoJs6/refs/heads/master/gradle.properties';
/**
* @param {string} latestVersion
* @return {Promise<void>}
*/
async function updateTemplateReadmeRhinoBadge(latestVersion) {
const templateReadmePath = path.resolve(process.cwd(), '../.readme/template_readme.md');
const fileContent = fs.readFileSync(templateReadmePath, 'utf8');
const rhinoBadgeRegex = /(href=.*?https:\/\/github\.com\/mozilla\/rhino.+?img\.shields\.io\/badge\/Rhino-)(.+)(-[a-f\d]{6}\b)/;
const matched = fileContent.match(rhinoBadgeRegex);
const oldVersion = matched[2].replaceAll('--', '-');
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(`-- ${oldVersion}`);
console.log(`-> ${latestVersion}`);
} else {
// console.log('[template_readme.md] 无需更新 (Rhino 徽标版本)');
}
}
/**
* @param {string} latestVersion
* @param {number} linenoOfLatestVersion
* @return {Promise<void>}
*/
async function updateCommonJsonWithRhinoData(latestVersion, linenoOfLatestVersion) {
const commonJsonPath = path.resolve(process.cwd(), '../.readme/common.json');
const commonRaw = fs.readFileSync(commonJsonPath, 'utf8');
const commonObj = JSON.parse(commonRaw);
const addressPrefix = 'http://rhino.autojs6.com/blob/master/gradle.properties';
const addressSuffix = linenoOfLatestVersion > 0 ? `#L${linenoOfLatestVersion}` : '';
const addressJsonValue = `[v${latestVersion}](${addressPrefix}${addressSuffix})`;
const latestCommitValue = await getLatestCommitDate('SuperMonster003', 'Rhino-For-AutoJs6');
const updatedCommon = {
...commonObj,
latest_rhino_engine_name_with_github_lineno_address: addressJsonValue,
var_date_rhino_engine_latest_committed: 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 => {
if (key in updatedCommon && key in commonObj && updatedCommon[key] !== commonObj[key]) {
console.log(`## ${key}`);
console.log(`-- ${commonObj[key]}`);
console.log(`-> ${updatedCommon[key]}`);
}
});
} else {
// console.log('[common.json] 无需更新 (Rhino 数据)');
}
}
async function main() {
const response = await fetch(URL);
const text = await response.text();
const lines = text.split('\n');
const latestVersion = lines.find(line => line.startsWith('version=')).split('=')[1];
const linenoOfLatestVersion = lines.findIndex(line => line.startsWith('version=')) + 1;
await updateTemplateReadmeRhinoBadge(latestVersion);
await updateCommonJsonWithRhinoData(latestVersion, linenoOfLatestVersion);
}
main().catch(err => {
console.error(err);
process.exitCode = 1;
});

View File

@@ -0,0 +1,46 @@
// 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';
function updateCommonJsonFile() {
const commonJsonPath = path.resolve(process.cwd(), '../.readme/common.json');
const commonRaw = fs.readFileSync(commonJsonPath, 'utf8');
const commonObj = JSON.parse(commonRaw);
const updatedCommon = {
...commonObj,
var_date_contribution_table_data_updated: toYYYYMMDD(),
};
if (JSON.stringify(updatedCommon) !== JSON.stringify(commonObj)) {
fs.writeFileSync(commonJsonPath, JSON.stringify(updatedCommon, null, 2), 'utf8');
console.log('[common.json] 已更新 (贡献参与数据统计日期)');
}
}
(async function main() {
const path = '../.readme/template_readme.md';
const stats = await fetchStatistics();
const newMarkdown = stats.map(stat => `| ${stat.contributorMarkdown} | ${stat.commitsCountMarkdown} | ${stat.latestCommitMarkdown} |`).join('\n');
const text = fs.readFileSync(path, { encoding: 'utf-8' });
const newText = text.replace(/((?:table_header_contribution_contributors|table_header_contribution_number_of_commits|table_header_contribution_recent_submissions).+\r?\n)([\s|:\-]+\r?\n)(\|\s*<span style=".+\r?\n)+/i, ($0, $1, $2, $3) => {
const cr = $3.match(/\|\s*<span style=".+(\r?\n)/)[1];
return `${$1}${$2}${newMarkdown}${cr}`;
});
if (text.replace(/\s+/g, '') !== newText.replace(/\s+/g, '')) {
fs.writeFileSync(path, newText, { encoding: 'utf-8' });
console.log('[template_readme.md] 已更新 (贡献参与统计列表)');
updateCommonJsonFile();
} else {
// console.log('[template_readme.md] 无需更新 (贡献参与统计列表)');
}
})().catch(err => {
console.error(err);
process.exitCode = 1;
});

12
.utils/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"module": "ESNext",
"moduleResolution": "Node",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"types": ["node"],
},
"include": ["**/*.js", "**/*.mjs", "**/*.ts"]
}

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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