1 Commits

Author SHA1 Message Date
ext.jeremy.guillot@maxicoffee.domains
420c3922c2 fix(manga): corriger le conflit de shortName sur MangaDiscoverResource 2026-03-15 21:55:06 +01:00
35 changed files with 260 additions and 1110 deletions

View File

@@ -1,7 +1,7 @@
<template>
<tr class="border-t dark:border-gray-700 hover:bg-green-100 dark:hover:bg-green-900/20">
<td class="px-4 py-2 text-gray-900 dark:text-gray-100" :class="{ 'text-green-500 dark:text-green-400': chapter.isAvailable }">
<template v-if="chapter.isVolumeGroup">{{ chapter.volumeChaptersRange }}</template>
<template v-if="chapter.isVolumeGroup">Vol. {{ chapter.volume }}</template>
<template v-else>{{ String(chapter.number).padStart(2, '0') }}</template>
</td>
<td class="px-4 py-2 w-full text-left text-gray-900 dark:text-gray-100">

View File

@@ -23,11 +23,7 @@ export const useContentSourceStore = defineStore('contentSource', {
importing: false,
exporting: false,
importError: null,
exportError: null,
// Health check state
checkingHealth: false,
checkHealthError: null,
exportError: null
}),
getters: {
@@ -178,36 +174,6 @@ export const useContentSourceStore = defineStore('contentSource', {
this.currentSourceError = null;
},
// Check all scrapers health
async checkAllHealth() {
if (this.checkingHealth) return;
this.checkingHealth = true;
this.checkHealthError = null;
try {
await contentSourceRepository.checkAllHealth();
} catch (error) {
this.checkHealthError = error.message;
console.error('Erreur lors du health check:', error);
throw error;
} finally {
this.checkingHealth = false;
}
},
// Update health status of a single source (called from Mercure)
updateSourceHealth(sourceId, status, error = null) {
const index = this.sources.findIndex(s => s.id === sourceId);
if (index !== -1) {
this.sources[index] = {
...this.sources[index],
healthStatus: status,
healthLastError: error,
};
}
},
// Clear errors
clearErrors() {
this.sourcesError = null;
@@ -215,7 +181,6 @@ export const useContentSourceStore = defineStore('contentSource', {
this.saveError = null;
this.importError = null;
this.exportError = null;
this.checkHealthError = null;
}
}
});

View File

@@ -1,6 +0,0 @@
export const ScraperHealthStatus = {
UNKNOWN: 'unknown',
OK: 'ok',
KO: 'ko',
TESTING: 'testing',
};

View File

@@ -82,17 +82,6 @@ export class ApiContentSourceRepository {
}
}
/**
* Déclenche le test de santé de tous les scrapers
*/
async checkAllHealth() {
try {
await this.apiClient.post('/scraping/check-all-health', {});
} catch (error) {
throw new Error(error.response?.data?.message || 'Erreur lors du lancement du health check');
}
}
/**
* Teste une configuration de scraper
*/

View File

@@ -1,7 +1,7 @@
<template>
<div
@click="$emit('edit', source)"
class="bg-white dark:bg-gray-800 shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
class="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-shadow duration-200 cursor-pointer">
<!-- Header avec URL et icône externe -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate" :title="source.cleanBaseUrl">
@@ -20,24 +20,16 @@
<!-- Badge type de scraping -->
<span
:class="getScrapingTypeBadgeClass(source.scrapingType)"
class="px-2 py-1 text-xs font-medium">
class="px-2 py-1 text-xs font-medium rounded-md">
{{ source.scrapingType?.toLowerCase() || 'N/A' }}
</span>
<!-- Badge orientation basé sur les sélecteurs -->
<span
:class="getOrientationBadgeClass(source)"
class="px-2 py-1 text-xs font-medium">
class="px-2 py-1 text-xs font-medium rounded-md">
{{ getOrientation(source) }}
</span>
<!-- Badge health status -->
<span
:class="getHealthBadgeClass(source.healthStatus)"
class="px-2 py-1 text-xs font-medium"
:title="source.healthLastError || ''">
{{ getHealthLabel(source.healthStatus) }}
</span>
</div>
@@ -47,7 +39,6 @@
<script setup>
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/outline';
import { ScraperHealthStatus } from '../../domain/model/ScraperHealthStatus';
defineProps({
source: {
@@ -95,26 +86,4 @@ const getOrientationBadgeClass = (source) => {
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
};
const getHealthLabel = (status) => {
switch (status) {
case ScraperHealthStatus.OK: return '✓ ok';
case ScraperHealthStatus.KO: return '✗ ko';
case ScraperHealthStatus.TESTING: return '⟳ test';
default: return '? unknown';
}
};
const getHealthBadgeClass = (status) => {
switch (status) {
case ScraperHealthStatus.OK:
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
case ScraperHealthStatus.KO:
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
case ScraperHealthStatus.TESTING:
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300';
default:
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
}
};
</script>

View File

@@ -1,7 +1,17 @@
<template>
<div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<!-- Header -->
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-4 border-b border-gray-200 dark:border-gray-600 rounded-t-lg">
<div class="flex items-center space-x-2">
<Cog6ToothIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ isEditing ? 'Edit Scrapper Configuration' : 'New Scrapper Configuration' }}
</h2>
</div>
</div>
<!-- Form -->
<form @submit.prevent="handleSubmit" class="space-y-6">
<form @submit.prevent="handleSubmit" class="p-6 space-y-6">
<!-- Base URL -->
<div>
<label for="baseUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -12,12 +22,25 @@
v-model="form.baseUrl"
type="url"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="https://example.com" />
</div>
<!-- Image Selector -->
<div>
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Image Selector
</label>
<input
id="imageSelector"
v-model="form.imageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".reading-content .page-break img" />
</div>
<!-- Chapter URL Format -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div>
<label for="chapterUrlFormat" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter URL Format <span class="text-gray-500">({slug}, {chapterNumber})</span>
</label>
@@ -26,132 +49,132 @@
v-model="form.chapterUrlFormat"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="https://example.com/manga/{slug}-{chapterNumber}/" />
</div>
<!-- Selectors -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
<div>
<label for="imageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Image Selector
</label>
<input
id="imageSelector"
v-model="form.imageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".reading-content .page-break img" />
</div>
<div>
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Next Page Selector <span class="text-gray-500">(laisser vide si lecteur vertical)</span>
</label>
<input
id="nextPageSelector"
v-model="form.nextPageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".next-page" />
</div>
<div>
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter Selector <span class="text-gray-500">(requis pour le scraping Javascript)</span>
</label>
<input
id="chapterSelector"
v-model="form.chapterSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".chapter-selector" />
</div>
<!-- Next Page Selector -->
<div>
<label for="nextPageSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Next Page Selector <span class="text-gray-500">(let empty if vertical reader)</span>
</label>
<input
id="nextPageSelector"
v-model="form.nextPageSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".next-page" />
</div>
<!-- Scraping Type + Token -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4">
<div>
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Scraping Type
</label>
<select
id="scrapingType"
v-model="form.scrapingType"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
<option value="html">HTML</option>
<option value="javascript">Javascript</option>
</select>
</div>
<!-- Chapter Selector -->
<div>
<label for="chapterSelector" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Chapter Selector <span class="text-gray-500">(required for Javascript scraping)</span>
</label>
<input
id="chapterSelector"
v-model="form.chapterSelector"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder=".chapter-selector" />
</div>
<div>
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Token
</label>
<input
id="token"
v-model="form.token"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Optional authentication token" />
</div>
<!-- Scraping Type -->
<div>
<label for="scrapingType" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Scraping Type
</label>
<select
id="scrapingType"
v-model="form.scrapingType"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white">
<option value="html">HTML</option>
<option value="javascript">Javascript</option>
</select>
</div>
<!-- Token (optionnel) -->
<div>
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Token
</label>
<input
id="token"
v-model="form.token"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Optional authentication token" />
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button
type="submit"
:disabled="saving"
class="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center space-x-2">
<ArrowPathIcon v-if="saving" class="w-4 h-4 animate-spin" />
<span>{{ isEditing ? 'Update Configuration' : 'Create Configuration' }}</span>
<PencilSquareIcon v-if="!saving" class="w-4 h-4" />
</button>
</div>
<!-- Error message -->
<div v-if="error" class="border-t border-gray-200 dark:border-gray-700 pt-6 text-red-600 dark:text-red-400 text-sm">
<div v-if="error" class="text-red-600 dark:text-red-400 text-sm">
{{ error }}
</div>
</form>
<!-- Test Configuration Section -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
<div class="flex items-center space-x-2 mb-6">
<div class="border-t border-gray-200 dark:border-gray-600 p-6 bg-gray-50 dark:bg-gray-700 rounded-b-lg">
<div class="flex items-center space-x-2 mb-4">
<WrenchScrewdriverIcon class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 class="text-sm font-medium text-gray-900 dark:text-white">Configuration de test (health check)</h3>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Test Configuration</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="testSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Manga Slug <span class="text-gray-500">(enregistré)</span>
<label for="testMangaSlug" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Manga Slug
</label>
<input
id="testSlug"
v-model="form.testSlug"
id="testMangaSlug"
v-model="testData.mangaSlug"
type="text"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="manga-slug" />
</div>
<div>
<label for="testChapterNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Numéro de chapitre <span class="text-gray-500">(enregistré)</span>
Chapter Number
</label>
<input
id="testChapterNumber"
v-model="form.testChapterNumber"
v-model="testData.chapterNumber"
type="number"
step="0.1"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="1" />
</div>
</div>
<!-- Preview URL -->
<div v-if="generatedTestUrl" class="mb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">URL qui sera testée</p>
<code class="text-xs text-gray-700 dark:text-gray-300 break-all">{{ generatedTestUrl }}</code>
<!-- Preview de l'URL qui sera testée -->
<div v-if="generatedTestUrl" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md">
<div class="text-sm text-blue-800 dark:text-blue-200">
<strong>URL qui sera testée :</strong>
<div class="mt-1 font-mono text-xs break-all">{{ generatedTestUrl }}</div>
</div>
</div>
<button
type="button"
@click="testConfiguration"
:disabled="testing || !canTest"
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium transition-colors duration-200 flex items-center justify-center space-x-2">
class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-md transition-colors duration-200 flex items-center justify-center space-x-2">
<ArrowPathIcon v-if="testing" class="w-4 h-4 animate-spin" />
<PlayIcon v-else class="w-4 h-4" />
<span>Tester maintenant</span>
<span>Test Configuration</span>
</button>
</div>
</div>
@@ -160,6 +183,8 @@
<script setup>
import {
ArrowPathIcon,
Cog6ToothIcon,
PencilSquareIcon,
PlayIcon,
WrenchScrewdriverIcon
} from '@heroicons/vue/24/outline';
@@ -191,9 +216,12 @@ const form = ref({
nextPageSelector: '',
chapterSelector: '',
scrapingType: 'html',
token: '',
testSlug: '',
testChapterNumber: '',
token: ''
});
const testData = ref({
mangaSlug: '',
chapterNumber: ''
});
const testing = ref(false);
@@ -201,19 +229,20 @@ const testing = ref(false);
const canTest = computed(() => {
return form.value.baseUrl &&
form.value.chapterUrlFormat &&
form.value.testSlug &&
form.value.testChapterNumber;
testData.value.mangaSlug &&
testData.value.chapterNumber;
});
const generatedTestUrl = computed(() => {
if (!form.value.chapterUrlFormat || !form.value.testSlug || !form.value.testChapterNumber) {
if (!form.value.chapterUrlFormat || !testData.value.mangaSlug || !testData.value.chapterNumber) {
return '';
}
return form.value.chapterUrlFormat
.replace('{slug}', form.value.testSlug)
.replace('{chapterNumber}', form.value.testChapterNumber);
.replace('{slug}', testData.value.mangaSlug)
.replace('{chapterNumber}', testData.value.chapterNumber);
});
// Initialize form with source data if editing, clear if creating new
watch(() => props.source, (newSource) => {
if (newSource) {
form.value = {
@@ -223,11 +252,10 @@ watch(() => props.source, (newSource) => {
nextPageSelector: newSource.nextPageSelector || '',
chapterSelector: newSource.chapterSelector || '',
scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
token: newSource.token || '',
testSlug: newSource.testSlug || '',
testChapterNumber: newSource.testChapterNumber ?? '',
token: newSource.token || ''
};
} else {
// Reset form when no source (creating new)
form.value = {
baseUrl: '',
imageSelector: '',
@@ -235,9 +263,7 @@ watch(() => props.source, (newSource) => {
nextPageSelector: '',
chapterSelector: '',
scrapingType: 'html',
token: '',
testSlug: '',
testChapterNumber: '',
token: ''
};
}
}, { immediate: true });
@@ -246,17 +272,14 @@ const handleSubmit = () => {
emit('submit', { ...form.value });
};
defineExpose({ submitForm: handleSubmit });
const testConfiguration = async () => {
testing.value = true;
try {
await emit('test', {
configuration: { ...form.value },
testData: {
mangaSlug: form.value.testSlug,
chapterNumber: form.value.testChapterNumber,
testUrl: generatedTestUrl.value,
...testData.value,
testUrl: generatedTestUrl.value
}
});
} finally {

View File

@@ -3,54 +3,72 @@
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Scrapper Configurations
</h1>
<p class="text-gray-600 dark:text-gray-400">
Gérez les configurations de scraping pour les différentes sources de manga
</p>
</div>
<!-- Loading State -->
<div v-if="loadingSources" class="flex justify-center py-12">
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<!-- Error State -->
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
<div v-else-if="sourcesError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
<div class="flex items-center">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
<p class="text-red-800 dark:text-red-200">{{ sourcesError }}</p>
</div>
<button
@click="contentSourceStore.loadSources()"
class="mt-3 px-4 py-2 bg-red-600 text-white hover:bg-red-700">
class="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">
Réessayer
</button>
</div>
<!-- Sources Grid -->
<section v-else class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Existing Sources -->
<ContentSourceCard
v-for="source in sources"
:key="source.id"
:source="source"
@edit="editSource"
@open-link="openSourceLink" />
<!-- Debug Info (temporary) -->
<div v-if="!loadingSources && !sourcesError && sources.length === 0" class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
<p class="text-blue-800 dark:text-blue-200">Aucune source trouvée. Rechargement en cours...</p>
<button
@click="contentSourceStore.loadSources()"
class="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Actualiser
</button>
</div>
<!-- Add New Configuration Card -->
<div
@click="addNewSource"
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
Add New Configuration
</span>
</div>
<!-- Sources Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Existing Sources -->
<ContentSourceCard
v-for="source in sources"
:key="source.id"
:source="source"
@edit="editSource"
@open-link="openSourceLink" />
<!-- Add New Configuration Card -->
<div
@click="addNewSource"
class="bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer flex flex-col items-center justify-center h-full">
<PlusIcon class="w-8 h-8 text-gray-400 dark:text-gray-500 mb-3" />
<span class="text-lg font-medium text-gray-600 dark:text-gray-400 mb-2">
Add New Configuration
</span>
</div>
</section>
</div>
<!-- Import/Export Success Messages -->
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
<div v-if="showImportSuccess" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
Configuration importée avec succès !
</div>
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 shadow-lg">
<div v-if="showExportSuccess" class="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
Configuration exportée !
</div>
</div>
@@ -58,12 +76,12 @@
<!-- Import Modal -->
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-md">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Importer des configurations</h3>
<textarea
v-model="importData"
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
class="w-full h-40 p-3 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
placeholder="Collez ici le JSON des configurations à importer..."></textarea>
<div class="flex justify-end space-x-3 mt-4">
@@ -75,7 +93,7 @@
<button
@click="handleImport"
:disabled="importing || !importData.trim()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white">
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-md">
{{ importing ? 'Import...' : 'Importer' }}
</button>
</div>
@@ -91,11 +109,10 @@ import {
ArrowPathIcon,
ArrowUpTrayIcon,
ExclamationTriangleIcon,
HeartIcon,
PlusIcon
} from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore';
@@ -109,13 +126,9 @@ const {
loadingSources,
sourcesError,
importing,
exporting,
checkingHealth,
exporting
} = storeToRefs(contentSourceStore);
// Mercure — écoute des mises à jour health
let mercureEventSource = null;
// Local state
const showImportModal = ref(false);
const showExportSuccess = ref(false);
@@ -125,45 +138,40 @@ const importData = ref('');
// Load sources on mount and clear current source
onMounted(async () => {
try {
contentSourceStore.clearCurrentSource();
contentSourceStore.clearErrors();
contentSourceStore.clearCurrentSource(); // Clear any previously loaded source
contentSourceStore.clearErrors(); // Clear any previous errors
await contentSourceStore.loadSources();
} catch (error) {
console.error('Erreur lors du chargement des sources:', error);
}
// Écoute Mercure pour les mises à jour de health status
const url = new URL('/.well-known/mercure', window.location.href);
sources.value.forEach(source => {
url.searchParams.append('topic', `scrapers/health/${source.id}`);
});
mercureEventSource = new EventSource(url.toString());
mercureEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
contentSourceStore.updateSourceHealth(data.sourceId, data.status, data.error);
} catch (e) {
console.error('Erreur parsing Mercure event:', e);
}
};
});
onUnmounted(() => {
mercureEventSource?.close();
});
// Toolbar configuration
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'label', text: 'Scrapers', class: 'text-sm font-medium' },
{
icon: ArrowPathIcon,
label: 'Actualiser',
type: 'button',
onClick: () => contentSourceStore.loadSources(),
active: loadingSources.value
}
],
rightSection: [
{ type: 'button', icon: ArrowPathIcon, label: 'Actualiser', onClick: () => contentSourceStore.loadSources(), disabled: loadingSources.value },
{ type: 'button', icon: HeartIcon, label: 'Tester tous', onClick: handleCheckAllHealth, disabled: checkingHealth.value },
{ type: 'button', icon: ArrowDownTrayIcon, label: 'Exporter', onClick: handleExport, disabled: exporting.value },
{ type: 'button', icon: ArrowUpTrayIcon, label: 'Importer', onClick: () => showImportModal.value = true },
],
{
icon: ArrowDownTrayIcon,
label: 'Exporter',
type: 'button',
onClick: handleExport,
disabled: exporting.value
},
{
icon: ArrowUpTrayIcon,
label: 'Importer',
type: 'button',
onClick: () => showImportModal.value = true
}
]
}));
// Actions
@@ -182,14 +190,6 @@ const openSourceLink = (url) => {
window.open(url, '_blank');
};
async function handleCheckAllHealth() {
try {
await contentSourceStore.checkAllHealth();
} catch (error) {
console.error('Erreur lors du health check:', error);
}
}
async function handleExport() {
try {
const exportData = await contentSourceStore.exportSources();

View File

@@ -3,36 +3,43 @@
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="px-6 py-8">
<section class="border-t border-gray-200 dark:border-gray-700 pt-6">
<!-- Loading State -->
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
<div class="animate-spin h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<div class="container mx-auto px-4 py-6">
<!-- Back Navigation -->
<div class="mb-6">
<button
@click="goBack"
class="flex items-center space-x-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
<ArrowLeftIcon class="w-5 h-5" />
<span>Retour aux configurations</span>
</button>
</div>
<!-- Error State -->
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-6">
<div class="flex items-center">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
</div>
</div>
<!-- Loading State -->
<div v-if="loadingCurrentSource" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<!-- Form -->
<div v-else>
<ContentSourceForm
ref="formRef"
:source="currentSource"
:saving="saving"
:error="saveError"
@submit="handleSubmit"
@test="handleTest" />
<!-- Error State -->
<div v-else-if="currentSourceError" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-6">
<div class="flex items-center">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400 mr-2" />
<p class="text-red-800 dark:text-red-200">{{ currentSourceError }}</p>
</div>
</section>
</div>
<!-- Form -->
<div v-else class="max-w-4xl mx-auto">
<ContentSourceForm
:source="currentSource"
:saving="saving"
:error="saveError"
@submit="handleSubmit"
@test="handleTest" />
</div>
<!-- Test Results Modal -->
<div v-if="showTestResults" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-600">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Résultats du test</h3>
@@ -47,7 +54,7 @@
<div class="p-6 overflow-y-auto">
<!-- Loading state during test -->
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
<div class="animate-spin h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mr-3"></div>
<span class="text-gray-600">Test en cours...</span>
</div>
@@ -58,7 +65,7 @@
<span class="font-medium">Test réussi !</span>
</div>
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 p-4">
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
@@ -85,11 +92,10 @@
<img
:src="imageUrl"
:alt="`Image ${index + 1}`"
class="w-full h-32 object-cover border border-gray-200 dark:border-gray-600"
referrerpolicy="no-referrer"
class="w-full h-32 object-cover rounded border border-gray-200 dark:border-gray-600"
@error="handleImageError"
@load="handleImageLoad" />
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity rounded flex items-center justify-center">
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
Page {{ index + 1 }}
</span>
@@ -101,7 +107,7 @@
</p>
</div>
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 p-4">
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
<div class="flex items-center">
<ExclamationTriangleIcon class="w-5 h-5 text-yellow-400 mr-2" />
<p class="text-yellow-800 dark:text-yellow-200">
@@ -119,7 +125,7 @@
<span class="font-medium">Test échoué</span>
</div>
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-4 mb-4">
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
<div class="text-sm text-red-800 dark:text-red-200">
<div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
<div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
@@ -132,14 +138,14 @@
<div
v-for="(error, index) in testResults.errors"
:key="index"
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4">
class="bg-red-100 dark:bg-red-800 border-l-4 border-red-400 p-4 rounded">
<div class="flex items-start">
<div class="flex-shrink-0">
<ExclamationTriangleIcon class="w-5 h-5 text-red-400" />
</div>
<div class="ml-3 flex-1">
<div class="flex items-center mb-1">
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-200 text-red-800 dark:bg-red-700 dark:text-red-200 mr-2">
{{ formatErrorType(error.type) }}
</span>
<span class="text-sm font-medium text-red-800 dark:text-red-200">
@@ -149,7 +155,7 @@
<p class="text-sm text-red-700 dark:text-red-300 mb-2">
{{ error.message }}
</p>
<div class="bg-red-50 dark:bg-red-900 p-2">
<div class="bg-red-50 dark:bg-red-900 rounded p-2">
<p class="text-xs text-red-600 dark:text-red-400">
<strong>Suggestion :</strong> {{ error.suggestion }}
</p>
@@ -160,7 +166,7 @@
</div>
<!-- Generic Error -->
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 p-3">
<div v-else-if="testResults.error" class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded p-3">
<code class="text-sm text-red-800 dark:text-red-200">
{{ testResults.error }}
</code>
@@ -171,7 +177,7 @@
</div>
<!-- Success Message -->
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg">
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
</div>
</div>
@@ -184,7 +190,6 @@ import {
ArrowLeftIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
PencilSquareIcon,
XCircleIcon,
XMarkIcon
} from '@heroicons/vue/24/outline';
@@ -209,9 +214,6 @@ const {
saveError
} = storeToRefs(contentSourceStore);
// Form ref
const formRef = ref(null);
// Local state
const showTestResults = ref(false);
const showSuccessMessage = ref(false);
@@ -231,18 +233,16 @@ onMounted(async () => {
});
// Toolbar configuration
const toolbarConfig = computed(() => ({
leftSection: [
{ type: 'button', icon: ArrowLeftIcon, label: 'Retour', onClick: () => router.push({ name: 'scrapper-configurations' }) },
{ type: 'divider' },
{ type: 'label', text: isEditing.value ? 'Modifier la configuration' : 'Nouvelle configuration', class: 'text-sm font-medium' },
],
rightSection: [
{ type: 'button', icon: PencilSquareIcon, label: isEditing.value ? 'Mettre à jour' : 'Créer', onClick: () => formRef.value?.submitForm(), disabled: saving.value },
],
}));
const toolbarConfig = {
leftSection: [],
rightSection: []
};
// Actions
const goBack = () => {
router.push({ name: 'scrapper-configurations' });
};
const handleSubmit = async (formData) => {
try {
if (isEditing.value) {
@@ -279,11 +279,6 @@ const handleTest = async ({ configuration, testData }) => {
testResults.value = {};
try {
// Persister testSlug + testChapterNumber avant de lancer le test
if (isEditing.value) {
await contentSourceStore.updateSource(route.params.id, configuration);
}
// Préparer les données selon le format de l'API
const testConfiguration = {
baseUrl: configuration.baseUrl,

View File

@@ -180,13 +180,6 @@ services:
tags:
- { name: messenger.message_handler, bus: command.bus }
# Scraper Health Check
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface:
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface:
alias: App\Domain\Setting\Infrastructure\Persistence\Repository\DoctrineContentSourceForHealthCheckRepository
# Import Domain Services
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260315221706 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE content_source ADD test_slug VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE content_source ADD test_chapter_number DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE content_source ADD health_status VARCHAR(20) DEFAULT \'unknown\' NOT NULL');
$this->addSql('ALTER TABLE content_source ADD health_last_tested_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE content_source ADD health_last_error TEXT DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE content_source DROP test_slug');
$this->addSql('ALTER TABLE content_source DROP test_chapter_number');
$this->addSql('ALTER TABLE content_source DROP health_status');
$this->addSql('ALTER TABLE content_source DROP health_last_tested_at');
$this->addSql('ALTER TABLE content_source DROP health_last_error');
}
}

View File

@@ -1,7 +0,0 @@
<?php
namespace App\Domain\Scraping\Application\Command;
readonly class CheckAllScrapersHealth
{
}

View File

@@ -1,64 +0,0 @@
<?php
namespace App\Domain\Scraping\Application\CommandHandler;
use App\Domain\Scraping\Application\Command\CheckAllScrapersHealth;
use App\Domain\Scraping\Application\Command\TestScraperConfiguration;
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface;
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface;
use Psr\Log\LoggerInterface;
readonly class CheckAllScrapersHealthHandler
{
public function __construct(
private ContentSourceForHealthCheckInterface $contentSourceForHealthCheckRepo,
private ContentSourceHealthRepositoryInterface $contentSourceHealthRepo,
private TestScraperConfigurationHandler $testScraperConfigurationHandler,
private LoggerInterface $logger,
) {
}
public function handle(CheckAllScrapersHealth $command): void
{
$sources = $this->contentSourceForHealthCheckRepo->getAll();
foreach ($sources as $source) {
if ($source->testSlug === null || $source->testChapterNumber === null) {
$this->logger->warning('ContentSource {id} has no test config, skipping health check.', ['id' => $source->id]);
continue;
}
try {
$this->contentSourceHealthRepo->markAsTesting($source->id);
$testUrl = str_replace(
['{slug}', '{chapterNumber}'],
[$source->testSlug, $source->testChapterNumber],
$source->chapterUrlFormat
);
$testCommand = new TestScraperConfiguration(
baseUrl: $source->baseUrl,
chapterUrlFormat: $source->chapterUrlFormat,
scrapingType: $source->scrapingType,
testUrl: $testUrl,
mangaSlug: $source->testSlug,
chapterNumber: $source->testChapterNumber,
imageSelector: $source->imageSelector,
nextPageSelector: $source->nextPageSelector,
chapterSelector: $source->chapterSelector,
);
$response = $this->testScraperConfigurationHandler->handle($testCommand);
if ($response->success) {
$this->contentSourceHealthRepo->markAsHealthy($source->id, new \DateTimeImmutable());
} else {
$firstError = $response->errors[0]['message'] ?? 'Erreur inconnue';
$this->contentSourceHealthRepo->markAsUnhealthy($source->id, new \DateTimeImmutable(), $firstError);
}
} catch (\Exception $e) {
$this->contentSourceHealthRepo->markAsUnhealthy($source->id, new \DateTimeImmutable(), $e->getMessage());
}
}
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Domain\Scraping\Domain\Contract\Repository;
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
interface ContentSourceForHealthCheckInterface
{
/** @return ContentSourceHealthCheckData[] */
public function getAll(): array;
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Domain\Scraping\Domain\Contract\Repository;
interface ContentSourceHealthRepositoryInterface
{
public function markAsTesting(int $sourceId): void;
public function markAsHealthy(int $sourceId, \DateTimeImmutable $testedAt): void;
public function markAsUnhealthy(int $sourceId, \DateTimeImmutable $testedAt, string $error): void;
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Domain\Scraping\Domain\Model\ValueObject;
readonly class ContentSourceHealthCheckData
{
public function __construct(
public int $id,
public string $baseUrl,
public string $chapterUrlFormat,
public string $scrapingType,
public ?string $imageSelector,
public ?string $nextPageSelector,
public ?string $chapterSelector,
public ?string $testSlug,
public ?float $testChapterNumber,
) {
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor\CheckAllScrapersHealthStateProcessor;
#[ApiResource(
shortName: 'Scraping',
operations: [
new Post(
uriTemplate: '/scraping/check-all-health',
processor: CheckAllScrapersHealthStateProcessor::class,
output: false,
status: 202,
description: 'Déclenche le test de santé de tous les scrapers configurés avec testSlug',
),
]
)]
class CheckAllScrapersHealthResource
{
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Scraping\Application\Command\CheckAllScrapersHealth;
use App\Domain\Scraping\Application\CommandHandler\CheckAllScrapersHealthHandler;
readonly class CheckAllScrapersHealthStateProcessor implements ProcessorInterface
{
public function __construct(
private CheckAllScrapersHealthHandler $handler,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
$this->handler->handle(new CheckAllScrapersHealth());
return null;
}
}

View File

@@ -20,14 +20,7 @@ readonly class ImageDownloader implements ImageDownloaderInterface
public function download(string $url, string $destination): void
{
$urlParts = parse_url($url);
$referer = ($urlParts['scheme'] ?? 'https') . '://' . ($urlParts['host'] ?? '');
$response = $this->httpClient->request('GET', $url, [
'headers' => [
'Referer' => $referer,
],
]);
$response = $this->httpClient->request('GET', $url);
$contentType = $response->getHeaders()['content-type'][0] ?? '';
if (!str_starts_with($contentType, 'image/')) {

View File

@@ -12,8 +12,6 @@ readonly class UpsertContentSourceCommand
public ?string $imageSelector = null,
public ?string $nextPageSelector = null,
public ?string $chapterSelector = null,
public ?string $testSlug = null,
public ?float $testChapterNumber = null,
) {
}
}

View File

@@ -26,8 +26,6 @@ readonly class UpsertContentSourceCommandHandler
imageSelector: $command->imageSelector,
nextPageSelector: $command->nextPageSelector,
chapterSelector: $command->chapterSelector,
testSlug: $command->testSlug,
testChapterNumber: $command->testChapterNumber,
);
$this->contentSourceRepository->save($contentSource);
}
@@ -40,8 +38,6 @@ readonly class UpsertContentSourceCommandHandler
imageSelector: $command->imageSelector,
nextPageSelector: $command->nextPageSelector,
chapterSelector: $command->chapterSelector,
testSlug: $command->testSlug,
testChapterNumber: $command->testChapterNumber,
);
$this->contentSourceRepository->save($contentSource);
}

View File

@@ -15,11 +15,6 @@ readonly class ContentSourceResponse
public ?string $nextPageSelector,
public ?string $chapterSelector,
public string $cleanBaseUrl,
public ?string $testSlug = null,
public ?float $testChapterNumber = null,
public string $healthStatus = 'unknown',
public ?\DateTimeImmutable $healthLastTestedAt = null,
public ?string $healthLastError = null,
) {
}
@@ -34,11 +29,6 @@ readonly class ContentSourceResponse
nextPageSelector: $contentSource->getNextPageSelector(),
chapterSelector: $contentSource->getChapterSelector(),
cleanBaseUrl: $contentSource->getCleanBaseUrl(),
testSlug: $contentSource->getTestSlug(),
testChapterNumber: $contentSource->getTestChapterNumber(),
healthStatus: $contentSource->getHealthStatus(),
healthLastTestedAt: $contentSource->getHealthLastTestedAt(),
healthLastError: $contentSource->getHealthLastError(),
);
}
}

View File

@@ -12,11 +12,6 @@ final class ContentSource
private ?string $imageSelector = null,
private ?string $nextPageSelector = null,
private ?string $chapterSelector = null,
private ?string $testSlug = null,
private ?float $testChapterNumber = null,
private string $healthStatus = 'unknown',
private ?\DateTimeImmutable $healthLastTestedAt = null,
private ?string $healthLastError = null,
) {
}
@@ -55,44 +50,6 @@ final class ContentSource
return $this->chapterSelector;
}
public function getTestSlug(): ?string
{
return $this->testSlug;
}
public function getTestChapterNumber(): ?float
{
return $this->testChapterNumber;
}
public function getHealthStatus(): string
{
return $this->healthStatus;
}
public function getHealthLastTestedAt(): ?\DateTimeImmutable
{
return $this->healthLastTestedAt;
}
public function getHealthLastError(): ?string
{
return $this->healthLastError;
}
public function updateTestConfig(?string $testSlug, ?float $testChapterNumber): void
{
$this->testSlug = $testSlug;
$this->testChapterNumber = $testChapterNumber;
}
public function updateHealthStatus(string $status, ?\DateTimeImmutable $testedAt = null, ?string $error = null): void
{
$this->healthStatus = $status;
$this->healthLastTestedAt = $testedAt;
$this->healthLastError = $error;
}
public function updateId(int $id): void
{
$this->id = $id;
@@ -114,8 +71,6 @@ final class ContentSource
?string $imageSelector = null,
?string $nextPageSelector = null,
?string $chapterSelector = null,
?string $testSlug = null,
?float $testChapterNumber = null,
): self {
return new self(
id: null,
@@ -125,8 +80,6 @@ final class ContentSource
imageSelector: $imageSelector,
nextPageSelector: $nextPageSelector,
chapterSelector: $chapterSelector,
testSlug: $testSlug,
testChapterNumber: $testChapterNumber,
);
}
@@ -137,8 +90,6 @@ final class ContentSource
?string $imageSelector = null,
?string $nextPageSelector = null,
?string $chapterSelector = null,
?string $testSlug = null,
?float $testChapterNumber = null,
): void {
$this->baseUrl = $baseUrl;
$this->chapterUrlFormat = $chapterUrlFormat;
@@ -146,7 +97,5 @@ final class ContentSource
$this->imageSelector = $imageSelector;
$this->nextPageSelector = $nextPageSelector;
$this->chapterSelector = $chapterSelector;
$this->testSlug = $testSlug;
$this->testChapterNumber = $testChapterNumber;
}
}

View File

@@ -30,11 +30,6 @@ class GetContentSourceResource
public readonly ?string $nextPageSelector,
public readonly ?string $chapterSelector,
public readonly string $cleanBaseUrl,
public readonly ?string $testSlug = null,
public readonly ?float $testChapterNumber = null,
public readonly string $healthStatus = 'unknown',
public readonly ?\DateTimeImmutable $healthLastTestedAt = null,
public readonly ?string $healthLastError = null,
) {
}
}

View File

@@ -43,8 +43,6 @@ class UpsertContentSourceResource
public readonly ?string $imageSelector = null,
public readonly ?string $nextPageSelector = null,
public readonly ?string $chapterSelector = null,
public readonly ?string $testSlug = null,
public readonly ?float $testChapterNumber = null,
) {
}
}

View File

@@ -30,8 +30,6 @@ readonly class UpsertContentSourceStateProcessor implements ProcessorInterface
imageSelector: $data->imageSelector,
nextPageSelector: $data->nextPageSelector,
chapterSelector: $data->chapterSelector,
testSlug: $data->testSlug,
testChapterNumber: $data->testChapterNumber,
);
$this->handler->handle($command);

View File

@@ -32,11 +32,6 @@ readonly class GetContentSourceStateProvider implements ProviderInterface
nextPageSelector: $response->nextPageSelector,
chapterSelector: $response->chapterSelector,
cleanBaseUrl: $response->cleanBaseUrl,
testSlug: $response->testSlug,
testChapterNumber: $response->testChapterNumber,
healthStatus: $response->healthStatus,
healthLastTestedAt: $response->healthLastTestedAt,
healthLastError: $response->healthLastError,
);
} catch (ContentSourceNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());

View File

@@ -30,11 +30,6 @@ readonly class ListContentSourceStateProvider implements ProviderInterface
nextPageSelector: $contentSourceResponse->nextPageSelector,
chapterSelector: $contentSourceResponse->chapterSelector,
cleanBaseUrl: $contentSourceResponse->cleanBaseUrl,
testSlug: $contentSourceResponse->testSlug,
testChapterNumber: $contentSourceResponse->testChapterNumber,
healthStatus: $contentSourceResponse->healthStatus,
healthLastTestedAt: $contentSourceResponse->healthLastTestedAt,
healthLastError: $contentSourceResponse->healthLastError,
),
$response->contentSources
);

View File

@@ -17,11 +17,6 @@ readonly class ContentSourceMapper
imageSelector: $entity->getImageSelector(),
nextPageSelector: $entity->getNextPageSelector(),
chapterSelector: $entity->getChapterSelector(),
testSlug: $entity->getTestSlug(),
testChapterNumber: $entity->getTestChapterNumber(),
healthStatus: $entity->getHealthStatus(),
healthLastTestedAt: $entity->getHealthLastTestedAt(),
healthLastError: $entity->getHealthLastError(),
);
}
@@ -34,12 +29,7 @@ readonly class ContentSourceMapper
->setScrapingType($contentSource->getScrapingType())
->setImageSelector($contentSource->getImageSelector())
->setNextPageSelector($contentSource->getNextPageSelector())
->setChapterSelector($contentSource->getChapterSelector())
->setTestSlug($contentSource->getTestSlug())
->setTestChapterNumber($contentSource->getTestChapterNumber())
->setHealthStatus($contentSource->getHealthStatus())
->setHealthLastTestedAt($contentSource->getHealthLastTestedAt())
->setHealthLastError($contentSource->getHealthLastError());
->setChapterSelector($contentSource->getChapterSelector());
return $entity;
}
@@ -51,12 +41,7 @@ readonly class ContentSourceMapper
->setScrapingType($contentSource->getScrapingType())
->setImageSelector($contentSource->getImageSelector())
->setNextPageSelector($contentSource->getNextPageSelector())
->setChapterSelector($contentSource->getChapterSelector())
->setTestSlug($contentSource->getTestSlug())
->setTestChapterNumber($contentSource->getTestChapterNumber())
->setHealthStatus($contentSource->getHealthStatus())
->setHealthLastTestedAt($contentSource->getHealthLastTestedAt())
->setHealthLastError($contentSource->getHealthLastError());
->setChapterSelector($contentSource->getChapterSelector());
return $entity;
}

View File

@@ -1,102 +0,0 @@
<?php
namespace App\Domain\Setting\Infrastructure\Persistence\Repository;
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface;
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface;
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
use App\Entity\ContentSource as ContentSourceEntity;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
readonly class DoctrineContentSourceForHealthCheckRepository implements ContentSourceForHealthCheckInterface, ContentSourceHealthRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private HubInterface $hub,
private LoggerInterface $logger,
) {
}
public function getAll(): array
{
$entities = $this->entityManager->getRepository(ContentSourceEntity::class)->findAll();
return array_map(
fn (ContentSourceEntity $entity) => new ContentSourceHealthCheckData(
id: $entity->getId(),
baseUrl: $entity->getBaseUrl(),
chapterUrlFormat: $entity->getChapterUrlFormat(),
scrapingType: $entity->getScrapingType(),
imageSelector: $entity->getImageSelector(),
nextPageSelector: $entity->getNextPageSelector(),
chapterSelector: $entity->getChapterSelector(),
testSlug: $entity->getTestSlug(),
testChapterNumber: $entity->getTestChapterNumber(),
),
$entities
);
}
public function markAsTesting(int $sourceId): void
{
$entity = $this->entityManager->find(ContentSourceEntity::class, $sourceId);
if (!$entity) {
return;
}
$entity->setHealthStatus('testing')
->setHealthLastError(null);
$this->entityManager->flush();
$this->publishUpdate($sourceId, 'testing', null);
}
public function markAsHealthy(int $sourceId, \DateTimeImmutable $testedAt): void
{
$entity = $this->entityManager->find(ContentSourceEntity::class, $sourceId);
if (!$entity) {
return;
}
$entity->setHealthStatus('ok')
->setHealthLastTestedAt($testedAt)
->setHealthLastError(null);
$this->entityManager->flush();
$this->publishUpdate($sourceId, 'ok', null);
}
public function markAsUnhealthy(int $sourceId, \DateTimeImmutable $testedAt, string $error): void
{
$entity = $this->entityManager->find(ContentSourceEntity::class, $sourceId);
if (!$entity) {
return;
}
$entity->setHealthStatus('ko')
->setHealthLastTestedAt($testedAt)
->setHealthLastError($error);
$this->entityManager->flush();
$this->publishUpdate($sourceId, 'ko', $error);
}
private function publishUpdate(int $sourceId, string $status, ?string $error): void
{
try {
$this->hub->publish(new Update(
"scrapers/health/{$sourceId}",
json_encode(['sourceId' => $sourceId, 'status' => $status, 'error' => $error])
));
} catch (\Throwable $e) {
$this->logger->warning('Mercure publish failed for scraper health update', [
'sourceId' => $sourceId,
'status' => $status,
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -36,21 +36,6 @@ class ContentSource
#[ORM\Column(length: 255, nullable: true)]
private ?string $ChapterSelector = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $testSlug = null;
#[ORM\Column(nullable: true)]
private ?float $testChapterNumber = null;
#[ORM\Column(length: 20, options: ['default' => 'unknown'])]
private string $healthStatus = 'unknown';
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $healthLastTestedAt = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $healthLastError = null;
public function getId(): ?int
{
return $this->id;
@@ -134,66 +119,6 @@ class ContentSource
return $this;
}
public function getTestSlug(): ?string
{
return $this->testSlug;
}
public function setTestSlug(?string $testSlug): static
{
$this->testSlug = $testSlug;
return $this;
}
public function getTestChapterNumber(): ?float
{
return $this->testChapterNumber;
}
public function setTestChapterNumber(?float $testChapterNumber): static
{
$this->testChapterNumber = $testChapterNumber;
return $this;
}
public function getHealthStatus(): string
{
return $this->healthStatus;
}
public function setHealthStatus(string $healthStatus): static
{
$this->healthStatus = $healthStatus;
return $this;
}
public function getHealthLastTestedAt(): ?\DateTimeImmutable
{
return $this->healthLastTestedAt;
}
public function setHealthLastTestedAt(?\DateTimeImmutable $healthLastTestedAt): static
{
$this->healthLastTestedAt = $healthLastTestedAt;
return $this;
}
public function getHealthLastError(): ?string
{
return $this->healthLastError;
}
public function setHealthLastError(?string $healthLastError): static
{
$this->healthLastError = $healthLastError;
return $this;
}
public function getCleanBaseUrl(): string
{
return preg_replace(

View File

@@ -3,12 +3,13 @@
namespace App\Scheduler;
use App\Message\RefreshAndDownloadChapters;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
// Désactivé : remplacé par MonitoringSchedule (DDD) dans src/Domain/Manga/Infrastructure/Scheduler/
#[AsSchedule]
class MainSchedule implements ScheduleProviderInterface
{
public function __construct(private CacheInterface $cache)

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Tests\Domain\Scraping\Adapter;
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceForHealthCheckInterface;
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
class InMemoryContentSourceForHealthCheckRepository implements ContentSourceForHealthCheckInterface
{
/** @var ContentSourceHealthCheckData[] */
private array $sources = [];
public function add(ContentSourceHealthCheckData $data): void
{
$this->sources[] = $data;
}
public function getAll(): array
{
return $this->sources;
}
public function clear(): void
{
$this->sources = [];
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Tests\Domain\Scraping\Adapter;
use App\Domain\Scraping\Domain\Contract\Repository\ContentSourceHealthRepositoryInterface;
class InMemoryContentSourceHealthRepository implements ContentSourceHealthRepositoryInterface
{
/** @var array<int, array{status: string, testedAt: ?\DateTimeImmutable, error: ?string}> */
private array $statuses = [];
public function markAsTesting(int $sourceId): void
{
$this->statuses[$sourceId] = ['status' => 'testing', 'testedAt' => null, 'error' => null];
}
public function markAsHealthy(int $sourceId, \DateTimeImmutable $testedAt): void
{
$this->statuses[$sourceId] = ['status' => 'ok', 'testedAt' => $testedAt, 'error' => null];
}
public function markAsUnhealthy(int $sourceId, \DateTimeImmutable $testedAt, string $error): void
{
$this->statuses[$sourceId] = ['status' => 'ko', 'testedAt' => $testedAt, 'error' => $error];
}
public function getStatus(int $sourceId): ?string
{
return $this->statuses[$sourceId]['status'] ?? null;
}
public function getError(int $sourceId): ?string
{
return $this->statuses[$sourceId]['error'] ?? null;
}
public function clear(): void
{
$this->statuses = [];
}
}

View File

@@ -1,154 +0,0 @@
<?php
namespace App\Tests\Domain\Scraping\Application\CommandHandler;
use App\Domain\Scraping\Application\Command\CheckAllScrapersHealth;
use App\Domain\Scraping\Application\CommandHandler\CheckAllScrapersHealthHandler;
use App\Domain\Scraping\Application\CommandHandler\TestScraperConfigurationHandler;
use App\Domain\Scraping\Domain\Model\ValueObject\ContentSourceHealthCheckData;
use App\Tests\Domain\Scraping\Adapter\InMemoryContentSourceForHealthCheckRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryContentSourceHealthRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperAdapter;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
class CheckAllScrapersHealthHandlerTest extends TestCase
{
private InMemoryContentSourceForHealthCheckRepository $sourceRepo;
private InMemoryContentSourceHealthRepository $healthRepo;
private InMemoryScraperFactory $scraperFactory;
private CheckAllScrapersHealthHandler $handler;
protected function setUp(): void
{
$this->sourceRepo = new InMemoryContentSourceForHealthCheckRepository();
$this->healthRepo = new InMemoryContentSourceHealthRepository();
$this->scraperFactory = new InMemoryScraperFactory();
$this->scraperFactory->addScraper('html', new InMemoryScraperAdapter());
$testScraperHandler = new TestScraperConfigurationHandler($this->scraperFactory);
$this->handler = new CheckAllScrapersHealthHandler(
$this->sourceRepo,
$this->healthRepo,
$testScraperHandler,
new NullLogger(),
);
}
public function testSourceWithoutTestSlugIsSkipped(): void
{
$this->sourceRepo->add(new ContentSourceHealthCheckData(
id: 1,
baseUrl: 'https://example.com',
chapterUrlFormat: 'https://example.com/{slug}/{chapterNumber}',
scrapingType: 'html',
imageSelector: 'img',
nextPageSelector: null,
chapterSelector: null,
testSlug: null,
testChapterNumber: null,
));
$this->handler->handle(new CheckAllScrapersHealth());
$this->assertNull($this->healthRepo->getStatus(1));
}
public function testSourceWithTestSlugIsMarkedAsHealthyOnSuccess(): void
{
$this->sourceRepo->add(new ContentSourceHealthCheckData(
id: 2,
baseUrl: 'https://example.com',
chapterUrlFormat: 'https://example.com/{slug}/{chapterNumber}',
scrapingType: 'html',
imageSelector: 'img',
nextPageSelector: null,
chapterSelector: null,
testSlug: 'one-piece',
testChapterNumber: 1.0,
));
$this->handler->handle(new CheckAllScrapersHealth());
$this->assertSame('ok', $this->healthRepo->getStatus(2));
$this->assertNull($this->healthRepo->getError(2));
}
public function testSourceIsMarkedAsUnhealthyWhenScraperThrows(): void
{
$failingScraper = new InMemoryScraperAdapter();
$failingScraper->simulateError(new \RuntimeException('Connexion refusée'));
$this->scraperFactory->addScraper('html', $failingScraper);
$this->sourceRepo->add(new ContentSourceHealthCheckData(
id: 3,
baseUrl: 'https://example.com',
chapterUrlFormat: 'https://example.com/{slug}/{chapterNumber}',
scrapingType: 'html',
imageSelector: 'img',
nextPageSelector: null,
chapterSelector: null,
testSlug: 'one-piece',
testChapterNumber: 1.0,
));
$this->handler->handle(new CheckAllScrapersHealth());
$this->assertSame('ko', $this->healthRepo->getStatus(3));
$this->assertNotNull($this->healthRepo->getError(3));
}
public function testMultipleSourcesAreAllProcessed(): void
{
$this->sourceRepo->add(new ContentSourceHealthCheckData(
id: 10,
baseUrl: 'https://siteA.com',
chapterUrlFormat: 'https://siteA.com/{slug}/{chapterNumber}',
scrapingType: 'html',
imageSelector: 'img',
nextPageSelector: null,
chapterSelector: null,
testSlug: 'manga-a',
testChapterNumber: 1.0,
));
$this->sourceRepo->add(new ContentSourceHealthCheckData(
id: 11,
baseUrl: 'https://siteB.com',
chapterUrlFormat: 'https://siteB.com/{slug}/{chapterNumber}',
scrapingType: 'html',
imageSelector: 'img',
nextPageSelector: null,
chapterSelector: null,
testSlug: null,
testChapterNumber: null,
));
$this->sourceRepo->add(new ContentSourceHealthCheckData(
id: 12,
baseUrl: 'https://siteC.com',
chapterUrlFormat: 'https://siteC.com/{slug}/{chapterNumber}',
scrapingType: 'html',
imageSelector: 'img',
nextPageSelector: null,
chapterSelector: null,
testSlug: 'manga-c',
testChapterNumber: 3.0,
));
$this->handler->handle(new CheckAllScrapersHealth());
$this->assertSame('ok', $this->healthRepo->getStatus(10));
$this->assertNull($this->healthRepo->getStatus(11)); // skippée
$this->assertSame('ok', $this->healthRepo->getStatus(12));
}
protected function tearDown(): void
{
$this->sourceRepo->clear();
$this->healthRepo->clear();
$this->scraperFactory->clear();
}
}

View File

@@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Tests\Feature\Scraping;
use App\Entity\ContentSource;
use App\Tests\Feature\AbstractApiTestCase;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\ResetDatabase;
final class CheckAllScrapersHealthTest extends AbstractApiTestCase
{
use ResetDatabase;
private function post(): void
{
static::createClient()->request('POST', '/api/scraping/check-all-health', [
'json' => new \stdClass(),
]);
}
public function testItReturns202WithNoSources(): void
{
$this->post();
$this->assertResponseStatusCodeSame(Response::HTTP_ACCEPTED);
}
public function testItReturns202WithSourcesHavingNoTestConfig(): void
{
$source = new ContentSource();
$source->setBaseUrl('https://example.com')
->setChapterUrlFormat('https://example.com/{slug}/{chapterNumber}')
->setScrapingType('html');
$this->entityManager->persist($source);
$this->entityManager->flush();
$this->post();
$this->assertResponseStatusCodeSame(Response::HTTP_ACCEPTED);
// La source sans testSlug ne doit pas avoir son statut modifié
$this->entityManager->clear();
$reloaded = $this->entityManager->find(ContentSource::class, $source->getId());
$this->assertSame('unknown', $reloaded->getHealthStatus());
}
public function testHealthStatusIsUpdatedForSourcesWithTestConfig(): void
{
$source = new ContentSource();
$source->setBaseUrl('https://example.com')
->setChapterUrlFormat('https://example.com/{slug}/{chapterNumber}')
->setScrapingType('html')
->setTestSlug('one-piece')
->setTestChapterNumber(1.0);
$this->entityManager->persist($source);
$this->entityManager->flush();
$this->post();
$this->assertResponseStatusCodeSame(Response::HTTP_ACCEPTED);
// Le statut ne doit plus être 'unknown' après le test
$this->entityManager->clear();
$reloaded = $this->entityManager->find(ContentSource::class, $source->getId());
$this->assertNotSame('unknown', $reloaded->getHealthStatus());
$this->assertNotSame('testing', $reloaded->getHealthStatus()); // doit être terminé
}
}