GAS での XPath を使ったスクレイピング

テクノロジー

追記:この記事をより分かりやすくしたものがこちらの記事になります

経緯

GAS を使用して、株情報サイトの株ページ内の時価やEPSなどの情報を取得しようと思いました。

元々スプレッドシートには importxml という関数があり、それを利用すると URL サイトページの任意の情報を XPath 指定で取得することができます。

最初は importxml で情報を取得していましたが、取得する情報が多くなると関数の呼び出しで返ってこなくなり、最悪スクリプトがタイムアウトを起こしていました。

importxml は呼び出すごとに、その都度ページ情報を取得している。同じページを呼び出していてものキャッシュ等はしてない。これらの理由から連続呼び出しで返ってこなくなる現象が起こると思われます。

また、importxml は関数なのでスプレッドシートでの使用になってしまい、GAS では直接扱えません。スプレッドシートを介すという点でも処理が重くなる原因になります。

スプレッドシート/GASにおいて、特定のサイトから XPath 指定での情報取得ができないか調べてみましたが、そのようなライブラリ等を見つけられなかったので、その機能を自分で作成するしました。

概要

  • 対象ページの URL と XPath を指定することにより、そのページの XPath の情報を取得
  • XPath は1度に複数指定

コード


function getTag(str, offset)
{
  var res = {};

  res.begin = str.indexOf('<', offset);
  res.end = str.indexOf('>', res.begin) + 1;
  res.tag = str.slice(res.begin, res.end);
  res.name = res.tag.replace('/','').replace('<','').replace('>','').split(' ')[0];
  res.isClose = res.tag.indexOf('</') >= 0;
  res.isComment = res.tag.indexOf('<!') >= 0;
  res.isSingle = res.tag.indexOf('/>') >= 0;

  return res;
}

function searchEnd(content, begin)
{
  var end = begin;
  var list = [];
  var offset = begin;
  while (true)
  {
    var res = getTag(content, offset);
    if (res.begin < 0) break;
    offset = res.end;

    if (res.isSingle) continue;
    if (res.isComment) continue;

    if (res.isClose)
    {
      while (list.length > 0)
      {
        var name = list.shift();
        if (name == res.name) break;
      }
    }
    else
    {
      list.unshift(res.name);
    }

    if (list.length <= 0)
    {
      end = offset;
      break;
    }
  }
  return end;
}

function removeParentTag(content)
{
  var begin = content.indexOf('>') + 1;
  var end = content.lastIndexOf('<');
  return content.slice(begin, end);
}

function searchId(content, hierarchy)
{
  var id = hierarchy.replace('*[@','').replace(']','');
  var index = content.indexOf(id);
  return content.lastIndexOf('<', index);
}

function searchTag(content, hierarchy)
{
  var array = hierarchy.replace('[', ',').replace(']', '').split(',');

  var tag = array[0];
  var count = 1;
  if (array.length > 1)
  {
    count = parseInt(array[1]);
  }

  var begin = 0;
  var list = [];
  var offset = 0;
  while (true)
  {
    var res = getTag(content, offset);
    if (res.begin < 0) break;
    offset = res.end;

    if (res.isSingle) continue;
    if (res.isComment) continue;

    if (res.isClose)
    {
      while (list.length > 0)
      {
        var name = list.shift();
        if (name == res.name) break;
      }
    }
    else
    {
      if (list.length <= 0)
      {
        if (tag == res.name)
        {
          --count;
        }
      }
      list.unshift(res.name);
    }

    if (count <= 0)
    {
      begin = res.begin;
      break;
    }
  }
  return begin;
}

function searchBegin(content, hierarchy)
{
  var index = hierarchy.indexOf('@');
  if (index < 0)
  {
    return searchTag(content, hierarchy);
  }
  return searchId(content, hierarchy);
}

function extractText(content)
{
  var offset = 0;
  var res = getTag(content, offset);
  if (res.begin < 0)
  {
    return content;
  }
  var begin = searchTag(content, res.name);
  var end = searchEnd(content, begin);
  var remove = content.slice(begin, end);
  return content.replace(remove, '');
}

function narrow(content, hierarchy)
{
  if (hierarchy.length <= 0) return content;

  if (hierarchy.indexOf('text()') >= 0)
  {
    return extractText(content);
  }

  var begin = searchBegin(content, hierarchy);
  var end = searchEnd(content, begin);
  var target = content.slice(begin, end);
  return removeParentTag(target);
}

function searchXPath(content, xpath)
{
  var result = [];
  for (var i in xpath)
  {
    var target = content;
    var array = xpath[i].split('/');
    for (var j in array)
    {
      if (array[j].length <= 0) continue;
      target = narrow(target, array[j]);
    }
    result.push(target);
  }
  return result;
}

function searchXPathFromURL(url, xpath)
{
  var content = UrlFetchApp.fetch(url).getContentText('UTF-8');
  return searchXPath(content, xpath);
}

function Test_01()
{
  var code = XXXX;
  var url = 'https://XXXX/stock/?code=' + code;

  var market = '//*[@id="stockinfo_i1"]/div[1]/span';                       // market
  var name = '//*[@id="stockinfo_i1"]/div[1]/h2/text()';                    // name
  var industry = '//*[@id="stockinfo_i2"]/div/a';                           // industry
  var price = '//*[@id="stockinfo_i1"]/div[2]/span[2]';                     // price
  var eps = '//*[@id="kobetsu_right"]/div[3]/table/tbody/tr[3]/td[4]';      // eps
  var dividend = '//*[@id="kobetsu_right"]/div[3]/table/tbody/tr[3]/td[5]'; // dividend
  var yield = '//*[@id="stockinfo_i3"]/table/tbody/tr[1]/td[3]/text()';     // yield
  var xpath = [market, name, industry, price, eps, dividend, yield];

  //var result = searchXPathFromURL(url, xpath);

  var content = UrlFetchApp.fetch(url).getContentText('UTF-8');
  var result = searchXPath(content, xpath);

  for (var i in xpath)
  {
    console.log(xpath[i] + ' > ' + result[i]);
  }
}

説明

各メソッドについての説明は割愛します。

searchXPathFromURL または searchXPath に対して URL と XPath を引数で渡すと、ページ内の対象の要素の情報を取得することができます。

Test_01 がテストコードになっています。こちらの code に任意の銘柄の番号を、url に情報を取得するページ、xpath 群に適当な xpath を指定し実行すると情報を取得できていることが確認できるかと思います。

1回のページアクセスで複数の XPath 分の情報を取得できるので、importxml よりは情報取得は早いです。

注意

こちらいくつかのサイトにおいては挙動の確認はできていますが、html が手打ちなどで、間違った記述になっているサイトの場合、正常の挙動しないことを確認しています。

また、ページの html 記述が正常でも常に正確に挙動する保証はないので、その辺はご了承ください。

まとめ

実装自体はかなりべたなつくりになってしまっています。同じような処理があるのでリファクタリングのしがいがありそうです。

ページ情報取得については今回用意した機能で格段に快適になりました。情報サイトへのアクセスも減るのでいくらか有用な実装になったかと思います。

実際には自分はスプレッドシートと GAS の併用をしており、GAS からスプレッドシートのセルにアクセスする際に処理が重くなっているようです。

セルの読み書きを極力減らし、最後に書き込みを行うようなやり方をすれば最適化できるようですが、それはまた機会があればやろうと思います。(管理銘柄数が増えてきたタイミング等で)

スプレッドシート/GAS における株管理についてもいずれ投稿したいと思っています。その際スプレッドシートの読み書きの最適化も一緒に公開したいです。

※2022/03/02追記:これを活用して実際に国内高配当株をスプレッドシートで管理する方法(サンプル付き)を公開しています

コメント

タイトルとURLをコピーしました