Skip to content

Commit 4268340

Browse files
authored
Merge pull request #2290 from talelboussetta/bugfix/crawler-configs-single-url-and-stream
fix(docker): apply crawler_configs to single-URL and streaming requests
2 parents 3bd2450 + 8276964 commit 4268340

6 files changed

Lines changed: 392 additions & 30 deletions

File tree

‎deploy/docker/README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,29 @@ async def test_stream_crawl(token: str = None): # Made token optional
658658
# asyncio.run(test_stream_crawl())
659659
```
660660
661+
#### Per-URL Configs (`crawler_configs`)
662+
663+
To crawl different URLs with different settings in one request, send `crawler_configs`: a list of `CrawlerRunConfig` payloads, each with a `url_matcher`. Every URL uses the first entry whose `url_matcher` matches it, and an entry without a `url_matcher` matches every URL.
664+
665+
```python
666+
payload = {
667+
"urls": ["https://docs.example.com/guide", "https://example.com/"],
668+
"crawler_configs": [
669+
{
670+
"type": "CrawlerRunConfig",
671+
"params": {"url_matcher": "*docs.example.com*", "css_selector": "main"},
672+
},
673+
# Catch-all: decides every URL the entries above did not match
674+
{"type": "CrawlerRunConfig", "params": {"url_matcher": "*"}},
675+
],
676+
}
677+
```
678+
679+
- The list is used for one URL or many, on both `/crawl` and `/crawl/stream`.
680+
- When a list is sent, `crawler_config` is ignored. It is not a fallback: a URL that no entry matches comes back as a failed result (`No matching configuration found for URL: ...`), so end the list with a catch-all entry (`"url_matcher": "*"`).
681+
- The server's `crawler.base_config` defaults from `config.yml` are applied to each entry on both endpoints.
682+
- A list that mixes `PDFContentScrapingStrategy` entries with other scraping strategies is refused with `400`, because one crawler serves every URL of a request.
683+
661684
### Asynchronous Jobs with Webhooks
662685
663686
For long-running crawls or when you want to avoid keeping connections open, use the job queue endpoints. Instead of polling for results, configure a webhook to receive notifications when jobs complete.

‎deploy/docker/api.py‎

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,57 @@ async def stream_results(crawler: AsyncWebCrawler, results_gen: AsyncGenerator)
648648
await _dispose_crawler(crawler)
649649

650650

651+
def _load_crawler_configs(
652+
crawler_configs: List[dict],
653+
base_config: Optional[dict] = None,
654+
stream: bool = False,
655+
) -> List[CrawlerRunConfig]:
656+
"""Deserialize a per-URL crawler_configs list under the untrusted gate.
657+
Shared by the streaming and non-streaming crawl handlers so both apply the
658+
same boundary and wire the PDF URL validator into every entry."""
659+
from crawl4ai.processors.pdf import PDFContentScrapingStrategy
660+
661+
config_list = [
662+
CrawlerRunConfig.load(cc, provenance=Provenance.UNTRUSTED)
663+
for cc in crawler_configs
664+
]
665+
for cfg in config_list:
666+
for key, value in (base_config or {}).items():
667+
if hasattr(cfg, key):
668+
current_value = getattr(cfg, key)
669+
if current_value is None or current_value == "":
670+
setattr(cfg, key, value)
671+
# SSRF: per-URL PDF strategies need the validator wired too
672+
if isinstance(cfg.scraping_strategy, PDFContentScrapingStrategy):
673+
cfg.scraping_strategy.url_validator = validate_url_destination
674+
if stream:
675+
# arun_many takes the stream flag from the first config of a list.
676+
cfg.stream = True
677+
return config_list
678+
679+
680+
def _needs_pdf_crawler(
681+
crawler_config: CrawlerRunConfig,
682+
config_list: Optional[List[CrawlerRunConfig]] = None,
683+
) -> bool:
684+
"""Whether the crawl runs on PDFCrawlerStrategy. A per-URL list decides when
685+
present, since it is what arun_many runs. One crawler serves every URL of a
686+
request, so a list mixing PDF and browser entries is refused rather than run
687+
on the wrong one."""
688+
from crawl4ai.processors.pdf import PDFContentScrapingStrategy
689+
690+
configs = config_list or [crawler_config]
691+
pdf = [
692+
isinstance(cfg.scraping_strategy, PDFContentScrapingStrategy) for cfg in configs
693+
]
694+
if any(pdf) and not all(pdf):
695+
raise HTTPException(
696+
status_code=400,
697+
detail="crawler_configs cannot mix PDFContentScrapingStrategy with other scraping strategies in one request",
698+
)
699+
return all(pdf)
700+
701+
651702
def _normalize_and_validate_seeds(urls: List[str]) -> Tuple[List[str], List[CrawlResult]]:
652703
"""Prefix bare hosts with https:// and SSRF-validate every seed URL's
653704
destination. Shared by the streaming and non-streaming crawl handlers so a
@@ -712,42 +763,39 @@ async def handle_crawl_request(
712763
) if config["crawler"]["rate_limiter"]["enabled"] else None
713764
)
714765

766+
base_config = config["crawler"]["base_config"]
767+
# Per-URL config list: deserialize each and apply base_config. Loaded
768+
# before a crawler is chosen, because the list decides which one runs.
769+
config_list = (
770+
_load_crawler_configs(crawler_configs, base_config)
771+
if crawler_configs
772+
else None
773+
)
774+
715775
from crawler_pool import get_crawler, release_crawler
716776
from crawl4ai.processors.pdf import PDFContentScrapingStrategy, PDFCrawlerStrategy
717-
is_pdf_crawl = isinstance(crawler_config.scraping_strategy, PDFContentScrapingStrategy)
777+
is_pdf_crawl = _needs_pdf_crawler(crawler_config, config_list)
718778
if is_pdf_crawl:
719779
if hooks_config:
720780
# PDFCrawlerStrategy has no browser page, so hooks can't attach to it
721781
raise HTTPException(status_code=400, detail="Hooks are not supported with PDFContentScrapingStrategy")
722782
# SSRF protection: vet the PDF download URL and every redirect hop
723-
crawler_config.scraping_strategy.url_validator = validate_url_destination
783+
if isinstance(crawler_config.scraping_strategy, PDFContentScrapingStrategy):
784+
crawler_config.scraping_strategy.url_validator = validate_url_destination
724785
# Use PDFCrawlerStrategy when scraping PDFs, as headless Chromium can't render PDFs inline
725786
crawler = AsyncWebCrawler(crawler_strategy=PDFCrawlerStrategy())
726787
await crawler.start()
727788
else:
728789
crawler = await get_crawler(browser_config)
729-
790+
730791
# Attach declarative hooks if provided
731792
hooks_status = {}
732793
if hooks_config:
733794
hooks_status = _attach_declarative_hooks(crawler, hooks_config)
734795
logger.info(f"Hooks attachment status: {hooks_status['status']}")
735-
736-
base_config = config["crawler"]["base_config"]
737796

738797
# Build the config(s) to pass to arun/arun_many
739-
if crawler_configs and len(urls) > 1:
740-
# Per-URL config list: deserialize each and apply base_config
741-
config_list = [CrawlerRunConfig.load(cc, provenance=Provenance.UNTRUSTED) for cc in crawler_configs]
742-
for cfg in config_list:
743-
for key, value in base_config.items():
744-
if hasattr(cfg, key):
745-
current_value = getattr(cfg, key)
746-
if current_value is None or current_value == "":
747-
setattr(cfg, key, value)
748-
# SSRF: per-URL PDF strategies need the validator wired too
749-
if isinstance(cfg.scraping_strategy, PDFContentScrapingStrategy):
750-
cfg.scraping_strategy.url_validator = validate_url_destination
798+
if config_list:
751799
effective_config = config_list
752800
else:
753801
# Single config (original behavior)
@@ -759,9 +807,12 @@ async def handle_crawl_request(
759807
effective_config = crawler_config
760808

761809
results = []
762-
func = getattr(crawler, "arun" if len(urls) == 1 else "arun_many")
810+
# arun takes one config, so a per-URL list goes to arun_many even for
811+
# a single URL; arun_many matches each URL against the list.
812+
use_many = len(urls) > 1 or isinstance(effective_config, list)
813+
func = getattr(crawler, "arun_many" if use_many else "arun")
763814
partial_func = partial(func,
764-
urls[0] if len(urls) == 1 else urls,
815+
urls if use_many else urls[0],
765816
config=effective_config,
766817
dispatcher=dispatcher)
767818
# Optional per-crawl wall-clock deadline (config limits.wall_clock_s; 0 = none).
@@ -911,7 +962,8 @@ async def handle_stream_crawl_request(
911962
browser_config: dict,
912963
crawler_config: dict,
913964
config: dict,
914-
hooks_config: Optional[dict] = None
965+
hooks_config: Optional[dict] = None,
966+
crawler_configs: Optional[List[dict]] = None,
915967
) -> Tuple[AsyncWebCrawler, AsyncGenerator, Optional[Dict]]:
916968
"""Handle streaming crawl requests with optional hooks."""
917969
hooks_info = None
@@ -936,6 +988,13 @@ async def handle_stream_crawl_request(
936988

937989
clamp_deep_crawl(crawler_config)
938990
crawler_config.stream = True
991+
config_list = (
992+
_load_crawler_configs(
993+
crawler_configs, config["crawler"]["base_config"], stream=True
994+
)
995+
if crawler_configs
996+
else None
997+
)
939998

940999
# Deep crawl streaming supports exactly one start URL
9411000
if crawler_config.deep_crawl_strategy is not None and len(urls) + len(refused) != 1:
@@ -949,12 +1008,13 @@ async def handle_stream_crawl_request(
9491008

9501009
from crawler_pool import get_crawler
9511010
from crawl4ai.processors.pdf import PDFContentScrapingStrategy, PDFCrawlerStrategy
952-
if isinstance(crawler_config.scraping_strategy, PDFContentScrapingStrategy):
1011+
if _needs_pdf_crawler(crawler_config, config_list):
9531012
if hooks_config:
9541013
# PDFCrawlerStrategy has no browser page, so hooks can't attach to it
9551014
raise HTTPException(status_code=400, detail="Hooks are not supported with PDFContentScrapingStrategy")
9561015
# SSRF protection: vet the PDF download URL and every redirect hop
957-
crawler_config.scraping_strategy.url_validator = validate_url_destination
1016+
if isinstance(crawler_config.scraping_strategy, PDFContentScrapingStrategy):
1017+
crawler_config.scraping_strategy.url_validator = validate_url_destination
9581018
# Use PDFCrawlerStrategy when scraping PDFs, as headless Chromium can't render PDFs inline
9591019
crawler = AsyncWebCrawler(crawler_strategy=PDFCrawlerStrategy())
9601020
await crawler.start()
@@ -984,7 +1044,7 @@ async def handle_stream_crawl_request(
9841044
)
9851045
results_gen = await crawler.arun_many(
9861046
urls=urls,
987-
config=crawler_config,
1047+
config=config_list or crawler_config,
9881048
dispatcher=dispatcher
9891049
)
9901050

‎deploy/docker/server.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1032,7 +1032,8 @@ async def stream_process(crawl_request: CrawlRequestWithHooks):
10321032
browser_config=crawl_request.browser_config,
10331033
crawler_config=crawl_request.crawler_config,
10341034
config=config,
1035-
hooks_config=hooks_config
1035+
hooks_config=hooks_config,
1036+
crawler_configs=crawl_request.crawler_configs,
10361037
)
10371038

10381039
# Add hooks info to response headers if available

0 commit comments

Comments
 (0)