Last active
December 12, 2025 17:00
-
-
Save zenvertao/fba47752bffbefc47711e255e0c6d772 to your computer and use it in GitHub Desktop.
Detect and download all favicons, touch icons, and bookmark icons from websites
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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