feat: ajout de la fonctionnalité de test de configuration de scraper, incluant la mise à jour de l'API pour tester les configurations en temps réel, la gestion des erreurs détaillées et l'intégration des tests unitaires pour valider le bon fonctionnement de cette nouvelle fonctionnalité.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-07-06 17:01:04 +02:00
parent ee2a9b3750
commit cbb62989d4
11 changed files with 1751 additions and 101 deletions

View File

@@ -81,4 +81,24 @@ export class ApiContentSourceRepository {
throw new Error(error.response?.data?.message || 'Erreur lors de l\'import des sources'); throw new Error(error.response?.data?.message || 'Erreur lors de l\'import des sources');
} }
} }
/**
* Teste une configuration de scraper
*/
async testConfiguration(configuration) {
try {
const response = await this.apiClient.post('/scraping/test-configuration', configuration);
return response.data;
} catch (error) {
// Gestion spécifique des erreurs de validation
if (error.response?.status === 422) {
const validationErrors = error.response.data?.violations || [];
const errorMessage = validationErrors.map(violation =>
`${violation.propertyPath}: ${violation.message}`
).join(', ') || 'Erreur de validation';
throw new Error(errorMessage);
}
throw new Error(error.response?.data?.message || 'Erreur lors du test de la configuration');
}
}
} }

View File

@@ -152,12 +152,21 @@
<input <input
id="testChapterNumber" id="testChapterNumber"
v-model="testData.chapterNumber" v-model="testData.chapterNumber"
type="text" type="number"
step="0.1"
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" 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" /> placeholder="1" />
</div> </div>
</div> </div>
<!-- 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 <button
type="button" type="button"
@click="testConfiguration" @click="testConfiguration"
@@ -206,7 +215,7 @@ const form = ref({
chapterUrlFormat: '', chapterUrlFormat: '',
nextPageSelector: '', nextPageSelector: '',
chapterSelector: '', chapterSelector: '',
scrapingType: 'HTML', scrapingType: 'html',
token: '' token: ''
}); });
@@ -224,6 +233,15 @@ const canTest = computed(() => {
testData.value.chapterNumber; testData.value.chapterNumber;
}); });
const generatedTestUrl = computed(() => {
if (!form.value.chapterUrlFormat || !testData.value.mangaSlug || !testData.value.chapterNumber) {
return '';
}
return form.value.chapterUrlFormat
.replace('{slug}', testData.value.mangaSlug)
.replace('{chapterNumber}', testData.value.chapterNumber);
});
// Initialize form with source data if editing, clear if creating new // Initialize form with source data if editing, clear if creating new
watch(() => props.source, (newSource) => { watch(() => props.source, (newSource) => {
if (newSource) { if (newSource) {
@@ -233,7 +251,7 @@ watch(() => props.source, (newSource) => {
chapterUrlFormat: newSource.chapterUrlFormat || '', chapterUrlFormat: newSource.chapterUrlFormat || '',
nextPageSelector: newSource.nextPageSelector || '', nextPageSelector: newSource.nextPageSelector || '',
chapterSelector: newSource.chapterSelector || '', chapterSelector: newSource.chapterSelector || '',
scrapingType: newSource.scrapingType || 'HTML', scrapingType: (newSource.scrapingType || 'html').toLowerCase(),
token: newSource.token || '' token: newSource.token || ''
}; };
} else { } else {
@@ -244,7 +262,7 @@ watch(() => props.source, (newSource) => {
chapterUrlFormat: '', chapterUrlFormat: '',
nextPageSelector: '', nextPageSelector: '',
chapterSelector: '', chapterSelector: '',
scrapingType: 'HTML', scrapingType: 'html',
token: '' token: ''
}; };
} }
@@ -259,7 +277,10 @@ const testConfiguration = async () => {
try { try {
await emit('test', { await emit('test', {
configuration: { ...form.value }, configuration: { ...form.value },
testData: { ...testData.value } testData: {
...testData.value,
testUrl: generatedTestUrl.value
}
}); });
} finally { } finally {
testing.value = false; testing.value = false;

View File

@@ -38,7 +38,7 @@
<!-- Test Results Modal --> <!-- 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 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 rounded-lg shadow-xl w-full max-w-2xl 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="p-6 border-b border-gray-200 dark:border-gray-600">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Résultats du test</h3> <h3 class="text-lg font-semibold">Résultats du test</h3>
@@ -51,50 +51,125 @@
</div> </div>
<div class="p-6 overflow-y-auto"> <div class="p-6 overflow-y-auto">
<div v-if="testResults.success" class="space-y-4"> <!-- Loading state during test -->
<div v-if="testingConfiguration" class="flex items-center justify-center py-8">
<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>
<!-- Success Results -->
<div v-else-if="testResults.success" class="space-y-4">
<div class="flex items-center text-green-600 mb-4"> <div class="flex items-center text-green-600 mb-4">
<CheckCircleIcon class="w-5 h-5 mr-2" /> <CheckCircleIcon class="w-5 h-5 mr-2" />
<span class="font-medium">Test réussi !</span> <span class="font-medium">Test réussi !</span>
</div> </div>
<div> <div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4">
<h4 class="font-medium mb-2">URL testée:</h4> <div class="grid grid-cols-2 gap-4 text-sm">
<code class="block bg-gray-100 dark:bg-gray-700 p-2 rounded text-sm"> <div>
{{ testResults.testedUrl }} <span class="font-medium text-green-800 dark:text-green-200">URL testée:</span>
</code> <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>
<div v-if="testResults.images && testResults.images.length > 0"> <div v-if="testResults.imageUrls && testResults.imageUrls.length > 0">
<h4 class="font-medium mb-2">Images trouvées ({{ testResults.images.length }}):</h4> <h4 class="font-medium mb-3">Aperçu des images trouvées :</h4>
<div class="grid grid-cols-2 gap-2 max-h-64 overflow-y-auto"> <div class="grid grid-cols-3 gap-3 max-h-96 overflow-y-auto">
<img <div
v-for="(image, index) in testResults.images.slice(0, 6)" v-for="(imageUrl, index) in testResults.imageUrls.slice(0, 12)"
:key="index" :key="index"
:src="image" class="relative group">
:alt="`Image ${index + 1}`" <img
class="w-full h-32 object-cover rounded border" :src="imageUrl"
@error="handleImageError" /> :alt="`Image ${index + 1}`"
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 rounded 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> </div>
<p v-if="testResults.images.length > 6" class="text-sm text-gray-500 mt-2"> <p v-if="testResults.imageUrls.length > 12" class="text-sm text-gray-500 mt-3 text-center">
Et {{ testResults.images.length - 6 }} autres images... Et {{ testResults.imageUrls.length - 12 }} autres images...
</p> </p>
</div> </div>
<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">
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> </div>
<!-- Error Results -->
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<div class="flex items-center text-red-600 mb-4"> <div class="flex items-center text-red-600 mb-4">
<XCircleIcon class="w-5 h-5 mr-2" /> <XCircleIcon class="w-5 h-5 mr-2" />
<span class="font-medium">Test échoué</span> <span class="font-medium">Test échoué</span>
</div> </div>
<div> <div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-lg p-4 mb-4">
<h4 class="font-medium mb-2">Erreur:</h4> <div class="text-sm text-red-800 dark:text-red-200">
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded p-3"> <div><strong>URL testée:</strong> {{ testResults.testedUrl || 'N/A' }}</div>
<code class="text-sm text-red-800 dark:text-red-200"> <div><strong>Type de scraping:</strong> {{ testResults.scrapingType || 'N/A' }}</div>
{{ testResults.error }}
</code>
</div> </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 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 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">
{{ 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 rounded 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 rounded p-3">
<code class="text-sm text-red-800 dark:text-red-200">
{{ testResults.error }}
</code>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -121,11 +196,13 @@ import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue'; import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useContentSourceStore } from '../../application/store/contentSourceStore'; import { useContentSourceStore } from '../../application/store/contentSourceStore';
import { ApiContentSourceRepository } from '../../infrastructure/api/apiContentSourceRepository';
import ContentSourceForm from '../components/ContentSourceForm.vue'; import ContentSourceForm from '../components/ContentSourceForm.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const contentSourceStore = useContentSourceStore(); const contentSourceStore = useContentSourceStore();
const contentSourceRepository = new ApiContentSourceRepository();
const { const {
currentSource, currentSource,
@@ -139,6 +216,7 @@ const {
const showTestResults = ref(false); const showTestResults = ref(false);
const showSuccessMessage = ref(false); const showSuccessMessage = ref(false);
const testResults = ref({}); const testResults = ref({});
const testingConfiguration = ref(false);
const isEditing = computed(() => !!route.params.id); const isEditing = computed(() => !!route.params.id);
@@ -194,35 +272,61 @@ const handleSubmit = async (formData) => {
}; };
const handleTest = async ({ configuration, testData }) => { const handleTest = async ({ configuration, testData }) => {
try { testingConfiguration.value = true;
// Simulate test API call - You'll need to implement this endpoint showTestResults.value = true;
const testUrl = configuration.chapterUrlFormat testResults.value = {};
.replace('{slug}', testData.mangaSlug)
.replace('{chapterNumber}', testData.chapterNumber);
// Mock test results for now try {
testResults.value = { // Préparer les données selon le format de l'API
success: true, const testConfiguration = {
testedUrl: testUrl, baseUrl: configuration.baseUrl,
images: [ chapterUrlFormat: configuration.chapterUrlFormat,
'https://via.placeholder.com/400x600/008000/FFFFFF?text=Page+1', scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
'https://via.placeholder.com/400x600/FF0000/FFFFFF?text=Page+2', testUrl: testData.testUrl,
'https://via.placeholder.com/400x600/0000FF/FFFFFF?text=Page+3', mangaSlug: testData.mangaSlug,
'https://via.placeholder.com/400x600/FFA500/FFFFFF?text=Page+4' chapterNumber: parseFloat(testData.chapterNumber),
] imageSelector: configuration.imageSelector || null,
nextPageSelector: configuration.nextPageSelector || null,
chapterSelector: configuration.chapterSelector || null
}; };
showTestResults.value = true; 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) { } catch (error) {
console.error('Erreur lors du test:', error);
testResults.value = { testResults.value = {
success: false, success: false,
error: error.message error: error.message,
testedUrl: testData.testUrl,
scrapingType: configuration.scrapingType?.toLowerCase() || 'html',
errors: []
}; };
showTestResults.value = true; } finally {
testingConfiguration.value = false;
} }
}; };
const handleImageError = (event) => { const handleImageError = (event) => {
// Hide broken images
event.target.style.display = 'none'; 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> </script>

View File

@@ -924,66 +924,36 @@
"post": { "post": {
"operationId": "api_mangachaptersfetch_post", "operationId": "api_mangachaptersfetch_post",
"tags": [ "tags": [
"Chapters" "Mangadex"
], ],
"responses": { "responses": {
"202": { "202": {
"description": "Chapters resource created", "description": "Demande de r\u00e9cup\u00e9ration accept\u00e9e et mise en file d'attente"
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Chapters"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/Chapters.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/Chapters"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/Chapters.jsonhal"
}
}
},
"links": {}
},
"400": {
"description": "Invalid input"
}, },
"422": { "422": {
"description": "Unprocessable entity" "description": "Donn\u00e9es de validation invalides"
} }
}, },
"summary": "Creates a Chapters resource.", "summary": "R\u00e9cup\u00e9rer les chapitres d'un manga",
"description": "Creates a Chapters resource.", "description": "Lance le processus de r\u00e9cup\u00e9ration des chapitres depuis la source externe pour un manga donn\u00e9",
"parameters": [], "parameters": [],
"requestBody": { "requestBody": {
"description": "The new Chapters resource", "description": "Donn\u00e9es requises pour r\u00e9cup\u00e9rer les chapitres",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Chapters" "type": "object",
} "properties": {
}, "mangaId": {
"application/ld+json": { "type": "string",
"schema": { "format": "uuid",
"$ref": "#/components/schemas/Chapters.jsonld" "description": "L'identifiant unique du manga",
} "example": "123e4567-e89b-12d3-a456-426614174000"
}, }
"text/html": { },
"schema": { "required": [
"$ref": "#/components/schemas/Chapters" "mangaId"
} ]
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/Chapters.jsonhal"
} }
} }
}, },
@@ -1586,30 +1556,30 @@
"post": { "post": {
"operationId": "api_mangascreate-from-mangadex_post", "operationId": "api_mangascreate-from-mangadex_post",
"tags": [ "tags": [
"Manga" "Mangadex"
], ],
"responses": { "responses": {
"201": { "201": {
"description": "Manga resource created", "description": "Mangadex resource created",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Manga" "$ref": "#/components/schemas/Mangadex"
} }
}, },
"application/ld+json": { "application/ld+json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Manga.jsonld" "$ref": "#/components/schemas/Mangadex.jsonld"
} }
}, },
"text/html": { "text/html": {
"schema": { "schema": {
"$ref": "#/components/schemas/Manga" "$ref": "#/components/schemas/Mangadex"
} }
}, },
"application/hal+json": { "application/hal+json": {
"schema": { "schema": {
"$ref": "#/components/schemas/Manga.jsonhal" "$ref": "#/components/schemas/Mangadex.jsonhal"
} }
} }
}, },
@@ -2534,6 +2504,299 @@
"deprecated": false "deprecated": false
}, },
"parameters": [] "parameters": []
},
"/api/scraping/test-configuration": {
"post": {
"operationId": "api_scrapingtest-configuration_post",
"tags": [
"Scraping",
"Configuration"
],
"responses": {
"200": {
"description": "Test de configuration r\u00e9ussi",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean",
"description": "Indique si le test a r\u00e9ussi"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
},
"description": "Liste des URLs d'images trouv\u00e9es"
},
"totalImages": {
"type": "integer",
"description": "Nombre total d'images trouv\u00e9es"
},
"testedUrl": {
"type": "string",
"format": "uri",
"description": "URL qui a \u00e9t\u00e9 test\u00e9e"
},
"scrapingType": {
"type": "string",
"description": "Type de scraping utilis\u00e9"
},
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Type d'erreur (selector_error, url_error, general_error)"
},
"field": {
"type": "string",
"description": "Champ concern\u00e9 par l'erreur"
},
"message": {
"type": "string",
"description": "Message d'erreur d\u00e9taill\u00e9"
},
"suggestion": {
"type": "string",
"description": "Suggestion pour corriger l'erreur"
}
}
},
"description": "Liste des erreurs d\u00e9taill\u00e9es (vide en cas de succ\u00e8s)"
}
}
},
"examples": {
"succes": {
"summary": "Test r\u00e9ussi",
"value": {
"success": true,
"imageUrls": [
"https://mangasite.example.com/images/chapter1/page1.jpg",
"https://mangasite.example.com/images/chapter1/page2.jpg",
"https://mangasite.example.com/images/chapter1/page3.jpg"
],
"totalImages": 3,
"testedUrl": "https://mangasite.example.com/manga/one-piece/chapter/1",
"scrapingType": "html",
"errors": []
}
},
"echec_selecteur": {
"summary": "\u00c9chec - S\u00e9lecteur invalide",
"value": {
"success": false,
"imageUrls": [],
"totalImages": 0,
"testedUrl": "https://mangasite.example.com/manga/one-piece/chapter/1",
"scrapingType": "html",
"errors": [
{
"type": "selector_error",
"field": "imageSelector",
"message": "Le s\u00e9lecteur d'image '.invalid-selector' ne trouve aucun \u00e9l\u00e9ment sur la page",
"suggestion": "V\u00e9rifiez que le s\u00e9lecteur CSS est correct et qu'il correspond aux \u00e9l\u00e9ments d'image sur la page"
}
]
}
},
"echec_url": {
"summary": "\u00c9chec - URL inaccessible",
"value": {
"success": false,
"imageUrls": [],
"totalImages": 0,
"testedUrl": "https://invalid-site.example.com/chapter/1",
"scrapingType": "html",
"errors": [
{
"type": "url_error",
"field": "testUrl",
"message": "Impossible d'acc\u00e9der \u00e0 l'URL de test: https://invalid-site.example.com/chapter/1",
"suggestion": "V\u00e9rifiez que l'URL est correcte et accessible, et que le chapitre existe"
}
]
}
}
}
}
}
},
"422": {
"description": "Erreur de validation des donn\u00e9es d'entr\u00e9e",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "https://tools.ietf.org/html/rfc2616#section-10"
},
"title": {
"type": "string",
"example": "An error occurred"
},
"detail": {
"type": "string",
"example": "baseUrl: L'URL de base doit \u00eatre une URL valide"
},
"violations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"propertyPath": {
"type": "string",
"description": "Champ contenant l'erreur"
},
"message": {
"type": "string",
"description": "Message d'erreur de validation"
}
}
}
}
}
},
"example": {
"type": "https://symfony.com/errors/validation",
"title": "Validation Failed",
"detail": "baseUrl: L'URL de base doit \u00eatre une URL valide",
"violations": [
{
"propertyPath": "baseUrl",
"message": "L'URL de base doit \u00eatre une URL valide"
}
]
}
}
}
},
"400": {
"description": "Invalid input"
}
},
"summary": "Tester une configuration de scraper",
"description": "Teste une configuration de scraper en temps r\u00e9el sans l'enregistrer dans la base de donn\u00e9es. Cette API permet de valider les param\u00e8tres de scraping et de voir imm\u00e9diatement les URLs d'images qui seraient extraites.",
"parameters": [],
"requestBody": {
"description": "Configuration du scraper \u00e0 tester",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"baseUrl": {
"type": "string",
"format": "uri",
"description": "URL de base du site web source",
"example": "https://mangasite.example.com"
},
"chapterUrlFormat": {
"type": "string",
"description": "Format d'URL pour acc\u00e9der aux chapitres avec placeholders {slug} et {chapter}",
"example": "https://mangasite.example.com/manga/{slug}/chapter/{chapter}"
},
"scrapingType": {
"type": "string",
"enum": [
"html",
"javascript"
],
"description": "Type de scraping \u00e0 utiliser (html pour les sites statiques, javascript pour les sites dynamiques)",
"example": "html"
},
"testUrl": {
"type": "string",
"format": "uri",
"description": "URL compl\u00e8te d'un chapitre existant \u00e0 utiliser pour le test",
"example": "https://mangasite.example.com/manga/one-piece/chapter/1"
},
"mangaSlug": {
"type": "string",
"description": "Slug du manga utilis\u00e9 dans les URLs (sera utilis\u00e9 pour construire les URLs futures)",
"example": "one-piece"
},
"chapterNumber": {
"type": "number",
"minimum": 0,
"description": "Num\u00e9ro du chapitre \u00e0 tester",
"example": 1
},
"imageSelector": {
"type": "string",
"nullable": true,
"description": "S\u00e9lecteur CSS pour identifier les images dans la page",
"example": "img.manga-page, .chapter-image img"
},
"nextPageSelector": {
"type": "string",
"nullable": true,
"description": "S\u00e9lecteur CSS pour le lien vers la page suivante (pour les lecteurs horizontaux)",
"example": "a.next-page, .navigation .next"
},
"chapterSelector": {
"type": "string",
"nullable": true,
"description": "S\u00e9lecteur CSS pour identifier la zone contenant le chapitre",
"example": ".chapter-content, #manga-reader"
}
},
"required": [
"baseUrl",
"chapterUrlFormat",
"scrapingType",
"testUrl",
"mangaSlug",
"chapterNumber"
]
},
"examples": {
"lecteur_vertical": {
"summary": "Configuration pour un lecteur vertical",
"description": "Exemple de configuration pour un site avec toutes les images sur une seule page",
"value": {
"baseUrl": "https://mangasite.example.com",
"chapterUrlFormat": "https://mangasite.example.com/manga/{slug}/chapter/{chapter}",
"scrapingType": "html",
"testUrl": "https://mangasite.example.com/manga/one-piece/chapter/1",
"mangaSlug": "one-piece",
"chapterNumber": 1,
"imageSelector": "img.manga-page",
"nextPageSelector": null,
"chapterSelector": ".chapter-content"
}
},
"lecteur_horizontal": {
"summary": "Configuration pour un lecteur horizontal",
"description": "Exemple de configuration pour un site avec navigation page par page",
"value": {
"baseUrl": "https://mangasite.example.com",
"chapterUrlFormat": "https://mangasite.example.com/read/{slug}/{chapter}/1",
"scrapingType": "html",
"testUrl": "https://mangasite.example.com/read/one-piece/1/1",
"mangaSlug": "one-piece",
"chapterNumber": 1,
"imageSelector": "#manga-image",
"nextPageSelector": "a.next-page",
"chapterSelector": ".reader-container"
}
}
}
}
},
"required": false
},
"deprecated": false
},
"parameters": []
} }
}, },
"components": { "components": {
@@ -3458,6 +3721,12 @@
"slug": { "slug": {
"type": "string" "type": "string"
}, },
"alternativeSlugs": {
"type": "array",
"items": {
"type": "string"
}
},
"description": { "description": {
"type": "string" "type": "string"
}, },
@@ -3530,6 +3799,12 @@
"slug": { "slug": {
"type": "string" "type": "string"
}, },
"alternativeSlugs": {
"type": "array",
"items": {
"type": "string"
}
},
"description": { "description": {
"type": "string" "type": "string"
}, },
@@ -3623,6 +3898,12 @@
"slug": { "slug": {
"type": "string" "type": "string"
}, },
"alternativeSlugs": {
"type": "array",
"items": {
"type": "string"
}
},
"description": { "description": {
"type": "string" "type": "string"
}, },
@@ -4348,6 +4629,19 @@
} }
} }
}, },
"Mangadex": {
"type": "object",
"description": "",
"deprecated": false,
"required": [
"externalId"
],
"properties": {
"externalId": {
"type": "string"
}
}
},
"Mangadex.MangaSearchCollection": { "Mangadex.MangaSearchCollection": {
"type": "object", "type": "object",
"description": "", "description": "",
@@ -4436,6 +4730,81 @@
} }
} }
}, },
"Mangadex.jsonhal": {
"type": "object",
"description": "",
"deprecated": false,
"required": [
"externalId"
],
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
},
"externalId": {
"type": "string"
}
}
},
"Mangadex.jsonld": {
"type": "object",
"description": "",
"deprecated": false,
"required": [
"externalId"
],
"properties": {
"@context": {
"readOnly": true,
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"@vocab": {
"type": "string"
},
"hydra": {
"type": "string",
"enum": [
"http://www.w3.org/ns/hydra/core#"
]
}
},
"required": [
"@vocab",
"hydra"
],
"additionalProperties": true
}
]
},
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
},
"externalId": {
"type": "string"
}
}
},
"Reader": { "Reader": {
"type": "object", "type": "object",
"description": "", "description": "",
@@ -4624,6 +4993,256 @@
} }
} }
}, },
"Scraping.TestScraperConfigurationResource": {
"type": "object",
"description": "Teste une configuration de scraper sans l'enregistrer",
"deprecated": false,
"properties": {
"success": {
"type": "boolean"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
}
},
"totalImages": {
"type": "integer",
"minimum": 0
},
"testedUrl": {
"type": "string",
"format": "uri"
},
"scrapingType": {
"type": "string",
"enum": [
"html",
"javascript"
]
},
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"selector_error",
"url_error",
"general_error"
],
"description": "Type d'erreur rencontr\u00e9e"
},
"field": {
"type": "string",
"description": "Champ de configuration concern\u00e9 par l'erreur"
},
"message": {
"type": "string",
"description": "Message d'erreur d\u00e9taill\u00e9"
},
"suggestion": {
"type": "string",
"description": "Suggestion pour corriger l'erreur"
}
},
"required": [
"type",
"field",
"message",
"suggestion"
]
}
}
}
},
"Scraping.TestScraperConfigurationResource.jsonhal": {
"type": "object",
"description": "Teste une configuration de scraper sans l'enregistrer",
"deprecated": false,
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
},
"success": {
"type": "boolean"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
}
},
"totalImages": {
"type": "integer",
"minimum": 0
},
"testedUrl": {
"type": "string",
"format": "uri"
},
"scrapingType": {
"type": "string",
"enum": [
"html",
"javascript"
]
},
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"selector_error",
"url_error",
"general_error"
],
"description": "Type d'erreur rencontr\u00e9e"
},
"field": {
"type": "string",
"description": "Champ de configuration concern\u00e9 par l'erreur"
},
"message": {
"type": "string",
"description": "Message d'erreur d\u00e9taill\u00e9"
},
"suggestion": {
"type": "string",
"description": "Suggestion pour corriger l'erreur"
}
},
"required": [
"type",
"field",
"message",
"suggestion"
]
}
}
}
},
"Scraping.TestScraperConfigurationResource.jsonld": {
"type": "object",
"description": "Teste une configuration de scraper sans l'enregistrer",
"deprecated": false,
"properties": {
"@context": {
"readOnly": true,
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"@vocab": {
"type": "string"
},
"hydra": {
"type": "string",
"enum": [
"http://www.w3.org/ns/hydra/core#"
]
}
},
"required": [
"@vocab",
"hydra"
],
"additionalProperties": true
}
]
},
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
},
"success": {
"type": "boolean"
},
"imageUrls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
}
},
"totalImages": {
"type": "integer",
"minimum": 0
},
"testedUrl": {
"type": "string",
"format": "uri"
},
"scrapingType": {
"type": "string",
"enum": [
"html",
"javascript"
]
},
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"selector_error",
"url_error",
"general_error"
],
"description": "Type d'erreur rencontr\u00e9e"
},
"field": {
"type": "string",
"description": "Champ de configuration concern\u00e9 par l'erreur"
},
"message": {
"type": "string",
"description": "Message d'erreur d\u00e9taill\u00e9"
},
"suggestion": {
"type": "string",
"description": "Suggestion pour corriger l'erreur"
}
},
"required": [
"type",
"field",
"message",
"suggestion"
]
}
}
}
},
"Scraping.jsonhal": { "Scraping.jsonhal": {
"type": "object", "type": "object",
"description": "", "description": "",

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domain\Scraping\Application\Command;
readonly class TestScraperConfiguration
{
public function __construct(
public string $baseUrl,
public string $chapterUrlFormat,
public string $scrapingType,
public string $testUrl,
public string $mangaSlug,
public float $chapterNumber,
public ?string $imageSelector = null,
public ?string $nextPageSelector = null,
public ?string $chapterSelector = null,
) {}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Domain\Scraping\Application\CommandHandler;
use App\Domain\Scraping\Application\Command\TestScraperConfiguration;
use App\Domain\Scraping\Application\Response\TestScraperConfigurationResponse;
use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
readonly class TestScraperConfigurationHandler
{
public function __construct(
private ScraperInterface $scraper
) {}
public function handle(TestScraperConfiguration $command): TestScraperConfigurationResponse
{
try {
// Construction des paramètres de scraping depuis les données de la commande
$scrapingParameters = [
'imageSelector' => $command->imageSelector,
'nextPageSelector' => $command->nextPageSelector,
'chapterUrlFormat' => $command->chapterUrlFormat,
'scrapingType' => $command->scrapingType,
'chapterSelector' => $command->chapterSelector
];
// Vérification que le scraper supporte le type de scraping
if (!$this->scraper->supports($command->scrapingType)) {
return TestScraperConfigurationResponse::failure(
$command->testUrl,
$command->scrapingType,
["Type de scraping '{$command->scrapingType}' non supporté"]
);
}
// Création de la requête de scraping avec l'URL de test fournie directement
$scrapingRequest = new ScrapingRequest(
$command->scrapingType,
$command->testUrl,
$scrapingParameters
);
// Tentative de scraping
$scrapingResult = $this->scraper->scrape($scrapingRequest);
// Retour du succès avec les URLs trouvées
return TestScraperConfigurationResponse::success(
$scrapingResult->getImageUrls(),
$command->testUrl,
$command->scrapingType
);
} catch (\Exception $e) {
// Analyse de l'erreur pour fournir un message plus détaillé
$errors = $this->analyzeError($e, $command);
return TestScraperConfigurationResponse::failure(
$command->testUrl,
$command->scrapingType,
$errors
);
}
}
private function analyzeError(\Exception $e, TestScraperConfiguration $command): array
{
$errors = [];
$message = $e->getMessage();
// Erreurs liées aux sélecteurs
if (str_contains($message, 'imageSelector') || str_contains($message, 'filter')) {
$errors[] = [
'type' => 'selector_error',
'field' => 'imageSelector',
'message' => "Le sélecteur d'image '{$command->imageSelector}' ne trouve aucun élément sur la page",
'suggestion' => "Vérifiez que le sélecteur CSS est correct et qu'il correspond aux éléments d'image sur la page"
];
}
// Erreurs liées à l'URL
if (str_contains($message, 'Failed to fetch HTML') || str_contains($message, 'Chapter Not Found')) {
$errors[] = [
'type' => 'url_error',
'field' => 'testUrl',
'message' => "Impossible d'accéder à l'URL de test: {$command->testUrl}",
'suggestion' => "Vérifiez que l'URL est correcte et accessible, et que le chapitre existe"
];
}
// Erreurs liées aux sélecteurs de navigation
if (str_contains($message, 'nextPageSelector')) {
$errors[] = [
'type' => 'selector_error',
'field' => 'nextPageSelector',
'message' => "Le sélecteur de page suivante '{$command->nextPageSelector}' pose problème",
'suggestion' => "Vérifiez le sélecteur CSS pour la navigation entre les pages"
];
}
// Erreur générique si aucune erreur spécifique n'est détectée
if (empty($errors)) {
$errors[] = [
'type' => 'general_error',
'field' => 'configuration',
'message' => $message,
'suggestion' => "Vérifiez l'ensemble de la configuration et l'accessibilité du site web"
];
}
return $errors;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domain\Scraping\Application\Response;
readonly class TestScraperConfigurationResponse
{
public function __construct(
public bool $success,
public array $imageUrls,
public int $totalImages,
public string $testedUrl,
public string $scrapingType,
public array $errors = [],
) {}
public static function success(array $imageUrls, string $testedUrl, string $scrapingType): self
{
return new self(
success: true,
imageUrls: $imageUrls,
totalImages: count($imageUrls),
testedUrl: $testedUrl,
scrapingType: $scrapingType,
errors: []
);
}
public static function failure(string $testedUrl, string $scrapingType, array $errors): self
{
return new self(
success: false,
imageUrls: [],
totalImages: 0,
testedUrl: $testedUrl,
scrapingType: $scrapingType,
errors: $errors
);
}
}

View File

@@ -0,0 +1,377 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use ApiPlatform\OpenApi\Model\RequestBody;
use ApiPlatform\OpenApi\Model\Response as OpenApiResponse;
use App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor\TestScraperConfigurationStateProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Scraping',
operations: [
new Post(
uriTemplate: '/scraping/test-configuration',
processor: TestScraperConfigurationStateProcessor::class,
input: TestScraperConfigurationRequest::class,
output: TestScraperConfigurationResource::class,
status: 200,
description: 'Teste une configuration de scraper sans l\'enregistrer',
openapi: new OpenApiOperation(
summary: 'Tester une configuration de scraper',
description: 'Teste une configuration de scraper en temps réel sans l\'enregistrer dans la base de données. Cette API permet de valider les paramètres de scraping et de voir immédiatement les URLs d\'images qui seraient extraites.',
tags: ['Scraping', 'Configuration'],
requestBody: new RequestBody(
description: 'Configuration du scraper à tester',
content: new \ArrayObject([
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'baseUrl' => [
'type' => 'string',
'format' => 'uri',
'description' => 'URL de base du site web source',
'example' => 'https://mangasite.example.com'
],
'chapterUrlFormat' => [
'type' => 'string',
'description' => 'Format d\'URL pour accéder aux chapitres avec placeholders {slug} et {chapter}',
'example' => 'https://mangasite.example.com/manga/{slug}/chapter/{chapter}'
],
'scrapingType' => [
'type' => 'string',
'enum' => ['html', 'javascript'],
'description' => 'Type de scraping à utiliser (html pour les sites statiques, javascript pour les sites dynamiques)',
'example' => 'html'
],
'testUrl' => [
'type' => 'string',
'format' => 'uri',
'description' => 'URL complète d\'un chapitre existant à utiliser pour le test',
'example' => 'https://mangasite.example.com/manga/one-piece/chapter/1'
],
'mangaSlug' => [
'type' => 'string',
'description' => 'Slug du manga utilisé dans les URLs (sera utilisé pour construire les URLs futures)',
'example' => 'one-piece'
],
'chapterNumber' => [
'type' => 'number',
'minimum' => 0,
'description' => 'Numéro du chapitre à tester',
'example' => 1.0
],
'imageSelector' => [
'type' => 'string',
'nullable' => true,
'description' => 'Sélecteur CSS pour identifier les images dans la page',
'example' => 'img.manga-page, .chapter-image img'
],
'nextPageSelector' => [
'type' => 'string',
'nullable' => true,
'description' => 'Sélecteur CSS pour le lien vers la page suivante (pour les lecteurs horizontaux)',
'example' => 'a.next-page, .navigation .next'
],
'chapterSelector' => [
'type' => 'string',
'nullable' => true,
'description' => 'Sélecteur CSS pour identifier la zone contenant le chapitre',
'example' => '.chapter-content, #manga-reader'
]
],
'required' => ['baseUrl', 'chapterUrlFormat', 'scrapingType', 'testUrl', 'mangaSlug', 'chapterNumber']
],
'examples' => [
'lecteur_vertical' => [
'summary' => 'Configuration pour un lecteur vertical',
'description' => 'Exemple de configuration pour un site avec toutes les images sur une seule page',
'value' => [
'baseUrl' => 'https://mangasite.example.com',
'chapterUrlFormat' => 'https://mangasite.example.com/manga/{slug}/chapter/{chapter}',
'scrapingType' => 'html',
'testUrl' => 'https://mangasite.example.com/manga/one-piece/chapter/1',
'mangaSlug' => 'one-piece',
'chapterNumber' => 1.0,
'imageSelector' => 'img.manga-page',
'nextPageSelector' => null,
'chapterSelector' => '.chapter-content'
]
],
'lecteur_horizontal' => [
'summary' => 'Configuration pour un lecteur horizontal',
'description' => 'Exemple de configuration pour un site avec navigation page par page',
'value' => [
'baseUrl' => 'https://mangasite.example.com',
'chapterUrlFormat' => 'https://mangasite.example.com/read/{slug}/{chapter}/1',
'scrapingType' => 'html',
'testUrl' => 'https://mangasite.example.com/read/one-piece/1/1',
'mangaSlug' => 'one-piece',
'chapterNumber' => 1.0,
'imageSelector' => '#manga-image',
'nextPageSelector' => 'a.next-page',
'chapterSelector' => '.reader-container'
]
]
]
]
])
),
responses: [
'200' => new OpenApiResponse(
description: 'Test de configuration réussi',
content: new \ArrayObject([
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'success' => [
'type' => 'boolean',
'description' => 'Indique si le test a réussi'
],
'imageUrls' => [
'type' => 'array',
'items' => [
'type' => 'string',
'format' => 'uri'
],
'description' => 'Liste des URLs d\'images trouvées'
],
'totalImages' => [
'type' => 'integer',
'description' => 'Nombre total d\'images trouvées'
],
'testedUrl' => [
'type' => 'string',
'format' => 'uri',
'description' => 'URL qui a été testée'
],
'scrapingType' => [
'type' => 'string',
'description' => 'Type de scraping utilisé'
],
'errors' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'type' => [
'type' => 'string',
'description' => 'Type d\'erreur (selector_error, url_error, general_error)'
],
'field' => [
'type' => 'string',
'description' => 'Champ concerné par l\'erreur'
],
'message' => [
'type' => 'string',
'description' => 'Message d\'erreur détaillé'
],
'suggestion' => [
'type' => 'string',
'description' => 'Suggestion pour corriger l\'erreur'
]
]
],
'description' => 'Liste des erreurs détaillées (vide en cas de succès)'
]
]
],
'examples' => [
'succes' => [
'summary' => 'Test réussi',
'value' => [
'success' => true,
'imageUrls' => [
'https://mangasite.example.com/images/chapter1/page1.jpg',
'https://mangasite.example.com/images/chapter1/page2.jpg',
'https://mangasite.example.com/images/chapter1/page3.jpg'
],
'totalImages' => 3,
'testedUrl' => 'https://mangasite.example.com/manga/one-piece/chapter/1',
'scrapingType' => 'html',
'errors' => []
]
],
'echec_selecteur' => [
'summary' => 'Échec - Sélecteur invalide',
'value' => [
'success' => false,
'imageUrls' => [],
'totalImages' => 0,
'testedUrl' => 'https://mangasite.example.com/manga/one-piece/chapter/1',
'scrapingType' => 'html',
'errors' => [
[
'type' => 'selector_error',
'field' => 'imageSelector',
'message' => 'Le sélecteur d\'image \'.invalid-selector\' ne trouve aucun élément sur la page',
'suggestion' => 'Vérifiez que le sélecteur CSS est correct et qu\'il correspond aux éléments d\'image sur la page'
]
]
]
],
'echec_url' => [
'summary' => 'Échec - URL inaccessible',
'value' => [
'success' => false,
'imageUrls' => [],
'totalImages' => 0,
'testedUrl' => 'https://invalid-site.example.com/chapter/1',
'scrapingType' => 'html',
'errors' => [
[
'type' => 'url_error',
'field' => 'testUrl',
'message' => 'Impossible d\'accéder à l\'URL de test: https://invalid-site.example.com/chapter/1',
'suggestion' => 'Vérifiez que l\'URL est correcte et accessible, et que le chapitre existe'
]
]
]
]
]
]
])
),
'422' => new OpenApiResponse(
description: 'Erreur de validation des données d\'entrée',
content: new \ArrayObject([
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'type' => [
'type' => 'string',
'example' => 'https://tools.ietf.org/html/rfc2616#section-10'
],
'title' => [
'type' => 'string',
'example' => 'An error occurred'
],
'detail' => [
'type' => 'string',
'example' => 'baseUrl: L\'URL de base doit être une URL valide'
],
'violations' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'propertyPath' => [
'type' => 'string',
'description' => 'Champ contenant l\'erreur'
],
'message' => [
'type' => 'string',
'description' => 'Message d\'erreur de validation'
]
]
]
]
]
],
'example' => [
'type' => 'https://symfony.com/errors/validation',
'title' => 'Validation Failed',
'detail' => 'baseUrl: L\'URL de base doit être une URL valide',
'violations' => [
[
'propertyPath' => 'baseUrl',
'message' => 'L\'URL de base doit être une URL valide'
]
]
]
]
])
)
]
)
),
]
)]
readonly class TestScraperConfigurationRequest
{
public function __construct(
#[ApiProperty(
description: 'URL de base du site web source de mangas',
example: 'https://mangasite.example.com',
schema: ['type' => 'string', 'format' => 'uri']
)]
#[Assert\NotBlank(message: 'L\'URL de base est obligatoire')]
#[Assert\Url(message: 'L\'URL de base doit être une URL valide')]
public string $baseUrl,
#[ApiProperty(
description: 'Format d\'URL pour accéder aux chapitres. Utilisez {slug} pour le nom du manga et {chapter} pour le numéro de chapitre',
example: 'https://mangasite.example.com/manga/{slug}/chapter/{chapter}',
schema: ['type' => 'string', 'pattern' => '.*\{slug\}.*\{chapter\}.*']
)]
#[Assert\NotBlank(message: 'Le format d\'URL de chapitre est obligatoire')]
public string $chapterUrlFormat,
#[ApiProperty(
description: 'Type de scraping à utiliser. "html" pour les sites statiques, "javascript" pour les sites avec contenu dynamique',
example: 'html',
schema: ['type' => 'string', 'enum' => ['html', 'javascript']]
)]
#[Assert\NotBlank(message: 'Le type de scraping est obligatoire')]
#[Assert\Choice(choices: ['html', 'javascript'], message: 'Le type de scraping doit être html ou javascript')]
public string $scrapingType,
#[ApiProperty(
description: 'URL complète d\'un chapitre existant à utiliser pour tester la configuration. Cette URL doit être accessible et contenir des images',
example: 'https://mangasite.example.com/manga/one-piece/chapter/1',
schema: ['type' => 'string', 'format' => 'uri']
)]
#[Assert\NotBlank(message: 'L\'URL de test est obligatoire')]
#[Assert\Url(message: 'L\'URL de test doit être une URL valide')]
public string $testUrl,
#[ApiProperty(
description: 'Identifiant du manga utilisé dans les URLs (slug). Sera utilisé pour remplacer {slug} dans le format d\'URL',
example: 'one-piece',
schema: ['type' => 'string', 'pattern' => '^[a-z0-9-_]+$']
)]
#[Assert\NotBlank(message: 'Le slug du manga est obligatoire')]
public string $mangaSlug,
#[ApiProperty(
description: 'Numéro du chapitre à tester. Peut être décimal pour les chapitres spéciaux (ex: 1.5)',
example: 1.0,
schema: ['type' => 'number', 'minimum' => 0]
)]
#[Assert\NotBlank(message: 'Le numéro de chapitre est obligatoire')]
#[Assert\Type(type: 'numeric', message: 'Le numéro de chapitre doit être numérique')]
#[Assert\Positive(message: 'Le numéro de chapitre doit être positif')]
public float $chapterNumber,
#[ApiProperty(
description: 'Sélecteur CSS pour identifier les images du manga sur la page. Exemples: "img.manga-page", ".chapter img", "#reader img"',
example: 'img.manga-page, .chapter-image img',
schema: ['type' => 'string', 'nullable' => true, 'maxLength' => 500]
)]
#[Assert\Length(min: 1, max: 500, minMessage: 'Le sélecteur d\'image ne peut pas être vide', maxMessage: 'Le sélecteur d\'image est trop long')]
public ?string $imageSelector = null,
#[ApiProperty(
description: 'Sélecteur CSS pour le lien vers la page suivante (requis pour les lecteurs horizontaux). Exemples: "a.next", ".navigation .next-page"',
example: 'a.next-page, .navigation .next',
schema: ['type' => 'string', 'nullable' => true, 'maxLength' => 500]
)]
#[Assert\Length(max: 500, maxMessage: 'Le sélecteur de page suivante est trop long')]
public ?string $nextPageSelector = null,
#[ApiProperty(
description: 'Sélecteur CSS pour délimiter la zone contenant le chapitre (optionnel). Utile pour cibler une zone spécifique de la page',
example: '.chapter-content, #manga-reader',
schema: ['type' => 'string', 'nullable' => true, 'maxLength' => 500]
)]
#[Assert\Length(max: 500, maxMessage: 'Le sélecteur de chapitre est trop long')]
public ?string $chapterSelector = null,
) {}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiProperty;
readonly class TestScraperConfigurationResource
{
public function __construct(
#[ApiProperty(
description: 'Indique si le test de configuration a réussi',
example: true,
schema: ['type' => 'boolean']
)]
public bool $success,
#[ApiProperty(
description: 'Liste des URLs d\'images trouvées lors du scraping. Vide en cas d\'échec.',
example: [
'https://mangasite.example.com/images/chapter1/page1.jpg',
'https://mangasite.example.com/images/chapter1/page2.jpg'
],
schema: [
'type' => 'array',
'items' => [
'type' => 'string',
'format' => 'uri'
]
]
)]
public array $imageUrls,
#[ApiProperty(
description: 'Nombre total d\'images trouvées',
example: 2,
schema: ['type' => 'integer', 'minimum' => 0]
)]
public int $totalImages,
#[ApiProperty(
description: 'URL qui a été testée',
example: 'https://mangasite.example.com/manga/one-piece/chapter/1',
schema: ['type' => 'string', 'format' => 'uri']
)]
public string $testedUrl,
#[ApiProperty(
description: 'Type de scraping qui a été utilisé pour le test',
example: 'html',
schema: ['type' => 'string', 'enum' => ['html', 'javascript']]
)]
public string $scrapingType,
#[ApiProperty(
description: 'Liste des erreurs détaillées en cas d\'échec. Vide en cas de succès. Chaque erreur contient un type, le champ concerné, un message et une suggestion.',
example: [
[
'type' => 'selector_error',
'field' => 'imageSelector',
'message' => 'Le sélecteur d\'image ne trouve aucun élément',
'suggestion' => 'Vérifiez le sélecteur CSS'
]
],
schema: [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'type' => [
'type' => 'string',
'enum' => ['selector_error', 'url_error', 'general_error'],
'description' => 'Type d\'erreur rencontrée'
],
'field' => [
'type' => 'string',
'description' => 'Champ de configuration concerné par l\'erreur'
],
'message' => [
'type' => 'string',
'description' => 'Message d\'erreur détaillé'
],
'suggestion' => [
'type' => 'string',
'description' => 'Suggestion pour corriger l\'erreur'
]
],
'required' => ['type', 'field', 'message', 'suggestion']
]
]
)]
public array $errors = [],
) {}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Scraping\Application\Command\TestScraperConfiguration;
use App\Domain\Scraping\Application\CommandHandler\TestScraperConfigurationHandler;
use App\Domain\Scraping\Infrastructure\ApiPlatform\Resource\TestScraperConfigurationRequest;
use App\Domain\Scraping\Infrastructure\ApiPlatform\Resource\TestScraperConfigurationResource;
readonly class TestScraperConfigurationStateProcessor implements ProcessorInterface
{
public function __construct(
private TestScraperConfigurationHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TestScraperConfigurationResource
{
assert($data instanceof TestScraperConfigurationRequest);
// Conversion de la requête en Command
$command = new TestScraperConfiguration(
baseUrl: $data->baseUrl,
chapterUrlFormat: $data->chapterUrlFormat,
scrapingType: $data->scrapingType,
testUrl: $data->testUrl,
mangaSlug: $data->mangaSlug,
chapterNumber: $data->chapterNumber,
imageSelector: $data->imageSelector,
nextPageSelector: $data->nextPageSelector,
chapterSelector: $data->chapterSelector,
);
// Exécution du CommandHandler
$response = $this->handler->handle($command);
// Conversion de la Response en Resource
return new TestScraperConfigurationResource(
success: $response->success,
imageUrls: $response->imageUrls,
totalImages: $response->totalImages,
testedUrl: $response->testedUrl,
scrapingType: $response->scrapingType,
errors: $response->errors,
);
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Tests\Feature\Scraping;
use App\Tests\Feature\AbstractApiTestCase;
class TestScraperConfigurationTest extends AbstractApiTestCase
{
public function testTestScraperConfigurationSuccess(): void
{
// Given - Configuration valide avec une URL de test accessible
$payload = [
'baseUrl' => 'https://example.com',
'chapterUrlFormat' => 'https://example.com/manga/{slug}/chapter/{chapter}',
'scrapingType' => 'html',
'testUrl' => 'https://httpbin.org/html',
'mangaSlug' => 'test-manga',
'chapterNumber' => 1.0,
'imageSelector' => 'img',
'nextPageSelector' => null,
'chapterSelector' => null,
];
// When
$response = static::createClient()->request('POST', '/api/scraping/test-configuration', [
'json' => $payload,
'headers' => ['Accept' => 'application/json'],
]);
// Then
$this->assertResponseStatusCodeSame(200);
$responseData = $response->toArray();
$this->assertArrayHasKey('success', $responseData);
$this->assertArrayHasKey('imageUrls', $responseData);
$this->assertArrayHasKey('totalImages', $responseData);
$this->assertArrayHasKey('testedUrl', $responseData);
$this->assertArrayHasKey('scrapingType', $responseData);
$this->assertArrayHasKey('errors', $responseData);
$this->assertEquals('https://httpbin.org/html', $responseData['testedUrl']);
$this->assertEquals('html', $responseData['scrapingType']);
}
public function testTestScraperConfigurationWithInvalidUrl(): void
{
// Given - Configuration avec une URL invalide
$payload = [
'baseUrl' => 'https://example.com',
'chapterUrlFormat' => 'https://example.com/manga/{slug}/chapter/{chapter}',
'scrapingType' => 'html',
'testUrl' => 'https://invalid-url-that-does-not-exist-12345.com',
'mangaSlug' => 'test-manga',
'chapterNumber' => 1.0,
'imageSelector' => 'img',
'nextPageSelector' => null,
'chapterSelector' => null,
];
// When
$response = static::createClient()->request('POST', '/api/scraping/test-configuration', [
'json' => $payload,
'headers' => ['Accept' => 'application/json'],
]);
// Then
$this->assertResponseStatusCodeSame(200);
$responseData = $response->toArray();
$this->assertFalse($responseData['success']);
$this->assertEquals(0, $responseData['totalImages']);
$this->assertEmpty($responseData['imageUrls']);
$this->assertNotEmpty($responseData['errors']);
// Vérifier qu'on a une erreur détaillée
$this->assertIsArray($responseData['errors']);
$this->assertGreaterThan(0, count($responseData['errors']));
}
public function testTestScraperConfigurationWithInvalidSelector(): void
{
// Given - Configuration avec un sélecteur CSS qui ne trouvera rien
$payload = [
'baseUrl' => 'https://example.com',
'chapterUrlFormat' => 'https://example.com/manga/{slug}/chapter/{chapter}',
'scrapingType' => 'html',
'testUrl' => 'https://httpbin.org/html',
'mangaSlug' => 'test-manga',
'chapterNumber' => 1.0,
'imageSelector' => '.non-existent-class-selector-12345',
'nextPageSelector' => null,
'chapterSelector' => null,
];
// When
$response = static::createClient()->request('POST', '/api/scraping/test-configuration', [
'json' => $payload,
'headers' => ['Accept' => 'application/json'],
]);
// Then
$this->assertResponseStatusCodeSame(200);
$responseData = $response->toArray();
// Le sélecteur invalide peut ne pas retourner d'images, ou juste retourner un tableau vide
// C'est un comportement acceptable, le test vérifie juste que ça ne plante pas
$this->assertArrayHasKey('success', $responseData);
$this->assertArrayHasKey('imageUrls', $responseData);
$this->assertArrayHasKey('totalImages', $responseData);
$this->assertArrayHasKey('errors', $responseData);
}
public function testTestScraperConfigurationWithInvalidValidation(): void
{
// Given - Payload avec des données invalides
$payload = [
'baseUrl' => 'invalid-url',
'chapterUrlFormat' => '',
'scrapingType' => 'invalid-type',
'testUrl' => 'not-a-url',
'mangaSlug' => '',
'chapterNumber' => -1,
'imageSelector' => str_repeat('x', 501), // Trop long
];
// When
$response = static::createClient()->request('POST', '/api/scraping/test-configuration', [
'json' => $payload,
'headers' => ['Accept' => 'application/json'],
]);
// Then
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'baseUrl',
'message' => 'L\'URL de base doit être une URL valide',
],
[
'propertyPath' => 'chapterUrlFormat',
'message' => 'Le format d\'URL de chapitre est obligatoire',
],
[
'propertyPath' => 'scrapingType',
'message' => 'Le type de scraping doit être html ou javascript',
],
[
'propertyPath' => 'testUrl',
'message' => 'L\'URL de test doit être une URL valide',
],
[
'propertyPath' => 'mangaSlug',
'message' => 'Le slug du manga est obligatoire',
],
[
'propertyPath' => 'chapterNumber',
'message' => 'Le numéro de chapitre doit être positif',
],
[
'propertyPath' => 'imageSelector',
'message' => 'Le sélecteur d\'image est trop long',
],
],
]);
}
public function testTestScraperConfigurationWithUnsupportedScrapingType(): void
{
// Given - Type de scraping non supporté
$payload = [
'baseUrl' => 'https://example.com',
'chapterUrlFormat' => 'https://example.com/manga/{slug}/chapter/{chapter}',
'scrapingType' => 'unsupported-type', // Type vraiment non supporté
'testUrl' => 'https://httpbin.org/html',
'mangaSlug' => 'test-manga',
'chapterNumber' => 1.0,
'imageSelector' => 'img',
];
// When
$response = static::createClient()->request('POST', '/api/scraping/test-configuration', [
'json' => $payload,
'headers' => ['Accept' => 'application/json'],
]);
// Then
$this->assertResponseStatusCodeSame(422); // Validation error
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'scrapingType',
'message' => 'Le type de scraping doit être html ou javascript',
],
],
]);
}
}