First Commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Services\FeedFetcherService;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
#[Signature('feeds:fetch {--feed=* : Limit to specific feed ID(s)} {--user=* : Limit to a user\'s feeds}')]
|
||||
#[Description('Fetch RSS/Atom feeds and store new articles')]
|
||||
class FetchFeeds extends Command
|
||||
{
|
||||
public function handle(FeedFetcherService $service): int
|
||||
{
|
||||
$query = Feed::query();
|
||||
|
||||
if ($feedIds = $this->option('feed')) {
|
||||
$query->whereIn('id', $feedIds);
|
||||
} elseif ($userIds = $this->option('user')) {
|
||||
$query->whereIn('user_id', $userIds);
|
||||
}
|
||||
|
||||
$feeds = $query->get();
|
||||
$totalNew = 0;
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
try {
|
||||
$result = $service->fetchFeed($feed);
|
||||
$totalNew += $result['new'];
|
||||
|
||||
if ($result['error'] !== null) {
|
||||
$this->warn("[{$feed->id}] {$feed->url} : {$result['error']}");
|
||||
} else {
|
||||
$this->info("[{$feed->id}] {$feed->url} : {$result['new']} nouvel(aux) article(s)");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->error("[{$feed->id}] {$feed->url} : {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('feeds:fetch completed', ['feeds' => $feeds->count(), 'new_articles' => $totalNew]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Services\ArticleReaderService;
|
||||
use App\Support\MarkdownRenderer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
public function show(Article $article, ArticleReaderService $reader): View
|
||||
{
|
||||
$this->authorize('view', $article);
|
||||
|
||||
if (! $article->is_read) {
|
||||
$article->update(['is_read' => true]);
|
||||
}
|
||||
|
||||
if ($article->full_content_markdown === null && $article->full_content_error === null) {
|
||||
$reader->fetchFullContent($article);
|
||||
}
|
||||
|
||||
return view('articles.show', [
|
||||
'article' => $article,
|
||||
'contentHtml' => MarkdownRenderer::toHtml($article->full_content_markdown),
|
||||
]);
|
||||
}
|
||||
|
||||
public function retry(Article $article, ArticleReaderService $reader): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $article);
|
||||
|
||||
$article->full_content_error = null;
|
||||
$article->save();
|
||||
$reader->fetchFullContent($article);
|
||||
|
||||
return redirect()->route('articles.show', $article);
|
||||
}
|
||||
|
||||
public function markRead(Article $article): JsonResponse|RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $article);
|
||||
|
||||
$article->update(['is_read' => true]);
|
||||
|
||||
return request()->wantsJson()
|
||||
? response()->json(['is_read' => true])
|
||||
: back();
|
||||
}
|
||||
|
||||
public function toggleFavorite(Article $article): JsonResponse|RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $article);
|
||||
|
||||
$article->update(['is_favorite' => ! $article->is_favorite]);
|
||||
|
||||
return request()->wantsJson()
|
||||
? response()->json(['is_favorite' => $article->is_favorite])
|
||||
: back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showRegister(): View|RedirectResponse
|
||||
{
|
||||
if (User::query()->exists()) {
|
||||
return redirect()->route('login')
|
||||
->with('status', 'Les inscriptions sont fermées sur cette instance. Connectez-vous.');
|
||||
}
|
||||
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
public function register(Request $request): RedirectResponse
|
||||
{
|
||||
if (User::query()->exists()) {
|
||||
return redirect()->route('login')
|
||||
->with('status', 'Les inscriptions sont fermées sur cette instance. Connectez-vous.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', 'min:8'],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
public function showLogin(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
return back()->withErrors([
|
||||
'email' => 'Ces identifiants ne correspondent à aucun compte.',
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Article;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$query = Article::forUser($user->id)->with('feed');
|
||||
|
||||
if ($request->boolean('unread')) {
|
||||
$query->unread();
|
||||
}
|
||||
|
||||
if ($feedId = $request->integer('feed')) {
|
||||
$query->where('feed_id', $feedId);
|
||||
}
|
||||
|
||||
$articles = $query->orderByDesc('published_at')->paginate(20)->withQueryString();
|
||||
|
||||
$feeds = $user->feeds()->withCount(['articles as unread_count' => fn ($q) => $q->unread()])
|
||||
->orderBy('title')
|
||||
->get();
|
||||
|
||||
return view('dashboard', [
|
||||
'articles' => $articles,
|
||||
'feeds' => $feeds,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Services\FeedFetcherService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FeedController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$feeds = $request->user()->feeds()->withCount('articles')->orderBy('title')->get();
|
||||
|
||||
return view('feeds.index', ['feeds' => $feeds]);
|
||||
}
|
||||
|
||||
public function store(Request $request, FeedFetcherService $service): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'url' => ['required', 'url', 'max:2048'],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$feed = $request->user()->feeds()->create([
|
||||
'url' => $validated['url'],
|
||||
'title' => $validated['title'] ?? null,
|
||||
]);
|
||||
|
||||
$result = $service->fetchFeed($feed);
|
||||
|
||||
if ($result['error'] !== null) {
|
||||
return back()->with('status', "Flux ajouté, mais la première récupération a échoué : {$result['error']}");
|
||||
}
|
||||
|
||||
return back()->with('status', "Flux ajouté avec {$result['new']} article(s).");
|
||||
}
|
||||
|
||||
public function destroy(Feed $feed): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $feed);
|
||||
|
||||
$feed->delete();
|
||||
|
||||
return back()->with('status', 'Flux supprimé.');
|
||||
}
|
||||
|
||||
public function refresh(Feed $feed, FeedFetcherService $service): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $feed);
|
||||
|
||||
$result = $service->fetchFeed($feed);
|
||||
|
||||
if ($result['error'] !== null) {
|
||||
return back()->with('status', "Échec de la récupération : {$result['error']}");
|
||||
}
|
||||
|
||||
return back()->with('status', "{$result['new']} nouvel(aux) article(s).");
|
||||
}
|
||||
|
||||
public function refreshAll(Request $request, FeedFetcherService $service): RedirectResponse
|
||||
{
|
||||
$feeds = $request->user()->feeds;
|
||||
$totalNew = 0;
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
$totalNew += $service->fetchFeed($feed)['new'];
|
||||
}
|
||||
|
||||
return back()->with('status', "{$totalNew} nouvel(aux) article(s) sur {$feeds->count()} flux.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function home(): View|RedirectResponse
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
return view('home', [
|
||||
'hasOwner' => User::query()->exists(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function selfHosting(): View
|
||||
{
|
||||
return view('self-hosting');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'guid', 'title', 'link', 'excerpt', 'content', 'published_at', 'is_read', 'is_favorite',
|
||||
'full_content_markdown', 'full_content_fetched_at', 'full_content_error',
|
||||
])]
|
||||
class Article extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'published_at' => 'datetime',
|
||||
'is_read' => 'boolean',
|
||||
'is_favorite' => 'boolean',
|
||||
'full_content_fetched_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function feed(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Feed::class);
|
||||
}
|
||||
|
||||
public function scopeForUser(Builder $query, int $userId): Builder
|
||||
{
|
||||
return $query->whereHas('feed', fn (Builder $q) => $q->where('user_id', $userId));
|
||||
}
|
||||
|
||||
public function scopeUnread(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_read', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['url', 'title', 'site_title', 'last_fetched_at', 'last_fetch_status'])]
|
||||
class Feed extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_fetched_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function articles(): HasMany
|
||||
{
|
||||
return $this->hasMany(Article::class);
|
||||
}
|
||||
|
||||
public function displayTitle(): string
|
||||
{
|
||||
return $this->title ?: ($this->site_title ?: $this->url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
public function feeds(): HasMany
|
||||
{
|
||||
return $this->hasMany(Feed::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\User;
|
||||
|
||||
class ArticlePolicy
|
||||
{
|
||||
public function view(User $user, Article $article): bool
|
||||
{
|
||||
return $user->id === $article->feed->user_id;
|
||||
}
|
||||
|
||||
public function update(User $user, Article $article): bool
|
||||
{
|
||||
return $user->id === $article->feed->user_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Feed;
|
||||
use App\Models\User;
|
||||
|
||||
class FeedPolicy
|
||||
{
|
||||
public function view(User $user, Feed $feed): bool
|
||||
{
|
||||
return $user->id === $feed->user_id;
|
||||
}
|
||||
|
||||
public function update(User $user, Feed $feed): bool
|
||||
{
|
||||
return $user->id === $feed->user_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Feed $feed): bool
|
||||
{
|
||||
return $user->id === $feed->user_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
if (str_starts_with((string) config('app.url'), 'https://')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Article;
|
||||
use fivefilters\Readability\Configuration as ReadabilityConfig;
|
||||
use fivefilters\Readability\Readability;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ArticleReaderService
|
||||
{
|
||||
public function fetchFullContent(Article $article): void
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(20)
|
||||
->withHeaders(['User-Agent' => 'BloomFeed/1.0 (+https://bloomfeed.local)'])
|
||||
->get($article->link);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException("HTTP {$response->status()}");
|
||||
}
|
||||
|
||||
$config = new ReadabilityConfig();
|
||||
$config->setFixRelativeURLs(true);
|
||||
$config->setOriginalURL($article->link);
|
||||
$config->setSubstituteEntities(true);
|
||||
|
||||
$readability = new Readability($config);
|
||||
$readability->parse($response->body());
|
||||
|
||||
$extracted = $readability->getContent();
|
||||
|
||||
if (! $extracted) {
|
||||
throw new RuntimeException("Impossible d'extraire le contenu de cette page.");
|
||||
}
|
||||
|
||||
$converter = new HtmlConverter([
|
||||
'strip_tags' => true,
|
||||
'remove_nodes' => 'script style noscript iframe form nav footer',
|
||||
'hard_break' => true,
|
||||
]);
|
||||
|
||||
$markdown = trim($converter->convert($extracted));
|
||||
|
||||
if ($markdown === '') {
|
||||
throw new RuntimeException('Le contenu extrait est vide.');
|
||||
}
|
||||
|
||||
$article->full_content_markdown = $markdown;
|
||||
$article->full_content_fetched_at = now();
|
||||
$article->full_content_error = null;
|
||||
$article->save();
|
||||
} catch (Throwable $e) {
|
||||
$article->full_content_error = Str::limit($e->getMessage(), 250, '');
|
||||
$article->full_content_fetched_at = now();
|
||||
$article->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?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
|
||||
{
|
||||
/**
|
||||
* 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();
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
|
||||
class MarkdownRenderer
|
||||
{
|
||||
public static function toHtml(?string $markdown): string
|
||||
{
|
||||
if (! $markdown) {
|
||||
return '';
|
||||
}
|
||||
|
||||
static $converter;
|
||||
|
||||
// html_input=strip: the source markdown comes from scraped third-party pages
|
||||
// (or LLM output echoing back scraped content), so raw HTML must never be
|
||||
// passed through as-is.
|
||||
$converter ??= new CommonMarkConverter([
|
||||
'html_input' => 'strip',
|
||||
'max_nesting_level' => 100,
|
||||
]);
|
||||
|
||||
return (string) $converter->convert($markdown);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user