diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d295d40 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.4.0 - 2026-10-12 + +Breaking: search/news engine-specific fields move from each result's top level to optional `metadata` (`kind`, `stars`, `language`, `num_comments`, `points`, `story_url`). Read e.g. `result.metadata.stars` instead of `result.stars`. `published_date` stays at the top level. No legacy field aliases. diff --git a/README.md b/README.md index 5395c78..d5f77c0 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,11 @@ for result in response["results"]: `bingcn`, `yandex`, `reddit`, `github`, `arxiv`, `wikipedia`, or `grokipedia`. `page` requests a later results page on engines with native pagination (`bing`, `bingcn`, `baidu`, `grokipedia`). Results carry `published_date` when the source -exposes one. +exposes one. Engine-specific fields are in each result's optional `metadata`: +GitHub uses `metadata.kind`, `metadata.stars`, `metadata.language`, and +`metadata.num_comments`; Hacker News news results use `metadata.story_url`, +`metadata.points`, and `metadata.num_comments`. The former top-level paths +are removed in 0.4.0; `published_date` stays at the top level. Use the client as a context manager when it owns the HTTP connection pool: diff --git a/openapi/search1api.openapi.json b/openapi/search1api.openapi.json index f66055d..2371d07 100644 --- a/openapi/search1api.openapi.json +++ b/openapi/search1api.openapi.json @@ -348,6 +348,16 @@ "description": "When the page was published, as ISO 8601: `YYYY-MM-DD` when only the day is known (web search engines), or `YYYY-MM-DDTHH:MM:SSZ` in UTC when the source carries a time (x, reddit, github, arxiv, youtube, bilibili, wechat, news feeds). Omitted when the source exposes no date, so treat it as optional per result.", "example": "2026-09-03" }, + "metadata": { + "$ref": "#/components/schemas/SearchResultMetadata" + } + }, + "additionalProperties": true + }, + "SearchResultMetadata": { + "type": "object", + "description": "Optional engine-specific fields. Omitted when none apply. GitHub fields appear on POST /search; Hacker News fields appear on POST /news.", + "properties": { "kind": { "type": "string", "enum": [ @@ -360,11 +370,11 @@ }, "stars": { "type": "integer", - "description": "Stargazer count of a `github` repository result (`kind: \"repo\"`)." + "description": "Stargazer count of a `github` repository result (`metadata.kind: \"repo\"`)." }, "language": { "type": "string", - "description": "Primary language of a `github` repository result (`kind: \"repo\"`). Omitted when GitHub reports none." + "description": "Primary language of a `github` repository result (`metadata.kind: \"repo\"`). Omitted when GitHub reports none." }, "num_comments": { "type": "integer", @@ -373,7 +383,7 @@ "story_url": { "type": "string", "format": "uri", - "description": "POST /news with `search_service: \"hackernews\"` only: the submitted article. `link` is the Hacker News discussion thread; `story_url` is empty for Ask HN / Show HN text posts." + "description": "POST /news with `search_service: \"hackernews\"` only: the submitted article. `link` is the Hacker News discussion thread; `metadata.story_url` is omitted for Ask HN / Show HN text posts." }, "points": { "type": "integer", @@ -631,7 +641,7 @@ "properties": { "search_query": { "type": "string", - "description": "Keywords sent to the engines, with platform and time phrases removed." + "description": "Query sent to the first engine in `sources`. Each engine searches its own span of the request words, without platform names; the time window is applied as a filter." }, "sources": { "type": "array", @@ -652,7 +662,7 @@ "year", null ], - "description": "Publication window applied, or null for none." + "description": "Publication window applied, or null for none. year means the past 365 days, not the current calendar year." } } }, diff --git a/pyproject.toml b/pyproject.toml index df645c2..c694be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "search1api" -version = "0.3.0" +version = "0.4.0" description = "Official Python client for Search1API" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/search1api/__init__.py b/src/search1api/__init__.py index 531ead4..6fc26e9 100644 --- a/src/search1api/__init__.py +++ b/src/search1api/__init__.py @@ -35,4 +35,4 @@ "UnprocessableEntityError", ] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/search1api/types.py b/src/search1api/types.py index 082d658..cfb5c0b 100644 --- a/src/search1api/types.py +++ b/src/search1api/types.py @@ -54,9 +54,7 @@ class SearchResultRequired(TypedDict): snippet: str -class SearchResult(SearchResultRequired, total=False): - content: str - published_date: str +class SearchResultMetadata(TypedDict, total=False): kind: Literal["repo", "issue", "pr", "discussion"] stars: int language: str @@ -65,6 +63,12 @@ class SearchResult(SearchResultRequired, total=False): story_url: str +class SearchResult(SearchResultRequired, total=False): + content: str + published_date: str + metadata: SearchResultMetadata + + class SearchResponseRequired(TypedDict): searchParameters: SearchRequest results: List[SearchResult] diff --git a/tests/test_client.py b/tests/test_client.py index cc02418..7ce782d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -24,6 +24,39 @@ def test_requires_api_key(monkeypatch): Search1API() +@pytest.mark.parametrize("method", ["search", "news"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_preserves_nested_metadata(method, asynchronous): + body = { + "searchParameters": {"query": "q"}, + "results": [ + { + "title": "Thread", + "link": "https://example.com/thread", + "snippet": "Discussion", + "published_date": "2026-10-12", + "metadata": {"num_comments": 0}, + } + ], + } + transport = httpx.MockTransport(lambda request: json_response(200, body, request)) + + async def run(): + async with httpx.AsyncClient(transport=transport) as http_client: + async with AsyncSearch1API("test-key", client=http_client) as client: + return await getattr(client, method)("q") + + if asynchronous: + response = asyncio.run(run()) + else: + with httpx.Client(transport=transport) as http_client: + with Search1API("test-key", client=http_client) as client: + response = getattr(client, method)("q") + assert response == body + assert response["results"][0]["metadata"]["num_comments"] == 0 + assert "num_comments" not in response["results"][0] + + def test_rejects_invalid_retry_and_timeout_settings(): with pytest.raises(Search1APIConfigurationError, match="max_retries"): Search1API("test-key", max_retries=-1)