@@ -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+
651702def _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
0 commit comments