Add ModelMayhem, Wix, Portfolio handlers; fix Reddit video extraction; update dependencies

New Features:
- Add ModelMayhem handler with username-to-ID resolution and worksafe mode handling
- Add Wix handler for Wix-hosted websites
- Add Portfolio handler for generic portfolio sites
- Add persistent settings utility for saving node preferences

Reddit Improvements:
- Fix API credential loading in prefers_api() method
- Add external video host resolution (RedGifs, Gfycat, Imgur) using yt-dlp
- Add v.redd.it video URL resolution

Gallery-dl Config:
- Add RedGifs, Gfycat, Imgur format preferences
- Enable Reddit video downloads with comments=0, videos=true

Dependencies:
- Add asyncpraw for Reddit API
- Add atproto for Bluesky
- Add instaloader for Instagram
- Add aiohttp for async HTTP
- Add Pillow explicitly

Other:
- Update .gitignore to exclude node_settings.json
- Update example configs with new settings
This commit is contained in:
Eric Rollei
2026-01-17 19:08:01 -08:00
parent d20664d081
commit 033f73d66c
13 changed files with 2637 additions and 80 deletions
+1
View File
@@ -118,6 +118,7 @@ configs/auth_config.json
configs/gallery-dl.conf
configs/gallery-dl-*.conf
configs/web_scraper/auth_config.json
configs/node_settings.json
*.secret
*.key
+31 -7
View File
@@ -6,9 +6,12 @@ ComfyUI custom nodes for downloading media from 1000+ websites including Instagr
- **Gallery-dl Node** - Download images and videos from 100+ websites
- Instagram, Reddit, Twitter/X, DeviantArt, Pixiv, and more
- Supports authentication via browser cookies
- **Resolution filtering** - Skip small images (default: 768px minimum)
- Supports authentication via config file or browser cookies
- Automatic file organization
- Download archive to avoid duplicates
- **Persistent config paths** - Paths are remembered between sessions
- **Configurable timeout** - Up to 1 hour for large downloads
- **Yt-dlp Node** - Download videos and audio from 1000+ platforms
- YouTube, TikTok, Vimeo, Twitch, and more
@@ -55,9 +58,11 @@ ComfyUI custom nodes for downloading media from 1000+ websites including Instagr
1. Add "Gallery-dl Downloader" node to your workflow
2. Enter a URL (e.g., Instagram profile, Reddit post)
3. Configure options:
- Enable `use_browser_cookies` for private content
- Set `config_path` to `./configs/gallery-dl.conf` (auto-saved for next time)
- Enable `filter_by_resolution` to skip small images (768px default)
- Enable `organize_files` to sort by type
- Enable `use_download_archive` to avoid duplicates
- Increase `download_timeout` for large galleries (default: 600s)
4. Execute!
**Supported Sites:** Instagram, Reddit, Twitter, DeviantArt, Pixiv, Tumblr, Pinterest, Flickr, and 90+ more. See [gallery-dl supported sites](https://github.com/mikf/gallery-dl/blob/master/docs/supportedsites.md).
@@ -82,9 +87,15 @@ ComfyUI custom nodes for downloading media from 1000+ websites including Instagr
| Setting | Value | Notes |
|---------|-------|-------|
| `config_path` | `./configs/gallery-dl.conf` | Contains your Instagram cookies |
| `config_path` | `./configs/gallery-dl.conf` | Contains all site credentials (auto-saved) |
| `cookie_file` | *(leave empty)* | Cookies are in config file |
| `use_browser_cookies` | ❌ False | Browser cookies require admin access |
| `use_browser_cookies` | ❌ False | Config file is more reliable |
| `filter_by_resolution` | ✅ True | Skip thumbnails and small images |
| `min_image_width/height` | 768 | Minimum resolution in pixels |
### One Config For All Sites
The `gallery-dl.conf` file contains credentials for **all supported sites** (Instagram, Reddit, 500px, DeviantArt, Pinterest, Flickr, Bluesky). Gallery-dl automatically uses the right credentials based on the URL you're downloading from.
### Why Not Browser Cookies?
@@ -138,13 +149,15 @@ Many sites require authentication for private content:
Config files are stored in `download-tools/configs/`:
- `gallery-dl.conf` - Gallery-dl settings
- `gallery-dl-browser-cookies.conf` - Browser cookie configuration
- `gallery-dl.conf` - **Main config** with credentials for all sites (Instagram, Reddit, 500px, DeviantArt, Pinterest, Flickr, Bluesky)
- `gallery-dl.conf.example` - Template for creating your own config
- `yt-dlp.conf` - Yt-dlp default settings
- `yt-dlp-audio.conf` - Audio extraction preset
- `yt-dlp-hq.conf` - High quality preset
You can create custom config files and reference them in the nodes.
### Persistent Paths
Config file paths are **automatically saved** between ComfyUI sessions. Once you set `config_path` or `cookie_file`, they'll be remembered for next time.
## 📚 Documentation
@@ -173,6 +186,17 @@ Authentication required:
- Or enable `use_browser_cookies` with Firefox
- 401 Unauthorized = expired cookies, export fresh ones
### "Downloads timing out"
For large galleries:
- Increase `download_timeout` (default: 600s, max: 3600s = 1 hour)
- Large Instagram profiles may need the full hour
### "Too many small images"
Enable resolution filtering:
- Set `filter_by_resolution` to True
- Adjust `min_image_width` and `min_image_height` (default: 768px)
- Videos are never filtered, only images
### "CUDA out of memory" / "FFmpeg not found"
For audio extraction:
- Install FFmpeg separately
+10
View File
@@ -38,6 +38,16 @@
"domain": "example.com",
"username": "YOUR_USERNAME",
"password": "YOUR_PASSWORD"
},
"modelmayhem.com": {
"auth_type": "form",
"domain": "modelmayhem.com",
"username": "YOUR_EMAIL",
"password": "YOUR_PASSWORD",
"timeout": 30000,
"scroll_delay_ms": 1500,
"max_scroll_count": 15,
"note": "Login credentials for ModelMayhem portfolio access"
}
}
}
+15 -1
View File
@@ -9,7 +9,21 @@
"client-secret": "YOUR_REDDIT_CLIENT_SECRET",
"user-agent": "MediaTool (by /u/YOUR_USERNAME)",
"username": "YOUR_REDDIT_USERNAME",
"password": "YOUR_REDDIT_PASSWORD"
"password": "YOUR_REDDIT_PASSWORD",
"comments": 0,
"videos": true
},
"redgifs": {
"format": ["hd", "sd", "gif"]
},
"gfycat": {
"format": ["mp4", "webm", "gif"]
},
"imgur": {
"mp4": true
},
"500px": {
+181 -40
View File
@@ -53,6 +53,26 @@ import folder_paths
from pathlib import Path
from typing import Any, Dict, List, Tuple
# Import persistent settings manager
try:
from ..utils.persistent_settings import get_persistent_setting, set_persistent_setting
PERSISTENT_SETTINGS_AVAILABLE = True
except ImportError:
try:
import sys
utils_dir = os.path.dirname(os.path.dirname(__file__))
if utils_dir not in sys.path:
sys.path.insert(0, utils_dir)
from utils.persistent_settings import get_persistent_setting, set_persistent_setting
PERSISTENT_SETTINGS_AVAILABLE = True
except ImportError:
PERSISTENT_SETTINGS_AVAILABLE = False
def get_persistent_setting(node_type, key, default=""):
return default
def set_persistent_setting(node_type, key, value):
return False
print("Warning: persistent_settings not available. Config paths will not persist.")
class GalleryDLDownloader:
def __init__(
@@ -72,6 +92,12 @@ class GalleryDLDownloader:
# New advanced options
instagram_include: str = "posts",
extra_options: str = "",
# Minimum resolution filtering
min_image_width: int = 768,
min_image_height: int = 768,
filter_by_resolution: bool = True,
# Timeout settings
download_timeout: int = 600,
):
self.url_list = url_list or []
self.url_file = url_file
@@ -87,6 +113,10 @@ class GalleryDLDownloader:
self.organize_files = organize_files
self.instagram_include = instagram_include
self.extra_options = extra_options
self.min_image_width = min_image_width
self.min_image_height = min_image_height
self.filter_by_resolution = filter_by_resolution
self.download_timeout = download_timeout
# Initialize debug info
self.debug_info = [] # Store debug information for status reporting
@@ -106,11 +136,6 @@ class GalleryDLDownloader:
command += ["-d", self.output_dir]
# Use config file if provided
if self.config_path:
command += ["--config", self.config_path]
self.debug_info.append(f"📄 Using config file: {self.config_path}")
# Detect target sites for better authentication handling
target_sites = set()
for url in urls:
@@ -120,7 +145,20 @@ class GalleryDLDownloader:
target_sites.add('reddit')
elif 'twitter.com' in url or 'x.com' in url:
target_sites.add('twitter')
# Add more site detection as needed
elif 'flickr.com' in url:
target_sites.add('flickr')
elif 'deviantart.com' in url:
target_sites.add('deviantart')
elif '500px.com' in url:
target_sites.add('500px')
elif 'pinterest' in url:
target_sites.add('pinterest')
elif 'bsky.app' in url or 'bluesky' in url:
target_sites.add('bluesky')
elif 'tumblr.com' in url:
target_sites.add('tumblr')
elif 'artstation.com' in url:
target_sites.add('artstation')
if target_sites:
self.debug_info.append(f"🎯 Detected target sites: {', '.join(target_sites)}")
@@ -130,23 +168,30 @@ class GalleryDLDownloader:
self.debug_info.append("⚠️ WARNING: Reddit may hang or require updated API credentials")
self.debug_info.append(" Consider testing with Instagram or other sites first")
# Handle cookie file (takes precedence over browser cookies)
if self.cookie_file:
# Warn if using cookies for non-matching sites (but allow Instagram + config combo)
if target_sites and 'instagram' not in target_sites:
self.debug_info.append(f"⚠️ Warning: Using Instagram cookies for {', '.join(target_sites)} - this may cause authentication conflicts")
elif target_sites and 'instagram' in target_sites:
self.debug_info.append("🔗 Using Instagram cookies - optimal setup!")
# Use config file if provided (contains site-specific credentials)
if self.config_path:
command += ["--config", self.config_path]
self.debug_info.append(f"📄 Using config file: {self.config_path}")
# Handle authentication: Browser cookies toggle vs cookie file
# When use_browser_cookies is TRUE: use browser cookies (even if cookie file exists)
# When use_browser_cookies is FALSE: use cookie file if provided
if self.use_browser_cookies:
# User explicitly wants browser cookies
command += ["--cookies-from-browser", self.browser_name]
self.debug_info.append(f"🍪 Using cookies from {self.browser_name} browser (toggle enabled)")
if self.cookie_file:
self.debug_info.append(f" ℹ️ Note: Cookie file '{self.cookie_file}' ignored - browser cookies selected")
# Test if browser cookies can be accessed
self._test_browser_cookie_access()
elif self.cookie_file:
# Use cookie file when browser cookies toggle is off
converted_cookie_file = self._convert_cookie_file()
if converted_cookie_file:
command += ["--cookies", converted_cookie_file]
self.debug_info.append(f"🍪 Using cookie file: {converted_cookie_file}")
elif self.use_browser_cookies:
command += ["--cookies-from-browser", self.browser_name]
self.debug_info.append(f"🍪 Extracting cookies from {self.browser_name} browser")
# Test if browser cookies can be accessed
self._test_browser_cookie_access()
if 'instagram' in target_sites:
self.debug_info.append("🔗 Instagram + cookie file - optimal setup!")
if self.use_download_archive:
command += ["--download-archive", self.archive_file]
@@ -159,6 +204,39 @@ class GalleryDLDownloader:
]
self.debug_info.append("🎬 Video files will be skipped")
# Add minimum resolution filter for images if enabled
if self.filter_by_resolution and (self.min_image_width > 0 or self.min_image_height > 0):
# gallery-dl filter expression for minimum resolution
# IMPORTANT: Not all extractors provide width/height metadata (e.g., Bunkr, Cyberdrop)
# gallery-dl uses eval() with metadata as locals(), so we can use locals().get()
# If width/height don't exist, we default to 99999 so the file passes the filter
# (download files with unknown dimensions rather than skip them)
filter_expr = []
if self.min_image_width > 0:
# If width is not available in metadata, default to 99999 (pass filter)
filter_expr.append(f"locals().get('width', 99999) >= {self.min_image_width}")
if self.min_image_height > 0:
# If height is not available in metadata, default to 99999 (pass filter)
filter_expr.append(f"locals().get('height', 99999) >= {self.min_image_height}")
# Combine with video filter if needed, or apply image filter
# Note: gallery-dl only allows one --filter, so we need to combine
if self.skip_videos:
# Already added video filter above, need to modify it
# Remove the last filter we added and combine
command = [c for c in command if c != "--filter"]
command = [c for c in command if "extension not in" not in c]
combined_filter = f"(extension not in ('mp4', 'webm', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v')) and ({' and '.join(filter_expr)})"
command += ["--filter", combined_filter]
else:
# Only filter images by resolution (videos pass through)
# Apply filter only to images, let videos through
# Also pass through if width/height are not available (unknown dimensions)
filter_condition = f"(extension in ('mp4', 'webm', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v')) or ({' and '.join(filter_expr)})"
command += ["--filter", filter_condition]
self.debug_info.append(f"📐 Minimum resolution filter: {self.min_image_width}x{self.min_image_height}px (files with unknown dimensions will be downloaded)")
# Add rate limiting to be respectful to servers (use correct format)
command += ["--sleep", "1.0"]
@@ -440,15 +518,15 @@ class GalleryDLDownloader:
universal_newlines=True,
cwd=self.output_dir, # Set working directory
)
stdout, stderr = process.communicate(timeout=300) # 5 minute timeout
stdout, stderr = process.communicate(timeout=self.download_timeout) # Use configurable timeout
process_returncode = process.returncode
except subprocess.TimeoutExpired:
process.kill()
process_success = False
process_returncode = -1
stderr = "Download process timed out after 5 minutes"
stderr = f"Download process timed out after {self.download_timeout // 60} minutes"
stdout = ""
self.debug_info.append("⏰ Download timed out, but continuing with file organization...")
self.debug_info.append(f"⏰ Download timed out after {self.download_timeout}s, but continuing with file organization...")
except Exception as e:
process_success = False
process_returncode = -1
@@ -671,65 +749,108 @@ class GalleryDLNode:
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
# Load saved config paths from persistent settings
saved_config_path = get_persistent_setting("gallery_dl", "config_path", "")
saved_cookie_file = get_persistent_setting("gallery_dl", "cookie_file", "")
return {
"required": {
"url_list": ("STRING", {
"multiline": True,
"default": "# Enter URLs here, one per line\n# Example:\n# https://imgur.com/gallery/example\n# https://example.com/image.jpg"
"default": "# Enter URLs here, one per line\n# Supports 100+ sites: Instagram, Reddit, Twitter/X, DeviantArt, Pixiv, 500px, Flickr, Pinterest, Bluesky, and more\n# Examples:\n# https://www.instagram.com/username/\n# https://www.reddit.com/r/subreddit/\n# https://twitter.com/username",
"tooltip": "Enter URLs to download from, one per line. Supports Instagram, Reddit, Twitter/X, DeviantArt, Pixiv, 500px, Flickr, Pinterest, Bluesky, and 90+ other sites. Lines starting with # are ignored."
}),
"output_dir": ("STRING", {
"default": "./gallery-dl-output",
"tooltip": "Directory where downloaded files will be saved"
"tooltip": "Directory where downloaded files will be saved. Relative paths are relative to ComfyUI's output folder. Files are organized into subfolders by site and user."
})
},
"optional": {
"url_file": ("STRING", {
"default": "",
"tooltip": "Path to a text file containing URLs (one per line)"
"tooltip": "Optional: Path to a text file containing URLs (one per line). Useful for batch downloading from a prepared list. Leave empty to use url_list above."
}),
"config_path": ("STRING", {
"default": "",
"tooltip": "Path to gallery-dl config file. For Instagram: use './configs/gallery-dl.conf' (has cookies). For Reddit: use './configs/gallery-dl-no-reddit.conf' or add API credentials to gallery-dl.conf."
"default": saved_config_path,
"tooltip": "Path to gallery-dl.conf config file. This single file contains credentials for ALL sites (Instagram, Reddit, 500px, etc.) - gallery-dl auto-selects the right ones based on URL. Path is auto-saved for next time. Recommended: ./configs/gallery-dl.conf"
}),
"cookie_file": ("STRING", {
"default": "",
"tooltip": "Path to exported cookie JSON file. For Instagram: use './configs/instagram_cookies.json'. For Reddit: LEAVE EMPTY (use browser cookies instead)."
"default": saved_cookie_file,
"tooltip": "Path to exported cookie file (Netscape/JSON format). Only used when 'Use Browser Cookies' is OFF and you need site-specific cookies not in config. Usually leave empty - config file credentials work better. Path is auto-saved."
}),
"use_browser_cookies": ("BOOLEAN", {
"default": True,
"tooltip": "Use browser cookies for authentication (ignored if cookie file is provided)"
"default": False,
"label_on": "Browser Cookies",
"label_off": "Config/Cookie File",
"tooltip": "OFF (recommended): Use credentials from config file - most reliable. ON: Extract cookies directly from browser (Firefox works best; Chrome/Edge require admin rights and browser closed)."
}),
"browser_name": (["firefox", "chrome", "chromium", "edge", "safari", "opera"], {
"default": "firefox",
"tooltip": "Browser to extract cookies from. Firefox works without admin. Chrome/Edge require admin AND browser closed. Recommended: use config file with cookies instead."
"tooltip": "Browser to extract cookies from when 'Use Browser Cookies' is ON. Firefox recommended - works without admin rights. Chrome/Edge need admin + browser closed."
}),
"use_download_archive": ("BOOLEAN", {
"default": True,
"tooltip": "Use archive file to skip already downloaded content"
"label_on": "Skip Downloaded",
"label_off": "Re-download All",
"tooltip": "ON (recommended): Track downloaded files in SQLite database to skip duplicates on future runs. OFF: Re-download everything, may create duplicates."
}),
"archive_file": ("STRING", {
"default": "./gallery-dl-archive.sqlite3",
"tooltip": "Path to the download archive database"
"tooltip": "Path to download archive database (SQLite). Tracks what's been downloaded to avoid duplicates. Delete this file to force re-downloading everything."
}),
"skip_videos": ("BOOLEAN", {
"default": False,
"tooltip": "Skip video files, download only images"
"label_on": "Images Only",
"label_off": "Images + Videos",
"tooltip": "ON: Download only images, skip all video files. OFF: Download both images and videos. Useful when you only want still images from a mixed gallery."
}),
"filter_by_resolution": ("BOOLEAN", {
"default": True,
"label_on": "Filter Small Images",
"label_off": "Keep All Sizes",
"tooltip": "ON: Skip images smaller than min_image_width/height (removes thumbnails, icons, low-res images). Videos are never filtered. Sites without dimension metadata (Bunkr, Cyberdrop) will download all images. OFF: Download all images regardless of size."
}),
"min_image_width": ("INT", {
"default": 768,
"min": 0,
"max": 8192,
"step": 64,
"tooltip": "Minimum image width in pixels when filter_by_resolution is ON. Images narrower than this are skipped. Set to 0 to disable width filtering. Note: Sites that don't provide dimension metadata will download all images."
}),
"min_image_height": ("INT", {
"default": 768,
"min": 0,
"max": 8192,
"step": 64,
"tooltip": "Minimum image height in pixels when filter_by_resolution is ON. Images shorter than this are skipped. Set to 0 to disable height filtering. Note: Sites that don't provide dimension metadata will download all images."
}),
"extract_metadata": ("BOOLEAN", {
"default": True,
"tooltip": "Save download metadata to JSON file"
"label_on": "Save Metadata",
"label_off": "No Metadata",
"tooltip": "ON: Save download metadata to JSON file (URLs, timestamps, file counts, errors). Useful for tracking what was downloaded. OFF: No metadata file created."
}),
"organize_files": ("BOOLEAN", {
"default": True,
"tooltip": "Sort downloaded files into subfolders (images/, videos/, audio/, other/)"
"label_on": "Sort by Type",
"label_off": "All in One Folder",
"tooltip": "ON: Sort downloaded files into subfolders by type (images/, videos/, audio/, other/). OFF: Put all files in the main output folder without sorting."
}),
"instagram_include": (["posts", "stories", "highlights", "reels", "tagged", "info", "avatar", "all", "posts,stories", "posts,reels", "stories,highlights"], {
"default": "posts",
"tooltip": "For Instagram profiles: What to include (posts, stories, highlights, reels, tagged, info, avatar, or all)"
"tooltip": "Instagram only: What content to download. 'posts' = feed posts, 'stories' = current stories (24hr), 'highlights' = saved story highlights, 'reels' = reels videos, 'all' = everything. Combine with comma: 'posts,reels'."
}),
"download_timeout": ("INT", {
"default": 600,
"min": 60,
"max": 3600,
"step": 60,
"tooltip": "Maximum time in seconds to wait for downloads to complete. Default 600s (10 min). Increase for large galleries - Instagram profiles with 1000+ posts may need 1800-3600s (30-60 min)."
}),
"extra_options": ("STRING", {
"default": "",
"tooltip": "Additional options for gallery-dl (e.g., '--no-skip-download')"
"multiline": True,
"tooltip": "Advanced gallery-dl options (one per line or space-separated):\n• --limit N: Max N files to download\n• --range 1-50: Download items 1-50 only\n• --no-download: Simulate without downloading\n• --write-metadata: Save per-file JSON metadata\n• --ugoira-conv: Convert Pixiv ugoira to video\n• -v: Verbose output for debugging\n• --sleep N: Wait N seconds between requests\n• --retries N: Retry failed downloads N times\nSee gallery-dl docs: https://github.com/mikf/gallery-dl/blob/master/docs/options.md"
})
}
}
@@ -739,7 +860,7 @@ class GalleryDLNode:
FUNCTION = "execute"
CATEGORY = "Downloaders"
DESCRIPTION = "Download images and media from various websites using gallery-dl"
DESCRIPTION = "Download images and media from 100+ websites using gallery-dl. Supports Instagram, Reddit, Twitter/X, DeviantArt, Pixiv, 500px, Flickr, Pinterest, Bluesky, and more. Features: resolution filtering, duplicate detection, automatic file organization, and persistent config paths."
def execute(
self,
@@ -753,10 +874,14 @@ class GalleryDLNode:
use_download_archive: bool = True,
archive_file: str = "./gallery-dl-archive.sqlite3",
skip_videos: bool = False,
filter_by_resolution: bool = True,
min_image_width: int = 768,
min_image_height: int = 768,
extract_metadata: bool = True,
organize_files: bool = True,
# New advanced options
instagram_include: str = "posts",
download_timeout: int = 600,
extra_options: str = "",
) -> Tuple[str, str, int, bool]:
"""
@@ -766,6 +891,10 @@ class GalleryDLNode:
tuple: (output_dir, summary, download_count, success)
"""
try:
# Save original config values for persistence before cleaning
original_config_path = config_path.strip() if config_path else ""
original_cookie_file = cookie_file.strip() if cookie_file else ""
# Clean up input parameters
if url_file and url_file.strip() == "":
url_file = None
@@ -775,6 +904,14 @@ class GalleryDLNode:
cookie_file = None
if archive_file and archive_file.strip() == "":
archive_file = "./gallery-dl-archive.sqlite3"
# Save config_path and cookie_file for future use if they were provided
if original_config_path:
set_persistent_setting("gallery_dl", "config_path", original_config_path)
print(f"Saved config_path for future use: {original_config_path}")
if original_cookie_file:
set_persistent_setting("gallery_dl", "cookie_file", original_cookie_file)
print(f"Saved cookie_file for future use: {original_cookie_file}")
# Sanitize output directory
base_output_dir = os.path.abspath(folder_paths.get_output_directory())
@@ -829,9 +966,13 @@ class GalleryDLNode:
use_download_archive=use_download_archive,
archive_file=archive_file,
skip_videos=skip_videos,
filter_by_resolution=filter_by_resolution,
min_image_width=min_image_width,
min_image_height=min_image_height,
extract_metadata=extract_metadata,
organize_files=organize_files,
instagram_include=instagram_include,
download_timeout=download_timeout,
extra_options=extra_options,
)
+83 -6
View File
@@ -97,6 +97,27 @@ except ImportError:
import pathlib
# Import persistent settings manager
try:
from ..utils.persistent_settings import get_persistent_setting, set_persistent_setting
PERSISTENT_SETTINGS_AVAILABLE = True
except ImportError:
try:
import sys
utils_dir = os.path.dirname(os.path.dirname(__file__))
if utils_dir not in sys.path:
sys.path.insert(0, utils_dir)
from utils.persistent_settings import get_persistent_setting, set_persistent_setting
PERSISTENT_SETTINGS_AVAILABLE = True
except ImportError:
PERSISTENT_SETTINGS_AVAILABLE = False
def get_persistent_setting(node_type, key, default=""):
return default
def set_persistent_setting(node_type, key, value):
return False
print("Warning: persistent_settings not available. Config paths will not persist.")
# Direct Playwright support
try:
import playwright
@@ -292,8 +313,15 @@ def load_site_handlers():
elif handler_class.__name__ == "YouTubeHandler":
return 5
# Give site-specific handlers higher priority
elif handler_class.__name__ in ["RedditHandler", "BskyHandler", "FlickrHandler", "ArtsyHandler"]:
elif handler_class.__name__ in ["RedditHandler", "BskyHandler", "FlickrHandler", "ArtsyHandler", "ModelMayhemHandler"]:
return 10
# Give WixHandler high priority (Wix-powered sites need special handling)
elif handler_class.__name__ == "WixHandler":
return 15
# Give PortfolioHandler medium-high priority (handles generic portfolio sites)
# Note: PortfolioHandler must be lower priority than site-specific handlers like ModelMayhemHandler
elif handler_class.__name__ == "PortfolioHandler":
return 50
# Default priority
return 100
@@ -326,6 +354,9 @@ class EricWebFileScraper:
def INPUT_TYPES(cls):
# Define common hash algorithms based on availability
hash_algos = ["average_hash", "phash", "dhash", "whash"] if IMAGEHASH_AVAILABLE else ["none"]
# Load saved auth_config_path from persistent settings
saved_auth_config = get_persistent_setting("web_scraper", "auth_config_path", "")
return {
"required": {
@@ -359,7 +390,7 @@ class EricWebFileScraper:
"crawl_subfolders": ("BOOLEAN", {"default": True, "label_on": "Subfolder Per Page", "label_off": "Single Folder"}),
"skip_first_page_download": ("BOOLEAN", {"default": False, "label_on": "Skip Listing Page", "label_off": "Download All Pages"}),
"link_include_pattern": ("STRING", {"default": "", "multiline": False, "placeholder": "Regex to match links (e.g. /[A-Z][a-z]+_)"}),
"link_exclude_pattern": ("STRING", {"default": "/production|/men|/women|/direct|/newface|/contacts|/about|/policy|/store|international", "multiline": False, "placeholder": "Regex to exclude links"}),
"link_exclude_pattern": ("STRING", {"default": "", "multiline": False, "placeholder": "Regex: /contact|/news|/about"}),
# --- Media Types ---
"download_audio": ("BOOLEAN", {"default": False, "label_on": "Extract Audio", "label_off": "Skip Audio"}),
@@ -382,7 +413,7 @@ class EricWebFileScraper:
"scroll_delay_ms": ("INT", {"default": 1000, "min": 50, "max": 5000, "step": 50, "display": "Scroll Delay (ms)"}),
# --- Interactions & Auth ---
"interaction_sequence": ("STRING", {"multiline": True, "placeholder": '[{"type": "click", "selector": "#button"}, ...]', "display": "Interaction Sequence (JSON)"}),
"auth_config_path": ("STRING", {"default": "", "placeholder": "Path to auth_config.json"}),
"auth_config_path": ("STRING", {"default": saved_auth_config, "placeholder": "Path to auth_config.json (saved for future use)"}),
"save_cookies": ("BOOLEAN", {"default": False, "label_on": "Save Cookies", "label_off": "Don't Save Cookies"}),
# --- Screenshots & Debug ---
"take_screenshot": ("BOOLEAN", {"default": False, "label_on": "Take Screenshot", "label_off": "No Screenshot"}),
@@ -987,6 +1018,11 @@ class EricWebFileScraper:
return ("", "", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, json.dumps(stats))
self.auth_config = self.load_auth_config(auth_config_path)
# Save auth_config_path for future use if it was provided
if auth_config_path and auth_config_path.strip():
set_persistent_setting("web_scraper", "auth_config_path", auth_config_path)
print(f"Saved auth_config_path for future use: {auth_config_path}")
handler_instance = self._get_handler_for_url(url)
if handler_instance:
@@ -2315,7 +2351,8 @@ class EricWebFileScraper:
cdn_domains = [
'tildacdn.com', 'cloudfront.net', 'cloudflare.com',
'akamaihd.net', 'fastly.net', 'imgix.net', 'twimg.com',
'cdninstagram.com', 'googleapis.com', 'gstatic.com'
'cdninstagram.com', 'googleapis.com', 'gstatic.com',
'photos.modelmayhem.com', 'assets.modelmayhem.com' # ModelMayhem CDN domains
]
is_trusted_cdn = any(cdn in item_domain.lower() for cdn in cdn_domains)
if is_trusted_cdn:
@@ -2591,12 +2628,51 @@ class EricWebFileScraper:
if is_video and not download_videos: reason = "Video download disabled"
return None, None, None, None, None, f"Skipped ({reason}): {url}"
# Get item dict for additional metadata (page_url, qualities, etc.)
item = url_or_dict if isinstance(url_or_dict, dict) else {}
try:
print(f" Downloading {file_type}: {url}")
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}
# Add Referer header for Wix videos (required to avoid 403)
page_url = item.get('page_url', '')
if 'wixstatic.com' in url:
if page_url:
headers['Referer'] = page_url
headers['Origin'] = '/'.join(page_url.split('/')[:3]) # Extract origin
else:
# Try to construct a reasonable referer from the URL
headers['Referer'] = 'https://www.wix.com/'
response = requests.get(url, stream=True, timeout=30, headers=headers)
if response.status_code != 200:
# Handle Wix video 403 errors with quality fallback
if response.status_code == 403 and 'video.wixstatic.com' in url:
qualities = item.get('qualities', {})
original_url = item.get('original_url', '')
# Try fallback qualities: 720p, 480p, then original
fallback_urls = []
if qualities:
for q in ['720p', '480p', '360p']:
if q in qualities and qualities[q] != url:
fallback_urls.append((q, qualities[q]))
if original_url and original_url != url:
fallback_urls.append(('original', original_url))
for quality, fallback_url in fallback_urls:
print(f" Trying {quality} fallback: {fallback_url}")
response = requests.get(fallback_url, stream=True, timeout=30, headers=headers)
if response.status_code == 200:
url = fallback_url # Update URL for filename generation
print(f" Success with {quality} quality")
break
else:
# All fallbacks failed
print(f"HTTP error {response.status_code} for {url} (all quality fallbacks failed)")
return None, None, None, None, None, f"HTTP error {response.status_code}"
elif response.status_code != 200:
print(f"HTTP error {response.status_code} for {url}")
return None, None, None, None, None, f"HTTP error {response.status_code}"
@@ -3857,7 +3933,8 @@ class EricWebFileScraper:
# Fallback: check common CDN domains directly (handles items from DevTools cache)
if not is_trusted_cdn:
cdn_domains = ['tildacdn.com', 'cloudfront.net', 'cloudflare.com', 'akamaized.net',
'fastly.net', 'cdn.', 'assets.', 'static.', 'media.', 'images.']
'fastly.net', 'cdn.', 'assets.', 'static.', 'media.', 'images.',
'photos.modelmayhem.com', 'assets.modelmayhem.com'] # ModelMayhem CDN
is_trusted_cdn = any(cdn in item_domain.lower() for cdn in cdn_domains)
if is_trusted_cdn:
with global_lock:
+42 -14
View File
@@ -1,8 +1,9 @@
# Download Tools - Requirements
# ComfyUI custom nodes for media downloading
# Author: Eric Hiss (GitHub: EricRollei)
# ============================================================================
# DOWNLOADER TOOLS (Required)
# DOWNLOADER TOOLS (Required for gallery-dl and yt-dlp nodes)
# ============================================================================
# Gallery-dl for downloading from various websites
@@ -11,25 +12,44 @@ gallery-dl>=1.26.0
# Yt-dlp for video/audio downloading
yt-dlp>=2023.12.0
# Browser cookie support (for authentication)
# Browser cookie support (for authentication with gallery-dl/yt-dlp)
browser-cookie3>=0.19.0
# ============================================================================
# WEB SCRAPER (Required for web_image_scraper node)
# WEB SCRAPER - Core Dependencies (Required for web_image_scraper node)
# ============================================================================
# Playwright for browser automation
# Playwright for browser automation (run 'playwright install' after pip install)
playwright>=1.40.0
# Scrapling for enhanced web scraping
# Scrapling for enhanced web scraping with anti-bot detection
scrapling>=0.2.0
# Image hashing for deduplication
imagehash>=4.3.0
# PIL/Pillow for image processing
Pillow>=10.0.0
# Async event loop nesting for ComfyUI compatibility
nest-asyncio>=1.5.0
# ============================================================================
# WEB SCRAPER - Site-Specific API Libraries (Optional but recommended)
# ============================================================================
# AsyncPRAW for Reddit API access
asyncpraw>=7.7.0
# AT Protocol SDK for Bluesky
atproto>=0.0.40
# Instaloader for Instagram (optional, provides additional features)
instaloader>=4.10.0
# aiohttp for async HTTP requests (used by some handlers)
aiohttp>=3.9.0
# ============================================================================
# CORE UTILITIES (Required)
# ============================================================================
@@ -46,25 +66,33 @@ colorama>=0.4.6
jsonschema>=4.19.0
# ============================================================================
# NOTES
# INSTALLATION NOTES
# ============================================================================
# 1. FFmpeg is required for yt-dlp audio extraction
# 1. FFmpeg is required for yt-dlp audio extraction and video processing
# Download from: https://ffmpeg.org/download.html
# Or install via package manager:
# - Windows: choco install ffmpeg
# - macOS: brew install ffmpeg
# - Linux: apt-get install ffmpeg
# 2. For gallery-dl authentication with private content:
# - Use browser cookies (automatic)
# 2. After installing playwright, run: playwright install
# This downloads the browser binaries needed for web scraping.
# 3. For gallery-dl authentication with private content:
# - Use browser cookies (automatic via browser-cookie3)
# - Or export cookies to configs/cookies.json
# 3. For web_image_scraper node:
# - After installing playwright, run: playwright install
# - Configure authentication in configs/web_scraper/auth_config.json
# - See configs/web_scraper/README.md for auth setup guide
# 4. For web_image_scraper node:
# - Configure authentication in configs/auth_config.json
# - See CONFIG_SETUP.md for auth setup guide
# 4. These tools are installed system-wide and can be used from command line:
# 5. Site-specific API setup:
# - Reddit: Create app at https://www.reddit.com/prefs/apps
# Add client_id, client_secret to auth_config.json
# - Bluesky: Add username/password to auth_config.json
# - Instagram: Add cookies or use browser session
# 6. These tools can also be used from command line:
# - gallery-dl <url>
# - yt-dlp <url>
+879
View File
@@ -0,0 +1,879 @@
"""
ModelMayhem Handler
Description: Handler for ModelMayhem.com portfolio/photo galleries
Author: Eric Hiss (GitHub: EricRollei)
Contact: eric@historic.camera, eric@rollei.us
License: Dual License (Non-Commercial and Commercial Use)
Copyright (c) 2025 Eric Hiss. All rights reserved.
Dual License:
1. Non-Commercial Use: This software is licensed under the terms of the
Creative Commons Attribution-NonCommercial 4.0 International License.
To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/4.0/
2. Commercial Use: For commercial use, a separate license is required.
Please contact Eric Hiss at eric@historic.camera or eric@rollei.us for licensing options.
Dependencies:
This code depends on several third-party libraries, each with its own license.
See CREDITS.md for a comprehensive list of dependencies and their licenses.
Third-party code:
- See CREDITS.md for complete list of dependencies
"""
"""
ModelMayhem-specific handler for the Web Image Scraper
Extracts full-resolution images from ModelMayhem portfolios
URL patterns:
- Profile: https://www.modelmayhem.com/4554791
- Portfolio (all): https://www.modelmayhem.com/portfolio/4554791/viewall
- Single photo: https://www.modelmayhem.com/portfolio/pic/48745727
- Photo CDN: https://photos.modelmayhem.com/photos/251111/17/6913e94c8f27f.jpg
"""
from site_handlers.base_handler import BaseSiteHandler
from urllib.parse import urlparse, urljoin
from typing import List, Dict, Any, Optional
import re
import json
import time
import asyncio
import traceback
# Playwright (async) – load only if available
try:
from playwright.async_api import Page as AsyncPage
from playwright.async_api import Browser, BrowserContext
PLAYWRIGHT_AVAILABLE = True
except ImportError:
AsyncPage = None
Browser = None
BrowserContext = None
PLAYWRIGHT_AVAILABLE = False
class ModelMayhemHandler(BaseSiteHandler):
"""Handler for ModelMayhem.com portfolio sites"""
# Class attributes for configuration
PRIORITY = 40 # Higher priority for this specific handler
# Domains associated with ModelMayhem
DOMAINS = [
"modelmayhem.com",
"www.modelmayhem.com",
"m.modelmayhem.com",
"secure.modelmayhem.com",
"m.secure.modelmayhem.com",
"photos.modelmayhem.com"
]
@classmethod
def can_handle(cls, url: str) -> bool:
"""Check if this handler can process the URL"""
url_lower = url.lower()
for domain in cls.DOMAINS:
if domain in url_lower:
return True
return False
def __init__(self, url, scraper=None):
super().__init__(url, scraper)
# Configuration defaults
self.max_scroll_count = 15
self.scroll_delay_ms = 1500
self.use_stealth_mode = True
self.retry_attempts = 2
self.dynamic_content_wait_ms = 2000
self.request_delay_ms = 1000
self.last_request_time = 0
# Track state
self.is_logged_in = False
self.extracted_media_cache = {}
self.seen_urls = set()
self._username_to_resolve = None # For username-based URLs that need numeric ID resolution
# Load credentials from auth config
self._load_api_credentials()
print(f"ModelMayhemHandler initialized for URL: {url}")
def get_trusted_domains(self) -> List[str]:
"""Return list of trusted CDN domains for ModelMayhem"""
return [
"photos.modelmayhem.com", # Main photo CDN
"assets.modelmayhem.com", # Assets CDN
"modelmayhem.com", # Main domain
"cloudfront.net" # Potential CDN
]
def _load_api_credentials(self):
"""Load credentials from the auth_config if available"""
self.username = None
self.password = None
if not hasattr(self, 'scraper') or not self.scraper:
return
if not hasattr(self.scraper, 'auth_config') or not self.scraper.auth_config:
return
auth_config = self.scraper.auth_config
# Try multiple domain variations
domains_to_try = [
"modelmayhem.com",
"www.modelmayhem.com",
"secure.modelmayhem.com"
]
domain_config = None
# Look in 'sites' section first
if 'sites' in auth_config:
for domain in domains_to_try:
if domain in auth_config['sites']:
domain_config = auth_config['sites'][domain]
print(f"ModelMayhemHandler: Found auth config for {domain}")
break
# Also try directly in the config (older format)
if not domain_config:
for domain in domains_to_try:
if domain in auth_config:
domain_config = auth_config[domain]
print(f"ModelMayhemHandler: Found auth config (legacy) for {domain}")
break
if domain_config:
self.username = domain_config.get('username')
self.password = domain_config.get('password')
# Load additional config if available
if 'max_scroll_count' in domain_config:
self.max_scroll_count = domain_config.get('max_scroll_count')
if 'scroll_delay_ms' in domain_config:
self.scroll_delay_ms = domain_config.get('scroll_delay_ms')
if self.username:
print(f"ModelMayhemHandler: Loaded credentials for user {self.username}")
def prefers_api(self) -> bool:
"""This handler doesn't use APIs, it scrapes the web pages"""
return False
def requires_api(self) -> bool:
"""This handler doesn't require API access"""
return False
async def _perform_login(self, page) -> bool:
"""Perform login to ModelMayhem if credentials are available"""
if not self.username or not self.password:
print("ModelMayhemHandler: No credentials available, continuing without login")
return False
if self.is_logged_in:
return True
try:
print(f"ModelMayhemHandler: Attempting login as {self.username}")
# Navigate to login page - use domcontentloaded for faster loading
await page.goto("https://www.modelmayhem.com/login", wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(3)
# Check if already logged in by looking for user menu or login form
is_logged_in_already = await page.evaluate('''
() => {
// Check for signs of being logged in
const userMenu = document.querySelector('.user-menu, .logged-in, [data-user], .account-menu');
const loginForm = document.querySelector('form[action*="login"], #login-form, .login-form');
return userMenu !== null || loginForm === null;
}
''')
if is_logged_in_already:
print("ModelMayhemHandler: Already logged in")
self.is_logged_in = True
return True
# Fill in login form
# Try multiple selector patterns for username field
username_selectors = [
'input[name="username"]',
'input[name="email"]',
'input[type="email"]',
'#username',
'#email',
'input[placeholder*="mail"]',
'input[placeholder*="user"]'
]
for selector in username_selectors:
try:
username_field = await page.query_selector(selector)
if username_field:
await username_field.fill(self.username)
print(f"ModelMayhemHandler: Filled username field using selector: {selector}")
break
except:
continue
# Try multiple selector patterns for password field
password_selectors = [
'input[name="password"]',
'input[type="password"]',
'#password'
]
for selector in password_selectors:
try:
password_field = await page.query_selector(selector)
if password_field:
await password_field.fill(self.password)
print(f"ModelMayhemHandler: Filled password field using selector: {selector}")
break
except:
continue
# Submit login form
submit_selectors = [
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Log In")',
'button:has-text("Sign In")',
'.login-button',
'#login-submit'
]
for selector in submit_selectors:
try:
submit_button = await page.query_selector(selector)
if submit_button:
await submit_button.click()
print(f"ModelMayhemHandler: Clicked submit using selector: {selector}")
break
except:
continue
# Wait for navigation/login to complete
await asyncio.sleep(3)
# Verify login succeeded
current_url = page.url
if "login" not in current_url.lower():
print("ModelMayhemHandler: Login successful")
self.is_logged_in = True
return True
else:
print("ModelMayhemHandler: Login may have failed, continuing anyway")
return False
except Exception as e:
print(f"ModelMayhemHandler: Login error: {e}")
traceback.print_exc()
return False
def _normalize_portfolio_url(self, url: str) -> str:
"""Convert any ModelMayhem URL to the portfolio viewall URL"""
# Extract user ID or username from various URL patterns
patterns = [
# Numeric ID patterns
(r'modelmayhem\.com/portfolio/(\d+)/viewall', r'\1', True), # Already viewall: /portfolio/4554791/viewall
(r'modelmayhem\.com/portfolio/(\d+)', r'\1', True), # Portfolio URL: /portfolio/4554791
(r'modelmayhem\.com/(\d+)', r'\1', True), # Profile URL with ID: /4554791
# Username patterns (alphanumeric, may include underscores)
(r'modelmayhem\.com/portfolio/([a-zA-Z][a-zA-Z0-9_]+)/viewall', r'\1', False), # Already viewall with username
(r'modelmayhem\.com/portfolio/([a-zA-Z][a-zA-Z0-9_]+)', r'\1', False), # Portfolio with username
(r'modelmayhem\.com/([a-zA-Z][a-zA-Z0-9_]+)(?:/|$)', r'\1', False), # Profile URL with username: /albertobevacqua
]
for pattern, group, is_numeric in patterns:
match = re.search(pattern, url)
if match:
user_id = match.group(1)
# Skip if it matches a reserved path
if user_id.lower() in ['portfolio', 'login', 'signup', 'search', 'browse', 'help', 'about', 'contact', 'terms', 'privacy', 'pic']:
continue
# For username-based URLs, we need to visit the profile page first to get the numeric ID
# Store the username for later resolution
if not is_numeric:
self._username_to_resolve = user_id
# Return profile URL - we'll resolve to numeric ID in extract_with_direct_playwright
result_url = f"https://www.modelmayhem.com/{user_id}"
print(f"ModelMayhemHandler: Username URL detected, will resolve numeric ID from profile: {result_url}")
return result_url
result_url = f"https://www.modelmayhem.com/portfolio/{user_id}/viewall"
print(f"ModelMayhemHandler: Normalized URL to {result_url}")
return result_url
# If it's already a pic URL, just return it
if '/portfolio/pic/' in url:
return url
# Last resort: return the URL with /viewall appended if it looks like a profile
print(f"ModelMayhemHandler: Could not normalize URL, using as-is: {url}")
return url
def _upgrade_thumbnail_to_full_res(self, url: str) -> str:
"""
Convert a ModelMayhem thumbnail URL to full resolution.
ModelMayhem URL patterns:
- Thumbnail: https://photos.modelmayhem.com/photos/251111/17/6913e94c8f27f_m.jpg
- Full res: https://photos.modelmayhem.com/photos/251111/17/6913e94c8f27f.jpg
The _m suffix indicates medium thumbnail. Remove it for full resolution.
"""
if not url:
return url
# Remove thumbnail suffixes (_m, _s, _t) before the extension
# Pattern: filename_X.ext -> filename.ext where X is m, s, or t
upgraded = re.sub(r'_[mst]\.([a-zA-Z]+)$', r'.\1', url)
if upgraded != url:
print(f"ModelMayhemHandler: Upgraded URL: {url} -> {upgraded}")
return upgraded
async def _verify_and_get_best_url(self, page, thumbnail_url: str, verbose: bool = False) -> tuple[str, str]:
"""
Verify if the full-res URL exists, fall back to thumbnail if not.
Returns:
tuple: (best_url, original_thumbnail_or_none)
- best_url: The URL that should be used for download
- original_thumbnail_or_none: The thumbnail URL if different from best_url, else None
"""
full_res_url = self._upgrade_thumbnail_to_full_res(thumbnail_url)
# If no upgrade happened (URL stayed the same), just use it
if full_res_url == thumbnail_url:
return (thumbnail_url, None)
# Verify the full-res URL exists
try:
response = await page.context.request.head(full_res_url, headers={
'Referer': 'https://www.modelmayhem.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
})
if response.status < 400:
# Full-res URL exists, use it with thumbnail as fallback
if verbose:
print(f"ModelMayhemHandler: Full-res URL verified: {full_res_url}")
return (full_res_url, thumbnail_url)
else:
# Full-res URL doesn't exist, use thumbnail instead
if verbose:
print(f"ModelMayhemHandler: Full-res URL returned {response.status}, falling back to thumbnail: {thumbnail_url}")
return (thumbnail_url, None)
except Exception as e:
# On error, use thumbnail as safe fallback
if verbose:
print(f"ModelMayhemHandler: Error verifying full-res URL ({e}), using thumbnail: {thumbnail_url}")
return (thumbnail_url, None)
async def extract_with_direct_playwright(self, page, **kwargs) -> List[Dict[str, Any]]:
"""Main extraction method using Playwright"""
print(f"ModelMayhemHandler: Starting extraction from {self.url}")
start_time = time.time()
media_items = []
try:
# First, normalize the URL and check if we need to resolve a username
target_url = self._normalize_portfolio_url(self.url)
# Attempt login first if we have credentials
await self._perform_login(page)
# If we have a username to resolve, go to profile page first to get numeric ID
if self._username_to_resolve:
print(f"ModelMayhemHandler: Navigating to profile page to resolve username: {target_url}")
try:
# Use domcontentloaded instead of networkidle for faster loading
await page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(3) # Give extra time for dynamic content
except Exception as e:
print(f"ModelMayhemHandler: Navigation timeout, retrying with load event: {e}")
await page.goto(target_url, wait_until="load", timeout=60000)
await asyncio.sleep(3)
numeric_id = await self._resolve_numeric_id_from_profile(page)
if numeric_id:
target_url = f"https://www.modelmayhem.com/portfolio/{numeric_id}/viewall"
print(f"ModelMayhemHandler: Resolved username '{self._username_to_resolve}' to numeric ID {numeric_id}")
else:
print(f"ModelMayhemHandler: Could not resolve numeric ID for username '{self._username_to_resolve}'")
return media_items
# Navigate to the portfolio page
print(f"ModelMayhemHandler: Navigating to portfolio: {target_url}")
try:
await page.goto(target_url, wait_until="domcontentloaded", timeout=60000)
await asyncio.sleep(3)
except Exception as e:
print(f"ModelMayhemHandler: Navigation timeout, retrying with load event: {e}")
await page.goto(target_url, wait_until="load", timeout=60000)
await asyncio.sleep(3)
# Disable worksafe mode if it's enabled (critical for seeing actual images)
await self._disable_worksafe_mode(page)
# Scroll to load all images (lazy loading)
await self._scroll_to_load_all(page)
# Check if this is a single pic page or portfolio page
if '/portfolio/pic/' in self.url:
# Single photo page - extract that one image
media_items = await self._extract_single_photo(page)
else:
# Portfolio page - extract all photo links then get full-res images
media_items = await self._extract_portfolio_images(page)
print(f"ModelMayhemHandler: Extracted {len(media_items)} images in {time.time() - start_time:.2f}s")
except Exception as e:
print(f"ModelMayhemHandler: Error during extraction: {e}")
traceback.print_exc()
return media_items
async def _resolve_numeric_id_from_profile(self, page) -> Optional[str]:
"""
Extract the numeric Model Mayhem ID from a profile page.
The ID is shown in "Model Mayhem #: XXXXXX" or in portfolio link URLs.
"""
try:
numeric_id = await page.evaluate('''
() => {
// Method 1: Look for "Model Mayhem #:" text
const textContent = document.body.innerText;
const mmMatch = textContent.match(/Model Mayhem #:\\s*(\\d+)/);
if (mmMatch) return mmMatch[1];
// Method 2: Look for portfolio link with numeric ID
const portfolioLink = document.querySelector('a[href*="/portfolio/"][href*="/viewall"]');
if (portfolioLink) {
const match = portfolioLink.href.match(/\\/portfolio\\/(\\d+)/);
if (match) return match[1];
}
// Method 3: Look for any link with /portfolio/NUMBER pattern
const allLinks = document.querySelectorAll('a[href*="/portfolio/"]');
for (const link of allLinks) {
const match = link.href.match(/\\/portfolio\\/(\\d+)/);
if (match) return match[1];
}
// Method 4: Look for profile link in navigation
const profileLinks = document.querySelectorAll('a[href^="/"]');
for (const link of profileLinks) {
const match = link.href.match(/modelmayhem\\.com\\/(\\d+)$/);
if (match) return match[1];
}
return null;
}
''')
if numeric_id:
print(f"ModelMayhemHandler: Found numeric ID: {numeric_id}")
return numeric_id
else:
print("ModelMayhemHandler: Could not find numeric ID on profile page")
return None
except Exception as e:
print(f"ModelMayhemHandler: Error resolving numeric ID: {e}")
return None
async def _disable_worksafe_mode(self, page):
"""
Disable worksafe mode to show actual images instead of placeholders.
ModelMayhem defaults to worksafe mode which shows 'nopic_worksafe-on.gif' placeholders.
"""
try:
# Check if worksafe mode is currently ON (link to turn it OFF is visible)
worksafe_off_link = await page.query_selector('a[href="/worksafe/0"], a[href*="worksafe/0"]')
if worksafe_off_link:
print("ModelMayhemHandler: Worksafe mode is ON, disabling it...")
await worksafe_off_link.click()
await asyncio.sleep(2) # Wait for page to reload/update
print("ModelMayhemHandler: Worksafe mode disabled")
else:
# Check the toggle text to see current state
page_text = await page.evaluate('() => document.body.innerText')
if 'Toggle Worksafe Mode: Off' in page_text:
print("ModelMayhemHandler: Worksafe mode already OFF")
elif 'Toggle Worksafe Mode:' in page_text and 'On' in page_text:
# Try clicking via JavaScript
clicked = await page.evaluate('''
() => {
const links = document.querySelectorAll('a');
for (const link of links) {
if (link.textContent.trim() === 'Off' &&
link.closest &&
link.closest('[class*="worksafe"], [id*="worksafe"]') ||
link.previousSibling?.textContent?.includes('Worksafe')) {
link.click();
return true;
}
}
// Alternative: find by href pattern
const wsLink = document.querySelector('a[href*="worksafe"]');
if (wsLink && wsLink.textContent.trim() === 'Off') {
wsLink.click();
return true;
}
return false;
}
''')
if clicked:
await asyncio.sleep(2)
print("ModelMayhemHandler: Worksafe mode disabled via JS click")
else:
print("ModelMayhemHandler: Could not determine worksafe mode state")
except Exception as e:
print(f"ModelMayhemHandler: Error disabling worksafe mode: {e}")
async def _scroll_to_load_all(self, page):
"""Scroll down the page to trigger lazy loading of images"""
print("ModelMayhemHandler: Scrolling to load all images...")
try:
previous_height = 0
scroll_count = 0
while scroll_count < self.max_scroll_count:
# Get current scroll height
current_height = await page.evaluate("document.body.scrollHeight")
if current_height == previous_height:
# No new content loaded, we're done
break
previous_height = current_height
# Scroll down
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(self.scroll_delay_ms / 1000)
scroll_count += 1
print(f"ModelMayhemHandler: Scroll {scroll_count}/{self.max_scroll_count}")
# Scroll back to top
await page.evaluate("window.scrollTo(0, 0)")
await asyncio.sleep(0.5)
except Exception as e:
print(f"ModelMayhemHandler: Scroll error: {e}")
async def _extract_single_photo(self, page) -> List[Dict[str, Any]]:
"""Extract the full-resolution image from a single photo page"""
media_items = []
try:
# Look for the main photo on the page
photo_data = await page.evaluate('''
() => {
const results = [];
// Look for images from photos.modelmayhem.com
const imgs = document.querySelectorAll('img');
for (const img of imgs) {
const src = img.src || img.dataset.src || '';
if (src.includes('photos.modelmayhem.com')) {
results.push({
url: src,
width: img.naturalWidth || 0,
height: img.naturalHeight || 0,
alt: img.alt || ''
});
}
}
// Also look for og:image meta tag (often has full-res URL)
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogImage && ogImage.content) {
results.push({
url: ogImage.content,
width: 0,
height: 0,
alt: 'og:image'
});
}
return results;
}
''')
for item in photo_data:
url = item.get('url', '')
if not url:
continue
# Upgrade thumbnail to full resolution
full_res_url = self._upgrade_thumbnail_to_full_res(url)
if full_res_url not in self.seen_urls:
self.seen_urls.add(full_res_url)
self.seen_urls.add(url) # Also mark original as seen
media_items.append({
'url': full_res_url,
'type': 'image',
'width': item.get('width', 0),
'height': item.get('height', 0),
'source_page': self.url,
'trusted_cdn': True
})
except Exception as e:
print(f"ModelMayhemHandler: Error extracting single photo: {e}")
return media_items
async def _extract_portfolio_images(self, page) -> List[Dict[str, Any]]:
"""
Extract all images from a portfolio page.
Strategy: Extract thumbnail URLs from the portfolio grid page and upgrade them
to full resolution URLs. This is much faster than visiting each individual
pic page, and works because ModelMayhem uses predictable URL patterns:
- Thumbnail: /photos/.../filename_m.jpg
- Full res: /photos/.../filename.jpg
"""
media_items = []
try:
# Debug: Log current URL and page title
current_url = page.url
page_title = await page.title()
print(f"ModelMayhemHandler: Current page URL: {current_url}")
print(f"ModelMayhemHandler: Page title: {page_title}")
# Debug: Count all images on page
all_img_count = await page.evaluate('() => document.querySelectorAll("img").length')
print(f"ModelMayhemHandler: Total img elements on page: {all_img_count}")
# Extract all images directly from the portfolio page
# These are thumbnails but we'll upgrade them to full resolution
page_images = await page.evaluate('''
() => {
const images = [];
const debugInfo = { total: 0, modelmayhem: 0, worksafe: 0, other: [] };
const imgs = document.querySelectorAll('img');
debugInfo.total = imgs.length;
for (const img of imgs) {
const src = img.src || img.dataset.src || img.getAttribute('data-src') || '';
// Skip worksafe placeholder images
if (src.includes('nopic_worksafe') || src.includes('worksafe')) {
debugInfo.worksafe++;
continue;
}
// Only include photos.modelmayhem.com images (the actual photos)
if (src.includes('photos.modelmayhem.com/photos/') ||
src.includes('photos.modelmayhem.com/covers/')) {
debugInfo.modelmayhem++;
images.push({
url: src,
width: img.naturalWidth || 0,
height: img.naturalHeight || 0
});
} else if (src && src.length > 10 && debugInfo.other.length < 5) {
// Log first few non-matching URLs for debugging
debugInfo.other.push(src.substring(0, 100));
}
}
// Also check for background images in divs (some galleries use this)
const divs = document.querySelectorAll('[style*="background-image"]');
for (const div of divs) {
const style = div.getAttribute('style') || '';
const match = style.match(/url\\(['"]?(https?:\\/\\/photos\\.modelmayhem\\.com[^'")\s]+)['"]?\\)/);
if (match && match[1]) {
debugInfo.modelmayhem++;
images.push({
url: match[1],
width: 0,
height: 0
});
}
}
// Check data attributes for lazy-loaded images
const lazyImgs = document.querySelectorAll('[data-src*="photos.modelmayhem.com"], [data-lazy-src*="photos.modelmayhem.com"]');
for (const img of lazyImgs) {
const src = img.dataset.src || img.dataset.lazySrc || '';
if (src && src.includes('photos.modelmayhem.com')) {
debugInfo.modelmayhem++;
images.push({
url: src,
width: 0,
height: 0
});
}
}
// Return both images and debug info
return { images: images, debug: debugInfo };
}
''')
# Extract debug info and images
debug_info = page_images.get('debug', {})
actual_images = page_images.get('images', [])
print(f"ModelMayhemHandler: Debug - Total imgs: {debug_info.get('total', 0)}, ModelMayhem imgs: {debug_info.get('modelmayhem', 0)}, Worksafe placeholders: {debug_info.get('worksafe', 0)}")
if debug_info.get('other'):
print(f"ModelMayhemHandler: Sample other image URLs: {debug_info.get('other', [])[:3]}")
print(f"ModelMayhemHandler: Found {len(actual_images)} ModelMayhem images on portfolio page")
# Upgrade each thumbnail URL to full resolution (with verification) and deduplicate
print(f"ModelMayhemHandler: Verifying {len(actual_images)} image URLs...")
verified_count = 0
fallback_count = 0
for img in actual_images:
thumb_url = img.get('url', '')
if not thumb_url:
continue
# Skip if we've already seen this thumbnail URL
if thumb_url in self.seen_urls:
continue
# Verify and get the best URL (full-res if it exists, else thumbnail)
best_url, original_thumbnail = await self._verify_and_get_best_url(page, thumb_url)
# Skip if we've already seen the best URL
if best_url in self.seen_urls:
continue
self.seen_urls.add(best_url)
self.seen_urls.add(thumb_url) # Also mark thumbnail as seen
# Track verification stats
if original_thumbnail:
verified_count += 1
else:
fallback_count += 1
media_items.append({
'url': best_url,
'type': 'image',
'width': img.get('width', 0),
'height': img.get('height', 0),
'source_page': self.url,
'trusted_cdn': True,
'original_thumbnail': original_thumbnail
})
print(f"ModelMayhemHandler: Extracted {len(media_items)} unique image URLs ({verified_count} full-res verified, {fallback_count} using thumbnail)")
# If we didn't find many images, fall back to visiting individual pic pages
# This handles edge cases where the portfolio page doesn't show all thumbnails
if len(media_items) < 10:
print("ModelMayhemHandler: Few images found, trying pic page extraction as fallback...")
fallback_items = await self._extract_via_pic_pages(page)
# Add any new URLs from fallback
for item in fallback_items:
url = item.get('url', '')
if url and url not in self.seen_urls:
self.seen_urls.add(url)
media_items.append(item)
print(f"ModelMayhemHandler: After fallback, total {len(media_items)} images")
except Exception as e:
print(f"ModelMayhemHandler: Error extracting portfolio images: {e}")
traceback.print_exc()
return media_items
async def _extract_via_pic_pages(self, page) -> List[Dict[str, Any]]:
"""
Fallback method: Visit individual pic pages to extract full-res images.
Used when the portfolio page doesn't have all thumbnails visible.
"""
media_items = []
try:
# Collect all portfolio pic links
pic_links = await page.evaluate('''
() => {
const links = [];
const anchors = document.querySelectorAll('a[href*="/portfolio/pic/"]');
for (const a of anchors) {
if (a.href && !links.includes(a.href)) {
links.push(a.href);
}
}
return links;
}
''')
print(f"ModelMayhemHandler: Found {len(pic_links)} pic page links for fallback extraction")
# Visit each pic page (no artificial limit - get all images)
for i, pic_link in enumerate(pic_links):
try:
if self.request_delay_ms > 0:
await asyncio.sleep(self.request_delay_ms / 1000)
if (i + 1) % 10 == 0:
print(f"ModelMayhemHandler: Processing pic {i+1}/{len(pic_links)}")
# Navigate to the pic page
await page.goto(pic_link, wait_until="domcontentloaded", timeout=15000)
await asyncio.sleep(0.5)
# Extract the full-res image from this page
pic_images = await self._extract_single_photo(page)
for item in pic_images:
url = item.get('url', '')
if url and url not in self.seen_urls:
self.seen_urls.add(url)
media_items.append(item)
except Exception as e:
print(f"ModelMayhemHandler: Error processing pic {i+1}: {e}")
continue
except Exception as e:
print(f"ModelMayhemHandler: Error in pic page extraction: {e}")
return media_items
async def extract_media_items_async(self, page_adaptor) -> List[Dict[str, Any]]:
"""
Async extraction method - wrapper that gets Playwright page from adaptor
"""
print(f"ModelMayhemHandler: extract_media_items_async called")
# Try to get the underlying Playwright page
pw_page = None
if hasattr(page_adaptor, 'page'):
pw_page = page_adaptor.page
elif hasattr(self, 'get_playwright_page'):
pw_page = await self.get_playwright_page(page_adaptor)
if pw_page:
return await self.extract_with_direct_playwright(pw_page)
else:
print("ModelMayhemHandler: No Playwright page available")
return []
+402
View File
@@ -0,0 +1,402 @@
"""
Portfolio Handler
Description: Handler for model portfolio sites like juliaromanova.com that use clickable image galleries
Author: Eric Hiss (GitHub: EricRollei)
Contact: eric@historic.camera, eric@rollei.us
License: Dual License (Non-Commercial and Commercial Use)
Copyright (c) 2025 Eric Hiss. All rights reserved.
Dual License:
1. Non-Commercial Use: This software is licensed under the terms of the
Creative Commons Attribution-NonCommercial 4.0 International License.
To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/4.0/
2. Commercial Use: For commercial use, a separate license is required.
Please contact Eric Hiss at eric@historic.camera or eric@rollei.us for licensing options.
"""
"""
Portfolio handler for simple model portfolio sites with clickable image galleries.
Handles sites like juliaromanova.com, artfolio-powered sites, and similar simple galleries.
"""
from site_handlers.base_handler import BaseSiteHandler
from urllib.parse import urljoin, urlparse
import re
import time
import traceback
# Try importing Playwright types safely
try:
from playwright.async_api import Page as AsyncPage
PLAYWRIGHT_AVAILABLE = True
except ImportError:
AsyncPage = None
PLAYWRIGHT_AVAILABLE = False
class PortfolioHandler(BaseSiteHandler):
"""
Handler for simple portfolio sites with clickable image galleries.
Targets sites that:
- Have thumbnail galleries where clicking opens larger images
- Use patterns like /galleries/, /portfolio/, /photos/
- Often use numbered image paths or ID-based URLs
- Include artfolio.com-powered sites
"""
# Sites and patterns this handler can process
KNOWN_PORTFOLIO_PATTERNS = [
r'juliaromanova\.com',
r'artfolio\.com',
r'/galleries?/',
r'/portfolio/',
r'/photos?/',
r'/book/',
r'/albums?/',
r'/models?/',
r'/shoots?/',
]
# Image URL patterns that indicate resizable images (can be upgraded to larger versions)
RESIZABLE_PATTERNS = [
(r'g_10_', 'g_30_'), # Artfolio pattern: 10% thumb to 30% large
(r'_thumb\.', '_full.'),
(r'_small\.', '_large.'),
(r'_t\.', '_l.'),
(r'/s/', '/l/'), # some sites use /s/ for small
(r'/small/', '/large/'),
(r'/thumb/', '/full/'),
(r'width=\d+', 'width=2000'),
(r'w=\d+', 'w=2000'),
(r'size=\w+', 'size=original'),
]
# Sites that have their own specific handlers - don't handle these
EXCLUDED_DOMAINS = [
'modelmayhem.com',
'instagram.com',
'flickr.com',
'500px.com',
'deviantart.com',
'artstation.com',
'behance.net',
]
@classmethod
def can_handle(cls, url):
"""Check if this handler can process the URL"""
url_lower = url.lower()
# Don't handle sites that have their own specific handlers
for excluded_domain in cls.EXCLUDED_DOMAINS:
if excluded_domain in url_lower:
return False
# Check for known portfolio patterns
for pattern in cls.KNOWN_PORTFOLIO_PATTERNS:
if re.search(pattern, url_lower, re.IGNORECASE):
print(f"PortfolioHandler can handle: {url} (matched pattern: {pattern})")
return True
return False
def __init__(self, url, scraper=None):
"""Initialize the portfolio handler"""
super().__init__(url, scraper)
self.debug_mode = True
self.gallery_links = []
self.seen_urls = set()
self.image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff']
print(f"PortfolioHandler initialized for URL: {url}")
def prefers_api(self):
"""This handler doesn't use APIs"""
return False
def requires_api(self):
"""This handler doesn't require API access"""
return False
async def extract_media_items_async(self, page):
"""
Extract media items from portfolio gallery pages.
This handler:
1. Finds all gallery links (clickable thumbnails)
2. Extracts direct image URLs from thumbnails
3. Upgrades thumbnail URLs to full-size versions
"""
print(f"PortfolioHandler: Extracting media from {self.url}")
start_time = time.time()
media_items = []
# Get the Playwright page
pw_page = await self._get_playwright_page_async(page)
if not pw_page:
print("No Playwright page available, falling back to DOM extraction")
return await self._extract_from_html(page)
try:
# Wait for the page to load
await pw_page.wait_for_load_state('networkidle', timeout=15000)
# First, try to extract all images directly with full resolution
media_items = await self._extract_and_upgrade_images(pw_page)
# If we found images, return them
if media_items:
print(f"PortfolioHandler: Found {len(media_items)} media items")
return media_items
# Fallback: Extract from gallery links and navigate to each
gallery_items = await self._extract_gallery_links(pw_page)
if gallery_items:
media_items.extend(gallery_items)
except Exception as e:
print(f"PortfolioHandler error: {e}")
traceback.print_exc()
print(f"PortfolioHandler: Extracted {len(media_items)} media items in {time.time() - start_time:.2f}s")
return media_items
async def _extract_and_upgrade_images(self, pw_page):
"""Extract images from the page and upgrade to full resolution"""
media_items = []
try:
# Get all images from the page
images = await pw_page.evaluate('''
() => {
const imgs = document.querySelectorAll('img');
const result = [];
imgs.forEach(img => {
const src = img.src || img.dataset.src || img.getAttribute('data-lazy-src') || '';
if (src && !src.includes('data:image') && !src.includes('blank.gif')) {
// Get the largest version we can find
const srcset = img.srcset || '';
let largestSrc = src;
let largestWidth = img.naturalWidth || 0;
// Parse srcset for larger versions
if (srcset) {
const srcsetParts = srcset.split(',');
for (const part of srcsetParts) {
const match = part.trim().match(/^(\S+)\s+(\d+)w$/);
if (match) {
const [, url, width] = match;
if (parseInt(width) > largestWidth) {
largestWidth = parseInt(width);
largestSrc = url;
}
}
}
}
// Get parent link if exists (for galleries)
let parentLink = null;
const link = img.closest('a');
if (link && link.href) {
parentLink = link.href;
}
result.push({
src: largestSrc,
originalSrc: src,
alt: img.alt || '',
title: img.title || '',
width: img.naturalWidth || 0,
height: img.naturalHeight || 0,
parentLink: parentLink
});
}
});
return result;
}
''')
print(f"PortfolioHandler: Found {len(images)} images on page")
for img_data in images:
src = img_data.get('src', '')
if not src:
continue
# Skip tracking pixels and tiny images
width = img_data.get('width', 0)
height = img_data.get('height', 0)
if width > 0 and width < 50 and height > 0 and height < 50:
continue
# Try to upgrade to full resolution
full_res_url = self._upgrade_to_full_resolution(src)
# Skip if we've already seen this URL
if full_res_url in self.seen_urls:
continue
self.seen_urls.add(full_res_url)
media_item = {
'url': full_res_url,
'original_url': src,
'type': 'image',
'alt_text': img_data.get('alt', ''),
'title': img_data.get('title', ''),
'source': 'portfolio_handler',
'parent_link': img_data.get('parentLink', '')
}
media_items.append(media_item)
except Exception as e:
print(f"Error extracting images: {e}")
traceback.print_exc()
return media_items
def _upgrade_to_full_resolution(self, url):
"""
Upgrade a thumbnail URL to full resolution version.
Uses known patterns to transform URLs.
"""
upgraded_url = url
for pattern, replacement in self.RESIZABLE_PATTERNS:
if re.search(pattern, url):
upgraded_url = re.sub(pattern, replacement, url)
if upgraded_url != url:
print(f"Upgraded URL: {url[:50]}... -> {upgraded_url[:50]}...")
break
return upgraded_url
async def _extract_gallery_links(self, pw_page):
"""Extract links to gallery pages (for multi-page galleries)"""
media_items = []
try:
# Get all links that look like gallery item links
links = await pw_page.evaluate('''
() => {
const links = document.querySelectorAll('a');
const galleryLinks = [];
for (const link of links) {
const href = link.href;
// Look for gallery/portfolio-style links with numbers or slugs
if (href && (
href.match(/\\/galleries?\\/.*\\/\\d+/) ||
href.match(/\\/portfolio\\/.*\\//) ||
href.match(/\\/photos?\\/.*\\//) ||
href.match(/\\/book\\/[\\w-]+/) ||
href.match(/\\/albums?\\/.*\\//)
)) {
// Check if link contains an image (thumbnail)
const img = link.querySelector('img');
if (img) {
galleryLinks.push({
href: href,
title: link.title || img.alt || '',
thumbSrc: img.src || ''
});
}
}
}
return galleryLinks;
}
''')
print(f"PortfolioHandler: Found {len(links)} gallery links")
# For each gallery link, try to extract the full-size image
for link_data in links:
href = link_data.get('href', '')
thumb_src = link_data.get('thumbSrc', '')
if href in self.seen_urls:
continue
self.seen_urls.add(href)
# Try to derive full-res URL from thumbnail
if thumb_src:
full_res_url = self._upgrade_to_full_resolution(thumb_src)
if full_res_url not in self.seen_urls:
self.seen_urls.add(full_res_url)
media_item = {
'url': full_res_url,
'original_url': thumb_src,
'type': 'image',
'alt_text': link_data.get('title', ''),
'source': 'portfolio_handler',
'gallery_page': href
}
media_items.append(media_item)
except Exception as e:
print(f"Error extracting gallery links: {e}")
traceback.print_exc()
return media_items
async def _get_playwright_page_async(self, page):
"""Get the underlying Playwright page object"""
if not PLAYWRIGHT_AVAILABLE:
return None
# Handle different page wrapper types
if hasattr(page, '_playwright_page'):
return page._playwright_page
elif hasattr(page, 'page'):
return page.page
elif isinstance(page, AsyncPage) if AsyncPage else False:
return page
elif hasattr(page, 'playwright_page'):
return page.playwright_page
return page
async def _extract_from_html(self, page):
"""Fallback HTML extraction when Playwright isn't available"""
media_items = []
try:
# Try to get HTML content
html = None
if hasattr(page, 'html'):
html = page.html
elif hasattr(page, 'content'):
html = await page.content() if callable(page.content) else page.content
if html:
# Simple regex to find image URLs
img_pattern = r'<img[^>]+src=["\']([^"\']+)["\']'
matches = re.findall(img_pattern, html, re.IGNORECASE)
for src in matches:
if src and not src.startswith('data:'):
full_url = urljoin(self.url, src)
full_res = self._upgrade_to_full_resolution(full_url)
if full_res not in self.seen_urls:
self.seen_urls.add(full_res)
media_items.append({
'url': full_res,
'original_url': full_url,
'type': 'image',
'source': 'portfolio_handler_html'
})
except Exception as e:
print(f"Error in HTML extraction: {e}")
return media_items
async def extract_with_direct_playwright(self, page, **kwargs):
"""Direct Playwright extraction method called by the scraper"""
return await self.extract_media_items_async(page)
+112 -12
View File
@@ -123,14 +123,18 @@ class RedditHandler(BaseSiteHandler):
self._extract_identifiers_from_url()
# --- Load API credentials ---
# self._load_api_credentials() remove as main code has not loaded confgig yet
# Credentials will be loaded lazily in prefers_api() when the scraper's auth_config is available
def prefers_api(self) -> bool:
"""Reddit handler prefers API if credentials were loaded."""
# Ensure credentials are loaded from scraper's auth_config if not already done
if not self.api_available and self.scraper:
self._load_api_credentials()
# Check if the necessary attributes exist AND have truthy values
has_creds = bool(self.client_id and self.client_secret)
print(f"RedditHandler prefers_api check. Result: {has_creds}")
print(f"RedditHandler prefers_api check. Result: {has_creds} (api_available={self.api_available})")
return has_creds
async def extract_api_data_async(self, **kwargs) -> list:
@@ -316,7 +320,24 @@ class RedditHandler(BaseSiteHandler):
# Check if it's a self post (text post)
if not submission.is_self and hasattr(submission, 'url'):
url = submission.url
if self._is_image_url(url):
# Check if this is an external video host that needs resolution
if self._is_external_video_host(url):
resolved = await self._resolve_external_video_url(url)
if resolved:
media_items.append({
'url': resolved['url'],
'alt': post_title,
'title': resolved.get('title') or meta_title,
'source_url': post_url,
'credits': credit_line,
'type': 'video',
'width': resolved.get('width', 0),
'height': resolved.get('height', 0),
'duration': resolved.get('duration'),
'_headers': {'Referer': url} # Use original URL as referer
})
elif self._is_image_url(url):
media_items.append({
'url': url,
'alt': post_title,
@@ -327,15 +348,29 @@ class RedditHandler(BaseSiteHandler):
'_headers': {'Referer': post_url}
})
elif self._is_video_url(url):
media_items.append({
'url': url,
'alt': post_title,
'title': meta_title,
'source_url': post_url,
'credits': credit_line,
'type': 'video',
'_headers': {'Referer': post_url}
})
# For v.redd.it links, they usually need special handling too
if 'v.redd.it' in url:
resolved = await self._resolve_external_video_url(url)
if resolved:
media_items.append({
'url': resolved['url'],
'alt': post_title,
'title': meta_title,
'source_url': post_url,
'credits': credit_line,
'type': 'video',
'_headers': {'Referer': post_url}
})
else:
media_items.append({
'url': url,
'alt': post_title,
'title': meta_title,
'source_url': post_url,
'credits': credit_line,
'type': 'video',
'_headers': {'Referer': post_url}
})
# Handle Reddit-hosted videos
if submission.is_video and submission.media and 'reddit_video' in submission.media:
@@ -502,6 +537,71 @@ class RedditHandler(BaseSiteHandler):
return False
def _is_external_video_host(self, url: str) -> bool:
"""Check if URL is from an external video host that needs resolution."""
external_hosts = ['redgifs.com', 'gfycat.com', 'imgur.com/a/', 'imgur.com/gallery/']
return any(host in url.lower() for host in external_hosts)
async def _resolve_external_video_url(self, url: str) -> Optional[Dict[str, Any]]:
"""
Resolve external video host URLs (RedGifs, Gfycat, Imgur) to direct video URLs.
Uses yt-dlp as it has excellent support for these sites.
Returns:
Dict with 'url' and 'type' keys, or None if resolution fails
"""
import subprocess
import tempfile
try:
print(f"RedditHandler: Resolving external video URL: {url}")
# Use yt-dlp to extract video info
result = subprocess.run(
['yt-dlp', '--dump-json', '--no-download', url],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and result.stdout:
info = json.loads(result.stdout)
# Get the best video URL
video_url = info.get('url')
if not video_url:
# Try to get from formats
formats = info.get('formats', [])
if formats:
# Prefer mp4, then webm
for fmt in reversed(formats):
if fmt.get('ext') == 'mp4' and fmt.get('url'):
video_url = fmt['url']
break
if not video_url:
video_url = formats[-1].get('url')
if video_url:
print(f"RedditHandler: Resolved to: {video_url[:80]}...")
return {
'url': video_url,
'type': 'video',
'ext': info.get('ext', 'mp4'),
'title': info.get('title', ''),
'duration': info.get('duration'),
'width': info.get('width'),
'height': info.get('height')
}
else:
print(f"RedditHandler: yt-dlp failed for {url}: {result.stderr[:200] if result.stderr else 'no error'}")
except subprocess.TimeoutExpired:
print(f"RedditHandler: Timeout resolving {url}")
except Exception as e:
print(f"RedditHandler: Error resolving external video: {e}")
return None
async def _extract_media_from_dom_async(self, page: AsyncPage, **kwargs) -> list:
"""Extract media from DOM using async Playwright"""
media_items = []
+695
View File
@@ -0,0 +1,695 @@
"""
Wix Handler
Description: Handler for Wix-powered websites with image galleries
Author: Eric Hiss (GitHub: EricRollei)
Contact: eric@historic.camera, eric@rollei.us
License: Dual License (Non-Commercial and Commercial Use)
Copyright (c) 2025 Eric Hiss. All rights reserved.
Dual License:
1. Non-Commercial Use: This software is licensed under the terms of the
Creative Commons Attribution-NonCommercial 4.0 International License.
To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/4.0/
2. Commercial Use: For commercial use, a separate license is required.
Please contact Eric Hiss at eric@historic.camera or eric@rollei.us for licensing options.
"""
"""
Wix handler for websites built on the Wix platform.
Handles image extraction and resolution upgrades for Wix's image CDN.
Wix image URL patterns:
- static.wixstatic.com/media/[id]/v1/fill/w_[w],h_[h],q_[q],...
- static.wixstatic.com/media/[id]~mv2.jpg (original)
- static.wixstatic.com/media/[id].jpg
Transform parameters:
- w_[width] - target width
- h_[height] - target height
- q_[quality] - quality (1-100)
- al_c - alignment center
- usm_0.66_1.00_0.01 - unsharp mask
- enc_avif - encoding format (avif, webp, jpg, png)
"""
from site_handlers.base_handler import BaseSiteHandler
from urllib.parse import urljoin, urlparse, parse_qs, urlencode
import re
import time
import traceback
# Try importing Playwright types safely
try:
from playwright.async_api import Page as AsyncPage
PLAYWRIGHT_AVAILABLE = True
except ImportError:
AsyncPage = None
PLAYWRIGHT_AVAILABLE = False
class WixHandler(BaseSiteHandler):
"""
Handler for Wix-powered websites.
Wix sites use static.wixstatic.com for image hosting with URL-based
image transformations. This handler extracts images and upgrades
them to maximum resolution.
"""
# Patterns to identify Wix sites
WIX_PATTERNS = [
r'wixstatic\.com',
r'wix\.com',
r'wixsite\.com',
r'_wix_',
r'wix-code',
]
# Known Wix-powered domains (model agencies, portfolios, etc.)
KNOWN_WIX_DOMAINS = [
r'new1mgmt\.com',
r'modelwerk\.de',
r'nextmodels\.com',
# Add more known Wix sites as discovered
]
@classmethod
def can_handle(cls, url):
"""Check if this handler can process the URL"""
url_lower = url.lower()
# Check for known Wix domains
for pattern in cls.KNOWN_WIX_DOMAINS:
if re.search(pattern, url_lower, re.IGNORECASE):
print(f"WixHandler can handle: {url} (known Wix domain: {pattern})")
return True
# Check for Wix patterns in URL
for pattern in cls.WIX_PATTERNS:
if re.search(pattern, url_lower, re.IGNORECASE):
print(f"WixHandler can handle: {url} (matched pattern: {pattern})")
return True
return False
def __init__(self, url, scraper=None):
"""Initialize the Wix handler"""
super().__init__(url, scraper)
self.debug_mode = True
self.seen_urls = set()
self.seen_base_ids = set() # Track base image IDs to avoid duplicates
print(f"WixHandler initialized for URL: {url}")
def prefers_api(self):
"""This handler doesn't use APIs"""
return False
def requires_api(self):
"""This handler doesn't require API access"""
return False
async def extract_media_items_async(self, page):
"""
Extract media items from Wix-powered pages.
"""
print(f"WixHandler: Extracting media from {self.url}")
start_time = time.time()
media_items = []
# Get the Playwright page
pw_page = await self._get_playwright_page_async(page)
if not pw_page:
print("No Playwright page available")
return []
try:
# Wait for the page to fully load
await pw_page.wait_for_load_state('networkidle', timeout=20000)
# Give extra time for Wix's lazy loading
await pw_page.wait_for_timeout(2000)
# Scroll to trigger lazy loading
await self._scroll_page(pw_page)
# Extract images from multiple sources
media_items = await self._extract_wix_images(pw_page)
# Extract videos from the page
video_items = await self._extract_wix_videos(pw_page)
media_items.extend(video_items)
# Also try to get images from network requests
network_images = await self._extract_from_page_resources(pw_page)
# Merge and deduplicate
for item in network_images:
base_id = self._extract_wix_image_id(item.get('url', ''))
if base_id and base_id not in self.seen_base_ids:
self.seen_base_ids.add(base_id)
media_items.append(item)
except Exception as e:
print(f"WixHandler error: {e}")
traceback.print_exc()
print(f"WixHandler: Extracted {len(media_items)} media items in {time.time() - start_time:.2f}s")
return media_items
async def _scroll_page(self, pw_page):
"""Scroll the page to trigger lazy loading"""
try:
# Get page height
scroll_height = await pw_page.evaluate('document.body.scrollHeight')
viewport_height = await pw_page.evaluate('window.innerHeight')
# Scroll in increments
current_position = 0
scroll_step = viewport_height * 0.8
while current_position < scroll_height:
current_position += scroll_step
await pw_page.evaluate(f'window.scrollTo(0, {current_position})')
await pw_page.wait_for_timeout(500)
# Check if height increased (more content loaded)
new_height = await pw_page.evaluate('document.body.scrollHeight')
if new_height > scroll_height:
scroll_height = new_height
# Scroll back to top
await pw_page.evaluate('window.scrollTo(0, 0)')
await pw_page.wait_for_timeout(500)
except Exception as e:
print(f"Error scrolling page: {e}")
async def _extract_wix_videos(self, pw_page):
"""
Extract videos from Wix-powered pages.
Wix videos are hosted at video.wixstatic.com with this pattern:
- https://video.wixstatic.com/video/{video_id}/1080p/mp4/file.mp4
Video IDs can be found from:
1. Direct video src attributes
2. Gallery items marked as video (poster images with 'f003' suffix)
3. Data attributes in gallery containers
"""
media_items = []
seen_video_ids = set()
try:
# Extract video data from the page
video_data = await pw_page.evaluate('''
() => {
const results = {
directVideos: [],
galleryVideos: [],
videoContainers: []
};
// 1. Get ALL video elements - check multiple src attributes
const videos = document.querySelectorAll('video');
for (const video of videos) {
// Check multiple possible source attributes
let src = video.src || video.currentSrc || '';
const poster = video.poster || '';
// Also check source child elements
if (!src) {
const sourceEl = video.querySelector('source');
if (sourceEl) src = sourceEl.src || '';
}
// Any wixstatic video URL (including video.wixstatic.com)
if (src && (src.includes('wixstatic.com') || src.includes('wix.com'))) {
results.directVideos.push({
src: src,
poster: poster,
type: 'direct',
className: video.className
});
}
// Poster images can help identify video IDs
if (poster && poster.includes('wixstatic')) {
results.directVideos.push({
poster: poster,
type: 'poster_reference'
});
}
}
// 2. Get gallery items marked as videos
const galleryVideoItems = document.querySelectorAll('[class*="gallery-item-video"]');
for (const item of galleryVideoItems) {
const img = item.querySelector('img');
const video = item.querySelector('video');
// Get poster/thumbnail image which contains the video ID
if (img && img.src && img.src.includes('wixstatic')) {
// Video poster images typically end with f003 or f000
const src = img.src;
results.galleryVideos.push({
posterSrc: src,
hasVideoElement: !!video,
type: 'gallery_video'
});
}
}
// 3. Look for video containers with data attributes
const videoContainers = document.querySelectorAll('[class*="video"]');
for (const container of videoContainers) {
const videoEl = container.querySelector('video[src*="video.wixstatic"]');
if (videoEl) {
results.videoContainers.push({
src: videoEl.src,
type: 'container'
});
}
}
return results;
}
''')
print(f"WixHandler: Found {len(video_data.get('directVideos', []))} direct videos, "
f"{len(video_data.get('galleryVideos', []))} gallery videos, "
f"{len(video_data.get('videoContainers', []))} container videos")
# Process direct videos (already have full URLs)
for vid in video_data.get('directVideos', []):
src = vid.get('src', '')
print(f"WixHandler: Processing direct video - src: {src[:100] if src else 'None'}...")
# Check for any wixstatic video URL
if src and ('video.wixstatic.com' in src or 'wixstatic.com' in src):
video_id = self._extract_wix_video_id(src)
print(f"WixHandler: Extracted video ID: {video_id}")
if video_id and video_id not in seen_video_ids:
seen_video_ids.add(video_id)
# Upgrade to 1080p if not already
full_url = self._upgrade_wix_video_url(src)
print(f"WixHandler: Upgraded video URL to: {full_url}")
media_items.append({
'url': full_url,
'original_url': src,
'type': 'video',
'poster': vid.get('poster', ''),
'source': 'wix_handler_video',
'page_url': self.url, # For Referer header
'qualities': {
'1080p': f"https://video.wixstatic.com/video/{video_id}/1080p/mp4/file.mp4",
'720p': f"https://video.wixstatic.com/video/{video_id}/720p/mp4/file.mp4",
'480p': f"https://video.wixstatic.com/video/{video_id}/480p/mp4/file.mp4"
}
})
# Process gallery videos (construct URLs from poster images)
for vid in video_data.get('galleryVideos', []):
poster_src = vid.get('posterSrc', '')
if poster_src:
video_id = self._extract_video_id_from_poster(poster_src)
if video_id and video_id not in seen_video_ids:
seen_video_ids.add(video_id)
# Construct direct MP4 URL
video_url = f"https://video.wixstatic.com/video/{video_id}/1080p/mp4/file.mp4"
media_items.append({
'url': video_url,
'type': 'video',
'poster': poster_src,
'source': 'wix_handler_gallery_video',
'page_url': self.url, # For Referer header
'qualities': {
'1080p': f"https://video.wixstatic.com/video/{video_id}/1080p/mp4/file.mp4",
'720p': f"https://video.wixstatic.com/video/{video_id}/720p/mp4/file.mp4",
'480p': f"https://video.wixstatic.com/video/{video_id}/480p/mp4/file.mp4"
}
})
print(f"WixHandler: Constructed video URL for ID {video_id}")
# Process video containers
for vid in video_data.get('videoContainers', []):
src = vid.get('src', '')
if src and 'video.wixstatic.com' in src:
video_id = self._extract_wix_video_id(src)
if video_id and video_id not in seen_video_ids:
seen_video_ids.add(video_id)
full_url = self._upgrade_wix_video_url(src)
media_items.append({
'url': full_url,
'original_url': src,
'type': 'video',
'source': 'wix_handler_container_video'
})
except Exception as e:
print(f"Error extracting Wix videos: {e}")
traceback.print_exc()
print(f"WixHandler: Extracted {len(media_items)} videos")
return media_items
def _extract_wix_video_id(self, url):
"""Extract video ID from a Wix video URL"""
if not url:
return None
# Pattern: video.wixstatic.com/video/{video_id}/quality/mp4/file.mp4
# Example: video.wixstatic.com/video/b52b2f_19e70a48dd054abfb015bb2598c1ff8d/1080p/mp4/file.mp4
match = re.search(r'video\.wixstatic\.com/video/([a-f0-9]+_[a-f0-9]+)', url)
if match:
return match.group(1)
return None
def _extract_video_id_from_poster(self, poster_url):
"""
Extract video ID from a poster image URL.
Poster images have patterns like:
- b52b2f_0a5d15df38dc4c08a18238b03b87082af003.jpg (f003 suffix = poster frame)
- The video ID is the part before 'f003' or 'f000'
"""
if not poster_url:
return None
# Pattern: media/{id}f003.jpg or media/{id}f000.jpg
# We need to strip the f00X suffix to get the video ID
patterns = [
# Match ID with frame suffix (f003, f000, etc.)
r'/media/([a-f0-9]+_[a-f0-9]+?)f\d{3}(?:~mv2)?\.(?:jpg|jpeg|png|webp)',
r'/media/([a-f0-9]+_[a-f0-9]+?)f\d{3}\.(?:jpg|jpeg|png|webp)',
# Direct ID pattern
r'/media/([a-f0-9]+_[a-f0-9]{32})',
]
for pattern in patterns:
match = re.search(pattern, poster_url, re.IGNORECASE)
if match:
video_id = match.group(1)
# Clean up any trailing frame indicator
video_id = re.sub(r'f\d{3}$', '', video_id)
return video_id
return None
def _upgrade_wix_video_url(self, url):
"""Upgrade video URL to highest quality (1080p)"""
if not url:
return url
# Replace quality specifier with 1080p
upgraded = re.sub(r'/\d{3,4}p/', '/1080p/', url)
return upgraded
async def _extract_wix_images(self, pw_page):
"""Extract and upgrade Wix images from the page"""
media_items = []
try:
# Get all images with their sources
images = await pw_page.evaluate('''
() => {
const results = [];
// Get all img elements
const imgs = document.querySelectorAll('img');
for (const img of imgs) {
const src = img.src || img.dataset.src || img.getAttribute('data-src') || '';
const srcset = img.srcset || '';
if (src) {
results.push({
src: src,
srcset: srcset,
alt: img.alt || '',
width: img.naturalWidth || img.width || 0,
height: img.naturalHeight || img.height || 0
});
}
}
// Also check for background images in styles
const allElements = document.querySelectorAll('*');
for (const el of allElements) {
const style = window.getComputedStyle(el);
const bgImage = style.backgroundImage;
if (bgImage && bgImage !== 'none' && bgImage.includes('wixstatic')) {
const match = bgImage.match(/url\\(["']?([^"']+)["']?\\)/);
if (match) {
results.push({
src: match[1],
srcset: '',
alt: '',
width: 0,
height: 0,
isBackground: true
});
}
}
}
// Check for Wix gallery data
const galleryData = document.querySelectorAll('[data-image-info]');
for (const el of galleryData) {
try {
const info = JSON.parse(el.getAttribute('data-image-info'));
if (info && info.imageData && info.imageData.uri) {
results.push({
src: 'https://static.wixstatic.com/media/' + info.imageData.uri,
srcset: '',
alt: info.imageData.title || '',
width: info.imageData.width || 0,
height: info.imageData.height || 0,
fromGalleryData: true
});
}
} catch (e) {}
}
return results;
}
''')
print(f"WixHandler: Found {len(images)} image elements")
for img_data in images:
src = img_data.get('src', '')
srcset = img_data.get('srcset', '')
# Skip non-wix images and tracking pixels
if not src:
continue
if 'wixstatic' not in src and 'wix.com' not in src:
continue
if img_data.get('width', 0) > 0 and img_data.get('width', 0) < 50:
continue
# Extract base image ID
base_id = self._extract_wix_image_id(src)
if not base_id:
continue
# Skip if we've already processed this image
if base_id in self.seen_base_ids:
continue
self.seen_base_ids.add(base_id)
# Try to get highest resolution from srcset first
best_url = self._get_best_from_srcset(srcset) if srcset else None
# Upgrade to maximum resolution
if best_url:
full_res_url = self._upgrade_wix_url(best_url)
else:
full_res_url = self._upgrade_wix_url(src)
if full_res_url in self.seen_urls:
continue
self.seen_urls.add(full_res_url)
media_item = {
'url': full_res_url,
'original_url': src,
'type': 'image',
'alt_text': img_data.get('alt', ''),
'source': 'wix_handler',
'original_width': img_data.get('width', 0),
'original_height': img_data.get('height', 0)
}
media_items.append(media_item)
except Exception as e:
print(f"Error extracting Wix images: {e}")
traceback.print_exc()
return media_items
def _extract_wix_image_id(self, url):
"""Extract the base image ID from a Wix URL"""
if not url:
return None
# Pattern: /media/[id]_[hash]~mv2.jpg or /media/[id].jpg
# Example: b52b2f_1f9ee9ba5e6c4b4792257297be983580~mv2.jpg
patterns = [
r'/media/([a-f0-9]+_[a-f0-9]+[^/]*?)(?:/v1/|\.(?:jpg|jpeg|png|webp|gif))',
r'/media/([a-f0-9]+_[^/]+?)(?:~mv2)?\.(?:jpg|jpeg|png|webp|gif)',
r'/media/([a-f0-9_]+)(?:/|~|\.)',
]
for pattern in patterns:
match = re.search(pattern, url, re.IGNORECASE)
if match:
return match.group(1)
return None
def _get_best_from_srcset(self, srcset):
"""Parse srcset and return the highest resolution URL"""
if not srcset:
return None
best_url = None
best_width = 0
# Parse srcset: "url1 800w, url2 1200w, ..."
parts = srcset.split(',')
for part in parts:
part = part.strip()
match = re.match(r'(\S+)\s+(\d+)w', part)
if match:
url, width = match.groups()
width = int(width)
if width > best_width:
best_width = width
best_url = url
return best_url
def _upgrade_wix_url(self, url):
"""
Upgrade a Wix image URL to maximum resolution.
Strategies:
1. Request original by removing transforms
2. Request very large dimensions
3. Change quality to maximum
"""
if not url:
return url
# If it's already an original URL (no /v1/fill/ or /v1/fit/), return as-is
if '/v1/fill/' not in url and '/v1/crop/' not in url and '/v1/fit/' not in url:
# Try to ensure we get the raw image
return url
try:
# Extract the base media URL and transform it
# Pattern: https://static.wixstatic.com/media/[id]/v1/fill/w_X,h_Y,.../filename.ext
# Or: https://static.wixstatic.com/media/[id]/v1/fit/w_X,h_Y,.../filename.ext
# Strategy 1: Request maximum resolution (4K+)
# Replace dimension parameters with large values
upgraded = url
# Replace width with large value (Wix typically supports up to 5000)
upgraded = re.sub(r'w_\d+', 'w_4000', upgraded)
# Replace height with large value
upgraded = re.sub(r'h_\d+', 'h_5000', upgraded)
# Set quality to maximum (90 is usually best balance, 100 can cause issues)
upgraded = re.sub(r'q_\d+', 'q_95', upgraded)
# Change fit to fill for better quality (fit may letterbox)
# Actually keep as-is since fit respects aspect ratio
# Prefer JPEG over AVIF/WebP for better compatibility and quality
upgraded = re.sub(r'enc_avif', 'enc_auto', upgraded)
upgraded = re.sub(r'enc_webp', 'enc_auto', upgraded)
# Remove quality_auto which adds compression
upgraded = re.sub(r',quality_auto', '', upgraded)
print(f"WixHandler: Upgraded URL dimensions to 4000x5000")
return upgraded
except Exception as e:
print(f"Error upgrading Wix URL: {e}")
return url
async def _extract_from_page_resources(self, pw_page):
"""Extract images from page resources/network requests"""
media_items = []
try:
# Get all loaded resources
resources = await pw_page.evaluate('''
() => {
const resources = [];
// Check performance entries for loaded resources
if (window.performance && window.performance.getEntriesByType) {
const entries = window.performance.getEntriesByType('resource');
for (const entry of entries) {
if (entry.initiatorType === 'img' ||
entry.name.match(/\\.(jpg|jpeg|png|webp|gif|avif)/i)) {
resources.push(entry.name);
}
}
}
return resources;
}
''')
for url in resources:
if 'wixstatic' not in url:
continue
base_id = self._extract_wix_image_id(url)
if not base_id or base_id in self.seen_base_ids:
continue
full_res_url = self._upgrade_wix_url(url)
if full_res_url not in self.seen_urls:
self.seen_urls.add(full_res_url)
media_items.append({
'url': full_res_url,
'original_url': url,
'type': 'image',
'source': 'wix_handler_network'
})
except Exception as e:
print(f"Error extracting from page resources: {e}")
return media_items
async def _get_playwright_page_async(self, page):
"""Get the underlying Playwright page object"""
if not PLAYWRIGHT_AVAILABLE:
return None
# Handle different page wrapper types
if hasattr(page, '_playwright_page'):
return page._playwright_page
elif hasattr(page, 'page'):
return page.page
elif isinstance(page, AsyncPage) if AsyncPage else False:
return page
elif hasattr(page, 'playwright_page'):
return page.playwright_page
return page
async def extract_with_direct_playwright(self, page, **kwargs):
"""Direct Playwright extraction method called by the scraper"""
return await self.extract_media_items_async(page)
+19
View File
@@ -0,0 +1,19 @@
"""
Utils Package
Utility modules for download_tools nodes.
"""
from .persistent_settings import (
PersistentSettings,
get_settings_manager,
get_persistent_setting,
set_persistent_setting
)
__all__ = [
'PersistentSettings',
'get_settings_manager',
'get_persistent_setting',
'set_persistent_setting'
]
+167
View File
@@ -0,0 +1,167 @@
"""
Persistent Settings Manager
Description: Manages persistent settings for download_tools nodes across reloads
Author: Eric Hiss (GitHub: EricRollei)
Contact: eric@historic.camera, eric@rollei.us
License: Dual License (Non-Commercial and Commercial Use)
Copyright (c) 2025 Eric Hiss. All rights reserved.
"""
import os
import json
from pathlib import Path
from typing import Optional, Any, Dict
class PersistentSettings:
"""
Manages persistent settings for download_tools nodes.
Settings are stored in a JSON file and persist across node/workflow reloads.
"""
_instance = None
_settings: Dict[str, Any] = {}
_settings_file: Path = None
def __new__(cls):
"""Singleton pattern to ensure only one instance exists."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
"""Initialize the settings manager."""
if self._initialized:
return
# Determine settings file path
self._settings_file = Path(__file__).parent.parent / "configs" / "node_settings.json"
self._settings = self._load_settings()
self._initialized = True
print(f"PersistentSettings initialized from: {self._settings_file}")
def _load_settings(self) -> Dict[str, Any]:
"""Load settings from the JSON file."""
default_settings = {
"web_scraper": {
"auth_config_path": ""
},
"gallery_dl": {
"config_path": "",
"cookie_file": ""
},
"yt_dlp": {
"config_path": ""
}
}
try:
if self._settings_file.exists():
with open(self._settings_file, 'r', encoding='utf-8') as f:
loaded = json.load(f)
# Merge with defaults to ensure all keys exist
for key in default_settings:
if key not in loaded:
loaded[key] = default_settings[key]
elif isinstance(default_settings[key], dict):
for subkey in default_settings[key]:
if subkey not in loaded[key]:
loaded[key][subkey] = default_settings[key][subkey]
return loaded
else:
# Create the file with defaults
self._settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._settings_file, 'w', encoding='utf-8') as f:
json.dump(default_settings, f, indent=4)
return default_settings
except Exception as e:
print(f"Error loading persistent settings: {e}")
return default_settings
def _save_settings(self) -> bool:
"""Save current settings to the JSON file."""
try:
self._settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._settings_file, 'w', encoding='utf-8') as f:
json.dump(self._settings, f, indent=4)
return True
except Exception as e:
print(f"Error saving persistent settings: {e}")
return False
def get(self, node_type: str, key: str, default: Any = "") -> Any:
"""
Get a setting value for a specific node type.
Args:
node_type: The type of node ('web_scraper', 'gallery_dl', 'yt_dlp')
key: The setting key to retrieve
default: Default value if setting not found
Returns:
The setting value or default
"""
try:
if node_type in self._settings and key in self._settings[node_type]:
value = self._settings[node_type][key]
return value if value else default
return default
except Exception:
return default
def set(self, node_type: str, key: str, value: Any) -> bool:
"""
Set a setting value for a specific node type.
Args:
node_type: The type of node ('web_scraper', 'gallery_dl', 'yt_dlp')
key: The setting key to set
value: The value to store
Returns:
True if successful, False otherwise
"""
try:
if node_type not in self._settings:
self._settings[node_type] = {}
# Only update if value is non-empty (don't overwrite with empty values)
if value and str(value).strip():
self._settings[node_type][key] = str(value).strip()
return self._save_settings()
return True
except Exception as e:
print(f"Error setting persistent setting: {e}")
return False
def get_all(self, node_type: str) -> Dict[str, Any]:
"""Get all settings for a specific node type."""
return self._settings.get(node_type, {})
def reload(self) -> None:
"""Reload settings from file (useful if file was edited externally)."""
self._settings = self._load_settings()
# Global instance for easy access
_settings_manager: Optional[PersistentSettings] = None
def get_settings_manager() -> PersistentSettings:
"""Get the global settings manager instance."""
global _settings_manager
if _settings_manager is None:
_settings_manager = PersistentSettings()
return _settings_manager
def get_persistent_setting(node_type: str, key: str, default: Any = "") -> Any:
"""Convenience function to get a persistent setting."""
return get_settings_manager().get(node_type, key, default)
def set_persistent_setting(node_type: str, key: str, value: Any) -> bool:
"""Convenience function to set a persistent setting."""
return get_settings_manager().set(node_type, key, value)