Amazonで商品を見る セール会場へ

Google Apps Script│サイト内リンクを一括クロールしてスプレッドシートに出力する

GASで、指定URLから見つかる同じホスト内のHTMLリンクをたどり、URL・参照元・深さ・HTTPステータス・タイトルをシートへ出力する簡易クローラーです。

検索エンジンのインデックス一覧やサイトの全ページ一覧を保証するものではありません。

目次

著者

WEB制作をしているデジタルノマド
WordPressのカスタマイズが好きで、色々と自作しています。

WordPressのカスタマイズに困ったらご相談ください!

使い方

自分が管理するスプレッドシートで、拡張機能→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の利用枠は未検証です。

実行前にスクリプトをコピーして保存し、問題があればそのコピーへ戻します。

参照:setValues、Apps Scriptの利用枠。

  • URLをコピーしました!

制作・運用のご依頼はこちら

MOTOKI合同会社のサービス

WordPressの制作・カスタマイズ・高速化・SEO対策・保守まで、Webサイトに関する業務を一括で承ります。料金はサービスごとに公開しています。

WAZAの有料記事のサブスクリプションも開始しました。

目次