【Java】RomeでRSSリーダーAPIを簡単に実装する

## まえがき
・RSSリーダーって何？という方は [Wikipedia:フィードリーダー](https://ja.wikipedia.org/wiki/%E3%83%95%E3%82%A3%E3%83%BC%E3%83%89%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC) をご参考ください。
・あまり流行してないですが、作る機会があったので載せます。
・RSSリーダーはあまり処理を行わず戻りのオブジェクトをJSON形式で出力、 JS側で整形する方式を想定。
・JSで直接他ドメインのRSSを読もうとすると [CORS](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS) のエラーが出てできないので、サーバー側でRSSリーダーの処理をしています。
・[Caffeine](https://qiita.com/a__i__r/items/44e9b0aa934de26ba5bd) 利用のキャッシュ有り
・JSで呼び出す方法も記載

## 環境
・Windows10 64bit
・Spring Framework 4
・Rome 1.15.0

## Rome とは
https://github.com/rometools/rome
> Rome is a Java framework for RSS and Atom feeds. The framework consist of several modules

Java の RSS, Atom フィード用ライブラリですね。

テストしてみたところ、国内の有名どころのRSSフィードは対応できているようです。
内容によってはエラーが出るものもありますが、特定のブログであり、書き方でカバーできそうです。

## API (json)
```RssApiController.java
@RestController
public class RssApiController {
	
	private static Logger logger = LoggerFactory.getLogger(RssApiController.class);
	private static @NonNull Cache<String, Object> rssReaderCache = null;

	public static final String TYPE_ALL = "ALL";
	public static final String TYPE_ENTRY = "ENTRY";
	
	// キャッシュ設定（お好みで）
	@PostConstruct
	public void initRssReaderCache() {
		rssReaderCache =
				Caffeine.newBuilder()
				.maximumSize(256)
				.expireAfterWrite(30, TimeUnit.MINUTES)
				.build();
	}
	
	/**
	 * RSS リーダーAPI
	 * @param q        RSS の URL
	 * @param type     出力タイプ（ALL:すべて, ENTRY:エントリのみ）
	 * @param limit    出力するエントリ件数
	 * @param request
	 * @param response
	 * @return
	 */
	@RequestMapping(value = "/api/rssReader/", 
			produces = MediaType.APPLICATION_JSON_UTF8_VALUE, method = RequestMethod.GET)
	public Object rssReader(@RequestParam(required=true) String q,
			@RequestParam(required=false) String type,
			@RequestParam(required=false) Integer limit,
			HttpServletRequest request, HttpServletResponse response) {
		
		if (StringUtils.isBlank(q)) {
			return new ErrorJson("URL が不正です。");
		}
		if (StringUtils.isBlank(type)) {
			type = TYPE_ALL;
		}
		if (!TYPE_ALL.equalsIgnoreCase(type)) {
			type = TYPE_ENTRY;
		}
		Object result = null;
		try {
			result = readCachedRss(q, type, limit);
		} catch (Exception e) {
			StringWriter sw = new StringWriter();
			e.printStackTrace(new PrintWriter(sw));
			logger.error(sw.toString());
			return new ErrorJson("RSS読み込みに失敗しました。");
		}
		return result;
	}
	
	private Object readCachedRss(final String q, final String type, final Integer limit)
			throws Exception {

		Object result = null;
		String strLimit = limit == null ? "" : limit.toString();
		final String[] params = { q, type, strLimit };
		final String cacheKey = String.join("-", params);
		try {
			result = rssReaderCache.get(cacheKey, key -> {
				try {
					return readRss(q, type, limit);
				} catch (Exception e) {
					throw new RuntimeException(e);
				}
			});
		} catch (Exception e) {
			throw e;
		}
		return result;
	}

	private Object readRss(String q, String type, Integer limit) throws Exception {
		URL url = null;
		try {
			url = new URL(q);
		} catch (MalformedURLException e) {
			throw e;
		}
		XmlReader reader = null;
		Object result = null;
		try {
			reader = new XmlReader(url);
			SyndFeed feeder = new SyndFeedInput().build(reader);
			List<SyndEntry> entryList = feeder.getEntries();
			if (limit != null && entryList != null && entryList.size() > limit) {
				feeder.setEntries(entryList.stream().limit(limit).collect(Collectors.toList()));
			}
			if (Objects.equal(TYPE_ENTRY, type)) {
				result = feeder.getEntries();
			} else {
				result = feeder;
			}
		} catch (Exception e) {
			throw e;
		} finally {
			if (reader != null) {
				try {
					reader.close();
				} catch (IOException e) {
					throw e;
				}
			}
		}
		return result;
	}
}

```

Romeのお陰で簡単にできますね。
ちなみに ErrorJson は単なるエラー用のクラスです。

```ErrorJson.java
public class ErrorJson {
	private String error;

	// 何らかのオブジェクトを付録として返したい場合に使う
	private Object appendix;

	public ErrorJson(String error) {
		this.error = error;
	}

	public ErrorJson(String error, Object appendix) {
		this.error = error;
		this.appendix = appendix;
	}

	public void setError(String error) {
		this.error = error;
	}

	public String getError() {
		return error;
	}

	public Object getAppendix() {
		return appendix;
	}

	public void setAppendix(Object appendix) {
		this.appendix = appendix;
	}
}
```

## Javascript（API呼び出し～整形）
```javascript
  <script>
    // HTMLから画像のURLを抽出する
    function extractImgSrcFromHtml(str) {

      var tmpDiv = document.createElement('div');
      tmpDiv.innerHTML = str;
      var imgs = tmpDiv.querySelectorAll('img');
      var result = [];
      for(var i=0;i < imgs.length;i++) {
        result.push(imgs[i].getAttribute('src'));
      }
      return result;
    }

    $(function(){
      // rss 処理 (最新１件を整形表示）
      $.ajax({
        url: '/your/api/rssReader/',
        type: 'GET',
        data: { 'q': '${rssUrl}', 'type': 'entry', 'limit': 1 },
        dataType: "json",
      })
      .done( function(data, textStatus, jqXHR) {
        if (data.error) {
          // エラー処理
          console.log(data.error);
          $('.rss_block').hide();    // rss 出力枠ごと非表示
          return;
        }
        if (data && data.length > 0) {
          // テンプレート
          var tmpl =
          '<div class="title">{title}</div>\
            <dl class="list">\
            <dt><img src="{teaserImageUrl}" alt="{title}" style="">\
            </dt>\
            <dd>{bodyPlain}\
              {externalUrlBlock}\
            </dd>\
            </dl>\
          </div>\
          ';
          var externalUrl = "${blogUrl}";
          var externalUrlBlock = '';
          if (externalUrl && externalUrl.length > 0) {
            externalUrlBlock = '<div><a href="${blogUrl}" target="_blank">ブログを見る</a></div>';
          }
          $.each(data, function(index, v){
            var title = "";
            if (v.title) title = v.title;
            if (!v.description || !v.description.value) return true;
            // タグを除去した内容を取得
            var bodyPlain = v.description.value.replace("<![CDATA[", "").replace("]]>", "");
            bodyPlain = bodyPlain.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'');
            // 長い場合は以下略で
            if (bodyPlain && bodyPlain.length > 200) {
              bodyPlain = bodyPlain.substr(0,200) + "...";
            }
            // 画像のURLを抽出
            var imgSrcs = extractImgSrcFromHtml(v.description.value);
            var imgUrl = "/image/no-img.png";   // 画像がない場合に表示する画像
            if (imgSrcs && imgSrcs.length > 0 && imgSrcs[ 0 ]) {
              imgUrl = imgSrcs[ 0 ];
            }
            // description にない場合でも contents にある場合がある
            else if (v.contents && v.contents.length > 0 && v.contents[0].value) {
              imgSrcs = extractImgSrcFromHtml(v.contents[0].value);
              if (imgSrcs && imgSrcs.length > 0 && imgSrcs[ 0 ]) {
                imgUrl = imgSrcs[ 0 ];
              }
            }
            // テンプレートを置換
            var replaced = tmpl.replace(/\{title\}/g, title);
            replaced = replaced.replace(/\{bodyPlain\}/g, bodyPlain);
            replaced = replaced.replace(/\{teaserImageUrl\}/g, imgUrl);
            replaced = replaced.replace(/\{externalUrlBlock\}/g, externalUrlBlock);
            var rssFeeds = document.getElementById('rss-feeds');
            rssFeeds.insertAdjacentHTML('beforeend', replaced);
          });
        }
        else {
          $('.rss_block').hide();
        }
      })
      .fail( function(data, textStatus, jqXHR) {
        $('.rss_block').hide();
        console.log("rssReader failed.");
      });
    });
  </script>
```


以上、お疲れさまでした！