Compare commits

...

3 Commits

Author SHA1 Message Date
8762f28b27 fix healthcheck 2025-05-24 23:54:09 +02:00
ea0c5c7765 fix cache dir 2025-05-24 23:50:44 +02:00
be66cf7e53 package for cloudron 2025-05-24 23:39:25 +02:00
18 changed files with 250 additions and 70 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.2

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

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

5
patch.sh Executable file
View File

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

View File

@ -32,6 +32,7 @@
</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..." />
@ -46,7 +47,7 @@
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) {

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) {

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