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

@@ -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,
);
}
}