hexo-bilibili-bangumi
Version:
314 lines (285 loc) • 9.34 kB
JavaScript
/*
* @Author : HCLonely
* @Date : 2024-09-11 15:27:32
* @LastEditTime : 2025-07-10 10:03:14
* @LastEditors : HCLonely
* @FilePath : /hexo-bilibili-bangumi/src/lib/get-bgmv0-data.js
* @Description : Bangumi.tv V0版API数据获取模块,支持获取用户的书籍、动画、音乐、
* 游戏和三次元内容数据。包含评分、标签、进度等详细信息,并支持
* 数据分页和自定义封面镜像源等功能。
*/
const fs = require('hexo-fs');
const path = require('path');
const axios = require('axios');
const hexoLog = require('hexo-log');
const ProgressBar = require('progress');
// 常量定义
const CONSTANTS = {
TYPE: {
1: '书籍',
2: '动画',
3: '音乐',
4: '游戏',
6: '三次元'
},
TYPE_EN: {
1: 'book',
2: 'bangumi',
3: 'music',
4: 'game',
6: 'cinema'
},
TYPE_STATUS: {
1: ['[想看]', '[已看]', '[在看]'],
2: ['[想看]', '[已看]', '[在看]'],
3: 'music',
4: ['[想玩]', '[已玩]', '[在玩]'],
6: ['[想看]', '[已看]', '[在看]']
},
API: {
BASE_URL: 'http://api.bgm.tv/v0',
PAGE_SIZE: 30,
USER_AGENT: 'HCLonely/hexo-bilibili-bangumi'
},
MAX_RETRIES: 3,
RETRY_DELAY: 1000
};
// 初始化日志
const log = typeof hexoLog.default === 'function'
? hexoLog.default({ debug: false, silent: false })
: hexoLog({ debug: false, silent: false });
/**
* 延迟函数
* @param {number} ms 延迟时间(毫秒)
* @returns {Promise<void>}
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* 带重试机制的API请求
* @param {Function} apiCall API调用函数
* @param {number} retries 重试次数
* @returns {Promise<any>}
*/
async function withRetry(apiCall, retries = CONSTANTS.MAX_RETRIES) {
for (let i = 0; i < retries; i++) {
try {
return await apiCall();
} catch (error) {
if (i === retries - 1) throw error;
await delay(CONSTANTS.RETRY_DELAY);
log.warn(`请求失败,正在进行第${i + 1}次重试...`);
}
}
}
/**
* 获取数据总页数
* @param {string} vmid 用户ID
* @param {number} status 状态
* @param {number} typeNum 类型
* @returns {Promise<{success: boolean, data: number|string}>}
*/
const getDataPage = async (vmid, status, typeNum) => {
const response = await withRetry(() => (
axios.get(`${CONSTANTS.API.BASE_URL}/users/${vmid}/collections`, {
params: {
subject_type: typeNum,
type: status,
limit: 1,
offset: 0
},
headers: {
'User-Agent': CONSTANTS.API.USER_AGENT
}
})
));
if (typeof response?.data?.total !== 'undefined') {
return {
success: true,
data: Math.ceil(response.data.total / CONSTANTS.API.PAGE_SIZE) + 1
};
}
return {
success: false,
data: response?.data || '获取数据失败'
};
};
/**
* 格式化条目数据
* @param {Object} bangumi 条目数据
* @param {string} coverMirror 图片镜像源
* @returns {Object} 格式化后的数据
*/
const formatItemData = (bangumi, coverMirror) => ({
title: bangumi?.subject?.name_cn || bangumi?.subject?.name,
type: CONSTANTS.TYPE[bangumi?.subject_type || bangumi?.type] || '未知',
cover: coverMirror + bangumi?.subject?.images?.common,
totalCount: bangumi?.subject?.eps,
id: bangumi?.subject_id || bangumi?.subject?.id,
score: bangumi?.subject?.score ?? '-',
des: bangumi?.subject?.short_summary ? `${bangumi.subject.short_summary.trim()}...` : '-',
collect: bangumi?.subject?.collection_total ?? '-',
myStars: bangumi?.rate || null,
myComment: bangumi?.comment || null,
progress: Math.round(((bangumi?.ep_status || 0) / (bangumi?.subject?.eps || 1)) * 100),
tags: bangumi?.subject?.tags?.[0]?.name || '-',
ep_status: bangumi?.ep_status || 0
});
/**
* 获取单页数据
* @param {string} vmid 用户ID
* @param {number} status 状态
* @param {number} typeNum 类型
* @param {number} pn 页码
* @param {string} coverMirror 图片镜像源
* @returns {Promise<Array>}
*/
const getData = async (vmid, status, typeNum, pn, coverMirror) => {
const response = await withRetry(() => (
axios.get(`${CONSTANTS.API.BASE_URL}/users/${vmid}/collections`, {
params: {
subject_type: typeNum,
type: status,
limit: CONSTANTS.API.PAGE_SIZE,
offset: pn
},
headers: {
'User-Agent': CONSTANTS.API.USER_AGENT
}
})
));
if (response?.status !== 200) {
throw new Error(`获取数据失败: HTTP ${response?.status}`);
}
return (response?.data?.data || []).map((item) => formatItemData(item, coverMirror));
};
/**
* 保存数据到文件
* @param {string} filePath 文件路径
* @param {Object} data 要保存的数据
* @param {string} type 数据类型(用于日志)
*/
const saveDataToFile = (filePath, data, type) => {
try {
fs.writeFileSync(filePath, JSON.stringify(data));
log.info(`Bgm ${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 合并顺序(1: 额外数据在前,其他: 额外数据在后)
* @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;
};
/**
* 处理分页数据
* @param {Object} params 参数对象
* @returns {Promise<Array>}
*/
const processData = async (vmid, status, showProgress, typeNum, coverMirror) => {
const page = await getDataPage(vmid, status, typeNum);
if (!page?.success) {
log.error(`Get ${CONSTANTS.TYPE_EN[typeNum]} data error:`, page?.data);
return [];
}
const list = [];
let bar = null;
if (showProgress) {
const statusText = CONSTANTS.TYPE_STATUS[typeNum][status - 1];
const typeText = CONSTANTS.TYPE[typeNum];
bar = new ProgressBar(`正在获取 ${statusText} ${typeText} [:bar] :percent :elapseds`, {
total: page.data - 1,
complete: '█'
});
}
try {
for (let i = 1; i < page.data; i++) {
if (showProgress) bar.tick();
const data = await getData(vmid, status, typeNum, (i - 1) * CONSTANTS.API.PAGE_SIZE, coverMirror);
list.push(...data);
}
return list;
} catch (error) {
log.error(`处理数据时发生错误: ${error.message}`);
return [];
}
};
/**
* 获取Bangumi数据
* @param {Object} options 配置选项
* @returns {Promise<void>}
*/
module.exports.getBgmv0Data = async ({
vmid,
type,
showProgress,
sourceDir,
extraOrder,
pagination,
coverMirror
}) => {
try {
log.info(`Getting bgm ${CONSTANTS.TYPE_EN[type]} data, please wait...`);
const startTime = new Date().getTime();
// 获取三种状态的数据
const wantWatch = await processData(vmid, 1, showProgress, type, coverMirror);
const watching = await processData(vmid, 3, showProgress, type, coverMirror);
const watched = await processData(vmid, 2, showProgress, type, coverMirror);
const endTime = new Date().getTime();
log.info(`${wantWatch.length + watching.length + watched.length} ${CONSTANTS.TYPE_EN[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, `${CONSTANTS.TYPE_EN[type]}s.json`),
bangumis,
CONSTANTS.TYPE_EN[type]
);
// 处理分页数据
if (pagination) {
let allBangumis = { ...bangumis };
const extraPath = path.join(dataDir, `extra_${CONSTANTS.TYPE_EN[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, `${CONSTANTS.TYPE_EN[type]}s.json`),
allBangumis,
`${CONSTANTS.TYPE_EN[type]} (with extras)`
);
}
} catch (error) {
log.error(`Failed to get bgm ${CONSTANTS.TYPE_EN[type]} data`);
console.error(error);
throw error;
}
};