From e1c9bda4e1440216e2d0ce452f4bedca98e558aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=87=E8=B6=A3=E4=BF=9D=E7=BD=97?= Date: Wed, 5 Aug 2026 18:50:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E8=84=9A=E6=9C=AC=EF=BC=8C=E5=8F=96=E4=BB=A3?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E6=B5=8F=E8=A7=88=E5=99=A8=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../download-ba-character-img.js | 0 .../download-nte-character-img.js | 0 .../download-wuthering-waves-character-img.js | 0 download-character-img/index.js | 281 ++++++++++++++++ download-character-img/package-lock.json | 310 ++++++++++++++++++ download-character-img/package.json | 8 + download-character-img/utils.js | 93 ++++++ 8 files changed, 694 insertions(+) create mode 100644 .gitignore rename download-ba-character-img.js => download-character-img-legacy/download-ba-character-img.js (100%) rename download-nte-character-img.js => download-character-img-legacy/download-nte-character-img.js (100%) rename download-wuthering-waves-character-img.js => download-character-img-legacy/download-wuthering-waves-character-img.js (100%) create mode 100644 download-character-img/index.js create mode 100644 download-character-img/package-lock.json create mode 100644 download-character-img/package.json create mode 100644 download-character-img/utils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92557fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +download-character-img/output +node_modules diff --git a/download-ba-character-img.js b/download-character-img-legacy/download-ba-character-img.js similarity index 100% rename from download-ba-character-img.js rename to download-character-img-legacy/download-ba-character-img.js diff --git a/download-nte-character-img.js b/download-character-img-legacy/download-nte-character-img.js similarity index 100% rename from download-nte-character-img.js rename to download-character-img-legacy/download-nte-character-img.js diff --git a/download-wuthering-waves-character-img.js b/download-character-img-legacy/download-wuthering-waves-character-img.js similarity index 100% rename from download-wuthering-waves-character-img.js rename to download-character-img-legacy/download-wuthering-waves-character-img.js diff --git a/download-character-img/index.js b/download-character-img/index.js new file mode 100644 index 0000000..e4cbeb2 --- /dev/null +++ b/download-character-img/index.js @@ -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); +}); diff --git a/download-character-img/package-lock.json b/download-character-img/package-lock.json new file mode 100644 index 0000000..4f1cd10 --- /dev/null +++ b/download-character-img/package-lock.json @@ -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" + } + } + } +} diff --git a/download-character-img/package.json b/download-character-img/package.json new file mode 100644 index 0000000..e77ee5c --- /dev/null +++ b/download-character-img/package.json @@ -0,0 +1,8 @@ +{ + "name": "download-character-img", + "private": true, + "type": "commonjs", + "dependencies": { + "cheerio": "^1.0.0" + } +} diff --git a/download-character-img/utils.js b/download-character-img/utils.js new file mode 100644 index 0000000..54ca78f --- /dev/null +++ b/download-character-img/utils.js @@ -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, +};