{"id":13616777,"url":"https://github.com/lotz84/cli-rss-reader","last_synced_at":"2025-12-25T06:30:49.731Z","repository":{"id":36101042,"uuid":"40402280","full_name":"lotz84/cli-rss-reader","owner":"lotz84","description":"Vtyを使って作る簡単なRSSリーダー","archived":false,"fork":false,"pushed_at":"2017-04-24T22:12:49.000Z","size":272,"stargazers_count":16,"open_issues_count":2,"forks_count":2,"subscribers_count":4,"default_branch":"master","last_synced_at":"2024-11-08T01:37:25.497Z","etag":null,"topics":["haskell","rss"],"latest_commit_sha":null,"homepage":"","language":"Haskell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lotz84.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2015-08-08T12:32:05.000Z","updated_at":"2024-06-01T08:59:48.000Z","dependencies_parsed_at":"2022-09-01T01:00:20.370Z","dependency_job_id":null,"html_url":"https://github.com/lotz84/cli-rss-reader","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lotz84%2Fcli-rss-reader","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lotz84%2Fcli-rss-reader/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lotz84%2Fcli-rss-reader/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lotz84%2Fcli-rss-reader/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lotz84","download_url":"https://codeload.github.com/lotz84/cli-rss-reader/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248815544,"owners_count":21165942,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["haskell","rss"],"created_at":"2024-08-01T20:01:33.210Z","updated_at":"2025-12-25T06:30:44.693Z","avatar_url":"https://github.com/lotz84.png","language":"Haskell","funding_links":[],"categories":["Haskell"],"sub_categories":[],"readme":"# Vtyを使って作る簡単なRSSリーダー\n[vty](https://hackage.haskell.org/package/vty)はテキストベースのUIを作れるようになるライブラリです。頑張ればvimのようなものを作れるようになります（がんばれば！）\n\nこのvtyを使って簡単なRSSリーダーを作ったので簡単に解説してみたいと思います。完成したものはこのレポジトリを`git clone`して`cabal install \u0026\u0026 cabal run`をすれば試すことができます。\n\n## HelloWorld\nまずはVtyの基本的な例です。実際に手元で動かしてみてください！\n\n```haskell\nimport Graphics.Vty\n\nmain = do\n    vty \u003c- standardIOConfig \u003e\u003e= mkVty\n    update vty . picForImage $ string (defAttr `withForeColor` green) \"Hello vty\"\n    e \u003c- nextEvent vty\n    shutdown vty\n    print e\n```\n\n`cabal install vty` を忘れずに！この例は[Graphics.Vty](https://hackage.haskell.org/package/vty/docs/Graphics-Vty.html)に載っているものをもう少し簡略化したものです。まず\n\n```haskell\nvty \u003c- standardIOConfig \u003e\u003e= mkVty\n```\n\nで操作の対象になる`Vty`型の値`vty`を作成します。UIに関する処理はこの`vty`を通じて行っていきます。\n\n```haskell\nupdate vty . picForImage $ string (defAttr `withForeColor` green) \"Hello vty\"\n```\n\n`string (defAttr 'withForeColor' green) \"Hello vty\"`で文字色が緑で\"Hello vty\"と書かれた`Image`を作成し`picForImage`を使って`Picture`に変換したあと`update vty`で画面に描画しています。\n\n```haskell\ne \u003c- nextEvent vty\n```\n\nで何らかのイベントを待ち、イベントが来たら\n\n```haskell\nshutdown vty\n```\n\nで`vty`を安全に終了して\n\n```haskell\nprint e\n```\n\nで最後に起こったイベントを表示する、と言った流れです。\n\n## アプリを設計する\nHello World を見て分かるようにVtyを使ったプログラムでは最初に`mkVty`で作ったVtyの値を色んな所で使いまわします。なのでアプリでは最初に作った`Vty`の値を`Reader`モナドに入れて引き回すようにするのがいいでしょう。またプログラムが予期せぬ処理で終わってしまうことも考えて`Except`モナドの中にも入れましょう。もちろん`Vty`をアップデートするのに`IO`モナドの中である必要もあります。扱いたい文脈が3つも出てきてしまったわけですがこんな時は慌てずにモナド交換子を使いましょう。以下のように型を作ります。\n\n```haskell\nimport Control.Monad.Except\nimport Control.Monad.Reader\nimport Control.Monad.IO.Class\n\ndata AppException = AppEscape\n\ntype App = ExceptT AppException (ReaderT Vty IO)\n\nrunApp :: Vty -\u003e App a -\u003e IO (Either AppException a)\nrunApp vty = (flip runReaderT vty) . runExceptT\n```\n\n`AppException`はアプリが吐くエラーの種類で、必要であれば追加していきます。`App`の値を作って`runApp`で実行するという寸法です。\n\n## 画面を設計する\nそれでは実際にRSSリーダーを作っていきましょう。画面は\n\n* 登録してるRSSを選択する(selectionView)\n* RSSを取ってくる(loadingView)\n* 記事を選択する(rssfeedView)\n* 記事を読む(previewView)\n\nの四つにしましょう。とりあえず型だけ実装してしまいます。\n\n```haskell\ndata RSS = RSS { _title :: String, _url :: String } deriving Show\n\nselectionView :: [RSS] -\u003e Int -\u003e App RSS\nselectionView _ _ = throwError AppEscape\n\nloadingView :: RSS -\u003e App Document\nloadingView _ = throwError AppEscape\n\ndata RSSFeedViewAction = RSSFeedViewBack | RSSFeedViewPreview String\n\nrssfeedView :: Document -\u003e Int -\u003e App RSSFeedViewAction\nrssfeedView _ _ = throwError AppEscape\n\npreviewView :: String -\u003e App ()\npreviewView _ = throwError AppEscape\n```\n\nデータの流れを意識して画面遷移だけを先に実装してしまいます。\n\n```haskell\nmain :: IO ()\nmain = do\n    vty \u003c- standardIOConfig \u003e\u003e= mkVty\n    rssList \u003c- getRSSList\n    runApp vty . forever $ do\n        rss \u003c- selectionView rssList 0\n        doc \u003c- loadingView rss\n        fix $ \\loop -\u003e do\n            act \u003c- rssfeedView doc 0\n            case act of\n                RSSFeedViewBack        -\u003e return ()\n                RSSFeedViewPreview url -\u003e previewView url \u003e\u003e loop\n    shutdown vty\n```\n\n`main`関数はこれで完成です！注目すべきは`main`の中の`runApp`の部分です。モナドの計算だけを使って画面遷移を記述しています。\n\n## 登録されてるRSSを取得する\nRSSの登録はローカルのYamlファイルに記述していくことにしましょう。\n\n```yaml\n-\n    title: flip map\n    url  : http://lotz84.github.io/feed.xml\n-\n    title: Planet Haskell\n    url  : http://planet.haskell.org/rss20.xml\n-\n    title: reddit - Haskell\n    url  : https://www.reddit.com/r/haskell/.rss\n```\n\nYamlのアクセスには[yaml-light](https://hackage.haskell.org/package/yaml-light), [yaml-light-lens](https://hackage.haskell.org/package/yaml-light-lens)を使います\n\n```haskell\nimport Data.Yaml.YamlLight\nimport Data.Yaml.YamlLight.Lens\n\ngetRSSList :: IO [RSS]\ngetRSSList = do\n    yaml \u003c- parseYamlFile \"rss.yml\"\n    let y2r y = do\n            title \u003c- y ^? key \"title\" . _Yaml\n            url   \u003c- y ^? key \"url\"   . _Yaml\n            return $ RSS title url\n    return $ yaml ^.. each . folding y2r\n```\n\n`y2r`は`YamlLight`から独自に定義した`RSS`に変換する関数です。この関数は失敗するかもしれないのですが`folding`を使うことで失敗した値を排除しています\n\n## RSSの選択画面\nselectionViewの中身を実装していきましょう\n\n![](img/selectioinView.png)\n\n```haskell\nselectionView :: [RSS] -\u003e Int -\u003e App RSS\nselectionView rssList selecting = do\n    vty \u003c- ask\n    let header       = string (defAttr `withStyle` underline) \"閲覧するRSSを選択してください\"\n        tableStyle n = if n == selecting then defAttr `withStyle` reverseVideo else defAttr\n        table        = vertCat $ map (\\(rss, n) -\u003e string (tableStyle n) (_title rss)) $ zip rssList [0..]\n        pic          = picForImage $ header \u003c-\u003e table\n    liftIO $ update vty pic\n    e \u003c- liftIO $ nextEvent vty\n    case e of\n        EvKey KEsc        _ -\u003e throwError AppEscape\n        EvKey KEnter      _ -\u003e return $ rssList !! selecting\n        EvKey (KChar 'j') _ -\u003e selectionView rssList (min (length rssList - 1) (selecting + 1))\n        EvKey (KChar 'k') _ -\u003e selectionView rssList (max 0 (selecting - 1))\n        _                   -\u003e selectionView rssList selecting\n```\n\nやっていることは単純で選択されている項目だけ色を反転しているだけです。`j`と`k`を押されると選択が上下に移動します。`Enter`が押されるとその時選択されている値が返却されます。\n\n## ローディング画面\nRSSを取得してくる画面を実装していきましょう\n\n![](img/loadingView.png)\n\nRSSが選択されたらその情報をWebまで取りに行き返ってきたXMLをパースしなければいけません。物によっては時間がかかるのでローディング画面を出してあげたほうが親切だと思います。しかしデータの処理とローディングのアニメーションは直列に書くことはできないので情報を取得してくるところは非同期で書くことにしましょう。XMLのパースには[xml-conduit](https://hackage.haskell.org/package/xml-conduit)を使います\n\n```haskell\nimport Control.Concurrent\nimport Control.Concurrent.MVar\nimport Network.HTTP.Conduit\nimport Text.XML\n\nloadingView :: RSS -\u003e App Document\nloadingView rss = do\n    vty    \u003c- ask\n    result \u003c- liftIO $ newEmptyMVar\n    liftIO . forkIO $ do\n        body \u003c- simpleHttp (_url rss)\n        let doc = parseLBS_ def body\n        putMVar result doc\n    liftIO . ($ 0) . fix $ \\loop n -\u003e do\n        let gauge = string defAttr $ \"Downloading\" ++ take n (repeat '.')\n            pic   = picForImage gauge\n        update vty pic\n        threadDelay 200000\n        doc \u003c- tryTakeMVar result\n        case doc of\n            Nothing  -\u003e loop (n+1)\n            Just doc -\u003e return doc\n```\n\n`result`に値が入るまで`.`の個数を増やしつつループしているだけですね\n\n##記事の選択画面\n![](img/rssfeedView.png)\n\n\"RSSの選択画面\"と処理はほとんど同じです。違うのはデータが入っているのが`Document`型の値なのでパースする必要があるところです。XMLのパースには[xml-lens](https://hackage.haskell.org/package/xml-lens)を使います\n\n```haskell\nimport Data.Text.Lens\nimport Text.XML.Lens\nimport qualified Text.XML.Lens as XML\n\nrssfeedView :: Document -\u003e Int -\u003e App RSSFeedViewAction\nrssfeedView doc selecting = do\n    vty \u003c- ask\n    let title = maybe \"no title\" id $ doc ^? root ./ el \"channel\" ./ el \"title\" . XML.text . unpacked\n        items = doc ^.. root ./ el \"channel\" ./ el \"item\" ./ el \"title\" . XML.text .unpacked\n        header    = string (defAttr `withStyle` underline) $ title\n        tableStyle n  = if n == selecting then defAttr `withStyle` reverseVideo else defAttr\n        table = vertCat $ map (\\(item, n) -\u003e string (tableStyle n) item) $ zip items [0..]\n        pic       = picForImage $ header \u003c-\u003e table\n    liftIO $ update vty pic\n    e \u003c- liftIO $ nextEvent vty\n    case e of\n        EvKey KEsc        _ -\u003e return RSSFeedViewBack\n        EvKey KEnter      _ -\u003e do\n            let url = (!! selecting) $ doc ^.. root ./ el \"channel\" ./ el \"item\" ./ el \"link\" . XML.text . unpacked\n            return $ RSSFeedViewPreview url\n        EvKey (KChar 'j') _ -\u003e rssfeedView doc (min (length items - 1) (selecting + 1))\n        EvKey (KChar 'k') _ -\u003e rssfeedView doc (max 0 (selecting - 1))\n        _                   -\u003e rssfeedView doc selecting\n```\n\nこの実装はよく考えると毎フレームXMLをパースすることになるので少し効率が悪いですね！予め必要な情報だけ持ったデータ構造に変換してやればもう少し動作が早くなりそうです（今のままでも十分速いですが）\n\n## 閲覧画面とまとめ\n\n```haskell\nimport System.Process\n\npreviewView :: String -\u003e App ()\npreviewView url = do\n    liftIO $ createProcess $ shell $ \"open \" ++ url\n    return ()\n```\n\n最後は閲覧画面です。が、ターミナル上でWebページを表示するのはかなり大変そうなので外部のアプリに委譲することにします。\n\n以上が簡単な解説です。完成したコードは110行しかなくずいぶん簡潔に書くことができました。完成したコードは[ここ](https://github.com/lotz84/cli-rss-reader/blob/master/Main.hs)から見ることができます。質問＆コメントあれば[issue](https://github.com/lotz84/cli-rss-reader/issues)までお願いします。Starもお願いします！\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flotz84%2Fcli-rss-reader","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flotz84%2Fcli-rss-reader","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flotz84%2Fcli-rss-reader/lists"}