Visit this app on my raspberry!
With scrollsurf you can
- scroll through the best wikipedia articles, wikimedia pictures, and wikiquote quotes
- vote on items to influence your feed
- look at all your voted items
- log in with your e-mail to sync your laptop and phone
- download all your data (seen, clicked, liked, disliked items)
- withdraw cookie consent to make me forget everything
- read my diary
The articles, pictures and quotes are randomly selected from these datasets:
npm installBefore you run the app for the first time, you have to download the datasets that you want using the provided package scripts. The downloads take a long time, but one dataset is enough to run the app: First, you have to add an .env file in scripts/, next to .env.example, and add your own User-Agent. Then you can
npm run download-vital-50000
npm run download-unusual
npm run download-good-articles
npm run download-featured-articles
npm run download-featured-pictures
npm run download-commons-featured-pictures
npm run download-quotesThen, you can categorize the articles by running
npm run categorize
npm run categorize-commonsCurrently, that's not very useful - it just builds a huge category tree that you can look at.
Then you have to manually assign topic buckets (long story)
npm run unify-topicsAnd then you should be able to
npm run devand go to http://localhost:3000.
But let's not kid ourselves, this is likely to fail at some point because it only ever ran on my machine.
Let me know if you need help :)
The feed is random, but influenced by user activity: likes, dislikes, and followed links build a per-topic-bucket affinity that weights the random draw — liked topics show up more often, disliked ones less, and nothing is ever hard-excluded. Without any votes (or without the consent cookie, see below) the feed is uniformly random.
Read more about how it all works in Dataflow.md.
Once the user agrees to use cookies, their seen items, likes and clicks are stored with the cookie as the key. So when the cookie is cleared or expires after inactivity, that data is lost. Logging in with an email binds that history to an account, which multiple browser cookies can then point at.
The tokens table maps each browser cookie to a row in users, and several cookies can point at the same account. An anonymous user is a users row with no email. Stale cookies are swept after USER_INACTIVITY_DAYS (default 14 of inactivity by cleanup_inactive_users on startup — the underlying user row and its history survive, only the cookie is dropped.
Login is code-only — there is no password:
- Enter your email → the server
create_login_codecreates a single-use 6-digit code, upserted into thelogin_codestable (one row per email, 15-minute expiry, 60-second resend rate-limit) and sent over SMTP. IfSMTP_HOSTis unset (dev/e2e) the code is just logged to the server console instead of emailed. - Enter the code →
verify_login_codechecks it and deletes it (single use; wrong attempts don't delete it, so you can retry until it expires). - On success
attach_loginbinds your browser token to an account, andsubmit_login_codesetsss_uidand grants consent — logging in implies consent.
attach_login reconciles any existing votes/clicks on login. It resolves the current browser cookie and the account matching the email, then picks one of these branches:
| Situation | Outcome |
|---|---|
| Token already points at this account | No-op, stay logged in |
| You're anonymous, account exists | Merge — fold your anon history into the account |
| You're logged into another account | Switch — repoint just this browser's cookie; no merge |
| No cookie at all, account exists | Mint a fresh cookie for the account |
| You're anonymous, email is new | Promote — your anon user becomes the account (keeps all history) |
| No cookie, email is new | Create a brand-new empty account |
When "logging in", i.e. changing from anonymous to email, the anonymous account's votes are merged into the existing account's votes with the existing one taking precedence on conflict. Append-only clicks are carried over (added, never replacing the account's), the cookie of the anonymous identity is repointed to the account, and the anon user row is deleted. When "switching" accounts, i.e. changing from one email account to another, the browser cookie is pointed to the other account without any further changes.
Consent is recorded in the client-readable ss_consent cookie (granted / denied /
unknown), surfaced through ConsentContext in
CookieConsent.tsx. Voting and link-click tracking are
consent-gated: without granted consent the client never fires the request and opens the
consent dialog instead.
Revoking consent while logged in (unlink_email) clears the email field only — the account's history stays intact but is no longer recoverable by email re-login. Logging in again with the same email therefore lands in the "email is new" branch and creates a fresh, empty account.
All e2e tests run against a small example database (e2e/.data/).
That database is created from the downloaded datasets using the test:e2e:create-db script.
It is committed so that you don't have to download all datasets before being able to run e2e tests.
npm run test:e2e # run all integration tests (seeds DB automatically)
npm run playwright-ui # same, but with Playwright's interactive UI
npm run test:e2e:update # updates screenshotsThese Main topic classifications are not what I have