blocket-toolkit 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,6 @@
1
+ """blocket-toolkit: search all of Blocket.se from the terminal."""
2
+
3
+ from .client import BlocketClient, Page
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["BlocketClient", "Page", "__version__"]
blocket_toolkit/cli.py ADDED
@@ -0,0 +1,563 @@
1
+ """Command-line interface for blocket-toolkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ from httpx import HTTPStatusError, RequestError
12
+
13
+ from . import __version__
14
+ from .client import AD_TYPES, BlocketClient, Page
15
+ from .enums import (
16
+ BoatSortOrder,
17
+ BoatType,
18
+ CarColor,
19
+ CarModel,
20
+ CarSortOrder,
21
+ CarTransmission,
22
+ CarWheelDrive,
23
+ Category,
24
+ Location,
25
+ McModel,
26
+ McSortOrder,
27
+ McType,
28
+ SortOrder,
29
+ SubCategory,
30
+ enum_items,
31
+ resolve,
32
+ resolve_many,
33
+ subcategories_of,
34
+ )
35
+ from .format import (
36
+ BOAT_COLUMNS,
37
+ CAR_COLUMNS,
38
+ LISTING_COLUMNS,
39
+ MC_COLUMNS,
40
+ OPTION_COLUMNS,
41
+ render,
42
+ render_json,
43
+ render_pairs,
44
+ )
45
+
46
+ Fetch = Callable[[int], Page]
47
+
48
+
49
+ def _sort_choices(enum_cls: type) -> list[str]:
50
+ return [member.name for member in enum_cls]
51
+
52
+
53
+ def _build_parser() -> argparse.ArgumentParser:
54
+ common = argparse.ArgumentParser(add_help=False)
55
+ common.add_argument(
56
+ "-o",
57
+ "--output",
58
+ choices=("json", "jsonl", "table"),
59
+ default="json",
60
+ help="Output format (default: json)",
61
+ )
62
+ common.add_argument(
63
+ "--raw",
64
+ action="store_true",
65
+ help="Return Blocket's raw API objects instead of compacted records",
66
+ )
67
+
68
+ paging = argparse.ArgumentParser(add_help=False)
69
+ paging.add_argument("--page", type=int, default=1, help="Page number, 1-based (default: 1)")
70
+ paging.add_argument(
71
+ "-n", "--limit", type=int, default=None, help="Maximum number of results to return"
72
+ )
73
+ paging.add_argument(
74
+ "--all", action="store_true", dest="all_pages", help="Fetch all pages (see --max-pages)"
75
+ )
76
+ paging.add_argument(
77
+ "--max-pages", type=int, default=20, help="Safety cap for --all (default: 20)"
78
+ )
79
+
80
+ locations = argparse.ArgumentParser(add_help=False)
81
+ locations.add_argument(
82
+ "-l",
83
+ "--location",
84
+ action="append",
85
+ metavar="REGION",
86
+ help="Region name or id (repeatable or comma-separated), e.g. STOCKHOLM,SKANE",
87
+ )
88
+
89
+ parser = argparse.ArgumentParser(
90
+ prog="blocket-toolkit",
91
+ description="Search all of Blocket.se: general items, cars, boats and motorcycles.",
92
+ )
93
+ parser.add_argument(
94
+ "--version", action="version", version=f"blocket-toolkit {__version__}"
95
+ )
96
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
97
+
98
+ p = sub.add_parser(
99
+ "search",
100
+ parents=[common, paging, locations],
101
+ help="Search general items (torget)",
102
+ description="Search general items on Blocket torget.",
103
+ )
104
+ p.add_argument("query", help="Search term")
105
+ p.add_argument(
106
+ "-c",
107
+ "--category",
108
+ help="Category name or id, e.g. ELEKTRONIK_OCH_VITVAROR or 0.93",
109
+ )
110
+ p.add_argument(
111
+ "--sub-category", help="Subcategory name or id, e.g. DATORER or 1.93.3215"
112
+ )
113
+ p.add_argument(
114
+ "--sort",
115
+ type=str.upper,
116
+ choices=_sort_choices(SortOrder),
117
+ default="RELEVANCE",
118
+ )
119
+ p.add_argument("--price-min", type=int, default=None, help="Minimum price, SEK (client-side)")
120
+ p.add_argument("--price-max", type=int, default=None, help="Maximum price, SEK (client-side)")
121
+ p.add_argument(
122
+ "--newer-than",
123
+ type=float,
124
+ default=None,
125
+ metavar="HOURS",
126
+ help="Only ads newer than N hours",
127
+ )
128
+ p.set_defaults(handler=_cmd_search)
129
+
130
+ p = sub.add_parser(
131
+ "cars",
132
+ parents=[common, paging, locations],
133
+ help="Search used cars",
134
+ description="Search used cars on Blocket mobility.",
135
+ )
136
+ p.add_argument("query", nargs="?", default=None, help="Optional free-text search term")
137
+ p.add_argument("-m", "--model", action="append", metavar="BRAND", help="Car brand, e.g. VOLVO")
138
+ p.add_argument("--color", action="append", metavar="COLOR", help="Exterior colour, e.g. SVART")
139
+ p.add_argument("--transmission", action="append", metavar="GEARBOX", help="AUTOMATIC or MANUAL")
140
+ p.add_argument(
141
+ "--wheel-drive", action="append", metavar="DRIVE", help="FWD, RWD, FOUR or TWO"
142
+ )
143
+ p.add_argument("--price-min", type=int, default=None, help="Minimum price, SEK")
144
+ p.add_argument("--price-max", type=int, default=None, help="Maximum price, SEK")
145
+ p.add_argument(
146
+ "--year-min", type=int, default=None, dest="year_from", help="Earliest model year"
147
+ )
148
+ p.add_argument(
149
+ "--year-max", type=int, default=None, dest="year_to", help="Latest model year"
150
+ )
151
+ p.add_argument(
152
+ "--mileage-min",
153
+ type=int,
154
+ default=None,
155
+ dest="mileage_from",
156
+ help="Minimum milage, km",
157
+ )
158
+ p.add_argument(
159
+ "--mileage-max",
160
+ type=int,
161
+ default=None,
162
+ dest="mileage_to",
163
+ help="Maximum milage, km",
164
+ )
165
+ p.add_argument(
166
+ "--hp-min", type=int, default=None, dest="horsepower_from", help="Minimum horsepower"
167
+ )
168
+ p.add_argument(
169
+ "--hp-max", type=int, default=None, dest="horsepower_to", help="Maximum horsepower"
170
+ )
171
+ p.add_argument(
172
+ "--sort", type=str.upper, choices=_sort_choices(CarSortOrder), default="RELEVANCE"
173
+ )
174
+ p.set_defaults(handler=_cmd_cars)
175
+
176
+ p = sub.add_parser(
177
+ "boats",
178
+ parents=[common, paging, locations],
179
+ help="Search used boats",
180
+ description="Search used boats on Blocket mobility.",
181
+ )
182
+ p.add_argument("query", nargs="?", default=None, help="Optional free-text search term")
183
+ p.add_argument(
184
+ "-t",
185
+ "--type",
186
+ action="append",
187
+ metavar="TYPE",
188
+ help="Boat type, e.g. SEGELBAT_MOTORSEGLARE",
189
+ )
190
+ p.add_argument("--price-min", type=int, default=None, help="Minimum price, SEK")
191
+ p.add_argument("--price-max", type=int, default=None, help="Maximum price, SEK")
192
+ p.add_argument(
193
+ "--length-min", type=int, default=None, dest="length_from", help="Minimum length, feet"
194
+ )
195
+ p.add_argument(
196
+ "--length-max", type=int, default=None, dest="length_to", help="Maximum length, feet"
197
+ )
198
+ p.add_argument(
199
+ "--sort", type=str.upper, choices=_sort_choices(BoatSortOrder), default="RELEVANCE"
200
+ )
201
+ p.set_defaults(handler=_cmd_boats)
202
+
203
+ p = sub.add_parser(
204
+ "mc",
205
+ parents=[common, paging, locations],
206
+ help="Search used motorcycles",
207
+ description="Search used motorcycles on Blocket mobility.",
208
+ )
209
+ p.add_argument("query", nargs="?", default=None, help="Optional free-text search term")
210
+ p.add_argument("-m", "--model", action="append", metavar="BRAND", help="MC brand, e.g. YAMAHA")
211
+ p.add_argument("-t", "--type", action="append", metavar="TYPE", help="MC type, e.g. SPORT")
212
+ p.add_argument("--price-min", type=int, default=None, help="Minimum price, SEK")
213
+ p.add_argument("--price-max", type=int, default=None, help="Maximum price, SEK")
214
+ p.add_argument(
215
+ "--engine-min",
216
+ type=int,
217
+ default=None,
218
+ dest="engine_volume_from",
219
+ help="Minimum engine volume, cc",
220
+ )
221
+ p.add_argument(
222
+ "--engine-max",
223
+ type=int,
224
+ default=None,
225
+ dest="engine_volume_to",
226
+ help="Maximum engine volume, cc",
227
+ )
228
+ p.add_argument(
229
+ "--sort", type=str.upper, choices=_sort_choices(McSortOrder), default="RELEVANCE"
230
+ )
231
+ p.set_defaults(handler=_cmd_mc)
232
+
233
+ p = sub.add_parser(
234
+ "ad",
235
+ parents=[common],
236
+ help="Fetch one listing",
237
+ description="Fetch the full listing for an ad id.",
238
+ )
239
+ p.add_argument("ad_id", type=int, help="The ad id from a search result")
240
+ p.add_argument(
241
+ "--type", choices=AD_TYPES, default="recommerce", help="Listing kind (default: recommerce)"
242
+ )
243
+ p.set_defaults(handler=_cmd_ad)
244
+
245
+ p = sub.add_parser(
246
+ "categories",
247
+ parents=[common],
248
+ help="List categories",
249
+ description="List all general item categories.",
250
+ )
251
+ p.set_defaults(handler=_cmd_categories)
252
+
253
+ p = sub.add_parser(
254
+ "subcategories",
255
+ parents=[common],
256
+ help="List subcategories",
257
+ description="List subcategories, optionally for a single category.",
258
+ )
259
+ p.add_argument("-c", "--category", help="Restrict to one category")
260
+ p.set_defaults(handler=_cmd_subcategories)
261
+
262
+ p = sub.add_parser(
263
+ "locations",
264
+ parents=[common],
265
+ help="List regions",
266
+ description="List all Swedish regions.",
267
+ )
268
+ p.set_defaults(handler=_cmd_locations)
269
+
270
+ p = sub.add_parser(
271
+ "car-options",
272
+ parents=[common],
273
+ help="List car filter options",
274
+ description="List car brands, colours, gearboxes and sort orders.",
275
+ )
276
+ p.set_defaults(handler=_cmd_car_options)
277
+
278
+ p = sub.add_parser(
279
+ "boat-options",
280
+ parents=[common],
281
+ help="List boat filter options",
282
+ description="List boat types and sort orders.",
283
+ )
284
+ p.set_defaults(handler=_cmd_boat_options)
285
+
286
+ p = sub.add_parser(
287
+ "mc-options",
288
+ parents=[common],
289
+ help="List motorcycle filter options",
290
+ description="List MC brands, types and sort orders.",
291
+ )
292
+ p.set_defaults(handler=_cmd_mc_options)
293
+
294
+ return parser
295
+
296
+
297
+ def _gather(fetch: Fetch, args: argparse.Namespace) -> tuple[list[dict], dict[str, Any]]:
298
+ limit = args.limit
299
+ items: list[dict[str, Any]] = []
300
+ last_page: int | None = None
301
+ total: int | None = None
302
+ start = args.page
303
+
304
+ if args.all_pages:
305
+ page_no = start
306
+ for _ in range(args.max_pages):
307
+ page = fetch(page_no)
308
+ items.extend(page.items)
309
+ last_page = page.last_page
310
+ total = page.total if total is None else total
311
+ if not page.items:
312
+ break
313
+ if limit is not None and len(items) >= limit:
314
+ break
315
+ if page.last_page is not None and page_no >= page.last_page:
316
+ break
317
+ page_no += 1
318
+ else:
319
+ page = fetch(start)
320
+ items = list(page.items)
321
+ last_page = page.last_page
322
+ total = page.total
323
+
324
+ if limit is not None:
325
+ items = items[:limit]
326
+
327
+ meta = {"page": start, "last_page": last_page, "total": total, "count": len(items)}
328
+ return items, meta
329
+
330
+
331
+ def _price_of(item: dict[str, Any]) -> int | None:
332
+ price = item.get("price")
333
+ if isinstance(price, dict):
334
+ price = price.get("amount")
335
+ if isinstance(price, (int, float)):
336
+ return int(price)
337
+ return None
338
+
339
+
340
+ def _filter_local(
341
+ items: list[dict[str, Any]],
342
+ *,
343
+ price_min: int | None,
344
+ price_max: int | None,
345
+ newer_than: float | None,
346
+ ) -> list[dict[str, Any]]:
347
+ cutoff = (time.time() - newer_than * 3600) * 1000 if newer_than else None
348
+ out = []
349
+ for item in items:
350
+ if price_min is not None or price_max is not None:
351
+ price = _price_of(item)
352
+ if price is None:
353
+ continue
354
+ if price_min is not None and price < price_min:
355
+ continue
356
+ if price_max is not None and price > price_max:
357
+ continue
358
+ if cutoff is not None:
359
+ timestamp = item.get("timestamp")
360
+ if not isinstance(timestamp, (int, float)) or timestamp < cutoff:
361
+ continue
362
+ out.append(item)
363
+ return out
364
+
365
+
366
+ def _print(items: list[dict], args: argparse.Namespace, meta: dict[str, Any], columns) -> None:
367
+ if args.output == "json":
368
+ print(render_json({**meta, "items": items}))
369
+ else:
370
+ print(render(items, args.output, columns))
371
+
372
+
373
+ def _print_options(items: Any, args: argparse.Namespace, columns=OPTION_COLUMNS) -> None:
374
+ print(render(items, args.output, columns))
375
+
376
+
377
+ def _cmd_search(args: argparse.Namespace) -> None:
378
+ if args.category and args.sub_category:
379
+ raise ValueError("Cannot combine --category and --sub-category")
380
+
381
+ client = BlocketClient()
382
+ locations = resolve_many(Location, args.location)
383
+ sort_order = resolve(SortOrder, args.sort)
384
+ category = resolve(Category, args.category) if args.category else None
385
+ sub_category = resolve(SubCategory, args.sub_category) if args.sub_category else None
386
+ compact = not args.raw
387
+
388
+ def fetch(page: int) -> Page:
389
+ return client.search(
390
+ args.query,
391
+ page=page,
392
+ sort_order=sort_order,
393
+ locations=locations,
394
+ category=category,
395
+ sub_category=sub_category,
396
+ compact=compact,
397
+ )
398
+
399
+ items, meta = _gather(fetch, args)
400
+ items = _filter_local(
401
+ items, price_min=args.price_min, price_max=args.price_max, newer_than=args.newer_than
402
+ )
403
+ meta["count"] = len(items)
404
+ _print(items, args, meta, LISTING_COLUMNS)
405
+
406
+
407
+ def _cmd_cars(args: argparse.Namespace) -> None:
408
+ client = BlocketClient()
409
+ fetch_ = lambda page: client.search_cars( # noqa: E731
410
+ args.query,
411
+ page=page,
412
+ sort_order=resolve(CarSortOrder, args.sort),
413
+ locations=resolve_many(Location, args.location),
414
+ models=resolve_many(CarModel, args.model),
415
+ price_from=args.price_min,
416
+ price_to=args.price_max,
417
+ year_from=args.year_from,
418
+ year_to=args.year_to,
419
+ mileage_from=args.mileage_from,
420
+ mileage_to=args.mileage_to,
421
+ horsepower_from=args.horsepower_from,
422
+ horsepower_to=args.horsepower_to,
423
+ colors=resolve_many(CarColor, args.color),
424
+ transmissions=resolve_many(CarTransmission, args.transmission),
425
+ wheel_drive=resolve_many(CarWheelDrive, args.wheel_drive),
426
+ compact=not args.raw,
427
+ )
428
+ items, meta = _gather(fetch_, args)
429
+ _print(items, args, meta, CAR_COLUMNS)
430
+
431
+
432
+ def _cmd_boats(args: argparse.Namespace) -> None:
433
+ client = BlocketClient()
434
+ fetch_: Fetch = lambda page: client.search_boats( # noqa: E731
435
+ args.query,
436
+ page=page,
437
+ sort_order=resolve(BoatSortOrder, args.sort),
438
+ locations=resolve_many(Location, args.location),
439
+ types=resolve_many(BoatType, args.type),
440
+ price_from=args.price_min,
441
+ price_to=args.price_max,
442
+ length_from=args.length_from,
443
+ length_to=args.length_to,
444
+ compact=not args.raw,
445
+ )
446
+ items, meta = _gather(fetch_, args)
447
+ _print(items, args, meta, BOAT_COLUMNS)
448
+
449
+
450
+ def _cmd_mc(args: argparse.Namespace) -> None:
451
+ client = BlocketClient()
452
+ fetch_: Fetch = lambda page: client.search_mc( # noqa: E731
453
+ args.query,
454
+ page=page,
455
+ sort_order=resolve(McSortOrder, args.sort),
456
+ locations=resolve_many(Location, args.location),
457
+ models=resolve_many(McModel, args.model),
458
+ types=resolve_many(McType, args.type),
459
+ price_from=args.price_min,
460
+ price_to=args.price_max,
461
+ engine_volume_from=args.engine_volume_from,
462
+ engine_volume_to=args.engine_volume_to,
463
+ compact=not args.raw,
464
+ )
465
+ items, meta = _gather(fetch_, args)
466
+ _print(items, args, meta, MC_COLUMNS)
467
+
468
+
469
+ def _cmd_ad(args: argparse.Namespace) -> None:
470
+ client = BlocketClient()
471
+ details = client.get_ad(args.ad_id, ad_type=args.type, raw=args.raw)
472
+ if args.output == "jsonl":
473
+ print(render([details], "jsonl"))
474
+ elif args.output == "table":
475
+ print(render_pairs(details))
476
+ else:
477
+ print(render_json(details))
478
+
479
+
480
+ def _cmd_categories(args: argparse.Namespace) -> None:
481
+ _print_options(enum_items(Category), args)
482
+
483
+
484
+ def _cmd_subcategories(args: argparse.Namespace) -> None:
485
+ items: list[dict[str, Any]] = []
486
+ if args.category:
487
+ category = resolve(Category, args.category)
488
+ items = [
489
+ {"category": category.name, "name": sub.name, "id": str(sub.value)}
490
+ for sub in subcategories_of(category)
491
+ ]
492
+ else:
493
+ for category in Category:
494
+ items.extend(
495
+ {"category": category.name, "name": sub.name, "id": str(sub.value)}
496
+ for sub in subcategories_of(category)
497
+ )
498
+ items.sort(key=lambda item: (item["category"], item["name"]))
499
+ _print_options(items, args, ("category", "name", "id"))
500
+
501
+
502
+ def _cmd_locations(args: argparse.Namespace) -> None:
503
+ _print_options(enum_items(Location), args)
504
+
505
+
506
+ def _cmd_car_options(args: argparse.Namespace) -> None:
507
+ _print_options(
508
+ {
509
+ "models": enum_items(CarModel),
510
+ "colors": enum_items(CarColor),
511
+ "transmissions": enum_items(CarTransmission),
512
+ "wheel_drives": enum_items(CarWheelDrive),
513
+ "sort_orders": enum_items(CarSortOrder),
514
+ },
515
+ args,
516
+ )
517
+
518
+
519
+ def _cmd_boat_options(args: argparse.Namespace) -> None:
520
+ _print_options(
521
+ {"types": enum_items(BoatType), "sort_orders": enum_items(BoatSortOrder)}, args
522
+ )
523
+
524
+
525
+ def _cmd_mc_options(args: argparse.Namespace) -> None:
526
+ _print_options(
527
+ {
528
+ "models": enum_items(McModel),
529
+ "types": enum_items(McType),
530
+ "sort_orders": enum_items(McSortOrder),
531
+ },
532
+ args,
533
+ )
534
+
535
+
536
+ def main(argv: list[str] | None = None) -> int:
537
+ parser = _build_parser()
538
+ args = parser.parse_args(argv)
539
+
540
+ try:
541
+ args.handler(args)
542
+ except ValueError as exc:
543
+ print(f"error: {exc}", file=sys.stderr)
544
+ return 2
545
+ except HTTPStatusError as exc:
546
+ print(
547
+ f"error: Blocket returned HTTP {exc.response.status_code} for {exc.request.url}",
548
+ file=sys.stderr,
549
+ )
550
+ return 1
551
+ except RequestError as exc:
552
+ print(f"error: network problem talking to Blocket: {exc}", file=sys.stderr)
553
+ return 1
554
+ except KeyboardInterrupt:
555
+ return 130
556
+ except Exception as exc: # noqa: BLE001
557
+ print(f"error: {exc}", file=sys.stderr)
558
+ return 1
559
+ return 0
560
+
561
+
562
+ if __name__ == "__main__":
563
+ sys.exit(main())
@@ -0,0 +1,366 @@
1
+ """Thin wrapper around ``blocket_api`` returning compact, JSON-friendly records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from blocket_api import BlocketAPI, BoatAd, CarAd, McAd, RecommerceAd
11
+
12
+ from .enums import (
13
+ BoatSortOrder,
14
+ BoatType,
15
+ CarColor,
16
+ CarModel,
17
+ CarSortOrder,
18
+ CarTransmission,
19
+ CarWheelDrive,
20
+ Category,
21
+ Location,
22
+ McModel,
23
+ McSortOrder,
24
+ McType,
25
+ SortOrder,
26
+ SubCategory,
27
+ )
28
+
29
+ AD_CLASSES: dict[str, type] = {
30
+ "recommerce": RecommerceAd,
31
+ "car": CarAd,
32
+ "boat": BoatAd,
33
+ "mc": McAd,
34
+ }
35
+
36
+ AD_TYPES: tuple[str, ...] = tuple(AD_CLASSES)
37
+
38
+ _LISTING_KEYS = (
39
+ "id",
40
+ "ad_id",
41
+ "heading",
42
+ "subheading",
43
+ "location",
44
+ "trade_type",
45
+ "flags",
46
+ "labels",
47
+ "dealer_segment",
48
+ "seller_type",
49
+ "org_id",
50
+ )
51
+
52
+ _VEHICLE_KEYS = (
53
+ "make",
54
+ "model",
55
+ "model_specification",
56
+ "series",
57
+ "registration_class",
58
+ "vehicle_type",
59
+ "regno",
60
+ "year",
61
+ "model_year",
62
+ "mileage",
63
+ "milage",
64
+ "mileage_unit",
65
+ "transmission",
66
+ "fuel",
67
+ "horsepower",
68
+ "engine_volume",
69
+ "volume",
70
+ "length",
71
+ "width",
72
+ "length_feet",
73
+ "motor_type",
74
+ "motor_fuel",
75
+ "motor_size",
76
+ "class",
77
+ "engine_type",
78
+ "seats",
79
+ "old_price",
80
+ )
81
+
82
+ _PRICE_UNITS_TO_DROP = {"TOTAL", "kr", "KR", "SEK"}
83
+
84
+
85
+ @dataclass
86
+ class Page:
87
+ """One page of search results."""
88
+
89
+ items: list[dict[str, Any]]
90
+ page: int = 1
91
+ last_page: int | None = None
92
+ total: int | None = None
93
+
94
+ def as_dict(self) -> dict[str, Any]:
95
+ return {
96
+ "page": self.page,
97
+ "last_page": self.last_page,
98
+ "total": self.total,
99
+ "count": len(self.items),
100
+ "items": self.items,
101
+ }
102
+
103
+
104
+ def _iso(timestamp_ms: float) -> str:
105
+ return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC).isoformat()
106
+
107
+
108
+ def compact_listing(doc: dict[str, Any]) -> dict[str, Any]:
109
+ """Reduce a raw Blocket search hit to a flat, predictable record."""
110
+ out: dict[str, Any] = {}
111
+
112
+ for key in _LISTING_KEYS + _VEHICLE_KEYS:
113
+ value = doc.get(key)
114
+ if value is not None:
115
+ out[key] = value
116
+
117
+ if isinstance(out.get("id"), str) and out["id"].isdigit():
118
+ out["id"] = int(out["id"])
119
+
120
+ url = doc.get("canonical_url") or doc.get("url")
121
+ if not url and doc.get("id") is not None:
122
+ url = f"https://www.blocket.se/recommerce/forsale/item/{doc['id']}"
123
+ if url:
124
+ out["url"] = url
125
+
126
+ image = doc.get("image")
127
+ if isinstance(image, dict) and image.get("url"):
128
+ out["image_url"] = image["url"]
129
+ elif doc.get("image_url"):
130
+ out["image_url"] = doc["image_url"]
131
+ elif doc.get("image_urls"):
132
+ out["image_urls"] = doc["image_urls"]
133
+
134
+ price = doc.get("price")
135
+ if isinstance(price, dict):
136
+ out["price"] = price.get("amount")
137
+ unit = price.get("price_unit")
138
+ if unit and unit not in _PRICE_UNITS_TO_DROP:
139
+ out["price_unit"] = unit
140
+ elif isinstance(price, (int, float)):
141
+ out["price"] = int(price)
142
+
143
+ timestamp = doc.get("timestamp")
144
+ if isinstance(timestamp, (int, float)):
145
+ out["timestamp"] = timestamp
146
+ out["published_at"] = _iso(timestamp)
147
+
148
+ return out
149
+
150
+
151
+ def _slim_recommerce(payload: dict[str, Any]) -> dict[str, Any]:
152
+ section = (payload.get("loaderData") or {}).get("item-recommerce") or {}
153
+ item = section.get("itemData")
154
+ if not isinstance(item, dict):
155
+ return payload
156
+
157
+ out = dict(item)
158
+ images = item.get("images")
159
+ if isinstance(images, list):
160
+ urls = [
161
+ image.get("url") or image.get("uri")
162
+ for image in images
163
+ if isinstance(image, dict) and (image.get("url") or image.get("uri"))
164
+ ]
165
+ if urls:
166
+ out["image_urls"] = urls
167
+ if section.get("jsonLd"):
168
+ out["jsonLd"] = section["jsonLd"]
169
+ if section.get("meta"):
170
+ out["meta"] = section["meta"]
171
+ return out
172
+
173
+
174
+ def _paging(raw: dict[str, Any], page: int) -> tuple[int, int | None, int | None]:
175
+ meta = raw.get("metadata") or {}
176
+ paging = meta.get("paging") or {}
177
+
178
+ current = paging.get("current") or paging.get("page") or page
179
+ last = paging.get("last") or paging.get("last_page") or paging.get("total_pages")
180
+
181
+ tracking = meta.get("tracking") or {}
182
+ tracking_object = tracking.get("object") or {}
183
+ total = (
184
+ (meta.get("result_size") or {}).get("match_count")
185
+ or tracking_object.get("numItems")
186
+ or meta.get("total")
187
+ or meta.get("total_count")
188
+ )
189
+
190
+ def as_int(value: Any) -> int | None:
191
+ try:
192
+ return int(value)
193
+ except (TypeError, ValueError):
194
+ return None
195
+
196
+ return as_int(current) or page, as_int(last), as_int(total)
197
+
198
+
199
+ class BlocketClient:
200
+ """Search Blocket.se for items, cars, boats and motorcycles."""
201
+
202
+ def __init__(self, api: BlocketAPI | None = None) -> None:
203
+ self._api = api if api is not None else BlocketAPI()
204
+
205
+ def _page(self, raw: dict[str, Any], page: int, compact: bool) -> Page:
206
+ docs = raw.get("docs") or []
207
+ items = [compact_listing(doc) for doc in docs] if compact else docs
208
+ current, last, total = _paging(raw, page)
209
+ return Page(items=items, page=current, last_page=last, total=total)
210
+
211
+ def search(
212
+ self,
213
+ query: str,
214
+ *,
215
+ page: int = 1,
216
+ sort_order: SortOrder = SortOrder.RELEVANCE,
217
+ locations: list[Location] | None = None,
218
+ category: Category | None = None,
219
+ sub_category: SubCategory | None = None,
220
+ compact: bool = True,
221
+ ) -> Page:
222
+ """Search general items (torget)."""
223
+ raw = self._api.search(
224
+ query,
225
+ page=page,
226
+ sort_order=sort_order,
227
+ locations=list(locations or []),
228
+ category=category,
229
+ sub_category=sub_category,
230
+ )
231
+ return self._page(raw, page, compact)
232
+
233
+ def search_cars(
234
+ self,
235
+ query: str | None = None,
236
+ *,
237
+ page: int = 1,
238
+ sort_order: CarSortOrder = CarSortOrder.RELEVANCE,
239
+ locations: list[Location] | None = None,
240
+ models: list[CarModel] | None = None,
241
+ price_from: int | None = None,
242
+ price_to: int | None = None,
243
+ year_from: int | None = None,
244
+ year_to: int | None = None,
245
+ mileage_from: int | None = None,
246
+ mileage_to: int | None = None,
247
+ horsepower_from: int | None = None,
248
+ horsepower_to: int | None = None,
249
+ colors: list[CarColor] | None = None,
250
+ transmissions: list[CarTransmission] | None = None,
251
+ wheel_drive: list[CarWheelDrive] | None = None,
252
+ compact: bool = True,
253
+ ) -> Page:
254
+ """Search used cars."""
255
+ raw = self._api.search_car(
256
+ query or None,
257
+ page=page,
258
+ sort_order=sort_order,
259
+ locations=list(locations or []),
260
+ models=list(models or []),
261
+ price_from=price_from,
262
+ price_to=price_to,
263
+ year_from=year_from,
264
+ year_to=year_to,
265
+ milage_from=mileage_from,
266
+ milage_to=mileage_to,
267
+ horsepower_from=horsepower_from,
268
+ horsepower_to=horsepower_to,
269
+ colors=list(colors or []),
270
+ transmissions=list(transmissions or []),
271
+ wheel_drive=list(wheel_drive or []),
272
+ )
273
+ return self._page(raw, page, compact)
274
+
275
+ def search_boats(
276
+ self,
277
+ query: str | None = None,
278
+ *,
279
+ page: int = 1,
280
+ sort_order: BoatSortOrder = BoatSortOrder.RELEVANCE,
281
+ locations: list[Location] | None = None,
282
+ types: list[BoatType] | None = None,
283
+ price_from: int | None = None,
284
+ price_to: int | None = None,
285
+ length_from: int | None = None,
286
+ length_to: int | None = None,
287
+ compact: bool = True,
288
+ ) -> Page:
289
+ """Search used boats."""
290
+ raw = self._api.search_boat(
291
+ query or None,
292
+ page=page,
293
+ sort_order=sort_order,
294
+ types=list(types or []),
295
+ locations=list(locations or []),
296
+ price_from=price_from,
297
+ price_to=price_to,
298
+ length_from=length_from,
299
+ length_to=length_to,
300
+ )
301
+ return self._page(raw, page, compact)
302
+
303
+ def search_mc(
304
+ self,
305
+ query: str | None = None,
306
+ *,
307
+ page: int = 1,
308
+ sort_order: McSortOrder = McSortOrder.RELEVANCE,
309
+ locations: list[Location] | None = None,
310
+ models: list[McModel] | None = None,
311
+ types: list[McType] | None = None,
312
+ price_from: int | None = None,
313
+ price_to: int | None = None,
314
+ engine_volume_from: int | None = None,
315
+ engine_volume_to: int | None = None,
316
+ compact: bool = True,
317
+ ) -> Page:
318
+ """Search used motorcycles."""
319
+ raw = self._api.search_mc(
320
+ query or None,
321
+ page=page,
322
+ sort_order=sort_order,
323
+ models=list(models or []),
324
+ types=list(types or []),
325
+ locations=list(locations or []),
326
+ price_from=price_from,
327
+ price_to=price_to,
328
+ engine_volume_from=engine_volume_from,
329
+ engine_volume_to=engine_volume_to,
330
+ )
331
+ return self._page(raw, page, compact)
332
+
333
+ def get_ad(
334
+ self, ad_id: int, ad_type: str = "recommerce", raw: bool = False
335
+ ) -> dict[str, Any]:
336
+ """Fetch the full listing for a single ad.
337
+
338
+ For ``recommerce`` listings the React-router hydration blob is reduced to
339
+ the useful ``itemData`` payload unless ``raw`` is set.
340
+ """
341
+ ad_class = AD_CLASSES.get(ad_type)
342
+ if ad_class is None:
343
+ raise ValueError(f"Unknown ad type: {ad_type!r}. Valid: {', '.join(AD_TYPES)}")
344
+
345
+ payload = self._api.get_ad(ad_class(int(ad_id)))
346
+ if raw or ad_type != "recommerce":
347
+ return payload
348
+ return _slim_recommerce(payload)
349
+
350
+ def paginate(
351
+ self,
352
+ fetch: Callable[[int], Page],
353
+ *,
354
+ start_page: int = 1,
355
+ max_pages: int = 20,
356
+ ) -> Iterator[Page]:
357
+ """Yield consecutive pages from a ``fetch(page_number) -> Page`` callable."""
358
+ page_no = start_page
359
+ for _ in range(max_pages):
360
+ page = fetch(page_no)
361
+ yield page
362
+ if not page.items:
363
+ return
364
+ if page.last_page is not None and page_no >= page.last_page:
365
+ return
366
+ page_no += 1
@@ -0,0 +1,114 @@
1
+ """Name and value resolution for the blocket_api enumerations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import TypeVar
7
+
8
+ from blocket_api import (
9
+ BoatSortOrder,
10
+ BoatType,
11
+ CarColor,
12
+ CarModel,
13
+ CarSortOrder,
14
+ CarTransmission,
15
+ CarWheelDrive,
16
+ Category,
17
+ Location,
18
+ McModel,
19
+ McSortOrder,
20
+ McType,
21
+ SortOrder,
22
+ SubCategory,
23
+ )
24
+
25
+ E = TypeVar("E", bound=Enum)
26
+
27
+ __all__ = [
28
+ "BoatSortOrder",
29
+ "BoatType",
30
+ "CarColor",
31
+ "CarModel",
32
+ "CarSortOrder",
33
+ "CarTransmission",
34
+ "CarWheelDrive",
35
+ "Category",
36
+ "Location",
37
+ "McModel",
38
+ "McSortOrder",
39
+ "McType",
40
+ "SortOrder",
41
+ "SubCategory",
42
+ "enum_items",
43
+ "resolve",
44
+ "resolve_many",
45
+ "subcategories_of",
46
+ ]
47
+
48
+
49
+ def _slug(token: str) -> str:
50
+ return token.strip().upper().replace(" ", "_").replace("-", "_")
51
+
52
+
53
+ def resolve(enum_cls: type[E], token: str) -> E:
54
+ """Resolve a user token to an enum member.
55
+
56
+ Accepts the member name case-insensitively (spaces and dashes are treated as
57
+ underscores) or the raw Blocket value, e.g. ``"elektronik_och_vitvaror"``,
58
+ ``"Elektronik och vitvaror"`` or ``"0.93"``.
59
+ """
60
+ if not isinstance(token, str) or not token.strip():
61
+ raise ValueError(f"Empty value for {enum_cls.__name__}")
62
+
63
+ key = _slug(token)
64
+ if key in enum_cls.__members__:
65
+ return enum_cls[key]
66
+
67
+ raw = token.strip()
68
+ for member in enum_cls:
69
+ if str(member.value) == raw:
70
+ return member
71
+
72
+ names = ", ".join(_names(enum_cls))
73
+ raise ValueError(
74
+ f"Unknown {enum_cls.__name__}: {token!r}. Valid names or ids: {names}"
75
+ )
76
+
77
+
78
+ def resolve_many(enum_cls: type[E], tokens: list[str] | None) -> list[E]:
79
+ """Resolve a list of tokens, flattening comma-separated values."""
80
+ if not tokens:
81
+ return []
82
+ out: list[E] = []
83
+ for token in tokens:
84
+ for part in str(token).split(","):
85
+ part = part.strip()
86
+ if part:
87
+ out.append(resolve(enum_cls, part))
88
+ return out
89
+
90
+
91
+ def _names(enum_cls: type[Enum]) -> list[str]:
92
+ return [m.name for m in enum_cls]
93
+
94
+
95
+ def enum_items(enum_cls: type[E]) -> list[dict[str, object]]:
96
+ """Return an enum as a sorted list of ``{"name", "id"}`` dicts."""
97
+ return sorted(
98
+ ({"name": m.name, "id": str(m.value)} for m in enum_cls),
99
+ key=lambda item: str(item["name"]),
100
+ )
101
+
102
+
103
+ def subcategories_of(category: Category) -> list[SubCategory]:
104
+ """Return the subcategories that belong to a category.
105
+
106
+ Blocket encodes the hierarchy in the ids: a category ``0.93`` has
107
+ subcategories ``1.93.<n>``.
108
+ """
109
+ prefix = f"0.{str(category.value).split('.')[-1]}"
110
+ return [
111
+ sub
112
+ for sub in SubCategory
113
+ if f"0.{str(sub.value).split('.')[1]}" == prefix
114
+ ]
@@ -0,0 +1,96 @@
1
+ """Output rendering: JSON, JSON-lines and plain text tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ LISTING_COLUMNS = ("id", "price", "heading", "location", "url")
9
+ CAR_COLUMNS = ("id", "price", "heading", "year", "mileage", "location", "url")
10
+ BOAT_COLUMNS = ("id", "price", "heading", "year", "length", "location", "url")
11
+ MC_COLUMNS = ("id", "price", "heading", "year", "volume", "location", "url")
12
+ OPTION_COLUMNS = ("name", "id")
13
+
14
+ MAX_CELL = 60
15
+
16
+
17
+ def render_json(data: Any) -> str:
18
+ return json.dumps(data, indent=2, ensure_ascii=False, default=str)
19
+
20
+
21
+ def render_jsonl(items: list[Any]) -> str:
22
+ return "\n".join(
23
+ json.dumps(item, ensure_ascii=False, default=str) for item in items
24
+ )
25
+
26
+
27
+ def _cell(value: Any) -> str:
28
+ if value is None or value == "":
29
+ return "-"
30
+ if isinstance(value, (dict, list, tuple)):
31
+ value = json.dumps(value, ensure_ascii=False, default=str)
32
+ text = str(value).replace("\n", " ").strip()
33
+ if len(text) > MAX_CELL:
34
+ text = text[: MAX_CELL - 1] + "…"
35
+ return text
36
+
37
+
38
+ def render_table(rows: list[dict[str, Any]], columns: tuple[str, ...]) -> str:
39
+ if not rows:
40
+ return "(no results)"
41
+
42
+ header = [column.upper() for column in columns]
43
+ cells = [[_cell(row.get(column)) for column in columns] for row in rows]
44
+
45
+ widths = [
46
+ max(len(header[i]), *(len(row[i]) for row in cells)) for i in range(len(columns))
47
+ ]
48
+
49
+ lines = [" ".join(header[i].ljust(widths[i]) for i in range(len(columns)))]
50
+ lines.append(" ".join("-" * widths[i] for i in range(len(columns))))
51
+ for row in cells:
52
+ lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(columns))))
53
+ return "\n".join(lines)
54
+
55
+
56
+ def render_pairs(data: dict[str, Any]) -> str:
57
+ lines: list[str] = []
58
+ for key, value in data.items():
59
+ if isinstance(value, (dict, list, tuple)):
60
+ rendered = render_json(value)
61
+ elif value is None or value == "":
62
+ rendered = "-"
63
+ else:
64
+ rendered = str(value)
65
+ lines.append(f"{key}: {rendered}")
66
+ return "\n".join(lines)
67
+
68
+
69
+ def render(items: Any, output: str, columns: tuple[str, ...] = LISTING_COLUMNS) -> str:
70
+ """Render ``items`` in the requested format.
71
+
72
+ ``items`` may be a ``Page`` (or its ``as_dict``), a list of records, or a
73
+ plain dict.
74
+ """
75
+ if output == "jsonl":
76
+ rows = items["items"] if isinstance(items, dict) and "items" in items else items
77
+ if isinstance(rows, dict):
78
+ rows = [rows]
79
+ return render_jsonl(rows)
80
+ if output == "table":
81
+ if isinstance(items, dict) and "items" in items:
82
+ return render_table(items["items"], columns)
83
+ if isinstance(items, list):
84
+ return render_table(items, columns)
85
+ if isinstance(items, dict):
86
+ sections = [
87
+ f"# {key}\n" + render_table(value, tuple(value[0].keys()))
88
+ for key, value in items.items()
89
+ if isinstance(value, list) and value and isinstance(value[0], dict)
90
+ ]
91
+ if sections:
92
+ return "\n\n".join(sections)
93
+ if all(not isinstance(v, (dict, list)) for v in items.values()):
94
+ return render_pairs(items)
95
+ return render_table([items], columns)
96
+ return render_json(items)
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.5
2
+ Name: blocket-toolkit
3
+ Version: 0.1.0
4
+ Summary: Search all of Blocket.se from the terminal: general items, cars, boats and motorcycles.
5
+ Project-URL: Homepage, https://github.com/dojje/blocket-toolkit
6
+ Project-URL: Repository, https://github.com/dojje/blocket-toolkit
7
+ Project-URL: Issues, https://github.com/dojje/blocket-toolkit/issues
8
+ Author: Daniel Hidefjäll
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: blocket,boats,cars,cli,marketplace,second-hand,sweden
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: blocket-api>=0.5.2
24
+ Requires-Dist: httpx>=0.28.1
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.6; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # blocket-toolkit
31
+
32
+ Search **all of Blocket.se** from the terminal: general items (*torget*), cars, boats
33
+ and motorcycles, with clean JSON output that is easy to pipe into other tools or hand
34
+ to an LLM.
35
+
36
+ Built on top of the [`blocket-api`](https://pypi.org/project/blocket_api/) Python
37
+ package.
38
+
39
+ ## Features
40
+
41
+ - **Everything on Blocket**: torget, cars, boats, motorcycles and full ad details.
42
+ - **Full filter surface**: category + subcategory, all 21 regions, price, year, milage,
43
+ horsepower, colour, gearbox, wheel drive, length, engine volume, brand/model.
44
+ - **AI-friendly output**: compacted, flat JSON by default; `jsonl` and text `table` when
45
+ you want them.
46
+ - **Self-documenting enums**: every category, subcategory, region, brand and model can
47
+ be listed or resolved by name *or* by Blocket's internal id.
48
+ - **Pagination**: `--page`, `-n/--limit`, or `--all`.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ uv tool install blocket-toolkit # or: pip install blocket-toolkit
54
+ ```
55
+
56
+ From a checkout:
57
+
58
+ ```bash
59
+ git clone https://github.com/dojje/blocket-toolkit
60
+ cd blocket-toolkit
61
+ uv venv && uv pip install -e '.[dev]'
62
+ ```
63
+
64
+ ## Quick start
65
+
66
+ ```bash
67
+ blocket-toolkit search "kindle" --price-max 800 --sort price_asc
68
+
69
+ blocket-toolkit search "grafikkort" -c elektronik_och_vitvaror --sub-category datorer -n 5
70
+
71
+ blocket-toolkit cars --model volvo --year-min 2018 --mileage-max 12000 --sort mileage_asc
72
+
73
+ blocket-toolkit boats --type segelbat_motorseglare --length-min 30
74
+
75
+ blocket-toolkit mc --model yamaha --engine-min 600 --price-max 80000
76
+
77
+ blocket-toolkit ad 20851738 --type recommerce
78
+ ```
79
+
80
+ Every command accepts `-o json|jsonl|table` (default `json`).
81
+
82
+ ## Commands
83
+
84
+ | Command | What it does |
85
+ |---|---|
86
+ | `search <query>` | General items on torget |
87
+ | `cars [query]` | Used cars |
88
+ | `boats [query]` | Used boats |
89
+ | `mc [query]` | Used motorcycles |
90
+ | `ad <ad_id> --type ...` | Full details for one listing |
91
+ | `categories` | All 11 top-level categories |
92
+ | `subcategories [-c CATEGORY]` | All subcategories, optionally for one category |
93
+ | `locations` | All 21 Swedish regions |
94
+ | `car-options` | Car brands, colours, gearboxes, wheel drives, sort orders |
95
+ | `boat-options` | Boat types and sort orders |
96
+ | `mc-options` | MC brands, types and sort orders |
97
+
98
+ Run `blocket-toolkit <command> --help` for the full filter list.
99
+
100
+ ### Common flags
101
+
102
+ | Flag | Applies to | Meaning |
103
+ |---|---|---|
104
+ | `-c, --category` | `search` | Category name or id (e.g. `elektronik_och_vitvaror` or `0.93`) |
105
+ | `--sub-category` | `search` | Subcategory name or id (mutually exclusive with `--category`) |
106
+ | `-l, --location` | all searches | Region(s), repeatable or comma-separated (`-l stockholm,skane`) |
107
+ | `--sort` | all searches | Sort order; valid values depend on the command |
108
+ | `--price-min/--price-max` | `search`, `cars`, `boats`, `mc` | Price range in SEK |
109
+ | `--newer-than HOURS` | `search` | Only ads published within the last N hours |
110
+ | `--page`, `-n/--limit`, `--all` | all searches | Pagination |
111
+ | `-o, --output` | everything | `json` (default), `jsonl`, `table` |
112
+ | `--raw` | searches | Return Blocket's raw objects instead of compacted records |
113
+
114
+ > `--price-min/--price-max` and `--newer-than` are applied **client-side** for torget,
115
+ > because Blocket's general search endpoint has no server-side price filter. For cars,
116
+ > boats and motorcycles the price range is sent to the API.
117
+
118
+ ## Output shape
119
+
120
+ Search commands return:
121
+
122
+ ```json
123
+ {
124
+ "page": 1,
125
+ "last_page": 20,
126
+ "total": 1187,
127
+ "count": 40,
128
+ "items": [
129
+ {
130
+ "id": 20851738,
131
+ "heading": "Kindle Paperwhite 11th gen",
132
+ "price": 750,
133
+ "location": "Stockholm",
134
+ "url": "https://www.blocket.se/annons/20851738",
135
+ "image_url": "https://...",
136
+ "timestamp": 1700000000000,
137
+ "published_at": "2023-11-14T22:13:20+00:00"
138
+ }
139
+ ]
140
+ }
141
+ ```
142
+
143
+ `ad` returns the full payload for a single listing.
144
+
145
+ ## Finding valid filter values
146
+
147
+ ```bash
148
+ blocket-toolkit categories -o table
149
+ blocket-toolkit subcategories -c elektronik_och_vitvaror -o table
150
+ blocket-toolkit locations -o table
151
+ blocket-toolkit car-options -o table
152
+ ```
153
+
154
+ Values are resolved case-insensitively and accept spaces or dashes, so
155
+ `ELEKTRONIK_OCH_VITVAROR`, `elektronik och vitvaror` and `0.93` are all valid.
156
+
157
+ ## Development
158
+
159
+ ```bash
160
+ uv venv && uv pip install -e '.[dev]'
161
+ pytest # unit tests (no network)
162
+ pytest -m live # smoke tests against the real Blocket API
163
+ ruff check .
164
+ ```
165
+
166
+ ## Notes and limitations
167
+
168
+ - Blocket has no official public API; this uses the same internal endpoints as the
169
+ website. Be gentle: don't hammer it with `--all --max-pages 200`.
170
+ - Torget's search endpoint does not accept a result count, so `-n/--limit` and
171
+ `--price-*` are applied after fetching.
172
+ - `ad` for `car`/`boat`/`mc` scrapes the mobility page and returns somewhat less
173
+ structured data than `recommerce`.
174
+
175
+ ## Credits
176
+
177
+ - [`blocket-api`](https://github.com/dunderrrrrr/blocket_api) by dunderrrrrr, the
178
+ underlying API wrapper.
179
+ - Blocket.se, obviously, for the data.
180
+
181
+ ## License
182
+
183
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ blocket_toolkit/__init__.py,sha256=tJDhnno_lGNc7B2boY9VAi3Hcl7FJd8WZqtIDKfDCR4,182
2
+ blocket_toolkit/cli.py,sha256=-RRgUvOFWW2V5L8reMqzqvMcvaBpmUHjvIO_e9pQjqs,17897
3
+ blocket_toolkit/client.py,sha256=OlasMXRCgwSp6dqtqEdM4r8VhGV7gH4M2negzdgo4Xo,10688
4
+ blocket_toolkit/enums.py,sha256=hgxPdoj1Ltys6wwkfSkpHHJasCi-l1m4U24Q368WaZg,2824
5
+ blocket_toolkit/format.py,sha256=9fUuyirq-N5fn-nmL4CI9wRxYrvQTlxG99Q7RePZ7cY,3363
6
+ blocket_toolkit-0.1.0.dist-info/METADATA,sha256=CDK8_w6NuMkENTYfBzNq8bdSE4-vDBi4JI3XCXLHAKI,6221
7
+ blocket_toolkit-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ blocket_toolkit-0.1.0.dist-info/entry_points.txt,sha256=Wxmc6ahNJscHZgdUUfL8b-LN-vtNskjdUlugF7tI4HM,61
9
+ blocket_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=SU_3mlh6-ov9lPRM4NDhhhS-O6TeJyQRl8-yqYSiICU,1074
10
+ blocket_toolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blocket-toolkit = blocket_toolkit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Hidefjäll
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.