中級者向け No.10

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

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

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

1. アプリ概要

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

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

Pythonでは tkinter を使うことで、クロスプラットフォームなGUIアプリを簡単に作成できます。このアプリを通じて、ウィジェットの配置・イベント処理・データ管理など、GUI開発の実践的なスキルを習得できます。

ソースコードは完全な動作状態で提供しており、コピーしてそのまま実行できます。まずは実行して動作を確認し、その後コードを読んで仕組みを理解していきましょう。カスタマイズセクションでは機能拡張のアイデアも紹介しています。

GUIアプリ開発は、プログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。アプリを作ることで、変数・関数・クラス・イベント処理など、プログラミングの重要な概念が自然と身についていきます。このアプリをきっかけに、オリジナルアプリの開発にも挑戦してみてください。

2. 機能一覧

  • ニュースフィードリーダーのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • キーボードショートカット対応

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows・Mac・Linux すべて対応

以下の環境で動作確認しています。

  • Python 3.10 以上
  • OS: Windows 10/11・macOS 12+・Ubuntu 20.04+

4. 完全なソースコード

💡
コードのコピー方法

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

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),
                  placeholder_text="RSS URLを入力").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クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。

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),
                  placeholder_text="RSS URLを入力").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()

LabelFrameによるセクション分け

ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。

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),
                  placeholder_text="RSS URLを入力").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()

Entryウィジェットとイベントバインド

ttk.Entryで入力フィールドを作成します。bind('', ...)でEnterキー押下時に処理を実行できます。これにより、マウスを使わずキーボードだけで操作できるUXが実現できます。

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),
                  placeholder_text="RSS URLを入力").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()

Textウィジェットでの結果表示

結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。

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),
                  placeholder_text="RSS URLを入力").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()

例外処理とmessagebox

try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。

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),
                  placeholder_text="RSS URLを入力").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()

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

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

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

    新しいファイルを作成して app10.py と保存します。

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

    App10クラスを定義し、__init__とmainloop()の最小構成を作ります。

  3. 3
    タイトルバーを作る

    Frameを使ってカラーバー付きのタイトルエリアを作ります。

  4. 4
    入力フォームを実装する

    LabelFrameとEntryウィジェットで入力エリアを作ります。

  5. 5
    処理ロジックを実装する

    _calculate()メソッドに計算・処理ロジックを実装します。

  6. 6
    結果表示を実装する

    TextウィジェットかLabelに結果を表示する_show_result()を実装します。

  7. 7
    エラー処理を追加する

    try-exceptとmessageboxでエラーハンドリングを追加します。

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

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

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

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

💡 データのエクスポート機能

計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 入力履歴機能

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

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

❌ 日本語フォントが表示されない

原因:システムに日本語フォントが見つからない場合があります。

解決法:font引数を省略するかシステムに合ったフォントを指定してください。

❌ ウィンドウのサイズが変更できない

原因:resizable(False, False)が設定されています。

解決法:resizable(True, True)に変更してください。

9. 練習問題

アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。

  1. 課題1:機能拡張

    ニュースフィードリーダーに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。

  2. 課題2:UIの改善

    色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしてみましょう。

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

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

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のNo.11に挑戦しましょう。