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:
parent
95f224d69a
commit
41ca08f20e
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||||
|
import { useMercureNotifications } from './shared/composables/useMercureNotifications';
|
||||||
|
|
||||||
|
useMercureNotifications();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
45
assets/vue/app/shared/composables/useMercureNotifications.js
Normal file
45
assets/vue/app/shared/composables/useMercureNotifications.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useNotifications } from './useNotifications';
|
||||||
|
|
||||||
|
export function useMercureNotifications() {
|
||||||
|
const { showSuccess, showError, showInfo, showWarning } = useNotifications();
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
const handleNotification = data => {
|
||||||
|
const message = data.message ?? 'Notification';
|
||||||
|
switch (data.status) {
|
||||||
|
case 'success': showSuccess(message); break;
|
||||||
|
case 'error': showError(message); break;
|
||||||
|
case 'warning': showWarning(message); break;
|
||||||
|
default: showInfo(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
const url = new URL('/.well-known/mercure', window.location.origin);
|
||||||
|
url.searchParams.append('topic', 'notifications');
|
||||||
|
|
||||||
|
eventSource = new EventSource(url, { withCredentials: true });
|
||||||
|
|
||||||
|
eventSource.onmessage = event => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleNotification(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('useMercureNotifications: erreur de parsing', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource?.close();
|
||||||
|
setTimeout(setup, 5000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(setup);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
55
src/Command/SendTestNotificationCommand.php
Normal file
55
src/Command/SendTestNotificationCommand.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:notify:test',
|
||||||
|
description: 'Envoie une notification de test via Mercure (utile en dev/prod pour vérifier le système)',
|
||||||
|
)]
|
||||||
|
class SendTestNotificationCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NotificationInterface $notification
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('type', 't', InputOption::VALUE_REQUIRED, 'Type de notification : info, success, error, warning', 'info')
|
||||||
|
->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Message à envoyer', 'Notification de test depuis Mangarr');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$type = $input->getOption('type');
|
||||||
|
$message = $input->getOption('message');
|
||||||
|
|
||||||
|
$allowed = ['info', 'success', 'error', 'warning'];
|
||||||
|
if (!in_array($type, $allowed, true)) {
|
||||||
|
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($type) {
|
||||||
|
'success' => $this->notification->sendSuccess($message),
|
||||||
|
'error' => $this->notification->sendError($message),
|
||||||
|
'warning' => $this->notification->sendWarning($message),
|
||||||
|
default => $this->notification->sendInfo($message),
|
||||||
|
};
|
||||||
|
|
||||||
|
$output->writeln(sprintf('<info>[%s] Notification envoyée : %s</info>', strtoupper($type), $message));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
|||||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
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\Chapter;
|
||||||
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
use App\Domain\Scraping\Domain\Model\ScrapingJob;
|
||||||
use App\Domain\Scraping\Domain\Model\Source;
|
use App\Domain\Scraping\Domain\Model\Source;
|
||||||
@@ -53,13 +54,16 @@ readonly class ScrapeChapterHandler
|
|||||||
throw new \InvalidArgumentException("Manga not found with ID: {$chapter->mangaId}");
|
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);
|
$sources = $this->getSourcesToTry($manga);
|
||||||
if (empty($sources)) {
|
if (empty($sources)) {
|
||||||
throw new \InvalidArgumentException("No sources available for scraping");
|
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;
|
$success = false;
|
||||||
$lastException = null;
|
$lastException = null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ namespace App\Domain\Scraping\Domain\Event;
|
|||||||
class ChapterScrapingStarted
|
class ChapterScrapingStarted
|
||||||
{
|
{
|
||||||
public function __construct(
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ namespace App\Domain\Scraping\Infrastructure\EventSubscriber;
|
|||||||
|
|
||||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
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\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||||
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
|
||||||
|
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
@@ -18,15 +20,25 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
|||||||
private readonly HubInterface $hub,
|
private readonly HubInterface $hub,
|
||||||
private readonly ChapterRepositoryInterface $chapterRepository,
|
private readonly ChapterRepositoryInterface $chapterRepository,
|
||||||
private readonly JobRepositoryInterface $jobRepository,
|
private readonly JobRepositoryInterface $jobRepository,
|
||||||
|
private readonly NotificationInterface $notification,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
public static function getSubscribedEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [];
|
||||||
// Les événements sont capturés via le système de message handlers
|
}
|
||||||
];
|
|
||||||
|
#[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]
|
#[AsMessageHandler]
|
||||||
@@ -42,7 +54,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le chapitre associé au job
|
|
||||||
$chapterId = $job->context['chapterId'] ?? null;
|
$chapterId = $job->context['chapterId'] ?? null;
|
||||||
$this->logger->info('ChapterId extrait du job: ' . $chapterId);
|
$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);
|
$this->logger->info('Chapitre trouvé - ID: ' . $chapter->id . ', MangaId: ' . $chapter->mangaId . ', Number: ' . $chapter->chapterNumber);
|
||||||
|
|
||||||
// Préparer les données à envoyer au front
|
|
||||||
$data = [
|
$data = [
|
||||||
'type' => 'chapter.scraped',
|
'type' => 'chapter.scraped',
|
||||||
'chapterId' => $chapter->id,
|
'chapterId' => $chapter->id,
|
||||||
@@ -65,21 +75,19 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
|||||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
'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 = [
|
$topics = [
|
||||||
'manga/chapter/' . $chapter->id, // Topic spécifique au chapitre
|
'manga/chapter/' . $chapter->id,
|
||||||
'manga/' . $chapter->mangaId . '/chapters', // Topic pour tous les chapitres d'un manga
|
'manga/' . $chapter->mangaId . '/chapters',
|
||||||
'scraping/status' // Topic général pour les événements de scraping
|
'scraping/status'
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
|
||||||
|
|
||||||
$update = new Update($topics, json_encode($data));
|
$update = new Update($topics, json_encode($data));
|
||||||
$this->hub->publish($update);
|
$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]
|
#[AsMessageHandler]
|
||||||
@@ -87,7 +95,6 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
|||||||
{
|
{
|
||||||
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
$this->logger->info('ChapterScrapingFailed reçu pour mangaId: ' . $event->getMangaId() . ', chapter: ' . $event->getChapterNumber());
|
||||||
|
|
||||||
// Préparer les données à envoyer au front
|
|
||||||
$data = [
|
$data = [
|
||||||
'type' => 'chapter.scraping.failed',
|
'type' => 'chapter.scraping.failed',
|
||||||
'mangaId' => $event->getMangaId(),
|
'mangaId' => $event->getMangaId(),
|
||||||
@@ -96,19 +103,16 @@ class ScrapingEventSubscriber implements EventSubscriberInterface
|
|||||||
'timestamp' => (new \DateTimeImmutable())->format('c')
|
'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 = [
|
$topics = [
|
||||||
'manga/' . $event->getMangaId() . '/chapters', // Topic pour tous les chapitres d'un manga
|
'manga/' . $event->getMangaId() . '/chapters',
|
||||||
'scraping/status' // Topic général pour les événements de scraping
|
'scraping/status'
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->logger->info('Topics Mercure: ' . implode(', ', $topics));
|
|
||||||
|
|
||||||
$update = new Update($topics, json_encode($data));
|
$update = new Update($topics, json_encode($data));
|
||||||
$this->hub->publish($update);
|
$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())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,13 @@ namespace App\Domain\Shared\Domain\Contract;
|
|||||||
|
|
||||||
interface NotificationInterface
|
interface NotificationInterface
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Envoie une notification de succès
|
|
||||||
*/
|
|
||||||
public function sendSuccess(string $message): void;
|
public function sendSuccess(string $message): void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Envoie une notification d'erreur
|
|
||||||
*/
|
|
||||||
public function sendError(string $message): void;
|
public function sendError(string $message): void;
|
||||||
|
|
||||||
/**
|
public function sendInfo(string $message): void;
|
||||||
* Envoie une notification avec un statut personnalisé
|
|
||||||
*/
|
public function sendWarning(string $message): void;
|
||||||
|
|
||||||
public function sendUpdate(array $data): void;
|
public function sendUpdate(array $data): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,18 +17,22 @@ readonly class SymfonyNotification implements NotificationInterface
|
|||||||
|
|
||||||
public function sendSuccess(string $message): void
|
public function sendSuccess(string $message): void
|
||||||
{
|
{
|
||||||
$this->sendUpdate([
|
$this->sendUpdate(['status' => 'success', 'message' => $message]);
|
||||||
'status' => 'success',
|
|
||||||
'message' => $message
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendError(string $message): void
|
public function sendError(string $message): void
|
||||||
{
|
{
|
||||||
$this->sendUpdate([
|
$this->sendUpdate(['status' => 'error', 'message' => $message]);
|
||||||
'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
|
public function sendUpdate(array $data): void
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Tests\Domain\Scraping\Application\CommandHandler;
|
|||||||
use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
||||||
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
|
||||||
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
|
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\Chapter;
|
||||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
|
||||||
@@ -81,11 +82,15 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
$job = array_values($job)[0];
|
$job = array_values($job)[0];
|
||||||
|
|
||||||
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
|
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
|
||||||
$this->assertCount(1, $dispatchedMessages);
|
$this->assertCount(2, $dispatchedMessages);
|
||||||
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[0]);
|
|
||||||
$this->assertEquals($job->id, $dispatchedMessages[0]->getJobId());
|
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
|
||||||
$this->assertEquals('1', $dispatchedMessages[0]->chapterId);
|
$this->assertSame(2.0, $dispatchedMessages[0]->getChapterNumber());
|
||||||
$this->assertEquals('/fake/pages/1', $dispatchedMessages[0]->pagesDirectory);
|
|
||||||
|
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]);
|
||||||
|
$this->assertEquals($job->id, $dispatchedMessages[1]->getJobId());
|
||||||
|
$this->assertEquals('1', $dispatchedMessages[1]->chapterId);
|
||||||
|
$this->assertEquals('/fake/pages/1', $dispatchedMessages[1]->pagesDirectory);
|
||||||
$this->assertNotNull($this->imageStorage->stored['1'] ?? null);
|
$this->assertNotNull($this->imageStorage->stored['1'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user