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.
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/PKG-INFO +3 -2
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/pyproject.toml +3 -2
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/metrics.py +5 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/base.py +15 -1
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/basic.py +49 -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
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/firecrawl.py +1 -1
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/jina.py +1 -1
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/tavily.py +1 -1
- {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
- {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
- unique_web_search-2026.22.0.dev2/src/unique_web_search/services/url_safety.py +458 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/settings.py +17 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/README.md +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/__init__.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/client_settings.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/config.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/prompts.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/schema.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/service.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/README.md +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/__init__.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/crawlers/utils.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/README.md +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/__init__.py +0 -0
- {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
- {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
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/executors/context.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/helpers.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/message_log.py +0 -0
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/query_elicitation.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/services/structured_llm.py +0 -0
- {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
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_web_search-2026.22.0.dev0 → unique_web_search-2026.22.0.dev2}/src/unique_web_search/utils.py
RENAMED
|
File without changes
|