271 lines
14 KiB
Markdown
271 lines
14 KiB
Markdown
# Youngtimer Market Observatory
|
||
|
||
First implementation: collect unnormalized listing observations from Leboncoin
|
||
search pages and individual ads, including descriptions and source-provided locations.
|
||
Finalized raw runs can now be cleaned and imported idempotently into PostgreSQL.
|
||
Manual Docker Compose execution is available; scheduling and automated deployment
|
||
remain deferred.
|
||
Asking prices are not sale prices; disappearing listings do not prove a sale.
|
||
|
||
## Run
|
||
|
||
Python 3.13+ and `uv`:
|
||
|
||
```sh
|
||
uv sync
|
||
uv run playwright install chromium
|
||
uv run car-tracker --family saab-900 --max-pages 1 --max-details 1 --headed
|
||
```
|
||
|
||
The smoke command deliberately caps coverage and returns exit code 1 if further
|
||
pages or details exist. Collect all configured searches and their individual ads with:
|
||
|
||
```sh
|
||
uv run car-tracker --headed
|
||
```
|
||
|
||
Options: `--config searches.toml`, `--output data/raw/leboncoin`, `--family`,
|
||
`--max-pages 10` (per search), `--delay 5` (minimum seconds between requests), `--headed`.
|
||
Detail visits are enabled by default, once per unique listing ID per run.
|
||
`--max-details N` caps detail requests across the whole run (unlimited by default).
|
||
`--search-only` disables detail visits. Each pause is sampled uniformly between
|
||
`--delay` and 1.5 times that value (5–7.5 seconds by default), for both search and
|
||
detail requests. The first request has no added pause. The manifest records the
|
||
minimum and maximum as `delay_seconds` and `delay_max_seconds`.
|
||
More detail visits mean substantially longer runs and greater exposure
|
||
to source access failures.
|
||
Linux may need Playwright's system libraries installed by an administrator.
|
||
Headed mode requires a graphical session. Omitting `--headed` runs headless, but
|
||
headless access failed in the initial live test on this machine.
|
||
Playwright Stealth is enabled for every collection, in headed and headless mode,
|
||
with French language overrides matching the browser locale. It does not guarantee
|
||
access or solve challenges; collection still stops on access blocks without retries.
|
||
Do not run overlapping jobs. No credentials, proxies, or CAPTCHA solvers are used.
|
||
|
||
`searches.toml` defines broad nationwide car-category searches. Add queries/families
|
||
there without changing code. Saab 900 and Volvo 850 queries do not restrict
|
||
generation, body style, or engine. Mercedes 124 queries include chassis spellings,
|
||
but ads using only badges such as "300 CE" may be missed. Search coverage is not a
|
||
complete census, and false positives remain for later cleaning. Overlapping query
|
||
results are intentionally preserved with their query provenance.
|
||
|
||
## Project Structure
|
||
|
||
```text
|
||
src/car_tracker/ # Installed Python package
|
||
__init__.py
|
||
__main__.py # python -m car_tracker entry point
|
||
collect.py # Collector and CLI
|
||
database.py # PostgreSQL migrations and import CLI
|
||
pipeline.py # Manual collect -> raw -> import job
|
||
container_entrypoint.py # Virtual display and graceful shutdown
|
||
transform.py # Pure, conservative silver field cleaning
|
||
migrations/ # Versioned SQL schema and analytical views
|
||
tests/ # Offline tests
|
||
docs/POSTGRESQL.md # Homelab provisioning, import, queries, database tests
|
||
docs/CI.md # Gitea 1.23 CI and repository-scoped runner registration
|
||
ops/ci-runner/ # Optional CI runner infrastructure, not app deployment
|
||
ops/app/ # Manual homelab job and runtime configuration example
|
||
docs/DEPLOYMENT.md # Storage, credentials, network access, and recovery
|
||
searches.toml # Search configuration
|
||
pyproject.toml # Dependencies, build configuration, and CLI entry point
|
||
data/ # Local snapshots (ignored by Git)
|
||
```
|
||
|
||
`uv sync` installs the package in editable mode from `src/`. Run commands from the
|
||
repository root so the default configuration and data paths resolve correctly.
|
||
`uv run python -m car_tracker` also works as an alternative to `uv run car-tracker`.
|
||
|
||
## Silver Import
|
||
|
||
Run the importer **on your laptop**, connecting to PostgreSQL **on your homelab**.
|
||
No local application database is required. See
|
||
[PostgreSQL setup and silver storage](docs/POSTGRESQL.md) for the one-time homelab
|
||
role/database setup, network access, and laptop connection configuration.
|
||
|
||
Once the remote connection is configured:
|
||
|
||
```sh
|
||
uv run car-tracker-db migrate
|
||
uv run car-tracker-db import data/raw/leboncoin/2026-09-08/ed585bcc-188d-401e-bc9f-8bc1318d4ca6/manifest.json
|
||
```
|
||
|
||
Importing an unchanged run again is a no-op. Search and detail observations retain
|
||
separate timestamps; source coverage and data-quality issues remain explicit.
|
||
SQL views expose typed listing history, latest observations, price history, coverage,
|
||
and validation issues. Generation/trim inference, redaction, and scheduling are deferred.
|
||
|
||
## Manual Homelab Job
|
||
|
||
For a manual **Run app** button in Gitea Actions, see [SSH jobs](docs/SSH_RUN.md).
|
||
CI publishes tested images; the button runs a digest-pinned image through a
|
||
restricted SSH command using host-local DB credentials. No automatic
|
||
migrations or retries of failed scrapes are introduced.
|
||
|
||
`car-tracker-pipeline` checks database connectivity/schema, collects raw observations,
|
||
and imports that exact finalized run. Partial/failed collections remain marked as
|
||
such even when their import succeeds. A lock prevents overlapping pipeline jobs on
|
||
the same output directory, and `pipeline.json` records the import outcome separately
|
||
from the immutable raw manifest.
|
||
|
||
For a laptop smoke run with the existing database connection configured:
|
||
|
||
```sh
|
||
uv run car-tracker-pipeline --headed --family saab-900 --max-pages 1 --max-details 1
|
||
```
|
||
|
||
For Docker on the homelab, follow [manual deployment](docs/DEPLOYMENT.md) first to
|
||
configure private raw storage, the password file, and container network access.
|
||
Then, from the homelab checkout:
|
||
|
||
```sh
|
||
docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml build app
|
||
docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app car-tracker-db check
|
||
docker compose --env-file /srv/car-tracker/app.env -f ops/app/compose.yaml run --rm app
|
||
```
|
||
|
||
The default image command is bounded to Saab 900, one search page and one detail
|
||
request. It uses headed Chromium through a virtual display, not a desktop/VNC
|
||
service. No PostgreSQL server, scheduler, or automatic migration is added.
|
||
Live Leboncoin access from the homelab still needs a manual smoke test.
|
||
|
||
## Raw Format
|
||
|
||
**Gzip-compressed JSON Lines (`.jsonl.gz`)**, with one observation per line and one
|
||
file per search page or individual ad. JSON preserves nested attributes and original types, including
|
||
missing fields and source strings. Gzip reduces storage using standard Python tools.
|
||
CSV would flatten or discard structure. Parquet is better considered later, once a
|
||
stable cleaned schema exists. These are filtered raw records, not full-page archives.
|
||
|
||
```text
|
||
data/raw/leboncoin/YYYY-MM-DD/<run-uuid>/
|
||
manifest.json
|
||
search-001-page-0001.jsonl.gz
|
||
search-001-page-0002.jsonl.gz
|
||
detail-3248166132.jsonl.gz
|
||
```
|
||
|
||
Each line contains `schema_version: 2`, `record_type` (`search` or `detail`), `source`,
|
||
`run_id`, UTC `observed_at`, and source `payload`. Search rows also include discovery
|
||
`family`, `search_text`, `search_url`, and `page`; detail rows include `listing_url`.
|
||
Join them using `(source, run_id, payload.list_id)`. A listing discovered through
|
||
multiple queries has multiple search rows but only one detail visit per run.
|
||
Family is a search label, not a validated vehicle classification.
|
||
|
||
Search observations are saved before detail requests. Detail observations have
|
||
their own timestamps and never overwrite search prices or other source values.
|
||
Prices and vehicle attributes are not coerced or normalized. Publication timestamps
|
||
remain distinct from observation timestamps. A new collection creates a new run,
|
||
never overwriting previous runs. Existing version-1 snapshots remain unchanged;
|
||
they lack the newly captured fields and cannot be enriched without recollection.
|
||
|
||
Files are finalized with an atomic rename. The manifest is updated after each page
|
||
and records counts, source-reported totals/page limits, the planned searches, and
|
||
coverage status. Use only finalized files referenced in a manifest for replay.
|
||
An interrupted process may leave `.tmp` files; ignore them. A hard kill can leave
|
||
`status: running`; treat that run as incomplete. Storage failures may also prevent
|
||
the final manifest update.
|
||
|
||
`complete` means all pages reported by the source for each configured query were
|
||
visited and every discovered unique listing had a successful detail visit (unless
|
||
explicitly disabled), not that every relevant ad was found. Results can change
|
||
during pagination; reported totals are diagnostic, not transactional guarantees.
|
||
`partial` means a page/detail cap, unavailable detail, interruption, or failure after
|
||
some pages; `failed` means no pages were saved. Exit code is 0 only for a complete run.
|
||
|
||
Search coverage and `details.status` are tracked separately. Detail coverage refers
|
||
only to discovered IDs, not ads on unvisited search pages. `details.listings` maps
|
||
each discovered ID to `complete`, `unavailable`, `failed`, or `not_attempted`, plus
|
||
its snapshot filename or failure/skip reason. Successful empty discovery needs no
|
||
detail requests; failed discovery does not imply successful detail coverage.
|
||
HTTP 404/410 marks a detail unavailable and continues, without inferring a sale.
|
||
A block (including 403/429), malformed payload, or repeated search page stops the
|
||
whole run without retries. Inspect `error_type`, `reason`, and `http_status` in the
|
||
manifest; exception bodies are not stored to avoid leaking page contents.
|
||
|
||
Read a snapshot without cleaning:
|
||
|
||
```python
|
||
import gzip
|
||
import json
|
||
|
||
with gzip.open("data/raw/leboncoin/<date>/<run>/search-001-page-0001.jsonl.gz", "rt") as f:
|
||
for line in f:
|
||
observation = json.loads(line)
|
||
print(observation["observed_at"], observation["payload"]["price"])
|
||
```
|
||
|
||
## Privacy And Retention
|
||
|
||
The expanded capture was explicitly requested for later vehicle analysis and a map
|
||
dashboard. It retains listing IDs, timestamps, category/status, prices, title
|
||
(`subject`), description (`body`), URL, brand, listing options/counters, and the full
|
||
source `location` object. This includes city/postcode, latitude/longitude, and
|
||
location provenance/precision metadata when supplied. Coordinates are **not proof
|
||
of an exact vehicle or seller address**; they may identify a city centroid or an
|
||
approximate point. Missing coordinates stay missing; no geocoding is performed.
|
||
|
||
Attributes are now retained by default, including newly introduced vehicle fields,
|
||
except known seller/profile/rating/contact fields, plate/VIN identifiers, and the
|
||
external vehicle-history report link. Structured seller objects, contact buttons,
|
||
photos, and related-listing objects are excluded. Unknown top-level fields remain
|
||
excluded; this is not a promise to capture every field the website may ever expose.
|
||
|
||
Titles and descriptions are stored **verbatim, without redaction**. They can contain
|
||
names, phone numbers, addresses, or other personal information supplied by sellers.
|
||
New attribute keys and location fields can also contain unexpected information;
|
||
the field filter is not an anonymization guarantee. Do not publish raw text or
|
||
location records directly in the dashboard. Add privacy review/redaction and an
|
||
appropriate map precision policy before exposing a public dataset or dashboard.
|
||
|
||
Listing IDs are linkable. Treat the raw directory as private: restrict filesystem
|
||
access, do not publish snapshots, and exclude it from Git (already configured).
|
||
New run directories are created with owner-only permissions (`0700` on Linux).
|
||
Initial retention policy: delete whole run directories after **30 days**, including
|
||
backups. Retention enforcement is currently manual, not an automatic deletion job;
|
||
set it up before unattended collection. Full page HTML is processed in browser
|
||
memory, not archived. Do not enable browser tracing or save browser profiles by default.
|
||
|
||
## Source Access
|
||
|
||
On 2026-09-08, direct HTTP search returned 403, while the inspection browser loaded
|
||
a Saab 900 search and exposed 35 records in
|
||
`#__NEXT_DATA__.props.pageProps.searchData` (101 reported results, three pages).
|
||
The collector therefore uses Playwright Chromium and reads that embedded JSON.
|
||
Individual ads expose `#__NEXT_DATA__.props.pageProps.ad`; inspection confirmed a
|
||
description was populated there while the search record's `body` was empty.
|
||
Access can differ across browser environments,
|
||
IP addresses, and runs. This observation is not proof of reliable daily access or
|
||
permission to collect; review applicable source terms and requirements before
|
||
unattended deployment. A challenge or changed payload is a failure, not zero ads.
|
||
|
||
The original search-only Python CLI was also tested on 2026-09-08: headless collection failed
|
||
before saving records; headed Chromium saved **35 real Saab 900 observations**.
|
||
A subsequent headed pagination test saved page 1, then received HTTP 403 on page 2.
|
||
Its manifest correctly reports `partial`, `SearchAccessError`, and `http_status: 403`.
|
||
Live first-page acquisition is verified; full pagination, all-family coverage, and
|
||
reliable unattended daily collection are **not** verified. The collector stops on
|
||
that access failure rather than retrying or presenting partial data as complete.
|
||
|
||
The expanded CLI was tested with `--max-pages 1 --max-details 1 --headed` on the
|
||
same date: it saved 35 search observations and one successful individual-ad snapshot
|
||
under run `ed585bcc-188d-401e-bc9f-8bc1318d4ca6`. The run is intentionally partial
|
||
because of both limits. Full detail coverage and reliable unattended collection
|
||
remain unverified.
|
||
|
||
## Tests
|
||
|
||
```sh
|
||
uv sync --locked --dev
|
||
uv run --locked ruff check src tests
|
||
uv run python -m unittest discover -s tests -v
|
||
```
|
||
|
||
Tests use clearly synthetic in-memory responses and temporary storage. They never
|
||
contact Leboncoin, and fixtures are not mixed with real observations.
|
||
|
||
[Gitea CI](docs/CI.md) runs linting, the full suite against disposable PostgreSQL,
|
||
and package validation on pushes and pull requests. Enable Repository Actions and
|
||
register the documented `python-ci` runner before expecting remote jobs to execute.
|
||
CI never scrapes Leboncoin or connects to the homelab application database.
|