13 Commits

22 changed files with 646 additions and 235 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.7

View File

@ -45,6 +45,11 @@ async function getMovieClassification(movieUrl) {
const label = $(el).find('span.vh').text().trim(); const label = $(el).find('span.vh').text().trim();
if (label) marks.push(label); 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 = []; const details = [];
$('.c-classificatie__item').each((_, el) => { $('.c-classificatie__item').each((_, el) => {
const type = $(el).find('svg use').first().attr('xlink:href') || ''; const type = $(el).find('svg use').first().attr('xlink:href') || '';
@ -61,6 +66,7 @@ async function getMovieClassification(movieUrl) {
genres, genres,
img, img,
marks, marks,
normalizedMarks,
details, details,
summary summary
}; };

View File

@ -6,7 +6,7 @@ const path = require('path');
const BASE_URL = 'https://www.filmspourenfants.net'; const BASE_URL = 'https://www.filmspourenfants.net';
// Setup disk cache // 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 }); if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
// Cache operations // Cache operations

View File

@ -5,7 +5,7 @@ const path = require('path');
const BASE_URL = 'https://www.filmstouspublics.fr'; const BASE_URL = 'https://www.filmstouspublics.fr';
// Setup disk cache // 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 }); if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
// Load cache from disk if available // Load cache from disk if available

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,29 +1,94 @@
// Utilitaire pour merger les résultats de plusieurs agrégateurs
function normalizeTitle(str) { function normalizeTitle(str) {
return str ? str.toLowerCase().replace(/[^a-z0-9]/g, '') : ''; return str ? str.toLowerCase().replace(/[^a-z0-9 ]/g, '') : '';
} }
function normalizeTitle(str) { function hasSignificantWordOverlap(a, b) {
return str ? str.toLowerCase().replace(/[^a-z0-9]/g, '') : ''; const skipWords = new Set(['the','le','la','les','de','du','des','and','et','a','an','un','une','dans','en','on']);
const aw = normalizeTitle(a).split(/\s+/).filter(w => w.length > 3 && !skipWords.has(w));
const bw = normalizeTitle(b).split(/\s+/).filter(w => w.length > 3 && !skipWords.has(w));
const overlap = aw.filter(w => bw.includes(w));
if (overlap.length > 0) {
console.log(`[MERGE] Overlap: "${a}" <-> "${b}" | Words: ${overlap.join(', ')}`);
return true;
}
return false;
} }
function mergeResults(arrays) {
const map = {}; // 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 stripSeason(title) {
return normalizeTitle(title).replace(/(saison|season)\s*\d+/g, '').trim();
}
function mergeResults(arrays, query = '', limit = 5) {
const merged = [];
arrays.flat().forEach(entry => { arrays.flat().forEach(entry => {
// Note: only title, fallback if no year const entryYear = entry.year ? entry.year.toString() : '';
const key = normalizeTitle(entry.title) + (entry.year ? '|' + entry.year : ''); let foundIdx = -1;
if (!map[key]) { for (let i = 0; i < merged.length; i++) {
map[key] = { const m = merged[i];
// Regroup series/seasons from same source if base title matches (strip "season X"/"saison X")
const isSeason = /saison|season/i.test(entry.title) && /saison|season/i.test(m.title);
if (
m.results[0] && m.results[0].source === entry.source &&
isSeason &&
stripSeason(m.title) === stripSeason(entry.title)
) {
foundIdx = i;
break;
}
// Default merge: Match same year AND at least one significant word in common
if (
(m.year ? m.year.toString() : '') === entryYear &&
hasSignificantWordOverlap(entry.title, m.title)
) {
foundIdx = i;
break;
}
}
if (foundIdx >= 0) {
merged[foundIdx].results.push({ source: entry.source, ...entry });
} else {
merged.push({
title: entry.title, title: entry.title,
year: entry.year, year: entry.year,
results: [] results: [{ source: entry.source, ...entry }],
}; __raw: entry // For tie-break
});
} }
map[key].results.push({
source: entry.source,
...entry
}); });
let out = merged;
if (query) {
out.forEach(f => f.__score = getMatchScore(f, query));
out = out.sort((a, b) => b.__score - a.__score);
// PATCH: filter only films with ALL significant query words in title
const normQuery = query.trim().toLowerCase();
const skip = new Set(['the','le','la','les','de','du','des','and','et','a','an','un','une','dans','en','on']);
const queryWords = normQuery.split(/\s+/).filter(w => w.length > 3 && !skip.has(w));
if (queryWords.length) {
out = out.filter(film => {
const title = (film.title || '').toLowerCase();
return queryWords.every(qw => title.includes(qw));
});
}
}
// Remove internals, trim to limit
return out.slice(0, limit).map(f => {
delete f.__score; delete f.__raw;
return f;
}); });
return Object.values(map);
} }
module.exports = { mergeResults }; module.exports = { mergeResults };

3
package-lock.json generated
View File

@ -13,8 +13,7 @@
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0" "express": "^5.1.0"
}, }
"devDependencies": {}
}, },
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",

5
patch.sh Executable file
View File

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

308
public/index.html Normal file
View File

@ -0,0 +1,308 @@
<!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; align-items: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;}
.maxagebox { display:flex;align-items:center;margin-left:25px;}
.slider-age-badge {
font-size:1.25em;font-weight:bold;
color:#fff;border-radius:0.5em;padding:2px 10px;
min-width:2.5em;display:inline-block;letter-spacing:0.05em;
margin-left:10px;transition:background 0.2s;
}
</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 class="maxagebox">
<label for="maxAge" style="margin-right:9px;">Âge max :</label>
<input type="range" id="maxAge" min="3" max="21" value="21" step="1" style="vertical-align:middle;width:140px;" oninput="updateMaxAgeDisplay()">
<span id="maxAgeDisplay" class="slider-age-badge">21+</span>
</div>
</div>
<div id="films"></div>
</div>
<script>
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';
}
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>`;
}
function shortSummary(str, n = 200) {
if (!str) return '-';
if (str.length <= n) return str;
return str.slice(0, n).replace(/\s+\S*$/, '') + '…';
}
function getAllAges(results) {
const ages = [];
results.forEach(r => {
if (r.source === 'commonsense' && r.age && !isNaN(parseInt(r.age))) {
ages.push(parseInt(r.age));
}
else if (r.source === 'cinecheck') {
if (Array.isArray(r.normalizedMarks) && r.normalizedMarks.length) {
r.normalizedMarks.forEach(a => { if (!isNaN(parseInt(a))) ages.push(parseInt(a)); });
} else if (r.marks && r.marks.length) {
r.marks.forEach(a => {
const n = parseInt(a);
if (!isNaN(n)) ages.push(n);
});
}
}
else if (r.source === 'filmages') {
if (r.details && r.details.ageLegal && !isNaN(parseInt(r.details.ageLegal))) {
ages.push(parseInt(r.details.ageLegal));
}
if (r.details && r.details.ageSuggested && !isNaN(parseInt(r.details.ageSuggested))) {
ages.push(parseInt(r.details.ageSuggested));
}
}
else if (r.age && !isNaN(parseInt(r.age))) {
ages.push(parseInt(r.age));
}
});
return ages;
}
function updateMaxAgeDisplay() {
var el = document.getElementById('maxAge');
var badge = document.getElementById('maxAgeDisplay');
var val = el.value || 21;
badge.textContent = val + '+';
badge.style.background = getAgeColor(val);
}
let lastQuery = '';
let lastResults = [];
async function search(force = false) {
const query = document.getElementById('q').value.trim();
if (!query) return;
const maxAge = parseInt(document.getElementById('maxAge').value, 10);
window.history.replaceState({}, '', '?q=' + encodeURIComponent(query) + (maxAge < 21 ? `&maxAge=${maxAge}` : ''));
const filmsDiv = document.getElementById('films');
filmsDiv.innerHTML = '<p class="loader">Searching...</p>';
// Si on a déjà cherché ce terme, et pas force, on ne refait pas le fetch
if (!force && lastQuery === query && lastResults.length) {
renderFilms(filterFilmsByMaxAge(lastResults, maxAge));
return;
}
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;
}
let films = await response.json();
films = films.map(film => {
film.results = film.results.filter(r => {
let hasDescription =
(r.summary && r.summary.length >= 8) ||
(r.parentsNeedToKnow && r.parentsNeedToKnow.length >= 8) ||
(r.details && r.details.summary && r.details.summary.length >= 8);
let ages = getAllAges([r]);
let hasAge = ages && ages.length > 0;
return hasDescription && hasAge;
});
return film;
}).filter(film => film.results.length > 0);
lastQuery = query;
lastResults = films;
renderFilms(filterFilmsByMaxAge(films, maxAge));
} catch (error) {
console.error('Search function error:', error);
filmsDiv.innerHTML = `<p class="no-results">Search failed. Check the console.</p>`;
}
}
function filterFilmsByMaxAge(films, maxAge) {
if (!isFinite(maxAge)) return films;
return films.filter(film => {
const uniqueResults = [];
const seenSources = new Set();
film.results.forEach(r => {
if (!seenSources.has(r.source)) {
uniqueResults.push(r);
seenSources.add(r.source);
}
});
const ages = getAllAges(uniqueResults);
if (ages.length === 0) return true;
return Math.max(...ages) <= maxAge;
});
}
// Extraction de la partie rendering pour pouvoir la réutiliser
function renderFilms(films) {
const filmsDiv = document.getElementById('films');
if (!films.length) {
filmsDiv.innerHTML = '<p class="no-results">No results for this max age.</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 => {
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>`;
const uniqueResults = [];
const seenSources = new Set();
film.results.forEach(r => {
if (!seenSources.has(r.source)) {
uniqueResults.push(r);
seenSources.add(r.source);
}
});
uniqueResults.forEach(r => {
html += `<div class="source-block">`;
html += `<p class="source-name">${r.source.charAt(0).toUpperCase() + r.source.slice(1)}</p>`;
if (r.link) {
html += `<p><a href="${r.link}" target="_blank">View details</a></p>`;
}
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>`;
}
}
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>`;
}
}
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> ${shortSummary(r.details.indications.join(', '), 100)}</p>`;
}
if (r.details.counterIndications && r.details.counterIndications.length) {
html += `<p><b>Contra-indications:</b> ${shortSummary(r.details.counterIndications.join(', '), 100)}</p>`;
}
}
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;
}
document.getElementById('maxAge').addEventListener('input', function() {
updateMaxAgeDisplay();
// Pas de fetch, juste filtre en front
const query = document.getElementById('q').value.trim();
if (!query || !lastResults.length) return;
const maxAge = parseInt(document.getElementById('maxAge').value, 10);
renderFilms(filterFilmsByMaxAge(lastResults, maxAge));
});
window.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const q = params.get('q');
const maxAge = params.get('maxAge');
if (maxAge) {
document.getElementById('maxAge').value = maxAge;
updateMaxAgeDisplay();
}
if (q) {
document.getElementById('q').value = q;
search(true); // force fetch initiale
}
});
document.getElementById('q').addEventListener('keydown', e => { if (e.key === 'Enter') search(true); });
</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 express = require('express');
const path = require('path');
const cors = require('cors'); const cors = require('cors');
const cinecheck = require('./aggregators/cinecheck-adapter'); const cinecheck = require('./aggregators/cinecheck-adapter');
const commonsense = require('./aggregators/commonsense-adapter'); const commonsense = require('./aggregators/commonsense-adapter');
@ -9,7 +10,7 @@ const { mergeResults } = require('./merge');
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(express.static(path.join(__dirname, 'public')));
app.get('/search', async (req, res) => { app.get('/search', async (req, res) => {
const q = req.query.q; const q = req.query.q;
if (!q) { if (!q) {
@ -48,8 +49,7 @@ app.get('/search', async (req, res) => {
console.log('Filmages results:', fa.length); console.log('Filmages results:', fa.length);
console.log('FilmsTousPublics results:', ftp.length); console.log('FilmsTousPublics results:', ftp.length);
console.log('FilmsPourEnfants results:', fpe.length); console.log('FilmsPourEnfants results:', fpe.length);
const merged = mergeResults([cine, cs, fa, ftp, fpe], q, 10); // limit=5
const merged = mergeResults([cine, cs, fa, ftp, fpe]);
res.json(merged); res.json(merged);
} catch (e) { } catch (e) {
console.error('General search error:', 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