Poipiku Ripper

Tải ảnh từ poipiku

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Poipiku Ripper
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Tải ảnh từ poipiku
// @author       Kaypi
// @match        https://poipiku.com/*
// @grant        GM_xmlhttpRequest
// @connect      cdn.poipiku.com
// @connect      poipiku.com
// @connect      *
// @run-at       document-end
// @license     MIT
// ==/UserScript==

(function() {
    'use strict';

    console.log('🎨 Poipiku Downloader');

    const MIN_FILE_SIZE = 1024; // 1KB
    let convertToJPG = false;
    const imageCache = new Map();
    let lastProcessedOverlay = null;
    let isProcessing = false;

    const style = document.createElement('style');
    style.textContent = `
        .ppk-container {
            position: relative;
            z-index: 999;
            text-align: center;
            margin: 10px auto 15px;
            padding: 15px;
            background: linear-gradient(135deg, rgba(102,126,234,0.15) 0%, rgba(118,75,162,0.15) 100%);
            border-radius: 15px;
            backdrop-filter: blur(10px);
            max-width: 500px;
        }
        .ppk-download-btn {
            padding: 14px 35px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 25px;
            cursor: pointer;
            font-weight: bold;
            font-size: 15px;
            box-shadow: 0 5px 20px rgba(102,126,234,0.4);
            transition: all 0.3s;
        }
        .ppk-download-btn:hover:not(:disabled) {
            transform: translateY(-3px);
            box-shadow: 0 8px 30px rgba(102,126,234,0.6);
        }
        .ppk-download-btn:disabled {
            opacity: 0.7;
            cursor: wait;
        }
        .ppk-download-btn.ready {
            background: linear-gradient(135deg, #00b894 0%, #00cec9 100%);
            animation: pulse 2s infinite;
        }
        @keyframes pulse {
            0%, 100% { box-shadow: 0 5px 20px rgba(0,184,148,0.4); }
            50% { box-shadow: 0 5px 30px rgba(0,184,148,0.7); }
        }
        .ppk-option-btn {
            padding: 8px 18px;
            background: rgba(52, 73, 94, 0.8);
            color: white;
            border: 2px solid transparent;
            border-radius: 20px;
            cursor: pointer;
            font-size: 12px;
            margin: 5px;
            transition: all 0.3s;
        }
        .ppk-option-btn.active {
            background: rgba(39, 174, 96, 0.9);
            border-color: #27ae60;
        }
        .ppk-cache-status {
            margin-top: 10px;
            padding: 8px 15px;
            background: rgba(0,0,0,0.3);
            border-radius: 10px;
            font-size: 13px;
            color: #fff;
        }
        .ppk-cache-status.caching { color: #fdcb6e; }
        .ppk-cache-status.ready { color: #00b894; }
        .ppk-cache-status.error { color: #e74c3c; }
        .ppk-progress {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.95);
            color: white;
            padding: 30px 50px;
            border-radius: 20px;
            z-index: 9999999;
            font-size: 16px;
            font-weight: bold;
            text-align: center;
            box-shadow: 0 20px 60px rgba(0,0,0,0.5);
            min-width: 320px;
        }
        .ppk-progress-bar {
            width: 100%;
            height: 10px;
            background: rgba(52, 73, 94, 0.8);
            border-radius: 5px;
            margin-top: 15px;
            overflow: hidden;
        }
        .ppk-progress-fill {
            height: 100%;
            background: linear-gradient(90deg, #00b894, #00cec9, #0984e3);
            background-size: 200% 100%;
            animation: shimmer 1.5s infinite linear;
            border-radius: 5px;
            transition: width 0.3s;
        }
        @keyframes shimmer {
            0% { background-position: 200% 0; }
            100% { background-position: -200% 0; }
        }
        .ppk-options { margin-top: 12px; }
        .ppk-log { font-size: 12px; color: #74b9ff; margin-top: 10px; }
    `;
    document.head.appendChild(style);

    // =============================================
    // OBSERVER
    // =============================================
    const observer = new MutationObserver((mutations) => {
        if (isProcessing) return;

        const overlay = document.getElementById('DetailOverlay');
        if (!overlay) return;

        const isOpen = overlay.classList.contains('overlay-on');

        if (isOpen) {
            const overlayInner = document.getElementById('DetailOverlayInner');
            if (!overlayInner) return;

            const currentContent = overlayInner.innerHTML.substring(0, 200);
            if (lastProcessedOverlay === currentContent) return;

            setTimeout(() => {
                if (overlayInner.querySelector('.ppk-container')) return;

                isProcessing = true;
                lastProcessedOverlay = currentContent;

                initOverlay(overlayInner);

                isProcessing = false;
            }, 300);
        } else {
            lastProcessedOverlay = null;
            imageCache.clear(); // Clear cache khi đóng overlay
        }
    });

    observer.observe(document.body, {
        attributes: true,
        attributeFilter: ['class'],
        childList: true,
        subtree: true
    });

    // =============================================
    // 🔥 LẤY URL THẬT
    // =============================================
    function getRealImageUrl(img) {
        // 1. data-lazy-src (lazy load URL thật)
        const lazySrc = img.getAttribute('data-lazy-src');
        if (lazySrc && lazySrc.startsWith('http')) {
            return lazySrc;
        }

        // 2. data-original-src (nếu là URL thật)
        const originalSrc = img.dataset.originalSrc;
        if (originalSrc && originalSrc.startsWith('http')) {
            return originalSrc;
        }

        // 3. src hiện tại (blob hoặc URL)
        if (img.src) {
            // Nếu là blob → site đã cache, cần lấy URL gốc từ attribute khác
            if (img.src.startsWith('blob:')) {
                // Thử tìm URL gốc đã lưu
                return img.dataset.realUrl || null;
            }
            if (img.src.startsWith('http')) {
                return img.src;
            }
        }

        return null;
    }

    // =============================================
    // INIT OVERLAY
    // =============================================
    async function initOverlay(overlayInner) {
        const images = overlayInner.querySelectorAll('img.DetailIllustItemImage');
        if (images.length === 0) return;

        // Collect valid URLs
        const imageData = [];
        images.forEach((img, idx) => {
            const url = getRealImageUrl(img);
            if (url) {
                imageData.push({ url, img, index: idx });
            }
        });

        console.log(`🔍 Tìm thấy ${imageData.length}/${images.length} ảnh có URL`);

        if (imageData.length === 0) return;

        // Tìm vị trí chèn
        const illustItemLink = overlayInner.querySelector('.DetailIllustItemLink');
        if (!illustItemLink) return;

        // 🔥 Hiển thị UI NGAY LẬP TỨC
        addDownloadUI(overlayInner, illustItemLink, imageData);

        // 🔥 Cache ảnh bằng GM_xmlhttpRequest (background)
        await cacheImagesWithGM(overlayInner, imageData);
    }

    // =============================================
    // 🔥 DOWNLOAD UI - Hiển thị ngay
    // =============================================
    function addDownloadUI(overlayInner, illustItemLink, imageData) {
        const container = document.createElement('div');
        container.className = 'ppk-container';
        container.innerHTML = `
            <button class="ppk-download-btn" id="ppk-dl-btn" disabled>
                ⏳ Đang chuẩn bị ${imageData.length} ảnh...
            </button>
            <div class="ppk-options">
                <button class="ppk-option-btn ${!convertToJPG ? 'active' : ''}" id="ppk-original">
                    📄 Giữ định dạng gốc
                </button>
                <button class="ppk-option-btn ${convertToJPG ? 'active' : ''}" id="ppk-convert">
                    🎨 Convert → JPG
                </button>
            </div>
            <div class="ppk-cache-status caching" id="ppk-cache-status">
                ⏳ Đang tải ảnh qua GM_xmlhttpRequest...
            </div>
        `;

        // 🔥 Chèn TRƯỚC ảnh (đầu tiên)
        illustItemLink.parentNode.insertBefore(container, illustItemLink);

        container.querySelector('#ppk-dl-btn').onclick = () => downloadAllImages();

        container.querySelector('#ppk-original').onclick = function() {
            convertToJPG = false;
            this.classList.add('active');
            container.querySelector('#ppk-convert').classList.remove('active');
        };

        container.querySelector('#ppk-convert').onclick = function() {
            convertToJPG = true;
            this.classList.add('active');
            container.querySelector('#ppk-original').classList.remove('active');
        };
    }

    // =============================================
    // 🔥 CACHE IMAGES VỚI GM_xmlhttpRequest
    // =============================================
    async function cacheImagesWithGM(container, imageData) {
        const statusEl = container.querySelector('#ppk-cache-status');
        const btn = container.querySelector('#ppk-dl-btn');

        let cached = 0;
        let skipped = 0;
        let failed = 0;

        for (let i = 0; i < imageData.length; i++) {
            const { url, img } = imageData[i];

            if (statusEl) {
                statusEl.textContent = `⏳ Đang cache ${i + 1}/${imageData.length}...`;
            }

            // Skip nếu đã cache
            if (imageCache.has(url)) {
                cached++;
                continue;
            }

            const result = await gmFetchImage(url);

            if (result.success) {
                if (result.blob.size >= MIN_FILE_SIZE) {
                    imageCache.set(url, {
                        blob: result.blob,
                        originalUrl: url,
                        originalExt: getFileExtension(url),
                        size: result.blob.size
                    });
                    cached++;
                    console.log(`✅ GM cached: ${getShortUrl(url)} → ${formatSize(result.blob.size)}`);
                } else {
                    skipped++;
                    console.log(`⏭️ Skipped (too small): ${formatSize(result.blob.size)}`);
                }
            } else {
                // Fallback to canvas
                console.log(`⚠️ GM failed, trying canvas...`);
                const canvasResult = await canvasFallback(url, img);
                if (canvasResult) {
                    cached++;
                } else {
                    failed++;
                }
            }
        }

        // Update UI
        if (btn) {
            if (cached > 0) {
                btn.disabled = false;
                btn.classList.add('ready');
                btn.textContent = `⬇️ Download ${cached} ảnh`;
            } else {
                btn.textContent = `❌ Không có ảnh`;
            }
        }

        if (statusEl) {
            statusEl.className = 'ppk-cache-status ready';
            let msg = `✅ Sẵn sàng! ${cached} ảnh`;
            if (skipped > 0) msg += ` (${skipped} bỏ qua)`;
            if (failed > 0) msg += ` (${failed} lỗi)`;
            statusEl.textContent = msg;
        }

        console.log(`📦 Cache done: ${cached} OK, ${skipped} skipped, ${failed} failed`);
    }

    // =============================================
    // 🔥 GM_xmlhttpRequest FETCH
    // =============================================
    function gmFetchImage(url) {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'blob',
                headers: {
                    'Accept': 'image/*',
                    'Referer': 'https://poipiku.com/'
                },
                onload: function(response) {
                    if (response.status === 200 && response.response) {
                        resolve({ success: true, blob: response.response });
                    } else {
                        resolve({ success: false, error: `HTTP ${response.status}` });
                    }
                },
                onerror: function(error) {
                    resolve({ success: false, error: error });
                },
                ontimeout: function() {
                    resolve({ success: false, error: 'Timeout' });
                }
            });
        });
    }

    // =============================================
    // CANVAS FALLBACK
    // =============================================
    function canvasFallback(url, imgElement) {
        return new Promise((resolve) => {
            const img = new Image();
            img.crossOrigin = 'anonymous';

            img.onload = function() {
                try {
                    const canvas = document.createElement('canvas');
                    canvas.width = img.naturalWidth || img.width;
                    canvas.height = img.naturalHeight || img.height;

                    if (canvas.width === 0 || canvas.height === 0) {
                        resolve(false);
                        return;
                    }

                    const ctx = canvas.getContext('2d');
                    const ext = getFileExtension(url);
                    let mimeType = 'image/jpeg';

                    if (ext === 'png' || ext === 'gif') {
                        mimeType = 'image/png';
                    } else {
                        ctx.fillStyle = '#FFFFFF';
                        ctx.fillRect(0, 0, canvas.width, canvas.height);
                    }

                    ctx.drawImage(img, 0, 0);

                    canvas.toBlob(function(blob) {
                        if (blob && blob.size >= MIN_FILE_SIZE) {
                            imageCache.set(url, {
                                blob: blob,
                                originalUrl: url,
                                originalExt: ext,
                                size: blob.size
                            });
                            console.log(`✅ Canvas cached: ${getShortUrl(url)} → ${formatSize(blob.size)}`);
                            resolve(true);
                        } else {
                            resolve(false);
                        }
                    }, mimeType, 1.0);
                } catch (e) {
                    resolve(false);
                }
            };

            img.onerror = () => resolve(false);

            // Thử load từ src hiện tại của element (có thể là blob đã được site cache)
            if (imgElement && imgElement.src && imgElement.src.startsWith('blob:')) {
                img.src = imgElement.src;
            } else {
                img.src = url;
            }
        });
    }

    // =============================================
    // DOWNLOAD ALL
    // =============================================
    async function downloadAllImages() {
        const btn = document.getElementById('ppk-dl-btn');
        btn.disabled = true;
        btn.textContent = '⏳ Đang tải xuống...';

        const cachedImages = [...imageCache.values()].filter(d => d.size >= MIN_FILE_SIZE);

        if (cachedImages.length === 0) {
            alert('❌ Không có ảnh nào!');
            btn.disabled = false;
            btn.textContent = `⬇️ Download`;
            return;
        }

        const ids = extractPoipikuIds(cachedImages[0].originalUrl);
        const progress = createProgressUI(cachedImages.length);
        document.body.appendChild(progress);

        let completed = 0;
        let success = 0;

        for (let i = 0; i < cachedImages.length; i++) {
            const data = cachedImages[i];
            const ext = convertToJPG ? 'jpg' : data.originalExt;
            const filename = `${ids.userId}_${ids.postId}_${String(i + 1).padStart(3, '0')}.${ext}`;

            try {
                let blobToDownload = data.blob;

                // Convert to JPG if needed
                if (convertToJPG && data.originalExt !== 'jpg') {
                    blobToDownload = await convertBlobToJPG(data.blob);
                }

                if (blobToDownload && blobToDownload.size >= MIN_FILE_SIZE) {
                    downloadBlob(blobToDownload, filename);
                    success++;
                    updateProgress(completed + 1, cachedImages.length, progress, `✅ ${filename} (${formatSize(blobToDownload.size)})`);
                } else {
                    updateProgress(completed + 1, cachedImages.length, progress, `❌ ${filename}`);
                }
            } catch (e) {
                updateProgress(completed + 1, cachedImages.length, progress, `❌ ${filename}`);
            }

            completed++;
            await sleep(300);
        }

        showFinalResult(success, cachedImages.length, progress);
        btn.disabled = false;
        btn.classList.add('ready');
        btn.textContent = `⬇️ Download ${cachedImages.length} ảnh`;
    }

    // =============================================
    // CONVERT BLOB → JPG
    // =============================================
    function convertBlobToJPG(blob) {
        return new Promise((resolve) => {
            const img = new Image();
            img.onload = function() {
                const canvas = document.createElement('canvas');
                canvas.width = img.naturalWidth;
                canvas.height = img.naturalHeight;
                const ctx = canvas.getContext('2d');

                ctx.fillStyle = '#FFFFFF';
                ctx.fillRect(0, 0, canvas.width, canvas.height);
                ctx.drawImage(img, 0, 0);

                canvas.toBlob(function(jpgBlob) {
                    URL.revokeObjectURL(img.src);
                    resolve(jpgBlob);
                }, 'image/jpeg', 1.0);
            };
            img.onerror = () => {
                URL.revokeObjectURL(img.src);
                resolve(null);
            };
            img.src = URL.createObjectURL(blob);
        });
    }

    // =============================================
    // UTILITIES
    // =============================================
    function extractPoipikuIds(url) {
        if (!url) return { userId: 'poipiku', postId: Date.now().toString() };
        const match = url.match(/\/0*(\d+)\/+0*(\d+)/);
        if (match) return { userId: match[1], postId: match[2] };
        return { userId: 'poipiku', postId: Date.now().toString() };
    }

    function downloadBlob(blob, filename) {
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 3000);
    }

    function formatSize(bytes) {
        if (bytes < 1024) return bytes + ' B';
        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
        return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
    }

    function getShortUrl(url) {
        if (!url) return 'unknown';
        const match = url.match(/\/([^\/]+)$/);
        return match ? match[1].substring(0, 30) : url.substring(0, 35);
    }

    function getFileExtension(url) {
        if (!url) return 'jpg';
        const clean = url.split('?')[0].toLowerCase();
        if (clean.endsWith('.png')) return 'png';
        if (clean.endsWith('.gif')) return 'gif';
        if (clean.endsWith('.webp')) return 'webp';
        return 'jpg';
    }

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

    function createProgressUI(total) {
        const progress = document.createElement('div');
        progress.className = 'ppk-progress';
        progress.innerHTML = `
            <div style="font-size: 28px; margin-bottom: 10px;">📥</div>
            <div id="ppk-status">⏳ Đang tải 0/${total}...</div>
            <div class="ppk-progress-bar">
                <div class="ppk-progress-fill" id="ppk-bar" style="width: 0%"></div>
            </div>
            <div class="ppk-log" id="ppk-log"></div>
        `;
        return progress;
    }

    function updateProgress(current, total, el, log) {
        el.querySelector('#ppk-status').textContent = `⏳ Đang tải ${current}/${total}...`;
        el.querySelector('#ppk-bar').style.width = `${(current / total * 100)}%`;
        if (log) el.querySelector('#ppk-log').textContent = log;
    }

    function showFinalResult(success, total, el) {
        const emoji = success === total ? '🎉' : '⚠️';
        el.querySelector('#ppk-status').innerHTML = `${emoji} Hoàn thành ${success}/${total} ảnh!`;
        el.querySelector('#ppk-bar').style.width = '100%';
        el.querySelector('#ppk-bar').style.background = success === total ?
            'linear-gradient(90deg, #00b894, #00cec9)' :
            'linear-gradient(90deg, #fdcb6e, #e17055)';
        setTimeout(() => el.remove(), 3000);
    }

    // Debug
    window.ppkDebug = () => {
        console.log('📊 Cache:', imageCache.size);
        imageCache.forEach((d, k) => console.log(`  ${getShortUrl(k)}: ${formatSize(d.size)}`));
    };

})();