Node.jsでリンク切れチェックツールを作る
Blogger用のリンク切れチェックツールが欲しいと思ったのですが、セキュリティ面でいろいろと問題が表出している昨今、出所が不明なツールを使って意図しないアクセスを生んだりするのも不安だったので、自作してみることにしました。
ページの最後にコピーで(おそらく)そのまま利用できるコードも紹介しています。
環境はWindows10の64bitで、Node.js(14.15.4)を利用しています。
もしNode.jsをインストールしていないようでしたらインストールしてください。それらの初期設定につきましては「Node.js環境の整備」の記事で紹介していますのでそちらを参考にしてください。
ブログのURLリストを取得する
まず、自分のサイトのURLのリストを取得します。筆者の利用しているBloggerではXMLサイトマップが自動で生成されるので、ここから元のリストを取得します。
記事の数が少ない時は、https://blogのホーム/sitemap.xmlにそのままサイトマップが表示されていますが、数が増えると複数のサイトマップへのリンクをまとめたサイトマップインデックスに変わります。その場合、サイトマップのリンクはhttps://nanbu.marune205.net/sitemap.xml?page=1というようにクエリパラメータにインデックスが入ったものになります。そのアドレスにアクセスすると、分割されたサイトマップが表示されます。
ここで利用するXMLサイトマップは次のようになっています。規格なのでおおむねどこも同じだとは思いますが、違うようなら後で説明するXMLパーサの部分を自身の環境に合うように修正してください。
<?xml version='1.0' encoding='UTF-8'?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url><loc>https://nanbu.marune205.net/2021/03/msword-font-check.html</loc><lastmod>2021-03-27T13:58:01Z</lastmod></url> <url><loc>https://nanbu.marune205.net/2021/03/make-ticket2.html</loc><lastmod>2021-03-26T07:36:16Z</lastmod></url> <url><loc>https://nanbu.marune205.net/2021/03/mariadbssl.html</loc><lastmod>2021-03-23T14:03:00Z</lastmod></url><url> ...
XMLサイトマップをダウンロードして利用する場合
まず、サイトマップを.xmlファイルとしてローカルにダウンロードしておいて、それを読み込み込む場合のケースを紹介します。XMLの読み込みには「htmlparser2」を利用します。
まず、Nodeプロジェクトを作成して、htmlparser2をインストールします。
PS> mkdir linkchecktool
PS> cd linkchecktool
PS> npm init -y
...
PS> npm install htmlparser2
インストールが終わったらテスト稼働してみます。ファイル読み書きには標準のfsライブラリを使います。fsのreadFileSync関数でstrData変数内にファイルの中身を一括で読みだせるので、この変数をXMLパーサのwriteメソッドに渡します。fs内の関数で、Syncが最後についているものは同期関数で処理を待ち受けます。
htmlparser2は、XMLを先頭から読み込んでいき、タグが開始された際(opentag)、開始されたタグの中身が記述された際(ontext)、タグが閉じられた際(onclosetag)の3パターンのイベントを検知します。実装時はそれらのイベントに合わせて処理を記述します。ここではlocタグのテキストがURLなので、それを取得するためにlocタグが開始されたらフラグをONにします。フラグがONの状態でontextイベントを検知したら、内容をURLとして保存します。loc以外のタグが始まったらフラグをOFFにします。
//Node.jsファイルライブラリ
const fs = require('fs');
//XMLパーサの設定
const htmlparser2 = require("htmlparser2");
let blogLinks = [];
let blnA = false;
const parserBlogLinks = new htmlparser2.Parser({
onopentag(name, attributes) {
//開始タグがlocタグだったらフラグを立てる
if (name === "loc") {
blnA = true;
} else {
blnA = false;
}
},
ontext(text) {
if (blnA) {
blogLinks.push(text);//開始タグがlocタグだったらテキストを取得
}
},
onclosetag(tagname) {
//閉じタグではなにもしない。
},
});
let strData="";
try {
strData = fs.readFileSync("c:¥¥sitemaps.xml", "utf8");
} catch(e) {
console.log(e.message);
}
parserBlogLinks.write(strData);
console.log(blogLinks);
XMLサイトマップを動的に取得する場合
自分のサイトの特定のアドレスからサイトマップを取得する場合はHTTPリクエストを使ってパースするXMLを取得する必要があります。ここではaxiosライブラリを使いました。(以前はGOTを使っていましたが、commonJS環境で利用できなくなってしまったようなので変更しました)
まずaxiosをインストールします。
PS> npm install axios
サイトマップインデックスだった場合は処理を再帰するようにコードを修正すると次のようになります。
//基準になるサイトマップ
const strSitemapRoot = 'https://nanbu.marune205.net/sitemap.xml';
//XMLパーサの設定
const htmlparser2 = require("htmlparser2");
//axiosライブラリの設定
const axios = require('axios');
async function getSiteMaps(strUrl) {
//サイトマップインデックスに対応したパーサ
let parserForBloggerSitemap = new htmlparser2.Parser({
onopentag(name, attributes) {
//ターゲットとなるタグの出現を保持するフラグ
parserForBloggerSitemap.blnTarget=false;
switch(name) {
case "loc":
//フラグをON
parserForBloggerSitemap.blnTarget=true;
break;
case "urlset":
parserForBloggerSitemap.blnNest=false;
break;
case "sitemapindex":
parserForBloggerSitemap.blnNest=true;
break;
default:
}
},
ontext(text) {
//タグ内のテキストではターゲットのタグの時のみ処理
if (parserForBloggerSitemap.blnTarget) {
//オブジェクト内部に結果を保持する設定
if (parserForBloggerSitemap.hasOwnProperty('results')===false) {
parserForBloggerSitemap.results = [];
}
parserForBloggerSitemap.results.push({nest: parserForBloggerSitemap.blnNest, url: text});
}
},
onclosetag(tagname) {
//閉じタグではなにもしない。
},
});
let r = await axios.get(strUrl).then(function (response) {
parserForBloggerSitemap.write(response.data);
parserForBloggerSitemap.end();
console.log(parserForBloggerSitemap.results);
return parserForBloggerSitemap.results;
}).catch(function(error) {
console.log(error);
return [];
});
return r;
}
async function getUrls(strUrl) {
//URLを取得してサイトマップインデックスだった場合は再帰
console.log(strUrl);
let results = await getSiteMapsFromWeb(strUrl);
let strUrls =[];
for (let i =0; i < results.length;i++) {
if (results[i].nest) {
//再帰
let concat = await getUrlsFromWeb(results[i].url);
for (let j = 0; j < concat.length; j++) {
strUrls.push(concat[j]);
}
} else {
strUrls.push(results[i].url);
}
}
return strUrls;
}
//メイン処理
async function main() {
let strBlogUrls = await getUrls(strSitemapRoot);
console.log(strBlogUrls);
}
//実行
main();
ブログのURLを指定してそのページ内のリンクを取得する
調査対象のURLが取得出来たら、次は、URLで表示されるページにあるリンクを取り出します。ブログページの場合はサイトマップとは違ってJavaScriptで生成される事もあるので、ヘッドレスブラウザのpuppeteerを使います。
PS> npm install puppeteer
puppeteerを使って、aタグのhref属性の値とinnerHTMLを配列にして取得します。
この時AdSenseのリンクを含まないように注意しましょう。
//ヘッドレスブラウザライブラリ
const puppeteer = require('puppeteer');
//ページのアドレスからリンクを取得する
async function getLinks(strTargetUrl){
const browser = await puppeteer.launch();
const page = await browser.newPage();
console.log(strTargetUrl);
let result=false;
try {
await page.goto(strTargetUrl, {waitUntil: 'networkidle2'});
//ヘッドレスブラウザの中でJSを使う。この中のコードは本体のコードとは別環境
result = await page.evaluate(function() {
//aタグのリストを取得
let as = document.getElementsByTagName('a');
let ret = [];
for (let i = 0; i < as.length; i++) {
//herf=javascript:等は対象にしない
if (as[i].href.startsWith("http")) {
//アドセンスだったら対象外に
if(as[i].href.indexOf("www.googleadservices.com") < 0) {
ret.push([as[i].href,as[i].innerHTML]);
}
}
}
return ret;
});
} catch(e) {
//donothing
await browser.close();
return false;
}
await browser.close();
return result;
}
リンクを切れをチェックする
ページ内のリンクのリストを取得できたら、先ほど動的サイトマップ用に設定したaxiosライブラリを使ってページの既存チェックをします。また、通信を削減するためにMapを使って結果を保持します。
//一度チェックしたページを記憶して通信を削減
let resultMap = new Map();
async function checkLinkPage(strTargetUrl){
//一度チェックしたページを記憶して通信を削減
if(resultMap.has(strTargetUrl)) {
return resultMap.get(strTargetUrl);
}
try {
let r = await axios.get(strTargetUrl).then(function (response) {
resultMap.set(strTargetUrl,String(e))
return response.status;
}).catch(function(error) {
console.log(error);
return String(error);
});
return r;
} catch(e) {
resultMap.set(strTargetUrl,String(e))
return String(e);
}
}
ちなみに、ピュアJavaScriptのXMLHttpRequestに近いUIを使いたいという場合は、しばらくメンテがされていないようですがNode.jsでXMLHttpRequestを提供するxmlhttprequestライブラリが存在します。その場合のコードは次のようになります。(インストールとモジュール読み込みは省略しています)
詳しく調べていませんが、こちらは301や302リダイレクトが発生した場合、ステータスが301や302として返るようです。
async function checkLinkPageXMLHttpRequest(strLinkUrl) {
if (resultMap.has(strLinkUrl)) {
return resultMap.get(strLinkUrl);
}
let res = await new Promise((resolve,reject)=> {
let req = new XMLHttpRequest();
let result;
//タイムアウト
let timeout=setTimeout(()=> {reject('timeout')},1000 * 60);
req.onreadystatechange=function(){
if (req.readyState == 4) {
clearInterval(timeout);
resultMap.set(strLinkUrl,req.status);
resolve(req.status);
}
};
//ここではPromiseを使いまいしたが、第3引数にfalseを指定して結果を待つ方法もあります
req.open('GET',strLinkUrl,true);
req.send(null);
});
return res;
}
ファイルの出力
チェックの結果は、fsライブラリでCSVファイル化しようと思います。その際にもう一つクリアしないといけない課題があります。
fsの出力は文字コードがデフォルトでutf8となっていて、エクセルで開くと文字化けしてしまいます。
writeSync等のメソッドは引数に文字コードをセットできるようになっているのですが、そこにutf8以外の文字コードをセットしてもうまくいきませんでした。
そこで、iconv-liteというライブラリを使って変換します。ライブラリで利用できる文字コードはリンク先に紹介されていますが、日本語文字コードは「Shift_JIS」「Windows-31j」「Windows932」「EUC-JP」があるそうです。
「Windows-31j」と「Windows932」の違いはよくわかりませんが、後者を使いました。
文字コードの変換と一緒に、CSVファイルの作成に邪魔になる文字である,(半角コンマ)や改行を除去しています。
//文字コード変換
const iconv = require('iconv-lite');
function sanitizeAndConv(str) {
//csvファイルの作成に邪魔になる文字を除去
//またエクセルで開いた時に文字化けしないようにiconvでSJISに
return iconv.encode(str.replace(/(¥r|¥n|,)/g,''),'Windows932');
}
最後にメイン関数を作成します。ここではBloggerのサイトマップから動的にリンクを取得する方法でまとめてみました。fsのopenSyncをwモードで開いてfilehandleの番号を受け取ります。移行はそれを使って書き込みをします。Windows環境ではwで開こうとした際に他のアプリがロックしていると例外が投げられました。
注意が必要なのはsanitizeAndConv関数は(iconv-lintのencode関数は)、StringではなくBufferを返します。fsはBufferでもStringでもどちらでも対応できますが、混在はできません。sanitizeAndConv(...)+','としてしまうとBufferと文字列が混ざってしまい意図したようになりません。
あとそのまま走らせてしまうと503が出たりするので、ページ毎にSleep処理を入れています。
CSV出力のレイアウトは「ページのアドレス」「リンク先アドレス」「リンクテキスト」「結果(NG/OK)」となっています。
//ファイルライブラリ
const fs = require('fs');
//文字コード変換ライブラリ
const iconv = require('iconv-lite');
//保存先
const outputFile ="d:¥¥test.csv";
//メイン処理
async function main() {
let strLinkUrls = await getUrls(strSitemapRoot);
let intFd = fs.openSync(outputFile,'w');
for (let i =0; i < strLinkUrls.length; i++) {
await sleep(5000);
let ret = await getLinks(strLinkUrls[i]);
if (ret === false) {
//ページのリンク取得時に404,503等のエラーが出た場合
fs.writeSync(intFd,sanitizeAndConv(strLinkUrls[i]));
fs.writeSync(intFd,',');
fs.writeSync(intFd,sanitizeAndConv('ページへアクセスできませんでした'));
fs.writeSync(intFd,',');
fs.writeSync(intFd,'');
fs.writeSync(intFd,',');
fs.writeSync(intFd,'NG¥r¥n');
} else {
for (let j = 0; j < ret.length; j++) {
let strStatus = await checkLinkPage(ret[j][0]);
fs.writeSync(intFd,sanitizeAndConv(strLinkUrls[i]));
fs.writeSync(intFd,',');
fs.writeSync(intFd,sanitizeAndConv(ret[j][0]));
fs.writeSync(intFd,',');
fs.writeSync(intFd,sanitizeAndConv(ret[j][1]));
fs.writeSync(intFd,',');
fs.writeSync(intFd,strStatus+'¥r¥n');
}
}
}
fs.closeSync(intFd);
//process.exit(0);//終了
}
function sanitizeAndConv(str) {
//csvファイルの作成に邪魔になる文字を除去
//またエクセルで開いた時に文字化けしないようにiconvでSJISに
return iconv.encode(str.replace(/(¥r|¥n|,)/g,''),'Windows932');
}
async function sleep(intMs) {
//スリープ
return new Promise(function(resolution){ setTimeout(resolution, intMs) });
}
//実行
main();
リンクチェックもpuppeteerで
途中puppeteerを使うのだから、リンクチェックもそれでやればいいと気づいたので追記しました。該当部のコードは次のようになります。puppeteerを使うとダウンロードファイルのチェックや、404表示後のJavaScriptによるリダイレクト等も追いかける事ができます。
async function checkLinkPage(strTargetUrl){
const page = await browser.newPage();
console.log(strTargetUrl);
try {
await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36");
await page._client.send('Page.setDownloadBehavior', {
behavior: 'deny',
downloadPath: 'c:¥¥temp'
});
let strErrMsg ="";
let r = await page.goto(strTargetUrl, {waitUntil: 'domcontentloaded'}).catch(function(e) {
if (0 <= String(e).indexOf('Error: net::ERR_ABORTED')) {
strErrMsg = "abort";
} else {
strErrMsg = String(e);
}
});
let strStatus = strErrMsg === "" ? String(r.status()) : strErrMsg;
await page.close();
return strStatus;
} catch(e) {
//donothing
return String(e);
}
}
page.setUserAgentから始まる設定はユーザーエージェントの設定です。これはブラウザがアクセスする際にソースを提供するサーバーに通知しているブラウザの種類やOSのバージョンなどの情報です。これが設定していないと403を返すサイトが散見されたので設定しています。
ちなみに現在の自分の環境でどのようなユーザーエージェントが渡されているかを確認するには、Google Chromeならアドレスバーに「chrome://version/」と入力することで出てきます。
page.clientから始まる設定は、ダウンロード用の設定です。通常は、behaviorに「allow」を設定することで、指定したdownloadPathに保存されます。
gotoメソッドにダウンロードリンクを渡した際のpuppeteerの挙動は、ダウンロードを開始してERR_ABORTEDをスローするようです。browserのインスタンスを作成する際に{headless:false}のオプション値をコンストラクタに渡して実行してみるとわかりやすいのですが、ダウンロードが始まったとたんにページが閉じられます。
gotoに続くコードの中でbrowser.close()を実行しなければ、ERR_ABORTEDが投げられた後もダウンロードは継続し、完了すれば指定したフォルダの中に入ります。
ただ、リンクチェック時はダウンロードリンクが確かに存在することがわかればいいだけなので、behaviorに「deny」を設定します。こうしておけば、リンク切れている時はステータスコード404が返り、リンクが存在した際はERR_ABORTEDだけが投げられます。ここではこれらの仕組みを利用しています。
checkLinkPageをこのコードに差し替えることにより、前述のコードより精度よくリンク切れを見つけることができるようになりました。
コード全体
このコードをgithub:link-checkにて公開しています。よろしければ利用してください。コードは無保証で、(このページにも)告知なく修正することがあります。あらかじめご了承ください。