28 changed files with 2161 additions and 152 deletions
@ -0,0 +1,34 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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