').attr('class', 'message-box').append($msgTxt).css({
height: 'auto',
minHeight: '46px',
width: '360px',
borderRadius: '4px',
transform: 'translateX(110%)',
backgroundColor: msgBgColor,
boxShadow: '0 0 10px rgba(0,0,0,0.3)',
padding: '10px 15px',
zIndex: '9999',
transition: 'transform 0.5s ease-out',
display: 'flex',
alignItems: 'flex-start', // 改成顶部对齐,避免多行时垂直居中不自然
justifyContent: 'flex-start',
opacity: 0.95,
});
$msgBoxes.prepend($msgBox);
setTimeout(() => { $msgBox.css({ transform: 'translateX(-10px)' }); }, 50);
setTimeout(() => {
$msgBox.css({ transform: 'translateX(110%)' });
setTimeout(() => { $msgBox.remove(); }, 500);
}, duration);
}
function findDomElementForSeries(komgaSeriesId) {
let $dom = $(`div.v-card[komgaseriesid="${komgaSeriesId}"]`);
if ($dom.length > 0) return $dom.first();
$dom = $(`div.my-2.mx-2[komgaseriesid="${komgaSeriesId}"]`);
if ($dom.length > 0) return $dom.first();
return null;
}
function loadSearchBtn($dom, komgaSeriesId) {
$dom.attr('komgaSeriesId', komgaSeriesId);
const width = $dom.width();
const btnDia = Math.max(width / 5.5, 34);
let $syncInfo = $('
').attr('komgaSeriesId', komgaSeriesId);
let $syncAll = $('
').attr('komgaSeriesId', komgaSeriesId);
const currentBtnStyle = { ...btnStyle, width: btnDia, height: btnDia };
// 检查当前是否在 '/collections' 或 '/readlists' 页面
const leftSidePages = ['/collections', '/readlists'];
const isLeftSidePage = leftSidePages.some(path => window.location.pathname.includes(path));
if (isLeftSidePage) {
// 在 '/collections' 或 '/readlists' 页面,图标移动到左侧
$syncInfo.css({ ...currentBtnStyle, left: '10px' });
$syncAll.css({ ...currentBtnStyle, left: btnDia + 15 + 'px' });
} else {
// 其他页面,图标保持在右侧
$syncAll.css({ ...currentBtnStyle, right: '10px' });
$syncInfo.css({ ...currentBtnStyle, right: btnDia + 15 + 'px' });
}
$syncAll.append('
');
$syncInfo.append('
');
$syncAll.add($syncInfo).on('mouseenter', function () {
$(this).css({ 'background-color': 'yellow', color: '#3c3c3c' });
}).on('mouseleave', function () {
$(this).css({ 'background-color': 'orange', color: '#efefef' });
});
$syncInfo.on('click', async (e) => {
e.stopPropagation();
await handleSearchClick(komgaSeriesId, 'meta', $dom);
});
$syncAll.on('click', async (e) => {
e.stopPropagation();
await handleSearchClick(komgaSeriesId, 'all', $dom);
});
$dom.append($syncAll).append($syncInfo);
$dom.on('mouseenter', function () {
$syncAll.add($syncInfo).css({ opacity: '1', 'pointer-events': 'auto' });
}).on('mouseleave', function () {
$syncAll.add($syncInfo).css({ opacity: '0', 'pointer-events': 'none' });
});
}
function showBookSelectionPanel(seriesListRes) {
return new Promise((resolve) => {
const $selBookPanel = $('
').css({ ...selPanelStyle }); // selPanelStyle 需要在全局定义
seriesListRes.forEach((series) => {
const $selBookBtn = $('
')
.attr('resSeriesId', series.id)
.css({ ...selPanelBtnStyle }); // selPanelBtnStyle 需要在全局定义
// 内部容器,用于更好的内容布局和padding
const $contentWrapper = $('
').css({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center', // 垂直居中内容
alignItems: 'center', // 水平居中内容
height: '100%', // 撑满按钮高度
padding: '5px', // 按钮内边距
boxSizing: 'border-box',
position: 'relative', // 为内部元素的z-index服务
zIndex: 2 // 确保内容在背景图遮罩之上
});
// 1. 显示主标题 (通常是 series.name_cn)
$contentWrapper.append(
$('
').css({
fontWeight: 'bold',
fontSize: '14px', // 可以根据按钮大小调整
marginBottom: '5px',
wordBreak: 'break-word', // 防止长标题溢出
maxHeight: '2.8em', // 限制标题高度,约两行
overflow: 'hidden',
textOverflow: 'ellipsis',
// lineHeight: '1.4em' // 可选,用于精确控制行高
}).text(series.title) // 使用 .text() 以避免HTML注入
);
// 2. 显示原始标题 (series.name / series.orititle),如果与主标题不同且存在
if (series.orititle && series.orititle.trim() !== '' && series.orititle.toLowerCase() !== series.title.toLowerCase()) {
$contentWrapper.append(
$('
').css({
fontSize: '11px',
color: '#e0e0e0', // 在深色背景/遮罩上应该可见
marginBottom: '4px',
fontStyle: 'italic',
wordBreak: 'break-word',
maxHeight: '1.3em', // 限制一行
overflow: 'hidden',
textOverflow: 'ellipsis',
}).text(series.orititle)
);
}
// 3. 显示作者 (排除 "取消选择" 按钮)
if (series.id !== -1) { // series.id === -1 是取消按钮
const authorText = series.author?.trim() || '未知作者';
$contentWrapper.append(
$('
').css({
fontSize: '12px',
color: '#f0f0f0', // 确保在背景图上可读
marginBottom: '4px',
wordBreak: 'break-word',
maxHeight: '1.4em', // 限制一行
overflow: 'hidden',
textOverflow: 'ellipsis',
}).text(authorText)
);
}
// 4. 显示别名 (排除 "取消选择" 按钮,且别名存在)
if (series.id !== -1 && series.aliases && series.aliases.trim() !== '') {
$contentWrapper.append(
$('
').css({
fontSize: '10px', // 别名用更小的字号
color: '#cccccc', // 浅灰色
marginTop: '2px',
wordBreak: 'break-word',
maxHeight: '2.4em', // 限制约两行的高度
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '1.2em', // 调整行高以适应两行
// whiteSpace: 'normal' // 确保能换行
}).text(`别名: ${series.aliases}`)
);
} else if (series.id !== -1 && (!series.aliases || series.aliases.trim() === '')) {
// 如果不是取消按钮,但没有别名,可以添加一个占位符以帮助对齐,如果需要的话
// $contentWrapper.append($('
').css({ height: '1.2em', marginTop: '2px' }));
}
// 如果是 "取消选择" 按钮的特殊处理 (清空内容,只显示标题)
if (series.id === -1) {
$contentWrapper.empty(); // 清空之前可能添加的内容
$contentWrapper.append(
$('
').css({
fontWeight: 'bold',
fontSize: '16px', // 取消按钮的文字可以大一些
color: 'white' // 确保在红色背景上白色文字清晰
}).text(series.title) // "取消选择"
);
$selBookBtn.css({ // 取消按钮的特定样式
backgroundColor: '#dc3545', // 红色背景
color: 'white',
backgroundImage: 'none', // 无背景图片
textShadow: 'none', // 无文字阴影
minHeight: '60px', // 保持一个最小高度
height: 'auto' // 高度自适应内容
});
}
$selBookBtn.append($contentWrapper);
// 背景图片和遮罩逻辑 (仅对非取消按钮)
if (series.cover && series.id !== -1) {
$selBookBtn.css({
'background-image': `url(${series.cover})`,
'background-size': 'cover',
'background-position': 'center',
'background-repeat': 'no-repeat',
'text-shadow': '1px 1px 3px #000, -1px -1px 3px #000, 1px -1px 3px #000, -1px 1px 3px #000', // 增强文字对比度
'color': 'white', // 确保文字默认为白色,在深色遮罩上可读
'position': 'relative', // 为遮罩定位
// 'border': '1px solid rgba(255,255,255,0.2)', // 可选的边框
});
// 添加遮罩层,确保它在背景图之上,内容在遮罩之上
const $overlay = $('
').css({
position: 'absolute',
top: 0, left: 0,
width: '100%', height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.65)', // 增加遮罩不透明度
borderRadius: '10px', // 与按钮圆角一致
zIndex: 1 // 遮罩在背景图之上
});
$selBookBtn.prepend($overlay); // Prepend 使其在 $contentWrapper 之下
}
$selBookBtn.on('click', function (e) {
e.stopPropagation();
const seriesIdRes = $(this).attr('resSeriesId');
$selBookPanel.remove(); // 关闭面板
resolve(seriesIdRes); // 返回选择的 ID
});
$selBookPanel.append($selBookBtn);
});
$selBookPanel.appendTo('body');
});
}
async function performSearchAndHandleResults(searchTerm, komgaSeriesId, $dom, searchType) {
if (!searchTerm || searchTerm.trim() === '') {
showMessage('搜索词不能为空', 'warning');
return Promise.reject('Empty search term');
}
showMessage('正在查找《' + searchTerm + '》', 'info', 2000);
try {
let seriesListRes = await fetchBookByName(searchTerm, searchType); // searchType is 'btv' or 'bof'
let seriesIdRes = 0;
if (seriesListRes.length > 0) {
if (seriesListRes.length > 8) seriesListRes = seriesListRes.slice(0, 8); // Limit to 8 results + Cancel
seriesListRes.push({ id: -1, title: '取消选择', author: '' }); // Add cancel option
seriesIdRes = await showBookSelectionPanel(seriesListRes);
if (seriesIdRes === "-1") { // User cancelled
showMessage('检索《' + searchTerm + '》已取消', 'warning');
return Promise.reject('Selection cancelled');
} else if (seriesIdRes && seriesIdRes !== "-1") { // Valid selection
console.log('performSearchAndHandleResults: calling fetchBookByUrl with', komgaSeriesId, seriesIdRes, searchType);
partLoadingStart($dom);
// seriesIdRes is the ID from the external source (BTV or BOF)
await fetchBookByUrl(komgaSeriesId, seriesIdRes, '', searchType); // searchType is 'btv' or 'bof'
return Promise.resolve();
} else {
return Promise.reject('Invalid selection ID');
}
} else {
showMessage('检索《' + searchTerm + '》未找到', 'error', 4000);
return Promise.reject('No results found');
}
} catch (error) {
console.error(`Error during search/fetch process for "${searchTerm}":`, error);
showMessage(`处理《${searchTerm}》时出错: ${error.message || error}`, 'error');
partLoadingEnd($dom); // Ensure loading ends on error
return Promise.reject(error);
}
// partLoadingEnd($dom); // This was here, but fetchBookByUrl handles its own partLoadingEnd now.
}
function extractSeriesTitles(seriesName, limitCount) {
const parts = [...seriesName.matchAll(/\[([^\[\]]+)\]/g)].map(m => m[1]);
let title = '';
let authors = [];
const authorCandidate = parts.find(p => /[×]/.test(p));
if (authorCandidate) {
authors = authorCandidate.split(/[×]/).map(s => t2s(s.trim()));
for (let p of parts) {
if (p !== authorCandidate && !title) {
title = t2s(p.trim());
}
}
} else {
if (parts.length > 0) title = t2s(parts[0].trim());
if (parts.length > 1) authors = [t2s(parts[1].trim())];
}
const titleParts = title ? title.split('_').map(t => t.trim()) : [];
const combinedParts = [...titleParts, ...authors];
let cleanedTitles = combinedParts.map(t =>
t2s(t.trim())
);
const minimalProcessedTitle = t2s(seriesName
.replace(/[\(\[【(]?境外版[\)\]】)]?\s*/g, '')
.replace(/[\(\[【(]?单行本[\)\]】)]?\s*$/g, '')
.replace(/[\(\[【(]?\d+卷[\)\]】)]?\s*$/g, '')
.replace(/\[.*?\]/g, '')
.replace(/【.*?】/g, '')
.replace(/[(())]/g, ' ')
.replace(/[_-]?\s*$/g, '')
.trim()
);
const finalTitles = [...new Set(
cleanedTitles
.map(t => t
.replace(/[::•·․,,。'’??!!~⁓~]/g, ' ')
.replace(///g, '/')
.trim()
)
.filter(Boolean)
)];
if (minimalProcessedTitle && !finalTitles.includes(minimalProcessedTitle)) {
finalTitles.unshift(minimalProcessedTitle);
}
if (typeof limitCount === 'number') {
if (limitCount <= 0) return [];
return finalTitles.slice(0, limitCount);
}
return finalTitles;
}
async function selectSeriesTitle(komgaSeriesId, $dom) {
return new Promise(async (resolve, reject) => {
const komgaMeta = await getKomgaSeriesMeta(komgaSeriesId).catch(() => null);
const oriTitle = await getKomgaOriTitle(komgaSeriesId).catch(() => '');
const seriesName = (komgaMeta?.title?.trim()) || oriTitle;
if (!seriesName) {
showMessage(`无法获取系列 ${komgaSeriesId} 的标题`, 'error');
return reject('Failed to get original title');
}
const selTitles = extractSeriesTitles(seriesName);
const $selTitlePanel = $('
').css({ ...selPanelStyle });
const searchType = localStorage.getItem(`STY-${komgaSeriesId}`); // 'btv' or 'bof'
if (selTitles.length > 0) {
selTitles.forEach((title) => {
let $btn = $('
').text(title).css(selPanelBtnStyle);
$btn.on('click', async function (e) {
e.stopPropagation();
$selTitlePanel.remove();
try {
await performSearchAndHandleResults(title, komgaSeriesId, $dom, searchType);
resolve();
} catch (err) {
reject(err);
}
});
$selTitlePanel.append($btn);
});
} else {
const $msg = $('
未能自动提取关键词,请手动输入。
');
$selTitlePanel.append($msg);
}
const $manualInputContainer = $('
').css({
gridColumn: '1 / -1',
display: 'flex',
gap: '10px',
marginTop: '15px',
padding: '10px',
borderTop: '1px solid #ccc'
});
const $manualInput = $('
').attr('id', 'manualSearchInput').css({
flexGrow: 1,
padding: '8px 10px',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '14px'
}).attr('placeholder', '或在此手动输入搜索词');
const $manualSearchBtn = $('
').css({
...selPanelBtnStyle,
width: 'auto',
height: 'auto',
padding: '8px 15px',
backgroundColor: '#007bff',
flexShrink: 0
});
$manualInput.on('keydown', function (e) {
if (e.key === 'Enter') $manualSearchBtn.click();
});
$manualSearchBtn.on('click', async function (e) {
e.stopPropagation();
const manualTerm = $('#manualSearchInput').val().trim();
if (!manualTerm) {
showMessage('请输入手动搜索词', 'warning');
return;
}
$selTitlePanel.remove();
try {
await performSearchAndHandleResults(manualTerm, komgaSeriesId, $dom, searchType);
resolve();
} catch (err) {
reject(err);
}
});
$manualInputContainer.append($manualInput).append($manualSearchBtn);
$selTitlePanel.append($manualInputContainer);
const $cancelBtn = $('
').css({
...selPanelBtnStyle,
gridColumn: '1 / -1',
marginTop: '10px',
backgroundColor: '#dc3545',
minHeight: '50px',
height: 'auto',
padding: '10px'
});
$cancelBtn.on('click', function (e) {
e.stopPropagation();
$selTitlePanel.remove();
showMessage('搜索已取消', 'warning');
reject('Title selection cancelled');
});
$selTitlePanel.append($cancelBtn);
$selTitlePanel.appendTo('body');
setTimeout(() => $manualInput.focus(), 100);
});
}
function showBatchProgress(current, total, stats) {
let bar = document.getElementById('batchProgressBar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'batchProgressBar';
bar.style.position = 'fixed';
bar.style.bottom = '0';
bar.style.left = '0';
bar.style.width = '100%';
bar.style.zIndex = '9999';
bar.style.fontFamily = 'Arial, sans-serif';
bar.innerHTML = `
`;
document.body.appendChild(bar);
}
let percent = ((current / total) * 100).toFixed(1);
document.getElementById('batchProgressText').textContent =
`批量匹配进度: ${current}/${total} | 成功: ${stats.successCount} 失败: ${stats.failureCount} 跳过: ${stats.skippedCount}`;
document.getElementById('batchProgressFill').style.width = percent + '%';
}
function hideBatchProgress() {
const bar = document.getElementById('batchProgressBar');
if (bar) bar.remove();
}
//
// ************************************** 事件处理 **************************************
//
async function handleSearchClick(komgaSeriesId, type, $dom) {
// type is 'meta' or 'all' (sync type for Komga)
localStorage.setItem(`SID-${komgaSeriesId}`, type); // Store Komga sync type
await search(komgaSeriesId, $dom);
}
//
// ************************************** 数据处理 **************************************
//
async function filterSeriesMeta(komgaSeriesId, seriesMeta) {
const komgaMeta = await getKomgaSeriesMeta(komgaSeriesId);
if (!komgaMeta) {
// If no existing Komga meta, return the new meta as is
return seriesMeta;
}
// Links: Merge and keep unique by label (case-insensitive)
const existingLinks = komgaMeta.links || [];
const newLinks = seriesMeta.links || [];
const combinedLinks = [...existingLinks, ...newLinks];
seriesMeta.links = combinedLinks.filter(
(link, index, self) =>
link.label && self.findIndex((t) => t.label && t.label.toLowerCase() === link.label.toLowerCase()) === index
);
// Tags: Merge, convert s2t, normalize, and keep unique
let combinedTags = [...(komgaMeta.tags || []), ...(seriesMeta.tags || [])].map((t) => t2s(t)); // s2t on all tags
combinedTags = combinedTags.map((t) => {
const matchingLabel = equalLabels.find((labels) => labels.split(',').includes(t));
return matchingLabel ? matchingLabel.split(',')[0] : t; // Normalize
}).filter(Boolean); // Remove empty tags
seriesMeta.tags = Array.from(new Set(combinedTags)); // Unique
// Alternate Titles: Merge, sort (原名 first, 别名 last), and keep unique by title (case-insensitive)
let combinedAltTitles = [...(komgaMeta.alternateTitles || []), ...(seriesMeta.alternateTitles || [])];
combinedAltTitles.sort((a, b) => { // Custom sort: "原名" first, "别名" tends to be less specific
if (a.label === '原名') return -1;
if (b.label === '原名') return 1;
if (a.label === '别名') return 1; // Put "别名" after more specific ones if not "原名"
if (b.label === '别名') return -1;
return 0;
});
seriesMeta.alternateTitles = combinedAltTitles.filter(
(altTitle, index, self) =>
altTitle.title && self.findIndex((t) => t.title && t.title.toLowerCase() === altTitle.title.toLowerCase()) === index
);
// Respect Komga's lock fields
for (const keyName in seriesMeta) {
if (komgaMeta[keyName + 'Lock'] === true) {
// console.log(`KomgaBangumi: Field "${keyName}" is locked for series ${komgaSeriesId}. Skipping update.`);
delete seriesMeta[keyName]; // Remove from payload if locked
delete seriesMeta[keyName + 'Lock']; // Also remove the lock field itself from payload if it was carried over
}
}
return seriesMeta;
}
function extractAndNormalizeTitle(str) {
const title = extractSeriesTitles(String(str), 1)[0] || '';
return title
.replace(/[::•·․,,。、'’??!!~⁓~]/g, ' ')
.replace(/\s+/g, '')
.trim()
.toLowerCase();
}
// 辅助函数:规范化日期字符串
function normalizeDate(dateStr) {
if (!dateStr) return undefined;
dateStr = dateStr.trim();
let match;
match = dateStr.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日$/);
if (match) {
const [, y, m, d] = match;
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
}
match = dateStr.match(/^(\d{4})年(\d{1,2})月$/);
if (match) {
const [, y, m] = match;
return `${y}-${m.padStart(2, '0')}-01`;
}
match = dateStr.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (match) {
const [, y, m, d] = match;
return `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
}
match = dateStr.match(/^(\d{4})-(\d{1,2})$/);
if (match) {
const [, y, m] = match;
return `${y}-${m.padStart(2, '0')}-01`;
}
return undefined;
}
function extractVolumeNumber(name) {
if (!name) return null;
let match = name.match(/Vol[.\s](\d{1,4})$/);
if (match) return parseInt(match[1], 10);
match = name.match(/\((\d+)\)$/);
if (match) return parseInt(match[1], 10);
match = name.match(/\s(\d{1,4})$/);
if (match) return parseInt(match[1], 10);
match = name.match(/第(\d{1,4})卷$/);
if (match) {
return parseInt(match[1], 10);
}
return null;
}
function normalizeVolNum(raw) {
const num = extractVolumeNumber(raw);
return num ? String(num) : '';
}
//
// ************************************** API封装 **************************************
//
function asyncReq(url, method, data_ry = {}, headers = null, responseType = 'text') {
return new Promise((resolve, reject) => {
let requestHeaders = { ...headers }; // 从传入的 headers 开始
let requestData = data_ry;
if (data_ry instanceof FormData) {
// 对于 FormData, Content-Type 由浏览器设置
} else if (method !== "GET" && typeof data_ry === 'object') {
requestData = JSON.stringify(data_ry);
requestHeaders = { ...defaultReqHeaders, ...requestHeaders }; // 与默认值合并,传入的 headers 优先
} else if (method === "GET") {
requestData = undefined;
// 对于 GET, 通常不需要 Content-Type
delete requestHeaders['content-type']; // 确保 GET 请求没有默认的 content-type
}
// 如果适用,添加 Bangumi 特定请求头和 Authorization 令牌
if (url.startsWith(btvApiUrl)) {
requestHeaders = { ...bangumiApiHeaders, ...requestHeaders }; // Bangumi 基础请求头优先,然后是特定调用的请求头
const accessToken = getBangumiAccessToken();
if (accessToken) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
// console.log("正在为请求使用Bangumi Access Token:", url.substring(0,60));
} else {
// console.log("未找到用于请求的Bangumi Access Token:", url.substring(0,60));
}
}
let requestUrl = url;
// 为 GET 请求添加缓存清除参数,除非是不喜欢它的API (例如外部API)
if (method === 'GET' && !url.startsWith(btvApiUrl) && !url.startsWith(bofUrl) && !url.startsWith(mangadexApiUrl)) {
requestUrl += (url.includes('?') ? '&' : '?') + '_=' + Date.now();
}
GM_xmlhttpRequest({
method: method,
url: requestUrl,
headers: requestHeaders,
data: requestData,
responseType: responseType,
timeout: 30000, // 30 秒超时
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(responseType === 'text' || responseType === 'json' ? response.responseText : response.response);
} else if (response.status === 401 && url.startsWith(btvApiUrl)) { // Bangumi API 认证失败
console.error(`[asyncReq] Bangumi API 授权错误 (401): ${method} ${requestUrl.substring(0,100)}...`, response.statusText, response.responseText?.substring(0, 200));
// 检查是否存在已配置的 Access Token
const currentToken = getBangumiAccessToken();
if (currentToken) {
showMessage(
`Bangumi API认证失败(401)。您配置的Access Token可能已失效或不正确。请通过油猴脚本菜单更新Token。`,
'error',
15000 // 显示更长时间
);
} else {
showMessage(
`Bangumi API认证失败(401)。如果您想使用Access Token,请通过油猴脚本菜单进行配置。`,
'error',
10000
);
}
reject(new Error(`HTTP Error ${response.status}: ${response.statusText || 'Unauthorized'}. Bangumi Access Token might be invalid or expired.`));
}
else {
console.error(`[asyncReq] HTTP Error (${response.status}): ${method} ${requestUrl.substring(0,100)}...`, response.statusText, response.responseText?.substring(0, 200));
showMessage(`请求错误 (${response.status}): ${method} ${requestUrl.substring(0, 60)}...`, 'error', 7000);
reject(new Error(`HTTP Error ${response.status}: ${response.statusText || 'Unknown error'}`));
}
},
onerror: (error) => {
console.error(`[asyncReq] Network Error: ${method} ${requestUrl.substring(0,100)}...`, error);
showMessage(`网络请求失败: ${method} ${requestUrl.substring(0, 60)}...`, 'error', 7000);
reject(new Error('Network request failed'));
},
ontimeout: () => {
console.error(`[asyncReq] Timeout: ${method} ${requestUrl.substring(0,100)}...`);
showMessage(`请求超时: ${method} ${requestUrl.substring(0, 60)}...`, 'error', 7000);
reject(new Error('Request timed out'));
}
});
});
}
async function asyncPool(items, asyncFn, limit = 5) {
const ret = [];
const executing = new Set();
for (let i = 0; i < items.length; i++) {
const item = items[i];
// 调用异步函数,得到 Promise
const p = Promise.resolve().then(() => asyncFn(item, i));
ret.push(p);
// 添加到执行中集合
executing.add(p);
// 当执行中任务达到最大并发数,等待最先完成的一个
if (executing.size >= limit) {
await Promise.race(executing);
}
// 一旦 Promise 完成,从执行集合移除
p.finally(() => executing.delete(p));
}
// 等待剩余所有任务完成
return Promise.all(ret);
}
//
//
// 单页获取符合条件的系列
async function getAllSeries(payload) {
const url = `${location.origin}/api/v1/series/list`;
const params = new URLSearchParams({
unpaged: "true",
sort: "lastModified,desc"
});
try {
const respText = await asyncReq(`${url}?${params.toString()}`, 'POST', payload);
const data = JSON.parse(respText);
return data?.content || [];
} catch (e) {
console.error(`[getAllSeries] 获取系列数据失败:`, e);
throw e;
}
}
async function getSeriesWithLibraryId(libraryId) {
const payload = {
condition: {
allOf: [
{ libraryId: { operator: "is", value: libraryId } },
{ deleted: { operator: "isFalse" } }
]
}
};
showMessage(`正在获取数据库 #${libraryId} 所有系列...`, 'info');
try {
const allSeries = await getAllSeries(payload);
showMessage(`数据库 #${libraryId} 系列列表获取完毕,共 ${allSeries.length} 个`, 'success');
return allSeries;
} catch (e) {
showMessage(`获取数据库 #${libraryId} 系列失败: ${e.message || e}`, 'error', 5000);
return [];
}
}
async function getSeriesWithCollection(collectionIds) {
const ids = Array.isArray(collectionIds) ? collectionIds : [collectionIds];
const allSeries = [];
for (const id of ids) {
const payload = {
condition: {
allOf: [
{ collectionId: { operator: "is", value: String(id) } },
{ deleted: { operator: "isFalse" } }
]
}
};
try {
const seriesList = await getAllSeries(payload);
allSeries.push(...seriesList);
showMessage(`收藏夹 #${id} 系列列表获取完毕,共 ${seriesList.length} 个`, 'success');
} catch (error) {
console.error(`获取收藏夹 #${id} 系列时出错:`, error);
showMessage(`获取收藏夹 #${id} 系列失败:${error.message || error}`, 'error', 5000);
}
}
return allSeries;
}
async function getLatestSeries(libraryIds = null, page = 0) {
const params = new URLSearchParams({
size: 30,
page,
deleted: "false"
});
if (libraryIds) {
const ids = Array.isArray(libraryIds) ? libraryIds : [libraryIds];
ids.forEach(id => params.append("library_id", id));
}
try {
const resText = await asyncReq(`${location.origin}/api/v1/series/latest?${params.toString()}`, 'GET');
const data = JSON.parse(resText);
showMessage(`已获取最近系列:共 ${data.content?.length || 0} 项`, 'success', 3000);
return data?.content || [];
} catch (error) {
console.error("获取最近系列时出错:", error);
showMessage(`获取最近系列失败:${error.message || error}`, 'error', 5000);
return [];
}
}
async function getKomgaSeriesData(komgaSeriesId) {
const seriesUrl = `${location.origin}/api/v1/series/${komgaSeriesId}`;
try {
const seriesResStr = await asyncReq(seriesUrl, 'GET');
return JSON.parse(seriesResStr);
} catch (error) {
console.error(`[getKomgaSeriesData] Failed for ID ${komgaSeriesId}:`, error);
showMessage(`获取系列 ${komgaSeriesId} 数据失败`, 'error');
return null;
}
}
async function getKomgaSeriesMeta(komgaSeriesId) {
const seriesData = await getKomgaSeriesData(komgaSeriesId);
return seriesData ? seriesData.metadata : null;
}
async function getKomgaOriTitle(komgaSeriesId) { // This gets series.name (folder name)
const seriesData = await getKomgaSeriesData(komgaSeriesId);
return seriesData ? seriesData.name : null;
}
async function updateKomgaSeriesMeta(komgaSeriesId, komgaSeriesName, komgaSeriesMeta) {
const bookMetaUrl = `${location.origin}/api/v1/series/${komgaSeriesId}/metadata`;
// Filter out null or empty string values before sending, but allow empty arrays (for tags, links etc.)
const cleanMeta = Object.fromEntries(
Object.entries(komgaSeriesMeta).filter(([_, v]) => v !== null && v !== '' || (Array.isArray(v)))
);
if (Object.keys(cleanMeta).length === 0) {
// console.log(`[updateKomgaSeriesMeta] No metadata to update for ${komgaSeriesName}.`);
return;
}
try {
await asyncReq(bookMetaUrl, 'PATCH', cleanMeta); // Komga API handles empty arrays correctly (e.g. clearing tags)
showMessage(`《${komgaSeriesName}》系列信息已更新`, 'success', 1500);
} catch (e) {
console.error(`[updateKomgaSeriesMeta] Failed for ${komgaSeriesName}:`, e);
showMessage(`《${komgaSeriesName}》系列信息更新失败`, 'error', 5000);
}
}
async function getKomgaSeriesCovers(komgaSeriesId) {
let allSeriesCoverUrl = `${location.origin}/api/v1/series/${komgaSeriesId}/thumbnails`;
try {
const coversStr = await asyncReq(allSeriesCoverUrl, 'GET');
return JSON.parse(coversStr);
} catch (e) {
console.error(`[getKomgaSeriesCovers] Failed for ID ${komgaSeriesId}:`, e);
return []; // Return empty array on error
}
}
async function updateKomgaSeriesCover(komgaSeriesId, komgaSeriesName, orderedImageUrls) {
if (!orderedImageUrls || orderedImageUrls.length === 0) {
showMessage(`《${komgaSeriesName}》系列封面URL列表为空,跳过更新`, 'warning');
return false;
}
await cleanKomgaSeriesCover(komgaSeriesId, komgaSeriesName);
let blob;
let validTried = false;
for (let i = 0; i < orderedImageUrls.length; i++) {
const imgUrl = orderedImageUrls[i];
const imageSizeLabel = i === 0 ? "首选" : (i === 1 ? "中尺寸" : (i === 2 ? "通用尺寸" : "小尺寸"));
try {
showMessage(`《${komgaSeriesName}》尝试上传 ${imageSizeLabel} 系列封面...`, 'info', 2000);
blob = await asyncReq(imgUrl, 'GET', undefined, {}, 'blob');
if (!blob || blob.size === 0) {
console.warn(`[updateKomgaSeriesCover] 下载图片 ${imgUrl} 失败或为空 blob。`);
throw new Error("下载图片 blob 失败或为空");
}
if (blob.size < 60 * 1024) {
console.warn(`[updateKomgaSeriesCover] 跳过 ${imageSizeLabel} 封面,文件太小: ${blob.size} bytes`);
showMessage(`《${komgaSeriesName}》${imageSizeLabel} 封面太小(${(blob.size / 1024).toFixed(1)}kB),跳过`, 'warning', 2000);
continue;
}
validTried = true;
let updateSeriesCoverUrl = `${location.origin}/api/v1/series/${komgaSeriesId}/thumbnails`;
const seriesCoverFormdata = new FormData();
const fileName = `series_cover_${komgaSeriesId}.jpg`;
const seriesCoverFile = new File([blob], fileName, { type: blob.type || 'image/jpeg' });
seriesCoverFormdata.append('file', seriesCoverFile);
seriesCoverFormdata.append('selected', 'true');
await asyncReq(updateSeriesCoverUrl, 'POST', seriesCoverFormdata);
showMessage(`《${komgaSeriesName}》系列封面 (${imageSizeLabel}) 已更新`, 'success', 2500);
return true;
} catch (e) {
const errorMessage = e.message || String(e);
if (errorMessage.includes("HTTP Error 413")) {
console.warn(`[updateKomgaSeriesCover] 《${komgaSeriesName}》上传 ${imageSizeLabel} 封面 (${imgUrl}) 失败 (413 Payload Too Large). 大小: ${blob ? blob.size + ' bytes' : '未知'}. 尝试下一个尺寸...`);
showMessage(`《${komgaSeriesName}》${imageSizeLabel} 封面过大(413),尝试更小尺寸...`, 'warning', 3000);
if (i === orderedImageUrls.length - 1 && validTried) {
showMessage(`《${komgaSeriesName}》所有尺寸系列封面均因过大(413)上传失败。请检查服务器配置。`, 'error', 7000);
}
} else {
console.error(`[updateKomgaSeriesCover] 《${komgaSeriesName}》上传 ${imageSizeLabel} 封面 (${imgUrl}) 失败:`, e);
showMessage(`《${komgaSeriesName}》系列封面 (${imageSizeLabel}) 更新失败: ${errorMessage}`, 'error', 5000);
return false;
}
}
}
if (!validTried) {
showMessage(`《${komgaSeriesName}》所有封面文件均小于 60kB,未上传封面`, 'error', 4000);
} else {
console.error(`[updateKomgaSeriesCover] 《${komgaSeriesName}》所有尝试均未能成功上传系列封面。`);
}
return false;
}
async function cleanKomgaSeriesCover(komgaSeriesId, komgaSeriesName) {
const thumbs = await getKomgaSeriesCovers(komgaSeriesId);
// Filter for thumbnails that are USER_UPLOADED and NOT currently selected
const thumbsToClean = thumbs?.filter((thumb) => thumb.type === 'USER_UPLOADED' && thumb.selected === false) || [];
if (thumbsToClean.length === 0) return;
const cleanSeriesCoverUrlBase = `${location.origin}/api/v1/series/${komgaSeriesId}/thumbnails/`;
for (const thumb of thumbsToClean) {
try {
await asyncReq(cleanSeriesCoverUrlBase + thumb.id, 'DELETE');
// showMessage(`《${komgaSeriesName}》旧封面 (ID: ${thumb.id}) 已清理`, 'info', 1000);
} catch (e) {
console.error(`[cleanKomgaSeriesCover] Failed to delete thumb ${thumb.id} for ${komgaSeriesName}:`, e);
showMessage(`《${komgaSeriesName}》系列封面清理失败 (ID: ${thumb.id})`, 'error', 5000);
}
}
}
//
//
async function getKomgaSeriesBooks(komgaSeriesId) {
const url = `${location.origin}/api/v1/books/list`;
const payload = {
condition: {
allOf: [
{
seriesId: {
operator: "is",
value: String(komgaSeriesId)
}
},
{
deleted: {
operator: "isFalse"
}
}
]
}
};
const params = new URLSearchParams({
unpaged: "true",
sort: "metadata.numberSort,asc"
});
try {
const resText = await asyncReq(`${url}?${params.toString()}`, 'POST', payload);
const data = JSON.parse(resText);
const allBooks = data?.content || [];
return {
content: allBooks,
numberOfElements: allBooks.length
};
} catch (e) {
console.error(`[getKomgaSeriesBooks] 获取系列 ${komgaSeriesId} 书籍失败:`, e);
showMessage(`获取系列 ${komgaSeriesId} 的书籍失败`, 'error');
return {
content: [],
numberOfElements: 0
};
}
}
async function updateKomgaBookMeta(book, komgaSeriesName, bookMeta) {
// Filter out null or empty string values before sending
const cleanMeta = Object.fromEntries(Object.entries(bookMeta).filter(([_, v]) => v !== null && v !== ''));
if (Object.keys(cleanMeta).length === 0) {
return; // No actual metadata to update
}
try {
await asyncReq(`${location.origin}/api/v1/books/${book.id}/metadata`, 'PATCH', cleanMeta);
showMessage(`《${komgaSeriesName}》第 ${book.number} 卷信息已更新`, 'success', 1000);
} catch (e) {
console.error(`[updateKomgaBookMeta] Failed for ${komgaSeriesName} Vol ${book.number}:`, e);
showMessage(`《${komgaSeriesName}》第 ${book.number} 卷信息更新失败`, 'error', 5000);
}
}
async function updateKomgaBookCover(book, komgaSeriesName, bookNumberForDisplay, orderedImageUrls) {
if (!orderedImageUrls || orderedImageUrls.length === 0) {
return false;
}
let blob;
let validTried = false;
for (let i = 0; i < orderedImageUrls.length; i++) {
const imgUrl = orderedImageUrls[i];
const imageSizeLabel = i === 0 ? "首选" : (i === 1 ? "中等" : (i === 2 ? "通用" : "较小"));
try {
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} 尝试上传 ${imageSizeLabel} 封面...`, 'info', 1500);
blob = await asyncReq(imgUrl, 'GET', undefined, {}, 'blob');
if (!blob || blob.size === 0) throw new Error("下载图片 blob 失败");
// 检查是否已存在相同大小的封面
const existingThumbsUrl = `${location.origin}/api/v1/books/${book.id}/thumbnails`;
const existingThumbsStr = await asyncReq(existingThumbsUrl, 'GET');
const existingThumbs = JSON.parse(existingThumbsStr);
const existingFileSizes = existingThumbs.map(thumb => thumb.fileSize);
if (existingFileSizes.includes(blob.size)) {
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} 封面已存在相同大小,跳过`, 'info');
return true; // 认为成功,因为已经存在
}
if (blob.size >= 1024 * 1024) {
console.warn(`[updateKomgaBookCover] 跳过 ${imageSizeLabel} 封面,文件太大: ${blob.size} bytes`);
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} ${imageSizeLabel} 封面太大(${(blob.size / 1024).toFixed(1)}kB),跳过`, 'warning', 2000);
continue;
}
if (blob.size < 30 * 1024) {
console.warn(`[updateKomgaBookCover] 跳过 ${imageSizeLabel} 封面,文件太小: ${blob.size} bytes`);
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} ${imageSizeLabel} 封面太小(${(blob.size / 1024).toFixed(1)}kB),跳过`, 'warning', 2000);
continue;
}
validTried = true;
let updateBookCoverUrl = `${location.origin}/api/v1/books/${book.id}/thumbnails`;
let bookCoverFormdata = new FormData();
let bookCoverName = `vol_${bookNumberForDisplay}_cover.jpg`;
let bookCoverFile = new File([blob], bookCoverName, { type: blob.type || 'image/jpeg' });
bookCoverFormdata.append('file', bookCoverFile);
bookCoverFormdata.append('selected', 'true');
await asyncReq(updateBookCoverUrl, 'POST', bookCoverFormdata);
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} 封面 (${imageSizeLabel}版本) 已更新`, 'success', 1500);
return true;
} catch (e) {
const errorMessage = e.message || String(e);
if (errorMessage.includes("HTTP Error 413")) {
console.warn(`[updateKomgaBookCover] 《${komgaSeriesName}》卷 ${bookNumberForDisplay} 上传 ${imageSizeLabel} 封面失败 (413): 大小 ${blob ? blob.size + ' bytes' : '未知'},尝试下一个`);
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} ${imageSizeLabel} 封面过大(413),尝试更小...`, 'warning', 2500);
if (i === orderedImageUrls.length - 1 && validTried) {
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} 所有尺寸封面均因过大(413)上传失败。`, 'error', 6000);
}
} else {
console.error(`[updateKomgaBookCover] 《${komgaSeriesName}》卷 ${bookNumberForDisplay} 上传 ${imageSizeLabel} 封面失败:`, e);
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} 封面 (${imageSizeLabel}版本) 更新失败: ${errorMessage}`, 'error', 5000);
return false;
}
}
}
if (!validTried) {
showMessage(`《${komgaSeriesName}》卷 ${bookNumberForDisplay} 所有封面均小于 60kB,未上传`, 'error', 4000);
} else {
console.error(`[updateKomgaBookCover] 《${komgaSeriesName}》卷 ${bookNumberForDisplay} 所有尝试均未能成功上传封面。`);
}
return false;
}
async function updateKomgaBookAll(komgaSeriesId, seriesBooks, seriesName, bookAuthors, bookVolumeCoverSets, volumeMates = []) {
// bookVolumeCoverSets 结构: [{ coverUrls: [urlL, urlM, urlC] }, { coverUrls: [...] }, ...]
// 或者是空数组 [] (无封面或只更新作者)
if (!seriesBooks || seriesBooks.numberOfElements === 0) return;
if (seriesBooks.numberOfElements >= maxReqBooks) {
showMessage(`系列《${seriesName}》的书籍数量 (${seriesBooks.numberOfElements}) 达到或超过 ${maxReqBooks} 的限制,跳过书籍处理。`, 'warning', 6000);
return;
}
const series = await getKomgaSeriesData(komgaSeriesId);
if (series.oneshot) {
const existingVol = (volumeMates && volumeMates.length > 0) ? volumeMates[0] : {};
const mergedVol = {
...existingVol,
...series,
};
if (series.metadata?.summary) {
mergedVol.summary = series.metadata.summary;
}
volumeMates = [mergedVol];
}
const booksToProcess = seriesBooks.content || [];
const bookUpdateNeeded = bookAuthors?.length > 0 || volumeMates.length > 0;
const coverUpdateNeeded = bookVolumeCoverSets &&
bookVolumeCoverSets.length > 0 &&
bookVolumeCoverSets.some(set => set && set.coverUrls && set.coverUrls.length > 0);
if (!bookUpdateNeeded && !coverUpdateNeeded) return;
const volumeTitlePattern = /(?:vol(?:ume)?s?|巻|卷|册|冊)[\W_]*?(?\d+)|第\s*(?\d+)\s*(?:巻|卷|册|冊|集)|(?\d+)\s*(?:巻|卷|册|冊|集)/i;
for (let i = 0; i < booksToProcess.length; i++) {
const book = booksToProcess[i];
const bookname = book.name ?? '';
const booktitle = book.metadata?.title ?? '';
let volNum = 0;
// 用正则提取卷号数字
const match = bookname.match(volumeTitlePattern) || booktitle.match(volumeTitlePattern);
if (match?.groups?.volNum || match?.groups?.volNum2 || match?.groups?.volNum3) {
const volStr = match.groups.volNum || match.groups.volNum2 || match.groups.volNum3;
volNum = parseInt(volStr, 10);
}
const bookNumberForDisplay = volNum.toString().padStart(2, '0');
try {
// 查找volumeMates中匹配的卷
const volNumInt = parseInt(volNum, 10);
let mate = null;
if (series.oneshot) {
mate = volumeMates.length > 0 ? volumeMates[0] : null;
} else if (volumeMates.length > 0 && volNumInt > 0) {
mate = volumeMates.find(m => parseInt(m.num, 10) === volNumInt) || null;
}
if (bookUpdateNeeded) {
const bookMeta = {
authors: bookAuthors,
title: (volumeTitlePattern.test(bookname) || volumeTitlePattern.test(booktitle)) ? `卷 ${bookNumberForDisplay}` : undefined,
number: (volumeTitlePattern.test(bookname) || volumeTitlePattern.test(booktitle)) ? bookNumberForDisplay : undefined,
};
if (mate) {
if (mate.summary) bookMeta.summary = mate.summary;
if (mate.releaseDate) bookMeta.releaseDate = mate.releaseDate;
if (mate.isbn) bookMeta.isbn = mate.isbn;
if (mate.metadata?.links) bookMeta.links = mate.metadata.links;
if (mate.metadata?.tags && mate.metadata.tags.length > 0) {
bookMeta.tags = mate.metadata.tags.map(tag => tag.trim()).filter(tag => tag !== '');
};
}
await updateKomgaBookMeta(book, seriesName, bookMeta);
}
if (coverUpdateNeeded) {
let coverUrls = null;
if (volumeMates.length > 0) {
// volumeMates 存在时,按匹配的卷号找对应封面
if (mate) {
const mateIndex = volumeMates.indexOf(mate);
if (bookVolumeCoverSets[mateIndex]?.coverUrls?.length > 0) {
coverUrls = bookVolumeCoverSets[mateIndex].coverUrls;
}
}
} else {
// volumeMates 不存在时,按原索引匹配封面
if (bookVolumeCoverSets[i]?.coverUrls?.length > 0) {
coverUrls = bookVolumeCoverSets[i].coverUrls;
}
}
if (coverUrls) {
await updateKomgaBookCover(book, seriesName, bookNumberForDisplay, coverUrls);
}
}
} catch (bookError) {
console.error(`[updateKomgaBookAll] Error processing book ${book.id} (Vol ${bookNumberForDisplay}) for series "${seriesName}":`, bookError);
}
}
}
function ifUpdateBook(seriesBooks, bookAuthors) {
// This function decides if book authors should be updated.
// It's a heuristic based on the format of the last book's title.
if (!bookAuthors || bookAuthors.length === 0) return false; // No authors to update with
if (!seriesBooks || !seriesBooks.content || seriesBooks.content.length === 0) return false; // No books in Komga
const seriesBooksContent = seriesBooks.content;
const lastBook = seriesBooksContent[seriesBooksContent.length - 1]; // Check the last book
const lastBookMeta = lastBook.metadata;
// If last book has no metadata, update
if (!lastBookMeta || !lastBookMeta.title || !lastBookMeta.summary || !lastBookMeta.releaseDate || !lastBookMeta.isbn) return true;
const lastBookTitle = lastBookMeta.title;
// If title is very long, or contains typical filename patterns, it's likely not manually set
if (lastBookTitle.length > 16) return true; // Arbitrary length, adjust if needed
if (lastBookTitle.includes('[') || lastBookTitle.includes(']')) return true;
if (lastBookTitle.toLowerCase().includes('.zip') || lastBookTitle.toLowerCase().includes('.cbz')) return true;
// If title looks like "Vol XX" or "卷 XX", it might be okay, but we still might want to ensure authors are set.
// If it does NOT look like a standard volume title, it's probably a filename, so update.
if (!/^vol(ume)?s?\s*\d+/i.test(lastBookTitle) && !/^(?:巻|卷|册|第)\s*\d+/i.test(lastBookTitle)) return true;
// If Komga authors for the last book are empty, update
const lastBookKomgaAuthors = lastBookMeta.authors || [];
if (lastBookKomgaAuthors.length === 0) return true;
// TODO: Could add a check to see if Komga authors match the new authors.
// For now, if authors exist, and title isn't a clear filename, assume it might be okay.
// The current logic is more aggressive towards updating if the title isn't "卷 XX" or "Vol XX"
// or if authors are missing.
return false; // Default to not updating if none of the above "bad title" conditions are met and authors exist
}
function getVolumeNumsNeedUpdate(seriesBooks) {
if (!seriesBooks || !seriesBooks.content || seriesBooks.content.length === 0) return new Set();
const volumeTitlePattern = /(?:vol(?:ume)?s?|巻|卷|册|冊)[\W_]*?(?\d+)|第\s*(?\d+)\s*(?:巻|卷|册|冊|集)|(?\d+)\s*(?:巻|卷|册|冊|集)/i;
const needUpdateVolumeNums = new Set();
for (const book of seriesBooks.content) {
const title = book?.metadata?.title || book?.name || "";
if (!volumeTitlePattern.test(title)) continue;
const match = title.match(volumeTitlePattern);
const volNum = match?.groups?.volNum || match?.groups?.volNum2 || match?.groups?.volNum3 || null;
if (!volNum) continue;
const meta = book?.metadata;
if (!meta || !meta.title || !meta.summary || !meta.releaseDate || !meta.isbn) {
needUpdateVolumeNums.add(Number(volNum));
}
}
return needUpdateVolumeNums;
}
//
//
const MANUAL_MATCH_COLLECTION_NAME = "手动匹配";
let _manualMatchCollectionId = null;
let _manualMatchCollectionExistingSeriesIds = []; // Cache existing series IDs in the collection
async function getAllCollections() {
const url = `${location.origin}/api/v1/collections?unpaged=true`;
try {
const resText = await asyncReq(url, 'GET');
const data = JSON.parse(resText);
return data?.content || [];
} catch (e) {
console.error('[getAllCollections] 获取收藏夹失败:', e);
throw e;
}
}
async function createCollection(name, seriesIds = [], ordered = false) {
const url = `${location.origin}/api/v1/collections`;
const payload = { name, seriesIds, ordered };
try {
const resText = await asyncReq(url, 'POST', payload);
const data = JSON.parse(resText);
return data;
} catch (e) {
console.error(`[createCollection] 创建收藏夹 "${name}" 失败:`, e);
throw e;
}
}
async function updateCollectionSeries(collectionId, seriesIds) {
if (!collectionId) throw new Error('collectionId 不能为空');
const url = `${location.origin}/api/v1/collections/${collectionId}`;
try {
await asyncReq(url, 'PATCH', { seriesIds });
} catch (e) {
console.error(`[updateCollectionSeries] 更新收藏夹 ${collectionId} 失败:`, e);
throw e;
}
}
async function ensureManualMatchCollectionExists(initialSeriesIdForCreation = null) {
if (_manualMatchCollectionId) return true; // Already found/created
try {
const collections = await getAllCollections();
const collection = collections.find(c => c.name === MANUAL_MATCH_COLLECTION_NAME);
if (collection) {
_manualMatchCollectionId = collection.id;
_manualMatchCollectionExistingSeriesIds = collection.seriesIds || [];
console.log(`[收藏夹] 已找到 "${MANUAL_MATCH_COLLECTION_NAME}" (ID:${_manualMatchCollectionId})。包含 ${_manualMatchCollectionExistingSeriesIds.length} 个系列`);
return true;
}
if (!initialSeriesIdForCreation) {
console.log(`[收藏夹] "${MANUAL_MATCH_COLLECTION_NAME}" 不存在,且未提供初始系列ID,将等待实际失败系列出现时创建`);
return false;
}
console.log(`[收藏夹] "${MANUAL_MATCH_COLLECTION_NAME}" 不存在,使用系列ID "${initialSeriesIdForCreation}" 创建`);
const created = await createCollection(MANUAL_MATCH_COLLECTION_NAME, [initialSeriesIdForCreation]);
_manualMatchCollectionId = created.id;
_manualMatchCollectionExistingSeriesIds = created.seriesIds || [initialSeriesIdForCreation];
showMessage(`[收藏夹] 已使用系列ID ${initialSeriesIdForCreation} 创建 "${MANUAL_MATCH_COLLECTION_NAME}" (ID:${_manualMatchCollectionId})`, 'success', 3500);
return true;
} catch (error) {
console.error(`[ensureManualMatchCollectionExists] 操作 "${MANUAL_MATCH_COLLECTION_NAME}" 失败:`, error);
showMessage(`操作 "${MANUAL_MATCH_COLLECTION_NAME}" 收藏夹失败: ${error.message || error}`, 'error', 7000);
_manualMatchCollectionId = null;
_manualMatchCollectionExistingSeriesIds = [];
return false;
}
}
async function addSeriesToManualMatchCollectionImmediately(seriesIdToAdd, seriesNameToAdd) {
if (!seriesIdToAdd) return false;
let collectionReady = _manualMatchCollectionId ? true : false;
if (!collectionReady) {
collectionReady = await ensureManualMatchCollectionExists(seriesIdToAdd);
}
if (!collectionReady || !_manualMatchCollectionId) {
showMessage(`[收藏夹] 因 "${MANUAL_MATCH_COLLECTION_NAME}" 未就绪/创建失败,无法添加《${seriesNameToAdd || seriesIdToAdd}》。`, 'error', 4000);
return false;
}
if (_manualMatchCollectionExistingSeriesIds.includes(seriesIdToAdd)) {
return true;
}
const newSeriesList = [..._manualMatchCollectionExistingSeriesIds, seriesIdToAdd];
try {
await updateCollectionSeries(_manualMatchCollectionId, newSeriesList);
_manualMatchCollectionExistingSeriesIds.push(seriesIdToAdd);
showMessage(`《${seriesNameToAdd || seriesIdToAdd}》已添加至 "${MANUAL_MATCH_COLLECTION_NAME}"。`, 'success', 3000);
return true;
} catch (error) {
console.error(`[addSeriesToCollImm] 添加系列 ${seriesIdToAdd} ("${seriesNameToAdd}") 到收藏夹 ${_manualMatchCollectionId} 失败:`, error);
showMessage(`添加《${seriesNameToAdd || seriesIdToAdd}》至 "${MANUAL_MATCH_COLLECTION_NAME}" 失败: ${error.message || error}`, 'error', 5000);
return false;
}
}
async function removeSeriesFromManualMatchCollectionIfExists(seriesIdToRemove, seriesNameToRemove) {
if (!seriesIdToRemove || !_manualMatchCollectionId) return false;
if (!_manualMatchCollectionExistingSeriesIds.includes(seriesIdToRemove)) {
return false; // Not in collection, nothing to remove
}
const newSeriesList = _manualMatchCollectionExistingSeriesIds.filter(id => id !== seriesIdToRemove);
try {
await updateCollectionSeries(_manualMatchCollectionId, newSeriesList);
_manualMatchCollectionExistingSeriesIds = newSeriesList;
console.info(`[收藏夹] 《${seriesNameToRemove || seriesIdToRemove}》已从 "${MANUAL_MATCH_COLLECTION_NAME}" 移除 (匹配成功)`);
return true;
} catch (error) {
console.error(`[removeSeriesFromColl] 从收藏夹移除系列 ${seriesIdToRemove} ("${seriesNameToRemove}") 失败:`, error);
return false;
}
}
//
// ************************************* 第三方请求 (Bangumi API and bookof.moe) *************************************
//
async function fetchBookByName(seriesName, source, limit = 8) {
source = source ? source.toLowerCase() : 'btv'; // Default to btv (Bangumi API)
try {
switch (source) {
case 'btv': return await fetchBtvSubjectByNameAPI(seriesName, limit);
case 'bof': return await fetchMoeBookByName(seriesName, limit); // Stays as is (scraping)
case 'mangadex': return await fetchMangadexBookByName(seriesName, limit);
default: return await fetchBtvSubjectByNameAPI(seriesName, limit);
}
} catch (error) {
console.error(`[fetchBookByName] Error searching "${seriesName}" on ${source}:`, error);
showMessage(`在 ${source.toUpperCase()} 搜索 《${seriesName}》 失败: ${error.message || error}`, 'error');
return []; // Return empty array on error
}
}
async function fetchBookByUrl(komgaSeriesId, reqSeriesId, reqSeriesUrl = '', source = 'btv') {
// reqSeriesId is the ID from BTV or BOF
// reqSeriesUrl is if a direct URL was already known (e.g. from Komga links)
source = source ? source.toLowerCase() : 'btv';
console.log('fetchBookByUrl called with source:', source);
const $dom = findDomElementForSeries(komgaSeriesId) || $('body'); // Fallback to body for loading indicator if DOM not found
try {
switch (source) {
case 'btv':
await fetchBtvSubjectByUrlAPI(komgaSeriesId, reqSeriesId, reqSeriesUrl);
break;
case 'bof':
await fetchMoeBookByUrl(komgaSeriesId, reqSeriesId, reqSeriesUrl); // Stays as is (scraping)
break;
case 'mangadex':
await fetchMangadexBookByUrl(komgaSeriesId, reqSeriesId, reqSeriesUrl);
break;
default:
await fetchBtvSubjectByUrlAPI(komgaSeriesId, reqSeriesId, reqSeriesUrl);
break;
}
} catch (error) {
console.error(`[fetchBookByUrl] Overall error fetching/processing for KomgaID ${komgaSeriesId} from ${source.toUpperCase()}:`, error);
showMessage(`处理系列 ${komgaSeriesId} (${source.toUpperCase()}) 时发生错误: ${error.message || error}`, 'error', 10000);
} finally {
partLoadingEnd($dom); // Ensure loading indicator is removed
}
}
// 辅助函数:尝试从 infobox 数组中提取特定 key 的值
function parseInfobox(infoboxArray, targetKey) {
if (!infoboxArray || !Array.isArray(infoboxArray)) return null;
const item = infoboxArray.find(i => i.key === targetKey);
if (!item) return null;
if (typeof item.value === 'string') return item.value;
if (Array.isArray(item.value)) { // e.g., [{v: "value1"}, {v: "value2"}] or simple array of strings
return item.value.map(v => (typeof v === 'object' && v.v !== undefined) ? v.v : v).filter(v => typeof v === 'string').join('、');
}
if (typeof item.value === 'object' && item.value.v !== undefined) return item.value.v; // Single object like {v: "value"}
return null;
}
// 辅助函数:提取任意结构中的别名信息
function extractAliases(infoboxArray) {
const aliases = new Set();
if (!infoboxArray || !Array.isArray(infoboxArray)) return "";
for (const item of infoboxArray) {
// 1. 处理直接别名项(支持简体和繁体)
const isAliasKey = item.key === "别名" || item.key === "別名";
if (isAliasKey) {
// 处理所有可能的值类型
if (typeof item.value === "string") {
aliases.add(item.value.trim());
} else if (Array.isArray(item.value)) {
item.value.forEach(v => {
if (typeof v === "string") {
aliases.add(v.trim());
} else if (v?.v && typeof v.v === "string") {
aliases.add(v.v.trim());
}
});
} else if (item.value?.v && typeof item.value.v === "string") {
aliases.add(item.value.v.trim());
}
}
// 2. 处理嵌套别名项(支持简体和繁体)
if (Array.isArray(item.value)) {
for (const subItem of item.value) {
// 检查子项是否是别名(支持简体和繁体)
const isSubAlias = subItem?.k === "别名" || subItem?.k === "別名";
if (isSubAlias && typeof subItem.v === "string") {
aliases.add(subItem.v.trim());
}
// 处理嵌套的别名数组
else if (isSubAlias && Array.isArray(subItem.v)) {
subItem.v.forEach(alias => {
if (typeof alias === "string") {
aliases.add(alias.trim());
} else if (alias?.v) {
aliases.add(alias.v.trim());
}
});
}
}
}
}
return Array.from(aliases).filter(a => a).join(" / ");
}
async function fetchBtvSubjectByNameAPI(seriesName, limit = 8) {
const searchUrl = `${btvApiUrl}/v0/search/subjects?limit=20`;
const requestBody = {
keyword: seriesName,
sort: "match",
filter: {
type: [1], // 1 for Books (漫画, 画集, 轻小说)
nsfw: true // 搜索结果包含 nsfw 条目,需要设置 Bangumi API Access Token
}
};
try {
const searchResStr = await asyncReq(searchUrl, 'POST', requestBody, {});
const searchRes = JSON.parse(searchResStr);
if (!searchRes || !searchRes.data || searchRes.data.length === 0) {
console.log(`[fetchBtvSubjectByNameAPI] 搜索 "${seriesName}" (type: 书籍) 未找到任何结果。`);
return [];
}
const matchType = getBangumiMatchType();
const filteredData = searchRes.data.filter(item => item.platform === matchType);
if (filteredData.length === 0) {
console.log(`[fetchBtvSubjectByNameAPI] 搜索 "${seriesName}" 未找到 platform 为 "${matchType}" 的条目。`);
return [];
}
const results = filteredData.map(item => {
let authorName = "未知作者";
let aliasesString = ""; // 用于存储处理后的别名字符串
if (item.infobox) {
// 提取作者 (优先作画,其次作者,再次原作)
const authorFromInfo = parseInfobox(item.infobox, "作画") ||
parseInfobox(item.infobox, "作者") ||
parseInfobox(item.infobox, "原作") ||
parseInfobox(item.infobox, "脚本");
if (authorFromInfo) {
authorName = authorFromInfo.split(/[、→・×]/)[0].replace(/[《【(\[\(][^》】)\]\)]*[》】)\]\)]\s*$/, '').trim(); // 取第一个作为主要作者
}
// 提取并处理别名
aliasesString = item.infobox ? extractAliases(item.infobox) : "";
}
return {
id: item.id,
title: item.name_cn || item.name, // 优先中文名
orititle: item.name, // 原始名
author: authorName,
aliases: aliasesString, // 别名
cover: item.image || item.images?.medium || item.images?.common || item.images?.small || null, // 优先 image (通常是主封面)
};
});
return (typeof limit === 'number' && limit > 0) ? results.slice(0, limit) : results; // Limit results if too many
} catch (error) {
// asyncReq already shows a message and logs the error
console.error(`[fetchBtvSubjectByNameAPI] POST 请求 "${seriesName}" 失败:`, error);
throw error; // Re-throw to be caught by caller
}
}
async function fetchBtvSubjectByUrlAPI(komgaSeriesId, reqSeriesId, reqSeriesUrl = '') {
const komgaSeries = await getKomgaSeriesData(komgaSeriesId);
const subjectId = reqSeriesId || (reqSeriesUrl.match(/subject\/(\d+)/) ? reqSeriesUrl.match(/subject\/(\d+)/)[1] : null);
if (!subjectId) {
throw new Error("Bangumi Subject ID is missing.");
}
const apiUrl = `${btvApiUrl}/v0/subjects/${subjectId}`;
const seriesResStr = await asyncReq(apiUrl, 'GET', undefined, {}); // API call
const btvData = JSON.parse(seriesResStr);
let seriesMeta = {
title: '', titleLock: false, titleSort: '', titleSortLock: false,
status: '', statusLock: false, tags: [], tagsLock: false,
links: [{ label: 'Btv', url: `${btvLegacyUrl}/subject/${subjectId}` }], linksLock: false,
publisher: '', publisherLock: false, totalBookCount: null, totalBookCountLock: false,
summary: '', summaryLock: false, alternateTitles: [], authors: [], authorsLock: false,
};
seriesMeta.title = btvData.name_cn && t2s(btvData.name_cn) || t2s(btvData.name);
seriesMeta.titleSort = seriesMeta.title;
if (btvData.name && btvData.name !== seriesMeta.title) { // If original name differs from CN name
seriesMeta.alternateTitles.push({ label: '原名', title: capitalize(btvData.name) });
}
seriesMeta.summary = (btvData.summary || '')
.replace(/\r\n|\r/g, '\n') // 统一换行
.split('\n')
.map(line => line.replace(/^[\s\u3000]+|[\s\u3000]+$/g, '')) // 去除每行前后空格(含全角空格)
.join('\n')
.trim();
seriesMeta.totalBookCount = btvData.volumes || btvData.eps || btvData.total_episodes || null;
seriesMeta.genres = komgaSeries.genres || [];
seriesMeta.genres.push(btvData.platform);
const statusTags = ["连载", "连载中", "完结", "已完结", "停刊", "长期休载", "停止连载", "休刊"];
if (btvData.tags && btvData.tags.length > 0) {
const rawApiTags = btvData.tags
.map(t => ({ name: t.name, count: t.count }))
.filter(tag => tagLabels.includes(tag.name + ',') && !statusTags.includes(tag.name));
if (rawApiTags.length > 0) {
let validTags = rawApiTags
.filter(tag => tagLabels.includes(tag.name + ",") && !statusTags.includes(tag.name))
.sort((a, b) => b.count - a.count);
const maxTagCount = Math.max(1, ...validTags.map(tag => tag.count));
let thresholdTagCount = 3;
if (maxTagCount > 200) {
thresholdTagCount = 35;
} else if (maxTagCount > 125) {
thresholdTagCount = 25;
} else if (maxTagCount > 60) {
thresholdTagCount = 15;
} else if (maxTagCount > 30) {
thresholdTagCount = 10;
} else if (maxTagCount > 10) {
thresholdTagCount = 5;
}
let finalTags = validTags.filter(tag => tag.count >= thresholdTagCount);
if (finalTags.length < 10) {
finalTags = validTags.slice(0, 10);
}
seriesMeta.tags = finalTags.map(tag => tag.name);
} else {
seriesMeta.tags = [];
}
}
// 追加识别系列文件夹名称中的出版社/汉化信息
const publisherKeywords = [
'台湾角川', '台湾东贩', '尖端', '青文', '东立', '长鸿', '尚禾', '大然', '龙成',
'群英', '未来数位', '新视界', '玉皇朝', '天下', '传信', '天闻角川', 'bili',
'bilibili', '哔哩哔哩', '汉化', '生肉', '日版', '原版', '正版', '官方',
'中文版', '简中', '繁中', '简体中文', '繁体中文', '简体', '繁体',
];
const seriesName = komgaSeries.name || '';
const matchedKeyword = publisherKeywords.find(keyword =>
t2s(seriesName).includes(keyword)
);
if (matchedKeyword && !seriesMeta.tags.includes(matchedKeyword)) {
seriesMeta.tags.push(matchedKeyword);
}
if (btvData.rating && typeof btvData.rating.score === 'number' && btvData.rating.score > 0) {
seriesMeta.tags.push(`${Math.round(btvData.rating.score)}分`);
}
if (seriesMeta.tags && seriesMeta.tags.length > 0) {
const hasCompleted = seriesMeta.tags.includes("已完结") || seriesMeta.tags.includes("完结");
if (hasCompleted) {
let keepTag = seriesMeta.tags.includes("已完结") ? "已完结" : "完结";
seriesMeta.tags = seriesMeta.tags.filter(
t => !statusTags.includes(t) || t === keepTag
);
}
}
const infobox = btvData.infobox || [];
let resAuthors = [];
let seriesIndividualAliases = []; // For aliases from infobox
let publisherVal = parseInfobox(infobox, '出版社') || parseInfobox(infobox, '连载杂志') || parseInfobox(infobox, '制作');
if (publisherVal) {
seriesMeta.publisher = t2s(publisherVal.split(/[//、_→×&,,]/)[0].trim()); // Take first publisher, convert to simplified
} else if (matchedKeyword && !seriesMeta.publisher) {
seriesMeta.publisher = matchedKeyword;
}
// Define author roles mapping for Komga
const authorRoles = {
'作者': 'writer', '原作': 'writer', '分镜': 'writer', '脚本·分镜': 'writer', '脚本': 'writer', '漫画家': 'writer',
'作画': 'penciller', '插图': 'illustrator', '插画家': 'illustrator', '人物原案': 'conceptor', '人物设定': 'designer',
'原案': 'story', '系列构成': 'scriptwriter', '铅稿': 'penciller', '上色': 'colorist'
// Add more roles as needed and map them to Komga's supported roles
};
for (const [key, role] of Object.entries(authorRoles)) {
let val = parseInfobox(infobox, key);
console.log(`[baseAsyncReq] Success (${val}...`);
if (val) {
val.split(/[//、_→・×&,,]/).forEach(name => { // Handle multiple authors for the same role
const trimmedName = name.replace(/[《【(\[\(][^》】)\]\)]*[》】)\]\)]\s*$/, '').trim();
if (trimmedName && !resAuthors.some(a => a.name === trimmedName && a.role === role)) {
resAuthors.push({ name: t2s(trimmedName), role: role });
}
});
}
}
const hasWriter = resAuthors.some(a => a.role === 'writer');
const hasPenciller = resAuthors.some(a => a.role === 'penciller');
if (!hasWriter) {
const pencillers = resAuthors.filter(a => a.role === 'penciller');
for (const p of pencillers) {
const alreadyAdded = resAuthors.some(a => a.name === p.name && a.role === 'writer');
if (!alreadyAdded) {
resAuthors.push({ name: p.name, role: 'writer' });
}
}
}
if (!hasPenciller && btvData.platform === '漫画') {
const writers = resAuthors.filter(a => a.role === 'writer');
for (const w of writers) {
const alreadyAdded = resAuthors.some(a => a.name === w.name && a.role === 'penciller');
if (!alreadyAdded) {
resAuthors.push({ name: w.name, role: 'penciller' });
}
}
}
seriesMeta.authors = resAuthors;
// Extract aliases from infobox ("别名")
const aliasStr = extractAliases(infobox);
if (aliasStr) {
seriesIndividualAliases.push(...aliasStr.split(' / ').filter(Boolean));
}
seriesIndividualAliases.forEach(alias => {
const aliasLower = alias.toLowerCase();
const titleLower = seriesMeta.title ? seriesMeta.title.toLowerCase() : '';
const oriNameLower = btvData.name ? btvData.name.toLowerCase() : '';
// Add if not empty, not same as title, not same as original name, and not already in alternateTitles
if (alias && aliasLower !== titleLower && aliasLower !== oriNameLower &&
!seriesMeta.alternateTitles.some(at => at.title.toLowerCase() === aliasLower)) {
seriesMeta.alternateTitles.push({ label: '别名', title: capitalize(alias) });
}
});
// Status from infobox (keys like "状态", "连载状态", "刊行状态")
let statusVal = parseInfobox(infobox, '状态') || parseInfobox(infobox, '连载状态') || parseInfobox(infobox, '刊行状态');
if (!statusVal) {
const foundStatus = seriesMeta.tags.find(tag => statusTags.includes(tag));
if (foundStatus) {
statusVal = foundStatus;
}
}
if (statusVal) {
statusVal = t2s(statusVal.toLowerCase()); // Convert to simplified Chinese and lower case for matching
if (statusVal.includes('休刊') || statusVal.includes('停刊') || statusVal.includes('停止连载') || statusVal.includes('长期休载')) seriesMeta.status = 'HIATUS';
else if (statusVal.includes('连载中') || statusVal.includes('连载')) seriesMeta.status = 'ONGOING';
else if (statusVal.includes('完结') || statusVal.includes('已完结')) seriesMeta.status = 'ENDED';
// else if (statusVal.includes('宣布动画化')) seriesMeta.status = 'ONGOING'; // Or some other appropriate status
}
if (parseInfobox(infobox, '结束') || parseInfobox(infobox, '完结') || (seriesMeta.totalBookCount && seriesMeta.totalBookCount > 0)) {
seriesMeta.status = 'ENDED';
}
let finalMeta = await filterSeriesMeta(komgaSeriesId, seriesMeta);
// Filter out null or empty string values, but allow empty arrays (for tags, links etc.)
finalMeta = Object.fromEntries(Object.entries(finalMeta).filter(([_, v]) => v !== null && v !== '' || Array.isArray(v)));
const seriesNameForDisplay = finalMeta.title || btvData.name_cn || btvData.name || '未知系列';
await updateKomgaSeriesMeta(komgaSeriesId, seriesNameForDisplay, finalMeta);
// 匹配成功后,如果系列在手动匹配收藏夹中,则自动移除
await ensureManualMatchCollectionExists(); // 确保收藏夹ID已初始化
await removeSeriesFromManualMatchCollectionIfExists(komgaSeriesId, seriesNameForDisplay);
// --- 获取系列和卷的多种封面尺寸 ---
const seriesCoverUrls = [];
if (btvData.images) { // 主条目的图片
if (btvData.images.large) seriesCoverUrls.push(btvData.images.large);
if (btvData.images.medium) seriesCoverUrls.push(btvData.images.medium);
if (btvData.images.common) seriesCoverUrls.push(btvData.images.common);
// if (btvData.images.small) seriesCoverUrls.push(btvData.images.small); // Usually too small
}
if (btvData.image && !seriesCoverUrls.includes(btvData.image)) { // `image` field is often the primary cover.
seriesCoverUrls.unshift(btvData.image); // Add to front as highest priority if different
}
const uniqueSeriesCoverUrls = [...new Set(seriesCoverUrls.filter(Boolean))]; // 去重和去空
const fetchSeriesType = localStorage.getItem(`SID-${komgaSeriesId}`);
const seriesBooks = await getKomgaSeriesBooks(komgaSeriesId);
const updateAuthorsFlag = finalMeta.authors && finalMeta.authors.length > 0 && ifUpdateBook(seriesBooks, finalMeta.authors);
const needUpdateVolumeNums = getVolumeNumsNeedUpdate(seriesBooks);
const needFetchVolumeData = getVolumeDataFetch();
// 过滤单行本卷,排序
const relatedSubjectsApiUrl = `${btvApiUrl}/v0/subjects/${subjectId}/subjects`;
const relatedSubjectsStr = await asyncReq(relatedSubjectsApiUrl, 'GET', undefined, {});
const relatedSubjects = JSON.parse(relatedSubjectsStr);
const volumes = relatedSubjects
.filter(rel => rel.relation === "单行本")
.sort((a, b) => {
const nameA = a.name_cn || a.name;
const nameB = b.name_cn || b.name;
const numA = extractVolumeNumber(nameA);
const numB = extractVolumeNumber(nameB);
if (numA !== null && numB !== null && numA !== numB) return numA - numB;
return a.id - b.id;
});
// 获取 volumeMates
const volumeMatesFetcher = async (vol, index) => {
const volCoverUrlsList = [];
if (vol.images) {
if (vol.images.large) volCoverUrlsList.push(vol.images.large);
if (vol.images.medium) volCoverUrlsList.push(vol.images.medium);
if (vol.images.common) volCoverUrlsList.push(vol.images.common);
}
if (vol.image && !volCoverUrlsList.includes(vol.image)) {
volCoverUrlsList.unshift(vol.image);
}
const uniqueVolCoverUrls = [...new Set(volCoverUrlsList.filter(Boolean))];
let num = extractVolumeNumber(vol.name_cn || vol.name) || (index + 1);
// 判断当前卷号是否需要更新元数据
const isNeedUpdate = needUpdateVolumeNums.has(num);
let summary = '', releaseDate = '', isbn = '';
if (isNeedUpdate && needFetchVolumeData) {
try {
const volDetailStr = await asyncReq(`${btvApiUrl}/v0/subjects/${vol.id}`, 'GET', undefined, {});
const volDetail = JSON.parse(volDetailStr);
summary = (volDetail.summary || '')
.replace(/\r\n|\r/g, '\n')
.split('\n')
.map(line => line.replace(/^[\s\u3000]+|[\s\u3000]+$/g, ''))
.join('\n')
.trim();
const dateStr = parseInfobox(volDetail.infobox || [], '发售日') || parseInfobox(volDetail.infobox || [], '放送开始');
if (dateStr) {
releaseDate = normalizeDate(dateStr);
}
const isbnVal = parseInfobox(volDetail.infobox || [], 'ISBN');
if (isbnVal) isbn = isbnVal;
} catch (e) {
console.warn(`[BtvAPI] 获取单行本详情失败 (${vol.id}):`, e);
}
}
return {
num,
summary,
releaseDate,
isbn,
coverUrls: uniqueVolCoverUrls,
};
};
const volumeMatesWithCovers = await asyncPool(volumes, volumeMatesFetcher, 10);
const bookVolumeCoverSets = volumeMatesWithCovers.map(v => ({ coverUrls: v.coverUrls }));
let volumeMates = volumeMatesWithCovers.map(({ coverUrls, ...meta }) => meta);
// --- 更新系列封面 ---
if (fetchSeriesType === 'all') {
if (uniqueSeriesCoverUrls.length > 0) {
await updateKomgaSeriesCover(komgaSeriesId, seriesNameForDisplay, uniqueSeriesCoverUrls);
} else {
showMessage(`《${seriesNameForDisplay}》未能获取系列主封面 (BGM API)`, 'warning');
}
await updateKomgaBookAll(komgaSeriesId, seriesBooks, seriesNameForDisplay, updateAuthorsFlag ? finalMeta.authors : [], bookVolumeCoverSets, volumeMates);
} else if (updateAuthorsFlag || (needUpdateVolumeNums && needUpdateVolumeNums.size > 0)) { // 'meta' only sync, but authors need update
console.log(`[fetchBtvSubjectByUrlAPI] 更新系列 ${komgaSeriesId} 的作者或卷信息`);
await updateKomgaBookAll(komgaSeriesId, seriesBooks, seriesNameForDisplay, finalMeta.authors, [], volumeMates); // Pass empty cover sets
}
}
async function fetchMoeBookByName(seriesName, limit = 8) {
// This function remains unchanged as it's for bookof.moe (scraping)
const moeSeriesName = s2t(seriesName); // Convert to traditional for BoF search
const searchUrl = `${bofUrl}/data_list.php?s=${encodeURIComponent(moeSeriesName)}&p=1`; // Search on page 1
try {
const searchRes = await asyncReq(searchUrl, 'GET', undefined, {}, 'text'); // Explicitly text for regex
// Regex to find datainfo-B entries (which often contain book info)
// datainfo-B=[分类],[ID],[标题],[作者],[出版日期]
const idxRe = /datainfo-B=[^,]+,(\d+),(.*?),([^,]*?),[\d-]+/g; // Made author group more flexible
// BoF script content might be split. Concatenate or iterate.
// The original split by '