bangumi collection export tool

导出和导入 Bangumi 收藏为 Excel

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        bangumi collection export tool
// @name:zh-CN  bangumi 收藏导出工具
// @namespace   https://github.com/22earth
// @description 导出和导入 Bangumi 收藏为 Excel
// @description:en-US export or import collection on bangumi.tv
// @description:zh-CN 导出和导入 Bangumi 收藏为 Excel
// @author      22earth,Liebessprache
// @homepage    https://github.com/22earth/gm_scripts
// @include     /^https?:\/\/(bangumi|bgm|chii)\.(tv|in)\/\w+\/list\/.*$/
// @include     /^https?:\/\/(bangumi|bgm|chii)\.(tv|in)\/index\/\d+/
// @include     /^https?:\/\/(bangumi|bgm|chii)\.(tv|in)\/(anime|book|music|game|real)\/browser\/?.*$/
// @version     1.2.3
// @note        1.2.0 在浏览页新增选中面板,添加持久缓存/多按钮 UX。
// @note        1.1.1 提升了收藏导入速度。
// @note        1.1.0 修复已知BUG。
// @note        1.0.1 修复已知BUG。
// @note        1.0.0 修复目录导出问题,兼容 Bangumi 新版导出逻辑。
// @note        0.0.6 导出格式改为 excel 和支持 excel 的导入。
// @note        0.0.4 添加导入功能。注意:不支持是否对自己可见的导入
// @grant       GM_xmlhttpRequest
// @connect     api.bgm.tv
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/jschardet.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js
// @run-at      document-end
// @license      MIT

// ==/UserScript==


function formatDate(time, fmt = 'yyyy-MM-dd') {
    const date = new Date(time);
    var o = {
        'M+': date.getMonth() + 1,
        'd+': date.getDate(),
        'h+': date.getHours(),
        'm+': date.getMinutes(),
        's+': date.getSeconds(),
        'q+': Math.floor((date.getMonth() + 3) / 3),
        S: date.getMilliseconds(), //毫秒
    };
    if (/(y+)/i.test(fmt)) {
        fmt = fmt.replace(RegExp.$1, (date.getFullYear() + '').substr(4 - RegExp.$1.length));
    }
    for (var k in o) {
        if (new RegExp('(' + k + ')', 'i').test(fmt)) {
            fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length));
        }
    }
    return fmt;
}
function dealDate(dataStr) {
    // 2019年12月19
    let l = [];
    if (/\d{4}年\d{1,2}月(\d{1,2}日?)?/.test(dataStr)) {
        l = dataStr
            .replace('日', '')
            .split(/年|月/)
            .filter((i) => i);
    }
    else if (/\d{4}\/\d{1,2}(\/\d{1,2})?/.test(dataStr)) {
        l = dataStr.split('/');
    }
    else if (/\d{4}-\d{1,2}(-\d{1,2})?/.test(dataStr)) {
        return dataStr;
    }
    else {
        return dataStr;
    }
    return l
        .map((i) => {
            if (i.length === 1) {
                return `0${i}`;
            }
            return i;
        })
        .join('-');
}
function extractDateFromText(text = '') {
    if (!text)
        return '';
    const dateMatch = text.match(/\d{4}[年\.\/-]\d{1,2}[月\.\/-]\d{1,2}/);
    if (!dateMatch)
        return '';
    const matched = dateMatch[0].replace(/\./g, '-');
    return dealDate(matched);
}
function formatExportCellValue(value) {
    if (value === undefined || value === null) {
        return 'Null';
    }
    if (typeof value === 'string') {
        if (!value.trim()) {
            return 'Null';
        }
        return value;
    }
    return value;
}
function parseImportedCell(value) {
    const isNullPlaceholder = typeof value === 'string' && value.trim().toLowerCase() === 'null';
    if (value === undefined || value === null || isNullPlaceholder) {
        return {
            value: '',
            isNullPlaceholder,
        };
    }
    return {
        value,
        isNullPlaceholder,
    };
}

// support GM_XMLHttpRequest
let retryCounter = 0;
function fetchInfo(url, type, opts = {}, TIMEOUT = 10 * 1000) {
    var _a;
    const method = ((_a = opts === null || opts === void 0 ? void 0 : opts.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) || 'GET';
    // @ts-ignore
    {
        const gmXhrOpts = Object.assign({}, opts);
        if (method === 'POST' && gmXhrOpts.body) {
            gmXhrOpts.data = gmXhrOpts.body;
        }
        if (opts.decode) {
            type = 'arraybuffer';
        }
        return new Promise((resolve, reject) => {
            // @ts-ignore
            GM_xmlhttpRequest(Object.assign({
                method, timeout: TIMEOUT, url, responseType: type, onload: function (res) {
                    if (res.status === 404) {
                        retryCounter = 0;
                        reject(404);
                    }
                    else if (res.status === 302 && retryCounter < 5) {
                        retryCounter++;
                        resolve(fetchInfo(res.finalUrl, type, opts, TIMEOUT));
                    }
                    if (opts.decode && type === 'arraybuffer') {
                        retryCounter = 0;
                        let decoder = new TextDecoder(opts.decode);
                        resolve(decoder.decode(res.response));
                    }
                    else {
                        retryCounter = 0;
                        resolve(res.response);
                    }
                }, onerror: (e) => {
                    retryCounter = 0;
                    reject(e);
                }
            }, gmXhrOpts));
        });
    }
}
function fetchText(url, opts = {}, TIMEOUT = 10 * 1000) {
    return fetchInfo(url, 'text', opts, TIMEOUT);
}

function sleep(num) {
    return new Promise((resolve) => {
        setTimeout(resolve, num);
    });
}
function randomSleep(max = 400, min = 200) {
    return sleep(randomNum(max, min));
}
function randomNum(max, min) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}
function getCollectionRouteByUrl(url = '') {
    if (!url)
        return '';
    const match = url.match(/\/(anime|book|music|game|real)\/list\//);
    return match ? match[1] : '';
}
const CURRENT_COLLECTION_ROUTE = typeof location !== 'undefined' ? getCollectionRouteByUrl(location.href) : '';

// @TODO 听和读没有区分开
const typeIdDict = {
    dropped: {
        name: '抛弃',
        id: '5',
    },
    on_hold: {
        name: '搁置',
        id: '4',
    },
    do: {
        name: '在看',
        id: '3',
    },
    collect: {
        name: '看过',
        id: '2',
    },
    wish: {
        name: '想看',
        id: '1',
    },
};
function createInterestLabelMap(overrides = {}) {
    return Object.assign({
        dropped: '抛弃',
        on_hold: '搁置',
        do: '在看',
        collect: '看过',
        wish: '想看',
    }, overrides);
}
const INTEREST_LABELS_BY_ROUTE = {
    default: createInterestLabelMap(),
    anime: createInterestLabelMap(),
    real: createInterestLabelMap(),
    book: createInterestLabelMap({
        do: '在读',
        collect: '读过',
        wish: '想读',
    }),
    music: createInterestLabelMap({
        do: '在听',
        collect: '听过',
        wish: '想听',
    }),
    game: createInterestLabelMap({
        do: '在玩',
        collect: '玩过',
        wish: '想玩',
    }),
};
function getRouteInterestLabels(route = '') {
    if (route && INTEREST_LABELS_BY_ROUTE[route]) {
        return INTEREST_LABELS_BY_ROUTE[route];
    }
    return INTEREST_LABELS_BY_ROUTE.default;
}
function matchInterestKeyByName(name, route = '') {
    const routeMap = getRouteInterestLabels(route);
    for (const [key, label] of Object.entries(routeMap)) {
        if (label === name) {
            return key;
        }
    }
    const defaultMap = INTEREST_LABELS_BY_ROUTE.default;
    for (const [key, label] of Object.entries(defaultMap)) {
        if (label === name) {
            return key;
        }
    }
    return '';
}
// 默认返回 2, 表示看过
function getInterestTypeIdByName(name, route = CURRENT_COLLECTION_ROUTE) {
    let type = '2';
    if (!name)
        return type;
    const matchedKey = matchInterestKeyByName(name, route);
    if (matchedKey && typeIdDict[matchedKey]) {
        return typeIdDict[matchedKey].id;
    }
    return type;
}
function getInterestTypeName(type, route = CURRENT_COLLECTION_ROUTE) {
    const labels = getRouteInterestLabels(route);
    if (labels[type]) {
        return labels[type];
    }
    if (typeIdDict[type]) {
        return typeIdDict[type].name;
    }
    return '';
}
function getSubjectId(url) {
    const m = url.match(/(?:subject|character)\/(\d+)/);
    if (!m)
        return '';
    return m[1];
}
function insertLogInfo($sibling, txt) {
    const $log = document.createElement('div');
    $log.classList.add('e-wiki-log-info');
    // $log.setAttribute('style', 'color: tomato;');
    $log.innerHTML = txt;
    $sibling.insertAdjacentElement('afterend', $log);
    return $log;
}
function convertItemInfo($item) {
    let $subjectTitle = $item.querySelector('h3>a.l');
    let itemSubject = {
        name: $subjectTitle.textContent.trim(),
        rawInfos: $item.querySelector('.info').textContent.trim(),
        // url 没有协议和域名
        url: $subjectTitle.getAttribute('href'),
        greyName: $item.querySelector('h3>.grey')
            ? $item.querySelector('h3>.grey').textContent.trim()
            : '',
    };
    let matchDate = $item
        .querySelector('.info')
        .textContent.match(/\d{4}[\-\/\年]\d{1,2}[\-\/\月]\d{1,2}/);
    if (matchDate) {
        itemSubject.releaseDate = dealDate(matchDate[0]);
    }
    const $rateInfo = $item.querySelector('.rateInfo');
    if ($rateInfo) {
        const rateInfo = {};
        if ($rateInfo.querySelector('.fade')) {
            rateInfo.score = $rateInfo.querySelector('.fade').textContent;
            rateInfo.count = $rateInfo
                .querySelector('.tip_j')
                .textContent.replace(/[^0-9]/g, '');
        }
        else {
            rateInfo.score = '0';
            rateInfo.count = '少于10';
        }
        itemSubject.rateInfo = rateInfo;
    }
    const $rank = $item.querySelector('.rank');
    if ($rank) {
        itemSubject.rank = $rank.textContent.replace('Rank', '').trim();
    }
    const $collectInfo = $item.querySelector('.collectInfo');
    const collectInfo = {};
    const $comment = $item.querySelector('#comment_box');
    if ($comment) {
        collectInfo.comment = $comment.textContent.trim();
    }
    if ($collectInfo) {
        const textArr = $collectInfo.textContent.split('/');
        collectInfo.date = textArr[0].trim();
        textArr.forEach((str) => {
            if (str.match('标签')) {
                collectInfo.tags = str.replace(/标签:/, '').trim();
            }
        });
        const $starlight = $collectInfo.querySelector('.starlight');
        if ($starlight) {
            $starlight.classList.forEach((s) => {
                if (/stars\d/.test(s)) {
                    collectInfo.score = s.replace('stars', '');
                }
            });
        }
    }
    const detectedLabel = getIndexSubjectTypeLabel($item);
    if (detectedLabel) {
        itemSubject.detectedInterestLabel = detectedLabel;
    }
    if (Object.keys(collectInfo).length) {
        itemSubject.collectInfo = collectInfo;
    }
    const $cover = $item.querySelector('.subjectCover img');
    if ($cover && $cover.tagName.toLowerCase() === 'img') {
        // 替换 cover/s --->  cover/l 是大图
        const src = $cover.getAttribute('src') || $cover.getAttribute('data-cfsrc');
        if (src) {
            itemSubject.cover = src.replace('pic/cover/s', 'pic/cover/l');
        }
    }
    return itemSubject;
}
function logDirectoryExportTypes(items, label = '') {
    try {
        const header = label ? `[目录导出][${label}]` : '[目录导出]';
        console.group(header);
        items.forEach((item, idx) => {
            var _a, _b;
            const type = ((_a = item.collectInfo) === null || _a === void 0 ? void 0 : _a.interestType) || ((_b = item.collectInfo) === null || _b === void 0 ? void 0 : _b.interestTypeLabel) || '未知';
            const key = item.name || `#${idx + 1}`;
            console.log({ [key]: type });
        });
        console.groupEnd();
    }
    catch (error) {
        console.warn('目录导出类型日志输出失败: ', error);
    }
}
function getItemInfos($doc = document) {
    const items = $doc.querySelectorAll('#browserItemList>li');
    const res = [];
    for (const item of Array.from(items)) {
        res.push(convertItemInfo(item));
    }
    return res;
}
function getSectionTitleFromNode($node) {
    if (!$node)
        return '';
    let current = $node.previousElementSibling;
    while (current) {
        if (current.classList && current.classList.contains('subtitle')) {
            return current.textContent.trim();
        }
        current = current.previousElementSibling;
    }
    return '';
}
function convertIndexAvatarItem($item, sectionTitle = '') {
    const $link = $item.querySelector('h3>a');
    if (!$link) {
        return null;
    }
    const infoNode = $item.querySelector('.prsn_info') || $item.querySelector('.tip') || $item.querySelector('.line_detail');
    const infoText = infoNode ? infoNode.textContent.replace(/\s+/g, ' ').trim() : '';
    const avatar = $item.querySelector('img');
    const cover = avatar ? avatar.getAttribute('src') || avatar.getAttribute('data-cfsrc') || '' : '';
    const rawInfos = [sectionTitle, infoText].filter(Boolean).join(' / ');
    const url = $link.getAttribute('href') || '';
    const commentText = extractCommonComment($item);
    return {
        name: $link.textContent.trim(),
        greyName: '',
        releaseDate: '',
        url,
        cover,
        rawInfos,
        collectInfo: {
            interestType: sectionTitle || '目录',
            comment: commentText,
            tags: '',
            date: '',
        },
    };
}
function getIndexCharacterInfos($doc = document) {
    const lists = $doc.querySelectorAll('.browserCrtList');
    const res = [];
    for (const list of Array.from(lists)) {
        const sectionTitle = getSectionTitleFromNode(list);
        const items = list.querySelectorAll('[id^="item_"]');
        for (const item of Array.from(items)) {
            const parsed = convertIndexAvatarItem(item, sectionTitle);
            if (parsed) {
                res.push(parsed);
            }
        }
    }
    return res;
}
function parseIndexDocument($doc = document) {
    const subjects = getItemInfos($doc);
    const characterInfos = getIndexCharacterInfos($doc);
    const combined = [...subjects, ...characterInfos];
    return combined.map((item) => {
        if (!item.collectInfo) {
            item.collectInfo = {};
        }
        if (!item.collectInfo.interestType) {
            if (item.subjectTypeId && SUBJECT_TYPE_LABEL_MAP[item.subjectTypeId]) {
                item.collectInfo.interestType = SUBJECT_TYPE_LABEL_MAP[item.subjectTypeId];
            }
            else if (item.detectedInterestLabel) {
                item.collectInfo.interestType = item.detectedInterestLabel;
            }
            else if (item.url && item.url.startsWith('/character')) {
                item.collectInfo.interestType = '角色';
            }
        }
        return item;
    });
}
function getIndexMainContainer($doc = document) {
    return $doc.querySelector('#columnSubjectBrowserA') || $doc;
}
function normalizeIndexText(text = '') {
    return (text || '').replace(/\s+/g, ' ').trim();
}
function extractCommonComment($item, extraSelectors = []) {
    const selectors = []
        .concat(extraSelectors || [])
        .concat([
        '.collectInfo .comment',
        '.comment_box .text',
        '.comment-box .text',
        '#comment_box',
        '.text_main_even .text',
        '.content',
        '.intro',
        '.line_detail',
        '.message',
        '.quote',
    ]);
    for (const selector of selectors) {
        if (!selector)
            continue;
        const $node = $item.querySelector(selector);
        if ($node) {
            const text = normalizeIndexText($node.textContent);
            if (text) {
                return text;
            }
        }
    }
    return '';
}
function getImageSrcFromNode($img) {
    if (!$img)
        return '';
    return $img.getAttribute('src') || $img.getAttribute('data-cfsrc') || '';
}
function parseIndexBlogDocument($doc = document) {
    const $container = getIndexMainContainer($doc);
    const result = [];
    const $items = $container.querySelectorAll('#entry_list .item');
    for (const $item of Array.from($items)) {
        const $title = $item.querySelector('h2.title a[href^="/blog/"]');
        if (!$title) {
            continue;
        }
        const $coverImg = $item.querySelector('.cover img');
        const cover = getImageSrcFromNode($coverImg);
        const $content = $item.querySelector('.content');
        const $time = $item.querySelector('.tools .time') || $item.querySelector('.info .time');
        const authorLink = $time === null || $time === void 0 ? void 0 : $time.querySelector('a[href^="/user/"]');
        const comment = extractCommonComment($item, ['.content']);
        const timeText = $time ? normalizeIndexText($time.textContent) : '';
        const rawInfos = [
            authorLink ? `作者: ${normalizeIndexText(authorLink.textContent)}` : '',
            timeText,
        ]
            .filter(Boolean)
            .join(' / ');
        const releaseDate = extractDateFromText(timeText);
        result.push({
            name: normalizeIndexText($title.textContent),
            greyName: '',
            releaseDate,
            url: $title.getAttribute('href'),
            cover,
            rawInfos,
            collectInfo: {
                interestType: '',
                comment,
                tags: '',
                date: releaseDate,
            },
        });
    }
    return result;
}
function parseIndexTopicList($doc = document, topicPrefix = '/group/topic/') {
    const $container = getIndexMainContainer($doc);
    const result = [];
    const $items = $container.querySelectorAll('ul.topic-list li');
    for (const $item of Array.from($items)) {
        const $title = $item.querySelector(`.inner a[href^="${topicPrefix}"]`);
        if (!$title) {
            continue;
        }
        const $coverImg = $item.querySelector('.avatar img');
        const cover = getImageSrcFromNode($coverImg);
        const $info = $item.querySelector('.info');
        const authorText = $info && $info.querySelector('.author') ? normalizeIndexText($info.querySelector('.author').textContent) : '';
        const relatedText = $info && $info.querySelector('.related') ? normalizeIndexText($info.querySelector('.related').textContent) : '';
        const timeText = $info && $info.querySelector('.time') ? normalizeIndexText($info.querySelector('.time').textContent) : '';
        const releaseDate = extractDateFromText(timeText);
        const rawInfos = [
            authorText ? `作者: ${authorText}` : '',
            relatedText ? `关联: ${relatedText}` : '',
            timeText,
        ]
            .filter(Boolean)
            .join(' / ');
        const typeLabel = topicPrefix.includes('subject') ? '条目话题' : '小组话题';
        const comment = extractCommonComment($item, ['.inner .quote', '.inner .line', '.inner .row']);
        result.push({
            name: normalizeIndexText($title.textContent),
            greyName: '',
            releaseDate,
            url: $title.getAttribute('href'),
            cover,
            rawInfos,
            collectInfo: {
                interestType: typeLabel,
                comment,
                tags: '',
                date: releaseDate,
            },
        });
    }
    return result;
}
function parseIndexGroupTopicDocument($doc = document) {
    return parseIndexTopicList($doc, '/group/topic/');
}
function parseIndexSubjectTopicDocument($doc = document) {
    return parseIndexTopicList($doc, '/subject/topic/');
}
function parseIndexEpisodeDocument($doc = document) {
    const $container = getIndexMainContainer($doc);
    const result = [];
    const $items = $container.querySelectorAll('ul.browserList li, #browserItemList li');
    for (const $item of Array.from($items)) {
        const $episodeLink = $item.querySelector('h3 a[href^="/ep/"]');
        if (!$episodeLink) {
            continue;
        }
        const $coverImg = $item.querySelector('.avatar img, .subjectCover img');
        const cover = getImageSrcFromNode($coverImg);
        const $subjectLink = $item.querySelector('a[href^="/subject/"] span, a[href^="/subject/"]');
        const subjectName = $subjectLink ? normalizeIndexText($subjectLink.textContent) : '';
        const comment = extractCommonComment($item, ['.comment_box .text']);
        const $time = $item.querySelector('.tools .time, .time.tip_j');
        const timeText = $time ? normalizeIndexText($time.textContent) : '';
        const releaseDate = extractDateFromText(timeText);
        const rawInfos = [
            subjectName ? `所属条目: ${subjectName}` : '',
            timeText,
        ]
            .filter(Boolean)
            .join(' / ');
        const typeLabel = getIndexSubjectTypeLabel($item) || '条目';
        result.push({
            name: normalizeIndexText($episodeLink.textContent),
            greyName: '',
            releaseDate,
            url: $episodeLink.getAttribute('href'),
            cover,
            rawInfos,
            collectInfo: {
                interestType: typeLabel,
                comment,
                tags: '',
                date: releaseDate,
            },
        });
    }
    return result;
}
function getIndexCategoryKey(url) {
    const fallback = 'default';
    const targetUrl = url || (typeof location !== 'undefined' ? location.href : '');
    if (!targetUrl) {
        return fallback;
    }
    try {
        const parsedUrl = new URL(targetUrl, (typeof location !== 'undefined' ? location.origin : 'https://bgm.tv'));
        const rawCat = (parsedUrl.searchParams.get('cat') || '').trim();
        if (rawCat) {
            return rawCat.toLowerCase();
        }
        const segments = parsedUrl.pathname.split('/').filter(Boolean);
        const lastSegment = segments[segments.length - 1] || '';
        if (lastSegment && !/^\d+$/.test(lastSegment)) {
            return lastSegment.toLowerCase();
        }
    }
    catch (error) {
        console.warn('无法解析目录分类 Key: ', error);
    }
    return fallback;
}
function getIndexCategoryLabel(url) {
    const key = getIndexCategoryKey(url);
    return INDEX_CATEGORY_LABEL_MAP[key] || INDEX_CATEGORY_LABEL_MAP.default;
}
function getIndexParserByUrl(url) {
    const key = getIndexCategoryKey(url);
    if (key && INDEX_PARSER_MAP[key]) {
        return INDEX_PARSER_MAP[key];
    }
    return INDEX_PARSER_MAP.default;
}
function getTotalPageNum($doc = document) {
    const extractPageNum = ($container) => {
        if (!$container)
            return 1;
        const links = $container.querySelectorAll('.p');
        if (links && links.length) {
            const parseNum = (node) => {
                if (!node || !node.getAttribute)
                    return 0;
                const href = node.getAttribute('href') || '';
                const match = href.match(/page=(\d+)/);
                if (!match)
                    return 0;
                const num = parseInt(match[1], 10);
                return Number.isNaN(num) ? 0 : num;
            };
            let tailNum = parseNum(links[links.length - 1]);
            if (!tailNum && links.length > 1) {
                tailNum = parseNum(links[links.length - 2]);
            }
            if (tailNum)
                return tailNum;
        }
        const $cur = $container.querySelector('.p_cur');
        if ($cur) {
            const num = parseInt($cur.textContent.trim(), 10);
            if (!Number.isNaN(num)) {
                return num;
            }
        }
        return 1;
    };
    const multipage = $doc.querySelector('#multipage');
    const totalFromMultipage = extractPageNum(multipage === null || multipage === void 0 ? void 0 : multipage.querySelector('.page_inner') || multipage);
    if (totalFromMultipage > 1) {
        return totalFromMultipage;
    }
    const pageInner = $doc.querySelector('.page_inner');
    const totalFromPageInner = extractPageNum(pageInner);
    return totalFromPageInner > 1 ? totalFromPageInner : 1;
}
const updateFormCache = new Map();
function extractFormEntries($form) {
    const entries = [];
    const elements = $form.querySelectorAll('input, select, textarea');
    elements.forEach((element) => {
        const name = element.getAttribute('name');
        if (!name || element.disabled)
            return;
        const type = (element.getAttribute('type') || '').toLowerCase();
        if (['checkbox', 'radio'].includes(type) && !element.checked) {
            return;
        }
        entries.push([name, element.value || '']);
    });
    return entries;
}
async function fetchUpdateFormMeta(subjectId) {
    if (updateFormCache.has(subjectId)) {
        return updateFormCache.get(subjectId);
    }
    const html = await fetchText(`/update/${subjectId}`);
    const doc = sharedDomParser.parseFromString(html, 'text/html');
    const $form = doc.querySelector('#collectBoxForm');
    if (!$form) {
        throw new Error('未获取到收藏表单');
    }
    const actionAttr = $form.getAttribute('action') || '';
    const action = new URL(actionAttr, location.origin).toString();
    const entries = extractFormEntries($form);
    const meta = {
        action,
        entries,
    };
    updateFormCache.set(subjectId, meta);
    return meta;
}
const IMPORT_MAX_CONCURRENT_REQUESTS = 3;
const IMPORT_MIN_REQUEST_INTERVAL = 400;
const IMPORT_MAX_REQUEST_INTERVAL = 2000;
const IMPORT_MAX_RETRY_TIMES = 3;
let lastInterestRequestTime = 0;
let currentInterestInterval = IMPORT_MIN_REQUEST_INTERVAL;
async function ensureInterestRequestInterval() {
    const now = Date.now();
    const diff = now - lastInterestRequestTime;
    if (diff < currentInterestInterval) {
        await sleep(currentInterestInterval - diff);
    }
    lastInterestRequestTime = Date.now();
}
function adjustInterestInterval(success) {
    if (success) {
        currentInterestInterval = Math.max(IMPORT_MIN_REQUEST_INTERVAL, Math.floor(currentInterestInterval * 0.8));
    }
    else {
        currentInterestInterval = Math.min(IMPORT_MAX_REQUEST_INTERVAL, Math.floor(currentInterestInterval * 1.5));
    }
}
function buildFormDataFromEntries(entries) {
    const formData = new FormData();
    entries.forEach(([key, value]) => {
        formData.append(key, value);
    });
    return formData;
}
/**
 * 更新用户收藏
 * @param subjectId 条目 id
 * @param data 更新数据
 */
async function updateInterest(subjectId, data) {
    const { action, entries } = await fetchUpdateFormMeta(subjectId);
    const formData = buildFormDataFromEntries(entries);
    const obj = Object.assign({ referer: 'ajax', tags: '', comment: '', update: '保存' }, data);
    for (let [key, val] of Object.entries(obj)) {
        if (!formData.has(key)) {
            formData.append(key, val);
        }
        else {
            // 标签和吐槽可以直接清空
            if (['tags', 'comment', 'rating'].includes(key)) {
                formData.set(key, val);
            }
            else if (!formData.get(key) && val) {
                formData.set(key, val);
            }
        }
    }
    await ensureInterestRequestInterval();
    const response = await fetch(action, {
        method: 'POST',
        body: formData,
        credentials: 'include',
    });
    if (!response.ok) {
        adjustInterestInterval(false);
        throw new Error(`HTTP ${response.status}`);
    }
    adjustInterestInterval(true);
}
function getInterestTypeIdBySlug(slug) {
    if (!slug) {
        return '';
    }
    const config = typeIdDict[slug];
    if (config && config.id) {
        return config.id;
    }
    return '';
}
function normalizeTagsValue(value) {
    if (!value)
        return '';
    return value
        .split(',')
        .map((tag) => tag.trim())
        .filter(Boolean)
        .sort()
        .join(',');
}
function normalizeCollectionFieldValue(key, value) {
    if (value === undefined || value === null) {
        return undefined;
    }
    if (key === 'tags') {
        return normalizeTagsValue(String(value));
    }
    if (key === 'comment') {
        return String(value).trim();
    }
    if (key === 'rating') {
        return String(value).trim();
    }
    if (key === 'interest') {
        return String(value).trim();
    }
    return String(value).trim();
}
function buildCollectionSnapshotMap(items) {
    const map = new Map();
    for (const item of items) {
        const subjectId = getSubjectId(item.url);
        if (!subjectId)
            continue;
        const collect = item.collectInfo || {};
        const record = {
            interest: normalizeCollectionFieldValue('interest', getInterestTypeIdBySlug(collect.interestType)),
            rating: normalizeCollectionFieldValue('rating', collect.score),
            tags: normalizeCollectionFieldValue('tags', collect.tags),
            comment: normalizeCollectionFieldValue('comment', collect.comment),
        };
        map.set(subjectId, record);
    }
    return map;
}
let collectionSnapshotPromise = null;
async function getCurrentCollectionSnapshot() {
    if (collectionSnapshotPromise) {
        return collectionSnapshotPromise;
    }
    const meta = getListPageMeta(typeof location !== 'undefined' ? location.href : '');
    if (!meta) {
        collectionSnapshotPromise = Promise.resolve(null);
        return collectionSnapshotPromise;
    }
    collectionSnapshotPromise = (async () => {
        try {
            const data = await fetchCollectionsViaApi(meta);
            if (!Array.isArray(data)) {
                return null;
            }
            return buildCollectionSnapshotMap(data);
        }
        catch (error) {
            console.warn('获取现有收藏信息失败: ', error);
            return null;
        }
    })();
    return collectionSnapshotPromise;
}
function shouldSkipCollectionUpdate(subjectId, info, snapshot) {
    if (!snapshot || !subjectId || !snapshot.has(subjectId)) {
        return false;
    }
    const current = snapshot.get(subjectId);
    if (!current) {
        return false;
    }
    for (const [key, rawValue] of Object.entries(info)) {
        const normalized = normalizeCollectionFieldValue(key, rawValue);
        if (normalized === undefined) {
            continue;
        }
        const currentValue = current[key] || '';
        if (normalized !== currentValue) {
            return false;
        }
    }
    return true;
}

/**
 * 为页面添加样式
 * @param style
 */
/**
 * dollar 选择单个
 * @param {string} selector
 */
function $q(selector) {
    if (window._parsedEl) {
        return window._parsedEl.querySelector(selector);
    }
    return document.querySelector(selector);
}
/**
 * @param {String} HTML 字符串
 * @return {Element}
 */
function htmlToElement(html) {
    var template = document.createElement('template');
    html = html.trim();
    template.innerHTML = html;
    // template.content.childNodes;
    return template.content.firstChild;
}
function ensureButtonHoverStyle() {
    const styleId = 'e-userjs-btn-style';
    if (document.getElementById(styleId)) {
        return;
    }
    const style = document.createElement('style');
    style.id = styleId;
    style.textContent = `
.e-userjs-btn {
  display: inline-block;
  transition: transform 0.2s ease, filter 0.2s ease;
}
.e-userjs-btn:hover {
  transform: translateY(-1px);
  filter: brightness(1.05);
}
.e-userjs-btn a {
  position: relative;
  padding-bottom: 2px;
}
.e-userjs-btn a::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background: rgba(255, 99, 71, 0.6);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.2s ease;
}
.e-userjs-btn:hover a::after {
  transform: scaleX(1);
}
.e-userjs-btn span {
  transition: color 0.2s ease;
}
.e-userjs-btn:hover span {
  color: #ff765a !important;
}
`;
    document.head.appendChild(style);
}
function decorateButtonNode($node) {
    ensureButtonHoverStyle();
    $node.classList.add('e-userjs-btn');
    return $node;
}
function createExportButton(options) {
    const { label, progressText = '导出中...', completeText = '导出完成', onClick } = options;
    const btnStr = `<li><a href="javascript:void(0);"><span style="color:tomato;">${label}</span></a></li>`;
    const $node = decorateButtonNode(htmlToElement(btnStr));
    const $text = $node.querySelector('span');
    $node.addEventListener('click', async () => {
        if (!$text || typeof onClick !== 'function')
            return;
        const originText = $text.innerText;
        $text.innerText = progressText;
        $node.style.pointerEvents = 'none';
        try {
            await onClick();
            $text.innerText = completeText;
        }
        catch (error) {
            $text.innerText = '导出失败';
            console.error('导出错误: ', error);
            setTimeout(() => {
                $text.innerText = originText;
            }, 1500);
        }
        finally {
            $node.style.pointerEvents = 'auto';
        }
    });
    return $node;
}

// 目前写死
const CSV_HEADER = '名称,别名,发行日期,地址,封面地址,收藏日期,我的评分,标签,吐槽,其它信息';
const CSV_HEADER_COLUMNS = CSV_HEADER.split(',');
const DIRECTORY_EXPORT_EXCLUDED_COLUMNS = ['收藏日期', '我的评分', '标签', '别名'];
const INDEX_CATEGORY_LABEL_MAP = {
    default: '条目',
    '1': '书籍',
    '2': '动画',
    '3': '音乐',
    '4': '游戏',
    '6': '三次元',
    character: '角色',
    person: '人物',
    persons: '人物',
    people: '人物',
    ep: '章节',
    blog: '日志',
    group_topic: '小组话题',
    subject_topic: '条目话题',
};
const INDEX_PARSER_MAP = {
    default: parseIndexDocument,
    ep: parseIndexEpisodeDocument,
    blog: parseIndexBlogDocument,
    group_topic: parseIndexGroupTopicDocument,
    subject_topic: parseIndexSubjectTopicDocument,
};
const INDEX_DYNAMIC_CATEGORY_KEYS = new Set(['ep', 'blog', 'group_topic', 'subject_topic']);
const DIRECTORY_TYPE_CONFIG_TEMPLATE = {
    书籍: { sectionId: "related_0", cat: "0", pattern: /subject\/(\d+)/i, path: "subject" },
    动画: { sectionId: "related_0", cat: "0", pattern: /subject\/(\d+)/i, path: "subject" },
    音乐: { sectionId: "related_0", cat: "0", pattern: /subject\/(\d+)/i, path: "subject" },
    游戏: { sectionId: "related_0", cat: "0", pattern: /subject\/(\d+)/i, path: "subject" },
    三次元: { sectionId: "related_0", cat: "0", pattern: /subject\/(\d+)/i, path: "subject" },
    条目: { sectionId: "related_0", cat: "0", pattern: /subject\/(\d+)/i, path: "subject" },
    角色: { sectionId: "related_1", cat: "1", pattern: /character\/(\d+)/i, path: "character" },
    人物: { sectionId: "related_2", cat: "2", pattern: /person\/(\d+)/i, path: "person" },
    章节: { sectionId: "related_3", cat: "3", pattern: /ep\/(\d+)/i, path: "ep" },
    日志: { sectionId: "related_4", cat: "4", pattern: /blog\/(\d+)/i, path: "blog" },
    小组话题: { sectionId: "related_5", cat: "5", pattern: /group\/topic\/(\d+)/i, path: "group/topic" },
    条目话题: { sectionId: "related_6", cat: "6", pattern: /subject\/topic\/(\d+)/i, path: "subject/topic" },
};
const BROWSER_SELECTION_STORAGE_KEY = "bangumi-browser-selection";
const BROWSER_PANEL_MIN_KEY = "bangumi-browser-panel-minimized";
const BROWSER_ROUTE_LABEL_MAP = {
    book: "书籍",
    anime: "动画",
    music: "音乐",
    game: "游戏",
    real: "三次元",
};
function isBrowserPage() {
    return /^\/(book|anime|music|game|real)\/browser/.test(location.pathname);
}
function getBrowserRoute() {
    const match = location.pathname.match(/^\/(book|anime|music|game|real)\//);
    return match ? match[1] : "";
}
function getBrowserTypeLabel(route = getBrowserRoute()) {
    return BROWSER_ROUTE_LABEL_MAP[route] || "";
}
function loadBrowserSelections() {
    try {
        const stored = localStorage.getItem(BROWSER_SELECTION_STORAGE_KEY);
        if (!stored)
            return {};
        const parsed = JSON.parse(stored);
        if (parsed && typeof parsed === "object") {
            return parsed;
        }
    }
    catch (error) {
        console.warn("读取浏览页选中缓存失败: ", error);
    }
    return {};
}
function saveBrowserSelections(map) {
    try {
        localStorage.setItem(BROWSER_SELECTION_STORAGE_KEY, JSON.stringify(map));
    }
    catch (error) {
        console.warn("保存浏览页选中缓存失败: ", error);
    }
}
function loadPanelMinimized() {
    try {
        return localStorage.getItem(BROWSER_PANEL_MIN_KEY) === "1";
    }
    catch (error) {
        return false;
    }
}
function savePanelMinimized(flag) {
    try {
        localStorage.setItem(BROWSER_PANEL_MIN_KEY, flag ? "1" : "0");
    }
    catch (error) {
        console.warn("保存面板最小化状态失败: ", error);
    }
}
function ensureBrowserPanelStyle() {
    if (document.getElementById("bangumi-browser-panel-style")) {
        return;
    }
    const style = document.createElement("style");
    style.id = "bangumi-browser-panel-style";
    style.textContent = `
.bangumi-browser-fade-in {
  animation: bangumi-browser-pop 220ms ease-out forwards;
  opacity: 0;
  transform: translateY(6px) scale(0.98);
}
.bangumi-browser-item {
  position: relative;
}
.bangumi-select-checkbox {
  position: absolute;
  top: 30px;
  right: 10px;
  width: 18px;
  height: 18px;
  cursor: pointer;
}
.bangumi-browser-panel {
  position: fixed;
  top: 80px;
  right: 20px;
  width: 300px;
  min-width: 300px;
  max-height: 420px;
  background: #ffffff;
  border: 1px solid #dfe6ef;
  box-shadow: 0 10px 28px rgba(26, 46, 86, 0.12);
  border-radius: 12px;
  padding: 12px;
  z-index: 999;
  display: flex;
  flex-direction: column;
  gap: 8px;
  backdrop-filter: blur(4px);
  transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.bangumi-browser-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
}
.bangumi-browser-panel h4 {
  margin: 0;
  font-size: 14px;
  color: #0f172a;
  letter-spacing: 0.3px;
}
.bangumi-minimize-btn {
  border: none;
  background: transparent;
  color: #94a3b8;
  cursor: pointer;
  width: 28px;
  height: 28px;
  border-radius: 6px;
  display: grid;
  place-items: center;
  transition: background 0.16s ease, color 0.16s ease;
}
.bangumi-minimize-btn:hover {
  background: #f1f5f9;
  color: #4f46e5;
}
.bangumi-browser-badge {
  min-width: 72px;
  text-align: center;
  padding: 6px 10px;
  border-radius: 999px;
  font-weight: 700;
  font-size: 12px;
  color: #4a5cff;
  background: #eef1ff;
  transition: transform 0.25s ease;
}
.bangumi-browser-badge.pulse {
  animation: badge-pulse 0.35s ease;
}
.bangumi-selected-list {
  flex: 1;
  min-height: 250px;
  max-height: 300px;
  overflow-y: auto;
  padding: 8px 10px;
  background: #f8fafc;
  border-radius: 12px;
  color: #2f384e;
  font-size: 12px;
  line-height: 1.6;
}
.bangumi-selected-empty {
  padding: 18px 8px;
  text-align: center;
  color: #94a3b8;
  font-size: 12px;
}
.bangumi-selected-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 8px;
}
.bangumi-selected-row::before,
.bangumi-selected-row::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  border-top: 1px solid #e5e7eb;
}
.bangumi-selected-row::before { top: 0; }
.bangumi-selected-row::after { bottom: 0; }
.bangumi-selected-name {
  flex: 1;
  font-size: 13px;
  color: #0f172a;
  line-height: 1.4;
}
.bangumi-selected-remove {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 1px solid #e2e8f0;
  background: #fff;
  color: #94a3b8;
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: all 0.18s ease;
}
.bangumi-selected-remove:hover {
  color: #e11d48;
  border-color: #fecdd3;
  box-shadow: 0 6px 14px rgba(225, 29, 72, 0.16);
  transform: translateY(-1px);
}
.bangumi-selected-item {
  margin-bottom: 4px;
  word-break: break-all;
}
.bangumi-browser-panel .bangumi-panel-actions {
  display: flex;
  gap: 6px;
  justify-content: center;
  flex-wrap: nowrap;
  width: 100%;
}
.bangumi-browser-panel .bangumi-pill-btn {
  border: 1.5px solid transparent;
  border-radius: 8px;
  padding: 9px 12px;
  font-size: 12px;
  cursor: pointer;
  background: #ffffff;
  color: #0f172a;
  box-shadow: 0 10px 18px rgba(148, 163, 184, 0.16);
  transition: transform 0.12s ease, box-shadow 0.12s ease, filter 0.12s ease, border 0.12s ease;
  min-width: 0;
  flex: 1 1 0;
  position: relative;
  overflow: hidden;
}
.bangumi-browser-panel .bangumi-pill-btn:hover {
  transform: translateY(-1px);
  box-shadow: 0 12px 22px rgba(148, 163, 184, 0.2);
  filter: brightness(1.01);
}
.bangumi-browser-panel .bangumi-pill-btn:active {
  transform: translateY(0);
  box-shadow: 0 6px 14px rgba(148, 163, 184, 0.18);
}
.bangumi-browser-panel .bangumi-pill-btn.ghost {
  flex: 0 0 40px;
  border-color: transparent;
  background: transparent;
  color: #8b96ab;
  box-shadow: none;
  position: relative;
}
.bangumi-browser-panel .bangumi-pill-btn.ghost:hover {
  box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
  background: #FEF2F2;
  color: #e11d48;
  filter: none;
}
.bangumi-browser-panel .bangumi-pill-btn.secondary {
  flex: 1 1 0;
  border-color: #e5e7eb;
  background: #ffffff;
  color: #0f172a;
  box-shadow: 0 12px 22px rgba(148, 163, 184, 0.14);
}
.bangumi-browser-panel .bangumi-pill-btn.secondary:hover {
  box-shadow: 0 16px 26px rgba(148, 163, 184, 0.22);
}
.bangumi-browser-panel .bangumi-pill-btn.primary {
  flex: 1 1 0;
  background: linear-gradient(135deg, #4f46e5 0%, #4a4de8 100%);
  color: #fff;
  box-shadow: 0 18px 44px rgba(79, 70, 229, 0.32);
}
.bangumi-browser-panel .bangumi-pill-btn.primary:hover {
  box-shadow: 0 18px 42px rgba(77, 92, 255, 0.38);
  filter: brightness(1.02);
}
.bangumi-browser-panel .bangumi-pill-btn.primary .spinner {
  width: 14px;
  height: 14px;
  border: 2px solid rgba(255,255,255,0.45);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  margin-right: 8px;
  display: inline-block;
  vertical-align: middle;
}
.bangumi-browser-panel .bangumi-pill-btn.primary .label {
  vertical-align: middle;
}
.bangumi-browser-mini-btn {
  position: fixed;
  right: 16px;
  bottom: 32px;
  display: none;
  align-items: center;
  gap: 6px;
  padding: 10px 12px;
  background: #ffffff;
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  box-shadow: 0 14px 30px rgba(15, 23, 42, 0.18);
  color: #0f172a;
  font-size: 12px;
  cursor: pointer;
  z-index: 999;
}
.bangumi-browser-mini-btn:hover {
  box-shadow: 0 18px 36px rgba(15, 23, 42, 0.22);
}
.bangumi-browser-mini-btn .mini-icon {
  font-size: 14px;
}
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
@keyframes badge-pulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.14); }
  100% { transform: scale(1); }
}
@keyframes bangumi-browser-pop {
  0% { opacity: 0; transform: translateY(6px) scale(0.98); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
`;
    document.head.appendChild(style);
}
function normalizeBrowserItemUrl(url = "") {
    try {
        const parsed = new URL(url, location.origin);
        return parsed.pathname.replace(/\/+$/, "");
    }
    catch (error) {
        return url;
    }
}
function parseBrowserItemData($item) {
    const $link = $item.querySelector("h3>a.l");
    if (!$link) {
        return null;
    }
    const name = $link.textContent.trim();
    const rawUrl = $link.getAttribute("href") || "";
    const url = normalizeBrowserItemUrl(rawUrl);
    const id = getSubjectId(url) || url || name;
    const $rate = $item.querySelector(".rateInfo .fade");
    const score = $rate ? $rate.textContent.trim() : "";
    return {
        id,
        name,
        url,
        score,
        watchStatus: "Null",
        type: getBrowserTypeLabel(),
    };
}
async function fetchSubjectScoreViaApi(subjectId) {
    if (!subjectId) {
        return null;
    }
    const apiUrl = `${BGM_API_BASE}/subjects/${subjectId}`;
    let payloadText;
    try {
        payloadText = await fetchText(apiUrl, {
            headers: {
                "User-Agent": API_USER_AGENT,
            },
        });
    }
    catch (error) {
        console.warn("评分 API 请求失败: ", error);
        return null;
    }
    try {
        const data = JSON.parse(payloadText);
        if (data && data.rating && data.rating.score !== undefined) {
            return data.rating.score;
        }
    }
    catch (error) {
        console.warn("评分 API 解析失败: ", error);
    }
    return null;
}
async function enrichSelectionsWithApiScores(items, concurrency = 3) {
    const tasks = items.slice();
    await runTasksWithConcurrency(tasks, async (item) => {
        if (item.score) {
            return;
        }
        const subjectId = /^\d+$/.test(item.id) ? item.id : getSubjectId(item.url);
        if (!subjectId) {
            return;
        }
        const score = await fetchSubjectScoreViaApi(subjectId);
        if (score !== null && score !== undefined) {
            item.score = score;
        }
    }, concurrency);
    return items;
}
function createMiniButton(onRestore) {
    const btn = document.createElement("button");
    btn.className = "bangumi-browser-mini-btn";
    const icon = document.createElement("span");
    icon.className = "mini-icon";
    icon.textContent = "⤢";
    const label = document.createElement("span");
    label.textContent = "展开条目面板";
    btn.appendChild(icon);
    btn.appendChild(label);
    btn.addEventListener("click", (e) => {
        e.preventDefault();
        onRestore && onRestore();
    });
    document.body.appendChild(btn);
    return btn;
}
function createBrowserPanel(state) {
    ensureBrowserPanelStyle();
    let panel = document.querySelector(".bangumi-browser-panel");
    if (panel) {
        const badge = panel.querySelector(".bangumi-browser-badge");
        const list = panel.querySelector(".bangumi-selected-list");
        return { panel, badge, list };
    }
    panel = document.createElement("div");
    panel.className = "bangumi-browser-panel bangumi-browser-fade-in";
    const header = document.createElement("div");
    header.className = "bangumi-browser-panel-header";
    const title = document.createElement("h4");
    title.textContent = "条目选择";
    const badge = document.createElement("span");
    badge.className = "bangumi-browser-badge";
    badge.textContent = "0 已选";
    const minimizeBtn = document.createElement("button");
    minimizeBtn.className = "bangumi-minimize-btn";
    minimizeBtn.title = "最小化";
    minimizeBtn.textContent = "⤡";
    header.appendChild(title);
    header.appendChild(badge);
    header.appendChild(minimizeBtn);
    const list = document.createElement("div");
    list.className = "bangumi-selected-list";
    const actions = document.createElement("div");
    actions.className = "bangumi-panel-actions";
    const selectAllBtn = document.createElement("button");
    selectAllBtn.className = "bangumi-pill-btn secondary";
    selectAllBtn.textContent = "全选条目";
    selectAllBtn.addEventListener("click", state.selectAllVisible);
    const clearBtn = document.createElement("button");
    clearBtn.className = "bangumi-pill-btn ghost";
    clearBtn.textContent = "🗑️";
    clearBtn.title = "清除选中";
    clearBtn.addEventListener("click", state.clearSelections);
    const exportBtn = document.createElement("button");
    exportBtn.className = "bangumi-pill-btn primary";
    const exportSpinner = document.createElement("span");
    exportSpinner.className = "spinner";
    exportSpinner.style.display = "none";
    const exportLabel = document.createElement("span");
    exportLabel.className = "label";
    exportLabel.textContent = "导出条目";
    exportBtn.appendChild(exportSpinner);
    exportBtn.appendChild(exportLabel);
    exportBtn.addEventListener("click", async () => {
        if (exportBtn.disabled) return;
        exportBtn.disabled = true;
        exportSpinner.style.display = "inline-block";
        exportLabel.textContent = "处理中...";
        try {
            await state.exportSelections();
            exportLabel.textContent = "导出成功!";
            setTimeout(() => {
                exportLabel.textContent = "导出条目";
            }, 1200);
        }
        catch (error) {
            exportLabel.textContent = "导出失败";
            setTimeout(() => {
                exportLabel.textContent = "导出条目";
            }, 1200);
        }
        finally {
            exportBtn.disabled = false;
            exportSpinner.style.display = "none";
        }
    });
    actions.appendChild(clearBtn);
    actions.appendChild(selectAllBtn);
    actions.appendChild(exportBtn);
    panel.appendChild(header);
    panel.appendChild(list);
    panel.appendChild(actions);
    document.body.appendChild(panel);
    return { panel, badge, list, minimizeBtn };
}
function renderBrowserPanelList(panel, selections, badge) {
    const list = panel.querySelector(".bangumi-selected-list");
    if (!list) {
        return;
    }
    list.innerHTML = "";
    const entries = Object.values(selections);
    if (badge) {
        badge.textContent = `${entries.length} 已选`;
        badge.classList.remove("pulse");
        void badge.offsetWidth;
        badge.classList.add("pulse");
    }
    if (!entries.length) {
        const empty = document.createElement("div");
        empty.className = "bangumi-selected-empty";
        empty.textContent = "暂无选中条目,试试全选吧";
        list.appendChild(empty);
        return;
    }
    entries.forEach((item, idx) => {
        const row = document.createElement("div");
        row.className = "bangumi-selected-row";
        row.style.animationDelay = `${idx * 50}ms`;
        const name = document.createElement("div");
        name.className = "bangumi-selected-name";
        name.textContent = item.name;
        const remove = document.createElement("button");
        remove.className = "bangumi-selected-remove";
        remove.type = "button";
        remove.title = "移除";
        remove.textContent = "×";
        remove.addEventListener("click", () => {
            delete selections[item.id];
            saveBrowserSelections(selections);
            renderBrowserPanelList(panel, selections, badge);
            const checkbox = document.querySelector(`.bangumi-select-checkbox[value="${item.id}"]`);
            if (checkbox) {
                checkbox.checked = false;
            }
        });
        row.appendChild(name);
        row.appendChild(remove);
        list.appendChild(row);
    });
}
function toggleBrowserItemCheckbox($item, selections, onChange) {
    if (!$item || $item.querySelector(".bangumi-select-checkbox")) {
        return;
    }
    $item.classList.add("bangumi-browser-item");
    const data = parseBrowserItemData($item);
    if (!data) {
        return;
    }
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.className = "bangumi-select-checkbox";
    checkbox.setAttribute("value", data.id);
    checkbox.title = "选中以加入导出";
    checkbox.checked = !!selections[data.id];
    checkbox.addEventListener("change", () => {
        onChange(data, checkbox.checked);
    });
    const target = $item.querySelector(".collectBlock") || $item;
    target.appendChild(checkbox);
}
function scanBrowserList(selections, onChange) {
    const $items = document.querySelectorAll("#browserItemList>li, ul.browserFull.browser-list>li");
    for (const $item of Array.from($items)) {
        toggleBrowserItemCheckbox($item, selections, onChange);
    }
}
async function exportBrowserSelections(selections) {
    const items = Object.values(selections || {});
    if (!items.length) {
        alert("没有选中的条目");
        return;
    }
    await enrichSelectionsWithApiScores(items);
    const rows = items.map((item) => {
        return {
            名称: item.name,
            地址: item.url,
            评分: item.score || "",
            观看状态: item.watchStatus || "Null",
            类型: item.type || "",
        };
    });
    const worksheet = XLSX.utils.json_to_sheet(rows, {
        header: ["名称", "地址", "评分", "观看状态", "类型"],
    });
    const workbook = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(workbook, worksheet, "选中条目");
    const filename = `选中条目-${formatDate(new Date())}.xlsx`;
    XLSX.writeFile(workbook, filename);
}
function initBrowserSelectionFeature() {
    if (!isBrowserPage()) {
        return;
    }
    ensureBrowserPanelStyle();
    let selections = loadBrowserSelections();
    let badge = null;
    let panelEl = null;
    let miniBtn = null;
    function setPanelMinimized(flag) {
        if (!panelEl || !miniBtn)
            return;
        panelEl.style.display = flag ? "none" : "flex";
        miniBtn.style.display = flag ? "flex" : "none";
        savePanelMinimized(flag);
    }
    function handleChange(data, checked) {
        if (checked) {
            selections[data.id] = data;
        }
        else {
            delete selections[data.id];
        }
        saveBrowserSelections(selections);
        renderBrowserPanelList(panelEl, selections, badge);
    }
    function handleClear() {
        selections = {};
        saveBrowserSelections(selections);
        renderBrowserPanelList(panelEl, selections, badge);
        const checkboxes = document.querySelectorAll(".bangumi-select-checkbox");
        checkboxes.forEach((box) => {
            box.checked = false;
        });
    }
    function handleSelectAll() {
        const $items = document.querySelectorAll("#browserItemList>li, ul.browserFull.browser-list>li");
        for (const $item of Array.from($items)) {
            const data = parseBrowserItemData($item);
            if (!data) {
                continue;
            }
            selections[data.id] = data;
            const checkbox = $item.querySelector(".bangumi-select-checkbox");
            if (checkbox) {
                checkbox.checked = true;
            }
        }
        saveBrowserSelections(selections);
        renderBrowserPanelList(panelEl, selections, badge);
    }
    const panelRes = createBrowserPanel({
        exportSelections: () => exportBrowserSelections(selections),
        clearSelections: handleClear,
        selectAllVisible: handleSelectAll,
    });
    panelEl = panelRes.panel;
    badge = panelRes.badge;
    miniBtn = createMiniButton(() => setPanelMinimized(false));
    if (panelRes.minimizeBtn) {
        panelRes.minimizeBtn.addEventListener("click", () => setPanelMinimized(true));
    }
    setPanelMinimized(loadPanelMinimized());
    renderBrowserPanelList(panelEl, selections, badge);
    scanBrowserList(selections, handleChange);
    const listContainer = document.querySelector("#browserItemList") || document.querySelector(".browserFull.browser-list");
    if (listContainer) {
        const observer = new MutationObserver(() => {
            scanBrowserList(selections, handleChange);
        });
        observer.observe(listContainer, { childList: true, subtree: false });
    }
}
function getDirectoryTypeConfig(label = '') {
    const key = (label || '').trim();
    if (!key || !DIRECTORY_TYPE_CONFIG_TEMPLATE[key])
        return null;
    return Object.assign({}, DIRECTORY_TYPE_CONFIG_TEMPLATE[key]);
}
function normalizeDirectoryAddress(address = '') {
    if (!address)
        return '';
    const value = String(address).trim();
    if (!value)
        return '';
    try {
        const origin = typeof location !== 'undefined' ? location.origin : 'https://bgm.tv';
        const parsed = new URL(value, origin);
        return parsed.pathname.replace(/\/+$/, '');
    }
    catch (error) {
        return value;
    }
}
function extractDirectoryId(address = '', config = {}) {
    if (!address)
        return '';
    const original = String(address).trim();
    const normalized = normalizeDirectoryAddress(address);
    if (!normalized)
        return '';
    if (config.pattern) {
        const match = normalized.match(config.pattern);
        if (match && match[1]) {
            return match[1];
        }
    }
    if (/^\d+$/.test(normalized)) {
        return normalized;
    }
    return original;
}
function buildDirectoryTargetHref(address = '', config = {}) {
    const normalized = normalizeDirectoryAddress(address);
    if (!normalized)
        return '';
    if (normalized.startsWith('http')) {
        try {
            const origin = typeof location !== 'undefined' ? location.origin : 'https://bgm.tv';
            const parsed = new URL(normalized, origin);
            return parsed.pathname.replace(/\/+$/, '');
        }
        catch (error) {
            return normalized;
        }
    }
    if (normalized.startsWith('/')) {
        return normalized.replace(/\/+$/, '');
    }
    if (/^(subject|character|person|ep|blog|group\/topic|subject\/topic)\//.test(normalized)) {
        return `/${normalized.replace(/\/+$/, '')}`;
    }
    if (/^\d+$/.test(normalized)) {
        const path = config.path || 'subject';
        return `/${path}/${normalized}`;
    }
    return `/${normalized.replace(/\/+$/, '')}`;
}
function getDirectoryFormhash() {
    const selectors = [
        '#ModifyRelatedForm input[name="formhash"]',
        '#newIndexRelatedForm input[name="formhash"]',
        'input[name="formhash"]',
    ];
    for (const selector of selectors) {
        const $input = document.querySelector(selector);
        if ($input && $input.value) {
            return $input.value;
        }
    }
    return '';
}
function getModifySubmitMeta() {
    const $form = document.querySelector('#ModifyRelatedForm');
    const defaultMeta = {
        name: 'submit',
        value: '提交',
    };
    if (!$form)
        return defaultMeta;
    const $submit = $form.querySelector('input[type="submit"][name]') || $form.querySelector('input[type="submit"]');
    if (!$submit) {
        return defaultMeta;
    }
    const name = $submit.getAttribute('name') || defaultMeta.name;
    const value = $submit.getAttribute('value') || $submit.value || defaultMeta.value;
    return {
        name,
        value,
    };
}
function findRelationIdFromHtml(html = '', targetHref = '') {
    if (!html || !targetHref)
        return '';
    try {
        const $doc = sharedDomParser.parseFromString(html, 'text/html');
        const $link = $doc.querySelector(`[href="${targetHref}"]`);
        if (!$link)
            return '';
        const $item = $link.closest('[id^="item_"], [attr-index-related], .indexItem');
        if (!$item)
            return '';
        const $rltLink = $item.querySelector('a.tb_idx_rlt');
        if (!$rltLink || !$rltLink.id)
            return '';
        const parts = $rltLink.id.split('_');
        return parts[1] || '';
    }
    catch (error) {
        console.warn('解析目录新增 HTML 失败: ', error);
    }
    return '';
}
async function updateDirectoryRelationComment(relationId, comment, order = '') {
    if (!relationId || !comment)
        return;
    const formhash = getDirectoryFormhash();
    if (!formhash) {
        throw new Error('缺少 formhash,无法更新评价');
    }
    const { name: submitName, value: submitValue } = getModifySubmitMeta();
    const params = new URLSearchParams();
    params.set('formhash', formhash);
    params.set('content', comment);
    params.set('order', order || '');
    params.set(submitName || 'submit', submitValue);
    const response = await fetch(`/index/related/${relationId}/modify`, {
        method: 'POST',
        credentials: 'same-origin',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
            'X-Requested-With': 'XMLHttpRequest',
        },
        body: params.toString(),
    });
    if (!response.ok) {
        throw new Error(`更新评价失败 HTTP ${response.status}`);
    }
}
function buildIndexCategoryUrl(url, categoryKey) {
    try {
        const origin = typeof location !== 'undefined' ? location.origin : 'https://bgm.tv';
        const parsedUrl = new URL(url, origin);
        parsedUrl.searchParams.set('cat', categoryKey);
        parsedUrl.searchParams.delete('page');
        parsedUrl.hash = '';
        return parsedUrl.toString();
    }
    catch (error) {
        console.warn('目录分类跳转生成失败: ', error);
    }
    const delimiter = url.includes('?') ? '&' : '?';
    return `${url}${delimiter}cat=${categoryKey}`;
}
async function fetchIndexDynamicCategoryItems(url) {
    const items = [];
    for (const categoryKey of INDEX_DYNAMIC_CATEGORY_KEYS) {
        const parser = INDEX_PARSER_MAP[categoryKey];
        if (typeof parser !== 'function') {
            continue;
        }
        const targetUrl = buildIndexCategoryUrl(url, categoryKey);
        try {
            const result = await getCollectionInfoFromHtml(targetUrl, parser);
            result.forEach((item) => {
                if (!item.collectInfo) {
                    item.collectInfo = {};
                }
                if (!item.collectInfo.interestType) {
                    item.collectInfo.interestType = INDEX_CATEGORY_LABEL_MAP[categoryKey] || categoryKey;
                }
            });
            items.push(...result);
        }
        catch (error) {
            console.warn(`目录附加分类[${categoryKey}]抓取失败: `, error);
        }
    }
    return items;
}
const WATCH_STATUS_STR = '观看状态';
const IMPORT_FIELD_CONFIGS = [
    {
        column: WATCH_STATUS_STR,
        key: 'interest',
        requireValue: true,
        transform: (value) => getInterestTypeIdByName(String(value)),
    },
    { column: '我的评分', key: 'rating' },
    { column: '吐槽', key: 'comment' },
    { column: '标签', key: 'tags' },
];
const interestTypeArr = [
    'wish',
    'collect',
    'do',
    'on_hold',
    'dropped',
];
const SUBJECT_TYPE_MAP = {
    book: 1,
    anime: 2,
    music: 3,
    game: 4,
    real: 6,
};
const SUBJECT_TYPE_LABEL_MAP = {
    1: '书籍',
    2: '动画',
    3: '音乐',
    4: '游戏',
    6: '三次元',
};
const SUBJECT_TYPE_TEXT_KEYWORDS = [
    { keyword: '动画', label: '动画' },
    { keyword: '书籍', label: '书籍' },
    { keyword: '图书', label: '书籍' },
    { keyword: '小说', label: '书籍' },
    { keyword: '音乐', label: '音乐' },
    { keyword: '游戏', label: '游戏' },
    { keyword: '三次元', label: '三次元' },
];
const COLLECTION_SLUG_TO_API = {
    wish: 1,
    collect: 2,
    do: 3,
    on_hold: 4,
    dropped: 5,
};
const COLLECTION_API_TO_SLUG = {
    1: 'wish',
    2: 'collect',
    3: 'do',
    4: 'on_hold',
    5: 'dropped',
};
const API_USER_AGENT = 'Bangumi Collection Export Tool (+https://github.com/22earth/gm_scripts)';
const BGM_API_BASE = 'https://api.bgm.tv/v0';
const sharedDomParser = new DOMParser();
function genListUrl(t) {
    let u = location.href.replace(/[^\/]+?$/, '');
    return u + t;
}
// 通过 URL 获取收藏的状态
function getInterestTypeByUrl(url) {
    let m = url.match(/[^\/]+?$/);
    if (!m)
        return '';
    const lastSegment = m[0];
    return lastSegment.split('#')[0].split('?')[0];
}
function getListPageMeta(url) {
    const m = url.match(/\/(anime|book|music|game|real)\/list\/([^\/]+)\/([^\/?#]+)/);
    if (!m)
        return null;
    const route = m[1];
    const username = decodeURIComponent(m[2]);
    const slug = m[3];
    const subjectType = SUBJECT_TYPE_MAP[route];
    const collectionType = COLLECTION_SLUG_TO_API[slug];
    if (!username || !subjectType || !collectionType) {
        return null;
    }
    return {
        username,
        subjectType,
        collectionSlug: slug,
        collectionType,
    };
}
function convertApiCollectionItem(item) {
    var _a, _b, _c, _d;
    const subject = item.subject || {};
    const rawInfoParts = [];
    if (subject.date) {
        rawInfoParts.push(subject.date);
    }
    if (subject.eps) {
        rawInfoParts.push(`eps: ${subject.eps}`);
    }
    if (subject.rank) {
        rawInfoParts.push(`Rank ${subject.rank}`);
    }
    const cover = ((_b = (_a = subject.images) === null || _a === void 0 ? void 0 : _a.large) !== null && _b !== void 0 ? _b : ((_d = (_c = subject.images) === null || _c === void 0 ? void 0 : _c.common) !== null && _d !== void 0 ? _d : (subject.images ? subject.images.medium || subject.images.small : ''))) || '';
    const tags = Array.isArray(item.tags) ? item.tags.join(',') : '';
    const interestType = COLLECTION_API_TO_SLUG[item.type] || '';
    return {
        name: subject.name || '',
        greyName: subject.name_cn || '',
        releaseDate: subject.date || '',
        url: subject.id ? `/subject/${subject.id}` : '',
        cover,
        rawInfos: rawInfoParts.filter(Boolean).join(' / '),
        collectInfo: {
            date: item.updated_at ? formatDate(item.updated_at) : '',
            score: item.rate || '',
            tags,
            comment: item.comment || '',
            interestType,
        },
    };
}
async function fetchCollectionsViaApi(meta) {
    if (!meta)
        return null;
    const { username, subjectType, collectionType } = meta;
    const limit = 50;
    let offset = 0;
    const result = [];
    while (true) {
        const params = new URLSearchParams({
            subject_type: String(subjectType),
            type: String(collectionType),
            limit: String(limit),
            offset: String(offset),
        });
        const apiUrl = `${BGM_API_BASE}/users/${encodeURIComponent(username)}/collections?${params.toString()}`;
        let payloadText;
        try {
            payloadText = await fetchText(apiUrl, {
                headers: {
                    'User-Agent': API_USER_AGENT,
                },
            });
        }
        catch (error) {
            throw error;
        }
        let payload;
        try {
            payload = JSON.parse(payloadText);
        }
        catch (error) {
            throw error;
        }
        if ((payload === null || payload === void 0 ? void 0 : payload.title) && (payload === null || payload === void 0 ? void 0 : payload.description)) {
            throw new Error(payload.description || payload.title);
        }
        const data = payload === null || payload === void 0 ? void 0 : payload.data;
        if (!Array.isArray(data) || !data.length) {
            break;
        }
        result.push(...data.map((item) => convertApiCollectionItem(item)));
        if (data.length < limit) {
            break;
        }
        offset += data.length;
    }
    return result;
}
async function getCollectionInfo(url) {
    const meta = getListPageMeta(url);
    if (meta) {
        try {
            const apiResult = await fetchCollectionsViaApi(meta);
            if (Array.isArray(apiResult)) {
                return apiResult;
            }
        }
        catch (error) {
            console.warn('API 导出失败,使用旧版抓取逻辑: ', error);
        }
    }
    return getCollectionInfoFromHtml(url);
}
async function getCollectionInfoFromHtml(url, parser = getItemInfos) {
    const rawText = await fetchText(url);
    const $doc = sharedDomParser.parseFromString(rawText, 'text/html');
    const totalPageNum = getTotalPageNum($doc);
    const res = [...parser($doc)];
    let page = 2;
    while (page <= totalPageNum) {
        let reqUrl = url;
        const m = url.match(/page=(\d*)/);
        if (m) {
            reqUrl = reqUrl.replace(m[0], `page=${page}`);
        }
        else {
            reqUrl = `${reqUrl}?page=${page}`;
        }
        await sleep(500);
        console.info('fetch info: ', reqUrl);
        const rawText = await fetchText(reqUrl);
        const $doc = sharedDomParser.parseFromString(rawText, 'text/html');
        res.push(...parser($doc));
        page += 1;
    }
    return res;
}
async function getIndexCollectionInfo(url) {
    const parser = getIndexParserByUrl(url);
    const categoryKey = getIndexCategoryKey(url);
    if (INDEX_DYNAMIC_CATEGORY_KEYS.has(categoryKey)) {
        return parser(document);
    }
    let items = await getCollectionInfoFromHtml(url, parser);
    if (!categoryKey || categoryKey === 'default') {
        try {
            const extraItems = await fetchIndexDynamicCategoryItems(url);
            if (extraItems.length) {
                items = items.concat(extraItems);
            }
        }
        catch (error) {
            console.warn('目录扩展抓取失败: ', error);
        }
    }
    return items;
}
function getRowItem(item, options = {}) {
    const { excludedColumnsSet = null, interestTypeLabel = WATCH_STATUS_STR } = options;
    const dict = {
        name: '名称',
        greyName: '别名',
        releaseDate: '发行日期',
        url: '地址',
        cover: '封面地址',
        rawInfos: '其它信息',
    };
    const dictCollection = {
        date: '收藏日期',
        score: '我的评分',
        tags: '标签',
        comment: '吐槽',
        interestType: interestTypeLabel,
    };
    const res = {};
    const hasExcludedColumns = !!(excludedColumnsSet && excludedColumnsSet.size);
    for (const [key, value] of Object.entries(dict)) {
        if (hasExcludedColumns && excludedColumnsSet.has(value)) {
            continue;
        }
        // @ts-ignore
        res[value] = formatExportCellValue(item[key]);
    }
    for (const [key, value] of Object.entries(dictCollection)) {
        if (hasExcludedColumns && excludedColumnsSet.has(value)) {
            continue;
        }
        const collect = item.collectInfo || {};
        let cellValue;
        if (key === 'interestType') {
            const interestType = collect.interestType;
            if (!interestType) {
                cellValue = '';
            }
            else if (typeIdDict[interestType]) {
                cellValue = getInterestTypeName(interestType);
            }
            else {
                cellValue = interestType;
            }
        }
        else {
            // @ts-ignore
            cellValue = collect[key];
        }
        res[value] = formatExportCellValue(cellValue);
    }
    return res;
}
function getExportHeader(excludedColumns = []) {
    if (!excludedColumns.length) {
        return CSV_HEADER_COLUMNS.slice();
    }
    const excludedSet = new Set(excludedColumns);
    return CSV_HEADER_COLUMNS.filter((column) => !excludedSet.has(column));
}
function downloadExcel(filename, items, options = {}) {
    const excludedColumns = options.excludedColumns || [];
    const interestTypeLabel = options.interestTypeLabel || WATCH_STATUS_STR;
    const excludedColumnsSet = excludedColumns.length ? new Set(excludedColumns) : null;
    const rowOptions = {
        excludedColumnsSet,
        interestTypeLabel,
    };
    const rows = items.map((item) => getRowItem(item, rowOptions));
    // @TODO 采用分步写入的方式
    const header = getExportHeader(excludedColumns);
    if (!excludedColumnsSet || !excludedColumnsSet.has(interestTypeLabel)) {
        header.push(interestTypeLabel);
    }
    const worksheet = XLSX.utils.json_to_sheet(rows, {
        header,
    });
    const workbook = XLSX.utils.book_new();
    XLSX.utils.book_append_sheet(workbook, worksheet, '用户收藏');
    XLSX.writeFile(workbook, filename);
}
function genAllExportBtn(filename) {
    return createExportButton({
        label: '导出所有收藏',
        completeText: '完成所有导出',
        onClick: async () => {
            let infos = [];
            for (const t of interestTypeArr) {
                let res = [];
                try {
                    res = await getCollectionInfo(genListUrl(t));
                }
                catch (error) {
                    console.error('抓取错误: ', error);
                }
                infos = infos.concat(res.map((item) => {
                    item.collectInfo.interestType = t;
                    return item;
                }));
            }
            downloadExcel(filename, infos);
        },
    });
}
function genExportBtn(filename) {
    return createExportButton({
        label: '导出收藏',
        onClick: async () => {
            let res = [];
            try {
                res = await getCollectionInfo(location.href);
            }
            catch (error) {
                console.error('抓取错误: ', error);
            }
            const interestType = getInterestTypeByUrl(location.href);
            downloadExcel(filename, res.map((item) => {
                item.collectInfo.interestType = interestType;
                return item;
            }));
        },
    });
}

function genIndexExportBtn(filename) {
    const btnStr = '<span><a href="javascript:void(0);"><span style="color:tomato;">导出目录</span></a></span>';
    const $node = decorateButtonNode(htmlToElement(btnStr));
    $node.style.marginLeft = '10px';
    $node.addEventListener('click', async () => {
        const $text = $node.querySelector('span');
        if (!$text) {
            return;
        }
        const originText = $text.innerText;
        $text.innerText = '导出中...';
        $node.style.pointerEvents = 'none';
        const indexCategoryLabel = getIndexCategoryLabel(location.href);
        try {
            let res = await getIndexCollectionInfo(location.href);
            res = res.map((item) => {
                if (!item.collectInfo) {
                    item.collectInfo = {};
                }
                if (!item.collectInfo.interestType) {
                    item.collectInfo.interestType = indexCategoryLabel;
                }
                return item;
            });
            logDirectoryExportTypes(res, indexCategoryLabel);
            downloadExcel(filename, res, {
                excludedColumns: DIRECTORY_EXPORT_EXCLUDED_COLUMNS,
                interestTypeLabel: '类型',
            });
            $text.innerText = '导出完成';
        }
        catch (error) {
            $text.innerText = '导出失败';
            console.error('目录导出失败: ', error);
        }
        finally {
            setTimeout(() => {
                $text.innerText = originText;
            }, 1500);
            $node.style.pointerEvents = 'auto';
        }
    });
    return $node;
}
function getDirectoryImportLogTarget() {
    const $form = document.querySelector('#newIndexRelatedForm');
    if (!$form)
        return null;
    const $submitWrapper = $form.querySelector('#submitBtnO');
    if ($submitWrapper)
        return $submitWrapper;
    return $form;
}
function getDirectorySectionForm(sectionId) {
    if (!sectionId) {
        return document.querySelector('#newIndexRelatedForm');
    }
    const $section = document.getElementById(sectionId);
    if ($section) {
        const $form = $section.querySelector('form#newIndexRelatedForm') || $section.querySelector('form');
        if ($form) {
            return $form;
        }
    }
    return document.querySelector('#newIndexRelatedForm');
}
function buildDirectoryFormBody($form, config, value, comment = '') {
    var _a, _b;
    const entryValue = value === undefined || value === null ? '' : String(value).trim();
    const commentValue = comment === undefined || comment === null ? '' : String(comment);
    const $titleInput = $form.querySelector('input[name="add_related"], #title');
    if ($titleInput) {
        $titleInput.value = entryValue;
    }
    const $catInput = $form.querySelector('input[name="cat"]');
    if ($catInput && config.cat !== undefined) {
        $catInput.value = config.cat;
    }
    const $reply = $form.querySelector('textarea[name="content"], #modify_content, textarea.reply');
    if ($reply) {
        $reply.value = commentValue;
    }
    const formData = new FormData($form);
    formData.set('add_related', entryValue);
    if (config.cat !== undefined) {
        formData.set('cat', config.cat);
    }
    if ($reply && $reply.name) {
        formData.set($reply.name, commentValue);
    }
    else {
        formData.set('content', commentValue);
    }
    const $submit = $form.querySelector('input[type="submit"][name]');
    if ($submit && $submit.name) {
        formData.set($submit.name, ((_a = $submit.getAttribute('value')) === null || _a === void 0 ? void 0 : _a.toString()) || $submit.value || '');
    }
    const params = new URLSearchParams();
    formData.forEach((formValue, key) => {
        if (typeof formValue === 'string') {
            params.append(key, formValue);
        }
    });
    const action = (_b = $form.getAttribute('action')) !== null && _b !== void 0 ? _b : location.href;
    return {
        body: params.toString(),
        method: ($form.getAttribute('method') || 'POST').toUpperCase(),
        url: new URL(action, location.origin).toString(),
    };
}
async function submitDirectoryImportItem(item) {
    const { config, entryValue, comment } = item;
    const $form = getDirectorySectionForm(config.sectionId);
    if (!$form) {
        throw new Error(`未找到 ${config.sectionId} 表单`);
    }
    const request = buildDirectoryFormBody($form, config, entryValue, comment);
    const response = await fetch(request.url, {
        method: request.method,
        credentials: 'same-origin',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
            'X-Requested-With': 'XMLHttpRequest',
        },
        body: request.body,
    });
    const responseText = await response.text();
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
    }
    if (comment) {
        const relationId = findRelationIdFromHtml(responseText, item.targetHref);
        if (!relationId) {
            throw new Error('未找到关联 ID,无法更新评价');
        }
        await updateDirectoryRelationComment(relationId, comment);
    }
}
function convertDirectoryRow(row) {
    const { value: typeLabel } = parseImportedCell(row['类型']);
    const { value: address } = parseImportedCell(row['地址']);
    if (!typeLabel || !address)
        return null;
    const config = getDirectoryTypeConfig(String(typeLabel));
    if (!config) {
        console.warn('未知的目录类型: ', typeLabel);
        return null;
    }
    const entryValue = extractDirectoryId(String(address), config);
    if (!entryValue) {
        console.warn('未能从地址解析条目: ', address);
        return null;
    }
    const targetHref = buildDirectoryTargetHref(String(address), config);
    const { value: comment } = parseImportedCell(row['吐槽']);
    const { value: name } = parseImportedCell(row['名称']);
    return {
        config,
        entryValue,
        comment: comment || '',
        name: name || entryValue,
        typeLabel,
        rawAddress: address,
        targetHref,
    };
}
async function processDirectoryImportTasks(tasks, $logTarget) {
    for (const task of tasks) {
        const label = `「${task.name}」(${task.typeLabel})`;
        try {
            await submitDirectoryImportItem(task);
            if ($logTarget) {
                insertLogInfo($logTarget, `${label} 导入成功`);
            }
        }
        catch (error) {
            console.error('目录导入错误: ', error);
            if ($logTarget) {
                const message = (error && error.message) || error;
                insertLogInfo($logTarget, `${label} 导入失败: ${message}`);
            }
        }
        await randomSleep(1800, 800);
    }
}
async function handleIndexImportFile(e) {
    const input = e.target;
    const file = input.files && input.files[0];
    if (!file)
        return;
    const $wrapper = input.closest('.e-userjs-btn') || input.parentElement;
    const $text = $wrapper ? $wrapper.querySelector('a>span') : null;
    const originText = $text ? $text.innerText : '';
    if ($text) {
        $text.innerText = '导入目录中...';
    }
    input.disabled = true;
    try {
        const rows = await parseWorkbookToJson(file);
        const tasks = rows
            .map((row) => convertDirectoryRow(row))
            .filter((item) => !!item);
        if (!tasks.length) {
            throw new Error('没有可导入的数据');
        }
        const $logTarget = getDirectoryImportLogTarget();
        if ($logTarget) {
            insertLogInfo($logTarget, `准备导入 ${tasks.length} 条目录关联`);
        }
        await processDirectoryImportTasks(tasks, $logTarget);
        if ($text) {
            $text.innerText = '导入完成';
        }
        alert('目录导入完成');
        location.reload();
    }
    catch (error) {
        console.error('目录导入失败: ', error);
        if ($text) {
            $text.innerText = '导入失败';
        }
        const message = (error && error.message) || error;
        alert(`目录导入失败: ${message || '未知错误'}`);
    }
    finally {
        input.disabled = false;
        input.value = '';
        setTimeout(() => {
            if ($text) {
                $text.innerText = originText || '导入目录';
            }
        }, 1500);
    }
}
function genIndexImportControl() {
    if (!document.querySelector('#newIndexRelatedForm')) {
        return null;
    }
    const btnStr = '<span><a href="javascript:void(0);"><span style="color:tomato;">导入目录</span></a><input type="file" accept=".xlsx,.xls,.csv" style="display:none" /></span>';
    const $node = decorateButtonNode(htmlToElement(btnStr));
    $node.style.marginLeft = '10px';
    const $link = $node.querySelector('a');
    const $file = $node.querySelector('input[type="file"]');
    if ($link && $file) {
        $link.addEventListener('click', (event) => {
            event.preventDefault();
            $file.click();
        });
        $file.addEventListener('change', handleIndexImportFile);
    }
    return $node;
}

async function updateUserInterest(subject, data, $infoDom) {
    const nameStr = `<span style="color:tomato">《${subject.name}》</span>`;
    try {
        const subjectId = getSubjectId(subject.url);
        if (!subjectId) {
            throw new Error('条目地址无效');
        }
        insertLogInfo($infoDom, `更新收藏 ${nameStr} …`);
        await updateInterest(subjectId, data);
        insertLogInfo($infoDom, `更新收藏 ${nameStr} 成功`);
    }
    catch (error) {
        insertLogInfo($infoDom, `导入 ${nameStr} 错误: ${error}`);
        console.error('导入错误: ', error);
    }
}
function readCSV(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        const detectReader = new FileReader();
        detectReader.readAsBinaryString(file);
        detectReader.onload = function (e) {
            const contents = this.result;
            const arr = contents.split(/\r\n|\n/);
            // 检测文件编码
            reader.readAsText(file, jschardet.detect(arr[0].toString()).encoding);
        };
        reader.onload = function (e) {
            resolve(this.result);
        };
        reader.onerror = function (e) {
            reject(e);
        };
    });
}
async function parseWorkbookToJson(file) {
    let workbook;
    if (file.name.includes('.csv')) {
        const data = await readCSV(file);
        workbook = XLSX.read(data, { type: 'string' });
    }
    else {
        const data = await file.arrayBuffer();
        workbook = XLSX.read(data);
    }
    const firstSheetName = workbook.SheetNames[0];
    const worksheet = workbook.Sheets[firstSheetName];
    return XLSX.utils.sheet_to_json(worksheet);
}
function ensureImportProgressStyle() {
    const styleId = 'bangumi-import-progress-style';
    if (document.getElementById(styleId)) {
        return;
    }
    const style = document.createElement('style');
    style.id = styleId;
    style.textContent = `
.bangumi-import-progress-panel {
  margin-top: 10px;
  padding: 10px;
  border-radius: 8px;
  border: 1px solid rgba(255, 99, 71, 0.4);
  background: rgba(255, 99, 71, 0.08);
  font-size: 12px;
  line-height: 1.6;
}
.bangumi-import-progress-summary {
  font-weight: bold;
  color: #ff7043;
}
.bangumi-import-progress-note {
  margin-top: 4px;
  color: #555;
}
.bangumi-import-progress-log {
  margin-top: 6px;
  max-height: 160px;
  overflow-y: auto;
}
.bangumi-import-progress-log .bangumi-log-entry {
  margin-bottom: 4px;
  color: #444;
}
.bangumi-import-progress-log .bangumi-log-warn {
  color: #d97706;
}
.bangumi-import-progress-log .bangumi-log-error {
  color: #dc2626;
}
`;
    document.head.appendChild(style);
}
function createImportProgressTracker(options = {}) {
    ensureImportProgressStyle();
    const container = options.container || document.body;
    const existing = container.querySelector('#bangumi-import-progress-panel');
    if (existing) {
        existing.remove();
    }
    const panel = document.createElement('div');
    panel.id = 'bangumi-import-progress-panel';
    panel.className = 'bangumi-import-progress-panel';
    const summaryEl = document.createElement('div');
    summaryEl.className = 'bangumi-import-progress-summary';
    const noteEl = document.createElement('div');
    noteEl.className = 'bangumi-import-progress-note';
    const logEl = document.createElement('div');
    logEl.className = 'bangumi-import-progress-log';
    panel.appendChild(summaryEl);
    panel.appendChild(noteEl);
    panel.appendChild(logEl);
    container.appendChild(panel);
    const state = {
        fileTotal: options.fileTotal || 0,
        total: options.total || 0,
        completed: 0,
        skipped: options.skipped || 0,
        failed: 0,
        retries: 0,
    };
    const failureSet = new Set();
    const getTaskKey = (task) => {
        if (!task)
            return '';
        const subject = task.subject || {};
        return subject.url || subject.name || '';
    };
    function render() {
        summaryEl.textContent = `共 ${state.fileTotal} 条,需更新 ${state.total} 条,已完成 ${state.completed}/${state.total},跳过 ${state.skipped},失败 ${state.failed}`;
        if (state.total === 0) {
            summaryEl.textContent = `共 ${state.fileTotal} 条,全部已最新或被跳过`;
        }
        noteEl.textContent = state.message || '';
    }
    function addLog(message, level = 'info') {
        if (!message)
            return;
        const entry = document.createElement('div');
        entry.className = `bangumi-log-entry ${level === 'warn'
            ? 'bangumi-log-warn'
            : level === 'error'
                ? 'bangumi-log-error'
                : ''}`;
        entry.textContent = message;
        logEl.appendChild(entry);
        logEl.scrollTop = logEl.scrollHeight;
    }
    render();
    return {
        addLog,
        markSuccess(task) {
            state.completed += 1;
            const key = getTaskKey(task);
            if (key && failureSet.has(key)) {
                failureSet.delete(key);
                state.failed = failureSet.size;
            }
            render();
        },
        markFailure(task, error) {
            const key = getTaskKey(task);
            if (key) {
                failureSet.add(key);
                state.failed = failureSet.size;
            }
            const subjectName = (task === null || task === void 0 ? void 0 : task.subject) ? task.subject.name : '';
            addLog(`${subjectName || '未知条目'} 更新失败: ${(error && error.message) || error}`, 'error');
            render();
        },
        markRetry(remain, attempt) {
            state.retries += 1;
            addLog(`第 ${attempt} 轮失败 ${remain} 条,准备重试`, 'warn');
        },
        setMessage(msg) {
            state.message = msg;
            render();
        },
        getState() {
            return Object.assign({}, state);
        },
        finish({ failedCount = 0, message = '' } = {}) {
            state.failed = failedCount;
            state.message = message || state.message;
            render();
        },
    };
}
async function runTasksWithConcurrency(items, iterator, concurrency = IMPORT_MAX_CONCURRENT_REQUESTS) {
    if (!items.length) {
        return;
    }
    const workerCount = Math.max(1, Math.min(concurrency, items.length));
    let currentIndex = 0;
    const workers = Array.from({ length: workerCount }, async () => {
        while (true) {
            const nextIndex = currentIndex;
            currentIndex += 1;
            if (nextIndex >= items.length) {
                break;
            }
            await iterator(items[nextIndex], nextIndex);
        }
    });
    await Promise.all(workers);
}
async function processImportTasksWithRetry(tasks, options = {}) {
    const { concurrency = IMPORT_MAX_CONCURRENT_REQUESTS, maxAttempts = IMPORT_MAX_RETRY_TIMES, hooks = {} } = options;
    const failedTasks = [];
    let pending = tasks.slice();
    if (!pending.length) {
        return failedTasks;
    }
    for (let attempt = 1; attempt <= maxAttempts && pending.length; attempt++) {
        hooks.onAttemptStart && hooks.onAttemptStart({ attempt, remainingCount: pending.length });
        const attemptFailed = [];
        await runTasksWithConcurrency(pending, async (task) => {
            try {
                await updateUserInterest(task.subject, task.info, task.$infoDom);
                hooks.onTaskSuccess && hooks.onTaskSuccess({ attempt, task });
            }
            catch (error) {
                task.lastError = error;
                attemptFailed.push(task);
                hooks.onTaskFail && hooks.onTaskFail({ attempt, task, error });
            }
        }, concurrency);
        if (!attemptFailed.length) {
            break;
        }
        if (attempt === maxAttempts) {
            failedTasks.push(...attemptFailed);
        }
        else {
            pending = attemptFailed;
            hooks.onRetryScheduled &&
                hooks.onRetryScheduled({ nextAttempt: attempt + 1, remainingCount: pending.length });
            await sleep(Math.min(3000, 1000 * attempt));
        }
    }
    return failedTasks;
}
async function handleFileAsync(e) {
    const target = e.target;
    const $parent = target.closest('li');
    const $label = $parent ? $parent.querySelector('a > span') : null;
    const file = target.files[0];
    if (!file) {
        return;
    }
    if ($label) {
        $label.innerHTML = '导入中…';
    }
    if ($parent) {
        $parent.style.pointerEvents = 'none';
    }
    const $menu = document.querySelector('#columnSubjectBrowserB .menu_inner');
    const logContainer = $menu || document.querySelector('#columnSubjectBrowserB') || document.body;
    let progressTracker = null;
    let jsonData = [];
    try {
        jsonData = await parseWorkbookToJson(file);
        const parsedTasks = [];
        const invalidRecords = [];
        for (const item of jsonData) {
            try {
                const { value: subjectName } = parseImportedCell(item['名称']);
                const { value: subjectUrl } = parseImportedCell(item['地址']);
                const subject = {
                    name: subjectName,
                    url: subjectUrl,
                };
                if (!subject.name || !subject.url) {
                    throw new Error('没有条目信息');
                }
                const info = {};
                for (const config of IMPORT_FIELD_CONFIGS) {
                    const cell = parseImportedCell(item[config.column]);
                    if (cell.isNullPlaceholder)
                        continue;
                    if (config.requireValue && !cell.value)
                        continue;
                    const mappedValue = config.transform ? config.transform(cell.value) : cell.value;
                    if (mappedValue !== undefined) {
                        info[config.key] = mappedValue;
                    }
                }
                parsedTasks.push({ subject, info, $infoDom: logContainer });
            }
            catch (error) {
                console.error('导入错误: ', error);
                invalidRecords.push(error);
            }
        }
        const snapshot = await getCurrentCollectionSnapshot();
        if (!snapshot) {
            console.warn('未能获取现有收藏快照,差分跳过不可用');
        }
        const pendingTasks = [];
        let diffSkipped = 0;
        for (const task of parsedTasks) {
            const subjectId = getSubjectId(task.subject.url);
            if (subjectId && shouldSkipCollectionUpdate(subjectId, task.info, snapshot)) {
                diffSkipped += 1;
                continue;
            }
            pendingTasks.push(task);
        }
        const initialSkipped = diffSkipped + invalidRecords.length;
        progressTracker = createImportProgressTracker({
            container: logContainer,
            fileTotal: jsonData.length,
            total: pendingTasks.length,
            skipped: initialSkipped,
        });
        if (invalidRecords.length) {
            progressTracker.addLog(`${invalidRecords.length} 条记录缺少有效条目信息,已跳过`, 'warn');
        }
        if (diffSkipped) {
            progressTracker.addLog(`${diffSkipped} 条记录与现有收藏一致,自动跳过`);
        }
        if (!snapshot) {
            progressTracker.addLog('未能获取现有收藏信息,无法进行差分比对', 'warn');
        }
        if (!pendingTasks.length) {
            if ($label) {
                $label.innerHTML = '无需更新';
            }
            progressTracker.setMessage('全部条目已是最新,无需导入');
            alert('所有条目均与现有收藏一致或数据无效,无需更新');
            return;
        }
        const hooks = {
            onAttemptStart: ({ attempt, remainingCount }) => {
                progressTracker.addLog(`开始第 ${attempt} 轮导入,剩余 ${remainingCount} 条`);
            },
            onTaskSuccess: ({ task }) => {
                progressTracker.markSuccess(task);
            },
            onTaskFail: ({ task, error }) => {
                progressTracker.markFailure(task, error);
            },
            onRetryScheduled: ({ nextAttempt, remainingCount }) => {
                progressTracker.markRetry(remainingCount, nextAttempt);
            },
        };
        const failedTasks = await processImportTasksWithRetry(pendingTasks, {
            concurrency: IMPORT_MAX_CONCURRENT_REQUESTS,
            maxAttempts: IMPORT_MAX_RETRY_TIMES,
            hooks,
        });
        const state = progressTracker.getState();
        if (failedTasks.length) {
            progressTracker.finish({
                failedCount: failedTasks.length,
                message: '导入完成,但仍有条目失败,请查看日志后重试。',
            });
            if ($label) {
                $label.innerHTML = '导入完成(有失败)';
            }
            alert(`导入完成,但 ${failedTasks.length} 条条目多次重试仍失败。成功 ${state.completed} 条,跳过 ${state.skipped} 条。`);
        }
        else {
            progressTracker.finish({
                failedCount: 0,
                message: '导入完成',
            });
            if ($label) {
                $label.innerHTML = '导入完成';
            }
            alert(`导入完成:成功 ${state.completed} 条,跳过 ${state.skipped} 条。`);
            location.reload();
        }
    }
    catch (error) {
        console.error('导入错误: ', error);
        if ($label) {
            $label.innerHTML = '导入失败';
        }
        if (progressTracker) {
            progressTracker.addLog(`导入过程中发生错误: ${(error && error.message) || error}`, 'error');
            progressTracker.setMessage('导入失败,请稍后重试');
        }
        alert(`导入失败: ${(error && error.message) || error}`);
    }
    finally {
        if ($parent) {
            $parent.style.pointerEvents = 'auto';
        }
        target.value = '';
    }
}
function genImportControl() {
    const btnStr = `<li title="支持和导出表头相同的 csv 和 xlsx 文件">
  <a href="javascript:void(0);"><span style="color:tomato;"><label for="e-userjs-import-csv-file">导入收藏</label></span></a>
  <input type="file" id="e-userjs-import-csv-file" style="display:none" />
</li>`;
    const $node = decorateButtonNode(htmlToElement(btnStr));
    const $file = $node.querySelector('#e-userjs-import-csv-file');
    // $file.addEventListener('change', handleInputChange);
    $file.addEventListener('change', handleFileAsync);
    return $node;
}
function addExportBtn(ext = 'xlsx') {
    var _a;
    const $nav = $q('#headerProfile .navSubTabs');
    if (!$nav)
        return;
    const type = ((_a = $nav.querySelector('.focus')) === null || _a === void 0 ? void 0 : _a.textContent) || '';
    const $username = $q('.nameSingle .inner>a');
    let name = '导出收藏';
    if ($username) {
        name = $username.textContent;
    }
    const dateStamp = formatDate(new Date());
    const filename = `${name}-${type}-${dateStamp}.${ext}`;
    $nav.appendChild(genAllExportBtn(`${name}-${dateStamp}.${ext}`));
    // 判断是否在单个分类页面
    const interestType = getInterestTypeByUrl(location.href);
    if (interestTypeArr.includes(interestType)) {
        $nav.appendChild(genExportBtn(filename));
    }
    $nav.appendChild(genImportControl());
}
initBrowserSelectionFeature();
// 索引
if (location.href.match(/index\/\d+/)) {
    const $header = $q('#header');
    if ($header) {
        const titleNode = $header.querySelector('h1');
        const baseName = (titleNode === null || titleNode === void 0 ? void 0 : titleNode.textContent.trim()) || '导出目录';
        let filename = `${baseName}.xlsx`;
        try {
            const currentUrl = new URL(location.href);
            const cat = currentUrl.searchParams.get('cat');
            if (cat) {
                filename = `${baseName}-${cat}.xlsx`;
            }
        }
        catch (error) {
            console.warn('生成目录文件名失败: ', error);
        }
        const $exportBtn = genIndexExportBtn(filename);
        if ($exportBtn) {
            $header.appendChild($exportBtn);
        }
        const $importBtn = genIndexImportControl();
        if ($importBtn) {
            $header.appendChild($importBtn);
        }
    }
}
if (location.href.match(/\w+\/list\//)) {
    addExportBtn();
}
function getIndexSubjectTypeLabelByIcon(item) {
    if (!item)
        return '';
    const icon = item.querySelector('.ico_subject_type[class*="subject_type_"]');
    if (!icon)
        return '';
    const match = icon.className.match(/subject_type_(\d+)/);
    if (!match)
        return '';
    const label = SUBJECT_TYPE_LABEL_MAP[match[1]];
    return label || '';
}
function getIndexSubjectTypeLabelByText(item) {
    if (!item)
        return '';
    const selectors = ['.info', '.info.tip', '.type', '.badge', '.subjectType'];
    const texts = [];
    for (const selector of selectors) {
        const nodeList = item.querySelectorAll(selector);
        for (const node of Array.from(nodeList)) {
            const text = normalizeIndexText(node.textContent);
            if (text)
                texts.push(text);
        }
    }
    if (!texts.length) {
        const fallbackText = normalizeIndexText(item.textContent || '');
        texts.push(fallbackText);
    }
    const combined = texts.join(' / ');
    for (const { keyword, label } of SUBJECT_TYPE_TEXT_KEYWORDS) {
        const regex = new RegExp(`(^|\\s|/|,|·)${keyword}($|\\s|/|,|·)`);
        if (regex.test(combined)) {
            return label;
        }
    }
    return '';
}
function getIndexSubjectTypeLabel(item) {
    const iconLabel = getIndexSubjectTypeLabelByIcon(item);
    if (iconLabel) {
        return iconLabel;
    }
    return getIndexSubjectTypeLabelByText(item);
}