OSDN Git Service

2010年年末コメント方式でダウンロードするインタフェースを追加. 過去ログ取得処理を再実装.
[coroid/NicoBrowser.git] / src / nicobrowser / NicoHttpClient.java
index 6267c92..f4e2664 100644 (file)
@@ -1,9 +1,14 @@
 /*$Id$*/
 package nicobrowser;
 
+import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.Set;
+import java.util.TreeMap;
 import java.util.regex.Matcher;
 import nicobrowser.entity.NicoContent;
+import nicobrowser.search.SortKind;
+import nicobrowser.search.SortOrder;
 import com.sun.syndication.feed.synd.SyndContentImpl;
 import com.sun.syndication.feed.synd.SyndEntryImpl;
 import com.sun.syndication.feed.synd.SyndFeed;
@@ -20,10 +25,12 @@ import java.io.InputStreamReader;
 import java.io.Reader;
 import java.io.StringReader;
 import java.net.URL;
+import java.net.URLEncoder;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Enumeration;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.regex.Pattern;
@@ -35,12 +42,17 @@ import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
 import nicobrowser.entity.NicoContent.Status;
+import nicobrowser.search.SearchKind;
+import nicobrowser.search.SearchResult;
 import nicobrowser.util.Result;
 import nicobrowser.util.Util;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang.ArrayUtils;
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
 import org.apache.http.HttpEntity;
 import org.apache.http.HttpException;
+import org.apache.http.HttpHost;
 import org.apache.http.HttpResponse;
 import org.apache.http.HttpStatus;
 import org.apache.http.NameValuePair;
@@ -49,10 +61,15 @@ import org.apache.http.client.methods.HttpGet;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.params.ClientPNames;
 import org.apache.http.client.params.CookiePolicy;
+import org.apache.http.conn.params.ConnRoutePNames;
 import org.apache.http.cookie.Cookie;
 import org.apache.http.entity.StringEntity;
 import org.apache.http.impl.client.DefaultHttpClient;
+import org.apache.http.impl.client.RedirectLocations;
 import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.protocol.BasicHttpContext;
+import org.apache.http.protocol.HttpContext;
+import org.apache.http.util.EntityUtils;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 import org.w3c.dom.NodeList;
@@ -64,7 +81,7 @@ import org.xml.sax.SAXException;
  */
 public class NicoHttpClient {
 
-    private static Log log = LogFactory.getLog(NicoHttpClient.class);
+    private static Log logger = LogFactory.getLog(NicoHttpClient.class);
     private final DefaultHttpClient http;
     private static final String LOGIN_PAGE =
             "https://secure.nicovideo.jp/secure/login?site=niconico";
@@ -76,9 +93,9 @@ public class NicoHttpClient {
     private static final String MOVIE_THUMBNAIL_PAGE_HEADER =
             "http://www.nicovideo.jp/api/getthumbinfo/";
     private static final String GET_FLV_INFO = "http://www.nicovideo.jp/api/getflv/";
-    private static final String SEARCH_HEAD = "http://www.nicovideo.jp/search/";
-    private static final String SEARCH_TAIL = "?sort=v";
+    private static final String SEARCH_HEAD = "http://www.nicovideo.jp/";
     private static final String ADD_MYLIST_PAGE = "http://www.nicovideo.jp/mylist_add/video/";
+    private static final String GET_THREAD_KEY_PAGE = "http://www.nicovideo.jp/api/getthreadkey?thread=";
 
     public NicoHttpClient() {
         http = new DefaultHttpClient();
@@ -87,6 +104,17 @@ public class NicoHttpClient {
     }
 
     /**
+     * プロキシサーバを経由してアクセスする場合のコンストラクタ.
+     * @param host プロキシサーバのホスト名.
+     * @param port プロキシサーバで利用するポート番号.
+     */
+    public NicoHttpClient(String host, int port) {
+        this();
+        HttpHost proxy = new HttpHost(host, port);
+        http.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy);
+    }
+
+    /**
      * ニコニコ動画へログインする.
      * @param mail ログイン識別子(登録メールアドレス).
      * @param password パスワード.
@@ -106,17 +134,17 @@ public class NicoHttpClient {
 
             //post.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY);
             HttpResponse response = http.execute(post);
-            log.debug("ログインステータスコード: " + response.getStatusLine().getStatusCode());
+            logger.debug("ログインステータスコード: " + response.getStatusLine().getStatusCode());
 
             // ログイン可否の判定.
             HttpEntity entity = response.getEntity();
-            entity.consumeContent();
+            EntityUtils.consume(entity);
             List<Cookie> cookies = http.getCookieStore().getCookies();
             if (!cookies.isEmpty()) {
                 auth = true;
             }
         } catch (IOException ex) {
-            log.error("ログイン時に問題が発生", ex);
+            logger.error("ログイン時に問題が発生", ex);
         }
         return auth;
     }
@@ -130,14 +158,14 @@ public class NicoHttpClient {
         HttpGet method = new HttpGet(LOGOUT_PAGE);
         try {
             HttpResponse response = http.execute(method);
-            log.debug("ログアウトステータスコード: " + response.getStatusLine().getStatusCode());
+            logger.debug("ログアウトステータスコード: " + response.getStatusLine().getStatusCode());
 
             if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                 result = true;
             }
-            response.getEntity().consumeContent();
+            EntityUtils.consume(response.getEntity());
         } catch (IOException ex) {
-            log.error("ログアウト時に問題が発生", ex);
+            logger.error("ログアウト時に問題が発生", ex);
         }
         return result;
     }
@@ -145,38 +173,48 @@ public class NicoHttpClient {
     /**
      * キーワード検索を行う.
      * @param word 検索キーワード
+     * @param sort ソート種別
+     * @param order ソート順
+     * @page 検索結果ページのうち, 結果を返すページ.
      * @return 検索結果.
      */
-    public List<NicoContent> search(String word) {
-        log.debug("検索:" + word);
+    public SearchResult search(SearchKind kind, String word, SortKind sort, SortOrder order, int page) throws
+            IOException {
+        logger.debug("検索:" + word);
 
         InputStream is = null;
-        List<NicoContent> conts = new ArrayList<NicoContent>();
-        String url = new String(SEARCH_HEAD + word + SEARCH_TAIL);
+        ArrayList<NicoContent> conts = new ArrayList<NicoContent>();
+        String url = SEARCH_HEAD + kind.getKey() + "/" + URLEncoder.encode(word, "UTF-8") + "?page=" + Integer.toString(
+                page) + "&sort=" + sort.getKey() + "&order=" + order.getKey();
 
         try {
-            while (url != null) {
-                HttpGet get = new HttpGet(url);
-                HttpResponse response;
-                response = http.execute(get);
-                is = new BufferedInputStream(response.getEntity().getContent());
-                assert is.markSupported();
-                is.mark(1024 * 1024);
-                List<Result> results = Util.parseSerchResult(is);
-                for (Result r : results) {
-                    NicoContent c = loadMyMovie(r.getId());
-                    if (c != null) {
-                        conts.add(c);
-                    }
+            HttpGet get = new HttpGet(url);
+            HttpResponse response;
+            response = http.execute(get);
+            is = new BufferedInputStream(response.getEntity().getContent());
+            assert is.markSupported();
+            is.mark(1024 * 1024);
+            List<Result> results = Util.parseSearchResult(is);
+            for (Result r : results) {
+                NicoContent c = loadMyMovie(r.getId());
+                if (c != null) {
+                    conts.add(c);
                 }
-                is.reset();
-                url = Util.getNextPage(is);
-                is.close();
             }
+            is.reset();
+            TreeMap<Integer, String> otherPages = Util.getOtherPages(is);
+            return new SearchResult(conts, otherPages);
         } catch (IOException ex) {
-            log.error("検索結果処理時に例外発生", ex);
+            logger.error("検索結果処理時に例外発生", ex);
+            throw ex;
+        } finally {
+            if (is != null) {
+                try {
+                    is.close();
+                } catch (IOException ex) {
+                }
+            }
         }
-        return conts;
     }
 
     /**
@@ -185,8 +223,8 @@ public class NicoHttpClient {
      */
     public List<NicoContent> loadMyListDaily() throws URISyntaxException, HttpException, InterruptedException {
         List<NicoContent> list = new ArrayList<NicoContent>();
-        String url = new String("http://www.nicovideo.jp/ranking/mylist/daily/all?rss=atom");
-        log.debug("全動画サイトのマイリスト登録数ランキング(本日)[全体] : " + url);
+        String url = "http://www.nicovideo.jp/ranking/mylist/daily/all?rss=atom";
+        logger.debug("全動画サイトのマイリスト登録数ランキング(本日)[全体] : " + url);
 
         HttpGet get = new HttpGet(url);
 
@@ -198,17 +236,17 @@ public class NicoHttpClient {
             // reader.skip(1);
             list = getNicoContents(reader);
             deleteRankString(list);
-            response.getEntity().consumeContent();
+            EntityUtils.consume(response.getEntity());
         } catch (FeedException ex) {
-            log.error("", ex);
+            logger.error("", ex);
         } catch (IOException ex) {
-            log.error("", ex);
+            logger.error("", ex);
         } finally {
             if (reader != null) {
                 try {
                     reader.close();
                 } catch (IOException ex) {
-                    log.error("", ex);
+                    logger.error("", ex);
                 }
             }
         }
@@ -221,7 +259,7 @@ public class NicoHttpClient {
      * @return コンテンツリスト.
      */
     public List<NicoContent> getContentsFromRss(String url) {
-        log.debug("アクセスURL: " + url);
+        logger.debug("アクセスURL: " + url);
         List<NicoContent> list = accessRssUrl(url);
         if (url.contains("ranking")) {
             deleteRankString(list);
@@ -230,6 +268,42 @@ public class NicoHttpClient {
     }
 
     /**
+     * 過去ログ取得用のキーを取得します.
+     * @param vi {@link #getVideoInfo(java.lang.String) }で取得したオブジェクト.
+     * @return 過去ログ取得用キー
+     * @throws IOException 取得に失敗した場合.
+     */
+    public String getWayBackKey(VideoInfo vi) throws IOException {
+        final String url = "http://flapi.nicovideo.jp/api/getwaybackkey?thread=" + vi.getThreadId();
+        final HttpGet get = new HttpGet(url);
+        HttpResponse response = http.execute(get);
+        String res;
+        try {
+            final int statusCode = response.getStatusLine().getStatusCode();
+            if (statusCode != HttpStatus.SC_OK) {
+                throw new IOException("waybackkey get error " + statusCode);
+            }
+
+            final BufferedReader br = new BufferedReader(new InputStreamReader(response.getEntity().getContent()));
+            res = br.readLine();
+            logger.debug("wayback get result text: " + res);
+        } finally {
+            EntityUtils.consume(response.getEntity());
+        }
+
+        final String keyWayBackKey = "waybackkey";
+        final String[] keyValues = res.split("&");
+        for (String s : keyValues) {
+            final String[] kv = s.split("=");
+            if (keyWayBackKey.equals(kv[0])) {
+                return kv[1];
+            }
+        }
+
+        throw new IOException("wayback key get fail: " + res);
+    }
+
+    /**
      * rankingの場合、本当のタイトルの前に"第XX位:"の文字列が
      * 挿入されているため, それを削る.
      * @param list 対象のリスト.
@@ -250,12 +324,28 @@ public class NicoHttpClient {
      * @return 動画一覧.
      */
     public List<NicoContent> loadMyList(String listNo) {
-        String url = new String(MY_LIST_PAGE_HEADER + listNo + "?rss=atom");
-        log.debug("マイリストURL: " + url);
+        String url = MY_LIST_PAGE_HEADER + listNo + "?rss=atom";
+        logger.debug("マイリストURL: " + url);
         return accessRssUrl(url);
     }
 
     /**
+     * コンテンツ概略のストリームを取得する.
+     * @param movieNo
+     * @return コンテンツ概略. 取得元でcloseすること.
+     * @throws IOException
+     */
+    public InputStream getThumbInfo(String movieNo) throws IOException {
+        String url = MOVIE_THUMBNAIL_PAGE_HEADER + movieNo;
+        logger.debug("動画サムネイルURL: " + url);
+
+        HttpGet get = new HttpGet(url);
+        HttpResponse response = http.execute(get);
+        return response.getEntity().getContent();
+
+    }
+
+    /**
      * 動画番号を指定したコンテンツ情報の取得.
      * @param movieNo 動画番号.
      * @return コンテンツ情報.
@@ -263,16 +353,9 @@ public class NicoHttpClient {
     public NicoContent loadMyMovie(String movieNo) {
         NicoContent cont = null;
         InputStream re = null;
-        List<SyndEntryImpl> list = null;
-        String url = new String(MOVIE_THUMBNAIL_PAGE_HEADER + movieNo);
-        log.debug("動画サムネイルURL: " + url);
-
-        HttpGet get;
 
         try {
-            get = new HttpGet(url);
-            HttpResponse response = http.execute(get);
-            re = response.getEntity().getContent();
+            re = getThumbInfo(movieNo);
             // ドキュメントビルダーファクトリを生成
             DocumentBuilderFactory dbfactory = DocumentBuilderFactory.newInstance();
             // ドキュメントビルダーを生成
@@ -283,7 +366,7 @@ public class NicoHttpClient {
             Element root = doc.getDocumentElement();
 
             if ("fail".equals(root.getAttribute("status"))) {
-                log.warn("情報取得できません: " + movieNo);
+                logger.warn("情報取得できません: " + movieNo);
                 return null;
             }
 
@@ -305,18 +388,18 @@ public class NicoHttpClient {
 //        } catch (ParseException ex) {
 //            Logger.getLogger(NicoHttpClient.class.getName()).log(Level.SEVERE, null, ex);
         } catch (SAXException ex) {
-            log.error("", ex);
+            logger.error("", ex);
         } catch (IOException ex) {
-            log.error("", ex);
+            logger.error("", ex);
         } catch (ParserConfigurationException ex) {
-            log.error("", ex);
+            logger.error("", ex);
         } finally {
             try {
                 if (re != null) {
                     re.close();
                 }
             } catch (IOException ex) {
-                log.error("", ex);
+                logger.error("", ex);
             }
         }
         return cont;
@@ -329,29 +412,29 @@ public class NicoHttpClient {
         try {
             HttpResponse response = http.execute(get);
             reader = new BufferedReader(new InputStreamReader(response.getEntity().getContent(), "UTF-8"));
-            if (log.isTraceEnabled()) {
+            if (logger.isTraceEnabled()) {
                 reader.mark(1024 * 1024);
                 while (true) {
                     String str = reader.readLine();
                     if (str == null) {
                         break;
                     }
-                    log.trace(str);
+                    logger.trace(str);
                 }
                 reader.reset();
             }
             contList = getNicoContents(reader);
         } catch (FeedException ex) {
-            log.warn("アクセスできません: " + url);
-            log.debug("", ex);
+            logger.warn("アクセスできません: " + url);
+            logger.debug("", ex);
         } catch (IOException ex) {
-            log.error("", ex);
+            logger.error("", ex);
         } finally {
             if (reader != null) {
                 try {
                     reader.close();
                 } catch (IOException ex) {
-                    log.error("", ex);
+                    logger.error("", ex);
                 }
             }
         }
@@ -359,11 +442,11 @@ public class NicoHttpClient {
     }
 
     private List<NicoContent> getNicoContents(Reader reader) throws FeedException {
-        List<SyndEntryImpl> list = null;
         SyndFeedInput input = new SyndFeedInput();
         SyndFeed feed = input.build(reader);
 
-        list = (List<SyndEntryImpl>) feed.getEntries();
+        @SuppressWarnings("unchecked")
+        final List<SyndEntryImpl> list = (List<SyndEntryImpl>) feed.getEntries();
 
         List<NicoContent> contList;
         if (list == null) {
@@ -374,6 +457,7 @@ public class NicoHttpClient {
         return contList;
     }
 
+    @SuppressWarnings("unchecked")
     private List<NicoContent> createContentsList(List<SyndEntryImpl> list) {
         class CallBack extends HTMLEditorKit.ParserCallback {
 
@@ -383,8 +467,8 @@ public class NicoHttpClient {
 
             @Override
             public void handleSimpleTag(HTML.Tag t, MutableAttributeSet a, int pos) {
-                log.debug("--------<" + t.toString() + ">--------");
-                log.debug(a);
+                logger.debug("--------<" + t.toString() + ">--------");
+                logger.debug(a);
                 if (HTML.Tag.IMG.equals(t)) {
                     imageLink = a.getAttribute(HTML.Attribute.SRC).toString();
                 }
@@ -398,8 +482,8 @@ public class NicoHttpClient {
                         descFlag = true;
                     }
                 }
-                log.debug("--------<" + t.toString() + ">--------");
-                log.debug(a);
+                logger.debug("--------<" + t.toString() + ">--------");
+                logger.debug(a);
             }
 
             @Override
@@ -407,7 +491,7 @@ public class NicoHttpClient {
                 if (HTML.Tag.P.equals(t)) {
                     descFlag = false;
                 }
-                log.debug("--------</" + t.toString() + ">--------");
+                logger.debug("--------</" + t.toString() + ">--------");
             }
 
             @Override
@@ -415,15 +499,15 @@ public class NicoHttpClient {
                 if (descFlag) {
                     description.append(data);
                 }
-                log.debug("--------TEXT--------");
-                log.debug(data);
+                logger.debug("--------TEXT--------");
+                logger.debug(data);
             }
 
             private void printAttributes(MutableAttributeSet a) {
-                Enumeration e = a.getAttributeNames();
+                final Enumeration<?> e = a.getAttributeNames();
                 while (e.hasMoreElements()) {
                     Object key = e.nextElement();
-                    log.debug("---- " + key.toString() + " : " + a.getAttribute(key));
+                    logger.debug("---- " + key.toString() + " : " + a.getAttribute(key));
                 }
             }
 
@@ -452,7 +536,7 @@ public class NicoHttpClient {
                     Reader reader = new StringReader(sc.getValue());
                     new ParserDelegator().parse(reader, callBack, true);
                 } catch (IOException ex) {
-                    log.error("RSSの読み込み失敗: " + content.getTitle());
+                    logger.error("RSSの読み込み失敗: " + content.getTitle());
                 }
             }
 
@@ -465,7 +549,7 @@ public class NicoHttpClient {
     /**
      * FLVファイルのURLを取得する. ログインが必要.
      * また, 実際にFLVファイルの実態をダウンロードするには
-     * 一度http://www.nicovideo.jp/watch/ビデオIDに一度アクセスする必要があることに
+     * 一度http://www.nicovideo.jp/watch/ビデオID に一度アクセスする必要があることに
      * 注意.
      * (参考: http://yusukebe.com/tech/archives/20070803/124356.html)
      * @param videoId ニコニコ動画のビデオID.
@@ -473,91 +557,134 @@ public class NicoHttpClient {
      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
      */
     public VideoInfo getVideoInfo(String videoId) throws IOException {
-        final String realVideoId = getRealVideoId(videoId);
+        final GetRealVideoIdResult res = accessWatchPage(videoId);
+        final String realVideoId = res.videoId;
 
         String accessUrl = GET_FLV_INFO + realVideoId;
         if (realVideoId.startsWith("nm")) {
             accessUrl += "?as3=1";
         }
-        log.debug("アクセス: " + accessUrl);
+        Map<String, String> map = getParameterMap(accessUrl);
+
+        LinkedHashMap<String, String> keyMap = new LinkedHashMap<String, String>();
+        if ("1".equals(map.get("needs_key"))) {
+            // 公式動画投稿者コメント取得用パラメータ.
+            keyMap = getParameterMap(GET_THREAD_KEY_PAGE + map.get(VideoInfo.KEY_THREAD_ID));
+        }
+        return new VideoInfo(realVideoId, res.title, map, keyMap);
+    }
+
+    private LinkedHashMap<String, String> getParameterMap(String accessUrl) throws IOException, IllegalStateException {
+        logger.debug("アクセス: " + accessUrl);
         HttpGet get = new HttpGet(accessUrl);
         String resultString;
         BufferedReader reader = null;
         try {
             HttpResponse response = http.execute(get);
             reader = new BufferedReader(new InputStreamReader(response.getEntity().getContent(), "UTF-8"));
-
             String str;
             StringBuilder strBuilder = new StringBuilder();
             while ((str = reader.readLine()) != null) {
                 strBuilder.append(str);
             }
             resultString = strBuilder.toString();
-            response.getEntity().consumeContent();
-            log.debug(resultString);
+            EntityUtils.consume(response.getEntity());
+            logger.debug(resultString);
         } finally {
             if (reader != null) {
                 reader.close();
             }
         }
-
         String[] params = resultString.split("&");
-//        final String marker = "url=";
-        Map<String, String> map = new HashMap<String, String>();
+        LinkedHashMap<String, String> map = new LinkedHashMap<String, String>();
         for (String param : params) {
             String[] elm = param.split("=");
             map.put(elm[0], elm[1]);
-            //            if (url.contains(marker)) {
-            //                String result = url.substring(marker.length());
-            //                result = URLDecoder.decode(result, "UTF-8");
-            //
-            //                return new URL(result);
-            //            }
         }
-//        throw new IOException("ダウンロードに失敗しました(削除済みの可能性もあります)。 ID: " + videoID + ", パラメータ:" + resultString);
-        return new VideoInfo(realVideoId, map);
+        return map;
+    }
+
+    /**
+     * watchページコンテンツからタイトルを抽出する.
+     * @param content watchページコンテンツのストリーム.
+     */
+    private String getTitleInWatchPage(InputStream content) throws IOException {
+        final String TITLE_PARSE_STR_START = "<title>";
+        BufferedReader br = new BufferedReader(new InputStreamReader(content, "UTF-8"));
+        String ret;
+        while ((ret = br.readLine()) != null) {
+            final int index = ret.indexOf(TITLE_PARSE_STR_START);
+            if (index >= 0) {
+                String videoTitle = ret.substring(index + TITLE_PARSE_STR_START.length(), ret.indexOf(" ‐", index));
+                return videoTitle;
+            }
+        }
+        return "";
+
+    }
+
+    private static class GetRealVideoIdResult {
+
+        private final String videoId;
+        private final String title;
+
+        private GetRealVideoIdResult(String videoId, String title) {
+            this.videoId = videoId;
+            this.title = title;
+        }
     }
 
-    private String getRealVideoId(String videoId) throws IOException {
+    /**
+     * WATCHページへアクセスする. getflvを行うためには, 必ず事前にWATCHページへアクセスしておく必要があるため.
+     * WATCHページ参照時にリダイレクトが発生する(so動画ではスレッドIDのWATCHページにリダイレクトされる)場合には
+     * そちらのページにアクセスし、そのスレッドIDをrealIdとして返します.
+     * @param videoId 取得したいビデオのビデオID.
+     * @return 実際のアクセスに必要なIDと、タイトル. タイトルはいんきゅばす互換用です.
+     * @throws IOException アクセスに失敗した場合. 有料動画などがこれに含まれます.
+     */
+    private GetRealVideoIdResult accessWatchPage(String videoId) throws IOException {
+        String realId = videoId;
+        String title;
         String watchUrl = WATCH_PAGE + videoId;
-        log.debug("アクセス: " + watchUrl);
-        http.getParams().setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, false);
+        logger.debug("アクセス: " + watchUrl);
+        final HttpGet get = new HttpGet(watchUrl);
+        final HttpContext context = new BasicHttpContext();
+        final HttpResponse response = http.execute(get, context);
         try {
-            HttpGet get = new HttpGet(watchUrl);
-            HttpResponse response = http.execute(get);
-            String realID = videoId;
-
-            // ステータスコード302など、リダイレクトが必要な場合
-            if (response.containsHeader("Location")) {
-                realID = response.getFirstHeader("Location").getValue().replace("/watch/", "");
-                response.getEntity().consumeContent();
-                watchUrl = WATCH_PAGE + realID;
-                log.debug("アクセス: " + watchUrl);
-                HttpGet watchGet = new HttpGet(watchUrl);
-                HttpResponse watchResponse = http.execute(get);
-                watchResponse.getEntity().consumeContent();
-            } else {
-                response.getEntity().consumeContent();
+            final RedirectLocations rl = (RedirectLocations) context.getAttribute(
+                    "http.protocol.redirect-locations");
+            // 通常の動画(sm動画など)はリダイレクトが発生しないためnullになる
+            if (rl != null) {
+                final List<URI> locations = rl.getAll();
+                logger.debug("リダイレクト数: " + locations.size());
+
+                // so動画はスレッドIDのページへリダイレクトされる
+                if (locations.size() == 1) {
+                    realId = locations.get(0).toString().replace(WATCH_PAGE, "");
+                } else if (locations.size() > 1) {
+                    throw new IOException("有料動画と思われるため処理を中断しました: " + ArrayUtils.toString(locations));
+                }
             }
-            return realID;
+
+            title = getTitleInWatchPage(response.getEntity().getContent());
         } finally {
-            http.getParams().setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, true);
+            EntityUtils.consume(response.getEntity());
         }
+        return new GetRealVideoIdResult(realId, title);
     }
 
     /**
      * ニコニコ動画から動画ファイルをダウンロードする.
-     * @param videoId smxxxx形式のビデオID.
-     * @param fileName ダウンロード後のファイル名. 拡張子は別途付与されるため不要.
+     * @param vi getVideoInfoメソッドで取得したオブジェクト.
+     * @param saveDir ダウンロードしたファイルを保存するディレクトリ.
+     * @param np 保存するファイル名の命名規則. 拡張子は別途付与されるため不要.
      * @param nowStatus ダウンロードしようとしている動画ファイルの, 現在のステータス.
      * @param needLowFile エコノミー動画をダウンロードするのであればtrue.
      * @return この処理を行った後の, 対象ファイルのステータス.
      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
      */
-    public GetFlvResult getFlvFile(String videoID, String fileName, Status nowStatus, boolean needLowFile) throws
-            IOException,
-            URISyntaxException, HttpException, InterruptedException {
-        VideoInfo vi = getVideoInfo(videoID);
+    public GetFlvResult getFlvFile(VideoInfo vi, File saveDir, NamePattern np, Status nowStatus, boolean needLowFile,
+            ProgressListener listener) throws IOException, URISyntaxException, HttpException, InterruptedException {
 
         final URL notifierUrl = vi.getSmileUrl();
 
@@ -566,26 +693,29 @@ public class NicoHttpClient {
             HttpGet get = new HttpGet(notifierUrl.toString());
             HttpResponse response = http.execute(get);
             userName = Util.getUserName(response.getEntity().getContent());
-            response.getEntity().consumeContent();
+            EntityUtils.consume(response.getEntity());
         }
 
         final URL url = vi.getVideoUrl();
         if (nowStatus == Status.GET_LOW || !needLowFile) {
             if (url.toString().contains("low")) {
-                log.info("エコノミー動画のためスキップ: " + videoID);
-                return new GetFlvResult(nowStatus, userName);
+                logger.info("エコノミー動画のためスキップ: " + vi.getRealVideoId());
+                return new GetFlvResult(null, nowStatus, userName);
             }
         }
+        final boolean isNotLow = !url.toString().contains("low");
+
+        final File downloadFile = new File(saveDir, np.createFileName(vi.getRealVideoId(), isNotLow));
 
         HttpGet get = new HttpGet(url.toURI());
         HttpResponse response = http.execute(get);
         String contentType = response.getEntity().getContentType().getValue();
-        log.debug(contentType);
-        log.debug(fileName);
+        logger.debug(contentType);
+        logger.debug(downloadFile.toString());
         if ("text/plain".equals(contentType) || "text/html".equals(contentType)) {
-            log.error("取得できませんでした. サーバが混みあっている可能性があります: " + videoID);
-            response.getEntity().consumeContent();
-            return new GetFlvResult(Status.GET_INFO, userName);
+            logger.error("取得できませんでした. サーバが混みあっている可能性があります: " + vi.getRealVideoId());
+            EntityUtils.consume(response.getEntity());
+            return new GetFlvResult(null, Status.GET_INFO, userName);
         }
         String ext = Util.getExtention(contentType);
         final long fileSize = response.getEntity().getContentLength();
@@ -593,48 +723,230 @@ public class NicoHttpClient {
         final int BUF_SIZE = 1024 * 32;
         BufferedInputStream in = new BufferedInputStream(response.getEntity().getContent());
 
-        File file = new File(fileName + "." + ext);
-        log.info("保存します(" + fileSize / 1024 + "KB): " + file.getPath());
+        File file = new File(downloadFile.toString() + "." + ext);
+        logger.info("保存します(" + fileSize / 1024 + "KB): " + file.getPath());
         FileOutputStream fos = new FileOutputStream(file);
         BufferedOutputStream out = new BufferedOutputStream(fos);
 
+        long downloadSize = 0;
         int i;
         byte[] buffer = new byte[BUF_SIZE];
         while ((i = in.read(buffer)) != -1) {
             out.write(buffer, 0, i);
+            downloadSize += i;
+            listener.progress(fileSize, downloadSize);
+            if (listener.getCancel()) {
+                return new GetFlvResult(null, Status.GET_INFO, userName);
+            }
         }
 
-        response.getEntity().consumeContent();
+        EntityUtils.consume(response.getEntity());
         out.close();
         in.close();
         if (url.toString().contains("low")) {
-            return new GetFlvResult(Status.GET_LOW, userName);
+            return new GetFlvResult(file, Status.GET_LOW, userName);
         }
-        return new GetFlvResult(Status.GET_FILE, userName);
+        return new GetFlvResult(file, Status.GET_FILE, userName);
     }
 
     /**
      * ニコニコ動画から動画ファイルをダウンロードする.
-     * @param videoId smxxxx形式のビデオID.
+     * @param vi getVideoInfoメソッドで取得したオブジェクト.
      * @param fileName ダウンロード後のファイル名. 拡張子は別途付与されるため不要.
+     * @param nowStatus ダウンロードしようとしている動画ファイルの, 現在のステータス.
+     * @param needLowFile エコノミー動画をダウンロードするのであればtrue.
      * @return この処理を行った後の, 対象ファイルのステータス.
      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
      */
-    public GetFlvResult getFlvFile(String videoID, String fileName) throws IOException, URISyntaxException,
+    public GetFlvResult getFlvFile(VideoInfo vi, String fileName, Status nowStatus, boolean needLowFile,
+            ProgressListener listener) throws IOException, URISyntaxException, HttpException, InterruptedException {
+        String file = FilenameUtils.getName(fileName);
+        String dir = fileName.substring(0, fileName.length() - file.length());
+        NamePattern np = new NamePattern(file, "", "", "");
+        return getFlvFile(vi, new File(dir), np, nowStatus, needLowFile, listener);
+    }
+
+    /**
+     * ニコニコ動画から動画ファイルをダウンロードする.
+     * @param vi getVideoInfoメソッドで取得したオブジェクト.
+     * @param fileName ダウンロード後のファイル名. 拡張子は別途付与されるため不要.
+     * @return この処理を行った後の, 対象ファイルのステータス.
+     * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
+     */
+    public GetFlvResult getFlvFile(VideoInfo vi, String fileName, ProgressListener listener) throws IOException,
+            URISyntaxException,
             HttpException, InterruptedException {
-        return getFlvFile(videoID, fileName, Status.GET_INFO, true);
+        return getFlvFile(vi, fileName, Status.GET_INFO, true, listener);
     }
 
     /**
      * ニコニコ動画から動画ファイルをダウンロードする.
      * ファイル名はビデオID名となる.
-     * @param videoId smxxxx形式のビデオID.
+     * @param vi getVideoInfoメソッドで取得したオブジェクト.
      * @return この処理を行った後の, 対象ファイルのステータス.
      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
      */
-    public GetFlvResult getFlvFile(String videoID) throws IOException, URISyntaxException, HttpException,
+    public GetFlvResult getFlvFile(VideoInfo vi) throws IOException, URISyntaxException, HttpException,
             InterruptedException {
-        return getFlvFile(videoID, videoID, Status.GET_INFO, true);
+        return getFlvFile(vi, vi.getRealVideoId(), Status.GET_INFO, true, ProgressListener.EMPTY_LISTENER);
+    }
+
+    public File getCommentFile(VideoInfo vi, String fileName, WayBackInfo wayback, int commentNum, boolean oldVersion)
+            throws Exception {
+        return downloadComment(vi, fileName, false, wayback, Integer.valueOf(commentNum), oldVersion);
+    }
+
+    public File getCommentFile(VideoInfo vi, String fileName) throws Exception {
+        return downloadComment(vi, fileName, false, null, null, false);
+    }
+
+    public File getTCommentFile(VideoInfo vi, String fileName) throws Exception {
+        return downloadComment(vi, fileName, true, null, Integer.valueOf(1000), true);
+    }
+
+    private File downloadComment(VideoInfo vi, String fileName, boolean isTcomm, WayBackInfo wayback, Integer commentNum,
+            boolean oldVersion)
+            throws Exception {
+        HttpResponse response = null;
+        BufferedOutputStream bos = null;
+
+        try {
+            final HttpPost post = new HttpPost(vi.getMessageUrl().toString());
+            final String param;
+            if (oldVersion || isTcomm) {
+                param = createCommentDownloadParameter20101222(vi, isTcomm, wayback, commentNum);
+            } else {
+                param = createCommentDownloadParameter(vi, wayback, commentNum);
+            }
+            final StringEntity se = new StringEntity(param);
+            post.setEntity(se);
+            response = http.execute(post);
+            final InputStream is = response.getEntity().getContent();
+            final BufferedInputStream bis = new BufferedInputStream(is);
+
+            final String outputFileName = (fileName.endsWith(".xml")) ? fileName : fileName + ".xml";
+            bos = new BufferedOutputStream(new FileOutputStream(outputFileName));
+
+            final byte[] buf = new byte[1024 * 1024];
+            int read;
+            while ((read = bis.read(buf, 0, buf.length)) > 0) {
+                bos.write(buf, 0, read);
+            }
+
+            return new File(outputFileName);
+        } catch (Exception e) {
+            throw new Exception("コメントダウンロードに失敗しました。", e);
+        } finally {
+            if (response != null) {
+                EntityUtils.consume(response.getEntity());
+            }
+            if (bos != null) {
+                bos.close();
+            }
+        }
+    }
+
+    /**
+     * 2011/2/3 以降のコメント表示仕様に基づいた取得パラメータ生成.
+     * @param vi ビデオ情報.
+     * @param wayback 過去ログ情報. 過去ログ取得でないバイはnull.
+     * @param commentNum コメント取得数. ビデオ再生時間に応じたデフォルト値を用いる場合にはnull.
+     * @return 生成されたパラメータ.
+     */
+    private String createCommentDownloadParameter(VideoInfo vi, WayBackInfo wayback, Integer commentNum) {
+        final Map<String, String> threadKey = vi.getKeyMap();
+        final Map<String, String> th = new HashMap<String, String>();
+        th.put("thread", vi.getThreadId());
+        th.put("version", "20090904");
+        th.put("user_id", vi.getUserId());
+        if (wayback != null) {
+            th.put("waybackkey", wayback.getKey());
+            th.put("when", Long.toString(wayback.getTime()));
+        }
+
+        final Map<String, String> leaf = new HashMap<String, String>();
+        leaf.put("thread", vi.getThreadId());
+        leaf.put("user_id", vi.getUserId());
+        if (wayback != null) {
+            leaf.put("waybackkey", wayback.getKey());
+            leaf.put("when", Long.toString(wayback.getTime()));
+        }
+
+        final int minutes = (int) Math.ceil(vi.getVideoLength() / 60.0);
+        // 1分当たり100件のコメントを表示するのは720分未満の動画だけで, それ以上は調整が入るらしい
+        // (どんなに長くても1動画当たり720*100件が最大。それを超える場合には1分当たりの件数を削減する)
+        final int max100perMin = 720;
+        final int perMin = (minutes < max100perMin) ? 100 : (max100perMin * 100) / minutes;
+
+        final int resFrom = (commentNum != null) ? commentNum.intValue() : vi.getResFrom();
+        final String element = "0-" + minutes + ":" + perMin + "," + resFrom;
+
+        final StringBuilder str = new StringBuilder();
+        str.append("<packet>");
+
+        str.append("<thread");
+        addMapToAttr(str, th);
+        addMapToAttr(str, threadKey);
+        str.append(" />");
+
+        str.append("<thread_leaves");
+        addMapToAttr(str, leaf);
+        addMapToAttr(str, threadKey);
+        str.append(">");
+        str.append(element);
+        str.append("</thread_leaves>");
+
+        str.append("</packet>");
+
+        return str.toString();
+    }
+
+    private static void addMapToAttr(final StringBuilder str, final Map<String, String> map) {
+        final String quote = "\"";
+        for (String k : map.keySet()) {
+            final String v = map.get(k);
+            str.append(" ");
+            str.append(k);
+            str.append("=");
+            str.append(quote);
+            str.append(v);
+            str.append(quote);
+        }
+    }
+
+    /**
+     * 2010/12/22 までのコメント表示仕様に基づいた取得パラメータ生成.
+     * 「コメントの量を減らす」にチェックを入れた場合は現在でもこれが用いられているはず.
+     */
+    private String createCommentDownloadParameter20101222(VideoInfo vi, boolean isTcomm, WayBackInfo wayback,
+            Integer commentNum) {
+        final Map<String, String> params = new HashMap<String, String>();
+
+        params.put(VideoInfo.KEY_USER_ID, vi.getUserId());
+        params.put("thread", vi.getThreadId());
+        params.put("version", "20061206");
+
+        final int resFrom = (commentNum == null || commentNum <= 0) ? vi.getResFrom() : commentNum.intValue();
+        params.put("res_from", "-" + Integer.toString(resFrom));
+
+        if (isTcomm) {
+            params.put("fork", "1");
+        }
+
+        if (wayback != null) {
+            params.put("waybackkey", wayback.getKey());
+            params.put("when", Long.toString(wayback.getTime()));
+        }
+
+        final StringBuilder str = new StringBuilder();
+        str.append("<thread");
+
+        addMapToAttr(str, vi.getKeyMap());
+        addMapToAttr(str, params);
+
+        str.append("/>");
+
+        return str.toString();
     }
 
     /**
@@ -682,7 +994,7 @@ public class NicoHttpClient {
                 }
             }
         } finally {
-            entity.consumeContent();
+            EntityUtils.consume(entity);
         }
 
         if (itemType == null || itemId == null || token == null) {
@@ -703,8 +1015,8 @@ public class NicoHttpClient {
         post.setEntity(se);
         response = http.execute(post);
         int statusCode = response.getStatusLine().getStatusCode();
-        response.getEntity().consumeContent();
-        if (statusCode != 200) {
+        EntityUtils.consume(response.getEntity());
+        if (statusCode != HttpStatus.SC_OK) {
             throw new IOException("マイリスト登録に失敗" + "マイリスト:" + myListId + ", 動画ID:" + videoId);
         }
     }