ADM-Purchasing-Tools / TECHNICAL_IMPLEMENTATION.md
abdulsalam2121
Implement comprehensive timeout and pagination improvements to prevent hangs during scans
5e341a9
|
Raw
History Blame Contribute Delete
12.9 kB
# Technical Implementation Details
## Key Code Changes
### 1. Listing Scraper - Thread-Based Page Extraction Timeout
**Before:**
```python
def _get_products_on_page(self) -> List[Dict[str, Any]]:
page = self.page
products: List[Dict[str, Any]] = []
rows = page.locator("div.row").all() # Could hang here
for idx, row in enumerate(rows):
# ... extraction code ...
return products
```
**After:**
```python
def _get_products_on_page(self) -> List[Dict[str, Any]]:
page = self.page
products: List[Dict[str, Any]] = []
extraction_timeout = self.page_timeout - 5 # Leave 5s buffer
result_container = {"products": [], "error": None, "done": False}
def extract_products():
try:
rows = page.locator("div.row").all() # Runs in separate thread
logger.debug(f"Found {len(rows)} potential product rows")
for idx, row in enumerate(rows):
# ... extraction code ...
except Exception as e:
result_container["error"] = str(e)
finally:
result_container["done"] = True
# Run extraction in thread with timeout
thread = threading.Thread(target=extract_products, daemon=True)
thread.start()
thread.join(timeout=extraction_timeout) # Max 25 seconds
if result_container["done"]:
if result_container["error"]:
logger.error(f"Product extraction error: {result_container['error']}")
products = result_container["products"]
else:
logger.warning(f"Product extraction timed out after {extraction_timeout}s")
try:
self.session.screenshot("extraction_timeout")
except Exception:
pass
logger.info(f"Extracted {len(products)} qualifying product(s) from page")
return products
```
**Why:** Locator operations can hang indefinitely if the page is still loading or has JavaScript errors. Threading with timeout ensures we detect and recover from hangs.
---
### 2. Pagination Loop - Comprehensive Timeout System
**Before:**
```python
def iter_qualifying_products(self, studio_url: str):
current_url = studio_url
page_num = 1
visited_urls = set()
max_pages = 10000 # Unlimited effectively
while current_url and page_num <= max_pages:
if current_url in visited_urls:
logger.warning("Pagination loop detected. Stopping scan.")
break
visited_urls.add(current_url)
products = self._get_products_on_page()
# ... rest of loop ...
```
**After:**
```python
def iter_qualifying_products(self, studio_url: str):
current_url = studio_url
page_num = 1
visited_urls = set()
consecutive_failures = 0
max_consecutive_failures = 3
pages_without_products = 0
max_empty_pages = 5
self.scan_start_time = time.time()
self.last_product_found_time = self.scan_start_time
logger.info(f"Starting listing scan with min_price=${self.min_price:.2f}")
logger.info(f"Limits: max_pages={self.max_pages}, page_timeout={self.page_timeout}s, total_timeout={self.total_timeout}s")
while current_url and page_num <= self.max_pages:
# CHECK 1: User stop signal
if self.stop_event and self.stop_event.is_set():
logger.info("Listing scan stopped by user")
break
# CHECK 2: Total time exceeded
elapsed_time = time.time() - self.scan_start_time
if elapsed_time > self.total_timeout:
logger.warning(f"Total scan timeout exceeded ({elapsed_time:.0f}s > {self.total_timeout}s). Stopping scan.")
break
# CHECK 3: Pagination loop
if current_url in visited_urls:
logger.warning("Pagination loop detected. Stopping scan.")
break
# CHECK 4: Too many empty pages
if pages_without_products >= max_empty_pages:
logger.warning(f"Too many empty pages ({pages_without_products}/{max_empty_pages}). Stopping scan.")
break
# CHECK 5: No products found in extended time
if self.collected == 0 and elapsed_time > 300:
logger.warning("No products found after 5 minutes. Stopping scan.")
break
visited_urls.add(current_url)
logger.info(f"Scanning listing page {page_num}: {current_url}")
# ... process page ...
# CHECK 6: Page load failures
if not self._load_page_with_retry(current_url, page_num, max_retries=3):
consecutive_failures += 1
if consecutive_failures >= max_consecutive_failures:
logger.error(f"Too many consecutive page failures ({consecutive_failures}). Stopping scan.")
break
products = self._get_products_on_page()
if not products:
pages_without_products += 1
else:
pages_without_products = 0
self.last_product_found_time = time.time()
# ... yield products ...
current_url = next_url
page_num += 1
```
**Why:** Multiple checks create a safety net. If one check fails, others catch the problem. Total timeout prevents infinite loops on any individual check failure.
---
### 3. Page Loading - Thread-Protected Navigation
**Before:**
```python
def _load_page_with_retry(self, url: str, page_num: int, max_retries: int = 3) -> bool:
for attempt in range(1, max_retries + 1):
try:
logger.info(f"Page load attempt {attempt}/{max_retries} for page {page_num}: {url}")
if self.session.goto(url): # Could hang here
logger.info(f"✓ Successfully loaded page {page_num} on attempt {attempt}")
try:
self.session.cleanup_page()
except Exception as e:
logger.debug(f"Cleanup warning: {e}")
return True
else:
logger.warning(f"✗ Page load failed for page {page_num}, attempt {attempt}")
except Exception as e:
logger.warning(f"✗ Exception during page load: {e}")
if attempt < max_retries:
delay = retry_delays[attempt - 1]
logger.info(f"Waiting {delay}s before retry...")
time.sleep(delay)
return False
```
**After:**
```python
def _load_page_with_retry(self, url: str, page_num: int, max_retries: int = 3) -> bool:
retry_delays = [2, 5, 10]
for attempt in range(1, max_retries + 1):
try:
logger.info(f"Page load attempt {attempt}/{max_retries} for page {page_num}: {url}")
# Load page with timeout thread
load_result = {"success": False, "done": False}
def load_thread():
try:
load_result["success"] = self.session.goto(url)
except Exception as e:
logger.warning(f"Navigation exception: {e}")
finally:
load_result["done"] = True
thread = threading.Thread(target=load_thread, daemon=True)
thread.start()
thread.join(timeout=self.page_timeout) # Max 30 seconds
if load_result["done"] and load_result["success"]:
logger.info(f"✓ Successfully loaded page {page_num} on attempt {attempt}")
try:
self.session.cleanup_page()
import gc
gc.collect() # Force garbage collection
except Exception as e:
logger.debug(f"Cleanup warning: {e}")
return True
else:
if not load_result["done"]:
logger.warning(f"✗ Page load timed out after {self.page_timeout}s for page {page_num}, attempt {attempt}")
else:
logger.warning(f"✗ Page load failed for page {page_num}, attempt {attempt}")
except Exception as e:
logger.warning(f"✗ Exception during page load: {e}")
if attempt < max_retries:
delay = retry_delays[attempt - 1]
logger.info(f"Waiting {delay}s before retry...")
time.sleep(delay)
logger.error(f"Failed to load page {page_num} after {max_retries} retries: {url}")
return False
```
**Why:** Threading ensures Playwright navigation doesn't hang. If network is slow or server doesn't respond, we detect it and move on after timeout instead of waiting forever.
---
### 4. Product Scraper - Timeout-Protected Field Extraction
**Before:**
```python
def scrape_product(self, url: str) -> Dict[str, Any]:
# ...
result["title"] = self._get_title() # Could hang
result["price"] = self._get_price() # Could hang
result["upc"] = (self._meta_value("UPC") or "").strip() # Could hang
result["category"] = self._get_category() # Could hang
# ...
```
**After:**
```python
def scrape_product(self, url: str) -> Dict[str, Any]:
# ...
result["title"] = self._get_title_safe() # 8s timeout
result["price"] = self._get_price_safe() # 8s timeout
result["upc"] = (self._meta_value_safe("UPC") or "").strip() # 8s timeout
result["category"] = self._get_category_safe() # 8s timeout
# ...
def _get_title_safe(self, timeout: int = 8) -> str:
"""Extract title with timeout protection."""
result = {"value": "", "done": False}
def extract():
try:
result["value"] = self._get_title()
except Exception as e:
logger.debug(f"Title extraction error: {e}")
finally:
result["done"] = True
thread = threading.Thread(target=extract, daemon=True)
thread.start()
thread.join(timeout=timeout)
if not result["done"]:
logger.warning("Title extraction timed out")
return result["value"]
```
**Why:** Individual fields might hang on slow servers. Timeout per field prevents one slow field from blocking entire scan.
---
### 5. Browser Session - Enhanced Cleanup
**Before:**
```python
def cleanup_page(self):
try:
if self.page:
self.page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
logger.debug("Cleared page storage")
except Exception as e:
logger.debug(f"Page cleanup warning: {e}")
```
**After:**
```python
def cleanup_page(self):
"""Cleanup page resources to prevent memory accumulation during long pagination runs."""
try:
if self.page:
self.page.evaluate("""
() => {
localStorage.clear();
sessionStorage.clear();
// Purge cache
if (window.caches) {
caches.keys().then(names => {
names.forEach(name => caches.delete(name));
});
}
}
""")
logger.debug("Cleared page storage and cache")
except Exception as e:
logger.debug(f"Page cleanup warning: {e}")
```
**Why:** On memory-constrained live servers, every bit helps. Clearing caches and storage prevents memory accumulation across pages.
---
## Configuration Examples
### Hugging Face Space (Limited Resources)
```python
ListingScraper(
session=session,
min_price=self.min_price,
max_pages=50, # Conservative
page_timeout=15, # Shorter timeouts
total_timeout=900, # 15 minutes max
)
```
### Local Development (Full Resources)
```python
ListingScraper(
session=session,
min_price=self.min_price,
max_pages=100, # Default
page_timeout=30, # Default
total_timeout=1800, # 30 minutes
)
```
### High-Traffic Sites (Slow Servers)
```python
ListingScraper(
session=session,
min_price=self.min_price,
max_pages=100,
page_timeout=45, # Longer for slow servers
total_timeout=2400, # 40 minutes
)
```
---
## Troubleshooting
### If scan still hangs:
1. Lower `page_timeout` to detect hangs faster
2. Lower `max_pages` limit
3. Lower `total_timeout` to terminate early
4. Check network connectivity
5. Look at screenshot files in `logs/` directory for visual debugging
### If scan terminates too early:
1. Increase `page_timeout`
2. Increase `total_timeout`
3. Check logs for "Page load timed out" messages
4. Consider increasing `max_pages` if legitimate pages are being skipped
### Memory issues on Hugging Face:
1. Use conservative settings (see above)
2. Ensure `cleanup_page()` is called (happens automatically)
3. Monitor with smaller `max_pages` values
4. Consider running multiple smaller scans instead of one large scan