mirror of
https://github.com/arthur-pbty/QCM_physique.git
synced 2026-06-03 23:36:21 +02:00
first commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
.git
|
||||
*.db
|
||||
__pycache__
|
||||
*.egg-info
|
||||
node_modules/
|
||||
/.vscode
|
||||
/.idea
|
||||
build/
|
||||
dist/
|
||||
+35
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,10 @@
|
||||
# Web
|
||||
Flask>=2.0
|
||||
|
||||
# Scraping
|
||||
requests>=2.28
|
||||
beautifulsoup4>=4.11
|
||||
urllib3>=1.26
|
||||
|
||||
# Optional WSGI server
|
||||
gunicorn>=20.1
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user