144 - Extract the RSS and website fetch actions from ArticleFetcher
This commit is contained in:
parent
d58fd8a231
commit
eefadff873
6 changed files with 323 additions and 95 deletions
64
app/Actions/FetchRssArticlesAction.php
Normal file
64
app/Actions/FetchRssArticlesAction.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Services\Http\HttpFetcher;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class FetchRssArticlesAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LogSaver $logSaver,
|
||||||
|
private SaveArticleAction $saveArticle,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Article>
|
||||||
|
*/
|
||||||
|
public function execute(Feed $feed): Collection
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$xml = HttpFetcher::fetchHtml($feed->url);
|
||||||
|
|
||||||
|
$previousUseErrors = libxml_use_internal_errors(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$rss = simplexml_load_string($xml);
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($previousUseErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rss === false || ! isset($rss->channel->item)) {
|
||||||
|
$this->logSaver->warning('Failed to parse RSS feed XML', null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$articles = collect();
|
||||||
|
foreach ($rss->channel->item as $item) {
|
||||||
|
$link = (string) $item->link;
|
||||||
|
if ($link !== '') {
|
||||||
|
$articles->push($this->saveArticle->execute($link, $feed->id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $articles;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logSaver->error('Failed to fetch articles from RSS feed', null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Actions/FetchWebsiteArticlesAction.php
Normal file
53
app/Actions/FetchWebsiteArticlesAction.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Services\Factories\HomepageParserFactory;
|
||||||
|
use App\Services\Http\HttpFetcher;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class FetchWebsiteArticlesAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LogSaver $logSaver,
|
||||||
|
private SaveArticleAction $saveArticle,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Article>
|
||||||
|
*/
|
||||||
|
public function execute(Feed $feed): Collection
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$parser = HomepageParserFactory::getParserForFeed($feed);
|
||||||
|
|
||||||
|
if (! $parser) {
|
||||||
|
$this->logSaver->warning('No parser available for feed URL', null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = HttpFetcher::fetchHtml($feed->url);
|
||||||
|
$urls = $parser->extractArticleUrls($html);
|
||||||
|
|
||||||
|
return collect($urls)
|
||||||
|
->map(fn (string $url) => $this->saveArticle->execute($url, $feed->id));
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logSaver->error('Failed to fetch articles from website feed', null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
namespace App\Services\Article;
|
namespace App\Services\Article;
|
||||||
|
|
||||||
use App\Actions\SaveArticleAction;
|
use App\Actions\FetchRssArticlesAction;
|
||||||
|
use App\Actions\FetchWebsiteArticlesAction;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Services\Factories\ArticleParserFactory;
|
use App\Services\Factories\ArticleParserFactory;
|
||||||
use App\Services\Factories\HomepageParserFactory;
|
|
||||||
use App\Services\Http\HttpFetcher;
|
use App\Services\Http\HttpFetcher;
|
||||||
use App\Services\Log\LogSaver;
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -16,7 +16,8 @@ class ArticleFetcher
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private LogSaver $logSaver,
|
private LogSaver $logSaver,
|
||||||
private SaveArticleAction $saveArticle,
|
private FetchRssArticlesAction $fetchRssArticles,
|
||||||
|
private FetchWebsiteArticlesAction $fetchWebsiteArticles,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,9 +26,9 @@ public function __construct(
|
||||||
public function getArticlesFromFeed(Feed $feed): Collection
|
public function getArticlesFromFeed(Feed $feed): Collection
|
||||||
{
|
{
|
||||||
if ($feed->type === 'rss') {
|
if ($feed->type === 'rss') {
|
||||||
return $this->getArticlesFromRssFeed($feed);
|
return $this->fetchRssArticles->execute($feed);
|
||||||
} elseif ($feed->type === 'website') {
|
} elseif ($feed->type === 'website') {
|
||||||
return $this->getArticlesFromWebsiteFeed($feed);
|
return $this->fetchWebsiteArticles->execute($feed);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logSaver->warning('Unsupported feed type', null, [
|
$this->logSaver->warning('Unsupported feed type', null, [
|
||||||
|
|
@ -38,87 +39,6 @@ public function getArticlesFromFeed(Feed $feed): Collection
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Article>
|
|
||||||
*/
|
|
||||||
private function getArticlesFromRssFeed(Feed $feed): Collection
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$xml = HttpFetcher::fetchHtml($feed->url);
|
|
||||||
|
|
||||||
$previousUseErrors = libxml_use_internal_errors(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$rss = simplexml_load_string($xml);
|
|
||||||
} finally {
|
|
||||||
libxml_clear_errors();
|
|
||||||
libxml_use_internal_errors($previousUseErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($rss === false || ! isset($rss->channel->item)) {
|
|
||||||
$this->logSaver->warning('Failed to parse RSS feed XML', null, [
|
|
||||||
'feed_id' => $feed->id,
|
|
||||||
'feed_url' => $feed->url,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
$articles = collect();
|
|
||||||
foreach ($rss->channel->item as $item) {
|
|
||||||
$link = (string) $item->link;
|
|
||||||
if ($link !== '') {
|
|
||||||
$articles->push($this->saveArticle->execute($link, $feed->id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $articles;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->logSaver->error('Failed to fetch articles from RSS feed', null, [
|
|
||||||
'feed_id' => $feed->id,
|
|
||||||
'feed_url' => $feed->url,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Article>
|
|
||||||
*/
|
|
||||||
private function getArticlesFromWebsiteFeed(Feed $feed): Collection
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
// Try to get parser for this feed
|
|
||||||
$parser = HomepageParserFactory::getParserForFeed($feed);
|
|
||||||
|
|
||||||
if (! $parser) {
|
|
||||||
$this->logSaver->warning('No parser available for feed URL', null, [
|
|
||||||
'feed_id' => $feed->id,
|
|
||||||
'feed_url' => $feed->url,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
$html = HttpFetcher::fetchHtml($feed->url);
|
|
||||||
$urls = $parser->extractArticleUrls($html);
|
|
||||||
|
|
||||||
return collect($urls)
|
|
||||||
->map(fn (string $url) => $this->saveArticle->execute($url, $feed->id));
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
$this->logSaver->error('Failed to fetch articles from website feed', null, [
|
|
||||||
'feed_id' => $feed->id,
|
|
||||||
'feed_url' => $feed->url,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace Tests\Traits;
|
namespace Tests\Traits;
|
||||||
|
|
||||||
|
use App\Actions\FetchRssArticlesAction;
|
||||||
|
use App\Actions\FetchWebsiteArticlesAction;
|
||||||
use App\Actions\SaveArticleAction;
|
use App\Actions\SaveArticleAction;
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
use App\Services\Log\LogSaver;
|
use App\Services\Log\LogSaver;
|
||||||
|
|
@ -13,18 +15,33 @@ trait CreatesArticleFetcher
|
||||||
protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetcher
|
protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetcher
|
||||||
{
|
{
|
||||||
if (! $logSaver) {
|
if (! $logSaver) {
|
||||||
$logSaver = Mockery::mock(LogSaver::class);
|
$logSaver = $this->mockLogSaver();
|
||||||
$logSaver->shouldReceive('info')->zeroOrMoreTimes();
|
|
||||||
$logSaver->shouldReceive('warning')->zeroOrMoreTimes();
|
|
||||||
$logSaver->shouldReceive('error')->zeroOrMoreTimes();
|
|
||||||
$logSaver->shouldReceive('debug')->zeroOrMoreTimes();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ArticleFetcher($logSaver, new SaveArticleAction($logSaver));
|
return $this->articleFetcherWith($logSaver);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{ArticleFetcher, MockInterface} */
|
/** @return array{ArticleFetcher, MockInterface} */
|
||||||
protected function createArticleFetcherWithMockedLogSaver(): array
|
protected function createArticleFetcherWithMockedLogSaver(): array
|
||||||
|
{
|
||||||
|
$logSaver = $this->mockLogSaver();
|
||||||
|
|
||||||
|
return [$this->articleFetcherWith($logSaver), $logSaver];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function articleFetcherWith(LogSaver $logSaver): ArticleFetcher
|
||||||
|
{
|
||||||
|
$saveArticle = new SaveArticleAction($logSaver);
|
||||||
|
|
||||||
|
return new ArticleFetcher(
|
||||||
|
$logSaver,
|
||||||
|
new FetchRssArticlesAction($logSaver, $saveArticle),
|
||||||
|
new FetchWebsiteArticlesAction($logSaver, $saveArticle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return LogSaver&MockInterface */
|
||||||
|
private function mockLogSaver(): MockInterface
|
||||||
{
|
{
|
||||||
$logSaver = Mockery::mock(LogSaver::class);
|
$logSaver = Mockery::mock(LogSaver::class);
|
||||||
$logSaver->shouldReceive('info')->zeroOrMoreTimes();
|
$logSaver->shouldReceive('info')->zeroOrMoreTimes();
|
||||||
|
|
@ -32,8 +49,6 @@ protected function createArticleFetcherWithMockedLogSaver(): array
|
||||||
$logSaver->shouldReceive('error')->zeroOrMoreTimes();
|
$logSaver->shouldReceive('error')->zeroOrMoreTimes();
|
||||||
$logSaver->shouldReceive('debug')->zeroOrMoreTimes();
|
$logSaver->shouldReceive('debug')->zeroOrMoreTimes();
|
||||||
|
|
||||||
$articleFetcher = new ArticleFetcher($logSaver, new SaveArticleAction($logSaver));
|
return $logSaver;
|
||||||
|
|
||||||
return [$articleFetcher, $logSaver];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
tests/Unit/Actions/FetchRssArticlesActionTest.php
Normal file
112
tests/Unit/Actions/FetchRssArticlesActionTest.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Actions;
|
||||||
|
|
||||||
|
use App\Actions\FetchRssArticlesAction;
|
||||||
|
use App\Actions\SaveArticleAction;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class FetchRssArticlesActionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function action(): FetchRssArticlesAction
|
||||||
|
{
|
||||||
|
$logSaver = app(LogSaver::class);
|
||||||
|
|
||||||
|
return new FetchRssArticlesAction($logSaver, new SaveArticleAction($logSaver));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rss(string ...$links): string
|
||||||
|
{
|
||||||
|
$items = '';
|
||||||
|
|
||||||
|
foreach ($links as $link) {
|
||||||
|
$items .= "<item><link>{$link}</link></item>";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<?xml version=\"1.0\"?><rss><channel>{$items}</channel></rss>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function feed(): Feed
|
||||||
|
{
|
||||||
|
return Feed::factory()->create([
|
||||||
|
'type' => 'rss',
|
||||||
|
'url' => 'https://example.com/feed.rss',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_saves_an_article_per_rss_item(): void
|
||||||
|
{
|
||||||
|
$feed = $this->feed();
|
||||||
|
|
||||||
|
Http::fake([$feed->url => Http::response(
|
||||||
|
$this->rss('https://example.com/one', 'https://example.com/two'), 200
|
||||||
|
)]);
|
||||||
|
|
||||||
|
$result = $this->action()->execute($feed);
|
||||||
|
|
||||||
|
$this->assertCount(2, $result);
|
||||||
|
$this->assertDatabaseHas('articles', ['url' => 'https://example.com/one', 'feed_id' => $feed->id]);
|
||||||
|
$this->assertDatabaseHas('articles', ['url' => 'https://example.com/two', 'feed_id' => $feed->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_skips_items_with_an_empty_link(): void
|
||||||
|
{
|
||||||
|
$feed = $this->feed();
|
||||||
|
|
||||||
|
Http::fake([$feed->url => Http::response(
|
||||||
|
$this->rss('https://example.com/one', ''), 200
|
||||||
|
)]);
|
||||||
|
|
||||||
|
$result = $this->action()->execute($feed);
|
||||||
|
|
||||||
|
$this->assertCount(1, $result);
|
||||||
|
$this->assertSame(1, Article::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_empty_for_malformed_xml(): void
|
||||||
|
{
|
||||||
|
$feed = $this->feed();
|
||||||
|
|
||||||
|
Http::fake([$feed->url => Http::response('<not-xml', 200)]);
|
||||||
|
|
||||||
|
$this->assertEmpty($this->action()->execute($feed));
|
||||||
|
$this->assertSame(0, Article::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_empty_when_the_feed_has_no_items(): void
|
||||||
|
{
|
||||||
|
$feed = $this->feed();
|
||||||
|
|
||||||
|
Http::fake([$feed->url => Http::response('<?xml version="1.0"?><rss><channel></channel></rss>', 200)]);
|
||||||
|
|
||||||
|
$this->assertEmpty($this->action()->execute($feed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_empty_when_the_fetch_throws(): void
|
||||||
|
{
|
||||||
|
$feed = $this->feed();
|
||||||
|
|
||||||
|
Http::fake(fn () => throw new \RuntimeException('connection refused'));
|
||||||
|
|
||||||
|
$this->assertEmpty($this->action()->execute($feed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_does_not_duplicate_articles_across_runs(): void
|
||||||
|
{
|
||||||
|
$feed = $this->feed();
|
||||||
|
|
||||||
|
Http::fake([$feed->url => Http::response($this->rss('https://example.com/one'), 200)]);
|
||||||
|
|
||||||
|
$this->action()->execute($feed);
|
||||||
|
$this->action()->execute($feed);
|
||||||
|
|
||||||
|
$this->assertSame(1, Article::count());
|
||||||
|
}
|
||||||
|
}
|
||||||
64
tests/Unit/Actions/FetchWebsiteArticlesActionTest.php
Normal file
64
tests/Unit/Actions/FetchWebsiteArticlesActionTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Actions;
|
||||||
|
|
||||||
|
use App\Actions\FetchWebsiteArticlesAction;
|
||||||
|
use App\Actions\SaveArticleAction;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class FetchWebsiteArticlesActionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private function action(): FetchWebsiteArticlesAction
|
||||||
|
{
|
||||||
|
$logSaver = app(LogSaver::class);
|
||||||
|
|
||||||
|
return new FetchWebsiteArticlesAction($logSaver, new SaveArticleAction($logSaver));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_empty_when_no_parser_matches_the_feed(): void
|
||||||
|
{
|
||||||
|
Http::fake(['*' => Http::response('<html></html>', 200)]);
|
||||||
|
|
||||||
|
$feed = Feed::factory()->create([
|
||||||
|
'type' => 'website',
|
||||||
|
'url' => 'https://no-parser-for-this-domain.example/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEmpty($this->action()->execute($feed));
|
||||||
|
$this->assertSame(0, Article::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_empty_when_the_fetch_throws(): void
|
||||||
|
{
|
||||||
|
Http::fake(fn () => throw new \RuntimeException('connection refused'));
|
||||||
|
|
||||||
|
$feed = Feed::factory()->create([
|
||||||
|
'type' => 'website',
|
||||||
|
'url' => 'https://www.vrt.be/vrtnws/nl/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEmpty($this->action()->execute($feed));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_a_collection_for_a_feed_with_a_parser(): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://www.vrt.be/vrtnws/nl/' => Http::response('<html><body>Sample VRT content</body></html>', 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$feed = Feed::factory()->create([
|
||||||
|
'type' => 'website',
|
||||||
|
'url' => 'https://www.vrt.be/vrtnws/nl/',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Collection::class, $this->action()->execute($feed));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue