ChinaCCP X 自动封锁 / Fast Block

在 X 个人主页加入 Fast Block 按钮,并支持从列表页批量封锁账号。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         ChinaCCP X Auto Block / Fast Block
// @name:en      ChinaCCP X Auto Block / Fast Block
// @name:zh-TW   ChinaCCP X 自動封鎖 / Fast Block
// @name:zh-CN   ChinaCCP X 自动封锁 / Fast Block
// @name:ja      ChinaCCP X 自動ブロック / Fast Block
// @name:ko      ChinaCCP X 자동 차단 / Fast Block
// @namespace    https://hollen9.com/
// @version      1.1.0
// @description       Fast block button on X profile + bulk blocking helper for list pages.
// @description:zh-TW 在 X 個人頁面加入 Fast Block 按鈕,並支援從清單頁批次封鎖帳號。
// @description:zh-CN 在 X 个人主页加入 Fast Block 按钮,并支持从列表页批量封锁账号。
// @description:ja    XプロフィールにFast Blockボタンを追加し、リストページからの一括ブロックをサポートします。
// @description:ko    X 프로필에 Fast Block 버튼을 추가하고, 목록 페이지에서 계정을 일괄 차단할 수 있게 도와줍니다.
// @author       Hollen9
// @match        https://x.com/*
// @match        https://twitter.com/*
// @match        https://pluto0x0.github.io/X_based_china/*
// @run-at       document-idle
// @license      MIT
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    if (window.__xAutoBlockInjected) return;
    window.__xAutoBlockInjected = true;

    const HOST = location.host;

    /* -------------------------------------------------
     * i18n
     * ------------------------------------------------- */

    const I18N = {
        'en': {
            languageLabel: 'Language:',
            fastBlockLabel: 'Fast Block',
            fastBlockTitle: 'Fast Block (auto open menu, click Block, and confirm)',
            alertTooManyAttempts:
                'Fast Block failed multiple times. The DOM structure may have changed, or this account is already blocked.',
            alertNotFound:
                'Could not find userActions / block menuitem. Please make sure you are on the user profile page.',

            panelTitle: 'X Bulk Blocker',
            statusIdle: 'Status: idle',
            statusPaused: 'Status: paused',
            statusRunning: 'Status: running',
            statusDone: 'Status: done (no accounts left on this page)',
            progressLabel: 'Progress: ',
            progressPageSuffix: ' (this page)',
            nextLabel: 'Next: ',
            nextEstimateLabel: 'Next (estimate): ',
            nextNone: 'Next: --',
            nextPausedSuffix: ' (paused)',
            secondsSuffix: ' s',
            taskStartLabel: 'Task start time: ',
            runtimeLabel: 'Running time: ',
            runtimeUnknown: '--:--:--',
            taskStartUnknown: '--',

            btnStart: 'Start',
            btnPause: 'Pause',
            btnReset: 'Reset done',
            btnExportFile: 'Export file',
            btnExportCopy: 'Copy JSON',
            btnImportJson: 'Import JSON',
            btnImportFile: 'Choose file',

            confirmStartMessage:
                'It will open a new tab at about two accounts per minute and try to block them.\n' +
                'Remaining on this page: {pending}\nAlready marked as done: {done}\n\n' +
                'Processed Twitter IDs are saved in localStorage (by ID, not index),\n' +
                'and will be skipped next time.\n\nContinue?',

            pageAllDoneInfo: 'Looks like all accounts on this page are already processed.',
            pausedInfo:
                'Batch blocking paused. You can resume later from the current progress.',
            resetConfirm:
                'Reset the "processed Twitter ID" list?\n\nThis will clear the records stored in localStorage. Next run will restart from the first account on this page.',
            resetBtnConfirmText: 'Reset',
            resetBtnCancelText: 'Cancel',
            resetDone: 'Processed list has been reset.',

            exportCopied: 'JSON has been copied to the clipboard.',
            exportClipboardFail:
                'Unable to write to clipboard. Please copy the JSON below manually:',

            importPromptLabel:
                'Paste JSON:\nSupported formats:\n' +
                '1) ["id1","id2",...]\n2) {"ids":["id1","id2",...],...}',
            importJsonParseError:
                'Failed to parse JSON. Please check that the format is correct.',
            importNoIdsError:
                'No valid "ids" array found.\nSupported: ["id1","id2"] or {"ids":["id1","id2"],...}',
            importEmptyIdsError: 'Imported "ids" is empty.',
            importConfirmReplace:
                'How to apply the imported IDs?\n\n"OK" = replace current list\n"Cancel" = merge with current list',
            importConfirmReplaceOk: 'Replace',
            importConfirmReplaceCancel: 'Merge',
            importDone: 'Import finished. Applied {count} IDs.',
            importFileReadError: 'An error occurred while reading the file.',

            confirmAllDone: 'It seems that all accounts on this page are done.',

            btnOk: 'OK',
            btnCancel: 'Cancel',
            swalImportTitle: 'Import JSON'
        },

        'ja': {
            languageLabel: '言語:',
            fastBlockLabel: 'Fast Block',
            fastBlockTitle:
                'Fast Block(メニューを開いて「ブロック」を押して確認まで自動)',
            alertTooManyAttempts:
                'Fast Block を複数回試しましたが失敗しました。DOM 構造が変わったか、このアカウントはすでにブロック済みかもしれません。',
            alertNotFound:
                'userActions / block メニュー項目が見つかりません。ユーザープロフィールページにいるか確認してください。',

            panelTitle: 'X 一括ブロック',
            statusIdle: '状態:待機中',
            statusPaused: '状態:一時停止',
            statusRunning: '状態:実行中',
            statusDone: '状態:完了(このページには残りアカウントなし)',
            progressLabel: '進捗:',
            progressPageSuffix: '(このページ)',
            nextLabel: '次回:',
            nextEstimateLabel: '次回(予測):',
            nextNone: '次回:--',
            nextPausedSuffix: '(一時停止中)',
            secondsSuffix: ' 秒',
            taskStartLabel: 'タスク開始時刻:',
            runtimeLabel: '稼働時間:',
            runtimeUnknown: '--:--:--',
            taskStartUnknown: '--',

            btnStart: '開始',
            btnPause: '一時停止',
            btnReset: '処理済みをリセット',
            btnExportFile: 'ファイル書き出し',
            btnExportCopy: 'JSON をコピー',
            btnImportJson: 'JSON を貼り付け',
            btnImportFile: 'ファイル選択',

            confirmStartMessage:
                '約 1 分に 2 アカウントのペースで新しいタブを開き、ブロックを試みます。\n' +
                'このページの残り:{pending} 件\n処理済み:{done} 件\n\n' +
                '処理済みの Twitter ID は localStorage に保存され、\n' +
                '次回はスキップされます。\n\n開始してもよろしいですか?',

            pageAllDoneInfo: 'このページのアカウントはすべて処理済みのようです。',
            pausedInfo:
                '一括ブロックを一時停止しました。あとで現在の進捗から再開できます。',
            resetConfirm:
                '「処理済みの Twitter ID」リストをリセットしますか?\n\nlocalStorage に保存された記録が消去され、次回はこのページの最初からやり直します。',
            resetBtnConfirmText: 'リセット',
            resetBtnCancelText: 'キャンセル',
            resetDone: '処理済みリストをリセットしました。',

            exportCopied: 'JSON をクリップボードにコピーしました。',
            exportClipboardFail:
                'クリップボードに書き込めませんでした。下の JSON を手動でコピーしてください。',

            importPromptLabel:
                'JSON を貼り付けてください:\n対応フォーマット:\n' +
                '1) ["id1","id2",...]\n2) {"ids":["id1","id2",...],...}',
            importJsonParseError: 'JSON の解析に失敗しました。フォーマットを確認してください。',
            importNoIdsError:
                '有効な ids 配列が見つかりませんでした。\n対応:["id1","id2"] または {"ids":["id1","id2"],...}',
            importEmptyIdsError: 'インポートした ids は空です。',
            importConfirmReplace:
                'インポートした ID をどう適用しますか?\n\n「OK」= 現在のリストを置き換え\n「キャンセル」= 現在のリストにマージ',
            importConfirmReplaceOk: '置き換え',
            importConfirmReplaceCancel: 'マージ',
            importDone: 'インポート完了。合計 {count} 件の ID を反映しました。',
            importFileReadError: 'ファイルの読み込み中にエラーが発生しました。',

            confirmAllDone: 'このページのアカウントはすべて処理済みのようです。',

            btnOk: 'OK',
            btnCancel: 'キャンセル',
            swalImportTitle: 'JSON をインポート'
        },

        'ko': {
            languageLabel: '언어:',
            fastBlockLabel: 'Fast Block',
            fastBlockTitle:
                'Fast Block (메뉴를 열고 차단 + 확인까지 자동 실행)',
            alertTooManyAttempts:
                'Fast Block을 여러 번 시도했지만 실패했습니다. DOM 구조가 변경되었거나 이미 차단된 계정일 수 있습니다.',
            alertNotFound:
                'userActions / block 메뉴 항목을 찾지 못했습니다. 현재 사용자 프로필 페이지인지 확인해주세요.',

            panelTitle: 'X 대량 차단기',
            statusIdle: '상태: 대기',
            statusPaused: '상태: 일시 중지',
            statusRunning: '상태: 실행 중',
            statusDone: '상태: 완료 (이 페이지에 남은 계정 없음)',
            progressLabel: '진행률: ',
            progressPageSuffix: '(이 페이지)',
            nextLabel: '다음: ',
            nextEstimateLabel: '다음 (예상): ',
            nextNone: '다음: --',
            nextPausedSuffix: '(일시 중지)',
            secondsSuffix: ' 초',
            taskStartLabel: '작업 시작 시간: ',
            runtimeLabel: '실행 시간: ',
            runtimeUnknown: '--:--:--',
            taskStartUnknown: '--',

            btnStart: '시작',
            btnPause: '일시 중지',
            btnReset: '처리 기록 초기화',
            btnExportFile: '파일로 내보내기',
            btnExportCopy: 'JSON 복사',
            btnImportJson: 'JSON 가져오기',
            btnImportFile: '파일 선택',

            confirmStartMessage:
                '약 1분에 2명 속도로 새 탭을 열어 차단을 시도합니다.\n' +
                '이 페이지 남은 계정: {pending}개\n처리된 계정: {done}개\n\n' +
                '처리된 Twitter ID는 localStorage에 저장되며,\n' +
                '다음 실행 시 건너뜁니다.\n\n시작하시겠습니까?',

            pageAllDoneInfo: '이 페이지의 계정은 모두 처리된 것 같습니다.',
            pausedInfo:
                '대량 차단이 일시 중지되었습니다. 나중에 현재 진행 상태에서 다시 시작할 수 있습니다.',
            resetConfirm:
                '"처리된 Twitter ID" 목록을 초기화하시겠습니까?\n\nlocalStorage에 저장된 기록이 삭제되고, 다음 실행은 이 페이지의 첫 계정부터 다시 시작합니다.',
            resetBtnConfirmText: '초기화',
            resetBtnCancelText: '취소',
            resetDone: '처리 목록을 초기화했습니다.',

            exportCopied: 'JSON을 클립보드에 복사했습니다.',
            exportClipboardFail:
                '클립보드에 쓸 수 없습니다. 아래 JSON을 수동으로 복사해주세요.',

            importPromptLabel:
                'JSON을 붙여넣어 주세요:\n지원 형식:\n' +
                '1) ["id1","id2",...]\n2) {"ids":["id1","id2",...],...}',
            importJsonParseError: 'JSON 파싱에 실패했습니다. 형식을 확인해 주세요.',
            importNoIdsError:
                '유효한 ids 배열을 찾지 못했습니다.\n지원: ["id1","id2"] 또는 {"ids":["id1","id2"],...}',
            importEmptyIdsError: '가져온 ids가 비어 있습니다.',
            importConfirmReplace:
                '가져온 ID를 어떻게 적용할까요?\n\n"확인" = 현재 목록을 덮어쓰기\n"취소" = 현재 목록에 병합',
            importConfirmReplaceOk: '덮어쓰기',
            importConfirmReplaceCancel: '병합',
            importDone: '가져오기 완료. 총 {count}개의 ID를 적용했습니다.',
            importFileReadError: '파일을 읽는 동안 오류가 발생했습니다.',

            confirmAllDone: '이 페이지의 계정은 모두 처리된 것 같습니다.',

            btnOk: '확인',
            btnCancel: '취소',
            swalImportTitle: 'JSON 가져오기'
        },

        'zh-TW': {
            languageLabel: '語言:',
            fastBlockLabel: 'Fast Block',
            fastBlockTitle: 'Fast Block(自動打開選單、點封鎖並確認)',
            alertTooManyAttempts:
                'Fast Block 多次嘗試失敗,可能是 DOM 結構變更,或這個帳號已被封鎖。',
            alertNotFound:
                '找不到 userActions / block 選單項目,請確認現在是在使用者主頁。',

            panelTitle: 'X 批次封鎖工具',
            statusIdle: '狀態:待機',
            statusPaused: '狀態:已暫停',
            statusRunning: '狀態:運行中',
            statusDone: '狀態:已完成(此頁無剩餘帳號)',
            progressLabel: '進度:',
            progressPageSuffix: '(此頁)',
            nextLabel: '下一個:',
            nextEstimateLabel: '下一個(預估):',
            nextNone: '下一個:--',
            nextPausedSuffix: '(暫停中)',
            secondsSuffix: ' 秒',
            taskStartLabel: '任務啟動時間:',
            runtimeLabel: '運行時間:',
            runtimeUnknown: '--:--:--',
            taskStartUnknown: '--',

            btnStart: '開始',
            btnPause: '暫停',
            btnReset: '重置已處理',
            btnExportFile: '匯出檔案',
            btnExportCopy: '複製 JSON',
            btnImportJson: '匯入 JSON',
            btnImportFile: '選擇檔案',

            confirmStartMessage:
                '將以「約每分鐘 2 位」的速度,依序開新分頁並嘗試封鎖。\n' +
                '此頁剩餘:約 {pending} 位,已標記處理:{done} 位。\n\n' +
                '已處理的 Twitter ID 會記錄在 localStorage(依 ID,而非 index),\n' +
                '下次回來會跳過處理過的帳號。\n\n確定要開始嗎?',

            pageAllDoneInfo: '看起來這一頁的帳號都已處理完囉 (´・ω・`)',
            pausedInfo:
                '已暫停批次封鎖,可以之後再從目前進度繼續。',
            resetConfirm:
                '確定要重置「已處理的 Twitter ID」嗎?\n\n這會清除本機 localStorage 中的紀錄,下次會從此頁的第一個帳號重新開始。',
            resetBtnConfirmText: '重置',
            resetBtnCancelText: '取消',
            resetDone: '已重置已處理清單。',

            exportCopied: '已複製 JSON 到剪貼簿。',
            exportClipboardFail:
                '無法直接寫入剪貼簿,請手動複製下列 JSON:',

            importPromptLabel:
                '請貼上 JSON:\n支援格式:\n' +
                '1) ["id1","id2",...]\n2) {"ids":["id1","id2",...],...}',
            importJsonParseError:
                'JSON 解析失敗,請確認格式是否正確。',
            importNoIdsError:
                '找不到有效的 ids 陣列,請確認格式。\n支援:["id1","id2"] 或 {"ids":["id1","id2"],...}',
            importEmptyIdsError: '匯入的 ids 為空。',
            importConfirmReplace:
                '要覆蓋目前的「已處理清單」嗎?\n\n「確定」= 覆蓋\n「取消」= 合併',
            importConfirmReplaceOk: '覆蓋',
            importConfirmReplaceCancel: '合併',
            importDone: '匯入完成,共處理 {count} 個 ID。',
            importFileReadError: '讀取檔案時發生錯誤。',

            confirmAllDone: '此頁帳號看起來都處理完了~',

            btnOk: '確定',
            btnCancel: '取消',
            swalImportTitle: '匯入 JSON'
        },

        'zh-CN': {
            languageLabel: '语言:',
            fastBlockLabel: 'Fast Block',
            fastBlockTitle: 'Fast Block(自动打开菜单、点击封锁并确认)',
            alertTooManyAttempts:
                'Fast Block 多次尝试失败,可能是 DOM 结构已变更,或该账号已经被封锁。',
            alertNotFound:
                '找不到 userActions / block 菜单项,请确认当前在用户主页。',

            panelTitle: 'X 批量封锁工具',
            statusIdle: '状态:待机',
            statusPaused: '状态:已暂停',
            statusRunning: '状态:运行中',
            statusDone: '状态:已完成(本页无剩余账号)',
            progressLabel: '进度:',
            progressPageSuffix: '(本页)',
            nextLabel: '下一个:',
            nextEstimateLabel: '下一个(预估):',
            nextNone: '下一个:--',
            nextPausedSuffix: '(暂停中)',
            secondsSuffix: ' 秒',
            taskStartLabel: '任务启动时间:',
            runtimeLabel: '运行时间:',
            runtimeUnknown: '--:--:--',
            taskStartUnknown: '--',

            btnStart: '开始',
            btnPause: '暂停',
            btnReset: '重置已处理',
            btnExportFile: '导出文件',
            btnExportCopy: '复制 JSON',
            btnImportJson: '导入 JSON',
            btnImportFile: '选择文件',

            confirmStartMessage:
                '将以「约每分钟 2 个」的速度依次打开新标签并尝试封锁。\n' +
                '本页剩余:约 {pending} 个,已标记处理:{done} 个。\n\n' +
                '已处理的 Twitter ID 会记录在 localStorage(按 ID,而非索引),\n' +
                '下次会跳过已经处理过的账号。\n\n确定要开始吗?',

            pageAllDoneInfo: '看起来这一页的账号都已经处理完了 (´・ω・`)',
            pausedInfo:
                '已暂停批量封锁,可以之后从当前进度继续。',
            resetConfirm:
                '确定要重置「已处理的 Twitter ID」吗?\n\n这会清除本地 localStorage 中的记录,下次会从本页第一个账号重新开始。',
            resetBtnConfirmText: '重置',
            resetBtnCancelText: '取消',
            resetDone: '已重置已处理列表。',

            exportCopied: '已复制 JSON 到剪贴板。',
            exportClipboardFail:
                '无法直接写入剪贴板,请手动复制下面的 JSON:',

            importPromptLabel:
                '请粘贴 JSON:\n支持格式:\n' +
                '1) ["id1","id2",...]\n2) {"ids":["id1","id2",...],...}',
            importJsonParseError:
                'JSON 解析失败,请确认格式是否正确。',
            importNoIdsError:
                '找不到有效的 ids 数组,请确认格式。\n支持:["id1","id2"] 或 {"ids":["id1","id2"],...}',
            importEmptyIdsError: '导入的 ids 为空。',
            importConfirmReplace:
                '要覆盖当前的「已处理列表」吗?\n\n「确定」= 覆盖\n「取消」= 合并',
            importConfirmReplaceOk: '覆盖',
            importConfirmReplaceCancel: '合并',
            importDone: '导入完成,共处理 {count} 个 ID。',
            importFileReadError: '读取文件时发生错误。',

            confirmAllDone: '本页账号看起来都已经处理完了~',

            btnOk: '确定',
            btnCancel: '取消',
            swalImportTitle: '导入 JSON'
        }
    };

    function detectInitialLang() {
        try {
            const stored =
                (typeof localStorage !== 'undefined' &&
                    localStorage.getItem('xAutoBlock_lang')) ||
                null;
            if (stored && I18N[stored]) return stored;
        } catch (e) {
            // ignore
        }

        let nav = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
        if (nav.startsWith('ja')) return 'ja';
        if (nav.startsWith('ko')) return 'ko';
        if (nav === 'zh-tw' || nav === 'zh-hk' || nav === 'zh-mo') return 'zh-TW';
        if (nav === 'zh-cn' || nav === 'zh-sg') return 'zh-CN';
        return 'en';
    }

    let currentLang = detectInitialLang();

    function tr(key) {
        const pack = I18N[currentLang] || I18N['en'];
        return pack[key] || I18N['en'][key] || key;
    }

    function trf(key, vars) {
        let s = tr(key);
        if (!vars) return s;
        return s.replace(/\{(\w+)\}/g, (m, k) =>
            Object.prototype.hasOwnProperty.call(vars, k) ? String(vars[k]) : m
        );
    }

    /* -------------------------------------------------
     * SweetAlert2 載入(非阻塞)
     * ------------------------------------------------- */

    let swalLoading = false;

    function ensureSweetAlert() {
        if (window.Swal && window.Swal.fire) return;
        if (swalLoading) return;
        swalLoading = true;

        const cssId = 'x-autoblock-swal2-css';
        if (!document.getElementById(cssId)) {
            const link = document.createElement('link');
            link.id = cssId;
            link.rel = 'stylesheet';
            link.href =
                'https://cdnjs.cloudflare.com/ajax/libs/sweetalert2/11.23.0/sweetalert2.css';
            link.integrity =
                'sha512-/j+6zx45kh/MDjnlYQL0wjxn+aPaSkaoTczyOGfw64OB2CHR7Uh5v1AML7VUybUnUTscY5ck/gbGygWYcpCA7w==';
            link.crossOrigin = 'anonymous';
            link.referrerPolicy = 'no-referrer';
            document.head.appendChild(link);
        }

        const jsId = 'x-autoblock-swal2-js';
        if (document.getElementById(jsId)) return;

        const script = document.createElement('script');
        script.id = jsId;
        script.src =
            'https://cdnjs.cloudflare.com/ajax/libs/sweetalert2/11.23.0/sweetalert2.min.js';
        script.integrity =
            'sha512-pnPZhx5S+z5FSVwy62gcyG2Mun8h6R+PG01MidzU+NGF06/ytcm2r6+AaWMBXAnDHsdHWtsxS0dH8FBKA84FlQ==';
        script.crossOrigin = 'anonymous';
        script.referrerPolicy = 'no-referrer';
        document.head.appendChild(script);
    }

    /* -------------------------------------------------
     * Alert / Confirm / Prompt 包裝(可 fallback)
     * ------------------------------------------------- */

    function swalAlert(text, icon = 'info', title = '') {
        if (!window.Swal || !window.Swal.fire) {
            alert((title ? title + '\n' : '') + text);
            return Promise.resolve();
        }
        return Swal.fire({
            icon,
            title: title || undefined,
            text,
            confirmButtonText: tr('btnOk')
        });
    }

    async function swalConfirm(text, opts = {}) {
        if (!window.Swal || !window.Swal.fire) {
            const ok = confirm(
                (opts.title ? opts.title + '\n' : '') + text
            );
            return ok;
        }
        const result = await Swal.fire({
            icon: opts.icon || 'question',
            title: opts.title || '',
            text,
            showCancelButton: true,
            confirmButtonText: opts.confirmText || tr('btnOk'),
            cancelButtonText: opts.cancelText || tr('btnCancel'),
            reverseButtons: true
        });
        return result.isConfirmed;
    }

    async function swalPromptTextarea(text, opts = {}) {
        if (!window.Swal || !window.Swal.fire) {
            const v = prompt(
                (opts.title ? opts.title + '\n\n' : '') + text,
                opts.defaultValue || ''
            );
            return v ?? null;
        }
        const result = await Swal.fire({
            icon: opts.icon || 'question',
            title: opts.title || tr('swalImportTitle'),
            input: 'textarea',
            inputLabel: text,
            inputValue: opts.defaultValue || '',
            inputAttributes: {
                spellcheck: 'false',
                style: 'font-family: monospace; min-height: 150px;'
            },
            showCancelButton: true,
            confirmButtonText: opts.confirmText || tr('btnOk'),
            cancelButtonText: opts.cancelText || tr('btnCancel')
        });
        if (result.isConfirmed && result.value) {
            return result.value;
        }
        return null;
    }

    /* -------------------------------------------------
     * 工具:bulk 模式 ID 傳遞 / 回報
     * ------------------------------------------------- */

    function getBulkIdFromUrl() {
        try {
            const params = new URLSearchParams(location.search);
            return params.get('xid');
        } catch (e) {
            return null;
        }
    }

    function notifyOpener(type) {
        const id = getBulkIdFromUrl();
        if (!id) return;
        if (!window.opener || window.opener.closed) return;
        try {
            window.opener.postMessage(
                {
                    source: 'XAutoBlock',
                    type,
                    id
                },
                '*'
            );
        } catch (e) {
            console.warn('[X AutoBlock] postMessage failed:', e);
        }
    }

    /* -------------------------------------------------
     * 共用:X 封鎖邏輯
     * ------------------------------------------------- */

    function clickConfirmIfAny() {
        const confirmBtn = document.querySelector(
            'div[data-testid="confirmationSheetDialog"] button[data-testid="confirmationSheetConfirm"]'
        );
        if (confirmBtn) {
            console.log('[X AutoBlock] confirmationSheetConfirm found:', confirmBtn);
            confirmBtn.click();

            notifyOpener('blocked');

            setTimeout(() => {
                try {
                    window.close();
                } catch (e) {
                    console.warn('[X AutoBlock] window.close() failed', e);
                }
            }, 1500);

            return true;
        }
        return false;
    }

    function clickBlockMenuitemIfAny() {
        const blockItem = document.querySelector(
            'div[role="menuitem"][data-testid="block"]'
        );
        if (!blockItem) return false;

        console.log('[X AutoBlock] block menuitem found:', blockItem);
        blockItem.style.outline = '2px solid red';
        setTimeout(() => {
            blockItem.style.outline = '';
        }, 800);

        blockItem.click();
        console.log('[X AutoBlock] block menuitem clicked.');
        return true;
    }

    function clickUserActionsIfAny() {
        const moreBtn = document.querySelector('button[data-testid="userActions"]');
        if (!moreBtn) return false;

        console.log('[X AutoBlock] userActions button found:', moreBtn);
        moreBtn.style.outline = '2px solid lime';
        setTimeout(() => {
            moreBtn.style.outline = '';
        }, 800);

        moreBtn.click();
        console.log('[X AutoBlock] userActions button clicked.');
        return true;
    }

    function isAlreadyBlocked() {
        const unblockBtn = document.querySelector('button[data-testid$="-unblock"]');
        if (!unblockBtn) return false;

        console.log('[X AutoBlock] This user is already blocked, skip blocking.');
        unblockBtn.style.outline = '2px solid orange';
        setTimeout(() => {
            unblockBtn.style.outline = '';
        }, 800);

        notifyOpener('alreadyBlocked');

        setTimeout(() => {
            try {
                window.close();
            } catch (e) {
                console.warn(
                    '[X AutoBlock] window.close() failed (already blocked case)',
                    e
                );
            }
        }, 1500);

        return true;
    }

    async function runAutoBlock(step = 0) {
        console.log('[X AutoBlock] step =', step);
        if (step > 5) {
            console.log('[X AutoBlock] too many attempts, abort.');
            await swalAlert(
                tr('alertTooManyAttempts'),
                'error'
            );
            return;
        }

        if (isAlreadyBlocked()) {
            console.log('[X AutoBlock] already blocked, nothing to do.');
            return;
        }

        if (clickConfirmIfAny()) {
            console.log('[X AutoBlock] Confirm clicked, done.');
            return;
        }

        if (clickBlockMenuitemIfAny()) {
            setTimeout(() => {
                if (!clickConfirmIfAny()) {
                    runAutoBlock(step + 1);
                }
            }, 400);
            return;
        }

        if (clickUserActionsIfAny()) {
            setTimeout(() => runAutoBlock(step + 1), 500);
            return;
        }

        console.log('[X AutoBlock] Neither confirm, block menuitem, nor userActions found.');
        await swalAlert(
            tr('alertNotFound'),
            'warning'
        );
    }

    /* -------------------------------------------------
     * X / Twitter 頁面:inline Fast Block 按鈕 + 自動模式
     * ------------------------------------------------- */

    function attachInlineFastBlockButton() {
        // 已經有就不重複插
        if (document.getElementById('x-fastblock-btn-inline')) return;

        // 三個點按鈕
        const moreBtn = document.querySelector('button[data-testid="userActions"]');
        if (!moreBtn) return;

        const wrapper = moreBtn.parentElement;
        if (!wrapper) return;

        // 包 Follow 的那個 block
        const placement = wrapper.querySelector('[data-testid="placementTracking"]');
        if (!placement) return;

        // 內層 container(css-175oi2r r-6gpygo)
        const innerContainer = placement.querySelector('.css-175oi2r.r-6gpygo');
        if (!innerContainer) return;

        const followBtn = innerContainer.querySelector('button[role="button"]');
        if (!followBtn) return;

        // ✅ 複製整個 container(包含 layout),讓 Fast Block 位置跟 Follow 完全一致
        const fastContainer = innerContainer.cloneNode(true);
        const fastBtn =
            fastContainer.querySelector('button[role="button"]') ||
            fastContainer.querySelector('button');

        if (!fastBtn) return;

        fastBtn.id = 'x-fastblock-btn-inline';
        fastBtn.removeAttribute('data-testid');       // 避免和 follow 撞 testid
        fastBtn.removeAttribute('aria-describedby'); // 不用 tooltip id
        fastBtn.setAttribute('aria-label', tr('fastBlockLabel'));
        fastBtn.title = tr('fastBlockTitle');

        // 改裡面的文字
        const labelSpan = fastBtn.querySelector('span.css-1jxf684');
        if (labelSpan) {
            labelSpan.textContent = tr('fastBlockLabel');
        } else {
            fastBtn.textContent = tr('fastBlockLabel');
        }

        // 顏色改成紅危險按鈕,但維持原本 padding / 圓角 / 字體
        fastBtn.style.borderColor = 'rgb(244, 33, 46)';
        fastBtn.style.backgroundColor = 'rgba(244, 33, 46, 0.08)';

        const textContainer = fastBtn.querySelector('div[dir="ltr"]');
        if (textContainer) {
            textContainer.style.color = 'rgb(244, 33, 46)';
        } else {
            fastBtn.style.color = 'rgb(244, 33, 46)';
        }

        fastBtn.addEventListener('mouseenter', () => {
            fastBtn.style.backgroundColor = 'rgba(244, 33, 46, 0.18)';
        });
        fastBtn.addEventListener('mouseleave', () => {
            fastBtn.style.backgroundColor = 'rgba(244, 33, 46, 0.08)';
        });

        // 點擊改成跑封鎖邏輯
        fastBtn.addEventListener('click', (ev) => {
            ev.preventDefault();
            ev.stopPropagation();
            runAutoBlock(0);
        });

        // 稍微跟 Follow 拉開一點距離
        fastContainer.style.marginLeft = '8px';
        // 插在 placement 旁邊,結構變成:
        // [userActions] [placement(=Follow)] [fastContainer(=Fast Block)]
        if (placement.nextSibling) {
            wrapper.insertBefore(fastContainer, placement.nextSibling);
        } else {
            wrapper.appendChild(fastContainer);
        }

        console.log('[X AutoBlock] Inline Fast Block button attached (cloned container).');
    }

    function initOnTwitter() {
        // 1. 裝上 inline Fast Block
        attachInlineFastBlockButton();
        // 2. SPA 模式下,profile 切換時重新嘗試掛上
        setInterval(attachInlineFastBlockButton, 1500);

        // 3. 有 ?xautoblock=1 的情況自動執行(批次模式開出來的分頁)
        try {
            const params = new URLSearchParams(location.search);
            if (params.has('xautoblock')) {
                console.log(
                    '[X AutoBlock] bulk mode detected via ?xautoblock, will auto run.'
                );
                setTimeout(() => {
                    runAutoBlock(0);
                }, 2000);
            }
        } catch (e) {
            console.warn('[X AutoBlock] URLSearchParams error:', e);
        }
    }

    /* -------------------------------------------------
     * pluto0x0 清單頁:批次開啟封鎖(用 postMessage 確認成功)
     * ------------------------------------------------- */

    function initOnListPage() {
        const INTERVAL_MS = 30 * 1000; // 30 秒一筆
        const STORAGE_KEY_IDS = 'xBulkBlock_doneIds_v1';
        const STORAGE_KEY_STATE = 'xBulkBlock_runState_v1';

        // 從頁面副標解析「總頁數 / 當前頁 / 總帳號數 / 本頁帳號數」
        let totalPages = null;
        let currentPageIndex = null;
        let totalAccountsAll = null;
        let totalAccountsThisPageText = null;

        function parseSubtitleMeta() {
            try {
                const sub = document.querySelector('p.page-subtitle');
                if (!sub) return;

                const text = sub.textContent || '';
                // 例:共 48 页 · 当前第 1 页 · 共 9415 账号 · 本页 200 账号
                const nums = text.match(/(\d+)/g);
                if (!nums || nums.length < 4) return;

                totalPages = parseInt(nums[0], 10) || null;  // 48
                currentPageIndex = parseInt(nums[1], 10) || null; // 1
                totalAccountsAll = parseInt(nums[2], 10) || null; // 9415
                totalAccountsThisPageText = parseInt(nums[3], 10) || null; // 200
            } catch (e) {
                console.warn('[X Bulk] parseSubtitleMeta error:', e);
            }
        }

        function loadProcessedIds() {
            try {
                const raw = localStorage.getItem(STORAGE_KEY_IDS);
                if (!raw) return {};
                const arr = JSON.parse(raw);
                if (!Array.isArray(arr)) return {};
                const map = {};
                for (const id of arr) {
                    if (typeof id === 'string' && id.length > 0) {
                        map[id] = true;
                    }
                }
                return map;
            } catch (e) {
                console.warn('[X Bulk] loadProcessedIds error:', e);
                return {};
            }
        }

        function saveProcessedIds(map) {
            try {
                const arr = Object.keys(map);
                localStorage.setItem(STORAGE_KEY_IDS, JSON.stringify(arr));
            } catch (e) {
                console.warn('[X Bulk] saveProcessedIds error:', e);
            }
        }

        function markCardDone(card) {
            card.style.opacity = '0.6';
            card.style.background = '#f0f0f0';
        }

        function clearCardStyle(card) {
            card.style.opacity = '';
            card.style.background = '';
        }

        function collectUsers(processedIds) {
            const cards = Array.from(document.querySelectorAll('.user-card'));
            const result = [];

            for (const card of cards) {
                const linkEl = card.querySelector('a[href^="https://twitter.com/"]');
                if (!linkEl) continue;

                let id = null;

                const idEl = card.querySelector('.user-id');
                if (idEl) {
                    const m = idEl.textContent.match(/(\d{5,})/);
                    if (m) {
                        id = m[1];
                    }
                }

                if (!id) {
                    const handleEl = card.querySelector('.user-handle');
                    if (handleEl) {
                        id = handleEl.textContent.trim();
                    } else {
                        id = linkEl.href;
                    }
                }

                const user = { id, url: linkEl.href, card };
                result.push(user);

                if (processedIds[id]) {
                    markCardDone(card);
                }
            }

            return result;
        }

        function getNextPageUrl() {
            const pager = document.querySelector('nav.pager');
            if (!pager) return null;

            const links = pager.querySelectorAll('a.pager-link');
            if (!links.length) return null;

            // 優先用「下一页 / Next」這種按鈕
            const lastLink = links[links.length - 1];
            const text = lastLink.textContent.trim();
            if (text.includes('下一') || /next/i.test(text)) {
                return lastLink.href || null;
            }

            // 沒有「下一頁」文字,就用 current 後面的第一個連結
            const current = pager.querySelector('.pager-link-current');
            if (!current) return null;

            const siblings = Array.from(pager.children);
            const idx = siblings.indexOf(current);
            if (idx < 0) return null;

            for (let i = idx + 1; i < siblings.length; i++) {
                const el = siblings[i];
                if (el.tagName === 'A' && el.classList.contains('pager-link')) {
                    return el.href || null;
                }
            }

            return null;
        }

        let processedIds = loadProcessedIds();
        parseSubtitleMeta();
        const allUsers = collectUsers(processedIds);

        if (!allUsers.length) {
            console.log('[X Bulk] No user cards found on this page.');
            return;
        }

        let running = false;
        let taskStartTime = null;
        let lastOpenedAt = 0;
        let totalRunMs = 0;
        let lastRunStart = null;
        let currentProcessingId = null;

        let uiTimerId = null;

        function saveRunState() {
            try {
                const payload = {
                    running,
                    taskStartTime,
                    totalRunMs
                };
                localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(payload));
            } catch (e) {
                console.warn('[X Bulk] saveRunState error:', e);
            }
        }

        function clearRunState() {
            try {
                localStorage.removeItem(STORAGE_KEY_STATE);
            } catch (e) {
                console.warn('[X Bulk] clearRunState error:', e);
            }
        }

        function restoreRunState() {
            try {
                const raw = localStorage.getItem(STORAGE_KEY_STATE);
                if (!raw) return;
                const s = JSON.parse(raw);
                if (!s || typeof s !== 'object') return;

                if (typeof s.taskStartTime === 'number') {
                    taskStartTime = s.taskStartTime;
                }
                if (typeof s.totalRunMs === 'number') {
                    totalRunMs = s.totalRunMs;
                }

                if (s.running) {
                    // 表示上一頁在「運行中」時跳轉過來 → 自動接續
                    running = true;

                    // 新頁面重新開始記這一段執行時間
                    lastRunStart = Date.now();

                    // 讓一載入就符合「可以開下一個」的條件
                    lastOpenedAt = Date.now() - INTERVAL_MS;

                    // 按鈕樣式也一起還原
                    toggleBtn.textContent = tr('btnPause');
                    toggleBtn.style.background = '#ff9800';
                }
            } catch (e) {
                console.warn('[X Bulk] restoreRunState error:', e);
            }
        }

        window.addEventListener('message', (event) => {
            const data = event.data;
            if (!data || data.source !== 'XAutoBlock') return;
            const { type, id } = data;
            if (!id) return;

            console.log('[X Bulk] message from child:', data);

            if (type === 'blocked' || type === 'alreadyBlocked') {
                processedIds[id] = true;
                saveProcessedIds(processedIds);

                const user = allUsers.find((u) => u.id === id);
                if (user) markCardDone(user.card);

                if (currentProcessingId === id) {
                    currentProcessingId = null;
                }

                updateUI();
            }
        });

        const panel = document.createElement('div');
        panel.id = 'x-bulk-panel';

        Object.assign(panel.style, {
            position: 'fixed',
            top: '80px',
            right: '20px',
            zIndex: 99999,
            padding: '8px',
            width: '300px',
            fontSize: '12px',
            background: 'rgba(0,0,0,0.85)',
            color: '#fff',
            borderRadius: '8px',
            boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
            fontFamily:
                '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'
        });

        const titleEl = document.createElement('div');
        Object.assign(titleEl.style, {
            fontWeight: 'bold',
            marginBottom: '4px',
            fontSize: '13px'
        });

        // 語言切換列
        const langRow = document.createElement('div');
        Object.assign(langRow.style, {
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
            marginBottom: '4px',
            gap: '6px'
        });

        const langLabel = document.createElement('span');
        langLabel.style.fontSize = '11px';

        const langSelect = document.createElement('select');
        Object.assign(langSelect.style, {
            flex: '1',
            fontSize: '11px',
            padding: '2px 4px',
            borderRadius: '4px',
            border: '1px solid #555',
            background: '#222',
            color: '#fff'
        });

        const LANG_OPTIONS = [
            { value: 'en', text: 'English' },
            { value: 'ja', text: '日本語' },
            { value: 'ko', text: '한국어' },
            { value: 'zh-TW', text: '繁體中文' },
            { value: 'zh-CN', text: '简体中文' }
        ];
        LANG_OPTIONS.forEach((opt) => {
            const o = document.createElement('option');
            o.value = opt.value;
            o.textContent = opt.text;
            langSelect.appendChild(o);
        });
        langSelect.value = currentLang;

        langRow.appendChild(langLabel);
        langRow.appendChild(langSelect);

        const statusEl = document.createElement('div');
        const progressEl = document.createElement('div');
        const countdownEl = document.createElement('div');
        const startTimeEl = document.createElement('div');
        const runtimeEl = document.createElement('div');

        statusEl.style.marginBottom = '2px';
        progressEl.style.marginBottom = '2px';
        countdownEl.style.marginBottom = '2px';
        startTimeEl.style.marginBottom = '2px';
        runtimeEl.style.marginBottom = '6px';
        startTimeEl.style.fontSize = '11px';
        runtimeEl.style.fontSize = '11px';

        const btnRow = document.createElement('div');
        Object.assign(btnRow.style, {
            display: 'flex',
            gap: '6px',
            marginBottom: '4px'
        });

        const toggleBtn = document.createElement('button');
        Object.assign(toggleBtn.style, {
            flex: '1',
            padding: '4px 0',
            borderRadius: '4px',
            border: 'none',
            cursor: 'pointer',
            fontSize: '12px',
            background: '#4caf50',
            color: '#fff'
        });

        const resetBtn = document.createElement('button');
        Object.assign(resetBtn.style, {
            flex: '1',
            padding: '4px 0',
            borderRadius: '4px',
            border: 'none',
            cursor: 'pointer',
            fontSize: '12px',
            background: '#f44336',
            color: '#fff'
        });

        btnRow.appendChild(toggleBtn);
        btnRow.appendChild(resetBtn);

        const btnRow2 = document.createElement('div');
        Object.assign(btnRow2.style, {
            display: 'flex',
            gap: '6px',
            marginTop: '2px'
        });

        const exportFileBtn = document.createElement('button');
        Object.assign(exportFileBtn.style, {
            flex: '1',
            padding: '3px 0',
            borderRadius: '4px',
            border: 'none',
            cursor: 'pointer',
            fontSize: '11px',
            background: '#2196f3',
            color: '#fff'
        });

        const exportCopyBtn = document.createElement('button');
        Object.assign(exportCopyBtn.style, {
            flex: '1',
            padding: '3px 0',
            borderRadius: '4px',
            border: 'none',
            cursor: 'pointer',
            fontSize: '11px',
            background: '#00bcd4',
            color: '#fff'
        });

        const importBtn = document.createElement('button');
        Object.assign(importBtn.style, {
            flex: '1',
            padding: '3px 0',
            borderRadius: '4px',
            border: 'none',
            cursor: 'pointer',
            fontSize: '11px',
            background: '#9c27b0',
            color: '#fff'
        });

        const importFileBtn = document.createElement('button');
        Object.assign(importFileBtn.style, {
            flex: '1',
            padding: '3px 0',
            borderRadius: '4px',
            border: 'none',
            cursor: 'pointer',
            fontSize: '11px',
            background: '#8bc34a',
            color: '#fff'
        });

        btnRow2.appendChild(exportFileBtn);
        btnRow2.appendChild(exportCopyBtn);
        btnRow2.appendChild(importBtn);
        btnRow2.appendChild(importFileBtn);

        panel.appendChild(titleEl);
        panel.appendChild(langRow);
        panel.appendChild(statusEl);
        panel.appendChild(progressEl);
        panel.appendChild(countdownEl);
        panel.appendChild(startTimeEl);
        panel.appendChild(runtimeEl);
        panel.appendChild(btnRow);
        panel.appendChild(btnRow2);

        document.body.appendChild(panel);

        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = '.json,application/json';
        fileInput.style.display = 'none';
        document.body.appendChild(fileInput);

        function formatHMS(sec) {
            const h = Math.floor(sec / 3600);
            const m = Math.floor((sec % 3600) / 60);
            const s = sec % 60;
            const pad = (n) => String(n).padStart(2, '0');
            return `${pad(h)}:${pad(m)}:${pad(s)}`;
        }

        function countDoneOnPage() {
            let c = 0;
            for (const u of allUsers) {
                if (processedIds[u.id]) c++;
            }
            return c;
        }

        function getNextPendingUser() {
            for (const u of allUsers) {
                if (!processedIds[u.id] && u.id !== currentProcessingId) return u;
            }
            return null;
        }

        function openNextUser() {
            const user = getNextPendingUser();
            if (!user) {
                // 這一頁沒帳號了,先看看有沒有下一頁
                const nextUrl = getNextPageUrl();

                if (nextUrl) {
                    console.log('[X Bulk] This page done, go next page:', nextUrl);

                    // 把目前這段執行時間加進 totalRunMs
                    if (running && lastRunStart) {
                        totalRunMs += Date.now() - lastRunStart;
                        lastRunStart = null;
                    }
                    
                    saveRunState();

                    // 不要關掉 running,讓 UI 保持「運行中」
                    statusEl.textContent = tr('statusDone');
                    countdownEl.textContent =
                        tr('nextLabel') + '3' + tr('secondsSuffix');

                    // 3 秒後自動跳轉下一頁
                    setTimeout(() => {
                        window.location.href = nextUrl;
                    }, 3000);

                    return;
                }

                // 真的沒有下一頁了,才停掉整個任務
                if (running && lastRunStart) {
                    totalRunMs += Date.now() - lastRunStart;
                    lastRunStart = null;
                }
                running = false;
                toggleBtn.textContent = tr('btnStart');
                toggleBtn.style.background = '#4caf50';
                statusEl.textContent = tr('statusDone');
                countdownEl.textContent = tr('nextNone');

                // ✅ 全部結束就把 runState 清掉(但記憶體裡的 totalRunMs 還在,畫面仍顯示)
                clearRunState();

                swalAlert(tr('pageAllDoneInfo'), 'success');
                return;
            }

            // --- opening profile ---
            currentProcessingId = user.id;
            lastOpenedAt = Date.now();

            try {
                const url = new URL(user.url);
                url.searchParams.set('xautoblock', '1');
                url.searchParams.set('xid', user.id);
                console.log('[X Bulk] opening:', user.id, url.toString());
                window.open(url.toString(), '_blank');
            } catch (e) {
                console.warn('[X Bulk] invalid URL for user', user, e);
            }
        }

        function updateUI() {
            // 如果一開始還沒 parse 到,再試一次
            if (totalPages === null || totalAccountsAll === null) {
                parseSubtitleMeta();
            }

            const now = Date.now();
            const total = allUsers.length;
            const done = countDoneOnPage();
            const pending = total - done;

            // 全域 done(跨頁,取自 localStorage 的 processedIds)
            const doneGlobal = Object.keys(processedIds).length;

            progressEl.textContent =
                tr('progressLabel') + `${done}/${total}` + tr('progressPageSuffix');

            let runMs = totalRunMs;
            if (running && lastRunStart) {
                runMs += now - lastRunStart;
            }

            if (!taskStartTime) {
                statusEl.textContent = tr('statusIdle');
                startTimeEl.textContent =
                    tr('taskStartLabel') + tr('taskStartUnknown');
                runtimeEl.textContent =
                    tr('runtimeLabel') + tr('runtimeUnknown');
            } else {
                const runSec = Math.floor(runMs / 1000);
                runtimeEl.textContent =
                    tr('runtimeLabel') + formatHMS(runSec);
                startTimeEl.textContent =
                    tr('taskStartLabel') +
                    new Date(taskStartTime).toLocaleTimeString();
            }

            // 小工具:在各種狀態文字後面加上「第幾頁 / 總共多少帳號」
            function buildStatusText(base) {
                let text = base;

                if (totalPages && currentPageIndex) {
                    text += ` · Page ${currentPageIndex}/${totalPages}`;
                }
                if (totalAccountsAll) {
                    const percentAll = ((doneGlobal / totalAccountsAll) * 100).toFixed(1);
                    text += ` · Total ${doneGlobal}/${totalAccountsAll} (${percentAll}%)`;
                }
                return text;
            }

            if (pending <= 0) {
                // 這一頁看起來處理完了,如果還在跑,就交給 openNextUser 判斷要不要跳下一頁
                if (running && !currentProcessingId) {
                    openNextUser();   // 裡面會自己判斷有沒有下一頁,沒有才真的全部結束
                } else {
                    statusEl.textContent = buildStatusText(tr('statusDone'));
                    countdownEl.textContent = tr('nextNone');
                }

                toggleBtn.disabled = false;
                return;
            }

            if (!running) {
                statusEl.textContent = buildStatusText(tr('statusPaused'));
            } else {
                statusEl.textContent = buildStatusText(tr('statusRunning'));
            }

            let remainingSec;
            if (!lastOpenedAt) {
                remainingSec = 0;
            } else {
                const elapsed = now - lastOpenedAt;
                const remainMs = Math.max(0, INTERVAL_MS - elapsed);
                remainingSec = Math.ceil(remainMs / 1000);
            }

            if (!running) {
                countdownEl.textContent =
                    tr('nextEstimateLabel') +
                    remainingSec +
                    tr('secondsSuffix') +
                    tr('nextPausedSuffix');
            } else {
                countdownEl.textContent =
                    tr('nextLabel') +
                    remainingSec +
                    tr('secondsSuffix');
                if (remainingSec <= 0 && !currentProcessingId) {
                    openNextUser();
                }
            }
        }

        function startBulk() {
            const total = allUsers.length;
            const done = countDoneOnPage();
            const pending = total - done;

            const now = Date.now();
            if (!taskStartTime) {
                taskStartTime = now;
            }

            if (!lastRunStart) {
                lastRunStart = now;
            }

            if (!lastOpenedAt) {
                // 讓第一次更新 UI 時,立刻有資格開第一個帳號
                lastOpenedAt = now - INTERVAL_MS;
            }

            running = true;
            toggleBtn.textContent = tr('btnPause');
            toggleBtn.style.background = '#ff9800';

            saveRunState();
            updateUI();   // ⬅ 這裡會看到 pending=0,就直接叫 openNextUser() 幫你跳下一頁
        }

        function pauseBulk() {
            const now = Date.now();
            if (running && lastRunStart) {
                totalRunMs += now - lastRunStart;
                lastRunStart = null;
            }
            running = false;
            toggleBtn.textContent = tr('btnStart');
            toggleBtn.style.background = '#4caf50';
            saveRunState();
            updateUI();
        }

        function buildExportJson() {
            const arr = Object.keys(processedIds);
            const payload = {
                version: 1,
                type: 'xBulkBlock_doneIds',
                ids: arr
            };
            return JSON.stringify(payload, null, 2);
        }

        function exportAsFile() {
            const json = buildExportJson();
            const blob = new Blob([json], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            const ts = new Date().toISOString().replace(/[:.]/g, '-');
            a.href = url;
            a.download = `xBulkBlock_doneIds_${ts}.json`;
            document.body.appendChild(a);
            a.click();
            setTimeout(() => {
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
            }, 0);
        }

        async function exportToClipboard() {
            const json = buildExportJson();
            if (navigator.clipboard && navigator.clipboard.writeText) {
                try {
                    await navigator.clipboard.writeText(json);
                    await swalAlert(tr('exportCopied'), 'success');
                    return;
                } catch (e) {
                    console.warn('[X Bulk] clipboard.writeText failed:', e);
                }
            }
            await swalPromptTextarea(tr('exportClipboardFail'), {
                defaultValue: json,
                title: tr('panelTitle')
            });
        }

        function parseImportJson(text) {
            let data;
            try {
                data = JSON.parse(text);
            } catch {
                return { error: tr('importJsonParseError') };
            }

            let idsArr = null;
            if (Array.isArray(data)) {
                idsArr = data;
            } else if (data && Array.isArray(data.ids)) {
                idsArr = data.ids;
            }

            if (!idsArr) {
                return { error: tr('importNoIdsError') };
            }

            const cleanIds = [];
            for (const v of idsArr) {
                if (typeof v === 'string' && v.length > 0) cleanIds.push(v);
            }
            if (!cleanIds.length) {
                return { error: tr('importEmptyIdsError') };
            }

            return { ids: cleanIds };
        }

        async function applyImportedIds(cleanIds) {
            const replace = await swalConfirm(
                tr('importConfirmReplace'),
                {
                    confirmText: tr('importConfirmReplaceOk'),
                    cancelText: tr('importConfirmReplaceCancel')
                }
            );

            if (replace) {
                processedIds = {};
            }

            for (const id of cleanIds) {
                processedIds[id] = true;
            }
            saveProcessedIds(processedIds);

            for (const u of allUsers) {
                if (processedIds[u.id]) {
                    markCardDone(u.card);
                } else {
                    clearCardStyle(u.card);
                }
            }

            updateUI();
            await swalAlert(
                trf('importDone', { count: cleanIds.length }),
                'success'
            );
        }

        async function importFromJsonPrompt() {
            const text = await swalPromptTextarea(
                tr('importPromptLabel'),
                {
                    title: tr('swalImportTitle'),
                    confirmText: tr('btnImportJson'),
                    cancelText: tr('btnCancel')
                }
            );
            if (!text) return;

            const result = parseImportJson(text);
            if (result.error) {
                await swalAlert(result.error, 'error');
                return;
            }

            await applyImportedIds(result.ids);
        }

        async function importFromFile(file) {
            try {
                const text = await file.text();
                const result = parseImportJson(text);
                if (result.error) {
                    await swalAlert(result.error, 'error');
                    return;
                }
                await applyImportedIds(result.ids);
            } catch (e) {
                console.warn('[X Bulk] importFromFile error:', e);
                await swalAlert(tr('importFileReadError'), 'error');
            }
        }

        function refreshStaticTexts() {
            titleEl.textContent = tr('panelTitle');
            langLabel.textContent = tr('languageLabel');
            toggleBtn.textContent = running ? tr('btnPause') : tr('btnStart');
            resetBtn.textContent = tr('btnReset');
            exportFileBtn.textContent = tr('btnExportFile');
            exportCopyBtn.textContent = tr('btnExportCopy');
            importBtn.textContent = tr('btnImportJson');
            importFileBtn.textContent = tr('btnImportFile');
        }

        toggleBtn.addEventListener('click', async () => {
            if (!running) {
                const total = allUsers.length;
                const done = countDoneOnPage();
                const pending = total - done;

                const ok = await swalConfirm(
                    trf('confirmStartMessage', { pending, done })
                );
                if (!ok) return;

                startBulk();
            } else {
                pauseBulk();
                await swalAlert(tr('pausedInfo'), 'info');
            }
        });

        resetBtn.addEventListener('click', async () => {
            const ok = await swalConfirm(
                tr('resetConfirm'),
                { icon: 'warning', confirmText: tr('resetBtnConfirmText'), cancelText: tr('resetBtnCancelText') }
            );
            if (!ok) return;

            processedIds = {};
            saveProcessedIds(processedIds);
            for (const u of allUsers) {
                clearCardStyle(u.card);
            }
            running = false;
            taskStartTime = null;
            lastOpenedAt = 0;
            totalRunMs = 0;
            lastRunStart = null;
            currentProcessingId = null;
            toggleBtn.textContent = tr('btnStart');
            toggleBtn.style.background = '#4caf50';
            toggleBtn.disabled = false;

            clearRunState();
            updateUI();
            await swalAlert(tr('resetDone'), 'success');
        });

        exportFileBtn.addEventListener('click', () => {
            exportAsFile();
        });

        exportCopyBtn.addEventListener('click', () => {
            exportToClipboard();
        });

        importBtn.addEventListener('click', () => {
            importFromJsonPrompt();
        });

        importFileBtn.addEventListener('click', () => {
            fileInput.value = '';
            fileInput.click();
        });

        fileInput.addEventListener('change', () => {
            const file = fileInput.files && fileInput.files[0];
            if (!file) return;
            importFromFile(file);
        });

        langSelect.addEventListener('change', () => {
            currentLang = langSelect.value;
            try {
                localStorage.setItem('xAutoBlock_lang', currentLang);
            } catch (e) {
                // ignore
            }
            refreshStaticTexts();
            updateUI();
        });

        // 先還原跨頁 state,再根據目前 running/時間 等來畫畫面
        restoreRunState();
        refreshStaticTexts();
        updateUI();
        uiTimerId = setInterval(updateUI, 1000);

        window.addEventListener('beforeunload', () => {
            if (uiTimerId) clearInterval(uiTimerId);
        });

        ensureSweetAlert();
    }

    /* -------------------------------------------------
     * 依 host 分流初始化
     * ------------------------------------------------- */

    if (HOST === 'x.com' || HOST === 'twitter.com') {
        initOnTwitter();
        ensureSweetAlert();
    } else if (HOST === 'pluto0x0.github.io') {
        initOnListPage();
        ensureSweetAlert();
    }
})();