OSDN Git Service

投稿者コメントの取得数は常に1000
[coroid/jnicoapi.git] / src / nicobrowser / NicoHttpClient.java
1 /*$Id$*/
2 package nicobrowser;
3
4 import java.net.URI;
5 import java.net.URISyntaxException;
6 import java.util.TreeMap;
7 import java.util.regex.Matcher;
8 import nicobrowser.entity.NicoContent;
9 import nicobrowser.search.SortKind;
10 import nicobrowser.search.SortOrder;
11 import com.sun.syndication.feed.synd.SyndContentImpl;
12 import com.sun.syndication.feed.synd.SyndEntryImpl;
13 import com.sun.syndication.feed.synd.SyndFeed;
14 import com.sun.syndication.io.FeedException;
15 import com.sun.syndication.io.SyndFeedInput;
16 import java.io.BufferedInputStream;
17 import java.io.BufferedOutputStream;
18 import java.io.BufferedReader;
19 import java.io.File;
20 import java.io.FileOutputStream;
21 import java.io.IOException;
22 import java.io.InputStream;
23 import java.io.InputStreamReader;
24 import java.io.Reader;
25 import java.io.StringReader;
26 import java.net.URL;
27 import java.net.URLEncoder;
28 import java.util.ArrayList;
29 import java.util.Arrays;
30 import java.util.EnumSet;
31 import java.util.Enumeration;
32 import java.util.HashMap;
33 import java.util.LinkedHashMap;
34 import java.util.List;
35 import java.util.Map;
36 import java.util.regex.Pattern;
37 import javax.swing.text.MutableAttributeSet;
38 import javax.swing.text.html.HTML;
39 import javax.swing.text.html.HTMLEditorKit;
40 import javax.swing.text.html.parser.ParserDelegator;
41 import javax.xml.parsers.DocumentBuilder;
42 import javax.xml.parsers.DocumentBuilderFactory;
43 import javax.xml.parsers.ParserConfigurationException;
44 import nicobrowser.entity.NicoContent.Status;
45 import nicobrowser.search.SearchKind;
46 import nicobrowser.search.SearchResult;
47 import nicobrowser.util.Result;
48 import nicobrowser.util.Util;
49 import org.apache.commons.io.FilenameUtils;
50 import org.apache.commons.lang.ArrayUtils;
51 import org.apache.commons.logging.Log;
52 import org.apache.commons.logging.LogFactory;
53 import org.apache.http.HttpEntity;
54 import org.apache.http.HttpException;
55 import org.apache.http.HttpHost;
56 import org.apache.http.HttpResponse;
57 import org.apache.http.HttpStatus;
58 import org.apache.http.NameValuePair;
59 import org.apache.http.client.entity.UrlEncodedFormEntity;
60 import org.apache.http.client.methods.HttpGet;
61 import org.apache.http.client.methods.HttpPost;
62 import org.apache.http.client.params.ClientPNames;
63 import org.apache.http.client.params.CookiePolicy;
64 import org.apache.http.conn.params.ConnRoutePNames;
65 import org.apache.http.cookie.Cookie;
66 import org.apache.http.entity.StringEntity;
67 import org.apache.http.impl.client.DefaultHttpClient;
68 import org.apache.http.impl.client.RedirectLocations;
69 import org.apache.http.message.BasicNameValuePair;
70 import org.apache.http.protocol.BasicHttpContext;
71 import org.apache.http.protocol.HttpContext;
72 import org.apache.http.util.EntityUtils;
73 import org.w3c.dom.Document;
74 import org.w3c.dom.Element;
75 import org.w3c.dom.NodeList;
76 import org.xml.sax.SAXException;
77
78 /**
79  *
80  * @author yuki
81  */
82 public class NicoHttpClient {
83
84     private static Log logger = LogFactory.getLog(NicoHttpClient.class);
85     private final DefaultHttpClient http;
86     private static final String LOGIN_PAGE =
87             "https://secure.nicovideo.jp/secure/login?site=niconico";
88     private static final String LOGOUT_PAGE =
89             "https://secure.nicovideo.jp/secure/logout";
90     private static final String WATCH_PAGE = "http://www.nicovideo.jp/watch/";
91     private static final String MY_LIST_PAGE_HEADER =
92             "http://www.nicovideo.jp/mylist/";
93     private static final String MOVIE_THUMBNAIL_PAGE_HEADER =
94             "http://ext.nicovideo.jp/api/getthumbinfo/";
95     private static final String GET_FLV_INFO = "http://www.nicovideo.jp/api/getflv/";
96     private static final String SEARCH_HEAD = "http://www.nicovideo.jp/";
97     private static final String ADD_MYLIST_PAGE = "http://www.nicovideo.jp/mylist_add/video/";
98     private static final String GET_THREAD_KEY_PAGE = "http://www.nicovideo.jp/api/getthreadkey?thread=";
99
100     public NicoHttpClient() {
101         http = new DefaultHttpClient();
102         http.getParams().setParameter(
103                 ClientPNames.COOKIE_POLICY, CookiePolicy.BROWSER_COMPATIBILITY);
104     }
105
106     /**
107      * プロキシサーバを経由してアクセスする場合のコンストラクタ.
108      * @param host プロキシサーバのホスト名.
109      * @param port プロキシサーバで利用するポート番号.
110      */
111     public NicoHttpClient(String host, int port) {
112         this();
113         HttpHost proxy = new HttpHost(host, port);
114         http.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy);
115     }
116
117     /**
118      * ニコニコ動画へログインする.
119      * @param mail ログイン識別子(登録メールアドレス).
120      * @param password パスワード.
121      * @return 認証がOKであればtrue.
122      */
123     public boolean login(String mail, String password) throws InterruptedException {
124         boolean auth = false;
125         HttpPost post = new HttpPost(LOGIN_PAGE);
126
127         try {
128             NameValuePair[] nvps = new NameValuePair[]{
129                 new BasicNameValuePair("mail", mail),
130                 new BasicNameValuePair("password", password),
131                 new BasicNameValuePair("next_url", "")
132             };
133             post.setEntity(new UrlEncodedFormEntity(Arrays.asList(nvps), "UTF-8"));
134
135             //post.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY);
136             HttpResponse response = http.execute(post);
137             logger.debug("ログインステータスコード: " + response.getStatusLine().getStatusCode());
138
139             // ログイン可否の判定.
140             HttpEntity entity = response.getEntity();
141             EntityUtils.consume(entity);
142             List<Cookie> cookies = http.getCookieStore().getCookies();
143             if (!cookies.isEmpty()) {
144                 auth = true;
145             }
146         } catch (IOException ex) {
147             logger.error("ログイン時に問題が発生", ex);
148         }
149         return auth;
150     }
151
152     /**
153      * ニコニコ動画からログアウトする.
154      * @return ログアウトに成功すればtrue.
155      */
156     public boolean logout() throws URISyntaxException, HttpException, InterruptedException {
157         boolean result = false;
158         HttpGet method = new HttpGet(LOGOUT_PAGE);
159         try {
160             HttpResponse response = http.execute(method);
161             logger.debug("ログアウトステータスコード: " + response.getStatusLine().getStatusCode());
162
163             if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
164                 result = true;
165             }
166             EntityUtils.consume(response.getEntity());
167         } catch (IOException ex) {
168             logger.error("ログアウト時に問題が発生", ex);
169         }
170         return result;
171     }
172
173     /**
174      * キーワード検索を行う.
175      * @param word 検索キーワード
176      * @param sort ソート種別
177      * @param order ソート順
178      * @page 検索結果ページのうち, 結果を返すページ.
179      * @return 検索結果.
180      */
181     public SearchResult search(SearchKind kind, String word, SortKind sort, SortOrder order, int page) throws
182             IOException {
183         logger.debug("検索:" + word);
184
185         InputStream is = null;
186         ArrayList<NicoContent> conts = new ArrayList<NicoContent>();
187         String url = SEARCH_HEAD + kind.getKey() + "/" + URLEncoder.encode(word, "UTF-8") + "?page=" + Integer.toString(
188                 page) + "&sort=" + sort.getKey() + "&order=" + order.getKey();
189
190         try {
191             HttpGet get = new HttpGet(url);
192             HttpResponse response;
193             response = http.execute(get);
194             is = new BufferedInputStream(response.getEntity().getContent());
195             assert is.markSupported();
196             is.mark(1024 * 1024);
197             List<Result> results = Util.parseSearchResult(is);
198             for (Result r : results) {
199                 NicoContent c = loadMyMovie(r.getId());
200                 if (c != null) {
201                     conts.add(c);
202                 }
203             }
204             is.reset();
205             TreeMap<Integer, String> otherPages = Util.getOtherPages(is);
206             return new SearchResult(conts, otherPages);
207         } catch (IOException ex) {
208             logger.error("検索結果処理時に例外発生", ex);
209             throw ex;
210         } finally {
211             if (is != null) {
212                 try {
213                     is.close();
214                 } catch (IOException ex) {
215                 }
216             }
217         }
218     }
219
220     /**
221      * 「マイリスト登録数ランキング(本日)」の動画一覧を取得する。
222      * @return 動画一覧.
223      */
224     public List<NicoContent> loadMyListDaily() throws URISyntaxException, HttpException, InterruptedException {
225         List<NicoContent> list = new ArrayList<NicoContent>();
226         String url = "http://www.nicovideo.jp/ranking/mylist/daily/all?rss=atom";
227         logger.debug("全動画サイトのマイリスト登録数ランキング(本日)[全体] : " + url);
228
229         HttpGet get = new HttpGet(url);
230
231         BufferedReader reader = null;
232         try {
233             HttpResponse response = http.execute(get);
234             reader = new BufferedReader(new InputStreamReader(response.getEntity().getContent(), "UTF-8"));
235             // BOMを読み捨て
236             // reader.skip(1);
237             list = getNicoContents(reader);
238             deleteRankString(list);
239             EntityUtils.consume(response.getEntity());
240         } catch (FeedException ex) {
241             logger.error("", ex);
242         } catch (IOException ex) {
243             logger.error("", ex);
244         } finally {
245             if (reader != null) {
246                 try {
247                     reader.close();
248                 } catch (IOException ex) {
249                     logger.error("", ex);
250                 }
251             }
252         }
253         return list;
254     }
255
256     /**
257      * ニコニコ動画のRSSからコンテンツリストを取得する.
258      * @param url 取得するrssのurl.
259      * @return コンテンツリスト.
260      */
261     public List<NicoContent> getContentsFromRss(String url) {
262         logger.debug("アクセスURL: " + url);
263         List<NicoContent> list = accessRssUrl(url);
264         if (url.contains("ranking")) {
265             deleteRankString(list);
266         }
267         return list;
268     }
269
270     /**
271      * 過去ログ取得用のキーを取得します.
272      * @param vi {@link #getVideoInfo(java.lang.String) }で取得したオブジェクト.
273      * @return 過去ログ取得用キー
274      * @throws IOException 取得に失敗した場合.
275      */
276     public String getWayBackKey(VideoInfo vi) throws IOException {
277         final String url = "http://flapi.nicovideo.jp/api/getwaybackkey?thread=" + vi.getThreadId();
278         final HttpGet get = new HttpGet(url);
279         HttpResponse response = http.execute(get);
280         String res;
281         try {
282             final int statusCode = response.getStatusLine().getStatusCode();
283             if (statusCode != HttpStatus.SC_OK) {
284                 throw new IOException("waybackkey get error " + statusCode);
285             }
286
287             final BufferedReader br = new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
288             res = br.readLine();
289             logger.debug("wayback get result text: " + res);
290         } finally {
291             EntityUtils.consume(response.getEntity());
292         }
293
294         final String keyWayBackKey = "waybackkey";
295         final String[] keyValues = res.split("&");
296         for (String s : keyValues) {
297             final String[] kv = s.split("=");
298             if (kv.length == 2) {
299                 if (keyWayBackKey.equals(kv[0])) {
300                     return kv[1];
301                 }
302             }
303         }
304
305         throw new IOException("pick up no waybackkey: " + res);
306     }
307
308     /**
309      * rankingの場合、本当のタイトルの前に"第XX位:"の文字列が
310      * 挿入されているため, それを削る.
311      * @param list 対象のリスト.
312      */
313     private void deleteRankString(List<NicoContent> list) {
314         for (NicoContent c : list) {
315             String title = c.getTitle();
316             int offset = title.indexOf(":") + 1;
317             c.setTitle(title.substring(offset));
318         }
319     }
320
321     /**
322      * マイリストに登録した動画一覧の取得.
323      * 「公開」設定にしていないリストからは取得できない.
324      * ログインしていなくても取得可能.
325      * @param listNo マイリストNo.
326      * @return 動画一覧.
327      */
328     public List<NicoContent> loadMyList(String listNo) {
329         String url = MY_LIST_PAGE_HEADER + listNo + "?rss=atom";
330         logger.debug("マイリストURL: " + url);
331         return accessRssUrl(url);
332     }
333
334     /**
335      * コンテンツ概略のストリームを取得する.
336      * @param movieNo
337      * @return コンテンツ概略. 取得元でcloseすること.
338      * @throws IOException
339      */
340     public InputStream getThumbInfo(String movieNo) throws IOException {
341         String url = MOVIE_THUMBNAIL_PAGE_HEADER + movieNo;
342         logger.debug("動画サムネイルURL: " + url);
343
344         HttpGet get = new HttpGet(url);
345         HttpResponse response = http.execute(get);
346         return response.getEntity().getContent();
347
348     }
349
350     /**
351      * 動画番号を指定したコンテンツ情報の取得.
352      * @param movieNo 動画番号.
353      * @return コンテンツ情報.
354      */
355     public NicoContent loadMyMovie(String movieNo) {
356         NicoContent cont = null;
357         InputStream re = null;
358
359         try {
360             re = getThumbInfo(movieNo);
361             // ドキュメントビルダーファクトリを生成
362             DocumentBuilderFactory dbfactory = DocumentBuilderFactory.newInstance();
363             // ドキュメントビルダーを生成
364             DocumentBuilder builder = dbfactory.newDocumentBuilder();
365             // パースを実行してDocumentオブジェクトを取得
366             Document doc = builder.parse(re);
367             // ルート要素を取得(タグ名:site)
368             Element root = doc.getDocumentElement();
369
370             if ("fail".equals(root.getAttribute("status"))) {
371                 logger.warn("情報取得できません: " + movieNo);
372                 return null;
373             }
374
375             NodeList list2 = root.getElementsByTagName("thumb");
376             cont = new NicoContent();
377             Element element = (Element) list2.item(0);
378
379             String watch_url = ((Element) element.getElementsByTagName("watch_url").item(0)).getFirstChild().
380                     getNodeValue();
381             cont.setPageLink(watch_url);
382
383             String title = ((Element) element.getElementsByTagName("title").item(0)).getFirstChild().getNodeValue();
384             cont.setTitle(title);
385
386             // TODO 投稿日の設定
387 //            String first_retrieve = ((Element) element.getElementsByTagName("first_retrieve").item(0)).getFirstChild().getNodeValue();
388 //            cont.setPublishedDate(DateFormat.getInstance().parse(first_retrieve));
389 //
390 //        } catch (ParseException ex) {
391 //            Logger.getLogger(NicoHttpClient.class.getName()).log(Level.SEVERE, null, ex);
392         } catch (SAXException ex) {
393             logger.error("", ex);
394         } catch (IOException ex) {
395             logger.error("", ex);
396         } catch (ParserConfigurationException ex) {
397             logger.error("", ex);
398         } finally {
399             try {
400                 if (re != null) {
401                     re.close();
402                 }
403             } catch (IOException ex) {
404                 logger.error("", ex);
405             }
406         }
407         return cont;
408     }
409
410     private List<NicoContent> accessRssUrl(String url) {
411         List<NicoContent> contList = new ArrayList<NicoContent>();
412         HttpGet get = new HttpGet(url);
413         BufferedReader reader = null;
414         try {
415             HttpResponse response = http.execute(get);
416             reader = new BufferedReader(new InputStreamReader(response.getEntity().getContent(), "UTF-8"));
417             if (logger.isTraceEnabled()) {
418                 reader.mark(1024 * 1024);
419                 while (true) {
420                     String str = reader.readLine();
421                     if (str == null) {
422                         break;
423                     }
424                     logger.trace(str);
425                 }
426                 reader.reset();
427             }
428             contList = getNicoContents(reader);
429         } catch (FeedException ex) {
430             logger.warn("アクセスできません: " + url);
431             logger.debug("", ex);
432         } catch (IOException ex) {
433             logger.error("", ex);
434         } finally {
435             if (reader != null) {
436                 try {
437                     reader.close();
438                 } catch (IOException ex) {
439                     logger.error("", ex);
440                 }
441             }
442         }
443         return contList;
444     }
445
446     private List<NicoContent> getNicoContents(Reader reader) throws FeedException {
447         SyndFeedInput input = new SyndFeedInput();
448         SyndFeed feed = input.build(reader);
449
450         @SuppressWarnings("unchecked")
451         final List<SyndEntryImpl> list = (List<SyndEntryImpl>) feed.getEntries();
452
453         List<NicoContent> contList;
454         if (list == null) {
455             contList = new ArrayList<NicoContent>();
456         } else {
457             contList = createContentsList(list);
458         }
459         return contList;
460     }
461
462     @SuppressWarnings("unchecked")
463     private List<NicoContent> createContentsList(List<SyndEntryImpl> list) {
464         class CallBack extends HTMLEditorKit.ParserCallback {
465
466             private boolean descFlag;
467             private String imageLink = new String();
468             private StringBuilder description = new StringBuilder();
469
470             @Override
471             public void handleSimpleTag(HTML.Tag t, MutableAttributeSet a, int pos) {
472                 logger.debug("--------<" + t.toString() + ">--------");
473                 logger.debug(a);
474                 if (HTML.Tag.IMG.equals(t)) {
475                     imageLink = a.getAttribute(HTML.Attribute.SRC).toString();
476                 }
477             }
478
479             @Override
480             public void handleStartTag(HTML.Tag t, MutableAttributeSet a, int pos) {
481                 if (HTML.Tag.P.equals(t)) {
482                     if ("nico-description".equals(
483                             a.getAttribute(HTML.Attribute.CLASS).toString())) {
484                         descFlag = true;
485                     }
486                 }
487                 logger.debug("--------<" + t.toString() + ">--------");
488                 logger.debug(a);
489             }
490
491             @Override
492             public void handleEndTag(HTML.Tag t, int pos) {
493                 if (HTML.Tag.P.equals(t)) {
494                     descFlag = false;
495                 }
496                 logger.debug("--------</" + t.toString() + ">--------");
497             }
498
499             @Override
500             public void handleText(char[] data, int pos) {
501                 if (descFlag) {
502                     description.append(data);
503                 }
504                 logger.debug("--------TEXT--------");
505                 logger.debug(data);
506             }
507
508             private void printAttributes(MutableAttributeSet a) {
509                 final Enumeration<?> e = a.getAttributeNames();
510                 while (e.hasMoreElements()) {
511                     Object key = e.nextElement();
512                     logger.debug("---- " + key.toString() + " : " + a.getAttribute(key));
513                 }
514             }
515
516             public String getImageLink() {
517                 return imageLink;
518             }
519
520             public String getDescription() {
521                 return description.toString();
522             }
523         }
524
525         List<NicoContent> contList = new ArrayList<NicoContent>();
526
527         for (SyndEntryImpl entry : list) {
528             NicoContent content = new NicoContent();
529
530             String title = entry.getTitle();
531             content.setTitle(title);
532             content.setPageLink(entry.getLink());
533
534             // サムネイル画像リンクと説明文の取得
535             CallBack callBack = new CallBack();
536             for (SyndContentImpl sc : (List<SyndContentImpl>) entry.getContents()) {
537                 try {
538                     Reader reader = new StringReader(sc.getValue());
539                     new ParserDelegator().parse(reader, callBack, true);
540                 } catch (IOException ex) {
541                     logger.error("RSSの読み込み失敗: " + content.getTitle());
542                 }
543             }
544
545 // リストへ追加.
546             contList.add(content);
547         }
548         return contList;
549     }
550
551     /**
552      * FLVファイルのURLを取得する. ログインが必要.
553      * また, 実際にFLVファイルの実態をダウンロードするには
554      * 一度http://www.nicovideo.jp/watch/ビデオID に一度アクセスする必要があることに
555      * 注意.
556      * (参考: http://yusukebe.com/tech/archives/20070803/124356.html)
557      * @param videoId ニコニコ動画のビデオID.
558      * @return FLVファイル実体があるURL.
559      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
560      */
561     public VideoInfo getVideoInfo(String videoId) throws IOException {
562         final GetRealVideoIdResult res = accessWatchPage(videoId);
563         final String realVideoId = res.videoId;
564
565         String accessUrl = GET_FLV_INFO + realVideoId;
566         if (realVideoId.startsWith("nm")) {
567             accessUrl += "?as3=1";
568         }
569         Map<String, String> map = getParameterMap(accessUrl);
570
571         LinkedHashMap<String, String> keyMap = new LinkedHashMap<String, String>();
572         if ("1".equals(map.get("needs_key"))) {
573             // 公式動画投稿者コメント取得用パラメータ.
574             keyMap = getParameterMap(GET_THREAD_KEY_PAGE + map.get(VideoInfo.KEY_THREAD_ID));
575         }
576         return new VideoInfo(realVideoId, res.title, map, keyMap);
577     }
578
579     private LinkedHashMap<String, String> getParameterMap(String accessUrl) throws IOException, IllegalStateException {
580         logger.debug("アクセス: " + accessUrl);
581         HttpGet get = new HttpGet(accessUrl);
582         String resultString;
583         BufferedReader reader = null;
584         try {
585             HttpResponse response = http.execute(get);
586             reader = new BufferedReader(new InputStreamReader(response.getEntity().getContent(), "UTF-8"));
587             String str;
588             StringBuilder strBuilder = new StringBuilder();
589             while ((str = reader.readLine()) != null) {
590                 strBuilder.append(str);
591             }
592             resultString = strBuilder.toString();
593             EntityUtils.consume(response.getEntity());
594             logger.debug(resultString);
595         } finally {
596             if (reader != null) {
597                 reader.close();
598             }
599         }
600         String[] params = resultString.split("&");
601         LinkedHashMap<String, String> map = new LinkedHashMap<String, String>();
602         for (String param : params) {
603             String[] elm = param.split("=");
604             map.put(elm[0], elm[1]);
605         }
606         return map;
607     }
608
609     /**
610      * watchページコンテンツからタイトルを抽出する.
611      * @param content watchページコンテンツのストリーム.
612      */
613     private String getTitleInWatchPage(InputStream content) throws IOException {
614         final String TITLE_PARSE_STR_START = "<title>";
615         BufferedReader br = new BufferedReader(new InputStreamReader(content, "UTF-8"));
616         String ret;
617         while ((ret = br.readLine()) != null) {
618             final int index = ret.indexOf(TITLE_PARSE_STR_START);
619             if (index >= 0) {
620                 String videoTitle = ret.substring(index + TITLE_PARSE_STR_START.length(), ret.indexOf(" ‐", index));
621                 return videoTitle;
622             }
623         }
624         return "";
625
626     }
627
628     private static class GetRealVideoIdResult {
629
630         private final String videoId;
631         private final String title;
632
633         private GetRealVideoIdResult(String videoId, String title) {
634             this.videoId = videoId;
635             this.title = title;
636         }
637     }
638
639     /**
640      * WATCHページへアクセスする. getflvを行うためには, 必ず事前にWATCHページへアクセスしておく必要があるため.
641      * WATCHページ参照時にリダイレクトが発生する(so動画ではスレッドIDのWATCHページにリダイレクトされる)場合には
642      * そちらのページにアクセスし、そのスレッドIDをrealIdとして返します.
643      * @param videoId 取得したいビデオのビデオID.
644      * @return 実際のアクセスに必要なIDと、タイトル. タイトルはいんきゅばす互換用です.
645      * @throws IOException アクセスに失敗した場合. 有料動画などがこれに含まれます.
646      */
647     private GetRealVideoIdResult accessWatchPage(String videoId) throws IOException {
648         String realId = videoId;
649         String title;
650         String watchUrl = WATCH_PAGE + videoId;
651         logger.debug("アクセス: " + watchUrl);
652         final HttpGet get = new HttpGet(watchUrl);
653         final HttpContext context = new BasicHttpContext();
654         final HttpResponse response = http.execute(get, context);
655         try {
656             final RedirectLocations rl = (RedirectLocations) context.getAttribute(
657                     "http.protocol.redirect-locations");
658             // 通常の動画(sm動画など)はリダイレクトが発生しないためnullになる
659             if (rl != null) {
660                 final List<URI> locations = rl.getAll();
661                 logger.debug("リダイレクト数: " + locations.size());
662
663                 // so動画はスレッドIDのページへリダイレクトされる
664                 if (locations.size() == 1) {
665                     realId = locations.get(0).toString().replace(WATCH_PAGE, "");
666                 } else if (locations.size() > 1) {
667                     throw new IOException("有料動画と思われるため処理を中断しました: " + ArrayUtils.toString(locations));
668                 }
669             }
670
671             title = getTitleInWatchPage(response.getEntity().getContent());
672         } finally {
673             EntityUtils.consume(response.getEntity());
674         }
675         return new GetRealVideoIdResult(realId, title);
676     }
677
678     /**
679      * ニコニコ動画から動画ファイルをダウンロードする.
680      * @param vi getVideoInfoメソッドで取得したオブジェクト.
681      * @param saveDir ダウンロードしたファイルを保存するディレクトリ.
682      * @param np 保存するファイル名の命名規則. 拡張子は別途付与されるため不要.
683      * @param nowStatus ダウンロードしようとしている動画ファイルの, 現在のステータス.
684      * @param needLowFile エコノミー動画をダウンロードするのであればtrue.
685      * @return この処理を行った後の, 対象ファイルのステータス.
686      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
687      */
688     public GetFlvResult getFlvFile(VideoInfo vi, File saveDir, NamePattern np, Status nowStatus, boolean needLowFile,
689             ProgressListener listener) throws IOException, URISyntaxException, HttpException, InterruptedException {
690
691         final URL notifierUrl = vi.getSmileUrl();
692
693         String userName = null;
694         if (notifierUrl != null) {
695             HttpGet get = new HttpGet(notifierUrl.toString());
696             HttpResponse response = http.execute(get);
697             userName = Util.getUserName(response.getEntity().getContent());
698             EntityUtils.consume(response.getEntity());
699         }
700
701         final URL url = vi.getVideoUrl();
702         if (nowStatus == Status.GET_LOW || !needLowFile) {
703             if (url.toString().contains("low")) {
704                 logger.info("エコノミー動画のためスキップ: " + vi.getRealVideoId());
705                 return new GetFlvResult(null, nowStatus, userName);
706             }
707         }
708         final boolean isNotLow = !url.toString().contains("low");
709
710         final File downloadFile = new File(saveDir, np.createFileName(vi.getRealVideoId(), isNotLow));
711
712         HttpGet get = new HttpGet(url.toURI());
713         HttpResponse response = http.execute(get);
714         String contentType = response.getEntity().getContentType().getValue();
715         logger.debug(contentType);
716         logger.debug(downloadFile.toString());
717         if ("text/plain".equals(contentType) || "text/html".equals(contentType)) {
718             logger.error("取得できませんでした. サーバが混みあっている可能性があります: " + vi.getRealVideoId());
719             EntityUtils.consume(response.getEntity());
720             return new GetFlvResult(null, Status.GET_INFO, userName);
721         }
722         String ext = Util.getExtention(contentType);
723         final long fileSize = response.getEntity().getContentLength();
724
725         final int BUF_SIZE = 1024 * 512;
726         BufferedInputStream in = null;
727         BufferedOutputStream out = null;
728         File file = new File(downloadFile.toString() + "." + ext);
729         try {
730             in = new BufferedInputStream(response.getEntity().getContent(), BUF_SIZE);
731
732             logger.info("保存します(" + fileSize / 1024 + "KB): " + file.getPath());
733             FileOutputStream fos = new FileOutputStream(file);
734             out = new BufferedOutputStream(fos, BUF_SIZE);
735
736             long downloadSize = 0;
737             int i;
738             byte[] buffer = new byte[BUF_SIZE];
739             while ((i = in.read(buffer)) != -1) {
740                 out.write(buffer, 0, i);
741                 downloadSize += i;
742                 listener.progress(fileSize, downloadSize);
743                 if (Thread.interrupted()) {
744                     logger.info("中断します");
745                     throw new InterruptedException("中断しました");
746                 }
747             }
748         } finally {
749             if (out != null) {
750                 out.close();
751             }
752             EntityUtils.consume(response.getEntity());
753             if (in != null) {
754                 in.close();
755             }
756         }
757
758         if (url.toString().contains("low")) {
759             return new GetFlvResult(file, Status.GET_LOW, userName);
760         }
761         return new GetFlvResult(file, Status.GET_FILE, userName);
762     }
763
764     /**
765      * ニコニコ動画から動画ファイルをダウンロードする.
766      * @param vi getVideoInfoメソッドで取得したオブジェクト.
767      * @param fileName ダウンロード後のファイル名. 拡張子は別途付与されるため不要.
768      * @param nowStatus ダウンロードしようとしている動画ファイルの, 現在のステータス.
769      * @param needLowFile エコノミー動画をダウンロードするのであればtrue.
770      * @return この処理を行った後の, 対象ファイルのステータス.
771      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
772      */
773     public GetFlvResult getFlvFile(VideoInfo vi, String fileName, Status nowStatus, boolean needLowFile,
774             ProgressListener listener) throws IOException, URISyntaxException, HttpException, InterruptedException {
775         String file = FilenameUtils.getName(fileName);
776         String dir = fileName.substring(0, fileName.length() - file.length());
777         NamePattern np = new NamePattern(file, "", "", "");
778         return getFlvFile(vi, new File(dir), np, nowStatus, needLowFile, listener);
779     }
780
781     /**
782      * ニコニコ動画から動画ファイルをダウンロードする.
783      * @param vi getVideoInfoメソッドで取得したオブジェクト.
784      * @param fileName ダウンロード後のファイル名. 拡張子は別途付与されるため不要.
785      * @return この処理を行った後の, 対象ファイルのステータス.
786      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
787      */
788     public GetFlvResult getFlvFile(VideoInfo vi, String fileName, ProgressListener listener) throws IOException,
789             URISyntaxException,
790             HttpException, InterruptedException {
791         return getFlvFile(vi, fileName, Status.GET_INFO, true, listener);
792     }
793
794     /**
795      * ニコニコ動画から動画ファイルをダウンロードする.
796      * ファイル名はビデオID名となる.
797      * @param vi getVideoInfoメソッドで取得したオブジェクト.
798      * @return この処理を行った後の, 対象ファイルのステータス.
799      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
800      */
801     public GetFlvResult getFlvFile(VideoInfo vi) throws IOException, URISyntaxException, HttpException,
802             InterruptedException {
803         return getFlvFile(vi, vi.getRealVideoId(), Status.GET_INFO, true, ProgressListener.EMPTY_LISTENER);
804     }
805
806     /**
807      * ニコニコ動画サービスからコメントファイルを取得します.
808      * @param vi {@link #getVideoInfo(java.lang.String)}で取得したオブジェクト.
809      * @param fileName 保存するファイル名.
810      * @param wayback 過去ログ情報. 過去ログ取得でなければnull.
811      * @param commentNum 取得するコメント数. 再生時間に応じて取得数を自動決定する場合は非正値.
812      * @param oldVersion 2010/12/22 以前のコメント表示仕様に基づいて取得する場合はtrue.
813      * @return コメントファイル.
814      * @throws Exception コメント取得失敗.
815      */
816     public File getCommentFile(VideoInfo vi, String fileName, WayBackInfo wayback, int commentNum, boolean oldVersion)
817             throws Exception {
818         final EnumSet<DownloadCommentType> set = EnumSet.noneOf(DownloadCommentType.class);
819         if(oldVersion) {
820             set.add(DownloadCommentType.COMMENT_OLD);
821         } else {
822             set.add(DownloadCommentType.COMMENT);
823         }
824         return getCommentFile(vi, fileName, set, wayback, commentNum);
825     }
826
827     /**
828      * ニコニコ動画サービスからコメントファイルを取得します.
829      * {@link #getCommentFile(nicobrowser.VideoInfo, java.lang.String, nicobrowser.WayBackInfo, int, boolean)}
830      * の簡易版です.
831      * @param vi {@link #getVideoInfo(java.lang.String)}で取得したオブジェクト.
832      * @param fileName 保存するファイル名.
833      * @return コメントファイル.
834      * @throws Exception コメント取得失敗.
835      */
836     public File getCommentFile(VideoInfo vi, String fileName) throws Exception {
837         return getCommentFile(vi, fileName, EnumSet.of(DownloadCommentType.COMMENT), null, -1);
838     }
839
840     /**
841      * ニコニコ動画サービスから投稿者コメントファイルを取得します.
842      * @param vi {@link #getVideoInfo(java.lang.String)}で取得したオブジェクト.
843      * @param fileName 保存するファイル名.
844      * @return 投稿者コメントファイル.
845      * @throws Exception 投稿者コメント取得失敗.
846      */
847     public File getTCommentFile(VideoInfo vi, String fileName) throws Exception {
848         return getCommentFile(vi, fileName, EnumSet.of(DownloadCommentType.OWNER), null, -1);
849     }
850
851     /**
852      * ニコニコ動画サービスからコメントファイルを取得します.
853      * @param vi {@link #getVideoInfo(java.lang.String)}で取得したオブジェクト.
854      * @param fileName 保存するファイル名.
855      * @param types ダウンロード対象とするコメントの種類.
856      * @param wayback 過去ログ情報. 過去ログ取得でなければnull.
857      * @param commentNum 取得するコメント数. 再生時間に応じて取得数を自動決定する場合は非正値.
858      * @return コメントファイル.
859      * @throws Exception コメント取得失敗.
860      */
861     public File getCommentFile(VideoInfo vi, String fileName, EnumSet<DownloadCommentType> types,
862             WayBackInfo wayback, int commentNum) throws Exception {
863         HttpResponse response = null;
864         BufferedOutputStream bos = null;
865
866         try {
867             final HttpPost post = new HttpPost(vi.getMessageUrl().toString());
868             final StringBuilder paramBuilder = new StringBuilder("<packet>");
869
870             // COMMENTとCOMMENT_OLDは二者択一
871             if(types.contains(DownloadCommentType.COMMENT)) {
872                 final String param = createCommentDownloadParameter(vi, wayback, commentNum);
873                 paramBuilder.append(param);
874             } else if (types.contains(DownloadCommentType.COMMENT_OLD)) {
875                 final String param = createCommentDownloadParameter20101222(vi, false, wayback, commentNum);
876                 paramBuilder.append(param);
877             }
878
879             if(types.contains(DownloadCommentType.OWNER)) {
880                 final String param = createCommentDownloadParameter20101222(vi, true, wayback, 1000);
881                 paramBuilder.append(param);
882             }
883
884             paramBuilder.append("</packet>");
885
886             final StringEntity se = new StringEntity(paramBuilder.toString());
887             post.setEntity(se);
888             response = http.execute(post);
889             final InputStream is = response.getEntity().getContent();
890             final BufferedInputStream bis = new BufferedInputStream(is);
891
892             final String outputFileName = (fileName.endsWith(".xml")) ? fileName : fileName + ".xml";
893             bos = new BufferedOutputStream(new FileOutputStream(outputFileName));
894
895             final byte[] buf = new byte[1024 * 1024];
896             int read;
897             while ((read = bis.read(buf, 0, buf.length)) > 0) {
898                 bos.write(buf, 0, read);
899             }
900
901             return new File(outputFileName);
902         } catch (Exception e) {
903             throw new Exception("コメントダウンロードに失敗しました。", e);
904         } finally {
905             if (response != null) {
906                 EntityUtils.consume(response.getEntity());
907             }
908             if (bos != null) {
909                 bos.close();
910             }
911         }
912     }
913
914     /**
915      * 2011/2/3 以降のコメント表示仕様に基づいた取得パラメータ生成.
916      * @param vi ビデオ情報.
917      * @param wayback 過去ログ情報. 過去ログ取得でないバイはnull.
918      * @param commentNum 取得するコメント数. 再生時間に応じて取得数を自動決定する場合は非正値.
919      * @return 生成されたパラメータ.
920      */
921     private String createCommentDownloadParameter(VideoInfo vi, WayBackInfo wayback, int commentNum) {
922         final Map<String, String> threadKey = vi.getKeyMap();
923         final Map<String, String> th = new HashMap<String, String>();
924         th.put("thread", vi.getThreadId());
925         th.put("version", "20090904");
926         th.put("user_id", vi.getUserId());
927         if (wayback != null) {
928             th.put("waybackkey", wayback.getKey());
929             th.put("when", Long.toString(wayback.getTime()));
930         }
931
932         final Map<String, String> leaf = new HashMap<String, String>();
933         leaf.put("thread", vi.getThreadId());
934         leaf.put("user_id", vi.getUserId());
935         if (wayback != null) {
936             leaf.put("waybackkey", wayback.getKey());
937             leaf.put("when", Long.toString(wayback.getTime()));
938         }
939
940         final int minutes = (int) Math.ceil(vi.getVideoLength() / 60.0);
941         // 1分当たり100件のコメントを表示するのは720分未満の動画だけで, それ以上は調整が入るらしい
942         // (どんなに長くても1動画当たり720*100件が最大。それを超える場合には1分当たりの件数を削減する)
943         final int max100perMin = 720;
944         final int perMin = (minutes < max100perMin) ? 100 : (max100perMin * 100) / minutes;
945
946         final int resFrom = (commentNum > 0) ? commentNum : vi.getResFrom();
947         final String element = "0-" + minutes + ":" + perMin + "," + resFrom;
948
949         final StringBuilder str = new StringBuilder();
950
951         str.append("<thread");
952         addMapToAttr(str, th);
953         addMapToAttr(str, threadKey);
954         str.append(" />");
955
956         str.append("<thread_leaves");
957         addMapToAttr(str, leaf);
958         addMapToAttr(str, threadKey);
959         str.append(">");
960         str.append(element);
961         str.append("</thread_leaves>");
962
963         return str.toString();
964     }
965
966     /**
967      * 2010/12/22 までのコメント表示仕様に基づいた取得パラメータ生成.
968      * 「コメントの量を減らす」にチェックを入れた場合は現在でもこれが用いられているはず.
969      * @param vi ビデオ情報.
970      * @param isTcomm 投稿者コメント取得パラメータを生成する場合にはtrue.
971      * @param wayback 過去ログ情報. 過去ログ取得でないバイはnull.
972      * @param commentNum 取得するコメント数. 再生時間に応じて取得数を自動決定する場合は非正値.
973      * @return 生成されたパラメータ.
974      */
975     private String createCommentDownloadParameter20101222(VideoInfo vi, boolean isTcomm, WayBackInfo wayback,
976             int commentNum) {
977         final Map<String, String> params = new HashMap<String, String>();
978
979         params.put(VideoInfo.KEY_USER_ID, vi.getUserId());
980         params.put("thread", vi.getThreadId());
981         params.put("version", "20061206");
982
983         final int resFrom = (commentNum > 0) ? commentNum : vi.getResFrom();
984         params.put("res_from", "-" + resFrom);
985
986         if (isTcomm) {
987             params.put("fork", "1");
988         }
989
990         if (wayback != null) {
991             params.put("waybackkey", wayback.getKey());
992             params.put("when", Long.toString(wayback.getTime()));
993         }
994
995         final StringBuilder str = new StringBuilder();
996         str.append("<thread");
997
998         addMapToAttr(str, vi.getKeyMap());
999         addMapToAttr(str, params);
1000
1001         str.append("/>");
1002
1003         return str.toString();
1004     }
1005
1006     private static void addMapToAttr(final StringBuilder str, final Map<String, String> map) {
1007         final String quote = "\"";
1008         for (String k : map.keySet()) {
1009             final String v = map.get(k);
1010             str.append(" ");
1011             str.append(k);
1012             str.append("=");
1013             str.append(quote);
1014             str.append(v);
1015             str.append(quote);
1016         }
1017     }
1018
1019     /**
1020      * 動画をマイリストへ登録する. ログインが必要.
1021      * @param myListId 登録するマイリストのID.
1022      * @param videoId 登録する動画ID.
1023      * @throws IOException 登録に失敗した.
1024      */
1025     public void addMyList(String myListId, String videoId) throws IOException {
1026         String itemType = null;
1027         String itemId = null;
1028         String token = null;
1029         HttpGet get = new HttpGet(ADD_MYLIST_PAGE + videoId);
1030         HttpResponse response = http.execute(get);
1031         HttpEntity entity = response.getEntity();
1032         try {
1033             InputStream is = entity.getContent();
1034             BufferedReader reader = new BufferedReader(new InputStreamReader(is));
1035             String line;
1036
1037             Pattern pattern = Pattern.compile("input type=\"hidden\" name=\"item_type\" value=\"(.+)\"");
1038             while ((line = reader.readLine()) != null) {
1039                 Matcher m = pattern.matcher(line);
1040                 if (m.find()) {
1041                     itemType = m.group(1);
1042                     break;
1043                 }
1044             }
1045
1046             pattern = Pattern.compile("input type=\"hidden\" name=\"item_id\" value=\"(.+)\"");
1047             while ((line = reader.readLine()) != null) {
1048                 Matcher m = pattern.matcher(line);
1049                 if (m.find()) {
1050                     itemId = m.group(1);
1051                     break;
1052                 }
1053             }
1054
1055             pattern = Pattern.compile("NicoAPI\\.token = \"(.*)\";");
1056             while ((line = reader.readLine()) != null) {
1057                 Matcher m = pattern.matcher(line);
1058                 if (m.find()) {
1059                     token = m.group(1);
1060                     break;
1061                 }
1062             }
1063         } finally {
1064             EntityUtils.consume(entity);
1065         }
1066
1067         if (itemType == null || itemId == null || token == null) {
1068             throw new IOException("マイリスト登録に必要な情報が取得できませんでした。 "
1069                     + "マイリスト:" + myListId + ", 動画ID:" + videoId + ", item_type:" + itemType + ", item_id:" + itemId
1070                     + ", token:" + token);
1071         }
1072
1073         StringEntity se = new StringEntity(
1074                 "group_id=" + myListId
1075                 + "&item_type=" + itemType
1076                 + "&item_id=" + itemId
1077                 + "&description=" + ""
1078                 + "&token=" + token);
1079
1080         HttpPost post = new HttpPost("http://www.nicovideo.jp/api/mylist/add");
1081         post.setHeader("Content-Type", "application/x-www-form-urlencoded");
1082         post.setEntity(se);
1083         response = http.execute(post);
1084         int statusCode = response.getStatusLine().getStatusCode();
1085         EntityUtils.consume(response.getEntity());
1086         if (statusCode != HttpStatus.SC_OK) {
1087             throw new IOException("マイリスト登録に失敗" + "マイリスト:" + myListId + ", 動画ID:" + videoId);
1088         }
1089     }
1090 }