unique-web-search 2026.26.0.dev7__tar.gz → 2026.26.0.dev8__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.26.0.dev7 → unique_web_search-2026.26.0.dev8}/PKG-INFO +2 -3
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/pyproject.toml +4 -4
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/__init__.py +1 -16
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/base.py +12 -6
- unique_web_search-2026.26.0.dev8/src/unique_web_search/services/crawlers/url_safety/__init__.py +21 -0
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/basic_proxy.py +0 -79
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/__init__.py +0 -15
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/dns.py +0 -53
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/models.py +0 -77
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/netloc.py +0 -62
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/policy.py +0 -52
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/redirect.py +0 -81
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/resolver.py +0 -103
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/service.py +0 -64
- unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/settings.py +0 -60
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/README.md +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/client_settings.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/metrics.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/prompts.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/service.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/exceptions.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/prompts/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/prompts/guidelines.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/prompts/rejection_response.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/prompts/system_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/prompts/user_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/argument_screening/service.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/client/proxy_config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/cleaning/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/cleaning/character_sanitize.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/cleaning/clean.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/cleaning/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/base.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/llm_guard_judge.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/llm_keyword_redact.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/llm_process.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_and_sanitize_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/judge_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/keyword_extract_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/page_context.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/sanitize_rules.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/system_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/prompts/user_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/settings.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/processing_strategies/truncate.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/content_processing/service.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/README.md +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/basic.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/crawl4ai.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/firecrawl.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/jina.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/tavily.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/crawlers/utils.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/README.md +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/base_config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/base_executor.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/context.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/executor.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/prompts/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/prompts/refine_query_system_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/prompts/restrict_date_description.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/prompts/tool_description.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/prompts/tool_description_for_system_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v1/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/executor.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/prompts/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/prompts/step_query_description.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/prompts/tool_description.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/prompts/tool_description_for_system_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v2/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v3/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v3/config.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v3/executor.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v3/prompts/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v3/prompts/tool_description.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v3/prompts/tool_description_for_system_prompt.j2 +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/executors/v3/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/helpers.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/message_log.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/query_elicitation.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/README.md +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/base.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/bing.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/brave.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/custom_api.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/firecrawl.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/google.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/jina.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/perplexity.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/tavily.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/google/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/bing/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/bing/client.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/bing/models.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/bing/runner.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/models.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/response_parsing.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/client.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/exceptions.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/gemini.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/utils/grounding/vertexai/response_handler.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/search_engine/vertexai.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/snippet_judge/__init__.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/snippet_judge/prompts.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/snippet_judge/schema.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/snippet_judge/service.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/services/structured_llm.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/settings.py +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/src/unique_web_search/skills/web-search-v3.md +0 -0
- {unique_web_search-2026.26.0.dev7 → unique_web_search-2026.26.0.dev8}/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.26.0.
|
|
3
|
+
Version: 2026.26.0.dev8
|
|
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>
|
|
@@ -28,8 +28,7 @@ Requires-Dist: certifi>=2025.11.12
|
|
|
28
28
|
Requires-Dist: jinja2>=3.1.6
|
|
29
29
|
Requires-Dist: playwright>=1.58.0
|
|
30
30
|
Requires-Dist: perplexityai>=0.34.0
|
|
31
|
-
Requires-Dist: unique-search-proxy-core>=2026.26.0.
|
|
32
|
-
Requires-Dist: unique-search-proxy-sdk>=2026.26.0.dev5,<2026.26.0rc0
|
|
31
|
+
Requires-Dist: unique-search-proxy-core>=2026.26.0.dev6,<2026.26.0rc0
|
|
33
32
|
Requires-Python: >=3.12, <4.0
|
|
34
33
|
Description-Content-Type: text/markdown
|
|
35
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique_web_search"
|
|
3
|
-
version = "2026.26.0.
|
|
3
|
+
version = "2026.26.0.dev8"
|
|
4
4
|
description = ""
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12,<4.0"
|
|
@@ -34,8 +34,8 @@ dependencies = [
|
|
|
34
34
|
"jinja2>=3.1.6",
|
|
35
35
|
"playwright>=1.58.0",
|
|
36
36
|
"perplexityai>=0.34.0",
|
|
37
|
-
"unique-search-proxy-core>=2026.26.0.
|
|
38
|
-
"unique-search-proxy-sdk>=2026.
|
|
37
|
+
"unique-search-proxy-core>=2026.26.0.dev6,<2026.26.0rc0",
|
|
38
|
+
# "unique-search-proxy-sdk>=2026.24.0.dev0",
|
|
39
39
|
]
|
|
40
40
|
|
|
41
41
|
[build-system]
|
|
@@ -93,5 +93,5 @@ extend-select = ["I"]
|
|
|
93
93
|
|
|
94
94
|
[tool.uv.sources]
|
|
95
95
|
unique-toolkit = { workspace = true }
|
|
96
|
-
unique-search-proxy-sdk = { workspace = true }
|
|
96
|
+
# unique-search-proxy-sdk = { workspace = true }
|
|
97
97
|
unique-search-proxy-core = { workspace = true }
|
|
@@ -9,10 +9,6 @@ from unique_web_search.services.crawlers.basic import (
|
|
|
9
9
|
BasicCrawler,
|
|
10
10
|
BasicCrawlerConfig,
|
|
11
11
|
)
|
|
12
|
-
from unique_web_search.services.crawlers.basic_proxy import (
|
|
13
|
-
BasicCrawlerProxyConfig,
|
|
14
|
-
BasicProxyCrawler,
|
|
15
|
-
)
|
|
16
12
|
from unique_web_search.services.crawlers.crawl4ai import (
|
|
17
13
|
Crawl4AiCrawler,
|
|
18
14
|
Crawl4AiCrawlerConfig,
|
|
@@ -31,17 +27,11 @@ from unique_web_search.services.crawlers.tavily import (
|
|
|
31
27
|
)
|
|
32
28
|
|
|
33
29
|
CrawlerTypes = (
|
|
34
|
-
BasicCrawler
|
|
35
|
-
| BasicProxyCrawler
|
|
36
|
-
| Crawl4AiCrawler
|
|
37
|
-
| FirecrawlCrawler
|
|
38
|
-
| JinaCrawler
|
|
39
|
-
| TavilyCrawler
|
|
30
|
+
BasicCrawler | Crawl4AiCrawler | FirecrawlCrawler | JinaCrawler | TavilyCrawler
|
|
40
31
|
)
|
|
41
32
|
|
|
42
33
|
CrawlerConfigTypes = (
|
|
43
34
|
BasicCrawlerConfig
|
|
44
|
-
| BasicCrawlerProxyConfig
|
|
45
35
|
| Crawl4AiCrawlerConfig
|
|
46
36
|
| FirecrawlCrawlerConfig
|
|
47
37
|
| JinaCrawlerConfig
|
|
@@ -54,7 +44,6 @@ CRAWLER_NAME_TO_CONFIG = {
|
|
|
54
44
|
"firecrawl": FirecrawlCrawlerConfig,
|
|
55
45
|
"jina": JinaCrawlerConfig,
|
|
56
46
|
"tavily": TavilyCrawlerConfig,
|
|
57
|
-
"basic_proxy": BasicCrawlerProxyConfig,
|
|
58
47
|
}
|
|
59
48
|
|
|
60
49
|
|
|
@@ -72,8 +61,6 @@ def get_crawler_service(
|
|
|
72
61
|
return FirecrawlCrawler(crawler_config)
|
|
73
62
|
case CrawlerType.JINA:
|
|
74
63
|
return JinaCrawler(crawler_config)
|
|
75
|
-
case CrawlerType.BASIC_PROXY:
|
|
76
|
-
return BasicProxyCrawler(crawler_config)
|
|
77
64
|
|
|
78
65
|
|
|
79
66
|
def get_crawler_config_types_from_names(crawler_names: list[str]) -> TypeAlias:
|
|
@@ -103,8 +90,6 @@ def get_default_crawler_config(
|
|
|
103
90
|
__all__ = [
|
|
104
91
|
"BasicCrawler",
|
|
105
92
|
"BasicCrawlerConfig",
|
|
106
|
-
"BasicProxyCrawler",
|
|
107
|
-
"BasicCrawlerProxyConfig",
|
|
108
93
|
"Crawl4AiCrawler",
|
|
109
94
|
"Crawl4AiCrawlerConfig",
|
|
110
95
|
"FirecrawlCrawler",
|
|
@@ -3,14 +3,16 @@ from enum import StrEnum
|
|
|
3
3
|
from typing import Generic, TypeVar
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel
|
|
6
|
+
from unique_search_proxy_core.url_safety import (
|
|
7
|
+
CrawlTargetValidationError,
|
|
8
|
+
ResolvedCrawlTarget,
|
|
9
|
+
UrlSafetyService,
|
|
10
|
+
)
|
|
6
11
|
from unique_toolkit.agentic.tools.config import (
|
|
7
12
|
get_configuration_dict,
|
|
8
13
|
)
|
|
9
14
|
|
|
10
|
-
from unique_web_search.
|
|
11
|
-
ResolvedCrawlTarget,
|
|
12
|
-
UrlSafetyService,
|
|
13
|
-
)
|
|
15
|
+
from unique_web_search.metrics import crawl_blocked
|
|
14
16
|
from unique_web_search.services.helpers import (
|
|
15
17
|
clean_model_title_generator,
|
|
16
18
|
experimental_model_title_generator,
|
|
@@ -20,7 +22,6 @@ from unique_web_search.services.helpers import (
|
|
|
20
22
|
class CrawlerType(StrEnum):
|
|
21
23
|
CRAWL4AI = "Crawl4AiCrawler"
|
|
22
24
|
BASIC = "BasicCrawler"
|
|
23
|
-
BASIC_PROXY = "BasicProxyCrawler"
|
|
24
25
|
NO_CRAWLER = "NoCrawler"
|
|
25
26
|
TAVILY = "TavilyCrawler"
|
|
26
27
|
FIRECRAWL = "FirecrawlCrawler"
|
|
@@ -55,7 +56,12 @@ class BaseCrawler(ABC, Generic[CrawlerConfig]):
|
|
|
55
56
|
self.config = config
|
|
56
57
|
|
|
57
58
|
async def crawl(self, urls: list[str]) -> list[str]:
|
|
58
|
-
|
|
59
|
+
try:
|
|
60
|
+
targets = await UrlSafetyService.validate_batch_urls(urls)
|
|
61
|
+
except CrawlTargetValidationError as exc:
|
|
62
|
+
for target in exc.blocked_targets:
|
|
63
|
+
crawl_blocked.labels(reason_category=target.category).inc()
|
|
64
|
+
raise
|
|
59
65
|
return await self._crawl(targets)
|
|
60
66
|
|
|
61
67
|
@abstractmethod
|
unique_web_search-2026.26.0.dev8/src/unique_web_search/services/crawlers/url_safety/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Deprecated re-exports — use ``unique_search_proxy_core.url_safety`` directly."""
|
|
2
|
+
|
|
3
|
+
from unique_search_proxy_core.url_safety import (
|
|
4
|
+
BlockedCrawlTarget,
|
|
5
|
+
CrawlTargetValidationError,
|
|
6
|
+
ResolvedCrawlTarget,
|
|
7
|
+
UrlSafetyOutcome,
|
|
8
|
+
UrlSafetyService,
|
|
9
|
+
UrlSafetySettings,
|
|
10
|
+
url_safety_settings,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BlockedCrawlTarget",
|
|
15
|
+
"CrawlTargetValidationError",
|
|
16
|
+
"ResolvedCrawlTarget",
|
|
17
|
+
"UrlSafetyOutcome",
|
|
18
|
+
"UrlSafetyService",
|
|
19
|
+
"UrlSafetySettings",
|
|
20
|
+
"url_safety_settings",
|
|
21
|
+
]
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from typing import Literal
|
|
3
|
-
|
|
4
|
-
from unique_search_proxy_core.crawlers import (
|
|
5
|
-
BasicConfig as BasicCrawlerProxyCoreConfig,
|
|
6
|
-
)
|
|
7
|
-
from unique_search_proxy_core.crawlers.base import CrawlerType as ProxyCrawlerType
|
|
8
|
-
from unique_search_proxy_sdk import UniqueSearchProxyClient
|
|
9
|
-
from unique_search_proxy_sdk._generated.models.crawl_url_result import CrawlUrlResult
|
|
10
|
-
from unique_search_proxy_sdk._generated.models.per_url_error import PerUrlError
|
|
11
|
-
|
|
12
|
-
from unique_web_search.client_settings import get_search_proxy_settings
|
|
13
|
-
from unique_web_search.services.crawlers.base import BaseCrawlerConfig, CrawlerType
|
|
14
|
-
|
|
15
|
-
_LOGGER = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class BasicCrawlerProxyConfig(
|
|
19
|
-
BaseCrawlerConfig[CrawlerType.BASIC_PROXY], BasicCrawlerProxyCoreConfig
|
|
20
|
-
):
|
|
21
|
-
crawler_type: Literal[CrawlerType.BASIC_PROXY] = CrawlerType.BASIC_PROXY
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class BasicProxyCrawler:
|
|
25
|
-
"""Crawler that delegates fetching to the remote Unique Search Proxy service.
|
|
26
|
-
|
|
27
|
-
Intermediate transition crawler: instead of fetching URLs in-process (see
|
|
28
|
-
:class:`BasicCrawler`), it forwards the request to the search-proxy service
|
|
29
|
-
via :class:`UniqueSearchProxyClient` and maps the per-URL results back to the
|
|
30
|
-
markdown list contract shared by the other crawlers.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
def __init__(self, config: BasicCrawlerProxyConfig):
|
|
34
|
-
self.config: BasicCrawlerProxyConfig = config
|
|
35
|
-
|
|
36
|
-
async def crawl(self, urls: list[str]) -> list[str]:
|
|
37
|
-
return await self._crawl(urls)
|
|
38
|
-
|
|
39
|
-
async def _crawl(self, urls: list[str]) -> list[str]:
|
|
40
|
-
base_url = get_search_proxy_settings().base_url
|
|
41
|
-
assert base_url is not None, "Unique Search Proxy base URL is not configured"
|
|
42
|
-
|
|
43
|
-
try:
|
|
44
|
-
async with UniqueSearchProxyClient(
|
|
45
|
-
base_url=base_url,
|
|
46
|
-
timeout=float(self.config.timeout),
|
|
47
|
-
) as client:
|
|
48
|
-
response = await client.crawl.crawl(
|
|
49
|
-
urls,
|
|
50
|
-
crawler=ProxyCrawlerType.BASIC,
|
|
51
|
-
content_types=self.config.content_types,
|
|
52
|
-
timeout=self.config.timeout,
|
|
53
|
-
max_concurrent_requests=self.config.max_concurrent_requests,
|
|
54
|
-
)
|
|
55
|
-
except Exception as error:
|
|
56
|
-
_LOGGER.exception("Unique Search Proxy crawl request failed")
|
|
57
|
-
return [f"Unable to crawl URL via search proxy: {error}" for _ in urls]
|
|
58
|
-
|
|
59
|
-
url_to_markdown = {
|
|
60
|
-
result.url: _result_to_markdown(result) for result in response.results
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return [
|
|
64
|
-
url_to_markdown.get(url, "Error: URL not found in search proxy response")
|
|
65
|
-
for url in urls
|
|
66
|
-
]
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _result_to_markdown(result: CrawlUrlResult) -> str:
|
|
70
|
-
if isinstance(result.content, str):
|
|
71
|
-
return result.content
|
|
72
|
-
|
|
73
|
-
if isinstance(result.error, PerUrlError):
|
|
74
|
-
return f"Error: {result.error.message}"
|
|
75
|
-
|
|
76
|
-
if isinstance(result.raw, str):
|
|
77
|
-
return result.raw
|
|
78
|
-
|
|
79
|
-
return "Error: No content returned by search proxy"
|
unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/__init__.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
]
|
|
@@ -1,53 +0,0 @@
|
|
|
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
|
unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/models.py
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
)
|
unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/netloc.py
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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}"
|
unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/policy.py
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
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.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/redirect.py
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
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
|
unique_web_search-2026.26.0.dev7/src/unique_web_search/services/crawlers/url_safety/resolver.py
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from ipaddress import ip_address
|
|
5
|
-
from urllib.parse import urlsplit
|
|
6
|
-
|
|
7
|
-
from unique_web_search.services.crawlers.url_safety import dns
|
|
8
|
-
from unique_web_search.services.crawlers.url_safety.models import (
|
|
9
|
-
BlockedCrawlTarget,
|
|
10
|
-
CrawlTargetValidationError,
|
|
11
|
-
ResolvedCrawlTarget,
|
|
12
|
-
)
|
|
13
|
-
from unique_web_search.services.crawlers.url_safety.netloc import extract_hostname
|
|
14
|
-
from unique_web_search.services.crawlers.url_safety.policy import validate_target_cheap
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
async def resolve_crawl_target(url: str) -> ResolvedCrawlTarget:
|
|
18
|
-
"""Validate and resolve a single URL, performing DNS exactly once."""
|
|
19
|
-
normalized_url = url.strip()
|
|
20
|
-
|
|
21
|
-
validation_error = validate_target_cheap(normalized_url)
|
|
22
|
-
if validation_error is not None:
|
|
23
|
-
category, reason = validation_error
|
|
24
|
-
raise CrawlTargetValidationError(
|
|
25
|
-
[
|
|
26
|
-
BlockedCrawlTarget(
|
|
27
|
-
hostname=extract_hostname(normalized_url),
|
|
28
|
-
category=category,
|
|
29
|
-
reason=reason,
|
|
30
|
-
)
|
|
31
|
-
]
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
parsed_url = urlsplit(normalized_url)
|
|
35
|
-
hostname = parsed_url.hostname
|
|
36
|
-
if hostname is None:
|
|
37
|
-
raise CrawlTargetValidationError(
|
|
38
|
-
[
|
|
39
|
-
BlockedCrawlTarget(
|
|
40
|
-
hostname=None,
|
|
41
|
-
category="host",
|
|
42
|
-
reason="URL host is missing or malformed",
|
|
43
|
-
)
|
|
44
|
-
]
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
normalized_host = hostname.rstrip(".").lower()
|
|
48
|
-
|
|
49
|
-
try:
|
|
50
|
-
target_ip = ip_address(normalized_host)
|
|
51
|
-
except ValueError:
|
|
52
|
-
resolved_addresses, validation_error = await dns.resolve_and_validate_host(
|
|
53
|
-
normalized_host
|
|
54
|
-
)
|
|
55
|
-
if validation_error is not None:
|
|
56
|
-
category, reason = validation_error
|
|
57
|
-
raise CrawlTargetValidationError(
|
|
58
|
-
[
|
|
59
|
-
BlockedCrawlTarget(
|
|
60
|
-
hostname=normalized_host,
|
|
61
|
-
category=category,
|
|
62
|
-
reason=reason,
|
|
63
|
-
)
|
|
64
|
-
]
|
|
65
|
-
)
|
|
66
|
-
return ResolvedCrawlTarget(
|
|
67
|
-
normalized_url=normalized_url,
|
|
68
|
-
hostname=normalized_host,
|
|
69
|
-
resolved_ip=resolved_addresses[0],
|
|
70
|
-
used_dns_resolution=True,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
return ResolvedCrawlTarget(
|
|
74
|
-
normalized_url=normalized_url,
|
|
75
|
-
hostname=normalized_host,
|
|
76
|
-
resolved_ip=str(target_ip),
|
|
77
|
-
used_dns_resolution=False,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
async def resolve_crawl_targets_batch(urls: list[str]) -> list[ResolvedCrawlTarget]:
|
|
82
|
-
"""Resolve and validate each URL, collecting all policy violations before raising."""
|
|
83
|
-
results: list[ResolvedCrawlTarget | BaseException] = list(
|
|
84
|
-
await asyncio.gather(
|
|
85
|
-
*[resolve_crawl_target(url) for url in urls],
|
|
86
|
-
return_exceptions=True,
|
|
87
|
-
)
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
blocked: list[BlockedCrawlTarget] = []
|
|
91
|
-
resolved: list[ResolvedCrawlTarget] = []
|
|
92
|
-
for result in results:
|
|
93
|
-
if isinstance(result, CrawlTargetValidationError):
|
|
94
|
-
blocked.extend(result.blocked_targets)
|
|
95
|
-
elif isinstance(result, BaseException):
|
|
96
|
-
raise result
|
|
97
|
-
else:
|
|
98
|
-
resolved.append(result)
|
|
99
|
-
|
|
100
|
-
if blocked:
|
|
101
|
-
raise CrawlTargetValidationError(blocked)
|
|
102
|
-
|
|
103
|
-
return resolved
|