Work Valkokangas

Valkokangas

Showtimes for the 41 cinema screens in Finland that do not belong to a chain. Twenty-nine cities, 1,652 screenings, one page. Every field on it was read from a cinema's own site or left empty.

Creator · Design & Engineering 2026 — live valkokangas.app

The Valkokangas front page in its dark theme. A heading reads 'Tänään ja huomenna pienissä saleissa', followed by a Now Starting panel listing six screenings with their times and cinemas.
The front page. Now Starting reads the browser's clock, which is the only thing on the site that needs JavaScript.

Small cinemas publish their programme on their own site and nowhere else. Nobody collects them, because 41 screens spread over 29 cities is not a business. If you want to know what is on tonight in Lahti and what is on in Kaarina, you open two sites, and the two lay out their evening in completely different ways.

Valkokangas reads all 41 once a day and puts them on one page, grouped by city and by day. Each screening carries what its own source actually said: the spoken language, the subtitle languages, the running time, the age rating, the link that sells the ticket. Fields a source does not publish are absent rather than filled in.

The first version of this was mine too. It was built on Lovable, and it worked by handing each cinema's pages to Firecrawl and asking a language model to read the showtimes out of the resulting markdown. Ticket links were assembled by slugifying the film title, which produced /elokuva/backrooms-everything-must-go-edition/ when the real address was /elokuva/backrooms/. Some cinema identifiers had gone stale months earlier. Only the current day was fetched, so the site had nothing to say about tomorrow.

Nothing about it looked broken from the outside, and that is the problem. A showtime is structured data with exactly one correct value. A model reading prose will produce a plausible value every time, including the times it is wrong.

A wrong showtime is worse than a missing one. A missing one does not send anybody to a cinema at the wrong time.

So every field is read from the source as it stands or left null. Nothing is defaulted, inferred or completed by a model. An adapter that cannot get a time it trusts drops the screening instead of guessing it. The same goes for ticket links: a link is either read from the source verbatim or attached to a screening by the source's own identifier, and every link format was opened and checked against at least two real screenings before it went anywhere near the site.

The rule is written down as a contract that the collector and the site both answer to. Either side can be rewritten freely. The contract between them changes only on purpose, and both sides change with it.

Thirty-odd sources, and no two of them write a time the same way. Three of them write it in a way that looks right and is not. Each was checked by hand against what the cinema's own page shows a visitor, because that is the only reference that cannot be argued with.

Real UTC, correctly labelled

One cinema's booking API returns 2026-07-29T14:15:00+00:00 for a screening its own site lists at 17.15. The stamp is honest: 14:15 UTC is 17.15 in Helsinki. Reading it as local time would have moved 142 screenings three hours.

The same screening, published twice, one of them wrong

One cinema's site publishes its programme through two endpoints. The one its own booking app calls says 11:15:00.000Z. The one it publishes for search engines says "startDate": "2026-07-29T11:15" with no zone at all, which under schema.org means local time. They cannot both be right.

The answer came from the checkout page, the view a paying customer sees, which writes the time out in words: 14.15 to 16.00. So 11:15 is UTC, the feed built for search engines is the wrong one, and it is the one the collector does not use, even though it would have been easier to parse.

A WordPress set to UTC+0

One cinema's markup says 2026-08-01T16:30:00+00:00 while its visible listing and its booking view both say 16.30. The plugin stores the time as the cinema typed it and appends the site's own offset when it serialises, and that site's offset happens to be zero. The screening really is at 16.30 Finnish time; only the label is wrong. The identical plugin on two other cinemas' sites writes +03:00 and can be read straight, so this is a check to run per site rather than a rule to apply.

A cinema's own page on Valkokangas in the light theme, listing five films for today with their start times, posters and running times.
One cinema's page in the light theme. The posters are mirrored onto this site's own server at build time.

Twelve screenings at the wrong address

An independent review three days before launch found 12 screenings filed under the wrong cinema. One ticketing API serves two Helsinki buildings and the collector had read it as one house. They are at different addresses. Nothing in the response says so; you have to know the city.

That is the exact failure the whole design exists to prevent, and the design did not catch it. What caught it was a review that read the output back against the real world instead of against the schema.

A parser that failed without saying anything

One ticketing platform serves two different shapes of the same structured data from the same address, alternating between requests: sometimes a bare array of Event objects, sometimes that same array under a structuredData key. The parser knew one shape. The other returned zero screenings and raised nothing.

I saw it in production and misread it. That cinema's run on 29 July came back empty and I put it down to a network blip. What held was the guard underneath: a source that returns zero when it returned forty yesterday is an error, not an empty programme, and an empty result never overwrites good data. Nothing on the site was ever wrong. The fix was three lines in the shared parser.

Four platforms, not forty-one problems

The set looks like 41 separate scraping jobs and is not. Four ticketing platforms account for most of it, and one of them serves six cinemas from a single feed. Once the shared parser for a platform exists, a new cinema on it is a three-line adapter: the base address, the source's own location id, and the name to file it under.

A poster belongs to a film, not to a screening

The sources disagree about that, so the same film had a poster on 45 of its screenings and none on the other 53. Some feeds ship an image with every showtime, some ship none, and the film is identical in both. Attaching the poster, the IMDb id and the running time to the film and sharing them across its screenings took poster coverage from 64% of screenings to 94%.

Manners toward the sources

The collector says who it is in its User-Agent, reads robots.txt and obeys it, honours Crawl-delay, and never opens two requests to the same host at once. One cinema's robots.txt disallows every address with a query string, which rules out the pagination its own API offers, so that source is read ten screenings at a time and no further. These are small houses on small servers, and this project has no claim on their bandwidth.

The status table on the Valkokangas about page, listing each cinema with the state of its last fetch, the time it ran, and how many screenings it returned.
Every cinema's last fetch is published, with its time and its error text if there was one.

No ads, no cookies, no analytics, and not one request to a third party. That last one is the hard part and it shaped most of the build. Posters are fetched during the build and mirrored onto the site's own server, so a visitor's browser never tells a cinema's server which film they were reading about. The typeface is hosted and subset here. There is no embedded map, no share button and no font from a CDN.

The light and dark switch stores nothing: no cookie, no localStorage, no sessionStorage. Following a link resets it to whatever the system theme is. Remembering it would have been one line of code and the only thing on the site that writes to a visitor's browser.

None of that has to be taken on trust. Open the network tab and see which addresses the page contacts.

The same Valkokangas front page shown twice on a phone-width screen, once in the dark theme and once in the light theme, both listing the screenings starting now.
Both themes at phone width. The choice is not stored anywhere, which is why it is a button and not a setting.

Every cinema's last fetch is published, with the time it ran and the error text if it failed. A cinema whose data has gone stale says so on the programme page as well, not only on the status page.

This is the same rule read from the other end. A silent empty list looks exactly like a cinema with nothing on, and it would keep somebody at home for no reason at all.

Finnkino is not there. It is the chain, so it sits outside what the site is for, but that is not the whole reason. Its site is behind a managed bot challenge, its current API needs a token only its own browser app can mint, and the public XML endpoint that open-source libraries were written against for years now returns 404. Collecting it would mean working around that protection, or paying a rendering service to work around it, which is the same act with an invoice attached.

The licensed route was read and it does not fit either. The evaluation terms of the commercial showtimes provider forbid storing retrieved data on your own servers and forbid publishing it to the public. A static site built from stored JSON is both of those at once. What is left is calling the API from the visitor's browser, which is the one thing this site promises never to do.

So the open route is to ask Finnkino directly. That letter is written.

  • 41 screens, in 29 cities, none of them part of a chain
  • 1,652 screenings, every one of them read from a structured source
  • 418 tests over the collector and its adapters
  • 0 requests to a third party, on any page of the site
  • 94% of screenings carry a poster, up from 64%
  • 100 Lighthouse, in all four categories

Bandpit

A songbook for bands, and a stage view that scrolls in tempo on every phone at once.

Next case