PixHost Enhanced

Drag-and-drop, Ctrl+V, re-host remote URLs to PixHost (auto-converts WebP/AVIF to PNG)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         PixHost Enhanced
// @namespace    https://pixhost.to/
// @version      0.3
// @description  Drag-and-drop, Ctrl+V, re-host remote URLs to PixHost (auto-converts WebP/AVIF to PNG)
// @author       Colder (URL re-host added locally)
// @match        https://pixhost.to/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      api.pixhost.to
// @connect      pixhost.to
// @connect      *
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const TAG = '[PixHost+]';
    const log  = (...a) => console.log(TAG, ...a);
    const warn = (...a) => console.warn(TAG, ...a);
    const err  = (...a) => console.error(TAG, ...a);

    log('script loaded, version 0.5');

    GM_addStyle(`
        #custom-upload-zone {
            position: fixed;
            top: 20px;
            right: 20px;
            width: 300px;
            background: white;
            border: 2px solid #ccc;
            border-radius: 8px;
            padding: 15px;
            z-index: 9999;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }

        #url-input {
            width: 100%;
            min-height: 60px;
            padding: 6px;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-family: monospace;
            font-size: 11px;
            resize: vertical;
            box-sizing: border-box;
        }

        #convert-urls-btn {
            width: 100%;
            margin-top: 6px;
            padding: 8px;
            background: #4CAF50;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
        }

        #convert-urls-btn:hover { background: #43a047; }
        #convert-urls-btn:disabled { background: #aaa; cursor: not-allowed; }

        .divider {
            text-align: center;
            color: #999;
            font-size: 11px;
            margin: 10px 0;
        }

        #drop-zone {
            border: 2px dashed #ccc;
            border-radius: 4px;
            padding: 20px;
            text-align: center;
            margin-bottom: 10px;
            background: #f9f9f9;
            transition: all 0.3s ease;
            cursor: pointer;
        }

        #drop-zone.drag-over {
            background: #e1f5fe;
            border-color: #2196F3;
        }

        .url-output { margin-top: 10px; }

        .url-textarea {
            width: 100%;
            min-height: 120px;
            margin: 5px 0;
            font-family: monospace;
            font-size: 12px;
            resize: vertical;
            white-space: pre;
            box-sizing: border-box;
        }

        .action-buttons {
            display: flex;
            gap: 5px;
            margin-top: 5px;
        }

        .copy-btn {
            background: #2196F3;
            color: white;
            border: none;
            padding: 8px 5px;
            border-radius: 4px;
            cursor: pointer;
            flex: 1;
            font-size: 11px;
            font-weight: bold;
            text-align: center;
            transition: background 0.2s;
        }

        .copy-btn:hover { background: #1976D2; }

        #status-container {
            margin-top: 10px;
            padding: 10px;
            border-radius: 4px;
            display: none;
            font-size: 13px;
            text-align: center;
        }

        .success { background: #E8F5E9; color: #2E7D32; }
        .error   { background: #FFEBEE; color: #C62828; }

        .progress {
            margin-top: 10px;
            font-size: 0.9em;
            color: #666;
            text-align: center;
        }
    `);

    const uploadInterface = document.createElement('div');
    uploadInterface.id = 'custom-upload-zone';
    uploadInterface.innerHTML = `
        <textarea id="url-input" placeholder="Paste image URLs (one per line, [img]...[/img] OK)"></textarea>
        <button id="convert-urls-btn">Re-host URLs to PixHost</button>
        <div class="divider">— or —</div>
        <div id="drop-zone" title="Click to browse files">
            Drag & Drop Images Here<br>
            <small>or click / Ctrl+V to paste</small>
            <input type="file" id="file-input" multiple style="display: none" accept="image/*">
        </div>
        <div class="progress"></div>
        <div id="status-container"></div>
        <div class="url-output"></div>
    `;

    document.body.appendChild(uploadInterface);

    const dropZone        = document.getElementById('drop-zone');
    const fileInput       = document.getElementById('file-input');
    const urlInput        = document.getElementById('url-input');
    const convertBtn      = document.getElementById('convert-urls-btn');
    const urlOutput       = document.querySelector('.url-output');
    const progressDiv     = document.querySelector('.progress');
    const statusContainer = document.getElementById('status-container');

    let uploadQueue   = [];
    let uploadResults = [];
    let isUploading   = false;
    let statusTimeout;

    dropZone.addEventListener('click', () => fileInput.click());

    ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
        dropZone.addEventListener(evt, preventDefaults, false);
        document.body.addEventListener(evt, preventDefaults, false);
    });
    ['dragenter', 'dragover'].forEach(evt => dropZone.addEventListener(evt, highlight, false));
    ['dragleave', 'drop'].forEach(evt => dropZone.addEventListener(evt, unhighlight, false));

    dropZone.addEventListener('drop', handleDrop, false);
    fileInput.addEventListener('change', (e) => handleFilesArray([...e.target.files]), false);
    convertBtn.addEventListener('click', handleUrlConvert);

    document.addEventListener('paste', (e) => {
        // Don't hijack paste into the URL textarea or other inputs
        if (e.target === urlInput) return;
        if (!e.clipboardData) return;
        const items = e.clipboardData.items;
        const files = [];
        for (let i = 0; i < items.length; i++) {
            if (items[i].type.indexOf('image') !== -1) {
                const file = items[i].getAsFile();
                if (file) files.push(file);
            }
        }
        if (files.length > 0) {
            e.preventDefault();
            handleFilesArray(files);
        }
    });

    function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
    function highlight()         { dropZone.classList.add('drag-over'); }
    function unhighlight()       { dropZone.classList.remove('drag-over'); }
    function handleDrop(e)       { handleFilesArray([...e.dataTransfer.files]); }

    // --- URL re-host pipeline -------------------------------------------------

    function handleUrlConvert() {
        const urls = urlInput.value.split('\n')
            .map(s => s.trim())
            .filter(Boolean)
            .map(s => {
                const m = s.match(/\[img\](.*?)\[\/img\]/i);
                return m ? m[1].trim() : s;
            });

        log('handleUrlConvert parsed URLs:', urls);

        if (urls.length === 0) {
            showStatus('No URLs entered.', 'error');
            return;
        }
        urlInput.value = '';
        handleUrlsArray(urls);
    }

    async function handleUrlsArray(urls) {
        convertBtn.disabled = true;
        showStatus(`Fetching ${urls.length} URL(s)...`, 'success');

        const fetched = [];
        for (const url of urls) {
            try {
                const blob = await fetchAsBlob(url);

                let detected = await detectImageType(blob);
                let finalBlob = blob;

                if (!detected) {
                    // Source served something that isn't PNG/JPEG/GIF natively.
                    // Try to decode it via the browser (handles WebP, AVIF, BMP, etc.)
                    // and re-encode as PNG so pixhost will accept it.
                    log('handleUrlsArray: not PNG/JPEG/GIF, trying canvas re-encode', { url, claimedType: blob.type, size: blob.size });
                    try {
                        finalBlob = await reencodeAsPng(blob);
                        detected = 'image/png';
                        log('handleUrlsArray re-encoded to PNG', { url, originalSize: blob.size, newSize: finalBlob.size });
                    } catch (decodeErr) {
                        const head = await blob.slice(0, 200).text().catch(() => '<binary>');
                        err('Source not decodable as image', {
                            url,
                            claimedType: blob.type || '(none)',
                            size: blob.size,
                            first200chars: head,
                            decodeError: decodeErr.message || decodeErr,
                        });
                        throw new Error(`Cannot decode ${blob.type || 'unknown'} (${decodeErr.message || decodeErr})`);
                    }
                }

                let name = (url.split('/').pop() || 'image').split('?')[0] || 'image';
                // If we re-encoded to PNG, force a .png extension so pixhost names it sensibly
                if (detected === 'image/png' && !/\.png$/i.test(name)) {
                    name = name.replace(/\.[^.]+$/, '') + '.png';
                }
                log('handleUrlsArray wrapping as File', { url, name, type: detected, size: finalBlob.size });
                fetched.push(new File([finalBlob], name, { type: detected }));
            } catch (e) {
                err('handleUrlsArray fetch failed', { url, error: e });
                showStatus(`Failed: ${url} (${e.message || e})`, 'error');
            }
        }

        convertBtn.disabled = false;
        if (fetched.length > 0) {
            handleFilesArray(fetched);
        } else {
            warn('handleUrlsArray: nothing succeeded');
        }
    }

    // Decode any browser-supported image format and re-encode as PNG via <canvas>.
    // Works for WebP / AVIF / BMP / SVG. Will reject if the bytes aren't a decodable image.
    function reencodeAsPng(blob) {
        return new Promise((resolve, reject) => {
            const objUrl = URL.createObjectURL(blob);
            const img = new Image();
            img.onload = () => {
                try {
                    const canvas = document.createElement('canvas');
                    canvas.width  = img.naturalWidth;
                    canvas.height = img.naturalHeight;
                    if (!canvas.width || !canvas.height) {
                        URL.revokeObjectURL(objUrl);
                        reject(new Error('decoded image has zero dimension'));
                        return;
                    }
                    const ctx = canvas.getContext('2d');
                    ctx.drawImage(img, 0, 0);
                    canvas.toBlob((out) => {
                        URL.revokeObjectURL(objUrl);
                        if (out) resolve(out);
                        else reject(new Error('canvas.toBlob returned null'));
                    }, 'image/png');
                } catch (e) {
                    URL.revokeObjectURL(objUrl);
                    reject(e);
                }
            };
            img.onerror = () => {
                URL.revokeObjectURL(objUrl);
                reject(new Error('browser decoder rejected the bytes (not a real image, or unsupported codec)'));
            };
            img.src = objUrl;
        });
    }

    // Magic-byte sniff: returns the real image MIME type or null.
    // Pixhost only accepts PNG / JPEG / GIF (per docs), so we test exactly those.
    async function detectImageType(blob) {
        try {
            const buf = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
            if (buf.length >= 8 &&
                buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47 &&
                buf[4] === 0x0D && buf[5] === 0x0A && buf[6] === 0x1A && buf[7] === 0x0A) return 'image/png';
            if (buf.length >= 3 &&
                buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return 'image/jpeg';
            if (buf.length >= 6 &&
                buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 &&
                buf[3] === 0x38 && (buf[4] === 0x37 || buf[4] === 0x39) && buf[5] === 0x61) return 'image/gif';
            return null;
        } catch (e) {
            err('detectImageType failed', e);
            return null;
        }
    }

    function fetchAsBlob(url) {
        log('fetchAsBlob ->', url);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'blob',
                // Ask for the formats pixhost accepts. Many CDNs (Fandom, Imgur,
                // Cloudflare Images) do Accept-based negotiation and would otherwise
                // hand us WebP/AVIF, which pixhost rejects with HTTP 414.
                headers: {
                    'Accept': 'image/png,image/jpeg,image/gif,image/*;q=0.8,*/*;q=0.5',
                },
                onload: (r) => {
                    log('fetchAsBlob onload', { url, status: r.status, finalUrl: r.finalUrl, type: r.response && r.response.type, size: r.response && r.response.size });
                    if (r.status >= 200 && r.status < 300) resolve(r.response);
                    else {
                        err('fetchAsBlob non-2xx', { url, status: r.status, statusText: r.statusText, headers: r.responseHeaders });
                        reject(new Error(`HTTP ${r.status}`));
                    }
                },
                onerror: (e) => {
                    err('fetchAsBlob onerror', { url, error: e });
                    reject(new Error(e.statusText || 'Network error'));
                },
                ontimeout: () => {
                    err('fetchAsBlob timeout', url);
                    reject(new Error('Timeout'));
                },
            });
        });
    }

    function guessTypeFromUrl(url) {
        const ext = (url.split('.').pop() || '').split('?')[0].toLowerCase();
        const map = {
            jpg: 'image/jpeg', jpeg: 'image/jpeg',
            png: 'image/png', gif: 'image/gif',
            webp: 'image/webp', bmp: 'image/bmp',
        };
        return map[ext] || 'image/jpeg';
    }

    // --- File upload pipeline (unchanged from upstream) -----------------------

    function handleFilesArray(filesArray) {
        log('handleFilesArray received', filesArray.map(f => ({ name: f.name, size: f.size, type: f.type })));
        const validFiles = filesArray
            .filter(f => f.type.startsWith('image/'))
            .sort((a, b) => a.name.localeCompare(b.name));

        if (validFiles.length === 0) {
            warn('handleFilesArray: no valid images after filter');
            showStatus('No valid images found.', 'error');
            return;
        }

        uploadQueue = uploadQueue.concat(validFiles);
        uploadQueue.sort((a, b) => a.name.localeCompare(b.name));

        updateProgress();
        if (!isUploading) processQueue();
    }

    function updateProgress() {
        const total     = uploadQueue.length + uploadResults.length;
        const completed = uploadResults.length;
        progressDiv.textContent = total > 0 ? `Progress: ${completed}/${total} files` : '';
    }

    async function processQueue() {
        if (uploadQueue.length === 0) {
            if (uploadResults.length > 0) {
                log('processQueue done, displaying results', uploadResults);
                displayUrls(uploadResults);
                uploadResults = [];
            } else {
                warn('processQueue: queue empty but no results');
            }
            isUploading = false;
            updateProgress();
            return;
        }

        isUploading = true;
        const file = uploadQueue.shift();

        const formData = new FormData();
        formData.append('img', file);
        formData.append('content_type', '0');
        formData.append('max_th_size', '420');

        try {
            await uploadFile(file, formData);
        } catch (error) {
            err('processQueue caught', { name: file.name, error });
            showStatus(`Error uploading ${file.name}: ${error}`, 'error');
        }

        processQueue();
    }

    function uploadFile(file, formData) {
        log('uploadFile ->', { name: file.name, size: file.size, type: file.type });
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'POST',
                url: 'https://api.pixhost.to/images',
                data: formData,
                headers: { 'Accept': 'application/json' },
                onload: async function(response) {
                    log('uploadFile onload', {
                        name: file.name,
                        status: response.status,
                        statusText: response.statusText,
                        finalUrl: response.finalUrl,
                        responseHeaders: response.responseHeaders,
                        bodySnippet: (response.responseText || '').slice(0, 500),
                    });
                    try {
                        if (response.status < 200 || response.status >= 300) {
                            throw new Error(`API HTTP ${response.status}: ${response.responseText && response.responseText.slice(0, 200)}`);
                        }
                        let data;
                        try {
                            data = JSON.parse(response.responseText);
                        } catch (e) {
                            err('uploadFile JSON parse failed', { body: response.responseText });
                            throw new Error('API did not return JSON (got HTML/text — see console)');
                        }
                        log('uploadFile parsed', data);
                        if (!data.show_url) {
                            throw new Error(data.error || 'API response missing show_url');
                        }
                        const directUrl = await extractDirectUrl(data.show_url);
                        uploadResults.push({
                            name: data.name || file.name,
                            directUrl: directUrl
                        });
                        updateProgress();
                        showStatus(`Uploaded: ${file.name}`, 'success');
                        resolve();
                    } catch (e) {
                        err('uploadFile rejection', e);
                        reject(e.message || e);
                    }
                },
                onerror: function(error) {
                    err('uploadFile onerror', error);
                    reject(error.statusText || 'Network error');
                },
                ontimeout: () => {
                    err('uploadFile timeout', file.name);
                    reject('Timeout');
                },
            });
        });
    }

    function extractDirectUrl(showUrl) {
        log('extractDirectUrl ->', showUrl);
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: showUrl,
                onload: function(response) {
                    log('extractDirectUrl onload', {
                        showUrl,
                        status: response.status,
                        finalUrl: response.finalUrl,
                        bodyLength: (response.responseText || '').length,
                    });
                    if (response.status < 200 || response.status >= 300) {
                        err('extractDirectUrl non-2xx', { showUrl, status: response.status, body: (response.responseText || '').slice(0, 500) });
                        reject(`HTTP ${response.status} on show page`);
                        return;
                    }
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(response.responseText, 'text/html');

                    // Try several selectors in order — pixhost may have changed the DOM
                    const candidates = [
                        '#image',
                        'img#image',
                        'img.image',
                        'img[src*="img"][src*="pixhost"]',
                        'img[src*="//img"]',
                        '.image-show img',
                        '#show_image img',
                        'img',
                    ];
                    for (const sel of candidates) {
                        const el = doc.querySelector(sel);
                        if (el && el.src && /^https?:/.test(el.src)) {
                            log('extractDirectUrl matched selector', { selector: sel, src: el.src });
                            resolve(el.src);
                            return;
                        }
                    }

                    err('extractDirectUrl: no selector matched. Dumping all <img> on show page:');
                    doc.querySelectorAll('img').forEach((img, i) => {
                        err(`  img[${i}]`, { id: img.id, class: img.className, src: img.src, alt: img.alt });
                    });
                    err('First 1000 chars of show page HTML:', (response.responseText || '').slice(0, 1000));
                    reject('Could not scrape direct image URL — see console for HTML dump');
                },
                onerror: function(error) {
                    err('extractDirectUrl onerror', error);
                    reject(error.statusText || 'Network error');
                },
                ontimeout: () => {
                    err('extractDirectUrl timeout', showUrl);
                    reject('Timeout');
                },
            });
        });
    }

    function displayUrls(results) {
        const rawUrls  = results.map(r => r.directUrl).join('\n');
        const bbcode   = results.map(r => `[img]${r.directUrl}[/img]`).join('\n');
        const markdown = results.map(r => `![${r.name}](${r.directUrl})`).join('\n');

        urlOutput.innerHTML = `
            <textarea class="url-textarea" readonly spellcheck="false">${rawUrls}</textarea>
            <div class="action-buttons">
                <button class="copy-btn" data-clipboard-text="${encodeURIComponent(rawUrls)}">Copy URLs</button>
                <button class="copy-btn" data-clipboard-text="${encodeURIComponent(bbcode)}">Copy BBCode</button>
                <button class="copy-btn" data-clipboard-text="${encodeURIComponent(markdown)}">Copy MD</button>
            </div>
        `;

        urlOutput.querySelectorAll('.copy-btn').forEach(btn => {
            btn.addEventListener('click', function() {
                const text = decodeURIComponent(this.getAttribute('data-clipboard-text'));
                navigator.clipboard.writeText(text).then(() => {
                    const originalText = this.textContent;
                    this.textContent = 'Copied!';
                    setTimeout(() => { this.textContent = originalText; }, 1200);
                });
            });
        });
    }

    function showStatus(message, type) {
        statusContainer.className = `status ${type}`;
        statusContainer.textContent = message;
        statusContainer.style.display = 'block';

        clearTimeout(statusTimeout);
        statusTimeout = setTimeout(() => {
            statusContainer.style.display = 'none';
        }, 3000);
    }
})();