IDM Download Assistant

Scan paginated lists and up to three webpage levels, repair filenames, prepare IDM browser-extension links, and export results.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         IDM Download Assistant
// @name:zh-CN   IDM下载助手
// @name:zh-TW   IDM下載助手
// @name:en      IDM Download Assistant
// @name:ja      IDM ダウンロードアシスタント
// @name:de      IDM-Download-Assistent
// @name:ru      Помощник загрузки IDM
// @namespace    https://tampermonkey.net/
// @version      1.01
// @description  Scan paginated lists and up to three webpage levels, repair filenames, prepare IDM browser-extension links, and export results.
// @description:zh-CN 列表分页与一至三级网页嗅探、中文文件名修复、IDM浏览器插件批量选择及多格式导出。
// @description:zh-TW 列表分頁與一至三級網頁嗅探、中文檔名修復、IDM瀏覽器外掛批次選取及多格式匯出。
// @description:en Scan paginated lists and up to three webpage levels, repair filenames, prepare IDM browser-extension links, and export results.
// @description:ja ページ分割された一覧と最大3階層のWebページを探索し、ファイル名の修復、IDM拡張機能用リンクの準備、各形式への書き出しを行います。
// @description:de Durchsucht paginierte Listen und bis zu drei Webseiten-Ebenen, korrigiert Dateinamen, bereitet Links für die IDM-Browsererweiterung vor und exportiert Ergebnisse.
// @description:ru Сканирует списки с пагинацией и до трёх уровней страниц, исправляет имена файлов, подготавливает ссылки для расширения IDM и экспортирует результаты.
// @author       Alen
// @license      MIT
// @match        http://*/*
// @match        https://*/*
// @run-at       document-idle
// @noframes
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_openInTab
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    /**********************************************************************
     * 0. 配置
     **********************************************************************/
    const CONFIG = {

        fileExts: [
            'pdf',
            'doc',
            'docx',
            'xls',
            'xlsx',
            'ppt',
            'pptx',
            'dwg',
            'dxf',
            'shp',
            'tif',
            'tiff',
            'zip',
            'rar',
            '7z'
        ],

        blockedExts: [
            'jpg',
            'jpeg',
            'png',
            'gif',
            'webp',
            'svg',
            'bmp',
            'ico',
            'css',
            'js',
            'mjs',
            'map',
            'woff',
            'woff2',
            'ttf',
            'eot',
            'mp3',
            'wav',
            'mp4',
            'avi',
            'mov',
            'm3u8'
        ],

        invalidTexts: [
            '下载',
            '点击下载',
            '立即下载',
            '查看附件',
            '附件',
            '附件下载',
            '文件下载',
            '下载文件',
            '查看',
            '点击查看',
            '打开附件',
            'download',
            '点击',
            '打开',
            '详情',
            '查看详情'
        ],

        navigationTexts: [
            '首页',
            '上一页',
            '下一页',
            '上页',
            '下页',
            '末页',
            '尾页',
            '返回',
            '返回首页',
            'prev',
            'previous',
            'next',
            'home',
            'last',
            'more',
            '更多'
        ],

        genericFileNames: [
            '初步设计报告',
            '初设报告',
            '可行性研究报告',
            '可研报告',
            '施工图',
            '设计图纸',
            '水资源论证报告',
            '洪水影响评价报告',
            '防洪评价报告',
            '水土保持方案',
            '批复文件',
            '批复',
            '报告',
            '公示材料',
            '招标文件',
            '图纸',
            '附件'
        ],

        // 单个页面最多加入多少个候选子页面。
        maxSniffLinksPerPage: 160,

        sniffConcurrency: 4,

        maxHeadRequestsPerScan: 50,

        headConcurrency: 4,

        mutationDebounceMs: 1000,

        supportUrl: 'https://ko-fi.com/alen685279',

        helpUrls: {
            'zh-CN': 'https://github.com/Alendarker/AlenDark_scripts/blob/main/idm-download-assistant/README.md',
            'zh-TW': 'https://github.com/Alendarker/AlenDark_scripts/blob/main/idm-download-assistant/README.zh-TW.md',
            en: 'https://github.com/Alendarker/AlenDark_scripts/blob/main/idm-download-assistant/README.en.md',
            ja: 'https://github.com/Alendarker/AlenDark_scripts/blob/main/idm-download-assistant/README.ja.md',
            de: 'https://github.com/Alendarker/AlenDark_scripts/blob/main/idm-download-assistant/README.de.md',
            ru: 'https://github.com/Alendarker/AlenDark_scripts/blob/main/idm-download-assistant/README.ru.md'
        }
    };


    const EXT_GROUP =
        CONFIG.fileExts.join('|');


    const FILE_EXT_RE =
        new RegExp(
            `\\.(${EXT_GROUP})(?:$|[?#&])`,
            'i'
        );


    const FILE_EXT_END_RE =
        new RegExp(
            `\\.(${EXT_GROUP})$`,
            'i'
        );


    const BLOCKED_EXT_RE =
        new RegExp(
            `\\.(${CONFIG.blockedExts.join('|')})(?:$|[?#&])`,
            'i'
        );


    const DOWNLOAD_URL_RE =
        /(download|attachment|attach|file|upload|resource|document|docfile|downfile|downloadfile|filedownload|files\/)/i;


    const ATTACHMENT_WORD_RE =
        /(附件|下载|文件|报告|批复|图纸|设计|论证|评价|公示材料|压缩包|电子版|文档|材料)/i;


    const CHINESE_RE =
        /[\u3400-\u9fff]/;


    const MIME_TO_EXT = {

        'application/pdf':
            'pdf',

        'application/msword':
            'doc',

        'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
            'docx',

        'application/vnd.ms-excel':
            'xls',

        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
            'xlsx',

        'application/vnd.ms-powerpoint':
            'ppt',

        'application/vnd.openxmlformats-officedocument.presentationml.presentation':
            'pptx',

        'application/zip':
            'zip',

        'application/x-zip-compressed':
            'zip',

        'application/x-rar-compressed':
            'rar',

        'application/vnd.rar':
            'rar',

        'application/x-7z-compressed':
            '7z',

        'image/tiff':
            'tif'
    };


    const STORAGE_KEYS = {

        panelCollapsed:
            'idm-download-assistant.panelCollapsed',

        panelPosition:
            'idm-download-assistant.panelPosition',

        themeMode:
            'idm-download-assistant.themeMode',

        listPages:
            'idm-download-assistant.listPages',

        sniffDepth:
            'idm-download-assistant.sniffDepth',

        sniffChildPages:
            'idm-download-assistant.sniffChildPages'
    };


    function readStoredValue(
        key,
        fallback
    ) {

        try {

            if (
                typeof GM_getValue ===
                'function'
            ) {

                return GM_getValue(
                    key,
                    fallback
                );
            }

        } catch (_) {}


        try {

            const raw =
                globalThis.localStorage
                    ?.getItem(
                        key
                    );


            return raw == null
                ? fallback
                : JSON.parse(
                    raw
                );

        } catch (_) {

            return fallback;
        }
    }


    function writeStoredValue(
        key,
        value
    ) {

        try {

            if (
                typeof GM_setValue ===
                'function'
            ) {

                GM_setValue(
                    key,
                    value
                );

                return;
            }

        } catch (_) {}


        try {

            globalThis.localStorage
                ?.setItem(
                    key,
                    JSON.stringify(
                        value
                    )
                );

        } catch (_) {}
    }


    function clampInteger(
        value,
        fallback,
        min
    ) {

        const number =
            Number.parseInt(
                value,
                10
            );


        if (
            !Number.isFinite(
                number
            ) ||
            number < min
        ) {

            return fallback;
        }


        return number;
    }


    function normalizeThemeMode(value) {

        return [
            'auto',
            'dark',
            'light'
        ].includes(
            value
        )
            ? value
            : 'auto';
    }


    function normalizeSniffDepth(value) {

        return [
            '1',
            '2',
            '3'
        ].includes(
            String(
                value
            )
        )
            ? String(
                value
            )
            : '1';
    }


    function normalizePanelPosition(value) {

        if (
            !value ||
            typeof value !==
            'object'
        ) {

            return null;
        }


        if (
            ![
                'left',
                'right'
            ].includes(
                value.horizontalAnchor
            )
        ) {

            return null;
        }


        const left =
            Number(
                value.left
            );


        const right =
            Number(
                value.right
            );


        const top =
            Number(
                value.top
            );


        if (
            !Number.isFinite(
                left
            ) ||
            !Number.isFinite(
                right
            ) ||
            !Number.isFinite(
                top
            )
        ) {

            return null;
        }


        return {
            left:
                Math.max(
                    0,
                    left
                ),
            right:
                Math.max(
                    0,
                    right
                ),
            top:
                Math.max(
                    0,
                    top
                ),
            horizontalAnchor:
                value.horizontalAnchor
        };
    }


    const state = {

        items: [],

        byUrl: new Map(),

        scanning: false,

        observerTimer: null,

        viewportResizeTimer: null,

        panelCollapsed:
            Boolean(
                readStoredValue(
                    STORAGE_KEYS.panelCollapsed,
                    false
                )
            ),

        panelPosition:
            normalizePanelPosition(
                readStoredValue(
                    STORAGE_KEYS.panelPosition,
                    null
                )
            ),

        themeMode:
            normalizeThemeMode(
                readStoredValue(
                    STORAGE_KEYS.themeMode,
                    'auto'
                )
            ),

        uiSettings: {

            listPages:
                clampInteger(
                    readStoredValue(
                        STORAGE_KEYS.listPages,
                        1
                    ),
                    1,
                    1
                ),

            sniffDepth:
                normalizeSniffDepth(
                    readStoredValue(
                        STORAGE_KEYS.sniffDepth,
                        '1'
                    )
                ),

            sniffChildPages:
                clampInteger(
                    readStoredValue(
                        STORAGE_KEYS.sniffChildPages,
                        20
                    ),
                    20,
                    1
                )
        },

        filterText: '',

        filenameAffixes: {

            prefixEnabled: false,

            prefixText: '',

            suffixEnabled: false,

            suffixText: ''
        }
    };


    const UI_LANGUAGE =
        (() => {

            const preferred =
                String(
                    globalThis.navigator
                        ?.languages?.[0] ||
                    globalThis.navigator
                        ?.language ||
                    'en'
                )
                    .toLowerCase();


            if (
                /^zh-(?:tw|hk|mo|hant)/.test(
                    preferred
                )
            ) {

                return 'zh-TW';
            }


            if (
                preferred.startsWith(
                    'zh'
                )
            ) {

                return 'zh-CN';
            }


            if (
                preferred.startsWith(
                    'ja'
                )
            ) {

                return 'ja';
            }


            if (
                preferred.startsWith(
                    'de'
                )
            ) {

                return 'de';
            }


            if (
                preferred.startsWith(
                    'ru'
                )
            ) {

                return 'ru';
            }


            return 'en';
        })();


    const I18N = {

        'zh-CN': {
            appTitle: 'IDM下载助手',
            collapseExpand: '收起/展开',
            hideWindow: '隐藏窗口',
            theme: '主题',
            themeTitle: '选择下载助手界面主题',
            themeAuto: '自动',
            themeDark: '深色',
            themeLight: '浅色',
            fetch: '抓取',
            listPages: '个列表页',
            sniffDepth: '嗅探级别',
            level1: '一级',
            level2: '二级',
            level3: '三级',
            perLevelMax: '每级最多',
            childPages: '个子页面',
            perLevelTooltip: '每个列表页最多进入这么多个二级页;每个二级页最多再进入这么多个三级页',
            startSniff: '开始嗅探',
            startSniffTitle: '按第一行设置扫描列表分页及下级网页',
            prepareIdm: '准备IDM插件',
            prepareIdmTitle: '准备并选中链接,然后使用 IDM 插件右键命令',
            copySelectedIdm: '复制选中→IDM',
            selectAll: '全选',
            selectNone: '全不选',
            invert: '反选',
            filterPlaceholder: '搜索/过滤名称、栏目、项目名…',
            selectFiltered: '勾选筛选结果',
            prefix: '前缀',
            suffix: '后缀',
            prefixToggleTitle: '勾选后启用;取消勾选会撤销本功能添加的前缀',
            suffixToggleTitle: '勾选后启用;取消勾选会撤销本功能添加的后缀',
            prefixPlaceholder: '输入前缀,可包含分隔符',
            prefixInputTitle: '内容会原样放在文件名前;默认仅提供页面标题作为建议,不会自动启用',
            suffixPlaceholder: '输入后缀,如 _已发布',
            suffixInputTitle: '后缀会放在扩展名前,例如 文件名_已发布.pdf',
            exportTxt: '导出TXT',
            exportCsv: '导出CSV',
            exportExcel: '导出Excel',
            clear: '清空',
            supportAuthor: '支持作者',
            supportAuthorTitle: '打开作者 Ko-fi 赞赏页面',
            help: '帮助',
            helpTitle: '打开使用帮助',
            helpUrlMissing: '帮助链接尚未设置。请在脚本 CONFIG.helpUrls 中填写公开帮助文档地址。',
            found: '找到附件:{count}',
            selected: '已选:{count}',
            initialStatus: '等待扫描。前缀和后缀默认均不启用;勾选后生效,取消勾选即可撤销。所有名称框仍可直接修改。',
            emptyResults: '暂未发现附件',
            itemCheckTitle: '勾选此附件',
            filenameEditTitle: '可直接修改最终文件名',
            unknownContext: '未识别上下文',
            depthPage: '{depth}级网页',
            scanCurrent: '正在扫描当前页面…',
            mojibakeRetry: '检测到疑似中文乱码,正在按原始网页字节重新识别编码…',
            currentAdded: '当前页新增 {added} 个,合计 {total} 个。正在核对无扩展名/哈希附件…',
            scanCompleteUnreliable: '扫描完成:共 {total} 个附件;仍有 {machine} 个名称未可靠识别(服务器机器名或原始乱码),可直接在黄色名称框内修改。',
            scanComplete: '扫描完成:共找到 {total} 个附件。',
            scanFailed: '扫描失败:{error}',
            sniffDepthProgress: '开始嗅探:正在扫描{depth}级网页 {current}/{total}…',
            scanAlreadyRunning: '已有扫描任务正在运行,请等待完成。',
            sniffInvalidUrl: '当前网址无法进行网页嗅探。',
            sniffListProgress: '开始嗅探:正在扫描列表父页面 {current}/{total}…',
            sniffMetadata: '网页扫描完成,正在核对新附件名称…',
            sniffComplete: '嗅探完成:列表父页面 {listDone}/{listTarget} 个,二级页 {level2} 个,三级页 {level3} 个;新增 {added} 个附件,共 {total} 个{failedPart}{stopNote}。每个父页面的下一级均分别限制为最多 {limit} 页。',
            sniffFailedPart: ';{failed} 个页面请求失败或超时',
            sniffStopDuplicate: ';后续列表分页出现重复地址',
            sniffStopNoNext: ';未再识别到后续列表分页',
            sniffStopOffsite: ';后续列表分页跳转到站外地址',
            sniffStopRequestFailed: ';第 {page} 个列表父页面请求失败',
            sniffAborted: '嗅探中止:{error}。已保留 {total} 个结果。',
            selectFilteredStatus: '已仅勾选当前筛选结果 {count} 个。',
            prefixValue: '前缀“{value}”',
            prefixEmpty: '前缀(内容为空)',
            suffixValue: '后缀“{value}”',
            suffixEmpty: '后缀(内容为空)',
            listSeparator: '、',
            affixEnabled: '已启用{enabled},已更新 {count} 个文件名;取消勾选即可撤销。',
            affixDisabled: '前缀和后缀均已关闭,已撤销由此功能添加的内容。',
            noSelected: '没有勾选附件。',
            copyRich: '已复制 {count} 条带修复文件名的 IDM 富文本链接;可在 IDM 使用“从剪贴板添加批量下载”。',
            copyPlainFallback: '已复制 {count} 条 URL + 文件名;当前页面只允许纯文本剪贴板,若 IDM 仍采用服务器名,请改用“准备IDM插件”。',
            copyPlain: '已复制 {count} 条 URL + 文件名。',
            copyFailed: '复制失败,请检查浏览器剪贴板权限。',
            idmSheetTitle: '准备交给 IDM 浏览器插件',
            close: '关闭',
            idmGuide: '已选中 {count} 条附件链接。请在下面的蓝色链接区域内<strong>右键</strong>,然后选择<strong>“使用 IDM 下载选定链接”</strong>,即可打开 IDM 原生批量筛选窗口。',
            idmReselect: '重新选中全部链接',
            idmReady: '已为 IDM 插件准备并选中 {count} 条链接;请在弹出的蓝色链接区域内右键,选择“使用 IDM 下载选定链接”。',
            exportedTxt: '已导出 {count} 个勾选附件的 TXT。',
            exportedCsv: '已导出 {count} 个勾选附件的 CSV。',
            exportedXlsx: '已导出 {count} 个勾选附件的 XLSX。',
            cleared: '结果已清空。',
            networkFailed: '网络请求失败',
            requestTimeout: '请求超时',
            filenameHeader: '文件名',
            urlHeader: '下载地址',
            sourcePageHeader: '来源页面',
            depthHeader: '嗅探层级',
            projectHeader: '项目名称',
            sectionHeader: '栏目标题',
            basisHeader: '命名依据',
            attachmentList: '附件列表',
            exportBase: 'IDM下载助手',
            unnamedAttachment: '未命名附件',
            sourcePending: '待探测',
            sourceServer: '服务器Content-Disposition',
            sourceLinkText: '链接文字',
            sourceAttribute: '{attr}属性',
            sourceChildTitle: '子节点title',
            sourceChildFilename: '子节点data-filename',
            sourceChildName: '子节点data-name',
            sourceChildAria: '子节点aria-label',
            sourceAdjacent: '相邻文本节点',
            sourceTableRow: '表格行',
            sourcePrevious: '前置兄弟节点',
            sourceNext: '后置兄弟节点',
            sourceParent: '父容器{selector}'
        },

        'zh-TW': {
            appTitle: 'IDM下載助手',
            collapseExpand: '收合/展開',
            hideWindow: '隱藏視窗',
            theme: '主題',
            themeTitle: '選擇下載助手介面主題',
            themeAuto: '自動',
            themeDark: '深色',
            themeLight: '淺色',
            fetch: '抓取',
            listPages: '個列表頁',
            sniffDepth: '嗅探層級',
            level1: '一級',
            level2: '二級',
            level3: '三級',
            perLevelMax: '每級最多',
            childPages: '個子頁面',
            perLevelTooltip: '每個列表頁最多進入這麼多個二級頁;每個二級頁最多再進入這麼多個三級頁',
            startSniff: '開始嗅探',
            startSniffTitle: '依第一列設定掃描列表分頁及下級網頁',
            prepareIdm: '準備IDM外掛',
            prepareIdmTitle: '準備並選取連結,然後使用 IDM 外掛右鍵命令',
            copySelectedIdm: '複製所選→IDM',
            selectAll: '全選',
            selectNone: '全部取消',
            invert: '反向選取',
            filterPlaceholder: '搜尋/篩選名稱、欄目、專案名稱…',
            selectFiltered: '勾選篩選結果',
            prefix: '前綴',
            suffix: '後綴',
            prefixToggleTitle: '勾選後啟用;取消勾選會撤銷本功能加入的前綴',
            suffixToggleTitle: '勾選後啟用;取消勾選會撤銷本功能加入的後綴',
            prefixPlaceholder: '輸入前綴,可包含分隔符號',
            prefixInputTitle: '內容會原樣放在檔名前;預設僅提供頁面標題作為建議,不會自動啟用',
            suffixPlaceholder: '輸入後綴,如 _已發布',
            suffixInputTitle: '後綴會放在副檔名前,例如 檔名_已發布.pdf',
            exportTxt: '匯出TXT',
            exportCsv: '匯出CSV',
            exportExcel: '匯出Excel',
            clear: '清除',
            supportAuthor: '支持作者',
            supportAuthorTitle: '開啟作者 Ko-fi 贊賞頁面',
            help: '幫助',
            helpTitle: '開啟使用說明',
            helpUrlMissing: '尚未設定幫助連結。請在腳本 CONFIG.helpUrls 中填入公開說明文件地址。',
            found: '找到附件:{count}',
            selected: '已選:{count}',
            initialStatus: '等待掃描。前綴和後綴預設均不啟用;勾選後生效,取消勾選即可撤銷。所有名稱欄仍可直接修改。',
            emptyResults: '尚未發現附件',
            itemCheckTitle: '勾選此附件',
            filenameEditTitle: '可直接修改最終檔名',
            unknownContext: '未識別內容',
            depthPage: '{depth}級網頁',
            scanCurrent: '正在掃描目前頁面…',
            mojibakeRetry: '偵測到疑似中文亂碼,正在依原始網頁位元組重新識別編碼…',
            currentAdded: '目前頁面新增 {added} 個,共 {total} 個。正在核對無副檔名/雜湊附件…',
            scanCompleteUnreliable: '掃描完成:共 {total} 個附件;仍有 {machine} 個名稱未可靠識別(伺服器機器名稱或原始亂碼),可直接在黃色名稱欄內修改。',
            scanComplete: '掃描完成:共找到 {total} 個附件。',
            scanFailed: '掃描失敗:{error}',
            sniffDepthProgress: '開始嗅探:正在掃描{depth}級網頁 {current}/{total}…',
            scanAlreadyRunning: '已有掃描工作正在執行,請等待完成。',
            sniffInvalidUrl: '目前網址無法進行網頁嗅探。',
            sniffListProgress: '開始嗅探:正在掃描列表父頁面 {current}/{total}…',
            sniffMetadata: '網頁掃描完成,正在核對新附件名稱…',
            sniffComplete: '嗅探完成:列表父頁面 {listDone}/{listTarget} 個,二級頁 {level2} 個,三級頁 {level3} 個;新增 {added} 個附件,共 {total} 個{failedPart}{stopNote}。每個父頁面的下一級均分別限制為最多 {limit} 頁。',
            sniffFailedPart: ';{failed} 個頁面請求失敗或逾時',
            sniffStopDuplicate: ';後續列表分頁出現重複網址',
            sniffStopNoNext: ';未再識別到後續列表分頁',
            sniffStopOffsite: ';後續列表分頁跳轉到站外網址',
            sniffStopRequestFailed: ';第 {page} 個列表父頁面請求失敗',
            sniffAborted: '嗅探中止:{error}。已保留 {total} 個結果。',
            selectFilteredStatus: '已僅勾選目前篩選結果 {count} 個。',
            prefixValue: '前綴「{value}」',
            prefixEmpty: '前綴(內容為空)',
            suffixValue: '後綴「{value}」',
            suffixEmpty: '後綴(內容為空)',
            listSeparator: '、',
            affixEnabled: '已啟用{enabled},已更新 {count} 個檔名;取消勾選即可撤銷。',
            affixDisabled: '前綴和後綴均已關閉,已撤銷由此功能加入的內容。',
            noSelected: '沒有勾選附件。',
            copyRich: '已複製 {count} 條帶修復檔名的 IDM 富文字連結;可在 IDM 使用「從剪貼簿新增批次下載」。',
            copyPlainFallback: '已複製 {count} 條 URL + 檔名;目前頁面只允許純文字剪貼簿,若 IDM 仍採用伺服器名稱,請改用「準備IDM外掛」。',
            copyPlain: '已複製 {count} 條 URL + 檔名。',
            copyFailed: '複製失敗,請檢查瀏覽器剪貼簿權限。',
            idmSheetTitle: '準備交給 IDM 瀏覽器外掛',
            close: '關閉',
            idmGuide: '已選取 {count} 條附件連結。請在下方藍色連結區域內<strong>按右鍵</strong>,然後選擇<strong>「使用 IDM 下載選定連結」</strong>,即可開啟 IDM 原生批次篩選視窗。',
            idmReselect: '重新選取全部連結',
            idmReady: '已為 IDM 外掛準備並選取 {count} 條連結;請在彈出的藍色連結區域內按右鍵,選擇「使用 IDM 下載選定連結」。',
            exportedTxt: '已匯出 {count} 個勾選附件的 TXT。',
            exportedCsv: '已匯出 {count} 個勾選附件的 CSV。',
            exportedXlsx: '已匯出 {count} 個勾選附件的 XLSX。',
            cleared: '結果已清除。',
            networkFailed: '網路請求失敗',
            requestTimeout: '請求逾時',
            filenameHeader: '檔名',
            urlHeader: '下載網址',
            sourcePageHeader: '來源頁面',
            depthHeader: '嗅探層級',
            projectHeader: '專案名稱',
            sectionHeader: '欄目標題',
            basisHeader: '命名依據',
            attachmentList: '附件列表',
            exportBase: 'IDM下載助手',
            unnamedAttachment: '未命名附件',
            sourcePending: '等待探測',
            sourceServer: '伺服器Content-Disposition',
            sourceLinkText: '連結文字',
            sourceAttribute: '{attr}屬性',
            sourceChildTitle: '子節點title',
            sourceChildFilename: '子節點data-filename',
            sourceChildName: '子節點data-name',
            sourceChildAria: '子節點aria-label',
            sourceAdjacent: '相鄰文字節點',
            sourceTableRow: '表格列',
            sourcePrevious: '前置同層節點',
            sourceNext: '後置同層節點',
            sourceParent: '父容器{selector}'
        },

        ja: {
            appTitle: 'IDM ダウンロードアシスタント',
            collapseExpand: '折りたたむ/展開',
            hideWindow: 'ウィンドウを非表示',
            theme: 'テーマ',
            themeTitle: 'ダウンロードアシスタントのテーマを選択',
            themeAuto: '自動',
            themeDark: 'ダーク',
            themeLight: 'ライト',
            fetch: '取得',
            listPages: '件のリストページ',
            sniffDepth: '探索階層',
            level1: '第1階層',
            level2: '第2階層',
            level3: '第3階層',
            perLevelMax: '各階層の上限',
            childPages: '件の子ページ',
            perLevelTooltip: '各リストページから開く第2階層ページの上限、および各第2階層ページから開く第3階層ページの上限',
            startSniff: '探索を開始',
            startSniffTitle: '上の設定に従ってリストのページ送りと下位ページを探索します',
            prepareIdm: 'IDM拡張機能を準備',
            prepareIdmTitle: 'リンクを準備して選択し、IDM拡張機能の右クリックメニューを使用します',
            copySelectedIdm: '選択項目をコピー → IDM',
            selectAll: 'すべて選択',
            selectNone: 'すべて解除',
            invert: '選択を反転',
            filterPlaceholder: 'ファイル名、セクション、プロジェクトを検索/絞り込み…',
            selectFiltered: '絞り込み結果を選択',
            prefix: '接頭辞',
            suffix: '接尾辞',
            prefixToggleTitle: 'チェックすると接頭辞を有効化し、外すとこの機能が追加した接頭辞を削除します',
            suffixToggleTitle: 'チェックすると接尾辞を有効化し、外すとこの機能が追加した接尾辞を削除します',
            prefixPlaceholder: '区切り文字を含む接頭辞を入力',
            prefixInputTitle: 'ファイル名の直前にそのまま追加されます。ページタイトルは候補として表示されるだけで、自動では有効になりません',
            suffixPlaceholder: '接尾辞を入力(例: _公開済み)',
            suffixInputTitle: '拡張子の直前に追加されます(例: ファイル名_公開済み.pdf)',
            exportTxt: 'TXTを書き出し',
            exportCsv: 'CSVを書き出し',
            exportExcel: 'Excelを書き出し',
            clear: 'クリア',
            supportAuthor: '作者を支援',
            supportAuthorTitle: '作者の Ko-fi 支援ページを開く',
            help: 'ヘルプ',
            helpTitle: '使い方を開く',
            helpUrlMissing: 'ヘルプリンクが未設定です。スクリプトの CONFIG.helpUrls に公開ドキュメントのURLを入力してください。',
            found: '添付ファイル:{count}',
            selected: '選択済み:{count}',
            initialStatus: '待機中です。接頭辞と接尾辞は既定で無効です。チェックで有効化し、チェックを外すと削除できます。各ファイル名は直接編集できます。',
            emptyResults: '添付ファイルはまだ見つかっていません',
            itemCheckTitle: 'この添付ファイルを選択',
            filenameEditTitle: '最終ファイル名を直接編集',
            unknownContext: 'コンテキストを特定できません',
            depthPage: '第{depth}階層ページ',
            scanCurrent: '現在のページをスキャンしています…',
            mojibakeRetry: '中国語の文字化けの可能性を検出しました。元のページデータから文字コードを再判定しています…',
            currentAdded: '現在のページで {added} 件追加、合計 {total} 件。拡張子なし/ハッシュ名の添付ファイルを確認しています…',
            scanCompleteUnreliable: 'スキャン完了:添付ファイル {total} 件。うち {machine} 件のファイル名は信頼できません(サーバー生成名または文字化け)。黄色のファイル名欄で直接編集できます。',
            scanComplete: 'スキャン完了:添付ファイルが {total} 件見つかりました。',
            scanFailed: 'スキャン失敗:{error}',
            sniffDepthProgress: '探索中:第{depth}階層ページ {current}/{total} をスキャンしています…',
            scanAlreadyRunning: 'スキャンはすでに実行中です。完了するまでお待ちください。',
            sniffInvalidUrl: '現在のURLはページ探索に使用できません。',
            sniffListProgress: '探索中:親リストページ {current}/{total} をスキャンしています…',
            sniffMetadata: 'ページのスキャンが完了しました。新しい添付ファイル名を確認しています…',
            sniffComplete: '探索完了:親リストページ {listDone}/{listTarget} 件、第2階層 {level2} 件、第3階層 {level3} 件。添付ファイルを {added} 件追加し、合計 {total} 件です{failedPart}{stopNote}。各親ページの次階層はそれぞれ最大 {limit} ページに制限されました。',
            sniffFailedPart: ';{failed} 件のページ要求が失敗またはタイムアウト',
            sniffStopDuplicate: ';後続のリストページでURLが重複',
            sniffStopNoNext: ';後続のリストページを検出できませんでした',
            sniffStopOffsite: ';後続のリストページが外部サイトへ移動しました',
            sniffStopRequestFailed: ';{page} 件目の親リストページを取得できませんでした',
            sniffAborted: '探索を中止しました:{error}。{total} 件の結果を保持しています。',
            selectFilteredStatus: '現在の絞り込み結果 {count} 件のみを選択しました。',
            prefixValue: '接頭辞「{value}」',
            prefixEmpty: '接頭辞(空)',
            suffixValue: '接尾辞「{value}」',
            suffixEmpty: '接尾辞(空)',
            listSeparator: '、',
            affixEnabled: '{enabled}を有効にし、{count} 件のファイル名を更新しました。チェックを外すと削除できます。',
            affixDisabled: '接頭辞と接尾辞はどちらも無効です。この機能が追加した文字列を削除しました。',
            noSelected: '添付ファイルが選択されていません。',
            copyRich: '修正済みファイル名を含む IDM リッチテキストリンクを {count} 件コピーしました。IDMで「クリップボードから一括ダウンロードを追加」を使用できます。',
            copyPlainFallback: 'URL + ファイル名を {count} 件コピーしました。このページではプレーンテキストのクリップボードのみ使用できます。IDMがサーバー名を使う場合は「IDM拡張機能を準備」を使用してください。',
            copyPlain: 'URL + ファイル名を {count} 件コピーしました。',
            copyFailed: 'コピーに失敗しました。ブラウザーのクリップボード権限を確認してください。',
            idmSheetTitle: 'IDMブラウザー拡張機能用リンクを準備',
            close: '閉じる',
            idmGuide: '{count} 件の添付リンクが選択されています。下の青いリンク領域内で<strong>右クリック</strong>し、<strong>「IDMで選択したリンクをダウンロード」</strong>を選ぶと、IDM標準の一括フィルター画面が開きます。',
            idmReselect: 'すべてのリンクを再選択',
            idmReady: 'IDM拡張機能用に {count} 件のリンクを準備して選択しました。青いリンク領域内で右クリックし、「IDMで選択したリンクをダウンロード」を選んでください。',
            exportedTxt: '選択した添付ファイル {count} 件をTXTに書き出しました。',
            exportedCsv: '選択した添付ファイル {count} 件をCSVに書き出しました。',
            exportedXlsx: '選択した添付ファイル {count} 件をXLSXに書き出しました。',
            cleared: '結果をクリアしました。',
            networkFailed: 'ネットワーク要求に失敗しました',
            requestTimeout: '要求がタイムアウトしました',
            filenameHeader: 'ファイル名',
            urlHeader: 'ダウンロードURL',
            sourcePageHeader: '取得元ページ',
            depthHeader: '探索階層',
            projectHeader: 'プロジェクト名',
            sectionHeader: 'セクション見出し',
            basisHeader: '命名元',
            attachmentList: '添付ファイル',
            exportBase: 'IDMダウンロードアシスタント',
            unnamedAttachment: '名前のない添付ファイル',
            sourcePending: 'メタデータ待ち',
            sourceServer: 'サーバー Content-Disposition',
            sourceLinkText: 'リンク文字列',
            sourceAttribute: '{attr} 属性',
            sourceChildTitle: '子要素の title',
            sourceChildFilename: '子要素の data-filename',
            sourceChildName: '子要素の data-name',
            sourceChildAria: '子要素の aria-label',
            sourceAdjacent: '隣接テキストノード',
            sourceTableRow: '表の行',
            sourcePrevious: '前の兄弟要素',
            sourceNext: '次の兄弟要素',
            sourceParent: '親コンテナー {selector}'
        },

        de: {
            appTitle: 'IDM-Download-Assistent',
            collapseExpand: 'Ein-/ausklappen',
            hideWindow: 'Fenster ausblenden',
            theme: 'Design',
            themeTitle: 'Design des Download-Assistenten auswählen',
            themeAuto: 'Automatisch',
            themeDark: 'Dunkel',
            themeLight: 'Hell',
            fetch: 'Scannen',
            listPages: 'Listenseite(n)',
            sniffDepth: 'Suchtiefe',
            level1: 'Ebene 1',
            level2: 'Ebene 2',
            level3: 'Ebene 3',
            perLevelMax: 'Maximum je Ebene',
            childPages: 'Unterseite(n)',
            perLevelTooltip: 'Maximale Anzahl von Seiten der Ebene 2 je Listenseite und Seiten der Ebene 3 je Seite der Ebene 2',
            startSniff: 'Suche starten',
            startSniffTitle: 'Listennavigation und Unterseiten mit den obigen Einstellungen scannen',
            prepareIdm: 'IDM-Erweiterung vorbereiten',
            prepareIdmTitle: 'Links vorbereiten und markieren, danach den Kontextmenübefehl der IDM-Erweiterung verwenden',
            copySelectedIdm: 'Auswahl kopieren → IDM',
            selectAll: 'Alle auswählen',
            selectNone: 'Keine auswählen',
            invert: 'Auswahl umkehren',
            filterPlaceholder: 'Dateiname, Bereich oder Projekt suchen/filtern…',
            selectFiltered: 'Gefilterte auswählen',
            prefix: 'Präfix',
            suffix: 'Suffix',
            prefixToggleTitle: 'Aktiviert das Präfix; beim Abwählen wird das von dieser Funktion hinzugefügte Präfix entfernt',
            suffixToggleTitle: 'Aktiviert das Suffix; beim Abwählen wird das von dieser Funktion hinzugefügte Suffix entfernt',
            prefixPlaceholder: 'Präfix einschließlich Trennzeichen eingeben',
            prefixInputTitle: 'Wird unverändert vor den Dateinamen gesetzt; der Seitentitel ist nur ein Vorschlag und wird nicht automatisch aktiviert',
            suffixPlaceholder: 'Suffix eingeben, z. B. _veröffentlicht',
            suffixInputTitle: 'Wird vor der Dateiendung eingefügt, z. B. dateiname_veröffentlicht.pdf',
            exportTxt: 'TXT exportieren',
            exportCsv: 'CSV exportieren',
            exportExcel: 'Excel exportieren',
            clear: 'Leeren',
            supportAuthor: 'Autor unterstützen',
            supportAuthorTitle: 'Ko-fi-Unterstützungsseite des Autors öffnen',
            help: 'Hilfe',
            helpTitle: 'Hilfe öffnen',
            helpUrlMissing: 'Der Hilfe-Link ist nicht festgelegt. Tragen Sie die öffentliche Dokumentationsadresse in CONFIG.helpUrls ein.',
            found: 'Anhänge: {count}',
            selected: 'Ausgewählt: {count}',
            initialStatus: 'Bereit. Präfix und Suffix sind standardmäßig deaktiviert. Aktivieren Sie ein Kontrollkästchen zum Anwenden und entfernen Sie es zum Rückgängigmachen. Jeder Dateiname bleibt direkt bearbeitbar.',
            emptyResults: 'Noch keine Anhänge gefunden',
            itemCheckTitle: 'Diesen Anhang auswählen',
            filenameEditTitle: 'Endgültigen Dateinamen direkt bearbeiten',
            unknownContext: 'Kontext nicht erkannt',
            depthPage: 'Seite der Ebene {depth}',
            scanCurrent: 'Aktuelle Seite wird gescannt…',
            mojibakeRetry: 'Möglicherweise fehlerhaft kodierte chinesische Zeichen erkannt. Die ursprünglichen Seitendaten werden erneut ausgewertet…',
            currentAdded: '{added} neue Einträge auf dieser Seite, insgesamt {total}. Anhänge ohne Endung oder mit Hash-Namen werden geprüft…',
            scanCompleteUnreliable: 'Scan abgeschlossen: {total} Anhänge; {machine} Dateinamen sind noch unzuverlässig (servergeneriert oder fehlerhaft kodiert). Sie können in den gelben Namensfeldern bearbeitet werden.',
            scanComplete: 'Scan abgeschlossen: {total} Anhänge gefunden.',
            scanFailed: 'Scan fehlgeschlagen: {error}',
            sniffDepthProgress: 'Suche läuft: Seiten der Ebene {depth} werden gescannt ({current}/{total})…',
            scanAlreadyRunning: 'Ein Scan wird bereits ausgeführt. Bitte warten Sie auf den Abschluss.',
            sniffInvalidUrl: 'Die aktuelle URL kann nicht durchsucht werden.',
            sniffListProgress: 'Suche läuft: übergeordnete Listenseite {current}/{total} wird gescannt…',
            sniffMetadata: 'Seitenscan abgeschlossen. Neue Anhangsnamen werden geprüft…',
            sniffComplete: 'Suche abgeschlossen: {listDone}/{listTarget} übergeordnete Listenseiten, {level2} Seiten der Ebene 2 und {level3} Seiten der Ebene 3; {added} neue Anhänge, insgesamt {total}{failedPart}{stopNote}. Pro übergeordneter Seite und Ebene wurden höchstens {limit} Unterseiten verarbeitet.',
            sniffFailedPart: '; {failed} Seitenanfragen sind fehlgeschlagen oder abgelaufen',
            sniffStopDuplicate: '; die nachfolgende Listennavigation wiederholte eine URL',
            sniffStopNoNext: '; keine weitere Listenseite erkannt',
            sniffStopOffsite: '; die nachfolgende Listennavigation führte zu einer externen Website',
            sniffStopRequestFailed: '; übergeordnete Listenseite {page} konnte nicht geladen werden',
            sniffAborted: 'Suche abgebrochen: {error}. {total} Ergebnisse wurden beibehalten.',
            selectFilteredStatus: 'Nur die {count} aktuell gefilterten Ergebnisse wurden ausgewählt.',
            prefixValue: 'Präfix „{value}“',
            prefixEmpty: 'Präfix (leer)',
            suffixValue: 'Suffix „{value}“',
            suffixEmpty: 'Suffix (leer)',
            listSeparator: ', ',
            affixEnabled: '{enabled} aktiviert; {count} Dateinamen aktualisiert. Zum Entfernen die Option abwählen.',
            affixDisabled: 'Präfix und Suffix sind deaktiviert. Von dieser Funktion hinzugefügte Inhalte wurden entfernt.',
            noSelected: 'Keine Anhänge ausgewählt.',
            copyRich: '{count} IDM-Rich-Text-Links mit korrigierten Dateinamen wurden kopiert. Verwenden Sie in IDM „Stapel-Download aus Zwischenablage hinzufügen“.',
            copyPlainFallback: '{count} Paare aus URL und Dateiname wurden kopiert. Diese Seite erlaubt nur Text in der Zwischenablage. Falls IDM weiterhin Servernamen nutzt, verwenden Sie „IDM-Erweiterung vorbereiten“.',
            copyPlain: '{count} Paare aus URL und Dateiname wurden kopiert.',
            copyFailed: 'Kopieren fehlgeschlagen. Prüfen Sie die Zwischenablageberechtigung des Browsers.',
            idmSheetTitle: 'Links für die IDM-Browsererweiterung vorbereiten',
            close: 'Schließen',
            idmGuide: '{count} Anhangslinks sind ausgewählt. Klicken Sie im blauen Linkbereich unten mit der <strong>rechten Maustaste</strong> und wählen Sie <strong>„Ausgewählte Links mit IDM herunterladen“</strong>, um das native IDM-Stapelfilterfenster zu öffnen.',
            idmReselect: 'Alle Links erneut auswählen',
            idmReady: '{count} Links wurden für die IDM-Erweiterung vorbereitet und ausgewählt. Klicken Sie im blauen Linkbereich mit der rechten Maustaste und wählen Sie „Ausgewählte Links mit IDM herunterladen“.',
            exportedTxt: '{count} ausgewählte Anhänge wurden als TXT exportiert.',
            exportedCsv: '{count} ausgewählte Anhänge wurden als CSV exportiert.',
            exportedXlsx: '{count} ausgewählte Anhänge wurden als XLSX exportiert.',
            cleared: 'Ergebnisse geleert.',
            networkFailed: 'Netzwerkanfrage fehlgeschlagen',
            requestTimeout: 'Zeitüberschreitung der Anfrage',
            filenameHeader: 'Dateiname',
            urlHeader: 'Download-URL',
            sourcePageHeader: 'Quellseite',
            depthHeader: 'Suchtiefe',
            projectHeader: 'Projektname',
            sectionHeader: 'Bereichsüberschrift',
            basisHeader: 'Quelle des Namens',
            attachmentList: 'Anhänge',
            exportBase: 'IDM-Download-Assistent',
            unnamedAttachment: 'Unbenannter Anhang',
            sourcePending: 'Metadaten ausstehend',
            sourceServer: 'Server Content-Disposition',
            sourceLinkText: 'Linktext',
            sourceAttribute: 'Attribut {attr}',
            sourceChildTitle: 'Untergeordnetes title',
            sourceChildFilename: 'Untergeordnetes data-filename',
            sourceChildName: 'Untergeordnetes data-name',
            sourceChildAria: 'Untergeordnetes aria-label',
            sourceAdjacent: 'Benachbarter Textknoten',
            sourceTableRow: 'Tabellenzeile',
            sourcePrevious: 'Vorheriges Geschwisterelement',
            sourceNext: 'Nächstes Geschwisterelement',
            sourceParent: 'Übergeordneter Container {selector}'
        },

        ru: {
            appTitle: 'Помощник загрузки IDM',
            collapseExpand: 'Свернуть/развернуть',
            hideWindow: 'Скрыть окно',
            theme: 'Тема',
            themeTitle: 'Выбрать тему помощника загрузки',
            themeAuto: 'Авто',
            themeDark: 'Тёмная',
            themeLight: 'Светлая',
            fetch: 'Сканировать',
            listPages: 'стр. списка',
            sniffDepth: 'Глубина поиска',
            level1: 'Уровень 1',
            level2: 'Уровень 2',
            level3: 'Уровень 3',
            perLevelMax: 'Макс. на уровень',
            childPages: 'дочерних стр.',
            perLevelTooltip: 'Максимум страниц уровня 2 для каждой страницы списка и страниц уровня 3 для каждой страницы уровня 2',
            startSniff: 'Начать поиск',
            startSniffTitle: 'Сканировать пагинацию списка и вложенные страницы с указанными выше параметрами',
            prepareIdm: 'Подготовить расширение IDM',
            prepareIdmTitle: 'Подготовить и выделить ссылки, затем использовать команду контекстного меню расширения IDM',
            copySelectedIdm: 'Копировать выбранное → IDM',
            selectAll: 'Выбрать все',
            selectNone: 'Снять выбор',
            invert: 'Инвертировать',
            filterPlaceholder: 'Поиск/фильтр по имени, разделу, проекту…',
            selectFiltered: 'Выбрать отфильтрованное',
            prefix: 'Префикс',
            suffix: 'Суффикс',
            prefixToggleTitle: 'Включает префикс; снимите флажок, чтобы удалить префикс, добавленный этой функцией',
            suffixToggleTitle: 'Включает суффикс; снимите флажок, чтобы удалить суффикс, добавленный этой функцией',
            prefixPlaceholder: 'Введите префикс, включая разделитель',
            prefixInputTitle: 'Добавляется без изменений перед именем файла; заголовок страницы предлагается как вариант, но не включается автоматически',
            suffixPlaceholder: 'Введите суффикс, например _опубликовано',
            suffixInputTitle: 'Вставляется перед расширением, например имя_опубликовано.pdf',
            exportTxt: 'Экспорт TXT',
            exportCsv: 'Экспорт CSV',
            exportExcel: 'Экспорт Excel',
            clear: 'Очистить',
            supportAuthor: 'Поддержать автора',
            supportAuthorTitle: 'Открыть страницу автора на Ko-fi',
            help: 'Справка',
            helpTitle: 'Открыть справку',
            helpUrlMissing: 'Ссылка на справку не задана. Укажите публичный адрес документации в CONFIG.helpUrls.',
            found: 'Вложений: {count}',
            selected: 'Выбрано: {count}',
            initialStatus: 'Готово. Префикс и суффикс по умолчанию отключены; установите флажок для применения и снимите для удаления. Любое имя файла можно редактировать напрямую.',
            emptyResults: 'Вложения пока не найдены',
            itemCheckTitle: 'Выбрать это вложение',
            filenameEditTitle: 'Изменить итоговое имя файла',
            unknownContext: 'Контекст не определён',
            depthPage: 'Страница уровня {depth}',
            scanCurrent: 'Сканируется текущая страница…',
            mojibakeRetry: 'Обнаружена возможная ошибка кодировки китайского текста. Кодировка повторно определяется по исходным данным страницы…',
            currentAdded: 'На текущей странице добавлено: {added}, всего: {total}. Проверяются вложения без расширения и с хеш-именами…',
            scanCompleteUnreliable: 'Сканирование завершено: вложений {total}; для {machine} имён нет надёжного результата (серверные имена или ошибка кодировки). Их можно изменить в жёлтых полях.',
            scanComplete: 'Сканирование завершено: найдено вложений {total}.',
            scanFailed: 'Ошибка сканирования: {error}',
            sniffDepthProgress: 'Поиск: сканируются страницы уровня {depth}, {current}/{total}…',
            scanAlreadyRunning: 'Сканирование уже выполняется. Дождитесь завершения.',
            sniffInvalidUrl: 'Текущий URL нельзя использовать для поиска страниц.',
            sniffListProgress: 'Поиск: сканируется родительская страница списка {current}/{total}…',
            sniffMetadata: 'Сканирование страниц завершено. Проверяются имена новых вложений…',
            sniffComplete: 'Поиск завершён: родительских страниц списка {listDone}/{listTarget}, страниц уровня 2 — {level2}, уровня 3 — {level3}; новых вложений {added}, всего {total}{failedPart}{stopNote}. Для каждой родительской страницы обработано не более {limit} дочерних страниц на уровень.',
            sniffFailedPart: '; запросов страниц с ошибкой или тайм-аутом: {failed}',
            sniffStopDuplicate: '; в последующей пагинации списка повторился URL',
            sniffStopNoNext: '; следующая страница списка не обнаружена',
            sniffStopOffsite: '; последующая пагинация списка перешла на внешний сайт',
            sniffStopRequestFailed: '; не удалось загрузить родительскую страницу списка {page}',
            sniffAborted: 'Поиск остановлен: {error}. Сохранено результатов: {total}.',
            selectFilteredStatus: 'Выбраны только текущие отфильтрованные результаты: {count}.',
            prefixValue: 'префикс «{value}»',
            prefixEmpty: 'префикс (пусто)',
            suffixValue: 'суффикс «{value}»',
            suffixEmpty: 'суффикс (пусто)',
            listSeparator: ', ',
            affixEnabled: 'Включено: {enabled}; обновлено имён файлов: {count}. Снимите флажок для удаления.',
            affixDisabled: 'Префикс и суффикс отключены. Добавленный этой функцией текст удалён.',
            noSelected: 'Вложения не выбраны.',
            copyRich: 'Скопировано форматированных ссылок IDM с исправленными именами: {count}. В IDM используйте «Добавить пакетную загрузку из буфера обмена».',
            copyPlainFallback: 'Скопировано пар URL + имя файла: {count}. Эта страница разрешает только обычный текст в буфере обмена; если IDM использует серверные имена, выберите «Подготовить расширение IDM».',
            copyPlain: 'Скопировано пар URL + имя файла: {count}.',
            copyFailed: 'Не удалось скопировать. Проверьте разрешение браузера на доступ к буферу обмена.',
            idmSheetTitle: 'Подготовка ссылок для расширения IDM',
            close: 'Закрыть',
            idmGuide: 'Выбрано ссылок на вложения: {count}. Щёлкните <strong>правой кнопкой мыши</strong> в синей области ссылок ниже и выберите <strong>«Скачать выбранные ссылки с помощью IDM»</strong>, чтобы открыть штатное окно пакетного фильтра IDM.',
            idmReselect: 'Повторно выбрать все ссылки',
            idmReady: 'Для расширения IDM подготовлено и выбрано ссылок: {count}. Щёлкните правой кнопкой в синей области ссылок и выберите «Скачать выбранные ссылки с помощью IDM».',
            exportedTxt: 'Выбранные вложения экспортированы в TXT: {count}.',
            exportedCsv: 'Выбранные вложения экспортированы в CSV: {count}.',
            exportedXlsx: 'Выбранные вложения экспортированы в XLSX: {count}.',
            cleared: 'Результаты очищены.',
            networkFailed: 'Ошибка сетевого запроса',
            requestTimeout: 'Истекло время ожидания запроса',
            filenameHeader: 'Имя файла',
            urlHeader: 'URL загрузки',
            sourcePageHeader: 'Исходная страница',
            depthHeader: 'Глубина поиска',
            projectHeader: 'Название проекта',
            sectionHeader: 'Заголовок раздела',
            basisHeader: 'Источник имени',
            attachmentList: 'Вложения',
            exportBase: 'Помощник_загрузки_IDM',
            unnamedAttachment: 'Безымянное вложение',
            sourcePending: 'Ожидание метаданных',
            sourceServer: 'Серверный Content-Disposition',
            sourceLinkText: 'Текст ссылки',
            sourceAttribute: 'Атрибут {attr}',
            sourceChildTitle: 'Дочерний title',
            sourceChildFilename: 'Дочерний data-filename',
            sourceChildName: 'Дочерний data-name',
            sourceChildAria: 'Дочерний aria-label',
            sourceAdjacent: 'Соседний текстовый узел',
            sourceTableRow: 'Строка таблицы',
            sourcePrevious: 'Предыдущий соседний элемент',
            sourceNext: 'Следующий соседний элемент',
            sourceParent: 'Родительский контейнер {selector}'
        },

        en: {
            appTitle: 'IDM Download Assistant',
            collapseExpand: 'Collapse/expand',
            hideWindow: 'Hide window',
            theme: 'Theme',
            themeTitle: 'Choose the download assistant theme',
            themeAuto: 'Auto',
            themeDark: 'Dark',
            themeLight: 'Light',
            fetch: 'Scan',
            listPages: 'list page(s)',
            sniffDepth: 'Sniff depth',
            level1: 'Level 1',
            level2: 'Level 2',
            level3: 'Level 3',
            perLevelMax: 'Per-level max',
            childPages: 'child page(s)',
            perLevelTooltip: 'Maximum level-2 pages opened from each list page, and maximum level-3 pages opened from each level-2 page',
            startSniff: 'Start sniffing',
            startSniffTitle: 'Scan list pagination and lower-level pages using the settings above',
            prepareIdm: 'Prepare IDM extension',
            prepareIdmTitle: 'Prepare and select links, then use the IDM extension context-menu command',
            copySelectedIdm: 'Copy selected → IDM',
            selectAll: 'Select all',
            selectNone: 'Select none',
            invert: 'Invert',
            filterPlaceholder: 'Search/filter filename, section, project…',
            selectFiltered: 'Select filtered',
            prefix: 'Prefix',
            suffix: 'Suffix',
            prefixToggleTitle: 'Enable the prefix when checked; uncheck to remove the prefix added by this feature',
            suffixToggleTitle: 'Enable the suffix when checked; uncheck to remove the suffix added by this feature',
            prefixPlaceholder: 'Enter a prefix, including any separator',
            prefixInputTitle: 'Added exactly before the filename; the page title is only a suggestion and is not enabled automatically',
            suffixPlaceholder: 'Enter a suffix, e.g. _published',
            suffixInputTitle: 'Inserted before the extension, for example filename_published.pdf',
            exportTxt: 'Export TXT',
            exportCsv: 'Export CSV',
            exportExcel: 'Export Excel',
            clear: 'Clear',
            supportAuthor: 'Support author',
            supportAuthorTitle: "Open the author's Ko-fi support page",
            help: 'Help',
            helpTitle: 'Open help',
            helpUrlMissing: 'The help link is not configured. Set CONFIG.helpUrls to public documentation URLs.',
            found: 'Attachments: {count}',
            selected: 'Selected: {count}',
            initialStatus: 'Ready. Prefix and suffix are disabled by default; check to enable and uncheck to remove them. Every filename remains directly editable.',
            emptyResults: 'No attachments found yet',
            itemCheckTitle: 'Select this attachment',
            filenameEditTitle: 'Edit the final filename directly',
            unknownContext: 'Context not identified',
            depthPage: 'Level-{depth} page',
            scanCurrent: 'Scanning the current page…',
            mojibakeRetry: 'Possible Chinese mojibake detected. Re-reading the original page bytes to identify the encoding…',
            currentAdded: '{added} new item(s) on this page, {total} total. Checking extensionless and hash-named attachments…',
            scanCompleteUnreliable: 'Scan complete: {total} attachment(s); {machine} filename(s) remain unreliable (server-generated or mojibake). You can edit them in the yellow filename fields.',
            scanComplete: 'Scan complete: {total} attachment(s) found.',
            scanFailed: 'Scan failed: {error}',
            sniffDepthProgress: 'Sniffing: scanning level-{depth} pages {current}/{total}…',
            scanAlreadyRunning: 'A scan is already running. Please wait for it to finish.',
            sniffInvalidUrl: 'The current URL cannot be sniffed.',
            sniffListProgress: 'Sniffing: scanning parent list page {current}/{total}…',
            sniffMetadata: 'Page scan complete. Checking new attachment filenames…',
            sniffComplete: 'Sniffing complete: {listDone}/{listTarget} parent list page(s), {level2} level-2 page(s), and {level3} level-3 page(s); {added} new attachment(s), {total} total{failedPart}{stopNote}. Each parent page was limited to {limit} child page(s) per level.',
            sniffFailedPart: '; {failed} page request(s) failed or timed out',
            sniffStopDuplicate: '; subsequent list pagination repeated a URL',
            sniffStopNoNext: '; no subsequent list page was detected',
            sniffStopOffsite: '; subsequent list pagination led off-site',
            sniffStopRequestFailed: '; parent list page {page} could not be loaded',
            sniffAborted: 'Sniffing stopped: {error}. {total} result(s) were kept.',
            selectFilteredStatus: 'Selected only the {count} currently filtered result(s).',
            prefixValue: 'prefix “{value}”',
            prefixEmpty: 'prefix (empty)',
            suffixValue: 'suffix “{value}”',
            suffixEmpty: 'suffix (empty)',
            listSeparator: ', ',
            affixEnabled: 'Enabled {enabled}; updated {count} filename(s). Uncheck an option to remove it.',
            affixDisabled: 'Prefix and suffix are both off. Content added by this feature has been removed.',
            noSelected: 'No attachments are selected.',
            copyRich: 'Copied {count} IDM rich-text link(s) with corrected filenames. In IDM, use “Add batch download from clipboard”.',
            copyPlainFallback: 'Copied {count} URL + filename pair(s). This page only permits plain-text clipboard access; if IDM still uses server filenames, use “Prepare IDM extension”.',
            copyPlain: 'Copied {count} URL + filename pair(s).',
            copyFailed: 'Copy failed. Check the browser clipboard permission.',
            idmSheetTitle: 'Prepare links for the IDM browser extension',
            close: 'Close',
            idmGuide: '{count} attachment link(s) are selected. <strong>Right-click</strong> inside the blue link area below, then choose <strong>“Download selected links with IDM”</strong> to open the native IDM batch-filter window.',
            idmReselect: 'Reselect all links',
            idmReady: 'Prepared and selected {count} link(s) for the IDM extension. Right-click inside the blue link area and choose “Download selected links with IDM”.',
            exportedTxt: 'Exported {count} selected attachment(s) to TXT.',
            exportedCsv: 'Exported {count} selected attachment(s) to CSV.',
            exportedXlsx: 'Exported {count} selected attachment(s) to XLSX.',
            cleared: 'Results cleared.',
            networkFailed: 'Network request failed',
            requestTimeout: 'Request timed out',
            filenameHeader: 'Filename',
            urlHeader: 'Download URL',
            sourcePageHeader: 'Source page',
            depthHeader: 'Sniff depth',
            projectHeader: 'Project name',
            sectionHeader: 'Section title',
            basisHeader: 'Naming source',
            attachmentList: 'Attachments',
            exportBase: 'IDM_Download_Assistant',
            unnamedAttachment: 'Unnamed attachment',
            sourcePending: 'Pending metadata',
            sourceServer: 'Server Content-Disposition',
            sourceLinkText: 'Link text',
            sourceAttribute: '{attr} attribute',
            sourceChildTitle: 'Child title',
            sourceChildFilename: 'Child data-filename',
            sourceChildName: 'Child data-name',
            sourceChildAria: 'Child aria-label',
            sourceAdjacent: 'Adjacent text node',
            sourceTableRow: 'Table row',
            sourcePrevious: 'Previous sibling',
            sourceNext: 'Next sibling',
            sourceParent: 'Parent container {selector}'
        }
    };


    function t(
        key,
        values = {}
    ) {

        const bundle =
            I18N[UI_LANGUAGE] ||
            I18N.en;


        const template =
            bundle[key] ??
            I18N.en[key] ??
            I18N['zh-CN'][key] ??
            key;


        return String(template)
            .replace(
                /\{(\w+)\}/g,
                (
                    _,
                    name
                ) =>
                    Object.prototype
                        .hasOwnProperty.call(
                            values,
                            name
                        )
                        ? String(
                            values[name]
                        )
                        : ''
            );
    }


    function localizeNameSource(source) {

        const value =
            String(source || '');


        const exactKeys = {
            '待探测':
                'sourcePending',
            '服务器Content-Disposition':
                'sourceServer',
            '链接文字':
                'sourceLinkText',
            '子节点title':
                'sourceChildTitle',
            '子节点data-filename':
                'sourceChildFilename',
            '子节点data-name':
                'sourceChildName',
            '子节点aria-label':
                'sourceChildAria',
            '相邻文本节点':
                'sourceAdjacent',
            '表格行':
                'sourceTableRow',
            '前置兄弟节点':
                'sourcePrevious',
            '后置兄弟节点':
                'sourceNext'
        };


        if (
            exactKeys[value]
        ) {

            return t(
                exactKeys[value]
            );
        }


        const attributeMatch =
            value.match(
                /^(.+)属性$/
            );


        if (attributeMatch) {

            return t(
                'sourceAttribute',
                {
                    attr:
                        attributeMatch[1]
                }
            );
        }


        const parentMatch =
            value.match(
                /^父容器(.+)$/
            );


        if (parentMatch) {

            return t(
                'sourceParent',
                {
                    selector:
                        parentMatch[1]
                }
            );
        }


        return value;
    }


    /**********************************************************************
     * 1. 通用工具
     **********************************************************************/

    function normalizeSpace(text) {

        return String(text || '')

            .replace(
                /[\u00A0\t\r\n]+/g,
                ' '
            )

            .replace(
                /\s{2,}/g,
                ' '
            )

            .trim();
    }


    function isMojibake(text) {

        const s =
            String(text || '');


        if (!s) {

            return false;
        }


        return (
            /\uFFFD/.test(s) ||
            /(?:锟斤拷|烫烫烫|鏂囦欢|涓嬭浇|闄勪欢|绔欑偣|馃)/.test(s) ||
            /(?:Ã.|Â.|â[€™œ“”]|ðŸ)/.test(s) ||
            /[\u00C0-\u00FF]{3,}/.test(s)
        );
    }


    function textQualityScore(text) {

        const s =
            String(text || '');


        const replacementCount =
            (
                s.match(
                    /\uFFFD/g
                ) ||
                []
            ).length;


        const controlCount =
            (
                s.match(
                    /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g
                ) ||
                []
            ).length;


        const chineseCount =
            (
                s.match(
                    /[\u3400-\u9fff]/g
                ) ||
                []
            ).length;


        return (
            chineseCount *
            5 -
            replacementCount *
            500 -
            controlCount *
            100 -
            (
                isMojibake(s)
                    ? 150
                    : 0
            ) -
            (
                (
                    s.match(
                        /%[0-9a-f]{2}/ig
                    ) ||
                    []
                ).length *
                3
            )
        );
    }


    function normalizeCharset(charset) {

        const value =
            String(charset || '')
                .trim()
                .toLowerCase()
                .replace(
                    /^['"]|['"]$/g,
                    ''
                );


        if (
            /^(?:gbk|gb2312|gb_2312|x-gbk|cp936|ms936|gb18030)$/.test(
                value
            )
        ) {

            return 'gb18030';
        }


        if (
            /^(?:utf8|utf-8)$/.test(
                value
            )
        ) {

            return 'utf-8';
        }


        return value;
    }


    function percentEncodedToBytes(value) {

        const source =
            String(value || '');


        const bytes =
            [];


        for (
            let i = 0;
            i < source.length;
            i++
        ) {

            if (
                source[i] === '%' &&
                /^[0-9a-f]{2}$/i.test(
                    source.slice(
                        i + 1,
                        i + 3
                    )
                )
            ) {

                bytes.push(
                    parseInt(
                        source.slice(
                            i + 1,
                            i + 3
                        ),
                        16
                    )
                );


                i +=
                    2;

                continue;
            }


            const encoded =
                new TextEncoder()
                    .encode(
                        source[i]
                    );


            bytes.push(
                ...encoded
            );
        }


        return new Uint8Array(
            bytes
        );
    }


    function decodeBytes(
        bytes,
        charset,
        fatal = false
    ) {

        try {

            return new TextDecoder(
                normalizeCharset(charset),
                {
                    fatal
                }
            )
                .decode(bytes);

        } catch (_) {

            return '';
        }
    }


    function repairByteMojibake(text) {

        const source =
            String(text || '');


        const chars =
            [...source];


        const highByteCount =
            chars
                .filter(
                    ch => {

                        const code =
                            ch.codePointAt(0);


                        return code >= 0x80 &&
                            code <= 0xff;
                    }
                )
                .length;


        if (
            highByteCount < 2 ||
            !/[\u0080-\u00ff]{2,}/i.test(
                source
            ) ||
            chars.some(
                ch =>
                    ch.codePointAt(0) >
                    0xff
            )
        ) {

            return source;
        }


        const bytes =
            new Uint8Array(
                chars.map(
                    ch =>
                        ch.codePointAt(0)
                )
            );


        const candidates = [

            source,

            decodeBytes(
                bytes,
                'utf-8',
                true
            ),

            decodeBytes(
                bytes,
                'gb18030'
            )
        ]
            .filter(Boolean);


        return candidates
            .sort(
                (
                    a,
                    b
                ) =>
                    textQualityScore(b) -
                    textQualityScore(a)
            )[0] ||
            source;
    }


    function decodeUrlComponentSmart(
        value,
        declaredCharset = ''
    ) {

        const source =
            String(value || '');


        if (
            !/%[0-9a-f]{2}/i.test(
                source
            )
        ) {

            return repairByteMojibake(
                source
            );
        }


        const bytes =
            percentEncodedToBytes(
                source
            );


        const charset =
            normalizeCharset(
                declaredCharset
            );


        if (
            charset &&
            charset !== 'utf-8'
        ) {

            const declared =
                decodeBytes(
                    bytes,
                    charset
                );


            if (declared) {

                return repairByteMojibake(
                    declared
                );
            }
        }


        try {

            return repairByteMojibake(
                decodeURIComponent(
                    source
                )
            );

        } catch (_) {}


        return repairByteMojibake(
            decodeBytes(
                bytes,
                charset ||
                'gb18030'
            ) ||
            source
        );
    }


    function safeDecode(value) {

        let s =
            String(value || '');


        for (
            let i = 0;
            i < 2;
            i++
        ) {

            const decoded =
                decodeUrlComponentSmart(
                    s
                );


            if (
                decoded === s
            ) {

                break;
            }


            s =
                decoded;
        }


        return repairByteMojibake(
            s
        );
    }


    function escapeHtml(text) {

        return String(text || '')

            .replace(
                /&/g,
                '&amp;'
            )

            .replace(
                /</g,
                '&lt;'
            )

            .replace(
                />/g,
                '&gt;'
            )

            .replace(
                /"/g,
                '&quot;'
            )

            .replace(
                /'/g,
                '&#039;'
            );
    }


    function normalizeUrl(
        url,
        baseUrl
    ) {

        try {

            const u =
                new URL(
                    url,
                    baseUrl ||
                    location.href
                );


            if (
                !/^https?:$/i.test(
                    u.protocol
                )
            ) {

                return '';
            }


            u.hash =
                '';


            return u.href;

        } catch (_) {

            return '';
        }
    }


    function getExtension(text) {

        const s =
            safeDecode(
                String(text || '')
            )
                .split('#')[0]
                .split('?')[0];


        const m =
            s.match(
                new RegExp(
                    `\\.(${EXT_GROUP})$`,
                    'i'
                )
            );


        return m
            ? m[1].toLowerCase()
            : '';
    }


    function stripExtension(name) {

        return String(name || '')
            .replace(
                new RegExp(
                    `\\.(${EXT_GROUP})$`,
                    'i'
                ),
                ''
            );
    }


    function sanitizeWindowsFilename(name) {

        let s =
            normalizeSpace(name);


        const replacements = {

            '\\':
                '\',

            '/':
                '/',

            ':':
                ':',

            '*':
                '*',

            '?':
                '?',

            '"':
                '"',

            '<':
                '<',

            '>':
                '>',

            '|':
                '|'
        };


        s =
            s.replace(
                /[\\/:*?"<>|]/g,
                ch =>
                    replacements[ch] ||
                    '_'
            );


        s =
            s.replace(
                /[\u0000-\u001F]/g,
                ''
            );


        s =
            s.replace(
                /[. ]+$/g,
                ''
            )
                .replace(
                    /^[. ]+/g,
                    ''
                );


        if (!s) {

            s =
                t(
                    'unnamedAttachment'
                );
        }


        if (
            /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(
                s
            )
        ) {

            s =
                '_' + s;
        }


        const maxLen =
            190;


        if (
            s.length >
            maxLen
        ) {

            const ext =
                getExtension(s);


            if (ext) {

                const suffix =
                    '.' + ext;


                s =
                    stripExtension(s)
                        .slice(
                            0,
                            maxLen -
                            suffix.length
                        ) +
                    suffix;

            } else {

                s =
                    s.slice(
                        0,
                        maxLen
                    );
            }
        }


        return s;
    }


    function ensureExtension(
        filename,
        ext
    ) {

        const name =
            sanitizeWindowsFilename(
                filename ||
                t(
                    'unnamedAttachment'
                )
            );


        if (
            getExtension(name)
        ) {

            return name;
        }


        return ext

            ? sanitizeWindowsFilename(
                `${name}.${String(ext).toLowerCase()}`
            )

            : name;
    }


    function isInvalidText(text) {

        const t =
            normalizeSpace(text)
                .toLowerCase()
                .replace(
                    /[::。.!!??\s]/g,
                    ''
                );


        if (!t) {

            return true;
        }


        // 附件1、附件2、文件1等也属于无意义名称。
        if (
            /^(?:附件|文件)\d*[::]?$/.test(
                t
            )
        ) {

            return true;
        }


        return CONFIG.invalidTexts
            .some(
                x =>
                    t ===
                    x.toLowerCase()
                        .replace(
                            /[::。.!!??\s]/g,
                            ''
                        )
            );
    }


    function isNavigationText(text) {

        const t =
            normalizeSpace(text)
                .toLowerCase()
                .replace(
                    /[>»›→\s]/g,
                    ''
                );


        return CONFIG.navigationTexts
            .some(
                x =>
                    t ===
                    x.toLowerCase()
                        .replace(
                            /[>»›→\s]/g,
                            ''
                        )
            );
    }


    function isBlockedResource(url) {

        return BLOCKED_EXT_RE
            .test(
                (url || '')
                    .split('#')[0]
            );
    }


    function looksLikeSupportedFile(text) {

        return FILE_EXT_RE
            .test(
                safeDecode(
                    text ||
                    ''
                )
            );
    }


    /**********************************************************************
     * 判断服务器机器文件名 / 哈希文件名
     *
     * 例如:
     *
     * 0f4a63d673944bf1b43a33f78f6ceb3d.pdf
     * 4927dda724274553a4e08c450a7b085b.pdf
     *
     * 这种名称即使有 PDF 扩展名,也不能视为有效附件名称。
     **********************************************************************/

    function isMachineFilename(filename) {

        const safe =
            sanitizeWindowsFilename(
                filename ||
                ''
            );


        const base =
            stripExtension(safe)

                .replace(
                    /^附件\s*\d*\s*[::_-]?\s*/i,
                    ''
                )

                .trim();


        if (
            isMojibake(
                safe
            )
        ) {

            return true;
        }


        if (!base) {

            return true;
        }


        if (
            [
                '未命名附件',
                'Unnamed attachment',
                '名前のない添付ファイル',
                'Unbenannter Anhang',
                'Безымянное вложение'
            ]
                .some(
                    name =>
                        base
                            .toLowerCase()
                            .startsWith(
                                name.toLowerCase()
                            )
                )
        ) {

            return true;
        }


        // MD5 / 哈希。
        if (
            /^[a-f0-9]{24,64}$/i.test(
                base
            )
        ) {

            return true;
        }


        // UUID。
        if (
            /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5a-f0-9][a-f0-9]{3}-[89ab0-9a-f][a-f0-9]{3}-[a-f0-9]{12}$/i.test(
                base
            )
        ) {

            return true;
        }


        // 长纯数字。
        if (
            /^\d{12,22}$/.test(
                base
            )
        ) {

            return true;
        }


        // 长随机英文数字串。
        if (
            !CHINESE_RE.test(base) &&
            base.length >= 28 &&
            /^[A-Za-z0-9_-]+$/.test(
                base
            )
        ) {

            const letters =
                (
                    base.match(
                        /[A-Za-z]/g
                    ) ||
                    []
                ).length;


            const digits =
                (
                    base.match(
                        /\d/g
                    ) ||
                    []
                ).length;


            if (
                letters > 4 &&
                digits > 4
            ) {

                return true;
            }
        }


        return false;
    }


    function extensionFromUrlOrText(
        url,
        text
    ) {

        for (
            const source
            of [text, url]
        ) {

            const m =
                safeDecode(
                    source ||
                    ''
                )
                    .match(
                        FILE_EXT_RE
                    );


            if (m) {

                return m[1]
                    .toLowerCase();
            }
        }


        return '';
    }


    function rawSearchParam(
        urlObject,
        wantedKey
    ) {

        const query =
            String(
                urlObject.search ||
                ''
            )
                .replace(
                    /^\?/,
                    ''
                );


        for (
            const part
            of query.split('&')
        ) {

            const separator =
                part.indexOf('=');


            const rawKey =
                separator >= 0
                    ? part.slice(
                        0,
                        separator
                    )
                    : part;


            const key =
                safeDecode(
                    rawKey.replace(
                        /\+/g,
                        ' '
                    )
                );


            if (
                key.toLowerCase() !==
                String(wantedKey)
                    .toLowerCase()
            ) {

                continue;
            }


            return separator >= 0
                ? part.slice(
                    separator + 1
                )
                : '';
        }


        return '';
    }


    function filenameFromUrl(url) {

        try {

            const u =
                new URL(
                    url,
                    location.href
                );


            const keys = [

                'filename',

                'fileName',

                'name',

                'downloadName',

                'attname',

                'file',

                'title'
            ];


            for (
                const key
                of keys
            ) {

                const rawValue =
                    rawSearchParam(
                        u,
                        key
                    );


                const value =
                    rawValue
                        ? decodeUrlComponentSmart(
                            rawValue.replace(
                                /\+/g,
                                ' '
                            )
                        )
                        : u.searchParams
                            .get(key);


                if (!value) {

                    continue;
                }


                const decoded =
                    safeDecode(value)

                        .split('/')
                        .pop()

                        .split('\\')
                        .pop();


                if (
                    decoded &&
                    decoded.length <= 240
                ) {

                    return sanitizeWindowsFilename(
                        decoded
                    );
                }
            }


            const last =
                safeDecode(

                    u.pathname

                        .split('/')

                        .filter(Boolean)

                        .pop() ||

                    ''
                );


            return last

                ? sanitizeWindowsFilename(
                    last
                )

                : '';

        } catch (_) {

            return '';
        }
    }


    function stripNoise(text) {

        let t =
            normalizeSpace(
                safeDecode(
                    text ||
                    ''
                )
            );


        t =
            t.replace(
                /https?:\/\/\S+/gi,
                ' '
            );


        t =
            t.replace(
                /(?:点击下载|立即下载|下载文件|文件下载|附件下载|查看附件|点击查看|查看详情|下载|查看|打开)\s*$/gi,
                ''
            );


        t =
            t.replace(
                /^\s*(?:文件名|附件名称|文件名称)\s*[::]\s*/i,
                ''
            );


        return normalizeSpace(t);
    }


    /**********************************************************************
     * 从网页文字中抽取文件名
     **********************************************************************/

    function extractFileNameFromText(text) {

        const t =
            stripNoise(text);


        if (!t) {

            return '';
        }


        // 短文本且只包含一个文件扩展名,
        // 可以把整个文本作为文件名。
        const extHits =
            t.match(
                new RegExp(
                    `\\.(${EXT_GROUP})(?=$|[\\s,,;;))\\]】])`,
                    'ig'
                )
            ) ||
            [];


        if (
            t.length <= 190 &&
            FILE_EXT_END_RE.test(t) &&
            extHits.length === 1
        ) {

            return sanitizeWindowsFilename(
                t
            );
        }


        // 较长上下文中,寻找“中文名称.xxx”。
        const re =
            new RegExp(

                `(?:^|[||;;。\\n]|\\s{2,})` +

                `([^||;;。\\n]{1,180}?\\.(${EXT_GROUP}))` +

                `(?=$|[\\s,,;;))\\]】]|(?:下载|查看|打开))`,

                'ig'
            );


        const matches =
            [
                ...t.matchAll(re)
            ];


        if (
            matches.length
        ) {

            let candidate =
                matches[
                    matches.length - 1
                ][1]
                    .trim();


            candidate =
                candidate.replace(

                    /^(?:附件名称|文件名称|文件名)\s*[::]\s*/i,

                    ''
                );


            return sanitizeWindowsFilename(
                candidate
            );
        }


        // 没有扩展名,但明显是短中文资料名称,
        // 后续可根据 URL 自动补扩展名。
        if (
            t.length >= 3 &&
            t.length <= 100 &&
            CHINESE_RE.test(t) &&
            !isInvalidText(t) &&
            !isNavigationText(t)
        ) {

            if (
                !/^(发布时间|来源|作者|浏览次数|字号|打印|关闭|分享)/.test(
                    t
                )
            ) {

                return sanitizeWindowsFilename(
                    t
                );
            }
        }


        return '';
    }


    /**********************************************************************
     * 2. 项目名称识别
     **********************************************************************/

    function cleanupProjectTitle(text) {

        let t =
            normalizeSpace(text)

                .replace(
                    /^(项目名称|工程名称|建设项目名称)\s*[::]\s*/i,
                    ''
                )

                .split(
                    /(?:建设单位|项目单位|建设地点|公示时间|发布时间|附件)[::]/
                )[0]

                .trim();


        if (
            t.length > 90
        ) {

            t =
                t.slice(
                    0,
                    90
                );
        }


        return sanitizeWindowsFilename(
            t
        );
    }


    function detectProjectTitle(doc) {

        const nodes =
            doc.querySelectorAll(
                'td,th,dt,dd,p,li,div,span'
            );


        let checked =
            0;


        for (
            const el
            of nodes
        ) {

            if (
                ++checked >
                3000
            ) {

                break;
            }


            const text =
                normalizeSpace(

                    el.innerText ||

                    el.textContent ||

                    ''
                );


            if (
                !text ||
                text.length > 180
            ) {

                continue;
            }


            const m =
                text.match(

                    /(?:项目名称|工程名称|建设项目名称)\s*[::]\s*(.{2,100})/i

                );


            if (
                m &&
                m[1]
            ) {

                const v =
                    cleanupProjectTitle(
                        m[1]
                    );


                if (v) {

                    return v;
                }
            }


            if (
                /^(项目名称|工程名称|建设项目名称)\s*[::]?$/i.test(
                    text
                )
            ) {

                const sibling =
                    el.nextElementSibling;


                const s =
                    normalizeSpace(

                        sibling?.innerText ||

                        sibling?.textContent ||

                        ''
                    );


                if (
                    s &&
                    s.length <= 100
                ) {

                    return cleanupProjectTitle(
                        s
                    );
                }
            }
        }


        const heading =
            doc.querySelector(

                'h1,.article-title,.content-title,.news-title,.title'

            );


        let title =
            cleanupProjectTitle(

                heading?.innerText ||

                heading?.textContent ||

                ''
            );


        if (
            title &&
            /(工程|项目|水库|灌区|河道|水利|供水|引水|水资源|评价|报告|公示)/.test(
                title
            )
        ) {

            title =
                title.replace(

                    /(?:公示|公告|批前公示|审批公示|信息公开).*$/g,

                    ''
                )
                    .trim();


            if (
                title.length >= 4
            ) {

                return title;
            }
        }


        return '';
    }


    function findNearestHeading(a) {

        const generic =
            /^(附件|附件下载|相关附件|下载|文件下载|正文|内容|公示|公告)$/;


        let node =
            a;


        for (
            let up = 0;
            up < 5 &&
            node;
            up++,
            node = node.parentElement
        ) {

            let prev =
                node.previousElementSibling;


            let steps =
                0;


            while (
                prev &&
                steps++ < 8
            ) {

                const heading =
                    prev.matches?.(

                        'h1,h2,h3,h4,h5,h6,.title,.tit,.section-title,.subtitle'

                    )

                        ? prev

                        : prev.querySelector?.(

                            'h1,h2,h3,h4,h5,h6,.title,.tit,.section-title,.subtitle'

                        );


                const text =
                    normalizeSpace(

                        heading?.innerText ||

                        heading?.textContent ||

                        ''
                    );


                if (
                    text &&
                    text.length <= 90 &&
                    !generic.test(text)
                ) {

                    return sanitizeWindowsFilename(
                        text
                    );
                }


                prev =
                    prev.previousElementSibling;
            }
        }


        return '';
    }


    /**********************************************************************
     * 3. 下载链接识别
     **********************************************************************/

    function extractUrlFromAnchor(
        a,
        baseUrl
    ) {

        const attrs = [

            a.getAttribute(
                'href'
            ),

            a.getAttribute(
                'data-href'
            ),

            a.getAttribute(
                'data-url'
            ),

            a.getAttribute(
                'data-file'
            ),

            a.getAttribute(
                'data-download'
            ),

            a.getAttribute(
                'download-url'
            )

        ].filter(Boolean);


        for (
            const raw
            of attrs
        ) {

            const s =
                String(raw)
                    .trim();


            if (
                !s ||
                s === '#' ||
                /^javascript:\s*void/i.test(s) ||
                /^mailto:/i.test(s)
            ) {

                continue;
            }


            if (
                !/^javascript:/i.test(
                    s
                )
            ) {

                const normalized =
                    normalizeUrl(
                        s,
                        baseUrl
                    );


                if (normalized) {

                    return normalized;
                }
            }
        }


        // 某些政府网站把下载地址写在 onclick 中。
        const onclick =
            a.getAttribute(
                'onclick'
            ) ||
            '';


        const quoted =
            [
                ...onclick.matchAll(
                    /['"]([^'"]+)['"]/g
                )
            ]
                .map(
                    m => m[1]
                );


        for (
            const part
            of quoted
        ) {

            if (
                looksLikeSupportedFile(part) ||
                DOWNLOAD_URL_RE.test(part)
            ) {

                const normalized =
                    normalizeUrl(
                        part,
                        baseUrl
                    );


                if (normalized) {

                    return normalized;
                }
            }
        }


        return '';
    }


    function getAnchorContextText(a) {

        const chunks =
            [];


        let node =
            a;


        for (
            let level = 0;
            level < 4 &&
            node;
            level++,
            node = node.parentElement
        ) {

            const text =
                normalizeSpace(

                    node.innerText ||

                    node.textContent ||

                    ''
                );


            if (
                text &&
                text.length <= 700
            ) {

                chunks.push(
                    text
                );
            }
        }


        return chunks
            .join(' | ')
            .slice(
                0,
                1800
            );
    }


    function isAttachmentCandidate(
        a,
        url
    ) {

        if (
            !url ||
            isBlockedResource(url)
        ) {

            return false;
        }


        const text =
            normalizeSpace(

                a.innerText ||

                a.textContent ||

                a.getAttribute(
                    'title'
                ) ||

                ''
            );


        const downloadAttr =
            a.getAttribute(
                'download'
            ) ||
            '';


        const title =
            normalizeSpace(

                a.getAttribute(
                    'title'
                ) ||

                ''
            );


        const context =
            getAnchorContextText(a);


        if (
            looksLikeSupportedFile(url) ||
            looksLikeSupportedFile(text) ||
            looksLikeSupportedFile(downloadAttr) ||
            looksLikeSupportedFile(title)
        ) {

            return true;
        }


        if (
            a.hasAttribute(
                'download'
            )
        ) {

            return true;
        }


        if (
            DOWNLOAD_URL_RE.test(url) &&
            (
                ATTACHMENT_WORD_RE.test(
                    `${text} ${title}`
                ) ||
                ATTACHMENT_WORD_RE.test(
                    context
                )
            )
        ) {

            return true;
        }


        if (
            (
                isInvalidText(text) ||
                ATTACHMENT_WORD_RE.test(text)
            ) &&
            DOWNLOAD_URL_RE.test(url)
        ) {

            return true;
        }


        if (
            isNavigationText(text)
        ) {

            return false;
        }


        return false;
    }


    /**********************************************************************
     * 4. 智能中文文件名识别
     **********************************************************************/

    function collectNameCandidates(
        a,
        url
    ) {

        const candidates =
            [];


        function push(
            value,
            origin,
            bonus = 0
        ) {

            const raw =
                normalizeSpace(
                    value ||
                    ''
                );


            if (
                !raw ||
                raw.length > 800
            ) {

                return;
            }


            const parsed =
                extractFileNameFromText(
                    raw
                );


            if (parsed) {

                candidates.push({

                    name:
                        parsed,

                    origin,

                    bonus,

                    raw
                });
            }
        }


        // 1. 链接显示文字。
        push(

            a.innerText ||
            a.textContent ||
            '',

            '链接文字',

            120
        );


        // 2. 常见文件名属性。
        const attrNames = [

            'download',

            'title',

            'aria-label',

            'data-filename',

            'data-file-name',

            'data-name',

            'data-title',

            'data-original-name',

            'data-original',

            'filename'
        ];


        for (
            const attr
            of attrNames
        ) {

            push(

                a.getAttribute(
                    attr
                ),

                `${attr}属性`,

                100
            );
        }


        // 3. 链接内部节点。
        for (
            const child
            of a.querySelectorAll(

                '[title],[data-filename],[data-name],[aria-label]'

            )
        ) {

            push(
                child.getAttribute(
                    'title'
                ),
                '子节点title',
                90
            );


            push(
                child.getAttribute(
                    'data-filename'
                ),
                '子节点data-filename',
                90
            );


            push(
                child.getAttribute(
                    'data-name'
                ),
                '子节点data-name',
                90
            );


            push(
                child.getAttribute(
                    'aria-label'
                ),
                '子节点aria-label',
                80
            );
        }


        // 4. 紧邻文本节点。
        // 常见:
        //
        // 项目报告.pdf <a href="hash.pdf">下载</a>
        //
        const parentNode =
            a.parentNode;


        if (parentNode) {

            const nodes =
                [
                    ...parentNode.childNodes
                ];


            const pos =
                nodes.indexOf(a);


            for (
                const offset
                of [
                    -2,
                    -1,
                    1,
                    2
                ]
            ) {

                const node =
                    nodes[
                        pos +
                        offset
                    ];


                if (
                    node &&
                    node !== a
                ) {

                    push(

                        node.textContent ||
                        '',

                        '相邻文本节点',

                        88 -
                        Math.abs(offset) *
                        3
                    );
                }
            }
        }


        // 5. 表格同行。
        const tr =
            a.closest(
                'tr'
            );


        if (tr) {

            for (
                const cell
                of tr.querySelectorAll(
                    'th,td'
                )
            ) {

                push(

                    cell.innerText ||
                    cell.textContent ||
                    '',

                    '表格行',

                    80
                );
            }
        }


        // 6. 前后兄弟元素。
        let prev =
            a.previousElementSibling;


        let next =
            a.nextElementSibling;


        for (
            let i = 0;
            i < 4 &&
            prev;
            i++,
            prev =
                prev.previousElementSibling
        ) {

            push(

                prev.innerText ||
                prev.textContent ||
                '',

                '前置兄弟节点',

                75 -
                i *
                5
            );
        }


        for (
            let i = 0;
            i < 3 &&
            next;
            i++,
            next =
                next.nextElementSibling
        ) {

            push(

                next.innerText ||
                next.textContent ||
                '',

                '后置兄弟节点',

                55 -
                i *
                5
            );
        }


        // 7. 最近父容器。
        for (
            const selector
            of [
                'li',
                'p',
                'dd',
                '.attachment',
                '.file',
                '.download',
                '.fj',
                '.list-item',
                'div'
            ]
        ) {

            const box =
                a.closest(
                    selector
                );


            if (box) {

                push(

                    box.innerText ||
                    box.textContent ||
                    '',

                    `父容器${selector}`,

                    60
                );
            }
        }


        // 8. URL 永远放在最后。
        const fromUrl =
            filenameFromUrl(
                url
            );


        if (fromUrl) {

            candidates.push({

                name:
                    fromUrl,

                origin:
                    'URL',

                bonus:
                    -20,

                raw:
                    fromUrl
            });
        }


        return candidates;
    }


    function candidateScore(
        candidate,
        urlExt
    ) {

        const name =
            sanitizeWindowsFilename(

                candidate.name ||

                ''
            );


        const base =
            stripExtension(name)
                .trim();


        if (
            !name ||
            !base ||
            isInvalidText(base) ||
            isNavigationText(base)
        ) {

            return -99999;
        }


        let score =
            candidate.bonus ||
            0;


        const ext =
            getExtension(name);


        // 中文名优先。
        if (
            CHINESE_RE.test(
                name
            )
        ) {

            score +=
                180;
        }


        // 有明确扩展名。
        if (ext) {

            score +=
                55;
        }


        if (
            urlExt &&
            ext &&
            ext === urlExt
        ) {

            score +=
                20;
        }


        if (
            base.length >= 4 &&
            base.length <= 100
        ) {

            score +=
                30;
        }


        if (
            ATTACHMENT_WORD_RE.test(
                name
            )
        ) {

            score +=
                20;
        }


        if (
            /^(附件\s*\d+\s*[::])/i.test(
                name
            )
        ) {

            score +=
                25;
        }


        if (
            candidate.origin ===
            '链接文字'
        ) {

            score +=
                45;
        }


        if (
            candidate.origin.includes(
                '表格行'
            )
        ) {

            score +=
                30;
        }


        if (
            candidate.origin.includes(
                '兄弟节点'
            )
        ) {

            score +=
                25;
        }


        // 机器哈希名直接重罚。
        if (
            isMachineFilename(
                name
            )
        ) {

            score -=
                500;
        }


        if (
            name.length > 160
        ) {

            score -=
                100;
        }


        if (
            /^(发布时间|来源|作者|浏览次数|打印|关闭|分享)/.test(
                base
            )
        ) {

            score -=
                300;
        }


        if (
            /^(http|www\.)/i.test(
                base
            )
        ) {

            score -=
                300;
        }


        return score;
    }


    function sanitizeFilenameAffix(text) {

        const normalized =
            normalizeSpace(text);


        return normalized
            ? sanitizeWindowsFilename(
                normalized
            )
            : '';
    }


    function splitFilenameForAffix(filename) {

        const name =
            String(filename || '');


        const match =
            name.match(
                new RegExp(
                    `\.(${EXT_GROUP})$`,
                    'i'
                )
            );


        return match
            ? {

                stem:
                    name.slice(
                        0,
                        -match[0].length
                    ),

                extension:
                    match[0]
            }
            : {

                stem: name,

                extension: ''
            };
    }


    function filenameWithoutAppliedAffixes(
        item,
        filename = item?.filename
    ) {

        let base =
            String(filename || '');


        const appliedPrefix =
            String(
                item?.appliedFilenamePrefix ||
                ''
            );


        const appliedSuffix =
            String(
                item?.appliedFilenameSuffix ||
                ''
            );


        if (
            appliedPrefix &&
            base.startsWith(
                appliedPrefix
            )
        ) {

            base =
                base.slice(
                    appliedPrefix.length
                );
        }


        const parts =
            splitFilenameForAffix(
                base
            );


        if (
            appliedSuffix &&
            parts.stem.endsWith(
                appliedSuffix
            )
        ) {

            parts.stem =
                parts.stem.slice(
                    0,
                    -appliedSuffix.length
                );
        }


        return sanitizeWindowsFilename(
            `${parts.stem}${parts.extension}`
        );
    }


    function composeFilenameWithAffixes(
        baseFilename
    ) {

        const base =
            sanitizeWindowsFilename(
                baseFilename ||
                t(
                    'unnamedAttachment'
                )
            );


        const parts =
            splitFilenameForAffix(
                base
            );


        let prefix =
            state.filenameAffixes
                .prefixEnabled
                ? sanitizeFilenameAffix(
                    state.filenameAffixes
                        .prefixText
                )
                : '';


        let suffix =
            state.filenameAffixes
                .suffixEnabled
                ? sanitizeFilenameAffix(
                    state.filenameAffixes
                        .suffixText
                )
                : '';


        // Windows 文件名总长度仍限制为 190 个字符。
        // 优先保留用户填写的前后缀,再缩短文件名主体。
        const stemLimit =
            Math.max(
                1,
                190 -
                parts.extension.length
            );


        const affixLimit =
            Math.max(
                0,
                stemLimit - 1
            );


        if (
            prefix.length +
            suffix.length >
            affixLimit
        ) {

            if (
                prefix &&
                suffix
            ) {

                const prefixLimit =
                    Math.ceil(
                        affixLimit / 2
                    );


                prefix =
                    prefix.slice(
                        0,
                        prefixLimit
                    );


                suffix =
                    suffix.slice(
                        0,
                        Math.max(
                            0,
                            affixLimit -
                            prefix.length
                        )
                    );

            } else if (prefix) {

                prefix =
                    prefix.slice(
                        0,
                        affixLimit
                    );

            } else {

                suffix =
                    suffix.slice(
                        0,
                        affixLimit
                    );
            }
        }


        const coreLimit =
            Math.max(
                1,
                stemLimit -
                prefix.length -
                suffix.length
            );


        const core =
            (
                parts.stem ||
                t(
                    'unnamedAttachment'
                )
            )
                .slice(
                    0,
                    coreLimit
                );


        return {

            filename:
                sanitizeWindowsFilename(
                    `${prefix}${core}${suffix}${parts.extension}`
                ),

            prefix,

            suffix
        };
    }


    function applyFilenameAffixesToItem(
        item
    ) {

        const base =
            filenameWithoutAppliedAffixes(
                item
            );


        const composed =
            composeFilenameWithAffixes(
                base
            );


        item.filename =
            composed.filename;


        item.appliedFilenamePrefix =
            composed.prefix;


        item.appliedFilenameSuffix =
            composed.suffix;
    }


    function applyFilenameAffixesToAll() {

        for (
            const item
            of state.items
        ) {

            applyFilenameAffixesToItem(
                item
            );
        }
    }


    function chooseBestFilename(
        a,
        url,
        projectTitle
    ) {

        const originalText =
            normalizeSpace(

                a.innerText ||

                a.textContent ||

                ''
            );


        const urlExt =
            extensionFromUrlOrText(
                url,
                originalText
            );


        const candidates =
            collectNameCandidates(
                a,
                url
            )

                .map(
                    c => ({
                        ...c,
                        score:
                            candidateScore(
                                c,
                                urlExt
                            )
                    })
                )

                .sort(
                    (
                        x,
                        y
                    ) =>
                        y.score -
                        x.score
                );


        const best =
            candidates[0];


        let filename =
            best?.name ||
            t(
                'unnamedAttachment'
            );


        let source =
            best?.origin ||
            '待探测';


        filename =
            ensureExtension(
                filename,
                urlExt
            );


        return {

            filename:
                sanitizeWindowsFilename(
                    filename
                ),

            source,

            originalText,

            candidateDebug:
                candidates.slice(
                    0,
                    5
                )
        };
    }


    function extractItemsFromDocument(
        doc,
        baseUrl,
        sourcePageUrl,
        sourceDepth = 1
    ) {

        const result =
            [];


        const projectTitle =
            detectProjectTitle(
                doc
            );


        for (
            const a
            of doc.querySelectorAll(
                'a'
            )
        ) {

            // 防止扫描脚本自身窗口。
            if (
                a.closest?.(
                    '#wra-helper-panel,#wra-idm-plugin-sheet'
                )
            ) {

                continue;
            }


            const url =
                extractUrlFromAnchor(
                    a,
                    baseUrl
                );


            if (
                !url ||
                !isAttachmentCandidate(
                    a,
                    url
                )
            ) {

                continue;
            }


            const visibleText =
                normalizeSpace(

                    a.innerText ||

                    a.textContent ||

                    ''
                );


            if (
                isNavigationText(
                    visibleText
                ) &&
                !looksLikeSupportedFile(
                    url
                ) &&
                !looksLikeSupportedFile(
                    visibleText
                )
            ) {

                continue;
            }


            const named =
                chooseBestFilename(
                    a,
                    url,
                    projectTitle
                );


            const heading =
                findNearestHeading(
                    a
                );


            result.push({

                id:
                    cryptoRandomId(),

                selected:
                    true,

                filename:
                    named.filename,

                url,

                sourcePage:
                    sourcePageUrl ||
                    baseUrl,

                sourceDepth,

                projectTitle:
                    projectTitle ||
                    '',

                sectionTitle:
                    heading ||
                    '',

                nameSource:
                    named.source,

                originalText:
                    named.originalText,

                needsMetadata:
                    isMachineFilename(
                        named.filename
                    ) ||
                    !getExtension(
                        named.filename
                    ),

                candidateDebug:
                    named.candidateDebug
            });
        }


        return result;
    }


    function cryptoRandomId() {

        try {

            if (
                globalThis.crypto
                    ?.randomUUID
            ) {

                return globalThis.crypto
                    .randomUUID();
            }

        } catch (_) {}


        return (
            'i_' +
            Date.now()
                .toString(36) +
            '_' +
            Math.random()
                .toString(36)
                .slice(2)
        );
    }


    function mergeItems(items) {

        let added =
            0;


        for (
            const item
            of items
        ) {

            const key =
                normalizeUrl(

                    item.url,

                    item.sourcePage ||
                    location.href
                );


            if (!key) {

                continue;
            }


            if (
                state.byUrl
                    .has(key)
            ) {

                const existing =
                    state.byUrl
                        .get(key);


                // 已有机器名,新结果找到中文名时自动覆盖。
                if (
                    isMachineFilename(
                        filenameWithoutAppliedAffixes(
                            existing
                        )
                    ) &&
                    !isMachineFilename(
                        filenameWithoutAppliedAffixes(
                            item
                        )
                    )
                ) {

                    existing.filename =
                        filenameWithoutAppliedAffixes(
                            item
                        );


                    existing.appliedFilenamePrefix =
                        '';


                    existing.appliedFilenameSuffix =
                        '';


                    existing.nameSource =
                        item.nameSource;


                    existing.projectTitle =
                        item.projectTitle ||
                        existing.projectTitle;


                    existing.sectionTitle =
                        item.sectionTitle ||
                        existing.sectionTitle;


                    existing.needsMetadata =
                        false;


                    applyFilenameAffixesToItem(
                        existing
                    );
                }


                continue;
            }


            item.url =
                key;


            applyFilenameAffixesToItem(
                item
            );


            state.items
                .push(
                    item
                );


            state.byUrl
                .set(
                    key,
                    item
                );


            added++;
        }


        return added;
    }


    /**********************************************************************
     * 5. HEAD探测
     *
     * 用于:
     *
     * /download?id=123
     * /file?id=456
     *
     * 或服务器哈希文件名。
     **********************************************************************/

    function parseHeaders(
        rawHeaders
    ) {

        const headers =
            {};


        String(
            rawHeaders ||
            ''
        )

            .split(
                /\r?\n/
            )

            .forEach(
                line => {

                    const idx =
                        line.indexOf(
                            ':'
                        );


                    if (
                        idx > 0
                    ) {

                        headers[
                            line
                                .slice(
                                    0,
                                    idx
                                )
                                .trim()
                                .toLowerCase()
                        ] =
                            line
                                .slice(
                                    idx + 1
                                )
                                .trim();
                    }
                }
            );


        return headers;
    }


    function filenameFromContentDisposition(
        cd
    ) {

        if (!cd) {

            return '';
        }


        let m =
            cd.match(

                /filename\*\s*=\s*["']?([^'";]*)'[^']*'([^;"']+)/i

            );


        if (m) {

            const value =
                decodeUrlComponentSmart(
                    m[2]
                        .trim(),
                    m[1]
                );


            if (value) {

                return sanitizeWindowsFilename(

                    value

                        .split('/')
                        .pop()

                        .split('\\')
                        .pop()
                );
            }
        }


        m =
            cd.match(

                /filename\*\s*=\s*([^;]+)/i

            );


        if (m) {

            const value =
                safeDecode(
                    m[1]
                        .trim()
                        .replace(
                            /^['"]|['"]$/g,
                            ''
                        )
                );


            if (value) {

                return sanitizeWindowsFilename(
                    value
                        .split('/')
                        .pop()
                        .split('\\')
                        .pop()
                );
            }
        }


        m =
            cd.match(

                /filename\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;]+))/i

            );


        if (m) {

            const value =
                safeDecode(

                    m[1] ||

                    m[2] ||

                    m[3] ||

                    ''
                )
                    .trim();


            if (value) {

                return sanitizeWindowsFilename(

                    value

                        .split('/')
                        .pop()

                        .split('\\')
                        .pop()
                );
            }
        }


        return '';
    }


    function headRequest(url) {

        return new Promise(
            resolve => {

                GM_xmlhttpRequest({

                    method:
                        'HEAD',

                    url,

                    timeout:
                        12000,

                    anonymous:
                        false,


                    onload:
                        resolve,


                    onerror:
                        () =>
                            resolve(
                                null
                            ),


                    ontimeout:
                        () =>
                            resolve(
                                null
                            )
                });
            }
        );
    }


    async function enrichItemMetadata(
        item
    ) {

        const res =
            await headRequest(
                item.url
            );


        if (!res) {

            return;
        }


        const headers =
            parseHeaders(
                res.responseHeaders
            );


        const cdName =
            filenameFromContentDisposition(

                headers[
                    'content-disposition'
                ]
            );


        const contentType =
            String(

                headers[
                    'content-type'
                ] ||

                ''
            )

                .split(';')[0]

                .trim()

                .toLowerCase();


        const mimeExt =
            MIME_TO_EXT[
                contentType
            ] ||
            '';


        let filename =
            filenameWithoutAppliedAffixes(
                item
            );


        // 只有服务器提供的是正常名称,
        // 才允许替换机器哈希名称。
        if (
            cdName &&
            !isMachineFilename(
                cdName
            ) &&
            (
                isMachineFilename(
                    filename
                ) ||
                !getExtension(
                    filename
                )
            )
        ) {

            filename =
                cdName;


            item.nameSource =
                '服务器Content-Disposition';
        }


        if (
            !getExtension(
                filename
            ) &&
            mimeExt
        ) {

            filename =
                ensureExtension(
                    filename,
                    mimeExt
                );
        }


        const baseFilename =
            sanitizeWindowsFilename(
                filename
            );


        item.filename =
            baseFilename;


        item.appliedFilenamePrefix =
            '';


        item.appliedFilenameSuffix =
            '';


        applyFilenameAffixesToItem(
            item
        );


        item.needsMetadata =
            isMachineFilename(
                baseFilename
            ) ||
            !getExtension(
                baseFilename
            );
    }


    async function enrichMissingMetadata(
        items
    ) {

        const queue =
            items

                .filter(
                    x =>
                        x.needsMetadata
                )

                .slice(
                    0,
                    CONFIG.maxHeadRequestsPerScan
                );


        if (
            !queue.length
        ) {

            return;
        }


        let index =
            0;


        async function worker() {

            while (
                index <
                queue.length
            ) {

                const current =
                    queue[
                        index++
                    ];


                await enrichItemMetadata(
                    current
                );


                renderResults();
            }
        }


        await Promise.all(

            Array.from(

                {
                    length:
                        Math.min(

                            CONFIG.headConcurrency,

                            queue.length
                        )
                },

                () =>
                    worker()
            )
        );
    }


    /**********************************************************************
     * 6. 扫描
     **********************************************************************/

    async function scanCurrentPage() {

        if (
            state.scanning
        ) {

            return;
        }


        state.scanning =
            true;


       setStatus(
            t('scanCurrent')
        );


        try {

            let items =
                extractItemsFromDocument(

                    document,

                    location.href,

                    location.href
                );


            if (
                items.some(
                    item =>
                        isMojibake(
                            `${item.filename} ${item.originalText}`
                        )
                )
            ) {

               setStatus(
                    t('mojibakeRetry')
                );


                try {

                    const res =
                        await requestText(
                            location.href
                        );


                    const recoveredUrl =
                        normalizeUrl(
                            res.finalUrl ||
                            location.href,
                            location.href
                        ) ||
                        location.href;


                    const recoveredDoc =
                        new DOMParser()
                            .parseFromString(
                                res.text,
                                'text/html'
                            );


                    items = [

                        ...items,

                        ...extractItemsFromDocument(
                            recoveredDoc,
                            recoveredUrl,
                            recoveredUrl
                        )
                    ];

                } catch (encodingError) {

                    console.warn(
                        '[IDM下载助手] 网页乱码重解失败:',
                        encodingError
                    );
                }
            }


            const added =
                mergeItems(
                    items
                );


            renderResults();

            updateSelectionCount();


           setStatus(

                t(
                    'currentAdded',
                    {
                        added,
                        total:
                            state.items.length
                    }
                )

           );


            await enrichMissingMetadata(
                items
            );


            renderResults();

            updateSelectionCount();


            const machineCount =
                state.items

                    .filter(
                        x =>
                           isMachineFilename(
                                filenameWithoutAppliedAffixes(
                                    x
                                )
                           )
                    )

                    .length;


            setStatus(

                machineCount

                    ? t(
                        'scanCompleteUnreliable',
                        {
                            total:
                                state.items.length,
                            machine:
                                machineCount
                        }
                    )

                    : t(
                        'scanComplete',
                        {
                            total:
                                state.items.length
                        }
                    )

            );

        } catch (err) {

            console.error(

                '[IDM下载助手] 扫描失败:',

                err
            );


           setStatus(

                t(
                    'scanFailed',
                    {
                        error:
                            err.message ||
                            err
                    }
                )

           );

        } finally {

            state.scanning =
                false;
        }
    }


    function installMutationObserver() {

        const observer =
            new MutationObserver(

                mutations => {

                    const hasNewLinks =
                        mutations.some(

                            m => {

                                if (
                                    m.target
                                        ?.closest?.(
                                            '#wra-helper-panel,#wra-idm-plugin-sheet'
                                        )
                                ) {

                                    return false;
                                }


                                return [
                                    ...(
                                        m.addedNodes ||
                                        []
                                    )
                                ]
                                    .some(

                                        node =>

                                            node.nodeType === 1 &&

                                            !node.closest?.(
                                                '#wra-helper-panel,#wra-idm-plugin-sheet'
                                            ) &&

                                            (
                                                node.matches?.(
                                                    'a'
                                                ) ||

                                                node.querySelector?.(
                                                    'a'
                                                )
                                            )
                                    );
                            }
                        );


                    if (
                        !hasNewLinks
                    ) {

                        return;
                    }


                    clearTimeout(
                        state.observerTimer
                    );


                    state.observerTimer =
                        setTimeout(

                            scanCurrentPage,

                            CONFIG.mutationDebounceMs
                        );
                }
            );


        observer.observe(

            document.documentElement,

            {
                childList:
                    true,

                subtree:
                    true
            }
        );
    }


    /**********************************************************************
     * 7. 列表分页识别与网页请求
     **********************************************************************/

    function findNextPageUrl(
        doc,
        baseUrl,
        visited
    ) {

        const anchors =
            [
                ...doc.querySelectorAll(
                    'a'
                )
            ];


        const urlOf =
            a => {

                const url =
                    extractUrlFromAnchor(
                        a,
                        baseUrl
                    );


                return url &&
                    !visited.has(url)
                    ? url
                    : '';
            };


        const labelOf =
            a =>
                normalizeSpace(
                    [
                        a.innerText ||
                        a.textContent ||
                        '',
                        a.getAttribute(
                            'title'
                        ) ||
                        '',
                        a.getAttribute(
                            'aria-label'
                        ) ||
                        ''
                    ]
                        .join(' ')
                );


        const paginationMarker =
            node => {

                for (
                    let current = node;
                    current;
                    current =
                        current.parentElement
                ) {

                    const marker =
                        `${current.id || ''} ${
                            typeof current.className ===
                            'string'
                                ? current.className
                                : ''
                        } ${current.getAttribute?.(
                            'aria-label'
                        ) || ''}`;


                    if (
                        /(?:^|[\s_-])(pagination|paging|pager|pagebar|page-nav|page-list|pages|fenye|fy)(?:$|[\s_-])/i.test(
                            marker
                        )
                    ) {

                        return true;
                    }


                    if (
                        current ===
                        doc.body
                    ) {

                        break;
                    }
                }


                return false;
            };


        // 优先使用标准 rel=next。
        for (
            const a
            of anchors.filter(
                anchor =>
                    /(?:^|\s)next(?:\s|$)/i.test(
                        anchor.getAttribute(
                            'rel'
                        ) ||
                        ''
                    )
            )
        ) {

            const url =
                urlOf(a);


            if (url) {

                return url;
            }
        }


        // 兼容“下一页 >”“Next »”、title/aria-label 以及 next 类名。
        for (const a of anchors) {

            const label =
                labelOf(a);


            const marker =
                `${a.id || ''} ${
                    typeof a.className ===
                    'string'
                        ? a.className
                        : ''
                } ${
                    typeof a.parentElement
                        ?.className ===
                    'string'
                        ? a.parentElement.className
                        : ''
                }`;


            if (
                !/(?:下一页|下页|后一页|next(?:\s*page)?)/i.test(
                    label
                ) &&
                !/^(?:\s*[›»>→]+\s*)$/i.test(
                    label
                ) &&
                !/(?:^|[\s_-])next(?:$|[\s_-])/i.test(
                    marker
                )
            ) {

                continue;
            }


            const url =
                urlOf(a);


            if (url) {

                return url;
            }
        }


        // 没有明确“下一页”时,从分页条中找当前页码之后最小的数字页。
        let currentPage = 0;


        for (
            const node
            of doc.querySelectorAll(
                '[aria-current="page"],.current,.active,.on,.selected,strong'
            )
        ) {

            if (
                !paginationMarker(node)
            ) {

                continue;
            }


            const match =
                normalizeSpace(
                    node.textContent ||
                    ''
                )
                    .match(
                        /^(\d{1,6})$/
                    );


            if (match) {

                currentPage =
                    Number(match[1]);


                break;
            }
        }


        if (!currentPage) {

            try {

                const currentUrl =
                    new URL(baseUrl);


                for (
                    const key
                    of [
                        'page',
                        'p',
                        'pageNo',
                        'pageNum',
                        'currentPage',
                        'pageIndex'
                    ]
                ) {

                    const value =
                        Number(
                            currentUrl.searchParams
                                .get(key)
                        );


                    if (
                        Number.isFinite(value) &&
                        value > 0
                    ) {

                        currentPage = value;

                        break;
                    }
                }

            } catch (_) {}
        }


        const numbered =
            anchors
                .filter(
                    paginationMarker
                )
                .map(
                    a => {

                        const match =
                            normalizeSpace(
                                a.textContent ||
                                ''
                            )
                                .match(
                                    /^(\d{1,6})$/
                                );


                        return match
                            ? {
                                page:
                                    Number(
                                        match[1]
                                    ),
                                url:
                                    urlOf(a)
                            }
                            : null;
                    }
                )
                .filter(
                    item =>
                        item?.url &&
                        item.page >
                        currentPage
                )
                .sort(
                    (
                        a,
                        b
                    ) =>
                        a.page -
                        b.page
                );


        if (numbered[0]?.url) {

            return numbered[0].url;
        }


        // 某些政府静态站只有第一页写出“下一页”,后续页采用
        // index_1.html、index_2.html 递增;有明确分页证据时补推下一页。
        try {

            const currentUrl =
                new URL(baseUrl);


            for (
                const [
                    key,
                    rawValue
                ]
                of currentUrl.searchParams
                    .entries()
            ) {

                if (
                    !/^(?:page|p|pageno|pagenum|currentpage|pageindex)$/i.test(
                        key
                    ) ||
                    !/^\d+$/.test(
                        rawValue
                    )
                ) {

                    continue;
                }


                const inferred =
                    new URL(
                        currentUrl.href
                    );


                inferred.searchParams
                    .set(
                        key,
                        String(
                            Number(rawValue) +
                            1
                        )
                    );


                if (
                    !visited.has(
                        inferred.href
                    )
                ) {

                    return inferred.href;
                }
            }


            const pathMatch =
                currentUrl.pathname
                    .match(
                        /^(.*?)([_-])(\d+)(\.(?:s?html?|xhtml))$/i
                    );


            if (pathMatch) {

                const [
                    ,
                    prefix,
                    separator,
                    rawNumber,
                    extension
                ] = pathMatch;


                const unnumberedPath =
                    `${prefix}${extension}`;


                let hasPaginationEvidence =
                    false;


                for (
                    const seenUrl
                    of visited
                ) {

                    try {

                        const seen =
                            new URL(seenUrl);


                        if (
                            seen.origin !==
                            currentUrl.origin
                        ) {

                            continue;
                        }


                        if (
                            seen.pathname ===
                            unnumberedPath ||
                            seen.pathname.match(
                                new RegExp(
                                    `^${prefix.replace(
                                        /[.*+?^${}()|[\]\\]/g,
                                        '\\$&'
                                    )}${separator}\\d+${extension.replace(
                                        '.',
                                        '\\.'
                                    )}$`,
                                    'i'
                                )
                            ) &&
                            seen.pathname !==
                            currentUrl.pathname
                        ) {

                            hasPaginationEvidence =
                                true;

                            break;
                        }

                    } catch (_) {}
                }


                if (hasPaginationEvidence) {

                    const inferred =
                        new URL(
                            currentUrl.href
                        );


                    inferred.pathname =
                        `${prefix}${separator}${
                            Number(rawNumber) +
                            1
                        }${extension}`;


                    if (
                        !visited.has(
                            inferred.href
                        )
                    ) {

                        return inferred.href;
                    }
                }
            }

        } catch (_) {}


        return '';
    }


    function charsetFromResponse(
        rawHeaders,
        bytes
    ) {

        const headers =
            parseHeaders(
                rawHeaders
            );


        const headerMatch =
            String(
                headers[
                    'content-type'
                ] ||
                ''
            )
                .match(
                    /charset\s*=\s*["']?([^;\s"']+)/i
                );


        const prefix =
            decodeBytes(
                bytes.slice(
                    0,
                    8192
                ),
                'windows-1252'
            );


        const metaMatch =
            prefix.match(
                /<meta[^>]+charset\s*=\s*["']?\s*([^\s"'/>;]+)/i
            ) ||
            prefix.match(
                /<meta[^>]+content\s*=\s*["'][^"']*charset\s*=\s*([^\s"';/>]+)/i
            );


        return normalizeCharset(
            metaMatch?.[1] ||
            headerMatch?.[1] ||
            ''
        );
    }


    function decodeHtmlBuffer(
        buffer,
        rawHeaders
    ) {

        const bytes =
            buffer instanceof Uint8Array
                ? buffer
                : ArrayBuffer.isView(
                    buffer
                )
                    ? new Uint8Array(
                        buffer.buffer,
                        buffer.byteOffset,
                        buffer.byteLength
                    )
                    : new Uint8Array(
                        buffer
                    );


        if (
            bytes.length >= 3 &&
            bytes[0] === 0xef &&
            bytes[1] === 0xbb &&
            bytes[2] === 0xbf
        ) {

            return decodeBytes(
                bytes,
                'utf-8'
            );
        }


        // 严格 UTF-8 可成功解码时优先采用,避免把正常 UTF-8 误判成 GBK。
        const strictUtf8 =
            decodeBytes(
                bytes,
                'utf-8',
                true
            );


        if (strictUtf8) {

            return strictUtf8;
        }


        const declaredCharset =
            charsetFromResponse(
                rawHeaders,
                bytes
            );


        const candidates = [

            declaredCharset
                ? decodeBytes(
                    bytes,
                    declaredCharset
                )
                : '',

            decodeBytes(
                bytes,
                'gb18030'
            ),

            decodeBytes(
                bytes,
                'utf-8'
            )
        ]
            .filter(Boolean);


        return candidates
            .sort(
                (
                    a,
                    b
                ) =>
                    textQualityScore(b) -
                    textQualityScore(a)
            )[0] ||
            '';
    }


    function responseTextDecoded(res) {

        try {

            if (
                res.response &&
                (
                    res.response instanceof ArrayBuffer ||
                    ArrayBuffer.isView(
                        res.response
                    ) ||
                    Object.prototype.toString.call(
                        res.response
                    ) === '[object ArrayBuffer]'
                )
            ) {

                return decodeHtmlBuffer(
                    res.response,
                    res.responseHeaders
                );
            }

        } catch (_) {}


        return String(
            res.responseText ||
            res.response ||
            ''
        );
    }


    function requestText(url) {

        return new Promise(
            (
                resolve,
                reject
            ) => {

                GM_xmlhttpRequest({

                    method:
                        'GET',

                    url,

                    timeout:
                        20000,

                    responseType:
                        'arraybuffer',

                    anonymous:
                        false,


                    onload:
                        res => {

                            if (
                                res.status >= 200 &&
                                res.status < 400
                            ) {

                                resolve({

                                    text:
                                        responseTextDecoded(
                                            res
                                        ),

                                    finalUrl:
                                        res.finalUrl ||
                                        url,

                                    responseHeaders:
                                        res.responseHeaders ||
                                        ''
                                });

                            } else {

                                reject(

                                    new Error(
                                        `HTTP ${res.status}`
                                    )
                                );
                            }
                        },


                    onerror:
                        () =>

                            reject(

                               new Error(
                                    t(
                                        'networkFailed'
                                    )
                                )
                            ),


                    ontimeout:
                        () =>

                            reject(

                               new Error(
                                    t(
                                        'requestTimeout'
                                    )
                                )
                            )
                });
            }
        );
    }


    /**********************************************************************
     * 8. 列表分页与一至三级网页嗅探
     *
     * 第一级为当前列表页及其后续分页;每个列表页分别挑选最多 N 个
     * 二级页,每个二级页再分别挑选最多 N 个三级页。
     **********************************************************************/

    function isSameOriginUrl(
        url,
        rootUrl
    ) {

        try {

            return new URL(url).origin ===
                new URL(rootUrl).origin;

        } catch (_) {

            return false;
        }
    }


    function looksLikeHtmlPageUrl(url) {

        try {

            const pathname =
                safeDecode(
                    new URL(url).pathname
                )
                    .toLowerCase();


            const match =
                pathname.match(
                    /\.([a-z0-9]{1,10})$/i
                );


            // 没有扩展名的链接通常是路由页或动态详情页。
            if (!match) {

                return true;
            }


            return [
                'htm',
                'html',
                'shtml',
                'xhtml',
                'php',
                'asp',
                'aspx',
                'jsp',
                'jspx',
                'do',
                'action'
            ]
                .includes(
                    match[1]
                );

        } catch (_) {

            return false;
        }
    }


    function isSiteChromeAnchor(a) {

        if (
            a.closest?.(
                'header,footer,nav,[role="navigation"],[role="banner"],[role="contentinfo"]'
            )
        ) {

            return true;
        }


        let node =
            a.parentElement;


        for (
            let level = 0;
            node &&
            level < 5;
            level++,
            node = node.parentElement
        ) {

            const marker =
                `${node.id || ''} ${
                    typeof node.className === 'string'
                        ? node.className
                        : ''
                }`;


            if (
                /(?:^|[\s_-])(nav|menu|breadcrumb|crumb|footer|header|sidebar|share|login|pagination|pager)(?:$|[\s_-])/i.test(
                    marker
                )
            ) {

                return true;
            }
        }


        return false;
    }


    function sniffLinkScore(
        a,
        url
    ) {

        const text =
            normalizeSpace(
                a.innerText ||
                a.textContent ||
                a.getAttribute(
                    'title'
                ) ||
                ''
            );


        let score =
            0;


        if (
            text.length >= 4 &&
            text.length <= 100
        ) {

            score +=
                3;
        }


        if (
            CHINESE_RE.test(
                text
            )
        ) {

            score +=
                3;
        }


        if (
            /(detail|article|content|info|view|show|news|notice|public|gongshi|gg|xxgk|id=)/i.test(
                safeDecode(url)
            )
        ) {

            score +=
                4;
        }


        if (
            a.closest?.(
                'main,article,[role="main"],.main,.content,.article,.list,.news-list,.notice-list'
            )
        ) {

            score +=
                5;
        }


        if (
            isInvalidText(
                text
            )
        ) {

            score -=
                2;
        }


        return score;
    }


    function collectSniffPageLinks(
        doc,
        baseUrl,
        rootUrl
    ) {

        const byUrl =
            new Map();


        let order =
            0;


        for (
            const a
            of doc.querySelectorAll(
                'a'
            )
        ) {

            if (
                a.closest?.(
                    '#wra-helper-panel,#wra-idm-plugin-sheet'
                ) ||
                isSiteChromeAnchor(a)
            ) {

                continue;
            }


            const url =
                extractUrlFromAnchor(
                    a,
                    baseUrl
                );


            if (
                !url ||
                !isSameOriginUrl(
                    url,
                    rootUrl
                ) ||
                !looksLikeHtmlPageUrl(
                    url
                ) ||
                isAttachmentCandidate(
                    a,
                    url
                )
            ) {

                continue;
            }


            const text =
                normalizeSpace(
                    a.innerText ||
                    a.textContent ||
                    a.getAttribute(
                        'title'
                    ) ||
                    ''
                );


            if (
                isNavigationText(
                    text
                ) ||
                /(?:^|[\/?#&=_-])(logout|login|signin|signup|register|search|print|share|comment)(?:$|[\/?#&=_-])/i.test(
                    safeDecode(url)
                )
            ) {

                continue;
            }


            const candidate = {

                url,

                score:
                    sniffLinkScore(
                        a,
                        url
                    ),

                order:
                    order++
            };


            const existing =
                byUrl.get(url);


            if (
                !existing ||
                candidate.score >
                existing.score
            ) {

                byUrl.set(
                    url,
                    candidate
                );
            }
        }


        return [
            ...byUrl.values()
        ]

            .sort(
                (
                    a,
                    b
                ) =>
                    b.score -
                    a.score ||
                    a.order -
                    b.order
            )

            .slice(
                0,
                CONFIG.maxSniffLinksPerPage
            )

            .map(
                x =>
                    x.url
            );
    }


    function addChildPageJobs(
        parents,
        depth,
        pagesPerParent,
        rootUrl,
        knownPageUrls
    ) {

        const jobs = [];


        for (const parent of parents) {

            let addedForParent = 0;


            const candidates =
                collectSniffPageLinks(
                    parent.doc,
                    parent.url,
                    rootUrl
                );


            for (const url of candidates) {

                if (
                    knownPageUrls.has(url)
                ) {

                    continue;
                }


                knownPageUrls.add(url);


                jobs.push({
                    url,
                    parentUrl:
                        parent.url,
                    depth
                });


                addedForParent++;


                if (
                    addedForParent >=
                    pagesPerParent
                ) {

                    break;
                }
            }
        }


        return jobs;
    }


    async function fetchSniffPageJobs(
        jobs,
        depth,
        rootUrl,
        resolvedPageUrls,
        progress
    ) {

        const results =
            new Array(
                jobs.length
            );


        let cursor = 0;


        let done = 0;


        async function worker() {

            while (
                cursor <
                jobs.length
            ) {

                const index =
                    cursor++;


                const job =
                    jobs[index];


               setStatus(
                    t(
                        'sniffDepthProgress',
                        {
                            depth,
                            current:
                                Math.min(
                                    done +
                                    CONFIG.sniffConcurrency,
                                    jobs.length
                                ),
                            total:
                                jobs.length
                        }
                    )
               );


                try {

                    const res =
                        await requestText(
                            job.url
                        );


                    const pageUrl =
                        normalizeUrl(
                            res.finalUrl ||
                            job.url,
                            job.url
                        ) ||
                        job.url;


                    if (
                        !isSameOriginUrl(
                            pageUrl,
                            rootUrl
                        ) ||
                        resolvedPageUrls.has(
                            pageUrl
                        )
                    ) {

                        continue;
                    }


                    resolvedPageUrls.add(
                        pageUrl
                    );


                    const pageDoc =
                        new DOMParser()
                            .parseFromString(
                                res.text,
                                'text/html'
                            );


                    const items =
                        extractItemsFromDocument(
                            pageDoc,
                            pageUrl,
                            pageUrl,
                            depth
                        );


                    mergeItems(items);


                    results[index] = {
                        url:
                            pageUrl,
                        doc:
                            pageDoc,
                        parentUrl:
                            job.parentUrl
                    };

                } catch (err) {

                    progress.failed++;


                    console.warn(
                        '[IDM下载助手] 网页嗅探跳过:',
                        job.url,
                        err
                    );

                } finally {

                    done++;

                    progress.processed++;


                    if (
                        done % 2 === 0 ||
                        done ===
                        jobs.length
                    ) {

                        renderResults();

                        updateSelectionCount();
                    }
                }
            }
        }


        await Promise.all(
            Array.from(
                {
                    length:
                        Math.min(
                            CONFIG.sniffConcurrency,
                            jobs.length
                        )
                },
                () =>
                    worker()
            )
        );


        return results
            .filter(Boolean);
    }


    async function startSniffing(
        maxListPages,
        maxDepth,
        pagesPerParent
    ) {

        if (state.scanning) {

           setStatus(
                t(
                    'scanAlreadyRunning'
                )
            );

            return;
        }


        const requestedListPages =
            Number(maxListPages);


        maxListPages =
            Number.isFinite(
                requestedListPages
            )
                ? Math.max(
                    1,
                    Math.floor(
                        requestedListPages
                    )
                )
                : 1;


        maxDepth =
            Math.max(
                1,
                Math.min(
                    3,
                    Number(maxDepth) ||
                    1
                )
            );


        pagesPerParent =
            Math.max(
                1,
                Math.floor(
                    Number(pagesPerParent) ||
                    20
                )
            );


        const rootUrl =
            normalizeUrl(
                location.href
            );


        if (!rootUrl) {

           setStatus(
                t(
                    'sniffInvalidUrl'
                )
            );

            return;
        }


        state.scanning = true;


        const existingUrls =
            new Set(
                state.byUrl.keys()
            );


        const listPages = [];


        const listPageUrls =
            new Set();


        const listVisitedUrls =
            new Set();


        const progress = {
            processed: 0,
            failed: 0
        };


        let level2Pages = [];


        let level3Pages = [];


        let listStopNote = '';


        try {

            let currentDoc =
                document;


            let currentUrl =
                rootUrl;


            for (
                let pageNo = 1;
                pageNo <= maxListPages;
                pageNo++
            ) {

                if (
                    listPageUrls.has(
                        currentUrl
                    )
                ) {

                   listStopNote =
                        t(
                            'sniffStopDuplicate'
                        );

                    break;
                }


                listPageUrls.add(
                    currentUrl
                );


                listVisitedUrls.add(
                    currentUrl
                );


                listPages.push({
                    url:
                        currentUrl,
                    doc:
                        currentDoc
                });


               setStatus(
                    t(
                        'sniffListProgress',
                        {
                            current:
                                pageNo,
                            total:
                                maxListPages
                        }
                    )
                );


                mergeItems(
                    extractItemsFromDocument(
                        currentDoc,
                        currentUrl,
                        currentUrl,
                        1
                    )
                );


                renderResults();


                if (
                    pageNo >=
                    maxListPages
                ) {

                    break;
                }


                const nextUrl =
                    findNextPageUrl(
                        currentDoc,
                        currentUrl,
                        listVisitedUrls
                    );


                if (!nextUrl) {

                   listStopNote =
                        t(
                            'sniffStopNoNext'
                        );

                    break;
                }


                if (
                    !isSameOriginUrl(
                        nextUrl,
                        rootUrl
                    )
                ) {

                   listStopNote =
                        t(
                            'sniffStopOffsite'
                        );

                    break;
                }


                listVisitedUrls.add(
                    nextUrl
                );


                try {

                    const res =
                        await requestText(
                            nextUrl
                        );


                    const finalUrl =
                        normalizeUrl(
                            res.finalUrl ||
                            nextUrl,
                            nextUrl
                        ) ||
                        nextUrl;


                    if (
                        !isSameOriginUrl(
                            finalUrl,
                            rootUrl
                        )
                    ) {

                       listStopNote =
                            t(
                                'sniffStopOffsite'
                            );

                        break;
                    }


                    currentDoc =
                        new DOMParser()
                            .parseFromString(
                                res.text,
                                'text/html'
                            );


                    currentUrl =
                        finalUrl;

                } catch (err) {

                    progress.failed++;


                   listStopNote =
                        t(
                            'sniffStopRequestFailed',
                            {
                                page:
                                    listPages.length +
                                    1
                            }
                        );


                    console.warn(
                        '[IDM下载助手] 列表分页读取停止:',
                        nextUrl,
                        err
                    );


                    break;
                }
            }


            const knownPageUrls =
                new Set(
                    listVisitedUrls
                );


            const resolvedPageUrls =
                new Set(
                    listPageUrls
                );


            if (
                maxDepth >= 2
            ) {

                const level2Jobs =
                    addChildPageJobs(
                        listPages,
                        2,
                        pagesPerParent,
                        rootUrl,
                        knownPageUrls
                    );


                level2Pages =
                    await fetchSniffPageJobs(
                        level2Jobs,
                        2,
                        rootUrl,
                        resolvedPageUrls,
                        progress
                    );


                level2Pages.forEach(
                    page =>
                        knownPageUrls.add(
                            page.url
                        )
                );
            }


            if (
                maxDepth >= 3 &&
                level2Pages.length
            ) {

                const level3Jobs =
                    addChildPageJobs(
                        level2Pages,
                        3,
                        pagesPerParent,
                        rootUrl,
                        knownPageUrls
                    );


                level3Pages =
                    await fetchSniffPageJobs(
                        level3Jobs,
                        3,
                        rootUrl,
                        resolvedPageUrls,
                        progress
                    );
            }


            const newItems =
                state.items
                    .filter(
                        item =>
                            !existingUrls.has(
                                item.url
                            )
                    );


           setStatus(
                t(
                    'sniffMetadata'
                )
            );


            await enrichMissingMetadata(
                newItems
            );


            renderResults();

            updateSelectionCount();


           setStatus(
                t(
                    'sniffComplete',
                    {
                        listDone:
                            listPages.length,
                        listTarget:
                            maxListPages,
                        level2:
                            level2Pages.length,
                        level3:
                            level3Pages.length,
                        added:
                            newItems.length,
                        total:
                            state.items.length,
                        failedPart:
                            progress.failed
                                ? t(
                                    'sniffFailedPart',
                                    {
                                        failed:
                                            progress.failed
                                    }
                                )
                                : '',
                        stopNote:
                            listStopNote,
                        limit:
                            pagesPerParent
                    }
                )
           );

        } catch (err) {

            console.error(
                '[IDM下载助手] 网页嗅探失败:',
                err
            );


            renderResults();

            updateSelectionCount();


           setStatus(
                t(
                    'sniffAborted',
                    {
                        error:
                            err.message ||
                            err,
                        total:
                            state.items.length
                    }
                )
           );

        } finally {

            state.scanning = false;
        }
    }


    /**********************************************************************
     * 9. 勾选及文本筛选
     **********************************************************************/

    function selectedItems() {

        return state.items
            .filter(
                x =>
                    x.selected
            );
    }


    function itemSearchText(item) {

        return [

            item.filename,

            item.originalText,

            item.projectTitle,

            item.sectionTitle,

           item.sourcePage,

           item.sourceDepth
                ? t(
                    'depthPage',
                    {
                        depth:
                            item.sourceDepth
                    }
                )
                : '',

            localizeNameSource(
                item.nameSource
            )
        ]

            .filter(Boolean)

            .join(' ');
    }


    function selectAll(
        value = true
    ) {

        state.items
            .forEach(
                x => {

                    x.selected =
                        value;
                }
            );


        renderResults();

        updateSelectionCount();
    }


    function invertSelection() {

        state.items
            .forEach(
                x => {

                    x.selected =
                        !x.selected;
                }
            );


        renderResults();

        updateSelectionCount();
    }


    function applyTextFilter(text) {

        state.filterText =
            normalizeSpace(
                text
            )
                .toLowerCase();


        renderResults();
    }


    function selectVisible() {

        let count =
            0;


        state.items
            .forEach(
                item => {

                    const visible =

                        !state.filterText ||

                        itemSearchText(item)
                            .toLowerCase()
                            .includes(
                                state.filterText
                            );


                    // “勾选筛选结果”表示只保留当前可见结果。
                    item.selected =
                        visible;


                    if (visible) {

                        count++;
                    }
                }
            );


        renderResults();

        updateSelectionCount();


       setStatus(

            t(
                'selectFilteredStatus',
                {
                    count
                }
            )

       );
    }


    function refreshFilenameAffixes() {

        applyFilenameAffixesToAll();


        renderResults();

        updateSelectionCount();


        const enabled = [];


        if (
            state.filenameAffixes
                .prefixEnabled
        ) {

            const prefix =
                sanitizeFilenameAffix(
                    state.filenameAffixes
                        .prefixText
                );


           enabled.push(
               prefix
                    ? t(
                        'prefixValue',
                        {
                            value:
                                prefix.length > 28
                                    ? `${prefix.slice(0, 28)}…`
                                    : prefix
                        }
                    )
                    : t(
                        'prefixEmpty'
                    )
            );
        }


        if (
            state.filenameAffixes
                .suffixEnabled
        ) {

            const suffix =
                sanitizeFilenameAffix(
                    state.filenameAffixes
                        .suffixText
                );


           enabled.push(
               suffix
                    ? t(
                        'suffixValue',
                        {
                            value:
                                suffix.length > 28
                                    ? `${suffix.slice(0, 28)}…`
                                    : suffix
                        }
                    )
                    : t(
                        'suffixEmpty'
                    )
            );
        }


       setStatus(
           enabled.length
                ? t(
                    'affixEnabled',
                    {
                        enabled:
                            enabled.join(
                                t(
                                    'listSeparator'
                                )
                            ),
                        count:
                            state.items.length
                    }
                )
                : t(
                    'affixDisabled'
                )
       );
    }


    /**********************************************************************
     * 9.5. 外部链接
     **********************************************************************/

    function openExternalUrl(url) {

        const targetUrl =
            String(
                url ||
                ''
            )
                .trim();


        if (
            !/^https?:\/\//i.test(
                targetUrl
            )
        ) {

            return false;
        }


        try {

            if (
                typeof GM_openInTab ===
                'function'
            ) {

                GM_openInTab(
                    targetUrl,
                    {
                        active: true,
                        insert: true,
                        setParent: true
                    }
                );

                return true;
            }

        } catch (_) {}


        try {

            window.open(
                targetUrl,
                '_blank',
                'noopener,noreferrer'
            );

        } catch (_) {

            location.href =
                targetUrl;
        }


        return true;
    }


    function openSupportAuthor() {

        openExternalUrl(
            CONFIG.supportUrl
        );
    }


    function openHelpPage() {

        const helpUrl =
            CONFIG.helpUrls
                ? (
                    CONFIG.helpUrls[
                        UI_LANGUAGE
                    ] ||
                    CONFIG.helpUrls.en
                )
                : '';


        if (
            !openExternalUrl(
                helpUrl
            )
        ) {

            setStatus(
                t(
                    'helpUrlMissing'
                )
            );
        }
    }


    /**********************************************************************
     * 10. IDM
     **********************************************************************/

    function getIdmText(
        items =
        selectedItems()
    ) {

        return items

            .map(
                x =>
                    `${x.filename}\t${x.url}`
            )

            .join(
                '\r\n'
            );
    }


    function getIdmClipboardPayload(items) {

        const plainText =
            items
                .map(
                    item =>
                        `${item.url}\t${sanitizeWindowsFilename(
                            item.filename
                        )}`
                )
                .join('\r\n');


        const html =
            `<div data-idm-download-list="1">${items
                .map(
                    item => {

                        const filename =
                            sanitizeWindowsFilename(
                                item.filename
                            );


                        return `<div><a href="${escapeHtml(
                            item.url
                        )}" download="${escapeHtml(
                            filename
                        )}" title="${escapeHtml(
                            filename
                        )}">${escapeHtml(
                            filename
                        )}</a></div>`;
                    }
                )
                .join('')}</div>`;


        return {
            plainText,
            html
        };
    }


    function copyRichClipboardByEvent(
        plainText,
        html
    ) {

        let copied = false;


        const onCopy =
            event => {

                if (
                    !event.clipboardData
                ) {

                    return;
                }


                event.preventDefault();


                event.clipboardData
                    .setData(
                        'text/plain',
                        plainText
                    );


                event.clipboardData
                    .setData(
                        'text/html',
                        html
                    );


                copied = true;
            };


        document.addEventListener(
            'copy',
            onCopy,
            true
        );


        try {

            document.execCommand(
                'copy'
            );

        } finally {

            document.removeEventListener(
                'copy',
                onCopy,
                true
            );
        }


        return copied;
    }


    async function copySelectedToIdm() {

        const items =
            selectedItems();


        if (
            !items.length
        ) {

           return setStatus(
                t('noSelected')
            );
        }


        const {
            plainText,
            html
        } =
            getIdmClipboardPayload(
                items
            );


        let copiedRich = false;


        try {

            if (
                navigator.clipboard?.write &&
                typeof ClipboardItem !==
                'undefined'
            ) {

                await navigator.clipboard
                    .write([
                        new ClipboardItem({
                            'text/plain':
                                new Blob(
                                    [plainText],
                                    {
                                        type:
                                            'text/plain;charset=utf-8'
                                    }
                                ),
                            'text/html':
                                new Blob(
                                    [html],
                                    {
                                        type:
                                            'text/html;charset=utf-8'
                                    }
                                )
                        })
                    ]);


                copiedRich = true;
            }

        } catch (_) {

            copiedRich = false;
        }


        if (!copiedRich) {

            try {

                copiedRich =
                    copyRichClipboardByEvent(
                        plainText,
                        html
                    );

            } catch (_) {

                copiedRich = false;
            }
        }


       if (copiedRich) {

           setStatus(
                t(
                    'copyRich',
                    {
                        count:
                            items.length
                    }
                )
            );


            return;
        }


        try {

            GM_setClipboard(
                plainText,
                'text'
            );


           setStatus(
                t(
                    'copyPlainFallback',
                    {
                        count:
                            items.length
                    }
                )
            );

        } catch (_) {

            navigator.clipboard

                .writeText(
                    plainText
                )

                .then(

                    () =>

                       setStatus(

                            t(
                                'copyPlain',
                                {
                                    count:
                                        items.length
                                }
                            )

                        ),

                    () =>

                       setStatus(

                            t(
                                'copyFailed'
                            )

                        )
                );
        }
    }


    /**********************************************************************
     * IDM 浏览器插件没有向网页公开“下载全部链接”的调用接口。
     * 这里准备一个只包含选中附件的链接区并自动选中;用户在链接区
     * 使用 IDM 插件的“下载选择的链接”右键命令,即可打开 IDM 原生
     * 批量链接筛选窗口。
     **********************************************************************/

    function selectIdmPluginLinks(container) {

        if (!container) {

            return;
        }


        const selection =
            window.getSelection();


        if (!selection) {

            return;
        }


        const range =
            document.createRange();


        const anchors =
            container.querySelectorAll(
                'a'
            );


        if (
            !anchors.length
        ) {

            return;
        }


        range.setStartBefore(
            anchors[0]
        );

        range.setEndAfter(
            anchors[
                anchors.length - 1
            ]
        );


        selection.removeAllRanges();

        selection.addRange(
            range
        );
    }


    function closeIdmPluginSheet() {

        document.getElementById(
            'wra-idm-plugin-sheet'
        )
            ?.remove();


        window.getSelection()
            ?.removeAllRanges();
    }


    function idmDownloadSelected() {

        const items =
            selectedItems();


        if (
            !items.length
        ) {

           return setStatus(
                t('noSelected')
            );
        }


        closeIdmPluginSheet();


        const sheet =
            document.createElement(
                'div'
            );


        sheet.id =
            'wra-idm-plugin-sheet';


        sheet.lang =
            UI_LANGUAGE;


        applyThemeToElement(
            sheet
        );


        sheet.innerHTML = `

<div id="wra-idm-plugin-dialog">

    <div id="wra-idm-plugin-head">

       <div>
            ${escapeHtml(t('idmSheetTitle'))}
       </div>

       <button
           id="wra-idm-plugin-close"
            title="${escapeHtml(t('close'))}"
        >
            ×
        </button>

    </div>


   <div id="wra-idm-plugin-guide">

        ${t(
            'idmGuide',
            {
                count:
                    items.length
            }
        )}

    </div>


    <div id="wra-idm-plugin-links">

        ${items
            .map(
                (
                    item,
                    index
                ) => `

<a
    href="${escapeHtml(item.url)}"
    download="${escapeHtml(item.filename)}"
    title="${escapeHtml(item.filename)}"
>
    ${index + 1}. ${escapeHtml(item.filename)}
</a>`
            )
            .join('')}

    </div>


    <div id="wra-idm-plugin-actions">

       <button id="wra-idm-plugin-reselect">
            ${escapeHtml(t('idmReselect'))}
       </button>

       <button id="wra-idm-plugin-cancel">
            ${escapeHtml(t('close'))}
        </button>

    </div>

</div>`;


        document.body
            .appendChild(
                sheet
            );


        const links =
            sheet.querySelector(
                '#wra-idm-plugin-links'
            );


        const reselect =
            () =>
                selectIdmPluginLinks(
                    links
                );


        links.addEventListener(
            'click',
            event => {

                if (
                    event.target.closest(
                        'a'
                    )
                ) {

                    event.preventDefault();

                    reselect();
                }
            }
        );


        // 右键发生时再次恢复选区,但不阻止浏览器原生菜单。
        links.addEventListener(
            'mousedown',
            event => {

                if (
                    event.button === 2
                ) {

                    reselect();
                }
            },
            true
        );


        links.addEventListener(
            'contextmenu',
            reselect,
            true
        );


        sheet
            .querySelector(
                '#wra-idm-plugin-reselect'
            )
            .addEventListener(
                'click',
                reselect
            );


        for (
            const selector
            of [
                '#wra-idm-plugin-close',
                '#wra-idm-plugin-cancel'
            ]
        ) {

            sheet
                .querySelector(
                    selector
                )
                .addEventListener(
                    'click',
                    closeIdmPluginSheet
                );
        }


        reselect();


        requestAnimationFrame(
            reselect
        );


       setStatus(
            t(
                'idmReady',
                {
                    count:
                        items.length
                }
            )
       );
    }


    /**********************************************************************
     * 11. TXT / CSV / Excel
     **********************************************************************/

    function timestampName() {

        const d =
            new Date();


        const pad =
            n =>
                String(n)
                    .padStart(
                        2,
                        '0'
                    );


        return (
            `${d.getFullYear()}` +

            `${pad(d.getMonth() + 1)}` +

            `${pad(d.getDate())}_` +

            `${pad(d.getHours())}` +

            `${pad(d.getMinutes())}` +

            `${pad(d.getSeconds())}`
        );
    }


    function downloadBlob(
        content,
        filename,
        mime
    ) {

        const blob =
            content instanceof Blob

                ? content

                : new Blob(

                    [
                        content
                    ],

                    {
                        type:
                            mime
                    }
                );


        const url =
            URL.createObjectURL(
                blob
            );


        const a =
            document.createElement(
                'a'
            );


        a.href =
            url;


        a.download =
            filename;


        document.body
            .appendChild(
                a
            );


        a.click();


        a.remove();


        setTimeout(

            () =>
                URL.revokeObjectURL(
                    url
                ),

            3000
        );
    }


    function exportTxt() {

        const items =
            selectedItems();


        if (
            !items.length
        ) {

           return setStatus(
                t('noSelected')
            );
        }


        downloadBlob(

            '\uFEFF' +
            getIdmText(
                items
            ),

            `${t('exportBase')}_${timestampName()}.txt`,

            'text/plain;charset=utf-8'
        );


       setStatus(

            t(
                'exportedTxt',
                {
                    count:
                        items.length
                }
            )

        );
    }


    function csvCell(value) {

        return (

            '"' +

            String(
                value ??
                ''
            )
                .replace(
                    /"/g,
                    '""'
                ) +

            '"'
        );
    }


    function exportCsv() {

        const items =
            selectedItems();


        if (
            !items.length
        ) {

           return setStatus(
                t('noSelected')
            );
        }


        const rows = [

           [
                t('filenameHeader'),
                t('urlHeader'),
                t('sourcePageHeader'),
                t('depthHeader'),
                t('projectHeader'),
                t('sectionHeader'),
                t('basisHeader')
            ],

            ...items.map(
                x => [

                    x.filename,

                    x.url,

                    x.sourcePage,

                    x.sourceDepth ||
                    1,

                    x.projectTitle,

                    x.sectionTitle,

                    localizeNameSource(
                        x.nameSource
                    )
                ]
            )
        ];


        const csv =
            '\uFEFF' +

            rows

                .map(
                    row =>
                        row

                            .map(
                                csvCell
                            )

                            .join(',')
                )

                .join(
                    '\r\n'
                );


        downloadBlob(

            csv,

            `${t('exportBase')}_${timestampName()}.csv`,

            'text/csv;charset=utf-8'
        );


       setStatus(

            t(
                'exportedCsv',
                {
                    count:
                        items.length
                }
            )

        );
    }


    /**********************************************************************
     * 以下为纯JS生成真正的XLSX
     * 不依赖SheetJS/CDN。
     **********************************************************************/

    function xmlEscape(value) {

        return String(
            value ??
            ''
        )

            .replace(
                /&/g,
                '&amp;'
            )

            .replace(
                /</g,
                '&lt;'
            )

            .replace(
                />/g,
                '&gt;'
            )

            .replace(
                /"/g,
                '&quot;'
            )

            .replace(
                /'/g,
                '&apos;'
            );
    }


    const CRC32_TABLE =
        (() => {

            const table =
                new Uint32Array(
                    256
                );


            for (
                let n = 0;
                n < 256;
                n++
            ) {

                let c =
                    n;


                for (
                    let k = 0;
                    k < 8;
                    k++
                ) {

                    c =
                        (
                            c &
                            1
                        )

                            ? (
                                0xEDB88320 ^
                                (
                                    c >>>
                                    1
                                )
                            )

                            : (
                                c >>>
                                1
                            );
                }


                table[n] =
                    c >>>
                    0;
            }


            return table;
        })();


    function crc32(bytes) {

        let c =
            0xFFFFFFFF;


        for (
            const b
            of bytes
        ) {

            c =
                CRC32_TABLE[
                    (
                        c ^
                        b
                    ) &
                    0xFF
                ] ^
                (
                    c >>>
                    8
                );
        }


        return (
            c ^
            0xFFFFFFFF
        ) >>>
        0;
    }


    function u16(n) {

        return [

            n &
            0xFF,

            (
                n >>>
                8
            ) &
            0xFF
        ];
    }


    function u32(n) {

        return [

            n &
            0xFF,

            (
                n >>>
                8
            ) &
            0xFF,

            (
                n >>>
                16
            ) &
            0xFF,

            (
                n >>>
                24
            ) &
            0xFF
        ];
    }


    function dosDateTime(
        date =
        new Date()
    ) {

        const year =
            Math.max(
                1980,
                date.getFullYear()
            );


        return {

            dosTime:

                (
                    (
                        date.getHours() &
                        0x1F
                    ) <<
                    11
                ) |

                (
                    (
                        date.getMinutes() &
                        0x3F
                    ) <<
                    5
                ) |

                (
                    Math.floor(
                        date.getSeconds() /
                        2
                    ) &
                    0x1F
                ),


            dosDate:

                (
                    (
                        (
                            year -
                            1980
                        ) &
                        0x7F
                    ) <<
                    9
                ) |

                (
                    (
                        (
                            date.getMonth() +
                            1
                        ) &
                        0x0F
                    ) <<
                    5
                ) |

                (
                    date.getDate() &
                    0x1F
                )
        };
    }


    function buildZipStore(files) {

        const enc =
            new TextEncoder();


        const now =
            dosDateTime();


        const localParts =
            [];


        const centralParts =
            [];


        let offset =
            0;


        let centralSize =
            0;


        for (
            const file
            of files
        ) {

            const nameBytes =
                enc.encode(
                    file.name
                );


            const dataBytes =
                typeof file.data ===
                'string'

                    ? enc.encode(
                        file.data
                    )

                    : file.data;


            const crc =
                crc32(
                    dataBytes
                );


            const size =
                dataBytes.length;


            const flags =
                0x0800;


            const localHeader =
                new Uint8Array([

                    ...u32(
                        0x04034B50
                    ),

                    ...u16(20),

                    ...u16(flags),

                    ...u16(0),

                    ...u16(
                        now.dosTime
                    ),

                    ...u16(
                        now.dosDate
                    ),

                    ...u32(crc),

                    ...u32(size),

                    ...u32(size),

                    ...u16(
                        nameBytes.length
                    ),

                    ...u16(0)
                ]);


            localParts.push(

                localHeader,

                nameBytes,

                dataBytes
            );


            const centralHeader =
                new Uint8Array([

                    ...u32(
                        0x02014B50
                    ),

                    ...u16(20),

                    ...u16(20),

                    ...u16(flags),

                    ...u16(0),

                    ...u16(
                        now.dosTime
                    ),

                    ...u16(
                        now.dosDate
                    ),

                    ...u32(crc),

                    ...u32(size),

                    ...u32(size),

                    ...u16(
                        nameBytes.length
                    ),

                    ...u16(0),

                    ...u16(0),

                    ...u16(0),

                    ...u16(0),

                    ...u32(0),

                    ...u32(
                        offset
                    )
                ]);


            centralParts.push(

                centralHeader,

                nameBytes
            );


            offset +=

                localHeader.length +

                nameBytes.length +

                size;


            centralSize +=

                centralHeader.length +

                nameBytes.length;
        }


        const end =
            new Uint8Array([

                ...u32(
                    0x06054B50
                ),

                ...u16(0),

                ...u16(0),

                ...u16(
                    files.length
                ),

                ...u16(
                    files.length
                ),

                ...u32(
                    centralSize
                ),

                ...u32(
                    offset
                ),

                ...u16(0)
            ]);


        return new Blob(

            [
                ...localParts,

                ...centralParts,

                end
            ],

            {
                type:
                    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
            }
        );
    }


    function excelColumnName(index) {

        let n =
            index +
            1;


        let name =
            '';


        while (
            n > 0
        ) {

            const rem =
                (
                    n -
                    1
                ) %
                26;


            name =
                String.fromCharCode(
                    65 +
                    rem
                ) +
                name;


            n =
                Math.floor(
                    (
                        n -
                        1
                    ) /
                    26
                );
        }


        return name;
    }


    function buildXlsxBlob(rows) {

        const worksheetRows =
            rows

                .map(
                    (
                        row,
                        r
                    ) => {

                        const cells =
                            row

                                .map(
                                    (
                                        value,
                                        c
                                    ) => {

                                        const ref =
                                            `${excelColumnName(c)}${r + 1}`;


                                        return (

                                            `<c r="${ref}" t="inlineStr">` +

                                            `<is><t xml:space="preserve">` +

                                            `${xmlEscape(value)}` +

                                            `</t></is></c>`

                                        );
                                    }
                                )

                                .join('');


                        return (

                            `<row r="${r + 1}">` +

                            `${cells}` +

                            `</row>`

                        );
                    }
                )

                .join('');


        const sheetXml =

            `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +

            `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +

            `<sheetData>${worksheetRows}</sheetData>` +

            `</worksheet>`;


        const files = [

            {
                name:
                    '[Content_Types].xml',

                data:

                    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +

                    `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +

                    `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +

                    `<Default Extension="xml" ContentType="application/xml"/>` +

                    `<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +

                    `<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +

                    `</Types>`
            },

            {
                name:
                    '_rels/.rels',

                data:

                    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +

                    `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +

                    `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +

                    `</Relationships>`
            },

            {
                name:
                    'xl/workbook.xml',

                data:

                    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +

                    `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ` +

                    `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +

                    `<sheets>` +

                    `<sheet name="${xmlEscape(
                        t(
                            'attachmentList'
                        )
                    )}" sheetId="1" r:id="rId1"/>` +

                    `</sheets>` +

                    `</workbook>`
            },

            {
                name:
                    'xl/_rels/workbook.xml.rels',

                data:

                    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +

                    `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +

                    `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +

                    `</Relationships>`
            },

            {
                name:
                    'xl/worksheets/sheet1.xml',

                data:
                    sheetXml
            }
        ];


        return buildZipStore(
            files
        );
    }


    function exportExcel() {

        const items =
            selectedItems();


        if (
            !items.length
        ) {

           return setStatus(
                t('noSelected')
            );
        }


        const rows = [

           [
                t('filenameHeader'),
                t('urlHeader'),
                t('sourcePageHeader'),
                t('depthHeader'),
                t('projectHeader'),
                t('sectionHeader'),
                t('basisHeader')
            ],

            ...items.map(
                x => [

                    x.filename,

                    x.url,

                    x.sourcePage,

                    x.sourceDepth ||
                    1,

                    x.projectTitle,

                    x.sectionTitle,

                    localizeNameSource(
                        x.nameSource
                    )
                ]
            )
        ];


        downloadBlob(

            buildXlsxBlob(
                rows
            ),

            `${t('exportBase')}_${timestampName()}.xlsx`,

            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
        );


       setStatus(

            t(
                'exportedXlsx',
                {
                    count:
                        items.length
                }
            )

        );
    }


    function clearResults() {

        closeIdmPluginSheet();


        state.items.length =
            0;


        state.byUrl
            .clear();


        renderResults();

        updateSelectionCount();


       setStatus(
            t(
                'cleared'
            )
       );
    }


    /**********************************************************************
     * 12. UI
     **********************************************************************/

    const themeMediaQuery =
        typeof window.matchMedia ===
        'function'
            ? window.matchMedia(
                '(prefers-color-scheme: dark)'
            )
            : null;


    function effectiveThemeMode() {

        if (
            state.themeMode ===
            'dark'
        ) {

            return 'dark';
        }


        if (
            state.themeMode ===
            'light'
        ) {

            return 'light';
        }


        return themeMediaQuery
            ?.matches
            ? 'dark'
            : 'light';
    }


    function applyThemeToElement(element) {

        if (!element) {

            return;
        }


        const dark =
            effectiveThemeMode() ===
            'dark';


        element.classList
            .toggle(
                'wra-theme-dark',
                dark
            );


        element.classList
            .toggle(
                'wra-theme-light',
                !dark
            );
    }


    function applyInterfaceTheme() {

        applyThemeToElement(
            document.getElementById(
                'wra-helper-panel'
            )
        );


        applyThemeToElement(
            document.getElementById(
                'wra-idm-plugin-sheet'
            )
        );
    }


    function measurePanelPosition(
        panel,
        preferredAnchor =
            null
    ) {

        const rect =
            panel.getBoundingClientRect();


        const left =
            Math.max(
                0,
                Math.round(
                    rect.left
                )
            );


        const right =
            Math.max(
                0,
                Math.round(
                    window.innerWidth -
                    rect.right
                )
            );


        const horizontalAnchor =
            [
                'left',
                'right'
            ].includes(
                preferredAnchor
            )
                ? preferredAnchor
                : right < left
                    ? 'right'
                    : 'left';


        return {
            horizontalAnchor,
            left:
                left,
            right:
                right,
            top:
                Math.max(
                    0,
                    Math.round(
                        rect.top
                    )
                )
        };
    }


    function persistPanelPosition(
        panel,
        preferredAnchor =
            null
    ) {

        state.panelPosition =
            measurePanelPosition(
                panel,
                preferredAnchor
            );


        writeStoredValue(
            STORAGE_KEYS.panelPosition,
            state.panelPosition
        );
    }


    function applyPanelPosition(
        panel,
        position
    ) {

        if (
            !position
        ) {

            return;
        }


        const targetWidth =
            state.panelCollapsed
                ? 48
                : panel.offsetWidth;


        const targetHeight =
            state.panelCollapsed
                ? 48
                : panel.offsetHeight;


        const maxLeft =
            Math.max(
                0,
                window.innerWidth -
                targetWidth
            );


        const requestedLeft =
            position.horizontalAnchor ===
            'right'
                ? window.innerWidth -
                position.right -
                targetWidth
                : position.left;


        const left =
            Math.max(
                0,
                Math.min(
                    maxLeft,
                    requestedLeft
                )
            );


        const top =
            Math.max(
                0,
                Math.min(
                    Math.max(
                        0,
                        window.innerHeight -
                        targetHeight
                    ),
                    position.top
                )
            );


        panel.style.left =
            left +
            'px';


        panel.style.top =
            top +
            'px';


        panel.style.right =
            'auto';
    }


    function applyStoredPanelPosition(panel) {

        applyPanelPosition(
            panel,
            state.panelPosition
        );
    }


    function keepPanelInsideViewport() {

        const panel =
            document.getElementById(
                'wra-helper-panel'
            );


        if (
            !panel ||
            panel.style.display ===
            'none'
        ) {

            return;
        }


        if (
            state.panelPosition
        ) {

            applyPanelPosition(
                panel,
                state.panelPosition
            );

            return;
        }


        const measuredPosition =
            measurePanelPosition(
                panel
            );


        applyPanelPosition(
            panel,
            measuredPosition
        );
    }


    function installViewportResizeHandler() {

        window.addEventListener(
            'resize',
            () => {

                clearTimeout(
                    state.viewportResizeTimer
                );


                state.viewportResizeTimer =
                    setTimeout(
                        keepPanelInsideViewport,
                        80
                    );
            }
        );
    }


    function persistUiSettingsFromControls(
        panel,
        normalizeControls =
            true
    ) {

        const pagesInput =
            panel.querySelector(
                '#wra-pages'
            );


        const depthSelect =
            panel.querySelector(
                '#wra-sniff-depth'
            );


        const childPagesInput =
            panel.querySelector(
                '#wra-sniff-pages'
            );


        const parsePositiveInteger =
            value => {

                const number =
                    Number.parseInt(
                        value,
                        10
                    );


                return Number.isFinite(
                    number
                ) &&
                    number >= 1
                    ? number
                    : null;
            };


        const listPages =
            parsePositiveInteger(
                pagesInput?.value
            );


        const childPages =
            parsePositiveInteger(
                childPagesInput?.value
            );


        if (
            normalizeControls ||
            listPages !== null
        ) {

            state.uiSettings
                .listPages =
                listPages ??
                1;
        }


        state.uiSettings
            .sniffDepth =
            normalizeSniffDepth(
                depthSelect?.value
            );


        if (
            normalizeControls ||
            childPages !== null
        ) {

            state.uiSettings
                .sniffChildPages =
                childPages ??
                20;
        }


        if (
            normalizeControls &&
            pagesInput
        ) {

            pagesInput.value =
                String(
                    state.uiSettings
                        .listPages
                );
        }


        if (
            normalizeControls &&
            depthSelect
        ) {

            depthSelect.value =
                state.uiSettings
                    .sniffDepth;
        }


        if (
            normalizeControls &&
            childPagesInput
        ) {

            childPagesInput.value =
                String(
                    state.uiSettings
                        .sniffChildPages
                );
        }


        writeStoredValue(
            STORAGE_KEYS.listPages,
            state.uiSettings
                .listPages
        );


        writeStoredValue(
            STORAGE_KEYS.sniffDepth,
            state.uiSettings
                .sniffDepth
        );


        writeStoredValue(
            STORAGE_KEYS.sniffChildPages,
            state.uiSettings
                .sniffChildPages
        );
    }


    function createPanel() {

        if (
            document.getElementById(
                'wra-helper-panel'
            )
        ) {

            return;
        }


        const suggestedPrefix =
            sanitizeFilenameAffix(
                detectProjectTitle(
                    document
                )
            );


        if (
            !state.filenameAffixes
                .prefixText &&
            suggestedPrefix
        ) {

            state.filenameAffixes
                .prefixText =
                `${suggestedPrefix}_`;
        }


        const style =
            document.createElement(
                'style'
            );


        style.textContent = `

#wra-helper-panel,
#wra-idm-plugin-sheet{
    --wra-bg:#ffffff;
    --wra-text:#1f2328;
    --wra-border:#cfd5dd;
    --wra-shadow:0 8px 30px rgba(0,0,0,.18);
    --wra-head-bg:#f5f7fa;
    --wra-head-border:#e5e8ec;
    --wra-muted:#667085;
    --wra-subtle:#8a9099;
    --wra-label:#555555;
    --wra-button-bg:#ffffff;
    --wra-button-text:#222222;
    --wra-button-border:#c9d0d8;
    --wra-hover-bg:#f0f5ff;
    --wra-hover-border:#8bb4f8;
    --wra-input-bg:#ffffff;
    --wra-input-text:#1f2328;
    --wra-input-border:#c9d0d8;
    --wra-input-disabled-bg:#f4f5f6;
    --wra-input-disabled-text:#8a9099;
    --wra-list-bg:#fafbfc;
    --wra-item-bg:#ffffff;
    --wra-item-border:#eceff2;
    --wra-name-hover-bg:#f9fcff;
    --wra-machine-bg:#fff7e6;
    --wra-machine-border:#ffd591;
    --wra-status-bg:#f6f8fa;
    --wra-status-text:#555555;
    --wra-link:#667085;
    --wra-primary:#1677ff;
    --wra-primary-text:#ffffff;
    --wra-idm:#00a870;
    --wra-idm-hover:#009461;
    --wra-support:#ff5f5f;
    --wra-warn-bg:#fff7e6;
    --wra-warn-border:#ffc069;
    --wra-guide-bg:#fffbe6;
    --wra-guide-border:#f0e5a6;
    --wra-plugin-links-bg:#f7faff;
    --wra-plugin-link-border:#e3ebf7;
}

#wra-helper-panel.wra-theme-dark,
#wra-idm-plugin-sheet.wra-theme-dark{
    --wra-bg:#1f2328;
    --wra-text:#f0f3f7;
    --wra-border:#46515f;
    --wra-shadow:0 10px 34px rgba(0,0,0,.48);
    --wra-head-bg:#292f38;
    --wra-head-border:#3a4350;
    --wra-muted:#a9b3c1;
    --wra-subtle:#8d99a8;
    --wra-label:#c2cad6;
    --wra-button-bg:#2b333d;
    --wra-button-text:#f0f3f7;
    --wra-button-border:#4a5564;
    --wra-hover-bg:#263a56;
    --wra-hover-border:#5d8fd6;
    --wra-input-bg:#171b21;
    --wra-input-text:#f3f6fa;
    --wra-input-border:#4a5564;
    --wra-input-disabled-bg:#242a32;
    --wra-input-disabled-text:#8792a0;
    --wra-list-bg:#171b21;
    --wra-item-bg:#232a33;
    --wra-item-border:#343d49;
    --wra-name-hover-bg:#1f3148;
    --wra-machine-bg:#4a341b;
    --wra-machine-border:#b7791f;
    --wra-status-bg:#171b21;
    --wra-status-text:#c8d1dc;
    --wra-link:#9fbbdf;
    --wra-warn-bg:#4a341b;
    --wra-warn-border:#b7791f;
    --wra-guide-bg:#342f18;
    --wra-guide-border:#6f632b;
    --wra-plugin-links-bg:#171f2b;
    --wra-plugin-link-border:#2d3b4c;
}

#wra-helper-panel.wra-theme-light,
#wra-idm-plugin-sheet.wra-theme-light{
    color-scheme:light;
}

#wra-helper-panel.wra-theme-dark,
#wra-idm-plugin-sheet.wra-theme-dark{
    color-scheme:dark;
}

#wra-helper-panel{
    position:fixed;
    right:16px;
    top:72px;
    width:520px;
    max-height:84vh;
    background:var(--wra-bg);
    color:var(--wra-text);
    border:1px solid var(--wra-border);
    border-radius:10px;
    box-shadow:var(--wra-shadow);
    z-index:2147483646;
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;
    font-size:13px;
    overflow:hidden;
}

#wra-helper-panel *{
    box-sizing:border-box;
}

#wra-helper-head{
    height:42px;
    display:flex;
    align-items:center;
    justify-content:space-between;
    padding:0 10px 0 12px;
    background:var(--wra-head-bg);
    border-bottom:1px solid var(--wra-head-border);
    cursor:move;
    user-select:none;
}

#wra-helper-title{
    font-weight:700;
    font-size:14px;
}

#wra-helper-titlebar{
    display:flex;
    align-items:center;
    min-width:0;
}

#wra-helper-actions{
    display:flex;
    align-items:center;
    gap:2px;
}

#wra-theme-wrap{
    display:inline-flex;
    align-items:center;
    gap:4px;
    margin-left:10px;
    color:var(--wra-label);
    font-size:12px;
    white-space:nowrap;
}

#wra-theme-select{
    width:76px;
    padding:3px 5px;
    border:1px solid var(--wra-input-border);
    border-radius:6px;
    background:var(--wra-input-bg);
    color:var(--wra-input-text);
    font-family:inherit;
    font-size:12px;
}

#wra-support.wra-title-support{
    padding:4px 8px;
    font-size:12px;
    line-height:1.2;
    white-space:nowrap;
}

#wra-mini-icon{
    display:none;
}

#wra-helper-head button{
    border:0;
    background:transparent;
    font-size:18px;
    cursor:pointer;
    padding:2px 6px;
    color:var(--wra-label);
}

#wra-helper-body{
    padding:10px;
    overflow:hidden;
}

#wra-helper-panel.collapsed{
    width:48px;
    height:48px;
    max-height:48px;
    border-radius:50%;
    border-color:var(--wra-idm);
}

#wra-helper-panel.collapsed #wra-helper-head{
    height:48px;
    padding:0;
    justify-content:center;
    border-bottom:0;
    border-radius:50%;
    background:var(--wra-idm);
}

#wra-helper-panel.collapsed #wra-helper-title,
#wra-helper-panel.collapsed #wra-helper-titlebar,
#wra-helper-panel.collapsed #wra-helper-actions,
#wra-helper-panel.collapsed #wra-helper-body{
    display:none;
}

#wra-helper-panel.collapsed #wra-mini-icon{
    display:flex;
    align-items:center;
    justify-content:center;
    width:100%;
    height:100%;
    border:0;
    border-radius:50%;
    background:var(--wra-idm);
    color:var(--wra-primary-text);
    font-size:11px;
    font-weight:800;
    letter-spacing:0;
    padding:0;
}

#wra-helper-panel.collapsed #wra-mini-icon:hover{
    background:var(--wra-idm-hover);
}

#wra-helper-toolbar,
#wra-sniffbar,
#wra-selectbar,
#wra-exportbar{
    display:flex;
    flex-wrap:wrap;
    gap:6px;
    margin-bottom:7px;
    align-items:center;
}

#wra-affixbar{
    display:grid;
    grid-template-columns:auto minmax(110px,1fr) auto minmax(110px,1fr);
    gap:6px;
    margin-bottom:7px;
    align-items:center;
}

#wra-affixbar label{
    display:inline-flex;
    align-items:center;
    gap:3px;
    white-space:nowrap;
    color:var(--wra-label);
}

#wra-prefix-text,
#wra-suffix-text{
    width:100%;
    min-width:0;
    padding:6px 8px;
    border:1px solid var(--wra-input-border);
    border-radius:6px;
    background:var(--wra-input-bg);
    color:var(--wra-input-text);
}

#wra-prefix-text:disabled,
#wra-suffix-text:disabled{
    background:var(--wra-input-disabled-bg);
    color:var(--wra-input-disabled-text);
}

#wra-helper-panel button{
    border:1px solid var(--wra-button-border);
    background:var(--wra-button-bg);
    border-radius:6px;
    padding:6px 9px;
    cursor:pointer;
    color:var(--wra-button-text);
    font-family:inherit;
}

#wra-helper-panel button:hover{
    background:var(--wra-hover-bg);
    border-color:var(--wra-hover-border);
}

#wra-helper-panel button.primary{
    background:var(--wra-primary);
    color:var(--wra-primary-text);
    border-color:var(--wra-primary);
}

#wra-helper-panel button.idm{
    background:var(--wra-idm);
    color:var(--wra-primary-text);
    border-color:var(--wra-idm);
    font-weight:600;
}

#wra-helper-panel button.support{
    background:var(--wra-support);
    color:var(--wra-primary-text);
    border-color:var(--wra-support);
    font-weight:600;
}

#wra-helper-panel button.warn{
    background:var(--wra-warn-bg);
    border-color:var(--wra-warn-border);
}

#wra-pages,
#wra-sniff-pages{
    width:54px;
    padding:5px;
    border:1px solid var(--wra-input-border);
    border-radius:6px;
    background:var(--wra-input-bg);
    color:var(--wra-input-text);
}

#wra-sniff-depth{
    width:72px;
    padding:5px;
    border:1px solid var(--wra-input-border);
    border-radius:6px;
    background:var(--wra-input-bg);
    color:var(--wra-input-text);
}

#wra-filter{
    flex:1;
    min-width:160px;
    padding:6px 8px;
    border:1px solid var(--wra-input-border);
    border-radius:6px;
    background:var(--wra-input-bg);
    color:var(--wra-input-text);
}

#wra-helper-info{
    display:flex;
    align-items:center;
    justify-content:space-between;
    gap:8px;
    margin:6px 0 8px;
}

#wra-helper-count{
    font-weight:700;
}

#wra-selection-count{
    color:var(--wra-primary);
    font-weight:700;
    margin-left:8px;
}

#wra-helper-list{
    max-height:43vh;
    overflow:auto;
    border:1px solid var(--wra-head-border);
    border-radius:6px;
    background:var(--wra-list-bg);
}

.wra-item{
    display:grid;
    grid-template-columns:24px minmax(0,1fr);
    gap:5px;
    padding:8px;
    border-bottom:1px solid var(--wra-item-border);
    background:var(--wra-item-bg);
}

.wra-item:last-child{
    border-bottom:0;
}

.wra-item.hidden{
    display:none;
}

.wra-check{
    margin-top:7px;
}

.wra-main{
    min-width:0;
}

.wra-name-input{
    width:100%;
    font-weight:600;
    line-height:1.4;
    border:1px solid transparent;
    border-radius:4px;
    padding:4px 5px;
    background:var(--wra-input-bg);
    color:var(--wra-input-text);
    font-family:inherit;
    font-size:13px;
}

.wra-name-input:hover,
.wra-name-input:focus{
    border-color:var(--wra-hover-border);
    outline:none;
    background:var(--wra-name-hover-bg);
}

.wra-machine{
    background:var(--wra-machine-bg)!important;
    border-color:var(--wra-machine-border)!important;
}

.wra-url{
    margin-top:3px;
    font-size:11px;
    line-height:1.35;
    color:var(--wra-muted);
    word-break:break-all;
    max-height:32px;
    overflow:hidden;
}

.wra-url a{
    color:var(--wra-link);
    text-decoration:none;
}

.wra-url a:hover{
    text-decoration:underline;
}

.wra-meta{
    margin-top:3px;
    font-size:11px;
    color:var(--wra-subtle);
    white-space:nowrap;
    overflow:hidden;
    text-overflow:ellipsis;
}

#wra-helper-status{
    margin-top:8px;
    padding:7px 8px;
    border-radius:6px;
    background:var(--wra-status-bg);
    color:var(--wra-status-text);
    line-height:1.4;
    min-height:30px;
}

#wra-idm-plugin-sheet{
    position:fixed;
    inset:0;
    z-index:2147483647;
    display:flex;
    align-items:center;
    justify-content:center;
    padding:24px;
    background:rgba(18,23,31,.62);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;
    color:var(--wra-text);
}

#wra-idm-plugin-sheet *{
    box-sizing:border-box;
}

#wra-idm-plugin-dialog{
    width:min(820px,94vw);
    max-height:88vh;
    overflow:hidden;
    background:var(--wra-bg);
    border-radius:10px;
    box-shadow:var(--wra-shadow);
}

#wra-idm-plugin-head{
    height:46px;
    display:flex;
    align-items:center;
    justify-content:space-between;
    padding:0 10px 0 16px;
    border-bottom:1px solid var(--wra-head-border);
    background:var(--wra-head-bg);
    font-size:15px;
    font-weight:700;
}

#wra-idm-plugin-head button{
    border:0;
    background:transparent;
    color:var(--wra-label);
    font-size:23px;
    cursor:pointer;
}

#wra-idm-plugin-guide{
    padding:12px 16px;
    line-height:1.65;
    background:var(--wra-guide-bg);
    border-bottom:1px solid var(--wra-guide-border);
    font-size:13px;
}

#wra-idm-plugin-links{
    max-height:56vh;
    overflow:auto;
    padding:8px 12px;
    background:var(--wra-plugin-links-bg);
    user-select:text;
    cursor:context-menu;
}

#wra-idm-plugin-links a{
    display:block;
    padding:6px 8px;
    border-bottom:1px solid var(--wra-plugin-link-border);
    color:var(--wra-link);
    text-decoration:none;
    font-size:13px;
    line-height:1.4;
    word-break:break-all;
    user-select:text;
}

#wra-idm-plugin-links a:last-child{
    border-bottom:0;
}

#wra-idm-plugin-links ::selection{
    color:var(--wra-primary-text);
    background:var(--wra-primary);
}

#wra-idm-plugin-actions{
    display:flex;
    justify-content:flex-end;
    gap:8px;
    padding:10px 14px;
    border-top:1px solid var(--wra-head-border);
}

#wra-idm-plugin-actions button{
    border:1px solid var(--wra-button-border);
    border-radius:6px;
    padding:7px 12px;
    background:var(--wra-button-bg);
    color:var(--wra-button-text);
    cursor:pointer;
    font-family:inherit;
}

#wra-idm-plugin-actions button:first-child{
    border-color:var(--wra-primary);
    background:var(--wra-primary);
    color:var(--wra-primary-text);
}

.wra-label{
    font-size:12px;
    color:var(--wra-label);
}
`;


        document.documentElement
            .appendChild(
                style
            );


        const panel =
            document.createElement(
                'div'
            );


       panel.id =
           'wra-helper-panel';

        panel.lang =
            UI_LANGUAGE;


        panel.dataset.language =
            UI_LANGUAGE;


        panel.innerHTML = `

<div id="wra-helper-head">

    <button
        id="wra-mini-icon"
        title="${escapeHtml(t('collapseExpand'))}"
        aria-label="${escapeHtml(t('collapseExpand'))}"
    >
        IDM
    </button>

    <div id="wra-helper-titlebar">

        <div id="wra-helper-title">
            ${escapeHtml(t('appTitle'))}
        </div>


        <label
            id="wra-theme-wrap"
            title="${escapeHtml(t('themeTitle'))}"
        >
            ${escapeHtml(t('theme'))}
            <select id="wra-theme-select">
                <option value="auto">${escapeHtml(t('themeAuto'))}</option>
                <option value="dark">${escapeHtml(t('themeDark'))}</option>
                <option value="light">${escapeHtml(t('themeLight'))}</option>
            </select>
        </label>


        <button
            class="support wra-title-support"
            id="wra-support"
            title="${escapeHtml(t('supportAuthorTitle'))}"
        >
            ${escapeHtml(t('supportAuthor'))}
        </button>

    </div>

    <div id="wra-helper-actions">

        <button
            id="wra-collapse"
            title="${escapeHtml(t('collapseExpand'))}"
        >
            −
        </button>

        <button
            id="wra-close"
            title="${escapeHtml(t('hideWindow'))}"
        >
            ×
        </button>

    </div>

</div>


<div id="wra-helper-body">

    <div id="wra-helper-toolbar">

        <span class="wra-label">

            ${escapeHtml(t('fetch'))}

            <input
                id="wra-pages"
                type="number"
                min="1"
                step="1"
                value="${escapeHtml(state.uiSettings.listPages)}"
            >

            ${escapeHtml(t('listPages'))}

        </span>


        <span class="wra-label">

            ${escapeHtml(t('sniffDepth'))}

            <select id="wra-sniff-depth">
                <option value="1" ${state.uiSettings.sniffDepth === '1' ? 'selected' : ''}>${escapeHtml(t('level1'))}</option>
                <option value="2" ${state.uiSettings.sniffDepth === '2' ? 'selected' : ''}>${escapeHtml(t('level2'))}</option>
                <option value="3" ${state.uiSettings.sniffDepth === '3' ? 'selected' : ''}>${escapeHtml(t('level3'))}</option>
            </select>

        </span>


        <span
            class="wra-label"
            title="${escapeHtml(t('perLevelTooltip'))}"
        >

            ${escapeHtml(t('perLevelMax'))}

            <input
                id="wra-sniff-pages"
                type="number"
                min="1"
                step="1"
                value="${escapeHtml(state.uiSettings.sniffChildPages)}"
            >

            ${escapeHtml(t('childPages'))}

        </span>

    </div>


    <div id="wra-sniffbar">

        <button
            class="primary"
            id="wra-start-sniff"
            title="${escapeHtml(t('startSniffTitle'))}"
        >
            ${escapeHtml(t('startSniff'))}
        </button>


        <button
            class="primary"
            id="wra-copy"
        >
            ${escapeHtml(t('copySelectedIdm'))}
        </button>


        <button
            class="idm"
            id="wra-idm-download"
            title="${escapeHtml(t('prepareIdmTitle'))}"
        >
            ${escapeHtml(t('prepareIdm'))}
        </button>


        <button
            id="wra-help"
            title="${escapeHtml(t('helpTitle'))}"
        >
            ${escapeHtml(t('help'))}
        </button>

    </div>


    <div id="wra-selectbar">

        <button id="wra-all">
            ${escapeHtml(t('selectAll'))}
        </button>


        <button id="wra-none">
            ${escapeHtml(t('selectNone'))}
        </button>


        <button id="wra-invert">
            ${escapeHtml(t('invert'))}
        </button>


        <input
            id="wra-filter"
            type="text"
            placeholder="${escapeHtml(t('filterPlaceholder'))}"
        >


        <button id="wra-select-visible">
            ${escapeHtml(t('selectFiltered'))}
        </button>

    </div>


    <div id="wra-affixbar">

       <label
           for="wra-prefix-enabled"
            title="${escapeHtml(t('prefixToggleTitle'))}"
        >
            <input
                id="wra-prefix-enabled"
                type="checkbox"
            >
            ${escapeHtml(t('prefix'))}
        </label>


        <input
            id="wra-prefix-text"
           type="text"
           value="${escapeHtml(state.filenameAffixes.prefixText)}"
            placeholder="${escapeHtml(t('prefixPlaceholder'))}"
            title="${escapeHtml(t('prefixInputTitle'))}"
        >


       <label
           for="wra-suffix-enabled"
            title="${escapeHtml(t('suffixToggleTitle'))}"
        >
            <input
                id="wra-suffix-enabled"
                type="checkbox"
            >
            ${escapeHtml(t('suffix'))}
        </label>


        <input
            id="wra-suffix-text"
           type="text"
           value="${escapeHtml(state.filenameAffixes.suffixText)}"
            placeholder="${escapeHtml(t('suffixPlaceholder'))}"
            title="${escapeHtml(t('suffixInputTitle'))}"
        >

    </div>


    <div id="wra-exportbar">


       <button id="wra-txt">
            ${escapeHtml(t('exportTxt'))}
        </button>


       <button id="wra-csv">
            ${escapeHtml(t('exportCsv'))}
        </button>


       <button id="wra-xls">
            ${escapeHtml(t('exportExcel'))}
        </button>


       <button id="wra-clear">
            ${escapeHtml(t('clear'))}
        </button>

    </div>


    <div id="wra-helper-info">

        <div>

           <span id="wra-helper-count">
                ${escapeHtml(t('found', { count: 0 }))}
            </span>


           <span id="wra-selection-count">
                ${escapeHtml(t('selected', { count: 0 }))}
            </span>

        </div>

    </div>


    <div id="wra-helper-list">
    </div>


   <div id="wra-helper-status">

        ${escapeHtml(t('initialStatus'))}

    </div>

</div>
`;


        document.body
            .appendChild(
                panel
            );


        applyInterfaceTheme();


        const themeSelect =
            panel.querySelector(
                '#wra-theme-select'
            );


        if (themeSelect) {

            themeSelect.value =
                state.themeMode;


            themeSelect.addEventListener(
                'change',
                e => {

                    state.themeMode =
                        normalizeThemeMode(
                            e.target.value
                        );


                    e.target.value =
                        state.themeMode;


                    writeStoredValue(
                        STORAGE_KEYS.themeMode,
                        state.themeMode
                    );


                    applyInterfaceTheme();
                }
            );
        }


        if (
            themeMediaQuery &&
            typeof themeMediaQuery.addEventListener ===
            'function'
        ) {

            themeMediaQuery.addEventListener(
                'change',
                () => {

                    if (
                        state.themeMode ===
                        'auto'
                    ) {

                        applyInterfaceTheme();
                    }
                }
            );
        } else if (
            themeMediaQuery &&
            typeof themeMediaQuery.addListener ===
            'function'
        ) {

            themeMediaQuery.addListener(
                () => {

                    if (
                        state.themeMode ===
                        'auto'
                    ) {

                        applyInterfaceTheme();
                    }
                }
            );
        }


        [
            '#wra-pages',
            '#wra-sniff-depth',
            '#wra-sniff-pages'
        ]
            .forEach(
                selector => {

                    const input =
                        panel.querySelector(
                            selector
                        );


                    if (!input) {

                        return;
                    }


                    input.addEventListener(
                        'change',
                        () =>
                            persistUiSettingsFromControls(
                                panel
                            )
                    );


                    if (
                        input.matches(
                            'input'
                        )
                    ) {

                        input.addEventListener(
                            'input',
                            () =>
                                persistUiSettingsFromControls(
                                    panel,
                                    false
                                )
                        );
                    }
                }
            );


        /******************************************************************
         * 按钮事件
         ******************************************************************/

        panel
            .querySelector(
                '#wra-start-sniff'
            )
            .addEventListener(
                'click',
                () => {

                    persistUiSettingsFromControls(
                        panel
                    );


                    return startSniffing(
                        state.uiSettings
                            .listPages,
                        state.uiSettings
                            .sniffDepth,
                        state.uiSettings
                            .sniffChildPages
                    );
                }
            );


        panel
            .querySelector(
                '#wra-copy'
            )
            .addEventListener(
                'click',
                copySelectedToIdm
            );


        panel
            .querySelector(
                '#wra-idm-download'
            )
            .addEventListener(
                'click',
                idmDownloadSelected
            );


        const prefixEnabledInput =
            panel.querySelector(
                '#wra-prefix-enabled'
            );


        const prefixTextInput =
            panel.querySelector(
                '#wra-prefix-text'
            );


        const suffixEnabledInput =
            panel.querySelector(
                '#wra-suffix-enabled'
            );


        const suffixTextInput =
            panel.querySelector(
                '#wra-suffix-text'
            );


        let filenameAffixInputTimer =
            null;


        const readFilenameAffixInputs =
            () => {

                state.filenameAffixes
                    .prefixEnabled =
                    prefixEnabledInput.checked;


                state.filenameAffixes
                    .prefixText =
                    prefixTextInput.value;


                state.filenameAffixes
                    .suffixEnabled =
                    suffixEnabledInput.checked;


                state.filenameAffixes
                    .suffixText =
                    suffixTextInput.value;
            };


        const applyFilenameAffixInputs =
            () => {

                clearTimeout(
                    filenameAffixInputTimer
                );


                readFilenameAffixInputs();

                refreshFilenameAffixes();
            };


        prefixEnabledInput
            .addEventListener(
                'change',
                applyFilenameAffixInputs
            );


        suffixEnabledInput
            .addEventListener(
                'change',
                applyFilenameAffixInputs
            );


        [
            prefixTextInput,
            suffixTextInput
        ]
            .forEach(
                input => {

                    input.addEventListener(
                        'input',
                        () => {

                            readFilenameAffixInputs();


                            const enabled =
                                input ===
                                prefixTextInput
                                    ? prefixEnabledInput
                                        .checked
                                    : suffixEnabledInput
                                        .checked;


                            if (!enabled) {

                                return;
                            }


                            clearTimeout(
                                filenameAffixInputTimer
                            );


                            filenameAffixInputTimer =
                                setTimeout(
                                    refreshFilenameAffixes,
                                    160
                                );
                        }
                    );


                    input.addEventListener(
                        'change',
                        () => {

                            input.value =
                                sanitizeFilenameAffix(
                                    input.value
                                );


                            readFilenameAffixInputs();


                            const enabled =
                                input ===
                                prefixTextInput
                                    ? prefixEnabledInput
                                        .checked
                                    : suffixEnabledInput
                                        .checked;


                            if (enabled) {

                                applyFilenameAffixInputs();
                            }
                        }
                    );
                }
            );


        panel
            .querySelector(
                '#wra-txt'
            )
            .addEventListener(
                'click',
                exportTxt
            );


        panel
            .querySelector(
                '#wra-csv'
            )
            .addEventListener(
                'click',
                exportCsv
            );


        panel
            .querySelector(
                '#wra-xls'
            )
            .addEventListener(
                'click',
                exportExcel
            );


        panel
            .querySelector(
                '#wra-clear'
            )
            .addEventListener(
                'click',
                clearResults
            );


        panel
            .querySelector(
                '#wra-support'
            )
            .addEventListener(
                'click',
                openSupportAuthor
            );


        panel
            .querySelector(
                '#wra-help'
            )
            .addEventListener(
                'click',
                openHelpPage
            );


        panel
            .querySelector(
                '#wra-all'
            )
            .addEventListener(

                'click',

                () =>
                    selectAll(
                        true
                    )
            );


        panel
            .querySelector(
                '#wra-none'
            )
            .addEventListener(

                'click',

                () =>
                    selectAll(
                        false
                    )
            );


        panel
            .querySelector(
                '#wra-invert'
            )
            .addEventListener(
                'click',
                invertSelection
            );


        panel
            .querySelector(
                '#wra-select-visible'
            )
            .addEventListener(
                'click',
                selectVisible
            );


        panel
            .querySelector(
                '#wra-filter'
            )
            .addEventListener(

                'input',

                e =>
                    applyTextFilter(
                        e.target.value
                    )
            );


        /******************************************************************
         * 文件列表事件委托
         ******************************************************************/

        panel
            .querySelector(
                '#wra-helper-list'
            )
            .addEventListener(

                'change',

                e => {

                    const row =
                        e.target.closest(
                            '.wra-item'
                        );


                    if (!row) {

                        return;
                    }


                    const item =
                        state.items
                            .find(
                                x =>
                                    x.id ===
                                    row.dataset.id
                            );


                    if (!item) {

                        return;
                    }


                    // 勾选。
                    if (
                        e.target.matches(
                            '.wra-check'
                        )
                    ) {

                        item.selected =
                            e.target.checked;


                        updateSelectionCount();

                    }

                    // 手工修改最终文件名。
                    else if (
                        e.target.matches(
                            '.wra-name-input'
                        )
                    ) {

                        item.filename =
                            sanitizeWindowsFilename(

                                e.target.value
                            );


                        applyFilenameAffixesToItem(
                            item
                        );


                        e.target.value =
                            item.filename;


                        e.target.classList
                            .toggle(

                                'wra-machine',

                                isMachineFilename(
                                    filenameWithoutAppliedAffixes(
                                        item
                                    )
                                )
                            );
                    }
                }
            );


        panel
            .querySelector(
                '#wra-helper-list'
            )
            .addEventListener(

                'input',

                e => {

                    if (
                        !e.target.matches(
                            '.wra-name-input'
                        )
                    ) {

                        return;
                    }


                    const row =
                        e.target.closest(
                            '.wra-item'
                        );


                    const item =
                        state.items
                            .find(
                                x =>
                                    x.id ===
                                    row?.dataset.id
                            );


                    if (item) {

                        item.filename =
                            e.target.value;
                    }
                }
            );


        /******************************************************************
         * 收起 / 隐藏
         ******************************************************************/

        const collapseButton =
            panel.querySelector(
                '#wra-collapse'
            );


        const miniIcon =
            panel.querySelector(
                '#wra-mini-icon'
            );


        const setPanelCollapsed =
            (
                collapsed,
                preservePosition =
                    true
            ) => {

                const previousPosition =
                    preservePosition
                        ? measurePanelPosition(
                            panel,
                            state.panelPosition
                                ?.horizontalAnchor
                        )
                        : null;

                state.panelCollapsed =
                    Boolean(
                        collapsed
                    );


                panel.classList
                    .toggle(

                        'collapsed',

                        state.panelCollapsed
                    );


                collapseButton.textContent =
                    state.panelCollapsed
                        ? '+'
                        : '−';


                writeStoredValue(
                    STORAGE_KEYS.panelCollapsed,
                    state.panelCollapsed
                );


                if (
                    previousPosition
                ) {

                    state.panelPosition =
                        previousPosition;


                    applyPanelPosition(
                        panel,
                        previousPosition
                    );


                    persistPanelPosition(
                        panel,
                        previousPosition
                            .horizontalAnchor
                    );
                }
            };


        setPanelCollapsed(
            state.panelCollapsed,
            false
        );


        applyStoredPanelPosition(
            panel
        );


        collapseButton
            .addEventListener(

                'click',

                () =>
                    setPanelCollapsed(
                        !state.panelCollapsed
                    )
            );


        miniIcon
            .addEventListener(

                'click',

                e => {

                    e.stopPropagation();


                    if (
                        panel.dataset
                            .wraDragMoved ===
                        '1'
                    ) {

                        return;
                    }


                    setPanelCollapsed(
                        false
                    );
                }
            );


        panel
            .querySelector(
                '#wra-close'
            )
            .addEventListener(

                'click',

                () => {

                    panel.style.display =
                        'none';
                }
            );


        makePanelDraggable(

            panel,

            panel.querySelector(
                '#wra-helper-head'
            )
        );
    }


    function makePanelDraggable(
        panel,
        handle
    ) {

        let dragging =
            false;


        let startX =
            0;


        let startY =
            0;


        let startLeft =
            0;


        let startTop =
            0;


        let moved =
            false;


        handle.addEventListener(

            'mousedown',

            e => {

                const miniDrag =
                    panel.classList
                        .contains(
                            'collapsed'
                        ) &&
                    e.target.closest(
                        '#wra-mini-icon'
                    );


                if (
                    !miniDrag &&
                    e.target.closest(
                        'button,input,select,textarea,label,a'
                    )
                ) {

                    return;
                }


                dragging =
                    true;


                moved =
                    false;


                panel.dataset
                    .wraDragMoved =
                    '0';


                const rect =
                    panel.getBoundingClientRect();


                startX =
                    e.clientX;


                startY =
                    e.clientY;


                startLeft =
                    rect.left;


                startTop =
                    rect.top;


                panel.style.right =
                    'auto';


                e.preventDefault();
            }
        );


        document.addEventListener(

            'mousemove',

            e => {

                if (
                    !dragging
                ) {

                    return;
                }


                const deltaX =
                    e.clientX -
                    startX;


                const deltaY =
                    e.clientY -
                    startY;


                if (
                    Math.abs(
                        deltaX
                    ) +
                    Math.abs(
                        deltaY
                    ) >
                    3
                ) {

                    moved =
                        true;


                    panel.dataset
                        .wraDragMoved =
                        '1';
                }


                const left =
                    Math.max(

                        0,

                        Math.min(

                            window.innerWidth -
                            panel.offsetWidth,

                            startLeft +
                            deltaX
                        )
                    );


                const top =
                    Math.max(

                        0,

                        Math.min(

                            window.innerHeight -
                            panel.offsetHeight,

                            startTop +
                            deltaY
                        )
                    );


                panel.style.left =
                    left +
                    'px';


                panel.style.top =
                    top +
                    'px';
            }
        );


        document.addEventListener(

            'mouseup',

            () => {

                if (
                    !dragging
                ) {

                    return;
                }


                dragging =
                    false;


                if (moved) {

                    persistPanelPosition(
                        panel
                    );
                }


                setTimeout(
                    () => {

                        delete panel.dataset
                            .wraDragMoved;
                    },
                    0
                );
            }
        );
    }


    function setStatus(text) {

        const el =
            document.getElementById(
                'wra-helper-status'
            );


        if (el) {

            el.textContent =
                text;
        }
    }


    function updateSelectionCount() {

        const count =
            document.getElementById(
                'wra-selection-count'
            );


        if (count) {

           count.textContent =
                t(
                    'selected',
                    {
                        count:
                            selectedItems().length
                    }
                );
        }
    }


    function renderResults() {

        const count =
            document.getElementById(
                'wra-helper-count'
            );


        const list =
            document.getElementById(
                'wra-helper-list'
            );


        if (
            !count ||
            !list
        ) {

            return;
        }


       count.textContent =
            t(
                'found',
                {
                    count:
                        state.items.length
                }
            );


        updateSelectionCount();


        if (
            !state.items.length
        ) {

           list.innerHTML =

                `<div style="padding:18px;text-align:center;color:var(--wra-subtle)">${escapeHtml(
                    t(
                        'emptyResults'
                    )
                )}</div>`;


            return;
        }


        list.innerHTML =
            state.items

                .map(
                    (
                        x,
                        i
                    ) => {

                        const text =
                            itemSearchText(x)
                                .toLowerCase();


                        const hidden =

                            state.filterText &&

                            !text.includes(
                                state.filterText
                            );


                        const machine =
                            isMachineFilename(
                                filenameWithoutAppliedAffixes(
                                    x
                                )
                            );


                        const meta =
                            [

                                x.projectTitle,

                               x.sectionTitle,

                               x.sourceDepth > 1
                                    ? t(
                                        'depthPage',
                                        {
                                            depth:
                                                x.sourceDepth
                                        }
                                    )
                                   : '',

                                localizeNameSource(
                                    x.nameSource
                                )

                            ]

                                .filter(Boolean)

                                .join(
                                    ' · '
                                );


                        return `

<div
    class="wra-item ${hidden ? 'hidden' : ''}"
    data-id="${escapeHtml(x.id)}"
>

    <input
        class="wra-check"
        type="checkbox"
       ${x.selected ? 'checked' : ''}
        title="${escapeHtml(t('itemCheckTitle'))}"
    >


    <div class="wra-main">

        <input
           class="wra-name-input ${machine ? 'wra-machine' : ''}"
           value="${escapeHtml(x.filename)}"
            title="${escapeHtml(t('filenameEditTitle'))}"
        >


        <div class="wra-url">

            <a
                href="${escapeHtml(x.url)}"
                target="_blank"
                rel="noopener noreferrer"
            >
                ${escapeHtml(x.url)}
            </a>

        </div>


        <div
            class="wra-meta"
            title="${escapeHtml(meta)}"
        >

            ${i + 1}. ${escapeHtml(
                meta ||
                t(
                    'unknownContext'
                )
            )}

        </div>

    </div>

</div>
`;
                    }
                )

                .join('');
    }


    function showPanel() {

        createPanel();


        document.getElementById(
            'wra-helper-panel'
        )
            .style.display =
                'block';
    }


    /**********************************************************************
     * 启动
     **********************************************************************/

    createPanel();

    installViewportResizeHandler();

    installMutationObserver();

    scanCurrentPage();

})();