scavio 0.21.1 → 0.23.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/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  TypeScript SDK for the [Scavio API](https://scavio.dev?utm_source=npm-scavio-js&utm_medium=package&utm_campaign=readme) — real-time structured web
4
4
  data across 36 platforms on one API key, plus `extract()` to read
5
- any URL as clean Markdown.
5
+ any URL as clean Markdown, and `extract.map()` / `extract.crawl()` /
6
+ `extract.batch()` to map, crawl and batch-extract whole websites.
6
7
 
7
8
  Structured JSON in, structured JSON out. No per-site parsers to maintain.
8
9
 
@@ -14,6 +15,7 @@ Structured JSON in, structured JSON out. No per-site parsers to maintain.
14
15
  - **Ads transparency** — Google Ads Transparency Center, Meta Ad Library
15
16
  - **Social and video** — YouTube, TikTok, Instagram, Threads, X, LinkedIn, Reddit, Kuaishou
16
17
  - **Any other page** — `extract()` turns a URL into Markdown, plain text or raw HTML
18
+ - **Whole websites** — `extract.map()` lists a site's URLs, `extract.crawl()` crawls a site, `extract.batch()` extracts a list of URLs in one job
17
19
 
18
20
  ## Install
19
21
 
@@ -103,6 +105,129 @@ boilerplate stripped. `text` is that markdown flattened to plain text. URLs are
103
105
  http(s) only, a bare host is upgraded to https, and loopback, private,
104
106
  link-local and cloud-metadata hosts are rejected with a 400.
105
107
 
108
+ ### Website map, crawl and batch extract (new in 0.23.0)
109
+
110
+ `extract` is also a namespace: calling it reads one page, exactly as before
111
+ (`client.extract({ url })` sends the same request it did in 0.22), and its
112
+ methods work on whole sites and URL lists, with each page returned exactly
113
+ like [Extract](https://scavio.dev/docs/extract?utm_source=npm-scavio-js&utm_medium=package&utm_campaign=readme).
114
+
115
+ ```typescript
116
+ // Map: every URL on a site, from its sitemaps and start page. 1 credit.
117
+ const map = await client.extract.map({
118
+ url: "https://example.com",
119
+ limit: 500, // 1-5,000, default 1,000 (plan caps apply)
120
+ sitemap: "include", // "include" | "only" | "skip"
121
+ include_paths: ["/blog/**"], // optional path globs
122
+ });
123
+ map.data.links; // [{ url, source: "sitemap" | "page", lastmod? }]
124
+ map.data.truncated; // true when more URLs exist than `limit`
125
+
126
+ // Crawl: start a job, then poll it (polling and cancelling are free).
127
+ const job = await client.extract.crawl(
128
+ { url: "https://example.com/docs", max_pages: 50, max_depth: 3, format: "markdown" },
129
+ { idempotencyKey: "docs-crawl-2026-10-12" }, // optional, sent as Idempotency-Key
130
+ );
131
+ job.data.id; // "crawl_..."
132
+ job.data.status_url;
133
+
134
+ const page = await client.extract.crawlStatus(job.data.id, { limit: 25 });
135
+ page.data.status; // "queued" | "running" | "completed" | "failed" | "cancelled"
136
+ page.data.pages; // { discovered, queued, succeeded, failed, skipped }
137
+ page.data.results; // [{ url, depth, status, status_code, content, ... }]
138
+ page.data.next_cursor; // pass as { cursor } for the next page, null at the end
139
+
140
+ await client.extract.cancelCrawl(job.data.id); // 409 once the crawl has finished
141
+
142
+ // Or wait for it: polls until done and returns every result in one response.
143
+ const done = await client.extract.crawl(
144
+ { url: "https://example.com/docs", max_pages: 20 },
145
+ { wait: true, pollInterval: 2000, waitTimeout: 600_000 },
146
+ );
147
+ done.data.results.length;
148
+
149
+ // Batch: extract a list of URLs in one job (results in input order).
150
+ const batch = await client.extract.batch({
151
+ urls: ["https://example.com/a", "https://example.com/b"],
152
+ mode: "normal",
153
+ });
154
+ batch.data.invalid_urls; // rejected up front, never billed
155
+ await client.extract.batchStatus(batch.data.id);
156
+ await client.extract.cancelBatch(batch.data.id);
157
+ ```
158
+
159
+ **Pricing.** Map costs 1 credit. Crawl and batch bill per **successful** page
160
+ at Extract's price for the tier: `normal` 1, `advanced` 1, `ultra` 2. Failed
161
+ and skipped pages are free, starting, polling and cancelling a job are free,
162
+ and credits are taken page by page: if your balance runs out the job stops
163
+ cleanly with `stopped_reason: "insufficient_credits"`.
164
+
165
+ **Plan limits.**
166
+
167
+ | Plan | Max pages per crawl | Active jobs | URLs per batch |
168
+ |------|--------------------:|------------:|---------------:|
169
+ | Free | 10 | 1 | 10 |
170
+ | PAYG | 100 | 1 | 50 |
171
+ | Project | 500 | 2 | 100 |
172
+ | Bootstrap | 2,000 | 3 | 500 |
173
+ | Startup | 5,000 | 5 | 1,000 |
174
+ | Growth | 10,000 | 10 | 1,000 |
175
+
176
+ Starting a job above your plan's active-job limit is a 429 (`RateLimitError`)
177
+ with `Retry-After`.
178
+
179
+ **Behavior.** Crawl and map always obey the site's robots.txt. A crawl stays on
180
+ the start URL's site (`allow_subdomains` opts into subdomains) and visits URLs
181
+ under the start path first. Batch treats each URL like a single `extract()`
182
+ call. Results are kept for 3 days after the job finishes (`expires_at`), up to
183
+ 2 MB per page and 250 MB per job, and a job runs for at most 6 hours.
184
+
185
+ **`wait: true`.** `crawl()` and `batch()` poll the status endpoint every
186
+ `pollInterval` ms (default 2,000) until the job is completed, failed or
187
+ cancelled, then page through every cursor and return
188
+ `{ data: { ...summary, results: [all], next_cursor: null } }`. If the job is
189
+ still running after `waitTimeout` ms (default 10 minutes) they throw
190
+ `ScavioJobTimeoutError`; the job keeps running and the error carries
191
+ `jobId`, `jobType` and `statusUrl` so you can keep polling or cancel it. For
192
+ big crawls, prefer a webhook or your own polling loop.
193
+
194
+ **Retries and idempotency.** The SDK retries 429/5xx and network errors. Pass
195
+ `idempotencyKey` when starting a job: repeating a POST with the same key within
196
+ 24 hours returns the original job, so a retried request never starts a second
197
+ one.
198
+
199
+ #### Completion webhooks
200
+
201
+ Pass `webhook_url` (https) to `crawl()` or `batch()` and Scavio POSTs the job
202
+ summary there when it completes, fails or is cancelled (retried at 0 s, 30 s
203
+ and 5 min). The signing secret comes back once, as `job.data.webhook_secret`.
204
+ Each delivery is signed with
205
+ `Scavio-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`; verify
206
+ it against the **raw** body with `verifyWebhook` (async, Web Crypto, so it runs
207
+ on Node 18+, Deno, Bun and edge runtimes):
208
+
209
+ ```typescript
210
+ import { verifyWebhook } from "scavio";
211
+
212
+ // e.g. a Next.js / Hono / Workers route handler
213
+ export async function POST(req: Request) {
214
+ const raw = await req.text();
215
+ const ok = await verifyWebhook(
216
+ process.env.SCAVIO_WEBHOOK_SECRET!, // the job's webhook_secret
217
+ req.headers.get("Scavio-Signature"),
218
+ raw,
219
+ 300, // max signature age in seconds (default)
220
+ );
221
+ if (!ok) return new Response("bad signature", { status: 401 });
222
+ const event = JSON.parse(raw);
223
+ // event.id, event.status, ...
224
+ return new Response("ok");
225
+ }
226
+ ```
227
+
228
+ `verifyWebhook` returns `false` (never throws) for a missing, malformed,
229
+ tampered or stale signature.
230
+
106
231
  ### Google
107
232
 
108
233
  Each method hits its own `/api/v2/google*` endpoint and returns Google's full
@@ -356,7 +481,7 @@ nickname, or a listing with no public questions, is a billed 404.
356
481
 
357
482
  ### YouTube
358
483
 
359
- Credit cost varies by endpoint: `transcript` costs 8; `streams` costs 3;
484
+ Credit cost varies by endpoint: `transcript` costs 5; `streams` costs 3;
360
485
  `search` and `shorts` cost 2; every other YouTube endpoint costs 1.
361
486
 
362
487
  ```typescript
@@ -402,6 +527,10 @@ await client.youtube.channelVideos({ channel_id: "UC..." });
402
527
  await client.youtube.channelShorts({ channel_id: "UC..." });
403
528
  await client.youtube.channelCommunity({ channel_id: "UC..." });
404
529
  await client.youtube.channelResolve({ channel: "@mkbhd" }); // handle/URL -> id
530
+
531
+ // One community post (text, author, likes, images, comments_cursor) and its comments
532
+ await client.youtube.communityPost({ post_id: "Ugkx_PqHZYEoCCP7e8WIOrh-1HCvtKoFMzVp" });
533
+ await client.youtube.communityPostComments({ post_id: "Ugkx_PqHZYEoCCP7e8WIOrh-1HCvtKoFMzVp" });
405
534
  ```
406
535
 
407
536
  ### Reddit
@@ -409,7 +538,8 @@ await client.youtube.channelResolve({ channel: "@mkbhd" }); // handle/URL -> id
409
538
  Every Reddit endpoint costs 1 credit.
410
539
 
411
540
  `search()` takes only `query` and `cursor` — there is no result-type or sort
412
- filter, so anything else is dropped server-side. It returns
541
+ filter, so anything else is dropped server-side. `searchComments()` does sort
542
+ (`RELEVANCE`, `HOT`, `TOP` or `NEW`). It returns
413
543
  `data.results` with `next_cursor` and `has_more`. `post()` returns a flat post
414
544
  object under `data` and carries no comments; use `postComments()` for those.
415
545
  The subreddit and user feeds return `data.posts`.
@@ -444,6 +574,10 @@ await client.reddit.userComments({ username: "spez" });
444
574
  // Site-wide popular feed and trending searches
445
575
  await client.reddit.popular();
446
576
  await client.reddit.trending();
577
+
578
+ // Comment search (each result carries its post and subreddit) and subreddit search
579
+ await client.reddit.searchComments({ query: "web scraping api", sort: "NEW" });
580
+ await client.reddit.searchSubreddits({ query: "web scraping" });
447
581
  ```
448
582
 
449
583
  ### X
@@ -467,8 +601,15 @@ await client.x.userFollowings({ screen_name: "elonmusk" });
467
601
 
468
602
  // Trending topics
469
603
  await client.x.trending({ country: "UnitedStates" });
604
+
605
+ // Communities: search, details (rules, member count), and posts
606
+ await client.x.searchCommunities({ keyword: "crypto" });
607
+ await client.x.community({ community_id: "1508567430188126209" });
608
+ await client.x.communityTweets({ community_id: "1508567430188126209", ranking: "Recency" });
470
609
  ```
471
610
 
611
+ Every X endpoint costs 1 credit.
612
+
472
613
  ### LinkedIn
473
614
 
474
615
  ```typescript
@@ -518,6 +659,10 @@ await client.tiktok.userPosts({ sec_user_id: "abc123", count: 30 });
518
659
  // Video details
519
660
  await client.tiktok.video({ video_id: "vid123" });
520
661
 
662
+ // Video transcript: timestamped segments and full text (video_id or url;
663
+ // optional 2-letter language). Caption-less videos return available: false.
664
+ await client.tiktok.videoTranscript({ video_id: "7609177768793787679" });
665
+
521
666
  // Video comments
522
667
  await client.tiktok.videoComments({ video_id: "vid123", count: 20 });
523
668
 
@@ -541,8 +686,39 @@ await client.tiktok.userFollowers({ sec_user_id: "abc123" });
541
686
 
542
687
  // User followings
543
688
  await client.tiktok.userFollowings({ sec_user_id: "abc123" });
689
+
690
+ // Trending searches and a sample of trending videos (about 6 per call)
691
+ await client.tiktok.trendingSearches();
692
+ await client.tiktok.trendingVideos();
693
+
694
+ // Search autocomplete
695
+ await client.tiktok.searchSuggestions({ keyword: "cooking" });
696
+
697
+ // Sounds: detail, videos using a sound, search, Top 50 / Viral 50 chart
698
+ await client.tiktok.music({ music_id: "7364498342501435408" });
699
+ await client.tiktok.musicVideos({ music_id: "7364498342501435408" });
700
+ await client.tiktok.searchMusic({ keyword: "espresso" });
701
+ await client.tiktok.musicChart({ chart: "viral" });
702
+
703
+ // Similar creators, playlists and the videos in one
704
+ await client.tiktok.userSimilar({ sec_user_id: "abc123" });
705
+ await client.tiktok.userPlaylists({ sec_user_id: "abc123" });
706
+ await client.tiktok.playlistVideos({ playlist_id: "7596415294902389534" });
707
+
708
+ // Live status and account region (region also returns sec_user_id)
709
+ await client.tiktok.userLive({ username: "mrbeast" });
710
+ await client.tiktok.userRegion({ username: "mrbeast" });
544
711
  ```
545
712
 
713
+ Every TikTok endpoint costs 1 credit. The trending, sound, similar, playlist,
714
+ live and region endpoints return TikTok's raw payload: large numeric ids can
715
+ lose precision when parsed as JSON numbers, so use the string forms (`id_str`,
716
+ `sec_uid`, `sec_user_id`). `trendingVideos()` returns about 6 videos per call
717
+ whatever `count` is set to, and `musicVideos()` ignores `count` (about 14 per
718
+ page). Search suggestions can be empty for long-tail keywords and still bill.
719
+ An unknown username is a billed 404 on `userLive()` and an unbilled 404 on
720
+ `userRegion()`.
721
+
546
722
  ### TikTok Shop
547
723
 
548
724
  Every TikTok Shop endpoint costs 1 credit. Two limits to design around:
@@ -1178,7 +1354,8 @@ All error classes:
1178
1354
  | `InsufficientCreditsError` | 402 | No credits remaining |
1179
1355
  | `NotFoundError` | 404 | No data for that id (see TikTok Shop above) |
1180
1356
  | `RateLimitError` | 429 | Rate limit exceeded |
1181
- | `ScavioAPIError` | other | Catch-all (has `.statusCode`) |
1357
+ | `ScavioAPIError` | other | Catch-all (has `.statusCode`; a 409 from cancelling a finished job lands here) |
1358
+ | `ScavioJobTimeoutError` | — | `crawl()`/`batch()` with `wait: true` outlived `waitTimeout`; carries `jobId` |
1182
1359
 
1183
1360
  Threads and Kuaishou answer a missing or conflicting identifier with **422**, not
1184
1361
  400 — those routes have no 400 at all. Both map to `BadRequestError`, so one