Quellcode durchsuchen

feat(api): add waitTime param to /visit for SPA hydration

Adds an optional waitTime field (0-60s) to VisitRequest. After
waitUntil resolves and networkidle is reached, the wrapper sleeps
waitTime additional seconds before capturing page.content().

Use case: MercadoLibre Chile's SPA needs ~8s after networkidle to
hydrate polycards. Previously the API captured pre-hydration HTML.

Backwards compatible: default is 0, no behavior change for existing
callers. Pydantic enforces ge=0/le=60 bounds.

Tests:
- schemas: minimal/full requests include waitTime; -1 and 61 rejected;
  0 and 60 accepted.
- controller: endpoint accepts valid waitTime; rejects -5 (422) and
  120 (422).
- 71 tests passing, 97.6% coverage maintained.
Hermes Agent vor 1 Tag
Ursprung
Commit
8ea32ddf82
4 geänderte Dateien mit 56 neuen und 0 gelöschten Zeilen
  1. 10 0
      app/models/schemas.py
  2. 6 0
      app/use_cases/camoufox/wrapper.py
  3. 24 0
      tests/test_api_controller.py
  4. 16 0
      tests/test_schemas.py

+ 10 - 0
app/models/schemas.py

@@ -9,6 +9,16 @@ class VisitRequest(BaseModel):
     url: HttpUrl
     screenshot: bool = False
     waitUntil: WaitUntilValue = Field(default="load")
+    waitTime: int = Field(
+        default=0,
+        ge=0,
+        le=60,
+        description=(
+            "Extra seconds to wait AFTER waitUntil resolves. "
+            "Useful for SPAs (e.g. MercadoLibre) that need extra time to hydrate polycards "
+            "after the network goes idle. Max 60s."
+        ),
+    )
     proxy: Optional[str] = None
     proxyAuth: Optional[dict[str, Any]] = None
     incognito: bool = False

+ 6 - 0
app/use_cases/camoufox/wrapper.py

@@ -159,6 +159,12 @@ class CamoufoxWrapper:
 
             await page.wait_for_load_state("networkidle", timeout=120000)
 
+            # Extra wait after networkidle (for SPAs that need hydration time)
+            extra_wait = options.get("waitTime", 0) or 0
+            if extra_wait > 0:
+                log.logger.info(f"Extra wait: {extra_wait}s after networkidle")
+                await asyncio.sleep(extra_wait)
+
             # Hacks específicos para Mercado Libre
             is_meli = "mercadolibre.cl" in url
             if is_meli:

+ 24 - 0
tests/test_api_controller.py

@@ -108,6 +108,30 @@ class TestVisitEndpoint:
             assert "code" in body
             assert body["code"] == "UNKNOWN_CODE"
 
+    def test_visit_with_wait_time_valid(self):
+        with mocked_client() as client:
+            response = client.post(
+                "/api/v1/visit",
+                json={"url": "https://example.com", "waitTime": 10},
+            )
+            assert response.status_code == 200
+
+    def test_visit_with_wait_time_invalid_negative(self):
+        with mocked_client() as client:
+            response = client.post(
+                "/api/v1/visit",
+                json={"url": "https://example.com", "waitTime": -5},
+            )
+            assert response.status_code == 422
+
+    def test_visit_with_wait_time_invalid_above_max(self):
+        with mocked_client() as client:
+            response = client.post(
+                "/api/v1/visit",
+                json={"url": "https://example.com", "waitTime": 120},
+            )
+            assert response.status_code == 422
+
 
 class TestVisitEndpointController:
     """Tests que verifican el controller directamente con VisitUseCase mockeado."""

+ 16 - 0
tests/test_schemas.py

@@ -19,6 +19,7 @@ class TestVisitRequest:
         assert str(req.url) == "https://example.com/"
         assert req.screenshot is False
         assert req.waitUntil == "load"
+        assert req.waitTime == 0
         assert req.proxy is None
         assert req.proxyAuth is None
         assert req.incognito is False
@@ -28,16 +29,31 @@ class TestVisitRequest:
             url="https://example.com",
             screenshot=True,
             waitUntil="networkidle",
+            waitTime=10,
             proxy="http://proxy:8080",
             proxyAuth={"username": "user", "password": "pass"},
             incognito=True,
         )
         assert req.screenshot is True
         assert req.waitUntil == "networkidle"
+        assert req.waitTime == 10
         assert req.proxy == "http://proxy:8080"
         assert req.proxyAuth["username"] == "user"
         assert req.incognito is True
 
+    def test_wait_time_negative_rejected(self):
+        with pytest.raises(ValidationError):
+            VisitRequest(url="https://example.com", waitTime=-1)
+
+    def test_wait_time_above_max_rejected(self):
+        with pytest.raises(ValidationError):
+            VisitRequest(url="https://example.com", waitTime=61)
+
+    def test_wait_time_boundary_values(self):
+        # Both 0 and 60 should be valid
+        VisitRequest(url="https://example.com", waitTime=0)
+        VisitRequest(url="https://example.com", waitTime=60)
+
     def test_invalid_url_raises_error(self):
         with pytest.raises(ValidationError):
             VisitRequest(url="not-a-url")