Page Crawling

图片视频抓取工具 分页列表自动去重 原图链接背景图抓取 图片批量ZIP下载 MP4批量下载 支持blob m3u8视频 脚本信息分页显示

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Page Crawling
// @namespace    http://tampermonkey.net/
// @version      1.5.9
// @description  图片视频抓取工具 分页列表自动去重 原图链接背景图抓取 图片批量ZIP下载 MP4批量下载 支持blob m3u8视频 脚本信息分页显示
// @author       苳:-)
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @connect      *
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// ==/UserScript==

(function() {
    'use strict';

    if(document.querySelector('#page-crawling-panel')) return;

    const SCRIPT_VERSION = '1.5.9';
    const STORAGE_KEY_ITEMS = 'ClipboardTool_Global_Items';
    const MAX_ITEMS = 1000;
    const MAX_FETCH_IMAGES = 200;
    const PAGE_SIZE = 20;

    const MP4_CONFIG = {
        MAX_CONCURRENT: 2,
        RETRY_LIMIT: 2,
        TIMEOUT: 30000
    };

    const IMAGE_CONFIG = {
        MAX_CONCURRENT: 2,
        TIMEOUT: 10000,
        MAX_SIZE: 10 * 1024 * 1024,
        BATCH_SIZE: 5,
        MAX_TOTAL_SIZE: 100 * 1024 * 1024
    };

    let downloadingCount = 0;
    let downloadQueue = [];
    let imageDownloadQueue = [];
    let imageDownloadingCount = 0;

    const config = {
        author: '苳:-)',
        contacts: [
            { name: 'Weibo', url: 'https://weibo.com/u/2809762605' },
            { name: 'Twitter', url: 'https://x.com/nkvvo?s=21' }
        ]
    };

    let items = [];
    try { items = JSON.parse(localStorage.getItem(STORAGE_KEY_ITEMS) || '[]'); } catch(e){ items=[]; }
    function saveItems(){ localStorage.setItem(STORAGE_KEY_ITEMS, JSON.stringify(items)); }

    function toOriginalUrl(url){
        if(!url) return url;
        try {
            const u = new URL(url);
            ['w','h','width','height','size','quality','x-oss-process'].forEach(p=>u.searchParams.delete(p));
            url = u.toString();
        } catch(e){}
        url = url.replace(/(_small|_thumb|_mini|-\d+x\d+|!thumb|_webp)/ig,'');
        url = url.replace(/#.*$/,'').replace(/(\?|&).*$/,'');
        return url;
    }

    function isDuplicateImage(url){
        const normalized = toOriginalUrl(url).replace(/^https?:\/\/[^\/]+/i, '');
        return items.some(i=>{
            if(i.type!=='image') return false;
            const existing = toOriginalUrl(i.content).replace(/^https?:\/\/[^\/]+/i, '');
            return existing === normalized;
        });
    }

    function addItem(type, content, page){
        if(type==='image' && isDuplicateImage(content)) return;
        const id = crypto.randomUUID();
        if(items.length>=MAX_ITEMS) items.shift();
        items.push({id,type,content,page:page||null,time:Date.now()});
        saveItems();
    }

    function createModule(title){
        const module = document.createElement('div');
        module.style.cssText = 'margin-bottom:8px;border-radius:12px;box-shadow:0 4px 14px rgba(0,0,0,0.15);overflow:hidden;font-family:"Segoe UI",sans-serif;';
        const header = document.createElement('div');
        header.style.cssText = 'background: linear-gradient(135deg,#4a90e2,#357ABD);color:white;padding:8px 12px;cursor:pointer;font-weight:bold;user-select:none;';
        header.innerText = title + ' ▼';
        const content = document.createElement('div');
        content.style.cssText = 'background:#f0f4f8;padding:10px;display:none;max-height:400px;overflow:auto;border-top:1px solid #d1dce6;';
        header.addEventListener('click', ()=>{
            if(content.style.display==='none'){ content.style.display='block'; header.innerText = title+' ▲'; }
            else { content.style.display='none'; header.innerText = title+' ▼'; }
        });
        module.appendChild(header);
        module.appendChild(content);
        return {module, content};
    }

    async function ensureJSZip() {
        if (window.JSZip) return window.JSZip;
        for (let i = 0; i < 30; i++) {
            if (window.JSZip) return window.JSZip;
            await new Promise(r => setTimeout(r, 100));
        }
        throw new Error('JSZip加载超时');
    }

    async function downloadImageWithGM(url, index, total) {
        return new Promise((resolve) => {
            if (typeof GM_xmlhttpRequest !== 'undefined') {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: url,
                    responseType: "blob",
                    timeout: IMAGE_CONFIG.TIMEOUT,
                    headers: {
                        "Referer": location.href,
                        "Origin": location.origin
                    },
                    onload: function(res) {
                        if (res.status === 200 || res.status === 206) {
                            const blob = res.response;
                            if (blob && blob.size > 0 && blob.size <= IMAGE_CONFIG.MAX_SIZE) {
                                resolve({ success: true, blob, url });
                            } else {
                                resolve({ success: false, url, error: '文件无效或过大' });
                            }
                        } else {
                            resolve({ success: false, url, error: `HTTP ${res.status}` });
                        }
                    },
                    onerror: () => resolve({ success: false, url, error: '网络错误' }),
                    ontimeout: () => resolve({ success: false, url, error: '超时' })
                });
            } else {
                fetch(url, { mode: 'cors', cache: 'force-cache', headers: { 'Referer': location.href } })
                    .then(res => res.blob())
                    .then(blob => {
                        if (blob.size > 0 && blob.size <= IMAGE_CONFIG.MAX_SIZE) {
                            resolve({ success: true, blob, url });
                        } else {
                            resolve({ success: false, url, error: '文件无效或过大' });
                        }
                    })
                    .catch(() => resolve({ success: false, url, error: '获取失败' }));
            }
        });
    }

    async function downloadAllImages() {
        const imageItems = items.filter(i => i.type === 'image');
        if (imageItems.length === 0) { alert("没有可下载的图片"); return; }
        if (imageItems.length > 50 && !confirm(`共有 ${imageItems.length} 张图片,打包下载可能需要较长时间,是否继续?`)) return;

        const progressDiv = document.createElement('div');
        progressDiv.style.cssText = `position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);background:#333; color:white; padding:25px; border-radius:15px;z-index:10000; box-shadow:0 4px 20px rgba(0,0,0,0.5);min-width:300px; text-align:center; font-family:monospace;`;
        progressDiv.innerHTML = `<div style="margin-bottom:15px; font-size:18px; font-weight:bold;">图片打包中</div><div id="zip-progress-status" style="margin-bottom:10px;">准备下载...</div><div style="background:#555; height:20px; border-radius:10px; overflow:hidden;"><div id="zip-progress-bar" style="background:#4CAF50; width:0%; height:100%; transition:width 0.3s;"></div></div><div id="zip-progress-detail" style="margin-top:10px; font-size:12px; color:#ccc;">0/${imageItems.length}</div><div style="margin-top:15px;"><button id="zip-cancel-btn" style="background:#f44336; border:none; color:white; padding:5px 15px; border-radius:5px; cursor:pointer;">取消</button></div>`;
        document.body.appendChild(progressDiv);

        const statusEl = document.getElementById('zip-progress-status');
        const barEl = document.getElementById('zip-progress-bar');
        const detailEl = document.getElementById('zip-progress-detail');
        const cancelBtn = document.getElementById('zip-cancel-btn');
        let cancelled = false;
        cancelBtn.onclick = () => { cancelled = true; progressDiv.remove(); alert('已取消下载'); };

        try {
            statusEl.innerText = '加载ZIP库...';
            const JSZip = await ensureJSZip();
            if (cancelled) return;
            const zip = new JSZip();
            const folderName = `images_${new Date().toISOString().slice(0,10)}`;
            const imgFolder = zip.folder(folderName);
            const successfulDownloads = [];
            const failedDownloads = [];
            const batchSize = IMAGE_CONFIG.BATCH_SIZE;
            let processed = 0;

            for (let i = 0; i < imageItems.length; i += batchSize) {
                if (cancelled) { progressDiv.remove(); return; }
                const batch = imageItems.slice(i, i + batchSize);
                statusEl.innerText = `下载图片 ${i + 1}-${Math.min(i + batchSize, imageItems.length)}/${imageItems.length}`;
                const batchPromises = batch.map(async (item, batchIndex) => {
                    const globalIndex = i + batchIndex;
                    const url = toOriginalUrl(item.content);
                    let ext = 'jpg';
                    try {
                        const urlExt = url.split('.').pop().split('?')[0];
                        if (urlExt && urlExt.match(/^(jpg|jpeg|png|gif|webp|bmp|svg)$/i)) ext = urlExt.toLowerCase();
                    } catch(e) {}
                    const filename = `image_${globalIndex + 1}.${ext}`;
                    const result = await downloadImageWithGM(url, globalIndex, imageItems.length);
                    if (result.success) { imgFolder.file(filename, result.blob); successfulDownloads.push(filename); }
                    else { failedDownloads.push(url); console.log(`下载失败: ${url}`, result.error); }
                    processed++;
                    const percent = Math.round((processed / imageItems.length) * 100);
                    barEl.style.width = percent + '%';
                    detailEl.innerText = `${processed}/${imageItems.length}`;
                });
                await Promise.allSettled(batchPromises);
                await new Promise(r => setTimeout(r, 100));
            }

            if (cancelled) { progressDiv.remove(); return; }
            if (successfulDownloads.length === 0) { progressDiv.remove(); alert('所有图片下载失败'); return; }
            statusEl.innerText = '正在生成ZIP文件...';
            barEl.style.width = '90%';

            const zipContent = await new Promise((resolve, reject) => {
                zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 1 } }, (metadata) => {
                    if (metadata.percent) {
                        const newProgress = 90 + Math.round(metadata.percent * 0.1);
                        barEl.style.width = newProgress + '%';
                        statusEl.innerText = `压缩中 ${Math.round(metadata.percent)}%`;
                    }
                }).then(resolve).catch(reject);
            });

            if (cancelled) { progressDiv.remove(); return; }
            statusEl.innerText = '下载ZIP文件...';
            barEl.style.width = '100%';
            const zipUrl = URL.createObjectURL(zipContent);
            const a = document.createElement('a');
            a.href = zipUrl; a.download = `${folderName}.zip`;
            document.body.appendChild(a); a.click();
            setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(zipUrl); progressDiv.remove(); }, 1000);
            alert(`下载完成!\n成功: ${successfulDownloads.length} 张\n失败: ${failedDownloads.length} 张`);
        } catch (error) {
            console.error("打包失败:", error);
            progressDiv.remove();
            if (confirm('打包失败,是否改用逐张下载?')) downloadImagesSequential(imageItems);
        }
    }

    async function downloadImagesSequential(imageItems) {
        const progressDiv = document.createElement('div');
        progressDiv.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#333;color:white;padding:20px;border-radius:10px;z-index:10000;';
        document.body.appendChild(progressDiv);
        let success = 0, failed = 0;
        for (let i = 0; i < imageItems.length; i++) {
            const item = imageItems[i];
            const url = toOriginalUrl(item.content);
            progressDiv.innerHTML = `逐张下载中... ${i+1}/${imageItems.length}<br>成功:${success} 失败:${failed}`;
            try {
                const result = await downloadImageWithGM(url, i, imageItems.length);
                if (result.success) {
                    const ext = url.split('.').pop().split('?')[0] || 'jpg';
                    const blobUrl = URL.createObjectURL(result.blob);
                    const a = document.createElement('a');
                    a.href = blobUrl; a.download = `image_${i+1}_${Date.now()}.${ext}`;
                    document.body.appendChild(a); a.click(); document.body.removeChild(a);
                    URL.revokeObjectURL(blobUrl); success++;
                } else failed++;
            } catch (e) { failed++; }
            await new Promise(r => setTimeout(r, 300));
        }
        progressDiv.remove();
        alert(`逐张下载完成\n成功: ${success} 张\n失败: ${failed} 张`);
    }

    function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

    function extractMp4Urls() {
        const urls = new Set();
        document.querySelectorAll("video, source").forEach(el => {
            const src = el.src || el.getAttribute("src");
            if (src && src.includes(".mp4") && !src.startsWith("blob:")) urls.add(src.split("?")[0]);
        });
        document.querySelectorAll("a[href*='.mp4']").forEach(a => {
            if (a.href && !a.href.startsWith('blob:')) urls.add(a.href.split("?")[0]);
        });
        document.querySelectorAll("*").forEach(el => {
            const bg = getComputedStyle(el).backgroundImage;
            if (bg && bg.includes(".mp4")) {
                const match = bg.match(/url\(["']?(.*?)["']?\)/);
                if (match && match[1]) urls.add(match[1].split("?")[0]);
            }
        });
        return [...urls];
    }

    async function downloadWithGM(url, filename, retry = 0) {
        return new Promise((resolve, reject) => {
            if (typeof GM_xmlhttpRequest === 'undefined') {
                const a = document.createElement('a'); a.href = url; a.download = filename;
                document.body.appendChild(a); a.click(); document.body.removeChild(a);
                resolve(); return;
            }
            GM_xmlhttpRequest({
                method: "GET", url, responseType: "blob", timeout: MP4_CONFIG.TIMEOUT,
                headers: { "Referer": location.href, "Origin": location.origin, "Range": "bytes=0-" },
                onload: function (res) {
                    if (res.status === 200 || res.status === 206) {
                        const blob = res.response;
                        if (!blob || blob.size === 0) { reject("Empty file"); return; }
                        const blobUrl = URL.createObjectURL(blob);
                        const a = document.createElement("a"); a.href = blobUrl; a.download = filename;
                        document.body.appendChild(a); a.click(); a.remove();
                        setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
                        resolve();
                    } else reject("HTTP " + res.status);
                },
                onerror: reject, ontimeout: () => reject("Timeout")
            });
        }).catch(async err => {
            if (retry < MP4_CONFIG.RETRY_LIMIT) {
                console.log(`[MP4下载] 重试 ${retry + 1}/${MP4_CONFIG.RETRY_LIMIT}:`, url);
                await sleep(1000 * (retry + 1));
                return downloadWithGM(url, filename, retry + 1);
            } else { console.error("[MP4下载] 失败:", url, err); throw err; }
        });
    }

    async function processDownloadQueue() {
        if (downloadingCount >= MP4_CONFIG.MAX_CONCURRENT || downloadQueue.length === 0) return;
        downloadingCount++;
        const task = downloadQueue.shift();
        try { await task(); } catch (error) { console.error("[MP4下载] 任务执行失败:", error); }
        downloadingCount--;
        processDownloadQueue();
    }

    function queueDownload(url, filename) {
        downloadQueue.push(() => downloadWithGM(url, filename));
        processDownloadQueue();
    }

    async function downloadAllMp4() {
        const urls = extractMp4Urls();
        if (urls.length === 0) { alert("未发现可下载的MP4视频"); return; }
        let newCount = 0;
        urls.forEach(url => {
            if (!items.some(i => i.type === 'video' && i.content === url)) {
                addItem('video', url, location.href); newCount++;
            }
        });
        urls.forEach((url, index) => {
            const filename = `video_${Date.now()}_${index + 1}.mp4`;
            queueDownload(url, filename);
        });
        alert(`已开始下载 ${urls.length} 个MP4视频\n并发 ${MP4_CONFIG.MAX_CONCURRENT} 个 失败重试 ${MP4_CONFIG.RETRY_LIMIT} 次`);
        if (typeof renderVideoList === 'function') renderVideoList();
    }

    const panel = document.createElement('div');
    panel.id = 'page-crawling-panel';
    panel.style.cssText = `position:fixed; top:12px; right:12px;background: linear-gradient(135deg,#2b5d9e,#1b3f6c);color:white; padding:8px 14px; border-radius:12px;box-shadow:0 6px 16px rgba(0,0,0,0.3); cursor:pointer;z-index:9999; font-family:"Segoe UI",sans-serif; font-size:13px;`;
    panel.innerText = 'Page Crawling 展开 ▼';
    const closeBtn = document.createElement('span');
    closeBtn.textContent = '×';
    closeBtn.style.cssText = 'margin-left:8px;font-weight:bold;cursor:pointer;color:#ff4d4f;';
    closeBtn.onclick = (e)=>{ e.stopPropagation(); panel.style.display='none'; infoPanel.style.display='none'; };
    panel.appendChild(closeBtn);
    document.body.appendChild(panel);

    const infoPanel = document.createElement('div');
    infoPanel.style.cssText = `position:fixed; top:50px; right:12px;background:#e9edf3; color:#333; padding:10px;border-radius:12px; font-size:13px;box-shadow:0 6px 16px rgba(0,0,0,0.25);display:none; z-index:9999; width:360px;max-height:600px; overflow:auto;`;
    document.body.appendChild(infoPanel);

    panel.addEventListener('click', ()=>{
        if(infoPanel.style.display==='none'){ infoPanel.style.display='block'; panel.firstChild.textContent='Page Crawling 折叠 ▲'; }
        else { infoPanel.style.display='none'; panel.firstChild.textContent='Page Crawling 展开 ▼'; }
    });

    infoPanel.innerHTML = `
        <div style="padding:6px;margin-bottom:6px;background:#d9e1f2;border-radius:8px;font-weight:bold;text-align:center;color:#1b3f6c;">Page Crawling v${SCRIPT_VERSION}</div>
        <div style="padding:6px;margin-bottom:6px;background:#f6f6f8;border-radius:8px;font-weight:bold;text-align:center;color:#222;">作者: ${config.author}</div>
        <div style="padding:6px;margin-bottom:6px;background:#f0f4f8;border-radius:8px;font-weight:bold;color:#444;">联系方式:${config.contacts.map(c=>`<a href="${c.url}" target="_blank" style="color:#357ABD;text-decoration:none;margin-left:6px;">${c.name}</a>`).join('')}</div>`;

    console.log(`[Page Crawling v${SCRIPT_VERSION}] 已启动 支持图片打包下载`);

    function renderModule(type){
        const moduleTitle = type==='image'?'图片':'视频';
        const {module, content} = createModule(moduleTitle);
        infoPanel.appendChild(module);
        let currentPage = 1;
        const pageSize = PAGE_SIZE;
        const searchInput = document.createElement('input');
        searchInput.placeholder = `搜索${moduleTitle}`;
        searchInput.style.cssText='width:100%;margin-bottom:6px;padding:4px;border:1px solid #c0cbd5;border-radius:4px;';
        content.appendChild(searchInput);
        const buttonContainer = document.createElement('div');
        buttonContainer.style.cssText = 'display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap;';
        const fetchBtn = document.createElement('button');
        fetchBtn.textContent = type==='image'?'抓取图片':'抓取视频';
        fetchBtn.style.cssText='flex:1;min-width:80px;padding:6px;background:#17a2b8;color:white;border:none;border-radius:5px;cursor:pointer;';
        buttonContainer.appendChild(fetchBtn);
        if (type === 'video') {
            const downloadAllBtn = document.createElement('button');
            downloadAllBtn.textContent = '下载MP4';
            downloadAllBtn.style.cssText = 'flex:1;min-width:80px;padding:6px;background:#ff5500;color:white;border:none;border-radius:5px;cursor:pointer;';
            downloadAllBtn.onclick = downloadAllMp4;
            buttonContainer.appendChild(downloadAllBtn);
        }
        if (type === 'image') {
            const downloadAllBtn = document.createElement('button');
            downloadAllBtn.textContent = '打包下载';
            downloadAllBtn.style.cssText = 'flex:1;min-width:80px;padding:6px;background:#28a745;color:white;border:none;border-radius:5px;cursor:pointer;';
            downloadAllBtn.onclick = downloadAllImages;
            buttonContainer.appendChild(downloadAllBtn);
        }
        content.appendChild(buttonContainer);
        const clearBtn = document.createElement('button');
        clearBtn.textContent = type === 'image' ? '清空图片' : '清空视频';
        clearBtn.style.cssText='margin-bottom:6px;width:100%;padding:6px;background:#dc3545;color:white;border:none;border-radius:5px;cursor:pointer;';
        clearBtn.onclick = ()=>{
            if(confirm(type==='image'?'确认清空全部图片':'确认清空全部视频')){
                items = items.filter(i=>i.type!==type);
                saveItems(); currentPage=1; renderList();
            }
        };
        content.appendChild(clearBtn);
        const listEl = document.createElement('div');
        content.appendChild(listEl);
        const paginationEl = document.createElement('div');
        paginationEl.style.cssText='text-align:center;margin-top:6px;';
        content.appendChild(paginationEl);

        function renderList(){
            listEl.innerHTML='';
            const query = searchInput.value.trim().toLowerCase();
            const filtered = items.filter(i=>i.type===type && (!query || i.content.toLowerCase().includes(query))).sort((a,b)=>b.time - a.time);
            const totalPages = Math.max(1, Math.ceil(filtered.length/pageSize));
            if(currentPage>totalPages) currentPage=totalPages;
            const pageItems = filtered.slice((currentPage-1)*pageSize, currentPage*pageSize);
            pageItems.forEach(item=>{
                const el = document.createElement('div');
                el.style.cssText='background:#f7f9fc;padding:6px;border-radius:6px;margin-bottom:6px;';
                if(type==='image'){
                    const img = document.createElement('img');
                    img.src = toOriginalUrl(item.content);
                    img.style.cssText='width:100%;max-height:120px;object-fit:cover;border-radius:4px;margin-bottom:4px;';
                    el.appendChild(img);
                }
                if(type==='video'){
                    const video = document.createElement('video');
                    video.src=item.content; video.controls=true;
                    video.style.cssText='width:100%;max-height:180px;border-radius:6px;margin-bottom:4px;';
                    el.appendChild(video);
                    if(item.content.endsWith('.m3u8')){
                        const hint = document.createElement('div');
                        hint.textContent = '⚠ m3u8需专用工具下载';
                        hint.style.cssText='color:#d9534f;font-size:12px;margin-bottom:4px;';
                        el.appendChild(hint);
                    }
                    if(item.content.startsWith('blob:')){
                        const hintBlob = document.createElement('div');
                        hintBlob.textContent = '⚠ blob视频仅当前页面有效';
                        hintBlob.style.cssText='color:#d9534f;font-size:12px;margin-bottom:4px;';
                        el.appendChild(hintBlob);
                    }
                }
                const btnRow = document.createElement('div');
                btnRow.style.cssText='display:flex;gap:4px;flex-wrap:wrap;';
                const copyBtn = document.createElement('button');
                copyBtn.textContent='复制';
                copyBtn.style.cssText='flex:1;min-width:50px;padding:4px;background:#357ABD;color:white;border:none;border-radius:4px;cursor:pointer;';
                copyBtn.onclick=()=>navigator.clipboard.writeText(item.content);
                btnRow.appendChild(copyBtn);
                const openBtn = document.createElement('button');
                openBtn.textContent='打开';
                openBtn.style.cssText='flex:1;min-width:50px;padding:4px;background:#17a2b8;color:white;border:none;border-radius:4px;cursor:pointer;';
                openBtn.onclick = ()=>window.open(toOriginalUrl(item.content),'_blank');
                btnRow.appendChild(openBtn);
                const downloadBtn = document.createElement('button');
                downloadBtn.textContent='下载';
                downloadBtn.style.cssText='flex:1;min-width:50px;padding:4px;background:#6c757d;color:white;border:none;border-radius:4px;cursor:pointer;';
                downloadBtn.onclick = async ()=>{
                    const url = toOriginalUrl(item.content);
                    if (type === 'image') {
                        const ext = url.split('.').pop().split('?')[0] || 'jpg';
                        const filename = `image_${Date.now()}.${ext}`;
                        downloadBtn.textContent = '下载中'; downloadBtn.disabled = true;
                        try {
                            const result = await downloadImageWithGM(url);
                            if (result.success) {
                                const blobUrl = URL.createObjectURL(result.blob);
                                const a = document.createElement('a');
                                a.href = blobUrl; a.download = filename;
                                document.body.appendChild(a); a.click(); document.body.removeChild(a);
                                URL.revokeObjectURL(blobUrl); downloadBtn.textContent = '完成';
                            } else { downloadBtn.textContent = '重试'; alert('下载失败'); }
                        } catch (error) { downloadBtn.textContent = '重试'; alert('下载失败'); }
                        downloadBtn.disabled = false;
                        setTimeout(() => { downloadBtn.textContent = '下载'; }, 2000);
                        return;
                    }
                    if (url.includes('.mp4')) {
                        const filename = `video_${Date.now()}.mp4`;
                        downloadBtn.textContent = '下载中'; downloadBtn.disabled = true;
                        try {
                            await downloadWithGM(url, filename);
                            downloadBtn.textContent = '完成';
                            setTimeout(() => { downloadBtn.textContent = '下载'; downloadBtn.disabled = false; }, 2000);
                        } catch (error) { downloadBtn.textContent = '重试'; downloadBtn.disabled = false; alert('下载失败'); }
                        return;
                    }
                    if(url.endsWith('.m3u8')){ alert('⚠ m3u8需专用工具下载'); return; }
                    if(url.startsWith('blob:')){
                        try {
                            const videoEl = document.querySelector(`video[src="${url}"]`);
                            if(!videoEl) throw new Error('未找到视频元素');
                            const mediaStream = videoEl.captureStream();
                            const mediaRecorder = new MediaRecorder(mediaStream);
                            const chunks = [];
                            mediaRecorder.ondataavailable = e => chunks.push(e.data);
                            mediaRecorder.onstop = ()=>{
                                const blob = new Blob(chunks, {type:'video/mp4'});
                                const a = document.createElement('a');
                                a.href = URL.createObjectURL(blob); a.download = `video_${Date.now()}.mp4`;
                                document.body.appendChild(a); a.click(); document.body.removeChild(a);
                                URL.revokeObjectURL(a.href);
                            };
                            mediaRecorder.start();
                            setTimeout(()=>mediaRecorder.stop(), 3000);
                        } catch(e){ alert('blob下载失败'); }
                        return;
                    }
                    try {
                        const resp = await fetch(url, {mode:'cors'});
                        if(!resp.ok) throw new Error('网络错误');
                        const blob = await resp.blob();
                        const ext = url.split('.').pop().split('?')[0] || 'mp4';
                        const a = document.createElement('a');
                        a.href = URL.createObjectURL(blob); a.download = `video_${Date.now()}.${ext}`;
                        document.body.appendChild(a); a.click(); document.body.removeChild(a);
                        URL.revokeObjectURL(a.href);
                    } catch(e){ alert('下载失败'); }
                };
                btnRow.appendChild(downloadBtn);
                const delBtn = document.createElement('button');
                delBtn.textContent='删除';
                delBtn.style.cssText='flex:1;min-width:50px;padding:4px;background:#dc3545;color:white;border:none;border-radius:4px;cursor:pointer;';
                delBtn.onclick=()=>{
                    const idx=items.findIndex(i=>i.id===item.id);
                    if(idx>=0){ items.splice(idx,1); saveItems(); renderList(); }
                };
                btnRow.appendChild(delBtn);
                el.appendChild(btnRow);
                listEl.appendChild(el);
            });
            paginationEl.innerHTML='';
            const prevBtn = document.createElement('button');
            prevBtn.textContent = '上一页';
            prevBtn.disabled = currentPage===1;
            prevBtn.style.cssText='margin-right:4px;padding:2px 6px;';
            prevBtn.onclick = ()=>{ currentPage--; renderList(); };
            const nextBtn = document.createElement('button');
            nextBtn.textContent = '下一页';
            nextBtn.disabled = currentPage===totalPages;
            nextBtn.style.cssText='margin-left:4px;padding:2px 6px;';
            nextBtn.onclick = ()=>{ currentPage++; renderList(); };
            const pageInfo = document.createElement('span');
            pageInfo.textContent = `${currentPage}/${totalPages}`;
            pageInfo.style.cssText='margin:0 6px;font-weight:bold;';
            paginationEl.appendChild(prevBtn);
            paginationEl.appendChild(pageInfo);
            paginationEl.appendChild(nextBtn);
        }
        searchInput.addEventListener('input', ()=>{ currentPage=1; renderList(); });
        fetchBtn.onclick = ()=>{
            if(type==='image'){
                const imgs = Array.from(document.querySelectorAll('img'))
                    .filter(img=>img.width>=80 && img.height>=80)
                    .filter(img=>img.src && !img.src.startsWith('data:'))
                    .map(img=>toOriginalUrl(img.src));
                document.querySelectorAll('*').forEach(el=>{
                    const cls = el.className || '';
                    const id = el.id || '';
                    if(/ad|banner|overlay|float/i.test(cls+id)) return;
                    const bg = getComputedStyle(el).backgroundImage;
                    if(bg && bg.startsWith('url(')){
                        const url = bg.slice(4,-1).replace(/["']/g,'');
                        if(url && !url.startsWith('data:') && !url.match(/1x1/)) imgs.push(toOriginalUrl(url));
                    }
                });
                const finalImgs = [...new Set(imgs)].filter(url=>!url.includes('favicon')).slice(0, MAX_FETCH_IMAGES);
                let count=0;
                finalImgs.forEach(url=>{ if(!isDuplicateImage(url)){ addItem('image',url,location.href); count++; } });
                alert(`抓取 ${count} 张图片`);
            } else {
                let urls = new Set();
                document.querySelectorAll('video').forEach(v=>{
                    if(v.src) urls.add(v.src);
                    v.querySelectorAll('source').forEach(s=>urls.add(s.src));
                });
                document.querySelectorAll('a').forEach(a=>{
                    if(a.href.match(/\.(mp4|webm|ogg|mov|m3u8)(\?|$)/i)) urls.add(a.href);
                });
                extractMp4Urls().forEach(url => urls.add(url));
                let count=0;
                urls.forEach(url=>{ if(!items.some(i=>i.type==='video' && i.content===url)){ addItem('video',url,location.href); count++; } });
                alert(`抓取 ${count} 个视频`);
            }
            currentPage=1; renderList();
        };
        renderList();
    }

    renderModule('image');
    renderModule('video');

    (function(){
        const {module, content} = createModule('脚本信息')
        infoPanel.appendChild(module)
        content.style.display = 'none'
        const SCRIPT_LOGS = [
            {ver:'v1.5.9', desc:'优化ZIP生成速度 添加进度条和取消功能 降低压缩级别'},
            {ver:'v1.5.8', desc:'增强JSZip加载机制 添加备选下载方案'},
            {ver:'v1.5.7', desc:'修复JSZip加载失败问题 多CDN源备选'},
            {ver:'v1.5.6', desc:'图片批量ZIP下载 MP4并发下载 进度显示'},
            {ver:'v1.5.5', desc:'MP4下载增强 并发重试 GM_xmlhttpRequest支持'},
            {ver:'v1.5.4', desc:'背景图抓取优化 Blob和m3u8视频提示'},
            {ver:'v1.5.3', desc:'修复重复注入 清空按钮精准化'},
            {ver:'v5.1.2', desc:'支持blob m3u8视频抓取 性能优化'},
            {ver:'v1.5.1', desc:'增加搜索过滤分页功能'},
            {ver:'v1.5.0', desc:'首次公开 图片视频抓取面板'},
        ]
        const pageSize = 3
        let currentPage = 1
        const descBlock = document.createElement('div')
        descBlock.style.cssText = 'padding:6px;margin-bottom:6px;background:#f0f4f8;border-radius:8px;color:#444'
        descBlock.innerHTML = `<strong>脚本说明:</strong> 抓取网页图片视频 自动去重原图链接 背景图抓取 图片批量ZIP下载 MP4批量下载 支持blob和m3u8 分页搜索功能`
        content.appendChild(descBlock)
        const versionBlock = document.createElement('div')
        versionBlock.style.cssText = 'padding:6px;margin-bottom:6px;background:#f6f6f8;border-radius:8px;color:#444'
        versionBlock.innerHTML = `<strong>当前版本:</strong> v${SCRIPT_VERSION}`
        content.appendChild(versionBlock)
        const logContainer = document.createElement('div')
        logContainer.style.cssText = 'padding:6px;margin-bottom:6px;background:#e9edf3;border-radius:8px;color:#444'
        logContainer.innerHTML = '<strong>更新日志:</strong>'
        content.appendChild(logContainer)
        const paginationEl = document.createElement('div')
        paginationEl.style.cssText = 'text-align:center;margin-top:4px'
        content.appendChild(paginationEl)
        function renderLogPage(){
            const oldUL = logContainer.querySelector('ul')
            if(oldUL) oldUL.remove()
            const start = (currentPage-1)*pageSize
            const pageItems = SCRIPT_LOGS.slice(start, start+pageSize)
            const ul = document.createElement('ul')
            ul.style.cssText = 'margin:4px 0 0 16px;padding:0'
            pageItems.forEach(item=>{
                const li = document.createElement('li')
                li.innerHTML = `<strong>${item.ver}</strong> ${item.desc}`
                ul.appendChild(li)
            })
            logContainer.appendChild(ul)
            const totalPages = Math.ceil(SCRIPT_LOGS.length/pageSize)
            paginationEl.innerHTML=''
            const prevBtn = document.createElement('button')
            prevBtn.textContent='上一页'
            prevBtn.disabled=currentPage===1
            prevBtn.style.cssText='margin-right:4px;padding:2px 6px'
            prevBtn.onclick=()=>{currentPage--;renderLogPage()}
            const nextBtn = document.createElement('button')
            nextBtn.textContent='下一页'
            nextBtn.disabled=currentPage===totalPages
            nextBtn.style.cssText='margin-left:4px;padding:2px 6px'
            nextBtn.onclick=()=>{currentPage++;renderLogPage()}
            const pageInfo = document.createElement('span')
            pageInfo.textContent=`${currentPage}/${totalPages}`
            pageInfo.style.cssText='margin:0 6px;font-weight:bold'
            paginationEl.appendChild(prevBtn)
            paginationEl.appendChild(pageInfo)
            paginationEl.appendChild(nextBtn)
        }
        renderLogPage()
    })();

})();