Twitterの新機能である動画投稿のツイートからの動画URLをJSONから抽出し、動画をじゃぶじゃぶダウンロードする

Twitterの公式アプリから動画(30秒まで)がアップロードできるようになりました。


![スクリーンショット 2015-02-03 1.09.17.png](https://qiita-image-store.s3.amazonaws.com/0/36931/fe2a9d53-72be-e175-d47a-69c4a4fac737.png)
https://twitter.com/n428dev/status/561814427150741505

その他サンプル
https://twitter.com/n428dev/status/564344850112192512

このツイートをJSONで取得すると以下のようになります

## 動画投稿情報を含むツイートのJSON

```json:video_tweet.json
{
  "contributors": null,
  "text": "ツイッター動画投稿テスト http://t.co/y9hjEfABw1",
  "geo": null,
  "retweeted": false,
  "in_reply_to_screen_name": null,
  "possibly_sensitive": false,
  "truncated": false,
  "lang": "ja",
  "entities": {
    "symbols": [],
    "urls": [],
    "hashtags": [],
    "media": [
      {
        "sizes": {
          "thumb": {
            "w": 150,
            "resize": "crop",
            "h": 150
          },
          "small": {
            "w": 340,
            "resize": "fit",
            "h": 340
          },
          "large": {
            "w": 720,
            "resize": "fit",
            "h": 720
          },
          "medium": {
            "w": 600,
            "resize": "fit",
            "h": 600
          }
        },
        "id": 561814337585569800,
        "media_url_https": "https://pbs.twimg.com/ext_tw_video_thumb/561814337585569793/pu/img/ZyW1taT-3_AkDB-4.jpg",
        "media_url": "http://pbs.twimg.com/ext_tw_video_thumb/561814337585569793/pu/img/ZyW1taT-3_AkDB-4.jpg",
        "expanded_url": "http://twitter.com/n428dev/status/561814427150741505/video/1",
        "indices": [
          13,
          35
        ],
        "id_str": "561814337585569793",
        "type": "photo",
        "display_url": "pic.twitter.com/y9hjEfABw1",
        "url": "http://t.co/y9hjEfABw1"
      }
    ],
    "user_mentions": []
  },
  "in_reply_to_status_id_str": null,
  "id": 561814427150741500,
  "extended_entities": {
    "media": [
      {
        "sizes": {
          "thumb": {
            "w": 150,
            "resize": "crop",
            "h": 150
          },
          "small": {
            "w": 340,
            "resize": "fit",
            "h": 340
          },
          "large": {
            "w": 720,
            "resize": "fit",
            "h": 720
          },
          "medium": {
            "w": 600,
            "resize": "fit",
            "h": 600
          }
        },
        "id": 561814337585569800,
        "media_url_https": "https://pbs.twimg.com/ext_tw_video_thumb/561814337585569793/pu/img/ZyW1taT-3_AkDB-4.jpg",
        "video_info": {
          "duration_millis": 11965,
          "variants": [
            {
              "bitrate": 832000,
              "content_type": "video/webm",
              "url": "https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/480x480/QKhWzHdEK0QDPtg8.webm"
            },
            {
              "bitrate": 1280000,
              "content_type": "video/mp4",
              "url": "https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/720x720/IdBKWbELa8D8cqLD.mp4"
            },
            {
              "bitrate": 320000,
              "content_type": "video/mp4",
              "url": "https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/240x240/7EK_gSEEPFBATzXI.mp4"
            },
            {
              "bitrate": 832000,
              "content_type": "video/mp4",
              "url": "https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/480x480/QKhWzHdEK0QDPtg8.mp4"
            },
            {
              "content_type": "application/x-mpegURL",
              "url": "https://video.twimg.com/ext_tw_video/561814337585569793/pu/pl/6G6kgS5lRXAOiCcg.m3u8"
            }
          ],
          "aspect_ratio": [
            1,
            1
          ]
        },
        "media_url": "http://pbs.twimg.com/ext_tw_video_thumb/561814337585569793/pu/img/ZyW1taT-3_AkDB-4.jpg",
        "expanded_url": "http://twitter.com/n428dev/status/561814427150741505/video/1",
        "indices": [
          13,
          35
        ],
        "id_str": "561814337585569793",
        "type": "video",
        "display_url": "pic.twitter.com/y9hjEfABw1",
        "url": "http://t.co/y9hjEfABw1"
      }
    ]
  },
  "source": "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>",
  "in_reply_to_user_id_str": null,
  "favorited": false,
  "in_reply_to_status_id": null,
  "retweet_count": 0,
  "created_at": "Sun Feb 01 09:12:52 +0000 2015",
  "in_reply_to_user_id": null,
  "favorite_count": 0,
  "id_str": "561814427150741505",
  "place": null,
  "user": {
    "location": "",
    "default_profile": true,
    "profile_background_tile": false,
    "statuses_count": 66,
    "lang": "ja",
    "profile_link_color": "0084B4",
    "id": 826661216,
    "following": false,
    "protected": false,
    "profile_location": null,
    "favourites_count": 5,
    "profile_text_color": "333333",
    "description": "428で働くプログラマー",
    "verified": false,
    "contributors_enabled": false,
    "profile_sidebar_border_color": "C0DEED",
    "name": "AKB428",
    "profile_background_color": "C0DEED",
    "created_at": "Sun Sep 16 07:06:52 +0000 2012",
    "is_translation_enabled": false,
    "default_profile_image": false,
    "followers_count": 9,
    "profile_image_url_https": "https://pbs.twimg.com/profile_images/507934202185011200/HcQGt2_r_normal.jpeg",
    "geo_enabled": false,
    "profile_background_image_url": "http://abs.twimg.com/images/themes/theme1/bg.png",
    "profile_background_image_url_https": "https://abs.twimg.com/images/themes/theme1/bg.png",
    "follow_request_sent": false,
    "entities": {
      "description": {
        "urls": []
      }
    },
    "url": null,
    "utc_offset": 32400,
    "time_zone": "Irkutsk",
    "notifications": false,
    "profile_use_background_image": true,
    "friends_count": 40,
    "profile_sidebar_fill_color": "DDEEF6",
    "screen_name": "n428dev",
    "id_str": "826661216",
    "profile_image_url": "http://pbs.twimg.com/profile_images/507934202185011200/HcQGt2_r_normal.jpeg",
    "listed_count": 0,
    "is_translator": false
  },
  "coordinates": null
}
```

JSONを見るとextended_entities:プロパティの、mediaプロパティから読んでいけば動画の情報が取得できることがわかります。

### extended_entitiesプロパティの構成図

extended_entities.mediaには投稿された写真の情報も格納されているようになっています。
JSONの構造を図におこすと以下になります。


![video2.png](https://qiita-image-store.s3.amazonaws.com/0/36931/f4c390ba-ed0b-40cc-740e-8805221a2895.png)

ただし、現状はiPhoneアプリ側の仕様で1つのツイートに複数の動画を投稿できません。
また写真と動画の混合の投稿もできないため（おそらくサーバー負荷のため）しばらくは動画投稿の場合は取得すべき動画情報はextended_entities.media[0]のみになると思います。

mediaプロパティにはtypeプロパティがあり、typeが**photo**の場合は写真、typeが**video**の場合はビデオ動画情報が格納されていることになります。

### media.type == 'video' の時のmediaプロパティのJSON構成図

![video.png](https://qiita-image-store.s3.amazonaws.com/0/36931/ef96384a-88fa-0bd3-1b17-767807154e85.png)

### video_infoプロパティ詳細
media.type が 'video' の場合**video_info**プロパティがmediaプロパティに存在します。
動画URLや動画形式はここから取得する形になります。
各プロパティの意味は以下のようなものです。

|video_info プロパティ |意味|
|:-----------|:------------|
|duration_millis | 動画の長さをミリセカンドで表したもの|
|aspect_ratio | 動画のアスペクト比率|
|variants | 投稿された動画を各種フォーマットに変換した情報配列 |


|variantsプロパティの配列の各要素 |意味|
|:-----------|:------------|
|bitrate|動画のビットレート|
|content_type|動画フォーマット|
|url|動画URL|

## (設計) video_info プロパティを言語の型に変換する

LL言語(Ruby/Python/Perlなど)であればこの情報を元にJSONをハッシュに変換するだけで話は終了だと思います。
ただし型がある言語はJSONをゴリゴリ型に変換するパーサーを書く必要があるのでマッピング表を作ります。

### Javaの場合

#### VideoInfo Class

|VideoInfo Class property |type|備考|
|:-----------|:------------|:------|
|durationMillis | Long | |
|aspectEatio | List &lt;Long&gt; | IntegerのListでもいいかも|
|variants | List &lt;Variant&gt; | 独自で作成するクラス、後述|

#### Variant Class

|Variant Class property |type|備考|
|:-----------|:------------|:------|
|bitrate | Long | |
|contentType | String| |
|url | String | url系のクラスにラップしてもいいかも |

#### VideoInfo Class. Method
なくてもいいですが、便利系メソッドをVideoInfoクラスに追加します

|VideoInfo Class. Method List |static or class|引数|備考|
|:-----------|:------------|:-----|:------|
|fromRawJson| static method |String rawJsonString  | ツイートの生JSON文字列からVideoInfo情報をListにして返却します | 
|fromVideoJsonNode | static method | JsonNode videoNode | videoInfo情報をJsonNodeクラスに変換したもの引数に私VideoInfoクラスに変換します| 
|printFormatVideoInfo | class method| なし | VideoInfoクラスをJSON構造体っぽく整形して返します。|
 |

## (実装) video_info プロパティを言語の型に変換する

### Javaでパーサーを実装

#### Variant.java

```java:Variant.java
package akb428.twitter.model.videoinfo;

public class Variant {

	private Long bitrate = null;
	private String contentType = null;
	private String url = null;

        //TODO publicなgetter setter メソッドは記載省略
}
```

#### VideoInfo.java

Jsonのパースに**jacksonライブラリ**を使用しています。

```java:VideoInfo.java
package akb428.twitter.model;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.JsonProcessingException;
import org.codehaus.jackson.map.ObjectMapper;

import akb428.twitter.model.videoinfo.Variant;

public class VideoInfo {

	private Long durationMillis = 0L;
	private List<Variant> variants = null;
	private List<Long> aspectRatio = null;

	/**
	 * ツイートの生JSON文字列からVideoInfo情報をListにして返却します VideoInfoが見つからない場合はnullを返却します
	 * ツイートの生JSONはTwitter4Jであれば
	 * 
	 * ----------------------------------------------------------------
	 * ConfigurationBuilder cb = new ConfigurationBuilder();
	 * cb.setJSONStoreEnabled(true); String rawJsonString =
	 * DataObjectFactory.getRawJSON(Status status);
	 * ----------------------------------------------------------------
	 * 
	 * で取得できます。
	 * 
	 * @param rawJsonString
	 *            JSON文字列
	 * @return VideoInfoのリスト。ビデオ情報がない場合はnull
	 * @throws JsonProcessingException
	 * @throws IOException
	 */
	public static List<VideoInfo> fromRawJson(String rawJsonString) throws JsonProcessingException, IOException {
		List<VideoInfo> videoInfoList = new ArrayList<>();
		ObjectMapper mapper = new ObjectMapper();
		JsonNode rootNode = mapper.readTree(rawJsonString);

		JsonNode extentdedEntitiesNode = rootNode.get("extended_entities");
		if (extentdedEntitiesNode == null) {
			return null;
		}
		JsonNode mediaNodo = extentdedEntitiesNode.get("media");
		if (mediaNodo == null) {
			return null;
		}

		Iterator<JsonNode> mediaNodeList = mediaNodo.getElements();
		// mediaノードにはビデオ以外もあるので、動画情報のみ取得するようにする
		// video_infoがあるかどうか
		// media.type="video" かどうか
		// のいずれかで判定する
		// media: [ {type: video, video_info:{}}, {type: video, video_info:{}},
		// {}, ]
		while (mediaNodeList.hasNext()) {
			JsonNode videoNode = mediaNodeList.next().get("video_info");
			if (videoNode != null) {
				VideoInfo videoInfo = fromVideoJsonNode(videoNode);
				videoInfoList.add(videoInfo);
			}
		}

		return videoInfoList;
	}

	/**
	 * videoInfo（JsonNodeクラス）をVideoInfoクラスに変換します
	 * VideoInfoクラスの各フィールドに対応するJSONプロパティが存在しないときはフィールドには何も設定しません。（nullのまま）
	 * 
	 * @param videoNode
	 * @return
	 */
	public static VideoInfo fromVideoJsonNode(JsonNode videoNode) {
		VideoInfo videoInfo = new VideoInfo();
		JsonNode durationMillisNode = videoNode.get("duration_millis");

		if (durationMillisNode != null) {
			videoInfo.setDurationMillis(durationMillisNode.getLongValue());
		}

		JsonNode aspectRatioNode = videoNode.get("aspect_ratio");
		if (aspectRatioNode != null) {
			List<Long> aspectRatioList = new ArrayList<>();
			Iterator<JsonNode> aspectRatioChildNodes = aspectRatioNode.getElements();
			while (aspectRatioChildNodes.hasNext()) {
				aspectRatioList.add(aspectRatioChildNodes.next().getLongValue());
			}
			videoInfo.setAspectRatio(aspectRatioList);
		}

		JsonNode variantsNode = videoNode.get("variants");

		if (variantsNode != null) {
			List<Variant> variantList = new ArrayList<>();
			Iterator<JsonNode> variantChildNodes = variantsNode.getElements();
			while (variantChildNodes.hasNext()) {
				Variant variant = new Variant();
				JsonNode child = variantChildNodes.next();
				if (child.get("bitrate") != null) {
					variant.setBitrate(child.get("bitrate").getLongValue());
				}
				variant.setContentType(child.get("content_type").getTextValue());
				variant.setUrl(child.get("url").getTextValue());
				variantList.add(variant);
			}
			videoInfo.setVariants(variantList);
		}

		return videoInfo;
	}

	/**
	 * VideoInfoリストを整形してprintします
	 */
	public void printFormatVideoInfo() {
		System.out.println("video_info");
		System.out.println("|-- duration_millis : " + this.getDurationMillis());
		System.out.println("|-- aspect_ratio : " + this.getAspectRatio());
		System.out.println("|-- variants : [ ");
		for (Variant variant : this.getVariants()) {
			System.out.println("  {");
			System.out.println("  |-- bitrate : " + variant.getBitrate());
			System.out.println("  |-- content_type : " + variant.getContentType());
			System.out.println("  |-- url : " + variant.getUrl());
			System.out.println("  }");
		}
		System.out.println("  ]");
	}

        //TODO publicなgetter setter メソッドは記載省略
}
```

特に難しいことはしてないですが、ジェネリクスの型省略を使っているためJava7以降でコンパイルできます。Java6以下で使用する場合はジェネリクスの型を指定してください。

VideoInfo.fromRawJsonメソッドに渡す生JSON(rawJson)はvideoInfo以外のプロパティが入っていても問題なく動作します。
コメントにもあるようにTwitter4Jで``DataObjectFactory.getRawJSON(Status status)``を使い取得したrawJSON文字列を渡せます。


### 実装したパーサーを利用する例1

実装したVideoInfoクラスを簡単なmainクラスで試してみます


#### TwitterVideoInfoParserSample.java


```java:TwitterVideoInfoParserSample.java
package akb428.tkws;

import java.io.IOException;
import java.util.List;

import org.codehaus.jackson.JsonProcessingException;
import akb428.twitter.model.VideoInfo;

//arg[0] = ツイッターの生JSON
public class TwitterVideoInfoParserSample {

	public static void main(String[] args) throws JsonProcessingException, IOException {

		String rawJsonString = args[0];

		List<VideoInfo> videoInfoList = VideoInfo.fromRawJson(rawJsonString);

		for (VideoInfo videoInfo : videoInfoList) {
			videoInfo.printFormatVideoInfo();
		}
	}

}
```

これを
```
java TwitterVideoInfoParserSample '{Twitterの生JSON}'
```
で実行すると以下のような出力がされます

```text:outSample.txt
video_info
|-- duration_millis : 11965
|-- aspect_ratio : [1, 1]
|-- variants : [ 
  {
  |-- bitrate : 832000
  |-- content_type : video/webm
  |-- url : https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/480x480/QKhWzHdEK0QDPtg8.webm
  }
  {
  |-- bitrate : 1280000
  |-- content_type : video/mp4
  |-- url : https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/720x720/IdBKWbELa8D8cqLD.mp4
  }
  {
  |-- bitrate : 320000
  |-- content_type : video/mp4
  |-- url : https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/240x240/7EK_gSEEPFBATzXI.mp4
  }
  {
  |-- bitrate : 832000
  |-- content_type : video/mp4
  |-- url : https://video.twimg.com/ext_tw_video/561814337585569793/pu/vid/480x480/QKhWzHdEK0QDPtg8.mp4
  }
  {
  |-- bitrate : null
  |-- content_type : application/x-mpegURL
  |-- url : https://video.twimg.com/ext_tw_video/561814337585569793/pu/pl/6G6kgS5lRXAOiCcg.m3u8
  }
  ]
```


### 実装したパーサーを利用する例2 (Twitter API[twitter4j]の後続で呼び出し)

長いのでコア部分だけ掲載

```java:GetUserTimeLineTweetSample.java
		Twitter twitter = new TwitterFactory(cb.build()).getInstance();
		twitter.setOAuthConsumer(twitterModel.getConsumerKey(), twitterModel.getConsumerSecret());
		twitter.setOAuthAccessToken(new AccessToken(twitterModel.getAccessToken(), twitterModel.getAccessToken_secret()));

		List<Status> statuses = twitter.getUserTimeline(args[0]);
		System.out.println("Showing home timeline.");
		for (Status status : statuses) {
			System.out.println(status.getUser().getName() + ":" + status.getText());
			String rawJSON = DataObjectFactory.getRawJSON(status);
			System.out.println(rawJSON);


// Twitter4Jを使用し画像URL(1枚のみ)を取得
			MediaEntity[] arrMedia = status.getMediaEntities();
			for (MediaEntity media : arrMedia) {
				System.out.println("MediaEntity= " + media.getMediaURL());
			}

// Twitter4Jを使用し画像URL(複数)を取得
			MediaEntity[] arrMediaExt = status.getExtendedMediaEntities();
			for (MediaEntity media : arrMediaExt) {
				System.out.println("ExtendedMediaEntities= " + media.getMediaURL());
			}

			URLEntity[] entity = status.getURLEntities();
			for (URLEntity urlEntity : entity) {
				System.out.println("URLEntity= " + urlEntity.getExpandedURL());
			}

// VideoInfoクラスを使用し動画URLを取得
			List<VideoInfo> videoInfoList = VideoInfo.fromRawJson(rawJSON);
			if (videoInfoList != null) {
				for (VideoInfo videoInfo : videoInfoList) {
					videoInfo.printFormatVideoInfo();
				}
			}
		}
```

全体のコードは以下
https://github.com/AKB428/nico/blob/master/src/akb428/tkws/GetUserTimeLineTweetSample.java
https://github.com/AKB428/nico



独自でパーサーを用意するにせよ、Twitterライブラリで実装するにせよこれで動画URLがじゃぶじゃぶダウンロードできるようになります。


Let's Twitter Video!
https://twitter.com/n428dev/status/564332206961614848