postgres-js failed to parse DATABASE_URL when the password contained URL-special characters (@ : / # ...). New createSqlClient(): uses DATABASE_URL if set, else falls back to PG* env vars (PGHOST/PGUSER/PGPASSWORD/...) which postgres-js reads directly — password passed verbatim, no URL encoding. docker-compose.prod.yml now sets PG* for the server instead of an inline DSN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
// Seed du contenu quiz. One-shot au déploiement : bun run db:seed
|
|
// - Open Trivia DB : catégories geek (mcq + truefalse), respect 1 req / 5 s.
|
|
// - Banque FR en dur : insérée comme source 'manual'.
|
|
// Idempotent : prompt unique → onConflictDoNothing.
|
|
|
|
import { eq } from "drizzle-orm"
|
|
import { drizzle } from "drizzle-orm/postgres-js"
|
|
import { createSqlClient } from "./client"
|
|
import {
|
|
quizCategory,
|
|
quizQuestion,
|
|
type NewQuizQuestion,
|
|
} from "./schema"
|
|
import { QUIZ_QUESTIONS } from "../game/modes/quiz/questions"
|
|
|
|
const client = createSqlClient({ max: 1 })
|
|
if (!client) {
|
|
console.error("Aucune config DB (DATABASE_URL ou PG*) — impossible de seeder.")
|
|
process.exit(1)
|
|
}
|
|
const db = drizzle(client, { schema: { quizCategory, quizQuestion } })
|
|
|
|
// Catégories Open Trivia DB pertinentes (id OpenTDB → nom affiché).
|
|
const OPENTDB_CATEGORIES = [
|
|
{ id: 15, name: "Jeux vidéo" },
|
|
{ id: 31, name: "Anime & Manga" },
|
|
{ id: 29, name: "Comics" },
|
|
{ id: 11, name: "Cinéma" },
|
|
{ id: 14, name: "Télévision" },
|
|
{ id: 18, name: "Informatique" },
|
|
]
|
|
const AMOUNT = 30
|
|
const RATE_LIMIT_MS = 5200 // OpenTDB : 1 requête / 5 s par IP
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
const decode = (s: string) => Buffer.from(s, "base64").toString("utf8")
|
|
const difficultyOf = (d: string) => (d === "hard" ? 3 : d === "medium" ? 2 : 1)
|
|
|
|
// Tous les champs string sont encodés en base64 (encode=base64).
|
|
interface OtdbQuestion {
|
|
type: string
|
|
difficulty: string
|
|
question: string
|
|
correct_answer: string
|
|
incorrect_answers: string[]
|
|
}
|
|
|
|
function shuffle<T>(items: T[]): T[] {
|
|
const arr = [...items]
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1))
|
|
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
|
}
|
|
return arr
|
|
}
|
|
|
|
async function getToken(): Promise<string | undefined> {
|
|
try {
|
|
const res = await fetch("https://opentdb.com/api_token.php?command=request")
|
|
const json = (await res.json()) as { token?: string }
|
|
return json.token
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
async function fetchBatch(
|
|
categoryId: number,
|
|
type: "multiple" | "boolean",
|
|
token: string | undefined
|
|
): Promise<OtdbQuestion[]> {
|
|
const url =
|
|
`https://opentdb.com/api.php?amount=${AMOUNT}&category=${categoryId}` +
|
|
`&type=${type}&encode=base64${token ? `&token=${token}` : ""}`
|
|
const res = await fetch(url)
|
|
const json = (await res.json()) as {
|
|
response_code: number
|
|
results: OtdbQuestion[]
|
|
}
|
|
if (json.response_code === 5) {
|
|
// Rate limit : on attend et on réessaie une fois.
|
|
await sleep(RATE_LIMIT_MS)
|
|
return fetchBatch(categoryId, type, token)
|
|
}
|
|
return json.response_code === 0 ? json.results : []
|
|
}
|
|
|
|
/** Upsert une catégorie et renvoie son id. */
|
|
async function upsertCategory(name: string): Promise<string> {
|
|
await db.insert(quizCategory).values({ name }).onConflictDoNothing()
|
|
const [row] = await db
|
|
.select({ id: quizCategory.id })
|
|
.from(quizCategory)
|
|
.where(eq(quizCategory.name, name))
|
|
return row.id
|
|
}
|
|
|
|
function toQuestion(
|
|
q: OtdbQuestion,
|
|
categoryId: string
|
|
): NewQuizQuestion {
|
|
// Avec encode=base64, TOUS les champs sont encodés (type/difficulty inclus).
|
|
const prompt = decode(q.question)
|
|
const type = decode(q.type)
|
|
const difficulty = difficultyOf(decode(q.difficulty))
|
|
if (type === "boolean") {
|
|
const correct = decode(q.correct_answer) // "True" | "False"
|
|
return {
|
|
categoryId,
|
|
format: "truefalse",
|
|
prompt,
|
|
difficulty,
|
|
source: "opentdb",
|
|
lang: "en",
|
|
choices: ["True", "False"],
|
|
correctIndex: correct === "True" ? 0 : 1,
|
|
}
|
|
}
|
|
const correct = decode(q.correct_answer)
|
|
const choices = shuffle([
|
|
correct,
|
|
...q.incorrect_answers.map(decode),
|
|
])
|
|
return {
|
|
categoryId,
|
|
format: "mcq",
|
|
prompt,
|
|
difficulty,
|
|
source: "opentdb",
|
|
lang: "en",
|
|
choices,
|
|
correctIndex: choices.indexOf(correct),
|
|
}
|
|
}
|
|
|
|
async function insertQuestions(rows: NewQuizQuestion[]): Promise<number> {
|
|
if (rows.length === 0) {
|
|
return 0
|
|
}
|
|
const inserted = await db
|
|
.insert(quizQuestion)
|
|
.values(rows)
|
|
.onConflictDoNothing()
|
|
.returning({ id: quizQuestion.id })
|
|
return inserted.length
|
|
}
|
|
|
|
async function seedOpenTdb(): Promise<number> {
|
|
const token = await getToken()
|
|
let total = 0
|
|
let first = true
|
|
for (const cat of OPENTDB_CATEGORIES) {
|
|
const categoryId = await upsertCategory(cat.name)
|
|
for (const type of ["multiple", "boolean"] as const) {
|
|
if (!first) {
|
|
await sleep(RATE_LIMIT_MS)
|
|
}
|
|
first = false
|
|
const batch = await fetchBatch(cat.id, type, token)
|
|
const rows = batch.map((q) => toQuestion(q, categoryId))
|
|
const n = await insertQuestions(rows)
|
|
total += n
|
|
console.log(` ${cat.name} (${type}): +${n}`)
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
async function seedManual(): Promise<number> {
|
|
let total = 0
|
|
for (const q of QUIZ_QUESTIONS) {
|
|
const categoryId = await upsertCategory(q.category)
|
|
total += await insertQuestions([
|
|
{
|
|
categoryId,
|
|
format: q.format,
|
|
prompt: q.prompt,
|
|
difficulty: q.difficulty,
|
|
source: "manual",
|
|
lang: "fr",
|
|
choices: q.choices,
|
|
correctIndex: q.correctIndex,
|
|
acceptedAnswers: q.acceptedAnswers,
|
|
},
|
|
])
|
|
}
|
|
return total
|
|
}
|
|
|
|
console.log("Seed Open Trivia DB (respect du 1 req / 5 s, patientez)…")
|
|
const otdb = await seedOpenTdb()
|
|
console.log("Seed banque FR (manual)…")
|
|
const manual = await seedManual()
|
|
console.log(`Terminé : ${otdb} questions OpenTDB + ${manual} manuelles.`)
|
|
await client.end()
|