GASで、指定URLから見つかる同じホスト内のHTMLリンクをたどり、URL・参照元・深さ・HTTPステータス・タイトルをシートへ出力する簡易クローラーです。
検索エンジンのインデックス一覧やサイトの全ページ一覧を保証するものではありません。
使い方
自分が管理するスプレッドシートで、拡張機能→Apps Scriptを開き、下のコード全文を保存します。
シートを再読込し、URL調査→URLを入力して開始を選び、調査を許可されたサイトのURLを入力します。
初回は必要なGoogleの権限を確認して実行します。
結果はURL一覧シートへ出力し、同名シートがあればURL一覧_1などの新規シートを作ります。
既存シートは消去しません。
コード全文
var CRAWLER = {
menuName: 'URL調査',
sheetName: 'URL一覧',
maxPages: 100,
batchSize: 5,
excludedExtensions: /\.(?:jpg|jpeg|png|gif|webp|svg|ico|css|js|mjs|map|pdf|zip|rar|7z|gz|mp4|mp3|wav|webm|mov|avi|doc|docx|xls|xlsx|ppt|pptx|woff|woff2|ttf|eot|otf|json|xml|rss|atom|txt)$/i
};
function onOpen() {
SpreadsheetApp.getUi()
.createMenu(CRAWLER.menuName)
.addItem('URLを入力して開始', 'showCrawlerPrompt')
.addToUi();
}
function showCrawlerPrompt() {
var ui = SpreadsheetApp.getUi();
var res = ui.prompt(
'URL調査',
'調査するURLを入力してください。',
ui.ButtonSet.OK_CANCEL
);
if (res.getSelectedButton() !== ui.Button.OK) return;
var input = res.getResponseText().trim();
if (!input) {
ui.alert('URLが空です。');
return;
}
crawlUrls(input);
ui.alert('完了しました。「URL一覧」を確認してください。');
}
function crawlUrls(inputUrl) {
var rootUrl = normalizeUrl_(inputUrl);
var rootHostKey = normalizeHostKey_(parseUrl_(rootUrl).host);
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = createOrClearSheet_(ss, CRAWLER.sheetName);
sheet.getRange(1, 1, 1, 5).setValues([[
'URL',
'Source URL',
'Depth',
'Status',
'Title'
]]);
var queue = [{ url: rootUrl, sourceUrl: '', depth: 0 }];
var seen = {};
var rows = [];
seen[getUrlKey_(rootUrl)] = true;
while (queue.length && rows.length < CRAWLER.maxPages) {
var tasks = queue.splice(0, CRAWLER.batchSize);
var requests = tasks.map(function (task) {
return {
url: task.url,
method: 'get',
followRedirects: false,
muteHttpExceptions: true,
validateHttpsCertificates: true,
headers: {
Accept: 'text/html,application/xhtml+xml,*/*;q=0.8'
}
};
});
var responses = fetchAllSafe_(requests);
for (var i = 0; i < tasks.length && rows.length < CRAWLER.maxPages; i++) {
var task = tasks[i];
var result = parseResponse_(task, responses[i], rootHostKey);
rows.push([
task.url,
task.sourceUrl,
task.depth,
result.status,
safeCellText_(result.title)
]);
result.links.forEach(function (url) {
if (rows.length + queue.length >= CRAWLER.maxPages) return;
var key = getUrlKey_(url);
if (seen[key]) return;
seen[key] = true;
queue.push({
url: url,
sourceUrl: task.url,
depth: task.depth + 1
});
});
}
}
if (rows.length) {
sheet.getRange(2, 1, rows.length, 5).setValues(rows);
}
styleSheet_(sheet);
}
function parseResponse_(task, response, rootHostKey) {
if (response && response.__error) {
return {
status: 'ERROR',
title: response.__error.message || '',
links: []
};
}
var status = response.getResponseCode();
var headers = response.getAllHeaders ? response.getAllHeaders() : response.getHeaders();
var contentType = getHeader_(headers, 'Content-Type');
if (status < 200 || status >= 300 || !isHtml_(contentType)) {
return {
status: status,
title: '',
links: []
};
}
var html = response.getContentText();
return {
status: status,
title: extractTitle_(html),
links: extractLinks_(html, task.url, rootHostKey)
};
}
function fetchAllSafe_(requests) {
try {
return UrlFetchApp.fetchAll(requests);
} catch (e) {
return requests.map(function (req) {
try {
return UrlFetchApp.fetch(req.url, req);
} catch (err) {
return { __error: err };
}
});
}
}
function extractLinks_(html, pageUrl, rootHostKey) {
var links = [];
var seen = {};
var regex = /<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/ig;
var match;
while ((match = regex.exec(html)) !== null) {
var href = decodeHtml_(match[1] || match[2] || match[3] || '');
var url = normalizeInternalUrl_(href, pageUrl, rootHostKey);
if (!url || !isPageUrl_(url)) continue;
var key = getUrlKey_(url);
if (seen[key]) continue;
seen[key] = true;
links.push(url);
}
return links;
}
function normalizeUrl_(url) {
url = url.trim();
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
}
var parts = parseUrl_(url);
if (!parts) throw new Error('URLの形式を確認してください。');
return buildUrl_(parts);
}
function normalizeInternalUrl_(href, baseUrl, rootHostKey) {
var resolved = resolveUrl_(href, baseUrl);
if (!resolved) return '';
var parts = parseUrl_(resolved);
if (!parts) return '';
if (normalizeHostKey_(parts.host) !== rootHostKey) return '';
return buildUrl_(parts);
}
function resolveUrl_(href, baseUrl) {
if (!href) return '';
href = href.trim();
if (!href || href.charAt(0) === '#') return '';
if (/^(?:mailto|tel|javascript|data|blob|file):/i.test(href)) return '';
var base = parseUrl_(baseUrl);
if (!base) return '';
href = href.replace(/#.*$/, '').replace(/\?.*$/, '');
if (/^https?:\/\//i.test(href)) {
return buildUrl_(parseUrl_(href));
}
if (/^\/\//.test(href)) {
return buildUrl_(parseUrl_(base.protocol + ':' + href));
}
var path;
if (href.charAt(0) === '/') {
path = href;
} else {
var baseDir = base.path;
if (baseDir.charAt(baseDir.length - 1) !== '/') {
baseDir = baseDir.substring(0, baseDir.lastIndexOf('/') + 1);
}
path = baseDir + href;
}
return buildUrl_({
protocol: base.protocol,
host: base.host,
path: normalizePath_(path)
});
}
function parseUrl_(url) {
var match = String(url || '').trim().replace(/#.*$/, '').match(/^(https?):\/\/([^\/?#]+)([^?#]*)/i);
if (!match) return null;
var path = match[3] || '/';
path = normalizePath_(path);
path = path.replace(/\/(?:index|default)\.(?:html?|php|aspx?)$/i, '/');
return {
protocol: match[1].toLowerCase(),
host: normalizeHost_(match[2]),
path: path
};
}
function buildUrl_(parts) {
return parts.protocol + '://' + parts.host + (parts.path || '/');
}
function normalizeHost_(host) {
return String(host || '')
.toLowerCase();
}
function normalizeHostKey_(host) {
return normalizeHost_(host);
}
function normalizePath_(path) {
var input = (path || '/').replace(/\/{2,}/g, '/');
var parts = input.split('/');
var output = [];
parts.forEach(function (part) {
if (!part || part === '.') return;
if (part === '..') {
output.pop();
} else {
output.push(part);
}
});
var result = '/' + output.join('/');
if (input.slice(-1) === '/' && result !== '/') result += '/';
return result || '/';
}
function getUrlKey_(url) {
var parts = parseUrl_(url);
if (!parts) return String(url || '').toLowerCase();
var path = parts.path;
if (path !== '/' && path.slice(-1) === '/') {
path = path.slice(0, -1);
}
return parts.protocol + "://" + parts.host + parts.path;
}
function isPageUrl_(url) {
var parts = parseUrl_(url);
if (!parts) return false;
var fileName = parts.path.split('/').pop();
return !CRAWLER.excludedExtensions.test(fileName);
}
function isHtml_(contentType) {
if (!contentType) return true;
return /text\/html|application\/xhtml\+xml/i.test(contentType);
}
function getHeader_(headers, name) {
var target = name.toLowerCase();
for (var key in headers) {
if (key.toLowerCase() === target) {
return String(headers[key]);
}
}
return '';
}
function extractTitle_(html) {
var match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!match) return '';
return decodeHtml_(stripTags_(match[1]))
.replace(/\s+/g, ' ')
.trim()
.substring(0, 500);
}
function stripTags_(text) {
return String(text || '').replace(/<[^>]*>/g, '');
}
function decodeHtml_(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'");
}
function createOrClearSheet_(ss, name) {
var candidate = name;
var n = 1;
while (ss.getSheetByName(candidate)) candidate = name + '_' + (n++);
var sheet = ss.insertSheet(candidate);
// 既存シートを消去せず、新規シートへ出力します。
return sheet;
}
function styleSheet_(sheet) {
sheet.setFrozenRows(1);
sheet.getRange(1, 1, 1, 5)
.setFontWeight('bold')
.setBackground('#e8f0fe');
sheet.setColumnWidths(1, 1, 520);
sheet.setColumnWidths(2, 1, 520);
sheet.setColumnWidths(3, 2, 90);
sheet.setColumnWidths(5, 1, 360);
}
function safeCellText_(value) {
var text = String(value == null ? "" : value);
return /^[=+@\-]/.test(text) ? "\'" + text : text;
}
対象と上限
初期値は最大100ページ、1バッチ5リクエストです。
サイト側の許容負荷とGASの利用枠を確認し、最初は小規模な対象で試してください。
自動リダイレクトは追わず、3xxのステータスを記録してその先のリンク抽出を止めます。
転送先を確認したうえで、必要なら起点URLを変更して再実行します。
wwwあり・なしは別ホストとして扱い、URLキーはパスの大文字小文字と末尾スラッシュを区別します。
ポート番号も勝手に削除しません。
このコードではできないこと
クエリ文字列を取り除くため、検索結果・ページ番号などをクエリで区別するサイトの調査には使えません。
index.htmlなどの既定ファイル名とパスの正規化も行う簡易実装なので、特殊なURL構成の完全な棚卸しには対応しません。
robots.txtの自動判定、JavaScript描画、base要素、途中再開は実装していません。
結果は終了時にまとめて保存するため、実行時間の上限で停止すると途中結果が残らない場合があります。
fetchAll失敗時の個別取得もリクエスト数に含まれるので、上限エラー時に連続実行しないでください。
保存される文字と検証範囲
タイトル先頭の数式記号をエスケープし、取得先のタイトルが数式として評価されることを防ぎます。
Node.jsの模擬GASサービスでは、新規シート作成、URL比較、転送抑止、タイトルの文字列化を確認しました。
実際のGoogle認証、実シートへの保存、対象サーバーの応答、GASの利用枠は未検証です。
実行前にスクリプトをコピーして保存し、問題があればそのコピーへ戻します。



