Files
Mangarr/assets/vue/app/domain/setting/presentation/pages/ScrapperEdit.vue
ext.jeremy.guillot@maxicoffee.domains 01b6628fa6 fix(scraping): corriger les 403 sur les images avec protection anti-hotlink
- Ajouter le header Referer (origin de l'image) dans ImageDownloader pour les téléchargements backend
- Ajouter referrerpolicy="no-referrer" sur les <img> de la modale de test pour les previews navigateur
2026-03-16 00:09:19 +01:00

340 lines
16 KiB
Vue

<template>
<div class="flex flex-col h-full">
<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>
<!-- 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>
<!-- Form -->
<div v-else>
<ContentSourceForm
ref="formRef"
:source="currentSource"
:saving="saving"
:error="saveError"
@submit="handleSubmit"
@test="handleTest" />
</div>
</section>
<!-- 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="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>
<button
@click="showTestResults = false"
class="text-gray-400 hover:text-gray-600">
<XMarkIcon class="w-6 h-6" />
</button>
</div>
</div>
<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>
<span class="text-gray-600">Test en cours...</span>
</div>
<!-- Success Results -->
<div v-else-if="testResults.success" class="space-y-4">
<div class="flex items-center text-green-600 mb-4">
<CheckCircleIcon class="w-5 h-5 mr-2" />
<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="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
<div class="text-green-700 dark:text-green-300 break-all">{{ testResults.testedUrl }}</div>
</div>
<div>
<span class="font-medium text-green-800 dark:text-green-200">Type de scraping:</span>
<div class="text-green-700 dark:text-green-300">{{ testResults.scrapingType }}</div>
</div>
<div>
<span class="font-medium text-green-800 dark:text-green-200">Images trouvées:</span>
<div class="text-green-700 dark:text-green-300">{{ testResults.totalImages || testResults.imageUrls?.length || 0 }}</div>
</div>
</div>
</div>
<div v-if="testResults.imageUrls && testResults.imageUrls.length > 0">
<h4 class="font-medium mb-3">Aperçu des images trouvées :</h4>
<div class="grid grid-cols-3 gap-3 max-h-96 overflow-y-auto">
<div
v-for="(imageUrl, index) in testResults.imageUrls.slice(0, 12)"
:key="index"
class="relative group">
<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"
@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">
<span class="text-white opacity-0 group-hover:opacity-100 text-sm font-medium">
Page {{ index + 1 }}
</span>
</div>
</div>
</div>
<p v-if="testResults.imageUrls.length > 12" class="text-sm text-gray-500 mt-3 text-center">
Et {{ testResults.imageUrls.length - 12 }} autres images...
</p>
</div>
<div v-else class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 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">
Le test s'est déroulé sans erreur mais aucune image n'a été trouvée.
Vérifiez vos sélecteurs CSS.
</p>
</div>
</div>
</div>
<!-- Error Results -->
<div v-else class="space-y-4">
<div class="flex items-center text-red-600 mb-4">
<XCircleIcon class="w-5 h-5 mr-2" />
<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="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>
</div>
</div>
<!-- Detailed Errors -->
<div v-if="testResults.errors && testResults.errors.length > 0" class="space-y-3">
<h4 class="font-medium text-red-800 dark:text-red-200">Erreurs détaillées :</h4>
<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">
<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">
{{ formatErrorType(error.type) }}
</span>
<span class="text-sm font-medium text-red-800 dark:text-red-200">
{{ error.field }}
</span>
</div>
<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">
<p class="text-xs text-red-600 dark:text-red-400">
<strong>Suggestion :</strong> {{ error.suggestion }}
</p>
</div>
</div>
</div>
</div>
</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">
<code class="text-sm text-red-800 dark:text-red-200">
{{ testResults.error }}
</code>
</div>
</div>
</div>
</div>
</div>
<!-- Success Message -->
<div v-if="showSuccessMessage" class="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 shadow-lg">
Configuration {{ isEditing ? 'mise à jour' : 'créée' }} avec succès !
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
ArrowLeftIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
PencilSquareIcon,
XCircleIcon,
XMarkIcon
} from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore';
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
import ContentSourceForm from '../components/ContentSourceForm.vue';
const route = useRoute();
const router = useRouter();
const contentSourceStore = useContentSourceStore();
const contentSourceRepository = new ApiContentSourceRepository();
const {
currentSource,
loadingCurrentSource,
currentSourceError,
saving,
saveError
} = storeToRefs(contentSourceStore);
// Form ref
const formRef = ref(null);
// Local state
const showTestResults = ref(false);
const showSuccessMessage = ref(false);
const testResults = ref({});
const testingConfiguration = ref(false);
const isEditing = computed(() => !!route.params.id);
// Load source if editing, clear if creating new
onMounted(async () => {
if (isEditing.value) {
await contentSourceStore.loadSource(route.params.id);
} else {
// Clear current source immediately when creating new
contentSourceStore.clearCurrentSource();
}
});
// 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 },
],
}));
// Actions
const handleSubmit = async (formData) => {
try {
if (isEditing.value) {
await contentSourceStore.updateSource(route.params.id, formData);
} else {
await contentSourceStore.createSource(formData);
}
// Clear current source and errors before redirecting
contentSourceStore.clearCurrentSource();
contentSourceStore.clearErrors();
// Show success message briefly then redirect
showSuccessMessage.value = true;
// Use nextTick to ensure the DOM is updated before redirecting
await new Promise(resolve => setTimeout(resolve, 1500));
// Navigate back to list
await router.push({ name: 'scrapper-configurations' });
// Hide success message after navigation
showSuccessMessage.value = false;
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
// Don't redirect if there's an error
}
};
const handleTest = async ({ configuration, testData }) => {
testingConfiguration.value = true;
showTestResults.value = true;
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,
chapterUrlFormat: configuration.chapterUrlFormat,
scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
testUrl: testData.testUrl,
mangaSlug: testData.mangaSlug,
chapterNumber: parseFloat(testData.chapterNumber),
imageSelector: configuration.imageSelector || null,
nextPageSelector: configuration.nextPageSelector || null,
chapterSelector: configuration.chapterSelector || null
};
console.log('Envoi de la configuration de test:', testConfiguration);
const response = await contentSourceRepository.testConfiguration(testConfiguration);
testResults.value = response;
console.log('Résultats du test:', response);
} catch (error) {
console.error('Erreur lors du test:', error);
testResults.value = {
success: false,
error: error.message,
testedUrl: testData.testUrl,
scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
errors: []
};
} finally {
testingConfiguration.value = false;
}
};
const handleImageError = (event) => {
// Hide broken images
event.target.style.display = 'none';
};
const handleImageLoad = (event) => {
// Ensure loaded images are visible
event.target.style.display = 'block';
};
const formatErrorType = (type) => {
const typeMap = {
'selector_error': 'Erreur sélecteur',
'url_error': 'Erreur URL',
'general_error': 'Erreur générale'
};
return typeMap[type] || type;
};
</script>