Laravel 13 可以轻松生成和管理 RSS 订阅源,本文介绍如何构建完整的 RSS 订阅系统。

RSS 概述

RSS 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

return [
'rss' => [
'feeds' => [
'main' => [
'title' => env('RSS_TITLE', config('app.name')),
'description' => env('RSS_DESCRIPTION', 'Latest articles and updates'),
'language' => env('RSS_LANGUAGE', 'en-us'),
'url' => env('RSS_URL', url('/feed')),
'image' => env('RSS_IMAGE', asset('images/rss-logo.png')),
],
],

'cache' => [
'enabled' => true,
'key' => 'rss.feed',
'ttl' => 3600,
],

'limits' => [
'items' => 50,
'title_length' => 100,
'description_length' => 500,
],
],
];

RSS 生成器

核心 RSS 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
<?php

namespace App\Services\RSS;

use DateTimeInterface;
use Illuminate\Support\Collection;

class RSSFeed
{
protected string $title;
protected string $description;
protected string $link;
protected string $language = 'en-us';
protected ?string $image = null;
protected ?DateTimeInterface $lastBuildDate = null;
protected Collection $items;

public function __construct(array $config = [])
{
$this->items = collect();

foreach ($config as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}

public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}

public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}

public function setLink(string $link): self
{
$this->link = $link;
return $this;
}

public function setLanguage(string $language): self
{
$this->language = $language;
return $this;
}

public function setImage(string $url, string $title = null, string $link = null): self
{
$this->image = [
'url' => $url,
'title' => $title ?? $this->title,
'link' => $link ?? $this->link,
];
return $this;
}

public function addItem(RSSItem $item): self
{
$this->items->push($item);
return $this;
}

public function addItems(iterable $items): self
{
foreach ($items as $item) {
$this->addItem($item);
}
return $this;
}

public function getItems(): Collection
{
return $this->items;
}

public function render(): string
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">' . "\n";
$xml .= '<channel>' . "\n";

$xml .= $this->renderChannel();

foreach ($this->items as $item) {
$xml .= $item->render();
}

$xml .= '</channel>' . "\n";
$xml .= '</rss>';

return $xml;
}

protected function renderChannel(): string
{
$xml = '';

$xml .= ' <title>' . $this->escape($this->title) . '</title>' . "\n";
$xml .= ' <description>' . $this->escape($this->description) . '</description>' . "\n";
$xml .= ' <link>' . $this->escape($this->link) . '</link>' . "\n";
$xml .= ' <language>' . $this->escape($this->language) . '</language>' . "\n";

if ($this->lastBuildDate) {
$xml .= ' <lastBuildDate>' . $this->lastBuildDate->format(DATE_RSS) . '</lastBuildDate>' . "\n";
} else {
$xml .= ' <lastBuildDate>' . now()->format(DATE_RSS) . '</lastBuildDate>' . "\n";
}

$xml .= ' <atom:link href="' . $this->escape($this->link) . '" rel="self" type="application/rss+xml" />' . "\n";

if ($this->image) {
$xml .= ' <image>' . "\n";
$xml .= ' <url>' . $this->escape($this->image['url']) . '</url>' . "\n";
$xml .= ' <title>' . $this->escape($this->image['title']) . '</title>' . "\n";
$xml .= ' <link>' . $this->escape($this->image['link']) . '</link>' . "\n";
$xml .= ' </image>' . "\n";
}

return $xml;
}

protected function escape(string $string): string
{
return htmlspecialchars($string, ENT_XML1, 'UTF-8');
}
}

RSS Item 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
<?php

namespace App\Services\RSS;

use DateTimeInterface;

class RSSItem
{
protected string $title;
protected string $description;
protected string $link;
protected ?string $guid = null;
protected ?DateTimeInterface $pubDate = null;
protected ?string $author = null;
protected array $categories = [];
protected ?string $enclosure = null;
protected ?string $content = null;

public function __construct(array $data = [])
{
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}

public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}

public function setDescription(string $description): self
{
$this->description = $description;
return $this;
}

public function setLink(string $link): self
{
$this->link = $link;
return $this;
}

public function setGuid(string $guid, bool $isPermaLink = true): self
{
$this->guid = $guid;
return $this;
}

public function setPubDate(DateTimeInterface $date): self
{
$this->pubDate = $date;
return $this;
}

public function setAuthor(string $author): self
{
$this->author = $author;
return $this;
}

public function addCategory(string $category): self
{
$this->categories[] = $category;
return $this;
}

public function setEnclosure(string $url, int $length = 0, string $type = 'audio/mpeg'): self
{
$this->enclosure = [
'url' => $url,
'length' => $length,
'type' => $type,
];
return $this;
}

public function setContent(string $content): self
{
$this->content = $content;
return $this;
}

public function render(): string
{
$xml = ' <item>' . "\n";

$xml .= ' <title>' . $this->escape($this->title) . '</title>' . "\n";
$xml .= ' <description><![CDATA[' . $this->description . ']]></description>' . "\n";
$xml .= ' <link>' . $this->escape($this->link) . '</link>' . "\n";

if ($this->guid) {
$xml .= ' <guid isPermaLink="false">' . $this->escape($this->guid) . '</guid>' . "\n";
} else {
$xml .= ' <guid isPermaLink="true">' . $this->escape($this->link) . '</guid>' . "\n";
}

if ($this->pubDate) {
$xml .= ' <pubDate>' . $this->pubDate->format(DATE_RSS) . '</pubDate>' . "\n";
}

if ($this->author) {
$xml .= ' <author>' . $this->escape($this->author) . '</author>' . "\n";
}

foreach ($this->categories as $category) {
$xml .= ' <category>' . $this->escape($category) . '</category>' . "\n";
}

if ($this->enclosure) {
$xml .= ' <enclosure url="' . $this->escape($this->enclosure['url']) .
'" length="' . $this->enclosure['length'] .
'" type="' . $this->escape($this->enclosure['type']) . '" />' . "\n";
}

if ($this->content) {
$xml .= ' <content:encoded><![CDATA[' . $this->content . ']]></content:encoded>' . "\n";
}

$xml .= ' </item>' . "\n";

return $xml;
}

protected function escape(string $string): string
{
return htmlspecialchars($string, ENT_XML1, 'UTF-8');
}
}

RSS 构建器

内容 RSS 构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
<?php

namespace App\Services\RSS;

use App\Models\Article;
use App\Models\Category;
use Illuminate\Support\Facades\Cache;

class RSSBuilder
{
protected int $limit = 50;
protected bool $cacheEnabled = true;
protected int $cacheTtl = 3600;

public function buildMainFeed(): RSSFeed
{
if ($this->cacheEnabled) {
return Cache::remember('rss.main', $this->cacheTtl, function () {
return $this->createMainFeed();
});
}

return $this->createMainFeed();
}

protected function createMainFeed(): RSSFeed
{
$config = config('rss.feeds.main', []);

$feed = new RSSFeed([
'title' => $config['title'] ?? config('app.name'),
'description' => $config['description'] ?? 'Latest articles',
'link' => $config['url'] ?? url('/feed'),
'language' => $config['language'] ?? 'en-us',
]);

if (!empty($config['image'])) {
$feed->setImage($config['image']);
}

$articles = Article::where('status', 'published')
->orderBy('published_at', 'desc')
->limit($this->limit)
->get();

foreach ($articles as $article) {
$feed->addItem($this->articleToItem($article));
}

return $feed;
}

public function buildCategoryFeed(Category $category): RSSFeed
{
$feed = new RSSFeed([
'title' => $category->name . ' - ' . config('app.name'),
'description' => $category->description ?? "Articles in {$category->name}",
'link' => route('rss.category', $category),
'language' => 'en-us',
]);

$articles = Article::where('status', 'published')
->where('category_id', $category->id)
->orderBy('published_at', 'desc')
->limit($this->limit)
->get();

foreach ($articles as $article) {
$feed->addItem($this->articleToItem($article));
}

return $feed;
}

public function buildAuthorFeed(string $authorId): RSSFeed
{
$author = \App\Models\User::findOrFail($authorId);

$feed = new RSSFeed([
'title' => "Articles by {$author->name}",
'description' => "Latest articles by {$author->name}",
'link' => route('rss.author', $author),
'language' => 'en-us',
]);

$articles = Article::where('status', 'published')
->where('author_id', $author->id)
->orderBy('published_at', 'desc')
->limit($this->limit)
->get();

foreach ($articles as $article) {
$feed->addItem($this->articleToItem($article));
}

return $feed;
}

protected function articleToItem(Article $article): RSSItem
{
$item = new RSSItem([
'title' => $article->title,
'description' => $article->excerpt ?? Str::limit(strip_tags($article->content), 300),
'link' => route('articles.show', $article),
'guid' => (string) $article->id,
'pubDate' => $article->published_at,
'author' => $article->author->email . ' (' . $article->author->name . ')',
'content' => $article->content,
]);

if ($article->categories->isNotEmpty()) {
foreach ($article->categories as $category) {
$item->addCategory($category->name);
}
}

if ($article->featured_image) {
$item->setEnclosure(
Storage::url($article->featured_image),
Storage::size($article->featured_image),
'image/jpeg'
);
}

return $item;
}

public function clearCache(): void
{
Cache::forget('rss.main');
}
}

RSS 控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\User;
use App\Services\RSS\RSSBuilder;
use Illuminate\Http\Response;

class RSSController extends Controller
{
protected RSSBuilder $builder;

public function __construct(RSSBuilder $builder)
{
$this->builder = $builder;
}

public function main(): Response
{
$feed = $this->builder->buildMainFeed();

return response($feed->render(), 200, [
'Content-Type' => 'application/xml',
'Cache-Control' => 'public, max-age=3600',
]);
}

public function category(Category $category): Response
{
$feed = $this->builder->buildCategoryFeed($category);

return response($feed->render(), 200, [
'Content-Type' => 'application/xml',
'Cache-Control' => 'public, max-age=3600',
]);
}

public function author(User $author): Response
{
$feed = $this->builder->buildAuthorFeed($author->id);

return response($feed->render(), 200, [
'Content-Type' => 'application/xml',
'Cache-Control' => 'public, max-age=3600',
]);
}
}

RSS 订阅管理

订阅模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class RSSSubscription extends Model
{
protected $fillable = [
'user_id',
'feed_url',
'feed_name',
'last_checked_at',
'last_item_id',
'is_active',
];

protected $casts = [
'last_checked_at' => 'datetime',
'is_active' => 'boolean',
];

public function user()
{
return $this->belongsTo(User::class);
}
}

RSS 解析器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php

namespace App\Services\RSS;

use SimpleXMLElement;

class RSSParser
{
public function parse(string $url): array
{
$content = @file_get_contents($url);

if ($content === false) {
throw new \RuntimeException("Unable to fetch RSS feed: {$url}");
}

$xml = new SimpleXMLElement($content);

return $this->parseFeed($xml);
}

protected function parseFeed(SimpleXMLElement $xml): array
{
$channel = $xml->channel;

$items = [];
foreach ($channel->item as $item) {
$items[] = $this->parseItem($item);
}

return [
'title' => (string) $channel->title,
'description' => (string) $channel->description,
'link' => (string) $channel->link,
'language' => (string) $channel->language,
'items' => $items,
];
}

protected function parseItem(SimpleXMLElement $item): array
{
$namespaces = $item->getNamespaces(true);

$data = [
'title' => (string) $item->title,
'description' => (string) $item->description,
'link' => (string) $item->link,
'guid' => (string) $item->guid,
'pub_date' => (string) $item->pubDate,
'author' => (string) $item->author,
];

if (isset($namespaces['content'])) {
$content = $item->children($namespaces['content']);
$data['content'] = (string) $content->encoded;
}

if ($item->enclosure) {
$data['enclosure'] = [
'url' => (string) $item->enclosure['url'],
'type' => (string) $item->enclosure['type'],
'length' => (int) $item->enclosure['length'],
];
}

foreach ($item->category as $category) {
$data['categories'][] = (string) $category;
}

return $data;
}
}

RSS 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php

namespace App\Console\Commands;

use App\Services\RSS\RSSBuilder;
use Illuminate\Console\Command;

class RSSGenerateCommand extends Command
{
protected $signature = 'rss:generate {--clear-cache : Clear cache before generating}';
protected $description = 'Generate RSS feeds';

public function handle(RSSBuilder $builder): int
{
if ($this->option('clear-cache')) {
$this->info('Clearing RSS cache...');
$builder->clearCache();
}

$this->info('Generating main RSS feed...');

$feed = $builder->buildMainFeed();

$this->info("Feed generated with {$feed->getItems()->count()} items");

return self::SUCCESS;
}
}

路由配置

1
2
3
4
5
6
7
<?php

use App\Http\Controllers\RSSController;

Route::get('/feed', [RSSController::class, 'main'])->name('rss.main');
Route::get('/feed/category/{category}', [RSSController::class, 'category'])->name('rss.category');
Route::get('/feed/author/{author}', [RSSController::class, 'author'])->name('rss.author');

总结

Laravel 13 的 RSS 订阅系统支持多种类型的订阅源生成,包括主订阅、分类订阅和作者订阅。通过缓存机制和灵活的配置,可以高效地生成和管理 RSS 订阅源。