- 2019/05/12 作成
Contents |
概略
pbs.twimg の画像 URL (たとえば https://pbs.twimg.com/media/D5tPa7vUIAAePq2.jpg とか) から投稿日時を復元できないかなーと思って調べた.
結論
以下の手順により,画像ファイル名から,画像アップロード開始時刻がエポックミリ秒で得られる.
- 画像ファイル名を URL セーフ Base64 デコードする
- それを 46 bit 右シフトする
- それに
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
= 1556755751389
⇔ 2019-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