OSDN Git Service

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