Done
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-07-04 23:09:00 +10:00
parent 0485b85a59
commit 439139a057
50 changed files with 2405 additions and 548 deletions
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Services;
use App\Models\Category;
use Illuminate\Support\Collection;
class ArticleSelector
{
public function forCategory(Category $category, int $limit = 10): Collection
{
$feeds = $category->feeds()->with([
'articles' => fn ($q) => $q->orderByDesc('published_at')->limit($limit),
])->get();
// Round robin: take article[0] from every feed, then article[1] from every feed, etc.
$buckets = $feeds->map(fn ($feed) => $feed->articles->values());
$maxDepth = $buckets->max(fn ($b) => $b->count()) ?? 0;
$selected = collect();
for ($i = 0; $i < $maxDepth && $selected->count() < $limit; $i++) {
foreach ($buckets as $bucket) {
if ($selected->count() >= $limit) break;
if ($bucket->has($i)) $selected->push($bucket[$i]);
}
}
return $selected;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services;
use App\Models\Article;
use App\Models\Feed;
use SimplePie\SimplePie;
class FeedFetcher
{
public function __construct(private OgImageResolver $ogResolver) {}
public function fetch(Feed $feed): void
{
$simplepie = new SimplePie();
$simplepie->set_feed_url($feed->url);
$simplepie->enable_cache(false); // you're doing your own caching in the DB
$simplepie->init();
if ($simplepie->error()) {
report(new \RuntimeException("Feed fetch failed for {$feed->url}: {$simplepie->error()}"));
return;
}
foreach ($simplepie->get_items() as $item) {
$imageUrl = $this->extractImage($item);
$attributes = [
'title' => strip_tags($item->get_title()),
'description' => strip_tags($item->get_description() ?? ''),
'link' => $item->get_permalink(),
'author' => $item->get_author()?->get_name(),
'published_at' => $item->get_date('Y-m-d H:i:s'),
];
if ($imageUrl) {
$attributes['image_url'] = $imageUrl;
}
$article = Article::updateOrCreate(
['feed_id' => $feed->id, 'guid' => $item->get_id()],
$attributes
);
if (! $article->image_url && ! $article->image_checked_at) {
$og = $this->ogResolver->resolve($article->link);
$article->update([
'image_url' => $og,
'image_checked_at' => now(),
]);
}
}
}
private function extractImage($item): ?string
{
$mediaContents = $item->get_item_tags('http://search.yahoo.com/mrss/', 'content');
if (empty($mediaContents)) {
$enclosure = $item->get_enclosure();
$link = $enclosure?->get_thumbnail() ?? $enclosure?->get_link();
return $link ? html_entity_decode($link) : null;
}
usort($mediaContents, fn ($a, $b) =>
(int) ($b['attribs']['']['width'] ?? 0) <=> (int) ($a['attribs']['']['width'] ?? 0)
);
return $mediaContents[0]['attribs']['']['url'] ?? null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class OgImageResolver
{
public function resolve(string $pageUrl): ?string
{
try {
$response = Http::withHeaders([
'User-Agent' => 'Mozilla/5.0 (compatible; DredgeNewsBot/1.0)',
])->timeout(8)->get($pageUrl);
if (! $response->successful()) {
return null;
}
libxml_use_internal_errors(true); // suppress warnings from imperfect HTML
$dom = new \DOMDocument();
$dom->loadHTML($response->body());
$xpath = new \DOMXPath($dom);
foreach (['og:image', 'twitter:image', 'twitter:image:src'] as $property) {
$node = $xpath->query("//meta[@property='{$property}']/@content")->item(0)
?? $xpath->query("//meta[@name='{$property}']/@content")->item(0);
if ($node && filled($node->nodeValue)) {
return $node->nodeValue;
}
}
return null;
} catch (\Throwable $e) {
report($e);
return null;
}
}
}