webcrawlerapi 2.1.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webcrawlerapi
3
- Version: 2.1.6
3
+ Version: 2.1.7
4
4
  Summary: Python SDK for WebCrawler API
5
5
  Home-page: https://github.com/webcrawlerapi/webcrawlerapi-python-sdk
6
6
  Author: Andrew
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="webcrawlerapi",
5
- version="2.1.6",
5
+ version="2.1.7",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "requests>=2.25.0",
@@ -351,31 +351,64 @@ class TestWebCrawlerAPI:
351
351
  assert result.success is False
352
352
 
353
353
  @responses.activate
354
- def test_scrape_with_polling_success(self, client):
355
- """Test scrape method returns done result from sync endpoint."""
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
- result = client.scrape(url="https://example.com")
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 test_scrape_with_polling_error(self, client):
375
- """Test scrape method that gets error from sync endpoint."""
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
- result = client.scrape(url="https://example.com")
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 test_scrape_with_polling_max_polls(self, client):
395
- """Test scrape method returns full response data from sync endpoint."""
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": "done",
401
- "success": True,
402
- "markdown": "# Content",
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
- result = client.scrape(url="https://example.com")
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 == "done"
413
- assert result.page_status_code == 200
414
- assert result.page_title == "Test Page"
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
@@ -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 (synchronous)
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.0.0"
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(success=False, status=status, page_status_code=0)
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 synchronously and return the result.
654
+ Scrape a single URL and wait for the result.
640
655
 
641
- Calls the synchronous scrape endpoint which blocks until the scrape is complete.
642
- For fire-and-poll behaviour use scrape_async() + get_scrape() instead.
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
- payload: Dict[str, Any] = {"url": url}
666
-
667
- if output_formats is not None:
668
- payload["output_formats"] = output_formats
669
- elif output_format is not None:
670
- payload["output_format"] = output_format
671
-
672
- if webhook_url:
673
- payload["webhook_url"] = webhook_url
674
- if clean_selectors:
675
- payload["clean_selectors"] = clean_selectors
676
- if prompt:
677
- payload["prompt"] = prompt
678
- if response_schema is not None:
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
- if not response.ok:
698
- self._raise_for_error(response)
699
+ deadline = time.monotonic() + self.SCRAPE_TIMEOUT_SECONDS
700
+ result = self.get_scrape(scrape_id)
699
701
 
700
- response_data = response.json()
701
- status = response_data.get("status")
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
- if status == "error":
704
- return ScrapeResponseError(
705
- success=False,
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
- return ScrapeResponse(
712
- success=response_data.get("success", True),
713
- status=status,
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webcrawlerapi
3
- Version: 2.1.6
3
+ Version: 2.1.7
4
4
  Summary: Python SDK for WebCrawler API
5
5
  Home-page: https://github.com/webcrawlerapi/webcrawlerapi-python-sdk
6
6
  Author: Andrew
File without changes
File without changes