Mac用ニコ生タイムシフトダウンローダー作った
NicoTimeShift(.dmg)
ソース:https://github.com/J-ogawa/NicoTimeShift
注:コメントと動画は別々のファイルでダウンロードします。
注:落とした.flvは特定のプレーヤーでしか観れなかったりします(僕はRealPlayerだけでしか観れませんでした)
注:コメントが1000以上のモノはすべて取得できません
注:うまく行かないときあります
初めて知った事が全体を通してかなりあったので1からまとめる事にしました。
作成手順
- コメント取得
- xmlからaddr, port, thread_id取得(コメントされた時刻計算の為にbase_time, start_timeも取得)
- addr, port でソケット通信開始し、thread_idを使ったリクエストを送信
- コメント受信、抽出してファイル出力
- 動画取得
- rtmpdumpの実行ファイル埋め込み
- xmlからrtmpdumpのパラメータとなる部分抽出
- NSTaskでrtmpdump実行
となります。
xml取得
- 各ブラウザのファイルからCookie(user_session)取得
Cookieファイルの場所は、
Safari: ~/Library/Cookies/Cookies.plist
Chrome: ~/Liblary/Application Support/Google/Chrome/Default/Cookies(sqliteファイル)
Firefox: ~/Library/Application Support/Firefox/Profiles/********.default/cookies.sqlite
-
- Safariの場合
plistファイルなので扱いが簡単です。NSArrayのarrayWithContentsOfFile:で指定すれば、中にNSDictionaryでできたクッキーがいっぱい入っているarrayが返ってきます。そこからニコニコのログインのセッションidを取得します。
どっちもsqliteファイルなので、sqlite3のライブラリを使って開いて、select文によってセッションidを取得しました。ただ、Firefoxの方は途中のディレクトリ名(*******.default)がユーザーによって違うので、ファイル検索をして取得する必要があります。
一つ上のProfilesから、NSFileManagerのcontentsOfDirectoryAtPathというやつを使って、存在するファイル(ディレクトリ)を取得してさらにその下のcookies.sqliteの存在をチェックして、一番新しいものを取得するというようにしました。
まとめるとこのような感じです。
-(BOOL)getUserSession:(NSInteger)browser{ NSString *a_home_dir = NSHomeDirectory(); NSString *cookiePath; if (browser == SAFARI) { //Safari cookiePath = [a_home_dir stringByAppendingPathComponent:@"Library/Cookies/Cookies.plist"]; NSArray *cookieArray = [NSArray arrayWithContentsOfFile:cookiePath]; for (NSDictionary *dic in cookieArray) { NSString *domain = [dic objectForKey:@"Domain"]; NSString *name = [dic objectForKey:@"Name"]; NSLog(@"domain : %@", domain); NSLog(@"name : %@", name); if ([domain isEqualToString:@".nicovideo.jp"] && [name isEqualToString:@"user_session"]) { self.sessionId = [NSString stringWithFormat:@"user_session=%@", [dic objectForKey:@"Value"]]; NSLog(@"if ok"); } } } else { //Chrome, Firefox sqlite3 *db_; if (browser == CHROME) { cookiePath = [a_home_dir stringByAppendingPathComponent:@"Library/Application Support/Google/Chrome/Default/Cookies"]; }else{ //Objcでファイル取得 検索+取得 NSFileManager *defaultFileManager = [NSFileManager defaultManager]; NSString* dirPath = [a_home_dir stringByAppendingPathComponent:@"Library/Application Support/Firefox/Profiles"]; NSError *error; NSArray *contents = [defaultFileManager contentsOfDirectoryAtPath:dirPath error:&error]; NSDate *newDate = [NSDate dateWithTimeIntervalSince1970:0]; for (int i = 0; i < [contents count]; i++) { NSString *name = [contents objectAtIndex: i]; NSString *cookiePathTmp = [NSString stringWithFormat:@"%@/%@/cookies.sqlite", dirPath, name]; NSLog(@"cookiePathTmp : %@", cookiePathTmp); if([defaultFileManager fileExistsAtPath:cookiePathTmp]){ NSDictionary *fileAttribs = [defaultFileManager attributesOfItemAtPath:cookiePathTmp error:&error]; NSDate *date = [fileAttribs valueForKey:NSFileModificationDate]; if([newDate compare:date] == NSOrderedAscending){ newDate = date; cookiePath = cookiePathTmp; } } } } if(sqlite3_open_v2([cookiePath UTF8String], &db_, SQLITE_OPEN_READONLY, nil) == SQLITE_OK){ }else { // エラー処理 NSLog(@"Error!"); exit(-1); } NSString *sqlString; if(browser == CHROME){ sqlString = [NSString stringWithString:@"select value from cookies where host_key = '.nicovideo.jp' and name = 'user_session' limit 1;"]; }else { sqlString = [NSString stringWithString:@"select value from moz_cookies where host = '.nicovideo.jp' and name = 'user_session';"]; } const char *sql_c = [sqlString cStringUsingEncoding:NSUTF8StringEncoding]; sqlite3_stmt *statement; int a0 = sqlite3_prepare_v2(db_, sql_c, -1, &statement, NULL); if(a0 != SQLITE_OK) NSAssert1(0, @"Error while creating add statement. '%s'", sqlite3_errmsg(db_)); //int a = sqlite3_step(statement); while (SQLITE_DONE != sqlite3_step(statement)) { const char *ch = (char*)sqlite3_column_text(statement, 0); self.sessionId = [NSString stringWithFormat:@"user_session=%@", [NSString stringWithCString:ch encoding:NSUTF8StringEncoding]]; } sqlite3_finalize(statement); sqlite3_close(db_); } return YES; }
Xcodeで使っているsqlite3のバージョンがなんか古かったみたいで、Firefoxのファイルを開くときにエラーが出て困りました。エラーメッセージの本文を忘れてしまったんですが・・(T_T) あれは大変でした。Mac本体に入っているsqlite3とバージョンが違って余計にややこしかったです。3.7.9にすれば解決しました。
あと、sqlite3_openではなくsqlite3_open_v2()を使って第3引数にSQLITE_OPEN_READONLYを入れないと、Chromeが起動中にChromeのCookieファイルを開けない、という事もありました。
NSMutableURLRequestのsetValue: forHTTPHeaderField:でhttpヘッダーにセッションidをセットして通信しました。
-(BOOL)getXml{ NSString *urlString = [NSString stringWithFormat:@"http://watch.live.nicovideo.jp/api/getplayerstatus?v=%@", lv]; NSURL *urlLogin = [NSURL URLWithString:urlString]; NSMutableURLRequest *urlRequestLogin = [[NSMutableURLRequest alloc]initWithURL:urlLogin]; [urlRequestLogin setHTTPMethod:@"POST"]; [urlRequestLogin setValue:sessionId forHTTPHeaderField:@"Cookie"];//:[sessionId dataUsingEncoding:NSUTF8StringEncoding]]; NSURLResponse* responseLogin; NSError* errorLogin = nil; NSData* resultLogin = [NSURLConnection sendSynchronousRequest:urlRequestLogin returningResponse:&responseLogin error:&errorLogin]; xml= [[NSString alloc]initWithData:resultLogin encoding:NSUTF8StringEncoding]; [urlRequestLogin release]; return YES; }
コメント取得
- xmlからaddr, port, thread_id取得(ついでにbase_time, start_timeも)
NSXMLDocumentのnodesForXPath:でxmlから要素を抽出しました。
Assertを入れたり入れなかったりしてますがちょっと気にしないで下さい。(どこで入れるべきかいまいち分かっていない。)
- (BOOL)getAPT{ //get addr, port, threadId NSError *error; NSXMLDocument *xmlDoc = [[NSXMLDocument alloc]initWithXMLString:xml options:NSXMLNodeOptionsNone error:&error]; // NSString *dockString = [NSString stringWithFormat:@"<xml>%@</xml>", xml]; NSArray *temp = [xmlDoc nodesForXPath:@"/getplayerstatus/ms/addr/text()" error:&error]; for (NSXMLNode *node in temp) { addr = [node stringValue]; } temp = [xmlDoc nodesForXPath:@"/getplayerstatus/ms/port/text()" error:&error]; for (NSXMLNode *node in temp) { port = [[node stringValue]integerValue]; } temp = [xmlDoc nodesForXPath:@"/getplayerstatus/ms/thread/text()" error:&error]; for (NSXMLNode *node in temp) { self.threadId = [node stringValue]; } temp = [xmlDoc nodesForXPath:@"/getplayerstatus/stream/base_time/text()" error:&error]; NSAssert([temp count] == 1, @"base_time is not one."); NSInteger baseTime = [[[temp objectAtIndex:0] stringValue]integerValue]; NSLog(@"baseTime : %ld", baseTime); temp = [xmlDoc nodesForXPath:@"/getplayerstatus/stream/open_time/text()" error:&error]; NSAssert([temp count] == 1, @"open_time is not one."); NSInteger openTime = [[[temp objectAtIndex:0] stringValue]integerValue]; NSAssert(baseTime == openTime, @"base_time is not equal open_time."); temp = [xmlDoc nodesForXPath:@"/getplayerstatus/stream/start_time/text()" error:&error]; NSAssert([temp count] == 1, @"start_time is not one."); NSInteger startTime = [[[temp objectAtIndex:0] stringValue]integerValue]; [xmlDoc release]; testTime = startTime - baseTime; return YES; }
- addr, port でソケット通信開始し、thread_idを使ったリクエストを送信
このサイトとここを大いに参考にさせて頂きました。
ソケット通信をするにはNSStreamを使うみたいです。
NSImputStreamとNSOutputStreamというNSStreamのサブクラスのモノも用意してソケット通信の入出力に使います。
-
- ソケットオープン
addr, portでオープンします。NSHostっていうのとか入出力のデリゲード設定などをします。
- (void)socketOpen:(NSString *)ipAddress port:(NSInteger)portNo { // data = [NSMutableData data]; if (isOpen == NO) { NSHost *host = [NSHost hostWithName:ipAddress]; [NSStream getStreamsToHost:host port:portNo inputStream:&inputStream outputStream:&outputStream]; [inputStream retain]; [outputStream retain]; [inputStream setDelegate:self]; [outputStream setDelegate:self]; [inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; [inputStream open]; [outputStream open]; isOpen = YES; } }
-
- stream: handleEvent:で処理
無事オープンするとサーバーからデータが送られてきてこのイベントメソッド(?)が発火します。送られてくるeventCodeの種類によって制御します。
- (void)stream:(NSStream *)stream handleEvent:(NSStreamEvent)eventCode { switch(eventCode) { case NSStreamEventOpenCompleted:{ NSLog(@"NSStreamEventOpenCompleted"); break; } case NSStreamEventHasBytesAvailable: { NSLog(@"NSStreamEventHasBytesAvailable"); } break; case NSStreamEventHasSpaceAvailable:{ NSLog(@"NSStreamEventHasSpaceAvailable"); break; } case NSStreamEventErrorOccurred:{ NSLog(@"NSStreamEventErrorOccurred"); break; } case NSStreamEventEndEncountered:{ NSLog(@"\n\n\n\nNSStreamEventEndCountered"); break; } } }
参考にさせてもらったサイトのまま書きますが、フラグの意味は、
NSStreamEventNone:イベントは発生していない
NSStreamEventOpenCompleted:ストリームが正常にオープンされた
NSStreamEventHasBytesAvailable:ストリームに読み取るデータがある
NSStreamEventHasSpaceAvailable:ストリームにデータを書き込める
NSStreamEventErrorOccurred:ストリームでエラーが発生した
NSStreamEventEndEncountered:ストリームの最後に達した
で、ソケットオープンするとOpenCompleted、HasSpaceAvailableの順にイベントが発火しました。
HasSpaceAvailableの中にリクエストを送信する部分を書きました。
受信の処理はHasBytesAvailable内です。
そして、受信完了してもなぜかEndEncounteredが来てくれなかったので、放送終了時に投稿される//disconnectというコメントを利用して自分で終了判定しました。
-
-
- リクエスト送信
-
case NSStreamEventHasSpaceAvailable:{ NSLog(@"NSStreamEventHasSpaceAvailable"); NSString *str = [NSString stringWithFormat:@"<thread thread=\"%@\" version=\"20061206\" res_from=\"-1000\" />", self.threadId]; const uint8_t *rawstring = (const uint8_t *)[str UTF8String]; [outputStream write:rawstring maxLength:strlen((char *)rawstring)]; uint8_t *rawString2[2]; rawString2[0] = 0; rawString2[1] = 0; [outputStream write:rawString2 maxLength:1]; [outputStream close]; break; }
threadIdを使ってリクエスト文を作るんですが、ここが少し曲者でした。最後に\0文字を付けなければいけないらしいんですが、これが分かりませんでした。言語によっては文の最後に半角スペースを付けたりするらしいです。この場合はoutputstreamに0を格納したuint8_tの配列を書き込んで付け足しました。
-
-
- リクエスト受信
-
イベント引数のstreamでlen = [(NSInputStream *)stream read:buf maxLength:1024];と言う風にデータをバッファに読み込んで、NSMutableDataのdataStreamにappendBytesします。
その後、データを使って、NSStringのinitWithDataで文字列取得します。
これが
...
..
の形で送られてきているコメントの情報です。
得た文字列のなかから区切りに使われてる\0文字を\nに置換して取り除きます。
断片で飛んでくるデータの区切り目はどこか分からないので、最後の\nで2つに分けて、前半を使用して後半は保持して、それは次の受信時にデータの頭になるようにします。
(\nが見つからなかったらそれは保持されてるものの尻にくっつけます)
また、
- vpos(base_timeからの経過秒数×100)
- user_id
これも取得します。
で、vposを使ってコメントされた時刻を得るんですが、これは放送テスト時間も含んだ場合の時刻になるのでテスト放送時間TestTime(start_time - base_time)を引いて使用します。
case NSStreamEventHasBytesAvailable: { NSLog(@"NSStreamEventHasBytesAvailable"); if (dataStream == nil) { dataStream = [[NSMutableData alloc] init]; } uint8_t buf[1024]; NSInteger len = 0; len = [(NSInputStream *)stream read:buf maxLength:1024]; if(len) { [dataStream appendBytes:(const void *)buf length:len]; int bytesRead; bytesRead += len; } else { NSLog(@"No data."); } NSString *str = [[NSString alloc] initWithData:dataStream encoding:NSUTF8StringEncoding]; NSString *strReplace = [str stringByReplacingOccurrencesOfString:@"\0" withString:@"\n"]; //XMLDocumentsにする //最後の改行で2つに分ける // 文字列strの中に@"\n"というパターンが存在するかどうか NSRange searchResult = [strReplace rangeOfString:@"\n"]; if(searchResult.location == NSNotFound){ // みつからない場 self.keepString = [NSString stringWithFormat:@"%@%@", self.keepString, strReplace]; }else{ // みつかった場合の処 NSRange range = [strReplace rangeOfString:@"\n" options:NSBackwardsSearch]; NSLog(@"range.location : %ld\n", range.location); NSString *front = [strReplace substringToIndex:range.location]; NSString *rear = [strReplace substringFromIndex:range.location + 1]; NSString *dockString = [NSString stringWithFormat:@"<xml>%@%@</xml>", self.keepString, front]; self.keepString = [NSString stringWithString:rear]; NSError *error; NSXMLDocument *xmlDoc = [[NSXMLDocument alloc]initWithXMLString:dockString options:NSXMLNodeOptionsNone error:&error]; NSArray *temp = [xmlDoc nodesForXPath:@"/xml/chat/text()" error:&error]; for (NSXMLNode *node in temp) { [commentArray addObject:[node stringValue]]; NSLog(@"comment : %@", [node stringValue]); } temp = [xmlDoc nodesForXPath:@"/xml/chat/@vpos" error:&error]; for (NSXMLNode *node in temp) { [vposArray addObject:[node stringValue]]; NSLog(@"vpos : %@", [node stringValue]); } temp = [xmlDoc nodesForXPath:@"/xml/chat/@user_id" error:&error]; for (NSXMLNode *node in temp) { [userIdArray addObject:[node stringValue]]; NSLog(@"userId : %@", [node stringValue]); } temp = [xmlDoc nodesForXPath:@"/xml/chat[@premium=\"2\" and text()=\"/disconnect\"]" error:&error]; [xmlDoc release]; if([temp count] != 0){ [self socketClose]; //NSString → NSNumber → NSString *a_home_dir = NSHomeDirectory(); NSString *path = [NSString stringWithFormat:@"%@/comment_%@.txt", a_home_dir, lv]; NSString *empty = @""; [empty writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:&error]; NSFileHandle *aFileHandle; // NSString *aFile; for (int i = 0; i < [commentArray count]; i++) { aFileHandle = [NSFileHandle fileHandleForWritingAtPath:path]; //telling aFilehandle what file write to [aFileHandle truncateFileAtOffset:[aFileHandle seekToEndOfFile]]; //setting aFileHandle to write at the end of the file NSInteger vposInt = [[vposArray objectAtIndex:i]integerValue] - testTime * 100L; NSString *time = [NSString stringWithFormat:@"%2ld:%2ld",vposInt/6000L, vposInt%6000L/100L]; [aFileHandle writeData:[[NSString stringWithFormat:@"%@ %@ id:%@\n", time, [commentArray objectAtIndex:i], [userIdArray objectAtIndex:i]]dataUsingEncoding:NSUTF8StringEncoding]]; //actually write the data } } } [str release]; [dataStream release]; dataStream = nil; break; }
これでコメントが取得できました。
動画取得
- rtmpdumpの実行ファイル埋め込み
macportsにてインストールしたrtmpdumpのUnix実行ファイルをプロジェクトに埋め込んで(生成した.app内に入る)、後にそれを実行します。
自分の場合は/opt/local/bin/rtmpdump これをコピーして、そのファイルをプロジェクトのResourseフォルダに入れておき(_rtmpdumpという名前にした)、プロジェクトの左カラムのファイルツリーにドラッグアンドドロップ。
こんな事ができるなんて知りませんでしたが、マジでスゴいと思いました。UNIXコマンドをCocoaアプリケーション化する事が可能なんですね。
- xmlからrtmpdumpのパラメータとなる部分抽出
既に得ているgetplayerstatusのxmlから、URL ,TICKETを取得します。
RegexkitLiteを使って正規表現抽出をしましたが、XPathの方が簡単です。
1つのタイムシフトの中でも途中で動画が切れていたりしたら動画ファイルは2個以上になり、その数に応じてURLも2個以上になります。
また、URLの文字は",/content"を"/mp4:content"に置換しています。
NSString *URLRegex = @"(rtmp://.*?)</que>"; NSString *TICKETRegex = @"<ticket>(.*)</ticket>"; NSArray *matchURL = [xml componentsMatchedByRegex:URLRegex capture:1L]; for (int i = 0; i < [matchURL count]; i++) { [URL addObject:(NSMutableString *)[[matchURL objectAtIndex:i] stringByReplacingOccurrencesOfString:@",/" withString:@"/mp4:"] ]; } self.TICKET = [xml stringByMatching:TICKETRegex capture:1L];
- NSTaskでrtmpdump実行
埋め込んだrtmpdumpのファイルパスを指定して前項目で得た引数を使ってNSTaskで実行します。
パスは[[NSBundle mainBundle] pathForResource:@"_rtmpdump" ofType:nil];であっけなく取得できちゃいます。
NSTaskにsetLaunchPathで”/bin/sh"を指定してシェルスクリプトを実行するという形にしました。
注意すべき点は、動画ファイルの数だけrtmpdumpを実行したいので、タスクの数がそのときによって変わってしまうという事です。
そこで、複数のタスクを動画数だけ用意して、それらの入出力を動画数のpipeでつなぐということをやりました。
こちらを参考にさせて頂きました。
-(void)doTask{ NSLog(@"doTask start taskIndex : %d", taskIndex); NSString *a_home_dir = NSHomeDirectory(); NSString *argument; NSString *rtmpdumpPath = [[NSBundle mainBundle] pathForResource:@"_rtmpdump" ofType:nil]; for (int i = 0; i < [URL count]; i++) { NSTask *task = [[[NSTask alloc]init]autorelease]; NSPipe *pipe = [[[NSPipe alloc]init]autorelease]; [task setStandardOutput:pipe]; [task setLaunchPath:@"/bin/sh"]; if (i != 0) { [task setStandardInput:[pipes objectAtIndex:i - 1]]; } if (i == 0) { argument = [NSString stringWithFormat:@"%@ -r \"%@\" -C S:\"%@\" -f \"MAC 10,0,32,18\" -s \"http://live.nicovideo.jp/liveplayer.swf?20100531\" -o %@/%@.flv", rtmpdumpPath, [URL objectAtIndex:i], self.TICKET, a_home_dir, lv]; }else{ argument = [NSString stringWithFormat:@"%@ -r \"%@\" -C S:\"%@\" -f \"MAC 10,0,32,18\" -s \"http://live.nicovideo.jp/liveplayer.swf?20100531\" -o %@/%@_%d.flv", rtmpdumpPath, [URL objectAtIndex:i], self.TICKET, a_home_dir, lv, i + 1]; } NSLog(@"argument : %@", argument); [task setArguments: [NSArray arrayWithObjects: @"-c", argument, nil]]; [tasks addObject:task]; [pipes addObject:pipe]; } for (int i = 0; i < [URL count]; i++) { [[tasks objectAtIndex:i] launch]; } //[task waitUntilExit]; //[delegate addObNotifi]; //NSData *dataOutput = [[[pipes objectAtIndex:[URL count] - 1] fileHandleForReading]readDataToEndOfFile]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(readData:) name:NSFileHandleReadCompletionNotification object:nil]; [[[pipes objectAtIndex:[URL count] - 1 ]fileHandleForReading] readInBackgroundAndNotify]; }
NSNotificationCenterで出力のCompleteを受け取りました。完了するとreadData:が作動します。
- (void)readData:(NSNotification *)notification { NSData *data = [[notification userInfo] valueForKey:NSFileHandleNotificationDataItem]; NSString *string = [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] autorelease]; NSLog(@"string : %@",string); for (int i = 0; i < [URL count]; i++) { if ( [[tasks objectAtIndex:i] isRunning] ) { [[[pipes objectAtIndex:i] fileHandleForReading] readInBackgroundAndNotify]; NSLog(@"task inRunning"); return; } else { } } [[NSNotificationCenter defaultCenter] removeObserver:self]; }
ちょっとここは挙動が怪しいです。2個タスクがあったときに最初の1個が完了したらもう出力が得られなくなったりしてます。一応ちゃんと2個分終了したら止まるようにはなってますが・・・
これで完成です。いや〜つかれました。
ニコ生リスナーの皆さんありがとうございました。自力だと全然無理でした。
これでようやく次のiPhoneアプリ制作に移れそうです。次は、将棋の棋譜ファイル(.kif)を再生するkifプレーヤーアプリを作ろうと思います。
これはMacApp版も作ります。Macでkifファイル再生できるいいソフトが無いので・・
↑と思ったんですが、iPhoneアプリでkif再生できるアプリは既にある模様。
う〜む。別の事を考えようかな。