serpkite 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SerpKite
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,276 @@
1
- # Temporary Holding Version
1
+ # serpkite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Official TypeScript/JavaScript SDK for [SerpKite](https://serpkite.com), the Google search API built
4
+ for AI agents: clean JSON or Markdown, credits that never expire.
5
+
6
+ - Zero runtime dependencies: uses the global `fetch`.
7
+ - Works in Node 18+, Bun, Deno, Cloudflare Workers and other edge runtimes.
8
+ - ESM and CommonJS builds with full type definitions, generated from the OpenAPI contract.
9
+ - Retries with exponential backoff and jitter on `429`, `5xx` and network errors (honours `Retry-After`).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install serpkite
15
+ # or: bun add serpkite / pnpm add serpkite / yarn add serpkite
16
+ ```
17
+
18
+ Deno: `import { SerpKite } from "npm:serpkite";`
19
+
20
+ ## Quickstart
21
+
22
+ ```ts
23
+ import { SerpKite } from "serpkite";
24
+
25
+ const sk = new SerpKite(); // reads SERPKITE_API_KEY
26
+ const res = await sk.search({ q: "best espresso machine", country: "us" });
27
+ console.log(res.results[0].title, res.meta.credits_used);
28
+
29
+ // Markdown for an LLM prompt: `format: "markdown"` resolves to a string.
30
+ const md = await sk.search({ q: "best espresso machine", format: "markdown" });
31
+ ```
32
+
33
+ Get an API key at [app.serpkite.com](https://app.serpkite.com) and export it:
34
+
35
+ ```bash
36
+ export SERPKITE_API_KEY=skt_live_...
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ ```ts
42
+ const sk = new SerpKite({
43
+ apiKey: "skt_live_...", // default: process.env.SERPKITE_API_KEY
44
+ baseUrl: "https://api.serpkite.com", // default: SERPKITE_BASE_URL or https://api.serpkite.com
45
+ timeoutMs: 60_000, // per attempt
46
+ maxRetries: 2, // retries after the first attempt
47
+ retryDelayMs: 500, // base of the exponential backoff
48
+ fetch: customFetch, // default: globalThis.fetch
49
+ headers: { "X-Request-Id": "my-trace-id" }, // sent with every request
50
+ });
51
+ ```
52
+
53
+ The constructor throws a `SerpKiteError` with code `missing_api_key` when no key is found. On
54
+ runtimes without `process.env` (browsers, some edge platforms) pass `apiKey` explicitly. Don't
55
+ ship a secret key to a public web page.
56
+
57
+ Every method also takes a second `options` argument:
58
+
59
+ ```ts
60
+ await sk.search(
61
+ { q: "espresso" },
62
+ {
63
+ signal: AbortSignal.timeout(10_000),
64
+ timeoutMs: 20_000,
65
+ maxRetries: 0,
66
+ headers: { "X-Request-Id": "trace-123" },
67
+ onResponse: (info) => console.log(info.creditsUsed, info.creditsRemaining, info.cache),
68
+ },
69
+ );
70
+ ```
71
+
72
+ `onResponse` receives the billing headers of the successful response: `requestId`, `creditsUsed`,
73
+ `creditsRemaining`, `costUsd`, `cache` (`HIT` | `MISS`), `latencyMs`, `tokensEstimate`, plus the raw
74
+ `headers` and `status`.
75
+
76
+ ## Methods
77
+
78
+ All request and response fields are snake_case, exactly as in the HTTP API. Every vertical returns
79
+ the same envelope: `request` (the normalised request), `results` (the vertical's primary list),
80
+ vertical-specific extras and `meta` (`request_id`, `credits_used`, `cached`, `latency_ms`, …).
81
+
82
+ | Method | Endpoint | Params | Returns |
83
+ | --- | --- | --- | --- |
84
+ | `search(params)` | `POST /v1/search` | `SearchParams` | `SearchResponse` (results, `answer_box`, `knowledge_graph`, `people_also_ask`, `related_searches`, `top_stories`, `places`, `ads`) |
85
+ | `images(params)` | `POST /v1/images` | `SearchParams` | `ImagesResponse` |
86
+ | `videos(params)` | `POST /v1/videos` | `SearchParams` | `VideosResponse` |
87
+ | `news(params)` | `POST /v1/news` | `SearchParams` | `NewsResponse` |
88
+ | `maps(params)` | `POST /v1/maps` | `SearchParams` (+ `ll`: `"@lat,lng,14z"`) | `PlacesResponse` |
89
+ | `places(params)` | `POST /v1/places` | `SearchParams` | `PlacesResponse` |
90
+ | `reviews(params)` | `POST /v1/reviews` | `ReviewsParams` (`place_id` \| `cid` \| `fid`, `sort`, `page_token`, `num` ≤ 50) | `ReviewsResponse` (+ `next_page_token`) |
91
+ | `shopping(params)` | `POST /v1/shopping` | `SearchParams` | `ShoppingResponse` |
92
+ | `scholar(params)` | `POST /v1/scholar` | `SearchParams` | `ScholarResponse` |
93
+ | `patents(params)` | `POST /v1/patents` | `SearchParams` | `PatentsResponse` |
94
+ | `autocomplete(params)` | `POST /v1/autocomplete` | `SearchParams` | `AutocompleteResponse` (`results[].value`) |
95
+ | `lens(params)` | `POST /v1/lens` | `LensParams` (`url` of an image) | `LensResponse` |
96
+ | `webpage(params)` | `POST /v1/webpage` | `WebpageParams` (`url`, `include_html`) | `WebpageResponse` (`markdown`, `text`, `metadata`) |
97
+ | `rank(params)` | `POST /v1/rank` | `RankParams` (`q`, `domain`, `num`: 10\|20\|30\|50\|100) | `RankResponse` (`position` or `null`, `matches`, `checked`) |
98
+ | `account()` | `GET /v1/account` | none | `Account` (`balance`, `plan`, `rate_limit_rps`, `month`, …) |
99
+ | `batches.create(params)` | `POST /v1/batches` | `BatchCreateParams` | `BatchCreateResponse` |
100
+ | `batches.get(id)` | `GET /v1/batches/{id}` | batch id | `Batch` |
101
+ | `batches.wait(id, options?)` | polls `GET /v1/batches/{id}` | batch id or entry | `Batch` (`done` or `failed`) |
102
+
103
+ Common `SearchParams`: `q` (required), `country` (default `us`), `language` (default `en`),
104
+ `location`, `uule`, `num` (10, or 100 for the depth bundle), `page` (1-10), `time`
105
+ (`hour`|`day`|`week`|`month`|`year`), `tbs`, `device` (`desktop`|`mobile`), `safe`, `autocorrect`,
106
+ `format` (`json`|`markdown`|`compact`), `fields`, `include_content` (0-5), `ads`, `max_age`,
107
+ `engine`.
108
+
109
+ ### Examples
110
+
111
+ ```ts
112
+ // News from the last day in Germany
113
+ const news = await sk.news({ q: "EZB Zinsen", country: "de", language: "de", time: "day" });
114
+
115
+ // Top 3 organic pages fetched as Markdown (+1 credit per page)
116
+ const deep = await sk.search({ q: "rust async runtime comparison", include_content: 3 });
117
+ console.log(deep.results[0].content);
118
+
119
+ // Only the fields you need
120
+ const lean = await sk.search({ q: "espresso", fields: "results.title,results.link,answer_box" });
121
+
122
+ // Token-lean JSON for agents
123
+ const compact = await sk.search({ q: "espresso", format: "compact" });
124
+
125
+ // Reviews, paged
126
+ let page = await sk.reviews({ place_id: "ChIJN1t_tDeuEmsRUsoyG83frY4", sort: "newest" });
127
+ while (page.next_page_token) {
128
+ page = await sk.reviews({ place_id: "ChIJN1t_tDeuEmsRUsoyG83frY4", page_token: page.next_page_token });
129
+ }
130
+
131
+ // Any URL as Markdown
132
+ const doc = await sk.webpage({ url: "https://example.com/blog/post" });
133
+ console.log(doc.metadata.title, doc.markdown);
134
+
135
+ // Where does a domain rank?
136
+ const r = await sk.rank({ q: "espresso machine", domain: "example.com" });
137
+ console.log(r.position ?? "not in top 100");
138
+
139
+ // Account balance
140
+ const { balance, month } = await sk.account();
141
+
142
+ // Cached result up to one hour old (half price on a hit)
143
+ await sk.search({ q: "espresso", max_age: 3600 });
144
+ ```
145
+
146
+ `format: "markdown"` resolves to a `string` for every vertical that supports it; `format:
147
+ "compact"` resolves to a `CompactResponse`. The return type follows the literal you pass. With
148
+ `fields` the response contains only the requested keys, so treat the typed fields as optional.
149
+
150
+ ## Search engines & fallback
151
+
152
+ By default every request is answered by Google only (`engine: "google"`); SerpKite already
153
+ fails over across its own proxy pools. Opt in to other providers with `engine`:
154
+
155
+ ```ts
156
+ // Fall back to other enabled providers when Google is blocked or times out
157
+ const res = await sk.search({ q: "best espresso machine", engine: "auto" });
158
+ console.log(res.meta.engine); // "google", or e.g. "brave" if Google was unavailable
159
+ console.log(res.meta.route); // [{ provider: "google", outcome: "blocked", ms: 812 }, { provider: "brave", outcome: "ok", ms: 431 }]
160
+
161
+ // Only these providers, in this order
162
+ await sk.news({ q: "espresso", engine: ["google", "brave"] });
163
+ ```
164
+
165
+ - `engine` is `"google"` (default), `"auto"`, `"consensus"`, one `Provider` (`"brave"`, `"bing"`,
166
+ `"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`) or a `Provider[]` list. `"auto"` and `"consensus"` can't
167
+ be combined with other names; unknown names, or a provider that doesn't serve the endpoint, return
168
+ `400 invalid_request`.
169
+ - `engine: "consensus"` (`search` only) asks several independent indexes in parallel, merges the
170
+ results by URL and ranks them by agreement: each result has `sources` (e.g.
171
+ `["google", "brave"]`), `meta.engine` is `"consensus"`, and it costs the sum of one page per
172
+ provider that returned results.
173
+ - `meta.engine` names the provider that answered; `meta.route` (`RouteStep[]`) lists each attempt
174
+ and its `outcome` (absent on cache hits). `request.engine` echoes what you asked for.
175
+ - Credits (`meta.credits_used`, `X-Credits-Used`) follow the answering provider's price.
176
+
177
+ ## Batches
178
+
179
+ Batch jobs cost half price. Submit 1-100 requests for one endpoint; each request becomes a job.
180
+
181
+ ```ts
182
+ import { isBatchError, SerpKite } from "serpkite";
183
+
184
+ const sk = new SerpKite();
185
+ const { batches } = await sk.batches.create({
186
+ endpoint: "search", // or images, news, maps, reviews, webpage, …
187
+ requests: [{ q: "a" }, { q: "b" }],
188
+ webhook_url: "https://example.com/hooks/serpkite", // optional
189
+ });
190
+
191
+ const done = await sk.batches.wait(batches[0].id); // polls until done/failed
192
+ console.log(done.status, done.result);
193
+
194
+ // Wait for all queued jobs; skip entries that were rejected at submit time
195
+ const jobs = await Promise.all(batches.filter((b) => !isBatchError(b)).map((b) => sk.batches.wait(b)));
196
+ ```
197
+
198
+ - `create` returns one entry per request, in order: a queued `Batch` or an error object
199
+ (`{ error: { code, message, request_id } }`). Use `isBatchError(entry)` to tell them apart.
200
+ Passing a rejected entry to `wait` throws its error.
201
+ - `wait(idOrEntry, { timeoutMs = 600000, pollIntervalMs = 1000, maxPollIntervalMs = 10000, signal })`
202
+ polls with a growing interval. It returns failed jobs (check `status` and `error`) and throws a
203
+ `SerpKiteError` with code `timeout` when the deadline passes.
204
+ - Pass `{ idempotencyKey }` as the second argument to `create` (e.g. a UUID you store with the batch)
205
+ to make it safe to retry: the server replays the first response for the same key and body for
206
+ 24 hours, and the SDK then also retries 5xx and network errors.
207
+ - Results are kept for 24 hours. Webhooks are always signed with `X-SerpKite-Signature` (HMAC-SHA256
208
+ of `<X-SerpKite-Timestamp>.<body>` with your webhook secret). Check a delivery with
209
+ `await verifyWebhook(secret, rawBody, req.headers)`.
210
+
211
+ ## Errors
212
+
213
+ Every failure is a `SerpKiteError`:
214
+
215
+ ```ts
216
+ import { SerpKite, SerpKiteError } from "serpkite";
217
+
218
+ try {
219
+ await sk.search({ q: "espresso" });
220
+ } catch (err) {
221
+ if (err instanceof SerpKiteError) {
222
+ console.log(err.status, err.code, err.message, err.requestId);
223
+ }
224
+ }
225
+ ```
226
+
227
+ | `code` | `status` | Meaning |
228
+ | --- | --- | --- |
229
+ | `invalid_request` | 400 | Bad or unknown parameter |
230
+ | `unauthorized` | 401 | Missing or invalid API key |
231
+ | `insufficient_credits` | 402 | Balance too low |
232
+ | `spend_cap_reached`, `key_limit_reached` | 403 | Account spend cap or per-key monthly limit hit |
233
+ | `forbidden` | 403 | Not allowed |
234
+ | `not_found` | 404 | Unknown batch id |
235
+ | `rate_limited` | 429 | Too many requests (retried automatically) |
236
+ | `upstream_error`, `upstream_blocked` | 503 | Google could not be fetched (not billed, retried) |
237
+ | `upstream_timeout` | 503 | The search took too long (not billed, retried) |
238
+ | `unavailable` | 503 | Temporarily unavailable (retried) |
239
+ | `internal` | 500 | Server error (retried) |
240
+ | `connection_error`, `timeout` | 0 | No response received |
241
+ | `missing_api_key` | 0 | No key passed and `SERPKITE_API_KEY` unset |
242
+
243
+ Failed, empty and blocked searches are refunded, so they never cost credits.
244
+
245
+ ## Retries
246
+
247
+ Requests are retried up to `maxRetries` times (default 2) on `429`, `5xx`, network errors and
248
+ timeouts, with exponential backoff (`retryDelayMs · 2^attempt`, capped at 8 s) and jitter. A
249
+ `Retry-After` header (seconds or HTTP date, capped at 60 s) takes precedence. Other `4xx` errors
250
+ are never retried. `batches.create` retries only on `429`, so a lost response can never queue
251
+ (and bill) the same requests twice.
252
+
253
+ ## Types
254
+
255
+ Types are generated from the OpenAPI contract (`backend/api/serp-api.yaml`) with
256
+ `openapi-typescript`. Friendly aliases are exported: `SearchParams`, `SearchResponse`,
257
+ `OrganicResult`, `AnswerBox`, `NewsResponse`, `NewsResult`, `ImagesResponse`, `PlacesResponse`,
258
+ `ReviewsResponse`, `ShoppingResponse`, `ScholarResponse`, `PatentsResponse`,
259
+ `AutocompleteResponse`, `LensResponse`, `WebpageResponse`, `RankResponse`,
260
+ `Account`, `Batch`, `BatchCreateParams`, `Meta`, `Engine`, `Provider`, `RouteStep`,
261
+ `CompactResponse` and more. The raw
262
+ `paths`, `components` and `operations` types are exported as well.
263
+
264
+ ## Development
265
+
266
+ ```bash
267
+ bun install # from the repo root
268
+ bun run gen # regenerate src/generated/openapi.ts from backend/api/serp-api.yaml
269
+ bun run typecheck
270
+ bun test
271
+ bun run build # dist/: ESM, CJS and .d.ts
272
+ ```
273
+
274
+ ## License
275
+
276
+ MIT