webcrawlerapi 2.1.5__tar.gz → 2.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/PKG-INFO +1 -1
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/setup.py +1 -1
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/tests/test_client.py +69 -20
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/tests/test_models.py +1 -1
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi/__init__.py +2 -2
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi/client.py +51 -57
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi/models.py +4 -2
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi.egg-info/PKG-INFO +1 -1
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/README.md +0 -0
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/pyproject.toml +0 -0
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/setup.cfg +0 -0
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/tests/__init__.py +0 -0
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi.egg-info/SOURCES.txt +0 -0
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi.egg-info/dependency_links.txt +0 -0
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi.egg-info/requires.txt +0 -0
- {webcrawlerapi-2.1.5 → webcrawlerapi-2.1.7}/webcrawlerapi.egg-info/top_level.txt +0 -0
|
@@ -351,31 +351,64 @@ class TestWebCrawlerAPI:
|
|
|
351
351
|
assert result.success is False
|
|
352
352
|
|
|
353
353
|
@responses.activate
|
|
354
|
-
def
|
|
355
|
-
"""
|
|
354
|
+
def test_scrape_starts_async_and_polls_until_done(self, client):
|
|
355
|
+
"""scrape() starts an async scrape and polls get_scrape() until done."""
|
|
356
356
|
responses.add(
|
|
357
357
|
responses.POST,
|
|
358
|
-
"https://api.test.com/v2/scrape",
|
|
358
|
+
"https://api.test.com/v2/scrape?async=true",
|
|
359
|
+
json={"id": "scrape-123"},
|
|
360
|
+
status=200,
|
|
361
|
+
)
|
|
362
|
+
responses.add(
|
|
363
|
+
responses.GET,
|
|
364
|
+
"https://api.test.com/v2/scrape/scrape-123",
|
|
365
|
+
json={
|
|
366
|
+
"status": "in_progress",
|
|
367
|
+
"success": False,
|
|
368
|
+
"recommended_pull_delay_ms": 1000,
|
|
369
|
+
},
|
|
370
|
+
status=200,
|
|
371
|
+
)
|
|
372
|
+
responses.add(
|
|
373
|
+
responses.GET,
|
|
374
|
+
"https://api.test.com/v2/scrape/scrape-123",
|
|
359
375
|
json={
|
|
360
376
|
"status": "done",
|
|
361
377
|
"success": True,
|
|
362
378
|
"markdown": "# Scraped Content",
|
|
379
|
+
"page_status_code": 200,
|
|
380
|
+
"page_title": "Test Page",
|
|
363
381
|
},
|
|
364
382
|
status=200,
|
|
365
383
|
)
|
|
366
384
|
|
|
367
|
-
|
|
385
|
+
with patch("webcrawlerapi.client.time.sleep") as mock_sleep:
|
|
386
|
+
result = client.scrape(url="https://example.com")
|
|
368
387
|
|
|
369
388
|
assert isinstance(result, ScrapeResponse)
|
|
370
389
|
assert result.status == "done"
|
|
371
390
|
assert result.markdown == "# Scraped Content"
|
|
391
|
+
assert result.page_status_code == 200
|
|
392
|
+
assert result.page_title == "Test Page"
|
|
393
|
+
|
|
394
|
+
assert len(responses.calls) == 3
|
|
395
|
+
assert responses.calls[0].request.url == (
|
|
396
|
+
"https://api.test.com/v2/scrape?async=true"
|
|
397
|
+
)
|
|
398
|
+
mock_sleep.assert_called_once_with(1.0) # recommended_pull_delay_ms / 1000
|
|
372
399
|
|
|
373
400
|
@responses.activate
|
|
374
|
-
def
|
|
375
|
-
"""
|
|
401
|
+
def test_scrape_returns_error_from_poll(self, client):
|
|
402
|
+
"""scrape() returns ScrapeResponseError when polling yields an error."""
|
|
376
403
|
responses.add(
|
|
377
404
|
responses.POST,
|
|
378
|
-
"https://api.test.com/v2/scrape",
|
|
405
|
+
"https://api.test.com/v2/scrape?async=true",
|
|
406
|
+
json={"id": "scrape-123"},
|
|
407
|
+
status=200,
|
|
408
|
+
)
|
|
409
|
+
responses.add(
|
|
410
|
+
responses.GET,
|
|
411
|
+
"https://api.test.com/v2/scrape/scrape-123",
|
|
379
412
|
json={
|
|
380
413
|
"status": "error",
|
|
381
414
|
"success": False,
|
|
@@ -385,30 +418,46 @@ class TestWebCrawlerAPI:
|
|
|
385
418
|
status=200,
|
|
386
419
|
)
|
|
387
420
|
|
|
388
|
-
|
|
421
|
+
with patch("webcrawlerapi.client.time.sleep") as mock_sleep:
|
|
422
|
+
result = client.scrape(url="https://example.com")
|
|
389
423
|
|
|
390
424
|
assert isinstance(result, ScrapeResponseError)
|
|
391
425
|
assert result.error_code == "FETCH_ERROR"
|
|
426
|
+
mock_sleep.assert_not_called()
|
|
392
427
|
|
|
393
428
|
@responses.activate
|
|
394
|
-
def
|
|
395
|
-
"""
|
|
429
|
+
def test_scrape_times_out_after_400_seconds(self, client):
|
|
430
|
+
"""scrape() stops polling after 400 seconds and returns the last state."""
|
|
396
431
|
responses.add(
|
|
397
432
|
responses.POST,
|
|
398
|
-
"https://api.test.com/v2/scrape",
|
|
433
|
+
"https://api.test.com/v2/scrape?async=true",
|
|
434
|
+
json={"id": "scrape-123"},
|
|
435
|
+
status=200,
|
|
436
|
+
)
|
|
437
|
+
responses.add(
|
|
438
|
+
responses.GET,
|
|
439
|
+
"https://api.test.com/v2/scrape/scrape-123",
|
|
399
440
|
json={
|
|
400
|
-
"status": "
|
|
401
|
-
"success":
|
|
402
|
-
"
|
|
403
|
-
"page_status_code": 200,
|
|
404
|
-
"page_title": "Test Page",
|
|
441
|
+
"status": "in_progress",
|
|
442
|
+
"success": False,
|
|
443
|
+
"recommended_pull_delay_ms": 1000,
|
|
405
444
|
},
|
|
406
445
|
status=200,
|
|
407
446
|
)
|
|
408
447
|
|
|
409
|
-
|
|
448
|
+
clock = {"now": 1000.0}
|
|
449
|
+
|
|
450
|
+
def fake_sleep(seconds):
|
|
451
|
+
clock["now"] += seconds
|
|
452
|
+
|
|
453
|
+
with patch("webcrawlerapi.client.time.monotonic", lambda: clock["now"]), patch(
|
|
454
|
+
"webcrawlerapi.client.time.sleep", side_effect=fake_sleep
|
|
455
|
+
) as mock_sleep:
|
|
456
|
+
result = client.scrape(url="https://example.com")
|
|
410
457
|
|
|
411
458
|
assert isinstance(result, ScrapeResponse)
|
|
412
|
-
assert result.status == "
|
|
413
|
-
assert
|
|
414
|
-
assert
|
|
459
|
+
assert result.status == "in_progress"
|
|
460
|
+
assert mock_sleep.call_count == 400
|
|
461
|
+
assert sum(c.args[0] for c in mock_sleep.call_args_list) == pytest.approx(400.0)
|
|
462
|
+
# 1 initial poll + 400 polls after each 1s sleep
|
|
463
|
+
assert len(responses.calls) == 402 # 1 POST + 401 GETs
|
|
@@ -428,4 +428,4 @@ class TestJob:
|
|
|
428
428
|
|
|
429
429
|
def test_job_terminal_statuses_constant(self):
|
|
430
430
|
"""Test that TERMINAL_STATUSES constant is correctly defined."""
|
|
431
|
-
assert Job.TERMINAL_STATUSES == {"done", "error", "cancelled"}
|
|
431
|
+
assert Job.TERMINAL_STATUSES == {"done", "error", "cancelled", "canceled"}
|
|
@@ -14,7 +14,7 @@ Basic usage:
|
|
|
14
14
|
>>> # Or asynchronous crawling
|
|
15
15
|
>>> response = crawler.crawl_async(url="https://example.com")
|
|
16
16
|
>>> job = crawler.get_job(response.id)
|
|
17
|
-
>>> # Single page scraping (
|
|
17
|
+
>>> # Single page scraping (starts the scrape and waits for the result)
|
|
18
18
|
>>> result = crawler.scrape(url="https://example.com", output_format="markdown")
|
|
19
19
|
>>> if result.success:
|
|
20
20
|
... print(result.markdown) # Access the scraped content
|
|
@@ -38,7 +38,7 @@ from .models import (
|
|
|
38
38
|
WebCrawlerApiError,
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
-
__version__ = "1.
|
|
41
|
+
__version__ = "2.1.7"
|
|
42
42
|
__all__ = [
|
|
43
43
|
"WebCrawlerAPI",
|
|
44
44
|
"WebCrawlerApiError",
|
|
@@ -26,6 +26,8 @@ class WebCrawlerAPI:
|
|
|
26
26
|
"""Python SDK for WebCrawler API."""
|
|
27
27
|
|
|
28
28
|
DEFAULT_POLL_DELAY_SECONDS = 5
|
|
29
|
+
SCRAPE_DEFAULT_POLL_DELAY_SECONDS = 2
|
|
30
|
+
SCRAPE_TIMEOUT_SECONDS = 400
|
|
29
31
|
|
|
30
32
|
def __init__(self, api_key: str, base_url: str = "https://api.webcrawlerapi.com"):
|
|
31
33
|
"""
|
|
@@ -609,6 +611,9 @@ class WebCrawlerAPI:
|
|
|
609
611
|
page_title=response_data.get("page_title"),
|
|
610
612
|
structured_data=response_data.get("structured_data"),
|
|
611
613
|
links=response_data.get("links"),
|
|
614
|
+
recommended_pull_delay_ms=response_data.get(
|
|
615
|
+
"recommended_pull_delay_ms"
|
|
616
|
+
),
|
|
612
617
|
)
|
|
613
618
|
elif status == "error":
|
|
614
619
|
return ScrapeResponseError(
|
|
@@ -616,9 +621,19 @@ class WebCrawlerAPI:
|
|
|
616
621
|
error_code=response_data.get("error_code", "unknown"),
|
|
617
622
|
error_message=response_data.get("error_message", "Scraping failed"),
|
|
618
623
|
status=status,
|
|
624
|
+
recommended_pull_delay_ms=response_data.get(
|
|
625
|
+
"recommended_pull_delay_ms"
|
|
626
|
+
),
|
|
619
627
|
)
|
|
620
628
|
else: # in_progress or any other status
|
|
621
|
-
return ScrapeResponse(
|
|
629
|
+
return ScrapeResponse(
|
|
630
|
+
success=False,
|
|
631
|
+
status=status,
|
|
632
|
+
page_status_code=0,
|
|
633
|
+
recommended_pull_delay_ms=response_data.get(
|
|
634
|
+
"recommended_pull_delay_ms"
|
|
635
|
+
),
|
|
636
|
+
)
|
|
622
637
|
|
|
623
638
|
def scrape(
|
|
624
639
|
self,
|
|
@@ -636,10 +651,13 @@ class WebCrawlerAPI:
|
|
|
636
651
|
keep_query_params: Optional[bool] = None,
|
|
637
652
|
) -> Union[ScrapeResponse, ScrapeResponseError]:
|
|
638
653
|
"""
|
|
639
|
-
Scrape a single URL
|
|
654
|
+
Scrape a single URL and wait for the result.
|
|
640
655
|
|
|
641
|
-
|
|
642
|
-
|
|
656
|
+
Starts the scrape with scrape_async() and polls get_scrape() until the status is
|
|
657
|
+
"done" or an error is returned, waiting the server-recommended delay between polls
|
|
658
|
+
(falls back to 2 seconds). Polling stops after 400 seconds in total; on timeout
|
|
659
|
+
the last known state is returned.
|
|
660
|
+
For manual control use scrape_async() + get_scrape() instead.
|
|
643
661
|
|
|
644
662
|
Args:
|
|
645
663
|
url (str): The URL to scrape
|
|
@@ -656,66 +674,42 @@ class WebCrawlerAPI:
|
|
|
656
674
|
keep_query_params (bool, optional): Keep URL query params when storing the URL (default: True).
|
|
657
675
|
|
|
658
676
|
Returns:
|
|
659
|
-
Union[ScrapeResponse, ScrapeResponseError]: The scrape result
|
|
677
|
+
Union[ScrapeResponse, ScrapeResponseError]: The scrape result, or the last known
|
|
678
|
+
state (status "in_progress") if the 400 second timeout is reached
|
|
660
679
|
|
|
661
680
|
Raises:
|
|
662
681
|
WebCrawlerApiError: If the API returns an error response
|
|
663
682
|
requests.exceptions.RequestException: If the HTTP request fails
|
|
664
683
|
"""
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
payload["response_schema"] = response_schema
|
|
680
|
-
if max_age is not None:
|
|
681
|
-
payload["max_age"] = max_age
|
|
682
|
-
if main_content_only:
|
|
683
|
-
payload["main_content_only"] = main_content_only
|
|
684
|
-
if respect_robots_txt:
|
|
685
|
-
payload["respect_robots_txt"] = respect_robots_txt
|
|
686
|
-
if keep_query_params is not None:
|
|
687
|
-
payload["keep_query_params"] = keep_query_params
|
|
688
|
-
if actions:
|
|
689
|
-
action_list = [actions] if not isinstance(actions, list) else actions
|
|
690
|
-
payload["actions"] = [vars(action) for action in action_list]
|
|
691
|
-
|
|
692
|
-
response = self.session.post(
|
|
693
|
-
urljoin(self.base_url, f"/{SCRAPER_VERSION}/scrape"),
|
|
694
|
-
json=payload,
|
|
695
|
-
)
|
|
684
|
+
scrape_id = self.scrape_async(
|
|
685
|
+
url,
|
|
686
|
+
output_formats=output_formats,
|
|
687
|
+
output_format=output_format,
|
|
688
|
+
webhook_url=webhook_url,
|
|
689
|
+
clean_selectors=clean_selectors,
|
|
690
|
+
prompt=prompt,
|
|
691
|
+
response_schema=response_schema,
|
|
692
|
+
actions=actions,
|
|
693
|
+
main_content_only=main_content_only,
|
|
694
|
+
max_age=max_age,
|
|
695
|
+
respect_robots_txt=respect_robots_txt,
|
|
696
|
+
keep_query_params=keep_query_params,
|
|
697
|
+
).id
|
|
696
698
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
+
deadline = time.monotonic() + self.SCRAPE_TIMEOUT_SECONDS
|
|
700
|
+
result = self.get_scrape(scrape_id)
|
|
699
701
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
+
while not isinstance(result, ScrapeResponseError) and result.status != "done":
|
|
703
|
+
remaining = deadline - time.monotonic()
|
|
704
|
+
if remaining <= 0:
|
|
705
|
+
return result
|
|
702
706
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
error_code=response_data.get("error_code", "unknown"),
|
|
707
|
-
error_message=response_data.get("error_message", "Scraping failed"),
|
|
708
|
-
status=status,
|
|
707
|
+
delay_ms = result.recommended_pull_delay_ms
|
|
708
|
+
delay = (
|
|
709
|
+
delay_ms / 1000 if delay_ms else self.SCRAPE_DEFAULT_POLL_DELAY_SECONDS
|
|
709
710
|
)
|
|
711
|
+
time.sleep(min(delay, remaining))
|
|
710
712
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
markdown=response_data.get("markdown"),
|
|
715
|
-
cleaned_content=response_data.get("cleaned_content"),
|
|
716
|
-
raw_content=response_data.get("raw_content"),
|
|
717
|
-
page_status_code=response_data.get("page_status_code", 0),
|
|
718
|
-
page_title=response_data.get("page_title"),
|
|
719
|
-
structured_data=response_data.get("structured_data"),
|
|
720
|
-
links=response_data.get("links"),
|
|
721
|
-
)
|
|
713
|
+
result = self.get_scrape(scrape_id)
|
|
714
|
+
|
|
715
|
+
return result
|
|
@@ -90,6 +90,7 @@ class ScrapeResponse:
|
|
|
90
90
|
page_title: Optional[str] = None
|
|
91
91
|
structured_data: Optional[Dict[str, Any]] = None
|
|
92
92
|
links: Optional[List[str]] = None
|
|
93
|
+
recommended_pull_delay_ms: Optional[int] = None
|
|
93
94
|
|
|
94
95
|
|
|
95
96
|
@dataclass
|
|
@@ -100,6 +101,7 @@ class ScrapeResponseError:
|
|
|
100
101
|
error_code: str
|
|
101
102
|
error_message: str
|
|
102
103
|
status: Optional[str] = None
|
|
104
|
+
recommended_pull_delay_ms: Optional[int] = None
|
|
103
105
|
|
|
104
106
|
|
|
105
107
|
class AgentRun:
|
|
@@ -284,7 +286,7 @@ class JobItem:
|
|
|
284
286
|
class Job:
|
|
285
287
|
"""Represents a crawling job."""
|
|
286
288
|
|
|
287
|
-
TERMINAL_STATUSES = {"done", "error", "cancelled"}
|
|
289
|
+
TERMINAL_STATUSES = {"done", "error", "cancelled", "canceled"}
|
|
288
290
|
|
|
289
291
|
def __init__(self, data: Dict[str, Any]):
|
|
290
292
|
self.id: str = data["id"]
|
|
@@ -317,5 +319,5 @@ class Job:
|
|
|
317
319
|
|
|
318
320
|
@property
|
|
319
321
|
def is_terminal(self) -> bool:
|
|
320
|
-
"""Check if the job is in a terminal state (done, error, or cancelled)."""
|
|
322
|
+
"""Check if the job is in a terminal state (done, error, or cancelled/canceled)."""
|
|
321
323
|
return self.status in self.TERMINAL_STATUSES
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|