RSS (Really Simple Syndication) 仍是內容分發標準格式。PHP 可動態生成符合 RSS 2.0 規範的 XML,支援部落格、新聞、Podcast 等場景。本文提供標準類別實作、資料庫整合、HTTP 快取頭、iTunes Podcast 擴充命名空間與驗證工具。
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:content="https://purl.org/rss/1.0/modules/content/"
xmlns:atom="https://www.w3.org/2005/Atom"
xmlns:itunes="https://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>網站標題</title>
<link>https://example.com</link>
<description>網站描述</description>
<language>zh-TW</language>
<lastBuildDate>Mon, 01 Jan 2024 00:00:00 +0800</lastBuildDate>
<atom:link href="https://example.com/rss.xml" rel="self" type="application/rss+xml" />
<item>
<title>文章標題</title>
<link>https://example.com/post/1</link>
<guid isPermaLink="true">https://example.com/post/1</guid>
<description><![CDATA[文章摘要或全文 HTML]]></description>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0800</pubDate>
<category>分類名稱</category>
</item>
</channel>
</rss>
class RssFeed
{
private string $title;
private string $link;
private string $description;
private string $language = 'zh-TW';
private string $copyright = '';
private array $items = [];
private array $categories = [];
private string $imageUrl = '';
private int $ttl = 60; // 分鐘
private array $namespaces = [];
// 設定方法 (鏈式)
public function title(string $t): self { $this->title = $t; return $this; }
public function link(string $l): self { $this->link = $l; return $this; }
public function description(string $d): self { $this->description = $d; return $this; }
public function language(string $l): self { $this->language = $l; return $this; }
public function copyright(string $c): self { $this->copyright = $c; return $this; }
public function ttl(int $m): self { $this->ttl = $m; return $this; }
public function image(string $url, string $title = '', string $link = ''): self {
$this->imageUrl = $url;
return $this;
}
public function addNamespace(string $prefix, string $uri): self {
$this->namespaces[$prefix] = $uri;
return $this;
}
// 新增項目
public function addItem(array $item): self
{
$defaults = [
'title' => '',
'link' => '',
'guid' => '',
'description' => '',
'pubDate' => null, // DateTimeImmutable 或字串
'categories' => [],
'author' => '',
'enclosure' => null, // ['url', 'type', 'length'] for Podcast
];
$this->items[] = array_merge($defaults, $item);
return $this;
}
// 產生 XML 字串
public function render(): string
{
$dom = new DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$rss = $dom->createElement('rss');
$rss->setAttribute('version', '2.0');
foreach ($this->namespaces as $prefix => $uri) {
$rss->setAttribute("xmlns:$prefix", $uri);
}
$dom->appendChild($rss);
$channel = $dom->createElement('channel');
$rss->appendChild($channel);
// Channel 必要元素
$this->appendElement($dom, $channel, 'title', $this->title);
$this->appendElement($dom, $channel, 'link', $this->link);
$this->appendElement($dom, $channel, 'description', $this->description);
$this->appendElement($dom, $channel, 'language', $this->language);
if ($this->copyright) $this->appendElement($dom, $channel, 'copyright', $this->copyright);
$this->appendElement($dom, $channel, 'lastBuildDate', $this->formatDate(new DateTimeImmutable()));
$this->appendElement($dom, $channel, 'ttl', (string)$this->ttl);
$this->appendElement($dom, $channel, 'atom:link', '', [
'href' => $this->link . '/rss.xml',
'rel' => 'self',
'type' => 'application/rss+xml',
]);
if ($this->imageUrl) {
$img = $dom->createElement('image');
$this->appendElement($dom, $img, 'url', $this->imageUrl);
$this->appendElement($dom, $img, 'title', $this->title);
$this->appendElement($dom, $img, 'link', $this->link);
$channel->appendChild($img);
}
// Items
foreach ($this->items as $item) {
$itemEl = $dom->createElement('item');
$this->appendElement($dom, $itemEl, 'title', $item['title']);
$this->appendElement($dom, $itemEl, 'link', $item['link']);
$guid = $dom->createElement('guid', $item['guid'] ?: $item['link']);
$guid->setAttribute('isPermaLink', $item['guid'] ? 'false' : 'true');
$itemEl->appendChild($guid);
$this->appendElement($dom, $itemEl, 'description', $item['description'], [], true); // CDATA
$this->appendElement($dom, $itemEl, 'pubDate', $this->formatDate($item['pubDate'] ?? new DateTimeImmutable()));
foreach ($item['categories'] as $cat) {
$this->appendElement($dom, $itemEl, 'category', $cat);
}
if ($item['author']) $this->appendElement($dom, $itemEl, 'author', $item['author']);
if ($item['enclosure']) {
$enc = $dom->createElement('enclosure');
$enc->setAttribute('url', $item['enclosure'][0]);
$enc->setAttribute('type', $item['enclosure'][1]);
$enc->setAttribute('length', (string)$item['enclosure'][2]);
$itemEl->appendChild($enc);
}
$channel->appendChild($itemEl);
}
return $dom->saveXML();
}
private function appendElement(DOMDocument $dom, DOMElement $parent, string $name, string $value, array $attrs = [], bool $cdata = false): void
{
if ($value === '') return;
$el = $dom->createElement($name);
if ($cdata) {
$el->appendChild($dom->createCDATASection($value));
} else {
$el->textContent = $value;
}
foreach ($attrs as $k => $v) $el->setAttribute($k, $v);
$parent->appendChild($el);
}
private function formatDate(DateTimeImmutable|string $date): string
{
if (is_string($date)) $date = new DateTimeImmutable($date);
return $date->format('r'); // RFC 2822 格式
}
// 輸出並設定 HTTP 快取頭
public function output(): void
{
header('Content-Type: application/rss+xml; charset=UTF-8');
header('Cache-Control: public, max-age=' . ($this->ttl * 60));
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
echo $this->render();
}
}
<?php
// rss.php
require 'vendor/autoload.php';
require 'RssFeed.php';
$pdo = new PDO('mysql:host=127.0.0.1;dbname=blog;charset=utf8mb4', 'user', 'pass');
$stmt = $pdo->query("SELECT id, title, slug, excerpt, content, published_at, category FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT 50");
$feed = new RssFeed();
$feed->title('我的部落格')
->link('https://blog.example.com')
->description('分享技術與生活')
->language('zh-TW')
->ttl(60)
->addNamespace('content', 'https://purl.org/rss/1.0/modules/content/')
->addNamespace('atom', 'https://www.w3.org/2005/Atom');
while ($row = $stmt->fetch()) {
$feed->addItem([
'title' => $row['title'],
'link' => "https://blog.example.com/post/{$row['slug']}",
'guid' => "https://blog.example.com/post/{$row['slug']}",
'description' => $row['excerpt'], // 或 $row['content'] 全文
'pubDate' => new DateTimeImmutable($row['published_at']),
'categories' => [$row['category']],
]);
}
$feed->output();
$feed->addNamespace('itunes', 'https://www.itunes.com/dtds/podcast-1.0.dtd');
// Channel 級
$feed->addItem([]); // dummy 為了在 channel 加入 itunes 元素,實際建議擴充類別
// Item 級 (音訊單集)
$feed->addItem([
'title' => 'EP01: PHP 8.2 新特性',
'link' => 'https://podcast.example.com/ep01',
'guid' => 'ep01',
'description' => '本集介紹 readonly class, DNF types...',
'pubDate' => new DateTimeImmutable('2024-01-01'),
'enclosure' => [
'https://cdn.example.com/ep01.mp3',
'audio/mpeg',
12345678, // bytes
],
'categories' => ['Technology', 'Programming'],
]);
// 需在 render() 中補充 itunes:author, itunes:summary, itunes:duration, itunes:explicit 等
https://validator.w3.org/feed/ 輸入 URL 或上傳 XML。https://castfeedvalidator.com/ 專門檢查 iTunes 命名空間。// 簡單檔案快取
$cacheFile = __DIR__ . '/cache/rss.xml';
$ttl = 300; // 5 分鐘
if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < $ttl)) {
header('Content-Type: application/rss+xml; charset=UTF-8');
readfile($cacheFile);
exit;
}
$feed = new RssFeed();
// ... 建構 feed
$xml = $feed->render();
file_put_contents($cacheFile, $xml);
echo $xml;
RSS 雖為成熟技術,但正確實作需注意「XML 編碼、CDATA 使用、RFC 2822 日期、命名空間宣告、HTTP 快取頭」。封裝可復用的 RssFeed 類別,能讓專案輕鬆支援多種訂閱格式 (RSS 2.0, Atom, JSON Feed, Podcast)。