はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Unity】ライブドアニュースからニュースのタイトル・本文・画像を取得してみる

はじめに

今回はライブドアニュースからニュースのタイトル・本文・画像を取得してみる記事になります!

とある用事でこのスクリプトを書いたので、ついでにブログにも載せちゃおうと思いました。

ただこれはAPIを使っているのではなくスクレイピングをしているので、ライブドアニュースの利用規約等を各自要確認してください

私は一切の責任を負えませんので…。

では早速やっていきましょう。

コード

NewsReader.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;

namespace Hanachiru
{
    public static class NewsReader
    {
        private static readonly string LIVEDOOR_NEWS_URL = "https://news.livedoor.com/{0}";
        private static readonly string SEARCH_URL = "search/article/?ie=euc-jp&word={0}";

        private const string SPACE_EUC_JP = "%A1%A1";

        //読み込む記事の数の上限
        private const int LOAD_ARTICLES_MAX_NUMBER = 10;

        /// <summary>
        /// ニュースを検索して指定した数の記事を読み込む
        /// </summary>
        /// <param name="keywords">キーワード</param>
        /// <returns>記事達の情報(タイトル・詳細記事のURL)</returns>
        public static NewsData[] ReadNews(IReadOnlyCollection<string> keywords = null)
        {
            var url = KeywordsToLivedoorURL(keywords);

            var task = Task.Run(() =>
            {
                return NewsTools.HttpRequest(url);
            });

            if (keywords == null)
            {
                //LIVEDOORのホーム画面(Home)から今日のニュースの一覧を取得
                string[] predetailURLs = ExtractPredetailURLsFromHomeText(task.Result);

                return predetailURLs.Select(u =>
                {
                    var task2 = Task.Run(() =>
                    {
                        return NewsTools.HttpRequest(u);
                    });
                    return PredetailTextToNewsData(task2.Result);
                })
                .ToArray();
            }
            else
            {
                //LIVEDOORの検索した画面(Serched)から記事の一覧を取得
                return SerchedTextToNewsData(task.Result);
            }

        }

        /// <summary>
        /// ニュースの詳細を読み込む
        /// </summary>
        /// <param name="newsData">ニュースの情報(タイトル・詳細のURL)</param>
        /// <returns>詳細(本文・画像)</returns>
        public static NewsDetailData ReadNewsDetail(NewsData newsData)
        {
            var task3 = Task.Run(() =>
            {
                return NewsTools.HttpRequest(newsData.DetailURL);
            });

            return DetailTextToDetailData(task3.Result);
        }

        /// <summary>
        /// keywordのコレクションからLIVEDOOR用URLに変換
        /// </summary>
        private static string KeywordsToLivedoorURL(IReadOnlyCollection<string> keywords)
        {
            if (keywords == null) return String.Format(LIVEDOOR_NEWS_URL, "");

            string serchText = "";
            bool isFirst = true;

            foreach (var keyword in keywords)
            {
                if (isFirst)
                {
                    serchText += NewsTools.EncodeUrl(keyword, CharacterCode.EUC_JP);
                    isFirst = false;
                    continue;
                }
                serchText += SPACE_EUC_JP + NewsTools.EncodeUrl(keyword, CharacterCode.EUC_JP);
            }

            return String.Format(LIVEDOOR_NEWS_URL, String.Format(SEARCH_URL, serchText));

        }

        /// <summary>
        /// LIVEDOORのホーム画面(Home)から記事の概要のURL(Predetail)を複数抜き出す
        /// </summary>
        private static string[] ExtractPredetailURLsFromHomeText(string homeText)
        {
            if (string.IsNullOrEmpty(homeText))
            {
                Debug.LogWarning("ライブドアニュースの検索に失敗しました。リクエスト時のURLが正しくない可能性があります。");
                return null;
            }

            string text = NewsTools.ExtractCharacter(homeText, "<ul class=\"subStraightList\">(.|\n)*?</ul>");
            string[] texts = NewsTools.ExtractCharacters(text, "\"https://news.livedoor.com/topics/detail/[^>]*?clicked=straight_news\"");
            return texts.Select(t => NewsTools.RemoveCharacter(t, "\""))
                .ToArray();
        }

        /// <summary>
        /// ニュースの概要(Predetail)からニュースの情報(タイトル・詳細のURL)を取得
        /// </summary>
        private static NewsData PredetailTextToNewsData(string predetailText)
        {
            if (string.IsNullOrEmpty(predetailText))
            {
                Debug.LogWarning("ライブドアニュースの検索に失敗しました。リクエスト時のURLが正しくない可能性があります。");
                return null;
            }

            var title = NewsTools.ExtractCharacter(predetailText, "<title>(.|\n)*?</title>");
            title = NewsTools.RemoveCharacter(title, "<title>");
            title = NewsTools.RemoveCharacter(title, "</title>");
            title = NewsTools.RemoveCharacter(title, "\\(\\d*?年\\d*?月\\d*?日掲載\\) - ライブドアニュース");

            var text = NewsTools.ExtractCharacter(predetailText, "<div class=\"articleMore\">(.|\n)*?</div>");

            var url = NewsTools.ExtractCharacter(text, "\"https://news.livedoor.com/article/detail/[^>]*?/\"");
            url = NewsTools.RemoveCharacter(url, "\"");

            return new NewsData(title, url);
        }

        /// <summary>
        /// 検索された記事(Serched)からニュースの情報(タイトル・詳細のURL)を複数取得
        /// </summary>
        private static NewsData[] SerchedTextToNewsData(string serchedText)
        {
            if (string.IsNullOrEmpty(serchedText))
            {
                Debug.LogWarning("ライブドアニュースの検索に失敗しました。リクエスト時のURLが正しくない可能性があります。");
                return null;
            }

            var text = NewsTools.ExtractCharacter(serchedText, "<ul class=\"articleList\">(.|\n)*?</ul>");

            return NewsTools.ExtractCharacters(text, "<li class=\"hasImg\">(.|\n)*?</li>")
                .Take(LOAD_ARTICLES_MAX_NUMBER)
                .Select(t =>
                {
                    string title = NewsTools.ExtractCharacter(t, "<h3 class=\"articleListTtl\">[^>]*?</h3>");
                    title = NewsTools.RemoveCharacter(title, "<h3 class=\"articleListTtl\">");
                    title = NewsTools.RemoveCharacter(title, "</h3>");
                    string detailUrl = NewsTools.ExtractCharacter(t, "\"https://news.livedoor.com/article/detail/[^>]*?/\"");
                    detailUrl = NewsTools.RemoveCharacter(detailUrl, "\"");
                    return new NewsData(title, detailUrl);
                })
                .ToArray();
        }

        /// <summary>
        /// LIVEDOORの詳細の記事(Detail)から必要な詳細のデータ(NewsDetailData)を取得
        /// </summary>
        private static NewsDetailData DetailTextToDetailData(string detailText)
        {
            if (string.IsNullOrEmpty(detailText))
            {
                Debug.LogWarning("ライブドアニュースの検索に失敗しました。リクエスト時のURLが正しくない可能性があります。");
                return null;
            }

            var imageURL = NewsTools.ExtractCharacter(detailText, "<meta property=\"og:image\" content=\"https://image.news.livedoor.com/newsimage/[^>]*?\">");
            imageURL = NewsTools.ExtractCharacter(imageURL, "\"https://image.news.livedoor.com/newsimage/[^>]*?\"");
            imageURL = NewsTools.RemoveCharacter(imageURL, "\"");

            var text = NewsTools.ExtractCharacter(detailText, "<span itemprop=\"articleBody\">(.|\n)*?</span>");
            text = NewsTools.RemoveCharacter(text, "<span itemprop=\"articleBody\">");
            text = NewsTools.RemoveCharacter(text, "</span>");
            text = NewsTools.RemoveCharacter(text, "<[^>]*?>");         //タグを削除

            return new NewsDetailData(text, imageURL);
        }

        /// <summary>
        /// 一覧表示するための記事の情報
        /// </summary>
        public class NewsData
        {
            public string Title { get; }
            public string DetailURL { get; }

            public NewsData(string title, string url)
            {
                Title = title;
                DetailURL = url;
            }
        }

        /// <summary>
        /// 詳細を表示するための特定の記事から抜き出す情報
        /// </summary>
        public class NewsDetailData
        {
            public string Detail { get; }
            public string ImageURL { get; }

            public NewsDetailData(string detail, string imageURL)
            {
                Detail = detail;
                ImageURL = imageURL;
            }
        }
    }
}
NewsTools.cs
using System;
using System.Text.RegularExpressions;
using System.Net.Http;
using System.Threading.Tasks;
using System.Text;
using UnityEngine;
using System.Linq;

namespace Hanachiru
{
    public static class NewsTools
    {
        public static readonly HttpClient _httpClient = new HttpClient();

        /// <summary>
        /// HTTPリクエストを送る
        /// </summary>
        /// <param name="url">URL</param>
        /// <returns>レスポンス</returns>
        public static async Task<string> HttpRequest(string url)
        {
            try
            {
                var response = await _httpClient.GetStringAsync(url);
                return response;
            }
            catch(Exception e)
            {
                Debug.LogWarning(e);
                return null;
            }
        }

        /// <summary>
        /// テキストをURLエンコードする
        /// </summary>
        public static string EncodeUrl(string text,CharacterCode characterCode)
        {
            if (string.IsNullOrEmpty(text)) return "";

            if (characterCode == CharacterCode.UTF_8) return Uri.EscapeDataString(text);

            if (characterCode == CharacterCode.EUC_JP) return UrlUtils.EscapeDataString(text, Encoding.GetEncoding("euc-jp"));

            return null;
        }

        /// <summary>
        /// テキストから正規表現を用いて文字列を抽出する
        /// </summary>
        /// <param name="text">テキスト</param>
        /// <param name="pattern">正規表現</param>
        public static string ExtractCharacter(string text,string pattern)
        {
            if (string.IsNullOrEmpty(text)) return "";
            if (string.IsNullOrEmpty(pattern)) return text;

            return Regex.Match(text, pattern).Value;
        }

        /// <summary>
        /// テキストから正規表現を用いて文字列を全て抽出する
        /// </summary>
        /// <param name="text">テキスト</param>
        /// <param name="pattern">正規表現</param>
        public static string[] ExtractCharacters(string text, string pattern)
        {
            if (string.IsNullOrEmpty(text)) return null;
            if (string.IsNullOrEmpty(pattern)) return new string[]{ text };

            return Regex.Matches(text, pattern)
                .Cast<Match>()
                .Select(m => m.Value)
                .ToArray();
        }

        /// <summary>
        /// テキストから正規表現を用いて文字列を削除
        /// </summary>
        /// <param name="text">テキスト</param>
        /// <param name="pattern">正規表現</param>
        public static string RemoveCharacter(string text,string pattern)
        {
            if (string.IsNullOrEmpty(text)) return "";
            if (string.IsNullOrEmpty(pattern)) return text;

            return Regex.Replace(text, pattern, string.Empty);
        }

    }

    public enum CharacterCode
    {
        UTF_8,
        EUC_JP,
    }

}
UrlUtils.cs
using System;
using System.Text;

namespace Hanachiru
{
    /// <summary>
    /// URLエンコード用のクラス
    /// </summary>
    /// <remarks>
    /// なぜかUTF-8以外での文字コードによるエンコードができなかった(おそらくUnityのバグ??)ため、ネットから拾ってきました
    /// https://dobon.net/vb/dotnet/internet/urlencode.html#section5
    /// </remarks>
    public static class UrlUtils
    {
        public static readonly string UnreservedCharacters = "-._~";
        public static readonly string ReservedCharacters = UnreservedCharacters + ":/?#[]@!$&'()*+,;=";

        /// <summary>
        /// RFC3986に基づいてURLエンコードを行います。
        /// </summary>
        /// <param name="stringToEscape">
        /// URLエンコードする文字列。
        /// </param>
        /// <param name="escapeEncoding">
        /// エンコード方式を指定するEncoding オブジェクト。
        /// </param>
        /// <returns>
        /// URLエンコードされた文字列。
        /// </returns>
        public static string EscapeDataString(string stringToEscape,
            Encoding escapeEncoding)
        {
            return PercentEncodeString(
                stringToEscape, UnreservedCharacters, escapeEncoding);
        }
        public static string EscapeDataString(string stringToEscape)
        {
            return EscapeDataString(stringToEscape, Encoding.UTF8);
        }

        /// <summary>
        /// RFC3986に基づいてURI文字列のURLエンコードを行います。
        /// </summary>
        /// <param name="stringToEscape">
        /// URLエンコードする文字列。
        /// </param>
        /// <param name="escapeEncoding">
        /// エンコード方式を指定するEncoding オブジェクト。
        /// </param>
        /// <returns>
        /// URLエンコードされた文字列。
        /// </returns>
        public static string EscapeUriString(string stringToEscape, Encoding escapeEncoding)
        {
            return PercentEncodeString(stringToEscape, ReservedCharacters, escapeEncoding);
        }
        public static string EscapeUriString(string stringToEscape)
        {
            return EscapeUriString(stringToEscape, Encoding.UTF8);
        }

        internal static string PercentEncodeString(string stringToEscape, string dontEscapeCharacters, Encoding escapeEncoding)
        {
            StringBuilder encodedString = new StringBuilder();

            foreach (char c in stringToEscape)
            {
                if (('0' <= c && c <= '9') ||
                    ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
                    (0 <= dontEscapeCharacters.IndexOf(c)))
                {
                    //エンコードしない文字の場合
                    encodedString.Append(c);
                }
                else
                {
                    //エンコードする文字の場合
                    encodedString.Append(HexEscape(c, escapeEncoding));
                }
            }

            return encodedString.ToString();
        }

        /// <summary>
        /// 指定した文字のパーセントエンコーディング(百分率符号化)を行います。
        /// </summary>
        /// <param name="character">
        /// パーセントエンコーディングする文字。
        /// </param>
        /// <param name="escapeEncoding">
        /// エンコード方式を指定するEncoding オブジェクト。
        /// </param>
        /// <returns>
        /// パーセントエンコーディングされた文字列。
        /// </returns>
        public static string HexEscape(char character, Encoding escapeEncoding)
        {
            if (255 < (int)character)
            {
                //characterが255を超えるときはUri.HexEscapeが使えない
                StringBuilder buf = new StringBuilder();
                byte[] characterBytes =
                    escapeEncoding.GetBytes(character.ToString());
                foreach (byte b in characterBytes)
                {
                    buf.AppendFormat("%{0:X2}", b);
                }

                return buf.ToString();
            }

            return Uri.HexEscape(character);
        }
    }
}

使い方

LivedoorNewsTest.cs
using System;
using System.Threading.Tasks;
using UnityEngine;
using System.Linq;

namespace Hanachiru
{
    public class LivedoorNewsTest : MonoBehaviour
    {
        void Start()
        {
            Task.Run(() =>
            {
                try
                {
                    //ニュースの一覧を取得
                    var news = NewsReader.ReadNews();

                    if (news == null) return;

                    foreach (var item in news)
                    {
                        Debug.Log(item.Title);
                    }

                    //ニュースの詳細を表示
                    var newsDetail = NewsReader.ReadNewsDetail(news.FirstOrDefault());

                    if (newsDetail == null) return;

                    Debug.Log(newsDetail.Detail);

                }
                catch (Exception e)
                {
                    Debug.LogWarning(e);
                }
            });
        }

    }
}

f:id:hanaaaaaachiru:20190714203951p:plain

さいごに

思ったよりも長いコードになってしまっていたので、gistやgithubでコードを公開した方がよかったかもしれませんね。

少しでも皆さんが見やすいよう今後も工夫していきたいと思います。