WebClient派生クラスでのクッキー読み書きについて

Tweet image download agent で、致命的なエラーを放置してた件。
もともとファボったツイートの画像だけを自動ダウンロードするために書いたコード。最近はもともとの動機となった機能はほとんど使わず、ツイッター内で検索した画像を自動ダウンロードするために使っていたので、いつの間にかログインできない状態になっていて、それにも気づかず・・・。コメントで報告してもらって初めて気づいたという、お粗末さ(^^;;;

それはともかく、原因は、ログイン後のクッキーの取り扱い。それが雑だったという、二重のお粗末さ・・・。C#使いとしては失格ですえ。

何が原因なのか、VisualStudio2015のIDEでとりあえず、該当箇所をステップ実行してデバッグしてたら、HttpWebResponse.Cookies に セッションクッキーしかストアされていないことに、まず気付いた。要するに、サーバーから返されたレスポンスヘッダ Set-Cookie の Expires が設定されていないものだけが HttpWebResponse.Cookiesにストアされている・・・。

どゆこと?

いくら実行しても、セッションクッキーしか保存されない・・・。これじゃログインが成功してたとしても、だめだわ・・・。
ChromeのDevToolsでTwitterサイトへのログインのレスポンスヘッダーを眺めてたら、あれ? もしかして、Expires に記述されている日付書式のパースに失敗してんのかな???と、グーグル先生に聞いてみると、.NETのSet-Cookieヘッダのパーサーはバカだよ(超意訳)、みたいな投稿が StackOverflowに出てた。

ってなわけで、WebClient.GetWebResponseをオーバーライドして、

WebResponse.Headers[“Set-Cookie”] から自前でクッキーをパースして、CookieContainer.Add しちゃいなよ!

っていうアドバイスに従い、テキトーにパースして Add しちゃう、しちゃう。

でもなー、前はちゃんと動いてたのに・・・。やっぱり、Twitter が吐く Set-Cookieヘッダが変わったぐらいしか、原因が分からないすッ。

探せばもっとマトモなコードがあると思われるので後で探そう・・・とりあえず↓でヨシとする。(要点のみ)
※ すべてのコードは、https://osdn.jp/users/earlgreyx/pf/TwitterImageDownloadAgent/wiki/FrontPage

/********************************************************
修正:2017/08/20 WebResponse.Headers.GetValuesメソッドから取得するように変更。
修正:2017/08/22 やっぱり forループよりLinq使った方がいいか・・・。
*********************************************************/
protected override WebResponse GetWebResponse(WebRequest request)
{
  WebResponse response = base.GetWebResponse(request);
  if(response is HttpWebResponse)
    {
      var httpWebResponse = response as HttpWebResponse;
      fixCookies(httpWebResponse);
 
      cookieContainer.Add(httpWebResponse.Cookies);
    }
  return response;
}
 
private void fixCookies(HttpWebResponse response) 
{
  var cookies = Enumerable.Range(1,response.Headers.Count)
                          .Where( i => response.Headers.GetKey(i - 1).ToLower() == "set-cookie" )
                          .SelectMany(i => response.Headers.GetValues(i - 1).Select(val => val));

  foreach(var singleCookie in cookies)
    {
      string domain=null,path=null,expires=null,n=null,v=null;
      bool httponly = false,secure = false;
 
      foreach(string el in Regex.Split(singleCookie, @"\s*;\s*"))
        {
          string[] kv = el.Split(new char[] {'='},2);
          string key=null,val=null;
          if(kv.Length == 2)
            {
              key = kv[0];
              val = kv[1];
            }
          else if(kv.Length == 1)
            {
              key = kv[0];
            }
 
          switch(key.ToLower())
            {
            case "domain":
              domain = val;
              break;
 
            case "expires":
              expires = val.Replace(" UTC","Z");
              break;

            case "path":
              path = val;
              break;

            case "httponly":
              httponly = true;
              break;

            case "secure":
              secure = true;
              break;

            default:
              n = key;
              v = val;
              break;
            }
        }
      var cookie = new Cookie(n,v);
      if(!string.IsNullOrEmpty(path))
        cookie.Path = path;
 
      /* Domainがない場合は、.twitter.com にしちゃう!
         Domainプロパティを設定しないと、Addが失敗しちゃう!! */
      cookie.Domain = string.IsNullOrEmpty(domain) ? ".twitter.com" : domain;

      if(!string.IsNullOrEmpty(expires))
        {
          //パースが失敗しちゃう場合は、Expiresは無かったことにしちゃう!
          DateTime dt;
          if(DateTime.TryParse(expires,out dt))
            cookie.Expires = dt;
        }
 
      cookie.HttpOnly = httponly;
      cookie.Secure = secure;
 
      response.Cookies.Add(cookie);
    }
}

Tweet image download agent.

【追記】2017/09/19 現在「いいね」画像のTL取得に失敗しているみたい。原因究明中(デバッグ中)使用しているアカウントがロックされたので、ちょっとお手上げ。ログインするととたんにロックされるのでビルド済みのファイルは公開中止。アカウントを永久凍結されると困るので、これにて終了(^^;; チキンハートw

ちょうどGUIをWinFomsからWPFに移行中でそろそろ完了しつつあったのでちょい残念。でもまぁ、WPFのバインディングやら非同期やら勉強できたので、いい教材だったかなと。


【追記】 2017/08/27 二段階認証に対応。画面をちょっと変更。ファイル拡張子の判別がちゃんと機能してなかった・・・他色々修正しまくり。


【追記】 2017/08/20 set-cookieヘッダの取得方法を若干変更。 現在2段階認証には対応しておりません。今度対応させます。


【追記】 2017/08/19 ログイン後の画像取得が失敗する不具合を修正。


【追記】2017/08/18 ↓の原因をほぼ特定。暫定的に修正したものを更新予定。


【追記】2017/08/17 ログインのプロセスが変更になった関係で、現在、ログインが成功しても画像の取得に失敗します。ログイン自体が失敗している模様です。また調査次第修正できるようであれば、修正します m(__)m


【追記】2017/07/09 取得するファイルタイプ(拡張子)、ファイルサイズの上下限を設定できるようにした。コードのリファクタリング実施。


【追記】2016/09/06 テキストボックスのところをコンボボックスに張り替え。新しいものから10個まで入力履歴を記憶できるように修正。他、諸々修正。


【追記】2016/04/27 GW前に、ちまちま修正他、検索ワードに対応。これで画像収集が捗るわい(^^;


ちょっと前に書いたツイッターの画像を一括ダウンロードするやつ、のGUI版を 非同期タスクのお勉強も兼ねて日曜プログラミング(^^;

コンソールでコマンドを打つ方が慣れているのでわざわざGUIみたいなクソめんどくさいもん書く必要もなかったんですが・・・。

とくに最近、C#でアシンクやらアウェイトやらウェイタブルとか今まで見たこともないキーワードがガンガン出てくるようなコードを見る機会が多くなったので、勉強がてら練習で作ってみました。・・・・というレベルです(m_m)
とはいいつつ、awaitとかasyncとかまだよく分からないので、Task.Run()をふんだんにちりばめてみました(^_^;;; ま、自分で使うツールだから動けばいいや。
検索とかはまだ実装していません、というか、自分には必要ないので、やるつもり無し。時間があればまたブラウザで解析して・・・時間があれば・・・。
そもそもこの手のツールは他にもっと便利なソフトが窓の杜とかVectorとか探せばあると思うので。。。
動画とかも落とせればいいかなー、とか思って昼休みにchromeの開発者ツールでちょっと調べてみたけど、結構めんどくさそうですね・・・。

twitterimageagent-1 twitterAgent2

Twitter APIじゃないので、ブラウザでの表示方法が変更されると誤動作するでしょう。

ビルド済みのファイルとあまり綺麗では無いソースファイルは、OSDN.JPの作業部屋 ↓↓↓から。(使い方よく分かってません・・・)
https://osdn.jp/users/earlgreyx/pf/TwitterImageDownloadAgent/wiki/FrontPage

twitterの「いいね」画像をダウンロード C#版

2017年7月15日
こっちに移動 → https://ptsv.jp/2016/04/20/tweet-image-download-agent/

2016年4月29日 Windows フォームを利用したGUI版追加
検索ワード対応とスレッドプールにダウンロード処理を投げるように、ソース本体も改編した版

2015年11月27日・29日 コード修正・他
コード修正 画像ファイルの正規表現の間違い修正及びcommandを追加。
コード修正 URL を favorites => likes に変更。文章をちょろっと変更。

2015年11月23日 コード修正
2回目以降のtimeline URLの取得方法が間違ってました。ごめんなさい。(m_m)


perl で組んだものを C# に書き直したコードです。perlで組んだもので僕がやりたかったことは全部できた訳ですが・・・ま、今仕事暇なんで(^^;

エラーチェックとか全然してません。このコードは参考テスト程度のものなので、あしからず。
間違ったtargetとか入力してサーバーが404エラー返すと例外吐いて死にます(^_^;; エラー処理をコードに盛り込むとコードの要点が見えずらくなるので・・・いないとは思いますが、コードを使う時は、エラー処理(例外捕捉とか)を入れてね。

また、Twitter API使った方法ではないので、twitter側がブラウザ表示の方法を変更してしまうと、たぶんエラーで落ちると思う。まぁ、いつまでこの方法で使えんのかな、という感じです。

コンソールプログラムで良ければ、コンパイルしたものを置いときます。[ getTweetImage.exe ]
※使用方法は下記コードのコメントを参照してください。
(EXEファイルをダウンロードすると、たぶんセキュリティーチェックで引っかかると思いますが、信用できない方は、下記コードをご自身でコンパイルしてください。 )

たぶん Windows以外のOSでも、monoが入っていたら 上記 exe ファイルがそのまま動くんじゃないかなー。
[追記]
CentOS6 + mono で実行してみましたが、https通信でセキュリティ?かなんかの例外が発生してしまいました。
デフォルトでインストールしたまんまのmonoの場合はルート証明書をインポートする等しないといけないみたいですね。mozroots.exeをインストールして解決しました。下記ページを参考にさせていただきましたm(__)m。
~Monoで、Secure Socket Layer (SSL) / Transport Layer Security (TLS)通信を行うには~
http://qiita.com/takanemu/items/129acc13d8b7ce088b92

[追記ここまで]

これにて終了。

/*******************************************************************************

  saving image file on twitter.(getTweetImage.cs)

   build: Visual Studio 2015 Community.( also Express Edition) or Windows SDK
     IDE使うほどでもないので、コマンドラインツールでcsc.exeでバイトコンパイルしてください。
     >> csc.exe getTweetImage.cs

  howto: Console command
     >> getTweetImage.exe command target [username password]
     
     command  : ターゲットが"いいね"した画像 => 'favo' 
                ターゲットがアップした画像   => 'profile'
                ターゲットのTLの画像        => 'timeline'
     target   : ターゲットとなる取得したい画像のアカウント名 
     username : 自分のログインアカウント
     password : 自分のパスワード

     usernameとpasswordは、「いいね」画像の取得及び、
     ターゲットとなるアカウントが非公開アカの場合に必要かと思います。

*******************************************************************************/
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using System.IO;
using System.Net;
using System.Linq;

namespace ptsv.jp
{
  public class ConsoleApplication1
    {
      private static Dictionary<string,string> TIMELINEURL = new Dictionary<string,string>
        {
          { "profile","https://twitter.com/i/profiles/show/{0}/media_timeline"},
          { "favo","https://twitter.com/{0}/likes/timeline"},
          { "timeline","https://twitter.com/i/profiles/show/{0}/timeline"}
        };

      /// <summary>
      /// スタートアップ
      /// </summary>
      /// <param name="args">コマンドライン引数</param>
      public static void Main(string [] args)
        {
          if(args.Length < 2)
            {
              Console.WriteLine("USAGE: getTweetImage.exe command target [username password]");
              Console.WriteLine("USAGE: command is 'favo' or 'profile'");
              Console.WriteLine("require username and password\n when command is 'favo',or target is private account.");
              return;
            }
          string command  = args[0];
          string target = args[1];
          string username_or_email = null;
          string password = null;

          if(args.Length == 4)
            {
               username_or_email = args[2];
               password = args[3];
            }

          if(command == "favo" && (string.IsNullOrEmpty(username_or_email) || string.IsNullOrEmpty(password)))
            {
               Console.WriteLine("when command is 'favo', username and password is required.");
               Console.WriteLine("USAGE: getTweetImage.exe command target [username password]");
               return;
            }

          string timeline_url = TIMELINEURL.ContainsKey(command) ? string.Format(TIMELINEURL[command],target) : null;

          var twitter = new TwitterDownload((type,v) =>
                                            {
                                              switch(type)
                                                {
                                                case "get-timeline":
                                                  Console.WriteLine("---- getting and parsing\n{0} ...\n----",v);
                                                  break;
                                                case "getting-image":
                                                  Console.WriteLine("fetching: {0}",v);
                                                  break;
                                                case "saved-image":
                                                  Console.WriteLine("saved {0}",v);
                                                  break;
                                                case "file-exists":
                                                  Console.WriteLine("{0} is already exists.",v);
                                                  break;
                                                case "fail-login":
                                                  Console.WriteLine("Auth code not found.","");
                                                  break;
                                                case "fail-auth":
                                                  Console.WriteLine("username or password were refused.","");
                                                  break;
                                                case "try-login":
                                                  Console.WriteLine("Trying to login by '{0}' for twitter.com", v);
                                                  break;
                                                }
                                            });

          if(!string.IsNullOrEmpty(timeline_url))
            {
              twitter.Agent(timeline_url,username_or_email,password);
            }
          else
            {
              Console.WriteLine("[INVALID COMMAND] command should be 'favo' or 'profile'");
            }
        }
    }

  /// <summary>
  /// 画像ダウンロード本体クラス
  /// </summary>
  public class TwitterDownload
    {
      /*------------------------------------------------------------------------------

        Const

      ------------------------------------------------------------------------------*/
      public const string URL_LOGIN    = "https://twitter.com/login";
      public const string URL_SESSIONS = "https://twitter.com/sessions";
      public const string USER_AGENT   = "Mozilla/5.0 (compatible; MSIE 11.0; Windows NT 6.0; Trident/5.0)"; //適当
      public const string REFERER      = "https://twitter.com/";
      private const string RE_MEDIA_URL = @"https://pbs\.twimg\.com/media/([\w\-]+\.\w{3,4})(:large)?";
      private const string RE_TWEET_ID  = @"data-tweet-id=\\""(\d+)\\""";
      private const string RE_AUTH_CODE = @"<input type=""hidden"" value=""([\d\w]+?)"" name=""authenticity_token""(?:\s*/)?>";

      /*------------------------------------------------------------------------------

        Instance

      ------------------------------------------------------------------------------*/
      private string OutputDirectory = Path.Combine(".", "tmp");
      private TwitterWebClient webClient;
      private Action<string,string> Callback;

      public TwitterDownload(Action<string,string> callback, string cookieFile)
        {
         this.Callback = callback;
         this.webClient =  new TwitterWebClient(cookieFile);
        }

      public TwitterDownload(Action<string,string> callback)
        {
          this.Callback = callback;
          this.webClient = new TwitterWebClient();
         }

      public string directory
        {
          set
            {
              this.OutputDirectory = value;
            }
          get
            {
              return this.OutputDirectory;
            }
        }

      public void Agent(string timelineURL,string username,string password)
        {
          if(String.IsNullOrEmpty(timelineURL))
            return;

          if(!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
            {
                if (!GetLogin(username, password))
                 {
                    Callback("fail-auth", "");
                    return;
                 }
            }

          if (!Directory.Exists(OutputDirectory))
            Directory.CreateDirectory(OutputDirectory);

          string param = "?include_available_features=1&include_entities=1";
          string url;
          do
            {
              url = timelineURL + param;
              Callback("get-timeline",url);

              string result = webClient.DownloadString(url);
              param = GetImage(result);

            } while(!string.IsNullOrEmpty(param));
        }

      private bool GetLogin(string username,string password)
        {
          Callback("try-login", username);
          webClient.Headers[HttpRequestHeader.UserAgent] = USER_AGENT;
          webClient.Headers[HttpRequestHeader.Referer] = REFERER;

          string loginResult = webClient.DownloadString(URL_LOGIN);
          Match authMatch = Regex.Match(loginResult,RE_AUTH_CODE);
          if(!authMatch.Success)
            return false;

          var param = new NameValueCollection();
          param.Add("session[username_or_email]",username);
          param.Add("session[password]",password);
          param.Add("authenticity_token",authMatch.Groups[1].Value);
          param.Add("remember_me","1");
          param.Add("redirect_after_login","/");

          return Encoding.UTF8.GetString(webClient.UploadValues(URL_SESSIONS,param)).IndexOf("error") < 0;
        }

      private string GetImage(string json)
        {
          json = json.Replace(@"\/", "/");

          foreach(Match match in Regex.Matches(json,RE_MEDIA_URL))
            {
              string url = match.Value;
              string basename = match.Groups[1].Value;
              string filename = Path.Combine(OutputDirectory,basename);

              url = string.IsNullOrEmpty(match.Groups[2].Value) ? url + ":orig" : url.Replace(":large",":orig");

              if(File.Exists(filename))
                {
                  Callback("file-exists",filename);
                }
              else
                {
                  Callback("getting-image",url);
                  webClient.DownloadFile(url,filename);
                  Callback("saved-image",filename);
                }
            }

          var ids = new List<string>();
          foreach(Match match in Regex.Matches(json,RE_TWEET_ID))
            {
              ids.Add(match.Groups[1].Value);
            }

          return ids.Count > 0 ? string.Format("?max_position={0}&include_available_features=1&include_entities=1",ids.Last()) : null;
        }
    }

  /// <summary>
  /// クッキーの読書に対応するためだけのWebClientクラス。
  /// </summary>
  internal class TwitterWebClient : WebClient
    {
      private CookieContainer cookieContainer = new CookieContainer();
      private bool isAutoRedirectEnabled = false;

      public TwitterWebClient() : this("") {}

      /// <summary>
      /// もしクッキーファイルが存在したらクッキーファイルを読み込んでCookieContainerに追加する。
      /// </summary>
      /// <param name="cookieFile">ブラウザからエクスポートしたクッキーファイル</param>
      public TwitterWebClient(string cookieFile)
        {
          if(File.Exists(cookieFile))
            {
              using(var sr = new StreamReader(cookieFile))
              {
                string line;
                var reCRLF = new Regex(@"[\r\n]$");
                var reEmpty = new Regex("^$");
                var reComment = new Regex("^#");
                var reSpace = new Regex(@"\s+");

                while ((line = sr.ReadLine()) != null) 
                  {
                    line = reCRLF.Replace(line,"");
                    if(reEmpty.Match(line).Success || reComment.Match(line).Success)
                      continue;
                    string[] ar =  reSpace.Split(line);

                    cookieContainer.Add(new Cookie(ar[5],ar[6],ar[2],ar[0]));
                  }
              }
            }
        }

      /// <summary>
      /// WebClient.GetWebRequestメソッドをオーバーライド
      /// </summary>
      protected override WebRequest GetWebRequest(Uri address)
        {
          WebRequest request = base.GetWebRequest(address);

          if (request is HttpWebRequest)
            {
              var httpWebRequest = request as HttpWebRequest;
              if(httpWebRequest != null)
                {
                  httpWebRequest.CookieContainer = this.cookieContainer;
                  httpWebRequest.AllowAutoRedirect = this.isAutoRedirectEnabled;
                }
            }

          return request;
        }
      /// <summary>
      /// 自動リダイレクトするか?
      /// </summary>
      public bool AutoRedirect
         {
            set { isAutoRedirectEnabled = value; }
            get { return isAutoRedirectEnabled; }
         }
    }

}

twitterの「いいね」画像をダウンロード 修正版

追記 2017/07/15
C#に書き直した修正版 → https://ptsv.jp/2016/04/20/tweet-image-download-agent/


2015年11月27日・29日 コード修正
コード修正 画像ファイルの正規表現の間違い修正・ちょこっと追加。
コード修正 URL を favorites => likes に変更

2015年11月23日 コード修正
2回目以降のtimelineのURLの決定方法が間違っておりましたので修正しました。ごめんなさい(m_m)

2015年11月17日 コード修正
ログインを行うコードを追加。これにより、ブラウザからクッキーファイルをエクスポートする手間を省くようにした。ログインコードはこれでいいのかどうかわからん。twitterってrails使ってんだっっけ? よくわからんが、適当。
本来はTwitter APIを使わないといけないと思うけど・・・API経由は正直メンドクサイ。勉強する気なし。(m_m)

C#で書き直したプロトタイプをさっき書いたので、これもデバッグが終わり次第また書こっと。
『perlなんかインストールしてねーよ、ボケ!』って方は、C#で書き直した方に ビルドしたやつを置いてます。


今年の2月ぐらいに書いたtwitter画像ダウンロードスクリプトが動かなくなったので修正・修正(^_^;;

2回目以降のタイムライン取得のURLが変更になったみたい?。昼休みにブラウザでアクセスしてデベロッパーツールでちょこっと解析したんですが・・・どのパラメーターで読めばいいのか、いまいち分かんないです。適当です。あくまで、自分用の備忘録なんで、すみません。取れればいいんです(m_m)

twitterにログインするところまで書きなおそうと一瞬思いましたが、また今度にします(・・;
ブラウザでログインしてクッキーエクスポート、twitter.comドメインだけ抜き出して、カレントディレクトリへcookie.txtというファイル名で保存。テキトーですまない。

使い方は下記参考。ログインしたcookie.txtが必要なのは、いいね(旧称:お気に入り)の取得のみ。他人のIDの「いいね」も同様にログインが必要です。この辺よくわからん。自分もしくは他人のIDのタイムラインにログインクッキーは必要ない。です。

#!/usr/bin/perl
=pod

=head1 ツイッターでのタイムラインから、画像のみダウンロードするスクリプト

使い方 targetに画像を取得したいアカウント名を指定する。

(いいね 画像の場合は type に favo をセット(デフォルト) 
(※ いいね 取得は自分以外のアカウントでも必ずログインが必要みたいです。)

> twitter.pl --username=xxxxx --password=yyyyy --type=favo --target=zzzz


(単に任意のアカウントのタイムラインの画像が欲しい場合は type に profileをセット)
(TLに流れているリツイートも含めた画像を取得したい場合は、timelineをセット)
(※通常公開されているアカウントのタイムラインではusename,passwordは必要ありません。
(※非公開アカウントではフォローを許可されたアカウントのusername/passwordが必要です。)

> twitter.pl --type=profile --target=公開アカウント名


ディレクトリ<tmp>に画像が吐き出されます。

=cut

use strict;
use warnings;
use LWP::UserAgent;
use Getopt::Long qw/:config no_ignore_case/;

my %CONFIG = (username => '', password => '', dir => './tmp', type => 'favo', target => '');
my %TIMELINE = (favo    => 'https://twitter.com/%s/likes/timeline',
                profile => 'https://twitter.com/i/profiles/show/%s/media_timeline',
                timeline => 'https://twitter.com/i/profiles/show/%s/timeline');

#エージェント
my $UserAgent = LWP::UserAgent->new(cookie_jar => {});

&{sub
{
  my @argv = @_;
  my $result = Getopt::Long::GetOptionsFromArray(\@argv,
                                                 'username=s' => \$CONFIG{username},
                                                 'password=s' => \$CONFIG{password},
                                                 'type=s'     => \$CONFIG{type},
                                                 'dir=s'      => \$CONFIG{dir},
                                                 'target=s'   => \$CONFIG{target});

  exists $TIMELINE{$CONFIG{type}} or die "invalid type....\n";
  $CONFIG{dir} || die "specified output directory...\n";
  $CONFIG{target} || die "specified target account name...\n";

  mkdir $CONFIG{dir} unless(-e $CONFIG{dir});

  &use_lwp_agent;

}}(@ARGV);

sub use_lwp_agent
{
  my ($param,$result) = ('?include_available_features=1&include_entities=1','');

  if($CONFIG{username} && $CONFIG{password})
    {
      my $response = $UserAgent->get('https://twitter.com/login');
      $result = $response->decoded_content;

      $result =~ /<input type="hidden" value="([\d\w]+?)" name="authenticity_token"(?:\s*\/)?>/ or die "can not detect authenticity_token...\n";

      my $auth = $1;
      $response = $UserAgent->post('https://twitter.com/sessions',
                                   {'session[username_or_email]' => $CONFIG{username},
                                    'session[password]'          => $CONFIG{password},
                                    'authenticity_token'         => $auth,
                                    'remember_me'                => '1',
                                    'redirect_after_login'       => '/' }) or die "can not access login page... \n";

      $result = $response->decoded_content;
      die "failed to login...\n" if($result =~ /error/);
    }

  my $url = sprintf($TIMELINE{$CONFIG{type}},$CONFIG{target});
  do
    {
      my $timeline = $url.$param;

      print "---- getting and parsing\n$timeline ...\n----\n";
      $result = &lwp_agent($timeline,'-') || die "can not get timeline. may be wrong url.\n";

      $param = &get_images($result,\&lwp_agent);

    } while( $param );

  print "done!\n";
}

#タイムラインのJSONデータから画像を取得して、次のタイムラインのパラメータを返す。
sub get_images
{
  my ($json,$agent) = @_;

  $json =~ s/\\\//\//g;
  foreach my $url_($json =~ m!https://pbs\.twimg\.com/media/[\w\-]+\.\w{3,4}(?::large)?!g)
    {
      if($url_ =~ m/([\w\-]+\.\w{3,4})(:large)?$/)
        {
          my $basename = $1;
          my $filename = "$CONFIG{dir}/$basename";
          if($2)
            {
              $url_ =~ s/:large/:orig/;
            }
          else
            {
              $url_ .= ':orig';
            }

          unless(-e $filename)
            {
              print "fetching: $url_\n";
              &$agent($url_,$filename);
              print "saved $basename\n";
            }
        }
    }

  my @ids = $json =~ m/data-tweet-id=\\\"([0-9]+)/g;
  my $max_id = @ids > 0 ? pop @ids : '';

  return $max_id ? "?max_position=${max_id}&include_available_features=1&include_entities=1" : undef;
}

#URLを取得して返す。
sub lwp_agent
{
  my ($url,$ofile) = @_;
  my %options = ();

  if($ofile ne '-')
    {
      if($ofile eq ':src')
        {
          if($url =~ /([\w\-\.%]+?\.\w)$/)
            {
              $ofile = "$CONFIG{dir}/$1";
            }
          else
            {
              goto cleanup;
            }
        }

      return $UserAgent->get($url,':content_file' => $ofile);
    }

cleanup:
  if(my $response = $UserAgent->get($url))
    {
      return $response->decoded_content;
    }

  0;
}

__END__

twitterのお気に入りの画像を一括ダウンロード

追記 2017/07/15
C#に書き直した修正版 → https://ptsv.jp/2016/04/20/tweet-image-download-agent/


追記 2015/11/10
修正版 → スクリプトを修正したものを投稿していますm(_ _)m


ツイッターで、検索したりタイムラインに流れる画像で気になったのは片っ端から「お気に入り」に入れてました。で、とある日曜の夜中にふと、「ファボッったツイートの画像を全部ダウンロードしたい!」欲求に駆られ、ググってみたら下記ブログ記事を見つけた。

dyama.org/2014/10/berryjack-simple-twitter-media-downloader-by-shellscript/

ここで書かれていたtimelineに直接リクエストを投げる方法を、ブラウザで確認しながら調べてみると、できそうだったので、シェルスクリプトを流用させてもらいました。(m_m)
・・・Windowsなので、perlに書き直して完了。Win32版のwget.exeが必要ですが・・・。

ただ、「お気に入り」の画像を一括ダウンロードするにはログインが必要なんで、その部分を調べて書くのが面倒だったんで、Internet Explorerでログインした後、クッキーをエクスポートし、twitter.comドメインの部分だけ抜き出し cookies.txt と保存し、それをwgetに食わせることで強引に解決(^^;;; とりあえずなんで目をつむる。

Twitter APIとか使わないといけないのかなー、と思ってたんですが、上記ブログ記事のシェルスクリプトのおかげで随分簡単にゲトできました。ありがとうございました(^.^)

探せば便利なソフトがあると思うんですが・・・簡単にできたんで別にいいか(^^;
また、時間があれば、C#に書き直してGUIつくろー。

#!/usr/bin/perl
# 使い方
#
# > perl twitter.pl 画像タイムラインのURL(下記参照)
#
# 【任意のTwitterIDの画像の場合】
# https://twitter.com/i/profiles/show/(TwitterID)/media_timeline

# 【自分のお気に入りの場合】
# https://twitter.com/(自分のTwitterID)/favorites/timeline
# 別途ログイン後のクッキーが必要。IEなどでログインしてエクスポート。
# twitter.comドメインだけ抜き出して 同じディレクトリにcookies.txt として保存する必要あり。
#
# tmpというディレクトリがカレントディレクトリに作成され、画像がダウンロードされます。
# ダウンロードエージェントに GNU Wgetを利用しています。
# おいらのようにwindowsな人は別途 Win32版 GUN WGETが必要。

# 【追記】 wgetを使用せず、LWP::UserAgentを使用するように加筆したものを一番下に追加

use bigint;
mkdir 'tmp' unless(-e './tmp');
$param = '';
$timeline = $ARGV[0] || die "input timeline url...\n";

@options = ('--no-check-certificate',
            '-O -',
            "-a ./tmp/wget_$$.log");
push @options,'--load-cookies=cookies.txt' if(-e 'cookies.txt');

$options = join(' ',@options);
do
{
  $wget = sprintf('wget %s "%s%s"',$options,$timeline,$param);
  print $wget,"\n";
  $result = `$wget`;
  $result =~ s/\\\//\//g;

  @result = $result =~ m!https://pbs\.twimg\.com/media/\w+\.\w{3,4}\:large!g;
  foreach(@result)
    {
      $out = '';
      if(m/(\w+\.\w{3,4})\:large$/)
        {
          $filename = "tmp/$1";
          $out = "-O $filename";

          unless(-e $filename)
            {
              system("wget --no-check-certificate -a ./tmp/wget_$$.log -P tmp -nd $out $_");
              print "saved $1\n";
            }
        }
    }

  @result = $result =~ m/data-tweet-id=\\\"([0-9]+)/g;
  @result = sort @result;

  $cxt_id = shift @result;
  $max_id = $cxt_id - 1;

  $param = "?contextual_tweet_id=$cxt_id&max_id=$max_id";
} while( $cxt_id );

【追記】

wget を使う代わりに、LWP::UserAgent を使用するように改変。使い方は上と一緒。
・・・なんか無駄にコードが増えた(ーー;;;

#!/usr/bin/perl

# 使い方は上記wgetを使用するものと一緒

use strict;
use warnings;
use bigint;
use LWP::UserAgent;
use HTTP::Cookies;
use Time::Piece;

#クッキーファイル名
my $COOKIE = 'cookies.txt';

#作業用クッキーファイルパス
my $TMP_COOKIE = './tmp/libwww.'.$COOKIE;

#エージェント
my $UserAgent = LWP::UserAgent->new;

&{sub
{
  #arguments
  my @argv = @_;
  mkdir 'tmp' unless(-e './tmp');

  my $tmp = "./tmp/$$.media";
  my $param = '';
  my $timeline = $argv[0] || die "input timeline url...\n";
  my ($cxt_id,$max_id);

  #cookies.txtがカレントディレクトリにあればフォーマット変換して作業用ファイルに保存
  if(-e $COOKIE)
    {
      &convert_cookie;
      $UserAgent->cookie_jar(HTTP::Cookies->new(file => $TMP_COOKIE,autosave => 1));
    }

  do
    {
      $timeline = $argv[0].$param;

      print "---- getting and parsing\n$timeline ...\n----\n";
      my $result = &lwp_agent($timeline,'-') || die "can not get timeline. may be wrong url.\n";
      $result =~ s/\\\//\//g;

      foreach my $url_($result =~ m!https://pbs\.twimg\.com/media/\w+\.\w{3,4}\:large!g)
        {
          my $out = '';
          if($url_ =~ m/(\w+\.\w{3,4})\:large$/)
            {
              my $filename = "tmp/$1";
              unless(-e $filename)
                {
                  &lwp_agent($url_,$filename);
                  print "saved $1\n";
                }
            }
        }

      my @result = $result =~ m/data-tweet-id=\\\"([0-9]+)/g;
      @result = sort @result;

      $cxt_id = shift @result;
      $max_id = $cxt_id - 1;

      $param = "?contextual_tweet_id=$cxt_id&max_id=$max_id" if(defined $cxt_id);

    } while( $cxt_id );

  print "done!\n";

  #作業用のクッキー削除
  unlink $TMP_COOKIE if(-e $TMP_COOKIE);

}}(@ARGV);

sub lwp_agent
{
  my ($url,$ofile) = @_;
  my %options = ();

  if($ofile ne '-')
    {
      return $UserAgent->get($url,':content_file' => $ofile);
    }
  else
    {
      if(my $response = $UserAgent->get($url))
        {
          return $response->decoded_content;
        }
    }
  0;
}

sub convert_cookie
{
  my $fin = IO::File->new($COOKIE);
  my $fout = IO::File->new('>'.$TMP_COOKIE);

  $fout->print("#LWP-Cookies-1.0\n");
  foreach my $line_($fin->getlines)
    {
      chomp $line_;
      next if($line_ =~ /^$/ || $line_ =~ /^\#/);
      my ($domain,$flag,$path,$secure,$expires,$name,$value) = split(/\s+/,$line_);
      my $t = gmtime($expires);
      $expires = sprintf('%s, %d-%s-%04d %02d:%02d:%02d GMT',
                         $t->day,
                         $t->mday,
                         $t->month,
                         $t->year,
                         $t->hour,
                         $t->min,
                         $t->sec);
      $secure = $secure eq 'TRUE' ? 'secure' : '';

      $fout->print(qq(Set-Cookie3: $name=$value; path="$path"; domain=$domain; path_spec; expires="$expires"; $secure\n));
    }

  $fin->close;
  $fout->close;
}
__END__