跳转到内容

User:Chatsven/NoReferenceFinder.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
//一个可以检索疑似没有参考来源、来源不足、参考格式错误的条目,并人工添加维护模板的脚本。
//反馈:[[User talk:Chatsven/NoReferenceFinder.js]]
//------------------------------------------------
(function () {
    'use strict';

    const TOOL_NAME = '无来源条目检索';
    const DEFAULT_LIMIT = 50;

    let nrfCurrentItem = null;
    let nrfCurrentItemId = null;

    mw.loader.using(['mediawiki.api', 'mediawiki.util'], function () {
        addStyles();
        addPortletButton();
    });

    function addStyles() {
        if ($('#no-reference-finder-style').length) {
            return;
        }

        $('head').append(
            $('<style>')
                .attr('id', 'no-reference-finder-style')
                .text(`
                    #nrf-overlay,
                    .nrf-sidebar-list,
                    .nrf-preview-area {
                        scrollbar-width: none;
                        -ms-overflow-style: none;
                    }

                    #nrf-overlay::-webkit-scrollbar,
                    .nrf-sidebar-list::-webkit-scrollbar,
                    .nrf-preview-area::-webkit-scrollbar {
                        display: none;
                        width: 0;
                        height: 0;
                    }

                    @keyframes nrf-window-open {
                        0% {
                            opacity: 0;
                            transform: translate(-50%, -48%) scale(0.96);
                        }

                        100% {
                            opacity: 1;
                            transform: translate(-50%, -50%) scale(1);
                        }
                    }

                    @keyframes nrf-window-close {
                        0% {
                            opacity: 1;
                            transform: translate(-50%, -50%) scale(1);
                        }

                        100% {
                            opacity: 0;
                            transform: translate(-50%, -48%) scale(0.96);
                        }
                    }

                     @keyframes nrf-detail-overlay-fade-in {
                        0% {
                            opacity: 0;
                        }

                        100% {
                            opacity: 1;
                        }
                    }

                     @keyframes nrf-detail-overlay-fade-out {
                        0% {
                            opacity: 1;
                        }

                        100% {
                            opacity: 0;
                        }
                    }

                    .nrf-overlay {
                        position: fixed;
                        inset: 0;
                        z-index: 9998;
                        background: rgba(32, 33, 36, 0.28);
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 12px;
                        line-height: 1.35;
                    }

                    .nrf-window {
                        position: fixed;
                        left: 50%;
                        top: 50%;
                        transform: translate(-50%, -50%);
                        width: 900px;
                        height: 600px;
                        box-sizing: border-box;
                        padding: 8px;
                        border-radius: 26px;
                        background: #fff;
                        overflow: hidden;
                        z-index: 9999;
                        box-shadow: 0 8px 40px 0 rgba(0, 0, 0, 0.12);
                        animation: nrf-window-open 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
                        transform-origin: center center;
                    }

                    .nrf-overlay.nrf-closing .nrf-window {
                        animation: nrf-window-close 0.18s cubic-bezier(0.4, 0, 1, 1) forwards;
                    }

                    .nrf-sidebar {
                        position: absolute;
                        left: 8px;
                        top: 8px;
                        width: 240px;
                        height: 584px;
                        box-sizing: border-box;
                        border-radius: 18px;
                        border: 1px solid #F0F0F0;
                        background: #FFFFFF;
                        overflow: hidden;
                    }

                    .nrf-close-dot {
                        position: absolute;
                        left: 11px;
                        top: 11px;
                        width: 14px;
                        height: 14px;
                        box-sizing: border-box;
                        border-radius: 100px;
                        border: 0.5px solid rgba(0, 0, 0, 0.10);
                        background: #FF6F68;
                        cursor: pointer;
                        padding: 0;
                        box-shadow: 0 8px 40px 0 rgba(0, 0, 0, 0.12);
                        z-index: 20;
                    }

                    .nrf-close-dot:focus {
                        outline: none;
                    }

                    .nrf-sidebar-list {
                        position: absolute;
                        left: 0;
                        top: 74px;
                        right: 0;
                        bottom: 0;
                        overflow: auto;
                        padding: 0 10px 16px 10px;
                        box-sizing: border-box;
                    }

                    .nrf-sidebar-list.nrf-sidebar-changing .nrf-list-item-selected {
                        background: rgba(227, 227, 227, 0);
                    }

                    .nrf-group {
                        margin-bottom: 16px;
                    }

                    .nrf-group-title {
                        margin: 0 0 12px 10px;
                        color: rgba(0, 0, 0, 0.50);
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 10px;
                        font-style: normal;
                        font-weight: 700;
                        line-height: 13px;
                        mix-blend-mode: plus-darker;
                    }

                    .nrf-list-item {
                        display: flex;
                        height: 24px;
                        padding: 0 10px 0 6px;
                        align-items: center;
                        gap: 4px;
                        align-self: stretch;
                        box-sizing: border-box;
                        border-radius: 8px;
                        cursor: pointer;
                        margin-bottom: 4px;
                        color: rgba(0, 0, 0, 0.85);
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 13px;
                        font-style: normal;
                        font-weight: 700;
                        line-height: 15px;
                        mix-blend-mode: plus-darker;
                        overflow: hidden;
                    }

                    .nrf-list-item-selected {
                        background: rgba(227, 227, 227, 1);
                        color: #007AFF;
                        transition: background 0.2s ease;
                    }

                    .nrf-list-item-selected .nrf-list-item-text {
                        color: #007AFF;
                    }

                    .nrf-list-item-added {
                        color: rgba(0, 0, 0, 0.28) !important;
                    }

                    .nrf-list-item-added .nrf-list-item-text {
                        color: rgba(0, 0, 0, 0.28) !important;
                    }

                    .nrf-list-item-added.nrf-list-item-selected {
                        background: rgba(227, 227, 227, 0.55);
                    }

                    .nrf-list-item-text {
                        flex: 1 0 0;
                        min-width: 0;
                        overflow: hidden;
                        white-space: nowrap;
                        text-overflow: ellipsis;
                    }

                    .nrf-content {
                        position: absolute;
                        left: 248px;
                        top: 8px;
                        right: 8px;
                        bottom: 8px;
                        display: flex;
                        padding: 20px;
                        align-items: flex-start;
                        box-sizing: border-box;
                        background: transparent;
                    }

                    .nrf-current-title {
                        position: absolute;
                        left: 20px;
                        top: 10px;
                        right: 120px;
                        color: rgba(0, 0, 0, 0.85);
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 14px;
                        font-style: normal;
                        font-weight: 700;
                        line-height: 16px;
                        mix-blend-mode: plus-darker;
                        overflow: hidden;
                        text-overflow: ellipsis;
                        white-space: nowrap;
                        z-index: 10;
                    }

                    .nrf-top-actions {
                        position: absolute;
                        top: 0;
                        right: 0;
                        width: 84px;
                        height: 36px;
                        display: flex;
                        align-items: center;
                        justify-content: center;
                        box-sizing: border-box;
                        border-radius: 100px;
                        border: 1px solid #F0F0F0;
                        background: #FFFFFF;
                        overflow: hidden;
                        box-shadow: none;
                        z-index: 30;
                    }

                    .nrf-top-button {
                        position: relative;
                        z-index: 31;
                        flex: 1 1 0;
                        height: 100%;
                        border: 0;
                        background: transparent;
                        color: rgba(0, 0, 0, 0.85);
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 13px;
                        font-weight: 700;
                        line-height: 16px;
                        cursor: pointer;
                        padding: 0;
                    }

                    .nrf-top-button:focus {
                        outline: none;
                    }

                    .nrf-top-button:disabled {
                        opacity: 0.45;
                        cursor: not-allowed;
                    }

                    .nrf-preview-area {
                        position: absolute;
                        left: 20px;
                        right: 8px;
                        top: 52px;
                        bottom: 0;
                        overflow: hidden;
                        background: transparent;
                        scrollbar-width: none;
                        -ms-overflow-style: none;
                        z-index: 1;
                    }

                    .nrf-preview-area::before {
                        content: "";
                        position: absolute;
                        left: 0;
                        right: 0;
                        top: 0;
                        height: 40px;
                        pointer-events: none;
                        z-index: 3;
                        opacity: 0;
                        transition: opacity 0.18s ease;
                        background: linear-gradient(
                            to bottom,
                            rgba(255, 255, 255, 1) 0%,
                            rgba(255, 255, 255, 0) 100%
                        );
                    }

                    .nrf-preview-area.nrf-preview-scrolled::before {
                        opacity: 1;
                    }

                    .nrf-preview-area::-webkit-scrollbar {
                        display: none;
                        width: 0;
                        height: 0;
                    }

                    .nrf-preview-frame {
                        position: relative;
                        z-index: 1;
                        width: 100%;
                        height: 100%;
                        border: 0;
                        background: #fff;
                        display: block;
                        opacity: 1;
                        transition: opacity 0.2s ease;
                    }

                    .nrf-preview-frame.nrf-preview-hidden {
                        opacity: 0;
                    }

                    .nrf-empty-preview {
                        color: rgba(0, 0, 0, 0.45);
                        font-size: 12px;
                        font-weight: 600;
                    }

                    .nrf-window.nrf-detail-open {
                        overflow: hidden;
                    }

                    .nrf-detail-layer {
                        position: absolute;
                        inset: 0;
                        z-index: 100;
                        display: flex;
                        align-items: center;
                        justify-content: center;
                        box-sizing: border-box;
                        border-radius: 26px;
                        background: rgba(0, 0, 0, 0.18);
                        backdrop-filter: blur(6px);
                        -webkit-backdrop-filter: blur(6px);
                        opacity: 0;
                        animation: nrf-detail-overlay-fade-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
                    }

                    .nrf-detail-layer.nrf-detail-closing {
                        pointer-events: none;
                        animation: nrf-detail-overlay-fade-out 0.18s cubic-bezier(0.4, 0, 1, 1) forwards;
                    }

                    .nrf-detail-card {
                        position: absolute;
                        left: 50%;
                        top: 50%;
                        width: 390px;
                        min-height: 330px;
                        height: 330px;
                        transform: translate(-50%, -50%);
                        box-sizing: border-box;
                        border-radius: 26px;
                        background: #FFFFFF;
                        overflow: hidden;
                        animation: nrf-window-open 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
                        transform-origin: center center;
                    }

                    .nrf-detail-layer.nrf-detail-closing .nrf-detail-card {
                        animation: nrf-window-close 0.18s cubic-bezier(0.4, 0, 1, 1) forwards;
                    }

                    .nrf-detail-title {
                        position: absolute;
                        left: 120px;
                        top: 20px;
                        right: 31px;
                        color: rgba(0, 0, 0, 0.85);
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 13px;
                        font-style: normal;
                        font-weight: 700;
                        line-height: 16px;
                        overflow: hidden;
                        text-overflow: ellipsis;
                        white-space: nowrap;
                    }

                    .nrf-detail-subtitle {
                        position: absolute;
                        left: 120px;
                        top: 44px;
                        right: 31px;
                        color: rgba(0, 0, 0, 0.85);
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 11px;
                        font-style: normal;
                        font-weight: 400;
                        line-height: 14px;
                    }

                    .nrf-detail-row-label {
                        position: absolute;
                        left: 34px;
                        display: flex;
                        width: 80px;
                        height: 24px;
                        flex-direction: column;
                        justify-content: center;
                        box-sizing: border-box;
                        color: rgba(0, 0, 0, 0.50);
                        text-align: right;
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 13px;
                        font-style: normal;
                        font-weight: 400;
                        line-height: 14px;
                        letter-spacing: -0.26px;
                    }

                    .nrf-detail-row-value {
                        position: absolute;
                        left: 126px;
                        right: 31px;
                        display: -webkit-box;
                        height: 24px;
                        -webkit-box-orient: vertical;
                        -webkit-line-clamp: 1;
                        align-items: center;
                        overflow: hidden;
                        color: rgba(0, 0, 0, 0.85);
                        text-overflow: ellipsis;
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 13px;
                        font-style: normal;
                        font-weight: 510;
                        line-height: 16px;
                        mix-blend-mode: plus-darker;
                    }

                    .nrf-detail-row-value > span {
                        display: flex;
                        height: 24px;
                        align-items: center;
                        overflow: hidden;
                        text-overflow: ellipsis;
                        white-space: nowrap;
                    }

                    .nrf-detail-row-value.nrf-detail-summary {
                        display: block;
                        height: auto;
                        min-height: 24px;
                        line-height: 16px;
                        overflow: visible;
                        text-overflow: clip;
                        -webkit-line-clamp: unset;
                        -webkit-box-orient: initial;
                    }

                    .nrf-detail-row-value.nrf-detail-summary > span {
                        display: block;
                        height: auto;
                        max-height: none;
                        align-items: initial;
                        overflow: visible;
                        white-space: normal;
                        overflow-wrap: anywhere;
                        word-break: break-word;
                        text-overflow: clip;
                    }

                    .nrf-detail-row-1 {
                        top: 96px;
                    }

                    .nrf-detail-row-2 {
                        top: 134px;
                    }

                    .nrf-detail-row-3 {
                        top: 172px;
                    }

                    .nrf-detail-row-4 {
                        top: 210px;
                    }

                    .nrf-detail-row-5 {
                        top: 248px;
                    }

                    .nrf-detail-actions {
                        position: absolute;
                        right: 20px;
                        bottom: 20px;
                        display: flex;
                        gap: 8px;
                    }

                    .nrf-detail-button {
                        display: flex;
                        width: 74px;
                        height: 24px;
                        justify-content: center;
                        align-items: center;
                        box-sizing: border-box;
                        border: 0;
                        border-radius: 8px;
                        cursor: pointer;
                        text-align: center;
                        font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", sans-serif;
                        font-size: 13px;
                        font-style: normal;
                        font-weight: 510;
                        line-height: 16px;
                        mix-blend-mode: plus-darker;
                    }

                    .nrf-detail-button:focus {
                        outline: none;
                    }

                    .nrf-detail-close {
                        background: rgba(0, 0, 0, 0.06);
                        color: #1A1A1A;
                    }

                    .nrf-detail-add {
                        background: #007AFF;
                        color: #FFFFFF;
                    }

                    .nrf-detail-add:disabled {
                        opacity: 0.45;
                        cursor: not-allowed;
                    }
                `)
        );
    }

    function addPortletButton() {
        $('#ca-no-reference-finder').remove();

        mw.util.addPortletLink(
            'p-cactions',
            '#',
            '检索来源',
            'ca-no-reference-finder',
            '检索疑似没有参考来源、来源不足或参考格式错误的条目'
        );

        $('#ca-no-reference-finder').on('click', function (e) {
            e.preventDefault();
            startSearch();
        });
    }

    function startSearch() {
        const keyword = prompt(
            '请输入检索关键词。\n例如:人物、公司、学校、电影。\n留空则随机检索部分条目。',
            ''
        );

        mw.notify('正在检索 50 个候选条目……', {
            title: TOOL_NAME,
            type: 'info'
        });

        if (keyword && keyword.trim()) {
            searchByKeyword(keyword.trim(), DEFAULT_LIMIT);
        } else {
            getRandomPages(DEFAULT_LIMIT);
        }
    }

    function searchByKeyword(keyword, limit) {
        const api = new mw.Api();

        api.get({
            action: 'query',
            list: 'search',
            srnamespace: 0,
            srlimit: limit,
            srsearch: keyword,
            format: 'json'
        }).done(function (data) {
            const titles = data.query.search.map(function (item) {
                return item.title;
            });

            inspectPages(titles);
        }).fail(function (code, result) {
            console.error(code, result);
            mw.notify('搜索失败:' + code, {
                title: TOOL_NAME,
                type: 'error'
            });
        });
    }

    function getRandomPages(limit) {
        const api = new mw.Api();

        api.get({
            action: 'query',
            list: 'random',
            rnnamespace: 0,
            rnlimit: limit,
            format: 'json'
        }).done(function (data) {
            const titles = data.query.random.map(function (item) {
                return item.title;
            });

            inspectPages(titles);
        }).fail(function (code, result) {
            console.error(code, result);
            mw.notify('随机取样失败:' + code, {
                title: TOOL_NAME,
                type: 'error'
            });
        });
    }

    function inspectPages(titles) {
        if (!titles.length) {
            mw.notify('没有找到候选条目。', {
                title: TOOL_NAME,
                type: 'warn'
            });
            return;
        }

        const api = new mw.Api();

        api.get({
            action: 'query',
            prop: 'revisions|info',
            titles: titles.join('|'),
            rvprop: 'content',
            rvslots: 'main',
            formatversion: 2,
            format: 'json'
        }).done(function (data) {
            const candidates = [];

            data.query.pages.forEach(function (page) {
                if (page.missing || !page.revisions || !page.revisions[0]) {
                    return;
                }

                const text = page.revisions[0].slots.main.content;
                const referenceCount = countReferences(text);
                const bodyLength = countBodyTextLength(text);
                const hasReferenceHeading = hasReferencesHeading(text);
                const advice = getTemplateAdvice(page.title, text, referenceCount, hasReferenceHeading, bodyLength);

                if (advice.shouldTag) {
                    candidates.push({
                        title: page.title,
                        text: text,
                        referenceCount: referenceCount,
                        bodyLength: bodyLength,
                        hasReferenceHeading: hasReferenceHeading,
                        advice: advice,
                        demand: getDemandLevel(advice.type, referenceCount, hasReferenceHeading, bodyLength)
                    });
                }
            });

            showCandidates(candidates);
        }).fail(function (code, result) {
            console.error(code, result);
            mw.notify('读取页面内容失败:' + code, {
                title: TOOL_NAME,
                type: 'error'
            });
        });
    }

    function countReferences(text) {
        let count = 0;

        const refMatches = text.match(/<ref[\s>\/]/gi);
        if (refMatches) {
            count += refMatches.length;
        }

        const citeMatches = text.match(/\{\{\s*(cite web|cite news|cite book|cite journal|cite encyclopedia|citation)\b/gi);
        if (citeMatches) {
            count += citeMatches.length;
        }

        const zhCiteMatches = text.match(/\{\{\s*(引用网页|引用網頁|引用新聞|引用新闻|引用書籍|引用书籍|引用期刊|引用文献|引用來源|引用来源)\b/gi);
        if (zhCiteMatches) {
            count += zhCiteMatches.length;
        }

        return count;
    }

    function countBodyTextLength(text) {
        let body = text;

        body = body.split(/(^|\n)==+\s*(参考资料|參考資料|参考文献|參考文獻|参考来源|參考來源|參見資料|注释|註釋|备注|備註|外部链接|外部連結|参见|參見|相关条目|相關條目)\s*==+/i)[0];

        body = body.replace(/<ref\b[^>]*>[\s\S]*?<\/ref>/gi, '');
        body = body.replace(/<ref\b[^\/>]*\/\s*>/gi, '');
        body = body.replace(/\{\|[\s\S]*?\|\}/g, '');

        for (let i = 0; i < 8; i++) {
            body = body.replace(/\{\{[^{}]*\}\}/g, '');
        }

        body = body.replace(/\[\[(File|Image|文件|圖像|图像|Category|分類|分类|[a-z-]+):[^\]]+\]\]/gi, '');
        body = body.replace(/\[\[[^\]|]+\|([^\]]+)\]\]/g, '$1');
        body = body.replace(/\[\[([^\]]+)\]\]/g, '$1');
        body = body.replace(/\[https?:\/\/[^\s\]]+\s+([^\]]+)\]/gi, '$1');
        body = body.replace(/\[https?:\/\/[^\]]+\]/gi, '');
        body = body.replace(/<[^>]+>/g, '');
        body = body.replace(/={2,}/g, '');
        body = body.replace(/^[*#:;]+/gm, '');
        body = body.replace(/'{2,5}/g, '');
        body = body.replace(/__[^_]+__/g, '');

        const chars = body.match(/[\u4e00-\u9fffA-Za-z0-9]/g);

        return chars ? chars.length : 0;
    }

    function hasReferencesHeading(text) {
        return /(^|\n)==+\s*(参考资料|參考資料|参考文献|參考文獻|参考来源|參考來源|參見資料|注释|註釋|备注|備註)\s*==+/i.test(text);
    }

    function isDisambiguationPage(title, text) {
        if (/\((消歧义|消歧義|disambiguation)\)$/i.test(title)) {
            return true;
        }

        if (/(消歧义|消歧義|Disambiguation)$/i.test(title)) {
            return true;
        }

        if (/\{\{\s*(disambiguation|disambig|dab|geodis|hndis|numberdis|surname|given name|消歧义|消歧義|消歧义页|消歧義頁|消歧义页面|消歧義頁面)\b/i.test(text)) {
            return true;
        }

        if (/\[\[\s*(Category|分類|分类)\s*:\s*(消歧义页|消歧義頁|消歧义页面|消歧義頁面|Disambiguation pages)/i.test(text)) {
            return true;
        }

        if (/(本页|此页|本頁|此頁|本條目|本条目|本文)\s*(是|為|为)?\s*(消歧义|消歧義)/i.test(text)) {
            return true;
        }

        return false;
    }

    function hasExistingSourceMaintenanceTemplate(text) {
        return /(?:\{\{\s*(unreferenced|refimprove|no[\s_-]*footnotes|more[\s_-]*citations[\s_-]*needed|citations[\s_-]*missing|citation[\s_-]*needed|primary[\s_-]*sources|third[\s_-]*party|BLP[\s_-]*unsourced|sources[\s_-]*exist|來源不足|来源不足|無來源|无来源|缺少來源|缺少来源|沒有腳註|没有脚注|沒有註腳|没有注脚|未列明來源|未列明来源|需要更多來源|需要更多来源|一次來源|一次来源|第三方來源|第三方来源)\b)/i.test(text);
    }

    function getTemplateAdvice(title, text, referenceCount, hasReferenceHeading, bodyLength) {
        if (/^#redirect/i.test(text)) {
            return { shouldTag: false };
        }

        if (isDisambiguationPage(title, text)) {
            return { shouldTag: false };
        }

        if (hasExistingSourceMaintenanceTemplate(text)) {
            return { shouldTag: false };
        }

        if (referenceCount === 0 && hasReferenceHeading) {
            return {
                shouldTag: true,
                type: 'format',
                templateName: 'no footnotes',
                templateText: '{{no footnotes|time=' + getToday() + '}}\n',
                label: '建议使用 {{no footnotes}}',
                reason: '有参考资料章节标题,但没有 <ref> 脚注或常见引用模板。'
            };
        }

        if (referenceCount === 0) {
            return {
                shouldTag: true,
                type: 'unreferenced',
                templateName: 'unreferenced',
                templateText: '{{unreferenced|time=' + getToday() + '}}\n',
                label: '建议使用 {{unreferenced}}',
                reason: '没有 <ref> 标签、常见引用模板,也没有明显的参考资料章节。'
            };
        }

        const refImproveReason = getRefImproveReason(referenceCount, bodyLength);
        if (refImproveReason) {
            return {
                shouldTag: true,
                type: 'refimprove',
                templateName: 'refimprove',
                templateText: '{{refimprove|time=' + getToday() + '}}\n',
                label: '建议使用 {{refimprove}}',
                reason: refImproveReason
            };
        }

        return { shouldTag: false };
    }

    function getRefImproveReason(referenceCount, bodyLength) {
        if (bodyLength < 800) {
            return false;
        }

        if (bodyLength >= 800 && bodyLength < 2000 && referenceCount <= 2) {
            return '正文约 ' + bodyLength + ' 字符,已有 ' + referenceCount + ' 个参考来源,来源数量偏少。';
        }

        if (bodyLength >= 2000 && bodyLength < 5000 && referenceCount <= 4) {
            return '正文约 ' + bodyLength + ' 字符,已有 ' + referenceCount + ' 个参考来源,来源覆盖可能不足。';
        }

        if (bodyLength >= 5000 && referenceCount <= 6) {
            return '正文约 ' + bodyLength + ' 字符,已有 ' + referenceCount + ' 个参考来源,来源数量明显不足。';
        }

        return false;
    }

    function buildEditSummary(templateName, adviceType, referenceCount, bodyLength, hasReferenceHeading) {
        let summary = '添加{{' + templateName + '}}:';

        if (adviceType === 'unreferenced') {
            summary += '條目未列明參考來源;正文約' + bodyLength + '字符;';
            summary += hasReferenceHeading ? '已有參考資料章節但未見有效引用' : '未見參考資料章節';
        } else if (adviceType === 'format') {
            summary += '已有參考資料章節,但未見腳註或常見引用模板;正文約' + bodyLength + '字符';
        } else if (adviceType === 'refimprove') {
            summary += '條目篇幅約' + bodyLength + '字符,目前僅有' + referenceCount + '個參考來源,來源覆蓋可能不足';
        } else {
            summary += '條目來源狀況需要維護';
        }

        if (summary.length > 230) {
            summary = summary.slice(0, 227) + '...';
        }

        return summary;
    }

    function getDemandLevel(type, referenceCount, hasReferenceHeading, bodyLength) {
        if (type === 'unreferenced') {
            return { level: '高需求', group: '高需求', label: '高需求' };
        }

        if (type === 'format') {
            return { level: '格式錯誤', group: '格式錯誤', label: '格式錯誤' };
        }

        if (type === 'refimprove') {
            return { level: '中需求', group: '中需求', label: '中需求' };
        }

        return { level: '低需求', group: '低需求', label: '低需求' };
    }

    function showCandidates(candidates) {
        if (!candidates.length) {
            mw.notify('没有发现明显需要添加来源维护模板的条目。', {
                title: TOOL_NAME,
                type: 'success'
            });
            return;
        }

        $('#nrf-overlay').remove();

        nrfCurrentItem = null;
        nrfCurrentItemId = null;

        const $overlay = $('<div>')
            .attr('id', 'nrf-overlay')
            .addClass('nrf-overlay');

        const $window = $('<div>').addClass('nrf-window');
        const $sidebar = $('<div>').addClass('nrf-sidebar');

        const $close = $('<button>')
            .attr('type', 'button')
            .attr('aria-label', '关闭')
            .addClass('nrf-close-dot')
            .on('click', function (e) {
                e.preventDefault();
                closeNrfWindow();
            });

        const $sidebarList = $('<div>').addClass('nrf-sidebar-list');
        const $content = $('<div>').addClass('nrf-content');

        const $title = $('<div>')
            .attr('id', 'nrf-current-title')
            .addClass('nrf-current-title')
            .text('條目名稱');

        const $actions = $('<div>').addClass('nrf-top-actions');

        const $addButton = $('<button>')
            .attr('type', 'button')
            .attr('id', 'nrf-current-add')
            .addClass('nrf-top-button')
            .text('添加')
            .on('click', function (e) {
                e.preventDefault();
                addCurrentItem();
            });

        const $detailButton = $('<button>')
            .attr('type', 'button')
            .attr('id', 'nrf-current-detail')
            .addClass('nrf-top-button')
            .text('詳情')
            .on('click', function (e) {
                e.preventDefault();
                showCurrentDetail();
            });

        const $previewArea = $('<div>')
            .attr('id', 'nrf-preview-area')
            .addClass('nrf-preview-area')
            .append(
                $('<div>')
                    .addClass('nrf-empty-preview')
                    .text('請從左側選擇條目。')
            );

        $actions.append($addButton, $detailButton);
        $content.append($title, $actions, $previewArea);

        const grouped = {
            '高需求': [],
            '中需求': [],
            '格式錯誤': []
        };

        candidates.forEach(function (item, index) {
            item._nrfId = 'nrf-item-' + index;
            item._editSummary = buildEditSummary(
                item.advice.templateName,
                item.advice.type,
                item.referenceCount,
                item.bodyLength,
                item.hasReferenceHeading
            );

            if (!grouped[item.demand.group]) {
                grouped[item.demand.group] = [];
            }

            grouped[item.demand.group].push(item);
        });

        ['高需求', '中需求', '格式錯誤'].forEach(function (groupName) {
            const groupItems = grouped[groupName];

            if (!groupItems.length) {
                return;
            }

            const $group = $('<div>').addClass('nrf-group');
            const $groupTitle = $('<div>')
                .addClass('nrf-group-title')
                .text(groupName);

            $group.append($groupTitle);

            groupItems.forEach(function (item) {
                const $item = $('<div>')
                    .attr('id', item._nrfId)
                    .addClass('nrf-list-item')
                    .on('click', function () {
                        selectItem(item);
                    });

                const $text = $('<div>')
                    .addClass('nrf-list-item-text')
                    .text(item.title);

                $item.append($text);
                $group.append($item);
            });

            $sidebarList.append($group);
        });

        $sidebar.append($close, $sidebarList);
        $window.append($sidebar, $content);
        $overlay.append($window);
        $('body').append($overlay);

        if (candidates[0]) {
            selectItem(candidates[0]);
        }

        mw.notify('发现 ' + candidates.length + ' 个候选条目。', {
            title: TOOL_NAME,
            type: 'success'
        });
    }

    function closeNrfWindow() {
        closeCurrentDetail(true);

        const $overlay = $('#nrf-overlay');

        if (!$overlay.length) {
            return;
        }

        $overlay.addClass('nrf-closing');

        window.setTimeout(function () {
            $overlay.remove();
        }, 190);
    }

    function selectItem(item) {
        const $sidebarList = $('.nrf-sidebar-list');

        $sidebarList.addClass('nrf-sidebar-changing');

        window.setTimeout(function () {
            nrfCurrentItem = item;
            nrfCurrentItemId = item._nrfId;

            $('.nrf-list-item').removeClass('nrf-list-item-selected');
            $('#' + item._nrfId).addClass('nrf-list-item-selected');

            $('#nrf-current-title').text(item.title);

            const $addButton = $('#nrf-current-add');

            if ($('#' + item._nrfId).hasClass('nrf-list-item-added')) {
                $addButton
                    .prop('disabled', true)
                    .text('已加');
            } else {
                $addButton
                    .prop('disabled', false)
                    .text('添加');
            }

            showPreviewContent(item, false);

            window.setTimeout(function () {
                $sidebarList.removeClass('nrf-sidebar-changing');
            }, 40);
        }, 200);
    }

    function showPreviewContent(item, forceReload) {
        const $preview = $('#nrf-preview-area');

        if (!$preview.length) {
            return;
        }

        $preview.removeClass('nrf-preview-scrolled');

        const oldFrame = $preview.find('iframe.nrf-preview-frame');
        oldFrame.addClass('nrf-preview-hidden');

        const params = {};

        if (forceReload) {
            params.nrfreload = Date.now();
        }

        const previewUrl = mw.util.getUrl(item.title, params);

        window.setTimeout(function () {
            const $iframe = $('<iframe>')
                .addClass('nrf-preview-frame nrf-preview-hidden')
                .attr('title', '条目预览:' + item.title)
                .attr('src', previewUrl)
                .on('load', function () {
                    const iframe = this;

                    preparePreviewFrame(iframe);
                    bindPreviewScrollState(iframe);

                    window.setTimeout(function () {
                        $(iframe).removeClass('nrf-preview-hidden');
                    }, 30);
                });

            $preview.empty().append($iframe);
        }, 200);
    }

    function preparePreviewFrame(iframe) {
        try {
            const doc = iframe.contentDocument || iframe.contentWindow.document;

            if (!doc || !doc.head) {
                return;
            }

            if (doc.getElementById('nrf-preview-frame-style')) {
                return;
            }

            const style = doc.createElement('style');
            style.id = 'nrf-preview-frame-style';
            style.textContent = `
                html,
                body {
                    scrollbar-width: none !important;
                    -ms-overflow-style: none !important;
                }

                html::-webkit-scrollbar,
                body::-webkit-scrollbar,
                *::-webkit-scrollbar {
                    display: none !important;
                    width: 0 !important;
                    height: 0 !important;
                }

                header.vector-header.mw-header.no-font-mode-scale,
                .vector-page-toolbar-container,
                #vector-page-titlebar-toc,
                span.mw-editsection,
                #scrollDownButton-zhwiki,
                #scrollUpButton-zhwiki,
                #footer-places {
                    display: none !important;
                }
            `;

            doc.head.appendChild(style);
        } catch (e) {
            console.warn('無法處理 iframe 內部樣式,可能受到同源策略限制。', e);
        }
    }

    function bindPreviewScrollState(iframe) {
        const $preview = $('#nrf-preview-area');

        if (!$preview.length) {
            return;
        }

        try {
            const win = iframe.contentWindow;
            const doc = iframe.contentDocument || win.document;

            if (!win || !doc) {
                $preview.removeClass('nrf-preview-scrolled');
                return;
            }

            function updateGradient() {
                const scrollTop =
                    win.scrollY ||
                    doc.documentElement.scrollTop ||
                    doc.body.scrollTop ||
                    0;

                if (scrollTop > 6) {
                    $preview.addClass('nrf-preview-scrolled');
                } else {
                    $preview.removeClass('nrf-preview-scrolled');
                }
            }

            win.addEventListener('scroll', updateGradient, { passive: true });
            updateGradient();
        } catch (e) {
            $preview.removeClass('nrf-preview-scrolled');
        }
    }

    function showCurrentDetail() {
        if (!nrfCurrentItem) {
            mw.notify('尚未选择条目。', {
                title: TOOL_NAME,
                type: 'warn'
            });
            return;
        }

        closeCurrentDetail();

        const item = nrfCurrentItem;
        const $window = $('.nrf-window');

        if (!$window.length) {
            return;
        }

        const $layer = $('<div>')
            .attr('id', 'nrf-detail-layer')
            .addClass('nrf-detail-layer');

        const $card = $('<div>')
            .addClass('nrf-detail-card')
            .on('click', function (e) {
                e.stopPropagation();
            });

        const $title = $('<div>')
            .addClass('nrf-detail-title')
            .text(item.title);

        const $subtitle = $('<div>')
            .addClass('nrf-detail-subtitle')
            .text(item.advice.reason);

        const rows = [
            {
                label: '正文長度:',
                value: item.bodyLength + ' 個字符'
            },
            {
                label: '參考來源:',
                value: item.referenceCount + ' 個'
            },
            {
                label: '參考標題:',
                value: item.hasReferenceHeading ? '是' : '否'
            },
            {
                label: '使用模板:',
                value: '{{' + item.advice.templateName + '}}'
            },
            {
                label: '修改日志:',
                value: item._editSummary
            }
        ];

        let $summaryValue = null;

        rows.forEach(function (row, index) {
            const rowClass = 'nrf-detail-row-' + (index + 1);
            const isSummaryRow = row.label === '修改日志:';

            const $label = $('<div>')
                .addClass('nrf-detail-row-label')
                .addClass(rowClass)
                .text(row.label);

            const $value = $('<div>')
                .addClass('nrf-detail-row-value')
                .addClass(rowClass)
                .toggleClass('nrf-detail-summary', isSummaryRow)
                .append(
                    $('<span>').text(row.value)
                );

            if (isSummaryRow) {
                $summaryValue = $value;
            }

            $card.append($label, $value);
        });

        const $actions = $('<div>').addClass('nrf-detail-actions');

        const $closeButton = $('<button>')
            .attr('type', 'button')
            .addClass('nrf-detail-button nrf-detail-close')
            .text('關閉')
            .on('click', function (e) {
                e.preventDefault();
                closeCurrentDetail();
            });

        const $addButton = $('<button>')
            .attr('type', 'button')
            .addClass('nrf-detail-button nrf-detail-add')
            .text($('#' + item._nrfId).hasClass('nrf-list-item-added') ? '已加' : '添加')
            .prop('disabled', $('#' + item._nrfId).hasClass('nrf-list-item-added'))
            .on('click', function (e) {
                e.preventDefault();
                closeCurrentDetail();
                addCurrentItem();
            });

        $actions.append($closeButton, $addButton);
        $card.append($title, $subtitle, $actions);
        $layer.append($card);
        $window.addClass('nrf-detail-open').append($layer);

        window.setTimeout(function () {
            if (!$summaryValue || !$summaryValue.length) {
                return;
            }

            const summaryTop = 248;
            const summaryTextHeight = Math.max(24, $summaryValue.find('span')[0].scrollHeight);
            const cardHeight = Math.max(330, summaryTop + summaryTextHeight + 64);

            $summaryValue.css('height', summaryTextHeight + 'px');
            $card.css('height', cardHeight + 'px');
        }, 0);
    }

    function closeCurrentDetail(immediate) {
        const $layer = $('#nrf-detail-layer');

        if (!$layer.length) {
            $('.nrf-window').removeClass('nrf-detail-open');
            return;
        }

        if (immediate) {
            $layer.remove();
            $('.nrf-window').removeClass('nrf-detail-open');
            return;
        }

        if ($layer.hasClass('nrf-detail-closing')) {
            return;
        }

        $layer.addClass('nrf-detail-closing');

        window.setTimeout(function () {
            $layer.remove();
            $('.nrf-window').removeClass('nrf-detail-open');
        }, 190);
    }

    function addCurrentItem() {
        if (!nrfCurrentItem) {
            mw.notify('尚未选择条目。', {
                title: TOOL_NAME,
                type: 'warn'
            });
            return;
        }

        const item = nrfCurrentItem;
        const $button = $('#nrf-current-add');

        $button
            .prop('disabled', true)
            .text('添加中');

        addTemplateToPage(
            item.title,
            item.advice.templateName,
            item.advice.templateText,
            item.advice.type,
            item._nrfId,
            $button
        );
    }

    function addTemplateToPage(title, templateName, templateText, adviceType, itemId, $button) {
        const api = new mw.Api();

        api.get({
            action: 'query',
            prop: 'revisions',
            titles: title,
            rvprop: 'content',
            rvslots: 'main',
            formatversion: 2,
            format: 'json'
        }).done(function (data) {
            const page = data.query.pages[0];

            if (!page.revisions || !page.revisions[0]) {
                mw.notify('无法读取页面内容:' + title, {
                    title: TOOL_NAME,
                    type: 'error'
                });

                markItemAsError(itemId, $button, '无法读取页面内容。');
                return;
            }

            const oldText = page.revisions[0].slots.main.content;
            const referenceCount = countReferences(oldText);
            const bodyLength = countBodyTextLength(oldText);
            const hasReferenceHeadingNow = hasReferencesHeading(oldText);
            const advice = getTemplateAdvice(title, oldText, referenceCount, hasReferenceHeadingNow, bodyLength);

            if (!advice.shouldTag) {
                mw.notify('页面状态已变化,未添加模板:' + title, {
                    title: TOOL_NAME,
                    type: 'warn'
                });

                markItemAsSkipped(itemId, $button, '页面状态已变化,未添加模板。');
                return;
            }

            const newText = templateText + oldText;
            const dynamicSummary = buildEditSummary(
                advice.templateName,
                advice.type,
                referenceCount,
                bodyLength,
                hasReferenceHeadingNow
            );

            api.postWithToken('csrf', {
                action: 'edit',
                title: title,
                text: newText,
                summary: dynamicSummary,
                minor: true,
                watchlist: 'nochange',
                format: 'json'
            }).done(function () {
                markItemAsTagged(itemId, $button, templateName, dynamicSummary);

                if (nrfCurrentItem && nrfCurrentItem.title === title) {
                    nrfCurrentItem.text = newText;
                    nrfCurrentItem.referenceCount = countReferences(newText);
                    nrfCurrentItem.bodyLength = countBodyTextLength(newText);
                    nrfCurrentItem.hasReferenceHeading = hasReferencesHeading(newText);
                    showPreviewContent(nrfCurrentItem, true);
                }

                mw.notify('已添加 {{' + templateName + '}}:' + title, {
                    title: TOOL_NAME,
                    type: 'success'
                });
            }).fail(function (code, result) {
                console.error(code, result);

                mw.notify('编辑失败:' + code, {
                    title: TOOL_NAME,
                    type: 'error'
                });

                markItemAsError(itemId, $button, '编辑失败:' + code);
            });
        }).fail(function (code, result) {
            console.error(code, result);

            mw.notify('读取页面内容失败:' + code, {
                title: TOOL_NAME,
                type: 'error'
            });

            markItemAsError(itemId, $button, '读取页面内容失败:' + code);
        });
    }

    function markItemAsTagged(itemId, $button, templateName, dynamicSummary) {
        const $item = $('#' + itemId);

        $item
            .addClass('nrf-list-item-added')
            .css({
                background: ''
            });

        if ($button && $button.length) {
            $button
                .prop('disabled', true)
                .text('已加');
        }
    }

    function markItemAsSkipped(itemId, $button, message) {
        const $item = $('#' + itemId);

        $item.css({
            background: 'rgba(172, 102, 0, 0.16)'
        });

        if ($button && $button.length) {
            $button
                .prop('disabled', true)
                .text('跳過');
        }
    }

    function markItemAsError(itemId, $button, message) {
        const $item = $('#' + itemId);

        $item.css({
            background: 'rgba(221, 51, 51, 0.16)'
        });

        if ($button && $button.length) {
            $button
                .prop('disabled', false)
                .text('重試');
        }
    }

    function getToday() {
        const d = new Date();
        const y = d.getFullYear();
        const m = String(d.getMonth() + 1).padStart(2, '0');
        const day = String(d.getDate()).padStart(2, '0');

        return y + '-' + m + '-' + day;
    }
})();