summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md9
-rw-r--r--README.md13
-rw-r--r--README_ja.md11
-rw-r--r--docs/tools-schema.md11
-rw-r--r--pyproject.toml2
-rw-r--r--src/ollama_web/config.py8
-rw-r--r--src/ollama_web/tools/search.py99
-rw-r--r--tests/test_logging.py4
-rw-r--r--tests/test_search.py285
-rw-r--r--tests/test_tools.py10
-rw-r--r--uv.lock154
11 files changed, 438 insertions, 168 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a493ee4..69eac17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@
## [Unreleased]
+### Changed
+
+- ddgs の最低バージョンとロックファイルを 9.16.0 へ更新しました。検索バックエンドは引き続き auto を使用します。
+- Web検索の地域をクエリから内部選択し、`OLLAMA_WEB_SEARCH_REGION` で auto・米国・日本を切り替えられるようにしました。LLM向け引数は変更していません。
+
+### Fixed
+
+- Web検索の空・URL形式タイトルを欠損として扱い、無効URLとYahoo包装解除後の既知Bing広告リンクを除外するようにしました。検索失敗は引き続きツールエラーとして伝えます。
+
## [0.5.4] - 2026-09-14
### Security
diff --git a/README.md b/README.md
index cb7a62b..7f39b27 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,19 @@ ollama-web
| `OLLAMA_WEB_MCP_STDIO_ALLOWLIST` | Not set | Comma-separated absolute paths of executable files allowed for stdio MCP |
| `OLLAMA_WEB_MCP_HTTPS_ALLOWLIST` | Not set | Comma-separated hostnames allowed for remote HTTPS MCP connections |
| `OLLAMA_WEB_LANGUAGE` | `ja` | Display language for the UI and LLM prompts (`ja` or `en`) |
+| `OLLAMA_WEB_SEARCH_REGION` | `auto` | Search region: `auto`, `us-en`, or `jp-jp`. Auto selects Japan for queries containing kana, otherwise the US. |
+
+Search uses ddgs with `backend="auto"`. Region is selected internally; the LLM tool
+still accepts only `query` and `max_results`. Detection normalizes half-width kana
+and ignores URLs and `site:`/`filetype:` tokens, without rewriting the search query.
+Kanji-only queries use `us-en`; set `jp-jp` to prefer Japan for every query. This
+setting is independent of the UI language and takes effect after restarting the app.
+Invalid values stop startup with a configuration error.
+
+Search summaries omit malformed URLs and known Bing ad links, and represent missing
+or URL-only titles as an empty string while keeping the URL and snippet. Filtering
+can reduce the result count; it does not trigger extra requests. Provider failures
+remain tool errors.
### Multilingual Support
diff --git a/README_ja.md b/README_ja.md
index ccb8a39..088ac73 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -79,6 +79,17 @@ ollama-web
| `OLLAMA_WEB_MCP_STDIO_ALLOWLIST` | 未設定 | stdio MCP で起動を許可する実行ファイル絶対パスのカンマ区切り |
| `OLLAMA_WEB_MCP_HTTPS_ALLOWLIST` | 未設定 | remote HTTPS MCP 接続を許可するホスト名のカンマ区切り |
| `OLLAMA_WEB_LANGUAGE` | `ja` | UI と LLM プロンプトの表示言語(`ja` または `en`) |
+| `OLLAMA_WEB_SEARCH_REGION` | `auto` | 検索地域。`auto` / `us-en` / `jp-jp`。auto はかなを含むクエリを日本、それ以外を米国として検索 |
+
+検索は ddgs の `backend="auto"` を使用します。地域は内部で選び、LLM に公開する引数は
+`query` と `max_results` のままです。判定では半角カナも正規化し、URL と `site:` / `filetype:`
+のトークンを除外します。検索へ送る元クエリは変更しません。漢字だけのクエリは `us-en`
+になるため、日本へ固定する場合は `jp-jp` を設定してください。UI 言語には連動せず、
+設定変更の反映にはアプリの再起動が必要です。不正な設定値では起動時にエラーになります。
+
+構文上無効な URL と既知の Bing 広告リンクを除外し、空・URL 形式のタイトルは空文字にして
+URL と要約を保持します。除外で件数が減っても追加検索は行いません。provider の失敗は
+ツールエラーとして伝えます。
### 多言語対応
diff --git a/docs/tools-schema.md b/docs/tools-schema.md
index 0b3f556..a3f1f43 100644
--- a/docs/tools-schema.md
+++ b/docs/tools-schema.md
@@ -31,7 +31,7 @@ ollama-web は LLM(Ollama)にツール呼び出し(function calling)機è
| ツール名 | 実装ファイル | 概要 | ページング対応 |
|----------|--------------|------|----------------|
-| `web_search` | [`search.py`](src/ollama_web/tools/search.py:1) | DuckDuckGo による Web 検索 | ❌ 対象外 |
+| `web_search` | [`search.py`](src/ollama_web/tools/search.py:1) | ddgs による複数検索元の Web 検索 | ❌ 対象外 |
| `scrape_url` | [`scrape.py`](src/ollama_web/tools/scrape.py:1) | URL のページ本文抽出 | ✅ 文字オフセット |
| `pdf_to_text` | [`pdf.py`](src/ollama_web/tools/pdf.py:1) | リモート PDF のテキスト抽出 | ✅ ページオフセット |
| `search_and_fetch` | [`fetch.py`](src/ollama_web/tools/fetch.py:1) | ファイル種別検索+取得 | ✅ ファイル単位 |
@@ -41,7 +41,7 @@ ollama-web は LLM(Ollama)にツール呼び出し(function calling)機è
## 1. web_search
- **実装**: [`web_search()`](src/ollama_web/tools/search.py:10)
-- **バックエンド**: [`ddgs`](https://pypi.org/project/ddgs/) (DuckDuckGo Search)
+- **バックエンド**: [`ddgs`](https://pypi.org/project/ddgs/) 9.16.0(ロック版)の `auto`。DuckDuckGo 専用ではなく、利用可能な検索元をライブラリが選択します。
### スキーマ
@@ -56,13 +56,16 @@ ollama-web は LLM(Ollama)にツール呼び出し(function calling)機è
| キー | 型 | 説明 |
|------|----|------|
-| `title` | `str` | 検索結果のタイトル |
+| `title` | `str` | 検索結果のタイトル。空・欠損・URL形式の場合は空文字。本文で補完しない |
| `href` | `str` | 結果URL(`href` が無い場合は `url` をフォールバック) |
| `body` | `str` | スニペット(`body` が無い場合は `snippet` をフォールバック) |
### 設計上の注意点
-- 検索失敗時は例外を握りつぶし空リスト `[]` を返す(チャットループ継続のため)。
+- provider の失敗はログへ記録して例外を伝播し、ツール実行層がエラー応答へ変換します。空クエリや provider が正常に空リストを返した場合とは区別します。
+- region は `OLLAMA_WEB_SEARCH_REGION`(既定 `auto`)から内部選択します。かなを含む検索本文は `jp-jp`、それ以外は `us-en`。固定指定も可能です。公開スキーマには追加しません。
+- backend は `auto` を維持します。検索元の認証・アクセス制限やHTML抽出不整合で取得に失敗する場合があり、今回の比較では複数backend指定による成功率向上も確認できていません。
+- 構文上無効なURLと、Yahoo包装解除後を含む既知のBing広告遷移URLを除外します。残る結果の順序は保持し、除外後の不足分を埋める追加検索はしません。
- **ページング対象外**。検索結果キャッシュが必要になること、DuckDuckGo API 呼び出し回数増でアクセス制限に引っかかりやすくなる懸念から除外。
---
diff --git a/pyproject.toml b/pyproject.toml
index e6fd0a6..d5cf90c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,7 +18,7 @@ classifiers = [
]
dependencies = [
"ollama>=0.6.2",
- "ddgs>=9.14.4",
+ "ddgs>=9.16.0",
"starlette>=0.37",
"uvicorn>=0.30",
"httpx>=0.27",
diff --git a/src/ollama_web/config.py b/src/ollama_web/config.py
index fa07384..484038a 100644
--- a/src/ollama_web/config.py
+++ b/src/ollama_web/config.py
@@ -12,6 +12,13 @@ def _csv_env(name: str) -> list[str]:
return [part.strip() for part in raw.split(",") if part.strip()]
+def _search_region_env() -> str:
+ region = os.environ.get("OLLAMA_WEB_SEARCH_REGION", "auto").strip().lower()
+ if region not in {"auto", "us-en", "jp-jp"}:
+ raise ValueError("OLLAMA_WEB_SEARCH_REGION must be one of: auto, us-en, jp-jp")
+ return region
+
+
@dataclass
class Settings:
"""Runtime settings resolved from environment variables."""
@@ -48,6 +55,7 @@ class Settings:
scrape_timeout: float = 15.0
scrape_max_chars: int = 4000
search_max_results: int = 5
+ search_region: str = field(default_factory=_search_region_env)
fetch_max_results: int = 3
fetch_max_chars: int = 8000
max_fetch_mb: int = 10
diff --git a/src/ollama_web/tools/search.py b/src/ollama_web/tools/search.py
index 773f7f3..c3127df 100644
--- a/src/ollama_web/tools/search.py
+++ b/src/ollama_web/tools/search.py
@@ -1,8 +1,12 @@
-"""Web search tool backed by DuckDuckGo (ddgs)."""
+"""Web metasearch with internal region selection and compact summaries."""
from __future__ import annotations
import logging
+import re
+import unicodedata
+from ipaddress import ip_address
+from urllib.parse import unquote, urlsplit
from ddgs import DDGS
@@ -10,9 +14,83 @@ from ..config import settings
logger = logging.getLogger(__name__)
+_QUERY_METADATA = re.compile(r"(?:https?://|www\.)\S+|(?<!\S)-?(?:site|filetype):\S+", re.I)
+_KANA = re.compile(r"[\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fd-\u30ff]")
+_HOST_LABEL = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", re.I)
+
+
+def _resolve_search_region(query: str) -> str:
+ if settings.search_region != "auto":
+ return settings.search_region
+ text = _QUERY_METADATA.sub("", unicodedata.normalize("NFKC", query))
+ return "jp-jp" if _KANA.search(text) else "us-en"
+
+
+def _is_http_url(value: str) -> bool:
+ """Validate syntax only; search summaries do not resolve or fetch URLs."""
+ if not value or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in value):
+ return False
+ try:
+ parsed = urlsplit(value)
+ host = parsed.hostname
+ if parsed.scheme not in {"http", "https"} or not host or parsed.username is not None:
+ return False
+ # Accessing port also rejects malformed/out-of-range port numbers.
+ if parsed.port is not None and parsed.port == 0:
+ return False
+ try:
+ ip_address(host)
+ return True
+ except ValueError:
+ ascii_host = host.rstrip(".").encode("idna").decode("ascii")
+ return len(ascii_host) <= 253 and all(
+ _HOST_LABEL.fullmatch(label) for label in ascii_host.split(".")
+ )
+ except (ValueError, UnicodeError):
+ return False
+
+
+def _result_url(value: str) -> str:
+ if not _is_http_url(value):
+ return ""
+ parsed = urlsplit(value)
+ if (parsed.hostname or "").rstrip(".") == "r.search.yahoo.com" and "/RU=" in parsed.path:
+ target = parsed.path.split("/RU=", 1)[1]
+ value = unquote(re.split(r"/R[KS]=", target, maxsplit=1)[0])
+ if not _is_http_url(value):
+ return ""
+ parsed = urlsplit(value)
+ if (
+ (parsed.hostname or "").rstrip(".") in {"bing.com", "www.bing.com"}
+ and parsed.path.rstrip("/") == "/aclick"
+ ):
+ return ""
+ return value
+
+
+def _url_title(title: str, href: str) -> bool:
+ def display_key(value: str) -> str:
+ value = re.sub(r"^https?://", "", unquote(value), flags=re.I)
+ value = re.sub(r"^www\.", "", value, flags=re.I)
+ return value.rstrip("/")
+
+ if display_key(title) == display_key(href):
+ return True
+ # Only whole URL-like labels, not headings such as "example.com の使い方".
+ candidate = title if re.match(r"^https?://", title, re.I) else "https://" + title
+ if _is_http_url(candidate):
+ host = urlsplit(candidate).hostname or ""
+ tld = host.rstrip(".").rsplit(".", 1)[-1].encode("idna").decode("ascii")
+ return bool(re.match(r"^https?://", title, re.I)) or (
+ "." in host and bool(re.fullmatch(r"[a-z]{2,}|xn--[a-z0-9-]+", tld, re.I))
+ )
+ return False
+
def web_search(query: str, max_results: int | None = None) -> list[dict[str, str]]:
- """Search the web using DuckDuckGo and return compact result summaries.
+ """Search the web with ddgs auto and return compact result summaries.
+
+ Search region is selected internally from the query or server settings.
Args:
query: The search query string.
@@ -20,7 +98,9 @@ def web_search(query: str, max_results: int | None = None) -> list[dict[str, str
Returns:
A list of dictionaries with keys 'title', 'href' and 'body' for each
- result. Returns an empty list if no results are found. Provider failures
+ result. A missing or URL-only title is an empty string; use the body
+ for context. Invalid URLs and known ad links are omitted, so fewer
+ results may be returned. Returns an empty list for empty results. Provider failures
are logged and propagated to the tool execution layer.
"""
if not query or not query.strip():
@@ -31,20 +111,25 @@ def web_search(query: str, max_results: int | None = None) -> list[dict[str, str
)
results: list[dict[str, str]] = []
+ region = _resolve_search_region(query)
try:
with DDGS() as ddgs:
- for item in ddgs.text(query, max_results=limit):
+ for item in ddgs.text(query, max_results=limit, region=region, backend="auto"):
+ href = _result_url(str(item.get("href") or item.get("url") or "").strip())
+ if not href:
+ continue
+ title = str(item.get("title") or "").strip()
results.append(
{
- "title": str(item.get("title", "")).strip(),
- "href": str(item.get("href") or item.get("url") or "").strip(),
+ "title": "" if _url_title(title, href) else title,
+ "href": href,
"body": str(item.get("body") or item.get("snippet") or "").strip(),
}
)
except Exception: # noqa: BLE001
# Preserve the provider exception for the tool layer while recording
# the traceback without logging the potentially sensitive query.
- logger.exception("DuckDuckGo web search failed")
+ logger.exception("ddgs web search failed")
raise
return results[:limit]
diff --git a/tests/test_logging.py b/tests/test_logging.py
index ef6fbd2..68aeb97 100644
--- a/tests/test_logging.py
+++ b/tests/test_logging.py
@@ -73,7 +73,7 @@ def test_web_search_provider_error_is_written_to_server_log(
def __exit__(self, *args):
return None
- def text(self, query, max_results=5):
+ def text(self, query, max_results=5, *, region, backend):
raise RuntimeError("403 Forbidden")
with patch("ollama_web.tools.search.DDGS", FailingDDGS), TestClient(create_app()):
@@ -83,6 +83,6 @@ def test_web_search_provider_error_is_written_to_server_log(
assert result == {"error": "RuntimeError: 403 Forbidden"}
content = (data_dir / "server.log").read_text(encoding="utf-8")
- assert "DuckDuckGo web search failed" in content
+ assert "ddgs web search failed" in content
assert "RuntimeError: 403 Forbidden" in content
assert "private search terms" not in content
diff --git a/tests/test_search.py b/tests/test_search.py
new file mode 100644
index 0000000..77f6ee4
--- /dev/null
+++ b/tests/test_search.py
@@ -0,0 +1,285 @@
+"""Search policy and result-boundary regression tests (no live network)."""
+
+import json
+import socket
+import subprocess
+import sys
+from concurrent.futures import ThreadPoolExecutor
+from threading import Barrier
+from urllib.parse import quote
+
+import pytest
+from ollama._utils import convert_function_to_tool
+
+from ollama_web.config import Settings, settings
+from ollama_web.tools import search
+from ollama_web.tools.helper.pagination import parse_paged_result
+from ollama_web.tools.registry import default_registry
+
+
+@pytest.fixture
+def provider(monkeypatch):
+ calls = []
+ rows = [{"title": "Recipe", "href": "https://example.com/recipe", "body": "Summary"}]
+
+ class FakeDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ pass
+
+ def text(self, query, **kwargs):
+ calls.append((query, kwargs))
+ return rows
+
+ monkeypatch.setattr(search, "DDGS", FakeDDGS)
+ monkeypatch.setattr(settings, "search_region", "auto")
+ return calls, rows
+
+
+@pytest.mark.parametrize(
+ "value,expected",
+ [
+ (None, "auto"),
+ ("auto", "auto"),
+ ("us-en", "us-en"),
+ ("jp-jp", "jp-jp"),
+ (" JP-JP ", "jp-jp"),
+ ],
+)
+def test_region_setting(monkeypatch, value, expected):
+ monkeypatch.delenv("OLLAMA_WEB_SEARCH_REGION", raising=False)
+ if value is not None:
+ monkeypatch.setenv("OLLAMA_WEB_SEARCH_REGION", value)
+ assert Settings().search_region == expected
+
+
+@pytest.mark.parametrize("value", ["", "ja", "jp", "en-US", "jp-ja"])
+def test_invalid_region_setting(monkeypatch, value):
+ monkeypatch.setenv("OLLAMA_WEB_SEARCH_REGION", value)
+ with pytest.raises(ValueError, match="OLLAMA_WEB_SEARCH_REGION.*auto, us-en, jp-jp"):
+ Settings()
+
+
+def test_invalid_region_fails_at_import(monkeypatch):
+ monkeypatch.setenv("OLLAMA_WEB_SEARCH_REGION", "invalid")
+ process = subprocess.run(
+ [sys.executable, "-c", "import ollama_web.config"], capture_output=True, text=True
+ )
+ assert process.returncode != 0
+ assert "OLLAMA_WEB_SEARCH_REGION must be one of: auto, us-en, jp-jp" in process.stderr
+
+
+@pytest.mark.parametrize(
+ "query,region",
+ [
+ ("カレー おすすめ レシピ", "jp-jp"),
+ ("引越し 費用 相場", "jp-jp"),
+ ("カレー レシピ", "jp-jp"),
+ ("best curry recipe", "us-en"),
+ ("東京 天気", "us-en"),
+ ("北京 天气", "us-en"),
+ ("рецепт карри", "us-en"),
+ ("curry レシピ", "jp-jp"),
+ ("カレー site:cookpad.com filetype:pdf", "jp-jp"),
+ ("recipe https://example.com/カレー", "us-en"),
+ ("recipe WWW.example.com/レシピ", "us-en"),
+ ("recipe SITE:レシピ.example -filetype:カレー", "us-en"),
+ ("recipe https://example.com/カレー おすすめ", "jp-jp"),
+ ],
+)
+def test_region_and_original_query(provider, query, region):
+ calls, _ = provider
+ search.web_search(query, 2)
+ assert calls == [(query, {"max_results": 2, "region": region, "backend": "auto"})]
+
+
+@pytest.mark.parametrize("region,query", [("us-en", "カレー"), ("jp-jp", "best curry recipe")])
+def test_fixed_region_overrides_query(provider, monkeypatch, region, query):
+ monkeypatch.setattr(settings, "search_region", region)
+ search.web_search(query)
+ assert provider[0][0][1]["region"] == region
+
+
+@pytest.mark.parametrize("query", ["", " \n\t"])
+def test_empty_query_does_not_search(provider, query):
+ assert search.web_search(query) == []
+ assert provider[0] == []
+
+
+def test_concurrent_regions_are_local(monkeypatch):
+ barrier = Barrier(2)
+ monkeypatch.setattr(settings, "search_region", "auto")
+
+ class ConcurrentDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ pass
+
+ def text(self, query, **kwargs):
+ barrier.wait(timeout=5)
+ return [{"title": kwargs["region"], "href": "https://example.com", "body": query}]
+
+ monkeypatch.setattr(search, "DDGS", ConcurrentDDGS)
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ results = list(executor.map(search.web_search, ["カレー", "curry"]))
+ assert [r[0]["title"] for r in results] == ["jp-jp", "us-en"]
+ assert settings.search_region == "auto"
+
+
+@pytest.mark.parametrize(
+ "title,expected",
+ [
+ (None, ""),
+ ("", ""),
+ (" ", ""),
+ ("Recipe", "Recipe"),
+ ("https://www.example.com/カレー/", ""),
+ ("www.example.com/カレー", ""),
+ ("example.com/%E3%82%AB%E3%83%AC%E3%83%BC/", ""),
+ ("https://other.example/recipe", ""),
+ ("other.example/recipe", ""),
+ ("example.com の使い方", "example.com の使い方"),
+ ("Recipe at example.com", "Recipe at example.com"),
+ ("Python3.12", "Python3.12"),
+ ("v9.16.0", "v9.16.0"),
+ ],
+)
+def test_title_semantics(provider, title, expected):
+ _, rows = provider
+ rows[:] = [
+ {"title": title, "href": "https://www.example.com/カレー/", "body": "A real summary"}
+ ]
+ assert search.web_search("recipe") == [
+ {"title": expected, "href": "https://www.example.com/カレー/", "body": "A real summary"}
+ ]
+
+
+@pytest.mark.parametrize(
+ "url",
+ [
+ None,
+ "",
+ "/relative",
+ "javascript:alert(1)",
+ "ftp://example.com/file",
+ "https:///path",
+ "https://bad host.example",
+ "https://[invalid",
+ "https://example.com:bad",
+ "https://example.com:99999",
+ "https://example..com",
+ "https://example.com/\npath",
+ ],
+)
+def test_invalid_url_is_omitted(provider, url):
+ provider[1][:] = [{"title": "Invalid", "href": url}]
+ assert search.web_search("recipe") == []
+ assert len(provider[0]) == 1
+
+
+def test_fallbacks_and_no_extra_network(provider, monkeypatch):
+ def unexpected(*args, **kwargs):
+ raise AssertionError("Search normalization must not resolve or fetch URLs")
+
+ monkeypatch.setattr(socket, "getaddrinfo", unexpected)
+ provider[1][:] = [
+ {
+ "title": None,
+ "href": None,
+ "url": "https://example.com/a",
+ "body": None,
+ "snippet": "Fallback",
+ }
+ ]
+ assert search.web_search("recipe") == [
+ {"title": "", "href": "https://example.com/a", "body": "Fallback"}
+ ]
+
+
+@pytest.mark.parametrize(
+ "href,expected",
+ [
+ ("https://bing.com/aclick?u=ad", None),
+ ("https://www.bing.com/aclick?u=ad", None),
+ ("https://www.bing.com./aclick?u=ad", None),
+ (
+ "https://r.search.yahoo.com/a/RU="
+ + quote("https://www.bing.com/aclick?u=ad", safe="")
+ + "/RK=2/RS=x",
+ None,
+ ),
+ (
+ "https://r.search.yahoo.com/a/RU="
+ + quote("https://example.com/article?q=1", safe="")
+ + "/RK=2/RS=x",
+ "https://example.com/article?q=1",
+ ),
+ ("https://r.search.yahoo.com/a/RU=javascript%3Aalert(1)/RK=2", None),
+ ("https://www.bing.com/search?q=curry", "https://www.bing.com/search?q=curry"),
+ ("https://example.com/RU=article/RK=2", "https://example.com/RU=article/RK=2"),
+ ("https://r.search.yahoo.com/article", "https://r.search.yahoo.com/article"),
+ ],
+)
+def test_known_ad_and_wrapper_boundaries(provider, href, expected):
+ provider[1][:] = [{"title": "Article", "href": href, "body": "Summary"}]
+ result = search.web_search("article")
+ assert [r["href"] for r in result] == ([] if expected is None else [expected])
+
+
+@pytest.mark.parametrize("limit,expected", [(None, 2), (0, 2), (-1, 2), (1, 1), (3, 3)])
+def test_limits_and_order(provider, monkeypatch, limit, expected):
+ monkeypatch.setattr(settings, "search_max_results", 2)
+ provider[1][:] = [{"title": str(i), "href": f"https://example.com/{i}"} for i in range(4)]
+ assert [r["title"] for r in search.web_search("recipe", limit)] == [
+ str(i) for i in range(expected)
+ ]
+ assert provider[0][0][1]["max_results"] == expected
+
+
+def test_omitted_results_do_not_trigger_retry(provider):
+ provider[1][:] = [
+ {"href": "https://bing.com/aclick?u=ad"},
+ {"title": "Kept", "href": "https://example.com/article"},
+ ]
+ assert len(search.web_search("recipe", 5)) == 1
+ assert len(provider[0]) == 1
+
+
+def test_public_schema_and_registry_result(provider):
+ schema = convert_function_to_tool(search.web_search).function.parameters
+ assert set(schema.properties) == {"query", "max_results"}
+ assert schema.required == ["query"]
+ results = json.loads(default_registry().execute("web_search", {"query": "カレー"}))
+ assert set(results[0]) == {"title", "href", "body"}
+ assert provider[0][0][1]["region"] == "jp-jp"
+
+
+def test_search_and_fetch_uses_cleaned_candidates_and_continues(provider, monkeypatch):
+ from ollama_web.tools import fetch
+
+ provider[1][:] = [
+ {"href": "https://bing.com/aclick?u=ad"},
+ {"title": "https://example.com/a.txt", "href": "https://example.com/a.txt"},
+ {"title": "B", "href": "https://example.com/b.txt"},
+ ]
+ monkeypatch.setattr(
+ fetch,
+ "fetch_public_bytes",
+ lambda url, timeout=None: (
+ ("FIRST" if url.endswith("a.txt") else "SECOND").encode(),
+ url,
+ "text/plain",
+ ),
+ )
+ first = fetch.search_and_fetch("カレー", "txt", max_results=2)
+ body, has_more, token = parse_paged_result(first)
+ assert "FIRST" in body and has_more and token
+ second = fetch.search_and_fetch("カレー", "txt", continue_from=token)
+ assert "SECOND" in parse_paged_result(second)[0]
+ assert provider[0] == [
+ ("カレー filetype:txt", {"max_results": 4, "region": "jp-jp", "backend": "auto"})
+ ]
diff --git a/tests/test_tools.py b/tests/test_tools.py
index 6ba537b..487ce98 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -103,7 +103,9 @@ def test_web_search_mocked():
def __exit__(self, *a):
return None
- def text(self, q, max_results=5):
+ def text(self, q, max_results=5, *, region, backend):
+ assert region == "us-en"
+ assert backend == "auto"
return fake
monkey = MagicMock()
@@ -126,7 +128,7 @@ def test_web_search_returns_empty_list_for_no_provider_results() -> None:
def __exit__(self, *args):
return None
- def text(self, query, max_results=5):
+ def text(self, query, max_results=5, *, region, backend):
return []
with patch.object(search_mod, "DDGS", EmptyDDGS):
@@ -145,7 +147,7 @@ def test_web_search_logs_and_propagates_provider_errors(
def __exit__(self, *args):
return None
- def text(self, query, max_results=5):
+ def text(self, query, max_results=5, *, region, backend):
raise RuntimeError("403 Forbidden")
with (
@@ -155,7 +157,7 @@ def test_web_search_logs_and_propagates_provider_errors(
with pytest.raises(RuntimeError, match="403 Forbidden"):
search_mod.web_search("private search terms")
- assert "DuckDuckGo web search failed" in caplog.text
+ assert "ddgs web search failed" in caplog.text
assert "private search terms" not in caplog.text
registry = ToolRegistry()
diff --git a/uv.lock b/uv.lock
index 7315a40..f940d8c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -97,89 +97,6 @@ wheels = [
]
[[package]]
-name = "brotli"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" },
- { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" },
- { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" },
- { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" },
- { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" },
- { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" },
- { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" },
- { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" },
- { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" },
- { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" },
- { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" },
- { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" },
- { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" },
- { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" },
- { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" },
- { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" },
- { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" },
- { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" },
- { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" },
- { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" },
- { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
- { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
- { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
- { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
- { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
- { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
- { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
- { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
- { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
- { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
- { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
- { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
- { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
- { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
- { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
- { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
- { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
- { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
- { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
- { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
- { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
- { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
- { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
- { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
- { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
- { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
- { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
- { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
- { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
- { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
-]
-
-[[package]]
-name = "brotlicffi"
-version = "1.2.0.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
- { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
- { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
- { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
- { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
- { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
- { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
- { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
- { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
- { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
- { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" },
- { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" },
- { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" },
- { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" },
-]
-
-[[package]]
name = "certifi"
version = "2026.6.17"
source = { registry = "https://pypi.org/simple" }
@@ -453,18 +370,16 @@ wheels = [
[[package]]
name = "ddgs"
-version = "9.14.4"
+version = "9.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
- { name = "fake-useragent" },
- { name = "httpx", extra = ["brotli", "http2", "socks"] },
{ name = "lxml" },
{ name = "primp" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/77/24/9d29eeb7dd4852c27c3673adcaf30c4dc55ced76b303c1fbb792ce7cae52/ddgs-9.14.4.tar.gz", hash = "sha256:f7b118a2b709a9e9c04a1dca6e96b98c25d4dfaca1a4b0a244d74454fcca48ef", size = 59742, upload-time = "2026-05-15T06:53:45.946Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/30/dd2ff7d817573e30836fb99bdc04e167ce03ac5b878072b3e9e892aa810b/ddgs-9.16.0.tar.gz", hash = "sha256:161ca8e78ea08d40cd3f83fb12279b49322ffb342d981368bfa39bed9847d874", size = 38018, upload-time = "2026-08-26T21:52:33.604Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e8/5f/32de4d99220eb559b7b1cd1c529a1856efa8097f7a3e10b6c207aa95e36c/ddgs-9.14.4-py3-none-any.whl", hash = "sha256:acb084c34bf1110c974caf7e5e5a2c1973beb4bd9e170bfd191fe5ed2d2b2d6c", size = 70638, upload-time = "2026-05-15T06:53:44.761Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0c/b43a87e81e45caf2c30fea9a5914dfd17b83a9f3c2c59ae22b6257bcf083/ddgs-9.16.0-py3-none-any.whl", hash = "sha256:175d9198c958a263f51a06a54368ba0b41294942c0cd29f4aa71be03dbcd5f4a", size = 47580, upload-time = "2026-08-26T21:52:32.001Z" },
]
[[package]]
@@ -480,15 +395,6 @@ wheels = [
]
[[package]]
-name = "fake-useragent"
-version = "2.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898, upload-time = "2025-04-14T15:32:19.238Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695, upload-time = "2025-04-14T15:32:17.732Z" },
-]
-
-[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
@@ -498,28 +404,6 @@ wheels = [
]
[[package]]
-name = "h2"
-version = "4.4.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "hpack" },
- { name = "hyperframe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
-]
-
-[[package]]
-name = "hpack"
-version = "4.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
-]
-
-[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
@@ -547,18 +431,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
-[package.optional-dependencies]
-brotli = [
- { name = "brotli", marker = "platform_python_implementation == 'CPython'" },
- { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" },
-]
-http2 = [
- { name = "h2" },
-]
-socks = [
- { name = "socksio" },
-]
-
[[package]]
name = "httpx-sse"
version = "0.4.3"
@@ -569,15 +441,6 @@ wheels = [
]
[[package]]
-name = "hyperframe"
-version = "6.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
-]
-
-[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
@@ -1058,7 +921,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.12" },
- { name = "ddgs", specifier = ">=9.14.4" },
+ { name = "ddgs", specifier = ">=9.16.0" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "jinja2", specifier = ">=3.1" },
{ name = "mcp", specifier = ">=1.6.0" },
@@ -1812,15 +1675,6 @@ wheels = [
]
[[package]]
-name = "socksio"
-version = "1.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
-]
-
-[[package]]
name = "soupsieve"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }