개발

라라벨에서 RSS / ATOM 피드 생성기 구현하기

진짜 오랜만에 개발글 하나 써봅니다.

오늘 소개할 내용은 대부분의 블로그에서 RSS 피드를 제공하는데, 그 RSS 피드를 직접 만든 사이트에서 제공하기위해 RSS 구조를 맞추어 XML 형태의 결과를 생성하는 방법이다.

RSS/ATOM 피드는 2000년대 초반부터 특정 사이트에서 발행하는 글을 직접 사이트에 들어가지 않고 '구독' 서비스 느낌으로 알림을 받는 기술로써 사용되어왔다.

한국내에서는 네이버 블로그를 시작으로 RSS 피드에 대해서 많이 사용되고 알려져왔고, 해외에서는 ATOM 피드를 더많이 쓰고있다고 한다.

사이트맵과는 약간 다른 성향을 띄는데, 사이트맵은 xml 형태의 실제 파일로 그 사이트가 담고있는 여러 링크를 하나로 모아둔 형태라고 볼 수 있다.
그에비해 피드(Feed)는 비교적 최신글이 발행되었을때 이를 구독자에게 '알려주기 위한' 용도로써 최근 글 10~20개 정도를 지속적으로 교체해가면서 보여주는 형태이다.

즉 피드는 일종의 새 글 알림의 형태를 띄고있으며 이런 구조에 대해 약속을 해두고 프로그램이나 기능에서 사용되는 하나의 규악이라고 볼 수 있다.


피드 생성기 구현

그럼 이런 피드를 직접 만드려면 어떻게 해야할까?
당연히 각자의 피드 구조에 맞게 내용을 생성하는 과정이 필요하다.

나는 라라벨을 통해 본 사이트를 제작했으므로 마찬가지로 PHP 클래스를 통해 아래와 같이 피드 생성기를 제작해보았다.

<?php
namespace App\Services;

class SimpleFeedGenerator
{
    protected $items = [];
    protected $channel = [
        'title' => '에루라보',
        'link' => 'https://erulabo.com/',
        'description' => '웹 개발 일지를 비롯해 애니메이션 리뷰, 게임 관련 리뷰와 정보글을 만나보세요. 다양한 경험과 기록을 이 곳에서 확인할 수 있습니다.',
        'language' => 'ko-kr'
    ];

    public function addItem($title, $link, $description, $pubDate = null)
    {
        $this->items[] = [
            'title' => $title,
            'link' => $link,
            'description' => $description,
            'pubDate' => $pubDate,
        ];
    }

    public function generateRss()
    {
        $xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><rss></rss>');
        $xml->addAttribute('version', '2.0');

        $channel = $xml->addChild('channel');
        $channel->addChild('title', htmlspecialchars($this->channel['title']));
        $channel->addChild('link', htmlspecialchars($this->channel['link']));
        $channel->addChild('description', htmlspecialchars($this->channel['description']));
        $channel->addChild('language', $this->channel['language']);

        foreach ($this->items as $item) {
            $itemElement = $channel->addChild('item');
            $itemElement->addChild('title', htmlspecialchars($item['title']));
            $itemElement->addChild('link', htmlspecialchars($item['link']));
            $itemElement->addChild('description', htmlspecialchars($item['description']));
            if ($item['pubDate']) {
                $itemElement->addChild('pubDate', $item['pubDate']);
            }
        }

        return $xml->asXML();
    }

    public function generateAtom()
    {
        $xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><feed></feed>');
        $xml->addAttribute('xmlns', 'http://www.w3.org/2005/Atom');

        // 필수 필드 추가
        $xml->addChild('title', htmlspecialchars($this->channel['title']));
        $xml->addChild('link')->addAttribute('href', $this->channel['link']);
        $xml->addChild('id', $this->channel['link']); // feed-level id 추가
        $xml->addChild('updated', now()->toIso8601String());

        // atom:link rel="self" 추가
        $selfLink = $xml->addChild('link');
        $selfLink->addAttribute('href', url('/feed/atom'));
        $selfLink->addAttribute('rel', 'self');
        $selfLink->addAttribute('type', 'application/atom+xml');

        foreach ($this->items as $item) {
            $entry = $xml->addChild('entry');
            $entry->addChild('title', htmlspecialchars($item['title']));
            $entry->addChild('link')->addAttribute('href', $item['link']);
            $entry->addChild('id', $item['link']); // 각 entry에 고유 id 추가
            $entry->addChild('updated', $item['pubDate'] ?? now()->toIso8601String());
            $entry->addChild('summary', htmlspecialchars($item['description']));

            // entry-level author 추가 (간단하게 채널명으로 대응 가능)
            $author = $entry->addChild('author');
            $author->addChild('name', '에루라보');
        }

        return $xml->asXML();
    }
}
Copy

RSS 피드든 ATOM 피드든 약간의 구조차이만 있을뿐이지 공통적으로 사이트에 대한 안내와 더불어 최신 게시글을 담을 구조만 만들 수 있다면 오케이다.

채널 배열에는 사이트를 나타내는 메타 태그의 정보를 기재해주고 각 피드에 들어갈 아이템 배열을 미리정의하고 이 아이템 배열에 내용을 적재해 최종적으로 각자의 구조에 맞는 피드를 만드는 구조이다.


피드 요청 메소드

위의 생성 클래스를 기반으로해 실제로는 아래처럼 컨트롤러에서 사용하면된다.

public function feedRss(Request $request)
{
    $feed = new SimpleFeedGenerator();
    $posts = Post::latest()->limit(10)->get();
    foreach ($posts as $post) {
        $feed->addItem(
            $post->title,
            url('/' . $post->id),
            $post->getDescription(),
            $post->created_at->toRfc2822String()
        );
    }

    return response($feed->generateRss(), 200)
        ->header('Content-Type', 'application/rss+xml; charset=UTF-8');
}

public function feedAtom(Request $request)
{
    $feed = new SimpleFeedGenerator();
    $posts = Post::latest()->limit(10)->get();

    foreach ($posts as $post) {
        $feed->addItem(
            $post->title,
            url('/' . $post->id),
            $post->getDescription(),
            $post->created_at->toIso8601String() // Atom은 ISO-8601 형식 사용
        );
    }

    return response($feed->generateAtom(), 200)
        ->header('Content-Type', 'application/atom+xml; charset=UTF-8');
}Copy

하나 주의해야할점은 RSS와 ATOM의 시간을 다루는 표준값이 다르다는거정도?
이 부분만 위의 코드처럼 신경써서 아이템을 적재한다음 XML코드를 만든 이후, 이 값을 리스폰스에 담아 XML 태그와 함께 반환하면 된다.


피드 요청 라우터

// Feeds
Route::get('feed/rss', 'HomeController@feedRss');
Route::get('feed/atom', 'HomeController@feedAtom');Copy

그리고 이 메소드를 실행시킬 라우터를 위와같이 등록하고나면 이제 라라벨에서도 피드를 쉽게 URL 접근으로 발행할 수 있다.


본문 이미지RSS 피드
본문 이미지ATOM 피드

그러면 위에처럼 최신글 10개를 담은 피드를 URL 주소를 통해 접근하면 각자의 규격에 맞는 XML 데이터를 얻을 수 있다.


피드 링크 및 메타 데이터 등록

본문 이미지푸터에서 각 피드 링크를 제공한다.

그리고 나는 이 피드링크를 푸터에 A 태그로 등록해놓았다.
사람이 누르는것 말고도 피드 확장프로그램이나 봇이 이를 인지할 수 있게 HTML문서의 헤더에도 아래와 같이 피드 정보를 입력하는 방법도 권장된다고한다.

<link rel="alternate" type="application/rss+xml" title="에루라보 RSS Feed" href="https://erulabo.com/feed/rss" />
<link rel="alternate" type="application/atom+xml" title="에루라보 Atom Feed" href="https://erulabo.com/feed/atom" />Copy


결론

사실 사이트맵도 그렇고 피드도 그렇고 라이브러리 잘 둘러보면 라라벨에 맞는 생성 라이브러리를 간단하게 찾을 수 있긴하다.
하지만 이런것도 직접 한 번 만들어보고 실제로 어떤식으로 구동되는지 알아야 나중에 손대기도 쉽고 내 방식대로 변환하기도 쉬워질거라고 생각한다.

요즘은 RSS 피드같은건 좀 구시대의 유물처럼 잘 안쓴다고는 하지만 그래도 개인 사이트는 이런거 저런거 다 한번씩 시도해보는 용도로 사용하고 있기도하고, 이런 시도 자체가 즐거운게 아닐까 싶다.

일단 만들고 좀 더 써보면서 문제가있으면 수정도 하고 보완해봐야겠지만 말이다.
끝!


좀더 업그레이드된 피드생성기 코드

이후 사이트명이나 이미지 관리를 좀 더 포함한 개선 버전을 만들어 보았다.

<?php
namespace App\Services;

class SimpleFeedGenerator
{
    protected $items = [];
    protected $channel = [
        'title' => '에루라보',
        'link' => 'https://erulabo.com/',
        'description' => '웹 개발 일지를 비롯해 애니메이션 리뷰, 게임 관련 리뷰와 정보글을 만나보세요. 다양한 경험과 기록을 이 곳에서 확인할 수 있습니다.',
        'language' => 'ko-kr',
        'generator' => 'erulabo.com FeedGen',
        'managingEditor' => 'erusya@erulabo.com',
        'author' => [
            'name' => '에루라보',
            'email' => 'erusya@erulabo.com'
        ],
        'ttl' => 3600,
        'pubDate' => null,
        'image' => [
            'title' => '에루라보',
            'url' => 'https://erulabo.com/web-app-manifest-512x512.png',
            'link' => 'https://erulabo.com/'
        ]
    ];

    public function setChannelPubDate($pubDate)
    {
        $this->channel['pubDate'] = $pubDate;
    }

    public function addItem($title, $link, $description, $pubDate = null, $imageUrl = null)
    {
        $this->items[] = [
            'title' => $title,
            'link' => $link,
            'description' => $description,
            'pubDate' => $pubDate,
            'image' => $imageUrl ? $imageUrl : 'https://erulabo.com/web-app-manifest-512x512.png'
        ];
    }

    protected function detectMimeType($url)
    {
        $ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
        switch ($ext) {
            case 'jpg':
            case 'jpeg':
                return 'image/jpeg';
            case 'png':
                return 'image/png';
            case 'gif':
                return 'image/gif';
            case 'webp':
                return 'image/webp';
            default:
                return 'application/octet-stream';
        }
    }

    public function generateRss()
    {
        $xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><rss></rss>');
        $xml->addAttribute('version', '2.0');

        $channel = $xml->addChild('channel');
        $channel->addChild('title', htmlspecialchars($this->channel['title']));
        $channel->addChild('link', htmlspecialchars($this->channel['link']));
        $channel->addChild('description', htmlspecialchars($this->channel['description']));
        $channel->addChild('language', $this->channel['language']);
        $channel->addChild('generator', $this->channel['generator']);
        $channel->addChild('ttl', $this->channel['ttl']);
        $channel->addChild('managingEditor', $this->channel['managingEditor']);
        if ($this->channel['pubDate']) {
            $channel->addChild('pubDate', $this->channel['pubDate']);
        }

        $image = $channel->addChild('image');
        $image->addChild('title', htmlspecialchars($this->channel['image']['title']));
        $image->addChild('url', htmlspecialchars($this->channel['image']['url']));
        $image->addChild('link', htmlspecialchars($this->channel['image']['link']));

        foreach ($this->items as $item) {
            $itemElement = $channel->addChild('item');
            $itemElement->addChild('title', htmlspecialchars($item['title']));
            $itemElement->addChild('link', htmlspecialchars($item['link']));
            $itemElement->addChild('description', htmlspecialchars($item['description']));
            if ($item['pubDate']) {
                $itemElement->addChild('pubDate', $item['pubDate']);
            }

            $mimeType = $this->detectMimeType($item['image']);

            $enclosure = $itemElement->addChild('enclosure');
            $enclosure->addAttribute('url', htmlspecialchars($item['image']));
            $enclosure->addAttribute('type', $mimeType);
            $enclosure->addAttribute('length', '0');

            $itemElement->addChild('guid', htmlspecialchars($item['link']))
                ->addAttribute('isPermaLink', 'true');

            $itemElement->addChild('author', htmlspecialchars($this->channel['author']['email']));
        }

        return $xml->asXML();
    }

    public function generateAtom()
    {
        $xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><feed></feed>');
        $xml->addAttribute('xmlns', 'http://www.w3.org/2005/Atom');

        $xml->addChild('title', htmlspecialchars($this->channel['title']));
        $xml->addChild('link')->addAttribute('href', $this->channel['link']);
        $xml->addChild('id', $this->channel['link']);
        $xml->addChild('updated', date(DATE_ATOM));

        $selfLink = $xml->addChild('link');
        $selfLink->addAttribute('href', url('/feed/atom'));
        $selfLink->addAttribute('rel', 'self');
        $selfLink->addAttribute('type', 'application/atom+xml');

        $xml->addChild('logo', htmlspecialchars($this->channel['image']['url']));

        foreach ($this->items as $item) {
            $entry = $xml->addChild('entry');
            $entry->addChild('title', htmlspecialchars($item['title']));
            $entry->addChild('link')->addAttribute('href', $item['link']);
            $entry->addChild('id', $item['link']);
            $entry->addChild('updated', $item['pubDate'] ? $item['pubDate'] : date(DATE_ATOM));

            $summaryContent = '<p>' . htmlspecialchars($item['description']) . '</p><img src="' . htmlspecialchars($item['image']) . '" alt="thumbnail" />';
            $summary = $entry->addChild('summary');
            $summaryNode = dom_import_simplexml($summary);
            $owner = $summaryNode->ownerDocument;
            $summaryNode->appendChild($owner->createCDATASection($summaryContent));
            $summary->addAttribute('type', 'html');

            $author = $entry->addChild('author');
            $author->addChild('name', $this->channel['author']['name']);
            $author->addChild('email', $this->channel['author']['email']);
        }

        return $xml->asXML();
    }
}
Copy

1. 채널 메타 정보 추가: generator, ttl, managingEdtior, pubDate, image(채널 이미지) 추가
2. 각 item에 대표이미지 추가: 포스팅 글 섬네일 보이도록
3. 자동 MIME 감지 기능 추가: 이미지가 추가됨에 따라 그 이미지의 MIME 타입을 분석하기위한 코드 추가
4. ATOM logo 추가: ATOM 피드에 로고 추가할 수 있어서 추가
5. channel-level pubDate 관리: 채널 레벨에서 가장 최신 pubDate를 사용하도록
6. Author 정보 고도화
7. 공통화 디폴트 처리: addItem 개선, 디폴트 이미지 추가, 유효성 검증 추가

뭔가 계속 손대니 욕심이 나서 하나 둘 추가하다보니 이렇게 코드가 길어졌다.
무서워라...

#php #Laravel
0 개의 댓글
×