1 # -*- coding: utf-8 -*-
3 # Copyright © 2014 dyknon
5 # This file is part of Pylib-nicovideo.
7 # Pylib-nicovideo is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 動画情報やらなにやらをお気楽に取得できるライブラリみたいなものです。
24 そのため使い方によってはサーバーに過大な負荷がかかる可能性もあります。
25 サーバーに負荷がかかりすぎないよう、十分注意して使ってください。
33 import xml.etree.ElementTree as XmlTree
38 """ニコニコに投稿された動画にアクセスするためのClass"""
39 def __init__(self, wid):
41 self.info_downloaded = {}
46 return "http://www.nicovideo.jp/watch/" + self.watch_id
48 def standard_info_url(self):
49 return "http://ext.nicovideo.jp/api/getthumbinfo/" + self.watch_id
51 def get_standard_info(self):
52 #ニコニコ動画動画に関する基本的な情報を得ることのできる
53 #有名なAPIであるhttp://ext.nicovideo.jp/api/getthumbinfo/を
54 #利用して得ることのできる情報を取得します。
55 #要修正:いくつか取得していない情報があります
57 if "thumbinfo" in self.info_downloaded:
62 thumbinfo["tags"] = []
64 conn = http.client.HTTPConnection("ext.nicovideo.jp")
65 conn.request("GET", "/api/getthumbinfo/" + self.watch_id)
66 res = conn.getresponse()
69 xmlroot = XmlTree.fromstring(resdata)
70 if xmlroot.tag != "nicovideo_thumb_response":
71 raise err.InterpretErr("ext.nicovideo.jp/api/getthumbinfo/")
72 if xmlroot.attrib["status"] != "ok":
75 if child.tag == "error":
77 if cerror.tag == "code":
79 raise err.NotFound(self.watch_id, errcode)
80 if len(xmlroot) != 1 or \
81 xmlroot[0].tag != "thumb":
82 raise err.InterpretErr("ext.nicovideo.jp/api/getthumbinfo/")
83 for info in xmlroot[0]:
84 if info.tag == "video_id":
85 self.info["videoid"] = info.text
86 elif info.tag == "title":
87 self.info["title"] = info.text
88 self.title = info.text
89 elif info.tag == "description":
90 self.info["description"] = info.text
91 elif info.tag == "thumbnail_url":
92 self.info["thumbnail_url"] = info.text
93 elif info.tag == "first_retrieve":
94 self.info["upload_time"] = tools.parse_time(info.text)
95 elif info.tag == "length": #5:19
96 if not "length" in self.info:
97 spt = info.text.split(":")
98 self.info["length"] = int(spt[0])*60 + int(spt[1])
99 elif info.tag == "movie_type": #flv
100 self.info["movie_type"] = info.text
101 elif info.tag == "size_high": #21138631
102 if not "videosize" in self.info:
103 self.info["videosize"] = {}
104 self.info["videosize"]["high"] = int(info.text)
105 elif info.tag == "size_low": #17436492
106 if not "videosize" in self.info:
107 self.info["videosize"] = {}
108 self.info["videosize"]["low"] = int(info.text)
109 elif info.tag == "view_counter":
110 self.info["view_counter"] = int(info.text)
111 elif info.tag == "comment_num":
112 self.info["comment_num"] = int(info.text)
113 elif info.tag == "mylist_counter":
114 self.info["mylist_counter"] = int(info.text)
115 elif info.tag == "last_res_body": #comments_new
116 self.info["last_res"] = info.text
117 elif info.tag == "watch_url":
119 elif info.tag == "thumb_type": #video
120 self.info["thumb_type"] = info.text
121 elif info.tag == "embeddable":
122 self.info["downloadable"] = int(info.text) != 0
123 elif info.tag == "no_live_play":
124 self.info["live_play"] = int(info.text) == 0
125 elif info.tag == "tags":
126 for tag_entry in info:
127 if tag_entry.tag != "tag":
129 if "lock" in tag_entry.attrib:
130 if int(tag_entry.attrib["lock"]):
132 append([tag_entry.text, True])
135 append([tag_entry.text, False])
137 thumbinfo["tags"].append([tag_entry.text, False])
138 self.info["tags"] = thumbinfo["tags"]
139 elif info.tag == "user_id":
140 self.info["user_id"] = info.text
141 thumbinfo[info.tag] = info.text
144 raise err.HttpErr(res.status, res.reason)
146 self.info_downloaded["thumbinfo"] = thumbinfo
148 def get_player_info(self):
149 #外部プレイヤーの呼び出しを行うJavascriptを解析(笑)して
151 #ここで取得できる情報には、コメントや動画URLを特定する手がかりが
152 #多く存在していますが、同時に何を意味するのかわからない情報も
155 if "playerinfo" in self.info_downloaded:
160 rawdata = tools.http_get_text_data("ext.nicovideo.jp", "/thumb_watch/" + self.watch_id)
161 pattern_playerURL_finder = re.compile(r"^\s*Nicovideo\.playerUrl\s*=\s*'(.+)'\s*;\s*$")
162 pattern_start_varvideo_finder = re.compile(r"^\s*var\s+video\s*=\s*new\s+Nicovideo\.Video\s*\(\s*\{\s*$")
163 pattern_end_varvideo_finder = re.compile(r"^(.*)\}\s*\)\s*;\s*$")
164 pattern_end_varplayer_finder = re.compile(r"^\s*}")
165 pattern_item_interrupter = re.compile(r"^\s*(.+)\s*:\s*(.+)\s*$")
166 pattern_item_interrupter2 = re.compile(r"^\s*'(.+)'\s*:\s*'(.+)'\s*$")
168 for line in rawdata.split("\n"):
170 mo = pattern_playerURL_finder.search(line)
172 player_info["player_url"] = mo.group(1)
173 self.info["player_url"] = mo.group(1)
176 if pattern_start_varvideo_finder.search(line):
179 mo = pattern_end_varvideo_finder.search(line)
183 for item in line.split(","):
184 mo = pattern_item_interrupter.search(item)
189 self.info["v_value"] = tools.un_javascript_escape(value[1:-1])
191 self.info["videoid"] = tools.un_javascript_escape(value[1:-1])
192 elif name == "title":
193 self.info["title"] = tools.un_javascript_escape(value[1:-1])
194 self.title = self.info["title"]
195 elif name == "description":
196 self.info["description"] = tools.un_javascript_escape(value[1:-1])
197 elif name == "thumbnail":
198 self.info["thumbnail_url"] = tools.un_javascript_escape(value[1:-1])
199 elif name == "postedAt":
201 elif name == "length":
202 self.info["length"] = int(value)
203 elif name == "viewCount":
204 self.info["view_counter"] = int(value)
205 elif name == "mylistCount":
206 self.info["mylist_counter"] = int(value)
207 elif name == "commentCount":
208 self.info["comment_num"] = int(value)
209 elif name == "movieType":
210 self.info["movie_type"] = tools.un_javascript_escape(value[1:-1])
211 elif name == "isDeleted":
212 self.info["is_deleted"] = (value == "true")
213 elif name == "isMymemory":
215 self.info["thumb_type"] = "mymemory"
217 if not "thumb_type" in self.info:
218 self.info["thumb_type"] = "video"
219 player_info[name] = value
223 #変数Playerは場合によって要素数が変化し、
227 #特別に変数を割り当てていないので、これらには
228 #object.player_info[<要素名>] でアクセスしてください。
229 if pattern_end_varplayer_finder.search(line):
231 for item in line.split(","):
232 mo = pattern_item_interrupter2.search(item)
234 name = tools.un_javascript_escape(mo.group(1))
235 value = tools.un_javascript_escape(mo.group(2))
236 if name == "thumbPlayKey":
237 self.info["thumb_playkey"] = value
238 elif name == "playerTimestamp":
239 self.info["thumb_player_timestamp"] = value
240 elif name == "language":
241 self.info["language"] = value
242 #以下の要素は存在しない場合も確認されていますが、
244 #なお、真偽値についてはFalseの場合に
246 elif name == "has_owner_thread":
248 self.info["comment_has_owner_thread"] = False
250 self.info["comment_has_owner_thread"] = True
251 elif name == "isWide":
253 self.info["is_wide"] = False
255 self.info["is_wide"] = True
256 player_info[name] = value
258 raise err.InterpretErr("ext.nicovideo.jp/thumb_watch/")
259 self.info_downloaded["playerinfo"] = player_info
261 def get_play_info(self):
262 #get_player_info()によって取得できた情報を元に、
264 #動画ファイルのURLや、それにアクセスするための情報などを取得します。
268 #ニコニコ動画から提供されるプレイヤーが行うことなので、
269 #連続アクセスなどでサーバーに負荷がかからないよう
272 if "playinfo" in self.info_downloaded:
277 self.get_player_info()
278 if not "player_url" in self.info or \
279 not "thumb_playkey" in self.info or \
280 not "thumb_player_timestamp" in self.info:
281 raise err.ApiUpdated("再生情報が十分に得られませんでした。")
283 spurl = urllib.parse.urlsplit(self.info["player_url"])
284 if spurl[0] != "http" or spurl[1] == "":
285 raise err.ApiUpdated("外部プレイヤーURLがhttp://から始まっていない")
286 conn = http.client.HTTPConnection(spurl[1])
288 conn.request("GET", spurl[2])
290 conn.request("GET", spurl[2] + "?" + spurl[3])
291 res = conn.getresponse()
292 if res.status != 200:
294 raise err.HttpErr(res.status, res.reason)
297 conn = http.client.HTTPConnection("ext.nicovideo.jp")
298 conn.request("GET", "/swf/player/nicoplayer.swf?thumbWatch=1&ts=" + self.info["thumb_player_timestamp"])
299 res = conn.getresponse()
300 if res.status != 200:
302 raise err.HttpErr(res.status, res.reason)
305 postmes = "k=" + urllib.parse.quote_plus(self.info["thumb_playkey"]) + "&as3=1&v=" + urllib.parse.quote_plus(self.info["v_value"])
306 logger.debug("play_info_req:\n" + postmes)
307 conn = http.client.HTTPConnection("ext.nicovideo.jp")
308 conn.request("POST", "/thumb_watch", body=postmes, headers={"Content-Length": len(postmes), "Content-Type": "application/x-www-form-urlencoded"})
309 res = conn.getresponse()
310 if res.status == 200:
312 cookie_raw = res.getheader("Set-Cookie")
313 if cookie_raw == None:
314 raise ApiUpdated("responce of /thumb_watch doesn't include needed information")
315 cookie = cookie_raw[:cookie_raw.find(";")]
316 self.info["playinfo_cookie"] = cookie
318 #このレスポンスは解析があまり進んでいないため、
319 #意味の理解できていない要素も含めてすべて保存します。
320 raw_body = res.read().decode("ascii", "replace")
321 logger.debug("play_info:\n" + raw_body)
322 for item in raw_body.split("&"):
325 play_info[urllib.parse.unquote_plus(item)] = True
327 play_info[urllib.parse.unquote_plus(item[:ind])] = urllib.parse.unquote_plus(item[ind+1:])
328 if "thread_id" in play_info:
329 self.info["play_thread_id"] = play_info["thread_id"]
330 if "nicos_id" in play_info:
331 self.info["play_nicos_id"] = play_info["nicos_id"]
332 if "ms" in play_info:
333 self.info["play_comments_url"] = play_info["ms"]
334 if "url" in play_info:
335 self.info["play_video_url"] = play_info["url"]
338 raise err.HttpErr(res.status, res.reason)
340 self.info_downloaded["playinfo"] = play_info
342 def generate_connection_to_coments(self):
343 #get_play_info()などで取得した情報を元に、
344 #ニコニコ動画のプレイヤーがサーバーから取得しているものに近い
346 #HTTPConnectionオブジェクトを生成し、
347 #requestまで行ったものを返します。
348 #つまり、getresponse()でレスポンスを得ることができる状態です。
352 #ニコニコ動画から提供されるプレイヤーが行うことなので、
353 #連続アクセスなどでサーバーに負荷がかからないよう
357 self.get_standard_info()
359 if not "play_thread_id" in self.info or \
360 not "play_comments_url" in self.info or \
361 not "length" in self.info:
362 raise ApiUpdated("コメント取得に必要な情報が取得できませんでした")
364 #コメントAPIに送信する情報には謎が多いので
366 if self.info["length"] < 60:
368 elif self.info["length"] < 300:
370 elif self.info["length"] < 600:
374 comment_from = "{}".format(comment_from)
379 #threads += '<thread thread="' + self.play_thread_id + '" version="20061206" res_from="' + comment_from + '" scores="1"/>'
380 threads += '<thread thread="' + self.info["play_thread_id"] + '" version="20090904" res_from="' + comment_from + '" scores="1"/>'
381 #時間帯ごとのコメント追加分(暫定処理,多分要修正)
382 #要解析:このスレッドについてはリクエストの詳細がわかりません。
383 #lvnは↑で取得した個数(これに含まれないものが取得できる)
384 #minsは動画の長さ(分)を小数点以下切り捨てしたものですが、
386 #レスポンスから推測すると、何かしらの間違いがあると思われます。
387 lvn = "{}".format(-int(comment_from))
388 mins = "{}".format(int(self.info["length"] / 60))
389 threads += '<thread_leaves thread="' + self.info["play_thread_id"] + '" scores="1">0-' + mins + ':100,' + lvn + '</thread_leaves>'
391 #ニコスクリプトによって投稿されたコメント?
393 if "play_nicos_id" in self.info:
394 threads += '<thread thread="' + self.info["play_nicos_id"] + '" version="20090904" res_from="' + comment_from + '" scores="1"/>'
395 threads += '<thread_leaves thread="' + self.info["play_nicos_id"] + '" scores="1">0-' + mins + ':100,' + lvn + '</thread_leaves>'
398 if "comment_has_owner_thread" in self.info and \
399 self.info["comment_has_owner_thread"]:
400 threads += '<thread thread="' + self.info["play_thread_id"] + '" version="20090904" res_from="-1000" fork="1" click_revision="-1" scores="1"/>'
402 postmes = "<packet>" + threads + "</packet>";
403 spurl = urllib.parse.urlsplit(self.info["play_comments_url"])
404 if spurl[0] != "http" or spurl[1] == "":
405 raise ApiUpdated("Comment API URL doesn't start whith http://")
406 conn = http.client.HTTPConnection(spurl[1])
408 "Content-Length": len(postmes),
409 "Content-Type": "text/xml",
410 "Cookie": self.info["playinfo_cookie"]
413 conn.request("POST", spurl[2], body=postmes, headers=posthead)
415 conn.request("POST", spurl[2] + "?" + spurl[3], body=postmes, headers=posthead)
418 def download_comments_to_file(self, fileobj):
419 #get_play_info()などで取得した情報を元に、
420 #ニコニコ動画のプレイヤーがサーバーから取得しているものに近い
421 #コメントの情報を取得し、fileobjに書き出します。
422 #fileobjがio.TextIOBaseのサブクラスなら
424 #そうでない場合はバイナリを書き込みます。
428 #ニコニコ動画から提供されるプレイヤーが行うことなので、
429 #連続アクセスなどでサーバーに負荷がかからないよう
432 conn = self.generate_connection_to_coments()
433 res = conn.getresponse()
434 if res.status == 200:
435 if isinstance(fileobj, io.TextIOBase):
436 #これくらいならいっぺんに書き込んでもいいかな?
437 #というか、utf-8でエンコードされたものを
438 #分割してデコードするなんてレベル高すぎて無理です
439 fileobj.write(res.read().decode("utf-8", "replace"))
441 #小分けにしたほうが負荷が少ないんじゃないかと思うけど
442 #CPUに対する負荷はかえって増える気がする。(主に言語的に)
451 raise err.HttpErr(res.status, res.reason)
454 def generate_connection_to_video(self):
455 #get_play_info()などで取得した情報を元に、
457 #HTTPConnectionオブジェクトを生成し、
458 #requestまで行ったものを返します。
459 #つまり、getresponse()でレスポンスを得ることができる状態です。
463 #ニコニコ動画から提供されるプレイヤーが行うことなので、
464 #連続アクセスなどでサーバーに負荷がかからないよう
468 if not "play_video_url" in self.info:
469 raise ApiUpdated("動画DLに必要な情報を取得できませんでした。")
471 spurl = urllib.parse.urlsplit(self.info["play_video_url"])
472 if spurl[0] != "http" or spurl[1] == "":
473 raise ApiUpdated("Video URL doesn't start whith http://")
474 conn = http.client.HTTPConnection(spurl[1])
476 "Cookie": self.info["playinfo_cookie"]
479 conn.request("GET", spurl[2], headers=gethead)
481 conn.request("GET", spurl[2] + "?" + spurl[3], headers=gethead)
484 def download_movie_to_file(self, fileobj):
485 #get_play_info()などで取得した情報を元に、
486 #動画をDLし、fileobjに書き出します。
490 #ニコニコ動画から提供されるプレイヤーが行うことなので、
491 #連続アクセスなどでサーバーに負荷がかからないよう
494 conn = self.generate_connection_to_video()
495 res = conn.getresponse()
496 if res.status == 200:
504 raise err.HttpErr(res.status, res.reason)
508 def __init__(self, uid):
510 self.info_downloaded = {}
514 def get_standard_info(self):
515 #ユーザー情報埋め込み用のhttp://ext.nicovideo.jp/thumb_user/から
517 if "thumb" in self.info_downloaded:
522 data = tools.http_get_text_data(
523 "ext.nicovideo.jp", "/thumb_user/" + self.id)
525 mo = re.search("<p (?:[^>]* )?class=\"TXT12\"[^>]*>(.+?)</p>", data)
527 logger.debug("TXT12発見できず")
528 raise err.ApiUpdated("ユーザー情報取得失敗")
529 if mo.group(1).find("<strong>") < 0:
530 raise err.NotFound(self.id, "ニコニコ曰く:" + mo.group(1))
532 smo = re.search("<strong>([^<]*?)</strong>", mo.group(1))
534 logger.debug("TXT12内にstrongブロック無し")
535 raise err.ApiUpdated("ユーザー情報取得失敗")
536 self.name = smo.group(1)
537 self.info["name"] = self.name
538 gotten["name"] = self.name
540 mo = re.search("<div (?:[^>]* )?class=\"user_img\"[^>]*>(.+?)</div>",
543 logger.debug("div id:user_img見つからず")
544 raise err.ApiUpdated("ユーザー情報取得失敗")
545 imo = re.search("<img (?:[^>]* )?src=\"([^\"]+)\"", mo.group(1))
547 logger.debug("div id:user_img内にimg見つからず")
548 raise err.ApiUpdated("ユーザー情報取得失敗")
549 self.info["img"] = imo.group(1)
550 gotten["img"] = self.info["img"]
551 if self.info["img"] == "http://res.nimg.jp/img/user/thumb/blank.jpg":
552 self.info["blank_img"] = True
554 self.info["blank_img"] = False
555 gotten["blank_img"] = self.info["blank_img"]
557 self.info_downloaded["thumb"] = gotten