From b8b3f8bddc59e559373ab8b9c1dbbb8f9a637c1f Mon Sep 17 00:00:00 2001 From: whidix Date: Sun, 1 Mar 2026 03:59:16 +0100 Subject: [PATCH] feat: implement daily character guessing game with local storage and hint system - Added character selection and history management using local storage. - Implemented hint system that unlocks based on the number of guesses. - Enhanced UI with animations for hint unlocks and special win conditions. - Created a server endpoint to record wins in the database. --- .dockerignore | 13 + .gitignore | 1 + Dockerfile | 26 + SCRAPER.md | 130 --- docker-entrypoint.sh | 9 + ...sage.sql => 0000_graceful_master_mold.sql} | 85 +- drizzle/meta/0000_snapshot.json | 528 ++++++++++- drizzle/meta/_journal.json | 4 +- package.json | 5 +- scripts/daily-characters.json | 145 +++ scripts/import-json.ts | 406 +++++++++ scripts/import-sql.js | 104 --- scripts/init-column-config.ts | 64 ++ scripts/scrape-onepiece.js | 825 +++++++++++------- scripts/set-daily-mode.ts | 84 ++ src/hooks.server.ts | 28 + src/lib/server/daily-character.ts | 152 ++++ src/lib/server/db/schema.ts | 90 +- src/routes/+page.server.ts | 9 + src/routes/+page.svelte | 60 +- src/routes/daily/+page.server.ts | 38 + src/routes/daily/+page.svelte | 769 +++++++++++++++- src/routes/daily/+server.ts | 33 + 23 files changed, 2988 insertions(+), 620 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 SCRAPER.md create mode 100644 docker-entrypoint.sh rename drizzle/{0000_dapper_sage.sql => 0000_graceful_master_mold.sql} (55%) create mode 100644 scripts/daily-characters.json create mode 100644 scripts/import-json.ts delete mode 100644 scripts/import-sql.js create mode 100644 scripts/init-column-config.ts create mode 100644 scripts/set-daily-mode.ts create mode 100644 src/lib/server/daily-character.ts create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/daily/+page.server.ts create mode 100644 src/routes/daily/+server.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa18946 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +npm-debug.log +.git +.gitignore +.vscode +.svelte-kit +build +dist +coverage +.env +.env.* +local.db +drizzle/meta diff --git a/.gitignore b/.gitignore index 9b12a10..b03f683 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules # OS .DS_Store Thumbs.db +/.vscode # Env .env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f865afc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:24-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:24-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app ./ +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +# Create non-root user +RUN addgroup -g 1000 node && adduser -D -u 1000 -G node node +RUN chown -R node:node /app + +USER node + +EXPOSE 4173 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] \ No newline at end of file diff --git a/SCRAPER.md b/SCRAPER.md deleted file mode 100644 index 299ca10..0000000 --- a/SCRAPER.md +++ /dev/null @@ -1,130 +0,0 @@ -# One Piece Scraper - -Script pour scraper les données des personnages de One Piece depuis le fandom wiki français. - -## Installation - -Installe les dépendances d'abord : - -```bash -npm install -``` - -## Utilisation - -```bash -# Scraper tous les formats (JSON, CSV, SQL) -npm run scrape - -# Ou spécifier un format -node scripts/scrape-onepiece.js json # JSON uniquement -node scripts/scrape-onepiece.js csv # CSV uniquement -node scripts/scrape-onepiece.js sql # SQL uniquement -node scripts/scrape-onepiece.js all # Tous les formats (défaut) -``` - -## Sortie - -Les données seront sauvegardées dans le dossier `scraped-data/` : - -- **characters.json** - Format JSON avec toutes les données structurées -- **characters.csv** - Format CSV pour importer dans Excel/Sheets -- **characters.sql** - Statements SQL avec gestion des conflits (upsert) - -## Données extraites - -Pour chaque personnage : -- 📝 **Nom** - Nom du personnage -- 👤 **Genre** - Masculin/Féminin (extrait des catégories) -- 🎂 **Âge** - Âge le plus récent (post-ellipse), chiffres uniquement -- 📏 **Taille** - Normalisée en mètres (format: "2.74" ou "174" pour cm) -- 🌍 **Origine** - Lieu d'origine (sans parenthèses) -- 😈 **Fruit du Démon** - Nom du fruit (si applicable) -- 👥 **Affiliations** - Liste des affiliations (équipages, organisations) -- 💰 **Prime** - Bounty la plus récente -- ⚡ **Haki** - Liste des types de Haki (Observation, Armament, Conqueror) -- 📖 **Première Apparition** - Numéro de chapitre -- 🖼️ **Image** - URL de l'image portrait nettoyée -- 🔗 **Fandom URL** - Lien vers la page wiki - -## Personnages scrapés - -Le script scrape tous les personnages canon de la liste officielle du Fandom wiki français. - -**Personnages actuellement filtrés** : Luffy et Moria (modifiable dans `fetchAllCharactersUrl`) - -Pour scraper tous les personnages, retire le filtre dans la fonction `fetchAllCharactersUrl`. - -## Fonctionnalités avancées - -### Requêtes parallèles -- Le scraper traite 5 personnages en parallèle pour plus d'efficacité -- Concurrency configurable dans le code - -### Nettoyage des données -- Suppression automatique des citations/références (`` tags) -- Âge : extraction du dernier âge (après ellipse), sans parenthèses -- Taille : normalisation m/cm et suppression des parenthèses -- Origine : suppression du contenu entre parenthèses -- Image : sélection automatique du portrait -- Première apparition : extraction du numéro de chapitre uniquement - -### SQL Upsert -- Le SQL généré utilise `INSERT ... ON CONFLICT(name) DO UPDATE` -- Met à jour les personnages existants au lieu de créer des doublons -- Compatible SQLite (utilisé par le projet) - -### Formats de données -- **Haki** : Stocké comme array JSON dans SQL : `["Observation","Armament"]` -- **Affiliations** : Liste dans JSON, comma-separated dans CSV/SQL -- Tous les champs nullable supportent `NULL` dans SQL - -## Configuration - -### Modifier les personnages filtrés - -Dans `scripts/scrape-onepiece.js`, fonction `fetchAllCharactersUrl` : - -```javascript -// Filtrer pour des personnages spécifiques -if (nameLower.includes('luffy') || nameLower.includes('moria')) { - characters.push({ name: charName, url: charLink }); -} -``` - -### Ajuster la concurrence - -Dans la fonction `main` : - -```javascript -const concurrency = 5; // Nombre de requêtes simultanées -``` - -## Importer les données SQL - -Après avoir généré le fichier SQL, importe-le dans la base de données : - -```bash -npm run db:import -``` - -Ce script : -- Lit automatiquement `scraped-data/characters.sql` -- Exécute chaque statement individuellement -- Affiche une barre de progression -- Gère les erreurs sans bloquer l'import complet -- Utilise le upsert pour éviter les doublons - -**Note** : Assure-toi d'avoir exécuté les migrations avant l'import : -```bash -npm run db:migrate -``` - -## Notes techniques - -- Source : `https://onepiece.fandom.com/fr/wiki` -- Parseur : Cheerio (DOM parsing) -- Traitement parallèle avec `Promise.all` -- User-Agent configuré pour éviter les blocages -- Pas de délai entre requêtes (batches parallèles) -- Gestion d'erreurs par personnage (ne bloque pas le scraping complet) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..c340507 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +# Migrate the database +npm run db:migrate + +# Start the production server +exec npm run preview --host 0.0.0.0 + diff --git a/drizzle/0000_dapper_sage.sql b/drizzle/0000_graceful_master_mold.sql similarity index 55% rename from drizzle/0000_dapper_sage.sql rename to drizzle/0000_graceful_master_mold.sql index 92b1a68..295c1f6 100644 --- a/drizzle/0000_dapper_sage.sql +++ b/drizzle/0000_graceful_master_mold.sql @@ -1,32 +1,103 @@ +CREATE TABLE `arc` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `startChapter` integer NOT NULL, + `endChapter` integer, + `url` text +); +--> statement-breakpoint CREATE TABLE `character` ( `id` text PRIMARY KEY NOT NULL, `name` text NOT NULL, `gender` text, `age` integer, `affiliations` text, - `devilFruit` text, - `haki` text, - `bounty` integer, + `devilFruitId` text, + `hakiObservation` integer DEFAULT false, + `hakiArmament` integer DEFAULT false, + `hakiConqueror` integer DEFAULT false, + `bounty` integer DEFAULT 0, `height` real, `origin` text, - `firstAppearance` text, + `firstAppearance` integer NOT NULL, `pictureUrl` text, - FOREIGN KEY (`devilFruit`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action + `epithets` text, + `status` text, + `arcId` text, + `url` text, + `isInDailyMode` integer DEFAULT true, + FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action ); --> statement-breakpoint CREATE TABLE `characterHistory` ( `id` text PRIMARY KEY NOT NULL, `characterId` text, - `date` integer, + `date` text, + `won` integer DEFAULT 0 NOT NULL, `createdAt` integer NOT NULL, `updatedAt` integer NOT NULL, FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action ); --> statement-breakpoint +CREATE TABLE `characterOverride` ( + `characterId` text PRIMARY KEY NOT NULL, + `name` text, + `gender` text, + `age` integer, + `affiliations` text, + `devilFruitId` text, + `hakiObservation` integer, + `hakiArmament` integer, + `hakiConqueror` integer, + `bounty` integer, + `height` real, + `origin` text, + `firstAppearance` integer NOT NULL, + `pictureUrl` text, + `epithets` text, + `status` text, + `arcId` text, + `url` text, + `notes` text, + FOREIGN KEY (`characterId`) REFERENCES `character`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `characterScrapeValidation` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `gender` text, + `age` integer, + `affiliations` text, + `devilFruitId` text, + `hakiObservation` integer DEFAULT false, + `hakiArmament` integer DEFAULT false, + `hakiConqueror` integer DEFAULT false, + `bounty` integer, + `height` real, + `origin` text, + `firstAppearance` integer NOT NULL, + `pictureUrl` text, + `epithets` text, + `status` text, + `arcId` text, + `url` text, + FOREIGN KEY (`devilFruitId`) REFERENCES `devilFruit`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`arcId`) REFERENCES `arc`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `config` ( + `key` text PRIMARY KEY NOT NULL, + `value` text +); +--> statement-breakpoint CREATE TABLE `devilFruit` ( `id` text PRIMARY KEY NOT NULL, `name` text NOT NULL, - `type` text + `type` text, + `url` text ); --> statement-breakpoint CREATE UNIQUE INDEX `devilFruit_name_unique` ON `devilFruit` (`name`);--> statement-breakpoint diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index aabacff..42e0a1b 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,9 +1,54 @@ { "version": "6", "dialect": "sqlite", - "id": "40edd98b-5a47-4a5e-a5b8-8a0f6eaaec76", + "id": "d1237d76-8f1c-4721-b8dd-d31082ed7b9a", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { + "arc": { + "name": "arc", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startChapter": { + "name": "startChapter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endChapter": { + "name": "endChapter", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "character": { "name": "character", "columns": { @@ -42,26 +87,44 @@ "notNull": false, "autoincrement": false }, - "devilFruit": { - "name": "devilFruit", + "devilFruitId": { + "name": "devilFruitId", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "haki": { - "name": "haki", - "type": "text", + "hakiObservation": { + "name": "hakiObservation", + "type": "integer", "primaryKey": false, "notNull": false, - "autoincrement": false + "autoincrement": false, + "default": false + }, + "hakiArmament": { + "name": "hakiArmament", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "hakiConqueror": { + "name": "hakiConqueror", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false }, "bounty": { "name": "bounty", "type": "integer", "primaryKey": false, "notNull": false, - "autoincrement": false + "autoincrement": false, + "default": 0 }, "height": { "name": "height", @@ -79,9 +142,9 @@ }, "firstAppearance": { "name": "firstAppearance", - "type": "text", + "type": "integer", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, "pictureUrl": { @@ -90,16 +153,65 @@ "primaryKey": false, "notNull": false, "autoincrement": false + }, + "epithets": { + "name": "epithets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arcId": { + "name": "arcId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isInDailyMode": { + "name": "isInDailyMode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true } }, "indexes": {}, "foreignKeys": { - "character_devilFruit_devilFruit_id_fk": { - "name": "character_devilFruit_devilFruit_id_fk", + "character_devilFruitId_devilFruit_id_fk": { + "name": "character_devilFruitId_devilFruit_id_fk", "tableFrom": "character", "tableTo": "devilFruit", "columnsFrom": [ - "devilFruit" + "devilFruitId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "character_arcId_arc_id_fk": { + "name": "character_arcId_arc_id_fk", + "tableFrom": "character", + "tableTo": "arc", + "columnsFrom": [ + "arcId" ], "columnsTo": [ "id" @@ -131,11 +243,19 @@ }, "date": { "name": "date", - "type": "integer", + "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, + "won": { + "name": "won", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, "createdAt": { "name": "createdAt", "type": "integer", @@ -171,6 +291,379 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "characterOverride": { + "name": "characterOverride", + "columns": { + "characterId": { + "name": "characterId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affiliations": { + "name": "affiliations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "devilFruitId": { + "name": "devilFruitId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiObservation": { + "name": "hakiObservation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiArmament": { + "name": "hakiArmament", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiConqueror": { + "name": "hakiConqueror", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bounty": { + "name": "bounty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstAppearance": { + "name": "firstAppearance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pictureUrl": { + "name": "pictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "epithets": { + "name": "epithets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arcId": { + "name": "arcId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "characterOverride_characterId_character_id_fk": { + "name": "characterOverride_characterId_character_id_fk", + "tableFrom": "characterOverride", + "tableTo": "character", + "columnsFrom": [ + "characterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "characterOverride_devilFruitId_devilFruit_id_fk": { + "name": "characterOverride_devilFruitId_devilFruit_id_fk", + "tableFrom": "characterOverride", + "tableTo": "devilFruit", + "columnsFrom": [ + "devilFruitId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "characterOverride_arcId_arc_id_fk": { + "name": "characterOverride_arcId_arc_id_fk", + "tableFrom": "characterOverride", + "tableTo": "arc", + "columnsFrom": [ + "arcId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "characterScrapeValidation": { + "name": "characterScrapeValidation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affiliations": { + "name": "affiliations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "devilFruitId": { + "name": "devilFruitId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hakiObservation": { + "name": "hakiObservation", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "hakiArmament": { + "name": "hakiArmament", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "hakiConqueror": { + "name": "hakiConqueror", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "bounty": { + "name": "bounty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firstAppearance": { + "name": "firstAppearance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pictureUrl": { + "name": "pictureUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "epithets": { + "name": "epithets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arcId": { + "name": "arcId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "characterScrapeValidation_devilFruitId_devilFruit_id_fk": { + "name": "characterScrapeValidation_devilFruitId_devilFruit_id_fk", + "tableFrom": "characterScrapeValidation", + "tableTo": "devilFruit", + "columnsFrom": [ + "devilFruitId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "characterScrapeValidation_arcId_arc_id_fk": { + "name": "characterScrapeValidation_arcId_arc_id_fk", + "tableFrom": "characterScrapeValidation", + "tableTo": "arc", + "columnsFrom": [ + "arcId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "devilFruit": { "name": "devilFruit", "columns": { @@ -194,6 +687,13 @@ "primaryKey": false, "notNull": false, "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false } }, "indexes": { diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f37eb67..833bfb5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1772148571269, - "tag": "0000_dapper_sage", + "when": 1772325597983, + "tag": "0000_graceful_master_mold", "breakpoints": true } ] diff --git a/package.json b/package.json index 6d487b0..19af576 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ "format": "prettier --write .", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", + "db:migrate": "drizzle-kit migrate && npx tsx scripts/init-column-config.ts", "db:studio": "drizzle-kit studio", - "db:import": "node scripts/import-sql.js", + "db:import": "npx tsx scripts/import-json.ts", + "db:set-daily-mode": "npx tsx scripts/set-daily-mode.ts", "auth:schema": "npx @better-auth/cli generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes", "scrape": "node scripts/scrape-onepiece.js" }, diff --git a/scripts/daily-characters.json b/scripts/daily-characters.json new file mode 100644 index 0000000..3cc7a5b --- /dev/null +++ b/scripts/daily-characters.json @@ -0,0 +1,145 @@ +[ + "aladdin_aladdin", + "alvida_alvida", + "aramaki_aramaki", + "arlong_arlong", + "ashura_doji_ashura_doji", + "baby_5_baby_5", + "baggy_baggy", + "bartholomew_kuma_bartholomew_kuma", + "bartolomeo_bartolomeo", + "basil_hawkins_basil_hawkins", + "batman_batman", + "bellamy_bellamy", + "belo_betty_belo_betty", + "ben_beckman_ben_beckman", + "bentham_bentham", + "bepo_bepo", + "black_maria_black_maria", + "boa_hancock_boa_hancock", + "boa_marigold_boa_marigold", + "boa_sandersonia_boa_sandersonia", + "borsalino_borsalino", + "brogy_brogy", + "brook_brook", + "camie_camie", + "capone_bege_capone_bege", + "caribou_caribou", + "carrot_carrot", + "catarina_devon_catarina_devon", + "cavendish_cavendish", + "cesar_clown_cesar_clown", + "chinjao_chinjao", + "coby_coby", + "corazon_corazon", + "crocodile_crocodile", + "crocus_crocus", + "curly_dadan_curly_dadan", + "dalton_dalton", + "daz_bones_daz_bones", + "denjiro_denjiro", + "diamante_diamante", + "doc_q_doc_q", + "don_quichotte_doflamingo_don_quichotte_doflamingo", + "don_quichotte_rossinante_don_quichotte_rossinante", + "dorry_dorry", + "dracule_mihawk_dracule_mihawk", + "duval_duval", + "edward_newgate_edward_newgate", + "edward_weevil_edward_weevil", + "emporio_ivankov_emporio_ivankov", + "enel_enel", + "eustass_kid_eustass_kid", + "fisher_tiger_fisher_tiger", + "foxy_foxy", + "franky_franky", + "fujitora_fujitora", + "gan_forr_gan_forr", + "gecko_moria_gecko_moria", + "gin_gin", + "gol_d_roger_gol_d_roger", + "haguar_d_sauro_haguar_d_sauro", + "hajrudin_hajrudin", + "hannyabal_hannyabal", + "hatchan_hatchan", + "hina_hina", + "hody_jones_hody_jones", + "hyogoro_hyogoro", + "iceburg_iceburg", + "imu_imu", + "inazuma_inazuma", + "inuarashi_inuarashi", + "issho_issho", + "izo_izo", + "jabra_jabra", + "jack_jack", + "jesus_burgess_jesus_burgess", + "jewelry_bonney_jewelry_bonney", + "jinbei_jinbei", + "joy_boy_joy_boy", + "kaidou_kaidou", + "kaku_kaku", + "kalgara_kalgara", + "kalifa_kalifa", + "karasu_karasu", + "karoo_karoo", + "kawamatsu_kawamatsu", + "kaya_kaya", + "killer_killer", + "kinemon_kinemon", + "koala_koala", + "koby_koby", + "kong_kong", + "kozuki_hiyori_kozuki_hiyori", + "kozuki_momonosuke_kozuki_momonosuke", + "kozuki_oden_kozuki_oden", + "krieg_krieg", + "kureha_kureha", + "kuro_kuro", + "kurozumi_orochi_kurozumi_orochi", + "kuzan_kuzan", + "kyros_kyros", + "laboon_laboon", + "laffitte_laffitte", + "lao_g_lao_g", + "leo_leo", + "lindbergh_lindbergh", + "loki_loki", + "lucky_roux_lucky_roux", + "magellan_magellan", + "makino_makino", + "marco_marco", + "marshall_d_teach_marshall_d_teach", + "monkey_d_dragon_monkey_d_dragon", + "monkey_d_garp_monkey_d_garp", + "monkey_d_luffy_monkey_d_luffy", + "montblanc_norland_montblanc_norland", + "morgans_morgans", + "morley_morley", + "mr_3_mr_3", + "nami_nami", + "nefertari_cobra_nefertari_cobra", + "nefertari_vivi_nefertari_vivi", + "nekomamushi_nekomamushi", + "neptune_neptune", + "nico_robin_nico_robin", + "oars_oars", + "orlumbus_orlumbus", + "otohime_otohime", + "page_one_page_one", + "pandaman_pandaman", + "pekoms_pekoms", + "pell_pell", + "perona_perona", + "pica_pica", + "portgas_d_ace_portgas_d_ace", + "queen_queen", + "raizo_raizo", + "rebecca_rebecca", + "rob_lucci_rob_lucci", + "rocks_d_xebec_rocks_d_xebec", + "roronoa_zoro_roronoa_zoro", + "sabo_sabo", + "vegapunk_vegapunk", + "yamato_yamato" +] \ No newline at end of file diff --git a/scripts/import-json.ts b/scripts/import-json.ts new file mode 100644 index 0000000..ac7421b --- /dev/null +++ b/scripts/import-json.ts @@ -0,0 +1,406 @@ +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { sql, eq } from 'drizzle-orm'; +import fs from 'fs'; +import { arc, character, devilFruit, characterScrapeValidation, type DevilFruitType } from '../src/lib/server/db/schema'; + +type ArcRecord = { + id: string; + name: string; + startChapter: number; + endChapter?: number | null; + url?: string | null; +}; + +type DevilFruitRecord = { + id: string; + name: string; + type?: DevilFruitType | string | null; + url?: string | null; +}; + +type CharacterRecord = { + id: string; + name: string; + gender?: string | null; + age?: number | null; + affiliations?: string[] | string | null; + devilFruitId?: string | null; + hakiObservation?: boolean; + hakiArmament?: boolean; + hakiConqueror?: boolean; + bounty?: number | null; + height?: number | null; + origin?: string | null; + firstAppearance?: number; + pictureUrl?: string | null; + epithets?: string[] | string | null; + status?: string | null; + arcId?: string | null; + url?: string | null; +}; + +const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db'; + +const client = createClient({ url: DATABASE_URL }); +const db = drizzle(client); + +function readJsonFile(path: string): T[] | null { + if (!fs.existsSync(path)) { + return null; + } + + const content = fs.readFileSync(path, 'utf-8'); + return JSON.parse(content) as T[]; +} + +function toNullable(value: T | undefined | null | ''): T | null { + return value === undefined || value === null || value === '' ? null : value; +} + +function toJsonArray(value: string[] | string | null | undefined): string[] | null { + if (Array.isArray(value)) { + return value.length > 0 ? value : null; + } + + if (typeof value === 'string' && value.trim() !== '') { + if (value.trim().startsWith('[')) { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : [value]; + } catch { + return [value]; + } + } + + const splitValues = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + return splitValues.length > 0 ? splitValues : null; + } + + return null; +} + +function toDevilFruitType(value: DevilFruitType | string | null | undefined): DevilFruitType | null { + if (!value) return null; + if (value === 'Paramecia' || value === 'Zoan' || value === 'Logia' || value === 'Unknown') { + return value; + } + return 'Unknown'; +} + +function toNumber(value: string | number | null | undefined): number | null { + if (value === null || value === undefined || value === '') return null; + const num = typeof value === 'string' ? parseFloat(value) : value; + return isNaN(num) ? null : num; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function logSqlOnError(statement: { sql: string; params: unknown[] } | null): void { + if (!statement) return; + console.error(` SQL: ${statement.sql}`); + console.error(` Params: ${JSON.stringify(statement.params)}`); +} + +function transformCharacterData(item: CharacterRecord) { + return { + id: item.id, + name: item.name, + gender: toNullable(item.gender), + age: toNullable(item.age), + affiliations: toJsonArray(item.affiliations), + devilFruitId: toNullable(item.devilFruitId), + hakiObservation: !!item.hakiObservation, + hakiArmament: !!item.hakiArmament, + hakiConqueror: !!item.hakiConqueror, + bounty: item.bounty ?? 0, + height: toNumber(item.height as any), + origin: toNullable(item.origin), + firstAppearance: item.firstAppearance ?? 0, + pictureUrl: toNullable(item.pictureUrl), + epithets: toJsonArray(item.epithets), + status: toNullable(item.status), + arcId: toNullable(item.arcId), + url: toNullable(item.url) + }; +} + +function hasChanged(jsonData: any, dbData: any): boolean { + if (!dbData) return true; + + // Print any differences for debugging + for (const key in jsonData) { + const jsonValue = jsonData[key]; + const dbValue = dbData[key]; + const jsonString = typeof jsonValue === 'object' ? JSON.stringify(jsonValue) : String(jsonValue); + const dbString = typeof dbValue === 'object' ? JSON.stringify(dbValue) : String(dbValue); + if (jsonString !== dbString) { + console.log(`\nField "${key}" changed for character ID ${jsonData.id}:`); + console.log(` JSON: ${jsonString}`); + console.log(` DB: ${dbString}`); + } } + + // Compare each field + return ( + jsonData.name != dbData.name || + jsonData.gender != dbData.gender || + jsonData.age != dbData.age || + JSON.stringify(jsonData.affiliations) != JSON.stringify(dbData.affiliations) || + jsonData.devilFruitId != dbData.devilFruitId || + jsonData.hakiObservation != dbData.hakiObservation || + jsonData.hakiArmament != dbData.hakiArmament || + jsonData.hakiConqueror != dbData.hakiConqueror || + jsonData.bounty != dbData.bounty || + jsonData.height != dbData.height || + jsonData.origin != dbData.origin || + jsonData.firstAppearance != dbData.firstAppearance || + jsonData.pictureUrl != dbData.pictureUrl || + JSON.stringify(jsonData.epithets) != JSON.stringify(dbData.epithets) || + jsonData.status != dbData.status || + jsonData.arcId != dbData.arcId || + jsonData.url != dbData.url + ); +} + +async function isCharacterTableEmpty(): Promise { + const result = await db.select({ count: sql`COUNT(*)` }).from(character); + return result[0]?.count === 0; +} + +async function importFromJson(): Promise { + let totalSuccess = 0; + let totalErrors = 0; + + try { + const arcs = readJsonFile('./scraped-data/arcs.json'); + if (arcs) { + console.log('\n=== Importing Arcs ===\n'); + console.log(`Found ${arcs.length} arcs\n`); + + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < arcs.length; i++) { + const item = arcs[i]; + let lastSql: { sql: string; params: unknown[] } | null = null; + try { + const query = db + .insert(arc) + .values({ + id: item.id, + name: item.name, + startChapter: item.startChapter, + endChapter: toNullable(item.endChapter), + url: toNullable(item.url) + }) + .onConflictDoUpdate({ + target: arc.id, + set: { + name: item.name, + startChapter: item.startChapter, + endChapter: toNullable(item.endChapter), + url: toNullable(item.url) + } + }); + + lastSql = query.toSQL(); + await query; + + successCount++; + process.stdout.write(`\rExecuted: ${successCount}/${arcs.length}`); + } catch (error) { + errorCount++; + console.error(`\n✗ Error at arc ${i + 1}:`); + console.error(` ID: ${item.id ?? 'N/A'}`); + console.error(` Message: ${getErrorMessage(error)}`); + logSqlOnError(lastSql); + } + } + + console.log(`\n\n✓ Arcs imported!`); + console.log(` Success: ${successCount}`); + console.log(` Errors: ${errorCount}`); + + totalSuccess += successCount; + totalErrors += errorCount; + } else { + console.log('\n⚠️ No arcs.json found, skipping...\n'); + } + + const fruits = readJsonFile('./scraped-data/devil-fruits.json'); + if (fruits) { + console.log('\n=== Importing Devil Fruits ===\n'); + console.log(`Found ${fruits.length} devil fruits\n`); + + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < fruits.length; i++) { + const item = fruits[i]; + let lastSql: { sql: string; params: unknown[] } | null = null; + try { + const query = db + .insert(devilFruit) + .values({ + id: item.id, + name: item.name, + type: toDevilFruitType(item.type), + url: toNullable(item.url) + }) + .onConflictDoUpdate({ + target: devilFruit.id, + set: { + name: item.name, + type: toDevilFruitType(item.type), + url: toNullable(item.url) + } + }); + + lastSql = query.toSQL(); + await query; + + successCount++; + process.stdout.write(`\rExecuted: ${successCount}/${fruits.length}`); + } catch (error) { + errorCount++; + console.error(`\n✗ Error at devil fruit ${i + 1}:`); + console.error(` ID: ${item.id ?? 'N/A'}`); + console.error(` Message: ${getErrorMessage(error)}`); + logSqlOnError(lastSql); + } + } + + console.log(`\n\n✓ Devil Fruits imported!`); + console.log(` Success: ${successCount}`); + console.log(` Errors: ${errorCount}`); + + totalSuccess += successCount; + totalErrors += errorCount; + } else { + console.log('\n⚠️ No devil-fruits.json found, skipping...\n'); + } + + const characters = readJsonFile('./scraped-data/characters.json'); + if (characters) { + console.log('\n=== Importing Characters ===\n'); + console.log(`Found ${characters.length} characters\n`); + + const isEmpty = await isCharacterTableEmpty(); + + let successCount = 0; + let errorCount = 0; + + if (isEmpty) { + // Populate empty character table + console.log('Characters table is empty, populating...\n'); + + for (let i = 0; i < characters.length; i++) { + const item = characters[i]; + let lastSql: { sql: string; params: unknown[] } | null = null; + try { + const data = transformCharacterData(item); + const query = db + .insert(character) + .values(data) + .onConflictDoUpdate({ + target: character.id, + set: data + }); + + lastSql = query.toSQL(); + await query; + + successCount++; + process.stdout.write(`\rExecuted: ${successCount}/${characters.length}`); + } catch (error) { + errorCount++; + console.error(`\n✗ Error at character ${i + 1}:`); + console.error(` ID: ${item.id ?? 'N/A'}`); + console.error(` Message: ${getErrorMessage(error)}`); + logSqlOnError(lastSql); + } + } + } else { + // Check for changes and update scrapeValidation table + console.log('Characters table not empty, checking for changes...\n'); + + for (let i = 0; i < characters.length; i++) { + const item = characters[i]; + let lastSql: { sql: string; params: unknown[] } | null = null; + try { + const selectQuery = db + .select() + .from(character) + .where(eq(character.id, item.id)); + + lastSql = selectQuery.toSQL(); + const [dbCharacter] = await selectQuery; + + const jsonData = transformCharacterData(item); + const changed = hasChanged(jsonData, dbCharacter); + + if (changed) { + // Update scrapeValidation table with changes + const upsertQuery = db + .insert(characterScrapeValidation) + .values(jsonData) + .onConflictDoUpdate({ + target: characterScrapeValidation.id, + set: jsonData + }); + + lastSql = upsertQuery.toSQL(); + await upsertQuery; + } else { + // No changes, delete from scrapeValidation if it exists + const deleteQuery = db + .delete(characterScrapeValidation) + .where(eq(characterScrapeValidation.id, item.id)); + + lastSql = deleteQuery.toSQL(); + await deleteQuery; + } + + successCount++; + process.stdout.write(`\rProcessed: ${successCount}/${characters.length}`); + } catch (error) { + errorCount++; + console.error(`\n✗ Error at character ${i + 1}:`); + console.error(` ID: ${item.id ?? 'N/A'}`); + console.error(` Message: ${getErrorMessage(error)}`); + logSqlOnError(lastSql); + } + } + } + + console.log(`\n\n✓ Characters imported!`); + console.log(` Success: ${successCount}`); + console.log(` Errors: ${errorCount}`); + + totalSuccess += successCount; + totalErrors += errorCount; + } else { + console.log('\n⚠️ No characters.json found, skipping...\n'); + } + + console.log(`\n=== Total Import Summary ===`); + console.log(` Total Success: ${totalSuccess}`); + console.log(` Total Errors: ${totalErrors}\n`); + } catch (error) { + console.error('✗ Import failed:', getErrorMessage(error)); + process.exit(1); + } finally { + client.close(); + } +} + +importFromJson().catch((error) => { + console.error(getErrorMessage(error)); + process.exit(1); +}); diff --git a/scripts/import-sql.js b/scripts/import-sql.js deleted file mode 100644 index de5cb0e..0000000 --- a/scripts/import-sql.js +++ /dev/null @@ -1,104 +0,0 @@ -import { createClient } from '@libsql/client'; -import fs from 'fs'; - -// Load environment variables -const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db'; - -const client = createClient({ - url: DATABASE_URL -}); - -async function importSQL() { - try { - let totalSuccess = 0; - let totalErrors = 0; - - // Step 1: Import Devil Fruits - if (fs.existsSync('./scraped-data/devil-fruits.sql')) { - console.log('\n=== Importing Devil Fruits ===\n'); - const devilFruitsSql = fs.readFileSync('./scraped-data/devil-fruits.sql', 'utf-8'); - const dfStatements = devilFruitsSql.split(';\n\n').filter(s => s.trim()); - - console.log(`Found ${dfStatements.length} devil fruit statements\n`); - - let successCount = 0; - let errorCount = 0; - - for (let i = 0; i < dfStatements.length; i++) { - const statement = dfStatements[i]; - if (statement.trim()) { - try { - await client.execute(statement.trim() + ';'); - successCount++; - process.stdout.write(`\rExecuted: ${successCount}/${dfStatements.length}`); - } catch (error) { - errorCount++; - const valuesMatch = statement.match(/VALUES\s*\(([^)]+)\)/); - const values = valuesMatch ? valuesMatch[1] : 'N/A'; - console.error(`\n✗ Error at statement ${i + 1}:`); - console.error(` Values: ${values}`); - console.error(` Message: ${error.message}`); - } - } - } - - console.log(`\n\n✓ Devil Fruits imported!`); - console.log(` Success: ${successCount}`); - console.log(` Errors: ${errorCount}`); - - totalSuccess += successCount; - totalErrors += errorCount; - } else { - console.log('\n⚠️ No devil-fruits.sql found, skipping...\n'); - } - - // Step 2: Import Characters - if (fs.existsSync('./scraped-data/characters.sql')) { - console.log('\n=== Importing Characters ===\n'); - const charactersSql = fs.readFileSync('./scraped-data/characters.sql', 'utf-8'); - const charStatements = charactersSql.split(';\n\n').filter(s => s.trim()); - - console.log(`Found ${charStatements.length} character statements\n`); - - let successCount = 0; - let errorCount = 0; - - for (let i = 0; i < charStatements.length; i++) { - const statement = charStatements[i]; - if (statement.trim()) { - try { - await client.execute(statement.trim() + ';'); - successCount++; - process.stdout.write(`\rExecuted: ${successCount}/${charStatements.length}`); - } catch (error) { - errorCount++; - const valuesMatch = statement.match(/VALUES\s*\(([^)]+)\)/); - const values = valuesMatch ? valuesMatch[1] : 'N/A'; - console.error(`\n✗ Error at statement ${i + 1}:`); - console.error(` Values: ${values}`); - console.error(` Message: ${error.message}`); - } - } - } - - console.log(`\n\n✓ Characters imported!`); - console.log(` Success: ${successCount}`); - console.log(` Errors: ${errorCount}`); - - totalSuccess += successCount; - totalErrors += errorCount; - } else { - console.log('\n⚠️ No characters.sql found, skipping...\n'); - } - - console.log(`\n=== Total Import Summary ===`); - console.log(` Total Success: ${totalSuccess}`); - console.log(` Total Errors: ${totalErrors}\n`); - - } catch (error) { - console.error('✗ Import failed:', error.message); - process.exit(1); - } -} - -importSQL().catch(console.error); diff --git a/scripts/init-column-config.ts b/scripts/init-column-config.ts new file mode 100644 index 0000000..59702a4 --- /dev/null +++ b/scripts/init-column-config.ts @@ -0,0 +1,64 @@ +/** + * Initialize config table with column visibility settings for characterHistory table + */ + +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { config } from '../src/lib/server/db/schema'; +import { like } from 'drizzle-orm'; + +// Load environment variables +const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db'; + +const client = createClient({ url: DATABASE_URL }); +const db = drizzle(client); + +// Define the columns in characterHistory table +const columns = [ + 'gender', + 'affiliations', + 'haki', + 'bounty', + 'height', + 'origin', + 'devilFruitType', + 'arc', + 'status' +] as const; + +async function initColumnConfig(): Promise { + try { + console.log('Initializing column visibility config...\n'); + + for (const column of columns) { + const key = `characterHistory.column.${column}.visible`; + const value = 'true'; + + await db.insert(config) + .values({ key, value }) + .onConflictDoNothing(); + + console.log(`✓ Added config key: ${key} = ${value}`); + } + + console.log('\n✓ Successfully initialized column visibility config'); + + // Display all config entries + const allConfig = await db.select() + .from(config) + .where(like(config.key, 'characterHistory.column.%.visible')); + + console.log('\nCurrent column visibility config:'); + allConfig.forEach(row => { + console.log(` ${row.key}: ${row.value}`); + }); + + } catch (error) { + console.error('Error initializing config:', error); + process.exit(1); + } finally { + client.close(); + } +} + +initColumnConfig(); diff --git a/scripts/scrape-onepiece.js b/scripts/scrape-onepiece.js index 8127d10..40473c2 100644 --- a/scripts/scrape-onepiece.js +++ b/scripts/scrape-onepiece.js @@ -1,11 +1,41 @@ import * as cheerio from 'cheerio'; import fs from 'fs'; +import https from 'https'; import { createObjectCsvWriter } from 'csv-writer'; const FANDOM_BASE_URL = 'https://onepiece.fandom.com/fr/wiki'; const OUTPUT_DIR = './scraped-data'; -const DEVIL_FRUIT_CONCURRENCY = 5; -const CHARACTER_CONCURRENCY = 10; +const MAX_RETRIES = 0; // Set to 0 to disable retries, can be increased if needed +const INITIAL_RETRY_DELAY = 1000; + +// Keep same HTTP session like a normal browser - maintain connection pool and allow cookie persistence +const httpsAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxFreeSockets: 10, + maxSockets: 50, + maxConnections: 50, + timeout: 30000 +}); + +// Store cookies across requests (simulate browser behavior) +const cookies = new Map(); + +function getCookieHeader() { + const cookieArray = Array.from(cookies.values()).map(c => c.split(';')[0]); + return cookieArray.length > 0 ? cookieArray.join('; ') : ''; +} + +function saveCookies(setCookieHeader) { + if (setCookieHeader) { + const cookiesList = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; + cookiesList.forEach(cookie => { + const [nameValue] = cookie.split(';'); + const [name] = nameValue.split('='); + if (name) cookies.set(name, cookie); + }); + } +} // Create output directory if (!fs.existsSync(OUTPUT_DIR)) { @@ -13,155 +43,168 @@ if (!fs.existsSync(OUTPUT_DIR)) { } /** - * Normalize string by removing accents and converting to lowercase + * Retry a fetch request with exponential backoff + */ +async function fetchWithRetry(url, options = {}, retries = 0) { + try { + const headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Firefox/150.0', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + ...options.headers + }; + + // Add cookies from previous requests + const cookieHeader = getCookieHeader(); + if (cookieHeader) { + headers['Cookie'] = cookieHeader; + } + + const response = await fetch(url, { + headers, + agent: httpsAgent, + ...options + }); + + // Save cookies from response + const setCookie = response.headers.get('set-cookie'); + if (setCookie) { + saveCookies(setCookie); + } + + // Check if response is OK (status 200-299) + if (response.ok) { + return response; + } + + // If not OK and we have retries left, retry + if (retries < MAX_RETRIES) { + const delay = INITIAL_RETRY_DELAY * Math.pow(2, retries); + console.log(`⚠️ HTTP ${response.status} for ${url}, retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + return fetchWithRetry(url, options, retries + 1); + } + + // If we've exhausted retries, throw error + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } catch (error) { + // If it's a network error and we have retries left, retry + if (retries < MAX_RETRIES) { + const delay = INITIAL_RETRY_DELAY * Math.pow(2, retries); + console.log(`⚠️ Network error: ${error.message}, retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + return fetchWithRetry(url, options, retries + 1); + } + + // If we've exhausted retries, throw error + throw error; + } +} + + +/** + * Normalize string by decoding URI components, punctuation, and replacing spaces with underscores */ function normalizeId(str) { return decodeURIComponent(str) .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[,:]/g, '') + .replace(/[,:.\(\)]/g, '') + .replace(/\s+/g, '_') .toLowerCase(); } /** - * Fetch all devil fruits URLs from One Piece fandom + * Fetch all arcs from One Piece fandom */ -async function fetchAllDevilFruitsUrl() { +async function fetchAllArcs() { try { - const url = `${FANDOM_BASE_URL}/Fruits_du_Démon`; - console.log('Fetching devil fruits list...'); - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 7.01; Windows NT 5.0)', - }, - }); + const url = `${FANDOM_BASE_URL}/Chapitres_et_Tomes`; + console.log('Fetching arcs list...'); + const response = await fetchWithRetry(url); const data = await response.text(); const $ = cheerio.load(data); - const devilFruits = []; + const arcs = []; - // Find the main navibox table - $('table.navibox.toccolours').each((mainTableIndex, mainTable) => { - const mainHeader = $(mainTable).find('th[colspan="3"]').first().find('span').last().text().trim(); - if (mainHeader !== 'Fruits du Démon') return; - - $(mainTable).find('table.collapsible').each((typeTableIndex, typeTable) => { - const typeHeader = $(typeTable).find('th[colspan="3"]').first().text().trim(); - let type = null; - - if (typeHeader.includes('Paramecia')) type = 'Paramecia'; - else if (typeHeader.includes('Zoan')) type = 'Zoan'; - else if (typeHeader.includes('Logia')) type = 'Logia'; - else if (typeHeader.includes('Type Inconnu')) type = 'Unknown'; - - if (!type) return; - - $(typeTable).find('tr.navibox-row').each((rowIndex, row) => { - const categoryHeader = $(row).find('th').text().trim(); + // Find all arc links in the table + $('table.wikitable td a').each((index, element) => { + const text = $(element).text().trim(); + const href = $(element).attr('href'); + + // Check if it's an arc link (contains "Arc" and chapter info) + if (text.includes('Arc') && text.includes('Ch.')) { + // Extract arc name and chapter range + // Example text: "Arc Ville d'Orange(Ch.8 à 21)[T.1 à 3]" + console.log(`Processing arc link: ${text} (${href})`); + const nameMatch = text.match(/^(.*?Arc.*?)\s*\(Ch\.(\d+)(?:\s*à\s*(?:(\d+)|(?:...)))?\)/); + if (nameMatch) { + let arcName = nameMatch[1].trim(); + // Remove "Arc " from the name + arcName = arcName.replace(/^Arc\s+/i, ''); - if (!categoryHeader.includes('Canon') && - !categoryHeader.includes('Standards') && - !categoryHeader.includes('Antiques') && - !categoryHeader.includes('Mythiques') && - !categoryHeader.includes('Hors-Série')) { - return; - } + const startChapter = parseInt(nameMatch[2]); + const endChapter = nameMatch[3] ? parseInt(nameMatch[3]) : null; - // Find all links in the row - $(row).find('td .hlist ul li a').each((linkIndex, link) => { - const name = $(link).text().trim(); - const href = $(link).attr('href'); - - if (name && href && href.startsWith('/fr/wiki/')) { - // Clean the URL - const cleanUrl = href.replace('/fr/wiki/', ''); - - // Skip classification pages and category pages - if (cleanUrl.includes('Classification') || - cleanUrl.includes('Catégorie:') || - cleanUrl === 'Fruits_du_Démon_Artificiels' || - cleanUrl === 'SMILE') { - return; - } + // Generate arc ID by normalizing the url + let arcId = normalizeId(href.replace('/fr/wiki/', '')); + // Remove "Arc_" from the id + arcId = arcId.replace(/^arc_/i, ''); - devilFruits.push({ - id: normalizeId(cleanUrl), - name, - type, - url: cleanUrl, - }); - } + arcs.push({ + id: arcId, + name: arcName, + startChapter, + endChapter, + url: href.replace('/fr/wiki/', '') }); - }); - }); + } + } }); - console.log(`Found ${devilFruits.length} devil fruits.`); - return devilFruits; + console.log(`Found ${arcs.length} arcs.`); + return arcs; } catch (error) { - console.error('Error fetching devil fruits list:', error.message); + console.error('Error fetching arcs list:', error.message); return []; } } /** - * Fetch devil fruit data from fandom using provided URL + * Save arcs to JSON */ -async function fetchDevilFruit(devilFruitUrl, devilFruitId, devilFruitName, devilFruitType) { - try { - console.log(`Fetching: ${devilFruitName}...`); - - const response = await fetch(`${FANDOM_BASE_URL}/${devilFruitUrl}`, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 7.01; Windows NT 5.0)', - }, - }); - const data = await response.text(); - const $ = cheerio.load(data); - - // Extract devil fruit name from page title if different - const name = $('h1.mw-page-title-main').text().trim() || devilFruitName; - - // Use the type from the list page - const type = devilFruitType; - - return { - id: devilFruitId, - name, - type - }; - } catch (error) { - console.error(`Error fetching ${devilFruitName}:`, error.message); - return null; - } -} - -/** - * Save devil fruits to JSON - */ -async function saveDevilFruitsToJSON(devilFruits) { - const filepath = `${OUTPUT_DIR}/devil-fruits.json`; - fs.writeFileSync(filepath, JSON.stringify(devilFruits, null, 2)); +async function saveArcsToJSON(arcs) { + const filepath = `${OUTPUT_DIR}/arcs.json`; + fs.writeFileSync(filepath, JSON.stringify(arcs, null, 2)); console.log(`✓ Saved to ${filepath}`); } /** - * Save devil fruits to SQL + * Save arcs to CSV */ -function saveDevilFruitsToSQL(devilFruits) { - const filepath = `${OUTPUT_DIR}/devil-fruits.sql`; - const escapeSql = (value) => (value ? `'${String(value).replace(/'/g, "''")}'` : 'NULL'); - - let sql = ''; - - devilFruits.forEach((df) => { - sql += `INSERT INTO devilFruit (id, name, type) \n`; - sql += `VALUES (${escapeSql(df.id)}, ${escapeSql(df.name)}, ${escapeSql(df.type)}) \n`; - sql += `ON CONFLICT(id) DO UPDATE SET \n`; - sql += ` name = excluded.name,\n`; - sql += ` type = excluded.type;\n\n`; +async function saveArcsToCSV(arcs) { + const filepath = `${OUTPUT_DIR}/arcs.csv`; + const csvWriter = createObjectCsvWriter({ + path: filepath, + header: [ + { id: 'id', title: 'ID' }, + { id: 'name', title: 'Name' }, + { id: 'startChapter', title: 'Start Chapter' }, + { id: 'endChapter', title: 'End Chapter' }, + { id: 'url', title: 'URL' } + ], }); - fs.writeFileSync(filepath, sql); + const records = arcs + .filter((arc) => arc !== null) + .map((arc) => ({ + id: arc.id || '', + name: arc.name || '', + startChapter: arc.startChapter || '', + endChapter: arc.endChapter || '', + url: arc.url || '' + })); + + await csvWriter.writeRecords(records); console.log(`✓ Saved to ${filepath}`); } @@ -172,26 +215,33 @@ async function fetchAllCharactersUrl() { try { const url = `${FANDOM_BASE_URL}/Liste_des_Personnages_Canon`; console.log('Fetching character list...'); - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 7.01; Windows NT 5.0)', - }, - }); + const response = await fetchWithRetry(url); const data = await response.text(); const $ = cheerio.load(data); const characters = []; $('table.wikitable tbody tr').each((index, element) => { if (index === 0) return; // Skip header row - const charpictureUrl = $(element).find('td:nth-child(1) a img').attr('data-src') || $(element).find('td:nth-child(1) a img').attr('src'); - const charLink = $(element).find('td:nth-child(2) a').attr('href'); - const charName = $(element).find('td:nth-child(2) a').text().trim(); - if (charLink) { - const cleanUrl = charLink.replace('/fr/wiki/', ''); + let charpictureUrl = $(element).find('td:nth-child(1) a img').attr('data-src') || $(element).find('td:nth-child(1) a img').attr('src'); + let charUrl = $(element).find('td:nth-child(2) a').attr('href'); + let charName = $(element).find('td:nth-child(2) a').text().trim(); + let charChapter = $(element).find('td:nth-child(3)').text().trim(); + + // Remove parentheses and their content from chapter info (e.g. "1 (flashback)" becomes "1") + charChapter = charChapter.replace(/\([^)]*\)/g, ''); + charChapter = charChapter.replace(/\D/g, ''); + + // If charChapter is empty, skip the character as it means they don't have a proper page and are just mentioned in the list + if (!charChapter) { + return; + } + + if (charUrl) { + charUrl = charUrl.replace('/fr/wiki/', ''); characters.push({ - id: normalizeId(cleanUrl), name: charName, - url: cleanUrl, + url: charUrl, pictureUrl: charpictureUrl, + chapter: charChapter, }); } }); @@ -206,16 +256,31 @@ async function fetchAllCharactersUrl() { /** * Fetch character data from fandom using provided URL */ -async function fetchCharacter(characterUrl, characterId, characterName, characterpictureUrl) { +async function fetchCharacter(characterUrl, characterName, characterpictureUrl, characterChapter) { try { console.log(`Fetching: ${characterName}...`); - const response = await fetch(`${FANDOM_BASE_URL}/${characterUrl}`, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 7.01; Windows NT 5.0)', - }, + const response = await fetchWithRetry(`${FANDOM_BASE_URL}/${characterUrl}`, { + redirect: 'follow' }); - // Log response status for debugging + + // Use final URL after redirects (canonical character page) + let finalCharacterUrl = characterUrl; + let finalCharacterId = normalizeId(characterUrl); + try { + const finalUrl = new URL(response.url); + const characterUrl = finalUrl.pathname.replace('/fr/wiki/', ''); + if (characterUrl) { + finalCharacterUrl = characterUrl; + finalCharacterId = normalizeId(characterUrl); + } + } catch { + // If HTTP is not ok or redirected URL, throw an error to be caught in the outer block + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } + const data = await response.text(); const $ = cheerio.load(data); @@ -223,6 +288,9 @@ async function fetchCharacter(characterUrl, characterId, characterName, characte // Extract character name const name = $('h1.mw-page-title-main').text().trim() || characterName.replace(/_/g, ' '); + // Generate character ID from URL + name combination + finalCharacterId = normalizeId(finalCharacterUrl + '_' + name); + // Extract gender from the specific categories link let gender = null; if ($('.page-header__categories a[title="Catégorie:Personnages Masculins"]').length > 0) { @@ -237,20 +305,18 @@ async function fetchCharacter(characterUrl, characterId, characterName, characte // Extract affiliations const affiliations = extractAffiliations($); + // Extract epithets + const epithets = extractEpithets($); + // Extract devil fruit - const devilFruit = await extractDevilFruit($); + const devilFruitData = await extractDevilFruit($); + const devilFruitId = devilFruitData?.devilFruitId || null; + const devilFruitUrl = devilFruitData?.devilFruitUrl || null; // Extract haki - let haki = []; - if ($('.page-header__categories a[title="Catégorie:Utilisateurs du Haki de l\'observation"]').length > 0) { - haki.push('Observation'); - } - if ($('.page-header__categories a[title="Catégorie:Utilisateurs du Haki de l\'armement"]').length > 0) { - haki.push('Armament'); - } - if ($('.page-header__categories a[title="Catégorie:Utilisateurs du Haki des rois"]').length > 0) { - haki.push('Conqueror'); - } + const hakiObservation = $('.page-header__categories a[title="Catégorie:Utilisateurs du Haki de l\'observation"]').length > 0; + const hakiArmament = $('.page-header__categories a[title="Catégorie:Utilisateurs du Haki de l\'armement"]').length > 0; + const hakiConqueror = $('.page-header__categories a[title="Catégorie:Utilisateurs du Haki des rois"]').length > 0; // Extract bounty const bounty = extractBounty($); @@ -258,28 +324,40 @@ async function fetchCharacter(characterUrl, characterId, characterName, characte // Extract height const height = extractHeight($); - // Extract first appearance - const firstAppearance = extractFirstAppearance($); + // Use chapter from character list, cast to int + let firstAppearance = parseInt(characterChapter); // Extract origin const origin = extractOrigin($); + // Extract status + const status = extractStatus($); + // Extract image URL and clean it let pictureUrl = characterpictureUrl; + if (pictureUrl && pictureUrl.includes('Image_Non_Disponible')) { + pictureUrl = null; + } return { - id: characterId, + id: finalCharacterId, name, gender, age, height, origin, - devilFruit, + devilFruitId, + devilFruitUrl, affiliations, bounty, - haki, + hakiObservation, + hakiArmament, + hakiConqueror, + epithets, firstAppearance, - pictureUrl + status, + pictureUrl, + url: finalCharacterUrl }; } catch (error) { console.error(`Error fetching ${characterName}:`, error.message); @@ -310,7 +388,7 @@ function extractAge($) { cleanText = cleanText.replace(/\([^)]*\)/g, ''); const digitsOnly = cleanText.replace(/\D/g, ''); - return digitsOnly || null; + return parseInt(digitsOnly) || null; } /** @@ -338,8 +416,35 @@ function extractAffiliations($) { return parts.length > 0 ? parts : []; } +/** + * Extract epithets from infobox + * Epithets are always between double quotes + */ +function extractEpithets($) { + const div = $('[data-source="épithète"] .pi-data-value'); + if (div.length === 0) return []; + + const cleanedDiv = div.clone(); + cleanedDiv.find('sup').remove(); + + let text = cleanedDiv.text(); + if (!text) return []; + + // Extract all text between double quotes (both straight and curly quotes) + const matches = text.match(/["«"]([^"»"]+)["»"]/g); + if (!matches) return []; + + // Remove the quotes and trim + const epithets = matches.map(match => + match.replace(/^["«"]|["»"]$/g, '').trim() + ).filter(Boolean); + + return epithets; +} + /** * Extract devil fruit from infobox + * Returns both normalized ID and URL */ async function extractDevilFruit($) { const link = $('[data-source="dfnom"] .pi-data-value a').first(); @@ -352,37 +457,30 @@ async function extractDevilFruit($) { try { // Fetch the page to follow redirects - const response = await fetch(`${FANDOM_BASE_URL}/${cleanUrl}`, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 7.01; Windows NT 5.0)', - }, + const response = await fetchWithRetry(`${FANDOM_BASE_URL}/${cleanUrl}`, { redirect: 'follow' // Explicitly follow redirects }); - // Check if response was a redirect (301, 302, etc.) - if (response.status === 301 || response.status === 302) { - // Use the final redirected URL - const finalUrl = new URL(response.url); - const pathname = finalUrl.pathname; - const finalPath = pathname.replace('/fr/wiki/', ''); - if (finalPath) { - return normalizeId(finalPath); - } - } else { - // Use the current URL if no redirect - const finalUrl = new URL(response.url); - const pathname = finalUrl.pathname; - const finalPath = pathname.replace('/fr/wiki/', ''); - if (finalPath) { - return normalizeId(finalPath); - } + // Use the final URL after redirects + const finalUrl = new URL(response.url); + const pathname = finalUrl.pathname; + const finalPath = pathname.replace('/fr/wiki/', ''); + + if (finalPath) { + return { + devilFruitId: normalizeId(finalPath), + devilFruitUrl: finalPath + }; } } catch (error) { console.error(`Error fetching devil fruit page: ${error.message}`); } // Fallback to the original href - return normalizeId(cleanUrl); + return { + devilFruitId: normalizeId(cleanUrl), + devilFruitUrl: cleanUrl + }; } /** @@ -390,10 +488,10 @@ async function extractDevilFruit($) { */ function extractBounty($) { const div = $('[data-source="prime"] .pi-data-value'); - if (div.length === 0) return null; + if (div.length === 0) return 0; let text = div.html(); - if (!text) return null; + if (!text) return 0; // Remove all sup blocks (citations) text = text.replace(/]*>.*?<\/sup>/gi, ''); @@ -402,10 +500,19 @@ function extractBounty($) { const firstValue = text.split(']*>/g, '').trim(); - // Remove spaces and dots - cleanText = cleanText.replace(/[\s.]/g, ''); + // Check if cleanText contains digits + if (!/\d/.test(cleanText)) { + // If no digits, try second value after
+ const secondValue = text.split('
')[1]; + if (secondValue) { + cleanText = secondValue.replace(/<[^>]*>/g, '').trim(); + } + } + + // Remove all non-digits + cleanText = cleanText.replace(/\D/g, ''); - return cleanText || null; + return cleanText || 0; } /** @@ -421,9 +528,18 @@ function extractHeight($) { // Remove all sup blocks (citations) text = text.replace(/]*>.*?<\/sup>/gi, ''); - // Extract the last value after any
tag - const lastValue = text.split('
').pop().trim(); - let cleanText = lastValue.replace(/<[^>]*>/g, '').trim(); + // Check if there's a

tag - if yes, use content from

+ let content; + const pMatch = text.match(/]*>(.*?)<\/p>/i); + if (pMatch) { + // Extract content from the

tag + content = pMatch[1]; + } else { + // Use the last value method (after any
tag) + content = text.split('
').pop(); + } + + let cleanText = content.replace(/<[^>]*>/g, '').trim(); // Remove content with parentheses cleanText = cleanText.replace(/\([^)]*\)/g, ''); @@ -439,29 +555,10 @@ function extractHeight($) { const parts = normalized.split('m').filter(Boolean); return parts.length > 0 ? parts.join('.') : null; } - + return normalized.replace(/\D/g, '') || null; } -/** - * Extract first appearance from infobox - */ -function extractFirstAppearance($) { - const div = $('[data-source="première"] .pi-data-value'); - if (div.length === 0) return null; - - let text = div.html(); - if (!text) return null; - - // Remove all sup blocks (citations) - text = text.replace(/]*>.*?<\/sup>/gi, ''); - - // Extract digits after "Chapitre" - const cleanText = text.replace(/<[^>]*>/g, '').trim(); - const match = cleanText.match(/Chapitre\s+(\d+)/i); - return match ? match[1] : null; -} - /** * Extract origin from infobox */ @@ -485,6 +582,24 @@ function extractOrigin($) { return cleanText || null; } +/** + * Extract status from infobox + */ +function extractStatus($) { + const div = $('[data-source="statut"] .pi-data-value'); + if (div.length === 0) return null; + + const statusText = div.text().trim().toLowerCase(); + + if (statusText.includes('vivant')) { + return 'Alive'; + } else if (statusText.includes('décédé')) { + return 'Dead'; + } + + return null; +} + /** * Save data to JSON @@ -509,12 +624,18 @@ async function saveToCSV(characters) { { id: 'age', title: 'Age' }, { id: 'height', title: 'Height' }, { id: 'origin', title: 'Origin' }, - { id: 'devilFruit', title: 'Devil Fruit' }, + { id: 'status', title: 'Status' }, + { id: 'epithets', title: 'Epithets' }, + { id: 'devilFruitId', title: 'Devil Fruit ID' }, { id: 'affiliations', title: 'Affiliations' }, { id: 'bounty', title: 'Bounty' }, - { id: 'haki', title: 'Haki' }, + { id: 'hakiObservation', title: 'Haki Observation' }, + { id: 'hakiArmament', title: 'Haki Armament' }, + { id: 'hakiConqueror', title: 'Haki Conqueror' }, { id: 'firstAppearance', title: 'First Appearance' }, - { id: 'pictureUrl', title: 'Image URL' } + { id: 'arcId', title: 'Arc ID' }, + { id: 'pictureUrl', title: 'Image URL' }, + { id: 'url', title: 'Fandom URL' } ], }); @@ -527,12 +648,18 @@ async function saveToCSV(characters) { age: c.age || '', height: c.height || '', origin: c.origin || '', - devilFruit: c.devilFruit || '', + status: c.status || '', + epithets: Array.isArray(c.epithets) ? c.epithets.join(', ') : (c.epithets || ''), + devilFruitId: c.devilFruitId || '', affiliations: Array.isArray(c.affiliations) ? c.affiliations.join(', ') : (c.affiliations || ''), - bounty: c.bounty || '', - haki: Array.isArray(c.haki) ? c.haki.join(', ') : (c.haki || ''), + bounty: c.bounty ?? 0, + hakiObservation: c.hakiObservation ? 1 : 0, + hakiArmament: c.hakiArmament ? 1 : 0, + hakiConqueror: c.hakiConqueror ? 1 : 0, firstAppearance: c.firstAppearance || '', - pictureUrl: c.pictureUrl || '' + arcId: c.arcId || '', + pictureUrl: c.pictureUrl || '', + url: c.url || '' })); await csvWriter.writeRecords(records); @@ -540,37 +667,78 @@ async function saveToCSV(characters) { } /** - * Save data to SQL + * Fetch devil fruit data from fandom using provided URL */ -function saveToSQL(characters) { - const filepath = `${OUTPUT_DIR}/characters.sql`; - const escapeSql = (value) => (value ? `'${String(value).replace(/'/g, "''")}'` : 'NULL'); - - let sql = ''; - - characters - .filter((c) => c !== null) - .forEach((c) => { - const affiliations = Array.isArray(c.affiliations) ? c.affiliations.join(', ') : c.affiliations; - const hakiValue = Array.isArray(c.haki) && c.haki.length > 0 ? JSON.stringify(c.haki) : null; - - sql += `INSERT INTO character (id, name, gender, age, height, origin, devilFruit, affiliations, bounty, haki, firstAppearance, pictureUrl) \n`; - sql += `VALUES (${escapeSql(c.id)}, ${escapeSql(c.name)}, ${escapeSql(c.gender)}, ${escapeSql(c.age)}, ${escapeSql(c.height)}, ${escapeSql(c.origin)}, ${escapeSql(c.devilFruit)}, ${escapeSql(affiliations)}, ${escapeSql(c.bounty)}, ${escapeSql(hakiValue)}, ${escapeSql(c.firstAppearance)}, ${escapeSql(c.pictureUrl)}) \n`; - sql += `ON CONFLICT(id) DO UPDATE SET \n`; - sql += ` name = excluded.name,\n`; - sql += ` gender = excluded.gender,\n`; - sql += ` age = excluded.age,\n`; - sql += ` height = excluded.height,\n`; - sql += ` origin = excluded.origin,\n`; - sql += ` devilFruit = excluded.devilFruit,\n`; - sql += ` affiliations = excluded.affiliations,\n`; - sql += ` bounty = excluded.bounty,\n`; - sql += ` haki = excluded.haki,\n`; - sql += ` firstAppearance = excluded.firstAppearance,\n`; - sql += ` pictureUrl = excluded.pictureUrl;\n\n`; - }); +async function fetchDevilFruit(devilFruitUrl, devilFruitId) { + try { + console.log(`Fetching devil fruit: ${devilFruitId}...`); - fs.writeFileSync(filepath, sql); + const response = await fetchWithRetry(`${FANDOM_BASE_URL}/${devilFruitUrl}`); + const data = await response.text(); + const $ = cheerio.load(data); + + const name = $('span.mw-page-title-main').text().trim(); + + // Extract type from label in infobox + let type = null; + const typeDiv = $('[data-source="type"] .pi-data-value'); + if (typeDiv.length > 0) { + const typeText = typeDiv.text().trim().toLowerCase(); + if (typeText.includes('zoan')) { + type = 'Zoan'; + } else if (typeText.includes('paramecia')) { + type = 'Paramecia'; + } else if (typeText.includes('logia')) { + type = 'Logia'; + } + } + + return { + id: devilFruitId, + name, + type, + url: devilFruitUrl + }; + } catch (error) { + console.error(`Error fetching devil fruit ${devilFruitUrl}:`, error.message); + return null; + } +} + +/** + * Save devil fruits to JSON + */ +async function saveDevilFruitsToJSON(devilFruits) { + const filepath = `${OUTPUT_DIR}/devil-fruits.json`; + fs.writeFileSync(filepath, JSON.stringify(devilFruits, null, 2)); + console.log(`✓ Saved to ${filepath}`); +} + +/** + * Save devil fruits to CSV + */ +async function saveDevilFruitsToCSV(devilFruits) { + const filepath = `${OUTPUT_DIR}/devil-fruits.csv`; + const csvWriter = createObjectCsvWriter({ + path: filepath, + header: [ + { id: 'id', title: 'ID' }, + { id: 'name', title: 'Name' }, + { id: 'type', title: 'Type' }, + { id: 'url', title: 'URL' } + ], + }); + + const records = devilFruits + .filter((df) => df !== null) + .map((df) => ({ + id: df.id || '', + name: df.name || '', + type: df.type || '', + url: df.url || '' + })); + + await csvWriter.writeRecords(records); console.log(`✓ Saved to ${filepath}`); } @@ -578,48 +746,40 @@ function saveToSQL(characters) { * Main execution */ async function main() { - const format = process.argv[2] || 'all'; // json, csv, sql, or all + const format = process.argv[2] || 'all'; // json, csv, or all console.log(`\nOne Piece Scraper - Mode: ${format}\n`); - // Step 1: Scraping Devil Fruits - console.log('=== Step 1: Scraping Devil Fruits ===\n'); - const devilFruitList = await fetchAllDevilFruitsUrl(); + // Step 1: Scraping Arcs + console.log('=== Step 1: Scraping Arcs ===\n'); + const arcsList = await fetchAllArcs(); - if (devilFruitList.length === 0) { - console.warn('No devil fruits found, continuing with characters...\n'); - } else { - const devilFruits = []; - - for (let i = 0; i < devilFruitList.length; i += DEVIL_FRUIT_CONCURRENCY) { - const batch = devilFruitList.slice(i, i + DEVIL_FRUIT_CONCURRENCY); - const results = await Promise.all( - batch.map((df) => fetchDevilFruit(df.url, df.id, df.name, df.type)) - ); - - results.filter(Boolean).forEach((data) => { - console.table({ - ID: data.id, - Name: data.name, - Type: data.type - }); - - devilFruits.push(data); + if (arcsList.length > 0) { + // Display arcs in table format + arcsList.forEach((arc) => { + console.table({ + ID: arc.id, + Name: arc.name, + StartChapter: arc.startChapter, + EndChapter: arc.endChapter || 'Ongoing', + URL: arc.url }); - } + }); - console.log(`\n✓ Scraped ${devilFruits.length} devil fruits\n`); + console.log(`\n✓ Found ${arcsList.length} arcs\n`); if (format === 'json' || format === 'all') { - await saveDevilFruitsToJSON(devilFruits); + await saveArcsToJSON(arcsList); } - if (format === 'sql' || format === 'all') { - saveDevilFruitsToSQL(devilFruits); + if (format === 'csv' || format === 'all') { + await saveArcsToCSV(arcsList); } + } else { + console.warn('No arcs found, continuing...\n'); } // Step 2: Scraping Characters - console.log('=== Step 2: Scraping Characters ===\n'); + console.log('=== Step 1: Scraping Characters ===\n'); const characterList = await fetchAllCharactersUrl(); if (characterList.length === 0) { @@ -628,43 +788,120 @@ async function main() { } const characters = []; + const devilFruitUrls = new Set(); + let failedCharacters = [...characterList]; - for (let i = 0; i < characterList.length; i += CHARACTER_CONCURRENCY) { - const batch = characterList.slice(i, i + CHARACTER_CONCURRENCY); - const results = await Promise.all( - batch.map((char) => fetchCharacter(char.url, char.id, char.name, char.pictureUrl)) - ); - results.filter(Boolean).forEach((data) => { - console.table({ - ID: data.id, - Name: data.name, - Gender: data.gender, - Age: data.age, - Affiliations: data.affiliations.join(', '), - DevilFruit: data.devilFruit, - Haki: data.haki.join(', '), - Height: data.height, - Bounty: data.bounty, - Origin: data.origin, - FirstAppearance: data.firstAppearance, - pictureUrl: data.pictureUrl - }); + while (failedCharacters.length > 0) { + const nextFailedCharacters = []; + console.log(`\nFetching ${failedCharacters.length} characters...`); - characters.push(data); - }); + for (let i = 0; i < failedCharacters.length; i++) { + const char = failedCharacters[i]; + const data = await fetchCharacter(char.url, char.name, char.pictureUrl, char.chapter); + + if (data) { + console.table({ + ID: data.id, + Name: data.name, + Gender: data.gender, + Age: data.age, + Status: data.status, + Epithets: data.epithets.join(', '), + Affiliations: data.affiliations.join(', '), + DevilFruitId: data.devilFruitId, + DevilFruitUrl: data.devilFruitUrl, + HakiObservation: data.hakiObservation ? 'Yes' : 'No', + HakiArmament: data.hakiArmament ? 'Yes' : 'No', + HakiConqueror: data.hakiConqueror ? 'Yes' : 'No', + Height: data.height, + Bounty: data.bounty, + Origin: data.origin, + FirstAppearance: data.firstAppearance, + pictureUrl: data.pictureUrl, + FandomURL: data.url + }); + + // Collect devil fruit URLs + if (data.devilFruitUrl) { + devilFruitUrls.add(data.devilFruitUrl); + } + + // Add arc IDs to character data + if (data.firstAppearance) { + const arc = arcsList.find(a => a.startChapter <= parseInt(data.firstAppearance) && (a.endChapter === null || a.endChapter >= parseInt(data.firstAppearance))); + if (arc) { + data.arcId = arc.id; + } + } + + characters.push(data); + } else { + // Add to retry list and wait before next character + nextFailedCharacters.push(char); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + failedCharacters = nextFailedCharacters; + if (failedCharacters.length > 0) { + console.log(`⚠️ ${failedCharacters.length} characters failed. Retrying...`); + } } console.log(`\n✓ Scraped ${characters.length} characters\n`); + console.log(`✓ Found ${devilFruitUrls.size} unique devil fruits\n`); + // Step 3: Scraping Devil Fruits + console.log('=== Step 2: Scraping Devil Fruits ===\n'); + + if (devilFruitUrls.size === 0) { + console.warn('No devil fruits found from characters, skipping...\n'); + } else { + const devilFruits = []; + const devilFruitUrlArray = Array.from(devilFruitUrls); + + for (let i = 0; i < devilFruitUrlArray.length; i++) { + const url = devilFruitUrlArray[i]; + const data = await fetchDevilFruit(url, normalizeId(url)); + + if (data) { + console.table({ + ID: data.id, + Name: data.name, + Type: data.type, + URL: data.url + }); + + devilFruits.push(data); + } + } + + console.log(`\n✓ Scraped ${devilFruits.length} devil fruits\n`); + + if (format === 'json' || format === 'all') { + await saveDevilFruitsToJSON(devilFruits); + } + if (format === 'csv' || format === 'all') { + await saveDevilFruitsToCSV(devilFruits); + } + + // Update characters with normalized devil fruit IDs + const devilFruitMap = new Map(devilFruits.map(df => [df.id, df.id])); + characters.forEach(char => { + if (char.devilFruitUrl) { + const normalizedId = normalizeId(char.devilFruitUrl); + char.devilFruitId = devilFruitMap.get(normalizedId) || normalizedId; + } + }); + } + + // Save characters after devil fruit IDs are updated if (format === 'json' || format === 'all') { await saveToJSON(characters); } if (format === 'csv' || format === 'all') { await saveToCSV(characters); } - if (format === 'sql' || format === 'all') { - saveToSQL(characters); - } console.log('\n✓ Done!\n'); } diff --git a/scripts/set-daily-mode.ts b/scripts/set-daily-mode.ts new file mode 100644 index 0000000..398fea4 --- /dev/null +++ b/scripts/set-daily-mode.ts @@ -0,0 +1,84 @@ +import { createClient } from '@libsql/client'; +import { drizzle } from 'drizzle-orm/libsql'; +import { eq } from 'drizzle-orm'; +import fs from 'fs'; +import { character } from '../src/lib/server/db/schema'; + +const DATABASE_URL = process.env.DATABASE_URL || 'file:local.db'; + +const client = createClient({ url: DATABASE_URL }); +const db = drizzle(client); + +function readJsonFile(path: string): string[] | null { + if (!fs.existsSync(path)) { + return null; + } + + const content = fs.readFileSync(path, 'utf-8'); + return JSON.parse(content) as string[]; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function setDailyCharacters(): Promise { + try { + const dailyCharacterIds = readJsonFile('./scripts/daily-characters.json'); + + if (!dailyCharacterIds || dailyCharacterIds.length === 0) { + console.error('❌ No daily characters found in daily-characters.json'); + process.exit(1); + } + + console.log(`\n=== Setting Daily Mode Characters ===\n`); + console.log(`Found ${dailyCharacterIds.length} characters to set as daily\n`); + + // Step 1: Disable isInDailyMode for all characters + console.log('Step 1: Disabling isInDailyMode for all characters...'); + await db.update(character).set({ isInDailyMode: false }); + console.log('✓ All characters disabled from daily mode\n'); + + // Step 2: Enable isInDailyMode for characters in the list + console.log('Step 2: Enabling isInDailyMode for daily characters...\n'); + + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < dailyCharacterIds.length; i++) { + const charId = dailyCharacterIds[i]; + try { + const result = await db + .update(character) + .set({ isInDailyMode: true }) + .where(eq(character.id, charId)); + + successCount++; + process.stdout.write(`\rUpdated: ${successCount}/${dailyCharacterIds.length}`); + } catch (error) { + errorCount++; + console.error(`\n✗ Error updating character ${i + 1}:`); + console.error(` ID: ${charId}`); + console.error(` Message: ${getErrorMessage(error)}`); + } + } + + console.log(`\n\n✓ Daily characters updated!`); + console.log(` Success: ${successCount}`); + console.log(` Errors: ${errorCount}`); + + if (errorCount === 0) { + console.log(`\n✅ Successfully set ${successCount} characters as daily mode characters\n`); + } + } catch (error) { + console.error('✗ Operation failed:', getErrorMessage(error)); + process.exit(1); + } finally { + client.close(); + } +} + +setDailyCharacters().catch((error) => { + console.error(getErrorMessage(error)); + process.exit(1); +}); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 22e93f9..ea41ed4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,8 +1,36 @@ import type { Handle } from '@sveltejs/kit'; import { building } from '$app/environment'; import { auth } from '$lib/server/auth'; +import { getDailyModeCharacters, getOrCreateTodayCharacter } from '$lib/server/daily-character'; import { svelteKitHandler } from 'better-auth/svelte-kit'; +declare global { + // eslint-disable-next-line no-var + var __dailyCharacterSchedulerStarted: boolean | undefined; +} + +async function runDailyCharacterSchedulerJob() { + try { + const characters = await getDailyModeCharacters(); + if (characters.length === 0) { + return; + } + + await getOrCreateTodayCharacter(characters); + } catch (error) { + console.error('Daily character scheduler failed:', error); + } +} + +if (!building && !globalThis.__dailyCharacterSchedulerStarted) { + globalThis.__dailyCharacterSchedulerStarted = true; + + void runDailyCharacterSchedulerJob(); + setInterval(() => { + void runDailyCharacterSchedulerJob(); + }, 60_000); +} + const handleBetterAuth: Handle = async ({ event, resolve }) => { const session = await auth.api.getSession({ headers: event.request.headers }); diff --git a/src/lib/server/daily-character.ts b/src/lib/server/daily-character.ts new file mode 100644 index 0000000..47cc807 --- /dev/null +++ b/src/lib/server/daily-character.ts @@ -0,0 +1,152 @@ +import { db } from '$lib/server/db'; +import { arc, character, characterHistory, devilFruit } from '$lib/server/db/schema'; +import { desc, eq } from 'drizzle-orm'; + +const characterWithRelationsSelect = { + id: character.id, + name: character.name, + gender: character.gender, + age: character.age, + affiliations: character.affiliations, + devilFruitId: character.devilFruitId, + devilFruitName: devilFruit.name, + devilFruitType: devilFruit.type, + hakiObservation: character.hakiObservation, + hakiArmament: character.hakiArmament, + hakiConqueror: character.hakiConqueror, + bounty: character.bounty, + height: character.height, + origin: character.origin, + firstAppearance: character.firstAppearance, + pictureUrl: character.pictureUrl, + epithets: character.epithets, + status: character.status, + url: character.url, + arcId: character.arcId, + arcName: arc.name +}; + +export type CharacterWithRelations = typeof character.$inferSelect & { + devilFruitName: string | null; + devilFruitType: string | null; + arcName: string | null; +}; + +function getDateKey(date: Date): string { + return date.toISOString().split('T')[0]; +} + +function normalizeDay(date: Date = new Date()): Date { + const normalized = new Date(date); + normalized.setHours(0, 0, 0, 0); + return normalized; +} + +function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations { + const dateStr = getDateKey(date); + const seed = dateStr.split('-').reduce((acc, value) => acc + parseInt(value), 0); + const index = seed % characters.length; + return characters[index]; +} + +export async function getDailyModeCharacters(): Promise { + return db + .select(characterWithRelationsSelect) + .from(character) + .leftJoin(arc, eq(character.arcId, arc.id)) + .leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id)) + .where(eq(character.isInDailyMode, true)) + .all() as Promise; +} + +export async function getCharacterById(characterId: string): Promise { + const [found] = await db + .select(characterWithRelationsSelect) + .from(character) + .leftJoin(arc, eq(character.arcId, arc.id)) + .leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id)) + .where(eq(character.id, characterId)) + .limit(1); + + return (found ?? null) as CharacterWithRelations | null; +} + +export async function getOrCreateTodayCharacter( + characters: CharacterWithRelations[], + date: Date = new Date() +): Promise { + if (characters.length === 0) { + return null; + } + + const today = normalizeDay(date); + const todayDate = getDateKey(today); + + const [existingEntry] = await db + .select() + .from(characterHistory) + .where(eq(characterHistory.date, todayDate)) + .limit(1); + + if (existingEntry?.characterId) { + return ( + characters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ?? + (await getCharacterById(existingEntry.characterId)) + ); + } + + const recentHistory = await db + .select({ characterId: characterHistory.characterId }) + .from(characterHistory) + .orderBy(desc(characterHistory.date)) + .limit(100); + + const excludedIds = new Set(recentHistory.map((entry) => entry.characterId)); + const availableCharacters = characters.filter((currentCharacter) => !excludedIds.has(currentCharacter.id)); + + const dailyCharacter = pickDailyCharacter( + availableCharacters.length > 0 ? availableCharacters : characters, + today + ); + + try { + await db.insert(characterHistory).values({ + characterId: dailyCharacter.id, + date: todayDate, + createdAt: Date.now(), + updatedAt: Date.now() + }); + } catch (error) { + console.error('Failed to record daily character:', error); + } + + return dailyCharacter; +} + +export async function getYesterdayCharacter( + date: Date = new Date(), + characters?: CharacterWithRelations[] +): Promise { + const baseDate = normalizeDay(date); + baseDate.setDate(baseDate.getDate() - 1); + const yesterdayDate = getDateKey(baseDate); + + const [yesterdayEntry] = await db + .select() + .from(characterHistory) + .where(eq(characterHistory.date, yesterdayDate)) + .limit(1); + + if (!yesterdayEntry?.characterId) { + return null; + } + + if (characters) { + return ( + characters.find((currentCharacter) => currentCharacter.id === yesterdayEntry.characterId) ?? + (await getCharacterById(yesterdayEntry.characterId)) + ); + } + + return getCharacterById(yesterdayEntry.characterId); +} \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index caca5d7..2991ed4 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,16 +1,29 @@ import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core'; -// Define haki types -export type HakiType = 'Observation' | 'Armament' | 'Conqueror'; - // Define devil fruit types export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown'; +// Define the site config table schema +export const config = sqliteTable('config', { + key: text('key').primaryKey(), + value: text('value') +}); + +// Define the arc table schema +export const arc = sqliteTable('arc', { + id: text('id').primaryKey(), + name: text('name').notNull(), + startChapter: integer('startChapter').notNull(), + endChapter: integer('endChapter'), + url: text('url') +}); + // Define the devil fruit table schema export const devilFruit = sqliteTable('devilFruit', { id: text('id').primaryKey(), name: text('name').notNull().unique(), - type: text('type').$type() + type: text('type').$type(), + url: text('url') }); // Define the character table schema @@ -19,15 +32,66 @@ export const character = sqliteTable('character', { name: text('name').notNull(), gender: text('gender'), age: integer('age'), - affiliations: text('affiliations'), - devilFruit: text('devilFruit').references(() => devilFruit.id), - haki: text('haki', { mode: 'json' }).$type(), - bounty: integer('bounty'), - // height in meters as a float (e.g. 1.75) + affiliations: text('affiliations', { mode: 'json' }).$type(), + devilFruitId: text('devilFruitId').references(() => devilFruit.id), + hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false), + hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false), + hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false), + bounty: integer('bounty').default(0), height: real('height'), origin: text('origin'), - firstAppearance: text('firstAppearance'), - pictureUrl: text('pictureUrl') + firstAppearance: integer('firstAppearance').notNull(), + pictureUrl: text('pictureUrl'), + epithets: text('epithets', { mode: 'json' }).$type(), + status: text('status'), + arcId: text('arcId').references(() => arc.id), + url: text('url'), + isInDailyMode: integer('isInDailyMode', { mode: 'boolean' }).default(true) +}); + +// Define the character override table schema +export const characterOverride = sqliteTable('characterOverride', { + characterId: text('characterId').primaryKey().references(() => character.id), + name: text('name'), + gender: text('gender'), + age: integer('age'), + affiliations: text('affiliations', { mode: 'json' }).$type(), + devilFruitId: text('devilFruitId').references(() => devilFruit.id), + hakiObservation: integer('hakiObservation', { mode: 'boolean' }), + hakiArmament: integer('hakiArmament', { mode: 'boolean' }), + hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }), + bounty: integer('bounty'), + height: real('height'), + origin: text('origin'), + firstAppearance: integer('firstAppearance').notNull(), + pictureUrl: text('pictureUrl'), + epithets: text('epithets', { mode: 'json' }).$type(), + status: text('status'), + arcId: text('arcId').references(() => arc.id), + url: text('url'), + notes: text('notes') +}); + +// Define the character scrape validation table schema +export const characterScrapeValidation = sqliteTable('characterScrapeValidation', { + id: text('id').primaryKey(), + name: text('name').notNull(), + gender: text('gender'), + age: integer('age'), + affiliations: text('affiliations', { mode: 'json' }).$type(), + devilFruitId: text('devilFruitId').references(() => devilFruit.id), + hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false), + hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false), + hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false), + bounty: integer('bounty'), + height: real('height'), + origin: text('origin'), + firstAppearance: integer('firstAppearance').notNull(), + pictureUrl: text('pictureUrl'), + epithets: text('epithets', { mode: 'json' }).$type(), + status: text('status'), + arcId: text('arcId').references(() => arc.id), + url: text('url') }); // Define the caracter history table schema @@ -36,9 +100,11 @@ export const characterHistory = sqliteTable('characterHistory', { .primaryKey() .$defaultFn(() => crypto.randomUUID()), characterId: text('characterId').references(() => character.id), - date: integer('date'), + date: text('date'), + won: integer('won').notNull().default(0), createdAt: integer('createdAt').notNull().$default(() => Date.now()), updatedAt: integer('updatedAt').notNull().$default(() => Date.now()), }); + export * from './auth.schema'; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..1391c61 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,9 @@ +import { getYesterdayCharacter } from '$lib/server/daily-character'; + +export async function load() { + const yesterdayCharacter = await getYesterdayCharacter(); + + return { + yesterdayCharacter: yesterdayCharacter || null + }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d4b7bd0..0eac569 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,19 +1,21 @@ + + OnePieceDle

-
+
-
- Jeu de devinettes Grand Line -

OnePieceDle @@ -24,7 +26,7 @@

-

Voyage du jour

+

Personnage du jour

Nouveau mystere toutes les 24 heures

Compare tes essais, debloque des indices et garde ta serie.

Entraine-toi avec des pirates legendaires

Choisis une epoque, regle la difficulte et vogue a ton rythme.

diff --git a/src/routes/daily/+page.server.ts b/src/routes/daily/+page.server.ts new file mode 100644 index 0000000..576800d --- /dev/null +++ b/src/routes/daily/+page.server.ts @@ -0,0 +1,38 @@ +import { error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { config } from '$lib/server/db/schema'; +import { getDailyModeCharacters, getOrCreateTodayCharacter, getYesterdayCharacter } from '$lib/server/daily-character'; +import { like } from 'drizzle-orm'; + +export async function load() { + const characters = await getDailyModeCharacters(); + const dailyCharacter = await getOrCreateTodayCharacter(characters); + + if (!dailyCharacter) { + throw error(404, 'No daily character available. Please check if characters are configured in daily mode.'); + } + + const yesterdayCharacter = await getYesterdayCharacter(new Date(), characters); + + // Load column visibility config + const columnConfig = await db + .select() + .from(config) + .where(like(config.key, 'characterHistory.column.%.visible')); + + // Convert to object for easier access + const columnVisibility: Record = {}; + columnConfig.forEach(row => { + const match = row.key.match(/characterHistory\.column\.(.+)\.visible/); + if (match) { + columnVisibility[match[1]] = row.value === 'true'; + } + }); + + return { + characters, + dailyCharacter, + yesterdayCharacter, + columnVisibility + }; +} diff --git a/src/routes/daily/+page.svelte b/src/routes/daily/+page.svelte index 9d94edb..89bea86 100644 --- a/src/routes/daily/+page.svelte +++ b/src/routes/daily/+page.svelte @@ -1,87 +1,772 @@ + + OnePieceDle - Mode du jour +
-
-
- Mode du jour + + +
+
+

+ Personnage du jour +

+ {#if hasWon} + + {/if}
-

- Mystere du jour -

- Devine le personnage. Chaque indice deblocque une nouvelle piste. + Devine le personnage. Chaque indice se débloque après un certain nombre de tentatives. Bonne chance !

-
-

Indices du jour

-
-
-

Indice 1

-

Origine: ???

-
-
-

Indice 2

-

Fruit du demon: ???

-
-
-

Indice 3

-

Affiliation: ???

+ {#if selectedCharacters.length > 0 && !hasWon} +
+
+ + +
-
+ {/if} -
+ {#if hasWon} + {#if isGeckoMoriaWin} +
+
+
🌑
+

Moria vous contrôle...

+

Vous avez succombé à l'ombre en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !

+
+ {#if dailyCharacter.pictureUrl} + + {dailyCharacter.name} + + {/if} +

{dailyCharacter.name}

+
+
+
+ {:else} +
+
+
🎉
+

Félicitations !

+

Vous avez trouvé le personnage en {selectedCharacters.length} {selectedCharacters.length > 1 ? 'tentatives' : 'tentative'} !

+
+ {#if dailyCharacter.pictureUrl} + + {dailyCharacter.name} + + {/if} +

{dailyCharacter.name}

+
+
+
+ {/if} + {:else} +

Entrer une supposition

- - + {/each} +
+ {/if} +
+
+ {/if}
-
-
+
+

Historique

-

Aucune tentative pour le moment.

- + {#if selectedCharacters.length === 0} +

Aucune tentative pour le moment.

+ {:else} +
+
+ +
+
+

Personnage

+
+ {#if columnVisibility.status !== false} +
+

Statut

+
+ {/if} + {#if columnVisibility.gender !== false} +
+

Genre

+
+ {/if} + {#if columnVisibility.affiliations !== false} +
+

Affiliations

+
+ {/if} + {#if columnVisibility.haki !== false} +
+

Haki

+
+ {/if} + {#if columnVisibility.bounty !== false} +
+

Prime

+
+ {/if} + {#if columnVisibility.height !== false} +
+

Taille

+
+ {/if} + {#if columnVisibility.origin !== false} +
+

Origine

+
+ {/if} + {#if columnVisibility.arc !== false} +
+

Arc

+
+ {/if} + {#if columnVisibility.devilFruitType !== false} +
+

Fruit

+
+ {/if} +
+ + + {#each selectedCharacters as character (character.id)} +
+ +
+ {#if character.pictureUrl} + + {character.name} + + {:else} +
+ {character.name} +
+ {/if} +
+ + + {#if columnVisibility.status !== false} +
+

+ {character.status === 'Alive' ? 'Vivant' : character.status === 'Deceased' || character.status === 'Dead' ? 'Mort' : character.status || 'Inconnu'} +

+
+ {/if} + + + {#if columnVisibility.gender !== false} +
+

+ {character.gender === 'Male' ? 'Homme' : character.gender === 'Female' ? 'Femme' : character.gender || 'Inconnu'} +

+
+ {/if} + + + {#if columnVisibility.affiliations !== false} +
+ {#if character.affiliations} + {@const parsedAffiliations = typeof character.affiliations === 'string' + ? (character.affiliations.includes('[') ? JSON.parse(character.affiliations) : character.affiliations.split(',').map((a: string) => a.trim())) + : character.affiliations} + {#if Array.isArray(parsedAffiliations) && parsedAffiliations.length > 0} +

{parsedAffiliations[0]}

+ {:else} +

{parsedAffiliations}

+ {/if} + {:else} +

-

+ {/if} +
+ {/if} + + + {#if columnVisibility.haki !== false} +
+

+ {#if character.hakiObservation}👁️{/if} + {#if character.hakiArmament}🦾{/if} + {#if character.hakiConqueror}👑{/if} + {#if !character.hakiObservation && !character.hakiArmament && !character.hakiConqueror} + + {/if} +

+
+ {/if} + + + {#if columnVisibility.bounty !== false} +
+ {#if character.bounty != null && dailyCharacter.bounty != null && character.bounty !== dailyCharacter.bounty} +
+ {/if} + {#if character.bounty != null} +

{formatBounty(character.bounty)} ฿

+ {:else} +

Inconnue

+ {/if} +
+ {/if} + + + {#if columnVisibility.height !== false} +
+ {#if character.height && dailyCharacter.height && character.height !== dailyCharacter.height} +
+ {/if} + {#if character.height} +

{character.height} m

+ {:else} +

Inconnue

+ {/if} +
+ {/if} + + + {#if columnVisibility.origin !== false} +
+

{character.origin || 'Inconnue'}

+
+ {/if} + + + {#if columnVisibility.arc !== false} +
+ {#if character.arcName !== dailyCharacter.arcName && character.firstAppearance && dailyCharacter.firstAppearance && character.firstAppearance !== dailyCharacter.firstAppearance} +
+ {/if} +

{character.arcName || 'Inconnu'}

+
+ {/if} + + + {#if columnVisibility.devilFruitType !== false} +
+ {#if character.devilFruitType} +

{character.devilFruitType}

+ {:else} +

+ {/if} +
+ {/if} +
+ {/each} +
+
+ {/if}
+ {#if yesterdayCharacter} +
+ {#if yesterdayCharacter.pictureUrl} + {yesterdayCharacter.name} + {:else} +
+ Photo +
+ {/if} +
+

Personnage d'hier

+

{yesterdayCharacter.name}

+ {#if yesterdayCharacter.epithets} +

+ {typeof yesterdayCharacter.epithets === 'string' + ? JSON.parse(yesterdayCharacter.epithets).join(', ') + : (yesterdayCharacter.epithets as string[]).join(', ')} +

+ {/if} +
+ + Voir la page + +
+ {:else}
Photo

Personnage d'hier

-

Placeholder

-

Revele apres validation de ta tentative.

+

Aucun personnage

+

Aucun personnage d'hier disponible

-
+ {/if}
diff --git a/src/routes/daily/+server.ts b/src/routes/daily/+server.ts new file mode 100644 index 0000000..5dc2fb6 --- /dev/null +++ b/src/routes/daily/+server.ts @@ -0,0 +1,33 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { characterHistory } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; + +export async function POST({ request }) { + try { + const { characterId } = await request.json(); + + if (!characterId) { + return json({ error: 'Missing characterId' }, { status: 400 }); + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayDate = today.toISOString().split('T')[0]; + + // Increment the won counter for today's entry + await db + .update(characterHistory) + .set({ + won: sql`${characterHistory.won} + 1`, + updatedAt: Date.now() + }) + .where(eq(characterHistory.date, todayDate)); + + return json({ success: true }); + } catch (error) { + console.error('Error recording win:', error); + return json({ error: 'Failed to record win' }, { status: 500 }); + } +}