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.
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/PKG-INFO +2 -2
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/pyproject.toml +2 -2
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/prompts.py +21 -2
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/README.md +11 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/base.py +7 -13
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/basic.py +17 -29
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/crawl4ai.py +14 -6
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/firecrawl.py +3 -1
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/jina.py +3 -1
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/tavily.py +3 -1
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/__init__.py +15 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/dns.py +53 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/models.py +77 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/netloc.py +62 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/policy.py +52 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/redirect.py +81 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/resolver.py +103 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/service.py +64 -0
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/settings.py +60 -0
- {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
- {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
- unique_web_search-2026.24.0.dev2/src/unique_web_search/services/executors/v3/prompts/tool_description_for_system_prompt.j2 +78 -0
- {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
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/settings.py +0 -17
- {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
- unique_web_search-2026.24.0.dev0/src/unique_web_search/services/executors/v3/prompts/tool_description_for_system_prompt.j2 +0 -40
- unique_web_search-2026.24.0.dev0/src/unique_web_search/services/url_safety.py +0 -458
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/README.md +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/__init__.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/client_settings.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/config.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/metrics.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/schema.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/service.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/__init__.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/crawlers/utils.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/README.md +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/__init__.py +0 -0
- {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
- {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
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/executors/context.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/helpers.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/message_log.py +0 -0
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/query_elicitation.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_web_search-2026.24.0.dev0 → unique_web_search-2026.24.0.dev2}/src/unique_web_search/services/structured_llm.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
61
|
-
|
|
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,
|
|
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.
|
|
19
|
-
from unique_web_search.services.url_safety import (
|
|
18
|
+
from unique_web_search.services.crawlers.url_safety import (
|
|
20
19
|
CrawlTargetValidationError,
|
|
21
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
96
|
+
if self._is_url_blacklisted(target.normalized_url):
|
|
95
97
|
return "Unable to crawl URL due to blacklisted pattern"
|
|
96
98
|
|
|
97
|
-
(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/__init__.py
ADDED
|
@@ -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
|
unique_web_search-2026.24.0.dev2/src/unique_web_search/services/crawlers/url_safety/redirect.py
ADDED
|
@@ -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
|