6.7.0 - Alpha6 - 重构 scrapers 工具并增加中英双语注释内容

This commit is contained in:
SuperMonster003
2025-09-24 16:01:46 +08:00
parent ef076d1cf8
commit c1ee107bc3
25 changed files with 1512 additions and 1072 deletions

View File

@@ -1,8 +1,10 @@
import { fileURLToPath } from 'node:url';
// fetch-and-parse-android-studio-archives.mjs
import puppeteer from 'puppeteer';
import { sleep } from './utils/async.mjs';
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 */
@@ -20,51 +22,64 @@ import { readPropertiesSync } from './utils/properties.mjs';
*/
const URL = 'https://developer.android.com/studio/archive?hl=en';
const SELECTOR_PRIMARY_BUTTON = 'button.button-primary';
const SELECTOR_DEVSITE_EXPANDABLE = 'devsite-expandable';
/**
* 在所有 frame (含主文档) 中查找 "同意" 按钮.
* 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}>}
* @returns {Promise<{ handle: ElementHandle<HTMLButtonElement>, frame: Frame }>}
*/
async function waitAndFindAgreeButton(page, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
const selector = 'button.button-primary';
/**
* @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) {
// 1) 先尝试主文档
// Attempt main document.
// zh-CN: 尝试主文档.
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() };
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() };
}
}
// 2) 再查所有子 frame
// Check all sub-frame.
// zh-CN: 检查所有子 frame.
const frames = page.frames();
for (const f of frames) {
/** @type {ElementHandle<HTMLButtonElement>} */
const btn = await f.$(selector);
const btn = await f.$(SELECTOR_PRIMARY_BUTTON);
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') {
const txt = await f.evaluate(el => el.textContent, btn);
if (containsAgreementText(txt)) {
return { handle: btn, frame: f };
}
}
// 3) 触发懒加载: 轻微滚动几次
// Trigger lazy-loading: scroll slightly several times.
// zh-CN: 触发懒加载, 轻微滚动几次.
await page.evaluate(() => window.scrollBy(0, 600));
await sleep(250);
await sleep(300);
}
throw new Error('未在任何文档中找到 "同意" 按钮 (超时)');
throw new Error('Unable to find "agree" button in any document (timeout)');
}
/**
* 在所有 frame 中等待某个选择器出现, 并返回该 frame.
* Wait for a selector to appear in any frame and return that frame.<br>
* zh-CN: 在所有 frame 中等待某个选择器出现, 并返回该 frame.
*
* @param {Page} page
* @param {string} selector
@@ -75,16 +90,17 @@ 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;
if (await f.$(selector)) {
return f;
}
}
await sleep(250);
await sleep(300);
}
throw new Error(` 未在任何 frame 中找到选择器: ${selector}`);
throw new Error(`Could not find selector "${selector}" in any frame`);
}
/**
* @return {Promise<ArchiveItem[]>}
* @returns {Promise<ArchiveItem[]>}
*/
export async function getAndroidStudioArchives() {
const browser = await puppeteer.launch({
@@ -99,74 +115,68 @@ export async function getAndroidStudioArchives() {
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)
// 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 || ''));
.find(h => /download|archive/i.test(h.textContent ?? ''));
if (anchor) anchor.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await sleep(500);
await sleep(300);
// 等待并点击 "同意" 按钮
try {
const { handle, frame } = await waitAndFindAgreeButton(page, 30000);
await frame.waitForSelector('button.button-primary', { visible: true, timeout: 15000 }).catch(() => {
});
await frame.waitForSelector(SELECTOR_PRIMARY_BUTTON, { visible: true, timeout: 15000 }).catch(_ => null);
await handle.click();
} catch (e) {
console.log('未检测到协议或已同意, 继续解析...');
console.log('No protocol detected or already agreed, continuing parsing...');
}
// 同意后不要在主文档等待; 改为在包含内容的 frame 里等待 devsite-expandable
// 若首次未出现, 尝试轻微滚动以触发懒加载, 再次检查
// 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, 'devsite-expandable', 20000);
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(250);
await sleep(300);
}
// 再次寻找
contentFrame = await waitForFrameWithSelector(page, 'devsite-expandable', 20000);
// 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('devsite-expandable', nodes => {
// 从内容 frame 中直接抽取 devsite-expandable 数据
/**
* @param {Node | null} el
* @returns {string}
*/
const pickText = el => (el?.textContent || '').trim();
/** @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 titleEl = n.querySelector('.expand-control');
const title = pickText(titleEl?.childNodes?.[0]); // 不含日期的主标题
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 linkEls = Array.from(n.querySelectorAll('.downloads a[href]'));
const links = linkEls.map(a => ({
const linkElements = Array.from(n.querySelectorAll('.downloads a[href]'));
const links = linkElements.map(a => ({
text: pickText(a),
href: a.getAttribute('href') || '',
href: a.getAttribute('href') ?? '',
}));
// 收集 checksums (在 .downloads 文本中)
/** @type {{ [filename: string]: string }} */
const checksums = {};
/** @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) {
@@ -175,11 +185,8 @@ export async function getAndroidStudioArchives() {
}
});
// 解析版本号 (2025.1.2 等), 优先从标题中提取
let version = null;
const vm = title.match(/\d{2,}\.\d+(?:\.\d+)?/);
if (vm) version = vm[0];
/* e.g. "2025.1.3". */
const version = title.match(/\d{2,}\.\d+(?:\.\d+)?/)?.[0] ?? null;
return { title, date, version, links, checksums };
});
});
@@ -211,10 +218,11 @@ async function main() {
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;
});
}
}