かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

node.js Mashup: axios で Blog 記事内のリンク切れを探してリスト化したファイルを作りたい

運用している blog にリンク切れのものがそこそこあるので、リストアップして欲しいという依頼を受けてやってみた知見のメモ

方針

  1. 記事 URL から記事内容を取得
  2. 記事内から a タグを取得
  3. a タグの リンク先にアクセスしてリンク切れになっているものをファイルに記録
  4. ブログ内のページネーションなどから次の記事 URL を取得して 1. に戻る
  5. 次の記事がなければ終了

localhost からスクリプトを叩くので所謂 マッシュアップ になります。 (勝手にやると怒られるやつなので気をつけましょう)

1. 記事 URL から記事内容を取得

axios を使って記事内容を get します。
最初はブラウザから実行しようと思っていたのですが、 cors の問題を突破できなかったので node.js から実行することにしました。 (node だと cros 制限ないのか)

$ yarn add -D axios

index.js

const axios = require('axios').default;
const URL = 'url';

async function getWebPage(url) {
  return await axios.get(url, {
    withCredentials: true
  })
    .then(function (res) {
      console.log(res);
      return res;
    })
    .catch(function (error) {
      throw error;
    });
}

getBrokenLink(URL);

axios.get でページ内容を取得するだけ。

2. 記事内から a タグを取得

axiox.get で取得されるのが文字列になった HTML 情報なので node-html-parser を使って DOM 操作できるようにして、記事内から a タグを取り出します

$ yarn add -D node-html-parser

index.js

const axios = require('axios').default;
const parse = require('node-html-parser').default;
const URL = 'url';
const BLOG_ENTRY_SELECTOR = '#entry-body';

// 中略

// link 情報を [{label, url}, ...] の形に整形する
function formatLinkData(links) {
  const linkList = links.reduce((arr, link) => {
    const url = link.getAttribute('href');
    // チェックしないリンク先を除く
    if ( !url || url.match(/^mailto:/) ) {
      return arr;
    }
    // 既にリンク先がある場合は除く
    if ( arr.some((data) => data.url === url) ) {
      return arr;
    }

    return [...arr, {
      label: link.text,
      url: url,
    }];
  }, []);
  
  return linkList.length? linkList : false; 
}

function getLinks(htmlStr) {
  const root = parse(htmlStr);
  const entry = root.querySelector(BLOG_ENTRY_SELECTOR);
  if (!entry) {
    console.warn('NO ENTRY!');
    return false;
  }

  const pageLinks = entry.querySelectorAll('a');
  if (pageLinks.length === 0) {
    console.log('no link page');
    return false;
  }
  return formatLinkData(pageLinks);
}

async function init(url) {
  let res;
  try {
    res = await getWebPage(url);
  } catch (err) {
    console.log(err);
    return;
  }
  // HTML 情報は data に入っている
  const links = getLinks(res.data);
  if (!links) {
    console.log(url);
    return false;
  }
  console.log(links);
}

init(URL);

Blog の様な形だとコンテンツが入っている箇所の id や class が固定なので、エリアを指定して a タグの情報を集めると不要なリンクもチェックする必要がなくなります。

3. a タグの リンク先にアクセスしてリンク切れになっているものをファイルに記録

3-1. リンク切れになっているリンクのリストを作成する

status が 200番台 以外、つまり axios.get()reject されるものを集めてリストにすれば良さそうです。
aタグから作成したリンクリストを Promise.all で全チェックすればよいのですが、Promise.allPromise.reject が返ってくると、そこで処理が終わってしまうので、今回のように最終的に reject になったものを集めたい場合は少し工夫が必要です。

index.js

// 略

async function getWebPage(url) {
  return await axios.get(url, {
    withCredentials: true
  })
    .then(function (res) {
      console.log(res);
      return res;
    })
    .catch(function (error) {
      throw error;
    });
}

async function checkURLVaild(url) {
  return await getWebPage(url)
    .catch((err) => {
      // catch でそのまま return すると reject されて Promise.all が終わってしまうんので、Promise.resolve を返すようにする
      return Promise.resolve(false);
    });
}

async function validLinks(links) {
  const validLinkList = await Promise.all(
    links.map(async (linkData) => {
      const linkURL = linkData.url;
      const validLink = await checkURLVaild(linkURL);
      console.log(linkURL, validLink.status || null);
      // リンク先が正しくあるものは今回不要
      if (validLink.status) {
        return null;
      }
      return {...linkData};
    })
  );
  // リンク先がある = null のデータを取り除いて返す
  return validLinkList.filter(Boolean);
}

async function init(url) {
  // 略
  const brokenLinks = await validLinks(links);
  if ( brokenLinks.length === 0 ) {
    // リンク切れ無し
    return;
  }
  console.log(brokenLinks);
}

init(URL);

Promise.all にチェックしたいリストを map で展開して渡すことで並行処理が出来る

また、Array.filter(Boolean)null なデータを取り除いていますが、Promise.all にくっつけて下記のように書くと非同期処理が完了する前に filter が走ってしまい意図したとおりに動作しません。

🙅

const validLinkList = await Promise.all(
  links.map( async (linkData) => ...)
).filter(Boolean); // Promise.all が完了する前に filter が走ってしまう

3-2. リンク切れになっているリストを ファイルに保存する

node.js blob を使ってファイルを作成する方法がイマイチわからなかったので、シンプルに fs でファイル書き込みをしていくことにしました

書き込み先のファイルを作成

$ touch brokenlist.csv

index.js

const path = require('path');
const fs = require('fs');

const DIST_FILE = path.resolve(__dirname, './brokenlist.csv');

// 略

function formatCSV(list) {
  return list.reduce((arr, data) => {
    const brokenLinks = data.brokenLinks.map((linkData) => {
      return Object.values(linkData).join(',');
    });
    return [
      ...arr,
      [data.page, ...brokenLinks].join("\n")
    ];
  }, []);
}

function appendFile(list) {
  const csvRows = formatCSV(list);
  if (csvRows.length === 0) {
    return;
  }
  try {
    // 行末に改行を加える
    fs.appendFileSync(path.join(DIST_FILE), csvRows.join("\n") + "\n");
  }  catch (err) {
    console.warn(err);
  }
}

async function init(url) {
  const brokenLinks = validLinkList.filter(Boolean);
  if ( brokenLinks.length === 0 ) {
    return;
  }
  appendFile([{
    page: url,
    brokenLinks,
  }]);
}

init(URL);

リンク切れのリストに該当ページのURLを追加して、CSV形式にフォーマットしてファイルに追加書き込みをしてるだけです。

Tips Array.filter(Boolean) で false になる値を取り除くことが出来ます。

4. ブログ内のページネーションなどから次の記事 URL を取得して再帰的にページ内のリンクをチェックできるようにする

Blog なので次の記事のリンクを探して存在すれば再帰的に記事内のリンクを探す処理を呼び出して、無ければ終了するようにすれば特定の記事から遡って次の記事が無くなるまで自動的にページ内にあるリンクをチェックしてファイルに書き出し続けるようになります。

つまり次にチェックするページのURLを取得して、 return していた部分を次のページがあれば再帰、無ければ終了する関数を呼び出すように変更すればOK。

index.js

const BLOG_NEXT_SELECTOR = '.next-link';

function getLinks(root) {
  const entry = root.querySelector(BLOG_ENTRY_SELECTOR);
  // 中略
  return formatLinkData(pageLinks);
}

function getNextLink(root) {
  const nextLink = root.querySelector(BLOG_NEXT_SELECTOR);
  if (!nextLink) {
    console.log('NO NEXT PAGE!');
    return false;
  }
  return nextLink.getAttribute('href');
}

// 略

async function goNextPage(nextURL) {
  if (!nextURL) {
    return;
  }
  // 再帰呼び出し
  return await checkBrokenLinkByURL(nextURL);
}

async function checkBrokenLinkByURL(url) {
  let res;
  try {
    res = await getWebPage(url);
  } catch (err) {
    console.log(err);
    return;
  }

  // HTML 情報は data に入っている
  const root = parse(res.data);
  const links = getLinks(root);
  // 次のリンクURLを取得 
  const nextURL = getNextLink(root);
  if (!links) {
    return await goNextPage(nextURL);
  }

  const brokenLinks = await validLinks(links);
  if (brokenLinks.length === 0) {
    // リンク切れ無し
    return await goNextPage(nextURL);
  }

  // ファイルにリンク切れのデータを追記する
  appendFile({ page: url, brokenLinks, });

  return await goNextPage(nextURL);
}

async function init(url) {
  await checkBrokenLinkByURL(url);
  console.log('COMPLETE!');
}

init(URL);

ポイントは、次のリンクの有無で再帰させるか、終了させるかを判別する関数の呼び出しを return await goNextPage(nextURL) の形で呼び出している点になります。
return が無いと再帰されず init 関数で先に COMPLETE! メッセージが表示されてしまいます。
また再帰的に呼び出す checkBrokenLinkByURL 関数が非同期処理を扱っているので、この関数を呼び出す goNextPage()await キーワードを付けて呼び出す必要があります。 await キーワードがない場合、非同期処理を待たないので、init 関数で処理が完了する前に COMPLETE! メッセージが表示されてしまいます。

所感

マッシュアップなのでブラウザからの実行は cors 問題に当たってしまう。(ブラウザ間のやり取りははお行儀よくAPI使いましょうという事ですね〜)
ファイルへの書き込みは、データを貯めて最後にまとめて書き出しでも良いのですが、記事数が多い場合メモリを食ってしまったり、ドコかでエラーが発生するとそれまでチェックしたデータも失われてしまうので、残しておく情報が出現する度にファイルに追記する方が安全だと思いました。


[参考]

Node.jsデザインパターン 第2版

Node.jsデザインパターン 第2版

じゃがりこでマッシュポテト…時々食べたくなるよね…