229 lines
8.7 KiB
JavaScript
229 lines
8.7 KiB
JavaScript
// fetch-and-parse-android-studio-archives.mjs
|
|
|
|
import puppeteer from 'puppeteer';
|
|
import { compareVersionStrings, isVersionStable } from './utils/versioning.mjs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { readPropertiesSync } from './utils/properties.mjs';
|
|
import { sleep } from './utils/async.mjs';
|
|
|
|
/** @typedef {import('puppeteer').Page} Page */
|
|
/** @typedef {import('puppeteer').Frame} Frame */
|
|
/**
|
|
* @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';
|
|
const SELECTOR_PRIMARY_BUTTON = 'button.button-primary';
|
|
const SELECTOR_DEVSITE_EXPANDABLE = 'devsite-expandable';
|
|
|
|
/**
|
|
* Find "agree" button in frame (including main document).<br>
|
|
* zh-CN: 在所有 frame (含主文档) 中查找 "同意" 按钮.
|
|
*
|
|
* @param {Page} page
|
|
* @param {number} [timeoutMs=30000]
|
|
* @returns {Promise<{ handle: ElementHandle<HTMLButtonElement>, frame: Frame }>}
|
|
*/
|
|
async function waitAndFindAgreeButton(page, timeoutMs = 30000) {
|
|
/**
|
|
* @param {string | null} s
|
|
* @returns {boolean}
|
|
*/
|
|
const containsAgreementText = (s) => s && /\b(?:i\s*agree|agree\s*to\s*the\s*terms|^agree$)/i.test(s.trim());
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
|
|
// Attempt main document.
|
|
// zh-CN: 尝试主文档.
|
|
|
|
const btnList = await page.$$(SELECTOR_PRIMARY_BUTTON);
|
|
for (const btn of btnList) {
|
|
const txt = await page.evaluate(el => el.textContent, btn);
|
|
if (containsAgreementText(txt)) {
|
|
return { handle: btn, frame: page.mainFrame() };
|
|
}
|
|
}
|
|
|
|
// Check all sub-frame.
|
|
// zh-CN: 检查所有子 frame.
|
|
|
|
const frames = page.frames();
|
|
for (const f of frames) {
|
|
/** @type {ElementHandle<HTMLButtonElement>} */
|
|
const btn = await f.$(SELECTOR_PRIMARY_BUTTON);
|
|
if (!btn) continue;
|
|
const txt = await f.evaluate(el => el.textContent, btn);
|
|
if (containsAgreementText(txt)) {
|
|
return { handle: btn, frame: f };
|
|
}
|
|
}
|
|
|
|
// Trigger lazy-loading: scroll slightly several times.
|
|
// zh-CN: 触发懒加载, 轻微滚动几次.
|
|
|
|
await page.evaluate(() => window.scrollBy(0, 600));
|
|
await sleep(300);
|
|
}
|
|
throw new Error('Unable to find "agree" button in any document (timeout)');
|
|
}
|
|
|
|
/**
|
|
* Wait for a selector to appear in any frame and return that frame.<br>
|
|
* zh-CN: 在所有 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()) {
|
|
if (await f.$(selector)) {
|
|
return f;
|
|
}
|
|
}
|
|
await sleep(300);
|
|
}
|
|
throw new Error(`Could not find selector "${selector}" in any frame`);
|
|
}
|
|
|
|
/**
|
|
* @returns {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 });
|
|
|
|
// Scroll to download area, trigger lazy loading (helpful for injecting iframe containing "agree" button).
|
|
// zh-CN: 滚动到下载区域, 触发懒加载 (有助于注入承载 "同意" 按钮的 iframe).
|
|
await page.evaluate(() => {
|
|
const anchor = document.querySelector('#downloads')
|
|
|| Array.from(document.querySelectorAll('h2, h3'))
|
|
.find(h => /download|archive/i.test(h.textContent ?? ''));
|
|
if (anchor) anchor.scrollIntoView({ behavior: 'instant', block: 'start' });
|
|
});
|
|
await sleep(300);
|
|
|
|
try {
|
|
const { handle, frame } = await waitAndFindAgreeButton(page, 30000);
|
|
await frame.waitForSelector(SELECTOR_PRIMARY_BUTTON, { visible: true, timeout: 15000 }).catch(_ => null);
|
|
await handle.click();
|
|
} catch (e) {
|
|
console.log('No protocol detected or already agreed, continuing parsing...');
|
|
}
|
|
|
|
// Don't wait in main document after agreeing; wait for devsite-expandable in content frame instead.
|
|
// If not found initially, try slightly scrolling to trigger lazy loading and check again.
|
|
// zh-CN:
|
|
// 同意后不要在主文档等待; 改为在包含内容的 frame 里等待 devsite-expandable.
|
|
// 若首次未出现, 尝试轻微滚动以触发懒加载, 再次检查.
|
|
|
|
/** @type {Frame} */
|
|
let contentFrame;
|
|
try {
|
|
contentFrame = await waitForFrameWithSelector(page, SELECTOR_DEVSITE_EXPANDABLE, 20000);
|
|
} catch {
|
|
// Attempt to trigger loading by scrolling. (zh-CN: 尝试滚动触发.)
|
|
for (let i = 0; i < 8; i++) {
|
|
await page.evaluate(() => window.scrollBy(0, 800));
|
|
await sleep(300);
|
|
}
|
|
// Check again. (zh-CN: 再次检查.)
|
|
contentFrame = await waitForFrameWithSelector(page, SELECTOR_DEVSITE_EXPANDABLE, 20000);
|
|
}
|
|
if (!contentFrame) {
|
|
throw new Error('Failed to find content frame');
|
|
}
|
|
|
|
/** @type {ArchiveItem[]} */
|
|
const archives = await contentFrame.$$eval(SELECTOR_DEVSITE_EXPANDABLE, nodes => {
|
|
const pickText = (/** @type {Node | null} */ el) => String(el?.textContent ?? '').trim();
|
|
return nodes.map(n => {
|
|
/** @type {Node} */
|
|
const titleElement = n.querySelector('.expand-control');
|
|
const title = pickText(titleElement?.childNodes?.[0] ?? null);
|
|
const date = pickText(n.querySelector('.expand-control span'))
|
|
.replace(/^([A-Z][a-z]{2})[a-z]*( \d+, \d+)$/, '$1$2');
|
|
/** @type {Element[]} */
|
|
const linkElements = Array.from(n.querySelectorAll('.downloads a[href]'));
|
|
const links = linkElements.map(a => ({
|
|
text: pickText(a),
|
|
href: a.getAttribute('href') ?? '',
|
|
}));
|
|
|
|
/** @type {{ [filename: string]: string }} */
|
|
const checksums = {};
|
|
/** @type {HTMLElement} */
|
|
const downloadsElement = n.querySelector('.downloads');
|
|
const bodyText = (downloadsElement?.innerText || '').trim();
|
|
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;
|
|
}
|
|
});
|
|
|
|
/* e.g. "2025.1.3". */
|
|
const version = title.match(/\d{2,}\.\d+(?:\.\d+)?/)?.[0] ?? null;
|
|
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);
|
|
}
|
|
|
|
// Determine if this file is being run directly.
|
|
// zh-CN: 判断是否为直接执行该文件.
|
|
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
await main().catch((err) => {
|
|
console.error(err);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|