commit 8334ba5cc4ea136763aed7bcbe3f91f6e9e1f6ba Author: dvirlabs <114520947+dvirlabs@users.noreply.github.com> Date: Mon Apr 20 00:08:24 2026 +0300 Create chrome extention diff --git a/yupoo-downloader/README.md b/yupoo-downloader/README.md new file mode 100644 index 0000000..264664c --- /dev/null +++ b/yupoo-downloader/README.md @@ -0,0 +1,348 @@ +# Yupoo Product Image Downloader - Chrome Extension + +A powerful Manifest V3 Chrome extension that automatically downloads all product images from Yupoo-style gallery pages, organizing them by product into individual folders. + +## Features + +✨ **Automatic Product Detection** - Scans gallery pages to find all product cards +🖼️ **Complete Image Collection** - Downloads all images from each product's detail page +📁 **Organized Downloads** - Creates one folder per product with numbered images +🔄 **Retry Logic** - Automatically retries failed downloads +⚙️ **Concurrency Control** - Limit simultaneous downloads to avoid overwhelming the server +🔀 **Deduplication** - Prevents downloading the same image twice in a session +📋 **Progress Tracking** - Real-time updates on scanning and downloading status +🛠️ **Easy Customization** - Simple selector configuration for different website structures +✅ **Rate Limiting** - Automatic delays between requests to be respectful to servers + +## Installation + +### 1. Prepare the Extension + +Clone or download this directory to your computer. You should have these files: +``` +yupoo-downloader/ + ├── manifest.json + ├── popup.html + ├── popup.js + ├── styles.css + ├── background.js + ├── content.js + └── README.md +``` + +### 2. Load Extension in Chrome + +1. Open Chrome and navigate to `chrome://extensions/` +2. Enable **Developer mode** (toggle in the top-right corner) +3. Click **"Load unpacked"** +4. Select the `yupoo-downloader` folder +5. The extension should now appear in your extensions list +6. Pin it to the toolbar for easy access (click the puzzle icon) + +### 3. Grant Permissions + +When you first use the extension, Chrome will ask for permissions: +- **Download files** - Needed to save images +- **Access current tab** - Needed to scan the page +- **Access all websites** - Needed to fetch product detail pages + +Click "Allow" to proceed. + +## Usage + +### Basic Workflow + +1. **Navigate to a Yupoo gallery page** with products you want to download +2. **Click the extension icon** in your toolbar +3. **Click "🔍 Scan Products"** - The extension will find all products on the current page +4. **Adjust settings** if needed: + - Set "Max Concurrent Downloads" (1-10, default 2) +5. **Click "⬇️ Download All"** - Downloads will begin automatically +6. **Monitor progress** in the activity log and statistics +7. **Downloads save to** your browser's default download folder + +### Download Structure + +Images are organized like this: +``` +Downloads/ + ├── 3me10101430/ + │ ├── 01.jpg + │ ├── 02.jpg + │ └── 03.jpg + ├── 3mf10083295/ + │ ├── 01.jpg + │ ├── 02.jpg + │ ├── 03.jpg + │ └── 04.jpg + └── product_001/ + ├── 01.jpg + └── 02.jpg +``` + +## Customization + +The extension is designed to work with Yupoo and similar gallery sites, but websites vary in their HTML structure. You can customize the selectors to match your target website. + +### Editing CSS Selectors + +#### For Gallery Page (Finding Products) + +Edit the `SELECTORS` object in **`content.js`** (around line 15): + +```javascript +const SELECTORS = { + // The container div for each product tile/card + productCard: [ + 'div.item', // Try these in order + 'div.product-item', + 'div.product-card', + // Add more selectors that match your site + ], + + // The link that goes to the product detail page + productLink: [ + 'a[href*="/item/"]', + 'a[href*="/product"]', + // Add more link selectors + ], + + // The visible product title/name + productTitle: [ + '.product-name', + '.product-title', + 'h3', + // Add more title selectors + ], +}; +``` + +**How to find the right selectors:** +1. Open the website in Chrome +2. Right-click on a product card → **"Inspect"** +3. Look at the HTML structure +4. Copy the class names or tag names you see +5. Add them to the corresponding array in `content.js` + +#### For Detail Page (Finding Images) + +Edit the `DETAIL_PAGE_SELECTORS` object in **`background.js`** (around line 30): + +```javascript +const DETAIL_PAGE_SELECTORS = { + // Container that holds all product images + imageContainer: [ + 'div.photo-list', + 'div.details-images', + 'div.gallery', + // Add more container selectors + ], + + // Individual image elements + imageElements: [ + 'img.photo', + 'img[class*="detail"]', + 'img', // Fallback to all images + ], + + // Links to large images + imageLinks: [ + 'a[href*=".jpg"]', + 'a[href*=".png"]', + ], +}; +``` + +### Testing Your Selectors + +1. **For gallery page:** + - Open DevTools (F12) on a gallery page + - Open the Console tab + - Paste: `document.querySelectorAll('your-selector-here').length` + - Should return > 0 if selector is correct + +2. **For detail page:** + - Open DevTools on a product detail page + - Try: `document.querySelectorAll('img').length` + - Should show number of images on that page + +## Configuration Options + +Edit the `CONFIG` object in **`background.js`** to adjust: + +```javascript +const CONFIG = { + REQUEST_DELAY: 800, // ms between requests (lower = faster but more load on server) + RETRY_ATTEMPTS: 3, // How many times to retry failed downloads + RETRY_DELAY: 2000, // ms to wait before retrying + MAX_CONCURRENT_DOWNLOADS: 2, // Can be overridden in UI (1-10) + TIMEOUT: 30000, // ms before request times out +}; +``` + +## Troubleshooting + +### "No products found" + +1. Check the URL - make sure you're on a gallery page with product links +2. Verify selectors are correct: + - Right-click product card → Inspect + - Check if the selector matches the HTML + - Update `SELECTORS` in `content.js` + +3. Check browser console (F12 → Console): + - Look for error messages + - Type: `document.querySelectorAll('your-selector').length` + - Should be > 0 + +### Downloads not starting + +1. Verify Chrome permissions: + - Go to `chrome://extensions/` + - Find "Yupoo Downloader" + - Click "Details" + - Check "Permissions" tab + +2. Check Chrome's download settings: + - Go to `chrome://settings/downloads` + - Verify download location is accessible + - Disable "Ask where to save each file" option + +### Slow downloads + +1. Increase `MAX_CONCURRENT_DOWNLOADS` in the popup (2-5 is usually good) +2. Decrease `REQUEST_DELAY` in `background.js` if the server allows +3. Check your internet connection + +### Getting 404 errors for images + +1. The image URLs might be dynamic or time-limited +2. Check detail page selector: + - Open a product page in your browser + - F12 → Inspect the images + - Update `DETAIL_PAGE_SELECTORS` in `background.js` + +3. The site might require headers: + - Add custom headers to `fetchWithRetry()` in `background.js` if needed + +### Extension stops working after Chrome update + +1. Go to `chrome://extensions/` +2. Click "Update" or toggle off/on +3. If still broken, reload the extension: + - Remove it + - Reload this folder again + +## How It Works + +### 1. Content Script (`content.js`) +- Runs on the gallery page you're viewing +- Finds all product cards/links using CSS selectors +- Extracts product codes and detail page URLs +- Sends data to the background worker + +### 2. Popup UI (`popup.html`, `popup.js`, `styles.css`) +- Shows "Scan Products" and "Download All" buttons +- Displays real-time progress +- Shows activity log +- Allows concurrency adjustment + +### 3. Background Service Worker (`background.js`) +- Fetches each product's detail page +- Parses the HTML to find all images +- Downloads images using Chrome's downloads API +- Manages concurrency queue +- Handles retry logic and deduplication +- Tracks progress and errors + +## Technical Details + +### Manifest V3 +- Uses service workers instead of background pages +- Latest Chrome extension security model +- Future-proof implementation + +### Downloads API +- Uses `chrome.downloads.download()` with `filename` parameter +- Automatically creates product folders +- Works with browser's native download system + +### Content Security Policy +- Safe from malicious scripts +- Follows Chrome extension best practices +- No external dependencies + +### Rate Limiting +- 800ms delay between requests (configurable) +- Respects server resources +- Can be adjusted for faster downloads + +## Performance Tips + +1. **Reduce concurrency on slow connections** - Set to 1-2 +2. **Increase concurrency on fast connections** - Set to 4-5 (but check server limits) +3. **Decrease REQUEST_DELAY for faster downloads** (but be respectful): + - 800ms (default) = 1.25 requests/sec + - 500ms = 2 requests/sec + - 300ms = 3.3 requests/sec +4. **Close other Chrome tabs** to free up bandwidth +5. **Disable VPN/proxy** if it's slowing things down + +## File Organization + +``` +popup.html → User interface +popup.js → UI logic and user interaction +styles.css → Visual styling + +content.js → Page scanning (runs on website) +background.js → Download orchestration (service worker) +manifest.json → Extension configuration + +README.md → This file +``` + +## Browser Support + +- Chrome 88+ (Manifest V3 support) +- Edge 88+ (also based on Chromium) +- Brave, Opera, and other Chromium-based browsers + +## Known Limitations + +- Does not work with infinite scroll pages (only scans initial visible products) +- Some sites with JavaScript-rendered images may not work +- Image watermarks are preserved as-is +- Login-required sites need active session + +## Future Improvements + +- [ ] Handle infinite scroll pages +- [ ] Support for lazy-loaded images +- [ ] Filter by product category +- [ ] Scheduling downloads +- [ ] Export metadata (product names, links) +- [ ] Resume incomplete downloads +- [ ] Image quality selection + +## Legal Notice + +This extension is for personal, non-commercial use only. Respect website ToS and copyright laws when downloading images. The author is not responsible for misuse. + +## License + +MIT License - feel free to modify and distribute + +## Support + +If you encounter issues: + +1. Check the activity log in the popup for error messages +2. Open DevTools (F12) and check the Console tab +3. Verify CSS selectors for your target website +4. Try on a different gallery page to isolate the issue +5. Check that you have permission to download from the website + +--- + +**Happy downloading! 📸** diff --git a/yupoo-downloader/background.js b/yupoo-downloader/background.js new file mode 100644 index 0000000..c05ba0e --- /dev/null +++ b/yupoo-downloader/background.js @@ -0,0 +1,606 @@ +/** + * Background Service Worker - Manifest V3 + * + * Responsibilities: + * - Manage communication with popup and content scripts + * - Fetch and parse product detail pages + * - Extract image URLs from detail pages + * - Download images using Chrome downloads API + * - Track progress and state + * - Handle retry logic and rate limiting + */ + +// ============================================================================ +// CONFIGURATION - Adjust selectors for detail page structure +// ============================================================================ + +const DETAIL_PAGE_SELECTORS = { + // Common image container selectors on Yupoo detail pages + imageContainer: [ + 'div.photo-list', + 'div.details-images', + 'div.album', + 'div.gallery', + 'div.photo-album', + 'div[class*="images"]', + 'div[class*="photo"]', + 'div[class*="album"]', + 'ul.photo-list', + ], + imageElements: [ + 'img.photo', + 'img[class*="detail"]', + 'img[class*="product"]', + 'img[data-src*=".jpg"]', + 'img[data-src*=".png"]', + 'img', + ], + imageLinks: [ + 'a[href*=".jpg"]', + 'a[href*=".png"]', + 'a[href*=".webp"]', + 'a[href*="/fs/"]', + 'a[href*="/f/"]', + ], +}; + +const CONFIG = { + REQUEST_DELAY: 800, // ms between requests to avoid hammering + RETRY_ATTEMPTS: 3, + RETRY_DELAY: 2000, // ms + MAX_CONCURRENT_DOWNLOADS: 2, // default, can be changed by user + TIMEOUT: 30000, // ms for fetch requests +}; + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +let state = { + products: [], + currentProductIndex: -1, + totalImagesDownloaded: 0, + errorCount: 0, + isRunning: false, + downloadedUrls: new Set(), // Deduplicate during this session + maxConcurrency: CONFIG.MAX_CONCURRENT_DOWNLOADS, + logs: [], +}; + +// ============================================================================ +// LOGGING +// ============================================================================ + +function log(message, type = 'info') { + const timestamp = new Date().toLocaleTimeString(); + const entry = `[${timestamp}] ${message}`; + state.logs.push({ message: entry, type }); + + // Keep last 100 logs + if (state.logs.length > 100) { + state.logs.shift(); + } + + console.log(entry); + broadcastUpdate(); +} + +function logError(message) { + log(`❌ ${message}`, 'error'); + state.errorCount++; +} + +function logSuccess(message) { + log(`✅ ${message}`, 'success'); +} + +function logWarning(message) { + log(`⚠️ ${message}`, 'warning'); +} + +// ============================================================================ +// COMMUNICATION & STATE BROADCAST +// ============================================================================ + +function broadcastUpdate() { + chrome.runtime.sendMessage({ + action: 'updateState', + state: { + products: state.products, + currentProductIndex: state.currentProductIndex, + totalImagesDownloaded: state.totalImagesDownloaded, + errorCount: state.errorCount, + isRunning: state.isRunning, + logs: state.logs, + }, + }).catch(() => { + // Popup might be closed, ignore + }); +} + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + console.log('[Background] Received:', request.action); + + if (request.action === 'updateProducts') { + state.products = request.products || []; + state.currentProductIndex = -1; + state.totalImagesDownloaded = 0; + state.errorCount = 0; + state.downloadedUrls.clear(); + state.logs = []; + log(`Loaded ${state.products.length} products`, 'info'); + broadcastUpdate(); + sendResponse({ success: true }); + + } else if (request.action === 'startDownload') { + state.maxConcurrency = request.maxConcurrency || CONFIG.MAX_CONCURRENT_DOWNLOADS; + log(`Starting downloads with max concurrency: ${state.maxConcurrency}`, 'info'); + startDownloadProcess(); + sendResponse({ success: true }); + + } else if (request.action === 'stopDownload') { + state.isRunning = false; + log('Download stopped by user', 'warning'); + broadcastUpdate(); + sendResponse({ success: true }); + + } else if (request.action === 'getState') { + sendResponse({ + state: { + products: state.products, + currentProductIndex: state.currentProductIndex, + totalImagesDownloaded: state.totalImagesDownloaded, + errorCount: state.errorCount, + isRunning: state.isRunning, + logs: state.logs, + }, + }); + } +}); + +// ============================================================================ +// DOWNLOAD MANAGEMENT +// ============================================================================ + +/** + * Main download process + */ +async function startDownloadProcess() { + if (state.isRunning) return; + + state.isRunning = true; + broadcastUpdate(); + + for (let i = 0; i < state.products.length; i++) { + if (!state.isRunning) break; + + const product = state.products[i]; + state.currentProductIndex = i; + + log(`Processing product ${i + 1}/${state.products.length}: ${product.productCode || 'unknown'}`, 'info'); + broadcastUpdate(); + + try { + await downloadProductImages(product); + await delay(CONFIG.REQUEST_DELAY); + } catch (error) { + logError(`Failed to process product: ${error.message}`); + } + } + + state.isRunning = false; + log('✨ Download process complete!', 'success'); + broadcastUpdate(); +} + +/** + * Download all images for a single product + */ +async function downloadProductImages(product) { + const folderName = sanitizeFolderName(product.productCode || `product_${product.index}`); + + try { + log(`Fetching detail page: ${product.detailUrl}`, 'info'); + const html = await fetchWithRetry(product.detailUrl); + + if (!html || html.length === 0) { + logError(`Detail page returned empty HTML for ${folderName}`); + return; + } + + log(`Detail page fetched: ${html.length} characters`, 'info'); + + const imageUrls = extractImageUrlsFromHtml(html, product.detailUrl); + + if (imageUrls.length === 0) { + logWarning(`No images found for product: ${folderName}. Check if page loads in browser.`); + return; + } + + log(`Found ${imageUrls.length} images for ${folderName}`, 'info'); + + // Download images with controlled concurrency + await downloadImagesWithConcurrency(imageUrls, folderName, state.maxConcurrency); + + logSuccess(`✓ Downloaded ${imageUrls.length} images for ${folderName}`); + } catch (error) { + logError(`Error downloading product ${folderName}: ${error.message}`); + } +} + +/** + * Download multiple images with concurrency limit + */ +async function downloadImagesWithConcurrency(imageUrls, folderName, maxConcurrency) { + const queue = [...imageUrls]; + const inProgress = []; + + while (queue.length > 0 || inProgress.length > 0) { + // Fill up to maxConcurrency + while (inProgress.length < maxConcurrency && queue.length > 0) { + const url = queue.shift(); + const downloadPromise = downloadSingleImage(url, folderName, imageUrls.indexOf(url) + 1) + .then(() => { + inProgress.splice(inProgress.indexOf(downloadPromise), 1); + }) + .catch(error => { + console.error('Download error:', error); + inProgress.splice(inProgress.indexOf(downloadPromise), 1); + }); + inProgress.push(downloadPromise); + } + + // Wait for at least one to complete + if (inProgress.length > 0) { + await Promise.race(inProgress); + } + } +} + +/** + * Download a single image + */ +async function downloadSingleImage(imageUrl, folderName, index) { + // Skip if already downloaded in this session + if (state.downloadedUrls.has(imageUrl)) { + logWarning(`Skipping duplicate image: ${imageUrl}`); + return; + } + + try { + // Make sure URL is absolute + let absoluteUrl = imageUrl; + if (!absoluteUrl.startsWith('http')) { + // Try to construct from base Yupoo URL + if (absoluteUrl.startsWith('/')) { + absoluteUrl = 'https://www.yupoo.com' + absoluteUrl; + } else { + absoluteUrl = 'https://www.yupoo.com/' + absoluteUrl; + } + } + + const filename = await fetchImageAndDownload(absoluteUrl, folderName, index); + + state.downloadedUrls.add(imageUrl); + state.totalImagesDownloaded++; + broadcastUpdate(); + + log(`Downloaded: ${folderName}/${filename}`, 'success'); + } catch (error) { + throw new Error(`Failed to download image: ${error.message}`); + } +} + +/** + * Fetch image and use Chrome downloads API + */ +async function fetchImageAndDownload(imageUrl, folderName, index) { + try { + const response = await fetch(imageUrl, { + method: 'GET', + headers: { + 'Referer': 'https://www.yupoo.com/', + }, + timeout: CONFIG.TIMEOUT, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const ext = getImageExtension(imageUrl, response); + const filename = `${String(index).padStart(2, '0')}.${ext}`; + const filepath = `${folderName}/${filename}`; + + // Create a blob URL for download + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + + // Use Chrome downloads API + return new Promise((resolve, reject) => { + chrome.downloads.download( + { + url: blobUrl, + filename: filepath, + saveAs: false, + }, + (downloadId) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + // Clean up blob URL after a delay + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + resolve(filename); + } + } + ); + }); + } catch (error) { + throw new Error(`Download failed: ${error.message}`); + } +} + +/** + * Fetch URL with retry logic + */ +async function fetchWithRetry(url, attempt = 1) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CONFIG.TIMEOUT); + + const response = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.yupoo.com/', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return await response.text(); + } catch (error) { + if (attempt < CONFIG.RETRY_ATTEMPTS) { + logWarning(`Fetch attempt ${attempt} failed, retrying... (${error.message})`); + await delay(CONFIG.RETRY_DELAY); + return fetchWithRetry(url, attempt + 1); + } + throw error; + } +} + +// ============================================================================ +// IMAGE EXTRACTION FROM HTML +// ============================================================================ + +/** + * Extract all image URLs from detail page HTML + */ +function extractImageUrlsFromHtml(html, baseUrl) { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const imageUrls = []; + const seenUrls = new Set(); + + console.log(`[Background] Parsing HTML for images... (length: ${html.length} chars)`); + + // Strategy 1: Find image containers + for (const containerSelector of DETAIL_PAGE_SELECTORS.imageContainer) { + try { + const containers = doc.querySelectorAll(containerSelector); + if (containers.length > 0) { + console.log(`[Background] Found ${containers.length} containers with selector: ${containerSelector}`); + containers.forEach(container => { + extractFromContainer(container, baseUrl, imageUrls, seenUrls); + }); + } + } catch (e) { + // Invalid selector + } + } + + // Strategy 2: If no images found, try direct image selectors + if (imageUrls.length === 0) { + console.log('[Background] No container images found, trying direct selectors...'); + for (const imgSelector of DETAIL_PAGE_SELECTORS.imageElements) { + try { + const images = doc.querySelectorAll(imgSelector); + if (images.length > 0) { + console.log(`[Background] Found ${images.length} images with selector: ${imgSelector}`); + images.forEach(img => { + const url = img.src || img.getAttribute('data-src'); + if (url && isValidImageUrl(url)) { + const absUrl = resolveUrl(url, baseUrl); + if (!seenUrls.has(absUrl)) { + imageUrls.push(absUrl); + seenUrls.add(absUrl); + } + } + }); + } + } catch (e) { + // Invalid selector + } + } + } + + // Strategy 3: Look for image links + if (imageUrls.length === 0) { + console.log('[Background] No direct images found, trying image links...'); + for (const linkSelector of DETAIL_PAGE_SELECTORS.imageLinks) { + try { + const links = doc.querySelectorAll(linkSelector); + if (links.length > 0) { + console.log(`[Background] Found ${links.length} image links with selector: ${linkSelector}`); + links.forEach(link => { + const url = link.href; + if (url && isValidImageUrl(url)) { + const absUrl = resolveUrl(url, baseUrl); + if (!seenUrls.has(absUrl)) { + imageUrls.push(absUrl); + seenUrls.add(absUrl); + } + } + }); + } + } catch (e) { + // Invalid selector + } + } + } + + // Strategy 4: Last resort - find ALL images and filter + if (imageUrls.length === 0) { + console.log('[Background] Last resort: scanning all images...'); + const allImages = doc.querySelectorAll('img'); + console.log(`[Background] Found ${allImages.length} total img elements`); + + let validCount = 0; + allImages.forEach(img => { + const url = img.src || img.getAttribute('data-src'); + // Filter to actual product images (skip small/thumbnail images) + if (url && isValidImageUrl(url) && (!img.width || img.width > 100)) { + const absUrl = resolveUrl(url, baseUrl); + if (!seenUrls.has(absUrl)) { + imageUrls.push(absUrl); + seenUrls.add(absUrl); + validCount++; + } + } + }); + console.log(`[Background] Added ${validCount} valid images from all images`); + } + + console.log(`[Background] ✓ Extracted ${imageUrls.length} total images from detail page`); + return imageUrls; + } catch (error) { + console.error('Error parsing HTML:', error); + return []; + } +} + +/** + * Extract images from a container element + */ +function extractFromContainer(container, baseUrl, imageUrls, seenUrls) { + // Look for img tags + container.querySelectorAll('img').forEach(img => { + const url = img.src || img.getAttribute('data-src'); + if (url && isValidImageUrl(url)) { + const absUrl = resolveUrl(url, baseUrl); + if (!seenUrls.has(absUrl)) { + imageUrls.push(absUrl); + seenUrls.add(absUrl); + } + } + }); + + // Look for image links + container.querySelectorAll('a[href*=".jpg"], a[href*=".png"], a[href*=".webp"]').forEach(link => { + const url = link.href; + if (url && isValidImageUrl(url)) { + const absUrl = resolveUrl(url, baseUrl); + if (!seenUrls.has(absUrl)) { + imageUrls.push(absUrl); + seenUrls.add(absUrl); + } + } + }); +} + +/** + * Check if URL looks like a valid image + */ +function isValidImageUrl(url) { + if (!url || url.length === 0) return false; + + const imageExtensions = /\.(jpg|jpeg|png|webp|gif|bmp)(\?.*)?$/i; + const isDataUrl = url.startsWith('data:'); + const isTooSmall = url.includes('favicon') || url.includes('logo') || url.includes('icon') || url.includes('.svg'); + + return !isDataUrl && !isTooSmall && imageExtensions.test(url); +} + +/** + * Convert relative URL to absolute + */ +function resolveUrl(url, baseUrl) { + if (!url) return ''; + + // Already absolute + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + try { + return new URL(url, baseUrl).href; + } catch (e) { + console.warn('Failed to resolve URL:', url, baseUrl); + return url; + } +} + +/** + * Get image extension from URL or content-type + */ +function getImageExtension(url, response) { + // Try from URL + const urlMatch = url.match(/\.([a-z]+)(\?|$)/i); + if (urlMatch) { + const ext = urlMatch[1].toLowerCase(); + if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'].includes(ext)) { + return ext === 'jpg' ? 'jpg' : ext; + } + } + + // Try from content-type + const contentType = response.headers.get('content-type'); + if (contentType) { + if (contentType.includes('jpeg')) return 'jpg'; + if (contentType.includes('png')) return 'png'; + if (contentType.includes('webp')) return 'webp'; + if (contentType.includes('gif')) return 'gif'; + } + + return 'jpg'; // Default +} + +// ============================================================================ +// UTILITY FUNCTIONS +// ============================================================================ + +/** + * Sanitize folder name for use in file system + */ +function sanitizeFolderName(name) { + if (!name) return 'product_unknown'; + + // Remove or replace invalid characters + return name + .replace(/[<>:"|?*\/\\]/g, '_') // Invalid file system characters + .replace(/\s+/g, '_') // Spaces to underscores + .replace(/_{2,}/g, '_') // Multiple underscores to single + .substring(0, 100) // Limit length + .toLowerCase(); +} + +/** + * Delay utility + */ +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +console.log('[Background Service Worker] Initialized'); +log('Service worker ready', 'info'); diff --git a/yupoo-downloader/content.js b/yupoo-downloader/content.js new file mode 100644 index 0000000..7fe3828 --- /dev/null +++ b/yupoo-downloader/content.js @@ -0,0 +1,390 @@ +/** + * Content Script - Runs on the target website page + * + * Responsibilities: + * - Scan the current page for product cards/links + * - Extract product identifiers and detail page URLs + * - Send product data to the background service worker + */ + +// ============================================================================ +// CONFIGURATION - Adjust selectors based on website structure +// ============================================================================ + +const SELECTORS = { + // Yupoo gallery page selectors - optimized for various Yupoo URL patterns + productCard: [ + 'a[href*="/fs/"]', // Yupoo /fs/ pattern + 'a[href*="/f/"]', // Yupoo /f/ pattern + 'a[href*="/item/"]', // Yupoo /item/ pattern + 'a[href*="/product/"]', // Product pattern + 'div[onclick*="/fs/"]', // Onclick handlers + 'div[onclick*="/f/"]', + ], + productLink: [ + 'a[href*="/fs/"]', + 'a[href*="/f/"]', + 'a[href*="/item/"]', + 'a[href*="/product"]', + ], + productTitle: [ + '.caption', + '.box-title', + '.item-name', + 'p', + 'span', + 'div', + ], +}; + +// ============================================================================ +// PRODUCT SCANNER +// ============================================================================ + +class ProductScanner { + constructor() { + this.products = []; + this.seenUrls = new Set(); + } + + /** + * Find the first matching element using a selector array + */ + findElement(parent, selectors) { + if (typeof selectors === 'string') { + return parent.querySelector(selectors); + } + + for (const selector of selectors) { + try { + const element = parent.querySelector(selector); + if (element) return element; + } catch (e) { + // Invalid selector, continue + } + } + return null; + } + + /** + * Scan the current page for products + */ + scanPage() { + console.log('[ProductScanner] Starting page scan...'); + this.products = []; + this.seenUrls.clear(); + + let productElements = []; + + // STRATEGY 1: Look for product thumbnail containers (divs with images inside) + const imageSelectors = [ + 'div.thumb', // Common Yupoo thumbnail class + 'div[class*="thumb"]', // Any thumb variant + 'li.photo-list-item', // List item in photo list + 'li[class*="item"]', // Any list item + 'div.photo-item', // Photo item + 'div[class*="box"]', // Box container + 'div > img', // Direct parent of image + ]; + + for (const selector of imageSelectors) { + try { + const elements = document.querySelectorAll(selector); + if (elements.length > 6 && elements.length < 200) { // Filter out nav/footer elements + console.log(`[ProductScanner] Found ${elements.length} elements with selector: ${selector}`); + productElements.push(...elements); + } + } catch (e) { + // Invalid selector + } + } + + console.log(`[ProductScanner] Found ${productElements.length} product container candidates`); + + // Extract product info from containers + const extractedProducts = []; + productElements.forEach((element, index) => { + try { + if (element.dataset.processed) return; + + // Method 1: Look for tag within the element + let link = element.querySelector('a[href]'); + + // Method 2: If no link found, check if element itself is an image - find parent link + if (!link && (element.tagName === 'IMG' || element.tagName === 'img')) { + let parent = element.parentElement; + let depth = 0; + while (parent && depth < 5) { + const potentialLink = parent.querySelector('a[href]'); + if (potentialLink) { + link = potentialLink; + break; + } + parent = parent.parentElement; + depth++; + } + } + + // Method 3: Check for onclick handler + const onclickAttr = element.getAttribute('onclick'); + if (!link && onclickAttr) { + // Try to extract URL from onclick + const urlMatch = onclickAttr.match(/['"](https?:\/\/[^'"]+)['"]/); + if (urlMatch) { + const fakeLink = document.createElement('a'); + fakeLink.href = urlMatch[1]; + link = fakeLink; + } + } + + if (link && link.href) { + const product = this.extractProductInfo(link, extractedProducts.length); + if (product && !this.seenUrls.has(product.detailUrl)) { + extractedProducts.push(product); + this.seenUrls.add(product.detailUrl); + element.dataset.processed = true; + console.log(`[ProductScanner] ✓ Added: ${product.productCode || 'unknown'}`); + } + } + } catch (e) { + console.warn(`[ProductScanner] Error processing element:`, e.message); + } + }); + + this.products = extractedProducts; + console.log(`[ProductScanner] ✓ Extracted ${this.products.length} unique products`); + + // If still 0 products, do a last-ditch search + if (this.products.length === 0) { + console.log('[ProductScanner] Last-ditch: scanning all images with image gallery patterns...'); + const allImages = document.querySelectorAll('img[src*=".jpg"], img[src*=".png"]'); + console.log(`[ProductScanner] Found ${allImages.length} image elements`); + + // Look for parent links of these images + allImages.forEach((img, idx) => { + if (idx > 100) return; // Sanity check + + let container = img.closest('a, div.thumb, div.photo, li'); + if (container) { + const link = container.tagName === 'A' ? container : container.querySelector('a'); + if (link && link.href) { + const product = this.extractProductInfo(link, this.products.length); + if (product && !this.seenUrls.has(product.detailUrl)) { + this.products.push(product); + this.seenUrls.add(product.detailUrl); + } + } + } + }); + } + + console.log(`[ProductScanner] Final count: ${this.products.length} products`); + return this.products; + } + + /** + * Extract product information from a link or container element + */ + extractProductInfo(element, index) { + let detailUrl = null; + let titleText = ''; + + // Get the URL + if (element.href) { + detailUrl = element.href; + } else { + return null; + } + + // Skip navigation/category links - we only want product detail pages + // Product detail pages should have specific patterns + const href = detailUrl.toLowerCase(); + + // Skip common non-product pages + if (href.includes('/categor') || + href.includes('/album?') || + href.includes('/albums') || + href.includes('/home') || + href.includes('/search') || + href.includes('javascript:') || + href.includes('#')) { + console.log(`[ProductScanner] Skipping non-product URL: ${detailUrl}`); + return null; + } + + // Must have a Yupoo domain + if (!href.includes('yupoo.com')) { + console.log(`[ProductScanner] Skipping non-Yupoo URL: ${detailUrl}`); + return null; + } + + // Normalize URL + try { + detailUrl = new URL(detailUrl, window.location.href).href; + } catch (e) { + console.warn(`[ProductScanner] Invalid URL:`, detailUrl); + return null; + } + + // Try to find title from nearby text + let container = element.closest('div, li') || element; + + // Look for text content in the container + const allText = container.innerText || container.textContent || ''; + const lines = allText.split('\n').filter(line => line.trim().length > 0); + if (lines.length > 0) { + titleText = lines[0].trim(); + if (titleText.length > 200) { + titleText = titleText.substring(0, 200); + } + } + + // If still no title, look for alt text on images + if (!titleText) { + const img = container.querySelector('img'); + if (img && img.alt) { + titleText = img.alt; + } + } + + // Extract product code + const productCode = this.extractProductCode(titleText, detailUrl); + + return { + index: index + 1, + productCode, + titleText, + detailUrl, + thumbnailUrl: this.extractThumbnailUrl(container), + }; + } + + /** + * Extract a unique product identifier from title or URL + * Look for patterns like "3ME10101430" or use URL slug + */ + extractProductCode(titleText, detailUrl) { + // Look for pattern: [XXXXX...] at the start of title (Yupoo format) + const bracketMatch = titleText.match(/\[([A-Z0-9]{4,})\]/); + if (bracketMatch) { + return bracketMatch[1]; + } + + // Look for 6+ alphanumeric characters + const matches = titleText.match(/[A-Z0-9]{6,}/); + if (matches) { + return matches[0]; + } + + // Try URL slug - look for various Yupoo patterns + let urlMatch = detailUrl.match(/\/fs\/([a-zA-Z0-9]+)/i) || // /fs/XXXXX + detailUrl.match(/\/f\/[\d.]+\/([a-zA-Z0-9]+)/i) || // /f/7.11.31/XXXXX + detailUrl.match(/\/item\/([a-zA-Z0-9]+)/i) || + detailUrl.match(/\/product\/([a-zA-Z0-9]+)/i) || + detailUrl.match(/\/album\/([a-zA-Z0-9]+)/i); + if (urlMatch && urlMatch[1]) { + return urlMatch[1]; + } + + // Last resort - extract last path segment + const urlObj = new URL(detailUrl); + const pathSegments = urlObj.pathname.split('/').filter(Boolean); + if (pathSegments.length > 0) { + const lastSegment = pathSegments[pathSegments.length - 1]; + if (lastSegment && lastSegment.length > 2 && !/^[0-9.]+$/.test(lastSegment)) { + return lastSegment; + } + } + + return null; + } + + /** + * Extract thumbnail image URL from card + */ + extractThumbnailUrl(element) { + // Look for img tag + let imgElement = element.querySelector('img'); + if (imgElement && imgElement.src) { + return imgElement.src; + } + + // If element is img itself + if (element.tagName === 'IMG' || element.tagName === 'img') { + return element.src; + } + + // Look for background image + const style = window.getComputedStyle(element); + const bgImage = style.backgroundImage; + if (bgImage && bgImage !== 'none') { + const match = bgImage.match(/url\(['"]*([^'"]*)['"]*\)/); + if (match) { + return match[1]; + } + } + + return null; + } +} + +// ============================================================================ +// MESSAGE LISTENER - Communicate with popup and background +// ============================================================================ + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + console.log('[Content] Received message:', request.action); + + try { + if (request.action === 'scanPage') { + const scanner = new ProductScanner(); + const products = scanner.scanPage(); + sendResponse({ + success: true, + productCount: products.length, + products: products, + }); + } else if (request.action === 'debug') { + // Debug diagnostics + const divOnclick = document.querySelectorAll('div[onclick*="/item/"]').length; + const linkItem = document.querySelectorAll('a[href*="/item/"]').length; + const linkProduct = document.querySelectorAll('a[href*="/product"]').length; + const divItem = document.querySelectorAll('div.item').length; + const allImages = document.querySelectorAll('img').length; + const allLinks = document.querySelectorAll('a[href]').length; + + // Get sample links to help debug + const allLinkHrefs = []; + document.querySelectorAll('a[href]').forEach((link, idx) => { + if (idx < 20) { // First 20 links + allLinkHrefs.push(link.href); + } + }); + + sendResponse({ + success: true, + divOnclick, + linkItem, + linkProduct, + divItem, + allImages, + allLinks, + htmlLength: document.documentElement.innerHTML.length, + sampleLinks: allLinkHrefs, + }); + } else if (request.action === 'ping') { + sendResponse({ success: true }); + } + } catch (error) { + console.error('[Content] Error:', error); + sendResponse({ + success: false, + error: error.message, + }); + } + + // Return true to indicate async response + return true; +}); + +console.log('[Content Script] Loaded and ready'); diff --git a/yupoo-downloader/manifest.json b/yupoo-downloader/manifest.json new file mode 100644 index 0000000..501c1e3 --- /dev/null +++ b/yupoo-downloader/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": 3, + "name": "Yupoo Product Image Downloader", + "version": "1.0.0", + "description": "Download all product images from Yupoo-style gallery pages organized by product", + + "permissions": [ + "downloads", + "scripting", + "activeTab", + "webRequest" + ], + + "host_permissions": [ + "" + ], + + "background": { + "service_worker": "background.js" + }, + + "action": { + "default_popup": "popup.html", + "default_title": "Download Yupoo Products" + }, + + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_end" + } + ] +} diff --git a/yupoo-downloader/popup.html b/yupoo-downloader/popup.html new file mode 100644 index 0000000..c122d54 --- /dev/null +++ b/yupoo-downloader/popup.html @@ -0,0 +1,58 @@ + + + + + + Yupoo Downloader + + + +
+

Yupoo Downloader

+ +
+ + +
+ +
+ + + +
+ +
+
+ Products Found: + 0 +
+
+ Processing: + +
+
+ Images Downloaded: + 0 +
+
+ Errors: + 0 +
+
+ +
+
+
+ +
+

Activity Log

+
+ +
+ +
+
+ + + + diff --git a/yupoo-downloader/popup.js b/yupoo-downloader/popup.js new file mode 100644 index 0000000..5751412 --- /dev/null +++ b/yupoo-downloader/popup.js @@ -0,0 +1,367 @@ +/** + * Popup Script - UI Logic + * + * Responsibilities: + * - Handle button clicks (Scan, Download) + * - Communicate with content script to scan products + * - Communicate with background service worker to start downloads + * - Update UI with progress information + * - Display logs + */ + +// ============================================================================ +// DOM ELEMENTS +// ============================================================================ + +const scanBtn = document.getElementById('scanBtn'); +const downloadBtn = document.getElementById('downloadBtn'); +const concurrencyInput = document.getElementById('concurrency'); +const clearLogBtn = document.getElementById('clearLogBtn'); +const debugBtn = document.getElementById('debugBtn'); + +const totalProductsEl = document.getElementById('totalProducts'); +const currentProductEl = document.getElementById('currentProduct'); +const imagesDownloadedEl = document.getElementById('imagesDownloaded'); +const errorCountEl = document.getElementById('errorCount'); +const progressBarEl = document.getElementById('progressBar'); +const logBoxEl = document.getElementById('logBox'); +const statusMsgEl = document.getElementById('statusMsg'); + +// ============================================================================ +// STATE +// ============================================================================ + +let products = []; +let currentState = { + products: [], + currentProductIndex: -1, + totalImagesDownloaded: 0, + errorCount: 0, + isRunning: false, + logs: [], +}; + +// ============================================================================ +// EVENT LISTENERS +// ============================================================================ + +scanBtn.addEventListener('click', handleScanClick); +downloadBtn.addEventListener('click', handleDownloadClick); +clearLogBtn.addEventListener('click', handleClearLogClick); +debugBtn.addEventListener('click', handleDebugClick); + +// ============================================================================ +// SCANNING LOGIC +// ============================================================================ + +async function handleDebugClick() { + try { + debugBtn.disabled = true; + updateLog('Running debug diagnostics...', 'info'); + + // Get current active tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs.length) { + throw new Error('No active tab found'); + } + + const tabId = tabs[0].id; + + // Send debug message + let response; + try { + response = await chrome.tabs.sendMessage(tabId, { + action: 'debug', + }); + } catch (e) { + updateLog('Content script not loaded, injecting...', 'warning'); + + await chrome.scripting.executeScript({ + target: { tabId: tabId }, + files: ['content.js'], + }); + + await new Promise(resolve => setTimeout(resolve, 500)); + response = await chrome.tabs.sendMessage(tabId, { action: 'debug' }); + } + + if (response.success) { + updateLog('=== DEBUG INFO ===', 'info'); + updateLog(`Total links on page: ${response.allLinks}`, 'info'); + updateLog(`a[href*="/item/"] = ${response.linkItem}`, 'info'); + updateLog(`a[href*="/product"] = ${response.linkProduct}`, 'info'); + updateLog(`div[onclick*="/item/"] = ${response.divOnclick}`, 'info'); + updateLog(`div.item = ${response.divItem}`, 'info'); + updateLog(`All images on page: ${response.allImages}`, 'info'); + + // Show sample links + if (response.sampleLinks && response.sampleLinks.length > 0) { + updateLog('--- Sample links on page (first 20) ---', 'info'); + response.sampleLinks.forEach((link, i) => { + // Show only the relevant part of href + const shortLink = link.length > 80 ? link.substring(0, 80) + '...' : link; + updateLog(` [${i + 1}] ${shortLink}`, 'info'); + }); + } + + updateLog('=== END DEBUG ===', 'info'); + + if (response.allLinks === 0) { + updateLog('⚠️ No links found! Page might not be fully loaded.', 'warning'); + } else if (response.allLinks > 0 && response.linkItem === 0 && response.linkProduct === 0) { + updateLog('⚠️ Found links but none match /fs/, /f/, /item/ patterns.', 'warning'); + updateLog('✅ Scanner will search for product containers instead.', 'info'); + } + } + } catch (error) { + updateLog(`Debug error: ${error.message}`, 'error'); + } finally { + debugBtn.disabled = false; + } +} + +async function handleScanClick() { + try { + scanBtn.disabled = true; + setStatus('Scanning page...', 'working'); + updateLog('Starting page scan...', 'info'); + + // Get current active tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs.length) { + throw new Error('No active tab found'); + } + + const tabId = tabs[0].id; + const tabUrl = tabs[0].url; + + // Verify content script is injected + let response; + try { + response = await chrome.tabs.sendMessage(tabId, { + action: 'scanPage', + }); + } catch (e) { + // Content script might not be loaded, try injecting it + updateLog('Content script not found, injecting...', 'warning'); + + try { + await chrome.scripting.executeScript({ + target: { tabId: tabId }, + files: ['content.js'], + }); + } catch (err) { + throw new Error(`Cannot inject script on this page. URL: ${tabUrl}. Some pages (chrome://, extensions://, etc) don't allow scripts.`); + } + + // Give it a moment to load + await new Promise(resolve => setTimeout(resolve, 500)); + + // Try again + response = await chrome.tabs.sendMessage(tabId, { + action: 'scanPage', + }); + } + + if (!response.success) { + throw new Error('Failed to scan page'); + } + + products = response.products || []; + const count = products.length; + + updateLog(`Found ${count} products`, 'success'); + totalProductsEl.textContent = count; + + if (count > 0) { + downloadBtn.disabled = false; + setStatus(`Ready to download ${count} products`, 'idle'); + + // Send products to background + await chrome.runtime.sendMessage({ + action: 'updateProducts', + products: products, + }); + } else { + setStatus('No products found. Check page structure and selectors.', 'error'); + } + } catch (error) { + console.error('Scan error:', error); + updateLog(`Error: ${error.message}`, 'error'); + setStatus(`Scan failed: ${error.message}`, 'error'); + } finally { + scanBtn.disabled = false; + } +} + +// ============================================================================ +// DOWNLOAD LOGIC +// ============================================================================ + +async function handleDownloadClick() { + try { + if (products.length === 0) { + setStatus('No products to download. Scan the page first.', 'error'); + return; + } + + downloadBtn.disabled = true; + const maxConcurrency = parseInt(concurrencyInput.value) || 2; + + updateLog(`Starting download of ${products.length} products...`, 'info'); + updateLog(`Max concurrent: ${maxConcurrency}`, 'info'); + + await chrome.runtime.sendMessage({ + action: 'startDownload', + maxConcurrency: maxConcurrency, + }); + + setStatus('Downloads in progress...', 'working'); + + // Start polling for updates + pollForUpdates(); + } catch (error) { + console.error('Download error:', error); + updateLog(`Error: ${error.message}`, 'error'); + setStatus(`Download failed: ${error.message}`, 'error'); + downloadBtn.disabled = false; + } +} + +// ============================================================================ +// STATE UPDATES & POLLING +// ============================================================================ + +async function pollForUpdates() { + while (currentState.isRunning) { + try { + const response = await chrome.runtime.sendMessage({ + action: 'getState', + }); + + if (response && response.state) { + currentState = response.state; + updateUI(); + } + } catch (error) { + // Extension context invalidated or popup closed + break; + } + + // Poll every 500ms + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Final update + updateUI(); + downloadBtn.disabled = false; + setStatus('Download complete!', 'success'); +} + +/** + * Update UI with current state + */ +function updateUI() { + totalProductsEl.textContent = currentState.products.length; + imagesDownloadedEl.textContent = currentState.totalImagesDownloaded; + errorCountEl.textContent = currentState.errorCount; + + // Update current product display + if (currentState.currentProductIndex >= 0 && currentState.products[currentState.currentProductIndex]) { + const product = currentState.products[currentState.currentProductIndex]; + currentProductEl.textContent = product.productCode || `Product ${currentState.currentProductIndex + 1}`; + } else { + currentProductEl.textContent = '—'; + } + + // Update progress bar + if (currentState.products.length > 0) { + const progress = ((currentState.currentProductIndex + 1) / currentState.products.length) * 100; + progressBarEl.style.width = Math.max(0, Math.min(100, progress)) + '%'; + } + + // Update logs + const logEntries = currentState.logs || []; + logBoxEl.innerHTML = ''; + + logEntries.forEach(entry => { + const div = document.createElement('div'); + div.className = `log-entry ${entry.type}`; + div.textContent = entry.message; + logBoxEl.appendChild(div); + }); + + // Auto-scroll to bottom + logBoxEl.scrollTop = logBoxEl.scrollHeight; +} + +// ============================================================================ +// UI HELPERS +// ============================================================================ + +function updateLog(message, type = 'info') { + const entry = document.createElement('div'); + entry.className = `log-entry ${type}`; + + const timestamp = new Date().toLocaleTimeString(); + entry.textContent = `[${timestamp}] ${message}`; + + logBoxEl.appendChild(entry); + logBoxEl.scrollTop = logBoxEl.scrollHeight; +} + +function setStatus(message, status = 'idle') { + statusMsgEl.textContent = message; + statusMsgEl.className = `status ${status}`; +} + +function handleClearLogClick() { + logBoxEl.innerHTML = ''; + updateLog('Log cleared', 'info'); +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +async function initializePopup() { + try { + // Get initial state from background + const response = await chrome.runtime.sendMessage({ + action: 'getState', + }); + + if (response && response.state) { + currentState = response.state; + products = currentState.products; + } + + updateUI(); + + // If download is in progress, start polling + if (currentState.isRunning) { + downloadBtn.disabled = true; + setStatus('Download in progress...', 'working'); + pollForUpdates(); + } else { + // Enable download button if we have products + if (products.length > 0) { + downloadBtn.disabled = false; + } + setStatus('Ready to scan', 'idle'); + } + } catch (error) { + console.error('Initialization error:', error); + updateLog('Failed to connect to background', 'error'); + } +} + +// Initialize when popup opens +document.addEventListener('DOMContentLoaded', initializePopup); + +// Cleanup listeners on popup close +window.addEventListener('beforeunload', () => { + // Popup is closing +}); + +console.log('[Popup] Script loaded'); diff --git a/yupoo-downloader/styles.css b/yupoo-downloader/styles.css new file mode 100644 index 0000000..fbc90bc --- /dev/null +++ b/yupoo-downloader/styles.css @@ -0,0 +1,253 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #333; + min-height: 100vh; +} + +.container { + width: 500px; + padding: 20px; + background: white; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); +} + +h1 { + font-size: 20px; + margin-bottom: 20px; + color: #667eea; + text-align: center; +} + +h3 { + font-size: 13px; + margin-top: 12px; + margin-bottom: 8px; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.controls { + display: flex; + gap: 8px; + margin-bottom: 15px; +} + +.btn { + flex: 1; + padding: 10px 14px; + border: none; + border-radius: 5px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #5568d3; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-success { + background: #48bb78; + color: white; +} + +.btn-success:hover:not(:disabled) { + background: #38a169; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(72, 187, 120, 0.4); +} + +.btn-success:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-small { + padding: 6px 10px; + font-size: 11px; + background: #e2e8f0; + color: #333; + margin-top: 8px; +} + +.btn-small:hover { + background: #cbd5e0; +} + +.settings { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + background: #f7fafc; + border-radius: 5px; + margin-bottom: 15px; + font-size: 12px; +} + +.settings label { + font-weight: 600; + color: #4a5568; +} + +.settings input { + width: 50px; + padding: 5px 8px; + border: 1px solid #cbd5e0; + border-radius: 3px; + font-size: 12px; +} + +.progress-section { + background: #f7fafc; + padding: 12px; + border-radius: 5px; + margin-bottom: 12px; +} + +.stat-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + font-size: 13px; + border-bottom: 1px solid #e2e8f0; +} + +.stat-row:last-child { + border-bottom: none; +} + +.stat-row .label { + color: #718096; + font-weight: 500; +} + +.stat-row .value { + color: #667eea; + font-weight: 700; + font-size: 14px; +} + +.progress-bar-container { + width: 100%; + height: 6px; + background: #e2e8f0; + border-radius: 3px; + margin-bottom: 15px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + width: 0%; + transition: width 0.3s ease; +} + +.log-section { + margin-bottom: 12px; +} + +.log-box { + background: #1a202c; + color: #68d391; + padding: 10px; + border-radius: 5px; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 11px; + height: 150px; + overflow-y: auto; + line-height: 1.4; + border: 1px solid #2d3748; +} + +.log-entry { + margin: 2px 0; + word-break: break-word; +} + +.log-entry.info { + color: #68d391; +} + +.log-entry.success { + color: #81e6d9; +} + +.log-entry.error { + color: #fc8181; +} + +.log-entry.warning { + color: #fbd38d; +} + +.status { + padding: 10px; + border-radius: 5px; + text-align: center; + font-size: 12px; + font-weight: 600; + min-height: 20px; +} + +.status.idle { + background: #e6ffed; + color: #22543d; +} + +.status.working { + background: #fef3c7; + color: #78350f; +} + +.status.success { + background: #d1fae5; + color: #065f46; +} + +.status.error { + background: #fee2e2; + color: #7f1d1d; +} + +/* Scrollbar styling */ +.log-box::-webkit-scrollbar { + width: 6px; +} + +.log-box::-webkit-scrollbar-track { + background: #2d3748; +} + +.log-box::-webkit-scrollbar-thumb { + background: #4a5568; + border-radius: 3px; +} + +.log-box::-webkit-scrollbar-thumb:hover { + background: #718096; +}