Fab API-Driven Helper

Automates acquiring free assets from Fab.com using its internal API, with a modern UI.

As of 2025-07-18. See the latest version.

// ==UserScript==
// @name         Fab API-Driven Helper
// @name:en      Fab API-Driven Helper
// @name:zh      Fab API 驱动助手
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Automates acquiring free assets from Fab.com using its internal API, with a modern UI.
// @description:en Automates acquiring free assets from Fab.com using its internal API, with a modern UI.
// @description:zh 通过调用内部API,自动化获取Fab.com上的免费资源,并配有现代化的UI。
// @author       gpt-4 & user & Gemini
// @match        https://www.fab.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// @grant        GM_xmlhttpRequest
// @grant        GM_webRequest
// @grant        unsafeWindow
// @grant        window.close
// @connect      api.fab.com
// @connect      www.fab.com
// ==/UserScript==

(function () {
    'use strict';

    // --- 模块一: 配置与常量 (Config & Constants) ---
    const Config = {
        SCRIPT_NAME: '[Fab API-Driven Helper v1.0.0]',
        UI_CONTAINER_ID: 'fab-helper-container-v8',
        DB_KEYS: {
            TODO: 'fab_todoList_v8',
            DONE: 'fab_doneList_v8',
            FAILED: 'fab_failedList_v8', // For items that failed processing
            HIDE: 'fab_hideSaved_v8',
            TASK: 'fab_activeDetailTask_v8',
            NEXT_URL: 'fab_reconNextUrl_v8', // REPLACES CURSOR
            DETAIL_LOG: 'fab_detailLog_v8', // For worker tab remote logging
        },
        SELECTORS: {
            card: 'div.fabkit-Stack-root.nTa5u2sc, div.AssetCard-root',
            cardLink: 'a[href*="/listings/"]',
            addButton: 'button[aria-label*="Add to"], button[aria-label*="添加至"], button[aria-label*="cart"]',
            rootElement: '#root',
            successBanner: 'div[class*="Toast-root"]'
        },
        TEXTS: {
            en: { hide: 'Hide', show: 'Show', recon: 'Recon', reconning: 'Reconning...', execute: 'Start Tasks', executing: 'Executing...', stopExecute: 'Stop', added: 'Added', failed: 'Failed', todo: 'To-Do', clearLog: 'Clear Log', copyLog: 'Copy Log', copied: 'Copied!', refresh: 'Refresh State', resetRecon: 'Reset Recon', log_init: 'Assistant is online!', log_db_loaded: 'Reading archive...', log_exec_no_tasks: 'To-Do list is empty.', log_recon_start: 'Starting scan for new items...', log_recon_end: 'Scan complete!', log_task_added: 'Found new item:', log_api_request: 'Requesting page data (Page: %page%). Scanned: %scanned%, Owned: %owned%...', log_api_owned_check: 'Checking ownership for %count% items...', log_api_owned_done: 'Ownership check complete. Found %newCount% new items.', log_verify_success: 'Verified and added to library!', log_verify_fail: "Couldn't add. Will retry later.", log_429_error: 'Request limit hit! Taking a 15s break...', log_recon_error: 'An error occurred during recon cycle:', goto_page_label: 'Page:', goto_page_btn: 'Go', retry_failed: 'Retry Failed' },
            zh: { hide: '隐藏', show: '显示', recon: '侦察', reconning: '侦察中...', execute: '启动任务', executing: '执行中...', stopExecute: '停止', added: '已添加', failed: '失败', todo: '待办', clearLog: '清空日志', copyLog: '复制日志', copied: '已复制!', refresh: '刷新状态', resetRecon: '重置进度', log_init: '助手已上线!', log_db_loaded: '正在读取存档...', log_exec_no_tasks: '"待办"清单是空的。', log_recon_start: '开始扫描新宝贝...', log_recon_end: '扫描完成!', log_task_added: '发现一个新宝贝:', log_api_request: '正在请求页面数据 (页码: %page%)。已扫描: %scanned%,已拥有: %owned%...', log_api_owned_check: '正在批量验证 %count% 个项目的所有权...', log_api_owned_done: '所有权验证完毕,发现 %newCount% 个全新项目!', log_verify_success: '搞定!已成功入库。', log_verify_fail: '哎呀,这个没加上。稍后会自动重试!', log_429_error: '请求太快被服务器限速了!休息15秒后自动重试...', log_recon_error: '侦察周期中发生严重错误:', goto_page_label: '页码:', goto_page_btn: '跳转', retry_failed: '重试失败' }
        },
        // Centralized keyword sets, based STRICTLY on the rules in FAB_HELPER_RULES.md
        OWNED_SUCCESS_CRITERIA: {
            // Check for an H2 tag with the specific success text.
            h2Text: ['已保存在我的库中', 'Saved in My Library'], 
            // Check for buttons/links with these texts.
            buttonTexts: ['在我的库中查看', 'View in My Library']
        },
        ACQUISITION_TEXT_SET: new Set(['添加到我的库', 'Add to my library']),

        // Kept for backward compatibility with recon logic.
        SAVED_TEXT_SET: new Set(['已保存在我的库中', 'Saved in My Library', '在我的库中', 'In My Library']),
        FREE_TEXT_SET: new Set(['免费', 'Free', '起始价格 免费']),
    };

    // --- 模块二: 全局状态管理 (Global State) ---
    const State = {
        lang: 'en',
        isInitialized: false,
        hideSaved: false,
        hiddenThisPageCount: 0,
        isReconning: false,
        isExecuting: false,
        reconScannedCount: 0,
        reconOwnedCount: 0,
        debounceTimer: null,
        db: {
            todo: [],
            done: [],
            failed: []
        },
        ui: { // To be populated by the UI module
            container: null,
            logPanel: null,
            statusDisplay: null,
            execBtn: null,
            hideBtn: null,
            seekBtn: null,
            reconBtn: null,
            retryBtn: null, // For the new button
            refreshBtn: null, // For the API refresh button
            resetReconBtn: null, // New button
            batchInput: null,
            reconProgressDisplay: null, // Replaces pageInput
        },
        valueChangeListeners: []
    };

    // --- 模块三: 日志与工具函数 (Logger & Utilities) ---
    const Utils = {
        logger: (type, ...args) => {
            console[type](`${Config.SCRIPT_NAME}`, ...args);
            // The actual logging to screen will be handled by the UI module
            // to keep modules decoupled.
            if (State.ui.logPanel) {
                const logEntry = document.createElement('div');
                logEntry.style.cssText = 'padding: 2px 4px; border-bottom: 1px solid #444; font-size: 11px;';
                const timestamp = new Date().toLocaleTimeString();
                logEntry.innerHTML = `<span style="color: #888;">[${timestamp}]</span> ${args.join(' ')}`;
                State.ui.logPanel.prepend(logEntry);
                while (State.ui.logPanel.children.length > 100) {
                    State.ui.logPanel.removeChild(State.ui.logPanel.lastChild);
                }
            }
        },
        getText: (key, replacements = {}) => {
            let text = (Config.TEXTS[State.lang]?.[key]) || (Config.TEXTS['en']?.[key]) || '';
            for (const placeholder in replacements) {
                text = text.replace(`%${placeholder}%`, replacements[placeholder]);
            }
            return text;
        },
        detectLanguage: () => {
            State.lang = window.location.href.includes('/zh-cn/') ? 'zh' : 'en';
        },
        waitForElement: (selector, timeout = 5000) => {
            return new Promise((resolve, reject) => {
                const interval = setInterval(() => {
                    const element = document.querySelector(selector);
                    if (element) {
                        clearInterval(interval);
                        resolve(element);
                    }
                }, 100);
                setTimeout(() => {
                    clearInterval(interval);
                    reject(new Error(`Timeout waiting for selector: ${selector}`));
                }, timeout);
            });
        },
        waitForButtonEnabled: (button, timeout = 5000) => {
            return new Promise((resolve, reject) => {
                const interval = setInterval(() => {
                    if (button && !button.disabled) {
                        clearInterval(interval);
                        resolve();
                    }
                }, 100);
                setTimeout(() => {
                    clearInterval(interval);
                    reject(new Error('Timeout waiting for button to be enabled.'));
                }, timeout);
            });
        },
        // This function is now for UI display purposes only.
        getDisplayPageFromUrl: (url) => {
            if (!url) return '1';
            try {
                const urlParams = new URLSearchParams(new URL(url).search);
                const cursor = urlParams.get('cursor');
                if (!cursor) return '1';
                
                // Try to decode offset-based cursors for a nice page number display.
                if (cursor.startsWith('bz')) {
                    const decoded = atob(cursor);
                    const offsetMatch = decoded.match(/o=(\d+)/);
                    if (offsetMatch && offsetMatch[1]) {
                        const offset = parseInt(offsetMatch[1], 10);
                        const pageSize = 24;
                        const pageNum = Math.round((offset / pageSize) + 1);
                        return pageNum.toString();
                    }
                }
                // For timestamp-based cursors, we can't calculate a page number.
                return 'Cursor Mode';
            } catch (e) {
                return '...';
            }
        },
        getCookie: (name) => {
            const value = `; ${document.cookie}`;
            const parts = value.split(`; ${name}=`);
            if (parts.length === 2) return parts.pop().split(';').shift();
            return null;
        },
        // Simulates a more forceful click by dispatching mouse events, which can succeed
        // where a simple .click() is ignored by a framework's event handling.
        deepClick: (element) => {
            if (!element) return;
            // A small delay to ensure the browser's event loop is clear and any framework
            // event listeners on the element have had a chance to attach.
            setTimeout(() => {
            const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

            Utils.logger('info', `Performing deep click on element: <${element.tagName.toLowerCase()} class="${element.className}">`);
                
                // Add pointerdown for modern frameworks
                const pointerDownEvent = new PointerEvent('pointerdown', { view: pageWindow, bubbles: true, cancelable: true });
            const mouseDownEvent = new MouseEvent('mousedown', { view: pageWindow, bubbles: true, cancelable: true });
            const mouseUpEvent = new MouseEvent('mouseup', { view: pageWindow, bubbles: true, cancelable: true });
                
                element.dispatchEvent(pointerDownEvent);
            element.dispatchEvent(mouseDownEvent);
            element.dispatchEvent(mouseUpEvent);
            // Also trigger the standard click for maximum compatibility.
            element.click();
            }, 50); // 50ms delay
        }
    };

    // --- 模块四: 异步网络请求 (Promisified GM_xmlhttpRequest) ---
    const API = {
        gmFetch: (options) => {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    anonymous: false, // Default to false to ensure cookies are sent
                    ...options,
                    onload: (response) => resolve(response),
                    onerror: (error) => reject(new Error(`GM_xmlhttpRequest error: ${error.statusText || 'Unknown Error'}`)),
                    ontimeout: () => reject(new Error('Request timed out.')),
                    onabort: () => reject(new Error('Request aborted.'))
                });
            });
        },
        // ... Other API-related functions will go here ...
    };


    // --- 模块五: 数据库交互 (Database Interaction) ---
    const Database = {
        load: async () => {
            State.db.todo = await GM_getValue(Config.DB_KEYS.TODO, []);
            State.db.done = await GM_getValue(Config.DB_KEYS.DONE, []);
            State.db.failed = await GM_getValue(Config.DB_KEYS.FAILED, []);
            State.hideSaved = await GM_getValue(Config.DB_KEYS.HIDE, false);
            Utils.logger('info', Utils.getText('log_db_loaded'), `To-Do: ${State.db.todo.length}, Done: ${State.db.done.length}, Failed: ${State.db.failed.length}`);
        },
        saveTodo: () => GM_setValue(Config.DB_KEYS.TODO, State.db.todo),
        saveDone: () => GM_setValue(Config.DB_KEYS.DONE, State.db.done),
        saveFailed: () => GM_setValue(Config.DB_KEYS.FAILED, State.db.failed),
        saveHidePref: () => GM_setValue(Config.DB_KEYS.HIDE, State.hideSaved),

        isDone: (url) => {
            if (!url) return false;
            return State.db.done.includes(url.split('?')[0]);
        },
        isTodo: (url) => {
             if (!url) return false;
            const cleanUrl = url.split('?')[0];
            return State.db.todo.some(task => task.url === cleanUrl);
        },
        markAsDone: async (task) => {
            if (!task || !task.uid) {
                Utils.logger('error', 'Debug: markAsDone received invalid task:', JSON.stringify(task));
                return;
            }

            Utils.logger('info', `Debug: Task to remove: UID=${task.uid}`);
            const initialTodoCount = State.db.todo.length;
            Utils.logger('info', `Debug: To-Do count before: ${initialTodoCount}`);

            State.db.todo = State.db.todo.filter(t => t.uid !== task.uid);

            if (State.db.todo.length === initialTodoCount && initialTodoCount > 0) {
                Utils.logger('warn', 'Debug: FILTER FAILED! UID not found in To-Do list.');
                const uidsInState = State.db.todo.map(t => t.uid).slice(0, 10).join(', '); // show first 10
                Utils.logger('info', `Debug: First 10 UIDs in To-Do list are: [${uidsInState}]`);
            }

            Utils.logger('info', `Debug: To-Do count after: ${State.db.todo.length}`);
            
            let changed = State.db.todo.length < initialTodoCount;

            // The 'done' list can still use URLs for simplicity, as it's for display/hiding.
            const cleanUrl = task.url.split('?')[0];
            if (!Database.isDone(cleanUrl)) {
                State.db.done.push(cleanUrl);
                changed = true;
            }

            if (changed) {
                await Promise.all([Database.saveTodo(), Database.saveDone()]);
            }
        },
        markAsFailed: async (task) => {
            if (!task || !task.uid) {
                Utils.logger('error', 'Debug: markAsFailed received invalid task:', JSON.stringify(task));
                return;
            }

            // Remove from todo
            const initialTodoCount = State.db.todo.length;
            State.db.todo = State.db.todo.filter(t => t.uid !== task.uid);
            let changed = State.db.todo.length < initialTodoCount;

            // Add to failed, ensuring no duplicates by UID
            if (!State.db.failed.some(f => f.uid === task.uid)) {
                State.db.failed.push(task); // Store the whole task object for potential retry
                changed = true;
            }

            if (changed) {
                await Promise.all([Database.saveTodo(), Database.saveFailed()]);
            }
        },
    };

    // --- 模块六: 网络请求过滤器 (Network Filter) ---
    const NetworkFilter = {
        init: () => {
            // This feature requires Tampermonkey v4.12+ or a manager supporting GM_webRequest.
            if (typeof GM_webRequest === 'undefined') {
                Utils.logger('warn', 'Resource blocking is disabled (GM_webRequest API not found).');
                return;
            }

            Utils.logger('info', 'Initializing domain-specific network filter for fab.com.');

            const resourceTypesToBlock = new Set(['image', 'media', 'font']);

            try {
                GM_webRequest(
                    [
                        // Rule #6: This selector is now domain-specific. It will only match requests
                        // to fab.com and its subdomains (like www.fab.com, cdn.fab.com, etc.).
                        { selector: '*://*.fab.com/*', action: 'cancel' }
                    ],
                    (info, message, details) => {
                        // Because the selector already filtered by domain, we only need to check the type.
                        if (resourceTypesToBlock.has(details.type)) {
                            // Add logging for transparency, so the user knows the filter is working.
                            Utils.logger('info', `Blocking resource [${details.type}]: ${details.url}`);
                            // Cancel the request if its type is in our block set.
                            return { cancel: true };
                        }
                        // For any other request type to fab.com (like 'script', 'xhr'), we do nothing.
                    }
                );
            } catch (e) {
                 Utils.logger('error', 'Failed to initialize GM_webRequest filter:', e.message);
            }
        }
    };


    // --- 模块七: 任务运行器与事件处理 (Task Runner & Event Handlers) ---
    const TaskRunner = {
        // --- Toggles ---
        toggleRecon: async () => {
            State.isReconning = !State.isReconning;
            UI.update();
            if (State.isReconning) {
                State.reconScannedCount = 0;
                State.reconOwnedCount = 0;
                Utils.logger('info', Utils.getText('log_recon_start'));
                const nextUrl = await GM_getValue(Config.DB_KEYS.NEXT_URL, null);
                if (nextUrl) {
                    Utils.logger('info', `Resuming recon from saved URL.`);
                }
                TaskRunner.reconWithApi(nextUrl);
            } else {
                Utils.logger('info', 'Reconnaissance stopped by user.');
            }
        },
        // This is now the dedicated function for starting the execution loop.
        // It ensures the main page never navigates away.
        startExecution: () => {
            if (State.isExecuting) {
                Utils.logger('info', '执行器已在运行中,新任务已加入队列等待处理。');
                return;
            }
            if (State.db.todo.length === 0) {
                Utils.logger('info', '“待办”清单是空的,无需启动。');
                return;
            }
            Utils.logger('info', '队列中有任务,即将开始执行...');
            State.isExecuting = true;
            UI.update();
            TaskRunner.executeBatch();
        },

        // This function is for the main UI button to toggle start/stop.
        toggleExecution: () => {
            if (State.isExecuting) {
                State.isExecuting = false;
                GM_deleteValue(Config.DB_KEYS.TASK); // Stop all workers
                Utils.logger('info', '执行已由用户手动停止。');
            } else {
                TaskRunner.startExecution();
            }
            UI.update();
        },
        toggleHideSaved: async () => {
            State.hideSaved = !State.hideSaved;
            await Database.saveHidePref();
            TaskRunner.runHideOrShow();
        },

        resetReconProgress: async () => {
            if (State.isReconning) {
                Utils.logger('warn', 'Cannot reset progress while recon is active.');
                return;
            }
            await GM_deleteValue(Config.DB_KEYS.NEXT_URL);
            if (State.ui.reconProgressDisplay) {
                State.ui.reconProgressDisplay.textContent = 'Page: 1';
            }
            Utils.logger('info', 'Recon progress has been reset. Next scan will start from the beginning.');
        },

        refreshVisibleStates: async () => {
            const API_ENDPOINT = 'https://www.fab.com/i/users/me/listings-states';
            const CARD_SELECTOR = 'div.fabkit-Stack-root.nTa5u2sc, div.AssetCard-root';
            const LINK_SELECTOR = 'a[href*="/listings/"]';
            const CSRF_COOKIE_NAME = 'fab_csrftoken';
            
            // Selectors for the part of the card that shows the price/owned status
            const FREE_STATUS_SELECTOR = '.csZFzinF'; // The container for the "免费" text
            const OWNED_STATUS_SELECTOR = '.cUUvxo_s'; // The container for the "已保存..." text

            Utils.logger('info', '[Fab DOM Refresh] Starting for VISIBLE items...');

            // --- DOM Creation Helpers ---
            const createOwnedElement = () => {
                const ownedDiv = document.createElement('div');
                ownedDiv.className = 'fabkit-Typography-root fabkit-Typography--align-start fabkit-Typography--intent-success fabkit-Text--sm fabkit-Text--regular fabkit-Stack-root fabkit-Stack--align_center fabkit-scale--gapX-spacing-1 fabkit-scale--gapY-spacing-1 cUUvxo_s';
                
                const icon = document.createElement('i');
                icon.className = 'fabkit-Icon-root fabkit-Icon--intent-success fabkit-Icon--xs edsicon edsicon-check-circle-filled';
                icon.setAttribute('aria-hidden', 'true');
                
                ownedDiv.appendChild(icon);
                ownedDiv.append('已保存在我的库中');
                return ownedDiv;
            };

            const createFreeElement = () => {
                const freeContainer = document.createElement('div');
                freeContainer.className = 'fabkit-Stack-root fabkit-Stack--align_center fabkit-scale--gapX-spacing-2 fabkit-scale--gapY-spacing-2 csZFzinF';
                const innerStack = document.createElement('div');
                innerStack.className = 'fabkit-Stack-root fabkit-scale--gapX-spacing-1 fabkit-scale--gapY-spacing-1 J9vFXlBh';
                const freeText = document.createElement('div');
                freeText.className = 'fabkit-Typography-root fabkit-Typography--align-start fabkit-Typography--intent-primary fabkit-Text--sm fabkit-Text--regular';
                freeText.textContent = '免费';
                innerStack.appendChild(freeText);
                freeContainer.appendChild(innerStack);
                return freeContainer;
            };
            
            const isElementInViewport = (el) => {
                if (!el) return false;
                const rect = el.getBoundingClientRect();
                return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
            };

            // --- Main Logic ---
            try {
                const csrfToken = Utils.getCookie(CSRF_COOKIE_NAME);
                if (!csrfToken) throw new Error('CSRF token not found. Are you logged in?');

                const visibleCards = [...document.querySelectorAll(CARD_SELECTOR)].filter(isElementInViewport);
                const uidToCardMap = new Map();
                
                visibleCards.forEach(card => {
                    const link = card.querySelector(LINK_SELECTOR);
                    if (link) {
                        const match = link.href.match(/listings\/([a-f0-9-]+)/);
                        if (match && match[1]) uidToCardMap.set(match[1], card);
                    }
                });

                const uidsToQuery = [...uidToCardMap.keys()];
                if (uidsToQuery.length === 0) {
                    Utils.logger('info', '[Fab DOM Refresh] No visible items to check.');
                    return;
                }
                Utils.logger('info', `[Fab DOM Refresh] Found ${uidsToQuery.length} visible items. Querying API...`);

                const apiUrl = new URL(API_ENDPOINT);
                uidsToQuery.forEach(uid => apiUrl.searchParams.append('listing_ids', uid));

                // Use fetch directly as it's a simple GET request with standard headers.
                const response = await fetch(apiUrl.href, {
                    headers: { 'accept': 'application/json, text/plain, */*', 'x-csrftoken': csrfToken, 'x-requested-with': 'XMLHttpRequest' }
                });

                if (!response.ok) throw new Error(`API request failed with status: ${response.status}`);
                const data = await response.json();
                
                const ownedUids = new Set(data.filter(item => item.acquired).map(item => item.uid));
                Utils.logger('info', `[Fab DOM Refresh] API reports ${ownedUids.size} owned items in this batch.`);

                let updatedCount = 0;
                uidToCardMap.forEach((card, uid) => {
                    const isOwned = ownedUids.has(uid);
                    
                    if (isOwned) {
                        const freeElement = card.querySelector(FREE_STATUS_SELECTOR);
                        if (freeElement) { // If it currently shows "Free", replace it.
                            freeElement.replaceWith(createOwnedElement());
                            updatedCount++;
                        }
                    } else { // Item is not owned
                        const ownedElement = card.querySelector(OWNED_STATUS_SELECTOR);
                        if (ownedElement) { // If it currently shows "Owned", replace it.
                            ownedElement.replaceWith(createFreeElement());
                            updatedCount++;
                        }
                    }
                });

                Utils.logger('info', `[Fab DOM Refresh] Complete. Updated ${updatedCount} card states.`);

                // 刷新后自动执行隐藏/显示逻辑,保证 UI 实时同步
                TaskRunner.runHideOrShow();

            } catch (e) {
                Utils.logger('error', '[Fab DOM Refresh] An error occurred:', e);
                alert('API 刷新失败。请检查控制台中的错误信息,并确认您已登录。');
            }
        },

        retryFailedTasks: async () => {
            if (State.db.failed.length === 0) {
                Utils.logger('info', 'No failed tasks to retry.');
                return;
            }
            const count = State.db.failed.length;
            Utils.logger('info', `Re-queuing ${count} failed tasks...`);
            State.db.todo.push(...State.db.failed); // Append failed tasks to the end of the todo list
            State.db.failed = []; // Clear the failed list
            await Promise.all([Database.saveTodo(), Database.saveFailed()]);
            Utils.logger('info', `${count} tasks moved from Failed to To-Do list.`);
            UI.update(); // Force immediate UI update
        },

        // --- Core Logic Functions ---
        reconWithApi: async (url = null) => {
            if (!State.isReconning) return;

            let searchResponse = null;
            
            // If no URL is provided, start from the beginning.
            const requestUrl = url || `https://www.fab.com/i/listings/search?is_free=1&sort_by=-relevance&page_size=24`;

            try {
                const csrfToken = Utils.getCookie('fab_csrftoken');
                if (!csrfToken) {
                    Utils.logger('error', "CSRF token not found. Please ensure you are logged in.");
                    State.isReconning = false;
                    UI.update();
                    return;
                }

                const langPath = State.lang === 'zh' ? '/zh-cn' : '';

                const apiHeaders = {
                    'accept': 'application/json, text/plain, */*',
                    'x-csrftoken': csrfToken,
                    'x-requested-with': 'XMLHttpRequest',
                    'Referer': window.location.href,
                    'User-Agent': navigator.userAgent
                };

                // --- Step 1: Initial Scan ---
                const displayPage = Utils.getDisplayPageFromUrl(requestUrl);
                // UX Improvement: Update the progress display.
                if (State.ui.reconProgressDisplay) {
                    State.ui.reconProgressDisplay.textContent = `Page: ${displayPage}`;
                }
                
                Utils.logger('info', "Step 1: " + Utils.getText('log_api_request', {
                    page: displayPage,
                    scanned: State.reconScannedCount,
                    owned: State.reconOwnedCount
                }));
                searchResponse = await API.gmFetch({ method: 'GET', url: requestUrl, headers: apiHeaders });

                if (searchResponse.finalUrl && new URL(searchResponse.finalUrl).pathname !== new URL(requestUrl).pathname) {
                    Utils.logger('warn', `Request was redirected, which may indicate a login issue. Final URL: ${searchResponse.finalUrl}`);
                }
                
                if (searchResponse.status === 429) {
                    Utils.logger('error', Utils.getText('log_429_error'));
                    await new Promise(r => setTimeout(r, 15000));
                    TaskRunner.reconWithApi(requestUrl); // Retry with the same URL
                    return;
                }
                
                const searchData = JSON.parse(searchResponse.responseText);
                const initialResultsCount = searchData.results.length;
                State.reconScannedCount += initialResultsCount;

                if (!searchData.results || initialResultsCount === 0) {
                    State.isReconning = false;
                    await GM_deleteValue(Config.DB_KEYS.NEXT_URL); // Recon is complete, delete the key.
                    Utils.logger('info', Utils.getText('log_recon_end'));
                    UI.update();
                    return;
                }

                // A much stricter filter to ensure we only process valid, complete item data from the API.
                const validResults = searchData.results.filter(item => {
                    const hasUid = typeof item.uid === 'string' && item.uid.length > 5;
                    const hasTitle = typeof item.title === 'string' && item.title.length > 0;
                    const hasOffer = item.startingPrice && typeof item.startingPrice.offerId === 'string' && item.startingPrice.offerId.length > 0;
                    return hasUid && hasTitle && hasOffer;
                });

                const candidates = validResults.map(item => ({
                    uid: item.uid,
                    // The API structure changed. The offerId is now in startingPrice.
                    offerId: item.startingPrice?.offerId
                })).filter(item => {
                    // This secondary filter only checks against our local database.
                    const itemUrl = `${window.location.origin}${langPath}/listings/${item.uid}`;
                    const isFailed = State.db.failed.some(failedTask => failedTask.uid === item.uid);
                    return !Database.isDone(itemUrl) && !Database.isTodo(itemUrl) && !isFailed;
                });

                const initiallySkippedCount = initialResultsCount - candidates.length;
                State.reconOwnedCount += initiallySkippedCount;

                if (candidates.length === 0) {
                    // No new candidates on this page, go to next page
                    const nextUrl = searchData.next;
                    if (nextUrl && State.isReconning) {
                        await GM_setValue(Config.DB_KEYS.NEXT_URL, nextUrl);
                        await new Promise(r => setTimeout(r, 300));
                        TaskRunner.reconWithApi(nextUrl);
                    } else {
                         State.isReconning = false;
                         await GM_deleteValue(Config.DB_KEYS.NEXT_URL); // Recon is complete, delete the key.
                         Utils.logger('info', Utils.getText('log_recon_end'));
                    }
                    UI.update();
                    return;
                }

                // --- Step 2: Ownership Check ---
                Utils.logger('info', `Step 2: Checking ownership for ${candidates.length} candidates...`);
                const statesUrl = new URL('https://www.fab.com/i/users/me/listings-states');
                candidates.forEach(item => statesUrl.searchParams.append('listing_ids', item.uid));
                const statesResponse = await API.gmFetch({ method: 'GET', url: statesUrl.href, headers: apiHeaders });
                const statesData = JSON.parse(statesResponse.responseText);

                // API returns an array, convert it to a Set for efficient lookup.
                const ownedUids = new Set(statesData.filter(s => s.acquired).map(s => s.uid));

                const notOwnedItems = [];
                candidates.forEach(item => {
                    if (!ownedUids.has(item.uid)) {
                        notOwnedItems.push(item);
                    } else {
                        // This item is already owned according to the API, so we increment the owned count.
                        State.reconOwnedCount++;
                    }
                });

                if (notOwnedItems.length === 0) {
                    Utils.logger('info', "No unowned items found in this batch.");
                } else {
                    // --- Step 3: Price Verification ---
                    Utils.logger('info', `Step 3: Verifying prices for ${notOwnedItems.length} unowned items...`);
                    const pricesUrl = new URL('https://www.fab.com/i/listings/prices-infos');
                    notOwnedItems.forEach(item => pricesUrl.searchParams.append('offer_ids', item.offerId));
                    const pricesResponse = await API.gmFetch({ method: 'GET', url: pricesUrl.href, headers: apiHeaders });
                    const pricesData = JSON.parse(pricesResponse.responseText);

                    // API returns { offers: [...] }, convert it to a Map for efficient lookup.
                    const priceMap = new Map();
                    if (pricesData.offers && Array.isArray(pricesData.offers)) {
                         pricesData.offers.forEach(offer => priceMap.set(offer.offerId, offer));
                    }

                    const newTasks = [];
                    notOwnedItems.forEach(item => {
                        const priceInfo = priceMap.get(item.offerId);
                        if (priceInfo && priceInfo.price === 0) {
                            const task = { 
                                url: `${window.location.origin}${langPath}/listings/${item.uid}`, 
                                type: 'detail', 
                                uid: item.uid 
                            };
                            newTasks.push(task);
                        }
                    });

                    if (newTasks.length > 0) {
                        Utils.logger('info', Utils.getText('log_api_owned_done', { newCount: newTasks.length }));
                        State.db.todo = State.db.todo.concat(newTasks);
                        await Database.saveTodo();
                    } else {
                        Utils.logger('info', "Found unowned items, but none were truly free after price check.");
                    }
                }


                // --- Pagination ---
                const nextUrl = searchData.next;
                if (nextUrl && State.isReconning) {
                    await GM_setValue(Config.DB_KEYS.NEXT_URL, nextUrl);
                    await new Promise(r => setTimeout(r, 500)); // Rate limit
                    TaskRunner.reconWithApi(nextUrl);
                } else {
                    State.isReconning = false;
                    await GM_deleteValue(Config.DB_KEYS.NEXT_URL); // Recon is complete, delete the key.
                    Utils.logger('info', Utils.getText('log_recon_end'));
                }

            } catch (error) {
                Utils.logger('error', Utils.getText('log_recon_error'), error.message);
                
                if (error instanceof SyntaxError && searchResponse?.responseText.trim().startsWith('<')) {
                    const responseSample = searchResponse.responseText.replace(/</g, '&lt;').substring(0, 500);
                    Utils.logger('error', "侦察失败:API没有返回有效数据,可能您已退出登录或网站正在维护。请尝试刷新页面或重新登录。");
                    Utils.logger('error', "Recon failed: The API returned HTML instead of JSON. You might be logged out or the site could be under maintenance. Please try refreshing or logging in again.");
                    Utils.logger('info', "API Response HTML (sample): " + responseSample);
                }

                State.isReconning = false;
            } finally {
                UI.update();
            }
        },

        executeBatch: async () => {
            if (!State.isExecuting) return;

            const batch = State.db.todo;
            if (batch.length === 0) {
                Utils.logger('info', 'All tasks completed!');
                State.isExecuting = false;
                UI.update();
                return;
            }
            // In this refactored version, all tasks are 'detail' tasks.
            const detailTasks = batch.filter(t => t.type === 'detail');
            if (detailTasks.length > 0) {
                const detailTaskPayload = {
                    batch: detailTasks,
                    currentIndex: 0
                };
                await GM_setValue(Config.DB_KEYS.TASK, detailTaskPayload);
                window.open(detailTaskPayload.batch[0].url, '_blank').focus();
            } else if (State.isExecuting) {
                setTimeout(TaskRunner.executeBatch, 1000);
            }
        },

        processDetailPage: async () => {
            const logBuffer = [`Task started on: ${window.location.href}`];
            // BUG FIX #2: Read the task payload ONCE at the beginning and use it throughout.
            // This prevents race conditions where the value in GM storage might change during execution.
            const taskPayload = await GM_getValue(Config.DB_KEYS.TASK);
            const currentTask = taskPayload?.batch?.[taskPayload?.currentIndex];

            if (!currentTask || !currentTask.uid) {
                logBuffer.push(`CRITICAL ERROR: Could not retrieve current task from GM_getValue or task is invalid.`);
                // Pass the potentially null taskPayload, advanceDetailTask must handle it gracefully.
                await TaskRunner.advanceDetailTask(taskPayload, false, logBuffer);
                return;
            }

            // --- New API-First Ownership Check ---
            try {
                const csrfToken = Utils.getCookie('fab_csrftoken');
                 if (!csrfToken) throw new Error("CSRF token not found for API check.");

                const statesUrl = new URL('https://www.fab.com/i/users/me/listings-states');
                statesUrl.searchParams.append('listing_ids', currentTask.uid);
                
                const response = await API.gmFetch({
                    method: 'GET',
                    url: statesUrl.href,
                    headers: { 'x-csrftoken': csrfToken, 'x-requested-with': 'XMLHttpRequest' }
                });

                const statesData = JSON.parse(response.responseText);
                const isOwned = statesData.some(s => s.uid === currentTask.uid && s.acquired);

                if (isOwned) {
                    logBuffer.push(`API check confirms item is already owned. Marking as success.`);
                    await TaskRunner.advanceDetailTask(taskPayload, true, logBuffer);
                    return; // Stop further processing
                }
                logBuffer.push(`API check confirms item is not owned. Proceeding with UI interaction.`);

            } catch (apiError) {
                logBuffer.push(`API ownership check failed: ${apiError.message}. Falling back to UI-based check.`);
            }
            // --- End of API Check ---


            // This function is the single source of truth for checking the "owned" state.
            // It adheres STRICTLY to the rules defined in FAB_HELPER_RULES.md.
            const isItemOwned = () => {
                const criteria = Config.OWNED_SUCCESS_CRITERIA;
                
                // Rule 1: Look for the H2 success message.
                const successHeader = document.querySelector('h2');
                if (successHeader && criteria.h2Text.some(text => successHeader.textContent.includes(text))) {
                    return { owned: true, reason: `H2 text "${successHeader.textContent}"` };
                }

                // Rule 1: Look for the "View in My Library" button.
                // NOTE: "Download" is explicitly IGNORED as per Rule 2.
                const allButtons = [...document.querySelectorAll('button, a.fabkit-Button-root')];
                const ownedButton = allButtons.find(btn => 
                    criteria.buttonTexts.some(keyword => btn.textContent.includes(keyword))
                );
                if (ownedButton) {
                    return { owned: true, reason: `Button text "${ownedButton.textContent}"` };
                }

                return { owned: false };
            };
            
            try {
                // --- Logic Based on FAB_HELPER_RULES.md ---

                // Step 1: Check for Owned State (Rule 1) - Kept as a fallback
                const initialState = isItemOwned();
                if (initialState.owned) {
                    logBuffer.push(`Item already owned on page load (UI Fallback PASS: ${initialState.reason}). Marking as success.`);
                    await TaskRunner.advanceDetailTask(taskPayload, true, logBuffer);
                    return;
                }

                // Step 2: Check for Multi-License State (Rule 3) - Now with MutationObserver
                const licenseButton = [...document.querySelectorAll('button')].find(btn => btn.textContent.includes('选择许可'));
                if (licenseButton) {
                    logBuffer.push(`Multi-license item detected. Setting up observer for dropdown.`);

                    // This promise now directly waits for the listbox element to appear in the DOM after a click.
                    // This is more robust than waiting for an attribute change and then for the element.
                    const findAndClickFreeLicenseOption = () => new Promise((resolve, reject) => {
                        logBuffer.push('Starting multi-attempt license selection process...');

                        let attemptCount = 0;
                        let retryTimeout = null;
                        let finalTimeout = null;

                        const cleanupAndResolve = () => {
                            clearTimeout(retryTimeout);
                            clearTimeout(finalTimeout);
                                        observer.disconnect();
                            logBuffer.push(`License option processed successfully.`);
                                        resolve();
                        };

                        const cleanupAndReject = (message) => {
                            observer.disconnect();
                            reject(new Error(message));
                        };

                        const observer = new MutationObserver((mutationsList, obs) => {
                            for (const mutation of mutationsList) {
                                if (mutation.addedNodes.length > 0) {
                                    for (const node of mutation.addedNodes) {
                                        if (node.nodeType !== 1) continue;

                                        const freeTextElement = Array.from(node.querySelectorAll('span, div')).find(el => 
                                            Array.from(el.childNodes).some(cn => cn.nodeType === 3 && cn.textContent.trim() === '免费')
                                        );

                                        if (freeTextElement) {
                                            logBuffer.push(`[Attempt ${attemptCount}] "MutationObserver" found the "免费" element. Finding clickable parent...`);
                                            const clickableParent = freeTextElement.closest('[role="option"], button');
                                            if (clickableParent) {
                                                logBuffer.push(`Clickable parent found. Performing deep click...`);
                                                Utils.deepClick(clickableParent);
                                                cleanupAndResolve();
                                                return; // Stop processing further mutations
                                            }
                                        }
                                    }
                                }
                            }
                        });

                        const tryClick = () => {
                            attemptCount++;
                            logBuffer.push(`[Attempt ${attemptCount}] Performing deep click on "选择许可".`);
                            Utils.deepClick(licenseButton);
                        };

                        // --- Execution Flow ---
                        observer.observe(document.body, { childList: true, subtree: true });
                        logBuffer.push('MutationObserver is now watching the document.');
                        
                        tryClick(); // First attempt

                        retryTimeout = setTimeout(() => {
                            logBuffer.push('Dropdown not detected after 1.5s. Retrying click.');
                            tryClick(); // Second attempt
                        }, 1500);

                        finalTimeout = setTimeout(() => {
                            cleanupAndReject('Timeout (5s): The "免费" option did not appear in the DOM after multiple click attempts.');
                        }, 5000);
                    });
                    
                    // Execute the new, combined function.
                    await findAndClickFreeLicenseOption();
                    
                    // --- NEW VERIFICATION STEP ---
                    // After clicking the license, the page might already be in an "owned" state.
                    // We must check for this state before proceeding.
                    logBuffer.push('Re-evaluating page state after license selection...');
                    await new Promise(r => setTimeout(r, 1500)); // A generous wait for the UI to update.
                    const stateAfterLicenseClick = isItemOwned();
                    if (stateAfterLicenseClick.owned) {
                        logBuffer.push(`Acquisition confirmed after license click! (PASS: ${stateAfterLicenseClick.reason})`);
                        await TaskRunner.advanceDetailTask(taskPayload, true, logBuffer);
                        return; // Mission accomplished, do not proceed further.
                    }
                    logBuffer.push('License selection did not result in ownership. Proceeding to find main button...');
                }

                // Step 3: Find and click the standard Acquisition Button (Rule 2)
                // This will run either after the multi-license logic or if it wasn't a multi-license item.
                const actionButton = [...document.querySelectorAll('button.fabkit-Button-root')].find(btn => 
                    [...Config.ACQUISITION_TEXT_SET].some(keyword => btn.textContent.includes(keyword))
                );

                if (actionButton) {
                    const originalButtonText = actionButton.textContent;
                    logBuffer.push(`Found acquisition button (Rule 2 PASS: "${originalButtonText}"). Performing deep click...`);
                    // Add a slight delay before clicking to ensure button is fully interactive
                    await new Promise(r => setTimeout(r, 250));
                    Utils.deepClick(actionButton);

                    // Step 4: Wait for the page state to change to "owned" (wait for Rule 1 to PASS).
                    await new Promise((resolve, reject) => {
                        const timeout = 10000;
                        const interval = setInterval(() => {
                            const currentState = isItemOwned();
                            if (currentState.owned) {
                                logBuffer.push(`Acquisition confirmed! (Rule 1 now PASS: ${currentState.reason})`);
                                clearInterval(interval);
                                resolve();
                            }
                        }, 200);
                        setTimeout(() => {
                            clearInterval(interval);
                            reject(new Error(`Timeout waiting for page to enter an 'owned' state after click.`));
                        }, timeout);
                    });
                    
                    await TaskRunner.advanceDetailTask(taskPayload, true, logBuffer);
                    return;
                }
                
                throw new Error('Could not find any button matching the acquisition keyword sets after all steps.');

            } catch (error) {
                logBuffer.push(`Acquisition FAILED. An unexpected error occurred.`);
                
                // --- ENHANCED "BLACK BOX" DIAGNOSTIC CODE ---
                // No longer checks for a specific message. It runs for ANY error during the process.
                logBuffer.push('--- BLACK BOX RECORDER ACTIVATED ---');
                logBuffer.push(`Error Details: Name: ${error.name}, Message: ${error.message}`);
                if (error.stack) {
                    logBuffer.push(`Stack Trace: ${error.stack}`);
                }
                logBuffer.push('Dumping all direct children of <body> that are currently visible, as popovers often live here.');
                
                const candidates = document.querySelectorAll('body > div');
                let foundVisibleCandidates = 0;
                
                if (candidates.length > 0) {
                    candidates.forEach((el, index) => {
                         const style = window.getComputedStyle(el);
                         // Loosened criteria: also check for elements that just take up space (height > 0)
                         if (style.display !== 'none' && style.visibility !== 'hidden' && (style.position !== 'static' || el.clientHeight > 0)) {
                             foundVisibleCandidates++;
                             const rect = el.getBoundingClientRect();
                             logBuffer.push(
                                 `[Visible Candidate #${index}] Tag: ${el.tagName}, ID: ${el.id || 'N/A'}, Class: ${el.className || 'N/A'}, Role: ${el.getAttribute('role') || 'N/A'}, z-index: ${style.zIndex}, Position: ${style.position}, Size: ${Math.round(rect.width)}x${Math.round(rect.height)}, Content: "${el.textContent.substring(0, 100).replace(/\s+/g, ' ')}"`
                             );
                         }
                    });
                }
                
                if (foundVisibleCandidates === 0) {
                     logBuffer.push('Diagnostic check found no visible, non-static <div> elements as direct children of <body>. The dropdown may be nested elsewhere or failed to trigger.');
                }
                logBuffer.push('--- END BLACK BOX REPORT ---');
                // --- END DIAGNOSTIC CODE ---

                await TaskRunner.advanceDetailTask(taskPayload, false, logBuffer);
            }
        },

        advanceDetailTask: async (taskPayload, success, logBuffer = []) => {
            // First, send the final log report back to the main tab.
            await GM_setValue(Config.DB_KEYS.DETAIL_LOG, logBuffer);

            // Gracefully handle cases where the task payload might be null
            if (!taskPayload || typeof taskPayload.currentIndex === 'undefined' || !taskPayload.batch) {
                Utils.logger('error', 'advanceDetailTask called with invalid or null taskPayload. Cannot advance. Closing tab.');
                window.close();
                return;
            }

            const currentTask = taskPayload.batch[taskPayload.currentIndex];

            if (success) {
                logBuffer.push(`SUCCESS: Item acquired.`);
                await Database.markAsDone(currentTask);
            } else {
                logBuffer.push(`FAILURE: Could not acquire item.`);
                await Database.markAsFailed(currentTask);
            }

            // After logging, add the progress message
            TaskRunner.addProgressToLog(logBuffer, taskPayload, success ? '成功' : '失败');

            // BUG FIX: After processing the current item, we MUST re-check if the master task has been cancelled.
            // The main tab signals a stop by deleting the TASK key. If it's gone, we must abort.
            const masterTaskStillActive = await GM_getValue(Config.DB_KEYS.TASK);
            if (!masterTaskStillActive) {
                Utils.logger('info', 'Execution stopped by main tab. Worker tab will now close.');
                window.close(); // Halt all further action.
                return;
            }

            taskPayload.currentIndex++;

            if (taskPayload.currentIndex >= taskPayload.batch.length) {
                await GM_deleteValue(Config.DB_KEYS.TASK);
                window.close();
            } else {
                await GM_setValue(Config.DB_KEYS.TASK, taskPayload);
                window.location.href = taskPayload.batch[taskPayload.currentIndex].url;
            }
        },

        runHideOrShow: () => {
            State.hiddenThisPageCount = 0;
            document.querySelectorAll(Config.SELECTORS.card).forEach(card => {
                const link = card.querySelector(Config.SELECTORS.cardLink);
                const text = card.textContent || '';
                const isNativelySaved = [...Config.SAVED_TEXT_SET].some(s => text.includes(s));
                const isScriptSaved = link && Database.isDone(link.href);
                if (isNativelySaved || isScriptSaved) {
                    card.style.display = State.hideSaved ? 'none' : '';
                    if(State.hideSaved) State.hiddenThisPageCount++;
                }
            });
            UI.update();
        },
    };


    // --- 模块八: 用户界面 (User Interface) ---
    const UI = {
        create: () => {
            if (document.getElementById(Config.UI_CONTAINER_ID)) return;

            // --- Style Injection ---
            const styles = `
                :root {
                    --bg-color: rgba(28, 28, 30, 0.7);
                    --border-color: rgba(255, 255, 255, 0.1);
                    --text-color-primary: #f5f5f7;
                    --text-color-secondary: #a0a0a5;
                    --radius-l: 16px;
                    --radius-m: 10px;
                    --radius-s: 8px;
                    --blue: #007aff; --pink: #ff2d55; --green: #34c759;
                    --orange: #ff9500; --gray: #8e8e93; --dark-gray: #555;
                }
                #${Config.UI_CONTAINER_ID} {
                    position: fixed;
                    bottom: 20px;
                    right: 20px;
                    z-index: 9999;
                    background: var(--bg-color);
                    backdrop-filter: blur(12px) saturate(1.5);
                    border: 1px solid var(--border-color);
                    border-radius: var(--radius-l);
                    color: var(--text-color-primary);
                    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
                    display: flex;
                    flex-direction: column;
                    gap: 12px;
                    padding: 12px;
                    width: 300px;
                    font-size: 14px;
                }
                .fab-helper-header, .fab-helper-row {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    gap: 8px;
                }
                .fab-helper-header h2 {
                    font-size: 16px; font-weight: 600; margin: 0;
                }
                .fab-helper-icon-btn {
                    background: transparent; border: none; color: var(--text-color-secondary);
                    cursor: pointer; padding: 4px; font-size: 18px; line-height: 1;
                }
                .fab-helper-status-bar {
                    display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;
                }
                .fab-helper-status-item {
                    background: rgba(255, 255, 255, 0.1); padding: 6px;
                    border-radius: var(--radius-s); font-size: 11px; text-align: center;
                    color: var(--text-color-secondary);
                }
                .fab-helper-status-item span {
                    display: block; font-size: 16px; font-weight: 600; color: #fff;
                }
                #${Config.UI_CONTAINER_ID} button {
                    border: none; border-radius: var(--radius-m); padding: 10px 14px;
                    font-size: 14px; font-weight: 500; cursor: pointer;
                    transition: all 0.2s; color: #fff; flex-grow: 1;
                }
                .fab-helper-btn-section {
                    display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px;
                }
                .fab-helper-section-title {
                    font-size: 13px; color: var(--text-color-secondary); font-weight: 600; margin: 8px 0 4px 0; letter-spacing: 1px;
                }
                .fab-helper-divider {
                    border: none; border-top: 1px solid var(--border-color); margin: 8px 0;
                }
            `;
            const styleSheet = document.createElement("style");
            styleSheet.type = "text/css";
            styleSheet.innerText = styles;
            document.head.appendChild(styleSheet);

            const container = document.createElement('div');
            container.id = Config.UI_CONTAINER_ID;

            // -- Header --
            const header = document.createElement('div');
            header.className = 'fab-helper-header';
            const title = document.createElement('h2');
            title.textContent = `Fab Helper ${Config.SCRIPT_NAME.match(/v(\d+\.\d+\.\d+)/)[1]}`;
            const headerControls = document.createElement('div');
            const copyLogBtn = document.createElement('button');
            copyLogBtn.className = 'fab-helper-icon-btn';
            copyLogBtn.innerHTML = '📄';
            copyLogBtn.title = Utils.getText('copyLog');
            copyLogBtn.onclick = () => {
                navigator.clipboard.writeText(State.ui.logPanel.innerText).then(() => {
                    const originalIcon = copyLogBtn.innerHTML;
                    copyLogBtn.innerHTML = '✅';
                    setTimeout(() => { copyLogBtn.innerHTML = originalIcon; }, 1500);
                }).catch(err => Utils.logger('error', 'Failed to copy log:', err));
            };
            const clearLogBtn = document.createElement('button');
            clearLogBtn.className = 'fab-helper-icon-btn';
            clearLogBtn.innerHTML = '🗑️';
            clearLogBtn.title = Utils.getText('clearLog');
            clearLogBtn.onclick = () => { State.ui.logPanel.innerHTML = ''; };
            headerControls.append(copyLogBtn, clearLogBtn);
            header.append(title, headerControls);

            // -- Status Bar --
            const statusBar = document.createElement('div');
            statusBar.className = 'fab-helper-status-bar';
            const createStatusItem = (id, label) => {
                const item = document.createElement('div');
                item.className = 'fab-helper-status-item';
                item.innerHTML = `${label} <span id="${id}">0</span>`;
                return item;
            };
            State.ui.statusTodo = createStatusItem('fab-status-todo', `📥 ${Utils.getText('todo')}`);
            State.ui.statusDone = createStatusItem('fab-status-done', `✅ ${Utils.getText('added')}`);
            State.ui.statusFailed = createStatusItem('fab-status-failed', `❌ ${Utils.getText('failed')}`);
            statusBar.append(State.ui.statusTodo, State.ui.statusDone, State.ui.statusFailed);

            // -- Log Panel --
            State.ui.logPanel = document.createElement('div');
            State.ui.logPanel.id = 'fab-log-panel';
            State.ui.logPanel.style.cssText = `
  background: rgba(30,30,30,0.85);
  color: #eee;
  font-size: 12px;
  line-height: 1.5;
  padding: 8px 6px 8px 8px;
  border-radius: 8px;
  margin: 8px 0;
  max-height: 40vh;
  overflow-y: auto;
  min-height: 40px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
  display: flex;
  flex-direction: column-reverse;
`;

            // -- Basic Section --
            const basicSection = document.createElement('div');
            basicSection.className = 'fab-helper-btn-section';
            const basicTitle = document.createElement('div');
            basicTitle.className = 'fab-helper-section-title';
            basicTitle.textContent = '🧩 基础功能 (Basic)';
            // 本页一键领取
            const addAllBtn = document.createElement('button');
            addAllBtn.innerHTML = '🛒 本页一键领取';
            addAllBtn.style.background = 'var(--green)';
            addAllBtn.onclick = () => {
                const cards = document.querySelectorAll(Config.SELECTORS.card);
                const newlyAddedList = [];
                let alreadyInQueueCount = 0;
                let ownedCount = 0;

                cards.forEach(card => {
                    const link = card.querySelector(Config.SELECTORS.cardLink);
                    const url = link ? link.href.split('?')[0] : null;

                    if (!url) return; // Skip cards without a valid link

                    const isOwned = Database.isDone(url);
                    const isTodo = Database.isTodo(url);
                    const isFailed = State.db.failed.some(t => t.url.startsWith(url));

                    if (isOwned) {
                        ownedCount++;
                        return; // Skip owned items
                    }

                    if (isTodo || isFailed) {
                        alreadyInQueueCount++;
                    } else {
                        // The new, simpler logic: if it's not owned or queued, add it.
                        newlyAddedList.push({ url, type: 'detail', uid: url.split('/').pop() });
                    }
                });

                const actionableCount = newlyAddedList.length + alreadyInQueueCount;

                if (newlyAddedList.length > 0) {
                    State.db.todo.push(...newlyAddedList);
                    Database.saveTodo(); // This will trigger the overlay update via the listener
                    Utils.logger('info', `已将 ${newlyAddedList.length} 个新商品加入待办队列。`);
                }
                
                if (actionableCount > 0) {
                    if (newlyAddedList.length === 0) {
                        Utils.logger('info', `本页的 ${actionableCount} 个可领取商品已全部在待办队列中。`);
                    }
                    // Always try to start execution if there are actionable items.
                    TaskRunner.startExecution();
                } else {
                    Utils.logger('info', '本页没有可领取的新商品。');
                }
            };
            // 本页刷新状态
            const refreshPageBtn = document.createElement('button');
            refreshPageBtn.innerHTML = '🔄 本页刷新状态';
            refreshPageBtn.style.background = 'var(--blue)';
            refreshPageBtn.onclick = TaskRunner.refreshVisibleStates;
            // 本页隐藏/显示已拥有
            State.ui.hideBtn = document.createElement('button');
            State.ui.hideBtn.innerHTML = '🙈 隐藏已拥有';
            State.ui.hideBtn.style.background = 'var(--blue)';
            State.ui.hideBtn.onclick = TaskRunner.toggleHideSaved;
            basicSection.append(basicTitle, addAllBtn, refreshPageBtn, State.ui.hideBtn);

            // -- Divider --
            const divider = document.createElement('hr');
            divider.className = 'fab-helper-divider';

            // -- Advanced Section --
            const advSection = document.createElement('div');
            advSection.className = 'fab-helper-btn-section';
            advSection.style.display = '';
            const advTitle = document.createElement('div');
            advTitle.className = 'fab-helper-section-title';
            advTitle.textContent = '⚡ 高级功能 (Advanced/API)';
            // 批量侦察
            State.ui.reconBtn = document.createElement('button');
            State.ui.reconBtn.innerHTML = '🔍 批量侦察';
            State.ui.reconBtn.style.background = 'var(--green)';
            State.ui.reconBtn.onclick = TaskRunner.toggleRecon;
            // 批量领取
            State.ui.execBtn = document.createElement('button');
            State.ui.execBtn.innerHTML = '🚀 批量领取';
            State.ui.execBtn.style.background = 'var(--pink)';
            State.ui.execBtn.onclick = TaskRunner.toggleExecution;
            // 批量重试失败
            State.ui.retryBtn = document.createElement('button');
            State.ui.retryBtn.innerHTML = '🔁 批量重试失败';
            State.ui.retryBtn.style.background = 'var(--orange)';
            State.ui.retryBtn.onclick = TaskRunner.retryFailedTasks;
            // 批量刷新所有状态
            State.ui.refreshBtn = document.createElement('button');
            State.ui.refreshBtn.innerHTML = '🔄 批量刷新所有状态';
            State.ui.refreshBtn.style.background = 'var(--blue)';
            State.ui.refreshBtn.onclick = TaskRunner.refreshVisibleStates;
            // 重置侦察进度
            State.ui.resetReconBtn = document.createElement('button');
            State.ui.resetReconBtn.innerHTML = '⏮️ 重置侦察进度';
            State.ui.resetReconBtn.style.background = 'var(--gray)';
            State.ui.resetReconBtn.onclick = TaskRunner.resetReconProgress;
            advSection.append(advTitle, State.ui.reconBtn, State.ui.execBtn, State.ui.retryBtn, State.ui.refreshBtn, State.ui.resetReconBtn);

            // -- Advanced Wrapper (状态栏+高级区) --
            const advancedWrapper = document.createElement('div');
            advancedWrapper.style.display = 'none'; // 默认隐藏
            advancedWrapper.append(statusBar, divider, advSection);

            // -- Assemble UI --
            container.append(header, State.ui.logPanel, basicSection, advancedWrapper);
            document.body.appendChild(container);
            State.ui.container = container;

            // --- 控制台解锁高级功能 ---
            window.FabHelperShowAdvanced = function() {
                advancedWrapper.style.display = '';
                console.log('Fab Helper 高级功能区和批量状态栏已显示。');
            };
            window.FabHelperHideAdvanced = function() {
                advancedWrapper.style.display = 'none';
                console.log('Fab Helper 高级功能区和批量状态栏已隐藏。');
            };

            UI.update();
        },

        update: () => {
            if (!State.ui.container) return;
            
            // Status Bar
            State.ui.container.querySelector('#fab-status-todo').textContent = State.db.todo.length;
            State.ui.container.querySelector('#fab-status-done').textContent = State.db.done.length;
            State.ui.container.querySelector('#fab-status-failed').textContent = State.db.failed.length;
            
            // Execute Button
            State.ui.execBtn.innerHTML = State.isExecuting ? `🛑 ${Utils.getText('stopExecute')}` : `🚀 ${Utils.getText('execute')}`;
            State.ui.execBtn.style.background = State.isExecuting ? 'var(--pink)' : 'var(--pink)';
            
            // Recon Button
            if (State.isReconning) {
                const displayPage = Utils.getDisplayPageFromUrl(GM_getValue(Config.DB_KEYS.NEXT_URL, ''));
                State.ui.reconBtn.innerHTML = `🔍 ${Utils.getText('reconning')} (${displayPage})`;
            } else {
                State.ui.reconBtn.innerHTML = `🔍 ${Utils.getText('recon')}`;
            }
            State.ui.reconBtn.disabled = State.isExecuting;
            State.ui.reconBtn.style.background = State.isReconning ? 'var(--orange)' : 'var(--green)';

            // Retry Button
            const hasFailedTasks = State.db.failed.length > 0;
            State.ui.retryBtn.innerHTML = `🔁 ${Utils.getText('retry_failed')} (${State.db.failed.length})`;
            State.ui.retryBtn.disabled = !hasFailedTasks || State.isExecuting;
            State.ui.retryBtn.style.background = 'var(--orange)';
            
            // Refresh Button
            State.ui.refreshBtn.innerHTML = `🔄 ${Utils.getText('refresh')}`;
            State.ui.refreshBtn.disabled = State.isExecuting || State.isReconning;
            State.ui.refreshBtn.style.background = 'var(--blue)';

            // Hide/Show Button
            const hideText = State.hideSaved ? Utils.getText('show') : Utils.getText('hide');
            State.ui.hideBtn.innerHTML = `${State.hideSaved ? '👀' : '🙈'} ${hideText} (${State.hiddenThisPageCount})`;
            State.ui.hideBtn.style.background = 'var(--blue)';

            // Reset Recon Button
            State.ui.resetReconBtn.innerHTML = `⏮️ ${Utils.getText('resetRecon')}`;
            State.ui.resetReconBtn.disabled = State.isExecuting || State.isReconning;
            State.ui.resetReconBtn.style.background = 'var(--gray)';
        },

        applyOverlay: (card, type = 'owned') => {
            // Remove existing overlay first to avoid duplicates
            const existingOverlay = card.querySelector('.fab-helper-overlay-v8');
            if (existingOverlay) existingOverlay.remove();

            const overlay = document.createElement('div');
            overlay.className = 'fab-helper-overlay-v8';
            
            const styles = {
                position: 'absolute', top: '0', left: '0', width: '100%', height: '100%',
                background: 'rgba(25, 25, 25, 0.6)', zIndex: '10', display: 'flex',
                justifyContent: 'center', alignItems: 'center', fontSize: '24px',
                fontWeight: 'bold', backdropFilter: 'blur(2px)', borderRadius: 'inherit'
            };

            if (type === 'owned') {
                styles.color = '#4caf50'; // Green
                overlay.innerHTML = '✅';
            } else if (type === 'queued') {
                styles.color = '#ff9800'; // Orange
                overlay.innerHTML = '⏳';
            }

            Object.assign(overlay.style, styles);

            const thumbnail = card.querySelector('.fabkit-Thumbnail-root, .AssetCard-thumbnail');
            if (thumbnail) {
                if (getComputedStyle(thumbnail).position === 'static') {
                    thumbnail.style.position = 'relative';
                }
                thumbnail.appendChild(overlay);
            }
        },

        applyOverlaysToPage: () => {
            document.querySelectorAll(Config.SELECTORS.card).forEach(card => {
                const link = card.querySelector(Config.SELECTORS.cardLink);
                if (link) {
                    const url = link.href;
                    if (Database.isDone(url)) {
                        UI.applyOverlay(card, 'owned');
                    } else if (Database.isTodo(url)) {
                        UI.applyOverlay(card, 'queued');
                    }
                }
            });
        }
    };


    // --- 模块九: 主程序与初始化 (Main & Initialization) ---
    async function main() {
        if (State.isInitialized) return;
        State.isInitialized = true;

        Utils.detectLanguage();
        // Initialize the network filter as early as possible, per Rule #6.
        NetworkFilter.init();
        await Database.load();

        // Stricter check to see if this tab is a "worker" tab.
        // It must be a listings page, have an active task payload, AND its URL must be in the batch.
        const activeTask = await GM_getValue(Config.DB_KEYS.TASK);
        if (activeTask && window.location.href.includes('/listings/')) {
            const currentCleanUrl = window.location.href.split('?')[0];
            const isLegitWorker = activeTask.batch.some(task => task.url === currentCleanUrl);

            if (isLegitWorker) {
                TaskRunner.processDetailPage();
                return; // This tab's only job is to run the detail task.
            }
        }

        // --- Standard page setup ---
        UI.create();

        // NEW: Immediately reflect saved recon progress in the UI on load.
        const savedNextUrl = await GM_getValue(Config.DB_KEYS.NEXT_URL, null);
        if (savedNextUrl && State.ui.reconProgressDisplay) {
            const displayPage = Utils.getDisplayPageFromUrl(savedNextUrl);
            State.ui.reconProgressDisplay.textContent = `Page: ${displayPage}`;
            Utils.logger('info', `Found saved recon progress. Ready to resume.`);
        }

        UI.applyOverlaysToPage();
        TaskRunner.runHideOrShow(); // Initial run

        Utils.logger('info', Utils.getText('log_init'));

        // Attach listeners and observers
        const mainObserver = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    mutation.addedNodes.forEach(node => {
                        // We only care about element nodes
                        if (node.nodeType === 1) {
                            // Check if the added node itself is a card
                            if (node.matches(Config.SELECTORS.card)) {
                                const link = node.querySelector(Config.SELECTORS.cardLink);
                                if (link && Database.isDone(link.href)) {
                                    UI.applyOverlay(node, 'owned');
                                } else if (link && Database.isTodo(link.href)) {
                                    UI.applyOverlay(node, 'queued');
                                }
                                TaskRunner.runHideOrShow(); // Run hide/show logic which is relatively fast
                            }
                            
                            // Check if the added node contains new cards (e.g., a container was added)
                            const newCards = node.querySelectorAll(Config.SELECTORS.card);
                            if (newCards.length > 0) {
                                UI.applyOverlaysToPage();
                                TaskRunner.runHideOrShow();
                            }
                        }
                    });
                }
            }
        });
        
        mainObserver.observe(document.body, { childList: true, subtree: true });

        // Listen for changes from other tabs
        State.valueChangeListeners.push(GM_addValueChangeListener(Config.DB_KEYS.DONE, (name, old_value, new_value) => {
            State.db.done = new_value;
            UI.update();
            UI.applyOverlaysToPage();
        }));
        State.valueChangeListeners.push(GM_addValueChangeListener(Config.DB_KEYS.TODO, (name, old_value, new_value) => {
            State.db.todo = new_value;
            UI.applyOverlaysToPage();
            UI.update();
        }));
        State.valueChangeListeners.push(GM_addValueChangeListener(Config.DB_KEYS.FAILED, (name, old_value, new_value) => {
            State.db.failed = new_value;
            UI.update();
        }));
        // NEW LISTENER: This now exclusively handles the execution flow continuation.
        // It triggers when a worker tab finishes its batch and deletes the TASK key.
        State.valueChangeListeners.push(GM_addValueChangeListener(Config.DB_KEYS.TASK, (name, old_value, new_value) => {
            if (State.isExecuting && !new_value && old_value) { // A batch has just finished.
                Utils.logger('info', 'Batch completed. Checking for more tasks...');
                // The individual TODO/DONE listeners are responsible for updating state.
                // This listener's ONLY job is to continue the execution loop.
                if (State.db.todo.length > 0) {
                    Utils.logger('info', 'More tasks to process. Starting next batch in 1 second.');
                    setTimeout(TaskRunner.executeBatch, 1000);
                } else {
                    Utils.logger('info', 'All tasks are completed. Execution stopped.');
                    State.isExecuting = false;
                    UI.update();
                }
            }
        }));
        // RESTORED LISTENER: For receiving and printing logs from worker tabs.
        State.valueChangeListeners.push(GM_addValueChangeListener(Config.DB_KEYS.DETAIL_LOG, (name, old_value, new_value) => {
            if (new_value && Array.isArray(new_value) && new_value.length > 0) {
                Utils.logger('info', '--- Log Report from Worker Tab ---');
                new_value.forEach(logMsg => {
                    const logType = logMsg.includes('FAIL') ? 'error' : 'info';
                    Utils.logger(logType, logMsg);
                });
                Utils.logger('info', '--- End Log Report ---');
                GM_deleteValue(Config.DB_KEYS.DETAIL_LOG); // Clean up after reading
            }
        }));
        
        window.addEventListener('beforeunload', () => {
            State.valueChangeListeners.forEach(id => GM_removeValueChangeListener(id));
        });
    }

    // --- Script Entry Point ---
    function runWhenReady() {
        const readyInterval = setInterval(() => {
            if (document.body && document.querySelector(Config.SELECTORS.rootElement)) {
                clearInterval(readyInterval);
                main();
                // guardian(); // Guardian can be added back later
            }
        }, 200);
        setTimeout(() => {
            if (!State.isInitialized) {
                clearInterval(readyInterval);
                Utils.logger('warn', 'Initialization timed out.');
            }
        }, 20000);
    }

    runWhenReady();

})();