feat: enhance character status extraction and update schema for nullable status
All checks were successful
Build Docker Image / build (push) Successful in 1m22s

This commit is contained in:
2026-03-03 23:26:53 +01:00
parent 70de84f3ab
commit b5816e6c28
4 changed files with 165 additions and 27 deletions

View File

@@ -659,9 +659,9 @@ function extractOrigin($: cheerio.CheerioAPI): string | null {
/** /**
* Extract status from infobox * Extract status from infobox
*/ */
function extractStatus($: cheerio.CheerioAPI): string { function extractStatus($: cheerio.CheerioAPI): string | null {
const div = $('[data-source="statut"] .pi-data-value'); const div = $('[data-source="statut"] .pi-data-value');
if (div.length === 0) return 'Alive'; if (div.length === 0) return null;
const statusText = div.text().trim().toLowerCase(); const statusText = div.text().trim().toLowerCase();
@@ -669,6 +669,8 @@ function extractStatus($: cheerio.CheerioAPI): string {
return 'Alive'; return 'Alive';
} else if (statusText.includes('décédé')) { } else if (statusText.includes('décédé')) {
return 'Dead'; return 'Dead';
} else if (statusText.includes('inconnu')) {
return 'Unknown';
} }
return 'Alive'; return 'Alive';

View File

@@ -4,6 +4,8 @@ import { user } from './auth.schema';
// Define devil fruit types // Define devil fruit types
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown'; export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Smile' | 'Unknown';
export type Status = 'Alive' | 'Dead' | 'Unknown';
// Define the site config table schema // Define the site config table schema
export const config = sqliteTable('config', { export const config = sqliteTable('config', {
key: text('key').primaryKey(), key: text('key').primaryKey(),
@@ -44,7 +46,7 @@ export const character = sqliteTable('character', {
firstAppearance: integer('firstAppearance').notNull(), firstAppearance: integer('firstAppearance').notNull(),
pictureUrl: text('pictureUrl'), pictureUrl: text('pictureUrl'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(), epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
status: text('status'), status: text('status').$type<Status | null>(),
arcId: text('arcId').references(() => arc.id), arcId: text('arcId').references(() => arc.id),
url: text('url'), url: text('url'),
isInDailyMode: integer('isInDailyMode', { mode: 'boolean' }).default(true) isInDailyMode: integer('isInDailyMode', { mode: 'boolean' }).default(true)
@@ -67,7 +69,7 @@ export const characterOverride = sqliteTable('characterOverride', {
firstAppearance: integer('firstAppearance'), firstAppearance: integer('firstAppearance'),
pictureUrl: text('pictureUrl'), pictureUrl: text('pictureUrl'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(), epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
status: text('status'), status: text('status').$type<Status | null>(),
arcId: text('arcId').references(() => arc.id), arcId: text('arcId').references(() => arc.id),
url: text('url'), url: text('url'),
notes: text('notes') notes: text('notes')
@@ -90,7 +92,7 @@ export const characterScrapeValidation = sqliteTable('characterScrapeValidation'
firstAppearance: integer('firstAppearance').notNull(), firstAppearance: integer('firstAppearance').notNull(),
pictureUrl: text('pictureUrl'), pictureUrl: text('pictureUrl'),
epithets: text('epithets', { mode: 'json' }).$type<string[]>(), epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
status: text('status'), status: text('status').$type<Status | null>(),
arcId: text('arcId').references(() => arc.id), arcId: text('arcId').references(() => arc.id),
url: text('url') url: text('url')
}); });

View File

@@ -1,5 +1,64 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { character, characterScrapeValidation } from '$lib/server/db/schema'; import { character, characterScrapeValidation } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
async function upsertCharacterFromScrapeValidation(characterId: string): Promise<boolean> {
const [scraped] = await db
.select()
.from(characterScrapeValidation)
.where(eq(characterScrapeValidation.id, characterId));
if (!scraped) {
return false;
}
await db
.insert(character)
.values({
id: scraped.id,
name: scraped.name,
gender: scraped.gender,
age: scraped.age,
affiliations: scraped.affiliations,
devilFruitId: scraped.devilFruitId,
hakiObservation: scraped.hakiObservation,
hakiArmament: scraped.hakiArmament,
hakiConqueror: scraped.hakiConqueror,
bounty: scraped.bounty,
height: scraped.height,
origin: scraped.origin,
firstAppearance: scraped.firstAppearance,
pictureUrl: scraped.pictureUrl,
epithets: scraped.epithets,
status: scraped.status,
arcId: scraped.arcId,
url: scraped.url
})
.onConflictDoUpdate({
target: character.id,
set: {
name: scraped.name,
gender: scraped.gender,
age: scraped.age,
affiliations: scraped.affiliations,
devilFruitId: scraped.devilFruitId,
hakiObservation: scraped.hakiObservation,
hakiArmament: scraped.hakiArmament,
hakiConqueror: scraped.hakiConqueror,
bounty: scraped.bounty,
height: scraped.height,
origin: scraped.origin,
firstAppearance: scraped.firstAppearance,
pictureUrl: scraped.pictureUrl,
epithets: scraped.epithets,
status: scraped.status,
arcId: scraped.arcId,
url: scraped.url
}
});
return true;
}
export async function load() { export async function load() {
// Get all characters from both tables // Get all characters from both tables
@@ -86,3 +145,37 @@ export async function load() {
}) })
}; };
} }
export const actions = {
acceptOne: async ({ request }) => {
const formData = await request.formData();
const characterId = formData.get('characterId');
if (!characterId || typeof characterId !== 'string') {
return { success: false, message: 'characterId is required' };
}
const applied = await upsertCharacterFromScrapeValidation(characterId);
return {
success: applied,
message: applied ? 'Character applied successfully' : 'Character not found in scrape validation table'
};
},
acceptAll: async () => {
const scrapedCharacters = await db.select().from(characterScrapeValidation);
let appliedCount = 0;
for (const scraped of scrapedCharacters) {
const applied = await upsertCharacterFromScrapeValidation(scraped.id);
if (applied) {
appliedCount++;
}
}
return {
success: true,
appliedCount
};
}
};

View File

@@ -6,6 +6,11 @@
const newCharacters = $derived(data.changes.filter((c: any) => c.type === 'new')); const newCharacters = $derived(data.changes.filter((c: any) => c.type === 'new'));
const modifiedCharacters = $derived(data.changes.filter((c: any) => c.type === 'modified')); const modifiedCharacters = $derived(data.changes.filter((c: any) => c.type === 'modified'));
function fandomUrl(path: string | null | undefined): string {
if (!path) return 'https://onepiece.fandom.com/fr/wiki';
return `https://onepiece.fandom.com/fr/wiki/${path}`;
}
function formatValue(value: any): string { function formatValue(value: any): string {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return '—'; return '—';
@@ -35,6 +40,16 @@
<div> <div>
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 mb-2">Character Changes</h1> <h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 mb-2">Character Changes</h1>
<p class="text-gray-400">Total changes: {newCharacters.length} new, {modifiedCharacters.length} modified</p> <p class="text-gray-400">Total changes: {newCharacters.length} new, {modifiedCharacters.length} modified</p>
{#if newCharacters.length + modifiedCharacters.length > 0}
<form method="POST" action="?/acceptAll" class="mt-4">
<button
type="submit"
class="rounded-full border border-emerald-300/40 bg-emerald-500/20 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-500/30"
>
✅ Accepter tous les changements
</button>
</form>
{/if}
</div> </div>
<!-- New Characters Section --> <!-- New Characters Section -->
@@ -46,19 +61,32 @@
<div class="grid gap-4"> <div class="grid gap-4">
{#each newCharacters as change (change.id)} {#each newCharacters as change (change.id)}
<div class="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4 space-y-3"> <div class="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if change.scraped.pictureUrl} {#if change.scraped.pictureUrl}
<a href={fandomUrl(change.scraped.url)} target="_blank" rel="noopener noreferrer">
<img <img
src={change.scraped.pictureUrl} src={change.scraped.pictureUrl}
alt={change.scraped.name} alt={change.scraped.name}
class="w-12 h-12 rounded object-cover" class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
/> />
</a>
{/if} {/if}
<div> <div>
<h3 class="font-bold text-emerald-300">{change.scraped.name}</h3> <h3 class="font-bold text-emerald-300">{change.scraped.name}</h3>
<p class="text-sm text-gray-500">{change.id}</p> <p class="text-sm text-gray-500">{change.id}</p>
</div> </div>
</div> </div>
<form method="POST" action="?/acceptOne">
<input type="hidden" name="characterId" value={change.id} />
<button
type="submit"
class="rounded-full border border-emerald-300/40 bg-emerald-500/20 px-3 py-1 text-xs font-semibold text-emerald-100 transition hover:bg-emerald-500/30"
>
Accepter
</button>
</form>
</div>
<div class="grid grid-cols-2 gap-2 text-sm"> <div class="grid grid-cols-2 gap-2 text-sm">
<div> <div>
<span class="text-gray-400">Status:</span> <span class="text-gray-400">Status:</span>
@@ -92,19 +120,32 @@
<div class="grid gap-6"> <div class="grid gap-6">
{#each modifiedCharacters as change (change.id)} {#each modifiedCharacters as change (change.id)}
<div class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-4"> <div class="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-4">
<div class="flex items-center gap-3 pb-4 border-b border-amber-500/20"> <div class="flex items-center justify-between gap-3 pb-4 border-b border-amber-500/20">
<div class="flex items-center gap-3">
{#if change.current?.pictureUrl} {#if change.current?.pictureUrl}
<a href={fandomUrl(change.current?.url ?? change.scraped.url)} target="_blank" rel="noopener noreferrer">
<img <img
src={change.current.pictureUrl} src={change.current.pictureUrl}
alt={change.current.name} alt={change.current.name}
class="w-12 h-12 rounded object-cover" class="w-12 h-12 rounded object-cover hover:opacity-80 transition"
/> />
</a>
{/if} {/if}
<div> <div>
<h3 class="font-bold text-amber-300">{change.current?.name ?? change.scraped.name}</h3> <h3 class="font-bold text-amber-300">{change.current?.name ?? change.scraped.name}</h3>
<p class="text-sm text-gray-500">{change.id}</p> <p class="text-sm text-gray-500">{change.id}</p>
</div> </div>
</div> </div>
<form method="POST" action="?/acceptOne">
<input type="hidden" name="characterId" value={change.id} />
<button
type="submit"
class="rounded-full border border-amber-300/40 bg-amber-500/20 px-3 py-1 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30"
>
Accepter
</button>
</form>
</div>
{#if change.differences} {#if change.differences}
<div class="space-y-3"> <div class="space-y-3">