亚马逊关键词排名-Amazon keywords Positioning by Asin

1.在亚马逊搜索结果页上定位ASIN, 获取排名 2.代码重构————dom操作->fetch+DOMParser 3.结果面板 4.批量导入excel关键词表,返回关键词排名表.xlsx

// ==UserScript==
// @name         亚马逊关键词排名-Amazon keywords Positioning by Asin
// @namespace    http://tampermonkey.net/
// @version      3.5.0
// @description  1.在亚马逊搜索结果页上定位ASIN, 获取排名 2.代码重构————dom操作->fetch+DOMParser 3.结果面板 4.批量导入excel关键词表,返回关键词排名表.xlsx
// @author       You
// @match        https://www.amazon.com/*
// @match        https://www.amazon.co.uk/*
// @match        https://www.amazon.ca/*
// @icon         https://www.amazon.com/favicon.ico
// @license      MIT
// @grant        none
// ==/UserScript==


(async function () {
    'use strict';

    // —— 配置区 ——  
    const DEFAULT_MAX_PAGES = 2; // 默认最多搜索页数
    const STYLE = `
    /* 容器 */
    #tm-asin-container {
        position: fixed;
        top: 60px;
        left: 0; right: 0;
        padding: 6px 12px;
        background: #fff;
        box-shadow: 0 2px 12px rgba(0,0,0,0.1);
        font-family: "Helvetica Neue", Arial, sans-serif;
        z-index: 9999;
        display: flex;
        align-items: center;
    }

    /* tag-wrapper-css */
    #tm-asin-container #tag-wrapper {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 8px;
        margin-right: 6px;
    }

    .tag-item {
        display: inline-flex;
        align-items: center;
        height: 28px;
        padding: 0 8px;
        font-size: 14px;
        background: #ecf5ff;
        color: #409eff;
        border: 1px solid #b3d8ff;
        border-radius: 4px;
    }

    .tag-item .tag-close {
        display: inline-block;
        margin-left: 4px;
        font-style: normal;
        cursor: pointer;
        color: #409eff;
        font-weight: bold;
    }

    .tag-item .tag-close:hover {
        color: #66b1ff;
    }

    .tag-add-btn {
        display: inline-flex;
        align-items: center;
        height: 32px;
        padding: 0 12px;
        font-size: 14px;
        color: #409eff;
        background: #fff;
        border: 1px solid #409eff;
        border-radius: 4px;
        cursor: pointer;
        transition: background-color .2s;
    }

    .tag-add-btn:hover {
        background-color: #ecf5ff;
    }

    /* 临时输入框 */
    .tag-input {
        flex: 1;
        min-width: 100px;
        height: 28px;
        padding: 0 6px;
        font-size: 14px;
        border: 1px solid #dcdfe6;
        border-radius: 4px;
        outline: none;
    }
    /* input错误提示 */
    .input-error {
        border-color: red;
        outline: none;
        box-shadow: 0 0 5px red;
    }

    /* ASIN 和页数输入框 */
    #tm-asin-container input[type="number"] {
        margin-right: 14px;
        font-size: 16px;
        border: 1px solid #dcdfe6;
        border-radius: 4px;
        color: #606266;
        outline: none;
        transition: border-color .2s, box-shadow .2s;
        width: 200px;
        box-sizing: border-box;
    }
    #tm-asin-container input:focus {
        border-color: #409eff;
        box-shadow: 0 0 2px rgba(64,158,255,0.2);
    }

    /* 文件上传按钮 追加 ElementUI Button 样式 */
    .el-button {
        display: inline-block;
        line-height: 1.5;
        white-space: nowrap;
        font-size: 14px;
        font-weight: 500;
        padding: 6px 12px;
        border: 1px solid #dcdfe6;
        border-radius: 4px;
        cursor: pointer;
        user-select: none;
        background-color: #fff;
        color: #606266;
        transition: background-color .2s, border-color .2s, color .2s;
        margin-right: 12px;
        }
    .el-button--primary {
        background-color: #409eff;
        border-color: #409eff;
        color: #fff;
        }
    .el-button--primary:hover {
        background-color: #66b1ff;
        border-color: #66b1ff;
        }
    /* 按钮 */
    #tm-asin-container button {
        margin-right: 12px;
        padding: 5px 10px;
        font-size: 14px;
        font-weight: 500;
        color: #fff;
        background-color: #409eff;
        border: 1px solid #409eff;
        border-radius: 4px;
        cursor: pointer;
        transition: background-color .2s, border-color .2s;
    }
    #tm-asin-container button:hover:not([disabled]) {
        background-color: #66b1ff;
        border-color: #66b1ff;
    }

    #tm-asin-container span {
        font-size: 16px;
    }
    /* 状态文字:紧跟按钮后面 */
    #tm-asin-container span#tm-status {
        margin-left: 12px;
        margin-right: 12px;
        font-size: 16px;
        color:rgb(110, 111, 111);
    }
    /* 面板button样式 */
    #batch-results-panel .rp-jump-btn {
        margin-top: 2px;
        margin-left: 6px;
        margin-bottom: 3.5px;
        line-height: 12px;
        color: #5ba7f4;
        background-color: #ecf5ff;
        border: 1px solid;
        border-radius: 5px;
        padding-top: 2px;
        cursor: pointer;
        transition: background-color .2s, color .2s, border-color .2s;
    }

    #batch-results-panel .rp-jump-btn:hover {
        background-color: #5ba7f4;
        color: #ffffff;
        border-color: #5ba7f4;
    }

    #batch-results-panel .dw-jump-btn {
        style='width: 0px;
        background-color: #ffffff;
        border: 0px;
        line-height: 12px;
        margin-top: -3px;
        font-size: 18px;
        padding: 0px;
        cursor: pointer;'
        transition: font-size .2s;
    }

    #batch-results-panel .dw-jump-btn:hover {
        font-size: 20px;
    }
    `;

    // —— 状态 ——  
    let results = {};
    let maxPages = DEFAULT_MAX_PAGES;
    let keywords = [];
    //tag-wrapper-2 初始化数据
    let tagAsins = []
    const maxTags = 3

    // —— 注入样式 & UI ——  
    const styleEl = document.createElement('style');
    styleEl.textContent = STYLE;
    document.head.appendChild(styleEl);

    // container框
    const container = document.createElement('div');
    container.id = 'tm-asin-container';
    // tag-wrapper-1
    const tagWrapper = document.createElement('div');
    tagWrapper.id = 'tag-wrapper'
    container.insertBefore(tagWrapper, container.firstChild);
    // Max🔎Pages
    const maxPageText = document.createElement('span');
    maxPageText.textContent = 'Max🔎Pages:';
    // maxpage input
    const inputPages = document.createElement('input');
    inputPages.type = 'number';
    inputPages.min = '1';
    inputPages.max = '9'
    inputPages.value = DEFAULT_MAX_PAGES;
    inputPages.style.width = '60px';
    // search button
    const btnSearch = document.createElement('button');
    btnSearch.textContent = '搜索排名';
    // clear storage
    const btnClearCache = document.createElement('button');
    btnClearCache.textContent = '清除缓存';
    // upload button
    const fileInput = document.createElement('input');
    fileInput.type = 'file';
    fileInput.accept = '.xlsx,.xls';
    fileInput.style.display = 'none';
    // status的div的元素
    const status = document.createElement('span');
    status.setAttribute("id", "tm-status");
    status.textContent = '请填写 ASIN, 点击"搜索排名"';
    // 创建一个 ElementUI 风格的标签按钮
    const uploadLabel = document.createElement('label');
    uploadLabel.className = 'el-button el-button--primary';
    uploadLabel.textContent = '⬆上传关键词';
    uploadLabel.appendChild(fileInput);  // 把 fileInput 内嵌到 label
    // 批量搜索按钮
    const batchSearchBtn = document.createElement('button');
    batchSearchBtn.className = 'el-button el-button--primary';
    batchSearchBtn.textContent = '批量搜索🔍';
    // 下载按钮
    const downloadBtn = document.createElement('button');
    downloadBtn.className = 'el-button el-button--primary';
    downloadBtn.textContent = '下载结果表';
    /* 动画过渡——container栏的伸缩 */
    container.style.transition = 'top 0.4s ease';
    let ticking = false;
    let lastScrollY = window.scrollY;
    window.addEventListener('scroll', e => {
        if (!ticking) {
            window.requestAnimationFrame(() => {
                container.style.top = window.scrollY > lastScrollY ? '0' : '55px';
                lastScrollY = window.scrollY;
                ticking = false;
            });
            ticking = true;
        }
    }, { passive: true });

    // —— 初始化时尝试读取缓存 ——  
    const storedTags = sessionStorage.getItem('tm_tagAsins');
    if (storedTags) {
        try {
            tagAsins = JSON.parse(storedTags);
        } catch { }
    }
    const storedResults = sessionStorage.getItem('tm_results');
    if (storedResults) {
        try {
            results = JSON.parse(storedResults);
        } catch { }
    }
    batchSearchBtn.disabled = true
    const keywordResult = sessionStorage.getItem('tm_keywords');
    if (keywordResult) {
        batchSearchBtn.disabled = false
        console.log(`已有缓存keywords 可以直接批量搜索`);
        try {
            keywords = JSON.parse(keywordResult);
        } catch { }
    }
    const storedBatch = sessionStorage.getItem('tm_batch_table');
    if (storedBatch) {
        try {
            const table = JSON.parse(storedBatch);
            renderResultsPanelFromTable(table);
        } catch { }
    }
    // tag-wrapper-3 渲染
    function renderTags() {
        tagWrapper.innerHTML = '';
        // 渲染每个 tag
        tagAsins.forEach((tag, idx) => {
            const span = document.createElement('span');
            span.className = 'tag-item';
            span.textContent = tag;
            // close按钮
            const close = document.createElement('i');
            close.className = 'tag-close';
            close.textContent = '×';
            close.addEventListener('click', () => {
                tagAsins.splice(idx, 1);
                renderTags();
                // 可在此触发“close”事件回调
            });
            span.appendChild(close);
            tagWrapper.appendChild(span);
        });
        // 渲染"+ New Asin"按钮
        const btnAdd = document.createElement('button');
        btnAdd.className = 'tag-add-btn';
        btnAdd.textContent = '+ New Asin';
        btnAdd.addEventListener('click', showInput);
        tagWrapper.appendChild(btnAdd);
    }

    // tag-wrapper-4 显示输入框新增
    function showInput() {
        // 如果已经有输入框,直接聚焦
        const existingInput = tagWrapper.querySelector('input.tag-input');
        if (existingInput) {
            existingInput.focus();
            return;
        }

        const input = document.createElement('input');
        input.className = 'tag-input';
        input.placeholder = 'Enter ASIN';
        // Asin检验格式
        const asinRegex = /^B0[A-Z0-9]{8}$/;
        // 插到按钮前
        tagWrapper.insertBefore(input, tagWrapper.querySelector('.tag-add-btn'));
        input.focus();

        // 只保留一个 confirmInput,接收事件对象
        const confirmInput = (e) => {
            const v = input.value.trim().replace(/,$/, '');

            // —— 1. 如果是 blur 触发,只处理“空值移除”或“合法新值添加”
            if (e.type === 'blur') {
                if (!v) {
                    input.remove();
                    renderTags();
                } else if (!tagAsins.includes(v) && tagAsins.length < maxTags && /^B0[A-Z0-9]{8}$/.test(v)) {
                    tagAsins.push(v);
                    input.remove();
                    renderTags();
                }
                // 其它情况(重复/不合法/超限)都不 alert,也不移除,让用户继续改
                return;
            }

            // —— 2. 如果是 keydown 且回车,做完整校验
            if (e.type === 'keydown' && e.key === 'Enter') {
                e.preventDefault();

                // 空值 —— 直接移除
                if (!v) {
                    input.remove();
                    renderTags();
                    return;
                }
                // 格式不对
                if (!/^B0[A-Z0-9]{8}$/.test(v)) {
                    input.classList.add('input-error');
                    alert(`ASIN "${v}" 格式不正确!`);
                    input.focus();
                    return;
                }
                // 重复
                if (tagAsins.includes(v)) {
                    input.classList.add('input-error');
                    alert(`ASIN "${v}" 已存在!`);
                    input.focus();
                    return;
                }
                // 超限
                if (tagAsins.length >= maxTags) {
                    input.classList.add('input-error');
                    alert(`最多只能添加 ${maxTags} 个 ASIN!`);
                    input.focus();
                    return;
                }
                // 全部通过——添加并移除
                tagAsins.push(v);
                input.remove();
                renderTags();
            }
        }

        input.addEventListener('keydown', confirmInput);
        input.addEventListener('blur', confirmInput);
    }

    // tag-wrapper-5 初次渲染
    renderTags();
    // 如果有旧的 results 就直接渲染面板
    if (Object.keys(results).length) {
        renderResultsPanel(results);
    }

    [maxPageText, inputPages, btnSearch, btnClearCache, status, uploadLabel, batchSearchBtn, downloadBtn].forEach(el => container.appendChild(el));
    document.body.appendChild(container);

    // —— 状态更新 ——  
    const updateStatus = txt => { status.textContent = txt; };

    // —— 主搜索逻辑 ——  
    btnSearch.addEventListener('click', async () => {
        // 搜索前清楚存储
        sessionStorage.removeItem('tm_results');
        // search-1 参数
        maxPages = parseInt(inputPages.value, 10) || DEFAULT_MAX_PAGES;
        if (!tagAsins.length) return alert('请先添加至少一个 ASIN!');
        // search-2 初始化结果存储
        results = {};
        tagAsins.forEach(a => results[a] = { found: false });

        // search-3 删除原有 page 参数
        const baseUrl = new URL(location.href);
        baseUrl.searchParams.delete('page');

        // search-4 顺序翻页
        updateStatus(`🔎 开始搜索 ${tagAsins.length}个 ASIN,最多 ${maxPages} 页......`);
        for (let page = 1; page <= maxPages; page++) {
            updateStatus(`🔎 正在搜索第 ${page} 页…`);
            const url = new URL(baseUrl);
            // 重新设置 page 参数
            url.searchParams.set('page', page);

            // search-4.1 拉取解析HTML
            // fetch获取html字符串 DOMParser转成document对象 再搜索
            let doc
            try {
                // 表示跨域请求时会带上 cookie(登录态)
                const resp = await fetch(url.href, { credentials: 'include' });
                const html = await resp.text();
                doc = new DOMParser().parseFromString(html, 'text/html');
            } catch (e) {
                console.error(e);
                updateStatus('❌ 网络请求出错,请重试');
                return;
            }
            // search-4.2 扫描当前页 统计所有未找到的 ASIN
            const items = doc.querySelectorAll('div[data-asin]')
            let nat = 0, sp = 0;
            for (const node of items) {
                // 带有购物车按钮的才算有效位置
                if (!node.querySelector('button.a-button-text, a.a-button-text')) continue;
                const asin = node.getAttribute('data-asin');
                // 广告位
                const isAd = !!node.querySelector('a.puis-label-popover.puis-sponsored-label-text');
                if (isAd) {
                    sp++;
                    continue;
                }
                nat++;
                // 如果ASIN在tagAsins列表里&切还未找到&不是广告位
                if (tagAsins.includes(asin) && !results[asin].found && !isAd) {
                    results[asin] = {
                        found: true,
                        page,
                        position: isAd ? sp : nat,
                        isAd
                    };
                }
            }
            // search-4.3 如果所有 ASIN 都已找到,则提前退出翻页
            const unfinished = tagAsins.filter(asin => !results[asin].found);
            if (unfinished.length === 0) {
                updateStatus(`✅ 全部 ASIN 在 ${page} 页内找到`);
                break;
            }
        }
        // search-5 更新最终状态 & (可选) 渲染结果
        const notFound = tagAsins.filter(asin => !results[asin].found);
        if (notFound.length) {
            updateStatus(`❌ 未找到:${notFound.join(', ')}`);
        } else {
            updateStatus(`✅ 全部 ASIN 已定位`);
        }
        // session存储
        sessionStorage.setItem('tm_tagAsins', JSON.stringify(tagAsins));
        sessionStorage.setItem('tm_results', JSON.stringify(results));
        renderResultsPanel(results)
    });

    // —— 搜索结果面板 ——
    function renderResultsPanel(results) {
        let panel = document.getElementById('results-panel');
        if (!panel) {
            panel = document.createElement('div');
            panel.id = 'results-panel';
            Object.assign(panel.style, {
                position: 'fixed',
                top: '100px',      // 根据你的 tm-asin-container 高度适当调整
                left: '5px',
                background: 'rgba(255,255,255,0.95)',
                border: '1px solid #ddd',
                borderRadius: '4px',
                padding: '5px',
                boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
                zIndex: '9999',
                fontSize: '14px',
                width: '290px',
                lineHeight: '1.4'
            });
            document.body.appendChild(panel);
        }
        // 1. 构造 HTML:带一个 drag-handle 和每行的“跳转”按钮
        const lines = Object.entries(results).map(([asin, r]) => {
            let text;
            if (r.found) {
                const totalRank = (r.page - 1) * 48 + r.position;
                text = `第${r.page}页${r.position}位-排名${totalRank}`;
            } else {
                text = `<span style="color:#f56c6c;">❌未找到</span>`;
            }
            // 如果找到了,就显示一个按钮,点击后跳转到该 ASIN 所在页
            const btn = r.found
                ? `
                <button class="rp-jump-btn" data-page="${r.page}" 
                    style="
                    margin-top: 2px;
                    margin-left: 6px;
                    margin-bottom: 3.5px;
                    line-height: 12px;
                    color: #5ba7f4;
                    background-color: #ecf5ff;
                    border: 1px solid;
                    border-radius: 5px;
                    padding-top: 2px;
                    cursor: pointer;"
                    onmouseover="this.style.backgroundColor='#5ba7f4'; this.style.color='#ffffff'; this.style.borderColor='#5ba7f4';"
                    onmouseout="this.style.backgroundColor='#ecf5ff'; this.style.color='#5ba7f4'; this.style.borderColor='initial';"
                >➡</button>
                <button class="dw-jump-btn" data-asin="${asin}" 
                    style='width: 0px;
                    background-color: #ffffff;
                    border: 0px;
                    line-height: 12px;
                    margin-top: -3px;
                    font-size: 18px;
                    padding: 0px;
                    cursor: pointer;'
                    onmouseover="this.style.fontSize='20px';"
                    onmouseout="this.style.fontSize='18px';"
                    >📍</button>`
                : '';
            return `<li style="margin-top:4px;list-style:none;"> 
                <strong>${asin}</strong>:${text}${btn}
                    </li>`;
        }).join('');

        panel.innerHTML = `
            <div class="drag-handle" style="
                cursor: move;
                background:#f5f5f5;
                padding:6px;
                border-bottom:1px solid #ddd;
                font-weight:500;
                font-size: 16px;
                ">查询结果</div>
            <ul style="padding:4px;margin:0;">${lines}</ul>
        `;

        // 2. 拖拽:只绑定到 .drag-handle
        const handle = panel.querySelector('.drag-handle');
        handle.onmousedown = e => {
            const rect = panel.getBoundingClientRect();
            const shiftX = e.clientX - rect.left;
            const shiftY = e.clientY - rect.top;
            function onMouseMove(e) {
                panel.style.left = (e.clientX - shiftX) + 'px';
                panel.style.top = (e.clientY - shiftY) + 'px';
            }
            document.addEventListener('mousemove', onMouseMove);
            document.onmouseup = () => {
                document.removeEventListener('mousemove', onMouseMove);
                document.onmouseup = null;
            };
            e.preventDefault();
        };
        handle.ondragstart = () => false;

        // 3. 点击 “跳转” 按钮 的事件委托
        panel.onclick = e => {
            if (e.target.matches('.rp-jump-btn')) {
                const page = parseInt(e.target.dataset.page, 10);
                // 当前页page
                const currentUrl = new URL(location.href);
                const currentPage = parseInt(currentUrl.searchParams.get('page'), 10) || 1;

                // 如果要跳转的页就是当前页
                if (page === currentPage) {
                    console.log(`Already on page ${page}, no navigation.`);
                    return;
                }
                // 跳转
                const gotoUrl = currentUrl;
                gotoUrl.searchParams.delete('page');
                if (page > 1) gotoUrl.searchParams.set('page', page);
                location.href = gotoUrl.href;
            }
            if (e.target.matches('.dw-jump-btn')) {
                // 需要高亮的asin
                const highLightAsin = e.target.dataset.asin;
                // 只在 Amazon 搜索结果区查找&非广告位
                const candidates = document.querySelectorAll(`.s-main-slot > [data-asin="${highLightAsin}"]`);
                const elem = Array.from(candidates).find(node =>
                    !node.querySelector('a.puis-label-popover.puis-sponsored-label-text')
                );
                if (!elem) {
                    return alert(`未能在当前页面找到ASIN-${highLightAsin}, 点击前方页面跳转按钮`);
                }
                // 4. 高亮 & 滚动到视图
                elem.style.border = '2px solid red';
                elem.style.padding = '5px';
                elem.scrollIntoView({ behavior: 'smooth', block: 'center' });
            }
        }
    };

    // —— 缓存清除 ——
    btnClearCache.addEventListener('click', () => {
        sessionStorage.removeItem('tm_tagAsins');
        sessionStorage.removeItem('tm_results');
        sessionStorage.removeItem('tm_keywords');
        sessionStorage.removeItem('tm_batch_table');

        // 请填写 ASIN, 点击"搜索排名
        updateStatus('请填写 ASIN, 点击"搜索排名')

        tagAsins = [];
        results = {};
        keywords = [];

        renderTags();
        renderResultsPanelFromTable()

        const panel = document.getElementById('results-panel');
        if (panel) panel.remove();

        const batchPanel = document.getElementById('batch-results-panel');
        if (batchPanel) batchPanel.remove();
    });

    // 动态加载 SheetJS(xlsx.full.min.js),确保全局有 XLSX
    async function loadSheetJSLib() {
        return new Promise((resolve, reject) => {
            if (window.XLSX) return resolve();
            const s = document.createElement('script');
            s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/xlsx.full.min.js';
            s.onload = () => resolve();
            s.onerror = () => reject(new Error('加载 XLSX 库失败'));
            document.head.appendChild(s);
        });
    }

    // 然后在设置 fileInput 监听之前,先调用它
    await loadSheetJSLib();

    // —— excel文件解析 ——
    fileInput.addEventListener('change', async e => {
        const file = e.target.files[0];
        if (!file) alert('未选择文件');

        // 校验文件大小:不超过 1MB
        const maxSize = 1 * 1024 * 1024; // 1MB
        if (file.size > maxSize) {
            alert('Excel 文件不能大于 1MB,请选择更小的文件。');
            fileInput.value = '';  // 清空选中文件
            return;
        }

        // 读取并解析
        const data = await file.arrayBuffer();
        const wb = XLSX.read(data, { type: 'array' });
        const sheet = wb.Sheets[wb.SheetNames[0]];
        const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });

        // 只取每行第一列,过滤空值并 trim
        keywords = rows
            .map(row => row[0])
            .filter(cell => typeof cell === 'string' && cell.trim().length > 0)
            .map(cell => cell.trim());

        sessionStorage.setItem('tm_keywords', JSON.stringify(keywords));
        batchSearchBtn.disabled = false;
        alert(`已导入并缓存 ${keywords.length} 条关键词`);
        console.log('keywords keywords keywords', keywords);
    });

    // 工具-睡眠和随机数
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    function randomBetween(min, max) {
        return Math.floor(Math.random() * (max - min + 1)) + min;
    }

    //  包装后的 fetch 函数,包含随机延迟 & 错误退避
    async function fetchAsinWithDelay(keyword, asin, maxPages) {
        // 在真正请求之前,先等待 1-3 秒(随机)
        await sleep(randomBetween(3000, 5000));

        try {
            return await fetchAsinPosition(keyword, asin, maxPages);
        } catch (err) {
            console.warn(`Request failed for ${keyword} / ${asin}:`, err);
            // 碰到错误(网络、429等),退避 30–60 秒再重试一次
            await sleep(randomBetween(30000, 60000));
            return fetchAsinPosition(keyword, asin, maxPages);
        }
    }

    // 批量搜索fetch函数
    async function fetchAsinPosition(keyword, asin, maxPages) {
        const base = new URL(location.href);
        base.searchParams.set('k', keyword);
        base.searchParams.delete('page');

        for (let page = 1; page <= maxPages; page++) {
            base.searchParams.set('page', page);
            const html = await fetch(base.href, { credentials: 'include' })
                .then(r => r.text());
            const doc = new DOMParser().parseFromString(html, 'text/html');
            let nat = 0;
            for (const node of doc.querySelectorAll('div[data-asin]')) {
                if (!node.querySelector('button.a-button-text, a.a-button-text')) continue;
                if (node.querySelector('.puis-sponsored-label-text')) continue;
                // 只加自然位
                nat++;
                if (node.getAttribute('data-asin') === asin) {
                    return { page, position: nat };
                }
            }
        }
        // 确保不返回undefined
        return { page: null, position: null };
    }

    // 渲染批量结果面板
    function renderResultsPanelFromTable(table) {
        let panel = document.getElementById('batch-results-panel');
        if (!panel) {
            panel = document.createElement('div');
            panel.id = 'batch-results-panel';
            Object.assign(panel.style, {
                position: 'fixed',
                top: '100px',
                left: '10px',
                background: 'rgba(255,255,255,0.95)',
                border: '1px solid #ddd',
                borderRadius: '4px',
                boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
                zIndex: '9999',
                width: '320px',
                fontSize: '14px',
                lineHeight: '1.4',
                overflow: 'hidden'
            });
            document.body.appendChild(panel);

            const header = document.createElement('div');
            header.id = 'results-header';
            header.textContent = '查询结果';
            Object.assign(header.style, {
                cursor: 'move',
                background: '#f5f5f5',
                padding: '6px 8px',
                borderBottom: '1px solid #ddd',
                fontWeight: '600',
                fontSize: '16px'
            });
            panel.appendChild(header);

            header.addEventListener('mousedown', e => {
                const rect = panel.getBoundingClientRect();
                const dx = e.clientX - rect.left;
                const dy = e.clientY - rect.top;
                function mm(ev) {
                    panel.style.left = ev.clientX - dx + 'px';
                    panel.style.top = ev.clientY - dy + 'px';
                }
                document.addEventListener('mousemove', mm);
                document.addEventListener('mouseup', () => {
                    document.removeEventListener('mousemove', mm);
                }, { once: true });
                e.preventDefault();
            });
        } else {
            panel.innerHTML = '';
            const header = document.createElement('div');
            header.id = 'batch-results-header';
            header.textContent = '查询结果';
            Object.assign(header.style, {
                cursor: 'move',
                background: '#f5f5f5',
                padding: '6px 8px',
                borderBottom: '1px solid #ddd',
                fontWeight: '600',
                fontSize: '16px'
            });
            panel.appendChild(header);
            header.addEventListener('mousedown', e => {
                const rect = panel.getBoundingClientRect();
                const dx = e.clientX - rect.left;
                const dy = e.clientY - rect.top;
                function mm(ev) {
                    panel.style.left = ev.clientX - dx + 'px';
                    panel.style.top = ev.clientY - dy + 'px';
                }
                document.addEventListener('mousemove', mm);
                document.addEventListener('mouseup', () => {
                    document.removeEventListener('mousemove', mm);
                }, { once: true });
                e.preventDefault();
            });
        }

        const ul = document.createElement('ul');
        ul.style.listStyle = 'none';
        ul.style.padding = '8px';
        ul.style.margin = '0';

        table.forEach(({ keyword, asin, page, position }) => {
            const li = document.createElement('li');
            li.style.marginBottom = '6px';

            const text = document.createElement('span');
            const totalRank = (page - 1) * 48 + position;
            text.innerHTML = `<strong>${keyword}</strong> | ASIN: ${asin} | ` +
                (page ? `第${page}页 第${position}位 总排名${totalRank}` : `<span style="color:#f56c6c;">未找到</span>`);
            li.appendChild(text);

            if (page) {
                const btnJump = document.createElement('button');
                btnJump.className = 'rp-jump-btn';
                btnJump.dataset.page = page;
                btnJump.dataset.keyword = keyword
                btnJump.textContent = '➡';
                btnJump.style.marginLeft = '8px';
                li.appendChild(btnJump);

                const btnLoc = document.createElement('button');
                btnLoc.className = 'dw-jump-btn';
                btnLoc.dataset.asin = asin;
                btnLoc.textContent = '📍';
                btnLoc.style.marginLeft = '4px';
                li.appendChild(btnLoc);
            }

            ul.appendChild(li);
        });

        panel.appendChild(ul);

        panel.onclick = e => {
            const jump = e.target.closest('.rp-jump-btn');
            if (jump) {
                const page = +jump.dataset.page;
                const keyword = jump.dataset.keyword;
                // 构造新的搜索 URL:带上 k=keyword 和 page=page(page>1 时)
                const url = new URL(window.location.origin + '/s');
                url.searchParams.set('k', keyword);
                if (page > 1) url.searchParams.set('page', page);
                location.href = url.href;
                return;
            }
            const loc = e.target.closest('.dw-jump-btn');
            if (loc) {
                const a = loc.dataset.asin;
                const nodes = Array.from(document.querySelectorAll(`.s-main-slot > [data-asin="${a}"]`));
                const el = nodes.find(n => !n.querySelector('.s-sponsored-label, .puis-sponsored-label-text'));
                if (el) {
                    el.style.border = '2px solid red';
                    el.style.padding = '5px';
                    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
                } else {
                    alert(`当前页未找到 ASIN:${a}`);
                }
            }
        };
    }

    // excel导出函数
    async function exportToExcel(data) {
        // data: [ { keyword, asin ,page, position }, … ]
        await loadSheetJSLib();

        // 2. 预处理数据:page/position 为空替换为 "-"
        const processed = data.map(({ asin, keyword, page, position, totalRank }) => ({
            asin,
            keyword,
            page: page == null ? "-" : page,
            position: position == null ? "-" : position,
            totalRank: totalRank == null ? "-" : totalRank
        }));

        // 把 JSON 转为工作表
        const ws = XLSX.utils.json_to_sheet(data, {
            header: ['关键词', 'ASIN', '页数', '位置', '总排名']
        });

        // 4. 给表头加粗且居中
        const range = XLSX.utils.decode_range(ws['!ref']);
        for (let C = range.s.c; C <= range.e.c; ++C) {
            const cellAddress = XLSX.utils.encode_cell({ r: 0, c: C });
            const cell = ws[cellAddress];
            if (!cell) continue;
            cell.s = {
                font: { bold: true },
                alignment: { horizontal: 'center' }
            };
        }

        // 新建工作簿并追加工作表
        const wb = XLSX.utils.book_new();
        XLSX.utils.book_append_sheet(wb, ws, '排名结果');

        // 6. 生成文件名:YYYY/M/D-站点-AsinKwRank
        const host = window.location.host;
        const siteMap = {
            'www.amazon.com': 'US',
            'www.amazon.co.uk': 'UK',
            'www.amazon.ca': 'CA',
            'www.amazon.de': 'DE',
            'www.amazon.fr': 'FR',
            'www.amazon.es': 'ES',
            'www.amazon.it': 'IT'
        };
        const siteCode = siteMap[host] || host;
        const now = new Date();
        const fileName = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}-${siteCode}-AsinKwRank.xlsx`;

        // 生成二进制数组
        const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });

        // 创建 Blob 并触发下载
        const blob = new Blob([wbout], { type: 'application/octet-stream' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'asin_keyword_rankings.xlsx';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    // —— 批量搜索按钮 ——
    batchSearchBtn.addEventListener('click', async () => {
        if (!keywords.length) {
            return alert('请先导入关键词文件');
        }
        if (!tagAsins.length) {
            return alert('请先添加至少一个 ASIN');
        }
        const tasks = [];
        for (const keyword of keywords) {
            for (const asin of tagAsins) {
                // {'winter gloves men', B0xxxxxxxx}
                tasks.push({ keyword, asin });
            }
        }
        // 循环执行,每次查询一个"关键词 × ASIN"
        const table = [];
        updateStatus(`🔎 开始批量查询:${tasks.length} 次`);
        for (const { keyword, asin } of tasks) {
            updateStatus(`🔎 查询 "${keyword}" 下 ASIN-${asin}`);
            // fetchAsinWithDelay 返回 { page, position }
            const { page, position } = await fetchAsinWithDelay(keyword, asin, maxPages);
            const totalRank = (page - 1) * 48 + position;
            table.push({ keyword, asin, page, position, totalRank });
        }
        console.log('结果table 结果table 结果table', table);
        sessionStorage.setItem('tm_batch_table', JSON.stringify(table));
        sessionStorage.setItem('tm_tagAsins', JSON.stringify(tagAsins));
        alert('搜索完成,共 ' + table.length + ' 条记录');
        renderResultsPanelFromTable(table);
    })

    // 点击时,从 sessionStorage 取出缓存的 table,并调用 exportToExcel
    downloadBtn.addEventListener('click', async () => {
        const raw = sessionStorage.getItem('tm_batch_table');
        if (!raw) {
            return alert('当前没有可下载的查询结果,请先执行批量搜索。');
        }
        let table;
        try {
            table = JSON.parse(raw);
        } catch {
            return alert('结果数据解析失败。');
        }
        // 调用之前定义的导出函数
        await exportToExcel(table);
    });
})();