Easy-to-manage touch screens
무료 오픈소스 터치스크린으로 USB 설정 가이드, 드래그‑드롭 편집기, 로컬 제어를 제공하며 Home Assistant용으로 제작되었습니다
스톡홀름·예테보리·룬드 학생 주택 213채를 지도에 표시, 대기일·월세·통학 거리 한눈에 확인
213 student homes available right now in Stockholm, Göteborg and Lund, from AF Bostäder, Bostadsförmedlingen, SGS, SSSB — queue days, rent and the commute to your campus, on one map.
라이프스타일HTML · PythonGitHub @ivarhak온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기This project scrapes SSSB and the Stockholm Bostadsförmedlingen to allow the user to find available housing in Stockholm relative to KTH, this project shows the user in a simple format where housing is located, how much it costs, and what the queue for the housing is like.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Nästet ("the nest") is a student housing dashboard for Sweden. It gathers every vacancy currently advertised by the major student landlords — SSSB and Bostadsförmedlingen in Stockholm, SGS in Göteborg, AF Bostäder in Lund — works out the commute from each address to your chosen campus, and puts the result on one map instead of four separate queue portals. It is read-only and asks nothing of you: no account, no sign-up, nothing tracked. If you want to support the work, there's a Ko-fi link in the header.README 전문 보기 ↗
Nästet ("the nest") is a student housing dashboard for Sweden. It gathers every vacancy currently advertised by the major student landlords — SSSB and Bostadsförmedlingen in Stockholm, SGS in Göteborg, AF Bostäder in Lund — works out the commute from each address to your chosen campus, and puts the result on one map instead of four separate queue portals.
It is read-only and asks nothing of you: no account, no sign-up, nothing tracked. If you want to support the work, there's a Ko-fi link in the header.
Live version: nästet.se — published from this repo and re-scraped hourly. Read-only; run it locally for desktop notifications and an on-demand Refresh. See section 4.
The pieces:
monitor.py — runs on your machine: Selenium scraping for SSSB, plain HTTP
fetches for the other three providers, commute math, and a small local API.index.html — the UI. Served by the script itself locally, and published
as-is to GitHub Pages, where it reads a pre-scraped listings-<city>.json
instead of the API.history.py — the site's memory: every published scrape is recorded on the
data branch, and summaries (history-<city>.json) drive the Market tab.enrich.py — build-time extras: SSSB floor plans, bike isochrones per
campus, Stockholm's road and rail noise, and campus building maps.og_image.py — the link-preview picture, drawn with the live count.worker/ — a Cloudflare Worker that keeps the hourly schedule and runs the
optional Discord bot (section 4).What the page does beyond the map:
?listing=<id> and ?area=<name> open straight to a
listing or area.Double-click start.command (start.bat on Windows). That's it.
It creates the virtual environment, installs the requirements, starts the
server and opens the dashboard in your browser. Everything except the last two
steps is skipped once it's been done, so every run after the first is just
"double-click, dashboard opens". Any arguments you pass go straight through, so
./start.command --interval 30 works too.
The only prerequisites are Python 3.10+ and Chrome (or Chromium).
webdriver-manager fetches the matching driver by itself. Chrome is only the
fallback path for reading SSSB's listing page (see "How it reads SSSB" in
section 3), but keep it installed so that fallback exists.
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python monitor.py # no arguments = serve the dashboard
Running with no arguments serves the dashboard, which is what you want almost
every time. In PyCharm or similar, point a run configuration at
monitor.py with no arguments and it'll do the same thing.
Drag start.command onto the Dock, or make an alias on the Desktop:
ln -s "$PWD/start.command" ~/Desktop/Nästet.command
The alias keeps working when you git pull, since it points at the file rather
than copying it. To give it a nicer icon, select the file in Finder, press
Cmd+I, and paste an image onto the small icon in the top-left of the Info window.
Optional, for real transit times (otherwise you'll just get the straight-line/bike estimate): get a free key for the Resrobot API at trafiklab.se (sign up → create a project → add the "Resrobot v2.1" API), then:
export RESROBOT_API_KEY="your key"
scrape_listings() finds real listings by looking for links containing
refid= in the URL (confirmed against a real SSSB booking link), then reads
the area/rent/size/queue-days/floor values out of the surrounding card text.
If it ever comes back empty while you can see real listings on the site
yourself:
python monitor.py --debug
That runs a visible browser window (so you can watch what happens) and
saves the fully-rendered page to debug_page.html. Check whether that file
actually contains refid= anywhere (grep -c "refid=" debug_page.html) — if
SSSB changed their link format, that's the thing to update.
If instead the run reports listings but with empty queue days, SSSB has probably put the "Ködagar" column back behind an account. The scrape prints a warning telling you so — the values simply go missing rather than the run failing, and every filter treats a missing value as "do not hide this".
Bostadsförmedlingen needs no selector fixing at all, since
fetch_bostadsformedlingen() reads a plain JSON feed rather than scraped
markup. If its field names ever drift, the terminal prints the first ad's
actual keys on every run, so you can fix the candidate names in _bf_field()
from that alone. The same goes for SGS and AF Bostäder — both are plain JSON
GETs.
If a whole city comes back empty, that is usually a source that didn't
answer rather than a landlord with no vacancies, and the two are now told
apart. The run prints the reason in full — the HTTP status with whatever
proxy or CDN sent it, or the actual TLS error rather than the bare word
SSLError — and the reason travels into the payload as source_notes, so the
dashboard says so at the top of the listings panel and links out to the
provider instead of showing a blank map captioned "No available listings here
right now". On GitHub Actions each failed source also raises a ::warning::
on the run page, because a build that publishes an empty city otherwise reads
as a perfectly normal green one.
Dashboard — double-click start.command, or from a terminal:
python monitor.py # no arguments needed
It scrapes if the saved listings are stale, serves the UI + API, and opens
http://localhost:5055 in your browser by itself (--no-browser if you'd
rather it didn't). It then auto-checks SSSB in the background every 15 minutes
(change with --interval 30, don't go below 5 — see the rate-limiting note
below), and the dashboard polls for fresh results every 60 seconds, so you don't
need to click anything for it to notice new listings — you'll just see them
appear, plus the desktop notification. "Refresh" still triggers an immediate
check on demand instead of waiting for the next scheduled one.
While a check is running, Refresh goes grey and tells you so, with a running timer under the button, and stays unclickable until it finishes. Clicking it again wouldn't have made anything happen faster — scrapes run one at a time, so a second click just queued up another full one behind the first. The page updates itself the moment the data lands, so there's nothing to click. The same applies while the background auto-check is running, and it survives reloading the page mid-check.
If that startup scrape fails — SSSB briefly down, no network, Chrome missing — the dashboard still comes up and serves the last listings it saved, and says in the terminal what went wrong. The background auto-check retries on its own.
One-off check (scrapes once, saves, notifies if there's something new, exits):
python monitor.py --once
This one does exit non-zero if the scrape fails, since it's what cron runs and a silent success would be worse than a loud failure.
Opening the .html directly as a file shows example data with a banner saying so — the live version only works served from
http://localhost:5055since that's what makes the/api/...calls same-origin.
A normal run first tries reading the vacancy list with a plain HTTP request, and only launches Chrome if that comes back empty — which makes the usual case noticeably faster, since no browser has to start at all. You don't need to think about this; it's automatic.
--http-only forces the fast path and fails loudly instead of falling back,
which is handy for checking whether it still works. If the page ever does need
a real browser, that run tells you so and prints any API-looking URLs it found
in the page, since one of those is probably the endpoint the page fetches its
listings from.
There's a live read-only copy of the dashboard published from this repo, so you can look at current listings without running anything:
A GitHub Actions workflow (.github/workflows/publish.yml) scrapes every
provider hourly (see the Worker below), writes one listings-<city>.json next to the
dashboard, and deploys them to Pages. The page picks up a new scrape on its own — a tab
left open re-checks the data file every 15 minutes.
It's the **same .html as the local version, not a second
copy to keep in sync. When no local server answers, it falls back to reading
./listings.json and adjusts: the Refresh button disappears (there's no server
to ask for a re-scrape) and a line in its place says how often the data updates
and where it comes from. Everything else — map, filters, campus picker, search,
both themes — works identically.
What the published version can't do, by nature:
worker/ is a tiny Cloudflare Worker that dispatches the publish workflow
every hour and posts to a Discord channel if the published data goes stale. It
runs on Cloudflare's free plan (one cron trigger out of the five allowed).
One-time setup, about ten minutes:
worker/ folder):
npm install -g wrangler
wrangler login
id into wrangler.toml:
wrangler kv namespace create SUBS
wrangler secret put GITHUB_TOKEN # the token from step 1
wrangler secret put ADMIN_WEBHOOK_URL # optional: a Discord webhook for "data went stale" alerts
wrangler deploy. Check it with wrangler tail — each hour logs
dispatch: ok (HTTP 204) and the age of the published data.Tests run with npm test in worker/ and need nothing but Node.
The same Worker runs a Discord bot. You tell it your city, queue days and filters once; it then DMs you a summary on your schedule — daily (the default), twice a day or weekly — or, if you'd rather, one DM per listing the moment it turns up. It needs no gateway connection: Discord calls the Worker, and the Worker calls Discord's REST API.
A daily summary looks like this:
Nästet · Stockholm · Mon 12 Oct
7 new listings since Sun 11 Oct, 08:00 (3 SSSB · 4 Bostadsförmedlingen)
→ 3 fit your filters (≤ 30 min by bike to KTH · ≤ 6 000 kr)
→ 1 you'd get today with your 312 queue days, plus 2 decided by deadline, open to apply
New and fitting
• Studentbacken 23 (Jerum) · 4 861 kr · 17 m² · 7 min by bike · ✅ you'd lead (61 queue days) — link
• Odengatan 1 (Vasastan) · 6 900 kr · 9 min by bike · apply by 2026-10-14 — link
Closing within two days (1)
• …
Right now: 126 open in Stockholm · 18 fit your filters · you'd lead 9 of the 30 with queue days
Days with nothing new and nothing closing are skipped unless you ask for a
message every time (always:True).
Commands (once a watch exists every option is optional — only what you give is changed):
| Command | What it does |
|---|---|
/watch set days:312 campus:KTH max_commute:30 max_rent:6000 | Start a watch, or change any part of it |
/watch set frequency:twice_daily hour:7 | Summaries at 07:00 and 19:00, Stockholm time. Also daily, weekly (Mondays) and instant |
/watch set bf_since:2025-09-01 | Your place in Bostadsförmedlingen's queue: its listings then say whether you'd lead so far, and a listing you lead counts as a match |
/watch set student_union:False | Not in a student union (kår): your days stop at 90, as SSSB counts them |
/watch set areas:Jerum, Lappkärrsberget | Only these areas; areas:any clears it, as max_rent:0 and max_commute:0 clear those |
/watch show | Your settings, and how many listings fit them right now |
/watch now | A summary straight away |
/watch stop | Stop, and delete everything stored about you |
The quick way: with Node.js installed, run
npm run setup in worker/. It does both this section and the Worker setup
above in one go — logs you in to Cloudflare, creates the KV namespace and
writes its id into wrangler.toml, deploys, stores the secrets (typed at
wrangler's own prompt, never saved to a file), registers the command, and
opens each page you need to copy something from. It skips whatever is
already done, so it's safe to run again. The steps below are the same thing
by hand.
Everything here is free (Cloudflare's free plan, a Discord application). Do the Worker setup above first; then, about fifteen minutes:
applications.commands and bot. Copy the Install Link.worker/):
wrangler secret put DISCORD_PUBLIC_KEY
wrangler secret put DISCORD_BOT_TOKEN
wrangler secret put NOTIFY_SECRET # any long random string: openssl rand -hex 32
wrangler deploy
https://nastet.<your-subdomain>.workers.dev/interactions → Save.
Discord sends a signed test request and only saves if the Worker answers
it, so a successful save means the keys are right.COMMANDS, it re-registers on the
next command or hourly run, using the bot token it already has. Global
commands can take a few minutes to show up:
DISCORD_APP_ID=… DISCORD_BOT_TOKEN=… npm run register-commands
/watch set days:100 and then /watch now. wrangler tail shows each
hour's notify: {…"summaries":1…}.NOTIFY_URL = https://nastet.<your-subdomain>.workers.dev/notify and
NOTIFY_SECRET = the same string as in step 3.DISCORD_INVITE in index.html to the
Install Link. The watch panel then offers "Prefer Discord?" with a
ready-made command — city, queue days, rent, areas, campus and
commute filled in from the page.What's stored, in the SUBS KV namespace under the user's Discord id: city,
queue days and the date they were given, max rent, areas, campus and longest
commute, how often and at what hour to write, the DM channel id, and the
listing ids already sent or already counted in a summary. Nothing else.
/watch stop deletes it; so does six months without a command.
The workflow caches two files between runs, both worth understanding:
bike_route_cache.json, so 26 areas × 7 campuses of real cycling routes aren't
re-fetched from a free community service hourly; and
current_listings.json, which is what new-listing diffing compares against —
without it, every run would start from nothing and mark all ~170 listings as NEW.
If a scrape fails the job fails deliberately, and Pages keeps serving the last good deploy rather than publishing an empty map.
If you leave python monitor.py --serve running, you're already
covered — its background auto-check (every 15 min by default) fires the
same desktop notification on new listings as --once does. The cron/Task
Scheduler route below is only needed if you'd rather not keep the
dashboard process running all the time and just want periodic checks:
macOS/Linux (cron) — checks every 30 min:
crontab -e
# add:
*/30 * * * * cd /path/to/this-folder && /path/to/venv/bin/python monitor.py --once >> cron.log 2>&1
Windows (Task Scheduler): create a basic task that runs
venv\Scripts\python.exe monitor.py --once every 30 minutes,
with "Start in" set to this folder.
localStorage, wrapped in a try so the page
still loads where storage is blocked (it just won't persist there). That's the
only browser storage this file touches; everything else about what's new since
the last check is computed server-side.data/address_cache.json — buildings don't move, so this is a one-time cost of
about 40 lookups. An address that won't resolve leaves that listing at the area
centre rather than dropping it, and so does one that resolves implausibly —
more than 3 km from its own area, which is how a same-named street in another
municipality gives itself away. The terminal names any address that gets
dropped that way so you can correct it in the cache file by hand.data/grocery_cache.json for 30 days, since supermarkets
don't move and re-fetching them every scrape would be pointless load on a free
community service. Overpass's main endpoint is busy and often refuses, so a
couple of mirrors are tried in turn and the terminal prints which answered;
puts your own first. A failed lookup keeps whatever the last
successful one found rather than losing the dots — the chip only disappears if
there has been a successful lookup, and the run's summary says so when
that happens (, with what each endpoint answered).
That cache file is , unlike the other
caches: Overpass rate-limits shared cloud IPs, so the GitHub Actions runner
that builds the published site is the one machine that can't reliably fetch it.
A list gathered from an ordinary connection and committed is what gives the
published site its shop dots. To refresh it, run the tool once and commit the
file if it changed.MIT — see LICENSE. Feel free to use or modify however.
That covers the code in this repo. The things it builds on have their own terms, all of them permissive but worth knowing if you fork this:
Three cities now: Stockholm, Göteborg and Lund. You pick one on arrival and
the site shows that one; the choice lives in the URL (…/#lund), so it's
bookmarkable and shareable. Each city brings its own providers, campuses, areas
and sensible slider defaults — a 45-minute commute filter means something across
Stockholm's 30 km and nothing in Lund, where everything is a short bike ride.
Providers differ in what they publish, and the dashboard reads that from the data rather than being told: SSSB states queue days, so its rows sort by them; SGS publishes no queue figure at all, so its rows say "open now"; AF publishes how many people have already applied, so Lund sorts by fewest applicants first. Each city's coverage note says plainly what it doesn't cover.
Adding a fourth is a scraper plus a registry entry in monitor.py — areas
build themselves from the feed where a provider states them. Worth checking
first whether the landlord runs on Momentum, since that scraper already exists.
Next up: more student cities. The scrape, the payload and the dashboard are
already scoped per city — each one has its own registry entry, its own campuses
and its own data file, and you pick a city from the URL (…/#lund). Stockholm is
simply the only entry so far, which is why the picker stays out of the way.
The plan is Göteborg and Lund next, each with its own housing providers and its own campus list — the smaller specialist colleges included, the way Stockholm lists Konstfack, KMH and KKH alongside KTH and SU, since those are exactly the places where which campus you measure from changes the answer.
Made by IvarHak, hosted on GitHub, coded partially with the help of Claude Code
/watch setwrangler secret put DIGEST_WEBHOOK_STOCKHOLM (or _GOTEBORG, _LUND);
each publish with new listings posts a list there.OVERPASS_URLshops: none — …--city picks one (omit it to do all of them), each gets
its own data/current_listings_<city>.json, and the page reads the city from
the URL fragment (…/#lund) so a choice is bookmarkable without any storage.
Stockholm is the only city so far, so the picker and the header's city button
hide themselves — a control that can't change anything is worse than none.--serve scrapes on startup unless the
saved listings are newer than your --interval, and prints which it's doing.
data/current_listings.json is regenerated output and deliberately not in
the repo — it used to be, which meant a fresh clone was served month-old test
listings that looked current. If the "updated" time in the top-right turns
amber, nothing has scraped in over an hour.data/geocode_cache.json. If a
pin looks wrong on the map, open that file and hand-correct the
[lat, lon] for that area.SCHOOLS in monitor.py and
the dropdown picks them up automatically.data/bike_route_cache.json, so the first run is slow (it routes each area
to each campus) and later runs are instant. A listing's row shows
12 min bike for a routed time and ~12 min bike for a fallback estimate,
so you can always tell which you're looking at. If the service is
unreachable the run says so and falls back to the old straight-line guess
rather than failing; --no-bike-routes skips routing entirely.bostad.stockholm.se/AllaAnnonser/) carries the whole Stockholm rental
queue; only ads flagged as student housing are kept. The flag is read
tolerantly, so a renamed field shows up as a loud terminal warning listing
the ad's real keys rather than silently returning zero listings. If the feed
moves again, BF_ADS_URL=<url> overrides it without editing the script.Url field, so that's what's used. Where
it's missing, the script falls back to building
bostad.stockholm.se/bostad/<n>/, where <n> is a year-prefixed 9-digit ad
number — not the feed's AnnonsId, which is shorter and 404s — found by
value shape since it's unclear which key holds it. The terminal reports the
split between the two, and anything unresolvable links to a search page zoomed
on the address rather than a dead page.
BF_LISTING_URL="https://…/{id}" overrides the fallback template.--bike-routes-bf opts in.osascript. (plyer, listed in requirements, needs a compiled
pyobjus extension that often won't install; it's only a fallback for
Windows now.) If a notification doesn't appear, the terminal prints what each
path actually reported, plus the new listings themselves, so nothing is lost
when the popup is. On macOS the usual cause is notification permission for
whichever terminal app you ran it from: System Settings → Notifications.fetch_bostadsformedlingen() pulls Stockholm's
city housing agency's public ad feed (bostad.stockholm.se/AllaAnnonser/)
and keeps only the student ads — no login, no API key. Svenska Bostäder
doesn't run a separate student queue of its own; their listings show up
through this same feed. There's no landlord field in it, so the row is
labelled with the queue the ad belongs to (KoNamn), which for the
external queues is the landlord running it. Each ad carries its own
coordinates, so these get individual purple pins on the map instead of
SSSB's per-area dots, and sort by application deadline instead of queue
days, since BF doesn't publish a queue-days figure up front.