unique_web_search 2026.22.0.dev0__tar.gz → 2026.22.0.dev2__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.
Files changed (118) hide show
  1. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/PKG-INFO +3 -2
  2. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/pyproject.toml +3 -2
  3. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/metrics.py +5 -0
  4. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/base.py +15 -1
  5. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/basic.py +49 -6
  6. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/crawl4ai.py +49 -2
  7. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/firecrawl.py +1 -1
  8. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/jina.py +1 -1
  9. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/tavily.py +1 -1
  10. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/executor.py +0 -1
  11. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v3/executor.py +0 -1
  12. unique_web_search-2026.22.0.dev2/src/unique_web_search/services/url_safety.py +458 -0
  13. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/settings.py +17 -0
  14. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/README.md +0 -0
  15. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/__init__.py +0 -0
  16. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/client_settings.py +0 -0
  17. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/config.py +0 -0
  18. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/prompts.py +0 -0
  19. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/schema.py +0 -0
  20. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/service.py +0 -0
  21. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/__init__.py +0 -0
  22. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/config.py +0 -0
  23. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/exceptions.py +0 -0
  24. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/prompts/__init__.py +0 -0
  25. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/prompts/guidelines.j2 +0 -0
  26. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/prompts/rejection_response.j2 +0 -0
  27. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/prompts/system_prompt.j2 +0 -0
  28. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/prompts/user_prompt.j2 +0 -0
  29. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/argument_screening/service.py +0 -0
  30. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/client/proxy_config.py +0 -0
  31. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/__init__.py +0 -0
  32. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/cleaning/__init__.py +0 -0
  33. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/cleaning/character_sanitize.py +0 -0
  34. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/cleaning/clean.py +0 -0
  35. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/cleaning/config.py +0 -0
  36. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/config.py +0 -0
  37. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/base.py +0 -0
  38. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/config.py +0 -0
  39. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/llm_guard_judge.py +0 -0
  40. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/llm_keyword_redact.py +0 -0
  41. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/llm_process.py +0 -0
  42. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/__init__.py +0 -0
  43. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_and_sanitize_prompt.j2 +0 -0
  44. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_prompt.j2 +0 -0
  45. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/keyword_extract_prompt.j2 +0 -0
  46. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/page_context.j2 +0 -0
  47. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/sanitize_rules.j2 +0 -0
  48. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/system_prompt.j2 +0 -0
  49. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/user_prompt.j2 +0 -0
  50. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/schema.py +0 -0
  51. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/settings.py +0 -0
  52. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/truncate.py +0 -0
  53. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/content_processing/service.py +0 -0
  54. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/README.md +0 -0
  55. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/__init__.py +0 -0
  56. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/utils.py +0 -0
  57. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/README.md +0 -0
  58. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/__init__.py +0 -0
  59. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/base_config.py +0 -0
  60. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/base_executor.py +0 -0
  61. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/context.py +0 -0
  62. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/__init__.py +0 -0
  63. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/config.py +0 -0
  64. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/executor.py +0 -0
  65. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/prompts/__init__.py +0 -0
  66. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/prompts/refine_query_system_prompt.j2 +0 -0
  67. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/prompts/restrict_date_description.j2 +0 -0
  68. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/prompts/tool_description.j2 +0 -0
  69. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/prompts/tool_description_for_system_prompt.j2 +0 -0
  70. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v1/schema.py +0 -0
  71. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/__init__.py +0 -0
  72. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/config.py +0 -0
  73. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/prompts/__init__.py +0 -0
  74. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/prompts/step_query_description.j2 +0 -0
  75. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/prompts/tool_description.j2 +0 -0
  76. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/prompts/tool_description_for_system_prompt.j2 +0 -0
  77. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v2/schema.py +0 -0
  78. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v3/__init__.py +0 -0
  79. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v3/config.py +0 -0
  80. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v3/prompts/__init__.py +0 -0
  81. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v3/prompts/tool_description.j2 +0 -0
  82. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v3/prompts/tool_description_for_system_prompt.j2 +0 -0
  83. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/v3/schema.py +0 -0
  84. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/helpers.py +0 -0
  85. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/message_log.py +0 -0
  86. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/query_elicitation.py +0 -0
  87. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/README.md +0 -0
  88. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/__init__.py +0 -0
  89. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/base.py +0 -0
  90. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/bing.py +0 -0
  91. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/brave.py +0 -0
  92. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/custom_api.py +0 -0
  93. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/firecrawl.py +0 -0
  94. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/google.py +0 -0
  95. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/jina.py +0 -0
  96. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/schema.py +0 -0
  97. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/tavily.py +0 -0
  98. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/google/schema.py +0 -0
  99. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/__init__.py +0 -0
  100. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/__init__.py +0 -0
  101. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/client.py +0 -0
  102. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/models.py +0 -0
  103. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/runner.py +0 -0
  104. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/models.py +0 -0
  105. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/response_parsing.py +0 -0
  106. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/__init__.py +0 -0
  107. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/client.py +0 -0
  108. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/exceptions.py +0 -0
  109. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/gemini.py +0 -0
  110. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/response_handler.py +0 -0
  111. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/search_engine/vertexai.py +0 -0
  112. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/snippet_judge/__init__.py +0 -0
  113. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/snippet_judge/prompts.py +0 -0
  114. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/snippet_judge/schema.py +0 -0
  115. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/snippet_judge/service.py +0 -0
  116. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/structured_llm.py +0 -0
  117. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/skills/web-search-v3.md +0 -0
  118. {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-web-search
3
- Version: 2026.22.0.dev0
3
+ Version: 2026.22.0.dev2
4
4
  Summary:
5
5
  Author: Andreas Hauri, Gustav Hartz, Rami Azouz
6
6
  Author-email: Andreas Hauri <andreas@unique.ch>, Gustav Hartz <gustav.hartz.ext@unique.ch>, Rami Azouz <rami.ext@unique.ch>
@@ -17,7 +17,7 @@ Requires-Dist: tavily-python>=0.7.11,<1
17
17
  Requires-Dist: unidecode>=1.4.0,<2
18
18
  Requires-Dist: azure-ai-projects>=1.0.0,<2
19
19
  Requires-Dist: azure-identity>=1.25.0,<2
20
- Requires-Dist: unique-toolkit[monitoring]>=2026.22.0.dev0,<2026.22.0rc0
20
+ Requires-Dist: unique-toolkit[monitoring]>=2026.22.0.dev2,<2026.22.0rc0
21
21
  Requires-Dist: azure-core>=1.36.0,<2
22
22
  Requires-Dist: google-cloud-aiplatform>=1.128.0,<2
23
23
  Requires-Dist: google-auth>=2.43.0,<3
@@ -26,6 +26,7 @@ Requires-Dist: rapidfuzz>=3.0.0,<4
26
26
  Requires-Dist: httpx>=0.28.1
27
27
  Requires-Dist: certifi>=2025.11.12
28
28
  Requires-Dist: jinja2>=3.1.6
29
+ Requires-Dist: playwright>=1.58.0
29
30
  Requires-Python: >=3.12, <4.0
30
31
  Description-Content-Type: text/markdown
31
32
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_web_search"
3
- version = "2026.22.0.dev0"
3
+ version = "2026.22.0.dev2"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4.0"
@@ -23,7 +23,7 @@ dependencies = [
23
23
  "unidecode>=1.4.0,<2",
24
24
  "azure-ai-projects>=1.0.0,<2",
25
25
  "azure-identity>=1.25.0,<2",
26
- "unique-toolkit[monitoring]>=2026.22.0.dev0,<2026.22.0rc0",
26
+ "unique-toolkit[monitoring]>=2026.22.0.dev2,<2026.22.0rc0",
27
27
  "azure-core>=1.36.0,<2",
28
28
  "google-cloud-aiplatform>=1.128.0,<2",
29
29
  "google-auth>=2.43.0,<3",
@@ -32,6 +32,7 @@ dependencies = [
32
32
  "httpx>=0.28.1",
33
33
  "certifi>=2025.11.12",
34
34
  "jinja2>=3.1.6",
35
+ "playwright>=1.58.0",
35
36
  ]
36
37
 
37
38
  [build-system]
@@ -39,3 +39,8 @@ crawl_duration = m.histogram("crawl_duration_seconds", "Crawl latency", ["crawle
39
39
  crawl_errors = m.counter(
40
40
  "crawl_errors_total", "Crawl failures", ["crawler", "error_type"]
41
41
  )
42
+ crawl_blocked = m.counter(
43
+ "crawl_blocked_total",
44
+ "Crawl targets blocked by URL safety policy",
45
+ ["reason_category"],
46
+ )
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  from abc import ABC, abstractmethod
2
3
  from enum import StrEnum
3
4
  from typing import Generic, TypeVar
@@ -11,6 +12,11 @@ from unique_web_search.services.helpers import (
11
12
  clean_model_title_generator,
12
13
  experimental_model_title_generator,
13
14
  )
15
+ from unique_web_search.services.url_safety import (
16
+ resolve_redirect_chain,
17
+ validate_crawl_urls,
18
+ )
19
+ from unique_web_search.settings import env_settings
14
20
 
15
21
 
16
22
  class CrawlerType(StrEnum):
@@ -50,5 +56,13 @@ class BaseCrawler(ABC, Generic[CrawlerConfig]):
50
56
  def __init__(self, config: CrawlerConfig):
51
57
  self.config = config
52
58
 
59
+ async def crawl(self, urls: list[str]) -> list[str]:
60
+ urls = await validate_crawl_urls(urls)
61
+ if env_settings.url_safety_resolve_redirects:
62
+ urls = list(
63
+ await asyncio.gather(*[resolve_redirect_chain(u) for u in urls])
64
+ )
65
+ return await self._crawl(urls)
66
+
53
67
  @abstractmethod
54
- async def crawl(self, urls: list[str]) -> list[str]: ...
68
+ async def _crawl(self, urls: list[str]) -> list[str]: ...
@@ -7,6 +7,7 @@ import timeout_decorator
7
7
  from httpx import AsyncClient, Timeout
8
8
  from markdownify import markdownify
9
9
  from pydantic import Field
10
+ from typing_extensions import override
10
11
 
11
12
  from unique_web_search.services.client.proxy_config import async_client
12
13
  from unique_web_search.services.crawlers.base import (
@@ -15,6 +16,10 @@ from unique_web_search.services.crawlers.base import (
15
16
  CrawlerType,
16
17
  )
17
18
  from unique_web_search.services.crawlers.utils import get_random_user_agent
19
+ from unique_web_search.services.url_safety import (
20
+ CrawlTargetValidationError,
21
+ resolve_crawl_target,
22
+ )
18
23
 
19
24
  _LOGGER = logging.getLogger(__name__)
20
25
 
@@ -49,18 +54,29 @@ class BasicCrawler(BaseCrawler[BasicCrawlerConfig]):
49
54
  def __init__(self, config: BasicCrawlerConfig):
50
55
  super().__init__(config)
51
56
 
52
- self.semaphore = asyncio.Semaphore(self.config.max_concurrent_requests)
57
+ self.semaphore: asyncio.Semaphore = asyncio.Semaphore(
58
+ self.config.max_concurrent_requests
59
+ )
53
60
 
54
61
  # TODO: Find a good way for tracking
55
62
  # @track(
56
63
  # tags=["basic", "scrape"],
57
64
  # )
58
- async def crawl(self, urls: list[str]) -> list[str]:
65
+ @override
66
+ async def _crawl(self, urls: list[str]) -> list[str]:
59
67
  async with async_client(timeout=Timeout(self.config.timeout)) as client:
60
68
  tasks = [self._crawl_url_with_client(client, url) for url in urls]
61
69
  results = await asyncio.gather(*tasks, return_exceptions=True)
62
70
 
63
- markdowns = []
71
+ validation_errors = [
72
+ result
73
+ for result in results
74
+ if isinstance(result, CrawlTargetValidationError)
75
+ ]
76
+ if validation_errors:
77
+ raise validation_errors[0]
78
+
79
+ markdowns: list[str] = []
64
80
  for result in results:
65
81
  if isinstance(result, BaseException):
66
82
  markdowns.append(
@@ -78,11 +94,22 @@ class BasicCrawler(BaseCrawler[BasicCrawlerConfig]):
78
94
  if self._is_url_blacklisted(url):
79
95
  return "Unable to crawl URL due to blacklisted pattern"
80
96
 
81
- response = await client.get(url, headers=headers)
97
+ (
98
+ request_url,
99
+ request_headers,
100
+ request_extensions,
101
+ ) = await self._build_request_target(url, headers)
102
+ response = await client.get(
103
+ request_url,
104
+ headers=request_headers,
105
+ extensions=request_extensions or None,
106
+ )
82
107
 
83
- response.raise_for_status()
108
+ _ = response.raise_for_status()
84
109
 
85
- content_type = response.headers.get("content-type", "").lower().split(";")[0]
110
+ content_type = (
111
+ str(response.headers.get("content-type", "")).lower().split(";")[0]
112
+ )
86
113
 
87
114
  if self._content_type_not_allowed(content_type):
88
115
  return f"Content type {content_type} is not allowed"
@@ -101,6 +128,22 @@ class BasicCrawler(BaseCrawler[BasicCrawlerConfig]):
101
128
  def _content_type_not_allowed(self, content_type: str) -> bool:
102
129
  return content_type in self.config.unwanted_content_types
103
130
 
131
+ async def _build_request_target(
132
+ self,
133
+ url: str,
134
+ headers: dict[str, str],
135
+ ) -> tuple[str, dict[str, str], dict[str, str]]:
136
+ resolved_target = await resolve_crawl_target(url)
137
+
138
+ request_headers = dict(headers)
139
+ request_extensions: dict[str, str] = {}
140
+ if resolved_target.host_header is not None:
141
+ request_headers["Host"] = resolved_target.host_header
142
+ if resolved_target.sni_hostname is not None:
143
+ request_extensions["sni_hostname"] = resolved_target.sni_hostname
144
+
145
+ return resolved_target.request_url, request_headers, request_extensions
146
+
104
147
 
105
148
  def _markdownify_html_with_timeout(content: str, timeout: float) -> str:
106
149
  @timeout_decorator.timeout(timeout)
@@ -2,6 +2,8 @@ import logging
2
2
  from enum import StrEnum
3
3
  from typing import Any, Literal
4
4
 
5
+ from crawl4ai.async_webcrawler import AsyncPlaywrightCrawlerStrategy
6
+ from playwright.async_api import Page, Request, Route
5
7
  from pydantic import BaseModel, Field
6
8
  from unique_toolkit.agentic.tools.config import get_configuration_dict
7
9
 
@@ -11,10 +13,45 @@ from unique_web_search.services.crawlers.base import (
11
13
  CrawlerType,
12
14
  )
13
15
  from unique_web_search.services.crawlers.utils import get_random_user_agent
16
+ from unique_web_search.services.url_safety import validate_url
14
17
 
15
18
  _LOGGER = logging.getLogger(__name__)
16
19
 
17
20
 
21
+ async def _ssrf_guard_hook(page: Page, **kwargs: object) -> None:
22
+ """Playwright ``before_goto`` hook that installs a route interceptor.
23
+
24
+ Registers a ``page.route`` handler that validates **every** network request
25
+ Chromium makes (navigation, sub-resources, JS ``fetch``) against the shared
26
+ url_safety rules. Requests to internal/private targets are aborted before
27
+ any TCP connection is opened, closing bypass vectors A–D and sub-resource
28
+ SSRF that pre-flight URL validation cannot see.
29
+
30
+ crawl4ai calls this hook as::
31
+
32
+ hook(page, context=context, url=url, config=config)
33
+
34
+ so all arguments after ``page`` are captured via ``**kwargs``.
35
+ """
36
+
37
+ async def _route_handler(route: Route, request: Request) -> None:
38
+ req_url = request.url
39
+ error = await validate_url(req_url)
40
+ if error is not None:
41
+ category, reason = error
42
+ _LOGGER.warning(
43
+ "Crawl4AI blocked internal request: %s — %s (%s)",
44
+ req_url,
45
+ reason,
46
+ category,
47
+ )
48
+ await route.abort("blockedbyclient")
49
+ return
50
+ await route.continue_()
51
+
52
+ await page.route("**/*", _route_handler)
53
+
54
+
18
55
  class DisplayMode(StrEnum):
19
56
  DETAILED = "DETAILED"
20
57
  AGGREGATED = "AGGREGATED"
@@ -164,7 +201,7 @@ class Crawl4AiCrawler(BaseCrawler[Crawl4AiCrawlerConfig]):
164
201
  # @track(
165
202
  # tags=["crawl4ai", "scrape"],
166
203
  # )
167
- async def crawl(self, urls: list[str]) -> list[str]:
204
+ async def _crawl(self, urls: list[str]) -> list[str]:
168
205
  # Lazy import of crawl4ai - only import when actually needed
169
206
  from crawl4ai import (
170
207
  AsyncWebCrawler,
@@ -234,8 +271,18 @@ class Crawl4AiCrawler(BaseCrawler[Crawl4AiCrawlerConfig]):
234
271
  rate_limiter=rate_limiter,
235
272
  )
236
273
 
274
+ crawler_strategy = AsyncPlaywrightCrawlerStrategy(
275
+ browser_config=browser_config,
276
+ hooks={
277
+ "before_goto": _ssrf_guard_hook,
278
+ },
279
+ )
280
+
237
281
  _LOGGER.info(f"Crawling {len(urls)} URLs with Crawl4AiCrawler")
238
- async with AsyncWebCrawler(config=browser_config) as crawler:
282
+
283
+ async with AsyncWebCrawler(
284
+ crawler_strategy=crawler_strategy, config=browser_config
285
+ ) as crawler:
239
286
  crawler_results = await crawler.arun_many(
240
287
  urls, config=run_config, dispatcher=dispatcher
241
288
  )
@@ -24,7 +24,7 @@ class FirecrawlCrawler(BaseCrawler[FirecrawlCrawlerConfig]):
24
24
  # @track(
25
25
  # tags=["firecrawl", "scrape"],
26
26
  # )
27
- async def crawl(self, urls: list[str]) -> list[str]:
27
+ async def _crawl(self, urls: list[str]) -> list[str]:
28
28
  api_key = get_firecrawl_search_settings().api_key
29
29
  assert api_key is not None, "Firecrawl API key is not configured"
30
30
 
@@ -52,7 +52,7 @@ class JinaCrawler(BaseCrawler[JinaCrawlerConfig]):
52
52
  # @track(
53
53
  # tags=["jina", "scrape"],
54
54
  # )
55
- async def crawl(self, urls: list[str]) -> list[str]:
55
+ async def _crawl(self, urls: list[str]) -> list[str]:
56
56
  jina_settings = get_jina_search_settings()
57
57
  api_key = jina_settings.api_key
58
58
  assert api_key is not None, "Jina API key is not configured"
@@ -24,7 +24,7 @@ class TavilyCrawler(BaseCrawler[TavilyCrawlerConfig]):
24
24
  # @track(
25
25
  # tags=["tavily", "scrape"],
26
26
  # )
27
- async def crawl(self, urls: list[str]) -> list[str]:
27
+ async def _crawl(self, urls: list[str]) -> list[str]:
28
28
  api_key = get_tavily_search_settings().api_key
29
29
  assert api_key is not None, "Tavily API key is not configured"
30
30
 
@@ -196,7 +196,6 @@ class WebSearchV2Executor(BaseWebSearchExecutor[WebSearchPlan]):
196
196
  crawler = self.crawler_service.config.crawler_type.value
197
197
  time_start = time()
198
198
  _LOGGER.info(f"Company {self.company_id} Crawling with {self.crawler_service}")
199
-
200
199
  with metric_scope(crawl_duration, crawl_errors, crawler=crawler):
201
200
  results = await self.crawler_service.crawl([step.query_or_url])
202
201
  await self._message_log_callback.log_web_search_results(
@@ -182,7 +182,6 @@ class WebSearchV3Executor(BaseWebSearchExecutor[WebSearchV3ToolParameters]):
182
182
  crawler = self.crawler_service.config.crawler_type.value
183
183
  time_start = time()
184
184
  _LOGGER.info("Company %s Crawling %s URLs", self.company_id, len(urls))
185
-
186
185
  await self._message_log_callback.log_queries(urls)
187
186
  with metric_scope(crawl_duration, crawl_errors, crawler=crawler):
188
187
  contents = await self.crawler_service.crawl(urls)
@@ -0,0 +1,458 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import socket
6
+ from dataclasses import dataclass
7
+ from ipaddress import ip_address
8
+ from urllib.parse import SplitResult, urljoin, urlsplit, urlunsplit
9
+
10
+ import httpx
11
+
12
+ from unique_web_search.metrics import crawl_blocked
13
+ from unique_web_search.settings import env_settings
14
+
15
+ _LOGGER = logging.getLogger(__name__)
16
+
17
+ _MAX_REDIRECT_HOPS = 10
18
+ _REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
19
+
20
+
21
+ def _allowed_schemes() -> frozenset[str]:
22
+ return frozenset(env_settings.url_safety_allowed_schemes)
23
+
24
+
25
+ def _localhost_hosts() -> frozenset[str]:
26
+ return frozenset(env_settings.url_safety_localhost_hosts)
27
+
28
+
29
+ def _metadata_hosts() -> frozenset[str]:
30
+ return frozenset(env_settings.url_safety_metadata_hosts)
31
+
32
+
33
+ def _cluster_local_suffix() -> str:
34
+ return env_settings.url_safety_cluster_local_suffix
35
+
36
+
37
+ def _service_suffix() -> str:
38
+ return env_settings.url_safety_service_suffix
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class BlockedCrawlTarget:
43
+ hostname: str | None
44
+ category: str
45
+ reason: str
46
+
47
+ @property
48
+ def display_target(self) -> str:
49
+ return self.hostname or "<unknown-host>"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ResolvedCrawlTarget:
54
+ """Carries the validated host/IP values reused when issuing crawl requests."""
55
+
56
+ normalized_url: str
57
+ hostname: str
58
+ resolved_ip: str
59
+ used_dns_resolution: bool
60
+
61
+ @property
62
+ def request_url(self) -> str:
63
+ return _replace_url_host(urlsplit(self.normalized_url), host=self.resolved_ip)
64
+
65
+ @property
66
+ def host_header(self) -> str | None:
67
+ if not self.used_dns_resolution:
68
+ return None
69
+
70
+ return _build_netloc(
71
+ host=self.hostname, port=urlsplit(self.normalized_url).port
72
+ )
73
+
74
+ @property
75
+ def sni_hostname(self) -> str | None:
76
+ if not self.used_dns_resolution:
77
+ return None
78
+
79
+ if urlsplit(self.normalized_url).scheme.lower() != "https":
80
+ return None
81
+
82
+ return self.hostname
83
+
84
+
85
+ class CrawlTargetValidationError(ValueError):
86
+ def __init__(self, blocked_targets: list[BlockedCrawlTarget]):
87
+ self.blocked_targets: list[BlockedCrawlTarget] = blocked_targets
88
+
89
+ for target in blocked_targets:
90
+ crawl_blocked.labels(reason_category=target.category).inc()
91
+
92
+ details = "; ".join(
93
+ f"{target.display_target} ({target.reason})"
94
+ for target in self.blocked_targets
95
+ )
96
+ super().__init__(f"Blocked crawl target(s) due to URL safety policy: {details}")
97
+
98
+
99
+ async def validate_crawl_urls(urls: list[str]) -> list[str]:
100
+ normalized_urls: list[str] = []
101
+ blocked_targets: list[BlockedCrawlTarget] = []
102
+
103
+ for raw_url in urls:
104
+ normalized_url = raw_url.strip()
105
+ validation_error = _validate_crawl_target_cheap(normalized_url)
106
+ if validation_error is not None:
107
+ category, reason = validation_error
108
+ blocked_targets.append(
109
+ BlockedCrawlTarget(
110
+ hostname=_extract_hostname(normalized_url or raw_url),
111
+ category=category,
112
+ reason=reason,
113
+ )
114
+ )
115
+ else:
116
+ normalized_urls.append(normalized_url)
117
+
118
+ if blocked_targets:
119
+ raise CrawlTargetValidationError(blocked_targets)
120
+
121
+ # Validate hostnames via DNS concurrently to avoid blocking the event loop
122
+ urls_needing_dns = [
123
+ (url, h)
124
+ for url in normalized_urls
125
+ if (h := _hostname_requiring_dns(url)) is not None
126
+ ]
127
+ if urls_needing_dns:
128
+ dns_results = await asyncio.gather(
129
+ *[_validate_resolved_host(h) for _, h in urls_needing_dns]
130
+ )
131
+ dns_blocked = [
132
+ BlockedCrawlTarget(
133
+ hostname=_extract_hostname(url),
134
+ category=category,
135
+ reason=reason,
136
+ )
137
+ for (url, _), dns_error in zip(urls_needing_dns, dns_results)
138
+ if dns_error is not None
139
+ for category, reason in [dns_error]
140
+ ]
141
+ if dns_blocked:
142
+ raise CrawlTargetValidationError(dns_blocked)
143
+
144
+ return normalized_urls
145
+
146
+
147
+ async def resolve_crawl_target(url: str) -> ResolvedCrawlTarget:
148
+ """Validate and resolve a single URL, performing DNS exactly once."""
149
+ normalized_url = url.strip()
150
+
151
+ validation_error = _validate_crawl_target_cheap(normalized_url)
152
+ if validation_error is not None:
153
+ category, reason = validation_error
154
+ raise CrawlTargetValidationError(
155
+ [
156
+ BlockedCrawlTarget(
157
+ hostname=_extract_hostname(normalized_url),
158
+ category=category,
159
+ reason=reason,
160
+ )
161
+ ]
162
+ )
163
+
164
+ parsed_url = urlsplit(normalized_url)
165
+ hostname = parsed_url.hostname
166
+ if hostname is None:
167
+ raise CrawlTargetValidationError(
168
+ [
169
+ BlockedCrawlTarget(
170
+ hostname=None,
171
+ category="host",
172
+ reason="URL host is missing or malformed",
173
+ )
174
+ ]
175
+ )
176
+
177
+ normalized_host = hostname.rstrip(".").lower()
178
+
179
+ try:
180
+ target_ip = ip_address(normalized_host)
181
+ except ValueError:
182
+ resolved_addresses, validation_error = await _resolve_and_validate_host(
183
+ normalized_host
184
+ )
185
+ if validation_error is not None:
186
+ category, reason = validation_error
187
+ raise CrawlTargetValidationError(
188
+ [
189
+ BlockedCrawlTarget(
190
+ hostname=normalized_host,
191
+ category=category,
192
+ reason=reason,
193
+ )
194
+ ]
195
+ )
196
+ return ResolvedCrawlTarget(
197
+ normalized_url=normalized_url,
198
+ hostname=normalized_host,
199
+ resolved_ip=resolved_addresses[0],
200
+ used_dns_resolution=True,
201
+ )
202
+
203
+ return ResolvedCrawlTarget(
204
+ normalized_url=normalized_url,
205
+ hostname=normalized_host,
206
+ resolved_ip=str(target_ip),
207
+ used_dns_resolution=False,
208
+ )
209
+
210
+
211
+ def _validate_crawl_target_cheap(url: str) -> tuple[str, str] | None:
212
+ """Static validation only — no DNS. Returns (category, reason) if blocked, None otherwise."""
213
+ if not url:
214
+ return "empty", "URL is empty"
215
+
216
+ parsed_url = urlsplit(url)
217
+ if parsed_url.scheme.lower() not in _allowed_schemes():
218
+ return "scheme", "URL scheme must be http or https"
219
+
220
+ hostname = parsed_url.hostname
221
+ if not hostname:
222
+ return "host", "URL host is missing or malformed"
223
+
224
+ normalized_host = hostname.rstrip(".").lower()
225
+
226
+ if normalized_host in _metadata_hosts():
227
+ return "metadata", "Target points to a metadata endpoint"
228
+
229
+ if normalized_host in _localhost_hosts() or normalized_host.endswith(".localhost"):
230
+ return "localhost", "Target points to a localhost host"
231
+
232
+ if normalized_host.endswith(_service_suffix()):
233
+ return "cluster", "Target points to an internal service host"
234
+
235
+ if normalized_host.endswith(_cluster_local_suffix()):
236
+ return "cluster", "Target points to an internal cluster-local host"
237
+
238
+ try:
239
+ target_ip = ip_address(normalized_host)
240
+ except ValueError:
241
+ pass
242
+ else:
243
+ if not target_ip.is_global:
244
+ return "private", "Target points to a private or special-use IP address"
245
+
246
+ return None
247
+
248
+ if "." not in normalized_host:
249
+ return "cluster", "Target points to a single-label internal host"
250
+
251
+ return None
252
+
253
+
254
+ async def validate_url(url: str) -> tuple[str, str] | None:
255
+ """Full validation including async DNS check.
256
+
257
+ Returns a ``(category, reason)`` tuple if the URL should be blocked, or
258
+ ``None`` if it is allowed. Callers that need to raise should wrap the
259
+ result in :class:`CrawlTargetValidationError`.
260
+ """
261
+ error = _validate_crawl_target_cheap(url)
262
+ if error is not None:
263
+ return error
264
+
265
+ hostname = urlsplit(url).hostname
266
+ if hostname is None:
267
+ return None
268
+
269
+ normalized_host = hostname.rstrip(".").lower()
270
+ try:
271
+ ip_address(normalized_host)
272
+ return None
273
+ except ValueError:
274
+ pass
275
+
276
+ return await _validate_resolved_host(normalized_host)
277
+
278
+
279
+ def _hostname_requiring_dns(url: str) -> str | None:
280
+ """Return the normalized hostname if DNS validation is needed, None otherwise."""
281
+ hostname = urlsplit(url).hostname
282
+ if hostname is None:
283
+ return None
284
+ normalized_host = hostname.rstrip(".").lower()
285
+ try:
286
+ ip_address(normalized_host)
287
+ return None
288
+ except ValueError:
289
+ return normalized_host
290
+
291
+
292
+ def _extract_hostname(url: str) -> str | None:
293
+ normalized_url = url.strip()
294
+ if not normalized_url:
295
+ return None
296
+
297
+ hostname = urlsplit(normalized_url).hostname
298
+ if hostname is None:
299
+ return None
300
+
301
+ return hostname.rstrip(".").lower()
302
+
303
+
304
+ async def _validate_resolved_host(host: str) -> tuple[str, str] | None:
305
+ _, validation_error = await _resolve_and_validate_host(host)
306
+ return validation_error
307
+
308
+
309
+ async def _resolve_and_validate_host(
310
+ host: str,
311
+ ) -> tuple[tuple[str, ...], tuple[str, str] | None]:
312
+ try:
313
+ resolved_addresses = await _resolve_host_addresses(host)
314
+ except socket.gaierror:
315
+ return (), (
316
+ "dns",
317
+ "Target host could not be resolved during safety validation",
318
+ )
319
+
320
+ return resolved_addresses, _block_reason_for_resolved_addresses(resolved_addresses)
321
+
322
+
323
+ async def _resolve_host_addresses(host: str) -> tuple[str, ...]:
324
+ loop = asyncio.get_running_loop()
325
+ resolved = await loop.run_in_executor(
326
+ None, lambda: socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
327
+ )
328
+ return tuple(
329
+ dict.fromkeys(str(ip_address(sockaddr[0])) for _, _, _, _, sockaddr in resolved)
330
+ )
331
+
332
+
333
+ def _block_reason_for_resolved_addresses(
334
+ resolved_addresses: tuple[str, ...],
335
+ ) -> tuple[str, str] | None:
336
+ if not resolved_addresses:
337
+ return (
338
+ "dns",
339
+ "Target host could not be resolved during safety validation",
340
+ )
341
+
342
+ for resolved_address in resolved_addresses:
343
+ if not ip_address(resolved_address).is_global:
344
+ return (
345
+ "private",
346
+ "Target host resolves to a private or special-use IP address",
347
+ )
348
+
349
+ return None
350
+
351
+
352
+ def _replace_url_host(parsed_url: SplitResult, *, host: str) -> str:
353
+ netloc = _build_netloc(
354
+ host=host,
355
+ port=parsed_url.port,
356
+ username=parsed_url.username,
357
+ password=parsed_url.password,
358
+ )
359
+ return urlunsplit(
360
+ (
361
+ parsed_url.scheme,
362
+ netloc,
363
+ parsed_url.path,
364
+ parsed_url.query,
365
+ "",
366
+ )
367
+ )
368
+
369
+
370
+ def _build_netloc(
371
+ host: str,
372
+ port: int | None,
373
+ username: str | None = None,
374
+ password: str | None = None,
375
+ ) -> str:
376
+ credential_prefix = ""
377
+ if username is not None:
378
+ credential_prefix = username
379
+ if password is not None:
380
+ credential_prefix = f"{credential_prefix}:{password}"
381
+ credential_prefix = f"{credential_prefix}@"
382
+
383
+ try:
384
+ host_ip = ip_address(host)
385
+ except ValueError:
386
+ normalized_host = host
387
+ else:
388
+ normalized_host = (
389
+ f"[{host_ip.compressed}]" if host_ip.version == 6 else host_ip.compressed
390
+ )
391
+
392
+ if port is None:
393
+ return f"{credential_prefix}{normalized_host}"
394
+
395
+ return f"{credential_prefix}{normalized_host}:{port}"
396
+
397
+
398
+ async def resolve_redirect_chain(
399
+ url: str,
400
+ *,
401
+ max_hops: int = _MAX_REDIRECT_HOPS,
402
+ timeout: float = 10.0,
403
+ ) -> str:
404
+ """Follow HTTP 3xx redirects hop-by-hop, validating each destination
405
+ against url_safety rules before issuing the next request.
406
+
407
+ Returns the final validated URL.
408
+ Raises CrawlTargetValidationError if any hop is blocked.
409
+ """
410
+ current = url
411
+ async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
412
+ for _ in range(max_hops):
413
+ error = await validate_url(current)
414
+ if error is not None:
415
+ category, reason = error
416
+ raise CrawlTargetValidationError(
417
+ [
418
+ BlockedCrawlTarget(
419
+ hostname=_extract_hostname(current),
420
+ category=category,
421
+ reason=reason,
422
+ )
423
+ ]
424
+ )
425
+
426
+ try:
427
+ resp = await client.head(current)
428
+ except Exception as exc:
429
+ _LOGGER.debug(
430
+ "Redirect resolution stopped at %s due to network error: %s",
431
+ current,
432
+ exc,
433
+ )
434
+ break
435
+
436
+ if resp.status_code not in _REDIRECT_STATUS_CODES:
437
+ break
438
+
439
+ location = resp.headers.get("location")
440
+ if not location:
441
+ break
442
+
443
+ current = urljoin(current, location)
444
+
445
+ error = await validate_url(current)
446
+ if error is not None:
447
+ category, reason = error
448
+ raise CrawlTargetValidationError(
449
+ [
450
+ BlockedCrawlTarget(
451
+ hostname=_extract_hostname(current),
452
+ category=category,
453
+ reason=reason,
454
+ )
455
+ ]
456
+ )
457
+
458
+ return current
@@ -109,6 +109,23 @@ class Base(BaseSettings):
109
109
  # LLM processor config override (JSON string matching LLMProcessorConfig schema)
110
110
  llm_process_config: str | None = None
111
111
 
112
+ # URL safety settings
113
+ url_safety_allowed_schemes: list[str] = ["http", "https"]
114
+ url_safety_localhost_hosts: list[str] = [
115
+ "localhost",
116
+ "localhost.localdomain",
117
+ ]
118
+ url_safety_metadata_hosts: list[str] = [
119
+ "100.100.100.200", # Alibaba Cloud
120
+ "169.254.169.254", # AWS / GCP / Azure IMDS
121
+ "169.254.170.2", # AWS ECS task credentials
122
+ "metadata.azure.internal",
123
+ "metadata.google.internal",
124
+ ]
125
+ url_safety_cluster_local_suffix: str = ".cluster.local"
126
+ url_safety_service_suffix: str = ".svc"
127
+ url_safety_resolve_redirects: bool = True
128
+
112
129
  @property
113
130
  def active_crawlers(self) -> list[str]:
114
131
  "Dynamically determine the active crawlers based on the API keys provided"