since the 14 Sep crawl, in band at €50,000 (court auctions excluded): 93 new · 7 not seen in the 15 Sep crawl · 0 unchanged — that is all 100 homes. No price moved.
Not drawn: 2 crawled places the coordinate table cannot place — not counted among the dotted circles, and never drawn from a listing's own coordinate: Ile Maurice (Île Maurice, 74501, France: no place/boundary match in OSM); Tirol Other (crawl bucket for Tirol postals with no district seat (scope.py returns it as a fallback) — it is a grouping, not a place in OSM). The table's full wording is under “How this is computed”.
Live verdicts could not be loaded — showing nothing ruled out is not the same as none ruled out. Rule-outs made on the live host are stored on the server and reappear on the next visit.
In band means asking price ≤ €50,000, property type house or apartment, not a rent, not a court auction — this exact filter, applied at build time:
SELECT COUNT(*) FROM housing_listing WHERE
property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';
15 towns hold a home at or under €50,000 (SELECT COUNT(DISTINCT city_slug) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';) and they hold 100 of them (SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';). The cheapest in band is €12,000 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';). Those are the figures the surface prints.
The ceiling control re-runs that filter live. Every priced house or apartment row at or under €1,000,000 is embedded in this file (955 rows) and the surface filters it in the browser: move the ceiling and the headline, the ranked list, the town sheet, the coverage map and the change strip all re-scope to your number — the map's discs, its caption, its legend and its accessible label included, and the strip's three figures still adding up to the headline. Nothing is re-typed — the ceiling only replaces the ≤ bound, so a figure at the default (€50,000) is still the query above. Rents and non-residential rows are not embedded at all, so no setting of the control can pull them in.
Court auctions are excluded from that canonical figure. 36 more rows are in band with the auction flag set: SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%' AND is_auction = 1; The auctions switch on the surface adds them back on purpose, and says so while it is on; each auction row is then labelled on its card and in its town's row.
Rents are excluded everywhere. 35 rows carry a rental-category portal URL whose price is a monthly sum, not a sale price: SELECT COUNT(*) FROM housing_listing
WHERE url LIKE '%/d/miet%'
OR url LIKE '%-mieten/%'; The category segment is what does the excluding — /d/mietwohnungen/, /d/haus-mieten/, /d/gewerbeimmobilien-mieten/. An earlier version matched the bare substring miet anywhere in the URL, which also dropped two genuine for-sale flats whose slug says vermietet (a let flat offered for sale, an Innsbruck one and a Zell am See one); the two are embedded rows again and no in-band figure moves, because both ask far above the band.
Rustico flags. A row is flagged rustico when the listing that row NAMES carries the word in its own title — 8 in-band rows do. It is that listing's own flag, never a town-wide count: where a town holds rustici that are not the home the row names, they are said in words on the same row ("2 more rustico in town") instead of being lent to the price beside it. The flag rides the same listing as the price and the size, so a cheap asking price cannot be read as a move-in price: SELECT city_slug, COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%'
AND title LIKE '%rustic%' GROUP BY city_slug;
The list is the answer: every town with stock under the ceiling, sorted by how much stock it has — one league table, stock first. Each row names one real home — the cheapest in that town under the ceiling, auctions excluded, that the latest crawl still saw — and prints that listing's asking price, that listing's advertised size and that listing's €/m², all three from one housing_listing row, with the listing ↗ link opening that same row at its portal. A price and a €/m² from two different properties are never printed side by side: that is how a row once implied a bargain nobody was selling. Area grouping is a heading inside that order, not an order of its own: an area heading appears wherever the area changes and says (continued) if it resumes further down, because sorting by area first put a town with 4 homes above a town with 5. The rank number is the row's position in reading order, with each town's own stock count in the homes column.
A home the latest crawl did not see is never sold to the reader as the cheapest. 7 of the 100 in-band homes are absent from the newest crawl window (SELECT DISTINCT substr(observed_at, 1, 10) FROM housing_price_snapshot ORDER BY 1;), so a row names the cheapest home the crawl still saw and says in words how many others it did not — “7 not seen in the 15 Sep crawl” on the row, “not seen in the 15 Sep crawl” on the card. The canonical MIN(asking_price_eur) in band is €12,000 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';); the cheapest one the newest crawl still observed is €12,000, and that is what a row names. A price from a crawl we missed is not the freshest number on a page whose whole subject is what changed.
The headline prints the cheapest of exactly the rows its own count is taken from — the same ceiling and the same auction scope, at every setting. So while the auctions switch is off the headline's price is €12,000 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';), and with it on the count grows to 136 and the price follows the auctions down to €7,790 (SELECT MIN(asking_price_eur) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';) — a court auction is cheaper than any home on the market in this band, and a count that included it beside a price that did not would be two figures for one scope. The ranked rows do not do this: a row names the cheapest home in its town that the latest crawl saw, never an auction.
A row also respects your rule-outs. The home a row names is the cheapest in band that you have NOT ruled out, and when every in-band home in that town is ruled out the row says so and points at the Undo pinned in the town sheet, instead of advertising a listing the page just recorded as rejected. Rule-outs are fetched live from GET /api/ruleouts; none is baked into this file.
The detail map above the list is geography — the towns inside its window that hold stock at your setting are named there (12 at the €50,000 default); a town inside it with nothing in band is drawn as the same hollow dotted circle the coverage map uses, unnamed, and every crawled place is drawn on the coverage map below, including the window's own dots, and all of them are in the list.
Valleys and distances. Each row carries the valley from TOWNS.md where that table knows the town, and a rough straight-line distance from Torino: great-circle distance at 6371 km mean radius between the town's own point and the Torino point, both taken from src/scrappy/housing/data/town_coords.json — the same table, and the same points, that position the dots, reproduced with uv run python -m scrappy.housing.geo show. The area group a town is listed under is that table's own region field for it. It is a straight line, not a road distance: printed with the formula visible here rather than implied to be a drive time. Torino itself is labelled a city, because that is what the coordinate table calls it and what it is: it is not an alpine village, and its own court auctions are counted on its row.
Two panels, because one Alps-wide frame cannot name twelve towns that sit inside 115 map units of each other: the detail window is chosen by a search over candidate windows (most towns inside, then most homes, then the smallest window), and the coverage map below it draws every crawled place — green where it holds homes in band, dotted where nothing in band came back, and with the window's own dots drawn inside its dashed frame rather than left as blank ground. 1 map unit ≈ 1.02 km of ground at this latitude, equirectangular.
118 places have been crawled (SELECT COUNT(DISTINCT city_slug) FROM housing_listing;). 15 of them hold in-band stock. 101 are drawn as dotted circles: crawled, placed, and nothing in band — that is the same figure the coverage map draws, dot for dot.
Every crawled place that has coordinates is drawn on the coverage map, including the ones inside the detail window. The window is marked as a dashed region with its own label (12 towns · 92 homes) rather than by leaving its dots out: omitting them made twelve towns holding 92 of the 100 in-band homes render as blank ground, and blank on that map means never crawled — so the picture pointed at Trentino while the stock sat in the Soana/Lanzo/Pellice cluster. The map's caption, its accessible label, its legend and the dots it actually draws are one and the same figure (116 drawn = 101 dotted + 15 green at the €50,000 default), and all four follow the ceiling and auction controls live: move the ceiling to €25,000 and the discs, the caption, the legend and the label re-count themselves from the same embedded rows the headline re-counts, so the picture can neither overstate nor understate the stock it is drawn from. Blank means one thing only: never crawled.
The coverage map draws 116 of the 118 crawled places; the other 2 cannot be placed — the coordinate table documents them as unplaceable, they are named below and counted nowhere else. The caption on the surface carries the key and this setting's counts, and nothing else — that sentence and both named places live here, so the figure is on the page without spending a line of the first screen on prose. The dashed box on the map is the detail window — 12 towns and 92 of the 100 in-band homes — not an omission: its own dots are drawn inside it like every other place's. The green and dotted counts that the legend and the accessible label print move with the ceiling and the auctions switch, and each states which auction scope it used; the 116 of the 118 sentence does not move, because the crawl does not. Blank ground is the absence of a crawl and nothing else: a dotted circle is a place that was visited and held nothing in band, a green disc one that was visited and did, and the two unplaced places above are the only ones this map cannot show at all.
2 crawled places are not drawn — and not because they lack coordinates, which is what this section used to claim: one of them (tirol-other) carries lat/lon on its own rows. They are places the coordinate table documents as unplaceable, so drawing them would either invent a position or contradict the page's own disclosure. They are NOT counted among the dotted circles, because a dotted circle means crawled and empty:
none — 2 further crawled places with no in-band homes: Ile Maurice, Tirol Other
Why each is unplaced, in the coordinate table's own written words (same command):
116 crawled places are on the map, © OpenStreetMap contributors, ODbL. Blank ground means never crawled, which is not the same as crawled-and-empty: a dotted circle is the second state, a green disc the first, and the two crawled-but-unplaced places above are the one exception — named, never pretended to be unvisited.
1173 rows across 118 places (SELECT COUNT(*) FROM housing_listing;). Of those, 1073 are residential sale rows (SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent');), of which 1021 carry a price (SELECT COUNT(*) FROM housing_listing WHERE property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND asking_price_eur IS NOT NULL;); 1109 rows carry a price of any kind (SELECT COUNT(*) FROM housing_listing WHERE asking_price_eur IS NOT NULL;), so 88 priced rows are land, commercial or rent and sit outside the band entirely.
Region counts from the portal are page-1 lower bounds — each TrovaCasa page shows at most 24 cards, and ORPI towns are detail-cap bounded samples. The band figures above are computed from the rows we hold, not from those page counts.
What changed: 93 of the 100 in-band listings first appeared in the newest crawl day (SELECT COUNT(*) FROM housing_listing
WHERE date(first_seen) = (SELECT MAX(date(first_seen)) FROM housing_listing)
AND property_type IN ('house','apartment')
AND (listing_kind IS NULL OR listing_kind != 'rent')
AND (is_auction IS NULL OR is_auction = 0)
AND asking_price_eur IS NOT NULL
AND asking_price_eur <= 50000
AND url NOT LIKE '%/d/miet%'
AND url NOT LIKE '%-mieten/%';). Crawls are newest-first and page-capped, so a listing that left the window was pushed out by newer ones: that is not a sale and is not counted as one.
The strip under the headline is that diff, on the surface. New, not-seen and unchanged figures come from scrappy.housing.changes.build_changes, which compares the newest observation day in housing_price_snapshot with the most recent one before it — SELECT DISTINCT substr(observed_at, 1, 10) FROM housing_price_snapshot ORDER BY 1; The three figures are a partition of the SAME set the headline counts: new + not seen in the newest crawl + unchanged = the headline total, for the same ceiling and the same auction setting, and the strip names that scope in the same sentence. (Before this round the strip printed only two of the three parts — “93 of the 100 first appeared · 7 no longer listed” — which closed by coincidence at the €50,000 band and silently dropped 193 of the 821 homes at €1,000,000, reading as if the band had shrunk. A price move is not part of the partition — a home that moved was still observed in both crawls — so it is a separate sentence.) The strip reports what it can support and nothing more: with fewer than two observed days it says so instead of inventing a delta, and a listing absent from a page-capped, newest-first window is reported as not seen in the 15 Sep crawl — never as no longer listed and never as sold, because window-absence is not evidence of a sale. Nothing there reads the wall clock: the newest day is the data's own MAX(observed_at).
A dot is a place the crawl has actually visited. Positions are real coordinates, never guesses. The primary source is the committed town table src/scrappy/housing/data/town_coords.json — one point per crawled place, from OpenStreetMap (Nominatim, with an Overpass fallback; the TOWNS.md target towns the table knows are reused verbatim from their OSM place nodes). It is read offline by scrappy.housing.geo and reproduces with uv run python -m scrappy.housing.geo show. It places 116 of the 118 places the crawl covers. Any place the table does not cover at all falls back to its own coordinates in the database — 40 crawled places carry them (SELECT COUNT(DISTINCT city_slug) FROM housing_listing
WHERE lat IS NOT NULL AND lon IS NOT NULL;) — and then to TOWNS.md. A slug the table documents as unplaceable is disclosed above instead, never drawn from a single listing's coordinate.
Every card carries a collapsed list of the things a visit would settle — no photo on file, a short description, no address, no listing date — each with the reason it is missing, so a buyer can ring the agent about named gaps rather than read a score. The engine also computes a weighted visit-worthiness number for each listing (scrappy.housing.triage.UNKNOWN_WEIGHTS), and an earlier version of this page printed it as "evidence nn/100 unknown". It is deliberately NOT printed any more: 16 of the 17 in-band Locana listings carried the identical 65, so the figure separated nothing a buyer could act on, and a number that cannot be acted on is worse than the list of gaps it stood for. The engine value is unchanged and still available with uv run python -m scrappy.housing.triage.
Source: data/alpine-housing.db, 1173 listings across 118 places, newest observation 2026-09-15T10:22:47Z. This page is rebuilt from that database by web/build_dashboard.py on every refresh; nothing on it is hand-typed. The build stamp is the newest last_seen in the database, not the time of the build, so rebuilding unchanged data reproduces this file byte for byte.
Ruled-out verdicts are not in this file. They live in the database table housing_rule_out, so they survive every refresh, and are fetched live from GET /api/ruleouts when the page opens — undo from the card, or scrappy housing ruleout <entity_key> <verdict> --undo. The generator calls the triage engine with ignore_rule_outs=True, so a verdict can never change this page's bytes. If the fetch fails the page says so instead of showing a silent “nothing ruled out”. The ceiling and the town you last opened are kept in the URL (?c=100000 and #locana) and in this browser's own storage, so both survive a reload, the back button and the phone's back gesture.