The retrieve pipeline is responsible for fetching raw cinema listing data from websites and APIs. It produces the unprocessed data that the transform pipeline later converts into a standardised format.
The retrieve step supports two kinds of modules:
- Cinemas -- individual venues (e.g.
odeon.co.uk-leicester-square). Each has its ownretrieve.js,transform.js, andattributes.js. - Sources -- external ticketing platforms (e.g. Eventbrite, Dice.fm). These supply supplementary event data that cinemas can incorporate during transform.
flowchart TD
A[scripts/retrieve/index.js] --> B{Cinema or Source?}
B -->|Cinema| C[cinemas/index.js]
B -->|Source| D[sources/index.js]
C --> E["Load cinema module"]
D --> F["Load source module"]
E --> G["Call retrieve()"]
F --> G
G --> H[Return Raw Data]
H --> I[Transform Pipeline]
The entry point (scripts/retrieve/index.js) resolves the location name to
either a cinema or source module, then calls its retrieve() function. The
returned data is opaque to the orchestrator -- each module defines its own
structure.
Most cinemas don't implement retrieval from scratch. Instead, they delegate to a
shared platform module in common/, passing venue-specific attributes:
cinemas/odeon.co.uk-leicester-square/retrieve.js
→ common/odeon.co.uk/retrieve.js
→ common/ocapi-v1/retrieve.js
A typical cinema retrieve.js looks like this:
// cinemas/odeon.co.uk-leicester-square/retrieve.js
const attributes = require("./attributes");
const odeonRetrieve = require("../../common/odeon.co.uk/retrieve");
async function retrieve() {
return odeonRetrieve(attributes);
}The attributes.js file provides venue-specific configuration:
// cinemas/odeon.co.uk-leicester-square/attributes.js
module.exports = {
id: "odeon.co.uk-leicester-square",
name: "ODEON Luxe Leicester Square",
domain: "https://www.odeon.co.uk",
url: "https://www.odeon.co.uk/cinemas/london-leicester-square",
cinemaId: "153",
// ... address, geo, socials, etc.
};Of the 145 cinemas with retrieve implementations, 112 delegate to one of 17
shared platforms in common/. The remaining ~33 have standalone
implementations.
Before choosing an approach below, work out where the listings actually live. Follow the trail the site itself leaves; do not guess at URLs. Every step here is cheap, and each one narrows what the next has to look for.
1. Fetch the page and read what the server sent.
curl -sSL -A "$UA" "https://example.com/whats-on" -o page.htmlLook for JSON-LD (application/ld+json), an embedded state blob
(__NEXT_DATA__, self.__next_f, wix-warmup-data,
type="application/json"), and complete <meta> tags — Square Online, for one,
puts a listing's full description in <meta name="description"> even though the
body is client-rendered. If the data is here, stop: no browser needed.
2. Look for iframes. A venue frequently embeds its ticketing platform rather
than hosting listings itself. document.body.innerText on the top frame does
not include iframe content, so a page can look empty while its whole
programme sits one frame down. Enumerate page.frames(), not just the document.
3. Render it and watch the network. If the body is client-rendered, open it
in Playwright and log every request and response, filtering out analytics. The
endpoint the page calls is the endpoint to use — it needs no guessing, and it is
usually reachable anonymously with a browser User-Agent.
page.on("response", async (r) => {
const url = r.url();
if (/analytics|googletagmanager|facebook|frog\.wix/.test(url)) return;
console.log(r.status(), r.request().method(), url);
});Some widgets only fire their data request on interaction — clicking a date picker, opening "Check availability" — so drive the page, don't just load it.
4. Reuse the ids already in the HTML. Client-rendered sites routinely ship
the ids their own API needs: site_id, user, merchantId, a Spektrix
client-name, an organisation slug. Grep the page for them before treating any
id as unknowable.
5. Only then consider sitemap.xml, a documented platform API, or the
platform's own docs. A sitemap is useful for discovery but is often stale —
Ognisko Polskie's lists 48 products where the live store shows 27.
If the trail runs out, ask. A site that visibly renders its programme is
very unlikely to be publishing incomplete data — far more often the request that
carries it hasn't been found yet. Say what was tried and what is missing rather
than concluding the venue can't be retrieved: that conclusion changes the
deliverable (see "Source-Only or Own Retriever?" in adding-a-venue.md) and is
the user's call, not an implementation detail.
The simplest approach: a single fetchJson() call returns all needed data.
Platforms: Electric Cinema (2 venues)
// common/electriccinema.co.uk/retrieve.js
async function retrieve({ domain }) {
const site = await fetchJson(`${domain}/data/data.json`);
return site;
}Fetches a single HTML page that contains all listing data. No detail page requests are needed because the listing page has enough information for the transform step.
Platforms: Firmdale Hotels (3 venues) Sources: Stow Film Lounge
// common/firmdalehotels.com/retrieve.js
async function retrieve({ url }) {
const movieListPage = await fetchText(url);
return { movieListPage };
}The most common pattern for standalone cinemas. Fetches a listing page, parses it with Cheerio to extract links, then fetches each detail page individually.
Platforms: Tate (2 venues), Olympic Studios (3 venues), The Castle Cinema (2 venues), Admit One (2 venues) Standalone: ~33 cinemas use this pattern with venue-specific selectors Sources: OutSavvy (over several hashtag listings), Wimbledon Film Club, WeGotTickets
// cinemas/ica.art/retrieve.js
async function retrieve() {
const movieListPage = await fetchText(url);
const $ = cheerio.load(movieListPage);
const moviePageUrls = new Set();
$(".item.films").each(function () {
const url = $(this).children("a").attr("href");
moviePageUrls.add(`${domain}${url}`);
});
const moviePages = {};
for (const moviePageUrl of [...moviePageUrls]) {
moviePages[moviePageUrl] = await fetchText(moviePageUrl);
}
return { movieListPage, moviePages };
}Each venue uses different CSS selectors (.item.films, .card-list .card a,
.programme-tile, .whatson_panel, etc.) but the fetch-parse-fetch structure
is the same.
Variant: Admit One uses fetchWin1252Text() instead of fetchText() to
handle legacy Windows-1252 encoded pages.
Fetches an HTML page and extracts structured data from embedded <script> tags
using regex or Cheerio. This avoids the need for a separate API call when the
page already embeds all data in JavaScript.
Patterns:
| Pattern | Used By |
|---|---|
var Events = {...} |
Savoy Systems (4 venues) |
window.initialData = {...} |
Odeon/Curzon (bootstrapping) |
#__NEXT_DATA__ |
Dice.fm (source) |
window.__SERVER_DATA__ |
Eventbrite (source) |
// common/savoysystems.co.uk/retrieve.js
async function retrieve({ url }) {
const page = await fetchText(url);
const events = page.match(/<script>\s*var\s+Events\s+=\s+(.*)\s+<\/script>/i);
const movieListPage = JSON.parse(events[1]);
const moviePages = {};
for (const movie of movieListPage.Events) {
if (movie.URL) {
moviePages[movie.ID] = await fetchText(movie.URL);
}
}
return { movieListPage, moviePages };
}For Dice.fm, the embedded JSON is in a Next.js #__NEXT_DATA__ script tag,
parsed with Cheerio:
const $ = cheerio.load(page);
const data = JSON.parse($("#__NEXT_DATA__").html());
return data.props.pageProps.events;Fetches an index or date list, then iterates to fetch detailed data for each date or item. This is typical of cinema chains with dedicated data APIs.
Platforms: Picturehouse (11 venues)
Picturehouse uses a POST request to fetch the initial movie list and HTML detail pages rather than JSON:
// common/picturehouses.com/retrieve.js (simplified)
const moviesResponse = await fetch(`${domain}/api/get-movies-ajax`, {
method: "POST",
body: new URLSearchParams({
start_date: "show_all_dates",
cinema_id: cinemaId,
}),
});A standardised cinema industry REST API authenticated with Bearer tokens. The retrieval flow fetches available screening dates, then iterates through each date to get showtimes.
Platforms: Odeon (20 venues), Curzon (10 venues)
// common/ocapi-v1/retrieve.js
async function retrieve({ cinemaId }, { url, apiUrl, authToken }) {
const getHeaders = () => ({
Accept: "application/json",
authorization: `Bearer ${authToken}`,
});
const prefix = url || apiUrl;
const { filmScreeningDates } = await fetch(
`${prefix}/ocapi/v1/film-screening-dates?siteIds=${cinemaId}`,
{ headers: getHeaders() },
).then((r) => r.json());
const moviePages = [];
for (const { businessDate } of filmScreeningDates) {
const showtimesData = await fetch(
`${prefix}/ocapi/v1/showtimes/by-business-date/${businessDate}?siteIds=${cinemaId}`,
{ headers: getHeaders() },
).then((r) => r.json());
moviePages.push(showtimesData);
}
return moviePages;
}OCAPI itself is a generic implementation. Each chain wraps it with its own strategy to obtain the API URL and auth token:
flowchart TD
subgraph "Odeon (20 venues)"
A1[Playwright loads Odeon page] --> A2["Extract window.initialData"]
A2 --> A3["Pass api.url + api.authToken to OCAPI"]
end
subgraph "Curzon (10 venues)"
B1["Fetch Omnia API for vistaCinema.key"] --> B2["Fetch main page HTML"]
B2 --> B3["Extract window.initialData"]
B3 --> B4["Pass cinemaId + api config to OCAPI"]
end
A3 --> C["common/ocapi-v1/retrieve.js"]
B4 --> C
C --> D["Screening Dates → Showtimes per Date"]
A single POST request with a GraphQL query and variables. The server returns a comprehensive dataset including movies, showings, ratings, and TMDB data.
Platform: Indy Cinema Group (3 venues)
// common/indycinemagroup.com/retrieve.js
const query = `
query ($limit: Int, $orderBy: String, $type: String) {
movies(limit: $limit, orderBy: $orderBy, type: $type) {
data {
id, name, urlSlug, synopsis, starring, directedBy,
duration, genre, rating, trailerYoutubeId, tmdbId,
showings { id, time, screenId, seatsRemaining, displayMetaData }
}
}
}
`;
async function retrieve({ siteId, domain }) {
const response = await fetch(`${domain}/graphql`, {
method: "POST",
body: JSON.stringify({
query,
variables: { limit: 1000, orderBy: "magic", type: "all-published" },
}),
headers: {
"Content-Type": "application/json",
"client-type": "consumer",
cookie: `site_id=${siteId}`,
},
});
return await response.json();
}The site_id cookie determines which venue's data is returned, allowing the
same endpoint to serve multiple cinemas.
Reconstructs data from a Gatsby-built site by extracting the webpack hash,
fetching static query blobs, then calling a BoxOffice API for schedule details.
Shared in common/boxofficeapi/; each chain passes the page to bootstrap from
and its own accessibility tag mapping.
Platforms: Everyman Cinema (16 venues), Cineworld (12 venues)
flowchart TD
A["Fetch listing page HTML (/venues-list, /cinemas)"] --> B["Extract webpack-runtime src prefix"]
B --> C["Fetch page-data.json for staticQueryHashes"]
C --> D["Fetch each static query blob"]
D --> E{"Find allMovie and allAttribute data"}
E --> F["Fetch BoxOffice movies API with IDs"]
F --> G["Fetch BoxOffice schedule API for cinema"]
G --> H["Return { movieListPage: schedule, moviePages: { movieData, movieDetails, attributeData } }"]
All Gatsby data fetches are wrapped in dailyCache() to avoid redundant
requests when processing multiple venues of the same chain in the same run.
For JavaScript-rendered pages or when API credentials can only be obtained by
running the page in a browser. Uses the shared get-page-with-playwright.js
helper which provides:
- Stealth plugin -- avoids bot detection
- Daily caching -- results are cached to disk so Playwright only runs once per day per cache key
- Error screenshots -- saved to
playwright-failures/on failure - 90-second timeout -- extended from default for slower runners
There are two sub-patterns:
Launches a browser, waits for the page to render, and returns the full HTML.
Platforms: BFI (2 venues), Ticketek (1 venue) Sources: TicketSource, Ticket Tailor, Ti.to
// common/ticketek.co.uk/retrieve.js (simplified)
const movieListPage = await getPageWithPlaywright(
url,
cacheKey,
async (page) => {
await page.waitForLoadState();
await page.locator("#contentShell").waitFor({ strict: false });
return await page.content();
},
);BFI is the most complex Playwright implementation: it handles paginated search results (clicking "next page" and waiting for URL changes), per-movie detail page fetches with retry logic and 30-second delays, error page detection, and known bad article ID filtering.
Launches a browser to establish a session, then executes fetch() from within
the page context to call APIs that require browser cookies or session state.
Platforms: MyVue (17 venues), Odeon (20 venues, for window.initialData)
// common/myvue.com/retrieve.js
async function retrieve({ domain, url, cinemaId }) {
return await getPageWithPlaywright(
url,
`myvue.com-${cinemaId}`,
async (page) => {
await page.waitForLoadState();
await page.locator(".header__box").waitFor();
return page.evaluate(
(url) => fetch(url).then((response) => response.json()),
`${domain}/api/microservice/showings/cinemas/${cinemaId}/films`,
);
},
);
}API calls authenticated with HMAC-SHA256 signatures. The request body is sorted, serialized, and signed with the API key concatenated with a Unix timestamp.
Platform: Cinesync (2 venues)
// common/cinesync.io/utils.js (simplified)
function generateSignature(body, apiKey, timestamp) {
const sortedBody = Object.keys(body).sort().reduce(/* ... */);
const hmacKey = apiKey + timestamp;
return crypto
.createHmac("sha256", hmacKey)
.update(JSON.stringify(sortedBody))
.digest("hex");
}The retrieval fetches available dates first, then performances for each date:
// common/cinesync.io/retrieve.js (simplified)
const movieDatesPage = await fetchSignedJson(apiKey, apiUrl, datesQuery);
const movieListPage = [];
for (const date of movieDatesPage.data.dates) {
movieListPage.push(
await fetchSignedJson(apiKey, apiUrl, performancesQuery(date)),
);
}
return { movieDatesPage, movieListPage };Some venues have no website to scrape. Their events come entirely from external sources (Eventbrite, Dice, etc.) which are incorporated during the transform step. Their retrieve function simply returns an empty object:
async function retrieve() {
return {};
}| Platform | Venues | Approach | Key File |
|---|---|---|---|
| Odeon (OCAPI) | 20 | Playwright + OCAPI | common/odeon.co.uk/retrieve.js |
| MyVue | 17 | Playwright + in-browser fetch | common/myvue.com/retrieve.js |
| Everyman | 16 | Gatsby + BoxOffice API | common/boxofficeapi/retrieve.js |
| Cineworld | 12 | Gatsby + BoxOffice API | common/boxofficeapi/retrieve.js |
| Picturehouse | 11 | POST API + HTML detail pages | common/picturehouses.com/retrieve.js |
| Curzon (OCAPI) | 10 | Omnia API + OCAPI | common/curzon.com/retrieve.js |
| Savoy Systems | 4 | Embedded JSON extraction | common/savoysystems.co.uk/retrieve.js |
| Indy Cinema Group | 3 | GraphQL API | common/indycinemagroup.com/retrieve.js |
| Firmdale Hotels | 3 | Single HTML page | common/firmdalehotels.com/retrieve.js |
| Olympic Studios | 3 | HTML list + detail pages | common/olympicstudios.com/retrieve.js |
| Electric Cinema | 2 | Direct JSON file | common/electriccinema.co.uk/retrieve.js |
| BFI | 2 | Playwright pagination + details | common/bfi.org.uk/retrieve.js |
| Tate | 2 | HTML list + detail pages | common/tate.org.uk/retrieve.js |
| Cinesync | 2 | Signed REST API | common/cinesync.io/retrieve.js |
| The Castle Cinema | 2 | HTML list + detail pages | common/thecastlecinema.com/retrieve.js |
| Admit One | 2 | HTML scraping (Win-1252) | common/admit-one.co.uk/retrieve.js |
| Ticketek | 1 | Playwright + HTML parsing | common/ticketek.co.uk/retrieve.js |
| Standalone | ~33 | Various (mostly HTML scraping) | Per-cinema cinemas/*/retrieve.js |
Sources are external ticketing platforms that aren't cinemas themselves. They're used in two ways:
- Direct retrieval -- fetching event listings from the platform
- Event supplementation -- during transform, cinema modules call each
source's
findEvents()to discover events at their venue that might not appear on their own website
| Source | Approach | Key Detail |
|---|---|---|
| bbk.ac.uk | HTML list + detail pages | Cheerio scraping, filtered by tag |
| bfi.org.uk-bfi-festivals | Playwright listing + film pages | Flare lists an A-Z; LFF lists each day, films fetched by article id |
| designmynight.com | Paginated REST API + monthly availability | Deduplicates occurrences across months |
| dice.fm | Embedded JSON (#__NEXT_DATA__) + pages |
Also searches theatre category for films |
| enchantedchristmascinema.co.uk | Home page + one page per venue | Next.js RSC payload; a pop-up whose host venues change each season |
| eventbrite.co.uk | Embedded JSON (__SERVER_DATA__) + pages |
Searches "screening" + "film-and-media" |
| feverup.com | HTML list + detail pages + sessions API | Fetches calendar dates then session times per plan/place/date |
| freefilmfestivals.org | HTML festival list + festival + events | The Events Calendar pages; times and venue geo from schema.org JSON-LD |
| japanesefilm.club | HTML list + detail pages + venue pages | Venue pages fetched for postcodes, as names collide UK-wide |
| outsavvy.com | HTML list + detail pages | Cheerio scraping |
| stowfilmlounge.com | Single HTML page | Simple fetchText |
| thecliq.app- | GraphQL API | Queries clubs + events + batched event details |
| ti.to | Playwright list + detail pages | Per-venue-slug retrieval |
| ticketsource.co.uk | Algolia search API + Playwright details | Multiple search filters (geo, location, NT Live, Exhibition On Screen) |
| tickettailor.com | Playwright per-venue-slug pages | Hardcoded venue slugs list |
| tufnellparkfilmclub.com | Single HTML page | Squarespace event list, Cheerio scraping |
| wegottickets.com | Session-backed search + detail pages | POST search held against a PHP session cookie; film and pop-up genres |
All retrieval modules use shared fetch helpers from common/utils.js:
| Function | Description |
|---|---|
fetchText |
Fetch URL, return response as text |
fetchJson |
Fetch URL, return parsed JSON |
fetchWin1252Text |
Fetch URL, decode as Windows-1252 text |
fetchWithRetry |
Fetch with configurable retries and delay |
withRetry |
Generic retry wrapper for any async function |
common/get-page-with-playwright.js wraps Playwright with:
playwright-extrastealth plugin to avoid bot detection- 90-second timeout (extended from default for slower CI runners)
- 1280x720 viewport for consistent rendering
- Daily caching via
dailyCache()-- the browser is only launched if no cached result exists for the current day - Error screenshots saved to
playwright-failures/on failure
common/cache.js provides file-based caching:
dailyCache(key, fn)-- caches the result offnto disk with a date suffix (key-yyyy-MM-dd). Subsequent calls on the same day return the cached result without executingfn.- Cache files are stored in a
cache/directory at the project root. - Used by Playwright operations, Gatsby blob fetches, and other expensive retrievals to avoid redundant network requests when processing multiple venues.
Retrieve functions return raw data in module-specific formats. The most common structure is:
{
movieListPage, moviePages;
}Where movieListPage is the listing data (HTML string, JSON object, or array)
and moviePages is a dictionary keyed by URL or ID containing detail page data.
Notable variants:
| Return Shape | Used By |
|---|---|
{ movieListPage, moviePages } |
Most cinemas and sources |
moviePages (plain array) |
OCAPI (Odeon, Curzon) |
site (plain object) |
Electric Cinema |
{ movieDatesPage, movieListPage } |
Cinesync |
{ movieListPage, moviePages: { movieData, movieDetails, attributeData } } |
Everyman |
{ movieListPages, moviePages } |
Dice.fm, DesignMyNight, Eventbrite, OutSavvy |
{ clubPages } |
Ticket Tailor |
{ venues: { [slug]: { movieListPage, moviePages } } } |
Ti.to |
{} |
Source-only venues |