186 lines
6.0 KiB
PHP
186 lines
6.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Article;
|
|
use App\Models\Feed;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use SimpleXMLElement;
|
|
use Throwable;
|
|
|
|
class FeedFetcherService
|
|
{
|
|
public function __construct(private readonly NotifierDispatchService $notifier)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Fetch, parse and store new articles for a single feed.
|
|
*
|
|
* @return array{new: int, error: string|null}
|
|
*/
|
|
public function fetchFeed(Feed $feed): array
|
|
{
|
|
try {
|
|
$response = Http::timeout(15)
|
|
->withHeaders(['User-Agent' => 'BloomFeed/1.0 (+https://bloomfeed.local)'])
|
|
->get($feed->url);
|
|
|
|
if ($response->failed()) {
|
|
throw new RuntimeException("HTTP {$response->status()}");
|
|
}
|
|
|
|
$items = $this->parse($response->body(), $feed);
|
|
} catch (Throwable $e) {
|
|
$feed->last_fetch_status = 'error: '.Str::limit($e->getMessage(), 200, '');
|
|
$feed->last_fetched_at = now();
|
|
$feed->save();
|
|
|
|
return ['new' => 0, 'error' => $e->getMessage()];
|
|
}
|
|
|
|
$newCount = 0;
|
|
|
|
foreach ($items as $item) {
|
|
$key = $item['guid'] !== '' ? $item['guid'] : $item['link'];
|
|
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
|
|
$article = Article::firstOrNew([
|
|
'feed_id' => $feed->id,
|
|
'guid' => Str::limit($key, 512, ''),
|
|
]);
|
|
|
|
if ($article->exists) {
|
|
continue;
|
|
}
|
|
|
|
$article->fill([
|
|
'title' => Str::limit($item['title'] !== '' ? $item['title'] : '(Sans titre)', 512, ''),
|
|
'link' => Str::limit($item['link'], 2048, ''),
|
|
'excerpt' => $item['excerpt'],
|
|
'content' => $item['content'],
|
|
'published_at' => $item['published_at'],
|
|
]);
|
|
$article->feed()->associate($feed);
|
|
$article->save();
|
|
|
|
$this->notifier->notifyNewArticle($article);
|
|
|
|
$newCount++;
|
|
}
|
|
|
|
$feed->last_fetched_at = now();
|
|
$feed->last_fetch_status = 'ok';
|
|
$feed->save();
|
|
|
|
return ['new' => $newCount, 'error' => null];
|
|
}
|
|
|
|
/**
|
|
* Parse an RSS 2.0 or Atom feed body into a normalized list of items.
|
|
* May set $feed->site_title as a side effect (persisted by the caller).
|
|
*
|
|
* @return list<array{guid: string, title: string, link: string, excerpt: string, content: string, published_at: ?Carbon}>
|
|
*/
|
|
private function parse(string $xml, Feed $feed): array
|
|
{
|
|
libxml_use_internal_errors(true);
|
|
$doc = simplexml_load_string($xml);
|
|
libxml_clear_errors();
|
|
|
|
if ($doc === false) {
|
|
throw new RuntimeException('Flux XML invalide.');
|
|
}
|
|
|
|
$namespaces = $doc->getNamespaces(true);
|
|
$items = [];
|
|
|
|
if (isset($doc->channel)) {
|
|
$channel = $doc->channel;
|
|
|
|
if ($feed->site_title === null && isset($channel->title)) {
|
|
$feed->site_title = Str::limit((string) $channel->title, 255, '');
|
|
}
|
|
|
|
foreach ($channel->item as $item) {
|
|
$content = isset($namespaces['content']) ? $item->children($namespaces['content']) : null;
|
|
$dc = isset($namespaces['dc']) ? $item->children($namespaces['dc']) : null;
|
|
$description = (string) ($item->description ?? '');
|
|
|
|
$items[] = [
|
|
'guid' => trim((string) ($item->guid ?? $item->link ?? '')),
|
|
'title' => trim((string) ($item->title ?? '')),
|
|
'link' => trim((string) ($item->link ?? '')),
|
|
'excerpt' => $this->cleanExcerpt($description),
|
|
'content' => ($content !== null && isset($content->encoded)) ? (string) $content->encoded : $description,
|
|
'published_at' => $this->parseDate((string) ($item->pubDate ?? ($dc->date ?? ''))),
|
|
];
|
|
}
|
|
} elseif ($doc->getName() === 'feed') {
|
|
if ($feed->site_title === null && isset($doc->title)) {
|
|
$feed->site_title = Str::limit((string) $doc->title, 255, '');
|
|
}
|
|
|
|
foreach ($doc->entry as $entry) {
|
|
$link = $this->extractAtomLink($entry);
|
|
$summary = (string) ($entry->summary ?? '');
|
|
$content = (string) ($entry->content ?? '');
|
|
$body = $content !== '' ? $content : $summary;
|
|
|
|
$items[] = [
|
|
'guid' => trim((string) ($entry->id ?? $link)),
|
|
'title' => trim((string) ($entry->title ?? '')),
|
|
'link' => $link,
|
|
'excerpt' => $this->cleanExcerpt($summary !== '' ? $summary : $body),
|
|
'content' => $body,
|
|
'published_at' => $this->parseDate((string) ($entry->published ?? ($entry->updated ?? ''))),
|
|
];
|
|
}
|
|
} else {
|
|
throw new RuntimeException('Format de flux non reconnu (ni RSS ni Atom).');
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
private function extractAtomLink(SimpleXMLElement $entry): string
|
|
{
|
|
foreach ($entry->link as $link) {
|
|
$attrs = $link->attributes();
|
|
$rel = isset($attrs['rel']) ? (string) $attrs['rel'] : 'alternate';
|
|
|
|
if ($rel === 'alternate') {
|
|
return (string) ($attrs['href'] ?? '');
|
|
}
|
|
}
|
|
|
|
$first = $entry->link[0] ?? null;
|
|
|
|
return $first !== null ? (string) ($first->attributes()['href'] ?? '') : '';
|
|
}
|
|
|
|
private function cleanExcerpt(string $html): string
|
|
{
|
|
return Str::limit(trim(strip_tags($html)), 500);
|
|
}
|
|
|
|
private function parseDate(string $date): ?Carbon
|
|
{
|
|
if ($date === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($date);
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|