🖼️

【.NET】Twitter APIを使って推しの画像に溺れたい⑤

2023/02/11に公開

こんにちは。
すっかり忘れてましたが、完成させます。

画像を指定のフォルダに保存する

前回まででツイート情報とメディア情報のひも付きは取得できているので、
メディア情報のURLから画像を取得してフォルダに保存させるだけです。

保存先のフォルダを作るには

画像格納用フォルダの下にハッシュタグごとのサブフォルダを作ります。
もしフォルダが作られていない場合は新規にフォルダを作ります。

フォルダ作成
string newFolderPath = "C:\Users\samegaki\Documents\oshipictures\gawrt";
if (!Directory.Exists(newFolderPath))
{
    Directory.CreateDirectory(newFolderPath);
}

HTTPで取得したファイルを保存するには

WebAPIのレスポンスはJSON文字列であることが多いため、そのままテキストファイルに書き出せばメモ帳とかで読めるようになります。
一方画像データはバイナリであるため、テキストファイルに書き出しても何だかよくわからない文字列が並ぶだけです。

レスポンスをそのまま書き出せるか否かで処理が変わるのはナンセンスであるため、C#ではシリアライズ(バイト列に変換)してストリーム(バッファみたいなもの)に格納し、空のファイルに書き出すという手法を取っています。
これにより、JSON文字列でもバイナリデータでも同じ方法でファイル保存することができます。

他の言語でも同じかもしれないですが、よく知りません(゚∀゚)

ファイル保存
// C#でHTTP操作するといったらHttpClient
HttpClient httpClient = new();

// URLを指定してレスポンス待ち
var response = await httpClient.GetAsync("http://...");

// 200 OKだったかチェック
if (response.StatusCode == HttpStatusCode.OK)
{
    // レスポンスをストリームで取得
    using var stream = await response.Content.ReadAsStreamAsync();
    // ストリームの吐き出し先ファイルを確保
    using var outStream = File.Create(path);
    // ファイルにストリーム吐き出し
    stream.CopyTo(outStream);
}
else
{
    // NLogでログ出力
    logger.Error($"Failed to fetch HTTP response. {response.StatusCode}:{response.ReasonPhrase}");
}
大きなサイズのファイルを取得するとき

HttpClient.GetAsyncはHTTPレスポンス全体の取得が完了するまで待機します。
非常に大きなデータ(超巨大なCSVファイルとか)を取得するとき、ネットワークのパフォーマンスによりますが長時間待たされることが予測されます。
そのためちょっとした工夫が必要になります。
今回はリクエスト1回で画像1枚なので、考慮する必要はありません。

https://qiita.com/thrzn41/items/2754bec8ebad97ecd7fd

デバッグ中に起きた不具合

1.メディアURLがNull

どうやらURLを使ってダウンロードできるのは画像だけのようで、動画およびGIFアニメはURLが付与されていないようです。
これらのメディアに対してのURLは空文字ではなくNullが当てられるため、考慮しておかないとNull参照になってしまいます。

2.ツイート情報のAttachmentsがNull

保存にガードがかかっている(?)画像の場合、Attachments部がNullで渡ってくることがあるようです。
実際にエラーになったツイートを見に行くと、タイムライン上では画像が表示されましたが、保存しようと画像をクリックすると「表示に失敗しました」というメッセージが現れました。
非常に惜しいですが、保存できなければ意味がないので、そのようなケースはスキップとします。

3.メディア情報のIncludes部がNull

画像付きツイートを検索対象にしているにもかかわらず、何もメディア情報が得られないなんてことがあるのか...?
と思いましたが、実際にあったので、Nullの場合はスキップします。

完成品

以上を踏まえて、完成したコードがこちらです。

Program.cs
using Microsoft.Extensions.Configuration;
using NLog;
using System.Net;
using Tweetinvi;
using Tweetinvi.Models.V2;
using Tweetinvi.Parameters.V2;

internal class Program
{
    private static HttpClient httpClient = new()
    {
        Timeout = TimeSpan.FromSeconds(180)
    };
    private static Logger logger = LogManager.GetCurrentClassLogger();

    private static async Task Main(string[] args)
    {
        var configuration = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json")
                .Build();

        // credentialの後入れができないため、コンストラクタで指定する必要がある
        TwitterClient userClient = new(
            configuration.GetValue<string>("ApiKey"),
            configuration.GetValue<string>("ApiKeySecret"),
            configuration.GetValue<string>("AccessToken"),
            configuration.GetValue<string>("AccessTokenSecret")
        );

        string[] hashtagList = configuration.GetSection("Hashtags").Get<string[]>() ?? new string[0];
        string basePath = configuration.GetValue<string>("BasePath") ?? "";
        if (hashtagList.Length == 0 || basePath.Length == 0)
        {
            logger.Error("\"Hashtags\" or \"BasePath\" is not set. Check appsettings.json.");
            return;
        }

        foreach (var hashtag in hashtagList)
        {
            await SaveImagesForHashtag(userClient, hashtag, basePath);
        }
    }


    /// <summary>
    /// 指定したハッシュタグで画像付きツイート(リツイート、リプライを除く)を検索し、画像を保存します。
    /// </summary>
    /// <param name="client">Twitterクライアント</param>
    /// <param name="hashtag">ハッシュタグ(#不要)</param>
    /// <param name="basePath">画像保存先ルートパス</param>
    /// <returns>非同期Task</returns>
    private static async Task SaveImagesForHashtag(TwitterClient client, string hashtag, string basePath)
    {
        // ツイートIDとメディアオブジェクトのリストをマッピング
        Dictionary<string, List<MediaV2>> tweetIdMediaListMap = new();
        List<Task> downloadTaskList = new();

        SearchTweetsV2Parameters parameters = new($"#{hashtag} -(is:retweet OR is:reply) has:images")
        {
            StartTime = DateTime.Now.AddDays(-1)
        };
        var seatchIterator = client.SearchV2.GetSearchTweetsV2Iterator(parameters);

        logger.Info($"#{hashtag} start.");

        while (!seatchIterator.Completed)
        {
            var searchResponse = (await seatchIterator.NextPageAsync()).Content;

            if (searchResponse.Tweets is null || searchResponse.Tweets.Length == 0)
            {
                // ツイートなしの場合スキップ
                logger.Info($"It was not tweeted within the timeframe");
                return;
            }
            if(searchResponse.Includes is null)
            {
                // メディアなしの場合スキップ
                logger.Info($"No media is linked with any tweets.");
                return;
            }

            TweetV2[] TweetList = searchResponse.Tweets;
            MediaV2[] MediaList = searchResponse.Includes.Media;

            // ツイートIDとメディアリストのマッピングを保持
            foreach (var tweet in TweetList)
            {
                if (tweet.Attachments is not null)
                {
                    string twid = tweet.Id;
                    // メディアリストのうち、ツイートに紐づくメディアキーを持つものを抽出
                    List<MediaV2> linkedMedia = MediaList.Where(m => tweet.Attachments.MediaKeys.Contains(m.MediaKey)).ToList();
                    if(linkedMedia.Count == 0)
                    {
                        logger.Info($"No media is linked");
                        return;
                    }
                    tweetIdMediaListMap.Add(tweet.Id, linkedMedia);
                }
                else
                {
                    // Attachmentsを持たないツイートの場合はスキップ
                    logger.Info($"Tweet ID {tweet.Id} doesn't have the Attachemnts object.");
                }
            }
        }

        // ハッシュタグフォルダを作成
        string tagFolderName = Path.Combine(basePath, hashtag);
        CreateNewDirectory(tagFolderName);

        // 日付フォルダを作成
        string dateFolderName = Path.Combine(tagFolderName, DateTime.Now.ToString("yyyy-MM-dd"));
        CreateNewDirectory(dateFolderName);

        // 画像保存Taskを作成
        foreach(var tweetIDMediaListPair in tweetIdMediaListMap)
        {
            // ツイートID
            string tkey = tweetIDMediaListPair.Key;

            foreach(var (md, idx) in tweetIDMediaListPair.Value.Select((v, i) => (v, i)))
            {
                if(md.Url is null)
                {
                    logger.Info($"URL of Media Key {md.MediaKey} is null(media type: {md.Type}). Linked tweet ID is {tkey}.");
                    continue;
                }

                // URL
                string url = md.Url;
                // ファイル名は [ツイートID]_[連番].png
                string fileName = $"{tkey}_{idx}.png";
                string imageFilePath = Path.Combine(dateFolderName, fileName);

                // タスク登録
                downloadTaskList.Add(DownloadImgAsync(url, imageFilePath));
            }
        }

        // 貯めたTaskを一斉に実行
        logger.Info($"{downloadTaskList.Count} images will be downloaded.");
        await Task.WhenAll(downloadTaskList);
        logger.Info("Completed.");
    }

    /// <summary>
    /// フォルダを新規作成します。
    /// </summary>
    /// <param name="newFolderPath">フルパス</param>
    private static void CreateNewDirectory(string newFolderPath)
    {
        if (!Directory.Exists(newFolderPath))
        {
            Directory.CreateDirectory(newFolderPath);
        }
    }

    /// <summary>
    /// 画像をダウンロードし保存するTaskを作成します。
    /// </summary>
    /// <param name="url">画像URL</param>
    /// <param name="path">保存先パス</param>
    /// <returns>非同期Task</returns>
    private static async Task DownloadImgAsync(string url, string path)
    {
        var response = await httpClient.GetAsync(url);
        if (response.StatusCode == HttpStatusCode.OK)
        {
            using var stream = await response.Content.ReadAsStreamAsync();
            using var outStream = File.Create(path);
            stream.CopyTo(outStream);
        }
        else
        {
            logger.Error($"Failed to fetch HTTP response. {response.StatusCode}:{response.ReasonPhrase}");
        }
    }
}
appsettings.json
{
  "ApiKey": "aaa",
  "ApiKeySecret": "bbb",
  "AccessToken": "ccc",
  "AccessTokenSecret": "ddd",
  "BasePath": "C:\\Users\\yagoo\\Downloads\\oshipics",
  "Hashtags": [
    "soraArt",
    "ロボ子Art",
    "miko_Art",
    "ほしまちぎゃらりー",
    "AZKiART",
    "メルArt",
    "アロ絵",
    "はあとart",
    "絵フブキ",
    "祭絵",
    "あくあーと",
    "シオンの書物",
    "百鬼絵巻",
    "しょこらーと",
    "プロテインザスバル",
    "みおーん絵",
    "絵かゆ",
    "できたてころね",
    "ぺこらーと",
    "絵クロマンサー",
    "しらぬえ",
    "ノエラート",
    "オークアート",
    "マリンのお宝",
    "かなたーと",
    "みかじ絵",
    "cocoART",
    "つのまきあーと",
    "TOWART",
    "ルーナート",
    "LamyArt",
    "ねねアルバム",
    "ししらーと",
    "まのあろ絵",
    "絵まる",
    "laplus_Artdesu",
    "Laplus_Artdesu",
    "Luillust",
    "こよりすけっち",
    "さかまた飼育日記",
    "いろはにも絵を",
    "絵ーちゃん",
    "のどかあーと",
    "iRISUtration",
    "HoshinovArt",
    "ioarts",
    "graveyART",
    "anyatelier",
    "Reinessance",
    "Zetacrylic",
    "inKaela",
    "AeruSeni",
    "callillust",
    "artsofashes",
    "inART",
    "gawrt",
    "ameliaRT",
    "IRySart",
    "galaxillust",
    "FineFaunart",
    "kronillust",
    "drawMEI",
    "illustrayBAE"
  ]
}

デバッグ実行

2023-02-11 15:50:50.9124 INFO  [1] Program - #soraArt start. 
2023-02-11 15:50:53.2376 INFO  [6] Program - 13 images will be downloaded. 
2023-02-11 15:50:54.0997 INFO  [7] Program - Completed. 

...

2023-02-11 15:52:50.2694 INFO  [38] Program - #illustrayBAE start. 
2023-02-11 15:52:50.5614 INFO  [38] Program - 4 images will be downloaded. 
2023-02-11 15:52:50.6317 INFO  [5] Program - Completed. 

1日分の画像を保存するのにちょうど2分かかりました。
っていうか、最後のべーちゃんの4枚を保存するのに0.07秒しかかかってないってすごいな。

リリース実行

2023-02-11 15:58:32.2797 INFO  [1] Program - #soraArt start. 
2023-02-11 15:58:35.3152 INFO  [3] Program - 13 images will be downloaded. 
2023-02-11 15:58:35.9274 INFO  [24] Program - Completed. 

...

2023-02-11 16:00:40.4805 INFO  [5] Program - #illustrayBAE start. 
2023-02-11 16:00:40.8498 INFO  [3] Program - 4 images will be downloaded. 
2023-02-11 16:00:41.3977 INFO  [3] Program - Completed. 

こちらも約2分でした。
かなり小規模なアプリなので、デバッグビルドとリリースビルドの差はほぼないんでしょうね。

設定ファイルを環境ごとにわける

appsettings.jsonとnlog.configは環境共通で使うようになっていますが、当然DEBUG版とRelease版では設定値が異なります。
そのため環境によってどちらを使うか変えられるようにしておきます。

設定方法

まずappsettings.jsonとnlog.configをdebug用/release用に分けます。
内容はひとまず同じでよいです。

次に各ファイルのプロパティから、「出力ディレクトリにコピー」を「コピーしない」に変更します。

最後に、ビルドイベントを設定します。
プロジェクト > (プロジェクト名)のプロパティを開きます。

VisualStudio2022の場合は ビルド > イベントを開き「ビルド後のイベント」欄を探します。
ここに以下の設定を書きます。

if "$(ConfigurationName)" == "Debug" (
    copy  /Y /V "$(ProjectDir)appsettings_debug.json" "$(ProjectDir)$(OutDir)appsettings.json"
    copy  /Y /V "$(ProjectDir)nlog_debug.config" "$(ProjectDir)$(OutDir)nlog.config"
)
 
if "$(ConfigurationName)" == "Release" (
    copy  /Y /V "$(ProjectDir)appsettings_release.json" "$(ProjectDir)$(OutDir)appsettings.json"
    copy  /Y /V "$(ProjectDir)nlog_release.config" "$(ProjectDir)$(OutDir)nlog.config"
)

debugビルドの場合はデバッグ用のファイルを、releaseビルドの場合はリリース用のファイルを、出力フォルダにコピーするというだけのスクリプトです。

実際にビルドしてみる

一度ビルドをクリーンしてからビルドします。
するとビルドログが以下のようになると思います。

ビルドを開始しました...
C:\Users\user\source\repos\OshiPicCollector\OshiPicCollector\OshiPicCollector.csproj を復元しました (228 ms)。
1>------ ビルド開始: プロジェクト: OshiPicCollector, 構成: Debug Any CPU ------
1>OshiPicCollector -> C:\Users\user\source\repos\OshiPicCollector\OshiPicCollector\bin\Debug\net6.0\OshiPicCollector.dll
1>        1 個のファイルをコピーしました。
1>        1 個のファイルをコピーしました。
========== ビルド: 成功 1、失敗 0、最新の状態 0、スキップ 0 ==========

ちゃんと2ファイルコピーされたことが出力されています。
出力フォルダを見てみると、たしかにコピーされていますね。

中身を見るとデバッグ用の設定になっています。

リリース版の方でも確かめてみてください。

次回

というわけで、アプリが完成しました。
次回はRaspberryPiへのリリース、諸々の設定(dotnetインストール、appsettings.jsonとnlog.configの書き換え)

Discussion