pbs.twimg の画像ファイル名から画像アップロード開始時刻がわかる

覚書


  • 2019/05/12 作成

Contents


概略

pbs.twimg の画像 URL (たとえば https://pbs.twimg.com/media/D5tPa7vUIAAePq2.jpg とか) から投稿日時を復元できないかなーと思って調べた.

結論

以下の手順により,画像ファイル名から,画像アップロード開始時刻がエポックミリ秒で得られる

  1. 画像ファイル名を URL セーフ Base64 デコードする
  2. それを 46 bit 右シフトする
  3. それに 1288834974657 (10進) を足す (→ エポックミリ秒が得られる)
    • さらに,それを 1000 で割ると普通の Unixtime が得られる!

基本的なこと

twimg の画像ファイル名の決定スキームは,あまり知られていない(はず).

上のページによると,一般的には「ユーザが投稿した生のファイル名をそのまま使うと,ファイル名の重複が生じるなどの不都合が生じるので,これを防ぐためにサーバ側で相異なるファイル名を割り当てている」という程度しか認識されていない.

もう少し調べると,すぐに以下の記事が出てくる.

Twitterのツイート ID が snowflake で生成されるというのは有名な話.では画像ファイル名はどうかというと,ファイル名冒頭が似通っていることから,snowflake と似たようなことをやっていそうなことは判別できる.上の記事では,画像ファイル名から media_id が取り出せることが示されている.

まずは,ツイート ID に関して snowflake の挙動を確認し,上述の記事で取り組まれたことを順に辿っておく.

ツイート ID 生成規則

まずは,ツイート ID の生成規則について復習.

ツイートURLがhttps://twitter.com/aiueo_666/status/1123741185531678721の場合を考える.ツイート ID は10進で1123741185531678721,つまり2進で111110011000010101001010011110000111000101111001000000000001 (60 bit) である.

snowflake の生成する ID は 64 bit の long 値なので,実際の ID は先頭に 0 を 4 bit 繋げた0000111110011000010101001010011110000111000101111001000000000001である.

これを0 (1 bit), 00011111001100001010100101001111000011100 (41 bit), 0101111001 (10 bit), 000000000001 (12 bit) の 4 つに分解する.

  • 先頭の 1 bit (0): 常に 0.
  • 次の 41 bit (00011111001100001010100101001111000011100, 10進で267920776732): timestamp, unixtime(ms) から 2010/11/04 10:42:54 の unixtime(ms) である 1288834974657 を引いた値.
  • 次の 10 bit (0101111001, 10進で377): machine id = datacenter id + worker id.
  • 最後の 12 bit (000000000001, 10進で1): sequence 番号,生成器ごとに採番.
ビット数 1 bit 41 bit 10 bit 12 bit
ビット列 0 00011111001100001010100101001111000011100 0101111001 000000000001
10進表記 0 267920776732 377 1
用途 常に0 timestamp machine id sequence 番号

大事なのは 41 bit の timestamp.1288834974657を足すとエポックミリ秒が得られる.267920776732 + 1288834974657 = 15567557513892019-05-02 09:09:11.389 +0900

つまり,ツイート ID の下位 22 bit を削って 1288834974657 を足すと,そのツイートの投稿日時がエポックミリ秒で得られる.Ruby なら次のような手順で Time インスタンスが得られる.

# ツイート ID
i_id = 1123741185531678721
# 日時
time = Time.at(((i_id >> 22) + 1288834974657) / 1000.0) # => 2019-05-02 09:09:11 +0900

これは有名な話なので,詳細は他に譲る.

API で取得できる情報

以降のサンプルでも https://twitter.com/aiueo_666/status/1123741185531678721 を用いる.このツイートの status を API から取得すると,次のような情報が得られる.このツイートには3枚の画像が関連付けられているが,extended_entitiesの中に3枚とも入っている.

{
	"created_at":"Thu May 02 00:09:11 +0000 2019",
	"id":1123741185531678721,
	(中略)
	"entities":{
		(中略)
		"media":[
			{
				"id":1123741155445886977,
				(中略)
				"media_url_https":"https://pbs.twimg.com/media/D5hUoIXW0AEbeni.jpg",
				"url":"https://t.co/8OAhmiEt2T",
				"display_url":"pic.twitter.com/8OAhmiEt2T",
				"expanded_url":"https://twitter.com/aiueo_666/status/1123741185531678721/photo/1",
				(中略)
			}
		]
	},
	"extended_entities":{
		"media":[
			{
				"id":1123741155445886977,
				(中略)
				"media_url_https":"https://pbs.twimg.com/media/D5hUoIXW0AEbeni.jpg",
				"url":"https://t.co/8OAhmiEt2T",
				"display_url":"pic.twitter.com/8OAhmiEt2T",
				"expanded_url":"https://twitter.com/aiueo_666/status/1123741185531678721/photo/1",
				(中略)
			},
			{
				"id":1123741166439206912,
				(中略)
				"media_url_https":"https://pbs.twimg.com/media/D5hUoxUXkAAzjIu.jpg",
				"url":"https://t.co/8OAhmiEt2T",
				"display_url":"pic.twitter.com/8OAhmiEt2T",
				"expanded_url":"https://twitter.com/aiueo_666/status/1123741185531678721/photo/1",
				(中略)
			},
			{
				"id":1123741175645704194,
				(中略)
				"media_url_https":"https://pbs.twimg.com/media/D5hUpTnXkAICToq.jpg",
				"url":"https://t.co/8OAhmiEt2T",
				"display_url":"pic.twitter.com/8OAhmiEt2T",
				"expanded_url":"https://twitter.com/aiueo_666/status/1123741185531678721/photo/1",
				(中略)
			}
		]
	},
	(中略)
	"user":{
		"id":1100068929697808384,
		(中略)
	},
	(中略)
}

なお,media 配下に含まれる各要素に割り当てられている id は,公式 API のエンドポイント media/upload のレスポンス json 内では media_id と記載されている.以降では,ツイートに割り当てられた id を「ツイート ID」,画像に割り当てられた ID を「media_id」と呼ぶこととする.

画像ファイル名の規則を探る

上述の画像ファイル名は以下の通り.拡張子は取り除いてある.

  • D5hUoIXW0AEbeni
  • D5hUoxUXkAAzjIu
  • D5hUpTnXkAICToq

これらを Base64 デコードすると,16進で 22 桁 (= 88 bit) の次のようなデータになる.以降,これをデコード済みファイル名と呼ぶことにする.

  • 0f9854a085d6d0011b7a78
  • 0f9854a315179000338c8b
  • 0f9854a539d79002024e8a

さて,API から取得した情報によると,これらに対応する media_id (10進表記,64-bit integer) は次のとおりである.

  • 1123741155445886977
  • 1123741166439206912
  • 1123741175645704194

これらを16進に直すと,media_id は次のように表記できる.前述のデコード済みファイル名と比較してほしい.

  • 0f9854a085d6d001
  • 0f9854a315179000
  • 0f9854a539d79002

そう,なんと,デコード済みファイル名の先頭 64 bit は media_id と一致するのである.

ここまででわかった,デコード済みファイル名のビット構成とデータ例は次の通り.

ビット数 88 bit
64 bit 24 bit
16進表記 0f 98 54 a0 85 d6 d0 01 1b 7a 78
用途 media_id ???

残り 24 bit の規則は?

デコード済みファイル名 88 bit 中 64 bit は media_id であることがわかった.では,残りの 24 bit は何を表しているのか?

その 24 bit を取り出すと16進では次の通り.

  • 1b7a78
  • 338c8b
  • 024e8a

10進だと次の通り.

  • 1800824
  • 3378315
  • 151178

2進では次の通り.

  • 000110110111101001111000
  • 001100111000110010001011
  • 000000100100111010001010

何の規則で並んでるのか,全然わからない.何かしらの画像固有の情報なのだろうが,規則はわからない.画像のサイズ・フォーマット・透過情報などの情報が埋め込まれていたら面白そうだが,サンプルをたくさん集めて検証しないとわからない.今回はギブアップ.

ツイート ID と media_id の関係

media_id と ツイート ID を並べてみる.

  • 1123741155445886977 (画像1枚目の media_id)
  • 1123741166439206912 (画像2枚目の media_id)
  • 1123741175645704194 (画像3枚目の media_id)
  • 1123741185531678721 (ツイート ID)

なんとなく似た数字が並んでいるので,media_id ってもしかしてツイート ID と同じビット構成になっているのでは?と思うことだろう.それを確かめてみたい.まず,これらを10進数とみなし,2進表記に直す.

  • 0000111110011000010101001010000010000101110101101101000000000001
  • 0000111110011000010101001010001100010101000101111001000000000000
  • 0000111110011000010101001010010100111001110101111001000000000010
  • 0000111110011000010101001010011110000111000101111001000000000001

media_id もツイート ID と同様のビット構成になっていると仮定し,ビット列を分割して整理する.

ビット数 1 bit 41 bit 10 bit 12 bit
用途 常に0 timestamp machine id sequence 番号
画像1 0 00011111001100001010100101000001000010111 0101101101 000000000001
画像2 0 00011111001100001010100101000110001010100 0101111001 000000000000
画像3 0 00011111001100001010100101001010011100111 0101111001 000000000010
ツイートID 0 00011111001100001010100101001111000011100 0101111001 000000000001

分かりづらいので10進表記に直す.

ビット数 1 bit 41 bit 10 bit 12 bit
用途 常に0 timestamp machine id sequence 番号
画像1 0 267920769559 365 1
画像2 0 267920772180 377 0
画像3 0 267920774375 377 2
ツイートID 0 267920776732 377 1

どうやら,マシン ID はツイート ID に含まれるものと一緒だったり一緒じゃなかったりするらしい.ところがタイムスタンプは全部バラバラ.ここでタイムスタンプだけ取り出して時刻表記にする.

- timestamp
画像1 2019-05-02 09:09:04.216 +0900
画像2 2019-05-02 09:09:06.837 +0900
画像3 2019-05-02 09:09:09.032 +0900
ツイートID 2019-05-02 09:09:11.389 +0900

日時としてヘンテコなものが出てこないところから推察するに,media_id はツイート ID と同じようなビット構成であるといっても差し支えない.

この前提に基づくと,時刻は 画像1 → 画像2 → 画像3 → ツイートID の順に変化している.間隔はどれも2秒強になっているが,たまたまだろうか?この時刻は,画像については投稿日時に類するものと認識しても問題ないのか?画像の日時情報はアップロード日時なのか?それとも別の何かなのか?もっとちゃんと調べる必要がある.

画像のタイムスタンプは何の時刻?

画像のタイムスタンプが何を指しているのか軽く確かめるために,テストツイートをしてみた.

テストツイート https://twitter.com/su_te_ak/status/1126866052296523779 は,

  • 2019-05-11 00:00:16 +0900 頃に Twitter タイムラインを PC ブラウザ上で開き.
  • 2019-05-11 00:01:16 +0900 頃に投稿フォームにフォーカスを移して投稿文を入力,
  • 2019-05-11 00:02:16 +0900 頃に 1 枚目の画像を入力し,
  • 2019-05-11 00:03:16 +0900 頃に 2 枚目の画像を入力し,
  • 2019-05-11 00:04:16 +0900 頃に 3 枚目の画像を入力し,
  • 2019-05-11 00:05:16 +0900 頃に 4 枚目の画像を入力し,
  • 2019-05-11 00:06:16 +0900 頃に「ツイート」ボタンを押下する

という手順で投稿したツイートである.

- filename
(deecoded)
ID timestamp
画像1 D6NtyitUwAU7xA4
(18905658293861827489809422)
1126865046850551813 2019-05-11 00:02:17 +0900
画像2 D6NuA_iUUAA3oF2
(18905662458916945501069405)
1126865295107182592 2019-05-11 00:03:17 +0900
画像3 D6NuQFZV4AAELuF
(18905666808762584826916577)
1126865554378186752 2019-05-11 00:04:18 +0900
画像4 D6NueKDUUAABuuP
(18905670864956018728745699)
1126865796146155520 2019-05-11 00:05:16 +0900
ツイート - 1126866052296523779 2019-05-11 00:06:17 +0900

PC ブラウザ上から画像つきツイートを投稿すると,画像に対応付けられる時刻は「ツイート」ボタンを押下するタイミングではなく,画像を入力した時刻になっているようである.この時刻がローカルの時刻ではないであろうことは容易に推測できるので,画像を入力したタイミングでアップロード・DB登録まで行われているらしいと推測することもできる.挙動的には公式 API の media/upload を画像入力直後に走らせるのと同じである.

PC ブラウザから画像を投稿する場合の挙動を調べるために,インスペクタを起動して Network log を監視してみた.すると,画像を入力したタイミングで,1 つの画像ファイルについて upload.json へのリクエストが何度も行われていた.1 回目は画像情報の登録,2 回目以降は画像バイナリの送信を一定容量ごとに区切って行うという仕様らしい.そして,1 回目のリクエストの応答が json になっており,そこに media_id が含まれていた.つまり,画像を入力すると即座に,画像ファイルのバイナリのアップロード開始よりも先に media_id が割り当てられる.したがって,PC ブラウザから画像を投稿する場合,media_id に含まれるタイムスタンプは画像を入力した時刻であるといえる.

なお,以上の話は PC ブラウザから画像を投稿する場合の話であり,API 経由でアップロードする場合は media/upload リクエストに対応するタイムスタンプが割り当てれられる.したがって,API を利用するクライアントアプリケーションから投稿された画像付きツイートについては,画像のタイムスタンプはクライアントアプリケーションが画像アップロードを開始した時刻であり,それが画像を入力した時刻なのか,ツイート投稿を確定して以降の時刻なのかは,クライアントアプリケーションの実装に依存する.

画像のタイムスタンプまとめ

デコード済みファイル名の先頭 64 bit が media_id であり,その 64 bit のうち 2 bit 目から 42 bit 目までの 41 bitが時刻情報である.したがって,デコード済みファイル名の 2 bit 目から 42 bit 目までの 41 bitが時刻情報である.

この時刻情報は,画像バイナリのサーバへのアップロードが開始された時刻に対応している.API を利用するクライアントアプリケーションの場合は,実装によってはツイートを確定した時刻になっている場合もあるし,画像を入力した時刻になっている場合もある.PC ブラウザから画像を投稿する場合は,画像を入力した時刻である.

Base64 decode の補足

画像ファイル名は,URL セーフになるように -, _ を用いた形にエンコードされている.+, /, = は使っていない.このようなエンコードのしかたを俗に「'base64url' エンコーディング」というらしい.

これをデコードするとき,たとえば Ruby なら既にそれ用の方法が用意されているので使った方が楽.以下の記事なんかは参考になるかもしれない.

Base64.urlsafe_decode64 はパディングが有っても無くても期待通り動いてくれるので,デコードだけなら新たにメソッド書き起こす必要はない.…と思ったが,ArgumentError: invalid base64と言われることが結構よくあるので,次のようにするのが安全.

require "base64"

module Base64
	def self.my_decode64(str)
		decode64(str.gsub('-','+').gsub('_','/'))
	end
end

s_filename = 'D5hUoIXW0AEbeni'
Base64.my_decode64(s_filename) # => "\x0F\x98T\xA0\x85\xD6\xD0\x01\ezx"
Base64.urlsafe_decode64(s_filename) # => ArgumentError: invalid base64

デコード済みファイル名のビット構成まとめ

デコード済みファイル名 (ファイル名を URL セーフ Base64 デコードしたもの) のビット構成と例を以下に示す.

ビット数 64 bit 24 bit
1 bit 41 bit 10 bit 12 bit
ビット列 0f 98 54 a0 85 d6 d0 01 1b 7a 78
0 00011111001100001010100101000001000010111 0101101101 000000000001
用途 media_id ???
常に0 timestamp machine id sequence 番号

結論

デコード済みファイル名 88 bit のうち先頭 64 bit が media_id (snowflake 方式) になっていて,末尾の残り 24 bit はこれに無関係である.また,media_id から下位 22 bit を削ぎ落として残ったビット列に 1288834974657 を足すとエポックミリ秒 (1970/01/01 00:00:00 +0000 基準) が得られる.

つまり,ファイル名を URL セーフ Base64 デコードし,下位 46 bit を削ぎ落し,1288834974657 を足すと,画像をサーバにアップロード開始した時刻がエポックミリ秒で得られる

Ruby なら次のような手順で日時が得られる.

require "base64"

module Base64
	def self.my_decode64(str) # urlsafe_decode64 の代わり
		decode64(str.gsub('-','+').gsub('_','/'))
	end
end

# 画像ファイル名
s_filename = 'D5hUoIXW0AEbeni'
# デコード済みファイル名
i_filename = Base64.my_decode64(s_filename).bytes.inject {|memo, byte| (memo << 8) + byte } # => 18853248093005222126516856
# 日時
time = Time.at(((i_filename >> 46) + 1288834974657) / 1000.0) # => 2019-05-02 09:09:04 +0900