Compare commits

..
7 Commits
Author SHA1 Message Date
Whidix 63951df5a7 feat: enhance devil fruit extraction logic to support multiple data sources and clean up text
Build Docker Image / build (push) Successful in 1m29s
2026-07-10 20:36:54 +02:00
Whidix dac084a75e feat: add fruit translation hints and update related logic in DailyFruitHint component
Build Docker Image / build (push) Successful in 1m26s
2026-07-10 19:57:42 +02:00
Whidix b0c071e3dd refactor: remove unused translated name fields from character selection
Build Docker Image / build (push) Successful in 1m23s
2026-07-10 19:40:17 +02:00
Whidix d3fafd75c6 feat: add translated names for Devil Fruits and update related schemas
Build Docker Image / build (push) Successful in 1m33s
- Added support for translated names and French translated names in the DevilFruitRecord and Character interfaces.
- Updated JSON import/export scripts to handle new translated name fields.
- Enhanced scraping logic to extract translated names from the source.
- Modified database schema to include translated names for Devil Fruits.
- Updated daily fruit selection to include translated names for characters.
2026-07-10 09:02:20 +02:00
Whidix efb0b3d330 refactor: simplify daily fruit selection logic and update type definitions
Build Docker Image / build (push) Successful in 1m33s
2026-07-08 23:11:29 +02:00
Whidix 2e6d90ce8c feat: filter characters by daily mode in fruit loading functions
Build Docker Image / build (push) Successful in 1m33s
2026-06-30 19:54:42 +02:00
Whidix 262f619013 fix: update fetch URL for recording daily character win
Build Docker Image / build (push) Successful in 1m28s
2026-06-28 22:19:25 +02:00
12 changed files with 1435 additions and 61 deletions
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `devil_fruit` ADD `translated_name` text;--> statement-breakpoint
ALTER TABLE `devil_fruit` ADD `fr_translated_name` text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -36,6 +36,13 @@
"when": 1782152097346,
"tag": "0004_magenta_screwball",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1783545248600,
"tag": "0005_uneven_talkback",
"breakpoints": true
}
]
}
+7 -1
View File
@@ -18,6 +18,8 @@ type ArcRecord = {
type DevilFruitRecord = {
id: string;
name: string;
translatedName?: string | null;
frTranslatedName?: string | null;
type?: DevilFruitType | string | null;
url?: string | null;
};
@@ -94,7 +96,7 @@ function toJsonArray(value: string[] | string | null | undefined): string[] | nu
function toDevilFruitType(value: DevilFruitType | string | null | undefined): DevilFruitType | null {
if (!value) return null;
if (value === 'Paramecia' || value === 'Zoan' || value === 'Logia' || value === 'Smile' || value === 'Unknown') {
if (value === 'Paramecia' || value === 'Zoan' || value === 'Logia' || value === 'Unknown') {
return value;
}
return 'Unknown';
@@ -229,6 +231,8 @@ async function importFromJson(): Promise<void> {
.values({
id: item.id,
name: item.name,
translatedName: toNullable(item.translatedName),
frTranslatedName: toNullable(item.frTranslatedName),
type: toDevilFruitType(item.type),
url: toNullable(item.url)
})
@@ -236,6 +240,8 @@ async function importFromJson(): Promise<void> {
target: devilFruit.id,
set: {
name: item.name,
translatedName: toNullable(item.translatedName),
frTranslatedName: toNullable(item.frTranslatedName),
type: toDevilFruitType(item.type),
url: toNullable(item.url)
}
+66 -22
View File
@@ -23,6 +23,8 @@ interface Character {
frOrigin: string | null;
devilFruitId: string | null;
devilFruitName: string | null;
devilFruitTranslatedName: string | null;
frDevilFruitTranslatedName: string | null;
devilFruitUrl: string | null;
affiliation: string | null;
frAffiliation: string | null;
@@ -49,12 +51,16 @@ interface CharacterListItem {
interface DevilFruitData {
devilFruitId: string;
devilFruitTitle: string;
devilFruitTranslatedName?: string | null;
frDevilFruitTranslatedName?: string | null;
devilFruitUrl: string;
}
interface DevilFruit {
id: string;
name: string;
translatedName?: string | null;
frTranslatedName?: string | null;
type: string | null;
url: string;
}
@@ -173,17 +179,6 @@ function removeParentheticalNotes(text: string): string {
return text.replace(/\([^)]*\)/g, '').trim();
}
/**
* Get the French link from the API response links array
*/
function getFrLink(links: { lang: string; ['*']: string; url: string }[]): { url: string } | null {
// Get french url by getting parse.langlinks where lang is "fr" and extract the name from there
const frLink = links.find(
(link: { lang: string; ['*']: string; url: string }) => link.lang === 'fr'
);
return frLink ? { url: frLink['url'] } : null;
}
/**
* Normalize string by decoding URI components, punctuation, and replacing spaces with underscores
@@ -485,9 +480,10 @@ async function fetchCharacter(
const epithets = extractEpithets($);
// Extract devil fruit
const devilFruitData = await extractDevilFruit($);
const devilFruitData = await extractDevilFruit($, 'en');
const devilFruitId = devilFruitData?.devilFruitId || null;
const devilFruitName = devilFruitData?.devilFruitTitle || null;
const devilFruitTranslatedName = devilFruitData?.devilFruitTranslatedName || null;
const devilFruitUrl = devilFruitData?.devilFruitUrl || null;
// Extract haki from JSON categories
@@ -546,6 +542,8 @@ async function fetchCharacter(
const frOrigin = frPage ? extractOrigin(frPage) : null;
const frDevilFruitTranslatedName = frPage ? (await extractDevilFruit(frPage, 'fr'))?.devilFruitTranslatedName || null : null;
if (name !== jsonData.parse?.title) {
frName = name;
}
@@ -563,6 +561,8 @@ async function fetchCharacter(
frOrigin,
devilFruitId,
devilFruitName,
devilFruitTranslatedName,
frDevilFruitTranslatedName,
devilFruitUrl,
affiliation,
frAffiliation,
@@ -703,17 +703,50 @@ function extractEpithets($: cheerio.CheerioAPI): string[] {
* Extract devil fruit from infobox
* Returns both normalized ID and URL
*/
async function extractDevilFruit($: cheerio.CheerioAPI): Promise<DevilFruitData | null> {
// dfname or dfename are used as data-source for devil fruit in the infobox, we check both to be safe
const fruit = $('[data-source="dfname"] .pi-data-value, [data-source="dfename"] .pi-data-value').first();
const link = fruit.find('a').first();
if (link.length === 0) return null;
async function extractDevilFruit($: cheerio.CheerioAPI, lang: 'en' | 'fr'): Promise<DevilFruitData | null> {
// dfname, dfename, or dfnomf may be used depending on the wiki language and page format.
const fruit = $(
lang === 'fr'
? '[data-source="dfnomf"] .pi-data-value, [data-source="dfname"] .pi-data-value, [data-source="dfename"] .pi-data-value'
: '[data-source="dfname"] .pi-data-value, [data-source="dfename"] .pi-data-value, [data-source="dfnomf"] .pi-data-value'
).first();
if (fruit.length === 0) return null;
let fruitTitle = fruit.text().trim().toLowerCase().includes('smile') ? fruit.text().trim() : link.text().trim();
const fruitClone = fruit.clone();
fruitClone.find('sup, s, del, strike').remove();
const fruitText = fruitClone.text().replace(/\s+/g, ' ').trim();
const link = fruit.find('a').first();
let fruitTitle = link.length > 0 ? link.text().trim() : fruitText;
if (!fruitTitle) return null;
let translatedName = null;
if (lang === 'en') {
const translatedNameBox = $('[data-source="dfename"] .pi-data-value').first();
if (translatedNameBox.length > 0) {
const translatedClone = translatedNameBox.clone();
translatedClone.find('sup, s, del, strike').remove();
translatedName = translatedClone.text().replace(/\s+/g, ' ').trim();
}
} else if (lang === 'fr') {
const translatedNameBox = $('[data-source="dfnomf"] .pi-data-value').first();
if (translatedNameBox.length > 0) {
const translatedClone = translatedNameBox.clone();
translatedClone.find('sup, s, del, strike').remove();
translatedName = translatedClone.text().replace(/\s+/g, ' ').trim();
}
}
const href = link.attr('href');
if (!href || !href.startsWith('/wiki/')) return null;
if (!href || !href.startsWith('/wiki/')) {
return {
devilFruitId: normalizeId(fruitTitle),
devilFruitTitle: fruitTitle,
devilFruitTranslatedName: translatedName || null,
devilFruitUrl: ''
};
}
const cleanUrl = href.replace('/wiki/', '');
@@ -727,6 +760,7 @@ async function extractDevilFruit($: cheerio.CheerioAPI): Promise<DevilFruitData
return {
devilFruitId: normalizeId(fruitTitle),
devilFruitTitle: fruitTitle,
devilFruitTranslatedName: translatedName || null,
devilFruitUrl: cleanUrl
};
}
@@ -918,6 +952,8 @@ async function saveToCSV(characters: Character[]): Promise<void> {
async function fetchDevilFruit(
devilFruitUrl: string,
devilFruitTitle: string,
devilFruitTranslatedName: string | null,
frdevilFruitTranslatedName: string | null,
devilFruitId: string
): Promise<DevilFruit | null> {
try {
@@ -956,6 +992,8 @@ async function fetchDevilFruit(
return {
id: devilFruitId,
name: devilFruitTitle.toLowerCase().includes('smile') ? devilFruitTitle : name,
translatedName: devilFruitTranslatedName || null,
frTranslatedName: frdevilFruitTranslatedName || null,
type,
url: devilFruitUrl
};
@@ -984,6 +1022,8 @@ async function saveDevilFruitsToCSV(devilFruits: DevilFruit[]): Promise<void> {
header: [
{ id: 'id', title: 'ID' },
{ id: 'name', title: 'Name' },
{ id: 'translatedName', title: 'Translated Name' },
{ id: 'frTranslatedName', title: 'Translated Name (FR)' },
{ id: 'type', title: 'Type' },
{ id: 'url', title: 'URL' }
]
@@ -994,6 +1034,8 @@ async function saveDevilFruitsToCSV(devilFruits: DevilFruit[]): Promise<void> {
.map((df) => ({
id: df.id || '',
name: df.name || '',
translatedName: df.translatedName || '',
frTranslatedName: df.frTranslatedName || '',
type: df.type || '',
url: df.url || ''
}));
@@ -1056,7 +1098,9 @@ async function main(): Promise<void> {
const name = c.devilFruitName!;
const url = c.devilFruitUrl!;
const id = normalizeId(name);
return [`${normalizeId(url)}::${id}`, { url, name, id }] as const;
const translatedName = c.devilFruitTranslatedName || null;
const frTranslatedName = c.frDevilFruitTranslatedName || null;
return [`${normalizeId(url)}::${id}`, { url, name, id, translatedName, frTranslatedName }] as const;
})
)
.values()
@@ -1064,7 +1108,7 @@ async function main(): Promise<void> {
console.log(`✓ Found ${devilFruitEntries.length} unique devil fruits\n`);
// Step 3: Scraping Devil Fruits
console.log('=== Step 2: Scraping Devil Fruits ===\n');
console.log('=== Step 3: Scraping Devil Fruits ===\n');
if (devilFruitEntries.length === 0) {
console.warn('No devil fruits found from characters, skipping...\n');
@@ -1075,7 +1119,7 @@ async function main(): Promise<void> {
const batch = devilFruitEntries.slice(i, i + FETCH_CONCURRENCY);
const batchResults = await Promise.all(
batch.map(async (entry) => {
const data = await fetchDevilFruit(entry.url, entry.name, entry.id);
const data = await fetchDevilFruit(entry.url, entry.name, entry.translatedName, entry.frTranslatedName, entry.id);
return { entry, data };
})
);
+53 -16
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
import type { DevilFruit } from '$lib/server/db/schema';
import { t } from '$lib/i18n';
import { language, t } from '$lib/i18n';
interface $$Props {
dailyFruit: DevilFruit | null;
@@ -12,12 +12,31 @@
export let selectedCharacters: NonNullable<$$Props['selectedCharacters']> = [];
let showFruitTypeHint = false;
let showFruitTranslationHint = false;
$: isFruitTypeAvailable = selectedCharacters.length >= 3;
$: isFruitTranslationAvailable = selectedCharacters.length >= 6;
$: guessesBeforeUnlock = Math.max(0, 3 - selectedCharacters.length);
$: translationGuessesBeforeUnlock = Math.max(0, 6 - selectedCharacters.length);
$: isFrench = $language === 'fr';
$: if (!isFruitTypeAvailable) {
showFruitTypeHint = false;
}
$: if (!isFruitTranslationAvailable) {
showFruitTranslationHint = false;
}
function getFruitTranslation(fruit: DevilFruit | null): string | null {
if (!fruit) {
return null;
}
if (isFrench) {
return fruit.frTranslatedName ?? fruit.translatedName ?? null;
}
return fruit.translatedName ?? fruit.frTranslatedName ?? null;
}
</script>
<svelte:head>
@@ -41,19 +60,37 @@
</svelte:head>
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
<button
type="button"
class="flex w-full flex-col items-center justify-center rounded-2xl border border-white/10 px-3 py-4 transition-colors hover:bg-slate-900/80 {isFruitTypeAvailable ? 'bg-slate-950/60' : 'cursor-not-allowed bg-slate-950/30 opacity-50 hover:bg-slate-950/30'}"
disabled={!isFruitTypeAvailable}
onclick={() => (showFruitTypeHint = !showFruitTypeHint)}
>
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.fruitKind}</p>
{#if showFruitTypeHint}
<p class="mt-2 text-xs font-semibold text-white">{dailyFruit?.type || $t.game.components.hints.unknown}</p>
{:else if guessesBeforeUnlock > 0}
<p class="mt-2 text-xs text-slate-400">{guessesBeforeUnlock} {$t.game.components.hints.beforeUnlock}</p>
{:else}
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
{/if}
</button>
<div class="grid gap-3 sm:grid-cols-2">
<button
type="button"
class="flex w-full flex-col items-center justify-center rounded-2xl border border-white/10 px-3 py-4 transition-colors hover:bg-slate-900/80 {isFruitTypeAvailable ? 'bg-slate-950/60' : 'cursor-not-allowed bg-slate-950/30 opacity-50 hover:bg-slate-950/30'}"
disabled={!isFruitTypeAvailable}
onclick={() => (showFruitTypeHint = !showFruitTypeHint)}
>
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.fruitKind}</p>
{#if showFruitTypeHint}
<p class="mt-2 text-xs font-semibold text-white">{dailyFruit?.type || $t.game.components.hints.unknown}</p>
{:else if guessesBeforeUnlock > 0}
<p class="mt-2 text-xs text-slate-400">{guessesBeforeUnlock} {$t.game.components.hints.beforeUnlock}</p>
{:else}
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
{/if}
</button>
<button
type="button"
class="flex w-full flex-col items-center justify-center rounded-2xl border border-white/10 px-3 py-4 transition-colors hover:bg-slate-900/80 {isFruitTranslationAvailable ? 'bg-slate-950/60' : 'cursor-not-allowed bg-slate-950/30 opacity-50 hover:bg-slate-950/30'}"
disabled={!isFruitTranslationAvailable}
onclick={() => (showFruitTranslationHint = !showFruitTranslationHint)}
>
<p class="text-sm font-medium text-amber-100">{$t.game.components.hints.fruitTranslation}</p>
{#if showFruitTranslationHint}
<p class="mt-2 text-xs font-semibold text-white">{getFruitTranslation(dailyFruit) || $t.game.components.hints.unknown}</p>
{:else if translationGuessesBeforeUnlock > 0}
<p class="mt-2 text-xs text-slate-400">{translationGuessesBeforeUnlock} {$t.game.components.hints.beforeUnlock}</p>
{:else}
<p class="mt-2 text-xs text-slate-400">{$t.game.components.hints.available}</p>
{/if}
</button>
</div>
</div>
+1
View File
@@ -161,6 +161,7 @@
"origin": "Origin",
"devilFruit": "Devil fruit",
"fruitKind": "Fruit type",
"fruitTranslation": "Fruit translation",
"affiliation": "Affiliation",
"unknown": "Unknown",
"none": "None",
+1
View File
@@ -161,6 +161,7 @@
"origin": "Origine",
"devilFruit": "Fruit du demon",
"fruitKind": "Type du fruit",
"fruitTranslation": "Traduction du fruit",
"affiliation": "Affiliation",
"unknown": "Inconnue",
"none": "Aucun",
+18 -18
View File
@@ -1,13 +1,11 @@
import { db } from '$lib/server/db';
import { character, devilFruit, devilFruitHistory, type DevilFruit, type Character } from '$lib/server/db/schema';
import { desc, eq, and } from 'drizzle-orm';
// Generate or get random seed for daily fruit selection
const RANDOM_SEED = Math.random();
import { character, devilFruit, devilFruitHistory, type DevilFruit, type Character, type DevilFruitType } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
const characterWithFruitSelect = {
id: character.id,
name: character.name,
frName: character.frName,
devilFruitId: character.devilFruitId,
devilFruitName: devilFruit.name,
devilFruitType: devilFruit.type,
@@ -15,9 +13,11 @@ const characterWithFruitSelect = {
url: devilFruit.url
};
export type CharacterWithFruit = Character & {
export type CharacterWithFruit = Pick<Character, 'id' | 'name' | 'devilFruitId'> & {
devilFruitName: string | null;
devilFruitType: string | null;
devilFruitType: DevilFruitType | null;
frName: string | null;
url: string | null;
};
export function getDateKey(date: Date): number {
@@ -30,29 +30,29 @@ export function normalizeDay(date: Date = new Date()): Date {
return normalized;
}
function pickDailyFruit(fruits: DevilFruit[], date: Date): DevilFruit {
const timestamp = getDateKey(date);
const daysSinceEpoch = Math.floor(timestamp / 1000 / 60 / 60 / 24);
const combinedSeed = (daysSinceEpoch + Math.floor(RANDOM_SEED * 1000000)) % fruits.length;
return fruits[combinedSeed];
function pickDailyFruit(fruits: DevilFruit[]): DevilFruit {
const randomIndex = Math.floor(Math.random() * fruits.length);
return fruits[randomIndex];
}
export async function getCharactersWithDevilFruit(): Promise<CharacterWithFruit[]> {
const rows = (await db
const rows = await db
.select(characterWithFruitSelect)
.from(character)
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
.all()) as any[];
.where(eq(character.isInDailyMode, true))
.all();
return rows.filter((r) => r.devilFruitId) as CharacterWithFruit[];
return rows.filter((r): r is CharacterWithFruit => Boolean(r.devilFruitId));
}
export async function getAllDevilFruitsFromCharacters(): Promise<DevilFruit[]> {
const characters = (await db
const characters = await db
.select(characterWithFruitSelect)
.from(character)
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
.all()) as any[];
.where(eq(character.isInDailyMode, true))
.all();
const map = new Map<string, DevilFruit>();
for (const row of characters) {
@@ -93,7 +93,7 @@ export async function getOrCreateTodayFruit(date: Date = new Date()): Promise<De
const allFruits = await getAllDevilFruitsFromCharacters();
if (allFruits.length === 0) return null;
const chosen = pickDailyFruit(allFruits, today);
const chosen = pickDailyFruit(allFruits);
try {
await db.insert(devilFruitHistory).values({
+2
View File
@@ -30,6 +30,8 @@ export type Arc = InferSelectModel<typeof arc>;
export const devilFruit = sqliteTable('devil_fruit', {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
translatedName: text('translated_name'),
frTranslatedName: text('fr_translated_name'),
type: text('type').$type<DevilFruitType>(),
url: text('url')
});
@@ -163,7 +163,7 @@
const triedCharacterIds = selectedCharacters.map(selected => selected.id);
// Send request to record win in database
fetch('/daily', {
fetch('/daily/character', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import { getAllDevilFruitsFromCharacters, getOrCreateTodayFruit, getYesterdayFruit, getTodayFruitWinsCount } from '$lib/server/daily-fruit';
import { getAllCharacters } from '$lib/server/daily-character';
import { getDailyModeCharacters } from '$lib/server/daily-character';
export async function load(event) {
const fruits = await getAllDevilFruitsFromCharacters();
@@ -12,8 +12,8 @@ export async function load(event) {
const yesterdayFruit = await getYesterdayFruit(new Date());
// Load all characters for searching and filter only those with a devil fruit
const allCharacters = await getAllCharacters();
// Load daily-mode characters for searching and filter only those with a devil fruit
const allCharacters = await getDailyModeCharacters();
const characters = allCharacters.filter((c) => c.devilFruitId);
// Find the character tied to the chosen fruit among characters that have a devil fruit