Skip to content

Instantly share code, notes, and snippets.

@zenvertao
Last active December 12, 2025 17:00
Show Gist options
  • Select an option

  • Save zenvertao/fba47752bffbefc47711e255e0c6d772 to your computer and use it in GitHub Desktop.

Select an option

Save zenvertao/fba47752bffbefc47711e255e0c6d772 to your computer and use it in GitHub Desktop.
Detect and download all favicons, touch icons, and bookmark icons from websites
// ==UserScript==
// @name Favicon Sniffer v1.2
// @namespace http://tampermonkey.net/
// @version 1.2
// @description Detect and download all favicons, touch icons, and bookmark icons from websites
// @description:zh-CN 自动探测并下载网站的所有图标资源
// @author ZenverTao & Gemini
// @match *://*/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect *
// ==/UserScript==
(function() {
'use-strict';
async function convertIcoToPng(icoUrl) {
try {
const buffer = await gmFetch(icoUrl, 'arraybuffer');
try {
const blob = new Blob([buffer], { type: 'image/png' });
const img = new Image();
const objUrl = URL.createObjectURL(blob);
img.src = objUrl;
await new Promise((resolve, reject) => {
img.onload = () => { URL.revokeObjectURL(objUrl); resolve(); };
img.onerror = () => { URL.revokeObjectURL(objUrl); reject(new Error('Failed to load as direct image.')); };
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return canvas.toDataURL('image/png');
} catch (e) {
// Fallback to ICO parsing
}
const dataView = new DataView(buffer);
if (dataView.getUint16(0, true) !== 0 || dataView.getUint16(2, true) !== 1) {
throw new Error('Invalid ICO header.');
}
const imageCount = dataView.getUint16(4, true);
let bestEntry = null;
for (let i = 0, maxResolution = 0; i < imageCount; i++) {
const entryOffset = 6 + i * 16;
const width = dataView.getUint8(entryOffset) || 256;
const height = dataView.getUint8(entryOffset + 1) || 256;
if (width * height > maxResolution) {
maxResolution = width * height;
bestEntry = {
width, height,
bpp: dataView.getUint16(entryOffset + 6, true),
size: dataView.getUint32(entryOffset + 8, true),
offset: dataView.getUint32(entryOffset + 12, true),
};
}
}
if (!bestEntry) throw new Error('No image entries found in ICO.');
const slice = buffer.slice(bestEntry.offset, bestEntry.offset + bestEntry.size);
const pngMagicNumber = new DataView(slice, 0, 8);
if (slice.byteLength >= 8 && pngMagicNumber.getBigUint64(0, false).toString(16) === '89504e470d0a1a0a') {
return URL.createObjectURL(new Blob([slice], { type: 'image/png' }));
}
const dibHeaderView = new DataView(slice);
const actualBmpHeight = dibHeaderView.getUint32(8, true) / 2;
let pixelDataOffsetInDib = dibHeaderView.getUint32(10, true) || (dibHeaderView.getUint32(0, true) + (bestEntry.bpp <= 8 ? Math.pow(2, bestEntry.bpp) * 4 : 0));
const bmpFileHeaderSize = 14;
let bmpBuffer = new ArrayBuffer(bmpFileHeaderSize + slice.byteLength);
new Uint8Array(bmpBuffer, bmpFileHeaderSize).set(new Uint8Array(slice));
const bmpView = new DataView(bmpBuffer);
bmpView.setUint16(0, 0x4D42, false);
bmpView.setUint32(2, bmpBuffer.byteLength, true);
bmpView.setUint32(10, bmpFileHeaderSize + pixelDataOffsetInDib, true);
new DataView(bmpBuffer, bmpFileHeaderSize).setUint32(8, actualBmpHeight, true);
const imageBitmap = await createImageBitmap(new Blob([bmpBuffer], { type: 'image/bmp' }));
const canvas = document.createElement('canvas');
canvas.width = bestEntry.width;
canvas.height = actualBmpHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageBitmap, 0, 0);
const paddedScanlineSize = Math.ceil(Math.ceil((bestEntry.width * bestEntry.bpp) / 8) / 4) * 4;
const andMaskStartOffset = pixelDataOffsetInDib + (actualBmpHeight * paddedScanlineSize);
const andMaskData = new Uint8Array(slice.slice(andMaskStartOffset));
const imageData = ctx.getImageData(0, 0, bestEntry.width, actualBmpHeight);
const andMaskBytesPerRow = Math.ceil(bestEntry.width / 8);
for (let y = 0; y < actualBmpHeight; y++) {
for (let x = 0; x < bestEntry.width; x++) {
const alpha = andMaskData.byteLength > (y * andMaskBytesPerRow) + Math.floor(x / 8) ? (((andMaskData[(y * andMaskBytesPerRow) + Math.floor(x / 8)] >> (7 - (x % 8))) & 1) ? 0 : 255) : 255;
imageData.data[((actualBmpHeight - 1 - y) * bestEntry.width + x) * 4 + 3] = alpha;
}
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
} catch (error) {
console.error('ICO to PNG conversion failed:', error);
return null;
}
}
const PANEL_ID = 'gemini-favicon-sniffer-panel-v11'; // Keep panel ID stable
const TRIGGER_ID = 'gemini-favicon-trigger-btn';
function gmFetch(url, responseType = 'blob') {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({ method: 'GET', url, responseType, onload: (r) => resolve(r.response), onerror: reject, ontimeout: reject });
});
}
// **REWRITTEN with CSP-safe data: URL handling**
async function forceDownload(url, filename, button) {
const originalText = button.innerText;
button.innerText = 'Downloading...';
button.classList.add('disabled');
try {
let blob;
if (url.startsWith('data:')) {
const parts = url.split(',');
const mimeMatch = parts[0].match(/:(.*?);/);
if (!mimeMatch) throw new Error("Invalid data URL format");
const mime = mimeMatch[1];
const bstr = atob(parts[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while(n--){
u8arr[n] = bstr.charCodeAt(n);
}
blob = new Blob([u8arr], {type:mime});
} else {
blob = await gmFetch(url, 'blob');
}
const blobUrl = URL.createObjectURL(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);
} catch (error) {
console.error('Download failed:', error);
button.innerText = 'Failed';
} finally {
setTimeout(() => {
if(button.isConnected) { button.innerText = originalText; button.classList.remove('disabled'); }
}, 1000);
}
}
async function findIcons() {
const iconUrlSet = new Set();
document.querySelectorAll('link[rel*="icon"]').forEach(link => { if (link.href) iconUrlSet.add(new URL(link.href, document.baseURI).href); });
const manifestLink = document.querySelector('link[rel="manifest"]');
if (manifestLink?.href) {
try {
const manifestUrl = new URL(manifestLink.href, document.baseURI).href;
const manifest = await gmFetch(manifestUrl, 'json');
manifest.icons?.forEach(icon => { if (icon.src) iconUrlSet.add(new URL(icon.src, manifestUrl).href); });
} catch (e) { /* ignore */ }
}
try {
await new Promise(resolve => {
GM_xmlhttpRequest({ method: 'HEAD', url: new URL('/favicon.ico', document.baseURI).href, onload: r => { if (r.status < 400) iconUrlSet.add(r.finalUrl); resolve(); }, onerror: resolve });
});
} catch(e) { /* ignore */ }
const msTile = document.querySelector('meta[name="msapplication-TileImage"]');
if (msTile?.content) iconUrlSet.add(new URL(msTile.content, document.baseURI).href);
return Array.from(iconUrlSet);
}
function createPanel() {
document.getElementById(PANEL_ID)?.remove();
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="sniffer-header"><h3>Favicon Sniffer v1.2</h3><button class="sniffer-close">✕</button></div>
<div class="sniffer-list-container"><div class="sniffer-loader">Sniffing for icons...</div></div>`;
document.body.appendChild(panel);
panel.querySelector('.sniffer-close').onclick = () => panel.remove();
return panel;
}
async function showFaviconPanel() {
const panel = createPanel();
const listContainer = panel.querySelector('.sniffer-list-container');
const icons = await findIcons();
if (icons.length === 0) {
listContainer.innerHTML = '<div class="sniffer-loader">No icons found on this page.</div>';
return;
}
panel.querySelector('h3').innerText = `Favicon Sniffer v1.2 (${icons.length} Found)`;
listContainer.innerHTML = '';
icons.forEach(url => {
const item = document.createElement('div');
item.className = 'sniffer-item';
const filename = new URL(url).pathname.split('/').pop() || 'icon';
item.innerHTML = `
<div class="sniffer-img-wrapper"><img crossOrigin="anonymous"></div>
<div class="sniffer-info">
<div class="sniffer-dims">Detecting...</div><div class="sniffer-url">${url}</div>
<div class="sniffer-actions"><button class="sniffer-btn download-original">Download</button></div>
</div>`;
const img = item.querySelector('img');
gmFetch(url, 'blob').then(blob => {
const blobUrl = URL.createObjectURL(blob);
img.src = blobUrl;
img.onload = () => {
item.querySelector('.sniffer-dims').innerText = `${img.naturalWidth}x${img.naturalHeight}`;
URL.revokeObjectURL(blobUrl);
};
}).catch(() => {
item.querySelector('.sniffer-dims').innerText = 'Load failed';
item.classList.add('sniffer-error');
});
img.onerror = () => { item.querySelector('.sniffer-dims').innerText = 'Load failed'; item.classList.add('sniffer-error'); };
item.querySelector('.download-original').onclick = (e) => forceDownload(url, filename, e.target);
if (url.toLowerCase().includes('.ico')) {
const convertBtn = document.createElement('button');
convertBtn.className = 'sniffer-btn convert';
convertBtn.innerText = 'Convert to PNG';
item.querySelector('.sniffer-actions').appendChild(convertBtn);
convertBtn.onclick = async (e) => {
const btn = e.target;
if (btn.classList.contains('disabled')) return;
btn.innerText = 'Converting...';
btn.classList.add('disabled');
const pngDataUrl = await convertIcoToPng(url);
if (pngDataUrl) {
btn.innerText = 'Download PNG';
forceDownload(pngDataUrl, filename.replace(/\.ico$/i, '.png'), btn);
} else { btn.innerText = 'Failed'; }
setTimeout(() => {
if(btn.isConnected) { btn.innerText = 'Convert to PNG'; btn.classList.remove('disabled'); }
}, 2000);
};
}
listContainer.appendChild(item);
});
}
function createTriggerButton() {
if (document.getElementById(TRIGGER_ID)) return;
const triggerButton = document.createElement('div');
triggerButton.id = TRIGGER_ID;
triggerButton.innerText = '🖼️';
triggerButton.title = 'Favicon Sniffer';
triggerButton.onclick = showFaviconPanel;
document.body.appendChild(triggerButton);
}
GM_addStyle(`
:root { --panel-bg: #fff; --panel-border: #d1d1d6; --header-border: #e5e5e7; --text-primary: #1d1d1f; --text-secondary: #6e6e73; --btn-bg: #e9e9eb; --btn-bg-hover: #dcdce0; --btn-text: #000; --btn-primary-bg: #007aff; --btn-primary-text: #fff; }
@media (prefers-color-scheme: dark) { :root { --panel-bg: #2c2c2e; --panel-border: #424245; --header-border: #3a3a3c; --text-primary: #f5f5f7; --text-secondary: #8e8e93; --btn-bg: #3a3a3c; --btn-bg-hover: #48484a; --btn-text: #f5f5f7; } }
#${TRIGGER_ID} { position: fixed; bottom: 20px; right: 20px; width: 48px; height: 48px; background-color: var(--panel-bg); border: 1px solid var(--panel-border); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24px; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 99998; transition: transform 0.2s ease; }
#${TRIGGER_ID}:hover { transform: scale(1.1); }
#${PANEL_ID} { position: fixed; top: 50px; right: 20px; width: 380px; max-width: 90vw; max-height: 80vh; background: var(--panel-bg); color: var(--text-primary) !important; border: 1px solid var(--panel-border); border-radius: 12px; box-shadow: 0 8px 25px rgba(0,0,0,0.15); z-index: 99999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: flex; flex-direction: column; }
#${PANEL_ID} .sniffer-header { padding: 10px 15px; border-bottom: 1px solid var(--header-border); display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
#${PANEL_ID} .sniffer-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: var(--text-primary) !important;}
#${PANEL_ID} .sniffer-close { background: var(--btn-bg); color: var(--text-secondary); border: none; border-radius: 50%; width: 24px; height: 24px; font-size: 14px; cursor: pointer; line-height: 24px; text-align: center; }
#${PANEL_ID} .sniffer-close:hover { background: var(--btn-bg-hover); }
#${PANEL_ID} .sniffer-list-container { overflow-y: auto; padding: 10px; flex-grow: 1; }
#${PANEL_ID} .sniffer-loader { padding: 40px 20px; text-align: center; color: var(--text-secondary); }
#${PANEL_ID} .sniffer-item { display: flex; align-items: center; padding: 10px; border-radius: 8px; margin-bottom: 8px; background: var(--panel-bg); border: 1px solid var(--header-border); }
#${PANEL_ID} .sniffer-item.sniffer-error { opacity: 0.6; }
#${PANEL_ID} .sniffer-img-wrapper { flex-shrink: 0; width: 50px; height: 50px; margin-right: 12px; display: flex; align-items: center; justify-content: center; background-color: rgba(0,0,0,0.05); border-radius: 6px; border: 1px dashed var(--panel-border); }
#${PANEL_ID} .sniffer-img-wrapper img { max-width: 100%; max-height: 100%; object-fit: contain; image-rendering: pixelated; }
#${PANEL_ID} .sniffer-info { flex-grow: 1; overflow: hidden; min-width: 0;}
#${PANEL_ID} .sniffer-dims { font-size: 13px; font-weight: 600; }
#${PANEL_ID} .sniffer-url { font-size: 11px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
#${PANEL_ID} .sniffer-actions { margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; }
#${PANEL_ID} .sniffer-btn { padding: 4px 10px; font-size: 12px; text-decoration: none; border-radius: 5px; background: var(--btn-bg); color: var(--btn-text); border: 1px solid var(--panel-border); cursor: pointer; transition: background-color 0.2s; }
#${PANEL_ID} .sniffer-btn:hover { background: var(--btn-bg-hover); }
#${PANEL_ID} .sniffer-btn.convert { background: var(--btn-primary-bg); color: var(--btn-primary-text); border: none; }
#${PANEL_ID} .sniffer-btn.disabled { opacity: 0.7; cursor: wait; }
`);
createTriggerButton();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment