#!/usr/bin/env node
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
const axios_1 = __importDefault(require("axios"));
const cheerio = __importStar(require("cheerio"));
const zod_1 = require("zod");
const express_1 = __importDefault(require("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 = {
"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 = {
"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é"
};
function mapTagsToAmenities(tags) {
const amenities = [];
if (!tags || !Array.isArray(tags))
return amenities;
tags.forEach(tag => {
if (typeof tag !== 'string')
return;
const t = tag.toLowerCase();
// Priority to longer strings to avoid partial matches (e.g. dolby71 vs 71)
const sortedKeys = Object.keys(AMENITY_MAPPING).sort((a, b) => b.length - a.length);
for (const key of sortedKeys) {
if (t.includes(key)) {
amenities.push(AMENITY_MAPPING[key]);
break;
}
}
});
return Array.from(new Set(amenities));
}
// --- Helper Functions ---
function parseAjaxMovieShowtimes(data) {
const results = [];
if (!data.results || !Array.isArray(data.results))
return results;
data.results.forEach((res) => {
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 = {
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();
const processShowtimes = (stList, diffusionVersion) => {
if (!stList)
return;
stList.forEach((st) => {
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;
if (st.data?.ticketing) {
const defaultTicket = st.data.ticketing.find((t) => t.provider === "default") || st.data.ticketing[0];
if (defaultTicket?.urls?.length > 0) {
bookingUrl = defaultTicket.urls[0];
}
}
const amenities = [];
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 = { 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, cinemaId, requestedDate) {
const program = {
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) => {
if (!res.movie)
return;
const movieTitle = res.movie.title;
const movieId = res.movie.internalId?.toString() || "";
const formatsMap = new Map();
const processShowtimes = (stList, diffusionVersion) => {
if (!stList)
return;
stList.forEach((st) => {
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;
if (st.data?.ticketing) {
const defaultTicket = st.data.ticketing.find((t) => t.provider === "default") || st.data.ticketing[0];
if (defaultTicket?.urls?.length > 0) {
bookingUrl = defaultTicket.urls[0];
}
}
const amenities = [];
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 = { 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) {
try {
const response = await axios_1.default.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) {
throw new Error(`Failed to fetch URL ${url}: ${error.response?.status} ${error.response?.statusText || error.message}`);
}
}
function extractIdFromUrl(url, prefix = "cfilm=") {
const regex = new RegExp(`${prefix}(\\w+)`);
const match = url.match(regex);
return match ? match[1] : null;
}
function decodeAllocineObfuscation(className) {
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) {
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, $) {
const amenities = [];
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 { }
}
// Fallback or addition from icons
const amenitiesNode = timeBlock.find(".showtimes-hour-block-amenities");
if (amenitiesNode.length) {
amenitiesNode.find("span").each((_, icon) => {
const cls = ($(icon).attr("class") || "").toLowerCase();
for (const [key, value] of Object.entries(AMENITY_MAPPING)) {
if (cls.includes(`icon-${key}`) && !amenities.includes(value)) {
amenities.push(value);
}
}
});
}
return Array.from(new Set(amenities));
}
const CARD_LABEL_SELECTOR = ".card-type, .badge, .label, [itemprop='paymentAccepted']";
function normalizeLabelText(value) {
return value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, " ")
.trim();
}
function normalizeWhitespace(value) {
return value.replace(/\s+/g, " ").trim();
}
function normalizeVersionLabel(label) {
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, $) {
let ugcIllimite = false;
let patheCinepass = false;
let chequeCinemaUniversel = false;
let cinecheque = false;
let cineCarteCip = false;
container.find(CARD_LABEL_SELECTOR).each((_, labelEl) => {
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) {
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) {
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, dateInput) {
const normalized = normalizeDateInput(dateInput);
const values = new Set();
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 = [];
const seen = new Set();
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, dateInput) {
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, baseUrl) {
if (!href)
return null;
try {
return new URL(href, baseUrl).toString();
}
catch {
return null;
}
}
function normalizeUrlKey(url) {
try {
const parsed = new URL(url);
const path = parsed.pathname.replace(/\/$/, "");
return `${parsed.origin}${path}${parsed.search}`;
}
catch {
return url;
}
}
function extractDayLabel($el, resolvedUrl) {
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($) {
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($, currentUrl, urlMatcher) {
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 = [];
const seen = new Set();
const addLink = (resolvedUrl, label) => {
if (!urlMatcher(resolvedUrl))
return;
const key = normalizeUrlKey(resolvedUrl);
if (seen.has(key))
return;
seen.add(key);
links.push({ url: resolvedUrl, label });
};
$(selectors.join(",")).each((_, el) => {
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((_, el) => {
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) {
const amenitiesKey = showtime.amenities ? showtime.amenities.slice().sort().join(",") : "";
return `${showtime.date || ""}|${showtime.startTime}|${showtime.bookingUrl || ""}|${amenitiesKey}`;
}
function mergeShowtimes(target, incoming) {
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, incoming) {
const targetByType = new Map();
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, incoming) {
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;
}
async function getNowShowing(maxPages = 10) {
let allResults = [];
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 = [];
$(".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) {
const url = `${BASE_URL}/rechercher/?q=${encodeURIComponent(query)}`;
const html = await fetchHTML(url);
const $ = cheerio.load(html);
const results = [];
$(".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;
}
async function searchCinemas(query) {
const url = `${BASE_URL}/rechercher/?q=${encodeURIComponent(query)}`;
const html = await fetchHTML(url);
const $ = cheerio.load(html);
const initialResults = [];
const foundIds = new Set();
$("[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 = await Promise.all(initialResults.slice(0, 5).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;
}
async function getCinemaShowtimes(cinemaId, date) {
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_1.default.get(ajaxUrl, {
headers: {
"User-Agent": USER_AGENT,
"X-Requested-With": "XMLHttpRequest",
"Referer": url,
"Accept": "application/json"
}
});
if (ajaxResponse.data && ajaxResponse.data.results) {
return parseAjaxCinemaShowtimes(ajaxResponse.data, cinemaId, isoDate);
}
}
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 = {
cinemaName: dayCinemaName,
cinemaId,
cards: dayCards,
movies: []
};
const moviesByKey = new Map();
$(".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 = [];
$(movieNode).find(".showtimes-version").each((_, versionNode) => {
const rawType = $(versionNode).find(".text").text().trim();
const type = normalizeVersionLabel(rawType);
const showtimes = [];
$(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;
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 = { startTime: timeVal, bookingUrl, amenities: amenities.length > 0 ? amenities : undefined };
showtime.date = "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, location, date) {
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_1.default.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();
const getCinemaKey = (cinemaName, cinemaAddress, cinemaId) => {
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 = [];
dayPage(theaterNode).find(".showtimes-version").each((_, versionNode) => {
const rawLabel = dayPage(versionNode).find(".text").text().trim();
const versionLabel = normalizeVersionLabel(rawLabel);
const showtimes = [];
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;
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 = { 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 getCinemasByCard(cardType, location) {
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 = [];
$(".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 index_js_1.Server({ name: "allocine-mcp-server", version: "1.1.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(types_js_1.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_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(types_js_1.CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
if (name === "search_movies") {
const { query } = zod_1.z.object({ query: zod_1.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 } = zod_1.z.object({ max_pages: zod_1.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 } = zod_1.z.object({ query: zod_1.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 } = zod_1.z.object({ movie_id: zod_1.z.string(), location: zod_1.z.string().optional(), date: zod_1.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 } = zod_1.z.object({ cinema_id: zod_1.z.string(), date: zod_1.z.string().optional() }).parse(args);
return { content: [{ type: "text", text: JSON.stringify(await getCinemaShowtimes(cinema_id, date), null, 2) }] };
}
if (name === "get_cinemas_by_card") {
const { location, card_type } = zod_1.z.object({ location: zod_1.z.string().optional(), card_type: zod_1.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 } = zod_1.z.object({
movie_id: zod_1.z.string(),
cinema_id: zod_1.z.string(),
date: zod_1.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) {
return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true };
}
});
// --- Start Server ---
const transportType = process.argv.includes("--sse") ? "sse" : "stdio";
if (transportType === "sse") {
const app = (0, express_1.default)();
let transport = null;
app.get("/sse", async (req, res) => {
console.error("New SSE connection");
transport = new sse_js_1.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 stdio_js_1.StdioServerTransport();
server.connect(transport).then(() => {
console.error("Allociné MCP Server running on Stdio");
});
}