6.7.0 - Alpha6 - 新增 Android 发行版本 scraper 脚本及信息打印脚本
This commit is contained in:
268
.utils/scrape-and-update-android-releases.mjs
Normal file
268
.utils/scrape-and-update-android-releases.mjs
Normal file
@@ -0,0 +1,268 @@
|
||||
// scrape-and-update-android-releases.mjs
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fetchAndroidReleases } from './fetch-and-parse-android-releases.mjs';
|
||||
import { printLinesDiffs } from './utils/print.mjs';
|
||||
|
||||
const CSV_PATH = path.resolve(process.cwd(), '../build-logic/ksp-version-codes-processor/src/main/resources/version-codes.csv');
|
||||
const TXT_PATH = path.resolve(process.cwd(), '../build-logic/ksp-version-codes-processor/src/main/resources/version-codes.txt');
|
||||
|
||||
const readFileSafe = (/** @type {string} */ p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '');
|
||||
|
||||
const quoteCsv = (/** @type {string | number} */ v) => {
|
||||
const s = String(v ?? '');
|
||||
if (s === '""') return s;
|
||||
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {AndroidReleaseCsv[]} rows
|
||||
* @returns {string}
|
||||
*/
|
||||
const toCsv = (rows) => {
|
||||
/** @type {(keyof AndroidReleaseCsv)[]} */
|
||||
const headers = [
|
||||
'VERSION_CODE',
|
||||
'RELEASE_NAME',
|
||||
'INTERNAL_CODENAME',
|
||||
'PLATFORM_VERSION',
|
||||
'API_LEVEL',
|
||||
'RELEASE_DATE',
|
||||
];
|
||||
const lines = rows.map(r => headers.map(k => quoteCsv(r[k])).join());
|
||||
return [ headers.join(), ...lines ].join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {AndroidReleaseCsv[]} data
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateCsv(data) {
|
||||
fs.mkdirSync(path.dirname(CSV_PATH), { recursive: true });
|
||||
fs.writeFileSync(CSV_PATH, toCsv(data), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AndroidReleaseCsv[]} data
|
||||
* @param {object} options
|
||||
* @param {boolean} [options.showIndex=false]
|
||||
* @param {{ mode: 'all' | 'not-all-numbers' | 'none', style: 'single' | 'double' }} [options.quote]
|
||||
* @param {{ key: string, compare: (a: AndroidReleaseCsv, b: AndroidReleaseCsv) => number } | { key: string, direction: 'asc' | 'desc' }} [options.sortBy]
|
||||
* @param {{ [key: string]: string }} [options.headerMap]
|
||||
* @param {'lower' | 'upper' | 'snake' | 'kebab' | 'title' | 'camel' | 'pascal' | null} [options.headerCase]
|
||||
* @param {string[]} [options.headers]
|
||||
* @param {{ [key: string]: (v: any, row: AndroidReleaseCsv, header: string) => string }} [options.renderers]
|
||||
* @returns {void}
|
||||
*/
|
||||
function updateTxt(data, options) {
|
||||
const defaultOptions = {
|
||||
showIndex: false,
|
||||
quote: {
|
||||
mode: 'not-all-numbers',
|
||||
style: 'single',
|
||||
},
|
||||
sortBy: null,
|
||||
headerMap: null,
|
||||
headerCase: null,
|
||||
headers: [
|
||||
'VERSION_CODE',
|
||||
'RELEASE_NAME',
|
||||
'INTERNAL_CODENAME',
|
||||
'PLATFORM_VERSION',
|
||||
'API_LEVEL',
|
||||
'RELEASE_DATE',
|
||||
],
|
||||
renderers: {},
|
||||
};
|
||||
const opts = Object.assign(defaultOptions, options ?? {});
|
||||
|
||||
const headers = opts.headers.slice();
|
||||
|
||||
const toWords = (/** @type {any} */ s) => String(s ?? '')
|
||||
.replace(/[_\-\s]+/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
|
||||
/**
|
||||
* @param {any} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const toCase = (s) => {
|
||||
const words = toWords(s);
|
||||
switch (opts.headerCase) {
|
||||
case 'lower':
|
||||
return words.join(' ').toLowerCase();
|
||||
case 'upper':
|
||||
return words.join(' ').toUpperCase();
|
||||
case 'snake':
|
||||
return words.map(w => w.toLowerCase()).join('_');
|
||||
case 'kebab':
|
||||
return words.map(w => w.toLowerCase()).join('-');
|
||||
case 'title':
|
||||
return words.map(w => w[0] ? (w[0].toUpperCase() + w.slice(1).toLowerCase()) : w).join(' ');
|
||||
case 'camel':
|
||||
return words.map((w, i) => i === 0 ? w.toLowerCase() : (w[0]?.toUpperCase() + w.slice(1).toLowerCase())).join('');
|
||||
case 'pascal':
|
||||
return words.map(w => w[0]?.toUpperCase() + w.slice(1).toLowerCase()).join('');
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy and sort data.<br>
|
||||
* zh-CN: 数据拷贝与排序.
|
||||
* @type {AndroidReleaseCsv[]}
|
||||
*/
|
||||
const rows = data.slice();
|
||||
if (opts.sortBy) {
|
||||
if (typeof opts.sortBy === 'function') {
|
||||
rows.sort(opts.sortBy);
|
||||
} else if (opts.sortBy && typeof opts.sortBy === 'object' && opts.sortBy.key) {
|
||||
const key = opts.sortBy.key;
|
||||
const dir = (opts.sortBy.direction || 'asc').toLowerCase() === 'desc' ? -1 : 1;
|
||||
const cmp = typeof opts.sortBy.compare === 'function'
|
||||
? opts.sortBy.compare
|
||||
: (/** @type {AndroidReleaseCsv} */ a, /** @type {AndroidReleaseCsv} */ b) => {
|
||||
const va = a?.[key];
|
||||
const vb = b?.[key];
|
||||
if (va == null && vb == null) return 0;
|
||||
if (va == null) return -1;
|
||||
if (vb == null) return 1;
|
||||
if (va < vb) return -1;
|
||||
if (va > vb) return 1;
|
||||
return 0;
|
||||
};
|
||||
rows.sort((a, b) => dir * cmp(a, b));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare column values (strings) and determine column quotation in "not-all-numbers" mode.<br>
|
||||
* zh-CN: 先把每列值准备好 (字符串), 并在 "not-all-numbers" 模式下做整列判定.
|
||||
* @type {string[][]}
|
||||
*/
|
||||
const tableValues = rows.map(r => {
|
||||
return headers.map(h => {
|
||||
const v = r[h];
|
||||
const renderer = opts.renderers?.[h];
|
||||
const rendered = renderer ? renderer(v, r, h) : (v ?? '');
|
||||
return String(rendered);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Column headers (remapping + case transformation + affixes).<br>
|
||||
* zh-CN: 列标题 (重映射 + 大小写 + 前后缀).
|
||||
* @type {string[]}
|
||||
*/
|
||||
const headerLabels = headers.map(h => {
|
||||
const mapped = opts.headerMap?.[h] ?? h;
|
||||
return toCase(mapped);
|
||||
});
|
||||
const finalHeader = opts.showIndex ? [ '(index)', ...headerLabels ] : headerLabels;
|
||||
|
||||
const qStyle = opts.quote?.style === 'double' ? `"` : `'`;
|
||||
const isNumericLike = (/** @type {any} */ v) => /^-?\d+(\.\d+)?$/.test(String(v ?? '').trim());
|
||||
|
||||
/** @type {boolean[]} */
|
||||
const quotedColumns = tableValues[0] ? new Array(tableValues[0].length).fill(false) : [];
|
||||
if (opts.quote?.mode === 'all') {
|
||||
for (let c = 0; c < quotedColumns.length; c++) {
|
||||
quotedColumns[c] = true;
|
||||
}
|
||||
} else if (opts.quote?.mode === 'not-all-numbers' || !opts.quote?.mode) {
|
||||
for (let c = 0; c < quotedColumns.length; c++) {
|
||||
const col = tableValues.map(row => row[c]);
|
||||
const allNumeric = col.length > 0 && col.every(isNumericLike);
|
||||
quotedColumns[c] = !allNumeric;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply quotes to data.<br>
|
||||
* zh-CN: 应用引号到数据.
|
||||
* @type {string[][]}
|
||||
*/
|
||||
const quotedTable = tableValues.map(row => {
|
||||
return row.map((cell, c) => quotedColumns[c]
|
||||
? qStyle + cell + qStyle
|
||||
: cell === `""` ? qStyle + qStyle : cell);
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculate the width of each column.<br>
|
||||
* zh-CN: 计算每列宽度.
|
||||
* @type {string[][]}
|
||||
*/
|
||||
const rowsForWidth = [];
|
||||
rowsForWidth.push(finalHeader);
|
||||
if (opts.showIndex) {
|
||||
for (let i = 0; i < quotedTable.length; i++) {
|
||||
rowsForWidth.push([ String(i), ...quotedTable[i] ]);
|
||||
}
|
||||
} else {
|
||||
rowsForWidth.push(...quotedTable);
|
||||
}
|
||||
const colCount = rowsForWidth[0]?.length ?? 0;
|
||||
/** @type {number[]} */
|
||||
const colWidths = new Array(colCount).fill(0);
|
||||
for (const r of rowsForWidth) {
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
colWidths[c] = Math.max(colWidths[c], String(r[c] ?? '').length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} s
|
||||
* @param {number} w
|
||||
* @returns {string}
|
||||
*/
|
||||
const pad = (s, w) => {
|
||||
const str = String(s ?? '');
|
||||
const len = str.length;
|
||||
return str + (len < w ? ' '.repeat(w - len) : '');
|
||||
};
|
||||
const joinRow = (/** @type {any[]} */ cells) => `| ${cells.map((v, i) => pad(v, colWidths[i])).join(' | ')} |`;
|
||||
const divider = `+-${colWidths.map(w => '-'.repeat(w)).join('-+-')}-+`;
|
||||
|
||||
const lines = [];
|
||||
lines.push(divider);
|
||||
lines.push(joinRow(finalHeader));
|
||||
lines.push(divider);
|
||||
if (quotedTable.length > 0) {
|
||||
for (let i = 0; i < quotedTable.length; i++) {
|
||||
const cells = opts.showIndex ? [ String(i), ...quotedTable[i] ] : quotedTable[i];
|
||||
lines.push(joinRow(cells));
|
||||
}
|
||||
}
|
||||
lines.push(divider);
|
||||
|
||||
fs.mkdirSync(path.dirname(TXT_PATH), { recursive: true });
|
||||
fs.writeFileSync(TXT_PATH, lines.join('\n'), 'utf8');
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const filename = path.basename(CSV_PATH);
|
||||
const releases = await fetchAndroidReleases();
|
||||
const prev = readFileSafe(CSV_PATH).replaceAll('\r\n', '\n');
|
||||
if (prev !== toCsv(releases)) {
|
||||
updateCsv(releases);
|
||||
updateTxt(releases, {
|
||||
showIndex: false,
|
||||
quote: {
|
||||
mode: 'none',
|
||||
style: 'single',
|
||||
},
|
||||
});
|
||||
console.log(`[${filename}] Updated (Android releases)`);
|
||||
printLinesDiffs(prev, toCsv(releases));
|
||||
} else {
|
||||
// console.log('[${filename}] No update needed (Android releases)');
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user