中級者向け No.10

ニュースフィードリーダー

RSSフィードを取得して最新ニュースを一覧表示するアプリ。feedparserライブラリとリスト表示の実装を学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ), feedparser ⏱️ 制作時間: 30〜90分

1. アプリ概要

RSSフィードを取得して最新ニュースを一覧表示するアプリ。feedparserライブラリとリスト表示の実装を学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・feedparser で、難易度は ★★☆ です。

このアプリはこんなときに使います。複数のニュースサイト・技術ブログ・YouTube チャンネルの新着を、サイトを巡回せず1画面に集約したいとき。RSS は Web API より手軽で、feedparser ひとつで RSS と Atom の両形式を吸収してくれます。この「取得 → パース → 一覧表示」の型は、株価・天気・GitHub リリース通知など、外部から定期的にデータを取ってくるアプリ全般に応用できます。

feedparser でつまずきやすい点が3つあります。① 取得中に画面が固まる: feedparser.parse(url) はネットワーク I/O でブロックします。threading で別スレッド取得し、結果は root.after でメインスレッドに戻して UI 更新してください(tkinter を別スレッドから直接触ると壊れます)。② タイトルが文字化けする: feedparser は概ね自動判定しますが、稀に化けます。フィードの encoding を確認しましょう。③ 日付の形式がフィードごとにバラバラ: entry.published_parsed(time.struct_time)を使うと統一的に扱えます。

慣れたら、こう応用できます。root.after で5分ごとの自動更新、既読・未読の管理、キーワードによる絞り込み、新着のデスクトップ通知などが定番の拡張です。RSS のない相手から HTTP で直接データを取る発展形は、Pythonライブラリ一覧 の requests 解説も参考にしてください。

ニュースフィードリーダー 実行画面(Windows)
実行画面(Windows)
ニュースフィードリーダー 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「🔄 更新」ボタン(_refresh())・「追加」ボタン(_add_feed())・「🌐 ブラウザで開く」ボタン(_open_article())で操作
  • イベント <<ListboxSelect>> から _on_feed_select() を実行
  • イベント <<TreeviewSelect>> から _on_article_select() を実行
  • マウス操作 <Double-1> から _open_article() を実行
  • tk.Listbox による一覧表示(スクロールバー付き)
  • ttk.Treeview による表形式の一覧表示
  • ウィンドウはタイトル「ニュースフィードリーダー」・サイズ 900x620 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

📦 必要なライブラリをインストール(初回のみ)
pip install feedparser
app10.py
import tkinter as tk
from tkinter import ttk, messagebox
import threading
import webbrowser
from datetime import datetime

try:
    import feedparser
    FEEDPARSER_AVAILABLE = True
except ImportError:
    FEEDPARSER_AVAILABLE = False


class App10:
    """ニュースフィードリーダー"""

    RSS_FEEDS = {
        "NHKニュース": "https://www.nhk.or.jp/rss/news/cat0.xml",
        "朝日新聞": "https://www.asahi.com/rss/asahi/newsheadlines.rdf",
        "Python.org Blog": "https://blog.python.org/feeds/posts/default",
        "TechCrunch": "https://techcrunch.com/feed/",
        "Hacker News": "https://news.ycombinator.com/rss",
        "GitHub Blog": "https://github.blog/feed/",
        "Dev.to": "https://dev.to/feed",
    }

    def __init__(self, root):
        self.root = root
        self.root.title("ニュースフィードリーダー")
        self.root.geometry("900x620")
        self.root.configure(bg="#1a1a2e")
        self.articles = []
        self._build_ui()

        if not FEEDPARSER_AVAILABLE:
            self.status_var.set(
                "⚠ feedparser が必要です: pip install feedparser")
        else:
            self._load_feed("Hacker News")

    def _build_ui(self):
        # ヘッダー
        header = tk.Frame(self.root, bg="#16213e", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="📰 ニュースフィードリーダー",
                 font=("Noto Sans JP", 14, "bold"),
                 bg="#16213e", fg="#e2b96f").pack(side=tk.LEFT, padx=12)
        ttk.Button(header, text="🔄 更新",
                   command=self._refresh).pack(side=tk.RIGHT, padx=12)

        main = tk.Frame(self.root, bg="#1a1a2e")
        main.pack(fill=tk.BOTH, expand=True)

        # 左: フィード一覧
        feed_frame = tk.Frame(main, bg="#16213e", width=180)
        feed_frame.pack(side=tk.LEFT, fill=tk.Y)
        feed_frame.pack_propagate(False)
        tk.Label(feed_frame, text="フィード一覧",
                 bg="#0f3460", fg="#e2b96f",
                 font=("Noto Sans JP", 11, "bold"),
                 pady=6).pack(fill=tk.X)
        self.feed_listbox = tk.Listbox(feed_frame,
                                       bg="#16213e", fg="#ccc",
                                       selectbackground="#0f3460",
                                       font=("Noto Sans JP", 10),
                                       relief=tk.FLAT, bd=0)
        for name in self.RSS_FEEDS:
            self.feed_listbox.insert(tk.END, f"  {name}")
        self.feed_listbox.pack(fill=tk.BOTH, expand=True)
        self.feed_listbox.bind("<<ListboxSelect>>", self._on_feed_select)

        # カスタムURL追加
        url_frame = tk.Frame(feed_frame, bg="#16213e")
        url_frame.pack(fill=tk.X, pady=4)
        self.url_var = tk.StringVar()
        ttk.Entry(url_frame, textvariable=self.url_var,
                  font=("Arial", 8)).pack(
            fill=tk.X, padx=4, pady=2)
        ttk.Button(url_frame, text="追加",
                   command=self._add_feed).pack(fill=tk.X, padx=4, pady=2)

        # 右エリア
        right = tk.Frame(main, bg="#1a1a2e")
        right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        # 検索
        search_frame = tk.Frame(right, bg="#1a1a2e", pady=4)
        search_frame.pack(fill=tk.X, padx=8)
        tk.Label(search_frame, text="🔍",
                 bg="#1a1a2e", fg="#ccc").pack(side=tk.LEFT)
        self.search_var = tk.StringVar()
        ttk.Entry(search_frame, textvariable=self.search_var,
                  width=30).pack(side=tk.LEFT, padx=4)
        self.search_var.trace_add("write", lambda *a: self._filter())

        # 記事リスト
        list_frame = tk.Frame(right, bg="#1a1a2e")
        list_frame.pack(fill=tk.BOTH, expand=False, padx=8)
        cols = ("title", "date", "source")
        self.article_tree = ttk.Treeview(list_frame, columns=cols,
                                          show="headings", height=14,
                                          selectmode="browse")
        for c, h, w in [("title", "タイトル", 400),
                         ("date", "日時", 130),
                         ("source", "ソース", 120)]:
            self.article_tree.heading(c, text=h)
            self.article_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(list_frame, command=self.article_tree.yview)
        self.article_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.article_tree.pack(fill=tk.BOTH, expand=True)
        self.article_tree.bind("<<TreeviewSelect>>", self._on_article_select)
        self.article_tree.bind("<Double-1>", self._open_article)

        # 記事詳細
        detail_frame = tk.Frame(right, bg="#0f3460",
                                pady=8, padx=10)
        detail_frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.detail_title = tk.Label(detail_frame, text="",
                                     bg="#0f3460", fg="#e2b96f",
                                     font=("Noto Sans JP", 12, "bold"),
                                     wraplength=600, justify="left")
        self.detail_title.pack(anchor="w")
        self.detail_date = tk.Label(detail_frame, text="",
                                    bg="#0f3460", fg="#888",
                                    font=("Arial", 9))
        self.detail_date.pack(anchor="w", pady=2)
        self.detail_text = tk.Text(detail_frame, height=5,
                                   bg="#0f3460", fg="#ccc",
                                   font=("Noto Sans JP", 10),
                                   wrap=tk.WORD, relief=tk.FLAT)
        self.detail_text.pack(fill=tk.BOTH, expand=True, pady=4)
        ttk.Button(detail_frame, text="🌐 ブラウザで開く",
                   command=self._open_article).pack(anchor="w")

        self.status_var = tk.StringVar(value="フィードを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#16213e", fg="#888", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

        self._selected_url = None

    def _on_feed_select(self, event):
        sel = self.feed_listbox.curselection()
        if sel:
            name = self.feed_listbox.get(sel[0]).strip()
            if name in self.RSS_FEEDS:
                self._load_feed(name)

    def _load_feed(self, name):
        if not FEEDPARSER_AVAILABLE:
            return
        url = self.RSS_FEEDS.get(name, "")
        if not url:
            return
        self.status_var.set(f"取得中: {name} ...")
        threading.Thread(target=self._fetch_feed, args=(name, url),
                         daemon=True).start()

    def _fetch_feed(self, name, url):
        try:
            feed = feedparser.parse(url)
            entries = feed.entries
            self.root.after(0, self._update_list, name, entries)
        except Exception as e:
            self.root.after(0, lambda: self.status_var.set(f"エラー: {e}"))

    def _update_list(self, source, entries):
        self.articles = entries
        self._fill_tree(entries, source)
        self.status_var.set(f"{source}  |  {len(entries)} 件")

    def _fill_tree(self, entries, source):
        self.article_tree.delete(*self.article_tree.get_children())
        for e in entries:
            title = e.get("title", "(タイトルなし)")
            date_raw = e.get("published", "")
            try:
                from email.utils import parsedate
                from time import mktime
                t = mktime(parsedate(date_raw))
                date_str = datetime.fromtimestamp(t).strftime("%Y/%m/%d %H:%M")
            except Exception:
                date_str = date_raw[:16] if len(date_raw) >= 16 else date_raw
            self.article_tree.insert("", "end",
                                     values=(title, date_str, source))

    def _filter(self):
        q = self.search_var.get().strip().lower()
        filtered = [e for e in self.articles
                    if q in e.get("title", "").lower()
                    or q in e.get("summary", "").lower()]
        self._fill_tree(filtered, "")
        self.status_var.set(f"{len(filtered)} 件表示 / 全 {len(self.articles)} 件")

    def _on_article_select(self, event):
        sel = self.article_tree.selection()
        if not sel:
            return
        idx = self.article_tree.index(sel[0])
        if idx < len(self.articles):
            e = self.articles[idx]
            self.detail_title.config(text=e.get("title", ""))
            self.detail_date.config(text=e.get("published", ""))
            summary = e.get("summary", "内容なし")
            # HTMLタグを簡易除去
            import re
            summary = re.sub(r"<[^>]+>", "", summary)
            self.detail_text.config(state=tk.NORMAL)
            self.detail_text.delete("1.0", tk.END)
            self.detail_text.insert("1.0", summary[:500])
            self.detail_text.config(state=tk.DISABLED)
            self._selected_url = e.get("link", "")

    def _open_article(self, event=None):
        if self._selected_url:
            webbrowser.open(self._selected_url)

    def _refresh(self):
        sel = self.feed_listbox.curselection()
        if sel:
            self._on_feed_select(None)

    def _add_feed(self):
        url = self.url_var.get().strip()
        if url:
            name = url.split("/")[2] if "/" in url else url
            self.RSS_FEEDS[name] = url
            self.feed_listbox.insert(tk.END, f"  {name}")
            self.url_var.set("")


if __name__ == "__main__":
    root = tk.Tk()
    app = App10(root)
    root.mainloop()

5. コード解説

ニュースフィードリーダーのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App10 クラスにアプリの全機能をまとめています(メソッド12個・全236行)。__init__ ではタイトル「ニュースフィードリーダー」とウィンドウサイズ 900x620 を設定します。最後に _build_ui() で画面を組み立てます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×8・tk.Label×6・ttk.Button×3・tk.Listboxttk.Entry×2・ttk.Treeviewttk.Scrollbartk.Text で構成しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「🔄 更新」ボタン(_refresh())・「追加」ボタン(_add_feed())・「🌐 ブラウザで開く」ボタン(_open_article())を command= で接続しています。また bind("<<ListboxSelect>>", ...) から _on_feed_select()bind("<<TreeviewSelect>>", ...) から _on_article_select()bind("<Double-1>", ...) から _open_article() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ExceptionImportError を捕捉しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

feedparser を使わずに標準ライブラリだけで RSS を読む

このアプリは外部ライブラリ feedparser を使っていますが、RSS の取得自体は標準ライブラリだけでも書けます。手順は3つです。① urllib.request.urlopen(url, timeout=10) でフィードの XML を取得する。② 取得したデータを xml.etree.ElementTree.fromstring(data) に渡してパースする。③ channel 配下の item 要素をループし、titlelinkpubDatefindtext() で取り出す、という流れです(ここまでは RSS 2.0 形式の場合)。

ただし Atom 形式のフィードでは、要素名が entry に変わるだけでなく XML 名前空間が付くため、findall("entry") では1件も取れません(findall("{http://www.w3.org/2005/Atom}entry") のように名前空間つきで指定する必要があります)。リンクも <link href="..."> の属性で、findtext("link") は空文字になります。こうした形式差をまとめて吸収してくれるため、実務では feedparser のほうが堅牢です。このアプリが feedparser を選んでいるのはそのためで、標準ライブラリ版はライブラリを追加インストールできない環境向けの代替手段と考えてください(利用にあたっては各フィード配信元の robots.txt・利用規約もあわせて確認してください)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app10.py と保存します。外部ライブラリ feedparser を使います。先に pip install feedparser を実行してください。

  2. 2
    クラスの骨格を作る

    App10 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("ニュースフィードリーダー")geometry("900x620")configure(bg="#1a1a2e") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×8・tk.Label×6・ttk.Button×3・tk.Listboxttk.Entry×2・ttk.Treeviewttk.Scrollbartk.Text を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_refresh()_add_feed()_open_article())につなぎます。bind("<<ListboxSelect>>", ...)bind("<<TreeviewSelect>>", ...)bind("<Double-1>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _update_list()(4行)・_on_article_select()(18行)・_add_feed()(7行)など を実装します。

  7. 7
    動作確認する

    python app10.py で起動し、各ボタンが反応すること・マウス操作(クリック・ホイールなど)が効くことを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 一覧の内容をファイルに書き出す

tk.Listbox に溜めた内容は、アプリを閉じると消えます(このコードに保存処理はありません)。一覧を1行ずつテキストファイルへ書き出す保存ボタンを追加しましょう。保存先の選択には tkinter の filedialog モジュールが使えます(このコードでは未使用なので from tkinter import filedialog の追記が必要です)。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

💡 登録した全フィードをまとめて更新する

現在の「🔄 更新」ボタンは選択中のフィード1件だけを取得します。全フィードをまとめて更新したい場合は、フィード一覧を for ループで回して取得し、結果を1つのリストにまとめてから画面へ渡すのがポイントです。現在の _update_list()_fill_tree() でツリーを毎回空にしてから入れ直すため、フィードごとにそのまま呼ぶと最後の1件しか残りません。threading.Thread で並列に取りにいく場合も、全件そろってから1回だけ描画する形にしましょう。同時に何本もリクエストを投げすぎると相手サーバーの負荷になるため、同時実行数を絞るなど配信元への配慮も忘れずに。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 記事タイトルに &amp;&#39; が混じる

原因:RSS フィードのタイトルに含まれる &' が、配信元で HTML エンティティ(&amp;&#39; など)としてエスケープされたまま渡ってくることがあります。

解決法:標準ライブラリの html.unescape() にタイトル文字列を通すと、&amp;& に、&#39;' に戻ります。取得した title をそのまま表示せず、html.unescape(title) の結果を表示に使いましょう。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("900x620") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は全アプリ共通の定型課題です)。

  1. 課題1:起動直後でも「🔄 更新」を効くようにする

    feedparser がある環境では、起動時に _load_feed("Hacker News") が呼ばれます。ただし左のフィード一覧はどれも選択されていません。_refresh() は選択中の行だけを見るため、起動直後に「🔄 更新」を押しても何も起きません。表示中のフィード名を覚えておき、いつでも取り直せるようにします。

    期待結果:起動してすぐ「🔄 更新」を押すと、下のステータス欄が「取得中: Hacker News ...」に変わり、記事一覧が読み直される。

    合格条件

    • 左のフィード一覧をクリックして切り替えたあとの更新は、今までどおり選んだフィードを取り直す
    • 「追加」で足したフィードを選んだあとでも更新が効く
    • feedparser が入っていない環境で更新を押してもエラーにならない

    ヒント①(どこを触るか): 触るのは __init__()_load_feed()_refresh() の3か所です。_refresh() が見ている self.feed_listbox.curselection() は、起動直後には空のタプルを返します。
    ヒント②(使うもの): _load_feed() の先頭で self.current_feed = name と控えておき、_refresh() はその値で self._load_feed() を呼ぶだけです。_on_feed_select()_load_feed() を通るので、切り替え時の記録は自動で済みます。
    つまずきやすい点: __init__() は feedparser が無いときステータス欄に警告を出すだけで _load_feed() を呼びません。__init__() 側に初期値を1行用意しておかないと、その環境で更新を押した瞬間に AttributeError になります。

  2. 課題2:絞り込んだあとに選んだ記事の詳細を正しく出す

    検索欄で絞り込んだあとに記事を選ぶと、別の記事の詳細が出ます。_on_article_select() が画面の行番号をそのまま self.articles の番号として使っているためです。表示中のリストと詳細欄を一致させます。

    期待結果:検索欄に「Python」と入れて絞り込み、一覧の1行目を選ぶと、詳細欄のタイトルがその行と同じになる(いまは絞り込み前の1件目が出る)。

    合格条件

    • 絞り込み中に選んだ記事で「🌐 ブラウザで開く」を押すと、その記事のページが開く
    • 検索欄を空に戻したあとも、選んだ行と詳細の中身が一致する
    • 1件もヒットしない検索語を入れてもエラーにならない

    ヒント①(どこを触るか): 触るのは _fill_tree()_on_article_select() の2か所です。_fill_tree()_update_list()_filter() の両方から呼ばれる唯一の描画口なので、ここで表示中のリストを控えると漏れません。
    ヒント②(使うもの): _fill_tree() の先頭で self.shown = list(entries) と控え、_on_article_select() では self.articles[idx] の代わりに self.shown[idx] を読みます。__init__()self.articles = [] の隣に初期値を1行足しておくと安全です。
    つまずきやすい点: _fill_tree() は毎回すべての行を消してから入れ直すので、index() が返すのは画面に見えている順番です。self.articles は取得したときの順番のままなので、絞り込み中は並びが一致しません。

  3. 課題3:保存機能の追加

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。