hexo-bilibili-bangumi
Version:
589 lines (526 loc) • 16.5 kB
JavaScript
/*
* @Author : HCLonely
* @Date : 2025-04-02 16:02:58
* @LastEditTime : 2025-07-10 10:02:16
* @LastEditors : HCLonely
* @FilePath : /hexo-bilibili-bangumi/src/lib/get-bgm-data.js
* @Description : Bangumi.tv数据获取模块,通过网页爬虫和API双重方式获取用户收藏数据。
* 支持从bangumi-data获取中文标题,可从CDN或API获取详细信息,
* 包含评分、收藏数、简介等,并提供数据缓存功能以提高性能。
*/
const fs = require('hexo-fs');
const path = require('path');
const axios = require('axios');
const hexoLog = require('hexo-log');
const ProgressBar = require('progress');
const cheerio = require('cheerio');
const tunnel = require('tunnel');
const bangumiData = require('bangumi-data');
// 常量定义
const CONSTANTS = {
TYPE: {
1: '书籍',
2: '动画',
3: '音乐',
4: '游戏',
6: '三次元'
},
TYPE_PATH_MAP: {
bangumi: 'anime',
game: 'game',
cinema: 'real'
},
STATUS_TEXT: {
wish: '[想看]',
do: '[在看]',
collect: '[已看]'
},
TYPE_TEXT: {
game: '游戏',
real: '追剧',
anime: '番剧',
bangumi: '番剧'
},
API: {
CDN_URL: 'https://cdn.jsdelivr.net/gh/czy0729/Bangumi-Subject@master/data',
BGM_API_URL: 'https://api.bgm.tv/v0/subjects',
USER_AGENT: 'HCLonely/hexo-bilibili-bangumi (https://github.com/HCLonely/hexo-bilibili-bangumi)',
BROWSER_USER_AGENT: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36 Edg/97.0.1072.69'
},
CACHE: {
SUBJECT_DIR: 'Bangumi-Subject-Cache',
API_DIR: 'Bangumi-Api-Cache'
},
REQUEST_TIMEOUT: 30 * 1000
};
// 初始化日志
const log = typeof hexoLog.default === 'function'
? hexoLog.default({ debug: false, silent: false })
: hexoLog({ debug: false, silent: false });
/**
* 从bangumi-data获取中文标题
* @param {string} name 原始标题
* @returns {string} 中文标题
*/
const jp2cnName = (name) => bangumiData.items.find((item) => item.title === name)?.titleTranslate?.['zh-Hans']?.[0] || name;
/**
* 创建HTTP请求配置
* @param {Object} proxy 代理配置
* @param {Object} itemData 项目数据
* @returns {Object} 请求配置
*/
const createRequestConfig = (proxy, itemData = null) => {
const config = {
itemData,
responseType: 'json',
validateStatus: (status) => (status >= 200 && status < 300) || status === 403,
proxy: false,
timeout: CONSTANTS.REQUEST_TIMEOUT
};
if (proxy?.host && proxy?.port) {
config.httpsAgent = tunnel.httpsOverHttp({
proxy,
options: { rejectUnauthorized: false }
});
}
return config;
};
/**
* 确保缓存目录存在
* @param {string} sourceDir 源目录
* @param {string} cacheDir 缓存目录名
* @returns {string} 缓存目录路径
*/
const ensureCacheDir = (sourceDir, cacheDir) => {
const cachePath = path.join(sourceDir, '/_data/', cacheDir);
if (!fs.existsSync(cachePath)) {
fs.mkdirSync(cachePath, { recursive: true });
}
return cachePath;
};
/**
* 处理API错误
* @param {Error} error 错误对象
*/
const handleApiError = (error) => {
if (error.response) {
log.error('API Error:', error.response.status);
} else {
log.error('Error:', error.stack);
}
};
/**
* 格式化条目数据(CDN数据源)
* @param {Object} data 原始数据
* @param {Object} config 配置信息
* @param {string} coverMirror 图片镜像源
* @returns {Object} 格式化后的数据
*/
const formatSubjectData = (data, config, coverMirror) => {
const { id, name, type, image, rating = {}, summary, collection = {}, eps, epsLength } = data;
const totalCount = epsLength || eps?.length;
return {
id: id || config.itemData.id,
title: jp2cnName(name || config.itemData.name),
type: CONSTANTS.TYPE[type] || '未知',
cover: coverMirror + image || config.itemData.cover,
score: rating.score || '-',
des: summary?.replace(/\r?\n/g, '').trim() || '-',
wish: collection.wish || '-',
doing: collection.doing || '-',
collect: collection.collect || '-',
totalCount: totalCount ? `全${totalCount}${type === 6 ? '集' : '话'}` : '-',
myStars: config.itemData.myStars,
myComment: config.itemData.myComment
};
};
/**
* 格式化条目数据(API数据源)
* @param {Object} data API返回的数据
* @param {Object} config 配置信息
* @param {string} coverMirror 图片镜像源
* @returns {Object} 格式化后的数据
*/
const formatApiData = (data, config, coverMirror) => {
const {
id, name, name_cn, type,
images: { common: image } = {},
rating = {}, summary,
collection = {}, eps, total_episodes
} = data;
const totalCount = total_episodes || eps;
return {
id: id || config.itemData.id,
title: name_cn || jp2cnName(name || config.itemData.name),
type: CONSTANTS.TYPE[type] || '未知',
cover: coverMirror + image || config.itemData.cover,
score: rating.score || '-',
des: summary?.replace(/\r?\n/g, '').trim() || '-',
wish: collection.wish || '-',
doing: collection.doing || '-',
collect: collection.collect || '-',
totalCount: totalCount ? `全${totalCount}${type === 6 ? '集' : '话'}` : '-',
myStars: config.itemData.myStars,
myComment: config.itemData.myComment
};
};
/**
* 从CDN获取Bangumi数据
* @param {Array} items 项目列表
* @param {string} sourceDir 源目录
* @param {Object} proxy 代理配置
* @param {string} coverMirror 图片镜像源
* @returns {Promise<Array>} 处理后的数据
*/
const getBangumiDataFromBangumiSubject = async (items, sourceDir, proxy, coverMirror) => {
const cachePath = ensureCacheDir(sourceDir, CONSTANTS.CACHE.SUBJECT_DIR);
const processItem = async (item) => {
const subjectPath = path.join(cachePath, `${item.id}.json`);
// 尝试从缓存获取数据
if (fs.existsSync(subjectPath)) {
try {
const cachedData = fs.readFileSync(subjectPath).toString();
return { config: { itemData: item }, data: cachedData, status: 200 };
} catch (error) {
log.error(`Failed to read cache for item ${item.id}:`, error);
}
}
// 从CDN获取数据
try {
const config = createRequestConfig(proxy, item);
const folderNum = parseInt(parseInt(item.id, 10) / 100, 10);
const response = await axios.get(
`${CONSTANTS.API.CDN_URL}/${folderNum}/${item.id}.json`,
config
);
return response;
} catch (error) {
handleApiError(error);
return { config: { itemData: item }, error };
}
};
const results = await Promise.allSettled(items.map(processItem));
return results.map(({ value, reason }) => {
const { config, data, status } = value || reason;
// 处理错误情况
if (reason || status === 403 || !data) {
return {
id: config?.itemData.id,
title: jp2cnName(config.itemData.name),
type: '未知',
cover: config.itemData.cover,
score: '-',
des: '-',
wish: '-',
doing: '-',
collect: '-',
totalCount: '-'
};
}
// 处理数据
let bangumiData = data;
if (typeof data === 'string') {
try {
bangumiData = JSON.parse(data.replace(/(?<!":|\/|\\)("[^":,\]}][^"]*?[^":])"(?!,|]|}|:)/g, '\\$1\\"'));
} catch (e) {
log.error(`Error parsing data for ID: ${config.itemData.id}:`, e);
return null;
}
}
// 缓存有效数据
const subjectPath = path.join(cachePath, `${config.itemData.id}.json`);
if (!fs.existsSync(subjectPath) && bangumiData.id) {
const cacheData = {
id: bangumiData.id,
name: bangumiData.name,
type: bangumiData.type,
image: bangumiData.image,
rating: { score: bangumiData.rating?.score },
summary: bangumiData.summary,
collection: {
wish: bangumiData.collection?.wish,
doing: bangumiData.collection?.doing,
collect: bangumiData.collection?.collect
},
epsLength: bangumiData.eps?.length
};
fs.writeFileSync(subjectPath, JSON.stringify(cacheData));
}
return formatSubjectData(bangumiData, config, coverMirror);
}).filter(Boolean);
};
/**
* 从API获取Bangumi数据
* @param {Array} items 项目列表
* @param {string} sourceDir 源目录
* @param {Object} proxy 代理配置
* @param {string} coverMirror 图片镜像源
* @returns {Promise<Array>} 处理后的数据
*/
const getBangumiDataFromBangumiApi = async (items, sourceDir, proxy, coverMirror) => {
const cachePath = ensureCacheDir(sourceDir, CONSTANTS.CACHE.API_DIR);
const processItem = async (item) => {
const subjectPath = path.join(cachePath, `${item.id}.json`);
// 尝试从缓存获取数据
if (fs.existsSync(subjectPath)) {
try {
const cachedData = JSON.parse(fs.readFileSync(subjectPath).toString());
return { config: { itemData: item }, data: cachedData, status: 200 };
} catch (error) {
log.error(`Failed to read cache for item ${item.id}:`, error);
}
}
// 从API获取数据
try {
const config = createRequestConfig(proxy, item);
config.headers = { 'user-agent': CONSTANTS.API.USER_AGENT };
const response = await axios.get(
`${CONSTANTS.API.BGM_API_URL}/${item.id}`,
config
);
return response;
} catch (error) {
handleApiError(error);
return { config: { itemData: item }, error };
}
};
const results = await Promise.allSettled(items.map(processItem));
return results.map(({ value, reason }) => {
const { config, data, status } = value || reason;
// 处理错误情况
if (reason || status === 403 || !data) {
return {
id: config.itemData.id,
title: jp2cnName(config.itemData.name),
type: '未知',
cover: config.itemData.cover,
score: '-',
des: '-',
wish: '-',
doing: '-',
collect: '-',
totalCount: '-'
};
}
// 缓存有效数据
const subjectPath = path.join(cachePath, `${config.itemData.id}.json`);
if (!fs.existsSync(subjectPath) && data.id) {
fs.writeFileSync(subjectPath, JSON.stringify(data));
}
return formatApiData(data, config, coverMirror);
}).filter(Boolean);
};
/**
* 从网页获取条目ID列表
* @param {Object} params 参数对象
* @returns {Promise<Array>} 条目列表
*/
const getItemsId = async ({
vmid,
type,
status,
showProgress,
sourceDir,
proxy,
infoApi,
host,
coverMirror
}) => {
const getBangumiData = infoApi === 'bgmSub'
? getBangumiDataFromBangumiSubject
: getBangumiDataFromBangumiApi;
const items = [];
let bar;
try {
// 获取第一页数据
const options = createRequestConfig(proxy);
const response = await axios.get(`https://${host}/${type}/list/${vmid}/${status}?page=1`, options);
const username = response.request.path.match(/(anime|game|real)\/list\/(.*?)\//)?.[2];
if (!username) {
throw new Error('Failed to get "username"!');
}
if (!response?.data) {
throw new Error('No data received from server');
}
const $ = cheerio.load(response.data);
// 获取总页数
const pageNum = Math.max(
...$('#multipage').find('a')
.map((index, el) => parseInt($(el).attr('href')?.match(/\?page=([\d]+)/)?.[1] || '0', 10))
.get()
) || $('#multipage').find('a').length;
// 处理第一页数据
items.push(...await getBangumiData(
extractItemsFromPage($),
sourceDir,
proxy,
coverMirror
));
// 显示进度条
if (showProgress) {
bar = new ProgressBar(
`正在获取 ${CONSTANTS.STATUS_TEXT[status]} ${CONSTANTS.TYPE_TEXT[type]} [:bar] :percent :elapseds`,
{ total: pageNum < 2 ? 1 : pageNum, complete: '█' }
);
bar.tick();
}
if (pageNum < 2) {
return items;
}
// 获取剩余页面数据
for (let i = 2; i <= pageNum; i++) {
if (showProgress) bar.tick();
const response = await axios.get(
`https://${host}/${type}/list/${username}/${status}?page=${i}`,
{
...options,
headers: { 'User-Agent': CONSTANTS.API.BROWSER_USER_AGENT }
}
);
const $ = cheerio.load(response.data);
items.push(...await getBangumiData(
extractItemsFromPage($),
sourceDir,
proxy,
coverMirror
));
}
return items;
} catch (error) {
log.error('Error fetching items:', error);
return [];
}
};
/**
* 从页面提取条目数据
* @param {Object} $ Cheerio对象
* @returns {Array} 条目数据列表
*/
const extractItemsFromPage = ($) => $('#browserItemList>li').map((index, el) => ({
id: $(el).attr('id').replace('item_', ''),
cover: $(el).find('img').attr('src'),
name: $(el).find('h3>a').text(),
myStars: $(el).find('.starlight').attr('class')
?.match(/stars([\d]+)/)?.[1],
myComment: $(el).find('#comment_box').text()
.trim()
})).get();
/**
* 保存数据到文件
* @param {string} filePath 文件路径
* @param {Object} data 要保存的数据
* @param {string} type 数据类型
*/
const saveDataToFile = (filePath, data, type) => {
try {
fs.writeFileSync(filePath, JSON.stringify(data));
log.info(`Bangumi ${type} data has been saved to ${filePath}`);
} catch (err) {
log.error(`Failed to write data to ${filePath}`);
console.error(err);
throw err;
}
};
/**
* 合并额外数据
* @param {Object} original 原始数据
* @param {Object} extra 额外数据
* @param {number} extraOrder 合并顺序
* @returns {Object} 合并后的数据
*/
const mergeExtraData = (original, extra, extraOrder) => {
const result = { ...original };
const categories = ['wantWatch', 'watching', 'watched'];
categories.forEach((category) => {
const extraData = extra[`${category}Extra`];
if (extraData) {
result[category] = extraOrder === 1
? [...extraData, ...result[category]]
: [...result[category], ...extraData];
}
});
return result;
};
/**
* 获取Bangumi数据
* @param {Object} options 配置选项
* @returns {Promise<void>}
*/
module.exports.getBgmData = async function getBgmData({
vmid,
type,
showProgress,
sourceDir,
extraOrder,
pagination,
proxy,
infoApi,
host,
coverMirror
}) {
try {
log.info('Getting bangumis, please wait...');
const startTime = new Date().getTime();
const options = {
vmid,
type: CONSTANTS.TYPE_PATH_MAP[type],
showProgress,
sourceDir,
proxy,
infoApi,
host,
coverMirror
};
// 获取三种状态的数据
const wantWatch = await getItemsId({
...options,
status: 'wish'
});
const watching = await getItemsId({
...options,
status: 'do'
});
const watched = await getItemsId({
...options,
status: 'collect'
});
const endTime = new Date().getTime();
log.info(`${wantWatch.length + watching.length + watched.length} ${type}s have been loaded in ${endTime - startTime} ms`);
const bangumis = { wantWatch, watching, watched };
// 确保目录存在
const dataDir = path.join(sourceDir, '/_data/');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// 保存原始数据
saveDataToFile(
path.join(dataDir, `${type}s.json`),
bangumis,
type
);
// 处理分页数据
if (pagination) {
let allBangumis = { ...bangumis };
const extraPath = path.join(dataDir, `extra_${type}s.json`);
// 合并额外数据
if (fs.existsSync(extraPath)) {
try {
const extraData = JSON.parse(fs.readFileSync(extraPath, 'utf8'));
allBangumis = mergeExtraData(allBangumis, extraData, extraOrder);
} catch (error) {
log.error(`Failed to parse extra data from ${extraPath}`);
console.error(error);
}
}
// 保存合并后的数据
saveDataToFile(
path.join(sourceDir, `${type}s.json`),
allBangumis,
`${type} (with extras)`
);
}
} catch (error) {
log.error('Failed to get bangumi data:', error);
throw error;
}
};