bangumi 自动生成编辑摘要

自动生成Bangumi编辑摘要

// ==UserScript==
// @name         bangumi 自动生成编辑摘要
// @namespace    https://bgm.tv/group/topic/433505
// @version      0.5.1
// @description  自动生成Bangumi编辑摘要
// @author       You
// @match        https://bgm.tv/subject/*/edit_detail
// @match        https://bgm.tv/character/*/edit
// @match        https://bgm.tv/person/*/edit
// @match        https://bgm.tv/*/add_related/*
// @match        https://bangumi.tv/subject/*/edit_detail
// @match        https://bangumi.tv/character/*/edit
// @match        https://bangumi.tv/person/*/edit
// @match        https://bangumi.tv/*/add_related/*
// @match        https://chii.in/subject/*/edit_detail
// @match        https://chii.in/character/*/edit
// @match        https://chii.in/person/*/edit
// @match        https://chii.in/*/add_related/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const editSummaryInput = document.querySelector('#editSummary');
    if (!editSummaryInput) return;

    const SPLIT_RULE = /[()[\]{}()<>《》「」『』【】+×·→//、,,;;:&&\\等]/;
    const isObject = v => v !== null && typeof v === 'object';
    const short = s => s.length > 13 ? `${s.slice(0, 6)}...${s.slice(-4)}` : s;

    // #region 获取页面类型和初始值
    let initialTitle, initialPlatform, initialPros, initialSeries, initialWcode, initialSummary, initialNsfw, initialTags;
    let titleInput, getPlatform, getPros, seriesCheckbox, summaryTextarea, nsfwCheckbox, tagsInput;

    let initialRelatedItems = [];

    let pageFeatures = {
        isRelated: false,
        hasWcode: false,
        relatedContextType: null
    };
    let autoStorageKey, lockedStorageKey;

    const path = window.location.pathname;
    pageFeatures.isRelated = path.includes('/add_related/');
    pageFeatures.hasWcode = path.match(/\/edit(_detail)?$/)

    if (pageFeatures.isRelated) {
        if (path.match(/add_related\/subject\//)) {
            pageFeatures.relatedContextType = 1;
        } else if (path.match(/(character|person)\/\d+\/add_related\/anime/) || path.match(/subject\/\d+\/add_related\/(person|character)/)) {
            pageFeatures.relatedContextType = 2;
        }

        initialRelatedItems = Array.from(document.querySelectorAll('#crtRelateSubjects li.old')).map(li => {
            return getRelItemData(li);
        });
    } else if (pageFeatures.hasWcode) {
        titleInput = document.querySelector('[name="subject_title"], [name="crt_name"]');
        getPlatform = () => document.querySelector(`[for="${[...document.querySelectorAll('input[name=platform]')].find(i => i.checked)?.id}"]`)?.textContent;
        getPros = () => [...document.querySelectorAll('[name^=prsn_pro]')].filter(c => c.checked).map(c => document.querySelector(`[for=${c.id}]`).textContent);
        seriesCheckbox = document.querySelector('#subjectSeries');
        summaryTextarea = document.querySelector('#subject_summary, #crt_summary');
        nsfwCheckbox = document.querySelector('input[name="subject_nsfw"]');
        tagsInput = document.querySelector('input#tags');

        initialTitle = titleInput?.value;
        initialPlatform = getPlatform?.();
        initialPros = getPros?.();
        initialSeries = seriesCheckbox?.checked;
        initialWcode = getWcode();
        initialSummary = summaryTextarea?.value;
        initialNsfw = nsfwCheckbox?.checked;
        initialTags = tagsInput?.value;
    }

    autoStorageKey = pageFeatures.hasWcode ? 'autoGenEditSummary' : 'autoGenRelEditSummary';
    lockedStorageKey = pageFeatures.hasWcode ? 'lockedEditSummary' : 'lockedERelditSummary';

    const lockedSummary = localStorage.getItem(lockedStorageKey);
    if (lockedSummary) editSummaryInput.value = lockedSummary;
    // #endregion

    if (pageFeatures.hasWcode) {
        const wrapper = document.createElement('div');
        wrapper.style.width = '100%';
        wrapper.style.display = 'flex';
        wrapper.style.gap = '5px';
        wrapper.style.marginBlock = '5px';
        editSummaryInput.style.width = '100%';
        editSummaryInput.after(wrapper);
        wrapper.append(editSummaryInput.previousElementSibling, editSummaryInput, newGenBtn(), newLockBtn());
        wrapper.after(newAutoGenBtn());
    } else if (pageFeatures.isRelated) { // 避免批量修改关联关系造成崩坏
        editSummaryInput.after(newGenBtn(), newLockBtn(), document.createElement('br'), newAutoGenBtn());
    }

    const submitBtn = document.querySelector('input.inputBtn[name="submit"]');
    submitBtn.addEventListener('click', () => {
        localStorage.getItem(autoStorageKey) === 'true' && genEditSummary();
    }, { capture: false });

    // #region UI
    function newAutoGenBtn() {
        const autoGenBtn = document.createElement('input');
        autoGenBtn.type = 'checkbox';
        autoGenBtn.id = autoStorageKey;
        autoGenBtn.style.marginRight = '5px';
        const autoGenLabel = document.createElement('label');
        autoGenLabel.htmlFor = autoStorageKey;
        autoGenLabel.textContent = '提交时自动生成';

        autoGenBtn.onchange = function () {
            localStorage.setItem(autoStorageKey, this.checked);
        };
        autoGenBtn.checked = localStorage.getItem(autoStorageKey) === 'true';
        autoGenLabel.prepend(autoGenBtn);

        return autoGenLabel;
    }

    function newGenBtn() {
        const genBtn = document.createElement('button');
        genBtn.type = 'button';
        genBtn.style.wordBreak = 'keep-all';
        genBtn.textContent = '生成摘要';
        genBtn.onclick = genEditSummary;
        return genBtn;
    }

    function newLockBtn() {
        let isLocked = lockedSummary;
        const lockBtn = document.createElement('button');
        lockBtn.type = 'button';
        lockBtn.textContent = isLocked ? '🔒' : '🔓';
        lockBtn.title = isLocked ? '编辑摘要已锁定' : '编辑摘要未锁定';
        lockBtn.style.cursor = 'pointer';
        lockBtn.style.background = 'none';
        lockBtn.style.border = 'none';
        lockBtn.style.fontSize = '16px';
        lockBtn.style.padding = '0';

        lockBtn.onclick = function () {
            isLocked = !isLocked;
            this.textContent = isLocked ? '🔒' : '🔓';
            this.title = isLocked ? '编辑摘要已锁定' : '编辑摘要未锁定';
            localStorage.setItem(lockedStorageKey, isLocked ? editSummaryInput.value : '');
        };

        return lockBtn;
    }
    // #endregion

    // #region 生成摘要
    function genEditSummary() {
        const changes = [];

        if (pageFeatures.hasWcode) {
            const newWcode = getWcode();
            const newSummary = summaryTextarea?.value;
            const newTags = tagsInput?.value;

            if (initialTitle !== titleInput?.value) {
                changes.push(`修改标题(${initialTitle} → ${titleInput.value})`);
            }

            const newPlatform = getPlatform?.();
            if (initialPlatform !== newPlatform) {
                changes.push(`修改类型(${initialPlatform} → ${newPlatform})`);
            }

            const newPros = getPros?.();
            const proChanges = genArrChanges(initialPros, newPros, '职业');
            if (proChanges.length > 0) changes.push(...proChanges);

            if (initialSeries !== seriesCheckbox?.checked) {
                changes.push(seriesCheckbox?.checked ? '标记为系列' : '取消系列标记');
            }

            const wcodeChanges = genWcodeChanges(initialWcode, newWcode);
            if (wcodeChanges.length > 0) {
                changes.push(...wcodeChanges);
            }

            if (initialSummary !== newSummary) {
                changes.push(`${initialSummary ? '修改' : '添加'}简介`);
            }

            if (initialNsfw !== nsfwCheckbox?.checked) {
                changes.push(nsfwCheckbox?.checked ? '标记为受限内容' : '取消受限内容标记');
            }

            const tagsToArr = tags => tags ? tags.split(/\s+/).filter(t => t) : [];
            const tagChanges = genArrChanges(tagsToArr(initialTags), tagsToArr(newTags), '标签');
            if (tagChanges.length > 0) changes.push(...tagChanges);

            if (document.querySelector('input[type=file]')?.value) {
                changes.push('新肖像');
            }
        } else if (pageFeatures.isRelated) {
            const currentLis = Array.from(document.querySelectorAll('#crtRelateSubjects li:has(.title)'));
            const currentOldLis = currentLis.filter(li => li.classList.contains('old'));
            const currentNewLis = currentLis.filter(li => !li.classList.contains('old'));

            currentOldLis.forEach(li => {
                const initialItem = initialRelatedItems.find(item => item.element === li);
                if (initialItem) {
                    const currentItem = getRelItemData(li);
                    const modifyChanges = genRelModifyChanges(initialItem, currentItem);
                    if (modifyChanges.length > 0) {
                        changes.push(...modifyChanges);
                    }
                }
            });

            currentNewLis.forEach(li => {
                const item = getRelItemData(li);
                changes.push(genRelExsistChanges(item, '添加'));
            });

            initialRelatedItems.forEach(initialItem => {
                const stillExists = currentOldLis.some(li => li === initialItem.element);
                if (!stillExists) {
                    changes.push(genRelExsistChanges(initialItem, '删除'));
                }
            });
        }

        if (editSummaryInput && changes.length > 0) {
            if (!editSummaryInput.dataset.userModified || editSummaryInput.value === '') {
                editSummaryInput.value = [...new Set(changes)].join(';');
            }
        } else if (editSummaryInput) {
            editSummaryInput.value = '空编辑';
        }
    }

    // #region 相关条目变化
    function genRelExsistChanges(item, verb) {
        if (item.infoName) {
            return `${verb}《${item.name}》${item.type}${item.infoName}`;
        }
        return `${verb}${item.type}${item.name}`;
    }

    function genRelModifyChanges(initial, current) {
        const changes = [];

        if (initial.type !== current.type) {
            const name = current.infoName ? `《${current.name}》${current.infoName}` : current.name;
            changes.push(`${name} ${initial.type}→${current.type}`);
        }

        if (initial.remark !== current.remark) {
            if (pageFeatures.relatedContextType === 1) {
                changes.push('排序');
            } else {
                const remarkName = pageFeatures.relatedContextType === 2 ? '参与' : '备注'
                if (current.remark && !initial.remark) {
                    changes.push(`添加${current.type}${current.name}${remarkName}`);
                } else if (!current.remark && initial.remark) {
                    changes.push(`删除${current.type}${current.name}${remarkName}`);
                } else {
                    changes.push(`${current.type}${current.name}${remarkName} ${initial.remark} → ${current.remark}`);
                }
            }
        }

        if (initial.checkboxes.length && current.checkboxes.length) {
            current.checkboxes.forEach((currentCheckbox, index) => {
                const initialCheckbox = initial.checkboxes[index];
                if (initialCheckbox && initialCheckbox.checked !== currentCheckbox.checked) {
                    changes.push(`${currentCheckbox.checked ? '标记' : '取消标记'}${current.type}${current.name}${currentCheckbox.title}`);
                }
            });
        }

        return changes;
    }
    // #endregion

    // #region wcode变化
    function genWcodeChanges(oldWcode, newWcode) {
        const oldData = parseWcode(oldWcode);
        const newData = parseWcode(newWcode);

        const getMultiData = data => Object.fromEntries(Object.entries(data).filter(([, v]) => isObject(v)));
        const oldMultiData = getMultiData(oldData);
        const newMultiData = getMultiData(newData);

        const multiKeyChanges = [];
        for (const key in oldMultiData) {
            if (key in newMultiData) {
                const subChanges = genFieldChanges(oldMultiData[key], newMultiData[key]);
                multiKeyChanges.push(...subChanges.map(change => `${key}${change}`));
                delete oldData[key];
                delete newData[key];
            }
        }

        return genFieldChanges(oldData, newData).concat(multiKeyChanges);
    }

    function genFieldChanges(oldData, newData) {
        const changes = [];
        const moves = { from: new Set(), to: new Set() };
        const movedKeys = new Set();
        const oldValueDel = (k, v) => v ? (`删除${k}${isObject(v) ? '' : `(${short(v)})`}`) : '';

        for (const key in oldData) {
            const oldValue = oldData[key];
            const newKey = getNewKey(oldData, newData, key);
            if (newKey) {
                if (oldData[newKey]) changes.push(oldValueDel(newKey, oldValue));
                changes.push(`${key} → ${newKey}`);
                if (newData[key]) changes.push(`添加${key}`);
                moves.from.add(key);
                moves.to.add(newKey);
                movedKeys.add(key).add(newKey);
                continue;
            }
        }
        for (const key in oldData) {
            if (key in newData || moves.from.has(key)) continue;
            changes.push(oldValueDel(key, oldData[key]));
        }
        for (const key in newData) {
            if (key in oldData || moves.to.has(key)) continue;
            changes.push(`添加${key}`);
        }

        for (const key in oldData) {
            if (movedKeys.has(key)) continue;
            if (key in newData) {
                const oldValue = oldData[key];
                const newValue = newData[key];
                if (oldValue === newValue) continue;

                if (oldValue === null) {
                    changes.push(`添加${key}`); // 纯值字段
                } else {
                    const splitValue = value =>
                        value.split(SPLIT_RULE).flatMap(v => {
                            v = v.trim();
                            return /^\d+(:\d{2})*$/.test(v) ? v : v.split(':').map(u => u.trim()); // 时间
                        }).filter(v => v);
                    const oldSubValues = splitValue(oldValue);
                    const newSubValues = splitValue(newValue);
                    if (oldSubValues.length > 1 || newSubValues.length > 1) {
                        const subChanges = genArrChanges(oldSubValues, newSubValues, key);
                        if (subChanges.length) {
                            changes.push(...subChanges);
                            continue;
                        }
                    }
                    changes.push(`修改${key}(${oldValue} → ${newValue})`);
                }
            }
        }

        return changes;
    }

    function getNewKey(oldData, newData, key) {
        const oldValue = oldData[key];
        if (!oldValue) return null;
        for (const newKey in newData) {
            if (newKey !== key && newData[newKey] === oldValue && oldValue !== oldData[newKey]) {
                return newKey;
            }
        }
        return null;
    }

    function parseWcode(wcode) {
        const result = {};
        const lines = wcode.split('\n').map(l => l.trim().replace(/^[|\[]/, '').replace(/]$/, ''))
            .filter(l => !['', '{{', '}}'].includes(l));

        let currentKey = null;
        let inMultiValue = false;

        for (const line of lines) {
            if (line.endsWith('={')) { // 多值字段开始
                currentKey = line.replace('={', '').trim();
                inMultiValue = true;
                continue;
            }

            if (inMultiValue && line === '}') { // 多值字段结束
                if (currentKey) {
                    currentKey = null;
                    inMultiValue = false;
                }
                continue;
            }

            if (inMultiValue) { // 多值字段内容
                const [subKey, ...subValueParts] = line.split('|');
                const subValue = subValueParts.join('|').trim();
                if (subKey.trim()) {
                    if (!result[currentKey]) result[currentKey] = {};
                    if (subValue) {
                        result[currentKey][subKey.trim()] = subValue;
                    } else { // 纯值子段
                        result[currentKey][subKey.trim()] = null;
                    }
                    continue;
                }
            }

            if (line.includes('=')) { // 普通字段
                const [key, ...valueParts] = line.split('=');
                const value = valueParts.join('=').trim();
                if (key.trim() && value) result[key.trim()] = value;
            }
        }

        return result;
    }
    // #endregion

    function genArrChanges(oldArr, newArr, label) {
        const changes = [];

        const addedItems = [...newArr].filter(item => !oldArr.includes(item));
        const removedItems = [...oldArr].filter(item => !newArr.includes(item));

        if (addedItems.length) {
            changes.push(`添加${label}${addedItems.join('、')}`);
        }
        if (removedItems.length) {
            changes.push(`删除${label}${removedItems.join('、')}`);
        }
        if (!(addedItems.length + removedItems.length) &&
            oldArr.join('') !== newArr.join('')) {
            changes.push(`调整${label}顺序`)
        }

        return changes;
    }
    // #endregion

    function getRelItemData(li) {
        return {
            name: short(li.querySelector('.title a').textContent) || '',
            infoName: li.querySelector('.info a')?.textContent || '',
            type: li.querySelectorAll('option')[li.querySelector('select').selectedIndex].textContent.split(' / ')[0],
            remark: li.querySelector('input[type=text]')?.value.trim() || '',
            checkboxes: [...li.querySelectorAll('input[type=checkbox]')].map(checkbox => ({
                checked: checkbox.checked,
                title: checkbox.previousElementSibling.textContent.slice(0, -1).trim() || ''
            })),
            element: li
        };
    }
})();

function getWcode() {
    if (nowmode === 'wcode') {
        return document.getElementById('subject_infobox').value;
    } else if (nowmode === 'normal') {
        info = new Array();
        ids = new Object();
        props = new Object();
        input_num = $("#infobox_normal input.id").length;
        ids = $("#infobox_normal input.id");
        props = $("#infobox_normal input.prop");
        for (i = 0; i < input_num; i++) {
            id = $(ids).get(i);
            prop = $(props).get(i);
            if ($(id).hasClass('multiKey')) {
                multiKey = $(id).val();
                info[multiKey] = new Object();
                var subKey = 0;
                i++;
                id = $(ids).get(i);
                prop = $(props).get(i);
                while (($(id).hasClass('multiSubKey') || $(prop).hasClass('multiSubVal')) && i < input_num) {
                    if (isNaN($(id).val())) {
                        info[multiKey][subKey] = {
                            key: $(id).val(),
                            value: $(prop).val()
                        };
                    } else {
                        info[multiKey][subKey] = $(prop).val();
                    }
                    subKey++;
                    i++;
                    id = $(ids).get(i);
                    prop = $(props).get(i);
                }
                i--;
            } else if ($.trim($(id).val()) != "") {
                info[$(id).val()] = $(prop).val();
            }
        }
        return WCODEDump(info);
    }
}