From 8811d3dd5eda9d92b6d398a6eb0f8ddd8a08a7b8 Mon Sep 17 00:00:00 2001 From: ThysTips Date: Sat, 1 Feb 2025 15:25:32 +0100 Subject: [PATCH 1/4] build: Add php deployer Signed-off-by: ThysTips --- deploy.php | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 deploy.php diff --git a/deploy.php b/deploy.php new file mode 100644 index 0000000..827948f --- /dev/null +++ b/deploy.php @@ -0,0 +1,39 @@ +set('remote_user', 'colgora') + ->set('deploy_path', '/var/www/mangarr') + ->set('branch', 'main'); + +// Hooks +after('deploy:vendors', 'npm:install'); +after('npm:install', 'webpack_encore:build'); +after('deploy:vendors', 'database:migrate'); +after('deploy:symlink', 'messenger:consume'); +after('deploy:failed', 'deploy:unlock'); From d4142012ec9b25ad47862c74b1c115989bc69237 Mon Sep 17 00:00:00 2001 From: ThysTips Date: Sat, 1 Feb 2025 17:02:55 +0100 Subject: [PATCH 2/4] fix: npm deployment script Signed-off-by: ThysTips --- deploy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy.php b/deploy.php index 827948f..f6ceaa8 100644 --- a/deploy.php +++ b/deploy.php @@ -18,7 +18,7 @@ set('shared_dirs', ['config/secrets','public/cbz','public/tmp','public/images']) desc('Runs webpack encore build'); task('webpack_encore:build', function () { - run("cd {{release_path}} && npm run encore {{webpack_encore/env}}"); + run("cd {{release_path}} && npm run build"); }); desc('Run messenger consume'); From 0e3d72cc5e0dd06ff9ee2d1db6bab593408d070d Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Fri, 31 Jan 2025 19:23:02 +0100 Subject: [PATCH 3/4] feat: debut rerefonte DDD CQRS --- compose.yaml | 2 +- .../Application/Command/ScrapeChapter.php | 11 +++ .../CommandHandler/ScrapeChapterHandler.php | 31 +++++++ .../ScrapingJobRepositoryInterface.php | 12 +++ .../Contract/Service/ScraperInterface.php | 12 +++ .../Domain/Event/ChapterScrapingCompleted.php | 21 +++++ .../Domain/Event/ChapterScrapingStarted.php | 15 ++++ .../Domain/Event/PageScrapingProgressed.php | 23 +++++ .../Scraping/Domain/Model/ScrapingJob.php | 84 +++++++++++++++++++ .../Domain/Model/ScrapingProgress.php | 19 +++++ .../Scraping/Domain/Model/ScrapingStatus.php | 11 +++ .../Domain/Model/ValueObject/ImageUrl.php | 24 ++++++ .../Domain/Model/ValueObject/PageNumber.php | 24 ++++++ .../DoctrineScrapingJobRepository.php | 51 +++++++++++ .../Persistence/Entity/ScrapingJobEntity.php | 83 ++++++++++++++++++ .../Service/Scraper/HtmlScraper.php | 61 ++++++++++++++ 16 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/Domain/Scraping/Application/Command/ScrapeChapter.php create mode 100644 src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php create mode 100644 src/Domain/Scraping/Domain/Contract/Repository/ScrapingJobRepositoryInterface.php create mode 100644 src/Domain/Scraping/Domain/Contract/Service/ScraperInterface.php create mode 100644 src/Domain/Scraping/Domain/Event/ChapterScrapingCompleted.php create mode 100644 src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php create mode 100644 src/Domain/Scraping/Domain/Event/PageScrapingProgressed.php create mode 100644 src/Domain/Scraping/Domain/Model/ScrapingJob.php create mode 100644 src/Domain/Scraping/Domain/Model/ScrapingProgress.php create mode 100644 src/Domain/Scraping/Domain/Model/ScrapingStatus.php create mode 100644 src/Domain/Scraping/Domain/Model/ValueObject/ImageUrl.php create mode 100644 src/Domain/Scraping/Domain/Model/ValueObject/PageNumber.php create mode 100644 src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php create mode 100644 src/Domain/Scraping/Infrastructure/Persistence/Entity/ScrapingJobEntity.php create mode 100644 src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php diff --git a/compose.yaml b/compose.yaml index f5e815a..d109c66 100644 --- a/compose.yaml +++ b/compose.yaml @@ -25,7 +25,7 @@ services: ports: # HTTP - target: 80 - published: ${HTTP_PORT:-80} + published: ${HTTP_PORT:-8081} protocol: tcp # HTTPS - target: 443 diff --git a/src/Domain/Scraping/Application/Command/ScrapeChapter.php b/src/Domain/Scraping/Application/Command/ScrapeChapter.php new file mode 100644 index 0000000..6c44853 --- /dev/null +++ b/src/Domain/Scraping/Application/Command/ScrapeChapter.php @@ -0,0 +1,11 @@ +scraper->createScrapingJob( + $command->chapterId, + $command->sourceId + ); + + $this->scrapingJobRepository->save($job); + + $this->eventBus->dispatch(new ChapterScrapingStarted($job->getId())); + + $this->scraper->scrape($job); + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Domain/Contract/Repository/ScrapingJobRepositoryInterface.php b/src/Domain/Scraping/Domain/Contract/Repository/ScrapingJobRepositoryInterface.php new file mode 100644 index 0000000..194e847 --- /dev/null +++ b/src/Domain/Scraping/Domain/Contract/Repository/ScrapingJobRepositoryInterface.php @@ -0,0 +1,12 @@ +jobId; + } + + public function getScrapedPages(): array + { + return $this->scrapedPages; + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php b/src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php new file mode 100644 index 0000000..2549442 --- /dev/null +++ b/src/Domain/Scraping/Domain/Event/ChapterScrapingStarted.php @@ -0,0 +1,15 @@ +jobId; + } +} diff --git a/src/Domain/Scraping/Domain/Event/PageScrapingProgressed.php b/src/Domain/Scraping/Domain/Event/PageScrapingProgressed.php new file mode 100644 index 0000000..0f4e6c0 --- /dev/null +++ b/src/Domain/Scraping/Domain/Event/PageScrapingProgressed.php @@ -0,0 +1,23 @@ +jobId; + } + + public function getProgress(): ScrapingProgress + { + return $this->progress; + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Domain/Model/ScrapingJob.php b/src/Domain/Scraping/Domain/Model/ScrapingJob.php new file mode 100644 index 0000000..5361c7f --- /dev/null +++ b/src/Domain/Scraping/Domain/Model/ScrapingJob.php @@ -0,0 +1,84 @@ +status = ScrapingStatus::PENDING; + $this->createdAt = new \DateTimeImmutable(); + } + + public function addPage(PageNumber $pageNumber, ImageUrl $imageUrl): void + { + $this->pages[$pageNumber->getValue()] = $imageUrl->getValue(); + if ($this->status === ScrapingStatus::PENDING) { + $this->status = ScrapingStatus::IN_PROGRESS; + } + } + + public function complete(): void + { + $this->status = ScrapingStatus::COMPLETED; + $this->completedAt = new \DateTimeImmutable(); + } + + public function fail(): void + { + $this->status = ScrapingStatus::FAILED; + $this->completedAt = new \DateTimeImmutable(); + } + + public function getId(): string + { + return $this->id; + } + + public function getChapterId(): string + { + return $this->chapterId; + } + + public function getMangaId(): string + { + return $this->mangaId; + } + + public function getSourceId(): string + { + return $this->sourceId; + } + + public function getPages(): array + { + return $this->pages; + } + + public function getStatus(): ScrapingStatus + { + return $this->status; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getCompletedAt(): ?\DateTimeImmutable + { + return $this->completedAt; + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Domain/Model/ScrapingProgress.php b/src/Domain/Scraping/Domain/Model/ScrapingProgress.php new file mode 100644 index 0000000..b5861b3 --- /dev/null +++ b/src/Domain/Scraping/Domain/Model/ScrapingProgress.php @@ -0,0 +1,19 @@ +totalPages === 0) { + return 0; + } + return ($this->pagesScraped / $this->totalPages) * 100; + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Domain/Model/ScrapingStatus.php b/src/Domain/Scraping/Domain/Model/ScrapingStatus.php new file mode 100644 index 0000000..1d88c84 --- /dev/null +++ b/src/Domain/Scraping/Domain/Model/ScrapingStatus.php @@ -0,0 +1,11 @@ +url; + } + + public function getExtension(): string + { + return pathinfo(parse_url($this->url, PHP_URL_PATH), PATHINFO_EXTENSION); + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Domain/Model/ValueObject/PageNumber.php b/src/Domain/Scraping/Domain/Model/ValueObject/PageNumber.php new file mode 100644 index 0000000..5c0fa9d --- /dev/null +++ b/src/Domain/Scraping/Domain/Model/ValueObject/PageNumber.php @@ -0,0 +1,24 @@ +number; + } + + public function getFormattedNumber(): string + { + return sprintf('%03d', $this->number); + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php b/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php new file mode 100644 index 0000000..20ee0b1 --- /dev/null +++ b/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php @@ -0,0 +1,51 @@ +entityManager->persist($job); + $this->entityManager->flush(); + } + + public function findById(string $id): ?ScrapingJob + { + return $this->entityManager->getRepository(ScrapingJob::class)->find($id); + } + + public function findByChapterId(string $chapterId): ?ScrapingJob + { + return $this->entityManager->getRepository(ScrapingJob::class) + ->findOneBy(['chapterId' => $chapterId]); + } + + public function findPendingJobs(): array + { + return $this->entityManager->getRepository(ScrapingJob::class) + ->createQueryBuilder('sj') + ->where('sj.status = :status') + ->setParameter('status', 'pending') + ->getQuery() + ->getResult(); + } + + public function findInProgressJobs(): array + { + return $this->entityManager->getRepository(ScrapingJob::class) + ->createQueryBuilder('sj') + ->where('sj.status = :status') + ->setParameter('status', 'in_progress') + ->getQuery() + ->getResult(); + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Infrastructure/Persistence/Entity/ScrapingJobEntity.php b/src/Domain/Scraping/Infrastructure/Persistence/Entity/ScrapingJobEntity.php new file mode 100644 index 0000000..6ed1e07 --- /dev/null +++ b/src/Domain/Scraping/Infrastructure/Persistence/Entity/ScrapingJobEntity.php @@ -0,0 +1,83 @@ +id = $job->getId(); + $entity->chapterId = $job->getChapterId(); + $entity->mangaId = $job->getMangaId(); + $entity->sourceId = $job->getSourceId(); + $entity->pages = $job->getPages(); + $entity->status = $job->getStatus()->value; + $entity->createdAt = $job->getCreatedAt(); + $entity->completedAt = $job->getCompletedAt(); + + return $entity; + } + + public function toDomain(): ScrapingJob + { + $job = new ScrapingJob( + $this->id, + $this->chapterId, + $this->mangaId, + $this->sourceId + ); + + // Reconstruire l'état du job à partir des données persistées + $reflection = new \ReflectionClass(ScrapingJob::class); + + $pagesProperty = $reflection->getProperty('pages'); + $pagesProperty->setAccessible(true); + $pagesProperty->setValue($job, $this->pages); + + $statusProperty = $reflection->getProperty('status'); + $statusProperty->setAccessible(true); + $statusProperty->setValue($job, ScrapingStatus::from($this->status)); + + $createdAtProperty = $reflection->getProperty('createdAt'); + $createdAtProperty->setAccessible(true); + $createdAtProperty->setValue($job, $this->createdAt); + + $completedAtProperty = $reflection->getProperty('completedAt'); + $completedAtProperty->setAccessible(true); + $completedAtProperty->setValue($job, $this->completedAt); + + return $job; + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php b/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php new file mode 100644 index 0000000..6939472 --- /dev/null +++ b/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php @@ -0,0 +1,61 @@ +buildUrl($job); // À implémenter selon votre logique + $response = $this->httpClient->request('GET', $url); + + $crawler = new Crawler($response->getContent()); + $images = $crawler->filter('img.manga-page'); // Adapter selon le site cible + + $pageNumber = 1; + $images->each(function (Crawler $image) use ($job, $pageNumber) { + $imageUrl = new ImageUrl($image->attr('src')); + $job->addPage(new PageNumber($pageNumber), $imageUrl); + + $this->eventDispatcher->dispatch( + new PageScrapingProgressed($job->getId(), $job->getProgress()) + ); + + $pageNumber++; + }); + + $this->eventDispatcher->dispatch( + new ChapterScrapingCompleted($job->getId(), $job->getPages()) + ); + } + + public function supports(string $sourceType): bool + { + return $sourceType === 'html'; + } +} \ No newline at end of file From 97d7bcf0614fdaf7616078ad081b90a57645e10b Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Sat, 1 Feb 2025 13:59:37 +0100 Subject: [PATCH 4/4] feat: suite du passage en DDD de Scraping --- .../DoctrineScrapingJobRepository.php | 26 +++-- .../Service/Scraper/AbstractScraper.php | 94 +++++++++++++++++++ .../Service/Scraper/HtmlScraper.php | 76 +++++++-------- .../Service/Scraper/JavascriptScraper.php | 38 ++++++++ 4 files changed, 190 insertions(+), 44 deletions(-) create mode 100644 src/Domain/Scraping/Infrastructure/Service/Scraper/AbstractScraper.php create mode 100644 src/Domain/Scraping/Infrastructure/Service/Scraper/JavascriptScraper.php diff --git a/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php b/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php index 20ee0b1..0b569f0 100644 --- a/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php +++ b/src/Domain/Scraping/Infrastructure/Persistence/DoctrineScrapingJobRepository.php @@ -3,7 +3,9 @@ namespace App\Domain\Scraping\Infrastructure\Persistence; use App\Domain\Scraping\Domain\Model\ScrapingJob; +use App\Domain\Scraping\Domain\Model\ScrapingStatus; use App\Domain\Scraping\Domain\Repository\ScrapingJobRepositoryInterface; +use App\Domain\Scraping\Infrastructure\Persistence\Entity\ScrapingJobEntity; use Doctrine\ORM\EntityManagerInterface; class DoctrineScrapingJobRepository implements ScrapingJobRepositoryInterface @@ -14,38 +16,48 @@ class DoctrineScrapingJobRepository implements ScrapingJobRepositoryInterface public function save(ScrapingJob $job): void { - $this->entityManager->persist($job); + $entity = ScrapingJobEntity::fromDomain($job); + $this->entityManager->persist($entity); $this->entityManager->flush(); } public function findById(string $id): ?ScrapingJob { - return $this->entityManager->getRepository(ScrapingJob::class)->find($id); + $entity = $this->entityManager->getRepository(ScrapingJobEntity::class) + ->find($id); + + return $entity?->toDomain(); } public function findByChapterId(string $chapterId): ?ScrapingJob { - return $this->entityManager->getRepository(ScrapingJob::class) + $entity = $this->entityManager->getRepository(ScrapingJobEntity::class) ->findOneBy(['chapterId' => $chapterId]); + + return $entity?->toDomain(); } public function findPendingJobs(): array { - return $this->entityManager->getRepository(ScrapingJob::class) + $entities = $this->entityManager->getRepository(ScrapingJobEntity::class) ->createQueryBuilder('sj') ->where('sj.status = :status') - ->setParameter('status', 'pending') + ->setParameter('status', ScrapingStatus::PENDING->value) ->getQuery() ->getResult(); + + return array_map(fn(ScrapingJobEntity $entity) => $entity->toDomain(), $entities); } public function findInProgressJobs(): array { - return $this->entityManager->getRepository(ScrapingJob::class) + $entities = $this->entityManager->getRepository(ScrapingJobEntity::class) ->createQueryBuilder('sj') ->where('sj.status = :status') - ->setParameter('status', 'in_progress') + ->setParameter('status', ScrapingStatus::IN_PROGRESS->value) ->getQuery() ->getResult(); + + return array_map(fn(ScrapingJobEntity $entity) => $entity->toDomain(), $entities); } } \ No newline at end of file diff --git a/src/Domain/Scraping/Infrastructure/Service/Scraper/AbstractScraper.php b/src/Domain/Scraping/Infrastructure/Service/Scraper/AbstractScraper.php new file mode 100644 index 0000000..75cef16 --- /dev/null +++ b/src/Domain/Scraping/Infrastructure/Service/Scraper/AbstractScraper.php @@ -0,0 +1,94 @@ +eventDispatcher->dispatch(new ChapterScrapingStarted($job->getId())); + + $tempDir = $this->createTempDirectory($job); + $pageData = $this->scrapePages($job); + + foreach ($pageData as $page) { + $this->downloadPage($job, $page, $tempDir); + } + + $job->complete(); + + $this->eventDispatcher->dispatch( + new ChapterScrapingCompleted($job->getId(), $job->getPages()) + ); + + $this->cleanupTempDirectory($tempDir); + + } catch (\Exception $e) { + $job->fail(); + throw $e; + } + } + + abstract protected function scrapePages(ScrapingJob $job): array; + + protected function createTempDirectory(ScrapingJob $job): string + { + $tempDir = $this->tempDir . '/' . uniqid('scraping_' . $job->getId() . '_'); + if (!mkdir($tempDir) && !is_dir($tempDir)) { + throw new \RuntimeException("Failed to create temporary directory: $tempDir"); + } + return $tempDir; + } + + protected function cleanupTempDirectory(string $tempDir): void + { + if (is_dir($tempDir)) { + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($tempDir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + rmdir($tempDir); + } + } + + protected function dispatchProgressEvent(ScrapingJob $job, int $current, int $total): void + { + $progress = new ScrapingProgress($current, $total); + $this->eventDispatcher->dispatch( + new PageScrapingProgressed($job->getId(), $progress) + ); + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php b/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php index 6939472..385563c 100644 --- a/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php +++ b/src/Domain/Scraping/Infrastructure/Service/Scraper/HtmlScraper.php @@ -2,60 +2,62 @@ namespace App\Domain\Scraping\Infrastructure\Service\Scraper; -use App\Domain\Scraping\Domain\Contract\ScraperInterface as ContractScraperInterface; -use App\Domain\Scraping\Domain\Service\ScraperInterface; use App\Domain\Scraping\Domain\Model\ScrapingJob; use App\Domain\Scraping\Domain\Model\ValueObject\ImageUrl; use App\Domain\Scraping\Domain\Model\ValueObject\PageNumber; -use App\Domain\Scraping\Domain\Event\PageScrapingProgressed; -use App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted; use Symfony\Component\DomCrawler\Crawler; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Contracts\HttpClient\HttpClientInterface; -class HtmlScraper implements ContractScraperInterface +class HtmlScraper extends AbstractScraper { - public function __construct( - private readonly HttpClientInterface $httpClient, - private readonly EventDispatcherInterface $eventDispatcher - ) {} - - public function createScrapingJob(string $chapterId, string $sourceId): ScrapingJob + protected function scrapePages(ScrapingJob $job): array { - return new ScrapingJob( - uniqid('scraping_'), - $chapterId, - $sourceId - ); - } - - public function scrape(ScrapingJob $job): void - { - $url = $this->buildUrl($job); // À implémenter selon votre logique + $url = $this->buildUrl($job); $response = $this->httpClient->request('GET', $url); $crawler = new Crawler($response->getContent()); - $images = $crawler->filter('img.manga-page'); // Adapter selon le site cible + $images = $crawler->filter('img.manga-page'); // Adapter selon le site - $pageNumber = 1; - $images->each(function (Crawler $image) use ($job, $pageNumber) { - $imageUrl = new ImageUrl($image->attr('src')); - $job->addPage(new PageNumber($pageNumber), $imageUrl); - - $this->eventDispatcher->dispatch( - new PageScrapingProgressed($job->getId(), $job->getProgress()) - ); - - $pageNumber++; + $pages = []; + $images->each(function (Crawler $image) use (&$pages) { + $pages[] = [ + 'url' => $image->attr('src'), + 'number' => count($pages) + 1 + ]; }); - $this->eventDispatcher->dispatch( - new ChapterScrapingCompleted($job->getId(), $job->getPages()) + return $pages; + } + + protected function downloadPage(ScrapingJob $job, array $page, string $tempDir): void + { + $imageUrl = new ImageUrl($page['url']); + $pageNumber = new PageNumber($page['number']); + + $fileName = sprintf('%s/%03d.%s', + $tempDir, + $pageNumber->getValue(), + $imageUrl->getExtension() ); + + $response = $this->httpClient->request('GET', $imageUrl->getValue()); + file_put_contents($fileName, $response->getContent()); + + $job->addPage($pageNumber, $imageUrl); + $this->dispatchProgressEvent($job, $page['number'], count($pages)); } public function supports(string $sourceType): bool { return $sourceType === 'html'; } -} \ No newline at end of file + + private function buildUrl(ScrapingJob $job): string + { + // À implémenter selon votre logique de construction d'URL + // Vous aurez probablement besoin d'injecter un service pour récupérer les informations du chapitre + return sprintf('https://example.com/manga/%s/chapter/%s', + $job->getMangaId(), + $job->getChapterId() + ); + } +} \ No newline at end of file diff --git a/src/Domain/Scraping/Infrastructure/Service/Scraper/JavascriptScraper.php b/src/Domain/Scraping/Infrastructure/Service/Scraper/JavascriptScraper.php new file mode 100644 index 0000000..69dedc7 --- /dev/null +++ b/src/Domain/Scraping/Infrastructure/Service/Scraper/JavascriptScraper.php @@ -0,0 +1,38 @@ +buildUrl($job); + $crawler = $client->request('GET', $url); + + // Attendre que les images soient chargées + $crawler->waitFor('img.manga-page'); + + $pages = []; + $crawler->filter('img.manga-page')->each(function ($image) use (&$pages) { + $pages[] = [ + 'url' => $image->attr('src'), + 'number' => count($pages) + 1 + ]; + }); + + return $pages; + } finally { + $client->quit(); + } + } + + public function supports(string $sourceType): bool + { + return $sourceType === 'javascript'; + } +} \ No newline at end of file