6.7.0 - Alpha5 - 新增 Gradle 版本 scraper 脚本
This commit is contained in:
75
.utils/fetch-and-parse-gradle-releases.mjs
Normal file
75
.utils/fetch-and-parse-gradle-releases.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
// fetch-and-parse-gradle-releases.mjs
|
||||
|
||||
/**
|
||||
* @typedef {Object} GradleRelease
|
||||
* @property {string} versionName
|
||||
* @property {string} releaseDate
|
||||
* @property {Object} link
|
||||
* @property {string} link.bin
|
||||
* @property {string} link.all
|
||||
* @property {string} link.checksums
|
||||
*/
|
||||
|
||||
import { load } from 'cheerio';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const LINK_PREFIX = 'https://gradle.org';
|
||||
const URL = `${LINK_PREFIX}/releases`;
|
||||
|
||||
/**
|
||||
* @return {Promise<GradleRelease[]>}
|
||||
*/
|
||||
export async function fetchGradleReleases() {
|
||||
const html = await fetch(URL).then(r => r.text());
|
||||
const $ = load(html);
|
||||
|
||||
const contents = $('.resources-contents').filter((_, el) => {
|
||||
return $(el).find('.u-text-with-icon').length > 0;
|
||||
});
|
||||
|
||||
if (contents.length === 0) {
|
||||
throw new Error('Could not find an element with its class named "resources-contents"');
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
contents.find('a[name]').each((_, a) => {
|
||||
const versionName = $(a).attr('name');
|
||||
const releaseDate = $(a).nextUntil('div.indent').find('span').filter((_, span) => {
|
||||
return /[A-Z][a-z]{2}\s+\d{1,2},\s*\d{4}/.test($(span).text());
|
||||
}).first().text();
|
||||
const link = {};
|
||||
$(a).nextUntil('div.indent').next().find('a').each((_, a) => {
|
||||
const href = $(a).attr('href');
|
||||
if (href.includes('version=') && href.includes('format=')) {
|
||||
const format = href.match(/format=(\w+)/)?.[1] ?? null;
|
||||
if (format !== null) {
|
||||
link[format] = href.match(/https?:\/\//) ? href : `${LINK_PREFIX}${href}`;
|
||||
}
|
||||
} else if (/-(bin|all)\.\w+/.test(href)) {
|
||||
const format = href.match(/-(bin|all)\.\w+/)?.[1] ?? null;
|
||||
if (format !== null) {
|
||||
link[format] = href.match(/https?:\/\//) ? href : `${LINK_PREFIX}${href}`;
|
||||
}
|
||||
} else if (href.includes('checksums')) {
|
||||
link.checksums = href.match(/https?:\/\//) ? href : `${LINK_PREFIX}${href}`;
|
||||
}
|
||||
});
|
||||
rows.push({ versionName, releaseDate, link });
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.table((await fetchGradleReleases()).map(({ versionName, releaseDate, link }) => ({
|
||||
versionName, releaseDate, 'link.bin': link.bin, 'link.all': link.all, 'link.checksums': link.checksums,
|
||||
})));
|
||||
}
|
||||
|
||||
// 判断是否为直接执行该文件
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
// 待执行脚本 (顺序可按需调整)
|
||||
const SCRIPT_LIST = [
|
||||
'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',
|
||||
@@ -57,7 +58,7 @@ function formatDuration(ms) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fs").PathLike} filePath
|
||||
* @param {import('fs').PathLike} filePath
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
async function ensureExists(filePath) {
|
||||
|
||||
138
.utils/scrape-and-inject-latest-gradle-wrapper.mjs
Normal file
138
.utils/scrape-and-inject-latest-gradle-wrapper.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
// scrape-and-inject-latest-gradle-wrapper.mjs
|
||||
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @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 { readPropertiesSync, writePropertiesSync } from './utils/properties.mjs';
|
||||
|
||||
const KEY = 'distributionUrl';
|
||||
const URL_PREFIX = 'https://services.gradle.org/distributions';
|
||||
|
||||
/** @type {Config} */
|
||||
const config = {
|
||||
// @Hint by SuperMonster003 on Sep 10, 2025.
|
||||
// ! Limit major version to 8.x.x, to:
|
||||
// ! - Legacy IDE/tooling compatibility
|
||||
// ! - JDK 17 constraint; Gradle 9+ requires JDK 21
|
||||
// ! - Align with AGP/KGP/plugins matrix
|
||||
// ! - Mitigate deprecation removals
|
||||
// ! Set to null/remove to track latest major.
|
||||
// ! zh-CN:
|
||||
// ! 将主版本限制在 8.x.x, 以便:
|
||||
// ! - 兼容旧版 IDE/Tooling API
|
||||
// ! - 满足 JDK 17 运行时约束, 避免 Gradle 9+ 需 JDK 21
|
||||
// ! - 与 AGP/KGP/第三方插件版本矩阵匹配
|
||||
// ! - 降低弃用 API 移除导致的构建中断风险
|
||||
// ! 如需跟随最新主版本, 将 majorVersionLimit 设为 null 或移除此项.
|
||||
majorVersionLimit: '8.x.x',
|
||||
format: 'bin',
|
||||
};
|
||||
|
||||
function isVersionLimited() {
|
||||
return !config.majorVersionLimit || String(config.majorVersionLimit).match(/([?x])(\.\1)*/i);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {GradleRelease[]} releases
|
||||
* @param {string} majorVersionLimit
|
||||
* @return {GradleRelease}
|
||||
*/
|
||||
function getLatestRelease(releases, majorVersionLimit) {
|
||||
const limitedRelease = releases
|
||||
.sort((a, b) => compareVersionStrings(b.versionName, a.versionName))
|
||||
.find((release) => compareVersionStrings(release.versionName, majorVersionLimit) <= 0);
|
||||
if (!limitedRelease) {
|
||||
throw new Error(`No release found for major version limit: "${majorVersionLimit}"`);
|
||||
}
|
||||
return limitedRelease;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} latestGradleVersion
|
||||
* @return {string}
|
||||
*/
|
||||
function getLatestGradleUrl(latestGradleVersion) {
|
||||
const format = config.format ?? 'bin';
|
||||
return `${URL_PREFIX}/gradle-${latestGradleVersion}-${format}.zip`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} data
|
||||
* @param {string} data.latestGradleVersion
|
||||
* @param {string} data.latestGradleUrl
|
||||
* @param {string} data.majorVersionLimit
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function updateGradleWrapperFileContent({ latestGradleVersion, latestGradleUrl, majorVersionLimit }) {
|
||||
const path = '../gradle/wrapper/gradle-wrapper.properties';
|
||||
const messages = [];
|
||||
const props = readPropertiesSync(path);
|
||||
let propUrl = props[KEY];
|
||||
if (!propUrl) {
|
||||
propUrl = latestGradleUrl;
|
||||
messages.push(`Append: ${KEY}=${propUrl}`);
|
||||
}
|
||||
const re = /(gradle-)(.+)(-(?:bin|all)\.zip)/;
|
||||
let propVersion = propUrl.match(re)?.[2];
|
||||
if (!propVersion) {
|
||||
messages.push(`-- ${propUrl}\n-> ${latestGradleUrl}`);
|
||||
propUrl = latestGradleUrl;
|
||||
propVersion = latestGradleVersion;
|
||||
}
|
||||
|
||||
if (compareVersionStrings(propVersion, majorVersionLimit) > 0) {
|
||||
const suffix = ` (降级, 受限于 "${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}")` : ` (升级)`;
|
||||
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 版本)');
|
||||
messages.forEach((message) => console.log(message));
|
||||
} else {
|
||||
// console.log('[gradle-wrapper.properties] 无需更新 (Gradle 版本)');
|
||||
}
|
||||
}
|
||||
|
||||
function parseMajorVersionLimit() {
|
||||
const majorVersionLimit = config.majorVersionLimit ?? 'x.x.x';
|
||||
const limits = String(majorVersionLimit).split('.');
|
||||
for (let i = 0; i < limits.length; i++) {
|
||||
let limit = limits[i];
|
||||
if (limit.match(/([?x])(\.\1)*/i)) {
|
||||
limits[i] = limit = '9'.repeat(9);
|
||||
}
|
||||
if (isNaN(parseInt(limit))) {
|
||||
throw new Error(`Invalid major version limit: ${majorVersionLimit}`);
|
||||
}
|
||||
}
|
||||
return limits.join('.');
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const releases = await fetchGradleReleases();
|
||||
if (!releases || releases.length === 0) {
|
||||
throw new Error('No Gradle releases found');
|
||||
}
|
||||
const majorVersionLimit = parseMajorVersionLimit();
|
||||
const latestRelease = getLatestRelease(releases, majorVersionLimit);
|
||||
const latestGradleVersion = latestRelease.versionName;
|
||||
const latestGradleUrl = getLatestGradleUrl(latestGradleVersion);
|
||||
await updateGradleWrapperFileContent({
|
||||
latestGradleVersion, latestGradleUrl, majorVersionLimit,
|
||||
});
|
||||
})().catch((e) => {
|
||||
console.error('Failed to scrape or inject latest Gradle wrapper:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -20,4 +20,34 @@ export function toYYYYMMDD(dateText = '') {
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}/${m}/${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 .properties 文件头部注释时间戳.
|
||||
* 形如: "#Thu Aug 28 12:05:55 CST 2025" (Properties.store 风格, en-US + short tz)
|
||||
* 注: 时区缩写依赖运行环境, 可能显示为 GMT+08/PDT 等.
|
||||
* @param {Date} [date=new Date()]
|
||||
* @param {string} [timeZone="Asia/Shanghai"] 可选时区, 如 "Asia/Shanghai"
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generatePropertiesFileTimestamp(date = new Date(), timeZone) {
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZoneName: 'short',
|
||||
...(timeZone ? { timeZone } : { timeZone: 'Asia/Shanghai' }),
|
||||
});
|
||||
const parts = fmt.formatToParts(date).reduce((acc, p) => {
|
||||
acc[p.type] = p.value;
|
||||
return acc;
|
||||
}, /** @type {Object<string, string>} */ ({}));
|
||||
// parts 示例: { weekday: 'Thu', month:'Aug', day:'28', hour:'12', minute:'05', second:'55', timeZoneName:'CST', year:'2025' }
|
||||
const stamp = `${parts.weekday} ${parts.month} ${parts.day} ${parts.hour}:${parts.minute}:${parts.second} ${parts.timeZoneName} ${parts.year}`;
|
||||
return `#${stamp}`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import { compareVersionStrings } from './versioning.mjs';
|
||||
import { generatePropertiesFileTimestamp } from './date.mjs';
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
@@ -55,6 +56,80 @@ function unescapeProperty(str) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JS 字符串按 .properties 规范转义 (store 格式)
|
||||
* - 转义: backslash, 空白/控制字符, 分隔符 (= :), 注释首字符 (# !)
|
||||
* - 非 ASCII 均编码为 \uXXXX (与 java.util.Properties.store 对齐)
|
||||
* @param {string} str
|
||||
* @param {boolean} isKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeProperty(str, isKey) {
|
||||
if (!str) return '';
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i];
|
||||
const code = ch.codePointAt(0);
|
||||
// 统一处理非 ASCII 或控制字符
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
if (ch === '\t') {
|
||||
out += '\\t';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
out += '\\n';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
out += '\\r';
|
||||
continue;
|
||||
}
|
||||
if (ch === '\f') {
|
||||
out += '\\f';
|
||||
continue;
|
||||
}
|
||||
// 其他非 ASCII -> \uXXXX
|
||||
const hex = code.toString(16).padStart(4, '0');
|
||||
out += '\\u' + hex.slice(-4);
|
||||
continue;
|
||||
}
|
||||
switch (ch) {
|
||||
case '\\':
|
||||
out += '\\\\';
|
||||
break;
|
||||
case '=':
|
||||
case ':':
|
||||
// 在 key 和 value 中都转义, 保证兼容性
|
||||
out += '\\' + ch;
|
||||
break;
|
||||
case ' ':
|
||||
// key 中任意空格需要转义; value 的前导空格需要转义
|
||||
if (isKey || out === '') out += '\\ ';
|
||||
else out += ' ';
|
||||
break;
|
||||
case '\t':
|
||||
case '\n':
|
||||
case '\r':
|
||||
case '\f':
|
||||
// 已在上方控制字符分支处理, 这里冗余保护
|
||||
out += ch === '\t' ? '\\t'
|
||||
: ch === '\n' ? '\\n'
|
||||
: ch === '\r' ? '\\r'
|
||||
: '\\f';
|
||||
break;
|
||||
case '#':
|
||||
case '!':
|
||||
// 作为 key 时或 value 的首字符, 为避免被解析为注释, 需转义
|
||||
if (isKey || out === '') out += '\\' + ch;
|
||||
else out += ch;
|
||||
break;
|
||||
default:
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {Object<string, string>}
|
||||
@@ -158,6 +233,48 @@ export function readPropertiesSync(filePath = '../version.properties', { encodin
|
||||
return parseProperties(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']]
|
||||
* @param {Object<string,string>} [props={}]
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
export async function writeProperties(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
|
||||
const lines = [];
|
||||
for (const key in props) {
|
||||
const value = props[key];
|
||||
if (value == null) continue;
|
||||
const k = escapeProperty(String(key), true);
|
||||
const v = escapeProperty(String(value), false);
|
||||
lines.push(`${k}=${v}`);
|
||||
}
|
||||
lines.unshift(generatePropertiesFileTimestamp());
|
||||
const text = lines.join('\n') + '\n';
|
||||
return fsp.writeFile(filePath, text, { encoding });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']]
|
||||
* @param {Object<string,string>} [props={}]
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @return {void}
|
||||
*/
|
||||
export function writePropertiesSync(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
|
||||
const lines = [];
|
||||
for (const key in props) {
|
||||
const value = props[key];
|
||||
if (value == null) continue;
|
||||
const k = escapeProperty(String(key), true);
|
||||
const v = escapeProperty(String(value), false);
|
||||
lines.push(`${k}=${v}`);
|
||||
}
|
||||
lines.unshift(generatePropertiesFileTimestamp());
|
||||
const text = lines.join('\n') + '\n';
|
||||
return fs.writeFileSync(filePath, text, { encoding });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {Object} options
|
||||
|
||||
Reference in New Issue
Block a user