Lovelace: RSS Accordion Card πŸ—žοΈ

TL;DR: Display RSS feed items in a clean accordion view on your Lovelace dashboard β€” with collapsible sections, β€œNEW” indicators, images, and more.

:point_right: HACS link | GitHub repo

Hi everyone! :wave:

I’d like to share a custom Lovelace card I built: RSS Accordion Card.

It displays RSS feed items from a sensor (feedparser) or an event entity (feedreader) in a clean accordion style directly in your Home Assistant dashboard.

Screenshot

Features

  • Display feed items in collapsible accordion sections.
  • Support for both sensor (Feed Parser) and event (Feedreader) entities.
  • Option to only allow one accordion item open at a time.
  • β€œNEW” pill for recent articles (configurable, 1 hour by default).
  • Visited items are greyed out for easier distinction.
  • Show a hero image if provided by the feed (hides duplicates in summary).
  • Channel information support (title, description, image).
  • Optionally crop the channel image to a neat 60x60 circle.
  • Show last channel update time (channel.published) if available.
  • Auto-open the newest item on card load.
  • Configurable number of feed items.

Installation

Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.

Links

If you try it out, I’d love to hear your feedback and ideas! :rocket:

2 Likes

Great card, love how you can collapse the feed item and the other options like new, i use it with feedparser but its shows an error displaying the date but the feed shows and but it shows just one feed item and no images. Same with feedreader but no date error but only one feed item no images. Can you share the config what you use with the New York Times ?

Solved, i ask grok to change the sensor.py file for me because i am a noob in code. Works perfect.

Could you please share how you fixed the date and image issue? Perhaps that needs to be raised on the GItHub repo as an issue and fix?

i ask grok to change the sensor.py file for me and he did a good job :wink:

grok has changed the code for me, i don’t have any code knowledge.

The code works on some feeds not all, there is no general fix, i had to copy the code from the sensor.py and the rss feed data to grok because not every rss feed uses the same format what can be read by RSS Accordion. So if people like to use it and like to change it with grok, make a copy of your sensor.py from here /homeassistant/custom_components/feedparser and the feed data from developers β€”> states β€”> sensor.yourfeed entity (just from one feed entry).

Its the same as the media player from the card, on some podcast RSS feeds it shows but not all just because the formats used in the feed is different and the player is not shown. So there is no need to to add the code to github repo.

Below is the config for the card feed i use for feedparser

- platform: feedparser
  name: Techniek en Wetenschap
  feed_url: 'https://tw.nl/feed'
  date_format: "%a %d %b %Y %H:%M"
  scan_interval:
    hours: 2
  show_topn: 5
  inclusions:
      - title
      - summary
      - link
      - published
      - description
      - id
      - duration
      - image
      - media

## Techniek en Wetenschap
- platform: template
  sensors:
    techniek_en_wetenschap_attributes_0:
      friendly_name: "Techniek en Wetenschap Content 0"
      value_template: >
        {{ states.sensor.techniek_en_wetenschap.attributes.entries[0].title }}

and below is the sensor.py

from __future__ import annotations

import re
import locale
from datetime import timedelta

import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from dateutil import parser as date_parser
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util

import feedparser

__version__ = "0.1.16"

CONF_FEED_URL = "feed_url"
CONF_DATE_FORMAT = "date_format"
CONF_LOCAL_TIME = "local_time"
CONF_INCLUSIONS = "inclusions"
CONF_EXCLUSIONS = "exclusions"
CONF_SHOW_TOPN = "show_topn"

DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Required(CONF_NAME): cv.string,
    vol.Required(CONF_FEED_URL): cv.string,
    vol.Required(CONF_DATE_FORMAT, default="%a, %d %b %Y %H:%M"): cv.string,
    vol.Optional(CONF_LOCAL_TIME, default=True): cv.boolean,
    vol.Optional(CONF_SHOW_TOPN, default=9999): cv.positive_int,
    vol.Optional(CONF_INCLUSIONS, default=[]): vol.All(cv.ensure_list, [cv.string]),
    vol.Optional(CONF_EXCLUSIONS, default=[]): vol.All(cv.ensure_list, [cv.string]),
    vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period,
})


async def async_setup_platform(
    hass: HomeAssistant,
    config: ConfigType,
    async_add_devices: AddEntitiesCallback,
    discovery_info: DiscoveryInfoType | None = None,
) -> None:
    """Set up the feedparser sensor."""
    async_add_devices([FeedParserSensor(
        feed=config[CONF_FEED_URL],
        name=config[CONF_NAME],
        date_format=config[CONF_DATE_FORMAT],
        local_time=config[CONF_LOCAL_TIME],
        show_topn=config[CONF_SHOW_TOPN],
        inclusions=config[CONF_INCLUSIONS],
        exclusions=config[CONF_EXCLUSIONS],
        scan_interval=config.get(CONF_SCAN_INTERVAL),
    )], True)


class FeedParserSensor(SensorEntity):
    def __init__(self, feed, name, date_format, local_time, show_topn, inclusions, exclusions, scan_interval):
        self._feed = feed
        self._attr_name = name
        self._attr_icon = "mdi:rss-box"
        self._date_format = date_format
        self._local_time = local_time
        self._show_topn = show_topn
        self._inclusions = [inc.lower() for inc in (inclusions or [])]
        self._exclusions = [exc.lower() for exc in (exclusions or [])]
        self._scan_interval = scan_interval

        self._state = 0
        self._entries = []

    @property
    def state(self):
        return self._state

    @property
    def extra_state_attributes(self):
        return {"entries": self._entries}

    def _parse_date(self, date_str: str):
        """Parse date with Dutch locale support (ma, di, wo, etc.)"""
        if not date_str:
            return None

        # Temporarily set locale to Dutch for correct abbr weekday/month parsing
        original_locale = locale.getlocale(locale.LC_TIME)
        try:
            locale.setlocale(locale.LC_TIME, ('nl_NL', 'UTF-8'))
        except locale.Error:
            try:
                locale.setlocale(locale.LC_TIME, 'Dutch_Netherlands')
            except locale.Error:
                pass  # fallback to default

        try:
            dt = date_parser.parse(date_str, fuzzy=False, ignoretz=True)
            if self._local_time:
                dt = dt_util.as_local(dt)
            return dt.strftime(self._date_format)
        except Exception:
            return date_str  # return raw if all fails
        finally:
            # Restore original locale
            if original_locale:
                locale.setlocale(locale.LC_TIME, original_locale)

    def _extract_image(self, entry):
        """Extract highest quality image – optimized for Telegraaf"""
        image_url = None

        # 1. media:content with medium="image" and highest width
        if getattr(entry, "media_content", None):
            images = [
                m["url"] for m in entry.media_content
                if m.get("medium") == "image" and m.get("url")
            ]
            if images:
                # Telegraaf uses width="1200" for main image
                wide_images = [url for url in images if "1200" in url or "1000" in url]
                image_url = wide_images[0] if wide_images else images[0]

        # 2. media:thumbnail (fallback)
        if not image_url and getattr(entry, "media_thumbnail", None):
            thumb = entry.media_thumbnail
            if isinstance(thumb, list):
                thumb = thumb[0]
            image_url = thumb.get("url")

        # 3. enclosures (image/*)
        if not image_url and getattr(entry, "enclosures", None):
            for enc in entry.enclosures:
                if enc.type and enc.type.startswith("image/"):
                    image_url = enc.get("href")
                    break

        # 4. Open Graph image in summary_detail (Telegraaf often puts og:image here)
        if not image_url and hasattr(entry, "summary_detail"):
            match = re.search(r'src=["\']([^"\']+telegraaf\.nl[^"\']+1200[^"\']*)["\']', entry.summary_detail.value or "", re.I)
            if match:
                image_url = match.group(1)

        # 5. Fallback: any img tag in summary
        if not image_url and hasattr(entry, "summary"):
            match = re.search(r'src=["\']([^"\']+)["\']', entry.summary)
            if match:
                image_url = match.group(1)

        return image_url or "https://www.home-assistant.io/images/favicon-192x192-full.png"

    def update(self):
        parsed_feed = feedparser.parse(self._feed)
        if not parsed_feed or not parsed_feed.get("entries"):
            self._state = 0
            self._entries = []
            return

        entries = parsed_feed.entries[:self._show_topn]
        self._state = len(entries)
        self._entries = []

        for entry in entries:
            item = {}

            for key, value in entry.items():
                lower_key = key.lower()

                # Skip excluded or not included
                if self._exclusions and lower_key in self._exclusions:
                    continue
                if self._inclusions and lower_key not in self._inclusions and lower_key != "image":
                    continue
                if lower_key.startswith("parsed"):
                    continue

                # Special handling for known date fields
                if lower_key in ["published", "updated", "created", "expired", "date"]:
                    if value:
                        item[key] = self._parse_date(value)
                    continue

                item[key] = value

            # Always try to add best possible image
            if not self._inclusions or "image" in self._inclusions:
                item["image"] = self._extract_image(entry)

            # Optional: clean up title/link/summary if you want
            # item["title"] = re.sub(r"\s+", " ", entry.title).strip()

            self._entries.append(item)

Could you please sort out the code formatting above? Also, what I was saying is that this code would be good to post as a fix to the problem on this cards Github repo so it becomes available for everyone who uses the card.

1 Like

Sorry for the code mess, i cleaned it up with extra info.

Cheers