こんにちは、にょろりんこの備忘録的技術ブログです。
今回はNode.jsを使い、アクセス不能になったURLをDBから削除する(DBクレンジング)という話です。
SaePornsのような動画検索サービスでは、日々大量のURLが自動で収集・登録されていきます。けれど、その中には収集時には存在していたが、その後に削除されたURLや、何らかの理由でアクセスできなくなったURLも混ざってきます。
そうした「もう存在しないURL」がDBに残り続けると、検索結果にノイズが増えたり、キャプション生成が止まったり、再生ページが404のまま残ったり──じわじわとサービス全体が詰まってきます。
ユーザーエクスペリエンス的にも、削除済みのURLがサイト上に表示され続けているのはあまり良くありませんよね。そこで必要になってくるのが、こうした「アクセス不能なURL」を検知し、そっと削除する──つまり、URLのクレンジング処理です。
そもそもこの操作自体は、GoogleやBingといった大手検索エンジンも日々行っている(はず)のものです。彼らがどのようなアルゴリズムや基準でURLの有効性を判定しているのかは外部からはわかりませんが、少しでもそれに近づけるように、SaePornsでも試行錯誤を続けています

なぜURLのクレンジングが必要なのか?
検索エンジンを作成していると避けられないのが不要なデータの蓄積です。
たとえば:
これらが urls テーブルに残り続けると、再処理されることはなく、永遠に「空の動画」としてDBに漂い続けることになります。
放置しておくと、UI上の違和感やバックエンド処理の詰まりだけでなく、ユーザー信頼の低下にもつながります。それを防ぐために行うのが、今回紹介する アクセス不能URLのクレンジング処理です。
このスクリプトの目的と難しさ
今回の主役であるクレンジングコードの目的は明快です。
アクセス不能なURLを見つけて、urls・videos・metadata の各テーブルから一括で削除する。ただし、対象は何万~何十万件にも及ぶため、帯域を無駄に消費せず、かつ信頼性のある確認処理が求められます。
また、これらの確認リクエストは当然、外部サイトにアクセスすることになりますが、あまりに短期間に大量のHEADリクエストを送ると、不正アクセスとみなされてIPブロックされることもあります。
だからこそ、帯域節約とプロキシ切替による分散アクセス、そして失敗時の安全設計が、このスクリプトのカギとなります。
では、実際にコードを見ていきます。
async function checkUrl(url) {
if (!url || url.trim() === '') return false;const proxy = await getRandomProxy();
const proxyUrl = `http://${proxy.username}:${proxy.password}@${proxy.ip}:${proxy.port}`;
const agent = new HttpsProxyAgent(proxyUrl);const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36'
};try {
const headRes = await axios.head(url, {
timeout: 5000,
headers,
httpAgent: agent,
httpsAgent: agent,
validateStatus: null
});if (headRes.status === 404) return false;
if ([200, 301, 302].includes(headRes.status)) return true;if ([403, 405].includes(headRes.status)) {
const getRes = await axios.get(url, {
timeout: 5000,
headers: { ...headers, 'Range': 'bytes=0-0' },
httpAgent: agent,
httpsAgent: agent,
validateStatus: null
});
return getRes.status !== 404;
}return true;
} catch {
return true;
}
}
この checkUrl() 関数は、指定されたURLがまだアクセス可能かどうかを判定するための関数です。ただし、普通にGETするのではなく、帯域を最小限にしながら確認し、誤判定やIPブロックを回避する工夫が詰まっています。
具体的には、以下のような順序で処理が進みます。
① 空のURLをスキップ
if (!url || url.trim() === '') return false;
まず、URLが空だったり空白のみだったりする場合は、無効とみなして false を返します。ここは前処理としての簡易バリデーションです。
② プロキシを取得し、通信設定を構築
const proxy = await getRandomProxy();
const agent = new HttpsProxyAgent(proxyUrl);
SaePornsではプロキシを使って大量アクセスのIP分散を行っているため、毎回ランダムなプロキシを取得し、axiosが使うHTTPエージェントとして設定します。
③ HEADリクエストで軽量確認
const headRes = await axios.head(url, { ... });
次に HEAD メソッドでアクセスを試みます。これはレスポンスヘッダーだけを受け取るリクエストで、動画データ本体をダウンロードしないため非常に軽量です。というか本体をダウンロードしてしまったら帯域がいくらあっても足りません。
④ ステータスコードで判定
if (headRes.status === 404) return false;
if ([200, 301, 302].includes(headRes.status)) return true;
- 404 → 明らかにページが存在しない → false
- 200/301/302 → 通常通りアクセス可能 → true
⑤ 一部の拒否(403, 405)の場合は再確認
if ([403, 405].includes(headRes.status)) {
const getRes = await axios.get(url, {
headers: { ...headers, 'Range': 'bytes=0-0' },
...
});
return getRes.status !== 404;
}
CDNや一部のホスティングサービスでは HEAD に対して403や405が返ることがあります。その場合は Rangeヘッダを付けたGET で再確認を行います。これは「本文全体を取らずに、先頭バイトだけを取得する」ため、GETでも帯域が抑えられます。
⑥ その他の通信エラー(タイムアウトなど)は例外処理へ
} catch {
return true;
}
通信エラーが起きた場合は true を返します。これは「失敗=無効とは限らない」ため、保守的に生かす設計です。
この関数の設計思想は:「確実に死んでいるURLだけを削除し、それ以外は生かしておく」そのために「帯域節約の HEAD」「判定が曖昧なときだけ軽量 GET」「プロキシによるIPローテーション」「タイムアウトや403は(生きている扱い)」という堅実な構成になっています。
繰り返しになりますが、この処理自体はGoogleやBingといった大手検索エンジンサイトも同じようなことをやっている(はず)です。何がベストプラクティスかは企業秘密と思いますが、おそらく近しい構成なのではないか?と思います。
次は、この関数を使ってどうやってクレンジングが実行されるかを見ていきましょう。
async function cleanseUrls() {
try {
const urls = await pool.query('SELECT id, url FROM urls');
console.log(`Found ${urls.length} urls in DB.`);let index = 0;
let batch = ;for (let row of urls) {
batch.push(
(async () => {
const exists = await checkUrl(row.url);
if (!exists) {
console.log(`Deleting URL ID ${row.id} (URL: ${row.url} returned dead)`);
await pool.query('DELETE FROM urls WHERE id = ?', [row.id]);
await pool.query('DELETE FROM videos WHERE url = ?', [row.url]);
await pool.query('DELETE FROM metadata WHERE url = ?', [row.url]);
}
})()
);if (batch.length >= CONCURRENT_LIMIT) {
await Promise.all(batch);
batch = ;
console.log(`Processed ${index + CONCURRENT_LIMIT} urls...`);
}
index++;
}if (batch.length > 0) {
await Promise.all(batch);
}console.log(" URL cleansing completed.");
} catch (error) {
console.error(" Error during URL cleansing:", error.message);
} finally {
pool.end();
}
}
この cleanseUrls() 関数は、先ほどの checkUrl() 関数を使って、urls テーブルの全レコードをチェックし、アクセス不能なものをDBから削除するという処理のメインループです。
Sae-Pornsでは、動画データのインデクシング処理を urls、videos、metadata の3つのテーブルに分割して管理しています。少しでも帯域の消費を抑えるために、URLが本質的にユニークであるという性質を利用し、URLをキーに3テーブルを同時に処理する設計を採用しています。
これにより、1回のアクセス確認で関連データすべてをまとめて削除できるようになり、結果として帯域使用量の削減にもつながっています。決して派手ではありませんが、こういった軽量化・省力化の試みは大手検索エンジンも行っている(はず)です。
この cleanseUrls() 関数は、単に不要なデータを削除しているだけのように見えるかもしれません。けれど、この処理が支えているのは「検索品質」と「プロダクトの寿命」です。
検索にノイズを入れないという選択、動画がすでに存在しないにもかかわらず、検索結果にサムネイル付きで堂々と登場し、クリックしたら 404──それって、地味にストレスですよね。
SaePornsは悪質な騙しリンクを貼りまくってPV数を稼ぐサービスではないので、「とりあえずページを見せてPV稼ごう」みたいな思想はありません。だからこそ、すでにアクセスできなくなったURLは、できるだけ静かに、でも確実に消していく必要があります。
Googleなどの検索エンジンも、きっと同じようにリンク切れのURLを検出し、インデックスから外しています。SaePornsの cleanseUrls() は、規模はもちろん、アルゴリズムもおそれく違いますが、思想としては「それに近づくための自前クローラー作業」なんだと思っています。
外からは見えないけど、地道で、正確で、丁寧で、それがあって初めて「検索」が信頼される、この処理は、そういう裏方の役目です。
それではみなさん良い開発ライフを。
※このコードが実際に使われている「追跡されないアダルト動画の検索エンジンSae-Porns」はこちら!よかったら見ていってください。(18歳未満の方はご利用できません。)