OSDN Git Service

2010年年末コメント方式でダウンロードするインタフェースを追加. 過去ログ取得処理を再実装.
[coroid/NicoBrowser.git] / src / nicobrowser / NicoHttpClient.java
index 33394df..f4e2664 100644 (file)
@@ -1,8 +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;
@@ -19,11 +25,15 @@ import java.io.InputStreamReader;
 import java.io.Reader;
 import java.io.StringReader;
 import java.net.URL;
-import java.net.URLDecoder;
+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;
 import javax.swing.text.MutableAttributeSet;
 import javax.swing.text.html.HTML;
 import javax.swing.text.html.HTMLEditorKit;
@@ -32,13 +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.Header;
 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;
@@ -47,9 +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;
@@ -59,34 +79,39 @@ import org.xml.sax.SAXException;
  *
  * @author yuki
  */
-public class NicoHttpClient extends DefaultHttpClient {
+public class NicoHttpClient {
 
-    private static Log log = LogFactory.getLog(NicoHttpClient.class);
-    static NicoHttpClient instance;
+    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";
     private static final String LOGOUT_PAGE =
             "https://secure.nicovideo.jp/secure/logout";
+    private static final String WATCH_PAGE = "http://www.nicovideo.jp/watch/";
     private static final String MY_LIST_PAGE_HEADER =
             "http://www.nicovideo.jp/mylist/";
     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=";
 
-    private NicoHttpClient() {
-        super();
-        getParams().setParameter(
+    public NicoHttpClient() {
+        http = new DefaultHttpClient();
+        http.getParams().setParameter(
                 ClientPNames.COOKIE_POLICY, CookiePolicy.BROWSER_COMPATIBILITY);
-        instance = this;
     }
 
-    public static NicoHttpClient getInstance() {
-        if (instance == null) {
-            return new NicoHttpClient();
-        }
-        return instance;
+    /**
+     * プロキシサーバを経由してアクセスする場合のコンストラクタ.
+     * @param host プロキシサーバのホスト名.
+     * @param port プロキシサーバで利用するポート番号.
+     */
+    public NicoHttpClient(String host, int port) {
+        this();
+        HttpHost proxy = new HttpHost(host, port);
+        http.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy);
     }
 
     /**
@@ -108,18 +133,18 @@ public class NicoHttpClient extends DefaultHttpClient {
             post.setEntity(new UrlEncodedFormEntity(Arrays.asList(nvps), "UTF-8"));
 
             //post.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY);
-            HttpResponse response = execute(post);
-            log.debug("ログインステータスコード: " + response.getStatusLine().getStatusCode());
+            HttpResponse response = http.execute(post);
+            logger.debug("ログインステータスコード: " + response.getStatusLine().getStatusCode());
 
             // ログイン可否の判定.
             HttpEntity entity = response.getEntity();
-            entity.consumeContent();
-            List<Cookie> cookies = getCookieStore().getCookies();
+            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;
     }
@@ -132,15 +157,15 @@ public class NicoHttpClient extends DefaultHttpClient {
         boolean result = false;
         HttpGet method = new HttpGet(LOGOUT_PAGE);
         try {
-            HttpResponse response = execute(method);
-            log.debug("ログアウトステータスコード: " + response.getStatusLine().getStatusCode());
+            HttpResponse response = http.execute(method);
+            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;
     }
@@ -148,38 +173,48 @@ public class NicoHttpClient extends DefaultHttpClient {
     /**
      * キーワード検索を行う.
      * @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 = 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;
     }
 
     /**
@@ -188,30 +223,30 @@ public class NicoHttpClient extends DefaultHttpClient {
      */
     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);
 
         BufferedReader reader = null;
         try {
-            HttpResponse response = execute(get);
+            HttpResponse response = http.execute(get);
             reader = new BufferedReader(new InputStreamReader(response.getEntity().getContent(), "UTF-8"));
             // BOMを読み捨て
             // 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);
                 }
             }
         }
@@ -224,7 +259,7 @@ public class NicoHttpClient extends DefaultHttpClient {
      * @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);
@@ -233,6 +268,42 @@ public class NicoHttpClient extends DefaultHttpClient {
     }
 
     /**
+     * 過去ログ取得用のキーを取得します.
+     * @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 対象のリスト.
@@ -253,12 +324,28 @@ public class NicoHttpClient extends DefaultHttpClient {
      * @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 コンテンツ情報.
@@ -266,16 +353,9 @@ public class NicoHttpClient extends DefaultHttpClient {
     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 = execute(get);
-            re = response.getEntity().getContent();
+            re = getThumbInfo(movieNo);
             // ドキュメントビルダーファクトリを生成
             DocumentBuilderFactory dbfactory = DocumentBuilderFactory.newInstance();
             // ドキュメントビルダーを生成
@@ -286,7 +366,7 @@ public class NicoHttpClient extends DefaultHttpClient {
             Element root = doc.getDocumentElement();
 
             if ("fail".equals(root.getAttribute("status"))) {
-                log.warn("情報取得できません: " + movieNo);
+                logger.warn("情報取得できません: " + movieNo);
                 return null;
             }
 
@@ -308,18 +388,18 @@ public class NicoHttpClient extends DefaultHttpClient {
 //        } 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;
@@ -330,31 +410,31 @@ public class NicoHttpClient extends DefaultHttpClient {
         HttpGet get = new HttpGet(url);
         BufferedReader reader = null;
         try {
-            HttpResponse response = execute(get);
+            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);
                 }
             }
         }
@@ -362,11 +442,11 @@ public class NicoHttpClient extends DefaultHttpClient {
     }
 
     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) {
@@ -377,6 +457,7 @@ public class NicoHttpClient extends DefaultHttpClient {
         return contList;
     }
 
+    @SuppressWarnings("unchecked")
     private List<NicoContent> createContentsList(List<SyndEntryImpl> list) {
         class CallBack extends HTMLEditorKit.ParserCallback {
 
@@ -386,8 +467,8 @@ public class NicoHttpClient extends DefaultHttpClient {
 
             @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();
                 }
@@ -401,8 +482,8 @@ public class NicoHttpClient extends DefaultHttpClient {
                         descFlag = true;
                     }
                 }
-                log.debug("--------<" + t.toString() + ">--------");
-                log.debug(a);
+                logger.debug("--------<" + t.toString() + ">--------");
+                logger.debug(a);
             }
 
             @Override
@@ -410,7 +491,7 @@ public class NicoHttpClient extends DefaultHttpClient {
                 if (HTML.Tag.P.equals(t)) {
                     descFlag = false;
                 }
-                log.debug("--------</" + t.toString() + ">--------");
+                logger.debug("--------</" + t.toString() + ">--------");
             }
 
             @Override
@@ -418,15 +499,15 @@ public class NicoHttpClient extends DefaultHttpClient {
                 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));
                 }
             }
 
@@ -455,7 +536,7 @@ public class NicoHttpClient extends DefaultHttpClient {
                     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());
                 }
             }
 
@@ -468,168 +549,475 @@ public class NicoHttpClient extends DefaultHttpClient {
     /**
      * 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.
+     * @param videoId ニコニコ動画のビデオID.
      * @return FLVファイル実体があるURL.
      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
      */
-    public URL getFlvUrl(String videoID) throws IOException {
-        String accessUrl = GET_FLV_INFO + videoID;
-        if (videoID.startsWith("nm")) {
+    public VideoInfo getVideoInfo(String videoId) throws IOException {
+        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 = execute(get);
+            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("&");
+        LinkedHashMap<String, String> map = new LinkedHashMap<String, String>();
+        for (String param : params) {
+            String[] elm = param.split("=");
+            map.put(elm[0], elm[1]);
+        }
+        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;
 
-        String[] urls = resultString.split("&");
-        final String marker = "url=";
-        for (String url : urls) {
-            if (url.contains(marker)) {
-                String result = url.substring(marker.length());
-                result = URLDecoder.decode(result, "UTF-8");
+        private GetRealVideoIdResult(String videoId, String title) {
+            this.videoId = videoId;
+            this.title = title;
+        }
+    }
 
-                return new URL(result);
+    /**
+     * 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;
+        logger.debug("アクセス: " + watchUrl);
+        final HttpGet get = new HttpGet(watchUrl);
+        final HttpContext context = new BasicHttpContext();
+        final HttpResponse response = http.execute(get, context);
+        try {
+            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));
+                }
             }
+
+            title = getTitleInWatchPage(response.getEntity().getContent());
+        } finally {
+            EntityUtils.consume(response.getEntity());
         }
-        throw new IOException("フォーマット仕様変更? ID: " + videoID + ", パラメータ:" + resultString);
+        return new GetRealVideoIdResult(realId, title);
     }
 
     /**
      * ニコニコ動画から動画ファイルをダウンロードする.
-     * @param videoID smxxxx形式のビデオID.
-     * @param fileName ダウンロード後のファイル名. 拡張子は別途付与されるため不要.
+     * @param vi getVideoInfoメソッドで取得したオブジェクト.
+     * @param saveDir ダウンロードしたファイルを保存するディレクトリ.
+     * @param np 保存するファイル名の命名規則. 拡張子は別途付与されるため不要.
      * @param nowStatus ダウンロードしようとしている動画ファイルの, 現在のステータス.
-     * @param mp4ExtIsMp4 mp4ファイルの拡張子に.mp4を用いるか. falseの場合は.flvを付与する(過去のCraving Explorer互換用).
+     * @param needLowFile エコノミー動画をダウンロードするのであればtrue.
      * @return この処理を行った後の, 対象ファイルのステータス.
      * @throws java.io.IOException ファイル取得失敗. 権限の無いファイルを取得しようとした場合も.
      */
-    public GetFlvResult getFlvFile(String videoID, String fileName, Status nowStatus, boolean mp4ExtIsMp4) throws
-            IOException,
-            URISyntaxException, HttpException, InterruptedException {
-        byte[] buffer = new byte[1024 * 32];
-
-        String watchUrl = "http://www.nicovideo.jp/watch/" + videoID;
-        log.debug("アクセス: " + watchUrl);
-        getParams().setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, false);
-        HttpGet get = new HttpGet(watchUrl);
-        HttpResponse response = execute(get);
-        String redirectId = videoID;
-        if (response.containsHeader("Location")) {
-            redirectId = response.getFirstHeader("Location").getValue().replace("/watch/", "");
-            response.getEntity().consumeContent();
-            watchUrl = "http://www.nicovideo.jp/watch/" + redirectId;
-            log.debug("アクセス: " + watchUrl);
-            get = new HttpGet(watchUrl);
-            response = execute(get);
-        }
-        getParams().setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, true);
-
-        final String userId = Util.getUserId(response.getEntity().getContent());
-        log.debug("userId: " + userId);
-        response.getEntity().consumeContent();
+    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();
 
         String userName = null;
-        if (userId != null) {
-            final String userUrl = "http://www.nicovideo.jp/user/" + userId;
-            log.debug("アクセス: " + watchUrl);
-            get = new HttpGet(userUrl);
-            response = execute(get);
+        if (notifierUrl != null) {
+            HttpGet get = new HttpGet(notifierUrl.toString());
+            HttpResponse response = http.execute(get);
             userName = Util.getUserName(response.getEntity().getContent());
-            response.getEntity().consumeContent();
+            EntityUtils.consume(response.getEntity());
         }
 
-        URL url = getFlvUrl(redirectId);
-        if (nowStatus == Status.GET_LOW && url.toString().contains("low")) {
-            log.info("lowファイル取得済みのためスキップ" + videoID + ":" + fileName);
-            return new GetFlvResult(nowStatus, userName);
+        final URL url = vi.getVideoUrl();
+        if (nowStatus == Status.GET_LOW || !needLowFile) {
+            if (url.toString().contains("low")) {
+                logger.info("エコノミー動画のためスキップ: " + vi.getRealVideoId());
+                return new GetFlvResult(null, nowStatus, userName);
+            }
         }
+        final boolean isNotLow = !url.toString().contains("low");
 
-        get = new HttpGet(url.toURI());
-        response = execute(get);
+        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 + ":" + fileName);
-            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);
-        if (!mp4ExtIsMp4) {
-            if (ext.equals("mp4")) {
-                ext = "flv";
-            }
-        }
+        final long fileSize = response.getEntity().getContentLength();
 
+        final int BUF_SIZE = 1024 * 32;
         BufferedInputStream in = new BufferedInputStream(response.getEntity().getContent());
 
-        File file = new File(fileName + "." + ext);
-//        int postfix = 0;
-//        while (file.isFile()) {
-//            postfix++;
-//            file = new File(fileName + "(" + postfix + ")" + "." + ext);
-//        }
-        log.info("保存します: " + 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,
-            HttpException,
-            InterruptedException {
-        return getFlvFile(videoID, fileName, Status.GET_INFO, true);
+    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(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();
+    }
+
+    /**
+     * 動画をマイリストへ登録する. ログインが必要.
+     * @param myListId 登録するマイリストのID.
+     * @param videoId 登録する動画ID.
+     * @throws IOException 登録に失敗した.
+     */
+    public void addMyList(String myListId, String videoId) throws IOException {
+        String itemType = null;
+        String itemId = null;
+        String token = null;
+        HttpGet get = new HttpGet(ADD_MYLIST_PAGE + videoId);
+        HttpResponse response = http.execute(get);
+        HttpEntity entity = response.getEntity();
+        try {
+            InputStream is = entity.getContent();
+            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
+            String line;
+
+            Pattern pattern = Pattern.compile("input type=\"hidden\" name=\"item_type\" value=\"(.+)\"");
+            while ((line = reader.readLine()) != null) {
+                Matcher m = pattern.matcher(line);
+                if (m.find()) {
+                    itemType = m.group(1);
+                    break;
+                }
+            }
+
+            pattern = Pattern.compile("input type=\"hidden\" name=\"item_id\" value=\"(.+)\"");
+            while ((line = reader.readLine()) != null) {
+                Matcher m = pattern.matcher(line);
+                if (m.find()) {
+                    itemId = m.group(1);
+                    break;
+                }
+            }
+
+            pattern = Pattern.compile("NicoAPI\\.token = \"(.*)\";");
+            while ((line = reader.readLine()) != null) {
+                Matcher m = pattern.matcher(line);
+                if (m.find()) {
+                    token = m.group(1);
+                    break;
+                }
+            }
+        } finally {
+            EntityUtils.consume(entity);
+        }
+
+        if (itemType == null || itemId == null || token == null) {
+            throw new IOException("マイリスト登録に必要な情報が取得できませんでした。 "
+                    + "マイリスト:" + myListId + ", 動画ID:" + videoId + ", item_type:" + itemType + ", item_id:" + itemId
+                    + ", token:" + token);
+        }
+
+        StringEntity se = new StringEntity(
+                "group_id=" + myListId
+                + "&item_type=" + itemType
+                + "&item_id=" + itemId
+                + "&description=" + ""
+                + "&token=" + token);
+
+        HttpPost post = new HttpPost("http://www.nicovideo.jp/api/mylist/add");
+        post.setHeader("Content-Type", "application/x-www-form-urlencoded");
+        post.setEntity(se);
+        response = http.execute(post);
+        int statusCode = response.getStatusLine().getStatusCode();
+        EntityUtils.consume(response.getEntity());
+        if (statusCode != HttpStatus.SC_OK) {
+            throw new IOException("マイリスト登録に失敗" + "マイリスト:" + myListId + ", 動画ID:" + videoId);
+        }
     }
 }