feat: notification system via Mercure for scraping events

- NotificationInterface: add sendInfo() and sendWarning() levels
- SymfonyNotification: implement new levels (publishes to 'notifications' topic)
- ChapterScrapingStarted: carry mangaTitle + chapterNumber, now dispatched
- ScrapeChapterHandler: dispatch ChapterScrapingStarted before scraping loop
- ScrapingEventSubscriber: wire NotificationInterface for started/scraped/failed events
- useMercureNotifications: new global Vue composable subscribing to 'notifications' topic
- App.vue: mount useMercureNotifications() at app root
- SendTestNotificationCommand: `app:notify:test --type --message` for dev/prod testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-12 00:57:21 +01:00
parent 95f224d69a
commit 41ca08f20e
9 changed files with 171 additions and 50 deletions

View File

@@ -11,6 +11,7 @@ use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\Source;
@@ -53,13 +54,16 @@ readonly class ScrapeChapterHandler
throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}");
}
// 3. Détermination des sources à utiliser
// 3. Dispatch de l'événement de démarrage
$this->eventBus->dispatch(new ChapterScrapingStarted($manga->getTitle(), $chapter->chapterNumber));
// 4. Détermination des sources à utiliser
$sources = $this->getSourcesToTry($manga);
if (empty($sources)) {
throw new \InvalidArgumentException("No sources available for scraping");
}
// 4. Essai de scraping sur chaque source jusqu'à succès
// 5. Essai de scraping sur chaque source jusqu'à succès
$success = false;
$lastException = null;

View File

@@ -5,12 +5,18 @@ namespace App\Domain\Scraping\Domain\Event;
class ChapterScrapingStarted
{
public function __construct(
private readonly string $jobId
private readonly string $mangaTitle,
private readonly float $chapterNumber,
) {
}
public function getJobId(): string
public function getMangaTitle(): string
{
return $this->jobId;
return $this->mangaTitle;
}
public function getChapterNumber(): float
{
return $this->chapterNumber;
}
}

View File

@@ -4,8 +4,10 @@ namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
use App\Domain\Shared\Domain\Event\ChapterScraped;
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use App\Domain\Shared\Domain\Contract\NotificationInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mercure\HubInterface;
@@ -18,15 +20,25 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
private readonly HubInterface $hub,
private readonly ChapterRepositoryInterface $chapterRepository,
private readonly JobRepositoryInterface $jobRepository,
private readonly NotificationInterface $notification,
private readonly LoggerInterface $logger
) {
}
public static function getSubscribedEvents(): array
{
return [
// Les événements sont capturés via le système de message handlers
];
return [];
}
#[AsMessageHandler]
public function onChapterScrapingStarted(ChapterScrapingStarted $event): void
{
$chapterNumber = $event->getChapterNumber();
$mangaTitle = $event->getMangaTitle();
$this->notification->sendInfo(
sprintf('Scraping du chapitre %s de "%s" démarré', $chapterNumber, $mangaTitle)
);
}
#[AsMessageHandler]
@@ -42,7 +54,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
return;
}
// Récupérer le chapitre associé au job
$chapterId = $job->context['chapterId'] ?? null;
$this->logger->info('ChapterId extrait du job: ' . $chapterId);
@@ -55,7 +66,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
$this->logger->info('Chapitre trouvé - ID: ' . $chapter->id . ', MangaId: ' . $chapter->mangaId . ', Number: ' . $chapter->chapterNumber);
// Préparer les données à envoyer au front
$data = [
'type' => 'chapter.scraped',
'chapterId' => $chapter->id,
@@ -65,21 +75,19 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
'timestamp' => (new \DateTimeImmutable())->format('c')
];
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
// Publier une mise à jour sur le hub Mercure
$topics = [
'manga/chapter/' . $chapter->id, // Topic spécifique au chapitre
'manga/' . $chapter->mangaId . '/chapters', // Topic pour tous les chapitres d'un manga
'scraping/status' // Topic général pour les événements de scraping
'manga/chapter/' . $chapter->id,
'manga/' . $chapter->mangaId . '/chapters',
'scraping/status'
];
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
$update = new Update($topics, json_encode($data));
$this->hub->publish($update);
$this->logger->info('Mise à jour publiée sur Mercure');
$mangaTitle = $job->context['mangaTitle'] ?? 'manga inconnu';
$this->notification->sendSuccess(
sprintf('Chapitre %s de "%s" scrappé avec succès', $chapter->chapterNumber, $mangaTitle)
);
}
#[AsMessageHandler]
@@ -87,7 +95,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
{
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
// Préparer les données à envoyer au front
$data = [
'type' => 'chapter.scraping.failed',
'mangaId' => $event->getMangaId(),
@@ -96,19 +103,16 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
'timestamp' => (new \DateTimeImmutable())->format('c')
];
$this->logger->info('Données préparées pour Mercure: ' . json_encode($data));
// Publier une mise à jour sur le hub Mercure
$topics = [
'manga/' . $event->getMangaId() . '/chapters', // Topic pour tous les chapitres d'un manga
'scraping/status' // Topic général pour les événements de scraping
'manga/' . $event->getMangaId() . '/chapters',
'scraping/status'
];
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
$update = new Update($topics, json_encode($data));
$this->hub->publish($update);
$this->logger->info('Mise à jour publiée sur Mercure');
$this->notification->sendError(
sprintf('Échec du scraping du chapitre %s : %s', $event->getChapterNumber(), $event->getReason())
);
}
}

View File

@@ -6,18 +6,13 @@ namespace App\Domain\Shared\Domain\Contract;
interface NotificationInterface
{
/**
* Envoie une notification de succès
*/
public function sendSuccess(string $message): void;
/**
* Envoie une notification d'erreur
*/
public function sendError(string $message): void;
/**
* Envoie une notification avec un statut personnalisé
*/
public function sendInfo(string $message): void;
public function sendWarning(string $message): void;
public function sendUpdate(array $data): void;
}

View File

@@ -17,18 +17,22 @@ readonly class SymfonyNotification implements NotificationInterface
public function sendSuccess(string $message): void
{
$this->sendUpdate([
'status' => 'success',
'message' => $message
]);
$this->sendUpdate(['status' => 'success', 'message' => $message]);
}
public function sendError(string $message): void
{
$this->sendUpdate([
'status' => 'error',
'message' => $message
]);
$this->sendUpdate(['status' => 'error', 'message' => $message]);
}
public function sendInfo(string $message): void
{
$this->sendUpdate(['status' => 'info', 'message' => $message]);
}
public function sendWarning(string $message): void
{
$this->sendUpdate(['status' => 'warning', 'message' => $message]);
}
public function sendUpdate(array $data): void