ニコニコ静画のクローラーをSelenium WebDriverで作る

#概要
UIテスト自動化ツール「[Selenium WebDriver](http://docs.seleniumhq.org/docs/03_webdriver.jsp)」を
[ニコニコ静画](http://seiga.nicovideo.jp/?track=home)用のクローラーに転用してみた。

タグ検索結果のイラスト群（もちろん春画も！）を
スライドショーのように表示しながら
作品データを収集できたら、
それはとっても素敵だなって思った。

#作業
01.Firefoxをインストールする。
02.Selenium WebDriverを外部参照するJavaプロジェクトを作る。
　　 手順は[こちらのサイト](http://blog.asial.co.jp/1180)を参考にさせていただいた。
03.下記コードを実装する。

```java:NicoNicoSeigaCrawler.java
/**
NicoNicoSeigaCrawler
Copyright (c) 2014 nezuq
This software is released under the MIT License.
http://opensource.org/licenses/mit-license.php
*/

import java.io.File;
import java.io.FileWriter;
import java.io.BufferedWriter;
import java.io.PrintWriter;
import java.io.IOException;
import java.util.*;
import java.text.*;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.firefox.FirefoxDriver;
 
 
public class NicoNicoSeigaCrawler {
		
    private static final int IndexMaxPage = 40; 
    
    public static void main(String[] args) {
    	// 実行ファイルの引数を受け取る
    	//  [0] 検索キーワード
    	//  [1] ログイン用メールアドレス
    	//  [2] ログイン用パスワード
        String keywords = "<実行ファイル引数1>";
        String mail = "<実行ファイル引数2>";
        String password = "<実行ファイル引数3>"; 
        if(0 < args.length){
        	keywords = args[0];
            if(1 < args.length){
            	mail = args[1];
                if(2 < args.length){
                	password = args[2];
                }
            }
        }
        WebDriver driver = new FirefoxDriver();
        driver.manage().window().maximize();
        createSearchResultFile(driver, keywords, getSearchResult(driver, keywords, mail, password));
        driver.quit();
    }
    
    private static SortedMap<String,NicoNicoSeigaInfo> getSearchResult(
        WebDriver driver, 
        String keywords,
        String mail,
        String password
            ){
        SortedMap<String,NicoNicoSeigaInfo> results = new TreeMap<String,NicoNicoSeigaInfo>();
        // イラスト情報を取得する
        NicoNicoSeigaInfo seigaInfo = getSeigaInfo(driver,keywords,mail,password);
        while(! (seigaInfo == null)){
            results.put(seigaInfo.getId(),seigaInfo);
            seigaInfo = getSeigaInfo(driver,keywords,mail,password);
        }
        // イラスト情報を返却する
        return results;
    }
    
    private static int Indexseiga = -1;
    private static NicoNicoSeigaInfo getSeigaInfo(
        WebDriver driver,
        String keywords,
    	String mail,
        String password
            ){
        Indexseiga += 1;
        int indexseigas = Indexseiga % IndexMaxPage;
        if(Indexseiga == 0){
        	// タグ検索結果一覧画面 初期ページ表示時
        	// タグ検索結果一覧画面へ遷移する
            driver.get("http://seiga.nicovideo.jp/tag/" + keywords + "?sort=image_created&target=illust_all");
            waitAfterTransaction();
            // ログイン有無を判定する
            List<WebElement> elements = driver.findElements(By.cssSelector(".siteHeaderLogin > a"));
            if(0 < elements.size()){
            	// ログイン無し時
            	// ログイン画面へ遷移する
            	elements.get(0).click();
                waitAfterTransaction();
            	// メールを入力する
                simulateSendKeys(driver.findElements(By.id("mail")).get(0),mail);
                waitSimulateChangingFocus();
            	// パスワードを入力する
                simulateSendKeys(driver.findElements(By.id("password")).get(0),password);
                waitSimulateChangingFocus();
            	// ログインボタンを押す
                driver.findElements(By.cssSelector(".login_button > input")).get(0).click();
                waitAfterTransaction();
            	// タグ検索結果一覧画面へ遷移する
                driver.get("http://seiga.nicovideo.jp/tag/" + keywords + "?sort=image_created&target=illust_all");
                waitAfterTransaction();
            }
        }else if(indexseigas == 0){
        	// タグ検索結果一覧画面 次ページ表示時
            List<WebElement> nextButtons = driver.findElements(By.cssSelector(".next > a"));
            if(nextButtons.isEmpty()){
            	// 次ページボタンを無効な時
                return null;
            }else{
            	// 次ページボタンを有効な時
            	// 次ページボタンを押す
                nextButtons.get(0).click();
                waitAfterTransaction();
            }
        }
        
        // イラストを選ぶ
        List<WebElement> seigas = driver.findElements(By.cssSelector(".list_item > a"));
        if(! (indexseigas < seigas.size())){
        	// イラスト情報取得完了時
            return null;
        }
        waitSimulateChoosingSeiga();
        // イラストをクリックする
    	seigas.get(indexseigas).click();
        waitAfterTransaction();
        List<WebElement> checkboxs = driver.findElements(By.name("skip"));
        if(0 < checkboxs.size()){
            // 春画コンテンツの警告画面表示時
        	checkboxs.get(0).click();
            waitSimulateChangingFocus();
        	driver.findElements(By.id("yes")).get(0).click();
            waitAfterTransaction();
        }
        //初期イラスト情報を用意する
        String[] urlParts = driver.getCurrentUrl().split("/");
        NicoNicoSeigaInfo seigaInfo = new NicoNicoSeigaInfo(
        	urlParts[urlParts.length - 1],
        	"",
        	"",
        	"",
        	(new String[0]),
        	(new Date(0)),
        	driver.getCurrentUrl());
        List<WebElement> errtitles = driver.findElements(By.id("error_ttl"));
        if(0 < errtitles.size()){
            // エラー画面表示時
            driver.navigate().back();
            waitAfterTransaction();
        	return seigaInfo;
        }
        
        // 春画コンテンツの判定をする
        boolean isR15 = 0 < driver.findElements(By.cssSelector(".illust_type")).size();
        
        // イラストを見る
        // img要素(中央イラスト)の取得にまれに失敗する。気休めに0.5秒停止する。
        wait(500);
        scroll4WatchingSeiga(driver,driver.findElements(By.cssSelector("#illust_link > img")).get(0).getLocation().y);
        waitSimulateWatchingSeiga();
        // イラスト情報を取得する        
        ArrayList<String> tagValues = new ArrayList<String>();
        for(WebElement tag:(driver.findElements(By.cssSelector(".tag")))){
        	String tagValue = tag.getText();
        	if(0 < tagValue.length()){
        		// タグが空でない時
                tagValues.add(tagValue);
        	}
        };
        
        try{
            seigaInfo = 
                new NicoNicoSeigaInfo( 
                    urlParts[urlParts.length - 1],
                    driver.findElements(By.cssSelector(isR15 ? ".title_text" : ".title")).get(0).getText(),
                    driver.findElements(By.cssSelector("#sg_pankuzu li:nth-child(2) > a > span")).get(0).getText().replace(" さんのイラスト", ""),
                    driver.findElements(By.cssSelector(isR15 ? ".illust_user_exp" : ".discription")).get(0).getText(),
                    (String[])tagValues.toArray(new String[tagValues.size()]),
                    (new SimpleDateFormat(isR15 ? "yyyy'年'MM'月'dd'日 'HH':'mm':'ss" : "yyyy'年'MM'月'dd'日 'HH':'mm")).parse(
                        driver.findElements(By.cssSelector(isR15 ? ".created_date" : ".other_info > .date")).get(0).getText().replace("&nbsp;投稿", "")),
                    driver.getCurrentUrl()
                        );
        }catch(ParseException e){
            e.printStackTrace();            
        }
        driver.navigate().back();
        waitAfterTransaction();
        
        return seigaInfo;
    }
    
    private static void simulateSendKeys(WebElement element, String keyword){
    	char[] keys = keyword.toCharArray();
    	for(char key:keys){
    		element.sendKeys();
        	element.sendKeys(String.valueOf(key));
        	waitSimulateInputingChar();
    	}
    }
    
    private static void waitAfterTransaction(){
        int minWaitTime = 1000;
        int maxWaitTime = 2000;
        wait(getRandomInt(minWaitTime,maxWaitTime));
    }
    
    private static void waitSimulateChoosingSeiga(){
        int minWaitTime = 1000;
        int maxWaitTime = 2000;
        wait(getRandomInt(minWaitTime,maxWaitTime));
    }
    
    private static void scroll4WatchingSeiga(WebDriver driver, int length){
        int minScrollLength = 35;
        int maxScrollLength = 45;
        scroll(driver,0,length - getRandomInt(minScrollLength,maxScrollLength));
    }
    
    private static void waitSimulateWatchingSeiga(){
        int minWaitTime = 4000;
        int maxWaitTime = 5000;
        wait(getRandomInt(minWaitTime,maxWaitTime));
    }
    
    private static void waitSimulateInputingChar(){
        int minWaitTime = 50;
        int maxWaitTime = 250;
        wait(getRandomInt(minWaitTime,maxWaitTime));
    }
    
    private static void waitSimulateChangingFocus(){
        int minWaitTime = 200;
        int maxWaitTime = 500;
        wait(getRandomInt(minWaitTime,maxWaitTime));
    }
    
    private static void wait(int millisecond) {
        try {
            Thread.sleep(millisecond);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
    private static void scroll(WebDriver driver, int scrollX, int scrollY){
        ((JavascriptExecutor)driver).executeScript("scroll(" + scrollX + ", " + scrollY + ")");
    }
    
	private static int getRandomInt(int minWaitTime, int maxWaitTime){
	    int sleepTime = -1;
	    while(! (minWaitTime <= sleepTime)){
	        sleepTime = (int)Math.round(Math.random() * maxWaitTime);
	    }
	    return sleepTime;
	}
    
    private static void createSearchResultFile(
        WebDriver driver,
        String keywords,
        SortedMap<String,NicoNicoSeigaInfo> results
            ){
        Date now = new Date();
        SimpleDateFormat sdf4fileName = new SimpleDateFormat("yyyyMMddHHmm");
        SimpleDateFormat sdf4created = new SimpleDateFormat("yyyy'-'MM'-'dd' 'HH':'mm':'ss");
        try{
        	// 新規XMLファイルを作る
            File file = new File(keywords.replace(" ", "-") + "_" + sdf4fileName.format(now) + ".XML");
        	// イラスト情報をファイルに書き込む
            PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter(file)));
            pw.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
            pw.println("<response>");
            for(NicoNicoSeigaInfo seigaInfo:results.values()){
                pw.println("  <image>");
                pw.println("    <id>" + seigaInfo.getId() + "</id>");            
                pw.println("    <title>" + seigaInfo.getTitle() + "</title>");
                pw.println("    <user_name>" + seigaInfo.getUserName() + "</user_name>");            
                pw.println("    <description>" + seigaInfo.getDescription() + "</description>");            
                pw.println("    <tag_list>");
                for(String tag:(seigaInfo.getTagList())){
                    pw.println("      <tag>");
                    pw.println("        <name>" + tag + "</name>");                
                    pw.println("      </tag>");
                }
                pw.println("    </tag_list>");
                pw.println("    <created>" + sdf4created.format(seigaInfo.getCreated()) + "</created>"); 
                pw.println("    <original_url>" + seigaInfo.getOriginalUrl() + "</original_url>");            
                pw.println("  </image>");
            }
            pw.println("</response>");
            pw.close();
        }catch(IOException e){
            e.printStackTrace();
        }
    }
}
```

```java:NicoNicoSeigaInfo.java
/**
NicoNicoSeigaCrawler
Copyright (c) 2014 nezuq
This software is released under the MIT License.
http://opensource.org/licenses/mit-license.php
*/

import java.util.*;
 
public class NicoNicoSeigaInfo {
    private String id;
    private String title;
    private String user_name;
    private String description;
    private String[] tag_list;
    private Date created;
    private String original_url;
    
    NicoNicoSeigaInfo(
        String id,
        String title,
        String user_name,
        String description,
        String[] tag_list,
        Date created,
        String original_url
            ){
        this.id = id;
        this.title = title;
        this.user_name = user_name;
        this.description = description;
        this.tag_list = tag_list;
        this.created = created;
        this.original_url = original_url;
    }
    
    public String getId()
    {
        return this.id;
    }
 
    public String getTitle()
    {
        return this.title;
    }
    
    public String getUserName()
    {
        return this.user_name;
    }
    
    public String getDescription()
    {
        return this.description;
    }
    
    public String[] getTagList()
    {
        return this.tag_list;
    }
    
    public Date getCreated()
    {
        return this.created;
    }
    
    public String getOriginalUrl()
    {
        return this.original_url;
    }
        
}
```

```txt:LICENSE.txt
The MIT License (MIT)

Copyright (c) 2014 nezuq

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```

04.Jarファイルとして出力する。
05.コマンドプロンプト（端末）から実行する。

```bash:クローラー(Firefox)を起動する
java -jar ./NicoNicoSeigaCrawler.jar "<検索タグ>(スペース区切りで複数指定可)" "<ニコニコ静画登録メールアドレス>" "<ニコニコ静画登録パスワード>"
```

なお、出力される作品データファイルは以下の形式になる。

```xml:検索タグ1-検索タグ2_yyyyMMddhhmmss.XML
<?xml version="1.0" encoding="UTF-8"?>
<response>
  <image>
    <id>im0000000</id>
    <title>イラストのタイトル</title>
    <user_name>アカウント名</user_name>
    <description>キャプション</description>
    <tag_list>
      <tag>
        <name>タグ1</name>
      </tag>
      <tag>
        <name>タグ2</name>
      </tag>
      <tag>
        <name>タグN</name>
      </tag>
    </tag_list>
    <created>yyyy-MM-dd hh:mm:ss</created>
    <original_url>http://seiga.nicovideo.jp/seiga/im0000000</original_url>
  </image>
</response>
```