Greasy Fork is available in English.
Drag-and-drop, Ctrl+V, re-host remote URLs to PixHost (auto-converts WebP/AVIF to PNG)
// ==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 => ``).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);
}
})();