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:
parent
ee2a9b3750
commit
cbb62989d4
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user