first commit

This commit is contained in:
Puechberty Arthur
2026-03-30 20:42:29 +02:00
commit a72f502342
13 changed files with 1001 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.pyc
.venv/
venv/
env/
.env
.git
*.db
__pycache__
*.egg-info
node_modules/
/.vscode
/.idea
build/
dist/
+35
View File
@@ -0,0 +1,35 @@
# Python
__pycache__/
*.py[cod]
*.so
.Python
.pytest_cache/
.mypy_cache/
# Virtual environments
venv/
.venv/
env/
# Local environment and secrets
.env
.env.*
!.env.example
# Local database and runtime files
*.db
*.sqlite
*.sqlite3
# IDE/editor
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Build/distribution
build/
dist/
*.egg-info/
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# system deps for building some packages
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# copy requirements first for caching
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
# copy app
COPY . /app
EXPOSE 5000
# default command runs the webapp; docker-compose will override for scraper service
CMD ["python", "webapp.py"]
+53
View File
@@ -0,0 +1,53 @@
# QCM Physique
Application Flask pour s'entrainer avec des QCM de physique.
Projet en ligne: [qcu.arthurp.fr](https://qcu.arthurp.fr)
## Fonctionnalites
- Scraping periodique des questions depuis une source distante.
- Stockage local dans SQLite (`qcm.db`).
- Interface web avec mode complet et mode aleatoire.
- Affichage du score et correction des reponses.
## Stack technique
- Python 3.11
- Flask
- SQLite
- Docker / Docker Compose
## Lancer le projet en local avec Docker
Prerrequis:
- Docker
- Docker Compose
Commandes:
```bash
docker compose build
docker compose up -d
```
Application web disponible sur: http://localhost:5000
## Services Docker
- `web`: demarre l'application Flask.
- `scraper`: execute `main.py` en boucle pour mettre a jour `qcm.db`.
Voir les logs:
```bash
docker compose logs -f web
docker compose logs -f scraper
```
Arreter les conteneurs:
```bash
docker compose down
```
+23
View File
@@ -0,0 +1,23 @@
services:
web:
image: python:3.11-slim
working_dir: /app
volumes:
- ./:/app
ports:
- "5000:5000"
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
command: sh -c "pip install --no-cache-dir -r requirements.txt && python webapp.py"
scraper:
image: python:3.11-slim
working_dir: /app
volumes:
- ./:/app
restart: unless-stopped
environment:
- PYTHONUNBUFFERED=1
command: sh -c "pip install --no-cache-dir -r requirements.txt && python main.py"
+172
View File
@@ -0,0 +1,172 @@
import requests
from bs4 import BeautifulSoup
import sqlite3
import time
import hashlib
import logging
import signal
import sys
import unicodedata
from datetime import datetime
# --- CONFIGURATION ---
URL = "https://alienor.myds.me/~cahierlabo/tmp/qcm_entrainement.html"
DB_FILE = "qcm.db"
INTERVAL = 10 * 60 # 10 minutes en secondes
# --- SETUP DE LA DB ---
conn = sqlite3.connect(DB_FILE, timeout=10)
c = conn.cursor()
# Crée la table si elle n'existe pas et ajoute un hash unique pour éviter les doublons
c.execute('''
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT,
question_hash TEXT UNIQUE,
answers TEXT,
last_scraped TEXT,
UNIQUE(question)
)
''')
# Index unique sur le hash pour garantir unicité même si le texte a de légères différences
c.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_question_hash ON questions(question_hash)')
conn.commit()
# --- FONCTIONS ---
def fetch_qcm(session):
logging.info("Récupération du QCM...")
r = session.get(URL, timeout=10)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
questions_data = []
# Récupère toutes les questions
li_questions = soup.select("li.QuizQuestion")
from urllib.parse import urljoin
for qi, q in enumerate(li_questions):
qt_elem = q.select_one(".QuestionText")
question_html = qt_elem.decode_contents() if qt_elem else ""
# convertir les src d'images en URLs absolues et récupérer la liste des images
q_soup = BeautifulSoup(question_html, "html.parser")
images = []
for img in q_soup.find_all('img'):
src = img.get('src')
if src:
src_abs = urljoin(URL, src)
img['src'] = src_abs
images.append(src_abs)
# question_html modifié avec src absolues
question_html = str(q_soup)
# texte brut pour le hash/normalisation
question_text = q_soup.get_text(" ", strip=True)
# Récupère les réponses
li_answers = q.select("ol li")
answers_list = []
for ai, li in enumerate(li_answers):
text = li.get_text(strip=True)
# enlever le ou les '?' de début (le site utilise un bouton avec '?')
import re
text = re.sub(r'^[\?\s]+', '', text)
# On essaie de trouver si la réponse est bonne depuis le JS I
# On récupère l'array I depuis le script
try:
# Cherche le script contenant I = [...]
script_tag = soup.find("script", text=lambda t: t and "I[" in t)
js_text = script_tag.string if script_tag else ""
# Cherche le pattern correspondant à la bonne réponse
# Exemple : I[0][3][0]=new Array('réponse', '', 1, 100, 1);
import re
pattern = re.compile(rf"I\[{qi}\]\[3\]\[{ai}\]=new Array\('.*?','',(\d),\d+,\d+\);")
match = pattern.search(js_text)
correct = match.group(1) == '1' if match else False
except Exception:
correct = False
answers_list.append({"text": text, "correct": correct})
questions_data.append({
"question": question_html,
"question_text": question_text,
"images": images,
"answers": answers_list,
"last_scraped": datetime.utcnow().isoformat()
})
return questions_data
def save_to_db(questions):
import json
for q in questions:
answers_json = json.dumps(q["answers"], ensure_ascii=False)
# Calcule un hash normalisé de la question (utilise le texte brut) pour empêcher doublons
q_norm = normalize_question(q.get("question_text") or q.get("question"))
qhash = hashlib.sha256(q_norm.encode('utf-8')).hexdigest()
last_scraped = q.get('last_scraped') or datetime.utcnow().isoformat()
# Insert ou update selon si le hash existe déjà
c.execute('''
INSERT INTO questions(question, question_hash, answers, last_scraped)
VALUES(?, ?, ?, ?)
ON CONFLICT(question_hash) DO UPDATE SET question=excluded.question, answers=excluded.answers, last_scraped=excluded.last_scraped
''', (q["question"], qhash, answers_json, last_scraped))
conn.commit()
logging.info(f"{len(questions)} questions sauvegardées / mises à jour dans la DB.")
def normalize_question(text):
# Normalise unicode, retire espaces multiples et passe en minuscule
if not text:
return ''
# si le texte contient du HTML, extraire le texte brut
if '<' in text and '>' in text:
try:
text = BeautifulSoup(text, 'html.parser').get_text(' ', strip=True)
except Exception:
pass
s = unicodedata.normalize('NFKC', text)
s = ' '.join(s.split())
return s.strip().lower()
# --- LOGGING ET SIGNALS ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
def shutdown(signum, frame):
logging.info("Arrêt demandé, fermeture de la DB.")
try:
conn.commit()
conn.close()
except Exception:
pass
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
# --- BOUCLE PRINCIPALE ---
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retries = Retry(total=3, backoff_factor=1, status_forcelist=[429,500,502,503,504])
session.mount('https://', HTTPAdapter(max_retries=retries))
session.mount('http://', HTTPAdapter(max_retries=retries))
while True:
try:
data = fetch_qcm(session)
save_to_db(data)
except Exception as e:
logging.exception("Erreur lors de la récupération ou sauvegarde:")
logging.info(f"Attente {INTERVAL//60} minutes...")
time.sleep(INTERVAL)
+10
View File
@@ -0,0 +1,10 @@
# Web
Flask>=2.0
# Scraping
requests>=2.28
beautifulsoup4>=4.11
urllib3>=1.26
# Optional WSGI server
gunicorn>=20.1
+62
View File
@@ -0,0 +1,62 @@
/* Global reset */
* { box-sizing: border-box; margin: 0; padding: 0; }
html,body{height:100%;}
:root{
--bg:#f7f9fb;
--card:#ffffff;
--muted:#707b86;
--accent:#0d6efd;
--success:#198754;
--danger:#dc3545;
--radius:8px;
--max-width:1100px;
--gap:12px;
--font-sans: Inter, Roboto, "Segoe UI", Arial, sans-serif;
}
body{
font-family: var(--font-sans);
background: var(--bg);
color: #12222b;
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
padding: 24px 16px;
}
.wrapper{max-width:var(--max-width);margin:0 auto;}
.site-header{display:flex;align-items:center;gap:12px;margin-bottom:18px;}
.brand{display:flex;align-items:center;gap:10px}
.brand h1{font-size:1.25rem;margin:0;font-weight:600}
.site-actions{margin-left:auto;display:flex;gap:8px}
.btn{display:inline-flex;align-items:center;gap:8px;background:var(--accent);color:white;border:none;padding:8px 12px;border-radius:6px;text-decoration:none;cursor:pointer}
.btn.secondary{background:#f0f2f5;color:var(--muted);border:1px solid #e1e6ea}
.small-btn{padding:6px 10px;border-radius:6px}
.tabs{display:flex;gap:8px;margin-bottom:12px}
.tab-button{background:transparent;border:1px solid transparent;padding:8px 10px;border-radius:6px;cursor:pointer;color:var(--muted)}
.tab-button.active{background:var(--accent);color:#fff}
.controls{display:flex;gap:8px;align-items:center;margin-bottom:12px}
.container-card{background:var(--card);border-radius:var(--radius);padding:14px;border:1px solid #e9eef2}
.question{margin-bottom:12px;padding:12px;border-radius:10px;background:linear-gradient(180deg,#fff,#fbfdff);border:1px solid #e6eef5}
.question > div:first-child{margin-bottom:8px}
.answers{margin-top:6px;display:flex;flex-direction:column;gap:6px}
.answer{display:flex;align-items:center;gap:8px;padding:9px;border-radius:8px;cursor:pointer;border:1px solid transparent}
.answer:hover{background:#f6fbff}
.answer input{margin-right:8px}
.answer.correct{background:#eaf6ec;border-color:rgba(25,135,84,0.15)}
.answer.wrong{background:#fff1f2;border-color:rgba(220,53,69,0.12)}
.feedback{margin-top:8px;font-weight:600}
.feedback .good{color:var(--success)}
.feedback .bad{color:var(--danger)}
.manage-item{padding:10px;border-radius:8px;border:1px solid #f0f3f5;background:linear-gradient(180deg,#fff,#fbfdff);margin-bottom:8px}
.hidden{display:none}
.footer{margin-top:22px;padding:12px;text-align:center;color:var(--muted);font-size:0.9rem}
.iframe-wrap{border-radius:8px;overflow:hidden;border:1px solid #e6eef5;background:white}
.iframe-wrap iframe{width:100%;height:80vh;border:0;display:block}
@media (max-width:800px){
.tabs{flex-wrap:wrap}
.site-header{flex-direction:column;align-items:flex-start;gap:8px}
.site-actions{margin-left:0}
.iframe-wrap iframe{height:60vh}
}
@media (max-width:420px){
.brand h1{font-size:1rem}
.btn{padding:8px}
}
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QCM - Entraînement</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script>
window.MathJax = {
tex: {inlineMath: [['\\(','\\)'], ['$', '$']], displayMath: [['$$','$$']]},
options: {processHtmlClass: 'tex2jax_process'}
};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
</head>
<body>
<header class="wrapper site-header">
<div class="brand">
<h1>Entraînement QCM</h1>
<div style="color:var(--muted);font-size:0.95rem">Révision et entraînement</div>
</div>
<div class="site-actions">
<a class="btn" href="/">Accueil</a>
<a class="btn secondary" href="/external">Site réel</a>
</div>
</header>
<main class="wrapper">
<div class="container-card">
{% block content %}{% endblock %}
</div>
</main>
<footer class="wrapper footer">© QCM — Entraînement</footer>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<div class="controls" style="margin-bottom:8px;align-items:center;">
<a class="btn" href="/">Retour</a>
<div style="color:var(--muted); margin-left:8px">Affichage du site externe</div>
</div>
<div class="iframe-wrap">
<iframe src="https://alienor.myds.me/~cahierlabo/tmp/qcm_entrainement.html" title="Site réel QCM"></iframe>
</div>
{% endblock %}
+349
View File
@@ -0,0 +1,349 @@
{% extends "base.html" %}
{% block content %}
<div class="tabs">
<button class="tab-button" id="tab-btn-all">Toutes</button>
<button class="tab-button" id="tab-btn-random">Aléatoire</button>
<button class="tab-button" id="tab-btn-manage">Gérer</button>
</div>
<!-- Onglet : Toutes (liste complète) -->
<div id="tab-all">
<div class="controls">
<button class="small-btn btn" id="reset-all">Réinitialiser</button>
<label style="margin-left:1rem;display:flex;align-items:center;gap:.4rem;">
<input type="checkbox" id="all-randomize"> Aléatoire
</label>
<span id="all-score" style="margin-left:1rem;color:#333"></span>
<div style="margin-left:auto;color:#666" id="all-count"></div>
</div>
<form id="qcm-form">
{% for q in questions %}
<div class="question" data-qid="{{ q.id }}">
<div><strong>Question {{ loop.index }}:</strong> {{ q.text | safe }}</div>
<div class="answers">
{% for a in q.answers %}
<label class="answer" data-idx="{{ a.idx }}">
<input type="radio" name="q_{{ q.id }}" value="{{ a.idx }}" data-correct="{% if a.correct %}1{% else %}0{% endif %}"> {{ a.text }}
</label>
{% endfor %}
</div>
<div class="feedback" style="margin-top:.5rem"></div>
</div>
{% endfor %}
</form>
</div>
<!-- Onglet : Aléatoire -->
<div id="tab-random" style="display:none">
<div class="controls" style="margin-bottom:.5rem">
<button class="small-btn btn" id="random-next">Nouvelle question</button>
<button class="small-btn btn" id="random-reset">Reset score</button>
<span id="random-score" style="margin-left:1rem"></span>
</div>
<div id="random-area"></div>
</div>
<!-- Onglet : Gérer -->
<div id="tab-manage" style="display:none">
<div style="margin-bottom:.5rem">Choisissez les questions autorisées</div>
<div id="manage-list">
{% for q in questions %}
<div class="manage-item">
<label>
<input type="checkbox" class="manage-allow" data-qid="{{ q.id }}"> Question {{ loop.index }}
</label>
<div style="margin-left:1.2rem">{{ q.text | safe }}</div>
<div style="margin-left:1.2rem;color:#666">
Dernière actualisation: {{
(
q.last_scraped[:10].split('-')[2] ~ '/' ~
q.last_scraped[:10].split('-')[1] ~ '/' ~
q.last_scraped[:10].split('-')[0] ~
' - ' ~
"%02d"|format((q.last_scraped[11:13] | int + 1) % 24) ~
'h' ~
q.last_scraped[14:16]
) or 'inconnu'
}}
</div>
<div style="margin-left:1.2rem;margin-top:.4rem">
<strong>Réponses :</strong>
<ul style="margin-top:.25rem">
{% for a in q.answers %}
<li style="margin:.15rem 0;">
<span {% if a.correct %} style="color:green;font-weight:bold" {% endif %}>{{ a.text }}</span>
{% if a.correct %} <strong>(bonne)</strong> {% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor %}
</div>
</div>
<script id="all-questions" type="application/json">{{ questions_json | safe }}</script>
<script>
// embed data for client-side random selection (JSON fourni côté serveur)
const ALL_QUESTIONS = JSON.parse(document.getElementById('all-questions').textContent);
document.addEventListener('DOMContentLoaded', function(){
// tab switching
function showTab(id){
document.getElementById('tab-all').style.display = id==='all' ? '' : 'none';
document.getElementById('tab-random').style.display = id==='random' ? '' : 'none';
document.getElementById('tab-manage').style.display = id==='manage' ? '' : 'none';
// re-typeset MathJax for the shown tab (if needed)
try{
if(window.MathJax && window.MathJax.typesetPromise){
const el = id==='all' ? document.getElementById('tab-all') : (id==='random' ? document.getElementById('tab-random') : document.getElementById('tab-manage'));
if(el) window.MathJax.typesetPromise([el]);
}
}catch(e){ /* ignore */ }
// toggle active class on tab buttons
document.querySelectorAll('.tab-button').forEach(function(b){ b.classList.remove('active'); });
if(id === 'all') document.getElementById('tab-btn-all').classList.add('active');
if(id === 'random') document.getElementById('tab-btn-random').classList.add('active');
if(id === 'manage') document.getElementById('tab-btn-manage').classList.add('active');
}
document.getElementById('tab-btn-all').addEventListener('click', ()=> { showTab('all'); localStorage.setItem('q_last_tab','all'); });
document.getElementById('tab-btn-random').addEventListener('click', ()=> { showTab('random'); localStorage.setItem('q_last_tab','random'); const q = pickRandomQuestion(); renderQuestionInto(document.getElementById('random-area'), q); });
document.getElementById('tab-btn-manage').addEventListener('click', ()=> { showTab('manage'); localStorage.setItem('q_last_tab','manage'); });
// restore allowed ids from localStorage or default to all
function getAllowed(){
let raw = localStorage.getItem('q_allowed_ids');
if(!raw) return ALL_QUESTIONS.map(q=>q.id);
try{ return JSON.parse(raw); }catch(e){ return ALL_QUESTIONS.map(q=>q.id); }
}
function setAllowed(arr){ localStorage.setItem('q_allowed_ids', JSON.stringify(arr)); }
// init manage checkboxes
const allowed = new Set(getAllowed());
document.querySelectorAll('.manage-allow').forEach(function(cb){
let id = Number(cb.getAttribute('data-qid'));
cb.checked = allowed.has(id);
cb.addEventListener('change', function(){
let cur = new Set(getAllowed());
if(cb.checked) cur.add(id); else cur.delete(id);
setAllowed(Array.from(cur));
// apply allowed set to the 'Toutes' tab and reapply ordering
applyAllowedToAllTab();
applyAllOrderSetting();
// if random area displays a question that is no longer allowed, clear it
const randArea = document.getElementById('random-area');
if(randArea){
const qEl = randArea.querySelector('.question');
if(qEl && !cur.has(Number(qEl.getAttribute('data-qid')))) randArea.innerHTML = '<div>Aucune question disponible.</div>';
}
});
});
// apply allowed set to the 'Toutes' tab (hide questions not allowed)
function applyAllowedToAllTab(){
const allowedIds = new Set(getAllowed());
const container = document.querySelector('#tab-all form');
if(!container) return;
const qs = Array.from(container.querySelectorAll('.question'));
qs.forEach(function(q){
const qid = Number(q.getAttribute('data-qid'));
if(allowedIds.has(qid)) q.style.display = '';
else q.style.display = 'none';
});
const visible = qs.filter(q => q.style.display !== 'none');
document.getElementById('all-count').textContent = `Questions: ${visible.length}`;
// if random area displays a question that is no longer allowed, clear it
const randArea = document.getElementById('random-area');
if(randArea){
const qEl = randArea.querySelector('.question');
if(qEl && !allowedIds.has(Number(qEl.getAttribute('data-qid')))) randArea.innerHTML = '<div>Aucune question disponible.</div>';
}
}
// random question logic
function pickRandomQuestion(){
const allowedIds = new Set(getAllowed());
const pool = ALL_QUESTIONS.filter(q => allowedIds.has(q.id));
if(pool.length === 0) return null;
return pool[Math.floor(Math.random()*pool.length)];
}
function renderQuestionInto(container, q){
if(!q){ container.innerHTML = '<div>Aucune question disponible.</div>'; return; }
let html = `<div class="question" data-qid="${q.id}">`;
html += `<div><strong>Question:</strong> ${q.text}</div>`;
html += '<div class="answers">';
// shuffle answers for this question
const answersShuffled = q.answers.slice().sort(()=>Math.random()-0.5);
answersShuffled.forEach(function(a){
html += `<label class="answer"><input type="radio" name="rand_${q.id}" value="${a.idx}" data-correct="${a.correct?1:0}"> ${a.text}</label>`;
});
html += '</div><div class="feedback" style="margin-top:.5rem"></div></div>';
container.innerHTML = html;
// render LaTeX in the injected content
try{
if(window.MathJax && window.MathJax.typesetPromise){
window.MathJax.typesetPromise([container]);
} else if(window.MathJax && window.MathJax.typeset){
window.MathJax.typeset([container]);
}
}catch(e){ /* ignore MathJax errors */ }
// attach immediate feedback handler
container.querySelectorAll('input[type=radio]').forEach(function(radio){
radio.addEventListener('change', function(e){
const input = e.target;
const qDiv = input.closest('.question');
const feedback = qDiv.querySelector('.feedback');
const isCorrect = input.getAttribute('data-correct') === '1';
qDiv.querySelectorAll('input').forEach(i=>i.disabled=true);
if(isCorrect) feedback.innerHTML = '<span style="color:green;font-weight:bold">Bonne réponse</span>';
else {
feedback.innerHTML = '<span style="color:red;font-weight:bold">Mauvaise réponse</span>';
qDiv.querySelectorAll('input').forEach(function(i){ if(i.getAttribute('data-correct')==='1') i.closest('label').style.outline='2px solid green'; });
}
input.closest('label').style.background = isCorrect? '#ddffdd' : '#ffdddd';
// update score display
updateRandomScore(isCorrect);
});
});
}
function updateRandomScore(lastCorrect){
let s = localStorage.getItem('q_random_score');
let st = s ? JSON.parse(s) : {correct:0,total:0};
if(typeof lastCorrect === 'boolean'){ st.total += 1; if(lastCorrect) st.correct +=1; }
localStorage.setItem('q_random_score', JSON.stringify(st));
document.getElementById('random-score').textContent = `Score: ${st.correct} / ${st.total}`;
}
document.getElementById('random-next').addEventListener('click', function(){
const q = pickRandomQuestion();
renderQuestionInto(document.getElementById('random-area'), q);
});
document.getElementById('random-reset').addEventListener('click', function(){
localStorage.removeItem('q_random_score'); updateRandomScore();
});
// init score for random tab
updateRandomScore();
// --- All-tab score handling ---
// --- All-tab score handling ---
function updateAllScore(lastCorrect){
let s = localStorage.getItem('q_all_score');
let st = s ? JSON.parse(s) : {correct:0,total:0};
if(typeof lastCorrect === 'boolean'){ st.total += 1; if(lastCorrect) st.correct +=1; }
localStorage.setItem('q_all_score', JSON.stringify(st));
const el = document.getElementById('all-score');
if(el) el.textContent = `Score: ${st.correct} / ${st.total}`;
}
// ensure all answers are displayed in random order for each question
function shuffleAnswersForQuestion(qElem){
if(!qElem) return;
const answers = qElem.querySelector('.answers');
if(!answers) return;
const labs = Array.from(answers.querySelectorAll('label'));
const shuffled = labs.sort(()=>Math.random()-0.5);
shuffled.forEach(l => answers.appendChild(l));
}
// maintain initial order of question elements but always randomize answers inside each question
const allContainer = document.querySelector('#tab-all form');
const initialQs = allContainer ? Array.from(allContainer.querySelectorAll('.question')) : [];
// shuffle answers initially for every question
initialQs.forEach(q => shuffleAnswersForQuestion(q));
function shuffleAllQuestions(){
if(!allContainer) return;
const qs = Array.from(allContainer.querySelectorAll('.question'));
const visible = qs.filter(q => q.style.display !== 'none');
// remove visible nodes and re-append them shuffled so hidden ones stay in place
visible.forEach(q => allContainer.removeChild(q));
const shuffled = visible.sort(()=>Math.random()-0.5);
shuffled.forEach(q=> allContainer.appendChild(q));
// always randomize answers too
shuffled.forEach(q => shuffleAnswersForQuestion(q));
document.getElementById('all-count').textContent = `Questions: ${shuffled.length}`;
}
function restoreInitialOrder(){
if(!allContainer) return;
// remove currently visible questions first
const qs = Array.from(allContainer.querySelectorAll('.question'));
const visible = qs.filter(q => q.style.display !== 'none');
visible.forEach(q => allContainer.removeChild(q));
// append initial questions but only those allowed (visible)
const allowedIds = new Set(getAllowed());
const toAppend = initialQs.filter(q => allowedIds.has(Number(q.getAttribute('data-qid'))));
toAppend.forEach(q => allContainer.appendChild(q));
// still randomize answers for each appended question
toAppend.forEach(q => shuffleAnswersForQuestion(q));
document.getElementById('all-count').textContent = `Questions: ${toAppend.length}`;
}
function applyAllOrderSetting(){
const enabled = localStorage.getItem('q_all_random') === '1';
if(enabled) shuffleAllQuestions(); else restoreInitialOrder();
}
// checkbox for toggling random display of question order (answers remain random)
const allRandCb = document.getElementById('all-randomize');
if(allRandCb){
allRandCb.checked = localStorage.getItem('q_all_random') === '1';
allRandCb.addEventListener('change', function(){
localStorage.setItem('q_all_random', allRandCb.checked ? '1' : '0');
applyAllOrderSetting();
});
}
// reset stored all-tab score on page load so refresh clears the score
localStorage.removeItem('q_all_score');
// init reset on all tab
document.getElementById('reset-all').addEventListener('click', function(){
document.querySelectorAll('#tab-all .question').forEach(function(q){ q.querySelectorAll('input').forEach(function(i){ i.disabled=false; i.checked=false; }); q.querySelector('.feedback').innerHTML=''; q.querySelectorAll('label').forEach(function(l){ l.style.background=''; l.style.outline=''; });
// re-randomize answers for this question
shuffleAnswersForQuestion(q);
});
// clear stored score for all tab
localStorage.removeItem('q_all_score'); updateAllScore();
// apply order setting (shuffle or restore)
applyAllOrderSetting();
});
// also attach immediate feedback for the all-tab radios and update the all score
document.querySelectorAll('#tab-all input[type=radio]').forEach(function(radio){
radio.addEventListener('change', function(e){
var input = e.target;
var qDiv = input.closest('.question');
var feedback = qDiv.querySelector('.feedback');
var isCorrect = input.getAttribute('data-correct') === '1';
qDiv.querySelectorAll('input').forEach(function(i){ i.disabled = true; });
if(isCorrect){ feedback.innerHTML = '<span style="color:green;font-weight:bold">Bonne réponse</span>'; }
else { feedback.innerHTML = '<span style="color:red;font-weight:bold">Mauvaise réponse</span>'; qDiv.querySelectorAll('input').forEach(function(i){ if(i.getAttribute('data-correct') === '1'){ var lab = i.closest('label'); if(lab) lab.style.outline = '2px solid green'; } }); }
var chosenLabel = input.closest('label'); if(chosenLabel){ chosenLabel.style.background = isCorrect ? '#ddffdd' : '#ffdddd'; }
updateAllScore(isCorrect);
});
});
// initial apply according to allowed/setting and init all score display
applyAllowedToAllTab();
applyAllOrderSetting();
updateAllScore();
// restore last tab from localStorage (or show all)
const lastTab = localStorage.getItem('q_last_tab') || 'all';
showTab(lastTab);
// if last tab was random, show a question immediately
if(lastTab === 'random'){
const q0 = pickRandomQuestion();
renderQuestionInto(document.getElementById('random-area'), q0);
}
});
</script>
<!-- script de feedback et reset global intégré dans le premier bloc; script dupliqué supprimé -->
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<div class="score">Score: {{ score.correct }} / {{ score.total }}</div>
{% for r in results %}
<div class="question">
<div><strong>Question {{ loop.index }}:</strong> {{ r.text | safe }}</div>
<div class="answers">
{% for a in r.answers %}
<div class="answer">
{% if a.is_correct %}
<span class="correct">(Bonne) </span>
{% endif %}
{% if a.selected and not a.is_correct %}
<span class="wrong">(Votre choix incorrect) </span>
{% endif %}
{{ a.text }}
</div>
{% endfor %}
</div>
<div>
{% if r.question_correct %}
<strong class="good">Question correcte</strong>
{% else %}
<strong class="bad">Question incorrecte</strong>
{% endif %}
</div>
</div>
{% endfor %}
<div style="margin-top:12px">
<a class="btn" href="/">Recommencer</a>
</div>
{% endblock %}
+178
View File
@@ -0,0 +1,178 @@
from flask import Flask, render_template, request
import unicodedata
import sqlite3
import json
import os
APP_DIR = os.path.dirname(__file__)
DB_PATH = os.path.join(APP_DIR, 'qcm.db')
app = Flask(__name__)
def get_questions():
conn = sqlite3.connect(DB_PATH)
# Récupérer les champs textes comme octets bruts pour les décoder manuellement
conn.text_factory = bytes
c = conn.cursor()
c.execute('SELECT id, question, answers, last_scraped FROM questions ORDER BY id')
rows = c.fetchall()
conn.close()
questions = []
def norm(s):
return unicodedata.normalize('NFC', s) if isinstance(s, str) else s
def decode_text(raw) -> str:
"""Décoder une valeur provenant de la base : bytes ou str.
Essaie plusieurs décodages usuels pour éviter le caractère de remplacement .
"""
if raw is None:
return raw
# Si on reçoit déjà une str, vérifier si elle contient des séquences suspectes
if isinstance(raw, str):
s = raw
if '\ufffd' not in s and 'Ã' not in s and 'Â' not in s:
return norm(s)
# tenter de ré-interpréter comme latin1 -> utf-8
try:
cand = s.encode('latin1').decode('utf-8')
if '\ufffd' not in cand:
return norm(cand)
except Exception:
pass
return s
# Si raw est bytes, essayer plusieurs encodages
if isinstance(raw, (bytes, bytearray)):
b = bytes(raw)
# Ordre: utf-8 strict, cp1252, latin1, utf-8 replace
try:
s = b.decode('utf-8')
# Cas fréquent : double-encodage UTF-8 -> on obtient des séquences "Ã"/"Â".
# Tenter la réparation double-encodage : encoder en latin1 puis décoder en utf-8.
if 'Ã' in s or 'Â' in s:
try:
repaired = s.encode('latin1', errors='replace').decode('utf-8', errors='replace')
# si la réparation donne des caractères accentués, la garder
if any(ch in repaired for ch in 'éèàêôçùÉÈÀÂ'):
return norm(repaired)
except Exception:
pass
if '\ufffd' not in s:
return norm(s)
except Exception:
pass
for enc in ('cp1252', 'latin1'):
try:
s = b.decode(enc)
if '\ufffd' not in s:
return norm(s)
except Exception:
continue
# fallback permissif
try:
return norm(b.decode('utf-8', errors='replace'))
except Exception:
return norm(b.decode('latin1', errors='replace'))
# si autre type, forcer str
try:
return norm(str(raw))
finally:
pass
for r in rows:
qid, text, answers_json, last_scraped = r
# Décoder proprement les champs (text, answers_json, last_scraped peuvent être bytes)
text = decode_text(text) if text is not None else text
answers_str = decode_text(answers_json) if answers_json is not None else '[]'
last_scraped = decode_text(last_scraped) if last_scraped is not None else None
try:
answers = json.loads(answers_str)
except Exception:
answers = []
# normalize answers structure
formatted = []
for i, a in enumerate(answers):
# a is expected to be dict with 'text' and 'correct'
at = a.get('text') if isinstance(a, dict) else str(a)
ac = a.get('correct') if isinstance(a, dict) else False
at = decode_text(at)
formatted.append({'idx': i, 'text': at, 'correct': bool(ac)})
questions.append({'id': qid, 'text': text, 'answers': formatted, 'last_scraped': last_scraped})
return questions
@app.route('/')
def index():
qs = get_questions()
# Sérialise les questions en JSON côté serveur pour l'insérer dans le JS sans dépendre du filtre tojson
import json as _json
# compact JSON, puis échapper </ pour éviter de fermer accidentellement le <script>
questions_json = _json.dumps(qs, ensure_ascii=False, separators=(',',':'))
questions_json = questions_json.replace('</', '<\/')
return render_template('index.html', questions=qs, questions_json=questions_json)
@app.route('/submit', methods=['POST'])
def submit():
qs = get_questions()
results = []
total = 0
correct_count = 0
for q in qs:
qid = q['id']
name = f'q_{qid}'
selected = request.form.getlist(name)
selected_idx = set(int(x) for x in selected) if selected else set()
# compute correct set
correct_set = set(i for i, a in enumerate(q['answers']) if a.get('correct'))
is_correct = (selected_idx == correct_set)
if is_correct:
correct_count += 1
total += 1
# prepare per-answer feedback
answers_feedback = []
for a in q['answers']:
idx = a['idx']
answers_feedback.append({
'text': a['text'],
'is_correct': a['correct'],
'selected': idx in selected_idx
})
results.append({
'id': qid,
'text': q['text'],
'answers': answers_feedback,
'question_correct': is_correct,
'correct_set': list(correct_set),
'selected': list(selected_idx)
})
score = {'correct': correct_count, 'total': total}
return render_template('result.html', results=results, score=score)
@app.route('/external')
def external():
# Page qui embarque le "vrai" site dans une iframe
external_url = 'https://alienor.myds.me/~cahierlabo/tmp/qcm_entrainement.html'
return render_template('external.html', external_url=external_url)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)