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
|
+
[](https://github.com/danyk20/tutti-scraper/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/tutti-scraper/)
|
|
39
|
+
[](#testing)
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
[](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,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())
|