One-off personal scrape of four Czech cottage/hut-rental sites (e-chalupy.cz,
chatyachalupy.cz, cs-chalupy.cz, chata.cz) to find a place for a group
weekend trip, filtered on capacity, budget, and distance to the nearest
bus/train stop. See pipeline/config.py for the actual trip requirements (dates,
minimum/preferred capacity, budget ceiling) - edit that file (or set the
matching environment variable) to reuse this for a different trip.
Standalone from the Bazos scraper project on purpose - unrelated domain, kept out of that repo's tests/lint/git history.
Requires uv.
uv sync
A single CLI (cli.py) replaces running each stage by hand. It bakes in
every ordering constraint that used to live only in prose here (each stage
mutates what the next one reads, so order matters):
uv run python cli.py all # scrape -> enrich -> geocode -> transit -> filter -> export
uv run python cli.py fast # scrape -> geocode -> filter -> export (skips the slow enrichers + transit.py)
all is the real full pipeline. fast exists because the enrichers and
transit.py are slow (one HTTP request per listing) - use it for a quick
rerun when you haven't changed anything those steps would need to pick up.
Re-running just one stage (e.g. after tweaking config.py's budget):
uv run python cli.py filter && uv run python cli.py export
One scraper or one enrichment step only:
uv run python cli.py scrape --only e_chalupy
uv run python cli.py enrich --only common-room,ratings
enrich's default order is common-room, ratings, availability - all
three patch data/raw/*.json in place and must finish before geocode.
reparse-prices is not in that default order: it was a one-time
migration that repaired records scraped before a cs_chalupy.py price-table
bug was fixed, and new scrapes already get the correct price directly. Run
it explicitly (cli.py enrich --only reparse-prices) only if a similar
upstream bug is ever found again.
Each pipeline module also still runs standalone with -m, the same
convention scrapers/ already uses - never invoke a moved file by bare path
(e.g. python pipeline/geocode.py), since that breaks the package's
absolute imports:
uv run python -m pipeline.geocode
uv run python -m pipeline.filter
uv run python -m enrichment.ratings # all 3 sites
uv run python -m enrichment.ratings e_chalupy # just one
Rerunning pipeline/geocode.py overwrites data/combined.json from
data/raw/*.json from scratch, discarding anything transit.py added -
cli.py all/fast always call them in the right order automatically, but
if you run geocode/transit as separate stages by hand, always rerun
transit right after geocode, or the report loses travel-time data. It
does, however, patch any newly-resolved lat/lon/nearest_stop_* back
onto each site's own data/raw/*.json as it goes, so a resolved record is
never re-queried against Nominatim/Overpass on a later run - only genuinely
new or still-unresolved records cost a fresh lookup.
Output: data/raw/*.json (one file per site), data/combined.json (merged,
geocoded, transit-enriched), data/filtered.json (ranked shortlist meeting
the requirements in pipeline/config.py), report-data.json (trimmed for
the page).
pipeline/config.py holds the tunable knobs, each overridable via an
environment variable of the same name without touching code:
| Name | Default | Meaning |
|---|---|---|
TRIP_CHECKIN / TRIP_CHECKOUT |
2026-11-13 / 2026-11-15 | the weekend being checked, ISO date |
MIN_CAPACITY |
17 | hard minimum beds/persons |
PREFERRED_CAPACITY |
30 | scored as a bonus, not a hard filter |
BUDGET_MAX_CZK |
30000 | whole-object price ceiling for the whole stay |
DEFAULT_REQUEST_DELAY_SECONDS |
1.0 | pause between requests in the scrapers |
ORIGIN_NAME |
Praha |
where the group travels from - IDOS.cz f= search text |
ORIGIN_LAT / ORIGIN_LON |
Prague center | for the straight-line prague_distance_km field |
IDOS_DEPARTURE_TIME |
15:00 |
departure time used for the IDOS.cz journey query |
MAX_PRAGUE_DISTANCE_KM |
unset (no cap) | hard filter in filter.py; set to narrow the search radius |
Example: MIN_CAPACITY=15 BUDGET_MAX_CZK=20000 uv run python cli.py filter
re-filters already-scraped data without rerunning the scrapers.
- Price fields are inconsistent across sites (per night / per weekend / per
week / per room, and sometimes per-person rather than whole-object -
a cheap-looking per-person rate can total more than a flat cottage rate
for a real group).
filter.pyclassifies each record'sprice_basistext and outputs_price_estimate_low_czk/_price_estimate_high_czk(assumingMIN_CAPACITY/PREFERRED_CAPACITYguests respectively) instead of a single number - treat both as estimates, not quotes. - cs-chalupy.cz publishes a real per-day availability calendar on every
detail page (
#obsazenost);scrapers/cs_chalupy.py::parse_availabilityreads it directly againstTRIP_CHECKIN/TRIP_CHECKOUT. A listing that doesn't expose the widget at all still leavesavailable_for_trip=None(unknown, not "unavailable") - runuv run python cli.py enrich --only availabilityto (re-)check already-scraped records without re-scraping everything else. It does, however, publish exact walking distance to the nearest bus/train stop itself, so those records skip the geocoding step entirely. - chata.cz has no scrapable price field at all (dynamic JS booking widget,
not static HTML) -
price_from_czkis always null there. Its results are markedavailable_for_trip=Truebased on the search itself already being date-filtered server-side, not independently re-verified per listing. - e-chalupy.cz has no capacity-filter search - every candidate is found by crawling all 19 regions' listing pages and filtering in Python, which turned up ~2000 candidates for capacity>=20 alone. Detail-page visits there are capped (see the scraper's own log/report for the exact count and which capacity range got covered) rather than visiting every one.
- chata.cz's robots.txt explicitly names and blocks
ClaudeBot(plus several other AI/SEO bots); this was a deliberate, explicit call by the project owner for this one-off personal, non-commercial lookup - not a default posture to carry into other projects. transit.py's IDOS.cz travel-time lookup only resolves a fraction of listings - its own scraped address text is often too vague (a bare region name) or too specific (a full postal address with house number) for IDOS's place search, and the Nominatim reverse-geocode fallback doesn't always turn up a name IDOS recognizes either. Unresolved listings fall back to the straight-lineprague_distance_km, which is always populated wherever coordinates exist.building_type(single building vs. multiple small units) andhas_common_roomare both inferred from listing text, not structured site data (except e-chalupy.cz's own "N objektu" card tag and itsicon-commonroommarker, and cs-chalupy.cz/chata.cz's "společenská místnost" amenity checkbox, which are read directly). Treatunknownas genuinely unknown, not "no".