From c5d02b399fbba9c047a1366d803ed66ae06bdc2e Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Fri, 12 Sep 2025 13:56:05 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha5=20-=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=AF=94=E8=BE=83=E6=97=B6=20Canary=20?= =?UTF-8?q?=E5=90=8E=E7=BC=80=E7=9A=84=E6=9D=83=E9=87=8D=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...etch-and-parse-android-studio-archives.mjs | 220 ++++++++++++++++++ ...nd-inject-android-studio-codename_maps.mjs | 178 +------------- .utils/utils/versioning.mjs | 87 ++++++- settings.gradle.kts | 79 +++++-- 4 files changed, 361 insertions(+), 203 deletions(-) create mode 100644 .utils/fetch-and-parse-android-studio-archives.mjs diff --git a/.utils/fetch-and-parse-android-studio-archives.mjs b/.utils/fetch-and-parse-android-studio-archives.mjs new file mode 100644 index 00000000..f2cb6446 --- /dev/null +++ b/.utils/fetch-and-parse-android-studio-archives.mjs @@ -0,0 +1,220 @@ +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer'; +import { sleep } from './utils/async.mjs'; +import { compareVersionStrings, isVersionStable } from './utils/versioning.mjs'; +import { readPropertiesSync } from './utils/properties.mjs'; + +/** @typedef {import('puppeteer').Page} Page */ +/** @typedef {import('puppeteer').Frame} Frame */ +/** + * @template {Node} T + * @typedef {import('puppeteer').ElementHandle} ElementHandle + */ +/** + * @typedef {Object} ArchiveItem + * @property {string} title + * @property {string} date + * @property {string | null} version + * @property {{ text: string, href: string }[]} links + * @property {{ [filename: string]: string }} checksums + */ + +const URL = 'https://developer.android.com/studio/archive?hl=en'; + +/** + * 在所有 frame (含主文档) 中查找 "同意" 按钮. + * + * @param {Page} page + * @param {number} [timeoutMs=30000] + * @returns {Promise<{handle: ElementHandle, 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} */ + 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} + */ +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}`); +} + +/** + * @return {Promise} + */ +export async function getAndroidStudioArchives() { + 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); + } + + /** + * @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')) + .replace(/^([A-Z][a-z]{2})[a-z]*( \d+, \d+)$/, '$1$2'); + /** @type {Element[]} */ + const linkEls = Array.from(n.querySelectorAll('.downloads a[href]')); + const links = linkEls.map(a => ({ + 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 = {}; + // 行格式: + 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 }; + }); + }); + + await browser.close(); + + return archives; +} + +async function main() { + const props = readPropertiesSync(); + const minSupportedAndroidStudioVersion = props['MIN_SUPPORTED_ANDROID_STUDIO_IDE_VERSION']; + const getVersionFromTitle = (/** @type {string} */ title) => title.split('|')[1].trim(); + const archives = await getAndroidStudioArchives(); + const results = archives.filter(archive => { + if (!archive.title.includes('|')) return false; + return compareVersionStrings(archive.version ?? '0', minSupportedAndroidStudioVersion) >= 0; + }).map(({ title, date, version, links }) => { + const windowsZipUrl = links.find(link => link.text.match(/-windows(-exe)?\.zip/i))?.href; + if (!windowsZipUrl) { + console.log('Unable to find Windows zip link for:'); + console.log(links.map(link => link.text).join('\n')); + } + const stable = isVersionStable(getVersionFromTitle(title)) || '-'; + return { title, date, version, stable, 'link for Windows (zip)': windowsZipUrl }; + }).sort((a, b) => { + return compareVersionStrings(getVersionFromTitle(b.title), getVersionFromTitle(a.title)); + }); + console.table(results); +} + +// 判断是否为直接执行该文件 +if (fileURLToPath(import.meta.url) === process.argv[1]) { + await main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} \ No newline at end of file diff --git a/.utils/scrape-and-inject-android-studio-codename_maps.mjs b/.utils/scrape-and-inject-android-studio-codename_maps.mjs index 0b09e901..20f527bb 100644 --- a/.utils/scrape-and-inject-android-studio-codename_maps.mjs +++ b/.utils/scrape-and-inject-android-studio-codename_maps.mjs @@ -2,191 +2,25 @@ /** @typedef {import('puppeteer').Page} Page */ /** @typedef {import('puppeteer').Frame} Frame */ +/** @typedef {import('./fetch-and-parse-android-studio-latest-stable-version.mjs').Row} Row */ +/** @typedef {import('./fetch-and-parse-android-studio-archives.mjs').ArchiveItem} ArchiveItem */ /** * @template {Node} T * @typedef {import('puppeteer').ElementHandle} 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, 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} */ - 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} - */ -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}`); -} +import { getAndroidStudioArchives } from './fetch-and-parse-android-studio-archives.mjs'; 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 = {}; - // 行格式: - 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 archives = await getAndroidStudioArchives(); 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'); @@ -198,7 +32,7 @@ async function main() { * 在归档中查找: 优先用 sha256 命中, 其次用文件名. * * @param {ArchiveItem[]} rows - * @param {import('./fetch-and-parse-android-studio-latest-stable-version.mjs').Row} target + * @param {Row} target * @return {ArchiveItem | null} */ const matchArchive = (rows, target) => { @@ -470,8 +304,6 @@ async function main() { lines: codenameMapLines, updatedLabel: 'Android Studio 代号名称映射', } ]); - - await browser.close(); } main().catch(err => { diff --git a/.utils/utils/versioning.mjs b/.utils/utils/versioning.mjs index 5c0d896c..6e809a44 100644 --- a/.utils/utils/versioning.mjs +++ b/.utils/utils/versioning.mjs @@ -1,6 +1,57 @@ // utils/versioning.mjs -const SUFFIX_PRIORITY = { '': 10, 'alpha': 1, 'beta': 2, 'canary': 3, 'rc': 5 }; +const SUFFIX_PRIORITY = { + // 预发布 (越小越靠前) + 'canary': 1, 'nightly': 1, 'snapshot': 1, 'dev': 1, + 'pre-alpha': 2, 'prealpha': 2, 'preview': 2, 'eap': 2, 'milestone': 2, + 'alpha': 3, + 'beta': 4, + 'rc': 5, + // 正式/稳定 + '': 10, 'stable': 10, 'ga': 10, 'final': 10, 'release': 10, 'lts': 10, +}; + +/** + * 统一后缀别名到规范键. + * + * @param {string} nameRaw + * @return {string} + */ +function normalizeSuffixName(nameRaw) { + const n = String(nameRaw || '').toLowerCase(); + switch (n) { + case 'a': + return 'alpha'; + case 'b': + return 'beta'; + case 'cr': + return 'rc'; + case 'm': + return 'milestone'; + case 'pre': + return 'preview'; + case 'canary': + case 'nightly': + case 'snapshot': + case 'dev': + case 'pre-alpha': + case 'prealpha': + case 'preview': + case 'eap': + case 'milestone': + case 'alpha': + case 'beta': + case 'rc': + case 'stable': + case 'ga': + case 'final': + case 'release': + case 'lts': + return n; + default: + return n; // 未知后缀维持原样, 优先级将落在默认分支 + } +} /** * @param {string} version @@ -16,15 +67,18 @@ export function toVersionParts(version) { return num; }); - // 解析后缀, 如 Alpha2 / Beta / RC1 等; 默认数字为 1 - const suffixPattern = /([A-Za-z]+)\s*(\d*)|([A-Za-z]*)\s*(\d+)/; + // 解析后缀, 支持 rc1 / rc 1 / rc.1 / 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 ] ]; + const rawName = (m[1] ?? m[3] ?? ''); + const rawNum = (m[2] ?? m[4] ?? ''); + const suffixName = normalizeSuffixName(rawName); + const suffixNumberParsed = parseInt(rawNum || '1', 10); + const suffixNumber = Number.isNaN(suffixNumberParsed) ? 1 : suffixNumberParsed; + return [ numberParts, [ suffixName, suffixNumber ] ]; } /** @@ -50,8 +104,8 @@ export function compareVersionParts(a, b) { export function compareVersionSuffix(s1, s2) { const [ name1Raw, num1 ] = s1; const [ name2Raw, num2 ] = s2; - const name1 = name1Raw.toLowerCase(); - const name2 = name2Raw.toLowerCase(); + const name1 = normalizeSuffixName(name1Raw); + const name2 = normalizeSuffixName(name2Raw); const p1 = SUFFIX_PRIORITY[name1] ?? Number.MAX_SAFE_INTEGER; const p2 = SUFFIX_PRIORITY[name2] ?? Number.MAX_SAFE_INTEGER; if (p1 !== p2) return p1 > p2 ? 1 : -1; @@ -83,6 +137,16 @@ export function compareVersionStringsDescending(v1, v2) { return cmp !== 0 ? cmp : compareVersionSuffix(s2, s1); } +/** + * @param {string} v + * @return {boolean} + */ +export function isVersionStable(v) { + const [ _, [ suffixName ] ] = toVersionParts(v); + const normalizedName = normalizeSuffixName(suffixName); + return (SUFFIX_PRIORITY[normalizedName] ?? Number.MAX_SAFE_INTEGER) === 10; +} + /** * @param {string} v * @param {Object} options @@ -91,7 +155,6 @@ export function compareVersionStringsDescending(v1, v2) { * @return {boolean} */ export function isVersionInRange(v, { min, max } = {}) { - return (min !== null && compareVersionStrings(v, min) >= 0) - && (max !== null && compareVersionStrings(v, max) <= 0); - -} \ No newline at end of file + return (min == null || compareVersionStrings(v, min) >= 0) + && (max == null || compareVersionStrings(v, max) <= 0); +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1bda8c47..c4fa6caa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -126,12 +126,12 @@ pluginManagement { // @AnchorBegin ANDROID_GRADLE_PLUGIN_RELEASES_LIST // @Script /.utils/scrape-and-inject-agp-releases.mjs // @Reference https://developer.android.com/reference/tools/gradle-api - // @Updated by SuperMonster003 on Sep 5, 2025. + // @Updated by SuperMonster003 on Sep 12, 2025. val agpReleases = listOf( - "9.0.0-alpha04", + "9.0.0-alpha05", "8.13.0", - "8.12.2", - "8.11.1", + "8.12.3", + "8.11.2", "8.10.1", "8.9.3", "8.8.2", @@ -211,6 +211,30 @@ pluginManagement { } val utils = object { + + private val SUFFIX_PRIORITY: Map = mapOf( + // 早期/快照 + "canary" to 1, "nightly" to 1, "snapshot" to 1, "dev" to 1, + "pre-alpha" to 2, "prealpha" to 2, "preview" to 2, "eap" to 2, "milestone" to 2, + "alpha" to 3, + "beta" to 4, + "rc" to 5, + // 稳定/正式 + "" to 10, "stable" to 10, "ga" to 10, "final" to 10, "release" to 10, "lts" to 10 + ) + + private fun normalizeSuffixName(raw: String?): String { + val n = (raw ?: "").trim().lowercase() + return when (n) { + "a" -> "alpha" + "b" -> "beta" + "cr" -> "rc" + "m" -> "milestone" + "pre" -> "preview" + else -> n + } + } + fun compareVersionStrings(v1: String, v2: String): Int { val (ver1Numbers, ver1Suffix) = toVersionParts(v1) val (ver2Numbers, ver2Suffix) = toVersionParts(v2) @@ -229,27 +253,43 @@ pluginManagement { } fun compareVersionSuffix(suffix1: Pair, suffix2: Pair): Int { - val suffixPriority = mapOf("" to 10, "Alpha" to 1, "Beta" to 2, "RC" to 5) - val (suffixName1, suffixNumber1) = suffix1 - val (suffixName2, suffixNumber2) = suffix2 - val priority1 = suffixPriority[suffixName1] ?: Int.MAX_VALUE - val priority2 = suffixPriority[suffixName2] ?: Int.MAX_VALUE - return priority1.compareTo(priority2).takeIf { it != 0 } ?: suffixNumber1.compareTo(suffixNumber2) + val (name1Raw, num1) = suffix1 + val (name2Raw, num2) = suffix2 + val name1 = normalizeSuffixName(name1Raw) + val name2 = normalizeSuffixName(name2Raw) + + val p1 = SUFFIX_PRIORITY[name1] ?: Int.MAX_VALUE + val p2 = SUFFIX_PRIORITY[name2] ?: Int.MAX_VALUE + + val byPriority = p1.compareTo(p2) + if (byPriority != 0) return byPriority + return num1.compareTo(num2) } fun toVersionParts(version: String): Pair, Pair> { - val parts = version.split(Regex("[+-]")) - val numberParts = parts[0].split('.').map { + // 支持: 1.2.3-rc1 / 1.2.3 RC 1 / 1.2.3-Alpha / 1.2.3.m2 / 1.2.3_preview-2 等 + // 以第一个空白/加号/连字符分隔数字部分与后缀部分 + val split = version.split(Regex("[\\s+\\-]"), limit = 2) + val numberStr = split[0] + val numberParts = numberStr.split('.').map { it.toIntOrNull() ?: throw IllegalArgumentException("Invalid version part: '$it' in version: '$version'") } - val suffixPattern = Regex("([A-Za-z]+)(\\d*)|([A-Za-z]*)(\\d+)") - val suffixMatch = suffixPattern.matchEntire(parts.getOrElse(1) { "" }) ?: return numberParts to ("" to 0) + val suffixStr = split.getOrNull(1)?.trim().orEmpty() + if (suffixStr.isEmpty()) return numberParts to ("" to 0) - val suffixName = suffixMatch.groupValues[1] // "Alpha", "Beta", "RC" or empty string - val suffixNumber = suffixMatch.groupValues[2].toIntOrNull() ?: 1 // Default to 1 for suffixes like "Alpha", "Beta", "RC" + // 更宽松的匹配: 名称 + 可选分隔符 + 可选数字; 或 空名称 + 数字 (极少见) + // 分隔符允许: 空格 . _ - + val regex = Regex("([A-Za-z]+)[\\s._-]*(\\d*)|([A-Za-z]*)[\\s._-]*(\\d+)", RegexOption.IGNORE_CASE) + val m = regex.matchEntire(suffixStr) ?: return numberParts to ("" to 0) - return numberParts to (suffixName to suffixNumber) + val rawName = (m.groups[1]?.value ?: m.groups[3]?.value).orEmpty() + val rawNum = (m.groups[2]?.value ?: m.groups[4]?.value).orEmpty() + + val normName = normalizeSuffixName(rawName) + val suffixNum = rawNum.toIntOrNull() ?: if (normName.isNotEmpty()) 1 else 0 + + return numberParts to (normName to suffixNum) } fun parseAndroidStudioBuildToVersion(): String? { @@ -257,9 +297,11 @@ pluginManagement { val build = providers.gradleProperty("android.studio.version") .orElse(providers.systemProperty("android.studio.version")) .orNull ?: return null + val parts = build.split('.') val baseStr = parts.getOrNull(0) ?: return null val base = baseStr.toIntOrNull() ?: return null + val year = 2000 + base / 10 val minor = base % 10 @@ -472,8 +514,9 @@ pluginManagement { // @AnchorBegin KSP_VERSION_MAP // @Script /.utils/scrape-and-inject-ksp-releases.mjs // @Reference https://github.com/google/ksp/releases - // @Updated by SuperMonster003 on Sep 4, 2025. + // @Updated by SuperMonster003 on Sep 12, 2025. val kspVersionMap = mapOf( + "2.2.20" to "2.0.3", /* Sep 12, 2025. */ "2.2.20-RC2" to "2.0.2", /* Sep 4, 2025. */ "2.2.20-RC" to "2.0.2", /* Aug 20, 2025. */ "2.2.20-Beta2" to "2.0.2", /* Aug 1, 2025. */