File size: 12,909 Bytes
ec2ef5e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# 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