From 0e725deed007dc1254f6b2214e989cf51966eb9b Mon Sep 17 00:00:00 2001 From: fatwang2 Date: Sat, 10 Oct 2026 23:52:57 +0800 Subject: [PATCH] Add ask and feedback, new search engines, and pagination (v0.3.0) Resync the OpenAPI snapshot with the live contract: POST /ask and POST /feedback, the bingcn, yandex and grokipedia engines (sogou removed), page on search, and published_date plus GitHub and Hacker News fields on results. ask() defaults to a 45s timeout because the gateway allows Ask 35s; feedback() is never retried because the API has no idempotency key. The client header now reports python/0.3.0 (it still said 0.2.0). --- README.md | 40 ++- openapi/search1api.openapi.json | 511 +++++++++++++++++++++++++++++++- pyproject.toml | 2 +- src/search1api/__init__.py | 2 +- src/search1api/client.py | 122 +++++++- src/search1api/types.py | 55 +++- tests/test_client.py | 119 ++++++++ 7 files changed, 833 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 99bf061..5395c78 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ for result in response["results"]: print(result["title"], result["link"]) ``` +`search_service` selects one engine (`google` by default), for example `bing`, +`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. + Use the client as a context manager when it owns the HTTP connection pool: ```python @@ -42,6 +48,24 @@ async with AsyncSearch1API() as client: response = await client.search("latest AI agent frameworks") ``` +## Ask + +`ask` sends a natural-language request and lets Search1API choose the engines +and time window. It returns at most 10 results ranked by relevance, and +`intent` reports what was searched: + +```python +answer = client.ask("What are developers saying about Bun 1.3 this month?") + +print(answer["intent"]["sources"], answer["intent"]["time_range"]) +for result in answer["results"]: + print(result["relevance"], result["source"], result["title"], result["link"]) +``` + +A completed request costs 5 credits. Ask is not available with pay-per-request +payments, and its default timeout is 45 seconds. Use `search` when you already +know which engine and keywords you want. + ## Deepcrawl `deepcrawl` starts a task and waits for it to finish: @@ -75,7 +99,21 @@ The clients also support news, crawl, sitemap, trending, extract, usage, and batch operations exposed by the Search1API HTTP API. Requests time out after 30 seconds and retry `429` and transient `5xx` responses twice by default. Authentication, payment, and validation errors are never retried. Deepcrawl -task creation is not retried automatically because it is not idempotent. +task creation and feedback are not retried automatically because they are not +idempotent. + +## Feedback + +`feedback` reports a Search1API problem, missing capability, or confusing +documentation. It is free. Do not include credentials or personal data: + +```python +client.feedback( + "Results for this query have no publication dates", + category="feature_request", + request_id="the x-search1api-request-id of the original request", +) +``` ## Development diff --git a/openapi/search1api.openapi.json b/openapi/search1api.openapi.json index 60f63b4..f66055d 100644 --- a/openapi/search1api.openapi.json +++ b/openapi/search1api.openapi.json @@ -4,7 +4,7 @@ "title": "Search1API", "description": "Web search, news, crawling, screenshot, and content extraction APIs for AI agents. Supports pay-per-request via MPP and x402 protocols.", "version": "1.0.0", - "x-guidance": "Search1API provides web search, news aggregation, URL crawling, webpage screenshots, sitemap extraction, trending topics, content extraction, and deep crawling. All paid endpoints accept POST with a JSON body. Use POST /search with a \"query\" field for web search. Use POST /news with a \"query\" field for news. Use POST /crawl with a \"url\" field to crawl a page. Use POST /screenshot with a \"url\" field to render a PNG, JPEG, or WebP image. Use POST /sitemap with a \"url\" field to extract sitemap URLs. Use POST /trending with a \"search_service\" field for trends. Use POST /extract with a \"url\" field for structured content extraction. Use POST /deepcrawl with a \"url\" field for deep multi-page crawling." + "x-guidance": "Search1API provides web search, news aggregation, URL crawling, webpage screenshots, sitemap extraction, trending topics, content extraction, and deep crawling. All paid endpoints accept POST with a JSON body. Use POST /search with a \"query\" field for web search. Use POST /news with a \"query\" field for news. Use POST /ask with a natural-language \"query\" to have Search1API choose the engines and time window and return only relevant results (API key only). Use POST /crawl with a \"url\" field to crawl a page. Use POST /screenshot with a \"url\" field to render a PNG, JPEG, or WebP image. Use POST /sitemap with a \"url\" field to extract sitemap URLs. Use POST /trending with a \"search_service\" field for trends. Use POST /extract with a \"url\" field for structured content extraction. Use POST /deepcrawl with a \"url\" field for deep multi-page crawling." }, "x-discovery": { "ownershipProofs": [] @@ -25,7 +25,7 @@ "schemas": { "ApiError": { "type": "object", - "description": "Search1API error. Payment challenges may use RFC 9457 problem detail fields.", + "description": "Search1API error. Every JSON error carries `ok: false`, `error` (a short status-derived label) and `message` (human-readable detail); validation failures add `errors`. Payment challenges may use RFC 9457 problem detail fields.", "properties": { "ok": { "type": "boolean", @@ -84,8 +84,10 @@ "enum": [ "google", "bing", + "bingcn", "duckduckgo", "yahoo", + "yandex", "youtube", "x", "reddit", @@ -95,8 +97,8 @@ "bilibili", "imdb", "wikipedia", + "grokipedia", "", - "sogou", "baidu", "360", "quark" @@ -108,6 +110,12 @@ "maximum": 50, "default": 5 }, + "page": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 1 + }, "crawl_results": { "type": "integer", "minimum": 0, @@ -334,6 +342,42 @@ }, "content": { "type": "string" + }, + "published_date": { + "type": "string", + "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" + }, + "kind": { + "type": "string", + "enum": [ + "repo", + "issue", + "pr", + "discussion" + ], + "description": "What a `github` result is: a repository, an issue, a pull request, or a discussion. Only present for `search_service: \"github\"`." + }, + "stars": { + "type": "integer", + "description": "Stargazer count of a `github` repository result (`kind: \"repo\"`)." + }, + "language": { + "type": "string", + "description": "Primary language of a `github` repository result (`kind: \"repo\"`). Omitted when GitHub reports none." + }, + "num_comments": { + "type": "integer", + "description": "Comment count on a `github` issue, pull request or discussion, or on a `hackernews` thread (POST /news)." + }, + "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." + }, + "points": { + "type": "integer", + "description": "POST /news with `search_service: \"hackernews\"` only: upvotes on the thread." } }, "additionalProperties": true @@ -526,6 +570,120 @@ } } }, + "AskResult": { + "type": "object", + "required": [ + "title", + "link", + "snippet", + "source", + "relevance" + ], + "properties": { + "title": { + "type": "string" + }, + "link": { + "type": "string", + "format": "uri" + }, + "snippet": { + "type": "string" + }, + "published_date": { + "type": "string", + "description": "When the page was published, as ISO 8601 (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ`). Omitted when the engine exposes no date.", + "example": "2026-09-03" + }, + "source": { + "type": "string", + "description": "Engine that returned the result." + }, + "relevance": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "How likely the result is to be about the query, two decimals. Only results scoring at least 0.5 are returned." + } + } + }, + "AskResponse": { + "type": "object", + "required": [ + "query", + "intent", + "results", + "errors" + ], + "properties": { + "query": { + "type": "string", + "description": "The query as sent." + }, + "intent": { + "type": "object", + "required": [ + "search_query", + "sources", + "time_range" + ], + "description": "How the request was interpreted and searched.", + "properties": { + "search_query": { + "type": "string", + "description": "Keywords sent to the engines, with platform and time phrases removed." + }, + "sources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Engines searched." + }, + "time_range": { + "type": [ + "string", + "null" + ], + "enum": [ + "day", + "week", + "month", + "year", + null + ], + "description": "Publication window applied, or null for none." + } + } + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AskResult" + }, + "description": "Relevant results from every engine, best first, with duplicates merged. May be empty." + }, + "errors": { + "type": "array", + "description": "Engines that failed while others completed. The request is still charged.", + "items": { + "type": "object", + "required": [ + "source", + "message" + ], + "properties": { + "source": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, "CrawlResult": { "type": "object", "required": [ @@ -771,10 +929,191 @@ ] } } + }, + "FeedbackResponse": { + "type": "object", + "required": [ + "id", + "status" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^fb_[a-zA-Z0-9_-]+$", + "maxLength": 80 + }, + "status": { + "type": "string", + "enum": [ + "new" + ] + } + }, + "additionalProperties": false } } }, "paths": { + "/feedback": { + "post": { + "operationId": "feedback", + "summary": "Report a Search1API problem or feature request", + "description": "Report a Search1API problem, missing capability, or confusing documentation encountered during a task. Only message is required. Optional request_id refers to the original problem request. Do not include credentials, personal data, or private user content. Reports are stored in Agentback and linked to an opaque account reference. Do not report every empty result or retry the same report automatically; feedback should not block the main task. Returns 201 only after storage succeeds. Maximum JSON body size: 16 KiB. Limited to 10 submissions per account per minute at a Cloudflare location. Free to call.", + "tags": [ + "Feedback" + ], + "responses": { + "201": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeedbackResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "intent": { + "type": "string", + "maxLength": 2000 + }, + "category": { + "type": "string", + "enum": [ + "bug", + "feature_request", + "docs", + "other" + ], + "default": "other" + }, + "request_id": { + "type": "string", + "maxLength": 256 + }, + "agent": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 128 + }, + "model": { + "type": "string", + "maxLength": 128 + } + }, + "additionalProperties": false + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + } + } + } + } + }, "/screenshot": { "post": { "operationId": "screenshot", @@ -1099,7 +1438,7 @@ "post": { "operationId": "search", "summary": "Search the web using multiple search engines", - "description": "Search the live public web when the answer depends on current information, sources, or research a model's training data cannot cover. Returns ranked results with id, title, URL, and snippet across 13+ engines, with optional images. Set `crawl_results` to pull the top N result pages in the same call — each crawled page is billed as an additional crawl — or pass a result URL to POST /crawl separately. Costs 1 credit per request.", + "description": "Search the live public web when the answer depends on current information, sources, or research a model's training data cannot cover. Returns ranked results with id, title, URL, snippet, and `published_date` (ISO 8601, when the engine exposes one) across 13+ engines, with optional images. Set `crawl_results` to pull the top N result pages in the same call — each crawled page is billed as an additional crawl — or pass a result URL to POST /crawl separately. Costs 1 credit per request.", "tags": [ "Search" ], @@ -1236,8 +1575,10 @@ "enum": [ "google", "bing", + "bingcn", "duckduckgo", "yahoo", + "yandex", "youtube", "x", "reddit", @@ -1247,8 +1588,8 @@ "bilibili", "imdb", "wikipedia", + "grokipedia", "", - "sogou", "baidu", "360", "quark" @@ -1260,6 +1601,12 @@ "maximum": 50, "default": 5 }, + "page": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 1 + }, "crawl_results": { "type": "integer", "minimum": 0, @@ -1317,8 +1664,10 @@ "enum": [ "google", "bing", + "bingcn", "duckduckgo", "yahoo", + "yandex", "youtube", "x", "reddit", @@ -1328,8 +1677,8 @@ "bilibili", "imdb", "wikipedia", + "grokipedia", "", - "sogou", "baidu", "360", "quark" @@ -1341,6 +1690,12 @@ "maximum": 50, "default": 5 }, + "page": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 1 + }, "crawl_results": { "type": "integer", "minimum": 0, @@ -1397,7 +1752,7 @@ "post": { "operationId": "news", "summary": "Search news articles across multiple sources", - "description": "Search recent news when the question is about events, announcements, or coverage rather than reference material. Returns articles from verified publishers with title, URL, snippet, and optional full page content, filterable by site, language, and time range. For questions that are not time-sensitive, prefer POST /search. Costs 1 credit per request.", + "description": "Search recent news when the question is about events, announcements, or coverage rather than reference material. Returns articles from verified publishers with title, URL, snippet, `published_date` (ISO 8601, when known), and optional full page content, filterable by site, language, and time range. For questions that are not time-sensitive, prefer POST /search. Costs 1 credit per request.", "tags": [ "Search" ], @@ -1669,11 +2024,129 @@ } } }, + "/ask": { + "post": { + "operationId": "ask", + "summary": "Search the engines a natural-language request calls for", + "description": "Agentic search. Describe what you are looking for in plain language and let a decision model (currently TypeSafe Jev) decide where to look. It picks up to five engines (web search, Hacker News, Reddit, GitHub, X, arXiv, Wikipedia, IMDb, WeChat, YouTube), rewrites the request into search keywords, infers a publication window from phrases such as \"this week\", searches the engines in parallel, and returns only the results judged relevant, merged and ranked, so agents read fewer off-topic results. The request takes only `query`; engines, keywords, and the window are always chosen by the model, and the response reports them in `intent`. Use POST /search instead when you already know the engine and keywords and want its raw ranking. Costs 5 credits per request.", + "tags": [ + "Search" + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AskResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "402": { + "description": "Payment Required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "What you are looking for, in natural language. Platform hints (\"on Reddit\", \"papers\") and time hints (\"this week\") steer engine and time-range selection." + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "examples": { + "community": { + "summary": "Let Search1API choose", + "description": "Engines and the time window are inferred from the wording: community discussion, within the past month.", + "value": { + "query": "What are developers saying about Bun 1.3 this month?" + } + } + } + } + } + } + } + }, "/crawl": { "post": { "operationId": "crawl", "summary": "Crawl a URL and extract its content", - "description": "Fetch one public URL and return its readable title and body as clean text, with navigation, boilerplate, and scripts stripped. Use it on a URL the user supplied or one returned by POST /search. To ingest a whole site rather than a single page, use POST /deepcrawl. Costs 1 credit per request.", + "description": "Fetch one public URL and return its readable title and body as clean text, with navigation, boilerplate, and scripts stripped. Use it on a URL the user supplied or one returned by POST /search. To ingest a whole site rather than a single page, use POST /deepcrawl. Send an array of request objects to crawl several URLs in one call: the response is an array containing only the URLs that succeeded, in no guaranteed order, so match items back to your input by `crawlParameters.url`; failed URLs are omitted and not charged. `enable_fallback` is accepted as a snake_case alias of `enableFallback`. Costs 1 credit per request.", "tags": [ "Crawl" ], @@ -1742,6 +2215,26 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "410": { + "description": "Gone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "422": { "description": "Validation Error", "content": { @@ -1852,7 +2345,7 @@ "post": { "operationId": "sitemap", "summary": "Extract sitemap URLs from a website", - "description": "Discover the public URLs of a site when you need to know which pages exist before fetching any of them — scoping a crawl, auditing coverage, or locating a section. Returns the links discovered for the given page or domain; it does not fetch their content. Costs 1 credit per request.", + "description": "Discover the public URLs of a site when you need to know which pages exist before fetching any of them — scoping a crawl, auditing coverage, or locating a section. With the default `type: \"sitemap\"` this reads the site's own sitemap: `Sitemap:` directives in robots.txt first, then the conventional paths, following sitemap index files and gzipped sitemaps, and returns the URLs it declares for the requested host. A site that publishes no sitemap falls back to the links found on the given page. Use `type: \"all\"` to skip the sitemap and get every link on that one page instead, including links to other hosts. Neither mode fetches page content. Costs 1 credit per request.", "tags": [ "Crawl" ], diff --git a/pyproject.toml b/pyproject.toml index 9217545..df645c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "search1api" -version = "0.2.1" +version = "0.3.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 0dddb78..531ead4 100644 --- a/src/search1api/__init__.py +++ b/src/search1api/__init__.py @@ -35,4 +35,4 @@ "UnprocessableEntityError", ] -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/src/search1api/client.py b/src/search1api/client.py index bc47cfe..3f44128 100644 --- a/src/search1api/client.py +++ b/src/search1api/client.py @@ -21,6 +21,7 @@ api_status_error, ) from .types import ( + AskResponse, BatchResponse, CrawlRequest, CrawlResponse, @@ -28,6 +29,9 @@ DeepcrawlAcceptedResponse, DeepcrawlStatusResponse, ExtractResponse, + FeedbackAgent, + FeedbackCategory, + FeedbackResponse, HealthResponse, NewsEngine, NewsRequest, @@ -47,6 +51,9 @@ DEFAULT_BASE_URL = "https://api.search1api.com" DEFAULT_TIMEOUT = 30.0 +# The gateway gives Ask up to 35 seconds, so the default 30 seconds would abort +# requests the API would still answer. +DEFAULT_ASK_TIMEOUT = 45.0 DEFAULT_MAX_RETRIES = 2 DEFAULT_RETRY_DELAY = 0.5 DEFAULT_DEEPCRAWL_POLL_INTERVAL = 2.0 @@ -62,6 +69,7 @@ def _search_payload( *, search_service: Optional[str] = None, max_results: Optional[int] = None, + page: Optional[int] = None, crawl_results: Optional[int] = None, image: Optional[bool] = None, include_sites: Optional[List[str]] = None, @@ -74,6 +82,7 @@ def _search_payload( "query": query, "search_service": search_service, "max_results": max_results, + "page": page, "crawl_results": crawl_results, "image": image, "include_sites": include_sites, @@ -84,6 +93,25 @@ def _search_payload( ) +def _feedback_payload( + message: str, + *, + intent: Optional[str] = None, + category: Optional[FeedbackCategory] = None, + request_id: Optional[str] = None, + agent: Optional[FeedbackAgent] = None, +) -> Dict[str, Any]: + return _compact( + { + "message": message, + "intent": intent, + "category": category, + "request_id": request_id, + "agent": _compact(cast(Mapping[str, Any], agent)) if agent else None, + } + ) + + def _screenshot_payload( url: str, *, @@ -179,7 +207,7 @@ def _request_headers(self, accept: str = "application/json") -> Dict[str, str]: return { "Accept": accept, "Authorization": f"Bearer {self.api_key}", - "X-Search1API-Client": "python/0.2.0", + "X-Search1API-Client": "python/0.3.0", **self.headers, } @@ -229,6 +257,7 @@ def search( *, search_service: Optional[SearchEngine] = None, max_results: Optional[int] = None, + page: Optional[int] = None, crawl_results: Optional[int] = None, image: Optional[bool] = None, include_sites: Optional[List[str]] = None, @@ -240,6 +269,7 @@ def search( query, search_service=search_service, max_results=max_results, + page=page, crawl_results=crawl_results, image=image, include_sites=include_sites, @@ -281,6 +311,16 @@ def news( def news_batch(self, requests: List[NewsRequest]) -> BatchResponse: return cast(BatchResponse, self._request_json("POST", "/news", json=requests)) + def ask(self, query: str, *, timeout: float = DEFAULT_ASK_TIMEOUT) -> AskResponse: + """Agentic search: Search1API picks the engines and time window. + + A completed request costs 5 credits. + """ + return cast( + AskResponse, + self._request_json("POST", "/ask", json={"query": query}, timeout=timeout), + ) + def crawl( self, url: str, *, enable_fallback: Optional[bool] = None ) -> CrawlResponse: @@ -442,6 +482,32 @@ def usage(self, period: Optional[TimeRange] = None) -> UsageResponse: def health(self) -> HealthResponse: return cast(HealthResponse, self._request_json("GET", "/health")) + def feedback( + self, + message: str, + *, + intent: Optional[str] = None, + category: Optional[FeedbackCategory] = None, + request_id: Optional[str] = None, + agent: Optional[FeedbackAgent] = None, + ) -> FeedbackResponse: + """Report a Search1API problem or missing capability. Free. + + Never retried automatically: the API has no idempotency key, so a retry + after a lost response would file a duplicate report. + """ + payload = _feedback_payload( + message, + intent=intent, + category=category, + request_id=request_id, + agent=agent, + ) + return cast( + FeedbackResponse, + self._request_json("POST", "/feedback", json=payload, retryable=False), + ) + def _request_json(self, method: str, path: str, **kwargs: Any) -> Any: response = self._request(method, path, **kwargs) try: @@ -454,6 +520,7 @@ def _request_json(self, method: str, path: str, **kwargs: Any) -> Any: def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: retryable = bool(kwargs.pop("retryable", True)) accept = str(kwargs.pop("accept", "application/json")) + timeout = float(kwargs.pop("timeout", self.timeout)) max_retries = self.max_retries if retryable else 0 for attempt in range(max_retries + 1): try: @@ -461,7 +528,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: method, f"{self.base_url}{path}", headers=self._request_headers(accept), - timeout=self.timeout, + timeout=timeout, **kwargs, ) except httpx.TimeoutException as exc: @@ -469,7 +536,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: time.sleep(self._retry_sleep(attempt)) continue raise APITimeoutError( - f"Search1API request timed out after {self.timeout:g}s" + f"Search1API request timed out after {timeout:g}s" ) from exc except httpx.RequestError as exc: if attempt < max_retries: @@ -530,6 +597,7 @@ async def search( *, search_service: Optional[SearchEngine] = None, max_results: Optional[int] = None, + page: Optional[int] = None, crawl_results: Optional[int] = None, image: Optional[bool] = None, include_sites: Optional[List[str]] = None, @@ -546,6 +614,7 @@ async def search( query, search_service=search_service, max_results=max_results, + page=page, crawl_results=crawl_results, image=image, include_sites=include_sites, @@ -600,6 +669,20 @@ async def news_batch(self, requests: List[NewsRequest]) -> BatchResponse: await self._request_json("POST", "/news", json=requests), ) + async def ask( + self, query: str, *, timeout: float = DEFAULT_ASK_TIMEOUT + ) -> AskResponse: + """Agentic search: Search1API picks the engines and time window. + + A completed request costs 5 credits. + """ + return cast( + AskResponse, + await self._request_json( + "POST", "/ask", json={"query": query}, timeout=timeout + ), + ) + async def crawl( self, url: str, *, enable_fallback: Optional[bool] = None ) -> CrawlResponse: @@ -775,6 +858,34 @@ async def usage(self, period: Optional[TimeRange] = None) -> UsageResponse: async def health(self) -> HealthResponse: return cast(HealthResponse, await self._request_json("GET", "/health")) + async def feedback( + self, + message: str, + *, + intent: Optional[str] = None, + category: Optional[FeedbackCategory] = None, + request_id: Optional[str] = None, + agent: Optional[FeedbackAgent] = None, + ) -> FeedbackResponse: + """Report a Search1API problem or missing capability. Free. + + Never retried automatically: the API has no idempotency key, so a retry + after a lost response would file a duplicate report. + """ + payload = _feedback_payload( + message, + intent=intent, + category=category, + request_id=request_id, + agent=agent, + ) + return cast( + FeedbackResponse, + await self._request_json( + "POST", "/feedback", json=payload, retryable=False + ), + ) + async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any: response = await self._request(method, path, **kwargs) try: @@ -787,6 +898,7 @@ async def _request_json(self, method: str, path: str, **kwargs: Any) -> Any: async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: retryable = bool(kwargs.pop("retryable", True)) accept = str(kwargs.pop("accept", "application/json")) + timeout = float(kwargs.pop("timeout", self.timeout)) max_retries = self.max_retries if retryable else 0 for attempt in range(max_retries + 1): try: @@ -794,7 +906,7 @@ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Respons method, f"{self.base_url}{path}", headers=self._request_headers(accept), - timeout=self.timeout, + timeout=timeout, **kwargs, ) except httpx.TimeoutException as exc: @@ -802,7 +914,7 @@ async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Respons await asyncio.sleep(self._retry_sleep(attempt)) continue raise APITimeoutError( - f"Search1API request timed out after {self.timeout:g}s" + f"Search1API request timed out after {timeout:g}s" ) from exc except httpx.RequestError as exc: if attempt < max_retries: diff --git a/src/search1api/types.py b/src/search1api/types.py index 406494d..082d658 100644 --- a/src/search1api/types.py +++ b/src/search1api/types.py @@ -8,8 +8,10 @@ SearchEngine = Literal[ "google", "bing", + "bingcn", "duckduckgo", "yahoo", + "yandex", "youtube", "x", "reddit", @@ -19,7 +21,7 @@ "bilibili", "imdb", "wikipedia", - "sogou", + "grokipedia", "baidu", "360", "quark", @@ -37,6 +39,7 @@ class SearchRequestRequired(TypedDict): class SearchRequest(SearchRequestRequired, total=False): search_service: SearchEngine max_results: int + page: int crawl_results: int image: bool include_sites: List[str] @@ -53,6 +56,13 @@ class SearchResultRequired(TypedDict): class SearchResult(SearchResultRequired, total=False): content: str + published_date: str + kind: Literal["repo", "issue", "pr", "discussion"] + stars: int + language: str + num_comments: int + points: int + story_url: str class SearchResponseRequired(TypedDict): @@ -82,6 +92,49 @@ class NewsRequest(NewsRequestRequired, total=False): time_range: TimeRange +class AskIntent(TypedDict): + search_query: str + sources: List[str] + time_range: Optional[TimeRange] + + +class AskResultRequired(TypedDict): + title: str + link: str + snippet: str + source: str + relevance: float + + +class AskResult(AskResultRequired, total=False): + published_date: str + + +class AskError(TypedDict): + source: str + message: str + + +class AskResponse(TypedDict): + query: str + intent: AskIntent + results: List[AskResult] + errors: List[AskError] + + +FeedbackCategory = Literal["bug", "feature_request", "docs", "other"] + + +class FeedbackAgent(TypedDict, total=False): + name: str + model: str + + +class FeedbackResponse(TypedDict): + id: str + status: Literal["new"] + + class BatchItemRequired(TypedDict): success: bool cost: int diff --git a/tests/test_client.py b/tests/test_client.py index dc9ea13..cc02418 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -215,6 +215,123 @@ def handler(request): http_client.close() +def test_search_sends_page_and_new_engines(): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return json_response(200, {"results": []}, request) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + client = Search1API("test-key", client=http_client) + + client.search("kubernetes", search_service="bingcn", page=2) + client.search("Alan Turing", search_service="grokipedia") + + assert bodies == [ + {"query": "kubernetes", "search_service": "bingcn", "page": 2}, + {"query": "Alan Turing", "search_service": "grokipedia"}, + ] + http_client.close() + + +def test_ask_sends_only_the_query_with_a_longer_timeout(): + seen = [] + body = { + "query": "recent papers on speculative decoding", + "intent": { + "search_query": "speculative decoding", + "sources": ["arxiv"], + "time_range": None, + }, + "results": [ + { + "title": "Speculative decoding survey", + "link": "https://arxiv.org/abs/1", + "snippet": "Survey", + "source": "arxiv", + "relevance": 0.88, + } + ], + "errors": [], + } + + def handler(request): + seen.append(request) + return json_response(200, body, request) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + client = Search1API("test-key", client=http_client) + + assert client.ask(body["query"]) == body + request = seen[0] + assert request.method == "POST" + assert str(request.url) == "https://api.search1api.com/ask" + assert json.loads(request.content) == {"query": body["query"]} + assert request.extensions["timeout"]["read"] == 45.0 + http_client.close() + + +def test_feedback_is_sent_once_without_retries(): + calls = [] + + def handler(request): + calls.append(json.loads(request.content)) + return json_response(503, {"ok": False, "message": "unavailable"}, request) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + client = Search1API("test-key", client=http_client, max_retries=2, retry_delay=0) + + with pytest.raises(InternalServerError, match="unavailable"): + client.feedback( + "Need publication dates", + category="feature_request", + request_id="req_1", + agent={"name": "codex"}, + ) + + assert calls == [ + { + "message": "Need publication dates", + "category": "feature_request", + "request_id": "req_1", + "agent": {"name": "codex"}, + } + ] + http_client.close() + + +def test_async_ask_and_feedback(): + async def run(): + paths = [] + + async def handler(request): + paths.append(request.url.path) + if request.url.path == "/feedback": + return json_response(201, {"id": "fb_1", "status": "new"}, request) + return json_response( + 200, + { + "query": "q", + "intent": {"search_query": "q", "sources": [], "time_range": None}, + "results": [], + "errors": [], + }, + request, + ) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = AsyncSearch1API("test-key", client=http_client) + try: + assert (await client.ask("q"))["results"] == [] + assert await client.feedback("docs typo") == {"id": "fb_1", "status": "new"} + finally: + await http_client.aclose() + assert paths == ["/ask", "/feedback"] + + asyncio.run(run()) + + def test_async_client_uses_the_same_api_shape(): async def run(): async def handler(request): @@ -252,10 +369,12 @@ def test_sdk_covers_every_public_openapi_operation(): ) operation_methods = { + "ask": "ask", "crawl": "crawl", "deepcrawl": "start_deepcrawl", "deepcrawlStatus": "get_deepcrawl_status", "extract": "extract", + "feedback": "feedback", "health": "health", "news": "news", "search": "search",