Afficher les séances de son cinéma sur son site, en HTML et JavaScript

Un div et un script affichent le programme d’un cinéma pour les jours qui viennent, affiches et liens de réservation compris, tenus à jour par un relais PHP ou Node.js de 50 lignes qui garde la clé et met en cache.

7 min de lecture

Sur cette page
  1. Pourquoi un relais, et pas la clé dans la page
  2. Ce qu’il vous faut
  3. 1.Le relais
  4. 2.Le script
  5. 3.La page et un peu de style
  6. 4.Comment ça marche
  7. 5.Pas de PHP chez votre hébergeur ? Le même relais en Node.js
  8. Pour aller plus loin

Le site d’un cinéma, une page de mairie, un office de tourisme ou un site d’actualité locale : le programme doit y être, et rester à jour sans que quelqu’un le ressaisisse le lundi matin. Ce tutoriel le fait avec un <div> et un script dans la page, plus un petit fichier côté serveur qui garde votre clé API et met les réponses en cache. Sans framework, sans build, sans dépendance.

HTML
<div class="cinema-showtimes" data-cinema="le-champo-paris-5e-arrondissement" data-days="7"></div>
<script src="showtimes.js" defer></script>

Le résultat : les jours qui viennent, film par film, avec affiche, durée, réalisateur et un lien par séance vers la page de réservation du cinéma.

Pourquoi un relais, et pas la clé dans la page

Tout ce qui est dans une page web peut être lu : une clé API dans showtimes.js serait trouvée en quelques minutes et n’importe qui pourrait consommer votre quota. La page appelle donc votre propre serveur (un showtimes.php de 50 lignes, ou un fichier Node.js si votre hébergeur n’a pas PHP), qui ajoute la clé, appelle API Cinéma et garde chaque réponse deux heures.

navigateur ──GET showtimes.php?cinema=…──▶ votre serveur ──Bearer clé──▶ API Cinéma
 (sans clé)                                 (la clé, un cache de 2 h)

Grâce au cache, une page vue 10 000 fois par jour coûte 12 requêtes par jour : dans l’offre gratuite.

Ce qu’il vous faut

  • Un hébergement qui exécute PHP (presque tous les mutualisés) ou Node.js : voir l’étape 5 pour la version Node.js.
  • Une clé API Cinéma : créez un compte gratuit, confirmez votre adresse e-mail, puis créez une clé depuis le tableau de bord. L’offre gratuite (150 requêtes par semaine) suffit pour essayer ou pour un site non commercial, grâce au cache ; le site d’un cinéma ou d’une entreprise nécessite un forfait payant, dès 12 € par mois.
  • Le slug du cinéma. Cherchez-le par son nom :
Terminal
curl "https://api.api-cinema.com/v1/cinemas?q=champo" -H "Authorization: Bearer ac_live_…"

La réponse donne "slug": "le-champo-paris-5e-arrondissement". Tout cinéma français ou américain de l’API fonctionne de la même façon.

Le relais : showtimes.php

Mettez votre clé dans la ligne define et déposez le fichier à côté de votre page :

showtimes.php
<?php
// Relais entre la page et API Cinéma : garde la clé, met le programme de chaque cinéma en cache deux heures,
// ne répond qu'à GET showtimes.php?cinema=<slug>&days=<1-14>. À déposer à côté de showtimes.js.
define('API_CINEMA_KEY', 'ac_live_…');
const CACHE_SECONDS = 2 * 3600;

$cinema = $_GET['cinema'] ?? '';
$days = max(1, min(14, (int) ($_GET['days'] ?? 7)));
header('Content-Type: application/json; charset=utf-8');
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $cinema) || strlen($cinema) > 80) {
    http_response_code(400);
    exit('{"error":"invalid cinema"}');
}

$cacheFile = sys_get_temp_dir() . '/api-cinema-' . $cinema . '-' . $days . '.json';
if (is_file($cacheFile) && filemtime($cacheFile) > time() - CACHE_SECONDS) {
    header('X-Cache: hit');
    readfile($cacheFile);
    exit;
}

$url = 'https://api.api-cinema.com/v1/cinemas/' . rawurlencode($cinema) . '/showtimes?' . http_build_query([
    'to' => date('Y-m-d', strtotime('+' . ($days - 1) . ' days')),
    'include' => 'movie',
    'limit' => 1000,
]);
$body = @file_get_contents($url, false, stream_context_create(['http' => [
    'header' => 'Authorization: Bearer ' . API_CINEMA_KEY,
    'timeout' => 10,
    'ignore_errors' => true,
]]));
$status = isset($http_response_header[0]) && preg_match('#HTTP/\S+ (\d{3})#', $http_response_header[0], $m) ? (int) $m[1] : 0;
if ($status === 404) {
    http_response_code(404);
    exit('{"error":"unknown cinema"}');
}
$data = $status === 200 ? json_decode($body, true)['data'] ?? null : null;

if ($data === null) {
    if (is_file($cacheFile)) { // l'API ne répond pas : le dernier programme plutôt que rien
        header('X-Cache: stale');
        readfile($cacheFile);
        exit;
    }
    http_response_code(502);
    exit('{"error":"api unavailable"}');
}

$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
file_put_contents($cacheFile, $json, LOCK_EX);
header('X-Cache: miss');
echo $json;

Ce qu’il fait : il n’accepte qu’un slug de cinéma (lettres, chiffres et tirets) et un nombre de jours de 1 à 14, donc personne ne peut s’en servir pour appeler une autre route avec votre clé. Il garde la réponse dans un fichier pendant deux heures. Quand API Cinéma ne répond pas, il sert le dernier programme qu’il a plutôt qu’une erreur. Les données passent telles quelles : showtimes.js lit les mêmes champs que l’API.

Le script : showtimes.js

showtimes.js
// Remplit chaque <div class="cinema-showtimes" data-cinema="…"> avec le programme du cinéma, jour par jour, film par film.
// La page ne contient jamais la clé API : elle interroge votre relais (showtimes.php ou server.js), qui appelle API Cinéma.
const TEXT = { loading: 'Chargement du programme…', empty: 'Aucune séance dans les jours qui viennent.', error: 'Programme indisponible pour le moment.' }
const LANG = document.documentElement.lang || navigator.language
const dayFormat = new Intl.DateTimeFormat(LANG, { weekday: 'long', day: 'numeric', month: 'long' })

document.querySelectorAll('.cinema-showtimes[data-cinema]').forEach(fill)

async function fill(root) {
  root.textContent = TEXT.loading
  const params = new URLSearchParams({ cinema: root.dataset.cinema, days: root.dataset.days || '7' })
  try {
    const res = await fetch(`${root.dataset.relay || 'showtimes.php'}?${params}`)
    if (!res.ok)
      throw new Error(`relay ${res.status}`)
    const showtimes = await res.json()
    root.replaceChildren(...(showtimes.length ? render(showtimes) : [el('p', TEXT.empty)]))
  }
  catch (err) {
    console.error(err)
    root.textContent = TEXT.error
  }
}

// Une <section> par jour, un <article> par film, un lien par séance (texte simple quand le cinéma n'a pas de réservation en ligne).
function render(showtimes) {
  const days = new Map()
  for (const s of showtimes) {
    const day = s.startsAt.slice(0, 10)
    if (!days.has(day))
      days.set(day, new Map())
    const films = days.get(day)
    if (!films.has(s.movie.id))
      films.set(s.movie.id, { movie: s.movie, times: [] })
    films.get(s.movie.id).times.push(s)
  }

  return [...days].map(([day, films]) => {
    const section = el('section', '', { class: 'cs-day' })
    section.append(el('h2', dayFormat.format(new Date(`${day}T12:00:00`))))
    for (const { movie, times } of films.values()) {
      const article = el('article', '', { class: 'cs-film' })
      if (movie.posterUrl)
        article.append(el('img', '', { src: movie.posterUrl, alt: '', loading: 'lazy', width: 60 }))
      const body = el('div', '', { class: 'cs-body' })
      body.append(el('h3', movie.title))
      const meta = [movie.durationMin && `${movie.durationMin} min`, movie.director].filter(Boolean).join(' · ')
      if (meta)
        body.append(el('p', meta, { class: 'cs-meta' }))
      const list = el('p', '', { class: 'cs-times' })
      for (const s of times) {
        const label = s.startsAt.slice(11, 16) + (s.version !== 'VF' ? ` ${s.version}` : '') + (s.format !== '2D' ? ` ${s.format}` : '')
        list.append(s.bookingUrl ? el('a', label, { href: s.bookingUrl, target: '_blank', rel: 'noopener nofollow' }) : el('span', label))
      }
      body.append(list)
      article.append(body)
      section.append(article)
    }
    return section
  })
}

// Un nœud texte, jamais du HTML : titres et liens viennent des sites des cinémas.
function el(tag, text, attrs = {}) {
  const node = document.createElement(tag)
  if (text)
    node.textContent = text
  for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v)
  return node
}

Tout est construit avec createElement et textContent : les titres et les liens viennent des sites des cinémas et ne sont jamais injectés comme du HTML. Les noms de jours suivent l’attribut lang de la page (lundi 2 novembre sur une page française, Monday 2 November sur une page anglaise).

La page et un peu de style

index.html
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Le Champo : le programme de la semaine</title>
  <link rel="stylesheet" href="showtimes.css">
</head>
<body>
  <h1>Le Champo</h1>
  <p>51 rue des Écoles, Paris 5e</p>

  <div class="cinema-showtimes" data-cinema="le-champo-paris-5e-arrondissement" data-days="7"></div>

  <script src="showtimes.js" defer></script>
</body>
</html>

data-days va de 1 à 14 (7 par défaut). data-relay est l’adresse du relais ; omettez-le quand showtimes.php est à côté de la page.

showtimes.css
.cinema-showtimes { max-width: 40rem; font-family: system-ui, sans-serif; }
.cs-day h2 { margin: 1.5em 0 0.5em; font-size: 1.1rem; }
.cs-day h2::first-letter { text-transform: uppercase; }
.cs-film { display: flex; gap: 0.8rem; margin: 0.8em 0; }
.cs-film img { width: 60px; height: 90px; object-fit: cover; border-radius: 4px; }
.cs-film h3 { margin: 0; font-size: 1rem; }
.cs-meta { margin: 0.1em 0; color: #666; font-size: 0.85rem; }
.cs-times { margin: 0.3em 0; }
.cs-times a, .cs-times span {
  display: inline-block; margin: 0.15em 0.3em 0.15em 0; padding: 0.15em 0.5em;
  border: 1px solid currentColor; border-radius: 4px; text-decoration: none; font-variant-numeric: tabular-nums;
}

Ouvrez la page : le programme apparaît après un court « Chargement du programme… », et la visite suivante est servie depuis le cache.

Comment ça marche

  • Une requête toutes les deux heures au plus, par cinéma et nombre de jours. 12 requêtes par jour, 84 par semaine, quel que soit le trafic.
  • to sans from. Sans from, l’API part de maintenant dans l’heure locale du cinéma, donc les séances déjà commencées sont exclues ; to=2026-11-08 inclut toute cette journée.
  • include=movie. Le cinéma est le même sur chaque ligne : n’embarquer que le film allège la réponse.
  • Heures locales. startsAt est l’heure affichée par le cinéma (2026-11-02T20:30:00) : le script la montre telle quelle, quel que soit le fuseau du serveur ou du visiteur.
  • Versions et formats. VF est la valeur par défaut en France et n’est pas affichée ; VO et VOST s’ajoutent à côté de l’heure, comme 3D, IMAX ou 4DX. Pour un cinéma américain toutes les séances sont en VO : remplacez le test par !['VF', 'VO'].includes(s.version) pour la masquer.
  • Erreurs. Un slug mal formé reçoit un 400, un cinéma inconnu un 404, une API injoignable un 502 (ou le cache périmé) : la page affiche alors « Programme indisponible pour le moment. » et le détail va dans la console.

Pas de PHP chez votre hébergeur ? Le même relais en Node.js

server.js sert votre dossier public/ et répond à GET /showtimes?cinema=…&days=… avec les mêmes règles et un cache en mémoire. Aucun paquet à installer.

server.js
import { readFile } from 'node:fs/promises'
// Relais Node.js, sans dépendance : sert le dossier public/ et GET /showtimes?cinema=<slug>&days=<1-14>.
// Lancer avec API_CINEMA_KEY=ac_live_… node server.js, puis ouvrir http://localhost:3000.
import { createServer } from 'node:http'
import { extname, join, normalize } from 'node:path'

const KEY = process.env.API_CINEMA_KEY
const PORT = process.env.PORT || 3000
const CACHE_MS = 2 * 3600 * 1000
const cache = new Map() // "slug|days" → { at, body }
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' }

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost')
  if (url.pathname === '/showtimes')
    return showtimes(url, res)
  const file = url.pathname === '/' ? '/index.html' : normalize(url.pathname)
  try {
    const body = await readFile(join('public', file))
    res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream' }).end(body)
  }
  catch {
    res.writeHead(404).end('Not found')
  }
}).listen(PORT, () => console.log(`http://localhost:${PORT}`))

async function showtimes(url, res) {
  const cinema = url.searchParams.get('cinema') || ''
  const days = Math.max(1, Math.min(14, Number(url.searchParams.get('days')) || 7))
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(cinema) || cinema.length > 80)
    return json(res, 400, '{"error":"invalid cinema"}')

  const key = `${cinema}|${days}`
  const hit = cache.get(key)
  if (hit && Date.now() - hit.at < CACHE_MS)
    return json(res, 200, hit.body, 'hit')

  const to = new Date(Date.now() + (days - 1) * 86_400_000).toISOString().slice(0, 10)
  try {
    const api = await fetch(`https://api.api-cinema.com/v1/cinemas/${cinema}/showtimes?to=${to}&include=movie&limit=1000`, {
      headers: { Authorization: `Bearer ${KEY}` },
      signal: AbortSignal.timeout(10_000),
    })
    if (api.status === 404)
      return json(res, 404, '{"error":"unknown cinema"}')
    if (!api.ok)
      throw new Error(`API Cinéma ${api.status}`)
    const body = JSON.stringify((await api.json()).data)
    cache.set(key, { at: Date.now(), body })
    json(res, 200, body, 'miss')
  }
  catch (err) {
    console.error(err)
    if (hit)
      return json(res, 200, hit.body, 'stale') // l'API ne répond pas : le dernier programme plutôt que rien
    json(res, 502, '{"error":"api unavailable"}')
  }
}

function json(res, status, body, cacheHeader) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...(cacheHeader && { 'X-Cache': cacheHeader }) })
  res.end(body)
}

Placez index.html, showtimes.js et showtimes.css dans public/, mettez data-relay="/showtimes" sur le <div>, puis :

Terminal
echo '{"type":"module"}' > package.json
API_CINEMA_KEY=ac_live_… node server.js

Ouvrez http://localhost:3000. Sur un hébergeur Node (Render, Fly.io, un VPS), mettez API_CINEMA_KEY dans ses secrets. Le relais du tutoriel React Native reprend la même idée avec une limite par adresse, utile sur un site très visité.

Pour aller plus loin

  • Plusieurs cinémas : le <div> fonctionne autant de fois que voulu sur une page ; chaque cinéma a son propre cache.
  • Synopsis et genres : movie.synopsis et movie.genres sont déjà dans la réponse ; ajoutez-les dans render().
  • Une page par film : movie.tmdbId et movie.allocineId relient le film à TMDB et à Allociné.
  • Tous les cinémas d’une ville : /v1/showtimes?city=… ou near=lat,lng au lieu d’un seul cinéma, avec include=movie,cinema pour avoir le nom du cinéma sur chaque ligne ; voir le guide pour la France ou pour les États-Unis.
  • Sur WordPress : le tutoriel WordPress fait la même chose avec un shortcode, sans JavaScript.

Chaque paramètre et chaque champ est décrit dans la documentation.