feat: 新增角色下载脚本,取代此前浏览器手动下载逻辑
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
download-character-img/output
|
||||
node_modules
|
||||
@@ -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);
|
||||
});
|
||||
Generated
+310
@@ -0,0 +1,310 @@
|
||||
{
|
||||
"name": "download-character-img",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "download-character-img",
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"encoding-sniffer": "^0.2.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^7.19.0",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "download-character-img",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/** 清洗 Fandom CDN 缩略图链接,去掉 scale-to-width-down 参数还原原图地址 */
|
||||
function cleanImageUrl(url) {
|
||||
return url.replace(/(\/scale-to-width-down\/\d+\?.*)$/, '');
|
||||
}
|
||||
|
||||
/** 从图片 URL 提取文件名并规范化(小写、下划线转短横线) */
|
||||
function generateImageName(url) {
|
||||
const fileNameMatch = url.match(/\/([^/]+)\.png/i);
|
||||
if (fileNameMatch && fileNameMatch[1]) {
|
||||
return fileNameMatch[1].toLowerCase().replace(/_/g, '-');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 对 Wiki 原始文件名应用站点自定义转换,得到最终保存用的基础名 */
|
||||
function toOutputBaseName(rawName, nameTransform) {
|
||||
const transformed = nameTransform ? nameTransform(rawName) : rawName;
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/** 根据 Fandom 站点根地址和页面标题拼接 parse API 请求 URL */
|
||||
function buildParseApiUrl(baseUrl, wikiTitle) {
|
||||
const apiBase = baseUrl.replace(/\/$/, '');
|
||||
const params = new URLSearchParams({
|
||||
action: 'parse',
|
||||
page: wikiTitle,
|
||||
prop: 'text',
|
||||
format: 'json',
|
||||
});
|
||||
return `${apiBase}/api.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** 判断图片 URL 是否为懒加载占位图 */
|
||||
function isPlaceholderImageUrl(url) {
|
||||
return !url || url.startsWith('data:image/gif');
|
||||
}
|
||||
|
||||
/** 从 img 元素读取图片地址,src 为占位图时自动回退到 data-src */
|
||||
function resolveImageUrl($img, imageUrlAttr = 'src') {
|
||||
const primary = $img.attr(imageUrlAttr);
|
||||
if (!isPlaceholderImageUrl(primary)) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
if (imageUrlAttr === 'src') {
|
||||
const fallback = $img.attr('data-src');
|
||||
if (!isPlaceholderImageUrl(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 延迟指定毫秒,用于控制下载请求间隔 */
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 从图片路径提取不含扩展名的文件名,兼容绝对 URL 与相对路径 */
|
||||
function getFileBaseNameFromUrl(url) {
|
||||
const normalized = url.split('?')[0].split('#')[0];
|
||||
const fileName = path.basename(normalized);
|
||||
return path.basename(fileName, path.extname(fileName));
|
||||
}
|
||||
|
||||
/** 读取本地保存目录中所有 .webp 角色的基础文件名列表 */
|
||||
function listLocalCharacters(savePath) {
|
||||
if (!fs.existsSync(savePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(savePath)
|
||||
.filter((file) => file.endsWith('.webp'))
|
||||
.map((file) => path.basename(file, '.webp'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanImageUrl,
|
||||
generateImageName,
|
||||
toOutputBaseName,
|
||||
buildParseApiUrl,
|
||||
isPlaceholderImageUrl,
|
||||
resolveImageUrl,
|
||||
getFileBaseNameFromUrl,
|
||||
listLocalCharacters,
|
||||
sleep,
|
||||
};
|
||||
Reference in New Issue
Block a user