Create chrome extention
This commit is contained in:
commit
8334ba5cc4
348
yupoo-downloader/README.md
Normal file
348
yupoo-downloader/README.md
Normal 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! 📸**
|
||||
606
yupoo-downloader/background.js
Normal file
606
yupoo-downloader/background.js
Normal 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
390
yupoo-downloader/content.js
Normal 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');
|
||||
34
yupoo-downloader/manifest.json
Normal file
34
yupoo-downloader/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
58
yupoo-downloader/popup.html
Normal file
58
yupoo-downloader/popup.html
Normal 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
367
yupoo-downloader/popup.js
Normal 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
253
yupoo-downloader/styles.css
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user