feat: 新增角色下载脚本,取代此前浏览器手动下载逻辑

This commit is contained in:
2026-08-05 18:50:18 +08:00
parent e5dd163d22
commit e1c9bda4e1
8 changed files with 694 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const cheerio = require('cheerio');
const {
cleanImageUrl,
generateImageName,
toOutputBaseName,
buildParseApiUrl,
resolveImageUrl,
getFileBaseNameFromUrl,
listLocalCharacters,
sleep,
} = require('./utils');
/**
* @typedef {Object} WikiConfig
* @property {string} name 站点标识,用于日志和 CLI 筛选
* @property {string} [baseUrl] Fandom 站点地
* @property {string} [url] 完整 parse API 地址(与 baseUrl + wikiTitle 二选一)
* @property {string} [wikiTitle] Wiki 页面标题
* @property {string} savePath 图片保存目录
* @property {string} imageSelector 图片 CSS 选择器
* @property {string} [imageUrlAttr='src'] 图片 URL 所在属性,如 src、data-src
* @property {(name: string) => string} [nameTransform] 文件名二次处理
* @property {number} [downloadDelayMs] 下载间隔,默认 500ms
* @property {string} [verifyApiUrl] 摄影集页面 API,配置后自动验证服务器已录入角色
*/
/** @type {WikiConfig[]} */
const wikiConfigs = [
{
name: 'nte',
baseUrl: 'https://neverness-to-everness.fandom.com/wiki/Character/List',
url: 'https://neverness-to-everness.fandom.com/api.php?action=parse&page=Character/List&prop=text&format=json',
wikiTitle: 'Character/List',
savePath: path.join(__dirname, 'output', 'nte'),
imageSelector: 'table.fandom-table img[data-relevant="1"]',
imageUrlAttr: 'data-src',
nameTransform: (name) => name.replace(/-icon$/, ''),
verifyApiUrl: 'https://paul.ren/api/page/get?slug=photo-collection-nte',
},
{
name: 'ww',
baseUrl: 'https://wutheringwaves.fandom.com',
wikiTitle: 'Resonator/List',
savePath: path.join(__dirname, 'output', 'wuthering-waves'),
imageSelector: 'table.article-table img[data-relevant="1"]',
nameTransform: (name) => name.replace(/^resonator-/, ''),
verifyApiUrl: 'https://paul.ren/api/page/get?slug=photo-collection-ww',
},
// {
// name: 'blue-archive',
// baseUrl: 'https://bluearchive.fandom.com',
// wikiTitle: 'Category:Students',
// savePath: path.join(__dirname, 'output', 'blue-archive'),
// imageSelector: 'table.article-table td:first-child img',
// nameTransform: (name) => name.replace(/-icon$/, ''),
// },
];
function resolveApiUrl(config) {
if (config.url) {
return config.url;
}
if (config.baseUrl && config.wikiTitle) {
return buildParseApiUrl(config.baseUrl, config.wikiTitle);
}
throw new Error(`[${config.name}] 需要提供 url,或同时提供 baseUrl 与 wikiTitle`);
}
async function fetchWikiHtml(config) {
const apiUrl = resolveApiUrl(config);
console.log('apiUrl', apiUrl);
const response = await fetch(apiUrl, {
headers: {
'user-agent': 'Mozilla/5.0 (compatible; download-character-img/1.0)',
accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`[${config.name}] 请求失败 ${response.status}: ${apiUrl}`);
}
const data = await response.json();
const html = data?.parse?.text?.['*'];
if (!html) {
throw new Error(`[${config.name}] API 响应中未找到页面 HTML`);
}
return html;
}
function extractImages(html, imageSelector, imageUrlAttr = 'src') {
const $ = cheerio.load(html);
const results = [];
const seenNames = new Set();
$(imageSelector).each((_, element) => {
const $img = $(element);
const rawUrl = resolveImageUrl($img, imageUrlAttr);
if (!rawUrl) {
return;
}
const cleanedUrl = cleanImageUrl(rawUrl);
const rawName = generateImageName(cleanedUrl);
if (!rawName || seenNames.has(rawName)) {
return;
}
seenNames.add(rawName);
results.push({ url: cleanedUrl, rawName });
});
return results;
}
async function downloadImage(url, filePath) {
const response = await fetch(url, {
headers: {
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'user-agent': 'Mozilla/5.0 (compatible; download-character-img/1.0)',
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(filePath, buffer);
}
async function downloadWiki(config) {
const delay = config.downloadDelayMs ?? 500;
const nameTransform = config.nameTransform ?? ((name) => name);
console.log(`\n=== ${config.name} ===`);
console.log(`保存目录: ${config.savePath}`);
fs.mkdirSync(config.savePath, { recursive: true });
const html = await fetchWikiHtml(config);
const images = extractImages(html, config.imageSelector, config.imageUrlAttr ?? 'src');
console.log(`共找到 ${images.length} 张角色图`);
let downloaded = 0;
let skipped = 0;
let failed = 0;
for (const [index, image] of images.entries()) {
const baseName = toOutputBaseName(image.rawName, nameTransform);
const fileName = `${baseName}.webp`;
const filePath = path.join(config.savePath, fileName);
if (fs.existsSync(filePath)) {
console.log(
`[${index + 1}/${images.length}] 跳过: ${fileName} 已存在(本地已有,原站可能有更新)`
);
skipped += 1;
continue;
}
console.log(`[${index + 1}/${images.length}] 下载: ${fileName}`);
console.log(` 来源: ${image.url}`);
try {
await downloadImage(image.url, filePath);
downloaded += 1;
} catch (error) {
console.error(` 失败: ${error.message}`);
failed += 1;
}
if (index < images.length - 1) {
await sleep(delay);
}
}
console.log(
`完成: 下载 ${downloaded},跳过 ${skipped},失败 ${failed}`
);
}
async function fetchServerCharacters(verifyApiUrl) {
const response = await fetch(verifyApiUrl, {
headers: {
'user-agent': 'Mozilla/5.0 (compatible; download-character-img/1.0)',
accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`验证 API 请求失败 ${response.status}: ${verifyApiUrl}`);
}
const res = await response.json();
const content = JSON.parse(res.data.content);
const components = content.filter((item) => item.type === 'component');
const ids = new Set();
for (const component of components) {
for (const item of component.props?.items ?? []) {
if (item.url) {
ids.add(getFileBaseNameFromUrl(item.url));
}
}
}
return [...ids].sort();
}
async function verifyWiki(config) {
console.log(`\n--- 验证 ${config.name} 服务器录入 ---`);
const localCharacters = listLocalCharacters(config.savePath);
const serverCharacters = await fetchServerCharacters(config.verifyApiUrl);
const serverSet = new Set(serverCharacters);
const missingOnServer = localCharacters.filter((id) => !serverSet.has(id));
const extraOnServer = serverCharacters.filter(
(id) => !localCharacters.includes(id)
);
console.log(`Wiki 角色数: ${localCharacters.length}`);
console.log(`服务器已录入: ${serverCharacters.length}`);
if (missingOnServer.length === 0) {
console.log('服务器已包含全部 Wiki 角色');
} else {
console.log(`\n服务器缺少 ${missingOnServer.length} 个角色,请手动补充:`);
for (const id of missingOnServer) {
console.log(` - ${id}`);
}
}
if (extraOnServer.length > 0) {
console.log(`\n服务器多余 ${extraOnServer.length} 个(Wiki 中未找到):`);
for (const id of extraOnServer) {
console.log(` - ${id}`);
}
}
}
async function main() {
const args = process.argv.slice(2);
const verifyOnly = args.includes('--verify');
const target = args.find((arg) => !arg.startsWith('--'));
const configs = target
? wikiConfigs.filter((config) => config.name === target)
: wikiConfigs;
if (target && configs.length === 0) {
const available = wikiConfigs.map((config) => config.name).join(', ');
console.error(`未找到站点 "${target}",可用: ${available}`);
process.exit(1);
}
for (const config of configs) {
if (!verifyOnly) {
await downloadWiki(config);
}
if (config.verifyApiUrl) {
await verifyWiki(config);
}
}
console.log('\n全部处理完成!');
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});