unique-search-proxy 2026.46.0.dev4__tar.gz → 2026.46.0.dev6__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_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/PKG-INFO +5 -3
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/README.md +2 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/pyproject.toml +3 -3
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/crawl.py +19 -4
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/service.py +42 -7
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/pinned_egress.py +2 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/provider_response.py +10 -0
- unique_search_proxy-2026.46.0.dev6/unique_search_proxy_client/web/core/url_safety/gate.py +172 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/monitoring/metrics.py +14 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/request_limits.py +9 -0
- unique_search_proxy-2026.46.0.dev4/unique_search_proxy_client/web/core/url_safety/gate.py +0 -87
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/health.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/agent_search.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/configuration.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/context_headers.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/openapi_examples.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/api/v1/search.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/app.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/context.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/citation_instructions.j2 +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/cleanup.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/client.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/instructions.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/runner.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/bing/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/factory.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/service_base.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/client.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/gemini.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/redirects.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/agent_engines/vertexai/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/azure_transport.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/credentials.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/client/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawl_limiter.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/errors.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/html_markdown.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/html.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/pdf.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/processors/plain_text.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/processing/registry.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/settings.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/basic/user_agent.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/factory.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/firecrawl/polling.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/firecrawl/request_body.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/firecrawl/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/jina/request_body.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/jina/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/tavily/request_body.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/crawlers/tavily/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/providers.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/registry.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/pagination.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/query_params.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/brave/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/custom_api/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/custom_api/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/descriptor.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/factory.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/pagination.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/query_params.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/google/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/pagination.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/perplexity/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/perplexity/request_body.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/perplexity/service.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/search_engines/service_base.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/core/url_safety/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/error_handlers.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/introspect.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/schema.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/template.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/templates/provider_values_yaml.j2 +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/generator/values_yaml.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/metadata.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/registry.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/helm/url_safety_registration.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/logging_config.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/middleware/context.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/monitoring/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/monitoring/setup.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/common.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/crawl.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/search.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/presets/types.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/app.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/base.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/client.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/monitoring.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/base.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/bing_agent.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/brave.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/firecrawl.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/google.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/jina.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/perplexity.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/tavily.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/providers/vertexai_agent.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/settings/secret_str.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/startup_report.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/usage.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/utils/__init__.py +0 -0
- {unique_search_proxy-2026.46.0.dev4 → unique_search_proxy-2026.46.0.dev6}/unique_search_proxy_client/web/utils/url.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: unique-search-proxy
|
|
3
|
-
Version: 2026.46.0.
|
|
3
|
+
Version: 2026.46.0.dev6
|
|
4
4
|
Summary: Web Search Proxy implementation
|
|
5
5
|
Author: ThePhilAz
|
|
6
6
|
Author-email: ThePhilAz <rami.azouz@philico.com>
|
|
@@ -20,8 +20,8 @@ Requires-Dist: certifi>=2025.11.12,<2027
|
|
|
20
20
|
Requires-Dist: google-genai>=1.73.0,<2
|
|
21
21
|
Requires-Dist: google-auth>=2.43.0,<3
|
|
22
22
|
Requires-Dist: jinja2>=3.1.6,<4
|
|
23
|
-
Requires-Dist: unique-toolkit[monitoring]>=2026.46.0.
|
|
24
|
-
Requires-Dist: unique-search-proxy-core>=2026.46.0.
|
|
23
|
+
Requires-Dist: unique-toolkit[monitoring]>=2026.46.0.dev10,<2026.46.0rc0
|
|
24
|
+
Requires-Dist: unique-search-proxy-core>=2026.46.0.dev2,<2026.46.0rc0
|
|
25
25
|
Requires-Dist: openai>=1.105.0,<3
|
|
26
26
|
Requires-Python: >=3.12
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
@@ -240,6 +240,8 @@ Per-URL outcomes — one URL failing does not fail the batch:
|
|
|
240
240
|
|
|
241
241
|
Crawler discriminators: `Basic`, `Tavily`, `Jina`, `Firecrawl`.
|
|
242
242
|
|
|
243
|
+
**Timeout:** with `Basic`, URLs still loading just before `timeout` come back as per-URL `UPSTREAM_TIMEOUT` results next to the finished pages. A busy worker can send the response up to 5 s after `timeout`, so set your HTTP client timeout at least 5 s above it. The other crawlers fail the whole batch with a `504` at `timeout`.
|
|
244
|
+
|
|
243
245
|
### 5.5 Errors
|
|
244
246
|
|
|
245
247
|
Structured envelope on all non-2xx responses:
|
|
@@ -212,6 +212,8 @@ Per-URL outcomes — one URL failing does not fail the batch:
|
|
|
212
212
|
|
|
213
213
|
Crawler discriminators: `Basic`, `Tavily`, `Jina`, `Firecrawl`.
|
|
214
214
|
|
|
215
|
+
**Timeout:** with `Basic`, URLs still loading just before `timeout` come back as per-URL `UPSTREAM_TIMEOUT` results next to the finished pages. A busy worker can send the response up to 5 s after `timeout`, so set your HTTP client timeout at least 5 s above it. The other crawlers fail the whole batch with a `504` at `timeout`.
|
|
216
|
+
|
|
215
217
|
### 5.5 Errors
|
|
216
218
|
|
|
217
219
|
Structured envelope on all non-2xx responses:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "unique-search-proxy"
|
|
3
|
-
version = "2026.46.0.
|
|
3
|
+
version = "2026.46.0.dev6"
|
|
4
4
|
description = "Web Search Proxy implementation"
|
|
5
5
|
authors = [{ name = "ThePhilAz", email = "rami.azouz@philico.com" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -22,8 +22,8 @@ dependencies = [
|
|
|
22
22
|
"google-genai>=1.73.0,<2",
|
|
23
23
|
"google-auth>=2.43.0,<3",
|
|
24
24
|
"jinja2>=3.1.6,<4",
|
|
25
|
-
"unique-toolkit[monitoring]>=2026.46.0.
|
|
26
|
-
"unique-search-proxy-core>=2026.46.0.
|
|
25
|
+
"unique-toolkit[monitoring]>=2026.46.0.dev10,<2026.46.0rc0",
|
|
26
|
+
"unique-search-proxy-core>=2026.46.0.dev2,<2026.46.0rc0",
|
|
27
27
|
"openai>=1.105.0,<3",
|
|
28
28
|
]
|
|
29
29
|
|
|
@@ -45,6 +45,13 @@ from unique_search_proxy_client.web.usage import record_request_usage
|
|
|
45
45
|
router = APIRouter(tags=["crawl"])
|
|
46
46
|
_LOGGER = logging.getLogger(__name__)
|
|
47
47
|
|
|
48
|
+
# Callers often use the crawl timeout as their HTTP client timeout. Finishing
|
|
49
|
+
# this much earlier lets the response reach them before they give up.
|
|
50
|
+
_RESPONSE_HEADROOM_SECONDS = 0.5
|
|
51
|
+
# Only catches work that ignores the deadline. A busy worker that cancels pages
|
|
52
|
+
# late must delay the response, not drop the pages that finished.
|
|
53
|
+
_SAFETY_NET_GRACE_SECONDS = 5.0
|
|
54
|
+
|
|
48
55
|
|
|
49
56
|
def _crawl_request_context(exc: ProxyError, *, crawler_id: str) -> ProxyError:
|
|
50
57
|
return attach_request_context(
|
|
@@ -108,12 +115,18 @@ async def crawl(
|
|
|
108
115
|
|
|
109
116
|
try:
|
|
110
117
|
_enforce_url_limits(body.urls)
|
|
111
|
-
async with
|
|
118
|
+
async with (
|
|
119
|
+
get_crawl_limiter(request.app).slot(),
|
|
120
|
+
asyncio.timeout(timeout + _SAFETY_NET_GRACE_SECONDS),
|
|
121
|
+
):
|
|
122
|
+
request_deadline = asyncio.get_running_loop().time() + timeout
|
|
123
|
+
deadline = request_deadline - _RESPONSE_HEADROOM_SECONDS
|
|
112
124
|
registry = get_http_client_registry(request.app)
|
|
113
125
|
client = await registry.client_for(get_request_context())
|
|
114
126
|
gate = await apply_url_safety_gate(
|
|
115
127
|
body.urls,
|
|
116
128
|
redirect_http_client=client,
|
|
129
|
+
deadline=deadline,
|
|
117
130
|
)
|
|
118
131
|
units = len(gate.allowed_targets)
|
|
119
132
|
if not gate.allowed_targets:
|
|
@@ -126,7 +139,7 @@ async def crawl(
|
|
|
126
139
|
)
|
|
127
140
|
merged_results = merge_crawl_results(
|
|
128
141
|
body.urls,
|
|
129
|
-
|
|
142
|
+
uncrawled_by_index=gate.uncrawled_by_index,
|
|
130
143
|
crawler_results=[],
|
|
131
144
|
)
|
|
132
145
|
record_crawl_url_outcomes(
|
|
@@ -157,9 +170,11 @@ async def crawl(
|
|
|
157
170
|
crawler_results = await crawler.crawl_pinned(
|
|
158
171
|
crawl_body,
|
|
159
172
|
gate.allowed_targets,
|
|
173
|
+
deadline=deadline,
|
|
160
174
|
)
|
|
161
175
|
else:
|
|
162
|
-
|
|
176
|
+
async with asyncio.timeout_at(request_deadline):
|
|
177
|
+
crawler_results = await crawler.crawl(crawl_body)
|
|
163
178
|
succeeded = True
|
|
164
179
|
except TimeoutError as exc:
|
|
165
180
|
record_crawl_error(
|
|
@@ -217,7 +232,7 @@ async def crawl(
|
|
|
217
232
|
record_crawl_success(crawler_id, len(body.urls), duration)
|
|
218
233
|
merged_results = merge_crawl_results(
|
|
219
234
|
body.urls,
|
|
220
|
-
|
|
235
|
+
uncrawled_by_index=gate.uncrawled_by_index,
|
|
221
236
|
crawler_results=crawler_results,
|
|
222
237
|
)
|
|
223
238
|
record_crawl_url_outcomes(crawler_id, _url_outcomes(merged_results))
|
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
from collections.abc import Coroutine
|
|
6
|
+
from typing import Any
|
|
5
7
|
|
|
6
8
|
import httpx
|
|
7
9
|
from httpx import AsyncClient, Timeout
|
|
@@ -33,6 +35,7 @@ from unique_search_proxy_client.web.core.crawlers.basic.user_agent import (
|
|
|
33
35
|
random_user_agent,
|
|
34
36
|
)
|
|
35
37
|
from unique_search_proxy_client.web.core.provider_response import (
|
|
38
|
+
crawl_deadline_missed,
|
|
36
39
|
crawl_forbidden_target,
|
|
37
40
|
crawl_upstream_error,
|
|
38
41
|
transport_error_raw,
|
|
@@ -97,7 +100,10 @@ class BasicCrawlerService(BaseCrawler[BasicCrawlRequest]):
|
|
|
97
100
|
self,
|
|
98
101
|
request: BasicCrawlRequest, # type: ignore[valid-type]
|
|
99
102
|
allowed_targets: list[AllowedCrawlTarget],
|
|
103
|
+
*,
|
|
104
|
+
deadline: float | None = None,
|
|
100
105
|
) -> list[CrawlUrlResult]:
|
|
106
|
+
"""Crawl targets in order; one still running at ``deadline`` fails on its own."""
|
|
101
107
|
client = self._http_client
|
|
102
108
|
if client is None:
|
|
103
109
|
raise RuntimeError("HTTP client is required for Basic crawler")
|
|
@@ -115,9 +121,9 @@ class BasicCrawlerService(BaseCrawler[BasicCrawlRequest]):
|
|
|
115
121
|
),
|
|
116
122
|
)
|
|
117
123
|
budget = ByteBudget(request_limits_settings.max_crawl_response_bytes)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
results = await asyncio.gather(
|
|
125
|
+
*[
|
|
126
|
+
self._crawl_one_before_deadline(
|
|
121
127
|
self._crawl_one(
|
|
122
128
|
client,
|
|
123
129
|
allowed_target.display_url,
|
|
@@ -126,11 +132,40 @@ class BasicCrawlerService(BaseCrawler[BasicCrawlRequest]):
|
|
|
126
132
|
semaphore=semaphore,
|
|
127
133
|
budget=budget,
|
|
128
134
|
content_type_handlers=request.content_types.to_handlers(),
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
),
|
|
136
|
+
deadline=deadline,
|
|
137
|
+
)
|
|
138
|
+
for allowed_target in allowed_targets
|
|
139
|
+
],
|
|
133
140
|
)
|
|
141
|
+
missed = sum(result is None for result in results)
|
|
142
|
+
if missed:
|
|
143
|
+
_LOGGER.warning(
|
|
144
|
+
"Basic crawl missed the request deadline timeout=%ss pages=%d",
|
|
145
|
+
timeout,
|
|
146
|
+
missed,
|
|
147
|
+
)
|
|
148
|
+
return [
|
|
149
|
+
result
|
|
150
|
+
if result is not None
|
|
151
|
+
else crawl_deadline_missed(
|
|
152
|
+
allowed_target.display_url,
|
|
153
|
+
f"Crawl did not finish before the {timeout}s request deadline",
|
|
154
|
+
)
|
|
155
|
+
for allowed_target, result in zip(allowed_targets, results, strict=True)
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
async def _crawl_one_before_deadline(
|
|
159
|
+
self,
|
|
160
|
+
crawl: Coroutine[Any, Any, CrawlUrlResult],
|
|
161
|
+
*,
|
|
162
|
+
deadline: float | None,
|
|
163
|
+
) -> CrawlUrlResult | None:
|
|
164
|
+
try:
|
|
165
|
+
async with asyncio.timeout_at(deadline):
|
|
166
|
+
return await crawl
|
|
167
|
+
except TimeoutError:
|
|
168
|
+
return None
|
|
134
169
|
|
|
135
170
|
async def _crawl_one(
|
|
136
171
|
self,
|
|
@@ -91,6 +91,16 @@ def crawl_forbidden_target(url: str, message: str) -> CrawlUrlResult:
|
|
|
91
91
|
)
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
def crawl_deadline_missed(url: str, message: str) -> CrawlUrlResult:
|
|
95
|
+
"""Build a per-URL crawl failure for a URL that missed the request deadline."""
|
|
96
|
+
return crawl_upstream_error(
|
|
97
|
+
url,
|
|
98
|
+
message,
|
|
99
|
+
code=ProxyErrorCode.UPSTREAM_TIMEOUT.value,
|
|
100
|
+
content_type=None,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
94
104
|
def raise_for_upstream_response(
|
|
95
105
|
response: httpx.Response,
|
|
96
106
|
*,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from unique_search_proxy_core.schema import CrawlUrlResult
|
|
10
|
+
from unique_search_proxy_core.url_safety import (
|
|
11
|
+
ResolvedCrawlTarget,
|
|
12
|
+
UrlSafetyOutcome,
|
|
13
|
+
UrlSafetyService,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from unique_search_proxy_client.web.core.provider_response import (
|
|
17
|
+
crawl_deadline_missed,
|
|
18
|
+
crawl_forbidden_target,
|
|
19
|
+
)
|
|
20
|
+
from unique_search_proxy_client.web.monitoring.metrics import (
|
|
21
|
+
record_crawl_blocked,
|
|
22
|
+
record_url_safety_check,
|
|
23
|
+
)
|
|
24
|
+
from unique_search_proxy_client.web.settings.request_limits import (
|
|
25
|
+
request_limits_settings,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from httpx import AsyncClient
|
|
30
|
+
|
|
31
|
+
_LOGGER = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class AllowedCrawlTarget:
|
|
36
|
+
"""User-facing URL paired with the validated resolution for pinned egress."""
|
|
37
|
+
|
|
38
|
+
display_url: str
|
|
39
|
+
resolved: ResolvedCrawlTarget
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class UrlSafetyGateResult:
|
|
44
|
+
allowed_targets: list[AllowedCrawlTarget]
|
|
45
|
+
blocked_by_index: dict[int, CrawlUrlResult]
|
|
46
|
+
timed_out_by_index: dict[int, CrawlUrlResult] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def uncrawled_by_index(self) -> dict[int, CrawlUrlResult]:
|
|
50
|
+
return {**self.blocked_by_index, **self.timed_out_by_index}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def apply_url_safety_gate(
|
|
54
|
+
urls: list[str],
|
|
55
|
+
*,
|
|
56
|
+
redirect_http_client: AsyncClient,
|
|
57
|
+
deadline: float | None = None,
|
|
58
|
+
) -> UrlSafetyGateResult:
|
|
59
|
+
"""Partition crawl URLs into allowed, blocked and timed-out targets."""
|
|
60
|
+
check_slots = asyncio.Semaphore(request_limits_settings.max_concurrent_url_checks)
|
|
61
|
+
outcomes = await asyncio.gather(
|
|
62
|
+
*[
|
|
63
|
+
_validate_before_deadline(
|
|
64
|
+
url,
|
|
65
|
+
redirect_http_client=redirect_http_client,
|
|
66
|
+
check_slots=check_slots,
|
|
67
|
+
deadline=deadline,
|
|
68
|
+
)
|
|
69
|
+
for url in urls
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
allowed_targets: list[AllowedCrawlTarget] = []
|
|
74
|
+
blocked_by_index: dict[int, CrawlUrlResult] = {}
|
|
75
|
+
timed_out_by_index: dict[int, CrawlUrlResult] = {}
|
|
76
|
+
|
|
77
|
+
for index, (url, outcome) in enumerate(zip(urls, outcomes, strict=True)):
|
|
78
|
+
if outcome is None:
|
|
79
|
+
timed_out_by_index[index] = crawl_deadline_missed(
|
|
80
|
+
url.strip(),
|
|
81
|
+
"URL safety check did not finish before the crawl deadline",
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if outcome.blocked is not None:
|
|
86
|
+
record_crawl_blocked(outcome.blocked.category)
|
|
87
|
+
blocked_by_index[index] = crawl_forbidden_target(
|
|
88
|
+
outcome.url.strip(),
|
|
89
|
+
outcome.blocked.reason,
|
|
90
|
+
)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if outcome.resolved is None:
|
|
94
|
+
msg = "URL safety allowed a crawl target without resolved metadata"
|
|
95
|
+
raise RuntimeError(msg)
|
|
96
|
+
|
|
97
|
+
allowed_targets.append(
|
|
98
|
+
AllowedCrawlTarget(
|
|
99
|
+
display_url=outcome.url.strip(),
|
|
100
|
+
resolved=outcome.resolved,
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if timed_out_by_index:
|
|
105
|
+
_LOGGER.warning(
|
|
106
|
+
"URL safety checks missed the crawl deadline unchecked=%d",
|
|
107
|
+
len(timed_out_by_index),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return UrlSafetyGateResult(
|
|
111
|
+
allowed_targets=allowed_targets,
|
|
112
|
+
blocked_by_index=blocked_by_index,
|
|
113
|
+
timed_out_by_index=timed_out_by_index,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _validate_before_deadline(
|
|
118
|
+
url: str,
|
|
119
|
+
*,
|
|
120
|
+
redirect_http_client: AsyncClient,
|
|
121
|
+
check_slots: asyncio.Semaphore,
|
|
122
|
+
deadline: float | None,
|
|
123
|
+
) -> UrlSafetyOutcome | None:
|
|
124
|
+
"""Return the URL's safety outcome, or ``None`` when the deadline passes first."""
|
|
125
|
+
try:
|
|
126
|
+
async with asyncio.timeout_at(deadline), check_slots:
|
|
127
|
+
return await _validate_and_record(
|
|
128
|
+
url,
|
|
129
|
+
redirect_http_client=redirect_http_client,
|
|
130
|
+
)
|
|
131
|
+
except TimeoutError:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _validate_and_record(
|
|
136
|
+
url: str,
|
|
137
|
+
*,
|
|
138
|
+
redirect_http_client: AsyncClient,
|
|
139
|
+
) -> UrlSafetyOutcome:
|
|
140
|
+
"""Validate one URL and record how long the check took, by outcome."""
|
|
141
|
+
started = time.perf_counter()
|
|
142
|
+
outcome_label = "unfinished"
|
|
143
|
+
try:
|
|
144
|
+
[outcome] = await UrlSafetyService.validate_urls_individually(
|
|
145
|
+
[url],
|
|
146
|
+
redirect_http_client=redirect_http_client,
|
|
147
|
+
)
|
|
148
|
+
outcome_label = "allowed" if outcome.blocked is None else "blocked"
|
|
149
|
+
return outcome
|
|
150
|
+
finally:
|
|
151
|
+
record_url_safety_check(outcome_label, time.perf_counter() - started)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def merge_crawl_results(
|
|
155
|
+
urls: list[str],
|
|
156
|
+
*,
|
|
157
|
+
uncrawled_by_index: dict[int, CrawlUrlResult],
|
|
158
|
+
crawler_results: list[CrawlUrlResult],
|
|
159
|
+
) -> list[CrawlUrlResult]:
|
|
160
|
+
"""Merge the gate's results for uncrawled URLs with crawler outcomes in order."""
|
|
161
|
+
merged: list[CrawlUrlResult] = []
|
|
162
|
+
crawler_index = 0
|
|
163
|
+
for index, _url in enumerate(urls):
|
|
164
|
+
uncrawled = uncrawled_by_index.get(index)
|
|
165
|
+
if uncrawled is not None:
|
|
166
|
+
merged.append(uncrawled)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
merged.append(crawler_results[crawler_index])
|
|
170
|
+
crawler_index += 1
|
|
171
|
+
|
|
172
|
+
return merged
|
|
@@ -40,6 +40,9 @@ def _exponential_buckets(start: float, ceiling: float, count: int) -> tuple[floa
|
|
|
40
40
|
_SEARCH_LATENCY_BUCKETS = _linear_buckets(ceiling=5.0, steps=20)
|
|
41
41
|
_CRAWL_LATENCY_BUCKETS = _linear_buckets(ceiling=60.0, steps=20)
|
|
42
42
|
_AGENT_SEARCH_LATENCY_BUCKETS = _linear_buckets(ceiling=120.0, steps=20)
|
|
43
|
+
# Half-second steps up to 10s, so checks that hit the per-hop redirect timeout
|
|
44
|
+
# (5s by default) show up as their own bucket.
|
|
45
|
+
_URL_SAFETY_CHECK_LATENCY_BUCKETS = _linear_buckets(ceiling=10.0, steps=20)
|
|
43
46
|
|
|
44
47
|
# One histogram covers every endpoint, from sub-second /v1/search to /v1/agent-search
|
|
45
48
|
# runs approaching 120s — exponential buckets keep resolution useful at both ends.
|
|
@@ -94,6 +97,12 @@ crawl_blocked_total = m.counter(
|
|
|
94
97
|
"Crawl targets blocked by URL safety policy",
|
|
95
98
|
["reason_category"],
|
|
96
99
|
)
|
|
100
|
+
url_safety_check_duration_seconds = m.histogram(
|
|
101
|
+
"url_safety_check_duration_seconds",
|
|
102
|
+
"Time to check one crawl URL (redirect probes and DNS), by outcome",
|
|
103
|
+
["outcome"],
|
|
104
|
+
buckets=_URL_SAFETY_CHECK_LATENCY_BUCKETS,
|
|
105
|
+
)
|
|
97
106
|
url_safety_application_checks_enabled = m.gauge(
|
|
98
107
|
"url_safety_application_checks_enabled",
|
|
99
108
|
"Whether application-level URL safety checks are enabled (1) or delegated "
|
|
@@ -182,6 +191,11 @@ def record_crawl_blocked(reason_category: str, count: int = 1) -> None:
|
|
|
182
191
|
crawl_blocked_total.labels(reason_category=reason_category).inc(count)
|
|
183
192
|
|
|
184
193
|
|
|
194
|
+
def record_url_safety_check(outcome: str, duration_seconds: float) -> None:
|
|
195
|
+
"""Record one URL check; ``outcome`` is allowed, blocked or unfinished."""
|
|
196
|
+
url_safety_check_duration_seconds.labels(outcome=outcome).observe(duration_seconds)
|
|
197
|
+
|
|
198
|
+
|
|
185
199
|
def set_url_safety_application_checks_enabled(enabled: bool) -> None:
|
|
186
200
|
url_safety_application_checks_enabled.set(int(enabled))
|
|
187
201
|
|
|
@@ -23,6 +23,7 @@ REQUEST_LIMITS_ENV_PREFIX = "REQUEST_LIMITS_"
|
|
|
23
23
|
"max_url_length",
|
|
24
24
|
"max_concurrent_fetches",
|
|
25
25
|
"max_concurrent_crawls",
|
|
26
|
+
"max_concurrent_url_checks",
|
|
26
27
|
],
|
|
27
28
|
"timeouts": ["max_timeout_seconds"],
|
|
28
29
|
},
|
|
@@ -82,6 +83,14 @@ class RequestLimitsSettings(BaseSettings):
|
|
|
82
83
|
"this get a 429 instead of queueing."
|
|
83
84
|
),
|
|
84
85
|
)
|
|
86
|
+
max_concurrent_url_checks: int = Field(
|
|
87
|
+
default=3,
|
|
88
|
+
ge=1,
|
|
89
|
+
description=(
|
|
90
|
+
"URL safety checks one crawl request runs at once before fetching. "
|
|
91
|
+
"Each check probes the URL's redirects with HEAD requests."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
85
94
|
max_timeout_seconds: int = Field(
|
|
86
95
|
default=120,
|
|
87
96
|
ge=1,
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
5
|
-
|
|
6
|
-
from unique_search_proxy_core.schema import CrawlUrlResult
|
|
7
|
-
from unique_search_proxy_core.url_safety import ResolvedCrawlTarget, UrlSafetyService
|
|
8
|
-
|
|
9
|
-
from unique_search_proxy_client.web.core.provider_response import crawl_forbidden_target
|
|
10
|
-
from unique_search_proxy_client.web.monitoring.metrics import record_crawl_blocked
|
|
11
|
-
|
|
12
|
-
if TYPE_CHECKING:
|
|
13
|
-
from httpx import AsyncClient
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@dataclass(frozen=True)
|
|
17
|
-
class AllowedCrawlTarget:
|
|
18
|
-
"""User-facing URL paired with the validated resolution for pinned egress."""
|
|
19
|
-
|
|
20
|
-
display_url: str
|
|
21
|
-
resolved: ResolvedCrawlTarget
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass(frozen=True)
|
|
25
|
-
class UrlSafetyGateResult:
|
|
26
|
-
allowed_targets: list[AllowedCrawlTarget]
|
|
27
|
-
blocked_by_index: dict[int, CrawlUrlResult]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
async def apply_url_safety_gate(
|
|
31
|
-
urls: list[str],
|
|
32
|
-
*,
|
|
33
|
-
redirect_http_client: AsyncClient,
|
|
34
|
-
) -> UrlSafetyGateResult:
|
|
35
|
-
"""Validate crawl URLs and partition them into allowed vs blocked targets."""
|
|
36
|
-
outcomes = await UrlSafetyService.validate_urls_individually(
|
|
37
|
-
urls,
|
|
38
|
-
redirect_http_client=redirect_http_client,
|
|
39
|
-
)
|
|
40
|
-
allowed_targets: list[AllowedCrawlTarget] = []
|
|
41
|
-
blocked_by_index: dict[int, CrawlUrlResult] = {}
|
|
42
|
-
|
|
43
|
-
for index, outcome in enumerate(outcomes):
|
|
44
|
-
if outcome.blocked is not None:
|
|
45
|
-
record_crawl_blocked(outcome.blocked.category)
|
|
46
|
-
blocked_by_index[index] = crawl_forbidden_target(
|
|
47
|
-
outcome.url.strip(),
|
|
48
|
-
outcome.blocked.reason,
|
|
49
|
-
)
|
|
50
|
-
continue
|
|
51
|
-
|
|
52
|
-
if outcome.resolved is None:
|
|
53
|
-
msg = "URL safety allowed a crawl target without resolved metadata"
|
|
54
|
-
raise RuntimeError(msg)
|
|
55
|
-
|
|
56
|
-
allowed_targets.append(
|
|
57
|
-
AllowedCrawlTarget(
|
|
58
|
-
display_url=outcome.url.strip(),
|
|
59
|
-
resolved=outcome.resolved,
|
|
60
|
-
),
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
return UrlSafetyGateResult(
|
|
64
|
-
allowed_targets=allowed_targets,
|
|
65
|
-
blocked_by_index=blocked_by_index,
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def merge_crawl_results(
|
|
70
|
-
urls: list[str],
|
|
71
|
-
*,
|
|
72
|
-
blocked_by_index: dict[int, CrawlUrlResult],
|
|
73
|
-
crawler_results: list[CrawlUrlResult],
|
|
74
|
-
) -> list[CrawlUrlResult]:
|
|
75
|
-
"""Merge per-URL blocked results with crawler outcomes in request order."""
|
|
76
|
-
merged: list[CrawlUrlResult] = []
|
|
77
|
-
crawler_index = 0
|
|
78
|
-
for index, _url in enumerate(urls):
|
|
79
|
-
blocked = blocked_by_index.get(index)
|
|
80
|
-
if blocked is not None:
|
|
81
|
-
merged.append(blocked)
|
|
82
|
-
continue
|
|
83
|
-
|
|
84
|
-
merged.append(crawler_results[crawler_index])
|
|
85
|
-
crawler_index += 1
|
|
86
|
-
|
|
87
|
-
return merged
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|