6.7.0 - Alpha6 - 新增 Android 发行版本 scraper 脚本及信息打印脚本

This commit is contained in:
SuperMonster003
2025-10-09 17:13:48 +08:00
parent 0ff16b8ec0
commit b83ce13d0e
9 changed files with 702 additions and 4 deletions

View File

@@ -0,0 +1,230 @@
// fetch-and-parse-android-releases.mjs
import * as cheerio from 'cheerio';
import fetch from 'node-fetch';
import { fileURLToPath } from 'node:url';
const versionCodeBlacklist = [ 'CUR_DEVELOPMENT' ];
const WIKI_URL = 'https://en.wikipedia.org/wiki/Android_version_history';
const ANDROID_VC_URL = 'https://developer.android.com/reference/android/os/Build.VERSION_CODES';
const norm = (/** @type {string} */ s) => (s ?? '').trim().replace(/\s+/g, ' ');
const trimAndNormDash = (/** @type {string} */ s) => s.replace(/\s*[\u002d\u2013\u2014]\s*/g, '-');
const stripSupportPrefix = (/** @type {string} */ s) => s.replace(/^(un)?supported:\s*/ig, '');
const handleCsvEmptyString = (/** @type {string} */ s) => /^-?$/.test(s) ? '""' : s;
/**
* @param {AndroidReleaseCsv} o
* @returns {AndroidReleaseCsv}
*/
function quoteIfNeeded(o) {
Object.keys(o).forEach((k) => {
if (typeof o[k] === 'string') {
o[k] = handleCsvEmptyString(o[k]);
}
});
return o;
}
/**
* Remove style, script, and sup.reference elements before getting text.<br>
* zh-CN: 获取文本前, 先移除 style, script 及 sup.reference.
*
* @param {Cheerio<DomElement>} cell
* @returns {string}
*/
function cleanCell(cell) {
const cloned = cell.clone();
cloned.find('style,script,sup.reference').remove();
return cloned.text().trim().replace(/\s+/g, ' ');
}
/**
* Fetch official VERSION_CODE and API_LEVEL from Android documentation.<br>
* zh-CN: 抓取官方 VERSION_CODE 与 API_LEVEL.
*
* @example Map<apiLevel, versionCode>
* @returns {Promise<Map<number, string>>}
*/
async function fetchOfficialVC() {
const html = await (await fetch(ANDROID_VC_URL)).text();
const $ = cheerio.load(html);
/**
* @example Map<apiLevel, versionCode>
* @type {Map<number, string>}
*/
const map = new Map();
$('h3.api-name').each((_i, h3) => {
const $h3 = $(h3);
const versionCode = norm($h3.attr('id') || $h3.text());
if (!versionCode || versionCodeBlacklist.includes(versionCode.toUpperCase())) return;
const container = $h3.parent();
const apiLevelText = container.find('.api-level').first().text();
const apiLevel = (/* @IIFE */ () => {
// "Constant Value: X"
const constValMatched = container.text().match(/Constant\s+Value:\s*(\d+)/i);
if (constValMatched) return parseInt(constValMatched[1], 10);
// "Added in API level X"
const addedInMatched = apiLevelText.match(/API level\s+(\d+)/i);
if (addedInMatched) return parseInt(addedInMatched[1], 10);
})();
if (apiLevel && Number.isFinite(apiLevel)) {
if (!map.has(apiLevel)) {
map.set(apiLevel, versionCode);
}
}
});
return map;
}
/**
* Fetch all fields except VERSION_CODE from Wikipedia.<br>
* zh-CN: 抓取维基除 VERSION_CODE 外的字段.
*
* @returns {Promise<AndroidReleaseWiki[]>}
*/
async function fetchWikiRows() {
const $ = cheerio.load(await (await fetch(WIKI_URL)).text());
const re = /^(Name|Internal\s*codename|Version\s*number(\(s\)|s)?|API\s*level|Release\s*date)$/i;
const table = $('table.wikitable').filter((_, table) => {
return Array.from($(table).find('tr th')).filter(th => {
const extractedText = $(th).text().replace(/\[\d+]/g, '').trim();
return re.test(extractedText);
}).length >= 3;
}).first();
/** @type {AndroidReleaseWiki[]} */
const rows = [];
/**
* Maintain column state.<br>
* zh-CN: 维护列状态.
*
* @example Record<columnIndex, { text: string; rowSpansLeft: number }>
* { 2: { text: "Gingerbread"; rowSpansLeft: 3 } }
*
* @type {Record<number, { cell: Cheerio<DomElement>; rowSpansLeft: number }>}
*/
const rowSpansState = {};
table.find('tr').each((_i, tr) => {
const $tr = $(tr);
const tds = $tr.find('td');
if (!tds.length) return;
/** @type {Cheerio<DomElement>[]} */
const cells = [];
let chosenTdsIdx = 0;
for (let i = 0; i < 5; i += 1) {
let cell = null;
if (i in rowSpansState) {
cell = rowSpansState[i].cell;
cells.push(cell);
rowSpansState[i].rowSpansLeft -= 1;
} else {
cell = tds.eq(chosenTdsIdx++);
if (!cell || cell.length === 0) return;
cells.push(cell);
const rowSpansLeft = parseInt(cell.attr('rowspan') || '1', 10) - 1;
if (rowSpansLeft > 0) {
rowSpansState[i] = { cell, rowSpansLeft };
}
}
}
const [ nameCell, codenameCell, versionCell, apiCell, dateCell ] = cells;
const name = cleanCell(nameCell);
const codename = trimAndNormDash(cleanCell(codenameCell));
// Version prefers data-sort-value (can be on current td or its child elements).
// zh-CN: Version 优先使用 data-sort-value (可在当前 td 或其子元素上).
const sortVal = versionCell.attr('data-sort-value') || versionCell.find('[data-sort-value]').attr('data-sort-value');
const versionText = trimAndNormDash(stripSupportPrefix(sortVal || cleanCell(versionCell)));
const api = cleanCell(apiCell);
const date = cleanCell(dateCell);
rows.push({
RELEASE_NAME: name,
INTERNAL_CODENAME: codename,
PLATFORM_VERSION: versionText,
API_LEVEL: parseInt(api, 10),
RELEASE_DATE: date,
});
for (const k of Object.keys(rowSpansState)) {
if (rowSpansState[k].rowSpansLeft === 0) {
delete rowSpansState[k];
}
}
});
return rows;
}
/**
* @returns {Promise<AndroidReleaseCsv[]>}
*/
export async function fetchAndroidReleases() {
const official = await fetchOfficialVC();
const wiki = await fetchWikiRows();
/** @type {Map<number, AndroidReleaseCsv>} */
const mergedByApi = new Map();
// First, populate with wiki data.
// zh-CN: 先放入 wiki 数据.
for (const r of wiki) {
const api = r.API_LEVEL;
if (!Number.isFinite(api)) continue;
const prev = mergedByApi.get(api) || {
VERSION_CODE: '', RELEASE_NAME: '', INTERNAL_CODENAME: '',
PLATFORM_VERSION: '', API_LEVEL: api, RELEASE_DATE: '',
};
mergedByApi.set(api, quoteIfNeeded({
...prev,
RELEASE_NAME: r.RELEASE_NAME || prev.RELEASE_NAME,
INTERNAL_CODENAME: r.INTERNAL_CODENAME || prev.INTERNAL_CODENAME,
PLATFORM_VERSION: r.PLATFORM_VERSION || prev.PLATFORM_VERSION,
RELEASE_DATE: r.RELEASE_DATE || prev.RELEASE_DATE,
}));
}
// Override VERSION_CODE with official data.
// zh-CN: 用 official 覆盖 VERSION_CODE.
for (const [ api, versionCode ] of official.entries()) {
const prev = mergedByApi.get(api) || {
VERSION_CODE: '', RELEASE_NAME: '', INTERNAL_CODENAME: '',
PLATFORM_VERSION: '', API_LEVEL: api, RELEASE_DATE: '',
};
mergedByApi.set(api, { ...prev, VERSION_CODE: versionCode, API_LEVEL: api });
}
// Output: Sort by API_LEVEL in descending order,
// keep only records with VERSION_CODE (officially released/named).
// zh-CN: 输出: 按 API_LEVEL 降序, 仅保留存在 VERSION_CODE 的记录 (即官方已发布/命名的).
return Array.from(mergedByApi.values())
.filter(r => r.VERSION_CODE)
.sort((a, b) => b.API_LEVEL - a.API_LEVEL);
}
async function main() {
console.table(await fetchAndroidReleases());
}
// Determine if this file is being run directly.
// zh-CN: 判断是否为直接执行该文件.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch(err => {
console.error(err);
process.exitCode = 1;
});
}