ニコ動のマイリストをバックアップするはなし

はじめに

この記事はWeb スクレイピング Advent Calendar 2017 - Adventar の 13 日目の記事です。

タイトルの通りニコ動のマイリストをバックアップします。

ここで面白い前口上とかがあればよかったのですが特に思いつきませんでした。

使用するもの

使用する言語は個人的に好きな Node.js を採用。

何も気にせずゴリゴリ書けるところが好きです。

非公式 API を使えばhttp.requestで事足りるのですが、それだと「Web スクレイピングっぽくない!」と思ったのでヘッドレスブラウザを使うことにしました。

ヘッドレスブラウザはいろいろあるみたいですが、puppeteer が一番ナウでヤングな感じがしたので採用しました。

処理を書いていく

「ざっくりした説明 ~ソースコードを添えて~」

感じ取ってくれ。

ログイン確認

  1. http://www.nicovideo.jp/my/mylistにアクセス
  2. リダイレクトとuserIdを確認

ログインページにリダイレクトされるか、ページ内でuserIdが定義されていなければログイン処理を行います。

こうしておけば初回だけログイン処理を行い、cookie に保持されている user_session が有効な限りはログイン処理を省けます。

page.evaluate()を使えばページ内で定義されている変数を取得したりできます。

puppeteer.launch()するときにuserDataDirを設定しておかないと cookie が保持されないっぽいので注意が必要です。

waitUntil に関して

page.goto()のオプションでwaitUntil: "networkidle0"を設定すれば、「500ms 間にネットワーク接続が 0 個になるまで待つ」ようにできるのですが、タイムアウトすることが頻繁にありました。

おそらく広告などの読み込みが常に発生している?などが原因かなと思っています。

networkidle0が使えれば楽なのですが安定しないので、ページ内で JavaScript の変数が定義されるまで待機ループさせることにしました。

定義されていない場合もあるのでとりあえず 200ms 間隔で 10 回までにしました。

定義されていない場合に 1 秒間のロスが出ますが許容範囲かなぁっと思っています。

const myPageUrl = "http://www.nicovideo.jp/my/mylist";

console.log("Login checking...");

// ログイン確認
await page.goto(myPageUrl);

let userId;
// userIdの取得ループ
for (let i = 0; i < 10 && !userId; i++) {
    if (i > 0) await page.waitFor(200);
    userId = await page.evaluate(() => {
        if (typeof userId == "undefined" || !userId) return;
        return userId;
    });
}

let loginResult;
if ((await page.url()) != myPageUrl || !userId) {
    // ログインできていない場合

    // メールアドレスとパスワードの入力待ち
    const inputResult = await inputMailAndPass();
    const loginMailtel = inputResult.mail;
    const loginPassword = inputResult.password;
    // ログイン処理を行う
    loginResult = await login(page, loginMailtel, loginPassword);
} else {
    // ログインできている場合
    loginResult = true;
}

ログイン処理

  1. https://account.nicovideo.jp/loginにアクセス
  2. #input__mailtel#input__passwoardを入力
  3. #login__submitをクリック
  4. リダイレクトとuserIdを確認

ログインに成功した場合はhttp://www.nicovideo.jp/video_topに、失敗した場合はhttps://account.nicovideo.jp/api/v1/login?show_button_twitter=1&site=niconico&show_button_facebook=1にリダイレクトされます。

page.waitForNavigationでリダイレクトを待機して、ページの URL とuserIdを確認してログインに成功したかを判定します。

/**
 * ログイン処理
 * @param {puppeteer.page} page ログインに使うページ
 * @param {string} loginMailtel ログインに使うメールアドレスor電話番号
 * @param {string} loginPassword ログインに使うパスワード
 * @returns {boolean} ログインに成功したかどうか
 */
async function login(page, loginMailtel, loginPassword) {
    const loginUrl = "https://account.nicovideo.jp/login";
    if ((await page.url()).split("?")[0] != loginUrl) {
        await page.goto(loginUrl);
    }

    // メールアドレスとパスワードを入力
    await page.type("#input__mailtel", loginMailtel);
    await page.type("#input__password", loginPassword);
    // ログインボタンをクリック(リダイレクトで待機するのでここでは待機しない)
    page.click("#login__submit");

    // リダイレクトを待つ
    await page.waitForNavigation();

    const nowUrl = await page.url();
    let userId;
    // userIdの取得ループ
    for (let i = 0; i < 10 && !userId; i++) {
        if (i > 0) page.waitFor(200);
        userId = await page.evaluate(() => {
            if (typeof userId == "undefined" || !userId) return;
            return userId;
        });
    }

    // ログインページ以外にリダイレクト & userIdが定義されていれば成功
    return !nowUrl.match("login") && userId;
}

マイリスト一覧の取得

  1. http://www.nicovideo.jp/my/mylistにアクセス
  2. ページ内で定義されているmy.groupsを取得

my.groups

[
    {
        id: 12345, // マイリストID
        user_id: 12345, // ユーザーID
        name: "hoge", // マイリスト名
        description: "", // マイリストの説明
        update_time: 1465813179, // 更新日時(秒まで)
        public: false, // 公開マイリストかどうか
        default_sort: 1, // 初期ソート
        sort_order: 19 // 現在のソート?
    }
    // 続く
];

といった感じの配列になっています。

namedescriptionは特殊文字が html エスケープされています。

とりあえずマイリストは含まれていないので適当に追加しておきます。

/**
 * マイリスト一覧を取得する
 * @param {puppeteer.page} page 取得に使うページ
 * @returns {array} マイリスト一覧
 */
async function getMylistGroups(page) {
    const myPageUrl = "http://www.nicovideo.jp/my/mylist";
    if ((await page.url()) != myPageUrl) {
        await page.goto(myPageUrl);
    }

    let groups;
    // my.groupsの取得ループ
    for (let i = 0; i < 10; i++) {
        if (i > 0) await page.waitFor(200);
        groups = await page.evaluate(() => {
            if (typeof my == "undefined") return;
            return my.groups;
        });
    }
    if (!Array.isArray(groups)) {
        return [];
    }
    groups.unshift({
        id: "deflist",
        name: "とりあえずマイリスト"
    });

    return groups;
}

マイリストの中身を取得する

  1. http://www.nicovideo.jp/mylist/ここにIDにアクセス
  2. my.currentItemsからアイテム情報を取得
  3. my.currentGroupからマイリスト情報を取得

マイページのマイリストページを使ってもいいのですが、他人のマイリストを取得することもあるかもしれないので通常のマイリストページを使います。

my.currentItemsにアイテムの情報が入っています。(マイページの方も同じ)

とりあえずマイリストは通常のマイリストページで表示できないのでマイページの方を使います。

マイリストの情報はmy.currentGroupに定義されているのですが、とりあえずマイリストの場合はnullになっているので適当につくります。

これまで通りpage.evaluate()で取り出します。

アイテムの情報はそのまま保存するには少々無駄なものが多いので、必要なものだけに絞ります。

/**
 * マイリストを取得する
 * @param {puppeteer.page} page 取得に使うページ
 * @param {string} mylistId マイリストID (とりあえずマイリストの場合はdeflist)
 */
async function getMylist(page, mylistId) {
    var pageUrl = "http://www.nicovideo.jp/mylist/" + mylistId;
    if (mylistId == "deflist") {
        pageUrl = "http://www.nicovideo.jp/my/mylist/#/home";
    }

    if ((await page.url()) != pageUrl) {
        await page.goto(pageUrl);
    }

    var rawItems;
    // my.currentItemsの取得ループ
    for (let i = 0; i < 10 && !rawItems; i++) {
        if (i > 0) await page.waitFor(200);
        rawItems = await page.evaluate(() => {
            if (typeof my == "undefined") return;
            return my.currentItems;
        });
    }
    var mylistData;
    if (mylistId == "deflist") {
        mylistData = {
            id: "deflist",
            name: "とりあえずマイリスト"
        };
    } else {
        // my.currentGroupの取得ループ
        for (let i = 0; i < 10 && !mylistData; i++) {
            if (i > 0) await page.waitFor(200);
            mylistData = await page.evaluate(() => {
                if (typeof my == "undefined") return;
                return my.currentGroup;
            });
        }
    }

    if (!Array.isArray(rawItems) || !mylistData) {
        return null;
    }

    var items = [];
    for (let i = 0; i < rawItems.length; i++) {
        var rawItem = rawItems[i];
        var rawData = rawItem.item_data;
        if (!rawData || rawItem.item_type != 0) continue;
        var item = {
            // マイリストコメント?
            description: rawItem.description,
            // マイリストに登録した日時
            create_time: rawItem.create_time,
            // マイリストアイテムID?マイリストを編集するAPIで使うっぽい
            item_id: rawItem.item_id,
            // マイリストアイテムの更新日時 コメントを編集したときなどに変わる
            update_time: rawItem.update_time,
            // 動画情報
            item_data: {
                video_id: rawData.video_id,
                watch_id: rawData.watch_id,
                title: rawData.title,
                thumbnail_url: rawData.thumbnail_url,
                length_seconds: rawData.length_seconds
            }
        };
        items.push(item);
    }

    return {
        data: mylistData,
        items: items
    };
}

完成したもの

基本的な処理は上記の通りです。

あとはマイリストを順番に取得して JSON で保存する処理を書いて終わりです。

マイリストの取得間隔は 3000ms にしました。

もう少し早くてもいいかなぁって思ったのですがリクエストが短時間に多すぎると怒られました。

GitHub にも置いてます。

totoraj930/nico-mylist-backup | GitHub

"use strict";
const fs = require("fs");
const puppeteer = require("puppeteer"); // メインディッシュ
const prompt = require("prompt"); // 入力受付に使う
const sanitize = require("sanitize-filename"); // ファイル名をつくるのに使う

let fileNameTemplate = "$ID-$NAME";

const WAIT_MAX_NUM = 10;
const WAIT_INTERVAL = 200;

(async () => {
    // puppeteer起動!!!
    const browser = await puppeteer.launch({
        headless: true,
        userDataDir: __dirname + "/UserData" // User Dataのディレクトリを設定(これ重要)
    });
    // ページを用意
    const page = await browser.newPage();
    const myPageUrl = "http://www.nicovideo.jp/my/mylist";

    console.log("Login checking...");

    // ログイン確認
    await page.goto(myPageUrl);

    // userIdの取得ループ
    let userId;
    for (let i = 0; i < WAIT_MAX_NUM && !userId; i++) {
        if (i > 0) await page.waitFor(WAIT_INTERVAL);
        userId = await page.evaluate(() => {
            if (typeof userId == "undefined" || !userId) return;
            return userId;
        });
    }

    // ログインできていなければログイン処理
    let loginResult;
    if ((await page.url()) != myPageUrl || !userId) {
        // メールアドレスとパスワードの入力待ち
        const inputResult = await inputMailAndPass();
        const loginMailtel = inputResult.mail;
        const loginPassword = inputResult.password;

        loginResult = await login(page, loginMailtel, loginPassword);
    } else {
        // ログイン済みであれば何もせずtrue
        loginResult = true;
    }

    // ログインに失敗したら終了
    if (!loginResult) {
        console.error("Login failed.");
        await browser.close();
        return;
    }

    console.log("Login success.");

    // マイリスト一覧を取得
    var mylists = await getMylistGroups(page);
    if (!mylists) {
        console.error("getMylistGroups() failed.");
        await browser.close();
        return;
    }
    console.log("getMylistGroups() success.");

    // マイリストを順番に取得
    for (let i = 0; i < mylists.length; i++) {
        if (i > 0) await page.waitFor(3000);
        var mylist = await getMylist(page, mylists[i].id);
        if (!mylist) continue;
        var filename = generateFileName(mylist.data);
        fs.writeFileSync("./mylists/" + filename, JSON.stringify(mylist, null, "    "));
        console.log("getMylist() success:", filename);
    }

    console.log("Backup completed!!!");

    await browser.close();
    return;
})();

/**
 * メールアドレスとパスワードの入力受付
 */
async function inputMailAndPass() {
    var scheme = {
        properties: {
            mail: {
                message: "Mail or TEL"
            },
            password: {
                message: "Password",
                hidden: true
            }
        }
    };
    return new Promise((resolve, reject) => {
        prompt.get(scheme, (err, res) => {
            if (err) {
                reject(err);
                return;
            }
            resolve({
                mail: res.mail,
                password: res.password
            });
        });
    });
}

/**
 * マイリスト保存時のファイル名を生成する
 * @param {string} mylistData マイリスト情報
 * @returns {string} ファイル名
 */
function generateFileName(mylistData) {
    var tmp = fileNameTemplate || "$ID-$NAME";
    tmp = tmp.replace(/\$ID/g, mylistData.id);
    tmp = tmp.replace(/\$NAME/g, mylistData.name);

    return sanitize(tmp) + ".json";
}

/**
 * ログイン処理
 * @param {puppeteer.page} page ログインに使うページ
 * @param {string} loginMailtel ログインに使うメールアドレスor電話番号
 * @param {string} loginPassword ログインに使うパスワード
 * @returns {boolean} ログインに成功したかどうか
 */
async function login(page, loginMailtel, loginPassword) {
    const loginUrl = "https://account.nicovideo.jp/login";
    if ((await page.url()).split("?")[0] != loginUrl) {
        await page.goto(loginUrl);
    }

    // メールアドレスとパスワードを入力
    await page.type("#input__mailtel", loginMailtel);
    await page.type("#input__password", loginPassword);
    // ログインボタンをクリック(リダイレクトで待機するのでここでは待機しない)
    page.click("#login__submit");

    // リダイレクトを待つ
    await page.waitForNavigation();

    // ログインページ以外にリダイレクト & userIdが定義されていれば成功
    const nowUrl = await page.url();
    let userId;
    for (let i = 0; i < WAIT_MAX_NUM && !userId; i++) {
        if (i > 0) await page.waitFor(WAIT_INTERVAL);
        userId = await page.evaluate(() => {
            if (typeof userId == "undefined" || !userId) return;
            return userId;
        });
    }
    return !nowUrl.match("login") && userId;
}

/**
 * マイリスト一覧を取得する
 * @param {puppeteer.page} page 取得に使うページ
 * @returns {array} マイリスト一覧
 */
async function getMylistGroups(page) {
    const myPageUrl = "http://www.nicovideo.jp/my/mylist";
    if ((await page.url()) != myPageUrl) {
        await page.goto(myPageUrl);
    }

    let groups;
    for (let i = 0; i < WAIT_MAX_NUM; i++) {
        if (i > 0) await page.waitFor(WAIT_INTERVAL);
        groups = await page.evaluate(() => {
            if (typeof my == "undefined") return;
            return my.groups;
        });
    }
    if (!Array.isArray(groups)) {
        return [];
    }
    groups.unshift({
        id: "deflist",
        name: "とりあえずマイリスト"
    });

    return groups;
}

/**
 * マイリストを取得する
 * @param {puppeteer.page} page 取得に使うページ
 * @param {string} mylistId マイリストID (とりあえずマイリストの場合はdeflist)
 */
async function getMylist(page, mylistId) {
    var pageUrl = "http://www.nicovideo.jp/mylist/" + mylistId;
    if (mylistId == "deflist") {
        pageUrl = "http://www.nicovideo.jp/my/mylist/#/home";
    }

    if ((await page.url()) != pageUrl) {
        await page.goto(pageUrl);
    }

    var rawItems;
    for (let i = 0; i < WAIT_MAX_NUM && !rawItems; i++) {
        if (i > 0) await page.waitFor(WAIT_INTERVAL);
        rawItems = await page.evaluate(() => {
            if (typeof my == "undefined") return;
            return my.currentItems;
        });
    }
    var mylistData;
    if (mylistId == "deflist") {
        mylistData = {
            id: "deflist",
            name: "とりあえずマイリスト"
        };
    } else {
        for (let i = 0; i < WAIT_MAX_NUM && !mylistData; i++) {
            if (i > 0) await page.waitFor(WAIT_INTERVAL);
            mylistData = await page.evaluate(() => {
                if (typeof my == "undefined") return;
                return my.currentGroup;
            });
        }
    }

    if (!Array.isArray(rawItems) || !mylistData) {
        return null;
    }

    var items = [];
    for (let i = 0; i < rawItems.length; i++) {
        var rawItem = rawItems[i];
        var rawData = rawItem.item_data;
        if (!rawData || rawItem.item_type != 0) continue;
        var item = {
            // マイリストコメント?
            description: rawItem.description,
            // マイリストに登録した日時
            create_time: rawItem.create_time,
            // マイリストアイテムID?マイリストを編集するAPIで使うっぽい
            item_id: rawItem.item_id,
            // マイリストアイテムの更新日時 コメントを編集したときなどに変わる
            update_time: rawItem.update_time,
            // 動画情報
            item_data: {
                video_id: rawData.video_id,
                watch_id: rawData.watch_id,
                title: rawData.title,
                thumbnail_url: rawData.thumbnail_url,
                length_seconds: rawData.length_seconds
            }
        };
        items.push(item);
    }

    return {
        data: mylistData,
        items: items
    };
}

さいごに

puppeteer 強すぎる。

ページを開いて JavaScript にアクセスできるのが凶悪だった。

普段通りブラウザの console いじる感覚で色々できるので良い。

けど名前打ち間違えやすいのと起動がちょっと遅い?かも。

自分のスペックの問題かな・・・。

名前をどうしても「ぷっぺてぇえあ」って読んでしまうので良くない。

以上です。