#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import * as cheerio from "cheerio";
import { z } from "zod";
import express from "express";
// --- Configuration & Constants ---
const BASE_URL = "https://www.allocine.fr";
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const PORT = process.env.PORT || 8000;
const DEFAULT_LOCATION = "121600"; // Saint-Denis (93200)
const CITY_MAPPING: Record<string, string> = {
"paris": "115755",
"lyon": "113315",
"marseille": "87914",
"toulouse": "96373",
"nice": "85327",
"nantes": "112461",
"strasbourg": "109748",
"montpellier": "97612",
"bordeaux": "96943",
"lille": "106323",
"rennes": "112102",
"reims": "109315",
"le havre": "99341",
"saint-etienne": "109923",
"toulon": "87627",
"grenoble": "112299",
"dijon": "91241",
"angers": "113847",
"nimes": "95640",
"villeurbanne": "113404",
"75001": "115755",
"75000": "115755",
"saint-denis": "121600",
"93200": "121600"
};
const AMENITY_MAPPING: Record<string, string> = {
"3d": "3D",
"imax": "IMAX",
"dolbycinema": "Dolby Cinema",
"dolbyatmos": "Dolby Atmos",
"dolbyvision": "Dolby Vision",
"4dx": "4DX",
"screenx": "ScreenX",
"hfr": "HFR",
"4k": "4K",
"laser": "Projection laser",
"digital": "Numérique",
"71": "7.1",
"dolby71": "Dolby 7.1",
"thx": "THX",
"plf": "PLF",
"ice": "ICE",
"luxury": "Luxury",
"infinite": "Infinite",
"grandlarge": "Grand Large",
"4demotion": "4D E-Motion",
"airconditioning": "Air conditionné"
};
const AMENITY_KEYS_SORTED = Object.keys(AMENITY_MAPPING).sort((a, b) => b.length - a.length);
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function matchesAmenityTag(tag: string, key: string): boolean {
const pattern = new RegExp(`(^|[^a-z0-9])${escapeRegex(key)}([^a-z0-9]|$)`, "i");
return pattern.test(tag);
}
function mapTagsToAmenities(tags: any[]): string[] {
const amenities: string[] = [];
if (!tags || !Array.isArray(tags)) return amenities;
tags.forEach(tag => {
if (typeof tag !== 'string') return;
const t = normalizeWhitespace(tag.toLowerCase());
// Priority to longer strings to avoid partial matches (e.g. dolby71 vs 71) while guarding against accidental substring matches (e.g. NICE -> ICE)
for (const key of AMENITY_KEYS_SORTED) {
if (matchesAmenityTag(t, key)) {
amenities.push(AMENITY_MAPPING[key]);
break;
}
}
});
return Array.from(new Set(amenities));
}
// --- Helper Functions ---
function parseAjaxMovieShowtimes(data: any): CinemaShowtimes[] {
const results: CinemaShowtimes[] = [];
if (!data.results || !Array.isArray(data.results)) return results;
data.results.forEach((res: any) => {
if (!res.theater) return;
const cinemaName = res.theater.name;
const cinemaAddress = `${res.theater.location?.address || ""} ${res.theater.location?.zip || ""} ${res.theater.location?.city || ""}`.trim();
const cards: Cards = {
ugcIllimite: res.theater.loyaltyCards?.includes("UGC_ILLIMITE") || false,
patheCinepass: res.theater.loyaltyCards?.includes("CINEPASS") || false,
chequeCinemaUniversel: res.theater.loyaltyCards?.includes("CCU") || false,
cinecheque: res.theater.loyaltyCards?.includes("CINECHEQUE") || false,
cineCarteCip: res.theater.loyaltyCards?.includes("CINE_CARTE_CIP") || false
};
const formatsMap = new Map<string, Showtime[]>();
const processShowtimes = (stList: any[], diffusionVersion: string) => {
if (!stList) return;
stList.forEach((st: any) => {
const type = normalizeVersionLabel(diffusionVersion);
const startTime = st.startsAt ? st.startsAt.split("T")[1].substring(0, 5) : "";
const date = st.startsAt ? st.startsAt.split("T")[0] : "";
let bookingUrl: string | undefined;
if (st.data?.ticketing) {
const defaultTicket = st.data.ticketing.find((t: any) => t.provider === "default") || st.data.ticketing[0];
if (defaultTicket?.urls?.length > 0) {
bookingUrl = defaultTicket.urls[0];
}
}
const amenities: string[] = [];
if (bookingUrl) amenities.push("Réservation en ligne");
if (st.tags) {
mapTagsToAmenities(st.tags).forEach(a => {
if (!amenities.includes(a)) amenities.push(a);
});
}
if (st.projection) {
mapTagsToAmenities(st.projection).forEach(a => {
if (!amenities.includes(a)) amenities.push(a);
});
}
if (st.experience) {
mapTagsToAmenities([st.experience]).forEach(a => {
if (!amenities.includes(a)) amenities.push(a);
});
}
const showtime: Showtime = { startTime, date, bookingUrl, amenities: amenities.length > 0 ? amenities : undefined };
if (!formatsMap.has(type)) formatsMap.set(type, []);
formatsMap.get(type)!.push(showtime);
});
};
if (res.showtimes) {
if (res.showtimes.original) processShowtimes(res.showtimes.original, "VO");
if (res.showtimes.multiple) processShowtimes(res.showtimes.multiple, "VF");
if (res.showtimes.original_st) processShowtimes(res.showtimes.original_st, "VOSTFR");
}
if (formatsMap.size > 0) {
results.push({
cinemaName,
cinemaAddress,
cards,
formats: Array.from(formatsMap.entries()).map(([type, showtimes]) => ({ type, showtimes }))
});
}
});
return results;
}
function parseAjaxCinemaShowtimes(data: any, cinemaId: string, requestedDate?: string): CinemaProgram {
const program: CinemaProgram = {
cinemaName: "",
cinemaId,
cards: { ugcIllimite: false, patheCinepass: false, chequeCinemaUniversel: false, cinecheque: false, cineCarteCip: false },
movies: []
};
if (!data.results || !Array.isArray(data.results)) return program;
data.results.forEach((res: any) => {
if (!res.movie) return;
const movieTitle = res.movie.title;
const movieId = res.movie.internalId?.toString() || "";
const formatsMap = new Map<string, Showtime[]>();
const processShowtimes = (stList: any[], diffusionVersion: string) => {
if (!stList) return;
stList.forEach((st: any) => {
const date = st.startsAt ? st.startsAt.split("T")[0] : "";
// STRICT FILTERING: skip if date requested and doesn't match
if (requestedDate && date !== requestedDate) return;
const type = normalizeVersionLabel(diffusionVersion);
const startTime = st.startsAt ? st.startsAt.split("T")[1].substring(0, 5) : "";
let bookingUrl: string | undefined;
if (st.data?.ticketing) {
const defaultTicket = st.data.ticketing.find((t: any) => t.provider === "default") || st.data.ticketing[0];
if (defaultTicket?.urls?.length > 0) {
bookingUrl = defaultTicket.urls[0];
}
}
const amenities: string[] = [];
if (bookingUrl) amenities.push("Réservation en ligne");
if (st.tags) {
mapTagsToAmenities(st.tags).forEach(a => {
if (!amenities.includes(a)) amenities.push(a);
});
}
if (st.projection) {
mapTagsToAmenities(st.projection).forEach(a => {
if (!amenities.includes(a)) amenities.push(a);
});
}
if (st.experience) {
mapTagsToAmenities([st.experience]).forEach(a => {
if (!amenities.includes(a)) amenities.push(a);
});
}
const showtime: Showtime = { startTime, date, bookingUrl, amenities: amenities.length > 0 ? amenities : undefined };
if (!formatsMap.has(type)) formatsMap.set(type, []);
formatsMap.get(type)!.push(showtime);
});
};
if (res.showtimes) {
if (res.showtimes.original) processShowtimes(res.showtimes.original, "VO");
if (res.showtimes.multiple) processShowtimes(res.showtimes.multiple, "VF");
if (res.showtimes.original_st) processShowtimes(res.showtimes.original_st, "VOSTFR");
}
if (formatsMap.size > 0) {
program.movies.push({
movieTitle,
movieId,
formats: Array.from(formatsMap.entries()).map(([type, showtimes]) => ({ type, showtimes }))
});
}
});
return program;
}
async function fetchHTML(url: string): Promise<string> {
try {
const response = await axios.get(url, {
headers: {
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
},
});
return response.data;
} catch (error: any) {
throw new Error(`Failed to fetch URL ${url}: ${error.response?.status} ${error.response?.statusText || error.message}`);
}
}
function extractIdFromUrl(url: string, prefix: string = "cfilm="): string | null {
const regex = new RegExp(`${prefix}(\\w+)`);
const match = url.match(regex);
return match ? match[1] : null;
}
function decodeAllocineObfuscation(className: string): string | null {
if (className.length < 4) return null;
const noise = className.substring(0, 3);
const parts = className.split("#");
const base64Part = parts[0].split(noise).join("");
const hashPart = parts.length > 1 ? "#" + parts[1].split(" ")[0] : "";
try {
const decoded = Buffer.from(base64Part, 'base64').toString('utf-8');
if (decoded.startsWith("http") || decoded.startsWith("/") || decoded.includes("fichefilm") || decoded.includes("cfilm=") || decoded.includes("csalle=")) {
return decoded + hashPart;
}
} catch {}
return null;
}
function getRelativeUrl(titleNode: any): string | null {
let relativeUrl = titleNode.attr("href") || null;
if (!relativeUrl) {
const classes = titleNode.attr("class")?.split(/\s+/) || [];
for (const cls of classes) {
if (cls === "meta-title-link") continue;
const decoded = decodeAllocineObfuscation(cls);
if (decoded) return decoded;
}
}
return relativeUrl;
}
function extractAmenities(timeBlock: any, $: any): string[] {
const amenities: string[] = [];
const linkNode = timeBlock.find("span[class*='ACr'], a[class*='ACr']").first();
const experiencesAttr = linkNode.attr("data-experiences");
if (experiencesAttr) {
try {
const experiences = JSON.parse(experiencesAttr);
if (Array.isArray(experiences)) {
mapTagsToAmenities(experiences).forEach(a => amenities.push(a));
}
} catch {}
}
const matchesAmenityClass = (classAttr: string, key: string): boolean => {
const pattern = new RegExp(`(^|\\s|-)icon-${escapeRegex(key)}(\\s|$)`);
return pattern.test(classAttr);
};
// Fallback or addition from icons
const amenitiesNode = timeBlock.find(".showtimes-hour-block-amenities");
if (amenitiesNode.length) {
amenitiesNode.find("span").each((_: any, icon: any) => {
const cls = ($(icon).attr("class") || "").toLowerCase();
for (const [key, value] of Object.entries(AMENITY_MAPPING)) {
if (matchesAmenityClass(cls, key) && !amenities.includes(value)) {
amenities.push(value);
}
}
});
}
return Array.from(new Set(amenities));
}
type Cards = {
ugcIllimite: boolean;
patheCinepass: boolean;
chequeCinemaUniversel: boolean;
cinecheque: boolean;
cineCarteCip: boolean;
};
interface DayLink {
url: string;
label?: string;
}
const CARD_LABEL_SELECTOR = ".card-type, .badge, .label, [itemprop='paymentAccepted']";
function normalizeLabelText(value: string): string {
return value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function normalizeVersionLabel(label: string): string {
let clean = label.replace(/^\d{1,2}\s+[a-zéûà]+\s+\d{4}\s*-\s*/i, "");
clean = clean.replace(/^En\s+/i, "");
return normalizeWhitespace(clean);
}
function detectCardsFromLabels(container: any, $: any): Cards {
let ugcIllimite = false;
let patheCinepass = false;
let chequeCinemaUniversel = false;
let cinecheque = false;
let cineCarteCip = false;
container.find(CARD_LABEL_SELECTOR).each((_: any, labelEl: any) => {
const labelText = normalizeLabelText($(labelEl).text());
if (!labelText) return;
if (labelText.includes("ugc") || labelText.includes("illimite")) ugcIllimite = true;
if (labelText.includes("cinepass") || labelText.includes("cine-pass") || labelText.includes("pathe")) patheCinepass = true;
if (labelText.includes("cheque") && labelText.includes("universel")) chequeCinemaUniversel = true;
if (labelText.includes("cinecheque")) cinecheque = true;
if (labelText.includes("cip") || labelText.includes("carte cip")) cineCarteCip = true;
});
return { ugcIllimite, patheCinepass, chequeCinemaUniversel, cinecheque, cineCarteCip };
}
function extractDateFromUrl(url: string): string | undefined {
const isoMatch = url.match(/(\d{4}-\d{2}-\d{2})/);
if (isoMatch) return isoMatch[1];
const compactMatch = url.match(/(\d{8})/);
if (compactMatch) {
const compact = compactMatch[1];
return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`;
}
return undefined;
}
function normalizeDateInput(value: string): { iso?: string; compact?: string } {
const trimmed = value.trim();
if (!trimmed) return {};
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return { iso: trimmed, compact: trimmed.replace(/-/g, "") };
}
if (/^\d{4}\/\d{2}\/\d{2}$/.test(trimmed)) {
const iso = trimmed.replace(/\//g, "-");
return { iso, compact: iso.replace(/-/g, "") };
}
const dmYMatch = trimmed.match(/^(\d{2})[\/.-](\d{2})[\/.-](\d{4})$/);
if (dmYMatch) {
const [, day, month, year] = dmYMatch;
const iso = `${year}-${month}-${day}`;
return { iso, compact: iso.replace(/-/g, "") };
}
if (/^\d{8}$/.test(trimmed)) {
const iso = `${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6, 8)}`;
return { iso, compact: trimmed };
}
return {};
}
function buildDateUrls(baseUrl: string, dateInput: string): string[] {
const normalized = normalizeDateInput(dateInput);
const values = new Set<string>();
if (normalized.iso) values.add(normalized.iso);
if (normalized.compact) values.add(normalized.compact);
// Also add DD/MM/YYYY if possible
const dmYMatch = dateInput.trim().match(/^(\d{2})[\/.-](\d{2})[\/.-](\d{4})$/);
if (dmYMatch) values.add(`${dmYMatch[1]}/${dmYMatch[2]}/${dmYMatch[3]}`);
values.add(dateInput.trim());
const urls: string[] = [];
const seen = new Set<string>();
const params = ["date", "day", "jour", "d"];
for (const value of values) {
if (!value) continue;
for (const param of params) {
try {
const candidate = new URL(baseUrl);
candidate.searchParams.set(param, value);
const url = candidate.toString();
const key = normalizeUrlKey(url);
if (seen.has(key)) continue;
seen.add(key);
urls.push(url);
} catch {}
}
}
return urls;
}
function filterDayLinksByDate(dayLinks: DayLink[], dateInput: string): DayLink[] {
const normalized = normalizeDateInput(dateInput);
const normalizedInput = normalizeLabelText(dateInput);
return dayLinks.filter((link) => {
const urlDate = extractDateFromUrl(link.url);
if (normalized.iso && urlDate === normalized.iso) return true;
if (normalized.compact && link.url.includes(normalized.compact)) return true;
if (link.label) {
const label = normalizeLabelText(link.label);
if (normalized.iso && label.includes(normalized.iso)) return true;
if (normalized.compact && label.includes(normalized.compact)) return true;
if (normalizedInput && label.includes(normalizedInput)) return true;
}
return false;
});
}
function resolveUrl(href: string | undefined, baseUrl: string): string | null {
if (!href) return null;
try {
return new URL(href, baseUrl).toString();
} catch {
return null;
}
}
function normalizeUrlKey(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname.replace(/\/$/, "");
return `${parsed.origin}${path}${parsed.search}`;
} catch {
return url;
}
}
function extractDayLabel($el: any, resolvedUrl?: string): string | undefined {
const dataDate =
$el.attr("data-date") ||
$el.attr("data-day") ||
$el.attr("data-show-date") ||
$el.attr("data-iso-date") ||
$el.parent().attr("data-date") ||
$el.parent().attr("data-day");
const fromUrl = resolvedUrl ? extractDateFromUrl(resolvedUrl) : undefined;
const text = normalizeWhitespace($el.text());
return dataDate?.trim() || fromUrl || text || undefined;
}
function extractSelectedDayLabel($: any): string | undefined {
const selectors = [
".showtimes-date .active",
".showtimes-date-list .active",
".showtimes-date .current",
".showtimes-date .selected",
".showtimes-date .is-active",
".showtimes-date-list .is-active",
".showtimes-date .on",
];
for (const selector of selectors) {
const text = normalizeWhitespace($(selector).first().text());
if (text) return text;
}
return undefined;
}
function collectShowtimeDayLinks($: any, currentUrl: string, urlMatcher: (url: string) => boolean): DayLink[] {
const selectors = [
".showtimes-date a",
".showtimes-date-list a",
".showtimes-date-selector a",
".showtimes-date .item a",
".showtimes-date .tab a",
".tabs-date a",
".showtimes-tabs a",
".date-picker a",
".datepicker-date a",
".day-selector a",
"a[data-date]",
"a[data-day]",
"a[data-show-date]",
"a[data-iso-date]",
"button[data-href]",
"button[data-date]",
"button[data-day]",
"span[class*='ACr']",
];
const links: DayLink[] = [];
const seen = new Set<string>();
const addLink = (resolvedUrl: string, label?: string) => {
if (!urlMatcher(resolvedUrl)) return;
const key = normalizeUrlKey(resolvedUrl);
if (seen.has(key)) return;
seen.add(key);
links.push({ url: resolvedUrl, label });
};
$(selectors.join(",")).each((_: any, el: any) => {
const $el = $(el);
let href = $el.attr("href") || $el.attr("data-href");
if (!href) {
const classes = $el.attr("class")?.split(/\s+/) || [];
for (const cls of classes) {
if (cls.startsWith("ACr")) {
href = decodeAllocineObfuscation(cls);
if (href) break;
}
}
}
const resolved = resolveUrl(href, currentUrl);
if (!resolved) return;
const label = extractDayLabel($el, resolved);
addLink(resolved, label);
});
// Extract dates from data attributes as fallback or reinforcement
const datesAttr = $("#theaterpage-showtimes-index-ui").attr("data-showtimes-dates");
if (datesAttr) {
try {
const dates = JSON.parse(datesAttr);
if (Array.isArray(dates)) {
dates.forEach(d => {
const candidateUrl = new URL(currentUrl);
candidateUrl.hash = `shwt_date=${d}`;
addLink(candidateUrl.toString(), d);
});
}
} catch {}
}
if (links.length === 0) {
$("a[href*='day='], a[href*='date='], a[href*='jour='], button[data-href*='day='], button[data-href*='date='], button[data-href*='jour=']").each((_: any, el: any) => {
const $el = $(el);
const href = $el.attr("href") || $el.attr("data-href");
const resolved = resolveUrl(href, currentUrl);
if (!resolved) return;
const label = extractDayLabel($el, resolved);
addLink(resolved, label);
});
}
const currentKey = normalizeUrlKey(currentUrl);
if (!seen.has(currentKey)) {
const currentLabel = extractSelectedDayLabel($) || extractDateFromUrl(currentUrl) || "Aujourd'hui";
links.unshift({ url: currentUrl, label: currentLabel });
}
return links;
}
function showtimeKey(showtime: Showtime): string {
const amenitiesKey = showtime.amenities ? showtime.amenities.slice().sort().join(",") : "";
return `${showtime.date || ""}|${showtime.startTime}|${showtime.bookingUrl || ""}|${amenitiesKey}`;
}
function mergeShowtimes(target: Showtime[], incoming: Showtime[]): void {
const seen = new Set(target.map(showtimeKey));
for (const showtime of incoming) {
const key = showtimeKey(showtime);
if (seen.has(key)) continue;
seen.add(key);
target.push(showtime);
}
}
function mergeFormats(target: { type: string; showtimes: Showtime[] }[], incoming: { type: string; showtimes: Showtime[] }[]): void {
const targetByType = new Map<string, { type: string; showtimes: Showtime[] }>();
target.forEach((format) => targetByType.set(format.type, format));
for (const format of incoming) {
const existing = targetByType.get(format.type);
if (existing) {
mergeShowtimes(existing.showtimes, format.showtimes);
} else {
const cloned = { type: format.type, showtimes: [...format.showtimes] };
target.push(cloned);
targetByType.set(format.type, cloned);
}
}
}
function mergeCards(target: Cards, incoming: Cards): void {
target.ugcIllimite = target.ugcIllimite || incoming.ugcIllimite;
target.patheCinepass = target.patheCinepass || incoming.patheCinepass;
target.chequeCinemaUniversel = target.chequeCinemaUniversel || incoming.chequeCinemaUniversel;
target.cinecheque = target.cinecheque || incoming.cinecheque;
target.cineCarteCip = target.cineCarteCip || incoming.cineCarteCip;
}
// --- Scraping Logic ---
interface MovieSearchResult {
id: string;
title: string;
url: string;
posterUrl?: string;
releaseDate?: string;
}
async function getNowShowing(maxPages: number = 10): Promise<MovieSearchResult[]> {
let allResults: MovieSearchResult[] = [];
for (let page = 1; page <= maxPages; page++) {
const url = `${BASE_URL}/film/aucinema/?page=${page}`;
const html = await fetchHTML(url);
const $ = cheerio.load(html);
const pageResults: MovieSearchResult[] = [];
$(".entity-card-list").each((_, element) => {
const titleNode = $(element).find(".meta-title-link");
const title = titleNode.text().trim();
const relativeUrl = getRelativeUrl(titleNode);
if (!title || !relativeUrl) return;
const id = extractIdFromUrl(relativeUrl, "cfilm=") || extractIdFromUrl(relativeUrl, "csalle=");
if (!id) return;
pageResults.push({
id,
title,
url: relativeUrl.startsWith("http") ? relativeUrl : `${BASE_URL}${relativeUrl}`,
posterUrl: $(element).find("img.thumbnail-img").attr("data-src") || $(element).find("img.thumbnail-img").attr("src"),
releaseDate: $(element).find(".meta-body-item.meta-body-info").text().trim().match(/(\d{1,2} [a-zéûà]+ \d{4})/i)?.[1]
});
});
if (pageResults.length === 0) break;
allResults = allResults.concat(pageResults);
if ($(".pagination-item-next.disabled").length > 0) break;
}
return allResults;
}
async function searchMovies(query: string): Promise<MovieSearchResult[]> {
const url = `${BASE_URL}/rechercher/?q=${encodeURIComponent(query)}`;
const html = await fetchHTML(url);
const $ = cheerio.load(html);
const results: MovieSearchResult[] = [];
$(".entity-card-list").each((_, element) => {
const titleNode = $(element).find(".meta-title-link");
const title = titleNode.text().trim();
const relativeUrl = getRelativeUrl(titleNode);
if (!title || !relativeUrl || !relativeUrl.includes("fichefilm")) return;
const id = extractIdFromUrl(relativeUrl, "cfilm=");
if (!id) return;
results.push({
id,
title,
url: relativeUrl.startsWith("http") ? relativeUrl : `${BASE_URL}${relativeUrl}`,
posterUrl: $(element).find("img.thumbnail-img").attr("data-src") || $(element).find("img.thumbnail-img").attr("src"),
releaseDate: $(element).find(".meta-body-item.meta-body-info").text().trim().match(/(\d{1,2} [a-zéûà]+ \d{4})/i)?.[1]
});
});
return results;
}
interface Cinema {
id: string;
name: string;
address: string;
cards: Cards;
}
async function searchCinemas(query: string): Promise<Cinema[]> {
const url = `${BASE_URL}/rechercher/?q=${encodeURIComponent(query)}`;
const html = await fetchHTML(url);
const $ = cheerio.load(html);
const initialResults: { id: string; name: string; address: string }[] = [];
const foundIds = new Set<string>();
$("[data-theater]").each((_, el) => {
try {
const data = JSON.parse($(el).attr("data-theater") || "{}");
if (data.id && data.name && !foundIds.has(data.id)) {
foundIds.add(data.id);
initialResults.push({
id: data.id,
name: data.name,
address: ""
});
}
} catch {}
});
$(".entity-card-list").each((_, element) => {
const titleNode = $(element).find(".meta-title-link, .title span");
const name = titleNode.text().trim();
const relativeUrl = getRelativeUrl(titleNode);
if (!name || !relativeUrl || !relativeUrl.includes("csalle=")) return;
const id = extractIdFromUrl(relativeUrl, "csalle=");
if (!id || foundIds.has(id)) return;
foundIds.add(id);
const address = $(element).find(".meta-body-item.meta-body-info").text().trim();
initialResults.push({
id,
name,
address
});
});
// Fetch details for each cinema to get cards
const results: Cinema[] = await Promise.all(initialResults.map(async (item) => {
try {
const theaterUrl = `${BASE_URL}/seance/salle_gen_csalle=${item.id}.html`;
const theaterHtml = await fetchHTML(theaterUrl);
const t$ = cheerio.load(theaterHtml);
const cards = detectCardsFromLabels(t$.root(), t$);
return {
...item,
cards
};
} catch {
return {
...item,
cards: { ugcIllimite: false, patheCinepass: false, chequeCinemaUniversel: false, cinecheque: false, cineCarteCip: false }
};
}
}));
return results;
}
interface Showtime {
startTime: string;
date?: string;
bookingUrl?: string;
amenities?: string[];
}
interface CinemaShowtimes {
cinemaName: string;
cinemaAddress: string;
formats: { type: string; showtimes: Showtime[] }[];
cards: Cards;
}
interface MovieProgram {
movieTitle: string;
movieId: string;
formats: { type: string; showtimes: Showtime[] }[];
}
interface CinemaProgram {
cinemaName: string;
cinemaId: string;
cards: Cards;
movies: MovieProgram[];
}
interface CinemaProgramWithDayLabel {
dateLabel?: string;
program: CinemaProgram;
}
async function getCinemaShowtimes(cinemaId: string, date?: string, resolvedLabel?: string): Promise<CinemaProgram> {
const url = `${BASE_URL}/seance/salle_gen_csalle=${cinemaId}.html`;
if (date) {
const normalized = normalizeDateInput(date);
const isoDate = normalized.iso || date.trim();
try {
const ajaxUrl = `${BASE_URL}/_/showtimes/theater-${cinemaId}/d-${isoDate}`;
const ajaxResponse = await axios.get(ajaxUrl, {
headers: {
"User-Agent": USER_AGENT,
"X-Requested-With": "XMLHttpRequest",
"Referer": url,
"Accept": "application/json"
}
});
if (ajaxResponse.data && ajaxResponse.data.results) {
const program = parseAjaxCinemaShowtimes(ajaxResponse.data, cinemaId, isoDate);
program.movies.forEach(movie => {
movie.formats.forEach(format => {
format.showtimes.forEach(showtime => {
if (!showtime.date) showtime.date = resolvedLabel || isoDate;
});
});
});
return program;
}
} catch (e) {
console.error(`AJAX path-based call failed for ${isoDate}:`, e);
}
}
const html = await fetchHTML(url);
const $ = cheerio.load(html);
const dayCinemaName = normalizeWhitespace($(".theater-name-title").text()) || normalizeWhitespace($("h1").first().text());
const dayCards = detectCardsFromLabels($.root(), $);
const program: CinemaProgram = {
cinemaName: dayCinemaName,
cinemaId,
cards: dayCards,
movies: []
};
const moviesByKey = new Map<string, MovieProgram>();
$(".movie-card-theater").each((_, movieNode) => {
const titleNode = $(movieNode).find(".meta-title-link");
const movieTitle = titleNode.text().trim();
if (!movieTitle) return;
const movieUrl = getRelativeUrl(titleNode);
const movieId = movieUrl ? (extractIdFromUrl(movieUrl, "cfilm=") || "") : "";
const formats: { type: string; showtimes: Showtime[] }[] = [];
$(movieNode).find(".showtimes-version").each((_, versionNode) => {
const rawType = $(versionNode).find(".text").text().trim();
const type = normalizeVersionLabel(rawType);
const showtimes: Showtime[] = [];
$(versionNode).find(".showtimes-hour-block").each((_, timeBlock) => {
const timeVal = $(timeBlock).text().replace("Réserver", "").trim().match(/(\d{2}:\d{2})/)?.[1];
if (!timeVal) return;
let bookingUrl: string | undefined;
const linkElement = $(timeBlock).find("[class*='ACr']");
if (linkElement.length) {
const classes = linkElement.attr("class")?.split(/\s+/) || [];
for (const cls of classes) {
if (!cls.startsWith("ACr")) continue;
const decoded = decodeAllocineObfuscation(cls);
if (decoded && (decoded.startsWith("http") || decoded.startsWith("/"))) {
bookingUrl = decoded.startsWith("/") ? `${BASE_URL}${decoded}` : decoded;
break;
}
}
}
const amenities = extractAmenities($(timeBlock), $);
if (bookingUrl) amenities.unshift("Réservation en ligne");
const showtime: Showtime = { startTime: timeVal, bookingUrl, amenities: amenities.length > 0 ? amenities : undefined };
showtime.date = resolvedLabel || "Aujourd'hui";
showtimes.push(showtime);
});
if (showtimes.length > 0) formats.push({ type, showtimes });
});
if (formats.length > 0) {
const key = movieId ? `id:${movieId}` : `title:${normalizeWhitespace(movieTitle).toLowerCase()}`;
moviesByKey.set(key, { movieTitle, movieId, formats });
}
});
program.movies = Array.from(moviesByKey.values());
return program;
}
async function getShowtimes(movieId: string, location?: string, date?: string): Promise<CinemaShowtimes[]> {
const cleanLocation = (location || "").toLowerCase().trim();
const locationId = CITY_MAPPING[cleanLocation] || location || DEFAULT_LOCATION;
const url = `${BASE_URL}/seance/film-${movieId}/pres-de-${encodeURIComponent(locationId)}/`;
if (date) {
const normalized = normalizeDateInput(date);
const isoDate = normalized.iso || date.trim();
try {
const ajaxUrl = `${BASE_URL}/_/showtimes/movie-${movieId}/near-${locationId}/d-${isoDate}`;
const ajaxResponse = await axios.get(ajaxUrl, {
headers: {
"User-Agent": USER_AGENT,
"X-Requested-With": "XMLHttpRequest",
"Referer": url,
"Accept": "application/json"
}
});
if (ajaxResponse.data && ajaxResponse.data.results) {
return parseAjaxMovieShowtimes(ajaxResponse.data);
}
} catch (e) {
console.error(`AJAX path-based call failed for movie ${movieId}:`, e);
}
}
const html = await fetchHTML(url);
const $ = cheerio.load(html);
let dayLinks = collectShowtimeDayLinks($, url, (candidate) => candidate.includes(`/seance/film-${movieId}/`));
const baseKey = normalizeUrlKey(url);
const resultsByKey = new Map<string, CinemaShowtimes>();
const getCinemaKey = (cinemaName: string, cinemaAddress: string, cinemaId?: string) => {
if (cinemaId) return `id:${cinemaId}`;
const normalizedName = normalizeWhitespace(cinemaName).toLowerCase();
const normalizedAddress = normalizeWhitespace(cinemaAddress).toLowerCase();
return normalizedAddress ? `${normalizedName}|${normalizedAddress}` : normalizedName;
};
for (const day of dayLinks) {
const dayHtml = normalizeUrlKey(day.url) === baseKey ? html : await fetchHTML(day.url);
const dayPage = cheerio.load(dayHtml);
const selectedLabel = extractSelectedDayLabel(dayPage);
const resolvedDayLabel = selectedLabel || day.label || extractDateFromUrl(day.url);
dayPage(".theater-card").each((_, theaterNode) => {
let cinemaName = dayPage(theaterNode).find(".theater-name h2, .theater-name a").first().text().trim();
let cinemaAddress = dayPage(theaterNode).find(".theater-address").text().trim();
if (!cinemaName) {
const lines = dayPage(theaterNode)
.find(".theater-infos")
.text()
.trim()
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length > 0) {
cinemaName = lines[0];
if (lines.length > 1) cinemaAddress = lines.slice(1).join(", ");
}
}
cinemaName = normalizeWhitespace(cinemaName);
cinemaAddress = normalizeWhitespace(cinemaAddress);
if (!cinemaName) return;
const formats: { type: string; showtimes: Showtime[] }[] = [];
dayPage(theaterNode).find(".showtimes-version").each((_, versionNode) => {
const rawLabel = dayPage(versionNode).find(".text").text().trim();
const versionLabel = normalizeVersionLabel(rawLabel);
const showtimes: Showtime[] = [];
dayPage(versionNode).find(".showtimes-hour-block").each((_, timeBlock) => {
const timeVal = dayPage(timeBlock).text().replace("Réserver", "").trim().match(/(\d{2}:\d{2})/)?.[1];
if (!timeVal) return;
let bookingUrl: string | undefined;
const linkElement = dayPage(timeBlock).find("[class*='ACr']");
if (linkElement.length) {
const classes = linkElement.attr("class")?.split(/\s+/) || [];
for (const cls of classes) {
if (!cls.startsWith("ACr")) continue;
const decoded = decodeAllocineObfuscation(cls);
if (decoded) {
bookingUrl = decoded.startsWith("/") ? `${BASE_URL}${decoded}` : decoded;
break;
}
}
}
const amenities = extractAmenities(dayPage(timeBlock), dayPage);
if (bookingUrl) amenities.unshift("Réservation en ligne");
const showtime: Showtime = { startTime: timeVal, bookingUrl, amenities: amenities.length > 0 ? amenities : undefined };
if (resolvedDayLabel) showtime.date = resolvedDayLabel;
showtimes.push(showtime);
});
if (showtimes.length > 0) formats.push({ type: versionLabel, showtimes });
});
if (formats.length > 0) {
const theaterLink = dayPage(theaterNode).find(".theater-name a").first();
const theaterUrl = getRelativeUrl(theaterLink);
const cinemaId = theaterUrl ? (extractIdFromUrl(theaterUrl, "csalle=") || "") : "";
const key = getCinemaKey(cinemaName, cinemaAddress, cinemaId);
const existing = resultsByKey.get(key);
const cards = detectCardsFromLabels(dayPage(theaterNode), dayPage);
if (!existing) {
resultsByKey.set(key, { cinemaName, cinemaAddress, formats, cards });
} else {
if (!existing.cinemaAddress && cinemaAddress) {
existing.cinemaAddress = cinemaAddress;
}
mergeFormats(existing.formats, formats);
mergeCards(existing.cards, cards);
}
}
});
}
return Array.from(resultsByKey.values());
}
async function getCinemaShowtimesAllDays(cinemaId: string): Promise<CinemaProgramWithDayLabel[]> {
const baseUrl = `${BASE_URL}/seance/salle_gen_csalle=${cinemaId}.html`;
const html = await fetchHTML(baseUrl);
const $ = cheerio.load(html);
const dayLinks = collectShowtimeDayLinks($, baseUrl, (candidate) => candidate.includes(`/salle_gen_csalle=${cinemaId}`));
const baseKey = normalizeUrlKey(baseUrl);
const programs: CinemaProgramWithDayLabel[] = [];
for (const day of dayLinks) {
const isBaseDay = normalizeUrlKey(day.url) === baseKey;
const dateFromUrl = extractDateFromUrl(day.url);
const label = day.label || dateFromUrl;
const program = await getCinemaShowtimes(cinemaId, isBaseDay ? undefined : (dateFromUrl || label), label);
programs.push({ dateLabel: label, program });
}
return programs;
}
async function getCinemasByCard(cardType: 'ugc' | 'pathe', location?: string): Promise<Cinema[]> {
const cleanLocation = (location || "").toLowerCase().trim();
const locationId = CITY_MAPPING[cleanLocation] || location || DEFAULT_LOCATION;
// card=1: UGC Illimité, card=2: CinéPass
const cardId = cardType === 'ugc' ? '1' : '2';
// Use the city-specific salle list URL
const url = `${BASE_URL}/salle/cinema/ville-${encodeURIComponent(locationId)}/?card=${cardId}`;
console.error(`Fetching cinemas filtered by card: ${url}`);
const html = await fetchHTML(url);
const $ = cheerio.load(html);
const results: Cinema[] = [];
$(".theater-card").each((_, el) => {
let name = $(el).find(".theater-name h2, .theater-name a, .theater-infos h2").first().text().trim();
let address = $(el).find(".theater-address").text().trim();
if (!name) {
const infosNode = $(el).find(".theater-infos");
const fullText = infosNode.text().trim();
const lines = fullText.split("\n").map(l => l.trim()).filter(l => l.length > 0);
if (lines.length > 0) {
name = lines[0];
if (lines.length > 1) address = lines.slice(1).join(", ");
}
}
// Search for obfuscated link to get ID
let id = "";
$(el).find("span[class*='ACr'], a[class*='ACr'], a[href*='salle_gen_csalle']").each((_, linkEl) => {
const relativeUrl = getRelativeUrl($(linkEl));
if (relativeUrl && relativeUrl.includes("csalle=")) {
id = extractIdFromUrl(relativeUrl, "csalle=") || "";
return false; // found it
}
});
if (name && id) {
results.push({
id,
name,
address,
cards: {
ugcIllimite: cardType === 'ugc',
patheCinepass: cardType === 'pathe',
chequeCinemaUniversel: false, // Not detected via this specific filter
cinecheque: false,
cineCarteCip: false
}
});
}
});
return results;
}
// --- MCP Server Definition ---
const server = new Server(
{ name: "allocine-mcp-server", version: "1.1.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_movies",
description: "Search for a movie on AlloCiné to get its ID and basic info.",
inputSchema: {
type: "object",
properties: { query: { type: "string", description: "Movie title" } },
required: ["query"]
}
},
{
name: "get_movies_now_showing",
description: "Get all movies currently in theaters (paginated).",
inputSchema: {
type: "object",
properties: { max_pages: { type: "number", description: "Number of pages to fetch (approx 15 movies per page). Default: 10.", default: 10 } },
}
},
{
name: "search_cinemas",
description: "Search for a cinema to get its ID and see if it accepts UGC Illimité or Pathé Cinépass.",
inputSchema: {
type: "object",
properties: { query: { type: "string", description: "Cinema name or city" } },
required: ["query"]
}
},
{
name: "get_showtimes",
description: "Get showtimes for a specific movie in a specific location (defaults to Saint-Denis), over several days.",
inputSchema: {
type: "object",
properties: {
movie_id: { type: "string" },
location: { type: "string", description: "Zip code or City (Paris, Lyon...). Default: Saint-Denis" },
date: { type: "string", description: "Optional date to target (YYYY-MM-DD or DD/MM/YYYY). If set, only that day is fetched." }
},
required: ["movie_id"]
}
},
{
name: "get_cinema_showtimes",
description: "Get the full program (all movies and times) for a specific cinema using its ID, over several days.",
inputSchema: {
type: "object",
properties: {
cinema_id: { type: "string", description: "The AlloCiné ID of the cinema (e.g. C0013)" },
date: { type: "string", description: "Optional date to target (YYYY-MM-DD or DD/MM/YYYY). If set, only that day is fetched." }
},
required: ["cinema_id"]
}
},
{
name: "get_cinema_showtimes_all_days",
description: "Get the full program for a cinema across all days available at request time.",
inputSchema: {
type: "object",
properties: {
cinema_id: { type: "string", description: "The AlloCiné ID of the cinema (e.g. C0013)" }
},
required: ["cinema_id"]
}
},
{
name: "get_cinemas_by_card",
description: "Get a list of cinemas accepting a specific card (UGC Illimité or Pathé Cinépass) in a location (defaults to Saint-Denis).",
inputSchema: {
type: "object",
properties: {
location: { type: "string", description: "Zip code or City. Default: Saint-Denis" },
card_type: { type: "string", enum: ["ugc", "pathe"], description: "The type of card: 'ugc' for UGC Illimité, 'pathe' for Pathé Cinépass" }
},
required: ["card_type"]
}
},
{
name: "get_movie_booking_links",
description: "Get direct booking links for a specific movie in a specific cinema.",
inputSchema: {
type: "object",
properties: {
movie_id: { type: "string", description: "The AlloCiné ID of the movie" },
cinema_id: { type: "string", description: "The AlloCiné ID of the cinema" },
date: { type: "string", description: "Optional date (YYYY-MM-DD)" }
},
required: ["movie_id", "cinema_id"]
}
},
],
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
if (name === "search_movies") {
const { query } = z.object({ query: z.string() }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await searchMovies(query), null, 2) }] };
}
if (name === "get_movies_now_showing") {
const { max_pages } = z.object({ max_pages: z.number().optional() }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await getNowShowing(max_pages || 10), null, 2) }] };
}
if (name === "search_cinemas") {
const { query } = z.object({ query: z.string() }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await searchCinemas(query), null, 2) }] };
}
if (name === "get_showtimes") {
const { movie_id, location, date } = z.object({ movie_id: z.string(), location: z.string().optional(), date: z.string().optional() }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await getShowtimes(movie_id, location, date), null, 2) }] };
}
if (name === "get_cinema_showtimes") {
const { cinema_id, date } = z.object({ cinema_id: z.string(), date: z.string().optional() }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await getCinemaShowtimes(cinema_id, date), null, 2) }] };
}
if (name === "get_cinema_showtimes_all_days") {
const { cinema_id } = z.object({ cinema_id: z.string() }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await getCinemaShowtimesAllDays(cinema_id), null, 2) }] };
}
if (name === "get_cinemas_by_card") {
const { location, card_type } = z.object({ location: z.string().optional(), card_type: z.enum(["ugc", "pathe"]) }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await getCinemasByCard(card_type, location), null, 2) }] };
}
if (name === "get_movie_booking_links") {
const { movie_id, cinema_id, date } = z.object({
movie_id: z.string(),
cinema_id: z.string(),
date: z.string().optional()
}).parse(args);
const program = await getCinemaShowtimes(cinema_id, date);
const movie = program.movies.find(m => m.movieId === movie_id);
if (!movie) {
return { content: [{ type: "text", text: `Le film ${movie_id} n'est pas à l'affiche dans le cinéma ${cinema_id}${date ? ` le ${date}` : ""} sur la période disponible.` }] };
}
return { content: [{ type: "text", text: JSON.stringify({ cinemaName: program.cinemaName, movieTitle: movie.movieTitle, formats: movie.formats }, null, 2) }] };
}
throw new Error(`Unknown tool: ${name}`);
} catch (error: any) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
// --- Start Server ---
let transportType: "sse" | "stdio" = "sse";
const modeArg = process.argv.find((arg) => arg.startsWith("--mode="));
if (modeArg) {
const mode = modeArg.split("=")[1];
if (mode === "stdio" || mode === "sse") {
transportType = mode;
}
} else if (process.argv.includes("--stdio")) {
transportType = "stdio";
} else if (process.argv.includes("--sse")) {
transportType = "sse";
}
if (transportType === "sse") {
const app = express();
let transport: SSEServerTransport | null = null;
app.get("/sse", async (req, res) => {
console.error("New SSE connection");
transport = new SSEServerTransport("/messages", res);
await server.connect(transport);
});
app.post("/messages", async (req, res) => {
if (!transport) {
res.status(400).send("No active SSE connection");
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(PORT, () => {
console.error(`Allociné MCP Server running on HTTP SSE at http://localhost:${PORT}/sse`);
});
} else {
const transport = new StdioServerTransport();
server.connect(transport).then(() => {
console.error("Allociné MCP Server running on Stdio");
});
}