Create chrome extention

This commit is contained in:
dvirlabs 2026-04-20 00:08:24 +03:00
commit 8334ba5cc4
7 changed files with 2056 additions and 0 deletions

348
yupoo-downloader/README.md Normal file
View File

@ -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! 📸**

View File

@ -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');

390
yupoo-downloader/content.js Normal file
View File

@ -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 <a> 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');

View File

@ -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": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Download Yupoo Products"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_end"
}
]
}

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yupoo Downloader</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1>Yupoo Downloader</h1>
<div class="controls">
<button id="scanBtn" class="btn btn-primary">🔍 Scan Products</button>
<button id="downloadBtn" class="btn btn-success" disabled>⬇️ Download All</button>
</div>
<div class="settings">
<label for="concurrency">Max Concurrent Downloads:</label>
<input type="number" id="concurrency" min="1" max="10" value="2">
<button id="debugBtn" class="btn btn-small" style="margin-left: auto;">🔧 Debug</button>
</div>
<div class="progress-section">
<div class="stat-row">
<span class="label">Products Found:</span>
<span class="value" id="totalProducts">0</span>
</div>
<div class="stat-row">
<span class="label">Processing:</span>
<span class="value" id="currentProduct"></span>
</div>
<div class="stat-row">
<span class="label">Images Downloaded:</span>
<span class="value" id="imagesDownloaded">0</span>
</div>
<div class="stat-row">
<span class="label">Errors:</span>
<span class="value" id="errorCount">0</span>
</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="log-section">
<h3>Activity Log</h3>
<div class="log-box" id="logBox"></div>
<button id="clearLogBtn" class="btn btn-small">Clear Log</button>
</div>
<div class="status" id="statusMsg"></div>
</div>
<script src="popup.js"></script>
</body>
</html>

367
yupoo-downloader/popup.js Normal file
View File

@ -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');

253
yupoo-downloader/styles.css Normal file
View File

@ -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;
}