Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
20 changes: 15 additions & 5 deletions openapi/search1api.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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."
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion src/search1api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@
"UnprocessableEntityError",
]

__version__ = "0.3.0"
__version__ = "0.4.0"
10 changes: 7 additions & 3 deletions src/search1api/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
33 changes: 33 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading