SwiftでシンプルなRSSリーダーを作る(Yahooニュース編)

# SwiftでシンプルなRSSリーダーを作る

暇だったのでYahooニュースの記事を閲覧できるRSS[^1]リーダーを作ってみました。
コミットの単位とかめちゃくちゃですが一応githubにコードを載せてます。
https://github.com/kawano108/YahooNewsMirror
作ってみてわかりましたが、RSSリーダーの作成はSwift初心者の学習に有効だと思います。
いろんなサイトがRSSを公開していますし、URLを叩いて情報を取ってきて加工してtableViewに表示するというiOSの基本が学べます。
[^1]: RSSとは「Really Simple Syndication」の略語であり、ニュースや記事のタイトル、リンクなどの内容を要約して配信する仕組みのことです。RSSは記事取得用のurlが用意されており、そのurlを叩くとXML形式で記事の内容を返してくれます。

## 要件
* RSSを取得して「タイトル」「日付」「サムネイル画像」の３つをtableViewに表示する。
* 記事をタッチして該当のニュースをwebViewで表示する
* タブメニューで記事の種類を選択できるようにする


完成イメージ
<img width="350" alt="スクリーンショット 2019-11-23 15.50.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/4e38c99a-6145-98fd-f911-6b6f480952cf.png">  <img width="350" alt="スクリーンショット 2019-11-23 15.56.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/ab0ffe60-8c37-0ab2-2fae-d05500ec0dba.png">


## 開発環境
###エディタ・言語など
* Xcode11.1
* Swift5.1
* cocoaPods1.5.3

### 使用ライブラリ
* [XLPagerTabStrip](https://github.com/xmartlabs/XLPagerTabStrip)
  * ページメニュー実装用
* [SDWebImage](https://github.com/SDWebImage/SDWebImage)
  * 画像取得＋キャッシュ用
* [HTMLReader](https://github.com/nolanw/HTMLReader)
  * サムネイル画像取得用

## RSSから記事を取得する
まずRSSから記事を取得してtableViewに渡す処理を作っていきます。
今回使用するのはYahoo!ニュースのRSSです。
https://headlines.yahoo.co.jp/rss/list

RSSはXML形式のレスポンスを返します。
XMLの扱うために以下２点の方法を考えました。
* 1.Swift標準の[XMLParser](https://developer.apple.com/documentation/foundation/xmlparser)を使用する。
* 2.XMLをJsonに変換し、JsonをCodableでモデル化して使用する。

それぞれメリットデメリットはありますが、Jsonの方が色々とこねくり回しやすいので、今回は２番目の方法で実装します。

### XMLをJsonに変換する
XMLからJsonへの変換はAPIを通して行いました。
以下のサイトがとても便利でした。
[rss to json]https://rss2json.com/#rss_url=http%3A%2F%2Ffeeds.twit.tv%2Fbrickhouse.xml

rssToJsonでは、RSSのurlを入力するだけでJsonに変換したレスポンスを表示し、Json取得用のAPIを作成してくれます。

|![rsstojson](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/8fc5b4fb-2822-3057-1737-b8c3f2df4820.png)|
|:-:|

APIを用意できたら記事を取得する準備をしていきましょう。
やることは以下の2つです。
* レスポンスを変換するモデルを作成する。
* APIを叩いて記事を取得する。

### レスポンス変換用のモデルを用意する
APIのレスポンス変換用にモデルを作成します。

まずは元のJsonの構造を理解しましょう。

```Json
{
    "status": "ok",
    "feed": {
        "url": "https://news.yahoo.co.jp/pickup/rss.xml",
        "title": "Yahoo!ニュース・トピックス - 主要",
        "link": "https://news.yahoo.co.jp/",
        "author": "",
        "description": "Yahoo! JAPANのニュース・トピックスで取り上げている最新の見出しを提供しています。",
        "image": ""
    },
    "items": [
        {
            "title": "官邸「譲らない」GSOMIA折衝",
            "pubDate": "2019-11-23 02:28:19",
            "link": "https://news.yahoo.co.jp/pickup/6343284",
            "guid": "yahoo/news/topics/6343284",
            "author": "",
            "thumbnail": "",
            "description": "",
            "content": "",
            "enclosure": {
                "link": "https://s.yimg.jp/images/icon/photo.gif",
                "type": "image/gif",
                "length": 133
            },
            "categories": []
        },

```
この構造を踏まえてモデルを作成します。
以下のようになりました。

```Swift
/// RSSから取得する記事リスト
struct ArticleList: Codable {
    let status: String
    let feed: Feed
    let items: [Item]
}
/// フィード
struct Feed: Codable {
    let url: String
    let title: String
    let link: String
    let author: String
    let description: String
}
/// 記事詳細
struct Item: Codable {
    let title: String
    let pubDate: String
    let link: String
    let guid: String
}
```
使わないデータや空のデータはモデルに含めなくても問題ありません。
これでレスポンス変換用のモデルができたので、APIを叩いて記事のJsonを取ってきましょう。

### APIを叩いて記事を取得する
引数にURLを渡すとそのURLから記事を取得する関数を作ります。

```Swift
/// RSS取得用クラス
class RssClient {

    /// 記事の一覧を取得します。
    /// - Parameter urlString: 取得元RSSのurl
    /// - Parameter completion: 完了時の処理
    static func fetchItems(urlString: String, completion: @escaping (Result<[Item], Error>) -> ()) {
        
         // URL型に変換できない文字列の場合は弾く
        guard let url = URL(string: urlString) else {
            completion(.failure(NetworkError.invalidURL))
            return
        }
        
        let task = URLSession.shared.dataTask(with: url, completionHandler: { data, response, error in
            
            if let error = error {
                completion(.failure(error))
                return
            }

            guard let data = data else {
                completion(.failure(NetworkError.unknown))
                return
            }
            
            let decoder = JSONDecoder()
            guard let articleList = try?decoder.decode(ArticleList.self, from: data) else {
                completion(.failure(NetworkError.invalidResponse))
                return
            }
            completion(.success(articleList.items))
        })
        task.resume()
    }
}
```

上記のコードでは３つのことをやっています。

* APIのURLをString型からURL型に変換する。
* URLSession.shared.dataTaskに渡して結果を受け取る。
* 受け取った結果をJson型からArticleList型に変換する

ポイントは関数のcompletionクロージャにResult型の引数を使っていることです。

```Swift
completion: @escaping (Result<[Item], Error>) -> ()
```
こいつを引数に渡してあげると、Result型のcase次第で成功と失敗を同じクロージャで使い分けることができます。

```Swift
/// A value that represents either a success or a failure, including an
/// associated value in each case.
public enum Result<Success, Failure> where Failure : Error {

    /// A success, storing a `Success` value.
    case success(Success)

    /// A failure, storing a `Failure` value.
    case failure(Failure)
```
成功ならsuccessで、失敗ならfailureでcompletionを実行し、
実行時の引数には記事取得結果である`Items`型と、エラーが起きたときの`Error`型を渡してやります。

```Swift

// 成功
completion(.success(articleList.items))

// 失敗
if let error = error {
    completion(.failure(error))
    return
}
```

これでTableViewに記事を表示する準備ができました。

### 取得した記事をTableViewに表示する

関数を用意できたのでTableViewを表示するクラス側で呼び出してあげます。

```Swift
/// ホーム画面
class NewsViewController: UITableViewController, IndicatorInfoProvider {

    /// 記事一覧
    private var items: [Item] = [] {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        RssClient.fetchItems(urlString: self.newsType.urlStr, completion: { (response) in
            switch response {
            case .success(let items):
                DispatchQueue.main.async() { [weak self] in
                    guard let me = self else { return }
                    me.items = items
                }
            case .failure(let err):
                print("記事の取得に失敗しました: reason(\(err))")
            }
        })
    }
```

`viewWillAppear`で記事を取得し、記事一覧のプロパティに記事をセットしたらtableViewのリロードが走るようにしています。

```Swift
/// 記事一覧
private var items: [Item] = [] {
    didSet {
        tableView.reloadData()
    }
}
```
Result形で定義していたcompetionクロージャは、`Result<[Item], Error>`を引数として、成功時の処理と失敗時の処理をswitchで分けて実行することができています。

```Swift
RssClient.fetchItems(urlString: self.newsType.urlStr, completion: { (response) in
    switch response {
    // 成功
    case .success(let items):
        DispatchQueue.main.async() { [weak self] in
            guard let me = self else { return }
                me.items = items
            }
    // 失敗
    case .failure(let err):
        print("記事の取得に失敗しました: reason(\(err))")
    }
})
```

これで取得した記事をTableViewに表示できるようになりました。

## サムネイル画像を取得する
RSSから記事は取得できました。
しかしYahooニュースのRSSにはサムネイル画像用のurlが用意されていません。
そこで記事のHTMLからサムネイル画像のurlを抽出し、そのurlからUIImageを取得してセルに表示することにしました。

やることは以下の2つです。
* 記事のURLからHTMLを取得する。
* サムネイル画像用のURLを抽出する。

### 記事のURLからHTMLを取得する

まずはサムネイルを取得したい記事のURLからHTMLのソースを取得してきます。

```Swift
/// 記事の画像のurlを取得します
/// - サムネイル表示のために用意
/// - Warning: URLから取得先のHTML全部取ってきてサムネだけ抜き出した上で画像のURL返してるから非常に冗長。
///            かつロードにも時間がかかるのでキャッシュに持たせるとかして修正を検討してください。
///
/// - Parameter urlStr: 画像取得先のurl
/// - Parameter completion: 完了後の処理
static func fetchThumnImgUrl(urlStr: String, completion: @escaping (Result<URL, Error>) -> ()) {
    // URL型に変換できない文字列の場合は弾く
    guard let targetURL = URL(string: urlStr) else {
        completion(.failure(NetworkError.invalidURL))
        return
    }
    do {
        // 入力したURLのページから、HTMLのソースを取得します。
        let sourceHTML = try String(contentsOf: targetURL, encoding: String.Encoding.utf8)
    catch {
        completion(.failure(error))
    }
}

```
SwiftのString型は指定したURLからHTMLを取得してエンコードすることができるみたいです。

<img width="990" alt="スクリーンショット 2019-11-23 18.54.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/f6041548-6bec-0eeb-8cc1-a6838c60c906.png">


#### TODO：HTML全部取ってくるのは重いし冗長なのでサムネイルだけ取得するように直したい
今回は記事のHTMLを全て取ってきているんですが、欲しいのはサムネイル画像だけなので冗長な作りになってしまいました。
セルの生成する度に毎度記事のHTMLを全て取得していると、ロードに時間がかかってアプリの操作感が悪くなります。
今回この問題は解決できませんでしたが、部分的に必要な情報だけ取得するように直すのが今後の課題になりました。

### HTMLからサムネイル画像を抽出する

HTMLが取得できたら[HTMLReader](https://github.com/nolanw/HTMLReader)を使ってサムネイル画像だけ抜き出しましょう。


```Swift
import HTMLReader

/// RSS取得用クラス
class RssClient {
    ...

    /// 記事の画像のurlを取得します
    /// - サムネイル表示のために用意
    /// - Warning: URLから取得先のHTML全部取ってきてサムネだけ抜き出した上で画像のURL返してるから非常に冗長。
    ///            かつロードにも時間がかかるのでキャッシュに持たせるとかして修正を検討してください。
    ///
    /// - Parameter urlStr: 画像取得先のurl
    /// - Parameter completion: 完了後の処理
    static func fetchThumnImgUrl(urlStr: String, completion: @escaping (Result<URL, Error>) -> ()) {
        // URL型に変換できない文字列の場合は弾く
        guard let targetURL = URL(string: urlStr) else {
            completion(.failure(NetworkError.invalidURL))
            return
        }
        do {
            // 入力したURLのページから、HTMLのソースを取得します。
            let sourceHTML = try String(contentsOf: targetURL, encoding: String.Encoding.utf8)
            
            let html = HTMLDocument(string: sourceHTML)
            // サムネイルの入ったエレメントを抜き出します。
            let htmlElement = html.firstNode(matchingSelector: "p[class^=\"tpcHeader_thumb_img\"]")
            // エレメントからstyleだけ抽出します。
            guard let style = htmlElement?.attributes["style"] else {
                completion(.failure(AppalicationError.unknown))
                return
            }
            // 無駄な文字列を削除して整形します。
            let imageUrlStr: String = {
                let startIndex = style.index(style.startIndex, offsetBy: 23)
                let endIndex = style.index(style.endIndex, offsetBy: -3)
                return String(style[startIndex..<endIndex])
            }()
            
            guard let imageUrl = URL(string: imageUrlStr) else {
                completion(.failure(NetworkError.invalidURL))
                return
            }
            
            completion(.success(imageUrl))
        }
        catch {
            completion(.failure(error))
        }
    }
}
```

`HTMLReader`はSwift版のHTMLパーサーライブラリであり、HTMLドキュメントから欲しいタグの情報を抽出することができます。
まずStringのHTMLからHTMLドキュメントを作ります。
僕はHTMLもCSSもよく分からないんですが、どうやらCSSSelectorというものを検索できるみたいです。
今回は`tpcHeader_thumb_img`とやらにサムネイルのurlがあったのでそこだけ取得することにしました。

```HTML
<div class="tpcHeader_thumb">
    <p class="tpcHeader_thumb_img" style="background-image: url('https://giwiz-tpc.c.yimg.jp/q/iwiz-tpc/images/tpc/2019/11/19/1574165035_20191113-00000622-san-000-view.jpg');"></p>
</div>
```

該当の箇所↓
HTMLは正しい用語が分からないのでコメントの内容間違ってたりしたらすみませんmm

```Swift
// 文字列のHTMLからHTMLDocumentを生成します。(ここからHTMLReaderの機能)
let html = HTMLDocument(string: sourceHTML)
// サムネイルの入ったエレメントを抜き出します。
let htmlElement = html.firstNode(matchingSelector: "p[class^=\"tpcHeader_thumb_img\"]")
// エレメントからstyleだけ抽出します。
guard let style = htmlElement?.attributes["style"] else {
    completion(.failure(AppalicationError.unknown))
    return
}
```
これでサムネイル画像取得用の関数が用意できました。
そしたらこの画像をTableViewに表示していきましょう。

### 取得したサムネイル画像をTableViewに表示する

サムネイルの取得に成功したらSDWebImageを使ってURLから画像をロードします。

```Swift
RssClient.fetchThumnImgUrl(urlStr: link, completion: { response in
    switch response {
    case .success(let url):
        SDWebImageManager.shared.loadImage(with: url,
                                           options: .progressiveLoad,
                                           context: nil,
                                           progress: nil,
                                           completed: { (image, data, error, cache, finished, url) in
                                            articleCell.articleImage.image = image
        })
    case .failure(let err):
        print("HTMLの取得に失敗しました: reason(\(err))")
    }
})
```
 [SDWebImage](https://github.com/SDWebImage/SDWebImage)は画像の取得、キャッシュの保存、キャッシュからの画像の読み出し、UIImageへのセットなどをよしなにやってくれるライブラリです。
`SDWebImageManager.shared.loadImage`を使って画像をDLすると指定したURLから画像を取得、キャッシュに保存してくれます。キャッシュに既にデータがあれば次回DL時はキャッシュから画像を呼び出しくれるという神仕様です。

ここまででRSSから「タイトル」「日付」「サムネイル」を取得し、セルに表示できるようになりました。

### 記事をタッチして該当のニュースをwebViewで表示する

今度はセルをタッチして記事の詳細を表示します。
RSSでは各記事の本文まで取得できないので、こっちはWebViewを使って作ることにしました。

まずはWebKitをインポートし、WebView表示用のクラスを作ります。

```Swift
import UIKit
import WebKit

class DetailWebViewController: UIViewController {
    
    // MARK: Properties

    /// 記事表示用webView
    private let wkWebView = WKWebView()
    /// 読み込むURL
    private var urlStr: String?
    
    // MARK: Initializer

    init(urlStr: String) {
        self.urlStr = urlStr
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: LifeCycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // TODO: WebView適当すぎるのでプログレスバーとか戻るボタンとか付けたい
        wkWebView.frame = view.frame
        wkWebView.navigationDelegate = self
        wkWebView.uiDelegate = self
        wkWebView.allowsBackForwardNavigationGestures = true
        let url = URLRequest(url: URL(string: urlStr!)!)
        wkWebView.load(url)
        view.addSubview(wkWebView)
    }
}

extension DetailWebViewController: WKNavigationDelegate {
    
}

extension DetailWebViewController: WKUIDelegate {
    
}
```

ViewControllerのViewと同じ広さのWebViewのインスタンスを作成し、URLを渡して読み込んでもらうだけのシンプルなクラスです。
(プチTODO：ちょっとシンプルすぎるのでプログレスビューとか戻るボタンとか今後付けたい）

WebView表示用クラスができたら、セルのタッチで画面遷移するように実装します。

```Swift

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let link = items[indexPath.row].link
    let vc = DetailWebViewController(urlStr: link)
    self.navigationController?.pushViewController(vc, animated: true)
}
```

これで記事をWebViewで閲覧できるようになりました。

## タブメニューで記事の種類を選択できるようにする
本家Yahooニュースしかりグノシーしかり、ニュースアプリ大体画面上部にタブメニューを持っており、タブの切り替えでニュースの分類を切り替えるように作られています。
というわけでトピックの数だけタブメニューを作成するように実装していきます。

↓ここの部分
<img width="378" alt="スクリーンショット 2019-12-01 17.16.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311282/5d2ca77e-18f7-bfaa-8cbb-4bf86025f595.png">

タブメニューを作成できるライブラリはいくつかありますが、今回は[XLPagerTabStrip](https://github.com/xmartlabs/XLPagerTabStrip)というライブラリを採用してみました。

* VCを渡すだけでいいから簡単
* ライブラリをインポートしたクラスからnavigationBarにアクセスできる。(他のライブラリはできないやつもあるらしい)

この辺りが採用の決め手です。


それではタブメニューを作っていきます。
やることは以下の３つです。

* XLPagerTabStripをインストール
* タブメニュー実装用親クラスを作成する
* タブメニューの中身になる子VCを作成する

### XLPagerTabStripをインストール

CocoaPods、もしくはCarthageを使ってインポートできます。
やり方は本家githubから確認できます。
https://github.com/xmartlabs/XLPagerTabStrip#cocoapods

### タブメニュー表示用の親クラスを作る

インストールが完了したら、タブメニューを表示するための親クラスを作っていきます。
今回はstoryboardを使いました。

タブメニューを実装する場合、親クラスのstoryboardに以下の２点を持つ必要があります。

* タブメニューの置き場となるCollectionView
* 各ページのVCが配置されるScrollView

各パーツの設定は詳しく説明してくださってる記事があるのでそちらを参考にしてください。(断じて書くのがめんどくさいわけではない。)
[XLPagerTabStripの使い方とカスタマイズ
](https://qiita.com/KikurageChan/items/35593dc3aac8d694db8e)

storyboardが用意できたら、今度はコードで必要な設定を書いていきます。
難しいことはなかったです。ポイントは３つあります。

* ButtonBarPagerTabStripViewControllerを継承したクラスを作る
* viewControllersメソッドをoverrideしてその返り値にメニューに詰め込みたいViewControllerを渡してあげる
* `super.viewDidLoad()`より上にタブメニューのレイアウト設定を書く

実際に書くとこんな感じになります。

```Swift
import XLPagerTabStrip

/// ページメニュー用ViewController
class PageMenuViewController: ButtonBarPagerTabStripViewController {
    
    // MARK: LifeCycle

    override func viewDidLoad() {
        setButtonBar()
        super.viewDidLoad()
        navigationItem.title = "Yahoo!ニュース"
    }
    
    /// メニューに表示するVCを渡す
    override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] {
        return createNewsViewController()
    }
    
    // MARK: Private Function

    /// タブメニューのレイアウトを設定します
    /// - Warning: 必ずsuper.viewDidLoad()の上で呼び出してください。
    private func setButtonBar() {
        settings.style.buttonBarBackgroundColor = .clear
        settings.style.selectedBarBackgroundColor = .orange
        settings.style.buttonBarMinimumLineSpacing = 2
    }
}
```

### タブメニューの中身になる子VCを作成する

今度はメニューに表示する子VCを作ります。
子VC側はstoryboardやxibの設定は必要ありません。
基本的には上で実装したクラスにVCを渡すだけなんですが、２点やることがあるので説明します。

* `IndicatorInfoProvider`プロトコルにVCを適合させる
* `indicatorInfo`メソッドにタブメニューの情報を追加する

```Swift
import XLPagerTabStrip

/// ホーム画面
class NewsViewController: UITableViewController, IndicatorInfoProvider {

    ...

    /// タブメニュー編集用インスタンス
    private var itemInfo = IndicatorInfo(title: "タブ名")

    // MARK: - IndicatorInfoProvider
    
    func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo {
        return itemInfo
    }
}
```

`IndicatorInfoProvider`プロトコルに準拠すると`indicatorInfo`メソッドの実装を要求されます。
この`indicatorInfo`に渡すのがタブメニューのタイトルや各トピックのごとにメニューに表示したい画像などのタブメニューの情報です。

IndicatorInfoのstructを見てみるとどんな設定値が用意されてるか分かります。

```Swift
public struct IndicatorInfo {

    public var title: String?
    public var image: UIImage?
    public var highlightedImage: UIImage?
    public var accessibilityLabel: String?
    public var userInfo: Any?

    ...
```


## まとめ

ここまでの紹介した手順でRSSリーダーというか、Yahooニュースもどきができました。
コミットの単位とかめちゃくちゃですが一応githubにコードを載せてます。
https://github.com/kawano108/YahooNewsMirror

RSSから記事を取ってきて表示するだけの予定でしたが、サムネイルのURLがレスポンスに入ってなかったりと結構てこずりました。
けどHTMLから取得した情報をSwiftで使う方法がわかったので良かったです。
YahooニュースのRSSは結構スカスカだったんですが、はてなのRSSは充実してると後から知りました。
今度ははてなのRSSを使って何か作ってみたいです。