28 changed files with 2161 additions and 152 deletions
@ -0,0 +1,34 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace DoctrineMigrations; |
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema; |
||||||
|
use Doctrine\Migrations\AbstractMigration; |
||||||
|
|
||||||
|
/** |
||||||
|
* Add RSS feed support to Nzine entity |
||||||
|
*/ |
||||||
|
final class Version20251007000000 extends AbstractMigration |
||||||
|
{ |
||||||
|
public function getDescription(): string |
||||||
|
{ |
||||||
|
return 'Add RSS feed URL, last fetched timestamp, and feed configuration to nzine table'; |
||||||
|
} |
||||||
|
|
||||||
|
public function up(Schema $schema): void |
||||||
|
{ |
||||||
|
$this->addSql('ALTER TABLE nzine ADD feed_url TEXT DEFAULT NULL'); |
||||||
|
$this->addSql('ALTER TABLE nzine ADD last_fetched_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); |
||||||
|
$this->addSql('ALTER TABLE nzine ADD feed_config JSON DEFAULT NULL'); |
||||||
|
$this->addSql('COMMENT ON COLUMN nzine.last_fetched_at IS \'(DC2Type:datetime_immutable)\''); |
||||||
|
} |
||||||
|
|
||||||
|
public function down(Schema $schema): void |
||||||
|
{ |
||||||
|
$this->addSql('ALTER TABLE nzine DROP feed_url'); |
||||||
|
$this->addSql('ALTER TABLE nzine DROP last_fetched_at'); |
||||||
|
$this->addSql('ALTER TABLE nzine DROP feed_config'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,507 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Command; |
||||||
|
|
||||||
|
use App\Factory\ArticleFactory; |
||||||
|
use App\Repository\ArticleRepository; |
||||||
|
use App\Repository\NzineRepository; |
||||||
|
use App\Service\EncryptionService; |
||||||
|
use App\Service\NostrClient; |
||||||
|
use App\Service\NzineCategoryIndexService; |
||||||
|
use App\Service\RssFeedService; |
||||||
|
use App\Service\RssToNostrConverter; |
||||||
|
use App\Service\TagMatchingService; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
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; |
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle; |
||||||
|
|
||||||
|
#[AsCommand( |
||||||
|
name: 'nzine:rss:fetch', |
||||||
|
description: 'Fetch RSS feeds and publish as Nostr events for configured nzines', |
||||||
|
)] |
||||||
|
class RssFetchCommand extends Command |
||||||
|
{ |
||||||
|
private SymfonyStyle $io; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly NzineRepository $nzineRepository, |
||||||
|
private readonly ArticleRepository $articleRepository, |
||||||
|
private readonly RssFeedService $rssFeedService, |
||||||
|
private readonly TagMatchingService $tagMatchingService, |
||||||
|
private readonly RssToNostrConverter $rssToNostrConverter, |
||||||
|
private readonly ArticleFactory $articleFactory, |
||||||
|
private readonly NostrClient $nostrClient, |
||||||
|
private readonly EntityManagerInterface $entityManager, |
||||||
|
private readonly EncryptionService $encryptionService, |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
private readonly NzineCategoryIndexService $categoryIndexService |
||||||
|
) { |
||||||
|
parent::__construct(); |
||||||
|
} |
||||||
|
|
||||||
|
protected function configure(): void |
||||||
|
{ |
||||||
|
$this |
||||||
|
->addOption('nzine-id', null, InputOption::VALUE_OPTIONAL, 'Process only this specific nzine ID') |
||||||
|
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Test without actually publishing events') |
||||||
|
->addOption('limit', null, InputOption::VALUE_OPTIONAL, 'Limit number of items to process per feed', 50); |
||||||
|
} |
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int |
||||||
|
{ |
||||||
|
$this->io = new SymfonyStyle($input, $output); |
||||||
|
|
||||||
|
$nzineId = $input->getOption('nzine-id'); |
||||||
|
$isDryRun = $input->getOption('dry-run'); |
||||||
|
$limit = (int) $input->getOption('limit'); |
||||||
|
|
||||||
|
$this->io->title('RSS Feed to Nostr Aggregator'); |
||||||
|
|
||||||
|
if ($isDryRun) { |
||||||
|
$this->io->warning('Running in DRY-RUN mode - no events will be published'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get nzines to process |
||||||
|
$nzines = $nzineId |
||||||
|
? [$this->nzineRepository->findRssNzineById((int) $nzineId)] |
||||||
|
: $this->nzineRepository->findActiveRssNzines(); |
||||||
|
|
||||||
|
$nzines = array_filter($nzines); // Remove nulls |
||||||
|
|
||||||
|
if (empty($nzines)) { |
||||||
|
$this->io->warning('No RSS-enabled nzines found'); |
||||||
|
return Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
$this->io->info(sprintf('Processing %d nzine(s)', count($nzines))); |
||||||
|
|
||||||
|
$totalStats = [ |
||||||
|
'nzines_processed' => 0, |
||||||
|
'items_fetched' => 0, |
||||||
|
'items_matched' => 0, |
||||||
|
'items_skipped_duplicate' => 0, |
||||||
|
'items_skipped_unmatched' => 0, |
||||||
|
'events_created' => 0, |
||||||
|
'events_updated' => 0, |
||||||
|
'errors' => 0, |
||||||
|
]; |
||||||
|
|
||||||
|
foreach ($nzines as $nzine) { |
||||||
|
try { |
||||||
|
$stats = $this->processNzine($nzine, $isDryRun, $limit); |
||||||
|
|
||||||
|
// Aggregate stats |
||||||
|
foreach ($stats as $key => $value) { |
||||||
|
$totalStats[$key] = ($totalStats[$key] ?? 0) + $value; |
||||||
|
} |
||||||
|
|
||||||
|
$totalStats['nzines_processed']++; |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->io->error(sprintf( |
||||||
|
'Error processing nzine #%d: %s', |
||||||
|
$nzine->getId(), |
||||||
|
$e->getMessage() |
||||||
|
)); |
||||||
|
$this->logger->error('Nzine processing error', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
'error' => $e->getMessage(), |
||||||
|
'trace' => $e->getTraceAsString(), |
||||||
|
]); |
||||||
|
$totalStats['errors']++; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Display final statistics |
||||||
|
$this->io->success('RSS feed processing completed'); |
||||||
|
$this->io->table( |
||||||
|
['Metric', 'Count'], |
||||||
|
[ |
||||||
|
['Nzines processed', $totalStats['nzines_processed']], |
||||||
|
['Items fetched', $totalStats['items_fetched']], |
||||||
|
['Items matched', $totalStats['items_matched']], |
||||||
|
['Events created', $totalStats['events_created']], |
||||||
|
['Events updated', $totalStats['events_updated']], |
||||||
|
['Duplicates skipped', $totalStats['items_skipped_duplicate']], |
||||||
|
['Unmatched skipped', $totalStats['items_skipped_unmatched']], |
||||||
|
['Errors', $totalStats['errors']], |
||||||
|
] |
||||||
|
); |
||||||
|
|
||||||
|
return $totalStats['errors'] > 0 ? Command::FAILURE : Command::SUCCESS; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process a single nzine's RSS feed |
||||||
|
*/ |
||||||
|
private function processNzine($nzine, bool $isDryRun, int $limit): array |
||||||
|
{ |
||||||
|
$stats = [ |
||||||
|
'items_fetched' => 0, |
||||||
|
'items_matched' => 0, |
||||||
|
'items_skipped_duplicate' => 0, |
||||||
|
'items_skipped_unmatched' => 0, |
||||||
|
'events_created' => 0, |
||||||
|
'events_updated' => 0, |
||||||
|
]; |
||||||
|
|
||||||
|
$this->io->section(sprintf('Processing Nzine #%d: %s', $nzine->getId(), $nzine->getSlug())); |
||||||
|
|
||||||
|
$feedUrl = $nzine->getFeedUrl(); |
||||||
|
if (empty($feedUrl)) { |
||||||
|
$this->io->warning('No feed URL configured'); |
||||||
|
return $stats; |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch RSS feed |
||||||
|
try { |
||||||
|
$feedItems = $this->rssFeedService->fetchFeed($feedUrl); |
||||||
|
$stats['items_fetched'] = count($feedItems); |
||||||
|
|
||||||
|
$this->io->text(sprintf('Fetched %d items from feed', count($feedItems))); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->io->error(sprintf('Failed to fetch feed: %s', $e->getMessage())); |
||||||
|
throw $e; |
||||||
|
} |
||||||
|
|
||||||
|
// Limit items if specified |
||||||
|
if ($limit > 0 && count($feedItems) > $limit) { |
||||||
|
$feedItems = array_slice($feedItems, 0, $limit); |
||||||
|
$this->io->text(sprintf('Limited to %d items', $limit)); |
||||||
|
} |
||||||
|
|
||||||
|
// Get nzine categories |
||||||
|
$categories = $nzine->getMainCategories(); |
||||||
|
if (empty($categories)) { |
||||||
|
$this->io->warning('No categories configured - skipping all items'); |
||||||
|
$stats['items_skipped_unmatched'] = count($feedItems); |
||||||
|
return $stats; |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure category index events exist in the database |
||||||
|
$categoryIndices = []; |
||||||
|
if (!$isDryRun) { |
||||||
|
$this->io->text('Ensuring category index events exist...'); |
||||||
|
try { |
||||||
|
$categoryIndices = $this->categoryIndexService->ensureCategoryIndices($nzine); |
||||||
|
$this->io->text(sprintf('Category indices ready: %d', count($categoryIndices))); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->io->warning(sprintf('Could not create category indices: %s', $e->getMessage())); |
||||||
|
$this->logger->warning('Category index creation failed', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
// Continue processing even if category indices fail |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Process each feed item |
||||||
|
$this->io->progressStart(count($feedItems)); |
||||||
|
|
||||||
|
foreach ($feedItems as $item) { |
||||||
|
$this->io->progressAdvance(); |
||||||
|
|
||||||
|
try { |
||||||
|
$result = $this->processRssItem($item, $nzine, $categories, $isDryRun, $categoryIndices); |
||||||
|
|
||||||
|
if ($result === 'created') { |
||||||
|
$stats['events_created']++; |
||||||
|
$stats['items_matched']++; |
||||||
|
} elseif ($result === 'updated') { |
||||||
|
$stats['events_updated']++; |
||||||
|
$stats['items_matched']++; |
||||||
|
} elseif ($result === 'duplicate') { |
||||||
|
$stats['items_skipped_duplicate']++; |
||||||
|
} elseif ($result === 'unmatched') { |
||||||
|
$stats['items_skipped_unmatched']++; |
||||||
|
} |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->io->error(sprintf( |
||||||
|
'Error processing RSS item "%s": %s', |
||||||
|
$item['title'] ?? 'unknown', |
||||||
|
$e->getMessage() |
||||||
|
)); |
||||||
|
$this->logger->error('Error processing RSS item', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
'item_title' => $item['title'] ?? 'unknown', |
||||||
|
'error' => $e->getMessage(), |
||||||
|
'trace' => $e->getTraceAsString(), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$this->io->progressFinish(); |
||||||
|
|
||||||
|
// Re-sign all category indices after articles have been added |
||||||
|
if (!$isDryRun && !empty($categoryIndices)) { |
||||||
|
$this->io->text('Re-signing category indices...'); |
||||||
|
try { |
||||||
|
$this->categoryIndexService->resignCategoryIndices($categoryIndices, $nzine); |
||||||
|
$this->io->text(sprintf('✓ Re-signed %d category indices', count($categoryIndices))); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->io->warning(sprintf('Failed to re-sign category indices: %s', $e->getMessage())); |
||||||
|
$this->logger->error('Category index re-signing failed', [ |
||||||
|
'nzine_id' => $nzine->getId(), |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Update last fetched timestamp |
||||||
|
if (!$isDryRun) { |
||||||
|
$nzine->setLastFetchedAt(new \DateTimeImmutable()); |
||||||
|
$this->entityManager->flush(); |
||||||
|
} |
||||||
|
|
||||||
|
$this->io->table( |
||||||
|
['Metric', 'Count'], |
||||||
|
[ |
||||||
|
['Items fetched', $stats['items_fetched']], |
||||||
|
['Items matched', $stats['items_matched']], |
||||||
|
['Events created', $stats['events_created']], |
||||||
|
['Events updated', $stats['events_updated']], |
||||||
|
['Duplicates skipped', $stats['items_skipped_duplicate']], |
||||||
|
['Unmatched skipped', $stats['items_skipped_unmatched']], |
||||||
|
] |
||||||
|
); |
||||||
|
|
||||||
|
return $stats; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process a single RSS item |
||||||
|
* |
||||||
|
* @return string Result: 'created', 'duplicate', or 'unmatched' |
||||||
|
*/ |
||||||
|
private function processRssItem(array $item, $nzine, array $categories, bool $isDryRun, array $categoryIndices): string |
||||||
|
{ |
||||||
|
// Generate slug for duplicate detection |
||||||
|
$slug = $this->rssToNostrConverter->generateSlugForItem($item); |
||||||
|
|
||||||
|
// Check if already exists |
||||||
|
$existing = $this->articleRepository->findOneBy(['slug' => $slug]); |
||||||
|
if ($existing) { |
||||||
|
if ($isDryRun) { |
||||||
|
$this->io->text(sprintf( |
||||||
|
' 🔄 Would update: "%s"', |
||||||
|
$item['title'] ?? 'unknown' |
||||||
|
)); |
||||||
|
return 'updated'; |
||||||
|
} |
||||||
|
|
||||||
|
$this->io->text(sprintf( |
||||||
|
' 🔄 Updating existing article: "%s"', |
||||||
|
$item['title'] ?? 'unknown' |
||||||
|
)); |
||||||
|
$this->logger->debug('Found existing article - updating', [ |
||||||
|
'slug' => $slug, |
||||||
|
'title' => $item['title'], |
||||||
|
]); |
||||||
|
|
||||||
|
// Match to category for fresh data |
||||||
|
$matchedCategory = $this->tagMatchingService->findMatchingCategory( |
||||||
|
$item['categories'] ?? [], |
||||||
|
$categories |
||||||
|
); |
||||||
|
|
||||||
|
// Convert to Nostr event to get fresh data with all processing applied |
||||||
|
$nostrEvent = $this->rssToNostrConverter->convertToNostrEvent( |
||||||
|
$item, |
||||||
|
$matchedCategory, |
||||||
|
$nzine |
||||||
|
); |
||||||
|
|
||||||
|
// Add original RSS categories as additional tags |
||||||
|
if (!empty($item['categories'])) { |
||||||
|
foreach ($item['categories'] as $rssCategory) { |
||||||
|
$categorySlug = strtolower(trim($rssCategory)); |
||||||
|
$tagExists = false; |
||||||
|
|
||||||
|
foreach ($nostrEvent->getTags() as $existingTag) { |
||||||
|
if (is_array($existingTag) && $existingTag[0] === 't' && isset($existingTag[1]) && $existingTag[1] === $categorySlug) { |
||||||
|
$tagExists = true; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!$tagExists) { |
||||||
|
$nostrEvent->addTag(['t', $categorySlug]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Convert to stdClass for processing |
||||||
|
$eventObject = json_decode($nostrEvent->toJson()); |
||||||
|
|
||||||
|
// Update all fields from the fresh event data |
||||||
|
$existing->setContent($eventObject->content); |
||||||
|
$existing->setTitle($item['title'] ?? ''); |
||||||
|
|
||||||
|
// Set createdAt and publishedAt from RSS pubDate if available |
||||||
|
if (isset($item['pubDate']) && $item['pubDate'] instanceof \DateTimeImmutable) { |
||||||
|
$existing->setCreatedAt($item['pubDate']); |
||||||
|
$existing->setPublishedAt($item['pubDate']); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract and set summary from tags (now with HTML stripped) |
||||||
|
foreach ($eventObject->tags as $tag) { |
||||||
|
if ($tag[0] === 'summary' && isset($tag[1])) { |
||||||
|
$existing->setSummary($tag[1]); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Clear existing topics and re-add from fresh data |
||||||
|
$existing->clearTopics(); |
||||||
|
foreach ($eventObject->tags as $tag) { |
||||||
|
if ($tag[0] === 't' && isset($tag[1])) { |
||||||
|
$existing->addTopic($tag[1]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$this->entityManager->persist($existing); |
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
$this->logger->info('Article updated with fresh RSS data', [ |
||||||
|
'slug' => $slug, |
||||||
|
'title' => $item['title'], |
||||||
|
]); |
||||||
|
|
||||||
|
return 'updated'; |
||||||
|
} |
||||||
|
|
||||||
|
// Match to category |
||||||
|
$matchedCategory = $this->tagMatchingService->findMatchingCategory( |
||||||
|
$item['categories'] ?? [], |
||||||
|
$categories |
||||||
|
); |
||||||
|
|
||||||
|
if (!$matchedCategory) { |
||||||
|
$this->io->text(sprintf( |
||||||
|
' ℹ No category match: "%s" [categories: %s] - importing as standalone', |
||||||
|
$item['title'] ?? 'unknown', |
||||||
|
implode(', ', $item['categories'] ?? ['none']) |
||||||
|
)); |
||||||
|
$this->logger->debug('No category match for item - importing as standalone', [ |
||||||
|
'title' => $item['title'], |
||||||
|
'categories' => $item['categories'] ?? [], |
||||||
|
]); |
||||||
|
// Don't return - continue processing without a category |
||||||
|
} |
||||||
|
|
||||||
|
if ($isDryRun) { |
||||||
|
$categoryLabel = $matchedCategory |
||||||
|
? ($matchedCategory['name'] ?? $matchedCategory['title'] ?? $matchedCategory['slug'] ?? 'unknown') |
||||||
|
: 'standalone'; |
||||||
|
|
||||||
|
$this->io->text(sprintf( |
||||||
|
' ✓ Would create: "%s" → %s', |
||||||
|
$item['title'] ?? 'unknown', |
||||||
|
$categoryLabel |
||||||
|
)); |
||||||
|
$this->logger->info('[DRY RUN] Would create event', [ |
||||||
|
'title' => $item['title'], |
||||||
|
'category' => $categoryLabel, |
||||||
|
'slug' => $slug, |
||||||
|
]); |
||||||
|
return 'created'; |
||||||
|
} |
||||||
|
|
||||||
|
// Convert to Nostr event (with or without category) |
||||||
|
$nostrEvent = $this->rssToNostrConverter->convertToNostrEvent( |
||||||
|
$item, |
||||||
|
$matchedCategory, |
||||||
|
$nzine |
||||||
|
); |
||||||
|
|
||||||
|
// Add original RSS categories as additional tags (topics) |
||||||
|
// This ensures RSS feed categories are preserved even if they don't match nzine categories |
||||||
|
if (!empty($item['categories'])) { |
||||||
|
foreach ($item['categories'] as $rssCategory) { |
||||||
|
// Add as 't' tag if not already present |
||||||
|
$categorySlug = strtolower(trim($rssCategory)); |
||||||
|
$tagExists = false; |
||||||
|
|
||||||
|
foreach ($nostrEvent->getTags() as $existingTag) { |
||||||
|
if (is_array($existingTag) && $existingTag[0] === 't' && isset($existingTag[1]) && $existingTag[1] === $categorySlug) { |
||||||
|
$tagExists = true; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!$tagExists) { |
||||||
|
$nostrEvent->addTag(['t', $categorySlug]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Convert Nostr Event to stdClass object for ArticleFactory |
||||||
|
$eventObject = json_decode($nostrEvent->toJson()); |
||||||
|
|
||||||
|
// Create Article entity from the event object |
||||||
|
$article = $this->articleFactory->createFromLongFormContentEvent($eventObject); |
||||||
|
$this->entityManager->persist($article); |
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
// Add article to category index if category matched |
||||||
|
if ($matchedCategory && isset($matchedCategory['slug']) && !empty($categoryIndices)) { |
||||||
|
$categorySlug = $matchedCategory['slug']; |
||||||
|
if (isset($categoryIndices[$categorySlug])) { |
||||||
|
$articleCoordinate = sprintf( |
||||||
|
'%d:%s:%s', |
||||||
|
$article->getKind()->value, |
||||||
|
$article->getPubkey(), |
||||||
|
$article->getSlug() |
||||||
|
); |
||||||
|
|
||||||
|
try { |
||||||
|
$this->categoryIndexService->addArticleToCategoryIndex( |
||||||
|
$categoryIndices[$categorySlug], |
||||||
|
$articleCoordinate |
||||||
|
); |
||||||
|
$this->entityManager->flush(); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->warning('Failed to add article to category index', [ |
||||||
|
'article_slug' => $article->getSlug(), |
||||||
|
'category_slug' => $categorySlug, |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$categoryLabel = $matchedCategory |
||||||
|
? ($matchedCategory['name'] ?? $matchedCategory['title'] ?? $matchedCategory['slug'] ?? 'unknown') |
||||||
|
: 'standalone'; |
||||||
|
|
||||||
|
$this->io->text(sprintf( |
||||||
|
' ✓ Created: "%s" → %s', |
||||||
|
$item['title'] ?? 'unknown', |
||||||
|
$categoryLabel |
||||||
|
)); |
||||||
|
|
||||||
|
// Publish to relays (async/background in production) |
||||||
|
try { |
||||||
|
// TODO: Get configured relays from nzine or use default |
||||||
|
// $this->nostrClient->publishEvent($nostrEvent, $relays); |
||||||
|
$this->logger->info('Event created and saved', [ |
||||||
|
'event_id' => $nostrEvent->getId() ?? 'unknown', |
||||||
|
'title' => $item['title'], |
||||||
|
'category' => $categoryLabel, |
||||||
|
]); |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->warning('Failed to publish to relays', [ |
||||||
|
'event_id' => $nostrEvent->getId() ?? 'unknown', |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
// Continue even if relay publishing fails |
||||||
|
} |
||||||
|
|
||||||
|
return 'created'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,159 @@ |
|||||||
|
<?php |
||||||
|
/** |
||||||
|
* Example script for setting up a Nzine with RSS feed |
||||||
|
* |
||||||
|
* This is a reference implementation showing how to configure a nzine |
||||||
|
* with RSS feed support. Adapt this to your needs (console command, controller, etc.) |
||||||
|
*/ |
||||||
|
|
||||||
|
namespace App\Examples; |
||||||
|
|
||||||
|
use App\Entity\Nzine; |
||||||
|
use App\Repository\NzineRepository; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
|
||||||
|
class RssNzineSetupExample |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly NzineRepository $nzineRepository, |
||||||
|
private readonly EntityManagerInterface $entityManager |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Example: Configure an existing nzine with RSS feed |
||||||
|
*/ |
||||||
|
public function setupRssFeedForNzine(int $nzineId): void |
||||||
|
{ |
||||||
|
$nzine = $this->nzineRepository->find($nzineId); |
||||||
|
|
||||||
|
if (!$nzine) { |
||||||
|
throw new \RuntimeException("Nzine not found: $nzineId"); |
||||||
|
} |
||||||
|
|
||||||
|
// Set the RSS feed URL |
||||||
|
$nzine->setFeedUrl('https://example.com/feed.rss'); |
||||||
|
|
||||||
|
// Configure categories with tags for RSS item matching |
||||||
|
$categories = [ |
||||||
|
[ |
||||||
|
'name' => 'Artificial Intelligence', |
||||||
|
'slug' => 'ai', |
||||||
|
'tags' => ['artificial-intelligence', 'machine-learning', 'AI', 'ML', 'deep-learning', 'neural-networks'] |
||||||
|
], |
||||||
|
[ |
||||||
|
'name' => 'Blockchain & Crypto', |
||||||
|
'slug' => 'blockchain', |
||||||
|
'tags' => ['crypto', 'cryptocurrency', 'blockchain', 'bitcoin', 'ethereum', 'web3', 'defi', 'nft'] |
||||||
|
], |
||||||
|
[ |
||||||
|
'name' => 'Programming', |
||||||
|
'slug' => 'programming', |
||||||
|
'tags' => ['programming', 'coding', 'development', 'software', 'javascript', 'python', 'rust', 'go'] |
||||||
|
], |
||||||
|
[ |
||||||
|
'name' => 'Nostr Protocol', |
||||||
|
'slug' => 'nostr', |
||||||
|
'tags' => ['nostr', 'decentralized', 'social-media', 'protocol'] |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$nzine->setMainCategories($categories); |
||||||
|
|
||||||
|
// Optional: Set custom feed configuration |
||||||
|
$nzine->setFeedConfig([ |
||||||
|
'enabled' => true, |
||||||
|
'description' => 'Tech news aggregator', |
||||||
|
// Future options: |
||||||
|
// 'max_age_days' => 7, |
||||||
|
// 'fetch_full_content' => true, |
||||||
|
]); |
||||||
|
|
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
echo "RSS feed configured for nzine #{$nzineId}\n"; |
||||||
|
echo "Feed URL: " . $nzine->getFeedUrl() . "\n"; |
||||||
|
echo "Categories: " . count($categories) . "\n"; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Example: Create a new RSS-enabled nzine from scratch |
||||||
|
*/ |
||||||
|
public function createRssEnabledNzine( |
||||||
|
string $title, |
||||||
|
string $summary, |
||||||
|
string $feedUrl, |
||||||
|
array $categories |
||||||
|
): Nzine { |
||||||
|
// Note: This is a simplified example. In practice, you should: |
||||||
|
// 1. Use NzineWorkflowService to create the bot and profile |
||||||
|
// 2. Create the main index |
||||||
|
// 3. Create nested indices |
||||||
|
// 4. Transition through the workflow states |
||||||
|
|
||||||
|
$nzine = new Nzine(); |
||||||
|
$nzine->setFeedUrl($feedUrl); |
||||||
|
$nzine->setMainCategories($categories); |
||||||
|
|
||||||
|
// You would normally use the workflow service here: |
||||||
|
// $this->nzineWorkflowService->init($nzine); |
||||||
|
// $this->nzineWorkflowService->createProfile(...); |
||||||
|
// etc. |
||||||
|
|
||||||
|
$this->entityManager->persist($nzine); |
||||||
|
$this->entityManager->flush(); |
||||||
|
|
||||||
|
return $nzine; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Example: List all RSS-enabled nzines |
||||||
|
*/ |
||||||
|
public function listRssNzines(): void |
||||||
|
{ |
||||||
|
$nzines = $this->nzineRepository->findActiveRssNzines(); |
||||||
|
|
||||||
|
echo "RSS-enabled Nzines:\n"; |
||||||
|
echo str_repeat("=", 80) . "\n"; |
||||||
|
|
||||||
|
foreach ($nzines as $nzine) { |
||||||
|
echo sprintf( |
||||||
|
"ID: %d | Slug: %s | Feed: %s\n", |
||||||
|
$nzine->getId(), |
||||||
|
$nzine->getSlug() ?? 'N/A', |
||||||
|
$nzine->getFeedUrl() |
||||||
|
); |
||||||
|
|
||||||
|
$lastFetched = $nzine->getLastFetchedAt(); |
||||||
|
if ($lastFetched) { |
||||||
|
echo " Last fetched: " . $lastFetched->format('Y-m-d H:i:s') . "\n"; |
||||||
|
} |
||||||
|
|
||||||
|
echo " Categories: " . count($nzine->getMainCategories()) . "\n"; |
||||||
|
echo "\n"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Example RSS feed URLs for testing |
||||||
|
*/ |
||||||
|
public static function getExampleFeeds(): array |
||||||
|
{ |
||||||
|
return [ |
||||||
|
'tech' => [ |
||||||
|
'TechCrunch' => 'https://techcrunch.com/feed/', |
||||||
|
'Hacker News' => 'https://hnrss.org/newest', |
||||||
|
'Ars Technica' => 'https://feeds.arstechnica.com/arstechnica/index', |
||||||
|
], |
||||||
|
'crypto' => [ |
||||||
|
'CoinDesk' => 'https://www.coindesk.com/arc/outboundfeeds/rss/', |
||||||
|
'Bitcoin Magazine' => 'https://bitcoinmagazine.com/.rss/full/', |
||||||
|
], |
||||||
|
'programming' => [ |
||||||
|
'Dev.to' => 'https://dev.to/feed', |
||||||
|
'GitHub Blog' => 'https://github.blog/feed/', |
||||||
|
] |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,189 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for fetching and parsing RSS feeds |
||||||
|
*/ |
||||||
|
class RssFeedService |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly HttpClientInterface $httpClient, |
||||||
|
private readonly LoggerInterface $logger |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch and parse an RSS feed from a URL |
||||||
|
* |
||||||
|
* @param string $feedUrl The URL of the RSS feed |
||||||
|
* @return array Array of feed items, each containing: title, link, pubDate, description, content, categories |
||||||
|
* @throws \Exception if feed cannot be fetched or parsed |
||||||
|
*/ |
||||||
|
public function fetchFeed(string $feedUrl): array |
||||||
|
{ |
||||||
|
try { |
||||||
|
$this->logger->info('Fetching RSS feed', ['url' => $feedUrl]); |
||||||
|
|
||||||
|
$response = $this->httpClient->request('GET', $feedUrl, [ |
||||||
|
'timeout' => 30, |
||||||
|
'headers' => [ |
||||||
|
'User-Agent' => 'Newsroom RSS Aggregator/1.0', |
||||||
|
], |
||||||
|
]); |
||||||
|
|
||||||
|
if ($response->getStatusCode() !== 200) { |
||||||
|
throw new \Exception(sprintf('HTTP error %d when fetching feed', $response->getStatusCode())); |
||||||
|
} |
||||||
|
|
||||||
|
$xmlContent = $response->getContent(); |
||||||
|
$items = $this->parseRssFeed($xmlContent); |
||||||
|
|
||||||
|
$this->logger->info('RSS feed fetched successfully', [ |
||||||
|
'url' => $feedUrl, |
||||||
|
'items' => count($items), |
||||||
|
]); |
||||||
|
|
||||||
|
return $items; |
||||||
|
} catch (\Exception $e) { |
||||||
|
$this->logger->error('Failed to fetch RSS feed', [ |
||||||
|
'url' => $feedUrl, |
||||||
|
'error' => $e->getMessage(), |
||||||
|
]); |
||||||
|
throw $e; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse RSS XML content into structured array |
||||||
|
* |
||||||
|
* @param string $xmlContent Raw XML content |
||||||
|
* @return array Array of parsed feed items |
||||||
|
* @throws \Exception if XML parsing fails |
||||||
|
*/ |
||||||
|
private function parseRssFeed(string $xmlContent): array |
||||||
|
{ |
||||||
|
libxml_use_internal_errors(true); |
||||||
|
$xml = simplexml_load_string($xmlContent); |
||||||
|
|
||||||
|
if ($xml === false) { |
||||||
|
$errors = libxml_get_errors(); |
||||||
|
libxml_clear_errors(); |
||||||
|
throw new \Exception('Failed to parse RSS XML: ' . json_encode($errors)); |
||||||
|
} |
||||||
|
|
||||||
|
$items = []; |
||||||
|
|
||||||
|
// Handle both RSS 2.0 and Atom feeds |
||||||
|
if (isset($xml->channel->item)) { |
||||||
|
// RSS 2.0 |
||||||
|
foreach ($xml->channel->item as $item) { |
||||||
|
$items[] = $this->parseRssItem($item); |
||||||
|
} |
||||||
|
} elseif (isset($xml->entry)) { |
||||||
|
// Atom feed |
||||||
|
foreach ($xml->entry as $entry) { |
||||||
|
$items[] = $this->parseAtomEntry($entry); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $items; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a single RSS 2.0 item |
||||||
|
*/ |
||||||
|
private function parseRssItem(\SimpleXMLElement $item): array |
||||||
|
{ |
||||||
|
$namespaces = $item->getNamespaces(true); |
||||||
|
$content = ''; |
||||||
|
|
||||||
|
// Try to get full content from content:encoded or description |
||||||
|
if (isset($namespaces['content'])) { |
||||||
|
$contentChildren = $item->children($namespaces['content']); |
||||||
|
if (isset($contentChildren->encoded)) { |
||||||
|
$content = (string) $contentChildren->encoded; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (empty($content)) { |
||||||
|
$content = (string) ($item->description ?? ''); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract categories |
||||||
|
$categories = []; |
||||||
|
if (isset($item->category)) { |
||||||
|
foreach ($item->category as $category) { |
||||||
|
$categories[] = (string) $category; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Parse publication date |
||||||
|
$pubDate = null; |
||||||
|
if (isset($item->pubDate)) { |
||||||
|
$pubDate = new \DateTimeImmutable((string) $item->pubDate); |
||||||
|
} |
||||||
|
|
||||||
|
return [ |
||||||
|
'title' => (string) ($item->title ?? ''), |
||||||
|
'link' => (string) ($item->link ?? ''), |
||||||
|
'pubDate' => $pubDate, |
||||||
|
'description' => (string) ($item->description ?? ''), |
||||||
|
'content' => $content, |
||||||
|
'categories' => $categories, |
||||||
|
'guid' => (string) ($item->guid ?? ''), |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a single Atom entry |
||||||
|
*/ |
||||||
|
private function parseAtomEntry(\SimpleXMLElement $entry): array |
||||||
|
{ |
||||||
|
$namespaces = $entry->getNamespaces(true); |
||||||
|
|
||||||
|
// Get link |
||||||
|
$link = ''; |
||||||
|
if (isset($entry->link)) { |
||||||
|
foreach ($entry->link as $l) { |
||||||
|
if ((string) $l['rel'] === 'alternate' || !isset($l['rel'])) { |
||||||
|
$link = (string) $l['href']; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Get content |
||||||
|
$content = (string) ($entry->content ?? $entry->summary ?? ''); |
||||||
|
|
||||||
|
// Get categories/tags |
||||||
|
$categories = []; |
||||||
|
if (isset($entry->category)) { |
||||||
|
foreach ($entry->category as $category) { |
||||||
|
$categories[] = (string) $category['term']; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Parse publication date |
||||||
|
$pubDate = null; |
||||||
|
if (isset($entry->published)) { |
||||||
|
$pubDate = new \DateTimeImmutable((string) $entry->published); |
||||||
|
} elseif (isset($entry->updated)) { |
||||||
|
$pubDate = new \DateTimeImmutable((string) $entry->updated); |
||||||
|
} |
||||||
|
|
||||||
|
return [ |
||||||
|
'title' => (string) ($entry->title ?? ''), |
||||||
|
'link' => $link, |
||||||
|
'pubDate' => $pubDate, |
||||||
|
'description' => (string) ($entry->summary ?? ''), |
||||||
|
'content' => $content, |
||||||
|
'categories' => $categories, |
||||||
|
'guid' => (string) ($entry->id ?? ''), |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,166 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Entity\Nzine; |
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use swentel\nostr\Event\Event; |
||||||
|
use swentel\nostr\Sign\Sign; |
||||||
|
use Symfony\Component\String\Slugger\AsciiSlugger; |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for converting RSS feed items to Nostr longform events |
||||||
|
*/ |
||||||
|
class RssToNostrConverter |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
private readonly EncryptionService $encryptionService |
||||||
|
) { |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert an RSS item to a Nostr longform event (kind 30023) |
||||||
|
* |
||||||
|
* @param array $rssItem The RSS item data |
||||||
|
* @param array|null $matchedCategory The matched nzine category (null if no match) |
||||||
|
* @param Nzine $nzine The nzine entity |
||||||
|
* @param string|null $categoryIndexEventId The event ID of the category index (for 'a' tag) |
||||||
|
* @return Event The created and signed Nostr event |
||||||
|
*/ |
||||||
|
public function convertToNostrEvent( |
||||||
|
array $rssItem, |
||||||
|
?array $matchedCategory, |
||||||
|
Nzine $nzine, |
||||||
|
?string $categoryIndexEventId = null |
||||||
|
): Event { |
||||||
|
$bot = $nzine->getNzineBot(); |
||||||
|
if (!$bot) { |
||||||
|
throw new \RuntimeException('Nzine bot not found'); |
||||||
|
} |
||||||
|
|
||||||
|
$bot->setEncryptionService($this->encryptionService); |
||||||
|
$privateKey = $bot->getNsec(); |
||||||
|
|
||||||
|
if (!$privateKey) { |
||||||
|
throw new \RuntimeException('Bot private key not found'); |
||||||
|
} |
||||||
|
|
||||||
|
// Create the event |
||||||
|
$event = new Event(); |
||||||
|
$event->setKind(KindsEnum::LONGFORM->value); |
||||||
|
|
||||||
|
// Set content |
||||||
|
$content = $rssItem['content'] ?? $rssItem['description'] ?? ''; |
||||||
|
|
||||||
|
// If we have both content and link, append a reference |
||||||
|
if (!empty($rssItem['link'])) { |
||||||
|
$content .= "\n\n---\n\nOriginal article: " . $rssItem['link']; |
||||||
|
} |
||||||
|
|
||||||
|
$event->setContent($content); |
||||||
|
|
||||||
|
// Generate unique slug from title and timestamp |
||||||
|
$slug = $this->generateSlug($rssItem['title'], $rssItem['pubDate']); |
||||||
|
$event->addTag(['d', $slug]); |
||||||
|
|
||||||
|
// Add title tag |
||||||
|
if (!empty($rssItem['title'])) { |
||||||
|
$event->addTag(['title', $rssItem['title']]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add summary tag |
||||||
|
if (!empty($rssItem['description'])) { |
||||||
|
$summary = $this->htmlToPlainText($rssItem['description']); |
||||||
|
$event->addTag(['summary', $summary]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add published_at tag |
||||||
|
if ($rssItem['pubDate'] instanceof \DateTimeImmutable) { |
||||||
|
$event->addTag(['published_at', (string) $rssItem['pubDate']->getTimestamp()]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add category tag (t tag) - only if category matched |
||||||
|
if ($matchedCategory && isset($matchedCategory['slug'])) { |
||||||
|
$event->addTag(['t', $matchedCategory['slug']]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add reference to original URL |
||||||
|
if (!empty($rssItem['link'])) { |
||||||
|
$event->addTag(['r', $rssItem['link']]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add reference to category index if provided and category matched |
||||||
|
if ($categoryIndexEventId && $matchedCategory && isset($matchedCategory['slug'])) { |
||||||
|
$npub = $nzine->getNpub(); |
||||||
|
$event->addTag(['a', KindsEnum::PUBLICATION_INDEX->value . ':' . $npub . ':' . $matchedCategory['slug']]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add client tag to indicate source |
||||||
|
$event->addTag(['client', 'newsroom-rss-aggregator']); |
||||||
|
|
||||||
|
// Sign the event |
||||||
|
$signer = new Sign(); |
||||||
|
$signer->signEvent($event, $privateKey); |
||||||
|
|
||||||
|
$this->logger->info('Created Nostr event from RSS item', [ |
||||||
|
'title' => $rssItem['title'], |
||||||
|
'slug' => $slug, |
||||||
|
'category' => $matchedCategory['name'] ?? null, |
||||||
|
]); |
||||||
|
|
||||||
|
return $event; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a unique slug from title and timestamp |
||||||
|
*/ |
||||||
|
private function generateSlug(string $title, ?\DateTimeImmutable $pubDate): string |
||||||
|
{ |
||||||
|
$slugger = new AsciiSlugger(); |
||||||
|
$baseSlug = $slugger->slug($title)->lower()->toString(); |
||||||
|
|
||||||
|
// Limit base slug length |
||||||
|
if (strlen($baseSlug) > 50) { |
||||||
|
$baseSlug = substr($baseSlug, 0, 50); |
||||||
|
} |
||||||
|
|
||||||
|
// Add timestamp for uniqueness |
||||||
|
$timestamp = $pubDate ? $pubDate->format('Y-m-d-His') : date('Y-m-d-His'); |
||||||
|
|
||||||
|
return $baseSlug . '-' . $timestamp; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Convert HTML content to plain text |
||||||
|
* Strips HTML tags and decodes HTML entities |
||||||
|
*/ |
||||||
|
private function htmlToPlainText(?string $html): string |
||||||
|
{ |
||||||
|
if (empty($html)) { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
// Strip HTML tags |
||||||
|
$text = strip_tags($html); |
||||||
|
|
||||||
|
// Decode HTML entities |
||||||
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
||||||
|
|
||||||
|
// Normalize whitespace |
||||||
|
$text = preg_replace('/\s+/', ' ', $text); |
||||||
|
|
||||||
|
// Trim |
||||||
|
return trim($text); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a slug already exists in the database |
||||||
|
* This is used by the command to detect duplicates |
||||||
|
*/ |
||||||
|
public function generateSlugForItem(array $rssItem): string |
||||||
|
{ |
||||||
|
return $this->generateSlug($rssItem['title'], $rssItem['pubDate']); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for matching RSS item categories/tags to nzine categories |
||||||
|
*/ |
||||||
|
class TagMatchingService |
||||||
|
{ |
||||||
|
public function __construct(private readonly LoggerInterface $logger) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Find the first matching nzine category for an RSS item |
||||||
|
* |
||||||
|
* @param array $rssItemCategories Array of category strings from the RSS item |
||||||
|
* @param array $nzineCategories Array of nzine categories (each has 'name', 'slug', 'tags') |
||||||
|
* @return array|null The matched category or null if no match found |
||||||
|
*/ |
||||||
|
public function findMatchingCategory(array $rssItemCategories, array $nzineCategories): ?array |
||||||
|
{ |
||||||
|
// Normalize RSS item categories to lowercase for case-insensitive matching |
||||||
|
$normalizedRssCategories = array_map('strtolower', array_map('trim', $rssItemCategories)); |
||||||
|
|
||||||
|
foreach ($nzineCategories as $nzineCategory) { |
||||||
|
if (!isset($nzineCategory['tags']) || empty($nzineCategory['tags'])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Parse tags - can be array or comma-separated string |
||||||
|
$tags = is_array($nzineCategory['tags']) |
||||||
|
? $nzineCategory['tags'] |
||||||
|
: explode(',', $nzineCategory['tags']); |
||||||
|
|
||||||
|
// Normalize tags to lowercase |
||||||
|
$normalizedTags = array_map('strtolower', array_map('trim', $tags)); |
||||||
|
|
||||||
|
// Check if any RSS category matches any nzine tag |
||||||
|
foreach ($normalizedRssCategories as $rssCategory) { |
||||||
|
if (in_array($rssCategory, $normalizedTags, true)) { |
||||||
|
$this->logger->debug('Category match found', [ |
||||||
|
'rss_category' => $rssCategory, |
||||||
|
'nzine_category' => $nzineCategory['name'] ?? $nzineCategory['title'] ?? $nzineCategory['slug'] ?? 'unknown', |
||||||
|
]); |
||||||
|
return $nzineCategory; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$this->logger->debug('No category match found', [ |
||||||
|
'rss_categories' => $rssItemCategories, |
||||||
|
]); |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract all unique tags from nzine categories |
||||||
|
* |
||||||
|
* @param array $nzineCategories Array of nzine categories |
||||||
|
* @return array Array of all unique tags |
||||||
|
*/ |
||||||
|
public function extractAllTags(array $nzineCategories): array |
||||||
|
{ |
||||||
|
$allTags = []; |
||||||
|
|
||||||
|
foreach ($nzineCategories as $category) { |
||||||
|
if (!isset($category['tags']) || empty($category['tags'])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
$tags = is_array($category['tags']) |
||||||
|
? $category['tags'] |
||||||
|
: explode(',', $category['tags']); |
||||||
|
|
||||||
|
$allTags = array_merge($allTags, array_map('trim', $tags)); |
||||||
|
} |
||||||
|
|
||||||
|
return array_unique($allTags); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
{% extends 'layout.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<section class="d-flex gap-3 center ln-section--newsstand"> |
||||||
|
<div class="container mt-5 mb-1"> |
||||||
|
<h1>Your NZines</h1> |
||||||
|
<p class="eyebrow">manage your digital magazines</p> |
||||||
|
</div> |
||||||
|
<div class="cta-row mb-5"> |
||||||
|
<a class="btn btn-primary" href="{{ path('nzine_index') }}">Create new</a> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<div class="w-container mb-5 mt-5"> |
||||||
|
{% if nzines is defined and nzines|length %} |
||||||
|
<ul class="list-unstyled d-grid gap-2 mb-4"> |
||||||
|
{% for nzine in nzines %} |
||||||
|
<li class="card p-3"> |
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3"> |
||||||
|
<div class="flex-fill"> |
||||||
|
<h3 class="h5 m-0">{{ nzine.title }}</h3> |
||||||
|
{% if nzine.summary %}<p class="small mt-1 mb-0">{{ nzine.summary }}</p>{% endif %} |
||||||
|
<small class="text-muted"> |
||||||
|
categories: {{ nzine.categoryCount }} |
||||||
|
{% if nzine.slug %} • slug: {{ nzine.slug }}{% endif %} |
||||||
|
• state: <span class="badge bg-{{ nzine.state == 'published' ? 'success' : 'secondary' }}">{{ nzine.state }}</span> |
||||||
|
{% if nzine.feedUrl %} • <span title="{{ nzine.feedUrl }}">RSS feed configured</span>{% endif %} |
||||||
|
</small> |
||||||
|
</div> |
||||||
|
<div class="d-flex flex-row gap-2"> |
||||||
|
<a class="btn btn-sm btn-primary" href="{{ path('nzine_edit', { npub: nzine.npub }) }}">Edit</a> |
||||||
|
{% if nzine.hasMainIndex %} |
||||||
|
<a class="btn btn-sm btn-outline-primary" href="{{ path('nzine_view', { npub: nzine.npub }) }}">View</a> |
||||||
|
{% else %} |
||||||
|
<span class="btn btn-sm btn-outline-secondary disabled" title="Publish the NZine first">View</span> |
||||||
|
{% endif %} |
||||||
|
{% if nzine.npub %} |
||||||
|
<span data-controller="copy-to-clipboard"> |
||||||
|
<span class="hidden" data-copy-to-clipboard-target="textToCopy">{{ nzine.npub }}</span> |
||||||
|
<button class="btn btn-sm btn-secondary" |
||||||
|
data-copy-to-clipboard-target="copyButton" |
||||||
|
data-action="click->copy-to-clipboard#copyToClipboard" |
||||||
|
title="Copy npub">Copy npub</button> |
||||||
|
</span> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% else %} |
||||||
|
<p><small>No NZines found. Create your first digital magazine!</small></p> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{% endblock %} |
||||||
|
|
||||||
@ -0,0 +1,157 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Tests\Service; |
||||||
|
|
||||||
|
use App\Service\TagMatchingService; |
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
|
||||||
|
class TagMatchingServiceTest extends TestCase |
||||||
|
{ |
||||||
|
private TagMatchingService $service; |
||||||
|
private LoggerInterface $logger; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->logger = $this->createMock(LoggerInterface::class); |
||||||
|
$this->service = new TagMatchingService($this->logger); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindMatchingCategory_ExactMatch(): void |
||||||
|
{ |
||||||
|
$rssCategories = ['artificial-intelligence', 'research']; |
||||||
|
$nzineCategories = [ |
||||||
|
[ |
||||||
|
'name' => 'AI & ML', |
||||||
|
'slug' => 'ai-ml', |
||||||
|
'tags' => ['artificial-intelligence', 'machine-learning', 'AI'] |
||||||
|
], |
||||||
|
[ |
||||||
|
'name' => 'Blockchain', |
||||||
|
'slug' => 'blockchain', |
||||||
|
'tags' => ['crypto', 'blockchain'] |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$result = $this->service->findMatchingCategory($rssCategories, $nzineCategories); |
||||||
|
|
||||||
|
$this->assertNotNull($result); |
||||||
|
$this->assertEquals('AI & ML', $result['name']); |
||||||
|
$this->assertEquals('ai-ml', $result['slug']); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindMatchingCategory_CaseInsensitive(): void |
||||||
|
{ |
||||||
|
$rssCategories = ['ai', 'RESEARCH']; |
||||||
|
$nzineCategories = [ |
||||||
|
[ |
||||||
|
'name' => 'AI & ML', |
||||||
|
'slug' => 'ai-ml', |
||||||
|
'tags' => ['AI', 'MachineLearning'] |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$result = $this->service->findMatchingCategory($rssCategories, $nzineCategories); |
||||||
|
|
||||||
|
$this->assertNotNull($result); |
||||||
|
$this->assertEquals('AI & ML', $result['name']); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindMatchingCategory_NoMatch(): void |
||||||
|
{ |
||||||
|
$rssCategories = ['sports', 'entertainment']; |
||||||
|
$nzineCategories = [ |
||||||
|
[ |
||||||
|
'name' => 'AI & ML', |
||||||
|
'slug' => 'ai-ml', |
||||||
|
'tags' => ['AI', 'machine-learning'] |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$result = $this->service->findMatchingCategory($rssCategories, $nzineCategories); |
||||||
|
|
||||||
|
$this->assertNull($result); |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindMatchingCategory_FirstMatchWins(): void |
||||||
|
{ |
||||||
|
$rssCategories = ['python']; |
||||||
|
$nzineCategories = [ |
||||||
|
[ |
||||||
|
'name' => 'AI & ML', |
||||||
|
'slug' => 'ai-ml', |
||||||
|
'tags' => ['python', 'AI'] |
||||||
|
], |
||||||
|
[ |
||||||
|
'name' => 'Programming', |
||||||
|
'slug' => 'programming', |
||||||
|
'tags' => ['python', 'coding'] |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$result = $this->service->findMatchingCategory($rssCategories, $nzineCategories); |
||||||
|
|
||||||
|
$this->assertNotNull($result); |
||||||
|
$this->assertEquals('AI & ML', $result['name']); // First category should win |
||||||
|
} |
||||||
|
|
||||||
|
public function testFindMatchingCategory_CommaSeparatedTags(): void |
||||||
|
{ |
||||||
|
$rssCategories = ['blockchain']; |
||||||
|
$nzineCategories = [ |
||||||
|
[ |
||||||
|
'name' => 'Blockchain', |
||||||
|
'slug' => 'blockchain', |
||||||
|
'tags' => 'crypto,blockchain,bitcoin' // Comma-separated string |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$result = $this->service->findMatchingCategory($rssCategories, $nzineCategories); |
||||||
|
|
||||||
|
$this->assertNotNull($result); |
||||||
|
$this->assertEquals('Blockchain', $result['name']); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExtractAllTags(): void |
||||||
|
{ |
||||||
|
$nzineCategories = [ |
||||||
|
[ |
||||||
|
'name' => 'AI & ML', |
||||||
|
'slug' => 'ai-ml', |
||||||
|
'tags' => ['AI', 'machine-learning'] |
||||||
|
], |
||||||
|
[ |
||||||
|
'name' => 'Blockchain', |
||||||
|
'slug' => 'blockchain', |
||||||
|
'tags' => ['crypto', 'blockchain', 'AI'] // Duplicate 'AI' |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$result = $this->service->extractAllTags($nzineCategories); |
||||||
|
|
||||||
|
$this->assertCount(4, $result); // Should have 4 unique tags |
||||||
|
$this->assertContains('AI', $result); |
||||||
|
$this->assertContains('machine-learning', $result); |
||||||
|
$this->assertContains('crypto', $result); |
||||||
|
$this->assertContains('blockchain', $result); |
||||||
|
} |
||||||
|
|
||||||
|
public function testExtractAllTags_WithCommaSeparated(): void |
||||||
|
{ |
||||||
|
$nzineCategories = [ |
||||||
|
[ |
||||||
|
'name' => 'Tech', |
||||||
|
'slug' => 'tech', |
||||||
|
'tags' => 'ai,blockchain,coding' // Comma-separated |
||||||
|
] |
||||||
|
]; |
||||||
|
|
||||||
|
$result = $this->service->extractAllTags($nzineCategories); |
||||||
|
|
||||||
|
$this->assertCount(3, $result); |
||||||
|
$this->assertContains('ai', $result); |
||||||
|
$this->assertContains('blockchain', $result); |
||||||
|
$this->assertContains('coding', $result); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue