scrapebadger-cli 0.14.0__tar.gz → 0.16.0__tar.gz

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.
Files changed (49) hide show
  1. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/PKG-INFO +14 -1
  2. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/README.md +13 -0
  3. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/pyproject.toml +1 -1
  4. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/depop.py +1 -1
  5. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/google.py +1 -1
  6. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/tiktok.py +264 -13
  7. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/vinted.py +65 -0
  8. scrapebadger_cli-0.16.0/tests/test_tiktok_pagination.py +36 -0
  9. scrapebadger_cli-0.16.0/tests/test_vinted_image_search.py +27 -0
  10. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/uv.lock +1 -1
  11. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/.gitignore +0 -0
  12. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/__init__.py +0 -0
  13. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/client.py +0 -0
  14. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/__init__.py +0 -0
  15. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/airbnb.py +0 -0
  16. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/amazon.py +0 -0
  17. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/apartments.py +0 -0
  18. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/app_store.py +0 -0
  19. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/auth.py +0 -0
  20. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/baidu.py +0 -0
  21. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/bing.py +0 -0
  22. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/booking.py +0 -0
  23. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/chatgpt.py +0 -0
  24. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/duckduckgo.py +0 -0
  25. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/ebay.py +0 -0
  26. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/facebook.py +0 -0
  27. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/gemini.py +0 -0
  28. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/google_play.py +0 -0
  29. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/idealista.py +0 -0
  30. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/immobiliare.py +0 -0
  31. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/instagram.py +0 -0
  32. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/leboncoin.py +0 -0
  33. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/linkedin.py +0 -0
  34. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/loopnet.py +0 -0
  35. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/perplexity.py +0 -0
  36. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/realtor.py +0 -0
  37. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/reddit.py +0 -0
  38. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/redfin.py +0 -0
  39. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/stream.py +0 -0
  40. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/twitter.py +0 -0
  41. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/walmart.py +0 -0
  42. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/web.py +0 -0
  43. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/yahoo.py +0 -0
  44. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/yandex.py +0 -0
  45. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/youtube.py +0 -0
  46. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/commands/zillow.py +0 -0
  47. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/config.py +0 -0
  48. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/main.py +0 -0
  49. {scrapebadger_cli-0.14.0 → scrapebadger_cli-0.16.0}/src/scrapebadger_cli/output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scrapebadger-cli
3
- Version: 0.14.0
3
+ Version: 0.16.0
4
4
  Summary: ScrapeBadger CLI — Twitter, Vinted, Google, and Web Scraping from the command line
5
5
  Author: ScrapeBadger Team
6
6
  License: MIT
@@ -58,3 +58,16 @@ scrapebadger vinted search "nike" --sizes 206 --materials 43
58
58
  The catalog lists 76 public read operations with parameter types and examples.
59
59
  Each mobile read costs 1 credit; discovery is free. Sold comparables are a
60
60
  Vinted-selected sample, not a complete sales history or guaranteed final sale prices.
61
+
62
+ ### Vinted image search
63
+
64
+ ```bash
65
+ scrapebadger vinted search-by-image --image photo.jpg --market nl --price-to 50
66
+ scrapebadger vinted search-by-image --image-url https://example.com/photo.webp --brand-ids 53
67
+ ```
68
+
69
+ Provide exactly one of `--image`, `--image-url` or `--image-base64`. JPEG, PNG and
70
+ WebP up to 5 MiB are supported. Returns active listings in visual ranking order;
71
+ 10 credits per successful request. Filter with `--catalog-ids`, `--brand-ids`,
72
+ `--status-ids`, `--color-ids`, `--size-ids`, `--material-ids` and price bounds.
73
+ Resend the same image and `--time` from pagination when requesting `--page 2`.
@@ -36,3 +36,16 @@ scrapebadger vinted search "nike" --sizes 206 --materials 43
36
36
  The catalog lists 76 public read operations with parameter types and examples.
37
37
  Each mobile read costs 1 credit; discovery is free. Sold comparables are a
38
38
  Vinted-selected sample, not a complete sales history or guaranteed final sale prices.
39
+
40
+ ### Vinted image search
41
+
42
+ ```bash
43
+ scrapebadger vinted search-by-image --image photo.jpg --market nl --price-to 50
44
+ scrapebadger vinted search-by-image --image-url https://example.com/photo.webp --brand-ids 53
45
+ ```
46
+
47
+ Provide exactly one of `--image`, `--image-url` or `--image-base64`. JPEG, PNG and
48
+ WebP up to 5 MiB are supported. Returns active listings in visual ranking order;
49
+ 10 credits per successful request. Filter with `--catalog-ids`, `--brand-ids`,
50
+ `--status-ids`, `--color-ids`, `--size-ids`, `--material-ids` and price bounds.
51
+ Resend the same image and `--time` from pagination when requesting `--page 2`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrapebadger-cli"
3
- version = "0.14.0"
3
+ version = "0.16.0"
4
4
  description = "ScrapeBadger CLI — Twitter, Vinted, Google, and Web Scraping from the command line"
5
5
  requires-python = ">=3.10"
6
6
  license = {text = "MIT"}
@@ -32,7 +32,7 @@ def search(
32
32
  sort: str | None = typer.Option(
33
33
  None,
34
34
  "--sort",
35
- help="relevance, newest, priceAscending, priceDescending, popularity",
35
+ help="relevance, priceAscending, priceDescending",
36
36
  ),
37
37
  fmt: str = FMT,
38
38
  fields: str | None = FIELDS,
@@ -881,7 +881,7 @@ def finance(
881
881
  fmt: str = FMT,
882
882
  fields: str | None = FIELDS,
883
883
  ) -> None:
884
- """Google Finance quote — via mKsvE RPC."""
884
+ """Google Finance quote."""
885
885
  data = api_get("/v1/google/finance/quote", {"q": q, "hl": hl, "gl": gl})
886
886
  render(data, fmt, fields)
887
887
 
@@ -31,10 +31,16 @@ def user_videos(
31
31
  count: int = COUNT,
32
32
  fmt: str = FMT,
33
33
  fields: str | None = FIELDS,
34
+ cursor: str | None = typer.Option(
35
+ None, "--cursor", help="Continuation cursor from the previous page"
36
+ ),
34
37
  ) -> None:
35
38
  """Get a user's posted videos."""
36
39
  render(
37
- api_get(f"/v1/tiktok/users/{username}/videos", {"region": region, "count": count}),
40
+ api_get(
41
+ f"/v1/tiktok/users/{username}/videos",
42
+ {**({"cursor": cursor} if cursor else {}), "region": region, "count": count},
43
+ ),
38
44
  fmt,
39
45
  fields,
40
46
  )
@@ -47,10 +53,16 @@ def followers(
47
53
  count: int = COUNT,
48
54
  fmt: str = FMT,
49
55
  fields: str | None = FIELDS,
56
+ cursor: str | None = typer.Option(
57
+ None, "--cursor", help="Continuation cursor from the previous page"
58
+ ),
50
59
  ) -> None:
51
60
  """Get a user's followers (best-effort)."""
52
61
  render(
53
- api_get(f"/v1/tiktok/users/{username}/followers", {"region": region, "count": count}),
62
+ api_get(
63
+ f"/v1/tiktok/users/{username}/followers",
64
+ {**({"cursor": cursor} if cursor else {}), "region": region, "count": count},
65
+ ),
54
66
  fmt,
55
67
  fields,
56
68
  )
@@ -63,10 +75,16 @@ def following(
63
75
  count: int = COUNT,
64
76
  fmt: str = FMT,
65
77
  fields: str | None = FIELDS,
78
+ cursor: str | None = typer.Option(
79
+ None, "--cursor", help="Continuation cursor from the previous page"
80
+ ),
66
81
  ) -> None:
67
82
  """Get accounts a user follows (best-effort)."""
68
83
  render(
69
- api_get(f"/v1/tiktok/users/{username}/following", {"region": region, "count": count}),
84
+ api_get(
85
+ f"/v1/tiktok/users/{username}/following",
86
+ {**({"cursor": cursor} if cursor else {}), "region": region, "count": count},
87
+ ),
70
88
  fmt,
71
89
  fields,
72
90
  )
@@ -98,12 +116,20 @@ def comments(
98
116
  username: str | None = typer.Option(None, "--username", "-u"),
99
117
  fmt: str = FMT,
100
118
  fields: str | None = FIELDS,
119
+ cursor: str | None = typer.Option(
120
+ None, "--cursor", help="Continuation cursor from the previous page"
121
+ ),
101
122
  ) -> None:
102
123
  """Get top-level comments on a video."""
103
124
  render(
104
125
  api_get(
105
126
  f"/v1/tiktok/videos/{video_id}/comments",
106
- {"region": region, "count": count, "username": username},
127
+ {
128
+ **({"cursor": cursor} if cursor else {}),
129
+ "region": region,
130
+ "count": count,
131
+ "username": username,
132
+ },
107
133
  ),
108
134
  fmt,
109
135
  fields,
@@ -118,12 +144,20 @@ def related(
118
144
  username: str | None = typer.Option(None, "--username", "-u"),
119
145
  fmt: str = FMT,
120
146
  fields: str | None = FIELDS,
147
+ cursor: str | None = typer.Option(
148
+ None, "--cursor", help="Continuation cursor from the previous page"
149
+ ),
121
150
  ) -> None:
122
151
  """Get related videos for a video."""
123
152
  render(
124
153
  api_get(
125
154
  f"/v1/tiktok/videos/{video_id}/related",
126
- {"region": region, "count": count, "username": username},
155
+ {
156
+ **({"cursor": cursor} if cursor else {}),
157
+ "region": region,
158
+ "count": count,
159
+ "username": username,
160
+ },
127
161
  ),
128
162
  fmt,
129
163
  fields,
@@ -177,10 +211,16 @@ def hashtag_videos(
177
211
  count: int = COUNT,
178
212
  fmt: str = FMT,
179
213
  fields: str | None = FIELDS,
214
+ cursor: str | None = typer.Option(
215
+ None, "--cursor", help="Continuation cursor from the previous page"
216
+ ),
180
217
  ) -> None:
181
218
  """Get videos tagged with a hashtag."""
182
219
  render(
183
- api_get(f"/v1/tiktok/hashtags/{name}/videos", {"region": region, "count": count}),
220
+ api_get(
221
+ f"/v1/tiktok/hashtags/{name}/videos",
222
+ {**({"cursor": cursor} if cursor else {}), "region": region, "count": count},
223
+ ),
184
224
  fmt,
185
225
  fields,
186
226
  )
@@ -204,10 +244,16 @@ def music_videos(
204
244
  count: int = COUNT,
205
245
  fmt: str = FMT,
206
246
  fields: str | None = FIELDS,
247
+ cursor: str | None = typer.Option(
248
+ None, "--cursor", help="Continuation cursor from the previous page"
249
+ ),
207
250
  ) -> None:
208
251
  """Get videos using a sound."""
209
252
  render(
210
- api_get(f"/v1/tiktok/music/{music_id}/videos", {"region": region, "count": count}),
253
+ api_get(
254
+ f"/v1/tiktok/music/{music_id}/videos",
255
+ {**({"cursor": cursor} if cursor else {}), "region": region, "count": count},
256
+ ),
211
257
  fmt,
212
258
  fields,
213
259
  )
@@ -221,24 +267,41 @@ def search(
221
267
  count: int = typer.Option(20, "--count", "-c"),
222
268
  fmt: str = FMT,
223
269
  fields: str | None = FIELDS,
270
+ cursor: str | None = typer.Option(
271
+ None, "--cursor", help="Continuation cursor from the previous page"
272
+ ),
224
273
  ) -> None:
225
274
  """Search TikTok (general/users/videos/hashtags)."""
226
275
  path = "/v1/tiktok/search" if kind == "general" else f"/v1/tiktok/search/{kind}"
227
- render(api_get(path, {"query": query, "region": region, "count": count}), fmt, fields)
276
+ render(
277
+ api_get(
278
+ path,
279
+ {
280
+ "query": query,
281
+ "region": region,
282
+ "count": count,
283
+ **({"cursor": cursor} if cursor else {}),
284
+ },
285
+ ),
286
+ fmt,
287
+ fields,
288
+ )
228
289
 
229
290
 
230
291
  @app.command()
231
292
  def trending(
232
293
  kind: str = typer.Option("videos", "--kind", "-k", help="videos | hashtags | songs"),
233
294
  region: str = REGION,
234
- period: int = typer.Option(7, "--period", help="Trend window in days (7/30/120)"),
295
+ period: int | None = typer.Option(
296
+ None, "--period", help="Historical windows unavailable; omit"
297
+ ),
235
298
  count: int = typer.Option(20, "--count", "-c"),
236
299
  fmt: str = FMT,
237
300
  fields: str | None = FIELDS,
238
301
  ) -> None:
239
302
  """Get trending data (videos/hashtags/songs)."""
240
303
  params = {"region": region, "count": count}
241
- if kind in ("hashtags", "songs"):
304
+ if kind in ("hashtags", "songs") and period is not None:
242
305
  params["period"] = period
243
306
  render(api_get(f"/v1/tiktok/trending/{kind}", params), fmt, fields)
244
307
 
@@ -305,7 +368,12 @@ def ad(
305
368
  render(api_get(f"/v1/tiktok/ads/{ad_id}", {"region": region}), fmt, fields)
306
369
 
307
370
 
308
- SHOP_REGION = typer.Option("US", "--region", "-r", help="Market: US, GB, ID")
371
+ SHOP_REGION = typer.Option(
372
+ "US",
373
+ "--region",
374
+ "-r",
375
+ help="Market: US, GB, ID, JP, MX, SG, MY, PH, TH, VN (coverage varies by endpoint)",
376
+ )
309
377
 
310
378
 
311
379
  @app.command(name="shop-categories")
@@ -324,17 +392,103 @@ def shop_categories(
324
392
  render(api_get(path, {"region": region}), fmt, fields)
325
393
 
326
394
 
395
+ @app.command(name="shop-category-products")
396
+ def shop_category_products(
397
+ category_id: str = typer.Argument(..., help="Numeric category id"),
398
+ region: str = SHOP_REGION,
399
+ count: int = typer.Option(20, "--count", "-c", min=1, max=40),
400
+ exclude_product_ids: list[str] = typer.Option(
401
+ [], "--exclude-product-id", help="Repeat for every next_exclude_product_ids value"
402
+ ),
403
+ fmt: str = FMT,
404
+ fields: str | None = FIELDS,
405
+ ) -> None:
406
+ """Page through a category's product recommendations."""
407
+ render(
408
+ api_get(
409
+ f"/v1/tiktok/shop/categories/{category_id}/products",
410
+ {
411
+ "region": region,
412
+ "count": count,
413
+ "exclude_product_ids": exclude_product_ids,
414
+ },
415
+ ),
416
+ fmt,
417
+ fields,
418
+ )
419
+
420
+
421
+ @app.command(name="shop-ranking")
422
+ def shop_ranking(
423
+ rank_id: str = typer.Argument(..., help="Native category/theme ranking id"),
424
+ region: str = SHOP_REGION,
425
+ rank_type: int = typer.Option(1, "--rank-type", min=1, max=3),
426
+ cursor: int = typer.Option(0, "--cursor", min=0),
427
+ count: int = typer.Option(20, "--count", "-c", min=1, max=20),
428
+ fmt: str = FMT,
429
+ fields: str | None = FIELDS,
430
+ ) -> None:
431
+ """Native Shop ranking; ID/PH/TH/VN require regional guest appliance profiles."""
432
+ render(
433
+ api_get(
434
+ f"/v1/tiktok/shop/rankings/{rank_id}",
435
+ {
436
+ "region": region,
437
+ "rank_type": rank_type,
438
+ "cursor": cursor,
439
+ "count": count,
440
+ },
441
+ ),
442
+ fmt,
443
+ fields,
444
+ )
445
+
446
+
447
+ @app.command(name="shop-bestsellers")
448
+ def shop_bestsellers(
449
+ region: str = SHOP_REGION,
450
+ category_id: str | None = typer.Option(None, "--category-id", help="Limit to one category"),
451
+ pages: int = typer.Option(2, "--pages", min=1, max=4),
452
+ limit: int = typer.Option(20, "--limit", "-l", min=1, max=100),
453
+ fmt: str = FMT,
454
+ fields: str | None = FIELDS,
455
+ ) -> None:
456
+ """Sales-ranked best-selling products; works for every market (incl. SG/MY/JP)."""
457
+ render(
458
+ api_get(
459
+ "/v1/tiktok/shop/bestsellers",
460
+ {
461
+ "region": region,
462
+ "category_id": category_id,
463
+ "pages": pages,
464
+ "limit": limit,
465
+ },
466
+ ),
467
+ fmt,
468
+ fields,
469
+ )
470
+
471
+
327
472
  @app.command(name="shop-search")
328
473
  def shop_search(
329
474
  query: str = typer.Argument(..., help="Keyword, e.g. 'wireless earbuds'"),
330
475
  region: str = SHOP_REGION,
476
+ page_token: str | None = typer.Option(None, "--page-token", help="Native ID/JP continuation"),
331
477
  offset: int = typer.Option(0, "--offset", help="Pass back pagination.next_offset (US)"),
332
478
  fmt: str = FMT,
333
479
  fields: str | None = FIELDS,
334
480
  ) -> None:
335
481
  """Search TikTok Shop products by keyword (30 per page, offset pagination in the US)."""
336
482
  render(
337
- api_get("/v1/tiktok/shop/search", {"q": query, "region": region, "offset": offset}),
483
+ api_get(
484
+ "/v1/tiktok/shop/search",
485
+ {
486
+ "q": query,
487
+ "region": region,
488
+ "offset": offset,
489
+ "page_token": page_token,
490
+ },
491
+ ),
338
492
  fmt,
339
493
  fields,
340
494
  )
@@ -384,7 +538,7 @@ def shop_reviews(
384
538
  fmt: str = FMT,
385
539
  fields: str | None = FIELDS,
386
540
  ) -> None:
387
- """Paginated TikTok Shop product reviews with the rating breakdown (US)."""
541
+ """Paginated product reviews (US; Tokopedia source for ID)."""
388
542
  params: dict = {
389
543
  "region": region,
390
544
  "page": page,
@@ -405,3 +559,100 @@ def regions(
405
559
  ) -> None:
406
560
  """List supported TikTok content regions."""
407
561
  render(api_get("/v1/tiktok/regions"), fmt, fields)
562
+
563
+
564
+ @app.command(name="shop-mall")
565
+ def shop_mall(
566
+ region: str = SHOP_REGION,
567
+ tab_id: int = typer.Option(0, "--tab-id", min=0),
568
+ page_token: str | None = typer.Option(None, "--page-token"),
569
+ fmt: str = FMT,
570
+ fields: str | None = FIELDS,
571
+ ) -> None:
572
+ """Regional mall navigation and feed; pass back the 15-minute page token."""
573
+ render(
574
+ api_get(
575
+ "/v1/tiktok/shop/mall",
576
+ {
577
+ "region": region,
578
+ "tab_id": tab_id,
579
+ "page_token": page_token,
580
+ },
581
+ ),
582
+ fmt,
583
+ fields,
584
+ )
585
+
586
+
587
+ @app.command()
588
+ def liked(
589
+ username: str = typer.Argument(..., help="TikTok handle"),
590
+ region: str = REGION,
591
+ count: int = COUNT,
592
+ fmt: str = FMT,
593
+ fields: str | None = FIELDS,
594
+ cursor: str | None = typer.Option(
595
+ None, "--cursor", help="Continuation cursor from the previous page"
596
+ ),
597
+ ) -> None:
598
+ """Get accounts a user follows (best-effort)."""
599
+ render(
600
+ api_get(
601
+ f"/v1/tiktok/users/{username}/liked",
602
+ {**({"cursor": cursor} if cursor else {}), "region": region, "count": count},
603
+ ),
604
+ fmt,
605
+ fields,
606
+ )
607
+
608
+
609
+ @app.command()
610
+ def reposts(
611
+ username: str = typer.Argument(..., help="TikTok handle"),
612
+ region: str = REGION,
613
+ count: int = COUNT,
614
+ fmt: str = FMT,
615
+ fields: str | None = FIELDS,
616
+ cursor: str | None = typer.Option(
617
+ None, "--cursor", help="Continuation cursor from the previous page"
618
+ ),
619
+ ) -> None:
620
+ """Get accounts a user follows (best-effort)."""
621
+ render(
622
+ api_get(
623
+ f"/v1/tiktok/users/{username}/reposts",
624
+ {**({"cursor": cursor} if cursor else {}), "region": region, "count": count},
625
+ ),
626
+ fmt,
627
+ fields,
628
+ )
629
+
630
+
631
+ @app.command()
632
+ def replies(
633
+ comment_id: str = typer.Argument(..., help="Comment id"),
634
+ video_id: str = typer.Argument(..., help="Numeric video id"),
635
+ region: str = REGION,
636
+ count: int = typer.Option(20, "--count", "-c"),
637
+ username: str | None = typer.Option(None, "--username", "-u"),
638
+ fmt: str = FMT,
639
+ fields: str | None = FIELDS,
640
+ cursor: str | None = typer.Option(
641
+ None, "--cursor", help="Continuation cursor from the previous page"
642
+ ),
643
+ ) -> None:
644
+ """Get replies to a public comment."""
645
+ render(
646
+ api_get(
647
+ f"/v1/tiktok/comments/{comment_id}/replies",
648
+ {
649
+ **({"cursor": cursor} if cursor else {}),
650
+ "region": region,
651
+ "video_id": video_id,
652
+ "count": count,
653
+ "username": username,
654
+ },
655
+ ),
656
+ fmt,
657
+ fields,
658
+ )
@@ -1,7 +1,9 @@
1
1
  """Vinted marketplace commands."""
2
2
 
3
+ import base64
3
4
  import json
4
5
  import re
6
+ from pathlib import Path
5
7
 
6
8
  import typer
7
9
 
@@ -157,3 +159,66 @@ def mobile_read(
157
159
  fmt,
158
160
  fields,
159
161
  )
162
+
163
+
164
+ @app.command(name="search-by-image")
165
+ def search_by_image(
166
+ image: Path | None = typer.Option(
167
+ None,
168
+ "--image",
169
+ "-i",
170
+ exists=True,
171
+ dir_okay=False,
172
+ help="Local JPEG, PNG or WebP, at most 5 MiB",
173
+ ),
174
+ image_url: str | None = typer.Option(None, "--image-url", help="Public image URL"),
175
+ image_base64: str | None = typer.Option(
176
+ None, "--image-base64", help="Base64 image or data URL"
177
+ ),
178
+ market: str = MARKET,
179
+ page: int = typer.Option(1, "--page"),
180
+ per_page: int = typer.Option(20, "--per-page"),
181
+ price_from: float | None = typer.Option(None, "--price-from"),
182
+ price_to: float | None = typer.Option(None, "--price-to"),
183
+ brand_ids: str | None = typer.Option(None, "--brand-ids"),
184
+ catalog_ids: str | None = typer.Option(None, "--catalog-ids"),
185
+ color_ids: str | None = typer.Option(None, "--color-ids"),
186
+ size_ids: str | None = typer.Option(None, "--size-ids"),
187
+ material_ids: str | None = typer.Option(None, "--material-ids"),
188
+ status_ids: str | None = typer.Option(None, "--status-ids"),
189
+ time: int | None = typer.Option(None, "--time"),
190
+ search_session_id: str | None = typer.Option(None, "--search-session-id"),
191
+ fmt: str = FMT,
192
+ fields: str | None = FIELDS,
193
+ ) -> None:
194
+ """Find active Vinted listings from a photo. 10 credits per successful search."""
195
+ if sum(bool(x) for x in (image, image_url, image_base64)) != 1:
196
+ raise typer.BadParameter("Pass exactly one of --image, --image-url or --image-base64")
197
+ if image:
198
+ with image.open("rb") as file:
199
+ raw = file.read(5 * 1024 * 1024 + 1)
200
+ if not raw or len(raw) > 5 * 1024 * 1024:
201
+ raise typer.BadParameter("Image must be nonempty and at most 5 MiB")
202
+ image_base64 = base64.b64encode(raw).decode("ascii")
203
+ body = {
204
+ "image_url": image_url,
205
+ "image_base64": image_base64,
206
+ "market": market,
207
+ "page": page,
208
+ "per_page": per_page,
209
+ "price_from": price_from,
210
+ "price_to": price_to,
211
+ "brand_ids": brand_ids,
212
+ "catalog_ids": catalog_ids,
213
+ "color_ids": color_ids,
214
+ "size_ids": size_ids,
215
+ "material_ids": material_ids,
216
+ "status_ids": status_ids,
217
+ "time": time,
218
+ "search_session_id": search_session_id,
219
+ }
220
+ render(
221
+ api_post("/v1/vinted/search_by_image", {k: v for k, v in body.items() if v is not None}),
222
+ fmt,
223
+ fields,
224
+ )
@@ -0,0 +1,36 @@
1
+ from unittest.mock import patch
2
+
3
+ import pytest
4
+
5
+ CliRunner = pytest.importorskip("typer.testing").CliRunner
6
+ app = pytest.importorskip(
7
+ "scrapebadger_cli.commands.tiktok", reason="CLI is tested with its own installed package"
8
+ ).app
9
+
10
+
11
+ @pytest.mark.parametrize(
12
+ "args,path",
13
+ [
14
+ (["followers", "tiktok"], "/users/tiktok/followers"),
15
+ (["following", "tiktok"], "/users/tiktok/following"),
16
+ (["liked", "tiktok"], "/users/tiktok/liked"),
17
+ (["reposts", "tiktok"], "/users/tiktok/reposts"),
18
+ (["replies", "123", "456"], "/comments/123/replies"),
19
+ (["search", "news", "--kind", "users"], "/search/users"),
20
+ ],
21
+ )
22
+ def test_continuations_are_forwarded(args, path):
23
+ with patch("scrapebadger_cli.commands.tiktok.api_get", return_value={}) as call:
24
+ result = CliRunner().invoke(app, args + ["--cursor", "tw1_test"])
25
+ assert result.exit_code == 0, result.output
26
+ assert call.call_args.args[0] == "/v1/tiktok" + path
27
+ assert call.call_args.args[1]["cursor"] == "tw1_test"
28
+ if args[0] == "replies":
29
+ assert call.call_args.args[1]["video_id"] == "456"
30
+
31
+
32
+ def test_trends_do_not_invent_a_historical_window():
33
+ with patch("scrapebadger_cli.commands.tiktok.api_get", return_value={}) as call:
34
+ result = CliRunner().invoke(app, ["trending", "--kind", "songs"])
35
+ assert result.exit_code == 0, result.output
36
+ assert "period" not in call.call_args.args[1]
@@ -0,0 +1,27 @@
1
+ import base64
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ CliRunner = pytest.importorskip("typer.testing").CliRunner
7
+ app = pytest.importorskip("scrapebadger_cli.commands.vinted").app
8
+
9
+
10
+ def test_local_image_is_encoded_and_filters_forwarded(tmp_path):
11
+ image = tmp_path / "shoe.jpg"
12
+ image.write_bytes(b"\xff\xd8\xfftest")
13
+ with patch("scrapebadger_cli.commands.vinted.api_post", return_value={"items": []}) as call:
14
+ result = CliRunner().invoke(
15
+ app, ["search-by-image", "--image", str(image), "--market", "nl", "--brand-ids", "53"]
16
+ )
17
+ assert result.exit_code == 0, result.output
18
+ path, body = call.call_args.args
19
+ assert path == "/v1/vinted/search_by_image"
20
+ assert base64.b64decode(body["image_base64"]) == image.read_bytes()
21
+ assert body["market"] == "nl" and body["brand_ids"] == "53"
22
+ result = CliRunner().invoke(
23
+ app,
24
+ ["search-by-image", "--image", str(image), "--image-url", "https://example.com/a.jpg"],
25
+ )
26
+ assert result.exit_code != 0
27
+ assert call.call_count == 1
@@ -228,7 +228,7 @@ wheels = [
228
228
 
229
229
  [[package]]
230
230
  name = "scrapebadger-cli"
231
- version = "0.14.0"
231
+ version = "0.15.0"
232
232
  source = { editable = "." }
233
233
  dependencies = [
234
234
  { name = "httpx" },