6.7.0 - Alpha5 - 修复版本比较时 Canary 后缀的权重错误
This commit is contained in:
220
.utils/fetch-and-parse-android-studio-archives.mjs
Normal file
220
.utils/fetch-and-parse-android-studio-archives.mjs
Normal file
@@ -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<T>} 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<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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {Promise<ArchiveItem[]>}
|
||||||
|
*/
|
||||||
|
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 = {};
|
||||||
|
// 行格式: <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 };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,191 +2,25 @@
|
|||||||
|
|
||||||
/** @typedef {import('puppeteer').Page} Page */
|
/** @typedef {import('puppeteer').Page} Page */
|
||||||
/** @typedef {import('puppeteer').Frame} Frame */
|
/** @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
|
* @template {Node} T
|
||||||
* @typedef {import('puppeteer').ElementHandle<T>} ElementHandle
|
* @typedef {import('puppeteer').ElementHandle<T>} ElementHandle
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import puppeteer from 'puppeteer';
|
|
||||||
import { getLatestStableWindows } from './fetch-and-parse-android-studio-latest-stable-version.mjs';
|
import { getLatestStableWindows } from './fetch-and-parse-android-studio-latest-stable-version.mjs';
|
||||||
import { batchUpdateAnchoredBlocks } from './utils/anchors.mjs';
|
import { batchUpdateAnchoredBlocks } from './utils/anchors.mjs';
|
||||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||||
import { toUpdatedStamp, toYYYYMMDD } from './utils/date.mjs';
|
import { toUpdatedStamp, toYYYYMMDD } from './utils/date.mjs';
|
||||||
import { sleep } from './utils/async.mjs';
|
|
||||||
import { bytes2GiB } from './utils/format.mjs';
|
import { bytes2GiB } from './utils/format.mjs';
|
||||||
import { getRemoteFileSizeBytes } from './utils/fetch.mjs';
|
import { getRemoteFileSizeBytes } from './utils/fetch.mjs';
|
||||||
|
import { getAndroidStudioArchives } from './fetch-and-parse-android-studio-archives.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() {
|
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
|
// 1) 用 "最新稳定版" 的校验和/文件名, 在归档中定位条目, 补全并更新 common.json
|
||||||
|
|
||||||
|
const archives = await getAndroidStudioArchives();
|
||||||
const latestRows = await getLatestStableWindows(); // [{kind, filename, sha256, url, size, ...}]
|
const latestRows = await getLatestStableWindows(); // [{kind, filename, sha256, url, size, ...}]
|
||||||
const latestExe = latestRows.find(x => x.kind === 'exe');
|
const latestExe = latestRows.find(x => x.kind === 'exe');
|
||||||
const latestZip = latestRows.find(x => x.kind === 'zip');
|
const latestZip = latestRows.find(x => x.kind === 'zip');
|
||||||
@@ -198,7 +32,7 @@ async function main() {
|
|||||||
* 在归档中查找: 优先用 sha256 命中, 其次用文件名.
|
* 在归档中查找: 优先用 sha256 命中, 其次用文件名.
|
||||||
*
|
*
|
||||||
* @param {ArchiveItem[]} rows
|
* @param {ArchiveItem[]} rows
|
||||||
* @param {import('./fetch-and-parse-android-studio-latest-stable-version.mjs').Row} target
|
* @param {Row} target
|
||||||
* @return {ArchiveItem | null}
|
* @return {ArchiveItem | null}
|
||||||
*/
|
*/
|
||||||
const matchArchive = (rows, target) => {
|
const matchArchive = (rows, target) => {
|
||||||
@@ -470,8 +304,6 @@ async function main() {
|
|||||||
lines: codenameMapLines,
|
lines: codenameMapLines,
|
||||||
updatedLabel: 'Android Studio 代号名称映射',
|
updatedLabel: 'Android Studio 代号名称映射',
|
||||||
} ]);
|
} ]);
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
main().catch(err => {
|
||||||
|
|||||||
@@ -1,6 +1,57 @@
|
|||||||
// utils/versioning.mjs
|
// 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
|
* @param {string} version
|
||||||
@@ -16,15 +67,18 @@ export function toVersionParts(version) {
|
|||||||
return num;
|
return num;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 解析后缀, 如 Alpha2 / Beta / RC1 等; 默认数字为 1
|
// 解析后缀, 支持 rc1 / rc 1 / rc.1 / RC1 等; 默认数字为 1
|
||||||
const suffixPattern = /([A-Za-z]+)\s*(\d*)|([A-Za-z]*)\s*(\d+)/;
|
const suffixPattern = /([A-Za-z]+)[\s._-]*(\d*)|([A-Za-z]*)[\s._-]*(\d+)/;
|
||||||
const suffixStr = parts[1] || '';
|
const suffixStr = parts[1] || '';
|
||||||
const m = suffixStr.match(suffixPattern);
|
const m = suffixStr.match(suffixPattern);
|
||||||
if (!m) return [ numberParts, [ '', 0 ] ];
|
if (!m) return [ numberParts, [ '', 0 ] ];
|
||||||
|
|
||||||
const suffixName = m[1] || '';
|
const rawName = (m[1] ?? m[3] ?? '');
|
||||||
const suffixNumber = parseInt(m[2] || '1', 10);
|
const rawNum = (m[2] ?? m[4] ?? '');
|
||||||
return [ numberParts, [ suffixName, Number.isNaN(suffixNumber) ? 1 : suffixNumber ] ];
|
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) {
|
export function compareVersionSuffix(s1, s2) {
|
||||||
const [ name1Raw, num1 ] = s1;
|
const [ name1Raw, num1 ] = s1;
|
||||||
const [ name2Raw, num2 ] = s2;
|
const [ name2Raw, num2 ] = s2;
|
||||||
const name1 = name1Raw.toLowerCase();
|
const name1 = normalizeSuffixName(name1Raw);
|
||||||
const name2 = name2Raw.toLowerCase();
|
const name2 = normalizeSuffixName(name2Raw);
|
||||||
const p1 = SUFFIX_PRIORITY[name1] ?? Number.MAX_SAFE_INTEGER;
|
const p1 = SUFFIX_PRIORITY[name1] ?? Number.MAX_SAFE_INTEGER;
|
||||||
const p2 = SUFFIX_PRIORITY[name2] ?? Number.MAX_SAFE_INTEGER;
|
const p2 = SUFFIX_PRIORITY[name2] ?? Number.MAX_SAFE_INTEGER;
|
||||||
if (p1 !== p2) return p1 > p2 ? 1 : -1;
|
if (p1 !== p2) return p1 > p2 ? 1 : -1;
|
||||||
@@ -83,6 +137,16 @@ export function compareVersionStringsDescending(v1, v2) {
|
|||||||
return cmp !== 0 ? cmp : compareVersionSuffix(s2, s1);
|
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 {string} v
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
@@ -91,7 +155,6 @@ export function compareVersionStringsDescending(v1, v2) {
|
|||||||
* @return {boolean}
|
* @return {boolean}
|
||||||
*/
|
*/
|
||||||
export function isVersionInRange(v, { min, max } = {}) {
|
export function isVersionInRange(v, { min, max } = {}) {
|
||||||
return (min !== null && compareVersionStrings(v, min) >= 0)
|
return (min == null || compareVersionStrings(v, min) >= 0)
|
||||||
&& (max !== null && compareVersionStrings(v, max) <= 0);
|
&& (max == null || compareVersionStrings(v, max) <= 0);
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -126,12 +126,12 @@ pluginManagement {
|
|||||||
// @AnchorBegin ANDROID_GRADLE_PLUGIN_RELEASES_LIST
|
// @AnchorBegin ANDROID_GRADLE_PLUGIN_RELEASES_LIST
|
||||||
// @Script /.utils/scrape-and-inject-agp-releases.mjs
|
// @Script /.utils/scrape-and-inject-agp-releases.mjs
|
||||||
// @Reference https://developer.android.com/reference/tools/gradle-api
|
// @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(
|
val agpReleases = listOf(
|
||||||
"9.0.0-alpha04",
|
"9.0.0-alpha05",
|
||||||
"8.13.0",
|
"8.13.0",
|
||||||
"8.12.2",
|
"8.12.3",
|
||||||
"8.11.1",
|
"8.11.2",
|
||||||
"8.10.1",
|
"8.10.1",
|
||||||
"8.9.3",
|
"8.9.3",
|
||||||
"8.8.2",
|
"8.8.2",
|
||||||
@@ -211,6 +211,30 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val utils = object {
|
val utils = object {
|
||||||
|
|
||||||
|
private val SUFFIX_PRIORITY: Map<String, Int> = 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 {
|
fun compareVersionStrings(v1: String, v2: String): Int {
|
||||||
val (ver1Numbers, ver1Suffix) = toVersionParts(v1)
|
val (ver1Numbers, ver1Suffix) = toVersionParts(v1)
|
||||||
val (ver2Numbers, ver2Suffix) = toVersionParts(v2)
|
val (ver2Numbers, ver2Suffix) = toVersionParts(v2)
|
||||||
@@ -229,27 +253,43 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun compareVersionSuffix(suffix1: Pair<String, Int>, suffix2: Pair<String, Int>): Int {
|
fun compareVersionSuffix(suffix1: Pair<String, Int>, suffix2: Pair<String, Int>): Int {
|
||||||
val suffixPriority = mapOf("" to 10, "Alpha" to 1, "Beta" to 2, "RC" to 5)
|
val (name1Raw, num1) = suffix1
|
||||||
val (suffixName1, suffixNumber1) = suffix1
|
val (name2Raw, num2) = suffix2
|
||||||
val (suffixName2, suffixNumber2) = suffix2
|
val name1 = normalizeSuffixName(name1Raw)
|
||||||
val priority1 = suffixPriority[suffixName1] ?: Int.MAX_VALUE
|
val name2 = normalizeSuffixName(name2Raw)
|
||||||
val priority2 = suffixPriority[suffixName2] ?: Int.MAX_VALUE
|
|
||||||
return priority1.compareTo(priority2).takeIf { it != 0 } ?: suffixNumber1.compareTo(suffixNumber2)
|
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<List<Int>, Pair<String, Int>> {
|
fun toVersionParts(version: String): Pair<List<Int>, Pair<String, Int>> {
|
||||||
val parts = version.split(Regex("[+-]"))
|
// 支持: 1.2.3-rc1 / 1.2.3 RC 1 / 1.2.3-Alpha / 1.2.3.m2 / 1.2.3_preview-2 等
|
||||||
val numberParts = parts[0].split('.').map {
|
// 以第一个空白/加号/连字符分隔数字部分与后缀部分
|
||||||
|
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'")
|
it.toIntOrNull() ?: throw IllegalArgumentException("Invalid version part: '$it' in version: '$version'")
|
||||||
}
|
}
|
||||||
|
|
||||||
val suffixPattern = Regex("([A-Za-z]+)(\\d*)|([A-Za-z]*)(\\d+)")
|
val suffixStr = split.getOrNull(1)?.trim().orEmpty()
|
||||||
val suffixMatch = suffixPattern.matchEntire(parts.getOrElse(1) { "" }) ?: return numberParts to ("" to 0)
|
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? {
|
fun parseAndroidStudioBuildToVersion(): String? {
|
||||||
@@ -257,9 +297,11 @@ pluginManagement {
|
|||||||
val build = providers.gradleProperty("android.studio.version")
|
val build = providers.gradleProperty("android.studio.version")
|
||||||
.orElse(providers.systemProperty("android.studio.version"))
|
.orElse(providers.systemProperty("android.studio.version"))
|
||||||
.orNull ?: return null
|
.orNull ?: return null
|
||||||
|
|
||||||
val parts = build.split('.')
|
val parts = build.split('.')
|
||||||
val baseStr = parts.getOrNull(0) ?: return null
|
val baseStr = parts.getOrNull(0) ?: return null
|
||||||
val base = baseStr.toIntOrNull() ?: return null
|
val base = baseStr.toIntOrNull() ?: return null
|
||||||
|
|
||||||
val year = 2000 + base / 10
|
val year = 2000 + base / 10
|
||||||
val minor = base % 10
|
val minor = base % 10
|
||||||
|
|
||||||
@@ -472,8 +514,9 @@ pluginManagement {
|
|||||||
// @AnchorBegin KSP_VERSION_MAP
|
// @AnchorBegin KSP_VERSION_MAP
|
||||||
// @Script /.utils/scrape-and-inject-ksp-releases.mjs
|
// @Script /.utils/scrape-and-inject-ksp-releases.mjs
|
||||||
// @Reference https://github.com/google/ksp/releases
|
// @Reference https://github.com/google/ksp/releases
|
||||||
// @Updated by SuperMonster003 on Sep 4, 2025.
|
// @Updated by SuperMonster003 on Sep 12, 2025.
|
||||||
val kspVersionMap = mapOf(
|
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-RC2" to "2.0.2", /* Sep 4, 2025. */
|
||||||
"2.2.20-RC" to "2.0.2", /* Aug 20, 2025. */
|
"2.2.20-RC" to "2.0.2", /* Aug 20, 2025. */
|
||||||
"2.2.20-Beta2" to "2.0.2", /* Aug 1, 2025. */
|
"2.2.20-Beta2" to "2.0.2", /* Aug 1, 2025. */
|
||||||
|
|||||||
Reference in New Issue
Block a user