tutti-scraper 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,510 @@
1
+ Metadata-Version: 2.4
2
+ Name: tutti-scraper
3
+ Version: 0.1.0
4
+ Summary: Free, keyless scraper and library for tutti.ch listings. Unofficial, not affiliated with Tamedia or tutti.ch.
5
+ Project-URL: Homepage, https://github.com/danyk20/tutti-scraper
6
+ Project-URL: Repository, https://github.com/danyk20/tutti-scraper
7
+ Project-URL: Issues, https://github.com/danyk20/tutti-scraper/issues
8
+ Project-URL: Changelog, https://github.com/danyk20/tutti-scraper/blob/master/CHANGELOG.md
9
+ Author-email: Daniel Koลกฤ <maintainer@danielkosc.eu>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: classifieds,cli,scraper,switzerland,tutti,web-scraping
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: requests~=2.34
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy~=2.1; extra == 'dev'
28
+ Requires-Dist: pytest-cov~=7.1; extra == 'dev'
29
+ Requires-Dist: pytest~=9.1; extra == 'dev'
30
+ Requires-Dist: responses~=0.26; extra == 'dev'
31
+ Requires-Dist: ruff~=0.15; extra == 'dev'
32
+ Requires-Dist: types-requests~=2.33; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # tutti.ch Scraper
36
+
37
+ [![CI](https://github.com/danyk20/tutti-scraper/actions/workflows/ci.yml/badge.svg)](https://github.com/danyk20/tutti-scraper/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/tutti-scraper)](https://pypi.org/project/tutti-scraper/)
39
+ [![Coverage](https://img.shields.io/badge/unit%20test%20coverage-100%25-brightgreen)](#testing)
40
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
41
+ [![Python 3.11 | 3.12](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)](https://www.python.org/)
42
+
43
+ > Unofficial, independently developed project. Not affiliated with,
44
+ > endorsed by, or sponsored by Tamedia or tutti.ch. "tutti.ch" is a
45
+ > trademark of its respective owner.
46
+
47
+ Fetches every listing matching a search phrase from tutti.ch, for free โ€” no
48
+ API key, no token, no paid scraping service.
49
+
50
+ This project is the sibling of
51
+ [`autoscout24-scraper`](https://github.com/danyk20/autoscout24-scraper) โ€”
52
+ same `scrape()`/`ScrapeResult`/CLI shape, deliberately kept interchangeable
53
+ so switching between them (or writing code that works with either) means
54
+ changing the import and the search arguments, not the whole integration.
55
+ See [Interchangeability with autoscout24-scraper](#interchangeability-with-autoscout24-scraper)
56
+ below for exactly what that means in practice.
57
+
58
+ **๐Ÿค– This project is robot-friendly.** It is explicitly intended to be used
59
+ by AI agents and bots exactly as a human developer would: to run it, read
60
+ its output, import it into another project, or adapt its code. It's released
61
+ under the very permissive [MIT license](LICENSE) specifically so there is no
62
+ ambiguity about that โ€” see [License](#license) below.
63
+
64
+ ## How it works
65
+
66
+ tutti.ch's own frontend loads its data from a **GraphQL API** at
67
+ `https://www.tutti.ch/api/v10/graphql` โ€” not a published/documented schema,
68
+ but the query documents this scraper sends were recovered from tutti.ch's
69
+ compiled JS bundles and verified against the live API. It needs a specific
70
+ header set (`X-Tutti-Hash` โ€” any UUID, regenerated per request โ€”
71
+ `X-Tutti-Source`, `X-Tutti-Client-Identifier`), which `TuttiClient` sets
72
+ automatically.
73
+
74
+ | Operation | Purpose |
75
+ |---|---|
76
+ | `searchListingsByQuery(query, category, constraints)` | the search, paginated up to 100 results at a time, plus tutti.ch's own suggested sub-categories for the query |
77
+ | `listing(listingID)` | full detail record for one listing โ€” visited once per listing by default |
78
+
79
+ **The pagination cap, and how this scraper works around it.** A single
80
+ `(query, category, constraints, sort)` combination can only be paged up to
81
+ an offset of about 3000 โ€” larger offsets return a server error, not more
82
+ results. For a query with more matches than that, this scraper recursively
83
+ partitions the search:
84
+
85
+ 1. Search the phrase with no filters. If the total is small enough, page
86
+ through it directly.
87
+ 2. Otherwise, split by every category tutti.ch suggests for the query, and
88
+ repeat per category.
89
+ 3. If a single category is still too large, bisect it by price range
90
+ (binary search on min/max price) until each slice is small enough to
91
+ page through.
92
+ 4. A dedicated free-only pass and an ascending-sort sweep mop up listings
93
+ that a price filter or a single sort order might otherwise miss.
94
+
95
+ Listings are de-duplicated by ID across all of this, so overlap between
96
+ slices is harmless. In pathological cases (thousands of listings clustered
97
+ at the exact same price, in the exact same category) full coverage still
98
+ isn't guaranteed โ€” see [Notes](#notes) below; the scraper logs a warning
99
+ whenever a slice it visited was larger than what's reachable.
100
+
101
+ **Two-phase scraping.** The search operation only returns a summary per
102
+ listing. To get everything (GPS coordinates, full-resolution images,
103
+ structured attributes, richer seller info), the scraper visits each
104
+ listing individually, one by one, via its detail operation, after the
105
+ search phase has collected every id. That's one extra HTTP request per
106
+ listing, with a delay between requests โ€” use `--no-detail` to skip this
107
+ and keep only the fast summary fields.
108
+
109
+ Every field the API returns for a listing is extracted โ€” nested objects
110
+ (seller, postcode/canton, category, images, structured attributes, ...)
111
+ are flattened into columns, and lists are joined into a single
112
+ semicolon-separated cell โ€” so no data from the API response is dropped on
113
+ the way into the CSV.
114
+
115
+ ## Locales
116
+
117
+ Every function and the CLI accept a `lang` (default `"de"`) โ€” `"fr"` and
118
+ `"it"` are both live tutti.ch locales, used both for the `Accept-Language`
119
+ header sent to the API and for which localized URL slug gets used to build
120
+ each listing's `url`.
121
+
122
+ ## Setup
123
+
124
+ Requires [pipenv](https://pipenv.pypa.io/) (`brew install pipenv` if you
125
+ don't have it).
126
+
127
+ ```bash
128
+ cd tutti-scraper
129
+ pipenv install --dev
130
+ ```
131
+
132
+ (`--dev` also installs the test/lint tooling โ€” pytest, pytest-cov, responses,
133
+ ruff, mypy. Leave it off if you only want to run the scraper.)
134
+
135
+ ```bash
136
+ pipenv run ruff check . # lint
137
+ pipenv run ruff format --check . # formatting (drop --check to auto-format)
138
+ pipenv run mypy tutti_scraper.py # type-check
139
+ ```
140
+
141
+ These are exactly the checks the CI workflow (`.github/workflows/ci.yml`)
142
+ runs on every push/PR, across Python 3.11 and 3.12.
143
+
144
+ ## Usage
145
+
146
+ The scraper works two ways: as a standalone CLI script that writes files, or
147
+ as a library you import into another project to get the data back directly.
148
+
149
+ ### As a CLI script
150
+
151
+ ```bash
152
+ pipenv run python tutti_scraper.py "velo"
153
+ ```
154
+
155
+ (If you installed the package via `pip install` instead, as described in the
156
+ "as a library" section below, the same command is just
157
+ `tutti-scraper "velo"` โ€” no `pipenv run` needed.)
158
+
159
+ This prints progress while paging through results, then visits every
160
+ matching listing one by one to pull full details, and writes two output
161
+ files in the current directory: `velo.csv` and `velo.json`.
162
+
163
+ ### Options
164
+
165
+ | Flag | Description |
166
+ |---|---|
167
+ | `--version` | Print the installed version and exit |
168
+ | `query` | Search phrase, e.g. `velo` or `"Tesla Roadster"` (required, positional) |
169
+ | `--lang` | Listing/URL locale โ€” `de` (default), `fr`, or `it` |
170
+ | `--out` | Output file base name, without extension. Defaults to a slug of the search phrase |
171
+ | `--no-detail` | Skip visiting each listing individually; keep only the summary fields from the search results (faster, fewer fields) |
172
+ | `--sort` | Sort order tutti.ch searches with โ€” `timestamp` (default), `price`, or `relevance` |
173
+ | `--max` | Stop after N listings |
174
+ | `--delay` | Seconds to wait between requests (default `1.0`) โ€” raise this if you get rate-limited |
175
+ | `-v` / `--verbose` | Also show debug-level detail, including every HTTP request made (mutually exclusive with `-q`) |
176
+ | `-q` / `--quiet` | Suppress progress output; only warnings/errors are shown (mutually exclusive with `-v`) |
177
+
178
+ ### Examples
179
+
180
+ ```bash
181
+ # Full run: search + visit every listing for full details (default)
182
+ pipenv run python tutti_scraper.py "velo"
183
+
184
+ # Custom output filename
185
+ pipenv run python tutti_scraper.py "velo" --out my_search
186
+
187
+ # Fast mode: search results only, skip visiting each listing
188
+ pipenv run python tutti_scraper.py "velo" --no-detail
189
+
190
+ # Stop after the first 50 listings found
191
+ pipenv run python tutti_scraper.py "velo" --max 50
192
+
193
+ # French locale
194
+ pipenv run python tutti_scraper.py "velo" --lang fr
195
+
196
+ # Any free-text phrase works, including multi-word ones
197
+ pipenv run python tutti_scraper.py "Tesla Roadster"
198
+ ```
199
+
200
+ ### As a library, from another project
201
+
202
+ Import `scrape()` and call it directly โ€” it does the same work as the CLI
203
+ (search, then visit every listing for full detail) but returns a
204
+ `ScrapeResult` object instead of writing files. No files are written unless
205
+ you explicitly ask for them.
206
+
207
+ ```python
208
+ from tutti_scraper import scrape
209
+
210
+ result = scrape("velo", max_results=50)
211
+
212
+ result.rows # list[dict]: one flattened dict per listing, CSV-ready
213
+ result.listings # list[dict]: raw (unflattened) API JSON per listing, each with a "url" field
214
+ result.query, result.total_elements, result.lang
215
+
216
+ for row in result.rows:
217
+ print(row["price"], row["title"], row["url"])
218
+
219
+ # Optional: write to disk anyway, e.g. for a one-off export
220
+ result.to_csv("velo.csv")
221
+ result.to_json("velo.json")
222
+ ```
223
+
224
+ This section is the authoritative reference for the return types โ€” both for
225
+ a human integrating this into another project, and for an AI agent that
226
+ needs to know exactly what it's going to get back without having to read
227
+ the whole source file.
228
+
229
+ #### `scrape()` signature
230
+
231
+ ```python
232
+ def scrape(
233
+ query: str, # free-text search phrase, e.g. "velo" or "Tesla Roadster"
234
+ *,
235
+ lang: str = "de", # "de" (default), "fr", or "it"
236
+ detail: bool = True, # visit every listing individually for full fields (slower)
237
+ sort: str = "timestamp", # "timestamp" (default), "price", or "relevance"
238
+ max_results: int | None = None, # stop after this many unique listings, if given
239
+ delay: float = 1.0, # seconds between HTTP requests
240
+ verbose: bool = True, # emit progress via the "tutti_scraper" logger at INFO level
241
+ client: TuttiClient | None = None, # reuse a client across calls if given
242
+ ) -> ScrapeResult:
243
+ ...
244
+ ```
245
+
246
+ Raises `TuttiError` on unrecoverable GraphQL/HTTP errors from tutti.ch after
247
+ retries are exhausted, and `requests.RequestException` subclasses on
248
+ unrecoverable network errors.
249
+
250
+ **Logging.** Library code never configures logging itself (no
251
+ `basicConfig`, no handlers) โ€” it only emits through
252
+ `logging.getLogger("tutti_scraper")`, same as any well-behaved library. That
253
+ means if you call `scrape()` from your own script with no logging
254
+ configuration of your own, `verbose=True`'s progress messages exist but
255
+ won't be visible anywhere, by design โ€” Python's standard "libraries don't
256
+ talk unless you ask them to" behavior. To see them:
257
+
258
+ ```python
259
+ import logging
260
+ logging.basicConfig(level=logging.INFO) # now scrape()'s progress is visible
261
+ ```
262
+
263
+ The CLI is the one place that *does* configure real handlers automatically
264
+ (see `--verbose`/`--quiet` above) โ€” that's the only difference between
265
+ running this as a script versus importing it.
266
+
267
+ #### `ScrapeResult` โ€” the return value
268
+
269
+ ```python
270
+ @dataclass
271
+ class ScrapeResult:
272
+ query: str # the search phrase, as requested
273
+ total_elements: int # number of unique listings found by the search phase
274
+ listings: list[dict] # raw API objects โ€” see "Data structure" below
275
+ rows: list[dict] # flattened dicts, one per listing, CSV-ready, sorted by price ascending
276
+ lang: str # locale that was scraped, e.g. "de"
277
+
278
+ def to_csv(self, path: str) -> None: ... # writes self.rows
279
+ def to_json(self, path: str) -> None: ... # writes self.listings
280
+ ```
281
+
282
+ `len(result.rows) == len(result.listings) == result.total_elements` always
283
+ holds (barring `--no-detail`/`detail=False`, where they still match โ€” detail
284
+ mode only adds fields, it never drops or adds listings).
285
+
286
+ Install it into your own project's environment with:
287
+
288
+ ```bash
289
+ pip install tutti-scraper
290
+ ```
291
+
292
+ (Not yet published? Install the latest unreleased code straight from GitHub
293
+ instead: `pip install git+https://github.com/danyk20/tutti-scraper.git`.)
294
+
295
+ Either way you also get a real `tutti-scraper` command (see `--version`
296
+ above), not just the importable module โ€” pipenv is only needed if you're
297
+ working on this repo itself (running its CLI from source, or its test
298
+ suite).
299
+
300
+ ## Interchangeability with autoscout24-scraper
301
+
302
+ Both projects share the same shape on purpose, so code written against one
303
+ transfers to the other with minimal changes:
304
+
305
+ | Concept | `autoscout24-scraper` | `tutti-scraper` |
306
+ |---|---|---|
307
+ | Search call | `scrape(make, model, **filters)` | `scrape(query, **filters)` |
308
+ | Return value | `ScrapeResult` (`.rows`, `.listings`, `.to_csv()`, `.to_json()`) | same |
309
+ | Common row keys | `row["price"]`, `row["url"]` | same |
310
+ | Detail toggle | `detail=True` / `--no-detail` | same |
311
+ | Reusable transport | `session: requests.Session | None` | `client: TuttiClient | None` (see below) |
312
+ | Locale/region | `domain: str = "ch"` | `lang: str = "de"` |
313
+ | Logging | `logging.getLogger("autoscout24_scraper")`, `-v`/`-q` CLI flags | `logging.getLogger("tutti_scraper")`, same flags |
314
+ | CLI entry point | `main(argv=None)` / `run_cli(argv=None)` | same |
315
+
316
+ Two things are genuinely different, not just renamed, because the two sites'
317
+ domains actually differ:
318
+
319
+ - **`client` vs. `session`.** tutti.ch's API needs a specific header set
320
+ regenerated per request (see [How it works](#how-it-works)), so tutti's
321
+ reusable transport object is a small `TuttiClient` class wrapping a
322
+ session, not a bare `requests.Session`. If you're writing code that
323
+ should work against either scraper, treat this parameter as opaque
324
+ (pass `None` to let each library build its own) rather than constructing
325
+ it yourself.
326
+ - **Search shape.** AutoScout24 lets you filter by price/mileage/year server
327
+ side, because it's a structured make/model catalog. tutti.ch is a general
328
+ classifieds site searched by free text, so those specific filters don't
329
+ have a tutti equivalent โ€” `scrape()`'s `sort`/`max_results` options exist
330
+ instead, for the free-text case.
331
+
332
+ If you're adapting this project to a *third* data source, following this
333
+ same shape (`scrape()` โ†’ `ScrapeResult`, `flatten_listing()`/`save_csv()`/
334
+ `save_json()`/`order_fieldnames()`, `main()`/`run_cli()`, logging via
335
+ `logging.getLogger(...)`) is the recommended path โ€” several of tutti's
336
+ utility functions (`_scalarize()`, `order_fieldnames()`, `save_csv()`,
337
+ `save_json()`) are generic enough to reuse close to verbatim.
338
+
339
+ ## Data structure
340
+
341
+ This section documents exactly what's in the output โ€” precisely enough that
342
+ a developer or an AI agent can parse it without having to run the scraper
343
+ first and reverse-engineer the shape themselves.
344
+
345
+ ### JSON (`result.listings` / the `.json` file)
346
+
347
+ The JSON file (and `ScrapeResult.listings`) is a **JSON array of listing
348
+ objects**, one per ad found. Every listing object always includes:
349
+
350
+ | Field | Type | Description |
351
+ |---|---|---|
352
+ | `listingID` | `string` | tutti.ch's internal listing id |
353
+ | `url` | `string \| null` | **Full URL of the original ad** on tutti.ch, e.g. `https://www.tutti.ch/de/vi/bern/velos/velo/76699338` โ€” added by this scraper (the raw API response does not include it), so you can always click straight back to the source listing. `null` if tutti.ch didn't return a URL slug for the requested `lang` |
354
+ | `title` | `string` | |
355
+ | `body` | `string` | ad description text |
356
+ | `timestamp` | `string` | ISO 8601 |
357
+ | `formattedPrice` | `string \| null` | display price as tutti.ch renders it, e.g. `"550.-"` |
358
+ | `postcodeInformation` | `object` | `{"postcode", "locationName", "canton": {"shortName", "name"}}` |
359
+ | `primaryCategory` | `object` | `{"categoryID", ...}` (richer in detail shape, see below) |
360
+ | `sellerInfo` | `object` | `{"alias", "logoURL", ...}` (richer in detail shape, see below) |
361
+
362
+ There are two possible **shapes** for the rest of the object, depending on
363
+ whether detail mode ran:
364
+
365
+ - **Summary shape** (`detail=False` / `--no-detail`): the search operation's
366
+ fields only โ€” includes a `thumbnail` (small renditions), but no GPS
367
+ coordinates, no full-resolution images, no structured attributes.
368
+ - **Detail shape** (`detail=True`, the default): adds `coordinates`
369
+ (`{"latitude", "longitude"}`), `images` (`list[{"rendition": {"src"}}]`,
370
+ full resolution), `properties` (`list[{"listingPropertyID", "label",
371
+ "text"}]`, structured attributes specific to the listing's category),
372
+ `address`, `phoneInfo` (`{"isMobile", "phoneHash"}`), a richer
373
+ `sellerInfo` (adds `locationName`, `url`, `memberSince`,
374
+ `publicAccountID`), and a richer `primaryCategory` (adds `label`,
375
+ `parent`). `seoInformation` also gains `numericPrice` (`number \| null`)
376
+ โ€” the raw numeric price the display-only `formattedPrice` string doesn't
377
+ give you, used to sort `result.rows` and promoted to a flat `price`
378
+ column in CSV output.
379
+
380
+ There is no published/versioned schema for these objects โ€” the tables above
381
+ reflect the fields observed in practice as of this writing, recovered from
382
+ tutti.ch's own compiled JS (see [How it works](#how-it-works)). Treat
383
+ unknown/missing fields defensively (`.get(...)`, not `[...]`) since tutti.ch
384
+ can add or omit fields per listing.
385
+
386
+ ### CSV (`result.rows` / the `.csv` file)
387
+
388
+ The CSV is a **flattened** version of the same data โ€” one row per listing,
389
+ same rows/listings correspondence and order. Flattening rules (also
390
+ available programmatically as `flatten_listing()`):
391
+
392
+ - `seoInformation.numericPrice` is promoted to a top-level `price` column
393
+ (also present as `seoInformation_numericPrice`).
394
+ - `sellerInfo` becomes `sellerAlias`, `sellerLocationName`,
395
+ `sellerMemberSince`.
396
+ - `primaryCategory` becomes `category` (label if available, else the raw
397
+ category id) and `categoryKey` (always the raw id).
398
+ - `postcodeInformation` becomes `postcode`, `locationName`, `canton`,
399
+ `cantonKey`.
400
+ - `thumbnail` becomes `thumbnailURL`.
401
+ - `images` is joined into one semicolon-separated cell of image URLs.
402
+ - `properties` is joined into one semicolon-separated cell of
403
+ `"label: text"` pairs.
404
+ - Any other nested object becomes `parent_child` columns, e.g.
405
+ `coordinates.latitude` โ†’ `coordinates_latitude`.
406
+ - `url` is always present as its own column (same value as the JSON `url`
407
+ field described above).
408
+ - Columns are the union of every field seen across all rows (heterogeneous
409
+ listings don't crash the writer โ€” missing values are an empty string),
410
+ with `listingID, title, price, formattedPrice, category, postcode,
411
+ locationName, canton, timestamp, sellerAlias, url` pinned first and
412
+ everything else sorted alphabetically after them.
413
+
414
+ ## Testing
415
+
416
+ The CI badge above is live (it reflects the actual state of the most recent
417
+ GitHub Actions run). The coverage badge is a static snapshot of the last
418
+ verified `pytest` run, not wired to a live coverage service โ€” enforced
419
+ locally and in CI via the `--cov-fail-under=95` gate described below, so it
420
+ can't silently drop without the build going red.
421
+
422
+ The test suite lives in `tests/` and is split into two kinds of tests:
423
+
424
+ - **Unit tests** (`tests/test_*.py`, excluding `test_e2e.py`) โ€” every
425
+ function is tested in isolation with HTTP mocked out (via the
426
+ [`responses`](https://github.com/getsentry/responses) library) or via an
427
+ in-memory `FakeClient` test double for the partitioning algorithm, so
428
+ they run in well under a second, need no network access, and never touch
429
+ the real site. This is the default `pytest` run.
430
+ - **End-to-end tests** (`tests/test_e2e.py`) โ€” make real calls against
431
+ tutti.ch. They're marked `@pytest.mark.e2e` and excluded by default; run
432
+ them explicitly when you want to confirm the scraper still works against
433
+ the live API (e.g. after tutti.ch changes something). They target "Tesla
434
+ Roadster" specifically because its inventory is small and stable, so the
435
+ full detail-visiting pipeline and a real CLI run both complete in a few
436
+ seconds without hammering the API.
437
+
438
+ ```bash
439
+ # Unit tests only (fast, no network) โ€” this is what `pytest` runs by default.
440
+ # Also prints a coverage report and fails the run if coverage drops below 95%.
441
+ pipenv run pytest
442
+
443
+ # End-to-end tests only (real network calls, several seconds)
444
+ pipenv run pytest -m e2e --no-cov
445
+
446
+ # Everything
447
+ pipenv run pytest -m "e2e or not e2e" --no-cov
448
+
449
+ # HTML coverage report you can open in a browser
450
+ pipenv run pytest --cov-report=html && open htmlcov/index.html
451
+ ```
452
+
453
+ The unit suite covers 100% of `tutti_scraper.py` (the one line excluded via
454
+ `# pragma: no cover` is the `if __name__ == "__main__":` guard itself,
455
+ which is exercised for real by the e2e suite's CLI run instead).
456
+
457
+ What's covered:
458
+
459
+ | Area | Unit tests | E2E tests |
460
+ |---|---|---|
461
+ | `TuttiClient._post` | retry-then-succeed and exhausted-retries paths for GraphQL errors, 429/5xx, connection errors; no retry on 4xx; fresh hash per attempt | โ€” |
462
+ | `Scraper` (partitioning) | direct paging, category split (once, not recursively), price bisection, free-only pass, ascending sweep, dedup, depth/range safety valve + warning | โ€” |
463
+ | `search_listings` / `visit_all_listings` | max-results early stop, progress logging, detail-fetch-failure fallback | real search + detail fetch |
464
+ | `flatten_listing` / `_scalarize` / `order_fieldnames` | every branch (nested dicts, lists, missing/unrecognized types) | implicitly, via real data |
465
+ | `save_csv` / `save_json` / `ScrapeResult` | heterogeneous rows, unicode, empty input | round-trip against real files |
466
+ | `scrape()` | orchestration, sorting, client reuse/construction, verbose logging | full real pipeline, with and without `detail` |
467
+ | `main()` / `run_cli()` | every CLI flag, default vs. custom output filenames, all three exit-code paths | real subprocess-equivalent run, real error exit code |
468
+
469
+ ## Notes
470
+
471
+ - Be a reasonable citizen: the default delay between requests is intentional.
472
+ Don't remove it or crank up concurrency โ€” this is an undocumented API the
473
+ site's own frontend uses, not a public API with a stated rate limit.
474
+ tutti.ch's `robots.txt` disallows `/api/` (the search *pages* themselves,
475
+ e.g. `/de/q/...`, are allowed) โ€” this scraper calls the API directly
476
+ because it's the only reliable way to get complete, structured data for
477
+ large result sets.
478
+ - If tutti.ch changes their GraphQL schema or required headers, the
479
+ `TuttiClient`, `Scraper`, `search_listings`, and `visit_all_listings`
480
+ functions/classes are the places to look โ€” the module docstring at the
481
+ top of `tutti_scraper.py` documents the endpoint shapes in more detail.
482
+ Run the e2e suite after any such change to confirm the fix.
483
+ - Extremely large, heavily price-clustered result sets (e.g. thousands of
484
+ listings at the exact same price, in the exact same category) can still
485
+ exceed what's reachable even after bisection; `Scraper` logs a warning
486
+ whenever a slice it scraped was larger than it could fully cover.
487
+
488
+ ## License
489
+
490
+ This project is released under the [MIT License](LICENSE) โ€” one of the most
491
+ permissive open-source licenses that exist. In plain terms: you can use,
492
+ copy, modify, merge, publish, distribute, and even sell copies of this code,
493
+ for free, for any purpose, commercial or not, as long as the license text
494
+ stays attached. There is no warranty.
495
+
496
+ **AI agents, LLM-based coding assistants, and other bots are explicitly
497
+ welcome to use this project** โ€” to run the scraper, to read and parse its
498
+ output, to import `scrape()` into another project, or to read and adapt its
499
+ source code โ€” under exactly the same terms as a human would, with no
500
+ additional restriction and no need to ask permission. That's the whole
501
+ point of the fully-typed [`scrape()` signature and `ScrapeResult`
502
+ reference](#as-a-library-from-another-project) and the [Data
503
+ structure](#data-structure) section above: so a bot reading this file can
504
+ integrate with the code correctly without a human in the loop, same as a
505
+ person reading it would.
506
+
507
+ The one thing this permissive license does *not* do is grant any rights to
508
+ tutti.ch's own data or terms of service โ€” this project only automates
509
+ requests to the same GraphQL API their own website already calls from any
510
+ visitor's browser; what you do with the results is between you and them.
@@ -0,0 +1,6 @@
1
+ tutti_scraper.py,sha256=Z1U4YYMs0sQwvaDK9v7eFT4ksFZXlyPoeG6bB-ueMds,27304
2
+ tutti_scraper-0.1.0.dist-info/METADATA,sha256=fphxHkAbDjbfFlx5lodhCf-zqf2iQyi_sQ59Gz5Ywmg,24960
3
+ tutti_scraper-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
4
+ tutti_scraper-0.1.0.dist-info/entry_points.txt,sha256=UahFy3SEWESqR59o8suuX4ybaCfEBQZC3qtElWHKhVU,56
5
+ tutti_scraper-0.1.0.dist-info/licenses/LICENSE,sha256=Z2X9MhMOvq79ywuoydtHNVmLydb6pxJ4JrahRxylfYU,1170
6
+ tutti_scraper-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tutti-scraper = tutti_scraper:run_cli
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Koลกฤ
4
+
5
+ Permission is hereby granted, free of charge, to any person or automated
6
+ agent (including AI assistants, bots, and other software acting on behalf
7
+ of a person) obtaining a copy of this software and associated documentation
8
+ files (the "Software"), to deal in the Software without restriction,
9
+ including without limitation the rights to use, copy, modify, merge,
10
+ publish, distribute, sublicense, and/or sell copies of the Software, and to
11
+ permit persons to whom the Software is furnished to do so, subject to the
12
+ following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in
15
+ all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
22
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
23
+ DEALINGS IN THE SOFTWARE.
tutti_scraper.py ADDED
@@ -0,0 +1,712 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tutti.ch listing scraper.
4
+
5
+ Talks directly to the GraphQL API tutti.ch's own website calls
6
+ (https://www.tutti.ch/api/v10/graphql) rather than scraping rendered HTML.
7
+ The query documents used here (SEARCH_QUERY, DETAIL_QUERY) were recovered
8
+ from tutti.ch's compiled JS bundles and verified against the live API; the
9
+ GraphQL schema itself isn't publicly documented.
10
+
11
+ Discovered endpoints:
12
+ POST https://www.tutti.ch/api/v10/graphql
13
+ operation searchListingsByQuery(query, category, constraints)
14
+ -> paginated listing summaries + suggested sub-categories
15
+ operation listing(listingID)
16
+ -> full detail record for one listing
17
+
18
+ Required headers (a normal `requests` client must set these explicitly;
19
+ tutti.ch's frontend sets them via its own bundled JS):
20
+ X-Tutti-Hash: any UUID, regenerated per request
21
+ X-Tutti-Source: "web LIVE"
22
+ X-Tutti-Client-Identifier: "web/1.0.0+env-live.git-0000000"
23
+
24
+ Pagination quirk: a single (query, category, constraints, sort) combination
25
+ can only be paged up to an offset of ~3000 - larger offsets return a server
26
+ error. To cover result sets bigger than that, this scraper recursively
27
+ partitions the search: first by every category tutti.ch suggests for the
28
+ query, then (if a single category is still too large) by binary-searched
29
+ price range, until every slice is small enough to page through completely.
30
+ A dedicated free-only pass and an ascending-sort sweep mop up listings a
31
+ price filter or a single sort order might otherwise miss. Listings are
32
+ de-duplicated by ID across all of this.
33
+
34
+ After the search phase collects every listing id, the scraper (by default)
35
+ visits each listing's detail operation one by one to extract fields the
36
+ search summary doesn't return: GPS coordinates, full-resolution images,
37
+ structured attributes, and richer seller info. This is slower (one extra
38
+ request per listing) but gives full details.
39
+
40
+ Language: every function that builds a URL or requests server-rendered
41
+ slugs takes an optional `lang` (default "de"), matching tutti.ch/<lang>/....
42
+ "fr" and "it" are also live tutti.ch locales.
43
+
44
+ This module can be used two ways:
45
+
46
+ 1. As a standalone CLI script that writes a CSV + JSON file:
47
+
48
+ python3 tutti_scraper.py "velo"
49
+ python3 tutti_scraper.py "Tesla Roadster" --out tesla_roadster
50
+ python3 tutti_scraper.py "velo" --no-detail # skip per-listing detail fetch
51
+
52
+ 2. As a library, imported from another project, returning data directly
53
+ instead of writing files:
54
+
55
+ from tutti_scraper import scrape
56
+
57
+ result = scrape("velo", max_results=50)
58
+ for row in result.rows: # flattened dicts, one per listing
59
+ print(row["price"], row["url"])
60
+ result.listings # raw (unflattened) API JSON per listing
61
+ result.to_csv("velo.csv") # optional, if you want a file after all
62
+ result.to_json("velo.json")
63
+ """
64
+
65
+ import argparse
66
+ import csv
67
+ import json
68
+ import logging
69
+ import re
70
+ import sys
71
+ import time
72
+ import uuid
73
+ from collections.abc import Iterable
74
+ from dataclasses import dataclass, field
75
+ from typing import Any
76
+
77
+ import requests
78
+
79
+ __version__ = "0.1.0"
80
+
81
+ API_URL = "https://www.tutti.ch/api/v10/graphql"
82
+
83
+ USER_AGENT = (
84
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
85
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
86
+ )
87
+
88
+ SEARCH_QUERY = """
89
+ query Search(
90
+ $q: String, $category: ID, $constraints: ListingSearchConstraints,
91
+ $first: Int!, $offset: Int!, $sort: ListingSortMode!, $direction: SortDirection!
92
+ ) {
93
+ searchListingsByQuery(query: $q, category: $category, constraints: $constraints) {
94
+ suggestedCategories { categoryID label }
95
+ listings(first: $first, offset: $offset, sort: $sort, direction: $direction) {
96
+ totalCount
97
+ edges {
98
+ node {
99
+ listingID
100
+ title
101
+ body
102
+ postcodeInformation { postcode locationName canton { shortName name } }
103
+ timestamp
104
+ formattedPrice
105
+ formattedSource
106
+ highlighted
107
+ primaryCategory { categoryID }
108
+ sellerInfo { alias logoURL }
109
+ thumbnail {
110
+ normalRendition: rendition(width: 235, height: 167) { src }
111
+ retinaRendition: rendition(width: 470, height: 334) { src }
112
+ }
113
+ seoInformation {
114
+ deSlug: slug(language: DE)
115
+ frSlug: slug(language: FR)
116
+ itSlug: slug(language: IT)
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ """
124
+
125
+ DETAIL_QUERY = """
126
+ query Detail($id: ListingID!) {
127
+ listing(listingID: $id) {
128
+ listingID
129
+ title
130
+ body
131
+ language
132
+ externalURL
133
+ postcodeInformation { postcode locationName canton { shortName name } }
134
+ coordinates { latitude longitude }
135
+ timestamp
136
+ formattedPrice
137
+ formattedSource
138
+ highlighted
139
+ sellerInfo { alias logoURL locationName url memberSince publicAccountID }
140
+ images(first: 15) { rendition(width: 1024, height: 768) { src } }
141
+ primaryCategory {
142
+ categoryID
143
+ label
144
+ parent { categoryID label }
145
+ }
146
+ address
147
+ phoneInfo { isMobile phoneHash }
148
+ properties {
149
+ ... on ListingPropertyDescription { listingPropertyID label text }
150
+ }
151
+ seoInformation {
152
+ deSlug: slug(language: DE)
153
+ frSlug: slug(language: FR)
154
+ itSlug: slug(language: IT)
155
+ numericPrice
156
+ }
157
+ }
158
+ }
159
+ """
160
+
161
+ PAGE_SIZE = 100
162
+ MAX_OFFSET = 3000 # highest offset the API accepts reliably (probed live)
163
+ MAX_TOTAL = MAX_OFFSET + PAGE_SIZE # items reachable in one linear pass
164
+ MAX_PRICE = 100_000_000 # CHF ceiling for price bisection (covers real estate)
165
+ MAX_BISECT_DEPTH = 40
166
+
167
+ PRIORITY_FIELDS = [
168
+ "listingID",
169
+ "title",
170
+ "price",
171
+ "formattedPrice",
172
+ "category",
173
+ "postcode",
174
+ "locationName",
175
+ "canton",
176
+ "timestamp",
177
+ "sellerAlias",
178
+ "url",
179
+ ]
180
+
181
+ # Library code only ever logs through this logger - it never calls
182
+ # basicConfig or attaches handlers of its own (that would be rude to a host
183
+ # application). The CLI (see _configure_cli_logging(), used by main()) is the
184
+ # only place that sets up real handlers, so plain library use is silent
185
+ # unless the caller configures logging themselves, e.g.:
186
+ # import logging; logging.basicConfig(level=logging.INFO)
187
+ logger = logging.getLogger("tutti_scraper")
188
+ logger.addHandler(logging.NullHandler())
189
+
190
+
191
+ class TuttiError(Exception):
192
+ pass
193
+
194
+
195
+ class TuttiClient:
196
+ """Thin GraphQL client for tutti.ch's API: session + required headers
197
+ + retry/backoff. Roughly the tutti equivalent of a plain
198
+ requests.Session for a REST API - except tutti's API needs a specific
199
+ header set (see module docstring) and a fresh X-Tutti-Hash per request,
200
+ so it's a small class rather than a bare session."""
201
+
202
+ def __init__(self, lang="de", delay=1.0, max_retries=5):
203
+ self.lang = lang
204
+ self.delay = delay
205
+ self.max_retries = max_retries
206
+ self.session = requests.Session()
207
+ self.session.headers.update(
208
+ {
209
+ "Content-Type": "application/json",
210
+ "User-Agent": USER_AGENT,
211
+ "Accept-Language": f"{lang}-CH",
212
+ "X-Tutti-Source": "web LIVE",
213
+ "X-Tutti-Client-Identifier": "web/1.0.0+env-live.git-0000000",
214
+ }
215
+ )
216
+
217
+ def _post(self, query, variables):
218
+ body = {"query": query, "variables": variables}
219
+ backoff = self.delay
220
+ last_exc: Exception | None = None
221
+ for attempt in range(1, self.max_retries + 1):
222
+ self.session.headers["X-Tutti-Hash"] = str(uuid.uuid4())
223
+ try:
224
+ resp = self.session.post(API_URL, json=body, timeout=30)
225
+ except requests.RequestException as exc:
226
+ last_exc = exc
227
+ logger.warning("POST %s failed (%s); retry %d/%d", API_URL, exc, attempt, self.max_retries)
228
+ time.sleep(backoff)
229
+ backoff *= 2
230
+ continue
231
+ if resp.status_code == 200:
232
+ data = resp.json()
233
+ if data.get("errors"):
234
+ last_exc = TuttiError(str(data["errors"]))
235
+ logger.warning("GraphQL errors: %s; retry %d/%d", data["errors"], attempt, self.max_retries)
236
+ time.sleep(backoff)
237
+ backoff *= 2
238
+ continue
239
+ time.sleep(self.delay)
240
+ return data["data"]
241
+ if resp.status_code in (429, 500, 502, 503, 504):
242
+ last_exc = TuttiError(f"HTTP {resp.status_code}")
243
+ logger.warning("POST %s -> %d; retry %d/%d", API_URL, resp.status_code, attempt, self.max_retries)
244
+ time.sleep(backoff)
245
+ backoff *= 2
246
+ continue
247
+ resp.raise_for_status()
248
+ raise TuttiError(f"giving up after {self.max_retries} attempts: {last_exc}")
249
+
250
+ def search(
251
+ self,
252
+ query,
253
+ category=None,
254
+ constraints=None,
255
+ offset=0,
256
+ first=PAGE_SIZE,
257
+ sort="TIMESTAMP",
258
+ direction="DESCENDING",
259
+ ):
260
+ variables = {
261
+ "q": query,
262
+ "category": category,
263
+ "constraints": constraints,
264
+ "first": first,
265
+ "offset": offset,
266
+ "sort": sort,
267
+ "direction": direction,
268
+ }
269
+ data = self._post(SEARCH_QUERY, variables)
270
+ return data["searchListingsByQuery"]
271
+
272
+ def fetch_detail(self, listing_id):
273
+ data = self._post(DETAIL_QUERY, {"id": listing_id})
274
+ return data["listing"]
275
+
276
+
277
+ def price_constraint(pmin=None, pmax=None, free_only=False):
278
+ entry = {"key": "price", "freeOnly": free_only}
279
+ if pmin is not None:
280
+ entry["min"] = pmin
281
+ if pmax is not None:
282
+ entry["max"] = pmax
283
+ return {"prices": [entry]}
284
+
285
+
286
+ class Scraper:
287
+ """Recursively partitions a search until every slice fits in one
288
+ linear pass, de-duplicating listings by ID across all slices. This is
289
+ tutti's equivalent of a simple paginated search - more involved than
290
+ that would otherwise be, only because of the ~3000-offset pagination
291
+ cap (see module docstring). search_listings(), below, is the plain
292
+ function wrapper most callers should use instead of this class directly."""
293
+
294
+ def __init__(self, client, query, sort="TIMESTAMP"):
295
+ self.client = client
296
+ self.query = query
297
+ self.sort = sort
298
+ self.seen = set()
299
+
300
+ def run(self):
301
+ yield from self._scrape(category=None, constraints=None, allow_category_split=True)
302
+ # Sweep from the opposite end too: for query/category combos that
303
+ # stayed too large even after splitting, DESCENDING and ASCENDING
304
+ # sample different items from the same offset-limited window.
305
+ yield from self._linear_page(category=None, constraints=None, direction="ASCENDING")
306
+
307
+ def _probe_total(self, category, constraints):
308
+ result = self.client.search(self.query, category=category, constraints=constraints, offset=0, first=1)
309
+ listings = result["listings"]
310
+ return listings["totalCount"], result.get("suggestedCategories") or []
311
+
312
+ def _linear_page(self, category, constraints, direction="DESCENDING"):
313
+ offset = 0
314
+ while True:
315
+ result = self.client.search(
316
+ self.query,
317
+ category=category,
318
+ constraints=constraints,
319
+ offset=offset,
320
+ first=PAGE_SIZE,
321
+ sort=self.sort,
322
+ direction=direction,
323
+ )
324
+ listings = result["listings"]
325
+ edges = listings["edges"]
326
+ if not edges:
327
+ break
328
+ for edge in edges:
329
+ node = edge["node"]
330
+ listing_id = node["listingID"]
331
+ if listing_id not in self.seen:
332
+ self.seen.add(listing_id)
333
+ yield node
334
+ offset += PAGE_SIZE
335
+ if offset > MAX_OFFSET or offset >= listings["totalCount"]:
336
+ break
337
+
338
+ def _scrape(self, category, constraints, allow_category_split):
339
+ total, suggested = self._probe_total(category, constraints)
340
+ if total == 0:
341
+ return
342
+ if total <= MAX_TOTAL:
343
+ yield from self._linear_page(category, constraints)
344
+ return
345
+ if allow_category_split and suggested:
346
+ for cat in suggested:
347
+ yield from self._scrape(cat["categoryID"], constraints, allow_category_split=False)
348
+ return
349
+ yield from self._bisect_price(category, constraints, 0, MAX_PRICE, depth=0)
350
+ # Price-bucketed constraints can exclude listings with no numeric
351
+ # price (e.g. "price on request"); a dedicated free-only pass
352
+ # recovers at least the free ones.
353
+ free_constraints = price_constraint(free_only=True)
354
+ yield from self._linear_page(category, free_constraints)
355
+
356
+ def _bisect_price(self, category, base_constraints, pmin, pmax, depth):
357
+ constraints = price_constraint(pmin, pmax)
358
+ total, _ = self._probe_total(category, constraints)
359
+ if total == 0:
360
+ return
361
+ if total <= MAX_TOTAL or depth >= MAX_BISECT_DEPTH or pmax - pmin <= 1:
362
+ yield from self._linear_page(category, constraints)
363
+ if total > MAX_TOTAL:
364
+ logger.warning(
365
+ "category=%s price=[%s,%s] has %d listings but only %d are reachable; "
366
+ "some listings in this slice may be missing",
367
+ category,
368
+ pmin,
369
+ pmax,
370
+ total,
371
+ MAX_TOTAL,
372
+ )
373
+ return
374
+ mid = (pmin + pmax) // 2
375
+ yield from self._bisect_price(category, base_constraints, pmin, mid, depth + 1)
376
+ yield from self._bisect_price(category, base_constraints, mid, pmax, depth + 1)
377
+
378
+
379
+ def search_listings(client, query, *, sort="TIMESTAMP", max_results=None, verbose=True):
380
+ """Search tutti.ch for `query` and return every reachable listing
381
+ summary as a list of raw node dicts (see SEARCH_QUERY for the shape).
382
+ Stops early once `max_results` unique listings have been collected, if
383
+ given."""
384
+ scraper = Scraper(client, query, sort=sort)
385
+ nodes = []
386
+ for node in scraper.run():
387
+ nodes.append(node)
388
+ if verbose and len(nodes) % PAGE_SIZE == 0:
389
+ logger.info("... %d listings found so far", len(nodes))
390
+ if max_results is not None and len(nodes) >= max_results:
391
+ break
392
+ return nodes
393
+
394
+
395
+ def visit_all_listings(client, listings, *, verbose=True):
396
+ """Visit each listing's detail operation one by one, merging the
397
+ richer detail fields (coordinates, full-res images, structured
398
+ attributes, seller info) into a copy of its summary dict. A listing
399
+ whose detail fetch fails keeps its summary fields instead of being
400
+ dropped."""
401
+ detailed = []
402
+ total = len(listings)
403
+ for i, node in enumerate(listings, 1):
404
+ record = dict(node)
405
+ try:
406
+ detail = client.fetch_detail(node["listingID"])
407
+ record.update(detail)
408
+ except TuttiError as exc:
409
+ logger.warning("Detail fetch failed for %s: %s", node["listingID"], exc)
410
+ detailed.append(record)
411
+ if verbose:
412
+ logger.info("Visited %d/%d listings", i, total)
413
+ return detailed
414
+
415
+
416
+ def listing_url(node, lang="de"):
417
+ slug = (node.get("seoInformation") or {}).get(f"{lang}Slug")
418
+ if not slug:
419
+ return None
420
+ return f"https://www.tutti.ch/{lang}/vi/{slug}/{node['listingID']}"
421
+
422
+
423
+ def _scalarize(value: Any) -> Any:
424
+ """Turn a nested dict/list value into something that fits one CSV cell."""
425
+ if value is None:
426
+ return ""
427
+ if isinstance(value, (str, int, float, bool)):
428
+ return value
429
+ if isinstance(value, dict):
430
+ for key in ("name", "label", "src", "shortName"):
431
+ if key in value and not isinstance(value[key], (dict, list)):
432
+ return value[key]
433
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
434
+ if isinstance(value, list):
435
+ return "; ".join(str(_scalarize(v)) for v in value)
436
+ return str(value)
437
+
438
+
439
+ def flatten_listing(item: dict[str, Any], lang: str = "de") -> dict[str, Any]:
440
+ """Flatten a listing (search-summary or full-detail shape) into one flat
441
+ dict covering every field tutti.ch returned for it, so nothing is lost."""
442
+ flat: dict[str, Any] = {}
443
+ for key, value in item.items():
444
+ if key == "seoInformation" and isinstance(value, dict):
445
+ flat["price"] = value.get("numericPrice")
446
+ for sub_key, sub_value in value.items():
447
+ flat[f"seoInformation_{sub_key}"] = _scalarize(sub_value)
448
+ continue
449
+ if key == "sellerInfo" and isinstance(value, dict):
450
+ flat["sellerAlias"] = value.get("alias")
451
+ flat["sellerLocationName"] = value.get("locationName")
452
+ flat["sellerMemberSince"] = value.get("memberSince")
453
+ continue
454
+ if key == "primaryCategory" and isinstance(value, dict):
455
+ flat["category"] = value.get("label") or value.get("categoryID")
456
+ flat["categoryKey"] = value.get("categoryID")
457
+ continue
458
+ if key == "postcodeInformation" and isinstance(value, dict):
459
+ flat["postcode"] = value.get("postcode")
460
+ flat["locationName"] = value.get("locationName")
461
+ canton = value.get("canton") or {}
462
+ flat["canton"] = canton.get("name")
463
+ flat["cantonKey"] = canton.get("shortName")
464
+ continue
465
+ if key == "thumbnail" and isinstance(value, dict):
466
+ flat["thumbnailURL"] = (value.get("normalRendition") or {}).get("src")
467
+ continue
468
+ if key == "images" and isinstance(value, list):
469
+ flat["images"] = "; ".join(
470
+ (img.get("rendition") or {}).get("src", "") for img in value if isinstance(img, dict)
471
+ )
472
+ continue
473
+ if key == "properties" and isinstance(value, list):
474
+ flat["properties"] = "; ".join(f"{p.get('label')}: {p.get('text')}" for p in value if isinstance(p, dict))
475
+ continue
476
+ if isinstance(value, dict):
477
+ for sub_key, sub_value in value.items():
478
+ flat[f"{key}_{sub_key}"] = _scalarize(sub_value)
479
+ continue
480
+ flat[key] = _scalarize(value)
481
+ # search_listings()/visit_all_listings() already embed a lang-correct
482
+ # "url" on the raw item; only fall back to computing a default one here
483
+ # for listings flattened without going through those (e.g. tests).
484
+ flat.setdefault("url", listing_url(item, lang))
485
+ return flat
486
+
487
+
488
+ def order_fieldnames(all_keys: Iterable[str]) -> list[str]:
489
+ ordered = [f for f in PRIORITY_FIELDS if f in all_keys]
490
+ remaining = sorted(k for k in all_keys if k not in ordered)
491
+ return ordered + remaining
492
+
493
+
494
+ def save_csv(rows: list[dict[str, Any]], path: str) -> None:
495
+ if not rows:
496
+ logger.warning("no rows to write")
497
+ return
498
+ all_keys: set[str] = set()
499
+ for row in rows:
500
+ all_keys.update(row.keys())
501
+ fieldnames = order_fieldnames(all_keys)
502
+ with open(path, "w", newline="", encoding="utf-8") as f:
503
+ writer = csv.DictWriter(f, fieldnames=fieldnames, restval="")
504
+ writer.writeheader()
505
+ writer.writerows(rows)
506
+
507
+
508
+ def save_json(rows: list[dict[str, Any]], path: str) -> None:
509
+ with open(path, "w", encoding="utf-8") as f:
510
+ json.dump(rows, f, ensure_ascii=False, indent=2)
511
+
512
+
513
+ @dataclass
514
+ class ScrapeResult:
515
+ """Everything a scrape() call produced, ready to use in-memory or save to disk."""
516
+
517
+ query: str
518
+ total_elements: int
519
+ listings: list[dict[str, Any]] = field(default_factory=list) # raw API objects (summary or full detail shape)
520
+ rows: list[dict[str, Any]] = field(default_factory=list) # flattened dicts, one per listing, CSV-ready
521
+ lang: str = "de" # locale that was scraped, e.g. "de"
522
+
523
+ def to_csv(self, path: str) -> None:
524
+ save_csv(self.rows, path)
525
+
526
+ def to_json(self, path: str) -> None:
527
+ save_json(self.listings, path)
528
+
529
+
530
+ def scrape(
531
+ query: str,
532
+ *,
533
+ lang: str = "de",
534
+ detail: bool = True,
535
+ sort: str = "timestamp",
536
+ max_results: int | None = None,
537
+ delay: float = 1.0,
538
+ verbose: bool = True,
539
+ client: "TuttiClient | None" = None,
540
+ ) -> ScrapeResult:
541
+ """Search tutti.ch for `query` and return the results in memory.
542
+
543
+ This is the library entry point: it does the same work as the CLI but
544
+ returns a ScrapeResult instead of writing files. The CLI (main(), below)
545
+ is a thin wrapper around this function.
546
+
547
+ Args:
548
+ query: Free-text search phrase, e.g. "velo" or "Tesla Roadster".
549
+ lang: Listing/URL locale - "de" (default), "fr", or "it".
550
+ detail: If True (default), visit every listing's detail operation
551
+ one by one to extract every field tutti.ch returns for it
552
+ (coordinates, full-res images, attributes, richer seller info).
553
+ If False, keep only the summary fields from the search results
554
+ (much faster).
555
+ sort: Sort order tutti.ch searches with - "timestamp" (default),
556
+ "price", or "relevance".
557
+ max_results: Stop after this many unique listings, if given.
558
+ delay: Seconds to wait between requests.
559
+ verbose: If True, emit progress via the "tutti_scraper" logger at
560
+ INFO level.
561
+ client: Optional TuttiClient to reuse (e.g. across repeated calls).
562
+ A new one is created (using `lang` and `delay`) if not given -
563
+ if you do pass one, make sure its `lang` matches this `lang`
564
+ argument, since they aren't cross-checked.
565
+
566
+ Returns:
567
+ A ScrapeResult with `.listings` (raw API objects, each including a
568
+ "url" pointing at the original ad) and `.rows` (flattened dicts, one
569
+ per listing, sorted by price).
570
+ """
571
+ client = client or TuttiClient(lang=lang, delay=delay)
572
+
573
+ if verbose:
574
+ logger.info("Searching tutti.ch for %r ...", query)
575
+ nodes = search_listings(client, query, sort=sort.upper(), max_results=max_results, verbose=verbose)
576
+ total_elements = len(nodes)
577
+ for node in nodes:
578
+ node["url"] = listing_url(node, lang)
579
+
580
+ if detail:
581
+ if verbose:
582
+ logger.info("Visiting each of %d listings one by one to extract full details ...", len(nodes))
583
+ listings = visit_all_listings(client, nodes, verbose=verbose)
584
+ else:
585
+ listings = nodes
586
+
587
+ rows = [flatten_listing(item, lang) for item in listings]
588
+ rows.sort(key=lambda r: (r.get("price") in (None, ""), r.get("price")))
589
+
590
+ return ScrapeResult(
591
+ query=query,
592
+ total_elements=total_elements,
593
+ listings=listings,
594
+ rows=rows,
595
+ lang=lang,
596
+ )
597
+
598
+
599
+ def _slugify(text: str) -> str:
600
+ slug = re.sub(r"[^a-z0-9]+", "-", text.strip().lower()).strip("-")
601
+ return slug or "listings"
602
+
603
+
604
+ def build_arg_parser() -> argparse.ArgumentParser:
605
+ parser = argparse.ArgumentParser(description="Scrape tutti.ch listings for a given search phrase.")
606
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
607
+ parser.add_argument("query", help="Search phrase, e.g. 'velo' or 'Tesla Roadster'")
608
+ parser.add_argument("--lang", default="de", choices=["de", "fr", "it"], help="Listing/URL locale (default: de)")
609
+ parser.add_argument(
610
+ "--out",
611
+ default=None,
612
+ help="Output file base name (without extension). Defaults to a slug of the search phrase.",
613
+ )
614
+ parser.add_argument(
615
+ "--no-detail",
616
+ action="store_true",
617
+ help="Skip visiting each listing's detail operation; keep only the summary "
618
+ "fields from the search results (faster, fewer fields).",
619
+ )
620
+ parser.add_argument(
621
+ "--sort",
622
+ default="timestamp",
623
+ choices=["timestamp", "price", "relevance"],
624
+ help="Sort order tutti.ch searches with (default: timestamp)",
625
+ )
626
+ parser.add_argument("--max", type=int, default=None, help="Stop after N listings.")
627
+ parser.add_argument("--delay", type=float, default=1.0, help="Delay in seconds between requests.")
628
+ verbosity = parser.add_mutually_exclusive_group()
629
+ verbosity.add_argument(
630
+ "-v", "--verbose", action="store_true", help="Show debug-level detail, including every HTTP request made."
631
+ )
632
+ verbosity.add_argument(
633
+ "-q", "--quiet", action="store_true", help="Suppress progress output; only warnings/errors are shown."
634
+ )
635
+ return parser
636
+
637
+
638
+ def _configure_cli_logging(*, verbose: bool, quiet: bool) -> None:
639
+ """Set up console logging for CLI use: progress (INFO, or DEBUG with -v)
640
+ goes to stdout, warnings/errors (-q still shows these) go to stderr.
641
+ Only main() calls this - plain library use of scrape() never touches
642
+ logging config, since that would be rude to whatever application
643
+ imported it."""
644
+ level = logging.DEBUG if verbose else logging.WARNING if quiet else logging.INFO
645
+ plain = logging.Formatter("%(message)s")
646
+
647
+ stdout_handler = logging.StreamHandler(sys.stdout)
648
+ stdout_handler.setLevel(level)
649
+ stdout_handler.addFilter(lambda record: record.levelno < logging.WARNING)
650
+ stdout_handler.setFormatter(plain)
651
+
652
+ stderr_handler = logging.StreamHandler(sys.stderr)
653
+ stderr_handler.setLevel(logging.WARNING)
654
+ stderr_handler.setFormatter(plain)
655
+
656
+ logger.handlers.clear()
657
+ logger.addHandler(stdout_handler)
658
+ logger.addHandler(stderr_handler)
659
+ logger.setLevel(level)
660
+ logger.propagate = False
661
+
662
+
663
+ def main(argv: list[str] | None = None) -> int:
664
+ """CLI entry point. Parses argv (defaults to sys.argv[1:]), scrapes, and
665
+ writes CSV + JSON files. Returns 0 on success; lets exceptions propagate
666
+ (see run_cli() for the error-handling / exit-code wrapper used by the
667
+ __main__ guard below)."""
668
+ parser = build_arg_parser()
669
+ args = parser.parse_args(argv)
670
+ _configure_cli_logging(verbose=args.verbose, quiet=args.quiet)
671
+
672
+ result = scrape(
673
+ args.query,
674
+ lang=args.lang,
675
+ detail=not args.no_detail,
676
+ sort=args.sort,
677
+ max_results=args.max,
678
+ delay=args.delay,
679
+ verbose=True,
680
+ )
681
+
682
+ out_base = args.out or _slugify(args.query)
683
+ csv_path = f"{out_base}.csv"
684
+ json_path = f"{out_base}.json"
685
+ result.to_csv(csv_path)
686
+ result.to_json(json_path)
687
+
688
+ logger.info("\nDone. %d unique listings found.", len(result.rows))
689
+ logger.info(" CSV: %s", csv_path)
690
+ logger.info(" JSON: %s", json_path)
691
+ return 0
692
+
693
+
694
+ def run_cli(argv: list[str] | None = None) -> int:
695
+ """Run main() and translate exceptions into (message, exit code) the way
696
+ the command line expects. Factored out from the __main__ guard so it can
697
+ be unit-tested directly without spawning a subprocess."""
698
+ try:
699
+ return main(argv) or 0
700
+ except TuttiError as exc:
701
+ logger.error("Error talking to tutti.ch: %s", exc)
702
+ return 1
703
+ except requests.RequestException as exc:
704
+ logger.error("Network error talking to tutti.ch: %s", exc)
705
+ return 1
706
+ except KeyboardInterrupt:
707
+ logger.error("\nInterrupted.")
708
+ return 130
709
+
710
+
711
+ if __name__ == "__main__": # pragma: no cover - exercised via subprocess in test_e2e.py
712
+ sys.exit(run_cli())