Saleor Store
우리 컬렉션을 쇼핑하세요
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A small tool that watches for new sommerhus (fritidshus / holiday home)
listings for sale in a set of Danish areas, and shows them on a simple
website you can open on your phone.
There's no database and no server that runs all the time. It's just:README 전문 보기 ↗A small tool that watches for new sommerhus (fritidshus / holiday home) listings for sale in a set of Danish areas, and shows them on a simple website you can open on your phone.
There's no database and no server that runs all the time. It's just:
scrape.py) that checks Boliga.dk's listings and writes
the results to a file: docs/data.json.rental_scrape.py) that checks which big rental
companies operate in each area, and writes docs/rentals.json.docs/index.html plus docs/rentals.html) that reads
those files and displays them. No frameworks, no build step - you can
open it by double-clicking it.docs/
folder as a website, so the page is always showing the latest data
without you doing anything.The very first time the scraper sees a listing, it stamps it with
first_seen (the date and time). Every time after that, it leaves
first_seen alone, even if the listing's price or details change. The
website uses that timestamp to show a "NY" (new) badge on anything first
seen in the last 48 hours.
If a listing disappears from Boliga (most likely because it sold, or the
agent took it down), the scraper doesn't delete it. It gets marked
"sold_or_removed": true and stays in the data file, so you keep a full
history of what you've seen instead of it just vanishing.
| File | What it's for |
|---|---|
scrape.py | The for-sale scraper. Run with python scrape.py. |
rental_scrape.py | The rental-companies checker. Run with python rental_scrape.py. |
areas.yaml | The list of areas and postal codes to watch. Edit this to add/remove areas. |
config.yaml | Optional filters (max price, min size, etc.) and politeness settings. |
rental_sources.yaml | Which page on each rental company's site covers each area. |
docs/index.html | The main website (for-sale listings). |
docs/rentals.html | The "which companies rent here" subpage. |
docs/data.json | The for-sale data. Rewritten every run - don't edit by hand. |
docs/rentals.json | The rental-companies data. Rewritten every run - don't edit by hand. |
.github/workflows/scrape.yml | Tells GitHub to run the scraper, rental checker, and scoring engine every hour. |
.github/workflows/sold_cache.yml | Tells GitHub to refresh the sold-price cache once a week. |
requirements.txt | The Python packages everything needs. |
vercel.json, .vercelignore | Tell Vercel this is a static site living in docs/, not a Python app. |
sold_cache.py | Fetches sold-price comparables from Boliga. Run with python sold_cache.py. |
bbr_enrich.py | Adds official building-register (BBR) details to each listing, read off Boliga's own pages. |
docs/analyser.html | Standalone investment calculator (mortgage, DSCR, IRR, etc.) - not tied to tracked listings. |
api/analyse.py | Vercel serverless function: fetches a pasted Boliga link server-side for analyser.html's link lookup. Vercel-only, see Phase 5. |
mispricing.py | Fits the pricing model and predicts each listing's "fair" price. |
coastal_distance.py | How far a point is from the Danish coastline, in metres. |
yield_calc.py | The net rental yield calculation, as a standalone function. |
motivation.py | The seller-motivation sub-score. |
composite.py | Combines everything into the final 0-100 score. |
score.py | Runs the whole scoring pipeline and writes the scores into docs/data.json. |
sold_cache.json | Cached sold-price data. Rewritten weekly - don't edit by hand. |
rental_benchmarks.yaml | Edit this yourself - typical weekly rental prices per area (see below). |
flags.yaml | Edit this yourself - manual veto flags per listing (see below). |
data/coastline_denmark.geojson | The Danish coastline shape, downloaded once. See data/README.md. |
pip install -r requirements.txt
python scrape.py
Then open docs/index.html in a browser. Note: because browsers block a
local page from fetching a local JSON file for security reasons, opening
index.html by just double-clicking it may show "Kunne ikke hente
data.json". If that happens, run a tiny local web server from the project
folder instead:
python -m http.server 8000 --directory docs
and open http://localhost:8000 in your browser. Once it's on GitHub
Pages this isn't an issue - it only affects testing on your own computer.
The scraper refuses to run again if the last run was less than about an
hour ago (to stay polite to Boliga's servers). Add --force to override
that, e.g. while testing changes.
main) and change
the folder dropdown from / (root) to /docs. Click Save.https://<your-username>.github.io/<repo-name>/) - that's your site.
It can take a minute or two to go live the first time.docs/data.json
updated every hour, and Pages will automatically serve whatever is in
docs/ on the default branch.If the hourly runs aren't happening: go to the Actions tab on the repo and check the "Scrape sommerhus listings" workflow has runs listed (not greyed out/disabled), and open a run to see any error.
propertyType=4 is what fritidshus/sommerhus listings use.
This isn't documented anywhere - I found it by querying the endpoint
directly and checking that the results were actual holiday homes in
known holiday-home areas (Blåvand, Bornholm, etc).zipcodeFrom/zipcodeTo
only support a numeric range, not an arbitrary list, and your areas
have non-adjacent postal codes (e.g. Blåvand is 6857, 6853, 6854, 6830 -
not a clean range). So the scraper asks Boliga once per postal code
instead. With ~33 postal codes across all areas and a 1.5 second pause
between requests, a full run takes roughly a minute.propertyType=4 - it looks like sponsored/boosted listings
can bypass the filter. The scraper throws these away itself rather than
trusting the API completely.config.yaml.https://www.boliga.dk/bolig/{id}/{slug}.
Verified this resolves correctly (redirects to Boliga's canonical page
for that address) rather than guessing.first_seen, per the spec. This is
a fixed constant in docs/index.html (NEW_WINDOW_HOURS) if you want to
change it later.priceChangePercentTotal, downPayment, and a field literally called
exp that turned out to be Ejerudgift once checked against a real
listing page), or computed directly from your own tracked listings
(the area average is just the mean kr/m² across everything currently
active in that area, recalculated by scrape.py every run). Two fields
that looked promising in Boliga's raw response - evaluationPrice
(public valuation) and lastSoldPrice/lastSoldDate (sale history) -
turned out to always be zero/empty across every listing checked, so
those aren't shown; showing them would mean showing a made-up number.
There's no mortgage payment estimate, transaction cost estimate, or
rental income/yield on the site - those would need real assumptions
(interest rate, loan term) or data sources that don't exist publicly,
and weren't part of what was asked for in this pass.docs/rentals.html (linked from the top of the main page) shows, for each
area, which of the big Danish rental companies operate there: DanCenter,
Novasol, Sol og Strand, and Feriepartner. It's driven by rental_scrape.py
and rental_sources.yaml, refreshed on the same hourly schedule as the
for-sale data.
What it actually shows, and why it's not a clean "5 companies, 1 number each" table:
"Din søgning fandt 66 ferieboliger."), so this one is trustworthy and genuinely current.docs/rentals.html shows
a note about this rather than a made-up answer.robots.txt
before scraping - only publicly listed, crawlable pages are used
(nothing behind a disallowed search/booking path).robots.txt explicitly allows crawling - so rental_scrape.py uses an
ordinary browser User-Agent string for its requests, unlike scrape.py's
more honest, descriptive one for Boliga (which Boliga has no problem
with).If you want real live counts from Novasol, Sol og Strand, and Feriepartner
too, the next step would be adding Playwright to requirements.txt and
having the GitHub Actions workflow install a headless Chromium browser -
that's a real jump in complexity and run time for this project, so it
wasn't done by default.
Every active listing on the site now gets a 0-100 score, shown big and bold on its card, plus a plain-language reason line and a "Se score-detaljer" button that expands the full breakdown.
Before you rely on this for anything: read the "What the score is (and isn't)" section below. It's also on every card and in the score breakdown, not just here.
The score is a weighted combination of three independent things, each turned into a percentile rank within the current pool of listings before being combined (not a raw z-score - sommerhus prices are heavily right-skewed by a handful of very expensive liebhaverhuse, and a z-score would let those few houses distort everyone else's score):
| Component | Weight | What it measures |
|---|---|---|
| Mispricing | 40% | Is the asking price cheap or expensive vs. what similar recently-sold houses suggest it should cost? |
| Nettoafkast | 30% | If rented out through a bureau, would it earn a decent return after real Danish taxes and costs? |
| Sælgers motivation | 20% | Is there room to negotiate - long time on market, price cuts, a relist, an off-season listing? |
| Kvalitet | 10% | Newer construction and a better energy label. |
Change the weights in config.yaml under score_weights if you disagree
with this balance.
Mispricing comes from a hedonic regression - a statistical model that learns typical prices from real recent sales, then compares each live listing's asking price to what a house with its exact characteristics "should" cost:
log(price) = b0 + b1·log(size) + b2·log(lot_size) + b3·build_year
+ b4·energy_label + b5·log(distance_to_coast) + b6·postnummer
Fitted on sold_cache.py's data: sold fritidshuse in your tracked postal
codes from the last 24 months, restricted to genuine arm's-length sales
(Boliga's sold-price data separately marks family sales and a vague
"other" category, both excluded as unreliable market comparables). As of
the last run, that's 6,026 sold houses feeding the model.
A few things worth knowing about the model itself:
areas.yaml) rather than getting its own
unreliable estimate. The card says so when this happens.mispricing.py/score.py on every run) is below the 0.6
"meaningful residuals" threshold for almost every single postnummer -
usually in the 0.2-0.5 range. This is a real, honest result, not a bug:
five structural variables (size, plot, age, energy label, coast
distance) simply can't explain most of what makes one sommerhus worth
more than a very similar-looking one next door. Condition, view, noise,
layout, kerb appeal - none of that is in Boliga's data. This is exactly
why the disclaimer below matters and is shown prominently, not buried.Nettoafkast is yield_calc.py's standalone function, using the 2026
Danish rules for renting a sommerhus out through a bureau (skematisk
metode). It needs rental_benchmarks.yaml filled in for an area before it
can compute anything for listings there - see "Setup you need to do"
below. Important caveats, also printed on every card that shows a yield:
Sælgers motivation combines five signals Boliga doesn't compute for
you but this project already tracks over time: how long the listing has
been up vs. similar houses nearby, the cumulative price cut, how many
separate times the price was cut (scrape.py counts this itself, run
over run - Boliga only exposes the cumulative total), whether it was ever
pulled and relisted (also tracked run over run), and whether it was first
listed in the Oct-Feb off-season. See motivation.py for the exact
weights.
Kvalitet is just build year and energy label - the brief's third factor, renovation year, isn't in Boliga's structured data anywhere, so it was left out rather than estimated. The 10% weight is split evenly across the two factors that are actually available.
flags.yaml)None of these six things are visible in Boliga's data - they need a human
to notice them from the listing text, a map, or an actual visit:
lejet_grund, kloakering_paa_vej, strandbeskyttelse,
kystnedbrydning, udlejning_forbudt, and
elvarme_daarligt_energimaerke (which you don't need to set by hand -
any listing with energy label E, F, or G is auto-flagged, since electric
heating with a poor label usually means real running costs; override it
to false in flags.yaml for a specific listing if you've confirmed
that's not actually a problem there).
Anything flagged drops out of the scored/ranked list entirely and shows instead in a separate, greyed-out "Udelukket" section at the bottom of the page - kept, not deleted, but out of the way so it doesn't crowd out houses you can actually compare fairly.
The scoring engine works out of the box for mispricing and motivation, but
yield won't show up for any area until you fill in
rental_benchmarks.yaml - open that file, and for each area look up a
few comparable houses on Novasol or Sol og Strand in that postnummer, and
fill in a typical high-season and low-season week price. This is
deliberately manual (see the "Phase 2" section above for why the rental
companies' real prices can't be scraped).
The score is a filter for where to spend a Saturday, not a decision. A large negative mispricing residual usually means the model is missing a variable it has no way to see - condition, sea view, road noise, an awkwardly shaped plot - not that a bargain was found. Given the per-postnummer R² figures above, treat the mispricing number as a rough steer, not a valuation. Always go look at the actual house, and its actual listing text, before acting on any of this.
python sold_cache.py # weekly job - fetches sold-price comparables (slow: ~10-15 min)
python mispricing.py # optional - just prints the model report, doesn't change any files
python score.py # the actual scoring step - reads docs/data.json, writes scores back into it
sold_cache.py refuses to re-run within about a week unless you pass
--force, same pattern as the hourly scraper's one-hour guard.
One more honest tradeoff: sold_cache.json (~7MB) is now committed weekly,
and docs/data.json (~4.6MB, up from under 1MB before scoring - each
listing now carries its 5 nearest comparables and full score breakdown) is
committed hourly. Git compresses repeated commits of a mostly-unchanged
file reasonably well, but this repo's history will grow noticeably faster
than before. Not a problem worth solving pre-emptively for a personal
project, but if it ever becomes one, the fix is squashing history
periodically rather than changing the file format.
One implementation note: sold_cache.py is the one script in this project
that doesn't make requests strictly one at a time. Backfilling lot size
and energy class needs one extra request per sold record (several thousand
for a 24-month nationwide window), and Boliga's estate-detail endpoint
showed no rate limiting even under a burst of test requests - so this one
script uses a small pool of 5 concurrent requests to keep a weekly refresh
down to minutes instead of hours. Everything else in this project still
makes one request at a time. Separately, Boliga's sold-search endpoint
(unlike its regular search endpoint) turned out to have its own strict
rate limit - 5 requests per ~11 seconds, discovered by watching its
X-RateLimit-* response headers - so that part paces itself accordingly
and retries with a proper wait if it ever gets rate-limited anyway.
Each listing's expandable score panel can now also show a few facts straight from Denmark's official building register (BBR) - toilet and bathroom facilities, exterior wall material, roof material, and sometimes a renovation year, alongside byggeår/m² you already had from Boliga's normal listing data.
This is not a real BBR API integration. Full BBR access goes through Datafordeler, which requires registering a service account - and that registration itself requires MitID Erhverv, Denmark's business digital ID. That's not something this project can set up on its own; it needs you personally, so it wasn't built. If you register for Datafordeler access yourself later and hand this project the API credentials as a secret, a proper integration could replace this.
What's built instead: bbr_enrich.py reads the same public Boliga page
your link already points to, and pulls out the same BBR-sourced fields
Boliga itself renders there (server-rendered HTML, no JavaScript needed
to read it, and no login for either you or this script). This is real
official data, just borrowed from Boliga's own page rather than queried
from BBR directly.
Coverage genuinely varies per listing. Some houses show 5-6 fields, some show none at all - most often still-under-construction new builds, where BBR itself has nothing registered yet since the building doesn't physically exist in final form. This isn't a bug; it reflects the real state of the register for that address.
This runs on demand only - never automatically. Fetching every
listing's own page just to check for BBR data most of them won't even
have would add real load for little benefit, so bbr_enrich.py isn't
part of the hourly workflow at all. Instead, run it yourself for a
specific listing you're actually curious about, or just ask in a Claude
Code session and it'll be run for you:
python bbr_enrich.py --id 2360854
python bbr_enrich.py --address "Pumavej 20"
python bbr_enrich.py --all # every active listing at once - slow
# (~1.5s each, so ~40 min for ~1,600
# listings) - only run this yourself if
# you actually want BBR data site-wide
Once fetched, a listing's BBR data is cached forever in docs/data.json
(a house's wall material doesn't change overnight) and shows up in that
listing's expandable score panel from then on.
docs/analyser.html (linked from the top of the main page) is a separate,
general-purpose investment calculator - not tied to your tracked
sommerhus listings at all. Paste any property link, or just type in the
numbers, pick a rental type and a strategy, and it works out a full
leveraged investment analysis: NOI, DSCR, cap rate, cash-on-cash, a
10-year IRR and equity multiple, an interest-rate stress test, and a
0-100 investment score.
The analysis itself runs entirely in your browser. All the math
(mortgage amortization, IRR via Newton-Raphson, everything) is plain
JavaScript in analyser.html, with nothing sent anywhere.
"Slå op" (the link lookup) is the one place this project has a real
backend - a single small exception to the "just files, no server"
design. A browser can't fetch an arbitrary external site directly -
Boliga's own API only allows cross-origin requests from boliga.dk itself
(checked its response headers directly), and most sites either block
automated requests outright or render their numbers with JavaScript a
plain fetch can't see. None of that applies to a request from server to
server, though, so api/analyse.py - a small Vercel serverless function,
the one piece of this project that isn't a static file - fetches the
pasted Boliga page itself and extracts price, size, rooms, byggeår,
Ejerudgift, and any BBR fields the page happens to show, the same way
bbr_enrich.py already does for tracked listings. The lookup tries three
things in order: (1) is this one of your tracked sommerhus listings,
matched via docs/data.json on the same origin - no backend needed for
that part at all; (2) if not, is it some other boliga.dk listing - fetched
live through api/analyse.py; (3) if it's not a boliga.dk link, or the
fetch fails, say so plainly and let you type the numbers in - manual entry
always works regardless.
This is genuinely deployment-specific: the serverless function only runs on Vercel. On GitHub Pages, which serves purely static files with no function runtime, link lookup falls back to step (1) only - your own tracked listings still resolve, but a random boliga.dk link won't. Manual entry works identically everywhere.
Two rental types, because they're taxed completely differently in Denmark:
Validated against a real example, not just internally consistent.
While building this, I was shown a screenshot of a similar tool's output
for a specific set of inputs (4.3M price, 21,500 kr/month rent, 20% down,
5% rate). I ran the exact same inputs through this implementation and
checked every line: rental income, vacancy, maintenance, driftsomkostninger,
NOI, monthly mortgage payment, DSCR, cap rate, bruttoafkast, cash flow,
cash-on-cash, and break-even occupancy all matched to the exact krone or
within rounding. Only the 10-year IRR and equity multiple differed
slightly (mine came out a bit higher), which is expected - those depend
on assumptions about how costs and terminal sale price are modelled over
a decade that a single screenshot can't fully pin down. One thing that
screenshot's example surfaced and fixed: maintenance reserve for a
long-term rental is conventionally a percentage of gross rent (that
example implied 8%), not of purchase price - the sommerhus side keeps the
percent-of-price convention from yield_calc.py, since that project
already explains why (no separate building-value figure exists to base
it on), but this page now uses the more standard rent-based convention
for the long-term-rental mode.
The 0-100 investment score here is a separate, simpler heuristic from the main site's percentile-based score - there's no natural pool of comparable analyses to rank against for a one-off calculator, so it's built from fixed rules-of-thumb thresholds for DSCR, cash-on-cash, and IRR instead, weighted differently depending on which strategy you pick (Cashflow weights cash-on-cash heaviest; Værdistigning weights IRR heaviest; Value-add blends the two). It's a sanity-check number, not empirically calibrated the way the main score is.
scrape.py makes
one extra request per run to Boliga's own search with
q=håndværkertilbud, and flags any of your tracked listings whose id
comes back in that result. Confirmed this is a real search (not
decoration) by testing a nonsense word against it first (0 results)
compared to a common word like "udsigt" (2,369 results) - and confirmed
it's checking more than just the visible page text, since the exact
phrase doesn't always appear in a matched listing's rendered
"Beskrivelse" section (Boliga sometimes shows its own auto-generated
summary there instead of the agent's original text, but the search
index still seems to see the original). One extra request per run,
regardless of how many listings you're tracking.prefers-color-scheme since you'll mostly be opening this on a phone.docs/data.json typically
holds all currently-tracked listings (currently around 1,600+ across all
11 areas), which is small enough that this approach scales fine for a
personal tool. If that ever became a real problem, the fix would be
archiving old sold_or_removed entries out of the live file rather than
switching to a database.