handson-md-link-checker
Version:
高性能並列処理マークダウンリンクチェッカー - Markdown文書内の壊れたリンク(404/410エラー)を検出
617 lines (526 loc) • 21.3 kB
JavaScript
const fs = require('fs');
const path = require('path');
const { Worker } = require('worker_threads');
const os = require('os');
// HTTP Agent for connection pooling
class HTTPAgent {
constructor() {
// Global fetch with connection pooling (Node.js 18+)
this.fetchOptions = {
keepalive: true,
// Allow more concurrent connections
headers: {
'User-Agent': 'Link-Checker/1.0'
}
};
}
}
class LinkChecker {
constructor(options = {}) {
this.brokenLinks = [];
this.checkedUrls = new Set();
this.ignoreGithubAuth = options.ignoreGithubAuth || false;
this.explicitLinksOnly = options.explicitLinksOnly || false;
this.githubAuthPagesSkipped = 0;
this.implicitLinksSkipped = 0;
this.excludePatterns = [
/^mailto:/,
/^tel:/,
/^#/,
/localhost/,
/127\.0\.0\.1/,
/^javascript:/,
/example\.com/,
/your-.*-id/,
/\{\{.*\}\}/,
// サンプル・プレースホルダー URL
/GitHubユーザー名/,
/github\.io.*リポジトリ名/,
/xxxx\.github\.io/,
/ユーザー名\.github\.io/,
/hoge\.com/,
/APP_PATH/,
/hook\.us1\.make\.com\/xxxxx/,
/xxxxx/,
/<ココ>/,
/\[ユーザー名\]/,
/\[リポジトリ名\]/,
// 一般的なプレースホルダー
/your-[\w-]+/,
/\{[\w-]+\}/,
/\[[\w\s]+\]/,
/<[\w\s-]+>/, // <ユーザー名>のようなプレースホルダー
/○+/, // ○○○○○のような日本語プレースホルダー
// 英語圏ドメインに日本語テキストが含まれる場合(プレースホルダー)
/github\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/google\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/microsoft\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/amazon\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/amazonaws\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/facebook\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/twitter\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/linkedin\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/stackoverflow\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/npmjs\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/heroku\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/railway\.app\/.*[ぁ-ゟァ-ヶー一-龯]/,
/vercel\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/netlify\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/firebase\.google\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/openai\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/docs\.rs\/.*[ぁ-ゟァ-ヶー一-龯]/,
/stripe\.com\/.*[ぁ-ゟァ-ヶー一-龯]/,
/slack\.com\/.*[ぁ-ゟァ-ヶー一-龯]/
];
}
/**
* GitHub認証が必要なページかどうかを判定
*/
isGithubAuthRequired(url, status) {
if (!url.includes('github.com')) {
return false;
}
// GitHub認証が必要なパスパターン
const authRequiredPatterns = [
/github\.com\/orgs\/[^/]+\/projects\//, // 組織プロジェクト
/github\.com\/orgs\/[^/]+\/teams\//, // チーム管理
/github\.com\/[^/]+\/[^/]+\/settings\//, // リポジトリ設定
/github\.com\/settings\//, // ユーザー設定
/github\.com\/notifications/, // 通知
/github\.com\/[^/]+\/[^/]+\/security\//, // セキュリティ設定
/github\.com\/[^/]+\/[^/]+\/pulse/, // プライベートリポジトリのPulse
/github\.com\/[^/]+\/[^/]+\/graphs\//, // プライベートリポジトリのGraphs
/github\.com\/[^/]+\/[^/]+\/network\//, // プライベートリポジトリのNetwork
/github\.com\/[^/]+\/[^/]+\/issues\/\d+/, // プライベートリポジトリのIssue
/github\.com\/[^/]+\/[^/]+\/pull\/\d+/, // プライベートリポジトリのPR
];
// パスパターンでのマッチング
if (authRequiredPatterns.some(pattern => pattern.test(url))) {
return true;
}
// 403 Forbidden または 404 Not Found の場合、GitHub認証が必要な可能性
if (status === 403 || status === 404) {
return true;
}
return false;
}
async checkUrl(url, filePath, lineNumber, maxRetries = 2) {
if (this.checkedUrls.has(url)) {
return;
}
this.checkedUrls.add(url);
// 除外パターンをチェック
if (this.excludePatterns.some(pattern => pattern.test(url))) {
return;
}
console.log(`Checking: ${url}`);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// まずHEADリクエストを試す
let response = await fetch(url, {
method: 'HEAD',
timeout: 8000, // 高速化のためタイムアウト短縮
redirect: 'follow'
});
// HEADが失敗した場合はGETで再試行
if (!response.ok && response.status !== 405) {
response = await fetch(url, {
method: 'GET',
timeout: 8000,
redirect: 'follow'
});
}
// GitHub認証必要ページの判定
if (this.ignoreGithubAuth && this.isGithubAuthRequired(url, response.status)) {
this.githubAuthPagesSkipped++;
console.log(` 🔐 GitHub認証必要ページをスキップ: ${url}`);
return;
}
// 明確な404エラーのみを壊れたリンクとして判定
const brokenCodes = [404, 410]; // 404 Not Found, 410 Gone のみ
if (brokenCodes.includes(response.status)) {
this.brokenLinks.push({
url,
status: response.status,
file: filePath,
line: lineNumber
});
}
return; // 成功したら終了
} catch (error) {
if (attempt === maxRetries) {
// ネットワークエラーも明確に判別
if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
// DNSエラーや接続拒否は明確な壊れたリンク
this.brokenLinks.push({
url,
status: error.message.includes('ENOTFOUND') ? 'DNS_ERROR' : 'CONNECTION_REFUSED',
file: filePath,
line: lineNumber
});
}
// その他のネットワークエラー(timeout等)は無視
} else {
// リトライ前に少し待機
console.log(` Retry ${attempt + 1}/${maxRetries}: ${url}`);
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
}
}
cleanUrl(url) {
// HTMLタグの残骸を完全除去
url = url.replace(/\\"[^"]*$/, ''); // \"で終わる部分
url = url.replace(/\\">.*$/, ''); // \">以降すべて
url = url.replace(/<[^>]*$/, ''); // 不完全なHTMLタグ
url = url.replace(/>[^>]*$/, ''); // >以降の文字列
url = url.replace(/\\".*$/, ''); // \"以降すべて
// HTMLタグの終了部分を除去 (scriptタグなど)
url = url.replace(/\"><\/.*$/, ''); // "></script など
url = url.replace(/\">\s*<\/.*$/, ''); // "> </script など
// Markdownの記載ミス対応: ]( が重複している場合
url = url.replace(/\]\([^)]*$/, ''); // ](以降を除去
// Markdownの画像サイズ指定を除去 (例: =200x, =500x400, =x300)
url = url.replace(/\s*=[0-9]*x[0-9]*$/, '');
url = url.replace(/\s*=x[0-9]+$/, '');
url = url.replace(/\s*=[0-9]+x$/, '');
// Markdownの画像タイトルを除去 (例: "タイトル")
url = url.replace(/\s*"[^"]*"$/, '');
url = url.replace(/\s*'[^']*'$/, '');
// HTMLの余分な属性やクオートを除去
url = url.replace(/"\s*$/, '');
url = url.replace(/'\s*$/, '');
url = url.replace(/>\s*$/, '');
// バッククオートを除去
url = url.replace(/`+$/, '');
url = url.replace(/^`+/, '');
// 末尾の句読点や余分な文字を除去
url = url.replace(/[.,;!?]+$/, '');
// JavaScriptのコメント記号や日本語説明文を除去
url = url.replace(/\";\/\/.*$/, '');
url = url.replace(/\";\s*$/, '');
url = url.replace(/;\/\/.*$/, '');
url = url.replace(/;.*$/, '');
// 末尾のクォート除去
url = url.replace(/'$/, '');
url = url.replace(/"$/, '');
// JavaScriptコード中のカンマやオブジェクト記法を除去
url = url.replace(/',\{.*$/, ''); // ',{ で始まる部分
url = url.replace(/'\s*,.*$/, ''); // ', で始まる部分
url = url.replace(/",\{.*$/, ''); // ",{ で始まる部分
url = url.replace(/"\s*,.*$/, ''); // ", で始まる部分
// 日本語説明文やバッククォートの文章を除去
url = url.replace(/`[ぁ-ゟ]+.*$/, '');
url = url.replace(/`を[^`]*$/, '');
url = url.replace(/`.*を[^`]*$/, '');
url = url.replace(/`[^`]*を[^`]*$/, '');
url = url.replace(/`[^`]*ましょう[^`]*$/, '');
// 単独のバッククォートも除去
url = url.replace(/`$/, '');
// 末尾の空白を除去
url = url.trim();
return url;
}
/**
* コンテキスト分析による文書説明用URLの判定
*/
isDocumentationUrl(line, url) {
// 説明用URLのパターン
const docPatterns = [
/API[::]\s*`[^`]*$/, // API: `https://...`
/エンドポイント[::]/, // エンドポイント: https://...
/ベースURL[::]/, // ベースURL: https://...
/例[::]\s*https/, // 例: https://...
/サンプル[::]/, // サンプル: https://...
/URL[::]/, // URL: https://...
/パス[::]/, // パス: https://...
/-\s+API[::]/, // - API: https://...
/\*\s+API[::]/, // * API: https://...
];
return docPatterns.some(pattern => pattern.test(line));
}
extractLinks(content) {
const links = [];
const lines = content.split('\n');
// Markdown link pattern: [text](url)
const markdownLinks = content.match(/\[([^\]]*)\]\(([^)]+)\)/g) || [];
markdownLinks.forEach(match => {
const urlMatch = match.match(/\[([^\]]*)\]\(([^)]+)\)/);
if (urlMatch) {
let url = this.cleanUrl(urlMatch[2]);
if (url.startsWith('http://') || url.startsWith('https://')) {
links.push({ url, type: 'explicit', source: 'markdown' });
}
}
});
// Broken Markdown pattern: [text](url](url) - よくある記載ミス
const brokenMarkdownLinks = content.match(/\[([^\]]*)\]\(([^)]+)\]\(([^)]+)\)/g) || [];
brokenMarkdownLinks.forEach(match => {
const urlMatch = match.match(/\[([^\]]*)\]\(([^)]+)\]\(([^)]+)\)/);
if (urlMatch) {
// 最初のURLを使用
let url = this.cleanUrl(urlMatch[2]);
if (url.startsWith('http://') || url.startsWith('https://')) {
links.push({ url, type: 'explicit', source: 'broken-markdown' });
}
}
});
// HTML img tags
const imgTags = content.match(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi) || [];
imgTags.forEach(match => {
const urlMatch = match.match(/src=["']([^"']+)["']/i);
if (urlMatch) {
let url = this.cleanUrl(urlMatch[1]);
if (url.startsWith('http://') || url.startsWith('https://')) {
links.push({ url, type: 'explicit', source: 'img' });
}
}
});
// HTML anchor tags
const htmlLinks = content.match(/<a[^>]+href=["']([^"']+)["'][^>]*>/g) || [];
htmlLinks.forEach(match => {
const urlMatch = match.match(/href=["']([^"']+)["']/);
if (urlMatch) {
let url = this.cleanUrl(urlMatch[1]);
if (url.startsWith('http://') || url.startsWith('https://')) {
links.push({ url, type: 'explicit', source: 'anchor' });
}
}
});
// Direct URL pattern (暗示的)
const directUrls = content.match(/https?:\/\/[^\s\)]+/g) || [];
directUrls.forEach(rawUrl => {
let url = this.cleanUrl(rawUrl);
if (url.startsWith('http://') || url.startsWith('https://')) {
// URLが含まれている行を特定
const lineContainingUrl = lines.find(line => line.includes(rawUrl)) || '';
const isDocUrl = this.isDocumentationUrl(lineContainingUrl, url);
links.push({
url,
type: 'implicit',
source: 'direct',
isDocumentation: isDocUrl
});
}
});
// 重複除去(URLベース)
const uniqueLinks = [];
const seenUrls = new Set();
for (const linkObj of links) {
if (!seenUrls.has(linkObj.url)) {
seenUrls.add(linkObj.url);
uniqueLinks.push(linkObj);
}
}
return uniqueLinks;
}
async checkFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
// ファイル内の全リンクを収集
const linkTasks = [];
for (let i = 0; i < lines.length; i++) {
const linkObjects = this.extractLinks(lines[i]);
for (const linkObj of linkObjects) {
// explicitLinksOnlyオプションが有効な場合、暗示的リンクをスキップ
if (this.explicitLinksOnly && linkObj.type === 'implicit') {
this.implicitLinksSkipped++;
console.log(` 📝 暗示的リンクをスキップ: ${linkObj.url}`);
continue;
}
linkTasks.push({
url: linkObj.url,
filePath,
lineNumber: i + 1,
linkType: linkObj.type,
linkSource: linkObj.source,
isDocumentation: linkObj.isDocumentation
});
}
}
return linkTasks;
} catch (error) {
console.error(`Error reading file ${filePath}:`, error.message);
return [];
}
}
async findMarkdownFiles(target) {
const files = [];
// Check if target is a file
if (fs.statSync(target).isFile()) {
if (target.endsWith('.md')) {
files.push(target);
}
return files;
}
// Target is a directory
const entries = fs.readdirSync(target, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(target, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and .git directories
if (entry.name !== 'node_modules' && entry.name !== '.git') {
files.push(...await this.findMarkdownFiles(fullPath));
}
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath);
}
}
return files;
}
async processInBatches(tasks, batchSize = 100) {
const results = [];
console.log(`Processing ${tasks.length} links in batches of ${batchSize}`);
for (let i = 0; i < tasks.length; i += batchSize) {
const batch = tasks.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(task => this.checkUrl(task.url, task.filePath, task.lineNumber))
);
results.push(...batchResults);
// 進捗表示
const progress = Math.round(((i + batchSize) / tasks.length) * 100);
console.log(`Progress: ${Math.min(progress, 100)}% (${Math.min(i + batchSize, tasks.length)}/${tasks.length})`);
// 高速化のため遅延を削除
// if (i + batchSize < tasks.length) {
// await new Promise(resolve => setTimeout(resolve, 50));
// }
}
return results;
}
async processWithWorkers(tasks, numWorkers = null) {
if (!numWorkers) {
// 大幅に並列度を向上(CPUコア数×4、最大16)
numWorkers = Math.min(16, os.cpus().length * 4);
}
const workerPath = path.join(__dirname, 'link-checker-worker.js');
// より小さなチャンクで分割して負荷分散を改善
const chunkSize = Math.max(10, Math.ceil(tasks.length / (numWorkers * 3)));
const chunks = [];
for (let i = 0; i < tasks.length; i += chunkSize) {
chunks.push(tasks.slice(i, i + chunkSize));
}
console.log(`Using ${numWorkers} workers with ${chunks.length} chunks (${chunkSize} tasks per chunk)`);
// ワーカープールを作成
const workers = [];
for (let i = 0; i < numWorkers; i++) {
workers.push({
worker: new Worker(workerPath),
busy: false,
id: i
});
}
let chunkIndex = 0;
const results = [];
const processChunk = (workerInfo) => {
return new Promise((resolve, reject) => {
if (chunkIndex >= chunks.length) {
resolve();
return;
}
const chunk = chunks[chunkIndex++];
workerInfo.busy = true;
workerInfo.worker.postMessage({
tasks: chunk,
options: {
ignoreGithubAuth: this.ignoreGithubAuth,
explicitLinksOnly: this.explicitLinksOnly
}
});
const onMessage = (result) => {
workerInfo.worker.off('message', onMessage);
workerInfo.worker.off('error', onError);
if (result.success) {
this.brokenLinks.push(...result.brokenLinks);
workerInfo.busy = false;
// 次のチャンクを処理
processChunk(workerInfo).then(resolve).catch(reject);
} else {
reject(new Error(result.error));
}
};
const onError = (error) => {
workerInfo.worker.off('message', onMessage);
workerInfo.worker.off('error', onError);
reject(error);
};
workerInfo.worker.on('message', onMessage);
workerInfo.worker.on('error', onError);
});
};
// 全ワーカーで並列処理開始
await Promise.all(workers.map(processChunk));
// ワーカーを終了
workers.forEach(workerInfo => {
workerInfo.worker.terminate();
});
}
async run(directory = '.') {
console.log('🔍 Starting link check...');
const markdownFiles = await this.findMarkdownFiles(directory);
console.log(`Found ${markdownFiles.length} markdown files`);
// 全ファイルからリンクタスクを収集
console.log('📄 Collecting links from all files...');
const allLinkTasks = [];
for (const filePath of markdownFiles) {
const linkTasks = await this.checkFile(filePath);
allLinkTasks.push(...linkTasks);
}
// 重複URLを除去(最初に見つかったファイル情報を保持)
const uniqueTasks = [];
const seenUrls = new Set();
for (const task of allLinkTasks) {
if (!seenUrls.has(task.url)) {
seenUrls.add(task.url);
uniqueTasks.push(task);
}
}
console.log(`Found ${allLinkTasks.length} total links (${uniqueTasks.length} unique)`);
// Worker Threadsを使って並列処理
console.log('🚀 Checking links with optimized parallel processing...');
if (uniqueTasks.length > 50) {
// 50個以上でWorker Threads使用(閾値を下げる)
await this.processWithWorkers(uniqueTasks);
} else {
// 少量の場合は高速化されたバッチ処理
console.log('Using optimized single-threaded processing for small dataset');
await this.processInBatches(uniqueTasks, 100);
}
console.log('\n✅ Link check completed');
// スキップ数の表示
if (this.ignoreGithubAuth && this.githubAuthPagesSkipped > 0) {
console.log(`🔐 GitHub認証必要ページを ${this.githubAuthPagesSkipped} 個スキップしました`);
}
if (this.explicitLinksOnly && this.implicitLinksSkipped > 0) {
console.log(`📝 暗示的リンクを ${this.implicitLinksSkipped} 個スキップしました`);
}
if (this.brokenLinks.length > 0) {
console.log(`\n❌ Found ${this.brokenLinks.length} broken links:`);
this.brokenLinks.forEach(link => {
console.log(` - ${link.url}`);
console.log(` Status: ${link.status}`);
console.log(` File: ${link.file}:${link.line}`);
console.log('');
});
// Create a report file
const report = {
timestamp: new Date().toISOString(),
totalBrokenLinks: this.brokenLinks.length,
brokenLinks: this.brokenLinks
};
fs.writeFileSync('link-check-report.json', JSON.stringify(report, null, 2));
console.log('📝 Report saved to link-check-report.json');
process.exit(1);
} else {
console.log('\n🎉 All links are working!');
}
}
}
// Run the script if called directly
if (require.main === module) {
const checker = new LinkChecker();
const directory = process.argv[2] || '.';
checker.run(directory).catch(console.error);
}
module.exports = LinkChecker;