unique_web_search 2026.24.0.dev0__tar.gz → 2026.24.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 (128) hide show
  1. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/PKG-INFO +2 -2
  2. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/pyproject.toml +2 -2
  3. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/prompts.py +21 -2
  4. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/README.md +11 -0
  5. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/base.py +7 -13
  6. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/basic.py +17 -29
  7. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/crawl4ai.py +14 -6
  8. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/firecrawl.py +3 -1
  9. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/jina.py +3 -1
  10. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/tavily.py +3 -1
  11. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/__init__.py +15 -0
  12. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/dns.py +53 -0
  13. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/models.py +77 -0
  14. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/netloc.py +62 -0
  15. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/policy.py +52 -0
  16. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/redirect.py +81 -0
  17. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/resolver.py +103 -0
  18. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/service.py +64 -0
  19. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/settings.py +60 -0
  20. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v3/executor.py +4 -2
  21. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v3/prompts/tool_description.j2 +6 -2
  22. unique_web_search-2026.24.0.dev2/src/unique_web_search/services/executors/v3/prompts/tool_description_for_system_prompt.j2 +78 -0
  23. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v3/schema.py +41 -4
  24. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/settings.py +0 -17
  25. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/skills/web-search-v3.md +128 -29
  26. unique_web_search-2026.24.0.dev0/src/unique_web_search/services/executors/v3/prompts/tool_description_for_system_prompt.j2 +0 -40
  27. unique_web_search-2026.24.0.dev0/src/unique_web_search/services/url_safety.py +0 -458
  28. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/README.md +0 -0
  29. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/__init__.py +0 -0
  30. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/client_settings.py +0 -0
  31. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/config.py +0 -0
  32. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/metrics.py +0 -0
  33. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/schema.py +0 -0
  34. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/service.py +0 -0
  35. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/__init__.py +0 -0
  36. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/config.py +0 -0
  37. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/exceptions.py +0 -0
  38. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/prompts/__init__.py +0 -0
  39. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/prompts/guidelines.j2 +0 -0
  40. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/prompts/rejection_response.j2 +0 -0
  41. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/prompts/system_prompt.j2 +0 -0
  42. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/prompts/user_prompt.j2 +0 -0
  43. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/argument_screening/service.py +0 -0
  44. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/client/proxy_config.py +0 -0
  45. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/__init__.py +0 -0
  46. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/cleaning/__init__.py +0 -0
  47. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/cleaning/character_sanitize.py +0 -0
  48. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/cleaning/clean.py +0 -0
  49. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/cleaning/config.py +0 -0
  50. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/config.py +0 -0
  51. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/base.py +0 -0
  52. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/config.py +0 -0
  53. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/llm_guard_judge.py +0 -0
  54. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/llm_keyword_redact.py +0 -0
  55. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/llm_process.py +0 -0
  56. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/__init__.py +0 -0
  57. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_and_sanitize_prompt.j2 +0 -0
  58. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_prompt.j2 +0 -0
  59. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/keyword_extract_prompt.j2 +0 -0
  60. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/page_context.j2 +0 -0
  61. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/sanitize_rules.j2 +0 -0
  62. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/system_prompt.j2 +0 -0
  63. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/prompts/user_prompt.j2 +0 -0
  64. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/schema.py +0 -0
  65. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/settings.py +0 -0
  66. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/processing_strategies/truncate.py +0 -0
  67. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/content_processing/service.py +0 -0
  68. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/__init__.py +0 -0
  69. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/utils.py +0 -0
  70. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/README.md +0 -0
  71. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/__init__.py +0 -0
  72. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/base_config.py +0 -0
  73. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/base_executor.py +0 -0
  74. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/context.py +0 -0
  75. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/__init__.py +0 -0
  76. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/config.py +0 -0
  77. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/executor.py +0 -0
  78. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/prompts/__init__.py +0 -0
  79. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/prompts/refine_query_system_prompt.j2 +0 -0
  80. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/prompts/restrict_date_description.j2 +0 -0
  81. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/prompts/tool_description.j2 +0 -0
  82. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/prompts/tool_description_for_system_prompt.j2 +0 -0
  83. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v1/schema.py +0 -0
  84. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/__init__.py +0 -0
  85. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/config.py +0 -0
  86. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/executor.py +0 -0
  87. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/prompts/__init__.py +0 -0
  88. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/prompts/step_query_description.j2 +0 -0
  89. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/prompts/tool_description.j2 +0 -0
  90. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/prompts/tool_description_for_system_prompt.j2 +0 -0
  91. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v2/schema.py +0 -0
  92. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v3/__init__.py +0 -0
  93. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v3/config.py +0 -0
  94. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/v3/prompts/__init__.py +0 -0
  95. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/helpers.py +0 -0
  96. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/message_log.py +0 -0
  97. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/query_elicitation.py +0 -0
  98. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/README.md +0 -0
  99. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/__init__.py +0 -0
  100. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/base.py +0 -0
  101. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/bing.py +0 -0
  102. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/brave.py +0 -0
  103. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/custom_api.py +0 -0
  104. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/firecrawl.py +0 -0
  105. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/google.py +0 -0
  106. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/jina.py +0 -0
  107. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/schema.py +0 -0
  108. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/tavily.py +0 -0
  109. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/google/schema.py +0 -0
  110. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/__init__.py +0 -0
  111. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/__init__.py +0 -0
  112. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/client.py +0 -0
  113. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/models.py +0 -0
  114. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/bing/runner.py +0 -0
  115. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/models.py +0 -0
  116. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/response_parsing.py +0 -0
  117. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/__init__.py +0 -0
  118. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/client.py +0 -0
  119. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/exceptions.py +0 -0
  120. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/gemini.py +0 -0
  121. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/response_handler.py +0 -0
  122. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/search_engine/vertexai.py +0 -0
  123. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/snippet_judge/__init__.py +0 -0
  124. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/snippet_judge/prompts.py +0 -0
  125. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/snippet_judge/schema.py +0 -0
  126. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/snippet_judge/service.py +0 -0
  127. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/structured_llm.py +0 -0
  128. {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.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.24.0.dev0
3
+ Version: 2026.24.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.24.0.dev0,<2026.24.0rc0
20
+ Requires-Dist: unique-toolkit[monitoring]>=2026.24.0.dev10,<2026.24.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_web_search"
3
- version = "2026.24.0.dev0"
3
+ version = "2026.24.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.24.0.dev0,<2026.24.0rc0",
26
+ "unique-toolkit[monitoring]>=2026.24.0.dev10,<2026.24.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",
@@ -39,11 +39,28 @@ When multiple sources are available, prioritize:
39
39
  4. **Primary sources**: Original reports over summaries
40
40
  5. **Domain expertise**: Medical info from health institutions, financial from finance sources
41
41
 
42
- **When sources are insufficient**: Clearly state "Based on available search results..." and indicate limitations.
43
-
44
42
  **REMEMBER**: Source numbers reset with each new WebSearch. Only use numbers from the most recent tool response.
45
43
  """
46
44
 
45
+ _TOOL_FORMAT_INFORMATION_SYNTHESIS_SECTION = """
46
+ ## Synthesizing WebSearch results
47
+
48
+ After one or more WebSearch tool calls, **synthesize** findings in your answer. Do not only cite facts when a single source made the answer obvious.
49
+
50
+ **Evidence ledger (per facet or sub-question):** Track each facet with a confidence label:
51
+
52
+ - **Confirmed** — the exact fact is supported by cited tool content [sourceX].
53
+ - **Partial** — only part of the facet is supported; say what is missing [sourceX].
54
+ - **Not found** — no on-topic public evidence after reasonable search/refinement; state what you tried (no citation required for absence).
55
+ - **Related** — tool content is adjacent but does not answer the exact ask; cite it [sourceX] and label it **related, not confirmed**.
56
+
57
+ **Mandatory behaviors:**
58
+
59
+ - **Always summarize partial findings** before saying something is unavailable. Never reply with only "I couldn't find X" if the tool returned related material—put related material in a **Related leads** (or equivalent) subsection with citations.
60
+ - For multi-facet tasks (diligence, Source of Wealth, company profiles), use a structured answer: direct answer → evidence by facet → gaps and limitations → related leads → optional next steps.
61
+ - Distinguish **Confirmed** from **Related** in prose—do not imply ownership, revenue, or UBO facts unless citations support them.
62
+ """.strip()
63
+
47
64
 
48
65
  _TOOL_FORMAT_INFORMATION_V3_DOMAIN_DIVERSITY_SECTION = """
49
66
  ## Domain Diversity Requirement
@@ -61,4 +78,6 @@ DEFAULT_TOOL_FORMAT_INFORMATION_FOR_SYSTEM_PROMPT_V3 = (
61
78
  DEFAULT_TOOL_FORMAT_INFORMATION_FOR_SYSTEM_PROMPT.rstrip()
62
79
  + "\n\n"
63
80
  + _TOOL_FORMAT_INFORMATION_V3_DOMAIN_DIVERSITY_SECTION
81
+ + "\n\n"
82
+ + _TOOL_FORMAT_INFORMATION_SYNTHESIS_SECTION
64
83
  )
@@ -4,6 +4,17 @@ This directory contains implementations of the different web crawlers used by `u
4
4
 
5
5
  All crawlers inherit from the shared base classes in `base.py`.
6
6
 
7
+ ### URL safety
8
+
9
+ Crawlers route every URL through `services/crawlers/url_safety/` before fetching:
10
+
11
+ - **`BaseCrawler.crawl`** calls `UrlSafetyService.validate_batch_urls` first, then passes `list[ResolvedCrawlTarget]` to each crawler’s `_crawl`. When `url_safety_settings.enabled` is `False`, validation is bypassed and each URL is wrapped via `bypass_crawl_target` without DNS resolution or policy checks.
12
+ - **`BasicCrawler`** uses `ResolvedCrawlTarget.request_url`, `host_header`, and `sni_hostname` for DNS pinning (single resolve inside `validate_batch_urls`).
13
+ - **`Crawl4AICrawler`** registers a Playwright `before_goto` hook that calls `UrlSafetyService.validate_url` on every navigation when `url_safety_settings.enabled` is `True`.
14
+ - **API crawlers** (Tavily, Firecrawl, Jina) use `target.normalized_url` from the validated targets.
15
+
16
+ Settings live in `url_safety/settings.py` (`url_safety_settings`, env prefix `URL_SAFETY_`). Key fields include `enabled` (`URL_SAFETY_ENABLED`), `resolve_redirects` (`URL_SAFETY_RESOLVE_REDIRECTS`), and the host/scheme allowlists (`allowed_schemes`, `localhost_hosts`, `metadata_hosts`, `cluster_local_suffix`, `service_suffix`).
17
+
7
18
  ```python
8
19
  class BaseCrawlerConfig(BaseModel):
9
20
  crawler_type: CrawlerType
@@ -1,4 +1,3 @@
1
- import asyncio
2
1
  from abc import ABC, abstractmethod
3
2
  from enum import StrEnum
4
3
  from typing import Generic, TypeVar
@@ -8,15 +7,14 @@ from unique_toolkit.agentic.tools.config import (
8
7
  get_configuration_dict,
9
8
  )
10
9
 
10
+ from unique_web_search.services.crawlers.url_safety import (
11
+ ResolvedCrawlTarget,
12
+ UrlSafetyService,
13
+ )
11
14
  from unique_web_search.services.helpers import (
12
15
  clean_model_title_generator,
13
16
  experimental_model_title_generator,
14
17
  )
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
20
18
 
21
19
 
22
20
  class CrawlerType(StrEnum):
@@ -57,12 +55,8 @@ class BaseCrawler(ABC, Generic[CrawlerConfig]):
57
55
  self.config = config
58
56
 
59
57
  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)
58
+ targets = await UrlSafetyService.validate_batch_urls(urls)
59
+ return await self._crawl(targets)
66
60
 
67
61
  @abstractmethod
68
- async def _crawl(self, urls: list[str]) -> list[str]: ...
62
+ async def _crawl(self, targets: list[ResolvedCrawlTarget]) -> list[str]: ...
@@ -15,11 +15,11 @@ from unique_web_search.services.crawlers.base import (
15
15
  BaseCrawlerConfig,
16
16
  CrawlerType,
17
17
  )
18
- from unique_web_search.services.crawlers.utils import get_random_user_agent
19
- from unique_web_search.services.url_safety import (
18
+ from unique_web_search.services.crawlers.url_safety import (
20
19
  CrawlTargetValidationError,
21
- resolve_crawl_target,
20
+ ResolvedCrawlTarget,
22
21
  )
22
+ from unique_web_search.services.crawlers.utils import get_random_user_agent
23
23
 
24
24
  _LOGGER = logging.getLogger(__name__)
25
25
 
@@ -63,9 +63,9 @@ class BasicCrawler(BaseCrawler[BasicCrawlerConfig]):
63
63
  # tags=["basic", "scrape"],
64
64
  # )
65
65
  @override
66
- async def _crawl(self, urls: list[str]) -> list[str]:
66
+ async def _crawl(self, targets: list[ResolvedCrawlTarget]) -> list[str]:
67
67
  async with async_client(timeout=Timeout(self.config.timeout)) as client:
68
- tasks = [self._crawl_url_with_client(client, url) for url in urls]
68
+ tasks = [self._crawl_url_with_client(client, target) for target in targets]
69
69
  results = await asyncio.gather(*tasks, return_exceptions=True)
70
70
 
71
71
  validation_errors = [
@@ -88,19 +88,23 @@ class BasicCrawler(BaseCrawler[BasicCrawlerConfig]):
88
88
 
89
89
  return markdowns
90
90
 
91
- async def _crawl_url_with_client(self, client: AsyncClient, url: str) -> str:
91
+ async def _crawl_url_with_client(
92
+ self, client: AsyncClient, target: ResolvedCrawlTarget
93
+ ) -> str:
92
94
  headers = {"User-Agent": get_random_user_agent()}
93
95
 
94
- if self._is_url_blacklisted(url):
96
+ if self._is_url_blacklisted(target.normalized_url):
95
97
  return "Unable to crawl URL due to blacklisted pattern"
96
98
 
97
- (
98
- request_url,
99
- request_headers,
100
- request_extensions,
101
- ) = await self._build_request_target(url, headers)
99
+ request_headers = dict(headers)
100
+ request_extensions: dict[str, str] = {}
101
+ if target.host_header is not None:
102
+ request_headers["Host"] = target.host_header
103
+ if target.sni_hostname is not None:
104
+ request_extensions["sni_hostname"] = target.sni_hostname
105
+
102
106
  response = await client.get(
103
- request_url,
107
+ target.request_url,
104
108
  headers=request_headers,
105
109
  extensions=request_extensions or None,
106
110
  )
@@ -128,22 +132,6 @@ class BasicCrawler(BaseCrawler[BasicCrawlerConfig]):
128
132
  def _content_type_not_allowed(self, content_type: str) -> bool:
129
133
  return content_type in self.config.unwanted_content_types
130
134
 
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
-
147
135
 
148
136
  def _markdownify_html_with_timeout(content: str, timeout: float) -> str:
149
137
  @timeout_decorator.timeout(timeout)
@@ -10,8 +10,12 @@ from unique_web_search.services.crawlers.base import (
10
10
  BaseCrawlerConfig,
11
11
  CrawlerType,
12
12
  )
13
+ from unique_web_search.services.crawlers.url_safety import (
14
+ ResolvedCrawlTarget,
15
+ UrlSafetyService,
16
+ url_safety_settings,
17
+ )
13
18
  from unique_web_search.services.crawlers.utils import get_random_user_agent
14
- from unique_web_search.services.url_safety import validate_url
15
19
 
16
20
  _LOGGER = logging.getLogger(__name__)
17
21
 
@@ -188,7 +192,7 @@ class Crawl4AiCrawler(BaseCrawler[Crawl4AiCrawlerConfig]):
188
192
 
189
193
  async def _route_handler(route: Route, request: Request) -> None:
190
194
  req_url = request.url
191
- error = await validate_url(req_url)
195
+ error = await UrlSafetyService.validate_url(req_url)
192
196
  if error is not None:
193
197
  category, reason = error
194
198
  _LOGGER.warning(
@@ -205,7 +209,8 @@ class Crawl4AiCrawler(BaseCrawler[Crawl4AiCrawlerConfig]):
205
209
 
206
210
  return _ssrf_guard_hook
207
211
 
208
- async def _crawl(self, urls: list[str]) -> list[str]:
212
+ async def _crawl(self, targets: list[ResolvedCrawlTarget]) -> list[str]:
213
+ urls = [target.normalized_url for target in targets]
209
214
  # Lazy import of crawl4ai - only import when actually needed
210
215
  from crawl4ai import (
211
216
  AsyncWebCrawler,
@@ -276,11 +281,14 @@ class Crawl4AiCrawler(BaseCrawler[Crawl4AiCrawlerConfig]):
276
281
  rate_limiter=rate_limiter,
277
282
  )
278
283
 
284
+ hooks = (
285
+ {"before_goto": self._get_ssrf_guard_hook()}
286
+ if url_safety_settings.enabled
287
+ else {}
288
+ )
279
289
  crawler_strategy = AsyncPlaywrightCrawlerStrategy(
280
290
  browser_config=browser_config,
281
- hooks={
282
- "before_goto": self._get_ssrf_guard_hook(),
283
- },
291
+ hooks=hooks,
284
292
  )
285
293
 
286
294
  _LOGGER.info(f"Crawling {len(urls)} URLs with Crawl4AiCrawler")
@@ -10,6 +10,7 @@ from unique_web_search.services.crawlers.base import (
10
10
  BaseCrawlerConfigExperimental,
11
11
  CrawlerType,
12
12
  )
13
+ from unique_web_search.services.crawlers.url_safety import ResolvedCrawlTarget
13
14
 
14
15
 
15
16
  class FirecrawlCrawlerConfig(BaseCrawlerConfigExperimental[CrawlerType.FIRECRAWL]):
@@ -24,7 +25,8 @@ class FirecrawlCrawler(BaseCrawler[FirecrawlCrawlerConfig]):
24
25
  # @track(
25
26
  # tags=["firecrawl", "scrape"],
26
27
  # )
27
- async def _crawl(self, urls: list[str]) -> list[str]:
28
+ async def _crawl(self, targets: list[ResolvedCrawlTarget]) -> list[str]:
29
+ urls = [target.normalized_url for target in targets]
28
30
  api_key = get_firecrawl_search_settings().api_key
29
31
  assert api_key is not None, "Firecrawl API key is not configured"
30
32
 
@@ -10,6 +10,7 @@ from unique_web_search.services.crawlers.base import (
10
10
  BaseCrawlerConfigExperimental,
11
11
  CrawlerType,
12
12
  )
13
+ from unique_web_search.services.crawlers.url_safety import ResolvedCrawlTarget
13
14
 
14
15
 
15
16
  class ReaderBody(BaseModel):
@@ -52,7 +53,8 @@ class JinaCrawler(BaseCrawler[JinaCrawlerConfig]):
52
53
  # @track(
53
54
  # tags=["jina", "scrape"],
54
55
  # )
55
- async def _crawl(self, urls: list[str]) -> list[str]:
56
+ async def _crawl(self, targets: list[ResolvedCrawlTarget]) -> list[str]:
57
+ urls = [target.normalized_url for target in targets]
56
58
  jina_settings = get_jina_search_settings()
57
59
  api_key = jina_settings.api_key
58
60
  assert api_key is not None, "Jina API key is not configured"
@@ -9,6 +9,7 @@ from unique_web_search.services.crawlers.base import (
9
9
  BaseCrawlerConfigExperimental,
10
10
  CrawlerType,
11
11
  )
12
+ from unique_web_search.services.crawlers.url_safety import ResolvedCrawlTarget
12
13
 
13
14
 
14
15
  class TavilyCrawlerConfig(BaseCrawlerConfigExperimental[CrawlerType.TAVILY]):
@@ -24,7 +25,8 @@ class TavilyCrawler(BaseCrawler[TavilyCrawlerConfig]):
24
25
  # @track(
25
26
  # tags=["tavily", "scrape"],
26
27
  # )
27
- async def _crawl(self, urls: list[str]) -> list[str]:
28
+ async def _crawl(self, targets: list[ResolvedCrawlTarget]) -> list[str]:
29
+ urls = [target.normalized_url for target in targets]
28
30
  api_key = get_tavily_search_settings().api_key
29
31
  assert api_key is not None, "Tavily API key is not configured"
30
32
 
@@ -0,0 +1,15 @@
1
+ from unique_web_search.services.crawlers.url_safety.models import (
2
+ BlockedCrawlTarget,
3
+ CrawlTargetValidationError,
4
+ ResolvedCrawlTarget,
5
+ )
6
+ from unique_web_search.services.crawlers.url_safety.service import UrlSafetyService
7
+ from unique_web_search.services.crawlers.url_safety.settings import url_safety_settings
8
+
9
+ __all__ = [
10
+ "BlockedCrawlTarget",
11
+ "url_safety_settings",
12
+ "CrawlTargetValidationError",
13
+ "ResolvedCrawlTarget",
14
+ "UrlSafetyService",
15
+ ]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import socket
5
+ from ipaddress import ip_address
6
+
7
+
8
+ async def resolve_host_addresses(host: str) -> tuple[str, ...]:
9
+ loop = asyncio.get_running_loop()
10
+ resolved = await loop.run_in_executor(
11
+ None, lambda: socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
12
+ )
13
+ return tuple(
14
+ dict.fromkeys(str(ip_address(sockaddr[0])) for _, _, _, _, sockaddr in resolved)
15
+ )
16
+
17
+
18
+ def block_reason_for_resolved_addresses(
19
+ resolved_addresses: tuple[str, ...],
20
+ ) -> tuple[str, str] | None:
21
+ if not resolved_addresses:
22
+ return (
23
+ "dns",
24
+ "Target host could not be resolved during safety validation",
25
+ )
26
+
27
+ for resolved_address in resolved_addresses:
28
+ if not ip_address(resolved_address).is_global:
29
+ return (
30
+ "private",
31
+ "Target host resolves to a private or special-use IP address",
32
+ )
33
+
34
+ return None
35
+
36
+
37
+ async def resolve_and_validate_host(
38
+ host: str,
39
+ ) -> tuple[tuple[str, ...], tuple[str, str] | None]:
40
+ try:
41
+ resolved_addresses = await resolve_host_addresses(host)
42
+ except socket.gaierror:
43
+ return (), (
44
+ "dns",
45
+ "Target host could not be resolved during safety validation",
46
+ )
47
+
48
+ return resolved_addresses, block_reason_for_resolved_addresses(resolved_addresses)
49
+
50
+
51
+ async def validate_resolved_host(host: str) -> tuple[str, str] | None:
52
+ _, validation_error = await resolve_and_validate_host(host)
53
+ return validation_error
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from urllib.parse import urlsplit
5
+
6
+ from unique_web_search.metrics import crawl_blocked
7
+ from unique_web_search.services.crawlers.url_safety.netloc import (
8
+ build_netloc,
9
+ replace_url_host,
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BlockedCrawlTarget:
15
+ hostname: str | None
16
+ category: str
17
+ reason: str
18
+
19
+ @property
20
+ def display_target(self) -> str:
21
+ return self.hostname or "<unknown-host>"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ResolvedCrawlTarget:
26
+ """Carries the validated host/IP values reused when issuing crawl requests."""
27
+
28
+ normalized_url: str
29
+ hostname: str
30
+ resolved_ip: str
31
+ used_dns_resolution: bool
32
+
33
+ @property
34
+ def request_url(self) -> str:
35
+ if not self.used_dns_resolution and not self.resolved_ip:
36
+ return self.normalized_url
37
+ return replace_url_host(urlsplit(self.normalized_url), host=self.resolved_ip)
38
+
39
+ @property
40
+ def host_header(self) -> str | None:
41
+ if not self.used_dns_resolution:
42
+ return None
43
+
44
+ return build_netloc(host=self.hostname, port=urlsplit(self.normalized_url).port)
45
+
46
+ @property
47
+ def sni_hostname(self) -> str | None:
48
+ if not self.used_dns_resolution:
49
+ return None
50
+
51
+ if urlsplit(self.normalized_url).scheme.lower() != "https":
52
+ return None
53
+
54
+ return self.hostname
55
+
56
+
57
+ class CrawlTargetValidationError(ValueError):
58
+ def __init__(self, blocked_targets: list[BlockedCrawlTarget]):
59
+ self.blocked_targets: list[BlockedCrawlTarget] = blocked_targets
60
+
61
+ for target in blocked_targets:
62
+ crawl_blocked.labels(reason_category=target.category).inc()
63
+
64
+ details = "; ".join(
65
+ f"{target.display_target} ({target.reason})"
66
+ for target in self.blocked_targets
67
+ )
68
+ super().__init__(f"Blocked crawl target(s) due to URL safety policy: {details}")
69
+
70
+
71
+ def bypass_crawl_target(url: str) -> ResolvedCrawlTarget:
72
+ return ResolvedCrawlTarget(
73
+ normalized_url=url.strip(),
74
+ hostname="",
75
+ resolved_ip="",
76
+ used_dns_resolution=False,
77
+ )
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from ipaddress import ip_address
4
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
5
+
6
+
7
+ def extract_hostname(url: str) -> str | None:
8
+ normalized_url = url.strip()
9
+ if not normalized_url:
10
+ return None
11
+
12
+ hostname = urlsplit(normalized_url).hostname
13
+ if hostname is None:
14
+ return None
15
+
16
+ return hostname.rstrip(".").lower()
17
+
18
+
19
+ def replace_url_host(parsed_url: SplitResult, *, host: str) -> str:
20
+ netloc = build_netloc(
21
+ host=host,
22
+ port=parsed_url.port,
23
+ username=parsed_url.username,
24
+ password=parsed_url.password,
25
+ )
26
+ return urlunsplit(
27
+ (
28
+ parsed_url.scheme,
29
+ netloc,
30
+ parsed_url.path,
31
+ parsed_url.query,
32
+ "",
33
+ )
34
+ )
35
+
36
+
37
+ def build_netloc(
38
+ host: str,
39
+ port: int | None,
40
+ username: str | None = None,
41
+ password: str | None = None,
42
+ ) -> str:
43
+ credential_prefix = ""
44
+ if username is not None:
45
+ credential_prefix = username
46
+ if password is not None:
47
+ credential_prefix = f"{credential_prefix}:{password}"
48
+ credential_prefix = f"{credential_prefix}@"
49
+
50
+ try:
51
+ host_ip = ip_address(host)
52
+ except ValueError:
53
+ normalized_host = host
54
+ else:
55
+ normalized_host = (
56
+ f"[{host_ip.compressed}]" if host_ip.version == 6 else host_ip.compressed
57
+ )
58
+
59
+ if port is None:
60
+ return f"{credential_prefix}{normalized_host}"
61
+
62
+ return f"{credential_prefix}{normalized_host}:{port}"
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from ipaddress import ip_address
4
+ from urllib.parse import urlsplit
5
+
6
+ from unique_web_search.services.crawlers.url_safety.settings import url_safety_settings
7
+
8
+
9
+ def validate_target_cheap(url: str) -> tuple[str, str] | None:
10
+ """Static validation only — no DNS. Returns (category, reason) if blocked, None otherwise."""
11
+ if not url:
12
+ return "empty", "URL is empty"
13
+
14
+ parsed_url = urlsplit(url)
15
+ if parsed_url.scheme.lower() not in url_safety_settings.allowed_schemes:
16
+ return "scheme", "URL scheme must be http or https"
17
+
18
+ hostname = parsed_url.hostname
19
+ if not hostname:
20
+ return "host", "URL host is missing or malformed"
21
+
22
+ normalized_host = hostname.rstrip(".").lower()
23
+
24
+ if normalized_host in url_safety_settings.metadata_hosts:
25
+ return "metadata", "Target points to a metadata endpoint"
26
+
27
+ if (
28
+ normalized_host in url_safety_settings.localhost_hosts
29
+ or normalized_host.endswith(".localhost")
30
+ ):
31
+ return "localhost", "Target points to a localhost host"
32
+
33
+ if normalized_host.endswith(url_safety_settings.service_suffix):
34
+ return "cluster", "Target points to an internal service host"
35
+
36
+ if normalized_host.endswith(url_safety_settings.cluster_local_suffix):
37
+ return "cluster", "Target points to an internal cluster-local host"
38
+
39
+ try:
40
+ target_ip = ip_address(normalized_host)
41
+ except ValueError:
42
+ pass
43
+ else:
44
+ if not target_ip.is_global:
45
+ return "private", "Target points to a private or special-use IP address"
46
+
47
+ return None
48
+
49
+ if "." not in normalized_host:
50
+ return "cluster", "Target points to a single-label internal host"
51
+
52
+ return None
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+ from urllib.parse import urljoin
6
+
7
+ import httpx
8
+
9
+ from unique_web_search.services.crawlers.url_safety.models import (
10
+ BlockedCrawlTarget,
11
+ CrawlTargetValidationError,
12
+ )
13
+ from unique_web_search.services.crawlers.url_safety.netloc import extract_hostname
14
+ from unique_web_search.services.crawlers.url_safety.settings import url_safety_settings
15
+
16
+ _LOGGER = logging.getLogger(__name__)
17
+
18
+ _REDIRECT_STATUS_CODES = frozenset({301, 302, 303, 307, 308})
19
+
20
+ ValidateUrlFn = Callable[[str], Awaitable[tuple[str, str] | None]]
21
+
22
+
23
+ async def resolve_redirect_chain(
24
+ url: str,
25
+ validate_url: ValidateUrlFn,
26
+ ) -> str:
27
+ """Follow HTTP 3xx redirects hop-by-hop, validating each destination.
28
+
29
+ Returns the final validated URL.
30
+ Raises CrawlTargetValidationError if any hop is blocked.
31
+ """
32
+ current = url
33
+ timeout = url_safety_settings.redirect_timeout_seconds
34
+ async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
35
+ for _ in range(url_safety_settings.max_redirect_hops):
36
+ error = await validate_url(current)
37
+ if error is not None:
38
+ category, reason = error
39
+ raise CrawlTargetValidationError(
40
+ [
41
+ BlockedCrawlTarget(
42
+ hostname=extract_hostname(current),
43
+ category=category,
44
+ reason=reason,
45
+ )
46
+ ]
47
+ )
48
+
49
+ try:
50
+ resp = await client.head(current)
51
+ except Exception as exc:
52
+ _LOGGER.debug(
53
+ "Redirect resolution stopped at %s due to network error: %s",
54
+ current,
55
+ exc,
56
+ )
57
+ break
58
+
59
+ if resp.status_code not in _REDIRECT_STATUS_CODES:
60
+ break
61
+
62
+ location = resp.headers.get("location")
63
+ if not location:
64
+ break
65
+
66
+ current = urljoin(current, location)
67
+
68
+ error = await validate_url(current)
69
+ if error is not None:
70
+ category, reason = error
71
+ raise CrawlTargetValidationError(
72
+ [
73
+ BlockedCrawlTarget(
74
+ hostname=extract_hostname(current),
75
+ category=category,
76
+ reason=reason,
77
+ )
78
+ ]
79
+ )
80
+
81
+ return current