ニュースフィードリーダー
RSSフィードを取得して最新ニュースを一覧表示するアプリ。feedparserライブラリとリスト表示の実装を学びます。
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 解説も参考にしてください。
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. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
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.Listbox・ttk.Entry×2・ttk.Treeview・ttk.Scrollbar・tk.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 で Exception・ImportError を捕捉しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
feedparser を使わずに標準ライブラリだけで RSS を読む
このアプリは外部ライブラリ feedparser を使っていますが、RSS の取得自体は標準ライブラリだけでも書けます。手順は3つです。① urllib.request.urlopen(url, timeout=10) でフィードの XML を取得する。② 取得したデータを xml.etree.ElementTree.fromstring(data) に渡してパースする。③ channel 配下の item 要素をループし、title・link・pubDate を findtext() で取り出す、という流れです(ここまでは 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ファイルを作成する
新しいファイルを作成して app10.py と保存します。外部ライブラリ
feedparserを使います。先にpip install feedparserを実行してください。 -
2クラスの骨格を作る
App10クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("ニュースフィードリーダー")・geometry("900x620")・configure(bg="#1a1a2e")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×8・tk.Label×6・ttk.Button×3・tk.Listbox・ttk.Entry×2・ttk.Treeview・ttk.Scrollbar・tk.Textを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_refresh()・_add_feed()・_open_article())につなぎます。bind("<<ListboxSelect>>", ...)・bind("<<TreeviewSelect>>", ...)・bind("<Double-1>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_update_list()(4行)・_on_article_select()(18行)・_add_feed()(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 引数を省略してください。
❌ 記事タイトルに & や ' が混じる
原因:RSS フィードのタイトルに含まれる & や ' が、配信元で HTML エンティティ(&・' など)としてエスケープされたまま渡ってくることがあります。
解決法:標準ライブラリの html.unescape() にタイトル文字列を通すと、& は & に、' は ' に戻ります。取得した title をそのまま表示せず、html.unescape(title) の結果を表示に使いましょう。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("900x620") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は全アプリ共通の定型課題です)。
-
課題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:絞り込んだあとに選んだ記事の詳細を正しく出す
検索欄で絞り込んだあとに記事を選ぶと、別の記事の詳細が出ます。
_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:保存機能の追加
入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。