// ==UserScript==
// @name Hugging Face Batch Downloader
// @namespace http://tampermonkey.net/
// @version 1.6.1
// @description Enhance Hugging Face model downloads with batch selection and custom naming
// @author Xunjian Yin
// @match https://huggingface.co/*/tree/*
// @match https://huggingface.co/*/blob/*
// @match https://hf-mirror.com/*/tree/*
// @match https://hf-mirror.com/*/blob/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// Wait for the page to fully load
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve) => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
observer.disconnect();
resolve(null);
}, timeout);
});
}
// Extract model path from URL (organization/model-name)
function getModelPath() {
const urlParts = window.location.pathname.split('/');
if (urlParts.length >= 3) {
// Return the organization/model-name format for folder structure
return `${urlParts[1]}/${urlParts[2]}`;
}
return 'huggingface-model';
}
// Get just the model name for logging purposes
function getModelName() {
const urlParts = window.location.pathname.split('/');
if (urlParts.length >= 3) {
return `${urlParts[1]}/${urlParts[2]}`;
}
return 'huggingface-model';
}
// Create download link with organized filename (browser-compatible)
function createDownloadLink(originalUrl, filename, modelPath) {
// Ensure we're using the resolve URL for downloads, not blob URL
let downloadUrl = originalUrl;
if (downloadUrl.includes('/blob/')) {
downloadUrl = downloadUrl.replace('/blob/', '/resolve/');
}
// Force download by adding download parameter if it's not already there
if (!downloadUrl.includes('download=true')) {
const separator = downloadUrl.includes('?') ? '&' : '?';
downloadUrl += `${separator}download=true`;
}
// Create organized filename since browsers don't create folders via download attribute
// Format: [Org-Model] filename.ext
const modelNameForFile = modelPath.replace('/', '-');
const downloadFilename = `[${modelNameForFile}] ${filename}`;
console.log(`HF Batch Downloader: Downloading ${downloadFilename} from ${downloadUrl}`);
// Create download link with proper attributes
const link = document.createElement('a');
link.href = downloadUrl;
link.download = downloadFilename;
link.target = '_blank'; // Fallback in case download fails
link.rel = 'noopener noreferrer';
link.style.display = 'none';
// Add to DOM, click, and remove
document.body.appendChild(link);
try {
link.click();
} catch (error) {
console.error(`HF Batch Downloader: Failed to trigger download for ${filename}:`, error);
// Fallback: open in new tab
window.open(downloadUrl, '_blank');
}
// Clean up after a short delay
setTimeout(() => {
if (link.parentNode) {
document.body.removeChild(link);
}
}, 100);
}
// Add batch download functionality
async function addBatchDownloadUI() {
console.log('HF Batch Downloader: Looking for file list...');
// Try multiple selectors to find the file list
let fileContainer = null;
const selectors = [
'[data-target="TreeView"]',
'.file-list',
'[class*="file"]',
'main',
'article',
'.container'
];
for (const selector of selectors) {
fileContainer = await waitForElement(selector, 3000);
if (fileContainer) {
console.log(`HF Batch Downloader: Found container with selector: ${selector}`);
break;
}
}
if (!fileContainer) {
console.log('HF Batch Downloader: No suitable container found, trying body');
fileContainer = document.body;
}
const modelName = getModelName();
const modelPath = getModelPath();
console.log(`HF Batch Downloader: Model path extracted: ${modelPath}`);
// Find all download links
const allLinks = document.querySelectorAll('a[href*="/resolve/"]');
console.log(`HF Batch Downloader: Found ${allLinks.length} potential download links`);
if (allLinks.length === 0) {
console.log('HF Batch Downloader: No download links found');
return;
}
// Create control panel with HF-like styling
const controlPanel = document.createElement('div');
controlPanel.id = 'hf-batch-downloader-panel';
controlPanel.style.cssText = `
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 12px 16px;
margin: 16px 0 8px 0;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
`;
controlPanel.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px; color: #475569; font-weight: 500;">
<span style="font-size: 16px;">📦</span>
<span>Batch Download</span>
</div>
<button id="hf-selectAll" style="
padding: 6px 12px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
">Select All</button>
<button id="hf-selectNone" style="
padding: 6px 12px;
background: #6b7280;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
">Select None</button>
<button id="hf-downloadSelected" style="
padding: 6px 12px;
background: #10b981;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
">Download Selected</button>
<span id="hf-selectedCount" style="color: #6b7280; margin-left: 4px; font-size: 13px;">0 files selected</span>
`;
// Find the best insertion point - look for file list container
let insertionPoint = null;
// Try to find the files section specifically
const filesSection = document.querySelector('[data-testid="files-section"]') ||
document.querySelector('.files') ||
document.querySelector('[class*="file"]') ||
document.querySelector('main section');
if (filesSection) {
insertionPoint = filesSection;
} else {
// Fallback to main content area
const mainContent = document.querySelector('main') || document.querySelector('.container');
if (mainContent) {
insertionPoint = mainContent;
}
}
if (insertionPoint) {
// Insert at the beginning of the files section, not the very top of main
const firstChild = insertionPoint.firstChild;
if (firstChild) {
insertionPoint.insertBefore(controlPanel, firstChild);
} else {
insertionPoint.appendChild(controlPanel);
}
} else {
// Last resort - insert after the header
const header = document.querySelector('header') || document.querySelector('nav');
if (header && header.nextSibling) {
header.parentNode.insertBefore(controlPanel, header.nextSibling);
} else {
document.body.insertBefore(controlPanel, document.body.firstChild);
}
}
// Process each download link
const fileList = [];
allLinks.forEach((link, index) => {
const href = link.href;
// Accept both /resolve/ and /blob/ links, we'll convert blob to resolve later
const isDownloadLink = href.includes('/resolve/') || href.includes('/blob/');
const isNotFolderLink = !href.includes('/tree/');
if (!isDownloadLink || !isNotFolderLink) {
return;
}
// Get the filename from the link text or URL
let fileName = link.textContent.trim();
if (!fileName || fileName === '') {
const urlParts = href.split('/');
fileName = urlParts[urlParts.length - 1] || `file-${index}`;
}
// Skip if filename is empty or looks like a folder
if (!fileName || fileName === '..') {
return;
}
console.log(`HF Batch Downloader: Processing file: ${fileName}`);
// Create a subtle, clean checkbox that integrates naturally
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `hf-file-checkbox-${index}`;
checkbox.style.cssText = `
width: 14px;
height: 14px;
margin: 0 6px 0 0;
cursor: pointer;
accent-color: #656d76;
opacity: 0.7;
transition: opacity 0.2s ease;
vertical-align: middle;
position: relative;
top: -1px;
`;
// Add hover effect to make it more visible on interaction
checkbox.addEventListener('mouseenter', () => {
checkbox.style.opacity = '1';
checkbox.style.accentColor = '#0969da';
});
checkbox.addEventListener('mouseleave', () => {
if (!checkbox.checked) {
checkbox.style.opacity = '0.7';
checkbox.style.accentColor = '#656d76';
}
});
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
checkbox.style.opacity = '1';
checkbox.style.accentColor = '#0969da';
} else {
checkbox.style.opacity = '0.7';
checkbox.style.accentColor = '#656d76';
}
});
// Find the file icon (first element in the row that's likely the icon)
const fileIcon = link.parentElement.querySelector('svg') ||
link.parentElement.querySelector('[class*="icon"]') ||
link.parentElement.querySelector('img');
if (fileIcon) {
// Insert checkbox right before the file icon for clean alignment
fileIcon.parentElement.insertBefore(checkbox, fileIcon);
} else {
// Fallback: find the first element that looks like a file row start
let insertionPoint = link;
// Walk up to find a container, but not too far
let currentElement = link.parentElement;
let attempts = 0;
while (currentElement && attempts < 3) {
if (currentElement.children.length > 1) {
insertionPoint = currentElement.firstChild;
break;
}
currentElement = currentElement.parentElement;
attempts++;
}
insertionPoint.parentElement.insertBefore(checkbox, insertionPoint);
}
fileList.push({
checkbox,
fileName,
downloadUrl: href,
element: link.parentElement
});
checkbox.addEventListener('change', updateSelectedCount);
});
console.log(`HF Batch Downloader: Processed ${fileList.length} downloadable files`);
// Control panel event handlers
function updateSelectedCount() {
const selectedCheckboxes = fileList.filter(file => file.checkbox.checked);
const countElement = document.getElementById('hf-selectedCount');
if (countElement) {
countElement.textContent = `${selectedCheckboxes.length} files selected`;
}
}
document.getElementById('hf-selectAll').addEventListener('click', () => {
fileList.forEach(file => file.checkbox.checked = true);
updateSelectedCount();
});
document.getElementById('hf-selectNone').addEventListener('click', () => {
fileList.forEach(file => file.checkbox.checked = false);
updateSelectedCount();
});
document.getElementById('hf-downloadSelected').addEventListener('click', async () => {
const selectedFiles = fileList.filter(file => file.checkbox.checked);
if (selectedFiles.length === 0) {
alert('Please select at least one file to download.');
return;
}
const downloadBtn = document.getElementById('hf-downloadSelected');
const originalText = downloadBtn.textContent;
downloadBtn.textContent = `Downloading ${selectedFiles.length} files...`;
downloadBtn.disabled = true;
console.log(`HF Batch Downloader: Starting download of ${selectedFiles.length} files`);
let successCount = 0;
let errorCount = 0;
// Download files with delay to avoid overwhelming the browser
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
console.log(`HF Batch Downloader: Downloading ${i + 1}/${selectedFiles.length}: ${file.fileName}`);
try {
createDownloadLink(file.downloadUrl, file.fileName, modelPath);
successCount++;
// Update button text with progress
downloadBtn.textContent = `Downloading ${i + 1}/${selectedFiles.length}...`;
} catch (error) {
console.error(`HF Batch Downloader: Error downloading ${file.fileName}:`, error);
errorCount++;
}
// Add delay between downloads (longer delay for more reliability)
if (i < selectedFiles.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1200));
}
}
downloadBtn.textContent = originalText;
downloadBtn.disabled = false;
console.log(`HF Batch Downloader: Completed. Success: ${successCount}, Errors: ${errorCount}`);
// Show appropriate completion message
if (errorCount === 0) {
alert(`✅ Successfully started downloading all ${selectedFiles.length} files! Check your downloads folder.`);
} else if (successCount > 0) {
alert(`⚠️ Started downloading ${successCount} files successfully, ${errorCount} failed. Check browser console for details and your downloads folder.`);
} else {
alert(`❌ Failed to download any files. Please check the browser console for error details and try again.`);
}
});
// Add hover effects for buttons
const buttons = controlPanel.querySelectorAll('button');
buttons.forEach(button => {
const originalBackground = button.style.background;
button.addEventListener('mouseenter', () => {
if (button.id === 'hf-selectAll') {
button.style.background = '#2563eb';
} else if (button.id === 'hf-selectNone') {
button.style.background = '#4b5563';
} else if (button.id === 'hf-downloadSelected') {
button.style.background = '#059669';
}
button.style.transform = 'translateY(-1px)';
button.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)';
});
button.addEventListener('mouseleave', () => {
button.style.background = originalBackground;
button.style.transform = 'translateY(0)';
button.style.boxShadow = 'none';
});
button.addEventListener('active', () => {
button.style.transform = 'translateY(0)';
});
});
console.log(`HF Batch Downloader: Enhanced ${fileList.length} files for model: ${modelName}`);
}
// Enhanced individual download links
function enhanceIndividualDownloads() {
const modelPath = getModelPath();
// Find and enhance existing download links
const downloadLinks = document.querySelectorAll('a[href*="/resolve/"]');
downloadLinks.forEach(link => {
if (link.dataset.enhanced) return; // Skip already enhanced links
const fileName = link.textContent.trim() || link.href.split('/').pop();
link.addEventListener('click', (e) => {
e.preventDefault();
createDownloadLink(link.href, fileName, modelPath);
});
link.dataset.enhanced = 'true';
const modelNameForFile = modelPath.replace('/', '-');
link.title = `Download as: [${modelNameForFile}] ${fileName}`;
});
}
// Show a temporary notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 10px 15px;
border-radius: 5px;
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
color: white;
background: ${type === 'error' ? '#ef4444' : type === 'success' ? '#10b981' : '#3b82f6'};
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: opacity 0.3s;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// Initialize the script
async function init() {
console.log('HF Batch Downloader: Initializing v1.6.1...');
// Check if we're on the right page
if (!window.location.pathname.includes('/tree/') && !window.location.pathname.includes('/blob/')) {
console.log('HF Batch Downloader: Not on a file page, skipping');
return;
}
// Show loading notification
showNotification('🚀 HF Batch Downloader loading...', 'info');
try {
// Add batch download UI
await addBatchDownloadUI();
// Enhance individual downloads
enhanceIndividualDownloads();
showNotification('✅ HF Batch Downloader ready!', 'success');
// Watch for dynamically loaded content
const observer = new MutationObserver((mutations) => {
// Only enhance if new links are added
let hasNewLinks = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.querySelector && node.querySelector('a[href*="/resolve/"]')) {
hasNewLinks = true;
}
}
});
}
});
if (hasNewLinks) {
console.log('HF Batch Downloader: New content detected, re-enhancing...');
enhanceIndividualDownloads();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
} catch (error) {
console.error('HF Batch Downloader: Error during initialization:', error);
showNotification('❌ HF Batch Downloader failed to load', 'error');
}
}
// Start the script when the page is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();