Facebook
From Commodious Meerkat, 3 Years ago, written in Python.
This paste is a reply to Re: Re: Untitled from Colossal Leopard - view diff
Embed
Download Paste or View Raw
Hits: 246
  1. import time
  2. import sys
  3. import locale
  4. import os
  5. import math
  6. import re
  7. import datetime
  8. import dateutil.parser
  9. from typing import Dict, Tuple, List
  10. import urllib.request
  11. import urllib.parse
  12. import http.client
  13.  
  14. from dateutil.parser import parse
  15.  
  16.  
  17. """
  18. ニコニコ実況のコメントをスレッドごとに保存するpythonスクリプト
  19. 保存場所は./logs/
  20. ライセンス:NYSL http://www.kmonos.net/nysl/
  21.  
  22. 制限
  23. ・threadは1つ24時間、朝4時更新前提とする
  24. ・vposが無いchat要素は無視。保存しない。stderrには流すので、大量に出てないか確認する事。
  25. ・・例:<chat thread="1600196401" no="46475" date="1600282802" date_usec="245298" mail="184" user_id="o4fQ5eBaInXz_RzBrw6yWR5IPn8" anonymity="1">平面大面積農地から、太陽光分光+多層農地へ</chat>
  26. ・コメントが1日通して0件の場合、getflvのend_timeという項目が異常な値(1970年付近や、-62170016397というマイナス値)になるので、その場合は2000/01/01 00:00:00とする
  27.  
  28. todo
  29. ・コメントのxmlのバリデーションが不十分。コメントを削除された時は?コメント本文に</chat> がある場合は?→対応したはずだが、新しいフォーマットが何時出てくるかわからんので要注意
  30. <chat thread="1603998001" no="1530" vpos="1075600" date="1604008756" date_usec="765453" deleted="2" anonymity="1"/>
  31. <chat thread="1603998001" no="1531" vpos="1076355" date="1604008766" date_usec="91256" mail="184" user_id="HtKW_y6IW7z0eRf3WQ7iZM45mek" premium="1" anonymity="1">今夜は寒いぞ</chat>'
  32.  
  33. コメントxmlのレアパターン
  34. ・コメントに改行が入る場合がある。ので、正規表現はdotall必須
  35. ・vposが-4707799 とかのすごい値になる事がある。dateプロパティと前後のコメントは普通。vposは処理には関係ないのでそのまま保存
  36. """
  37.  
  38.  
  39. class ChatXml:
  40.     def __init__(self, input: str) -> None:
  41.         self.originalXml = input
  42.         self.formattedXml = self._chatElementInsertStr(input)
  43.         self.xmlData = self._getChatXmlData(input)
  44.         self.number = self.xmlData[0]
  45.  
  46.     def numerVposDateFormatStr(self) -> str:
  47.         dateStr = f"{self.xmlData[2]:%Y/%m/%d %H:%M:%S}"
  48.         return f"no = {self.xmlData[0]:>10} , vpos = {int(self.xmlData[1])} , date = {dateStr}"
  49.  
  50.     def _getChatXmlData(self, input: str) -> Tuple[int, int, datetime.datetime]:
  51.         """
  52.        xmlの文字列から、no vpos dateのプロパティを取得する。dateはdate型にする
  53.        """
  54.         noMatch = re.findall(r"no=\"(\d+)\"", input)
  55.         vposMatch = re.findall(r"vpos=\"(-?\d+)\"", input)
  56.         dateMatch = re.findall(r"date=\"(\d+)\"", input)
  57.         if len(noMatch) != 1:
  58.             raise Exception(f"no属性が0個もしくは2個以上。\n{input}")
  59.         if len(vposMatch) != 1:
  60.             raise Exception(f"vpos属性が0個もしくは2個以上。\n{input}")
  61.         if len(dateMatch) != 1:
  62.             raise Exception(f"date属性が0個もしくは2個以上。\n{input}")
  63.         return int(noMatch[0]), int(vposMatch[0]), datetime.datetime.fromtimestamp(int(dateMatch[0]))
  64.  
  65.     def _chatElementInsertStr(self, input: str) -> str:
  66.         """
  67.        <chat thread="1270407602" no="14239" vpos="7637459" date="1270483976" name="hoge" user_id="719" premium="3">アニヲタ</chat>
  68.        を、人間に見やすいように↓にする
  69.        <chat date_str="2020/01/01(月)00:00:00" vpos_str="00:00.000" thread="1270407602" no="14239" vpos="7637459" date="1270483976" name="hoge" user_id="719" premium="3">アニヲタ</chat>
  70.        """
  71.         if input.startswith("<chat ") == False:
  72.             raise Exception(f"chatエレメントが検出できない。 \"{input}\"")
  73.         xmlData = self._getChatXmlData(input)
  74.         locale.setlocale(locale.LC_ALL, 'ja_JP.UTF-8')
  75.         fDate = f"{xmlData[2]:%Y/%m/%d(%a)%H:%M:%S}"
  76.         fVpos = f"{(int(xmlData[1])/10):>10.1f}"
  77.         vposInt = int(xmlData[1])/100
  78.         if vposInt < 3600:
  79.             # 0分0.0秒
  80.             min = math.floor(vposInt/60)
  81.             sec = vposInt % 60
  82.             fVpos = f"{min}:{sec:05.2f}"
  83.         else:
  84.             # 0時間00分0.0秒
  85.             hour = math.floor(vposInt/3600)
  86.             min = math.floor(vposInt/60) % 60
  87.             sec = vposInt % 60
  88.             fVpos = f"{hour}:{min:02}:{sec:05.2f}"
  89.         result = input.replace("<chat ", f"<chat date_str=\"{fDate}\" vpos_str=\"{fVpos}\" ")
  90.         return result
  91.  
  92.  
  93. class Jikkyo:
  94.     def __init__(self, cookie: str, jkId: str, startDateUnixTimeSec: int) -> None:
  95.         self.cookie = cookie
  96.         self.jkId = jkId
  97.         self.startDateUnixTimeSec = startDateUnixTimeSec
  98.         self.getFlv = {}  # type: Dict[str,str]
  99.         self.waybackKey = ""
  100.  
  101.     def start(self):
  102.         self._getFlv()
  103.         self._getWaybackKey()
  104.         self._getThread()
  105.  
  106.     def _getFlv(self):
  107.         # endTimeは不要?
  108.         url = f"http://jk.nicovideo.jp/api/v2/getflv?v={self.jkId}&start_time={self.startDateUnixTimeSec}"
  109.         headers = {
  110.             'Content-Type': 'application/json',
  111.             "Cookie": f"user_session={self.cookie}"
  112.         }
  113.         req = urllib.request.Request(url, None, headers)
  114.         result: Dict[str, str] = {}
  115.         with urllib.request.urlopen(req) as res:
  116.             apiResponse = str(res.read().decode("utf-8")).split("&")
  117.             for a in apiResponse:
  118.                 [b, c] = a.split("=", 1)
  119.                 result[b] = urllib.parse.unquote(c)
  120.         self.getFlv = result
  121.         if True:
  122.             print(f"thread_id  : {unixTimeToStr(float(result['thread_id']))}")
  123.             print(f"base_time  : {unixTimeToStr(float(result['base_time']))}")
  124.             print(f"open_time  : {unixTimeToStr(float(result['open_time']))}")
  125.             print(f"start_time : {unixTimeToStr(float(result['start_time']))}")
  126.             print(f"end_time   : {unixTimeToStr(float(result['end_time']))}")
  127.         # thread_id base_time open_time start_time 。thread以外のbase open startは一致するはず。
  128.         # thread_idは、base open startと同じ場合もあれば、10秒以上ズレる事もある(jk番号ごとに処理をして、jk594みたいに番号の大きいチャンネルはズレが伸びる?jk1は99%ズレが無かった)。
  129.         # base open startは一致しているはず。thread_idは、baseから60秒以内 をエラーの基準とする
  130.         thread_id_diff = int(result["thread_id"]) - int(result["base_time"])
  131.         if result['base_time'] != result['open_time'] or result['open_time'] != result['start_time']:
  132.             raise Exception("一致するはずの値が不一致")
  133.         elif 60 <= thread_id_diff:
  134.             raise Exception(f"thread_idのズレが許容範囲以上の{thread_id_diff} 秒でした")
  135.  
  136.     def _getWaybackKey(self):
  137.         url = f"http://jk.nicovideo.jp/api/v2/getwaybackkey?thread={self.getFlv['thread_id']}"
  138.         headers = {
  139.             'Content-Type': 'application/json',
  140.             "Cookie": f"user_session={self.cookie}"
  141.         }
  142.         req = urllib.request.Request(url, None, headers)
  143.         with urllib.request.urlopen(req) as res:
  144.             apiResponse = str(res.read().decode("utf-8")).split("&")
  145.             for a in apiResponse:
  146.                 [b, c] = a.split("=", 1)
  147.                 if b == "waybackkey":
  148.                     self.waybackKey = urllib.parse.unquote(c)
  149.                     return
  150.         raise Exception("waybackKeyが取得出来ませんでした")
  151.  
  152.     def _getThread(self):
  153.         apiVersion = "20061206"
  154.         # スレッドの中にコメントが無い場合はend_timeが0や-62170016397 といった値になるので、その場合はstart_time+24時間を暫定的にwhen_parameteとする
  155.         whenParameter = max(int(self.getFlv['end_time']), int(self.getFlv['start_time']) + (24*60*60))
  156.         serverHost = f"{self.getFlv['ms']}:{self.getFlv['http_port']}"
  157.         userId = self.getFlv['user_id']
  158.         totalResult = []  # type: List[ChatXml]
  159.         allowRetry = True
  160.         while True:
  161.             time.sleep(1)
  162.             print(f"thread request. when = {datetime.datetime.fromtimestamp(whenParameter):%Y/%m/%d %H:%M:%S}")
  163.             url = f"http://{serverHost}/api/thread?thread={self.getFlv['thread_id']}&res_from=-1000&version={apiVersion}&when={whenParameter}&user_id={userId}&waybackkey={self.waybackKey}"
  164.             req = urllib.request.Request(url)
  165.             with urllib.request.urlopen(req) as res:
  166.                 try:
  167.                     apiResponse = str(res.read().decode("utf-8"))
  168.                 except (http.client.IncompleteRead) as e:
  169.                     if allowRetry == False:
  170.                         raise e
  171.                     allowRetry = True
  172.                     continue
  173.                 allowRetry = True
  174.                 chatMatchresult = re.findall(r"<chat .+?(?:/>|</chat>)", apiResponse, re.DOTALL)
  175.                 if len(chatMatchresult) == 0:
  176.                     break
  177.                 minDateUnixTimeSec = 9999999999
  178.                 maxValue = 0
  179.                 formattedChatXml = []  # type: List[str]
  180.                 for chat in chatMatchresult:
  181.                     try:
  182.                         chatXml = ChatXml(chat)
  183.                     except Exception as e:
  184.                         print(f"xmlのパースに失敗しました。スキップします。\n{e}", file=sys.stderr)
  185.                         continue
  186.                     totalResult.append(chatXml)
  187.                     maxValue = max(maxValue, chatXml.number)
  188.                     minDateUnixTimeSec = min(minDateUnixTimeSec, int(chatXml.xmlData[2].timestamp()))
  189.                     formattedChatXml.append(chat)
  190.                 if False:
  191.                     print("↓start")
  192.                     print("\n".join([i.numerVposDateFormatStr() for i in totalResult[0:2]]))
  193.                     print("↓end")
  194.                     print("\n".join([i.numerVposDateFormatStr() for i in totalResult[-2:]]))
  195.                     print("end")
  196.                 if len(totalResult) == 1:
  197.                     # 最後は1つしか返ってこない
  198.                     break
  199.                 whenParameter = minDateUnixTimeSec
  200.         if len(totalResult) == 0:
  201.             print("no chat xml reseived")
  202.             self._saveResult([])
  203.         else:
  204.             self._saveResult(totalResult)
  205.  
  206.     def _saveResult(self, chatXmlList: List[ChatXml]):
  207.         chatXmlList = sorted(chatXmlList, key=lambda x: x.number)
  208.         os.makedirs(f"./logs/{self.jkId}", exist_ok=True)
  209.         threadNo = int(self.getFlv["thread_id"])
  210.         endDateUnixTime = max(int(self.getFlv['end_time']), parse("2000/01/01 00:00:00").timestamp())  # end_timeの値は極端に小さな値が来る事があるので、その場合は固定値を返す
  211.         endDateObj = datetime.datetime.fromtimestamp(endDateUnixTime)
  212.         startDateObj = datetime.datetime.fromtimestamp(int(self.getFlv['start_time']))
  213.         locale.setlocale(locale.LC_ALL, 'ja_JP.UTF-8')
  214.         endDate = f"{endDateObj:%Y年%m月%d日(%a)%H時%M分%S秒}"
  215.         startDate = f"{startDateObj:%Y年%m月%d日(%a)%H時%M分%S秒}"
  216.         # ファイル名は人間が読みやすい方式。nicojk系ツールと同じフォーマットにする場合はここを編集
  217.         # 1270407602-__15257.res_2010年04月05日(月)04時00分02秒~2010年04月06日(火)04時04分56秒
  218.         saveFileName = f"{threadNo}-{len(chatXmlList):_>7}.res_{startDate}~{endDate}.txt"
  219.         #saveFileName = f"{threadNo}.txt"
  220.         saveFilePath = f"./logs/{self.jkId}/{saveFileName}"
  221.         with open(saveFilePath, mode='w', encoding="utf-8") as f:
  222.             if len(chatXmlList) == 0:
  223.                 f.write("<!-- no chat comment -->")
  224.                 f.write("\n")
  225.             else:
  226.                 for chatXml in chatXmlList:
  227.                     f.write(chatXml.formattedXml)
  228.                     f.write("\n")
  229.         print(f"save {len(chatXmlList)} comments. {saveFilePath}")
  230.  
  231.  
  232. def unixTimeToStr(unixTimeSec: float) -> str:
  233.     if unixTimeSec < 0:
  234.         return f"error {unixTimeSec}"
  235.     dt = datetime.datetime.fromtimestamp(unixTimeSec)
  236.     return f"{dt:%Y/%m/%d %H:%M:%S}"
  237.  
  238.  
  239. def getUnixTimeSec(str: str) -> int:
  240.     a = dateutil.parser.parse(str).timestamp()
  241.     return int(a)
  242.  
  243.  
  244. if __name__ == "__main__":
  245.     # user_session_000000_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
  246.     cookie = "xxxx"
  247.     startDate = getUnixTimeSec("2020/07/23 00:00:00")
  248.     channel = "jk1"
  249.     while True:
  250.         jikkyo = Jikkyo(cookie, channel, startDate)
  251.         jikkyo.start()
  252.         startDate -= (24*60*60)
  253.