6 Commits

21 changed files with 473 additions and 223 deletions

10
CHANGELOG.md Normal file
View File

@ -0,0 +1,10 @@
# Changelog
## [1.0.0] - YYYY-MM-DD
### Ajouté
- Recherche full texte.
- Archivage de liens (TXT et HTML).
- Gestion des tags.
- Navigation vers les liens entrants et sortants.
- Interface de recherche avec surlignage des résultats.

26
CloudronManifest.json Normal file
View File

@ -0,0 +1,26 @@
{
"id": "be.zoemp.cinekids",
"title": "Cine Kids",
"author": "Morgan",
"description": "file://DESCRIPTION.md",
"changelog": "file://CHANGELOG.md",
"tagline": "Ciné-agrégateur multi-source pour enfants.",
"version": "1.0.0",
"healthCheckPath": "/search",
"httpPort": 3000,
"addons": {
"localstorage": {}
},
"manifestVersion": 2,
"website": "https://zoemp.be/cine-kids",
"contactEmail": "morgan@zoemp.be",
"icon": "file://logo.png",
"tags": [
"cinema",
"children",
"cloudron"
],
"minBoxVersion": "7.5.0",
"optionalSso": false
}

18
DESCRIPTION.md Normal file
View File

@ -0,0 +1,18 @@
# Cine Kids
Cine Kids est un agrégateur multi-source pour trouver rapidement les avis, classifications dâge et résumés de films pour enfants.
Utile pour les parents qui veulent éviter la merde industrielle et savoir ce que leurs enfants vont regarder.
## Fonctionnalités principales
- Recherche simultanée sur plusieurs bases (Cinecheck, CommonSense, Filmages, etc).
- Synthèse et fusion des résultats (titre, année, résumé, âge recommandé, pictogrammes…).
- Interface simple : tu cherches, tu obtiens toutes les infos utiles dun coup.
- Donne accès direct aux fiches originales pour vérif rapide.
Idéal pour :
- Filtrer les films grand public.
- Repérer dun coup dœil les critères dâge, la violence, la pub ou les contenus toxiques.
- Éviter de perdre 20 minutes sur 5 sites différents pour chaque film.

23
Dockerfile.cloudron Normal file
View File

@ -0,0 +1,23 @@
FROM debian:stable-slim
RUN apt-get update && apt-get install -y curl ca-certificates bash xz-utils && apt-get clean && rm -rf /var/lib/apt/lists/*
ARG NODE_VERSION=20.12.2
RUN curl -fsSL https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz \
| tar -xJ -C /usr/local --strip-components=1
RUN mkdir -p /app/code /app/data
COPY ./ /app/code/
COPY ./public/ /app/code/public/
WORKDIR /app/code/
RUN npm ci --omit=dev
VOLUME ["/app/data"]
COPY start.sh /app/code/
RUN chmod +x /app/code/start.sh
CMD ["/app/code/start.sh"]

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.0.4

View File

@ -45,6 +45,11 @@ async function getMovieClassification(movieUrl) {
const label = $(el).find('span.vh').text().trim();
if (label) marks.push(label);
});
const normalizedMarks = marks.map(m => {
// Extract first number found, else null
const n = parseInt((m + '').replace(/\D/g, ''), 10);
return isNaN(n) ? null : n;
}).filter(x => x !== null);
const details = [];
$('.c-classificatie__item').each((_, el) => {
const type = $(el).find('svg use').first().attr('xlink:href') || '';
@ -61,6 +66,7 @@ async function getMovieClassification(movieUrl) {
genres,
img,
marks,
normalizedMarks,
details,
summary
};

View File

@ -6,7 +6,7 @@ const path = require('path');
const BASE_URL = 'https://www.filmspourenfants.net';
// Setup disk cache
const CACHE_DIR = path.join(__dirname, '../cache');
const CACHE_DIR = process.env.CACHE_DIR || '/app/data/cache';
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
// Cache operations
@ -38,7 +38,7 @@ function saveCache(type, key, data) {
// Extract age from string like "À partir de 8 ans" or "Déconseillé aux moins de: 8 ans"
function extractAgeFromText(text) {
if (!text) return null;
const match = text.match(/(\d+)\s*ans/i);
if (match && match[1]) {
return parseInt(match[1]);
@ -50,24 +50,24 @@ async function searchMovies(query) {
// Check cache first
const cached = loadCache('search', query);
if (cached) return cached;
const searchUrl = `${BASE_URL}/films-resultats/?_s=${encodeURIComponent(query)}`;
console.log('Searching FilmsPourEnfants:', searchUrl);
try {
const response = await axios.get(searchUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
const response = await axios.get(searchUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
const results = [];
$('section.gp-post-item').each((_, el) => {
const title = $(el).find('h2.gp-loop-title a').text().trim();
const link = $(el).find('h2.gp-loop-title a').attr('href');
const img = $(el).find('.gp-post-thumbnail img').attr('src');
const ageText = $(el).find('.gp-loop-cats a').text().trim();
const age = extractAgeFromText(ageText);
if (title && link) {
results.push({
title,
@ -78,7 +78,7 @@ async function searchMovies(query) {
});
}
});
console.log(`FilmsPourEnfants found ${results.length} results`);
saveCache('search', query, results);
return results;
@ -90,27 +90,27 @@ async function searchMovies(query) {
async function getMovieDetails(movieUrl) {
if (!movieUrl) return {};
// Check cache first
const cached = loadCache('detail', movieUrl);
if (cached) return cached;
console.log('Fetching details for:', movieUrl);
try {
const response = await axios.get(movieUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
const response = await axios.get(movieUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
const details = {};
// Title
details.title = $('h1.gp-entry-title').text().trim();
// Get metadata
$('.gp-entry-meta .gp-post-meta').each((_, el) => {
const text = $(el).text().trim();
if (text.includes('Année:')) {
details.year = $(el).find('a').text().trim();
} else if (text.includes('Déconseillé aux moins de:')) {
@ -125,11 +125,11 @@ async function getMovieDetails(movieUrl) {
});
}
});
// More detailed metadata
$('#gp-hub-details span').each((_, el) => {
const label = $(el).find('strong').text().trim();
if (label === 'Déconseillé aux moins de:') {
details.ageText = $(el).find('a').text().trim();
details.age = extractAgeFromText(details.ageText);
@ -153,13 +153,13 @@ async function getMovieDetails(movieUrl) {
});
}
});
// Get summary - first paragraph in the entry-text
details.summary = $('.gp-entry-text h4').first().text().trim();
// Get main image
details.img = $('.gp-post-thumbnail img').attr('src') || $('.gp-hub-header-thumbnail img').attr('src');
// Get messages section
let messages = '';
$('.gp-entry-text h3').each((_, el) => {
@ -170,7 +170,7 @@ async function getMovieDetails(movieUrl) {
}
});
details.messages = messages.trim();
// Get difficult scenes section
const difficultScenesHeading = $('.gp-entry-text h2:contains("SCÈNES DIFFICILES")');
let difficultScenes = '';
@ -184,7 +184,7 @@ async function getMovieDetails(movieUrl) {
}
}
details.difficultScenes = difficultScenes.trim();
console.log(`Fetched details for: ${details.title}, Age: ${details.age}`);
saveCache('detail', movieUrl, details);
return details;
@ -199,7 +199,7 @@ async function searchAndEnrich(query) {
const results = await searchMovies(query);
return await Promise.all(results.map(async movie => {
const details = await getMovieDetails(movie.link);
return {
title: movie.title,
year: details.year || null,

View File

@ -5,7 +5,7 @@ const path = require('path');
const BASE_URL = 'https://www.filmstouspublics.fr';
// Setup disk cache
const CACHE_DIR = path.join(__dirname, '../cache');
const CACHE_DIR = process.env.CACHE_DIR || '/app/data/cache';
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
// Load cache from disk if available
@ -46,19 +46,19 @@ function calculateAverageAge(ageRatings) {
return !isNaN(numAge) && numAge > 0; // Only include positive ages
})
.map(age => typeof age === 'string' ? parseInt(age) : age);
if (ages.length === 0) return null;
// Calculate average
const avg = ages.reduce((sum, age) => sum + age, 0) / ages.length;
// Calculate median (more useful for skewed distributions)
const sorted = [...ages].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return { average: avg.toFixed(1), median, countries: ages.length, min: sorted[0], max: sorted[sorted.length-1] };
}
@ -66,33 +66,33 @@ async function searchMovies(query) {
// Check cache first
const cached = loadCache('search', query);
if (cached) return cached;
const searchUrl = `${BASE_URL}/?s=${encodeURIComponent(query)}`;
console.log('Searching FilmsTousPublics:', searchUrl);
try {
const response = await axios.get(searchUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
const response = await axios.get(searchUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
const results = [];
// Better selector for different article structures
$('article[class*="tipi-xs-12"], article[class*="post"]').each((_, el) => {
const title = $(el).find('h3.title a, .title-wrap .title a').text().trim();
const link = $(el).find('h3.title a, .title-wrap .title a').attr('href');
// Handle lazy-loaded images properly
const imgEl = $(el).find('.mask img');
const img = imgEl.attr('data-lazy-src') || imgEl.attr('src');
// Get rating if available
let rating = null;
const ratingEl = $(el).find('.lets-review-api-wrap, .lets-review-final-score');
if (ratingEl.length) {
rating = ratingEl.attr('data-api-score') || ratingEl.text().trim();
}
if (title && link) {
results.push({
title,
@ -102,7 +102,7 @@ async function searchMovies(query) {
});
}
});
console.log(`FilmsTousPublics found ${results.length} results`);
saveCache('search', query, results);
return results;
@ -114,33 +114,33 @@ async function searchMovies(query) {
async function getMovieClassification(movieUrl) {
if (!movieUrl) return {};
// Check cache first
const cached = loadCache('detail', movieUrl);
if (cached) return cached;
console.log('Fetching details for:', movieUrl);
try {
const response = await axios.get(movieUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
const response = await axios.get(movieUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
const $ = cheerio.load(response.data);
// Get country age ratings
const ageRatings = {};
console.log('Found pullquote elements:', $('aside.pullquote').length);
// More robust approach: Find all <p> tags inside the pullquote section
$('aside.pullquote p').each((_, el) => {
const text = $(el).text().trim();
console.log('Processing age text:', text);
// Detect "Tous publics" for France (All audiences)
if (text.includes('Tous publics')) {
ageRatings.france = 0; // Set to 0 for averaging but "All" for display
console.log('Found France rating: Tous publics (0)');
}
}
// More flexible regex for the weird dashes used
else {
// Just extract any number that appears in string after "Déconseillé aux"
@ -148,12 +148,12 @@ async function getMovieClassification(movieUrl) {
if (match && match[1]) {
const age = parseInt(match[1]);
console.log('Found age restriction:', age);
// Identify country by image alt or src
const img = $(el).find('img');
const alt = img.attr('alt') || '';
const src = img.attr('src') || '';
// Check for all possible countries - more flexible matching
if (alt.includes('France') || src.toLowerCase().includes('france')) {
ageRatings.france = age;
@ -178,19 +178,19 @@ async function getMovieClassification(movieUrl) {
}
}
});
console.log('Found age ratings:', ageRatings);
// Get summary/plot (first few paragraphs)
let summary = '';
$('.entry-content > p').each((i, el) => {
// Skip pullquote or other non-content paragraphs
if (!$(el).find('.pullquote').length && i < 3 && $(el).text().trim().length > 30) {
if (!$(el).find('.pullquote').length && i < 3 && $(el).text().trim().length > 30) {
summary += $(el).text().trim() + ' ';
}
});
summary = summary.trim();
// Get movie metadata
const metadata = {};
$('h3:contains("Informations") + ul li').each((_, el) => {
@ -211,14 +211,14 @@ async function getMovieClassification(movieUrl) {
metadata.studio = text.replace('Studio :', '').trim();
}
});
// Get overall rating
let overallRating = null;
const ratingEl = $('.lets-review-block__final-score .score');
if (ratingEl.length) {
overallRating = ratingEl.text().trim();
}
// Extract year from release date if available
let year = null;
if (metadata.releaseDate) {
@ -227,7 +227,7 @@ async function getMovieClassification(movieUrl) {
year = yearMatch[0];
}
}
const result = {
summary,
year,
@ -235,7 +235,7 @@ async function getMovieClassification(movieUrl) {
overallRating,
...metadata
};
// Cache the result
saveCache('detail', movieUrl, result);
return result;
@ -250,11 +250,11 @@ async function searchAndEnrich(query) {
const results = await searchMovies(query);
return await Promise.all(results.map(async movie => {
const details = await getMovieClassification(movie.link);
// Calculate average age
const ageStats = calculateAverageAge(details.ageRatings || {});
console.log(`Movie: ${movie.title}, Age stats:`, ageStats);
// Convert country codes to readable names for frontend display
const countryNames = {
france: "France",
@ -266,22 +266,22 @@ async function searchAndEnrich(query) {
netherlands: "Pays-Bas",
usa: "États-Unis"
};
// Format age ratings for display
const formattedAgeRatings = {};
for (const [country, age] of Object.entries(details.ageRatings || {})) {
const countryName = countryNames[country] || country;
formattedAgeRatings[countryName] = age === 0 ? "Tous publics" : `${age}+`;
}
// Get a recommended age - prefer median, then average, then fallback
const recommendedAge =
const recommendedAge =
ageStats?.median ? `${ageStats.median}+` :
ageStats?.average ? `${ageStats.average}+` :
details.ageRatings?.france === 0 ? "Tous publics" :
details.ageRatings?.france ? `${details.ageRatings.france}+` :
"Non spécifié";
return {
title: movie.title,
year: details.year,
@ -291,8 +291,8 @@ async function searchAndEnrich(query) {
rating: movie.rating || details.overallRating,
// Age information for display
age: recommendedAge.replace('Tous publics', '0+').replace('Non spécifié', '-'),
ageFrance: details.ageRatings?.france === 0 ? "Tous publics" :
details.ageRatings?.france ? `${details.ageRatings.france}+` :
ageFrance: details.ageRatings?.france === 0 ? "Tous publics" :
details.ageRatings?.france ? `${details.ageRatings.france}+` :
"Non spécifié",
ageAverage: ageStats?.average || null,
ageMedian: ageStats?.median || null,

8
build.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -x
set -eu
docker build --platform linux/amd64 -t dr.zoemp.be/cine-kids:$(cat VERSION) -f Dockerfile.cloudron .
docker push dr.zoemp.be/cine-kids:$(cat VERSION)
cloudron update --image dr.zoemp.be/cine-kids:$(cat VERSION) --app cine-kids

38
bump_version.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# Read current version
VERSION=$(cat VERSION)
# Split version into components
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
# Decide which part to bump based on the commit type
case $1 in
major)
((MAJOR++))
MINOR=0
PATCH=0
;;
minor)
((MINOR++))
PATCH=0
;;
patch)
((PATCH++))
;;
*)
echo "Usage: $0 {major|minor|patch}"
exit 1
;;
esac
# Construct the new version
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
# Save the new version to the VERSION file
echo "$NEW_VERSION" > VERSION
echo "Version bumped to $NEW_VERSION"
git add VERSION
git tag "v$MAJOR.$MINOR.$PATCH"
git push origin "v$MAJOR.$MINOR.$PATCH"

11
dev.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
set -x
set -eu
docker build --platform linux/amd64 -t dr.zoemp.be/cine-kids:$(cat VERSION) -f Dockerfile.cloudron .
docker run --platform linux/amd64 --rm -it -v "$(pwd)/data:/app/data/" -p 3000:3000 dr.zoemp.be/cine-kids:$(cat VERSION)
# Optionnel : test d'API locale après boot
# sleep 2
# curl http://localhost:3000/search?q=test || echo "API failed (normal si pas de data mock)"

View File

@ -0,0 +1,20 @@
services:
cine-kids:
build:
context: .
dockerfile: Dockerfile.cloudron
ports:
- "3000:3000"
volumes:
- ./app:/app/code
- ./data:/app/data
environment:
- CLOUDRON_APP_ORIGIN=https://your-cloudron-origin.com
- CLOUDRON_APP_DOMAIN=yourdomain.cloudron
user: "${MY_UID}:${MY_GID}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/search?q=test"]
interval: 2s
timeout: 2s
retries: 3

View File

@ -1,145 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Agrégateur Multi-Source</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #1a1a1a; color: #e0e0e0; margin: 0; padding: 20px; font-size: 14px; }
.container { max-width: 1200px; margin: auto; }
h1 { color: #fff; text-align: center; }
.searchbox { margin: 20px 0 30px; display: flex; justify-content: center; }
input[type="text"] { font-size: 1.1em; width: clamp(200px, 60%, 500px); background: #2c2c2c; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 10px 12px; }
button { font-size: 1.1em; padding: 10px 18px; background: #007aff; color: #fff; border-radius: 4px; border: none; margin-left: 10px; cursor: pointer; transition: background-color 0.2s; }
button:hover { background: #005bb5; }
#films { margin-top: 20px; }
.results-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.results-table th, .results-table td { border: 1px solid #333; padding: 12px; text-align: left; vertical-align: top; }
.results-table th { background-color: #252525; color: #ccc; font-weight: 600; }
.results-table td:nth-child(1) { width: 25%; }
.results-table td:nth-child(2) { width: 10%; }
.results-table td:nth-child(3) { width: 65%; }
.source-block { border: 1px solid #383838; border-radius: 4px; padding: 10px; margin-bottom: 10px; background-color: #222; }
.source-block:last-child { margin-bottom: 0; }
.source-name { font-weight: bold; color: #58a6ff; margin-bottom: 8px; font-size: 1.1em; }
.source-block img { max-height: 100px; float: right; margin-left: 10px; border-radius: 3px; }
.source-block p { margin: 4px 0; line-height: 1.5; }
.source-block a { color: #79c0ff; text-decoration: none; }
.source-block a:hover { text-decoration: underline; }
.year { color: #aaa; }
.loader { text-align: center; font-size: 1.2em; color: #888; }
.no-results { text-align: center; font-size: 1.1em; color: #999; margin-top:30px;}
</style>
</head>
<body>
<div class="container">
<h1>Ciné-agrégateur Multi-Source</h1>
<div class="searchbox">
<input type="text" id="q" placeholder="Ex: Dune, Spider-Man, Oppenheimer..." />
<button onclick="search()">Rechercher</button>
</div>
<div id="films"></div>
</div>
<script>
async function search() {
const query = document.getElementById('q').value.trim();
if (!query) return;
const filmsDiv = document.getElementById('films');
filmsDiv.innerHTML = '<p class="loader">Recherche en cours...</p>';
try {
const response = await fetch(`http://localhost:3000/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
// Gilfoyle: "The network, or your server, is garbage."
filmsDiv.innerHTML = `<p class="no-results">Erreur: ${response.status} ${response.statusText || 'Impossible de joindre le backend.'}</p>`;
return;
}
const films = await response.json();
if (!Array.isArray(films) || !films.length) {
filmsDiv.innerHTML = '<p class="no-results">Aucun résultat trouvé. Essayez un autre terme.</p>';
return;
}
let html = `<table class="results-table">
<thead>
<tr>
<th>Titre</th>
<th>Année</th>
<th>Sources d'information</th>
</tr>
</thead>
<tbody>`;
films.forEach(film => {
html += `<tr>
<td>${film.title || 'Titre inconnu'}</td>
<td class="year">${film.year || 'N/A'}</td>
<td>`;
film.results.forEach(r => {
html += `<div class="source-block">
<p class="source-name">${r.source.charAt(0).toUpperCase() + r.source.slice(1)}</p>`;
if (r.img) {
html += `<img src="${r.img}" alt="Affiche pour ${film.title}">`;
}
if (r.link) {
html += `<p><a href="${r.link}" target="_blank">Voir la fiche détaillée</a></p>`;
}
// CommonSense Media specific
if (r.source === 'commonsense') {
html += `<p><b>Âge conseillé:</b> ${r.age || '-'}</p>`;
html += `<p><b>Résumé (CSM):</b> ${r.summary || r.parentsNeedToKnow || '-'}</p>`;
if (r.details && r.details.length) {
html += `<p><b>Détails (CSM):</b></p><ul>`;
r.details.forEach(d => {
html += `<li>${d.type}: ${d.score}/5 - ${d.description || ''}</li>`;
});
html += `</ul>`;
}
}
// Cinecheck specific
else if (r.source === 'cinecheck') {
html += `<p><b>Âge(s) (Cinecheck):</b> ${r.marks && r.marks.length ? r.marks.join(', ') : '-'}</p>`;
html += `<p><b>Résumé (Cinecheck):</b> ${r.summary || '-'}</p>`;
if (r.details && r.details.length) {
html += `<p><b>Pictogrammes (Cinecheck):</b> ${r.details.map(d => d.type).join(', ') || '-'}</p>`;
}
}
// Filmages specific
else if (r.source === 'filmages') {
html += `<p><b>Titre original (Filmages):</b> ${r.details.titleOriginalPage || r.details.titleOriginal || '-'}</p>`;
html += `<p><b>Âge légal (Filmages):</b> ${r.details.ageLegal || '-'}</p>`;
html += `<p><b>Âge suggéré (Filmages):</b> ${r.details.ageSuggested || '-'}</p>`;
html += `<p><b>Résumé (Filmages):</b> ${r.details.summary || '-'}</p>`;
html += `<p><b>Synthèse (Filmages):</b> ${r.details.synthesis || '-'}</p>`;
if (r.details.indications && r.details.indications.length) {
html += `<p><b>Indications:</b> ${r.details.indications.join(', ')}</p>`;
}
if (r.details.counterIndications && r.details.counterIndications.length) {
html += `<p><b>Contre-indications:</b> ${r.details.counterIndications.join(', ')}</p>`;
}
}
// Fallback for any other or new source
else {
html += `<p><b>Résumé:</b> ${r.summary || '-'}</p>`;
html += `<p><b>Âge:</b> ${r.age || '-'}</p>`;
}
html += `</div>`;
});
html += `</td></tr>`;
});
html += '</tbody></table>';
filmsDiv.innerHTML = html;
} catch (error) {
// Mike: "Didn't go as planned."
console.error('Search function error:', error);
filmsDiv.innerHTML = `<p class="no-results">Une erreur s'est produite lors de la recherche. Vérifiez la console.</p>`;
}
}
document.getElementById('q').addEventListener('keydown', e => { if (e.key === 'Enter') search(); });
</script>
</body>
</html>

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -1,21 +1,28 @@
// Utilitaire pour merger les résultats de plusieurs agrégateurs
function normalizeTitle(str) {
return str ? str.toLowerCase().replace(/[^a-z0-9]/g, '') : '';
}
function normalizeTitle(str) {
return str ? str.toLowerCase().replace(/[^a-z0-9]/g, '') : '';
// Compute match score: exact > startsWith > includes > other
function getMatchScore(film, query) {
const nTitle = normalizeTitle(film.title);
const nQuery = normalizeTitle(query);
if (nTitle === nQuery) return 100;
if (nTitle.startsWith(nQuery)) return 80;
if (nTitle.includes(nQuery)) return 60;
return 10;
}
function mergeResults(arrays) {
// Merge and rank by match
function mergeResults(arrays, query = '', limit = 5) {
const map = {};
arrays.flat().forEach(entry => {
// Note: only title, fallback if no year
const key = normalizeTitle(entry.title) + (entry.year ? '|' + entry.year : '');
if (!map[key]) {
map[key] = {
title: entry.title,
year: entry.year,
results: []
results: [],
__raw: entry // For tie-break
};
}
map[key].results.push({
@ -23,7 +30,16 @@ function mergeResults(arrays) {
...entry
});
});
return Object.values(map);
let out = Object.values(map);
if (query) {
out.forEach(f => f.__score = getMatchScore(f, query));
out = out.sort((a, b) => b.__score - a.__score);
}
// Remove internals, trim to limit
return out.slice(0, limit).map(f => {
delete f.__score; delete f.__raw;
return f;
});
}
module.exports = { mergeResults };

5
patch.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
set -x
set -eu
./bump_version.sh patch
./build.sh

195
public/index.html Normal file
View File

@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Agrégateur Multi-Source</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #1a1a1a; color: #e0e0e0; margin: 0; padding: 20px; font-size: 14px; }
.container { max-width: 1200px; margin: auto; }
h1 { color: #fff; text-align: center; }
.searchbox { margin: 20px 0 30px; display: flex; justify-content: center; }
input[type="text"] { font-size: 1.1em; width: clamp(200px, 60%, 500px); background: #2c2c2c; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 10px 12px; }
button { font-size: 1.1em; padding: 10px 18px; background: #007aff; color: #fff; border-radius: 4px; border: none; margin-left: 10px; cursor: pointer; transition: background-color 0.2s; }
button:hover { background: #005bb5; }
#films { margin-top: 20px; }
.results-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.results-table th, .results-table td { border: 1px solid #333; padding: 12px; text-align: left; vertical-align: top; }
.results-table th { background-color: #252525; color: #ccc; font-weight: 600; }
.results-table td:nth-child(1) { width: 25%; }
.results-table td:nth-child(2) { width: 10%; }
.results-table td:nth-child(3) { width: 65%; }
.source-block { border: 1px solid #383838; border-radius: 4px; padding: 10px; margin-bottom: 10px; background-color: #222; }
.source-block:last-child { margin-bottom: 0; }
.source-name { font-weight: bold; color: #58a6ff; margin-bottom: 8px; font-size: 1.1em; }
.source-block img { max-height: 100px; float: right; margin-left: 10px; border-radius: 3px; }
.source-block p { margin: 4px 0; line-height: 1.5; }
.source-block a { color: #79c0ff; text-decoration: none; }
.source-block a:hover { text-decoration: underline; }
.year { color: #aaa; }
.loader { text-align: center; font-size: 1.2em; color: #888; }
.no-results { text-align: center; font-size: 1.1em; color: #999; margin-top:30px;}
</style>
</head>
<body>
<div class="container">
<img src="/logo.png" alt="Logo Cine Kids" style="display:block;margin:30px auto 10px;max-width:130px;height:auto;">
<h1>Ciné-agrégateur Multi-Source</h1>
<div class="searchbox">
<input type="text" id="q" placeholder="Ex: Dune, Spider-Man, Oppenheimer..." />
<button onclick="search()">Rechercher</button>
</div>
<div id="films"></div>
</div>
<script>
// Age color logic
function getAgeColor(age) {
age = parseInt(age, 10);
if (isNaN(age)) return '#999';
if (age <= 6) return '#19c94a';
if (age <= 9) return '#68c3ee';
if (age <= 12) return '#ffe45e';
if (age <= 16) return '#ffb154';
if (age <= 18) return '#ff6384';
return '#e2525c';
}
// Show a big colored badge for age, else fallback
function ageBadge(age) {
if (!age) return '';
const c = getAgeColor(age);
return `<span class="age-badge" style="
display:inline-block;
font-size:1.2em;
min-width:2.5em;
font-weight:bold;
color:#222;
background:${c};
border-radius:0.4em;
text-align:center;
margin-right:0.7em;
padding:0.15em 0.7em;
box-shadow:0 2px 8px #0003;
letter-spacing:0.03em;
">${age}+</span>`;
}
// Truncate summary at N chars, add ellipsis
function shortSummary(str, n = 200) {
if (!str) return '-';
if (str.length <= n) return str;
return str.slice(0, n).replace(/\s+\S*$/, '') + '…';
}
async function search() {
const query = document.getElementById('q').value.trim();
if (!query) return;
const filmsDiv = document.getElementById('films');
filmsDiv.innerHTML = '<p class="loader">Searching...</p>';
try {
const base = window.location.origin;
const response = await fetch(`${base}/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
filmsDiv.innerHTML = `<p class="no-results">Error: ${response.status} ${response.statusText || 'Backend unreachable.'}</p>`;
return;
}
const films = await response.json();
if (!Array.isArray(films) || !films.length) {
filmsDiv.innerHTML = '<p class="no-results">No results. Try another query.</p>';
return;
}
let html = `<table class="results-table">
<thead>
<tr>
<th>Title</th>
<th>Year</th>
<th>Information sources</th>
</tr>
</thead>
<tbody>`;
films.forEach(film => {
// Get first non-empty image from sources
const allImgs = film.results.map(r => r.img).filter(Boolean);
const mainImg = allImgs.length ? allImgs[0] : null;
html += `<tr>
<td style="vertical-align:top;">
<div style="text-align:center;">
<div style="font-weight:bold;margin-bottom:0.7em;">${film.title || 'Unknown title'}</div>
${mainImg ? `<img src="${mainImg}" alt="Poster for ${film.title}" style="display:block;margin:auto;max-width:100px;max-height:150px;border-radius:5px;box-shadow:0 2px 8px #0003;margin-bottom:10px;">` : ''}
</div>
</td>
<td class="year" style="vertical-align:top;">${film.year || 'N/A'}</td>
<td>`;
film.results.forEach(r => {
html += `<div class="source-block">`;
html += `<p class="source-name">${r.source.charAt(0).toUpperCase() + r.source.slice(1)}</p>`;
// (No image here anymore)
if (r.link) {
html += `<p><a href="${r.link}" target="_blank">View details</a></p>`;
}
// CommonSense Media
if (r.source === 'commonsense') {
html += `<p><b>Recommended age:</b> ${ageBadge(r.age)}</p>`;
html += `<p><b>Summary (CSM):</b> ${shortSummary(r.summary || r.parentsNeedToKnow)}</p>`;
if (r.details && r.details.length) {
html += `<p><b>Details (CSM):</b></p><ul>`;
r.details.forEach(d => {
html += `<li>${d.type}: ${d.score}/5 - ${shortSummary(d.description, 80)}</li>`;
});
html += `</ul>`;
}
}
// Cinecheck
else if (r.source === 'cinecheck') {
let numericAges = Array.isArray(r.normalizedMarks) && r.normalizedMarks.length
? r.normalizedMarks
: (r.marks || []).map(x => {
let n = parseInt((x + '').replace(/\d+/, ''), 10);
return isNaN(n) ? null : n;
}).filter(n => n !== null && !isNaN(n));
let minAge = numericAges.length ? Math.min(...numericAges) : '';
html += `<p><b>Age(s) (Cinecheck):</b> ${minAge ? ageBadge(minAge) : ''}${r.marks && r.marks.length ? r.marks.join(', ') : '-'}</p>`;
html += `<p><b>Summary (Cinecheck):</b> ${shortSummary(r.summary)}</p>`;
if (r.details && r.details.length) {
html += `<p><b>Pictograms (Cinecheck):</b> ${r.details.map(d => d.type).join(', ') || '-'}</p>`;
}
}
// Filmages
else if (r.source === 'filmages') {
html += `<p><b>Original title (Filmages):</b> ${r.details.titleOriginalPage || r.details.titleOriginal || '-'}</p>`;
html += `<p><b>Legal age (Filmages):</b> ${ageBadge(r.details.ageLegal)}</p>`;
html += `<p><b>Suggested age (Filmages):</b> ${ageBadge(r.details.ageSuggested)}</p>`;
html += `<p><b>Summary (Filmages):</b> ${shortSummary(r.details.summary)}</p>`;
html += `<p><b>Synthesis (Filmages):</b> ${shortSummary(r.details.synthesis, 100)}</p>`;
if (r.details.indications && r.details.indications.length) {
html += `<p><b>Indications:</b> ${r.details.indications.join(', ')}</p>`;
}
if (r.details.counterIndications && r.details.counterIndications.length) {
html += `<p><b>Contra-indications:</b> ${r.details.counterIndications.join(', ')}</p>`;
}
}
// Fallback
else {
html += `<p><b>Summary:</b> ${shortSummary(r.summary)}</p>`;
html += `<p><b>Age:</b> ${ageBadge(r.age)}</p>`;
}
html += `</div>`;
});
html += `</td></tr>`;
});
html += '</tbody></table>';
filmsDiv.innerHTML = html;
} catch (error) {
console.error('Search function error:', error);
filmsDiv.innerHTML = `<p class="no-results">Search failed. Check the console.</p>`;
}
}
document.getElementById('q').addEventListener('keydown', e => { if (e.key === 'Enter') search(); });
</script>
</body>
</html>

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -1,4 +1,5 @@
const express = require('express');
const path = require('path');
const cors = require('cors');
const cinecheck = require('./aggregators/cinecheck-adapter');
const commonsense = require('./aggregators/commonsense-adapter');
@ -9,7 +10,7 @@ const { mergeResults } = require('./merge');
const app = express();
app.use(cors());
app.use(express.static(path.join(__dirname, 'public')));
app.get('/search', async (req, res) => {
const q = req.query.q;
if (!q) {
@ -48,8 +49,7 @@ app.get('/search', async (req, res) => {
console.log('Filmages results:', fa.length);
console.log('FilmsTousPublics results:', ftp.length);
console.log('FilmsPourEnfants results:', fpe.length);
const merged = mergeResults([cine, cs, fa, ftp, fpe]);
const merged = mergeResults([cine, cs, fa, ftp, fpe], q, 10); // limit=5
res.json(merged);
} catch (e) {
console.error('General search error:', e);

8
setup.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
export MY_UID=$(id -u)
export MY_GID=$(id -g)
docker compose down
docker compose up --build --detach --timestamps
docker compose logs --follow | grep -E 'python|error|app'

10
start.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
set -eu
echo "Starting Cine-Kids (Node.js)"
cd /app/code
export NODE_ENV=production
export PORT=3000
exec node server.js