From 3ea52f48268bdfb0808817fc589d009c87eb01e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:45:43 +0000 Subject: [PATCH] Add maps() for the Maps API, release 0.5.0 Sync the OpenAPI snapshot with the live contract (adds POST /maps and the 50-item batch limit) and add maps() to the sync and async clients with country, language, location, page and max_results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DuYf7vcVyyiBvDBCtA6mHH --- CHANGELOG.md | 4 + README.md | 22 ++ openapi/search1api.openapi.json | 404 +++++++++++++++++++++++++++++++- pyproject.toml | 2 +- src/search1api/__init__.py | 2 +- src/search1api/client.py | 77 ++++++ src/search1api/types.py | 64 +++++ tests/test_client.py | 77 ++++++ 8 files changed, 644 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d295d40..a7b0174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.5.0 - 2026-11-03 + +Adds `maps(query, ...)` on `Search1API` and `AsyncSearch1API` for `POST /maps`: one Google Maps page of up to 20 places with name, place ID and Maps link, plus address, phone, website, rating, review count, coordinates and opening hours when available. Keyword arguments: `country`, `language`, `location` (`latitude`, `longitude`, optional `zoom`), `page` (1-6, pages after the first need `location`) and `max_results`. 3 credits per completed page; API key or OAuth only. The bundled OpenAPI snapshot also records the 50-item batch limit for search, news and crawl. + ## 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 d5f77c0..d9595ad 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,28 @@ 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. +## Maps + +`maps` searches Google Maps and returns one page of up to 20 places. Each place +has a name, place ID and Maps link, plus address, phone, website, rating, +review count, coordinates and opening hours when available: + +```python +places = client.maps( + "coffee shops", + location={"latitude": 40.7455, "longitude": -74.0083, "zoom": 15}, + max_results=10, +) + +for place in places["results"]: + print(place["name"], place.get("rating"), place.get("address")) +``` + +A completed page costs 3 credits, including a confirmed empty page. +`max_results` only trims the page and does not lower the price. Request `page` +2-6 with the same query and `location` for more places; each page is billed +separately. Maps is not available with pay-per-request payments. + ## Deepcrawl `deepcrawl` starts a task and waits for it to finish: diff --git a/openapi/search1api.openapi.json b/openapi/search1api.openapi.json index 2371d07..0d81feb 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 /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-guidance": "Search1API provides web search, Maps place discovery, 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 /maps with a \"query\" and optional location coordinates to discover Google Maps places (API key only). 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": [] @@ -159,6 +159,212 @@ ], "additionalProperties": false }, + "MapsResponse": { + "type": "object", + "properties": { + "searchParameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Google Maps search phrase; leading and trailing whitespace is removed." + }, + "country": { + "type": "string", + "pattern": "^[a-zA-Z]{2}$", + "default": "us", + "description": "Two-letter country code, normalized to lowercase. Defaults to us." + }, + "language": { + "type": "string", + "pattern": "^[a-z]{2,3}(-[a-zA-Z]{2,4})?$", + "default": "en", + "description": "Result language, for example en, de or zh-CN. Defaults to en." + }, + "location": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "minimum": -180, + "maximum": 180 + }, + "zoom": { + "type": "number", + "minimum": 3, + "maximum": 30, + "default": 14 + } + }, + "required": [ + "latitude", + "longitude" + ], + "additionalProperties": false, + "description": "Search origin coordinates and map zoom (default 14). Required for page > 1. Latitude must be -90 to 90, longitude -180 to 180, and zoom 3 to 30. Example: {\"latitude\":40.7455,\"longitude\":-74.0083,\"zoom\":15}." + }, + "page": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "default": 1, + "description": "Page number, 1–6; default 1. Accepts an integer or canonical integer string such as \"2\". Empty strings use the default. Pages after the first require location." + }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 20, + "description": "Maximum places to return from this page, 1–20; default 20. Accepts an integer or canonical integer string such as \"10\". Empty strings use the default. Truncation does not reduce the page charge or carry places to the next page." + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 1, + "description": "Position in the requested native page, offset by earlier page capacities. Gaps can remain after filtering or deduplication; this is not a stable SEO rank." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Business or place name." + }, + "place_id": { + "type": "string", + "minLength": 1, + "description": "Google Maps place identifier, also used to deduplicate this page." + }, + "maps_url": { + "type": "string", + "format": "uri", + "description": "Public Google Maps link for this place." + }, + "address": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string", + "format": "uri" + }, + "thumbnail": { + "type": "string", + "format": "uri" + }, + "category": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "rating": { + "type": "number", + "minimum": 0, + "maximum": 5 + }, + "review_count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "coordinates": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + }, + "required": [ + "latitude", + "longitude" + ], + "additionalProperties": false + }, + "opening_hours": { + "type": "object", + "properties": { + "monday": { + "type": "string" + }, + "tuesday": { + "type": "string" + }, + "wednesday": { + "type": "string" + }, + "thursday": { + "type": "string" + }, + "friday": { + "type": "string" + }, + "saturday": { + "type": "string" + }, + "sunday": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "Opening hours by weekday, as displayed by the source; omitted when unavailable." + }, + "open_state": { + "type": "string", + "description": "Source display text for the current opening state, not a normalized boolean." + }, + "price": { + "type": "string", + "description": "Source display text for the business price range, not a numeric amount or normalized currency." + }, + "description": { + "type": "string" + } + }, + "required": [ + "position", + "name", + "place_id", + "maps_url" + ], + "additionalProperties": false + }, + "maxItems": 20 + } + }, + "required": [ + "searchParameters", + "results" + ], + "additionalProperties": false + }, "NewsParameters": { "type": "object", "properties": { @@ -356,7 +562,7 @@ }, "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.", + "description": "Optional engine-specific fields. Omitted when none apply. GitHub and Reddit fields appear on POST /search; Hacker News fields appear on POST /news.", "properties": { "kind": { "type": "string", @@ -378,7 +584,7 @@ }, "num_comments": { "type": "integer", - "description": "Comment count on a `github` issue, pull request or discussion, or on a `hackernews` thread (POST /news)." + "description": "Comment count on a `github` issue, pull request or discussion, on a `reddit` post, or on a `hackernews` thread (POST /news)." }, "story_url": { "type": "string", @@ -964,6 +1170,189 @@ } }, "paths": { + "/maps": { + "post": { + "operationId": "maps", + "summary": "Search Google Maps for local businesses and places", + "description": "Discover businesses and places with names, place IDs and public Maps links, plus addresses, contact details, ratings, review counts, coordinates and opening hours when available. Optional result fields are omitted when unavailable. Use POST /search for webpages and POST /crawl to read a business website.\n\nEach request fetches one Google Maps page of up to 20 places. page is 1-based (1–6); page > 1 requires location coordinates. max_results only truncates that page and does not change the price or carry unused places into the next page. Send the same query, country, language and location with the next page number for another separately billed request. There is no automatic pagination, retry or filling from another engine. Results can change between requests; an empty or short page does not guarantee that no later results exist.\n\nThe response contains searchParameters and results, following the single-request format of POST /search and POST /news. searchParameters includes the requested page and normalized defaults. Result positions preserve native page order with possible gaps, not a stable SEO rank. A short or empty results array does not assert a total count or a next page.\n\nA confirmed empty page is completed and charged. Technical failures return an error and release the credit reservation. GET /usage separately returns the remaining account balance.\n\nAccount credits only; no batch requests or payment protocols. Unknown fields are rejected; validation failures return 422 with errors entries containing field, message and code. Normal account limits apply (200 requests per minute). Set your client timeout above 20 seconds. SDK and MCP methods are not included in this release. Costs 3 credits per request.", + "tags": [ + "Maps" + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapsResponse" + } + } + } + }, + "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" + } + } + } + }, + "422": { + "description": "Validation Error", + "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" + } + } + } + }, + "502": { + "description": "Bad Gateway", + "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": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Google Maps search phrase; leading and trailing whitespace is removed.", + "example": "coffee in Manhattan" + }, + "country": { + "type": "string", + "pattern": "^[a-zA-Z]{2}$", + "default": "us", + "description": "Two-letter country code, normalized to lowercase. Defaults to us." + }, + "language": { + "type": "string", + "pattern": "^[a-z]{2,3}(-[a-zA-Z]{2,4})?$", + "default": "en", + "description": "Result language, for example en, de or zh-CN. Defaults to en." + }, + "location": { + "type": "object", + "properties": { + "latitude": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "minimum": -180, + "maximum": 180 + }, + "zoom": { + "type": "number", + "minimum": 3, + "maximum": 30, + "default": 14 + } + }, + "required": [ + "latitude", + "longitude" + ], + "additionalProperties": false, + "description": "Search origin coordinates and map zoom (default 14). Required for page > 1. Latitude must be -90 to 90, longitude -180 to 180, and zoom 3 to 30. Example: {\"latitude\":40.7455,\"longitude\":-74.0083,\"zoom\":15}." + }, + "page": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "default": 1, + "description": "Page number, 1–6; default 1. Accepts an integer or canonical integer string such as \"2\". Empty strings use the default. Pages after the first require location." + }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 20, + "description": "Maximum places to return from this page, 1–20; default 20. Accepts an integer or canonical integer string such as \"10\". Empty strings use the default. Truncation does not reduce the page charge or carry places to the next page." + } + }, + "required": [ + "query" + ], + "additionalProperties": false + } + } + } + } + } + }, "/feedback": { "post": { "operationId": "feedback", @@ -1749,7 +2138,8 @@ ], "additionalProperties": false }, - "minItems": 1 + "minItems": 1, + "maxItems": 50 } ] } @@ -2025,7 +2415,8 @@ ], "additionalProperties": false }, - "minItems": 1 + "minItems": 1, + "maxItems": 50 } ] } @@ -2342,7 +2733,8 @@ ], "additionalProperties": false }, - "minItems": 1 + "minItems": 1, + "maxItems": 50 } ] } diff --git a/pyproject.toml b/pyproject.toml index c694be8..1354065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "search1api" -version = "0.4.0" +version = "0.5.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 6fc26e9..3498479 100644 --- a/src/search1api/__init__.py +++ b/src/search1api/__init__.py @@ -35,4 +35,4 @@ "UnprocessableEntityError", ] -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/search1api/client.py b/src/search1api/client.py index 3f44128..ad1d822 100644 --- a/src/search1api/client.py +++ b/src/search1api/client.py @@ -33,6 +33,8 @@ FeedbackCategory, FeedbackResponse, HealthResponse, + MapsLocation, + MapsResponse, NewsEngine, NewsRequest, NewsResponse, @@ -64,6 +66,27 @@ def _compact(payload: Mapping[str, Any]) -> Dict[str, Any]: return {key: value for key, value in payload.items() if value is not None} +def _maps_payload( + query: str, + *, + country: Optional[str], + language: Optional[str], + location: Optional[MapsLocation], + page: Optional[int], + max_results: Optional[int], +) -> Dict[str, Any]: + return _compact( + { + "query": query, + "country": country, + "language": language, + "location": dict(location) if location is not None else None, + "page": page, + "max_results": max_results, + } + ) + + def _search_payload( query: str, *, @@ -321,6 +344,30 @@ def ask(self, query: str, *, timeout: float = DEFAULT_ASK_TIMEOUT) -> AskRespons self._request_json("POST", "/ask", json={"query": query}, timeout=timeout), ) + def maps( + self, + query: str, + *, + country: Optional[str] = None, + language: Optional[str] = None, + location: Optional[MapsLocation] = None, + page: Optional[int] = None, + max_results: Optional[int] = None, + ) -> MapsResponse: + """Google Maps place search: one page of up to 20 places. + + A completed page costs 3 credits. Pages after the first need ``location``. + """ + payload = _maps_payload( + query, + country=country, + language=language, + location=location, + page=page, + max_results=max_results, + ) + return cast(MapsResponse, self._request_json("POST", "/maps", json=payload)) + def crawl( self, url: str, *, enable_fallback: Optional[bool] = None ) -> CrawlResponse: @@ -683,6 +730,36 @@ async def ask( ), ) + async def maps( + self, + query: str, + *, + country: Optional[str] = None, + language: Optional[str] = None, + location: Optional[MapsLocation] = None, + page: Optional[int] = None, + max_results: Optional[int] = None, + ) -> MapsResponse: + """Google Maps place search: one page of up to 20 places. + + A completed page costs 3 credits. Pages after the first need ``location``. + """ + return cast( + MapsResponse, + await self._request_json( + "POST", + "/maps", + json=_maps_payload( + query, + country=country, + language=language, + location=location, + page=page, + max_results=max_results, + ), + ), + ) + async def crawl( self, url: str, *, enable_fallback: Optional[bool] = None ) -> CrawlResponse: diff --git a/src/search1api/types.py b/src/search1api/types.py index cfb5c0b..bd73777 100644 --- a/src/search1api/types.py +++ b/src/search1api/types.py @@ -126,6 +126,70 @@ class AskResponse(TypedDict): errors: List[AskError] +class MapsLocationRequired(TypedDict): + latitude: float + longitude: float + + +class MapsLocation(MapsLocationRequired, total=False): + zoom: float + + +class MapsCoordinates(TypedDict): + latitude: float + longitude: float + + +class MapsOpeningHours(TypedDict, total=False): + monday: str + tuesday: str + wednesday: str + thursday: str + friday: str + saturday: str + sunday: str + + +class MapsPlaceRequired(TypedDict): + position: int + name: str + place_id: str + maps_url: str + + +class MapsPlace(MapsPlaceRequired, total=False): + address: str + phone: str + website: str + thumbnail: str + category: str + categories: List[str] + rating: float + review_count: int + coordinates: MapsCoordinates + opening_hours: MapsOpeningHours + open_state: str + price: str + description: str + + +class MapsParametersRequired(TypedDict): + query: str + country: str + language: str + page: int + max_results: int + + +class MapsParameters(MapsParametersRequired, total=False): + location: MapsLocation + + +class MapsResponse(TypedDict): + searchParameters: MapsParameters + results: List[MapsPlace] + + FeedbackCategory = Literal["bug", "feature_request", "docs", "other"] diff --git a/tests/test_client.py b/tests/test_client.py index 7ce782d..0501288 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -305,6 +305,82 @@ def handler(request): http_client.close() +def test_maps_sends_location_and_paging(): + seen = [] + body = { + "searchParameters": { + "query": "coffee shops", + "country": "us", + "language": "en", + "location": {"latitude": 40.7455, "longitude": -74.0083, "zoom": 15}, + "page": 2, + "max_results": 5, + }, + "results": [ + { + "position": 21, + "name": "Example Coffee", + "place_id": "ChIJ123", + "maps_url": "https://www.google.com/maps/search/?api=1&query=Example", + "rating": 4.6, + } + ], + } + + 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) + + response = client.maps( + "coffee shops", + location={"latitude": 40.7455, "longitude": -74.0083, "zoom": 15}, + page=2, + max_results=5, + ) + + assert response == body + assert str(seen[0].url) == "https://api.search1api.com/maps" + assert json.loads(seen[0].content) == { + "query": "coffee shops", + "location": {"latitude": 40.7455, "longitude": -74.0083, "zoom": 15}, + "page": 2, + "max_results": 5, + } + http_client.close() + + +def test_async_maps(): + async def run(): + async def handler(request): + assert json.loads(request.content) == {"query": "ramen", "country": "jp"} + return json_response( + 200, + { + "searchParameters": { + "query": "ramen", + "country": "jp", + "language": "en", + "page": 1, + "max_results": 20, + }, + "results": [], + }, + request, + ) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = AsyncSearch1API("test-key", client=http_client) + try: + assert (await client.maps("ramen", country="jp"))["results"] == [] + finally: + await http_client.aclose() + + asyncio.run(run()) + + def test_feedback_is_sent_once_without_retries(): calls = [] @@ -409,6 +485,7 @@ def test_sdk_covers_every_public_openapi_operation(): "extract": "extract", "feedback": "feedback", "health": "health", + "maps": "maps", "news": "news", "search": "search", "screenshot": "screenshot",