Help us understand the problem. What is going on with this article?

Google Apps Scriptで快適にWEBスクレイピングするためのライブラリを作ったら、重すぎて動かなかったお話。

efgrhj.png
ウガアッ!

作るに至った経緯

GASでのWEBスクレイピングといえば、送られてきたhtmlソースをただの文字列として受け取り、正規表現やmatch、split、indexOfなどで無理矢理目的の要素・テキストを削り出してくるもの。一応スクレイピング用のライブラリもあるが、やっていることは対して変わらない。

「我々が求めるものはPythonのbs4のような、CSSセレクタなどで簡単に要素を見つけ出し、情報を抽出するものではなかろうか?」

そんなこんなで火が付いた。作ったプロトタイプもGoogle Chromeの開発者ツール上では、少々レスポンスが遅いもののキチンと想定道理に動いていたから、やれる自信はあった。再帰呼び出しの回数が多すぎて、どうやってもGASでは動かないと気付いた時、私は燃え尽きた。

どういうライブラリだったか

CSSセレクタベースで要素を見つけるもの。最初は親・子孫要素や属性([attr="hogehoge"])などにも対応させていたが、少しでも再帰呼び出しを減らすため、どんどんオプションを削られ、しまいには親要素からの検索も露と消えた。まあ結局動かなかったけど。というわけでこのライブラリ(笑)は、Google Chromeの開発者ツール上でしか、筆者は正常に動かしたことがない。悲しい。せっかく作ったのに。

呼び出し

var h = HTMLparser(`
<html>
<head></head>
<body>
<p class="cls">htmlソーステキスト</p>
...
</body>
</html>
`);
var tag_p = h.search("p.cls");
//[{tagname: "p", attr: {…}, innerText: "htmlソーステキスト", tree: Array(1), parent: {…}}]

呼び出し元のコード

function HTMLparser(html_str) {
    if(!(this instanceof HTMLparser)) {
        return new HTMLparser(html_str);
    }else {
        this.html = [];
        this.attr = {};
        this.tags = {};
        var html_obj = {tree: []};
        //str: htmlソース, ary: htmlのツリー, parent: 親要素, self: HTMLparserのthisの参照用
        function per(str, ary, parent, self) {
            var obj = {tagname: "_Node", attr: null, innerText: "", tree: [], parent: null};
            obj.parent = parent;

            //開始タグを見つける
            var matchTag = str.match(/<([a-zA-Z][^\t\n\r\f \/>\x00]*?)(| [a-zA-Z][^\t\n\r\f>\x00]*?[^\/])>([\s\S]*?)$/);
            //[0]: 全体, [1]: タグ名, [2]: 属性, [3]: その開始タグ以降のテキスト

            function sameTagBothReg(tagname) {
                return new RegExp("(<" + tagname + ">|<" + tagname + " [a-zA-Z][^\t\n\r\f>\x00]*?[^\/]>|<\\/" + tagname + ">)");
            }
            function sameTagStartReg(tagname) {
                return new RegExp("(<" + tagname + ">|<" + tagname + " [a-zA-Z][^\t\n\r\f>\x00]*?[^\/]>)");
            }
            function sameTagEndReg(tagname, count) {
                return new RegExp("(<\\/" + tagname + ">)");
            }
            var attrReg = /([a-zA-Z][^\t\n\r\f >\x00]*=\".*?\")/g;
            var attrReg_g = /([a-zA-Z][^\t\n\r\f >\x00]*)=\"(.*?)\"/g;
            if(matchTag) {
                var attr_obj = {};
                var attr_node_list = matchTag[2].split(attrReg).filter(function(r) {return r.match(attrReg);})
                attr_node_list.forEach(function(r) {
                    //タグの属性を収集
                    var a = r.split(attrReg_g);

                    if(!self.attr[a[1]]) {
                        self.attr[a[1]] = {};
                    }

                    var v;
                    //クラスのみリスト化
                    if(a[1] == "class") {
                        v = a[2].split(" ");
                        v.forEach(function(r) {
                            if(!self.attr[a[1]][r]) {
                                self.attr[a[1]][r] = [];
                            }
                            self.attr[a[1]][r].push(obj);
                        });
                    }else {
                        v = a[2];
                        if(!self.attr[a[1]][v]) {
                            self.attr[a[1]][v] = [];
                        }
                        self.attr[a[1]][v].push(obj);
                    }

                    attr_obj[a[1]] = v;
                });
                obj.attr = attr_obj;
                obj.tagname = matchTag[1];

                //その開始タグの対となる終了タグを見つける
                //もし開始タグがspanだった場合、その開始タグ以降のテキストから、
                //spanの開始タグと終了タグをまとめて探索する
                var st_cnt = 1;//開始タグの数(開始タグはすでに一個あるので、初期値は1)
                var ed_cnt = 0;//終了タグの数
                var sp_idx = 0;//目的の終了タグのインデックス
                var splitted_same_tag = matchTag[3].split(sameTagBothReg(matchTag[1]));
                splitted_same_tag.forEach(function(v, i) {
                    if(sp_idx) {
                        return;
                    }
                    //開始タグがマッチしたら+1
                    else if(v.match(sameTagStartReg(matchTag[1]))) {
                        st_cnt++;
                        return;
                    }
                    //終了タグがマッチしたら+1
                    else if(v.match(sameTagEndReg(matchTag[1]))) {
                        ed_cnt++;
                        //開始タグ数と終了タグ数が一致したら、インデックスを記録
                        if(st_cnt == ed_cnt) {
                            sp_idx = i;
                        }else {
                            return;
                        }
                    }
                });
                //始点からインデックスまでが、そのタグの子要素
                var child = splitted_same_tag.slice(0, sp_idx).join("");
                if(matchTag[1] == "title") {
                    self.title = child;
                }
                //scriptタグの中には稀にhtmlのタグが紛れ込んでいるので、
                //誤マッチ回避のため、scriptタグの中身は切り捨て
                if(matchTag[1] !== "script") {
                    //子要素をターゲットにして自身(obj)を親とし、perを再帰的に呼び出し
                    per(child, obj.tree, obj, self);
                }
                //インデックスから終点までが、そのタグの兄弟要素
                var bro = splitted_same_tag.slice(sp_idx + 1).join("");
                if(bro !== "") {
                    //兄弟要素をターゲットにして自身と同じ親要素(parent)
                    //を親とし、perを再帰的に呼び出し
                    per(bro, ary, parent, self);
                }
            }else {
                obj.tree = [str];
            }

            ary.unshift(obj);

            //もしタグ名が見つからなかったら(_Nodeのままなら)、
            //自身と親・先祖のinnerTextに中身の文字列を追加
            if(obj.tagname == "_Node") {
                Text(obj, obj.tree.join(""));
                function Text(o, txt) {
                    o.innerText += txt;
                    if(o.parent) {
                        Text(o.parent, txt);
                    }
                }
            }else {
                if(!self.tags[obj.tagname]) {
                    self.tags[obj.tagname] = [];
                }
                self.tags[obj.tagname].push(obj);
            }

        }
        per(html_str.replace(/<!--[\s\S]*?-->/g, ""), this.html, null, this);
    }
}

//検索機能(最も削り取られた部分)
HTMLparser.prototype.search = function(selector) {
    var sel = sel_parse(selector);
    //終端の1要素のみ読み取る
    //CSSセレクタは「右から」読み取るのが効率的
    return check_tree(sel[0], this);

    function check_tree(s, self) {
        var r = [];
        var _tag = s.tag;
        if(_tag) {
            if(self.tags[_tag]) {
                for(var t of self.tags[_tag]) {
                    var _atr = t.attr;
                    if(s.class.length) {
                        if(_atr.class) {
                            var _chk = false;
                            for(var cls of s.class) {
                                if(_atr.class.indexOf(cls) < 0) {
                                    _chk = true;
                                    break;
                                }
                            }
                            if(_chk) {continue;}
                        }else {
                            continue;
                        }
                    }
                    if(s.id.length) {
                        if(s.id[0] !== _atr.id) {
                            continue;
                        }
                    }
                    r.push(t);
                }
            }
        }else {
            var c_ary = [];
            var i_ary = [];
            var a_ary = [];//削り取られた残滓

            if(s.class.length) {
                var _chk = true;
                var clsList = Object.keys(self.attr.class);
                for(var cls of s.class) {
                    if(clsList.indexOf(cls) < 0) {
                        break;
                    }else {
                        if(!c_ary.length) {
                            c_ary = self.attr.class[cls];
                        }else {
                            c_ary = c_ary.concat(self.attr.class[cls]).filter(function(x, i, self) {
                                return self.indexOf(x) === i && i !== self.lastIndexOf(x);
                            });
                        }
                    }
                }
            }
            if(s.id.length) {
                var idList = Object.keys(self.attr.id);
                if(-1 < idList.indexOf(s.id[0])) {
                    i_ary = self.attr.id[s.id[0]];
                }
            }
            if(s.attr.length) {
                var _chk = false;
                for(var a of s.attr) {
                    var _k = a.atr;
                    var _m = false;
                    if(_k.match(/(\*|\^|\$)$/)) {
                        _m = _k.match(/(\*|\^|\$)$/)[1];
                        _k = _k.replace(/(\*|\^|\$)$/, "");
                    }
                }
            }
            var full_cnt = 0;
            if(c_ary.length) {
                full_cnt++;
                r = r.concat(c_ary);
            }
            if(i_ary.length) {
                full_cnt++;
                r = r.concat(i_ary);
            }
            if(a_ary.length) {
                full_cnt++;
                r = r.concat(a_ary);
            }
            if(1 < full_cnt) {
                r = r.filter(function(x, i, self) {
                    return self.indexOf(x) === i && i !== self.lastIndexOf(x);
                });
            }
        }
        return r;
    }

    //CSSセレクタの解析用
    function sel_parse(sel) {
        if(sel.match(/ ?[\+\~] ?/g)) {
            throw Error('You cannot use Adjacent sibling combinator "+/~".');
        }
        else if(sel.match(/\:(nth-child\(|nth-of-type\(|not\(|first-child|first-of-type|last-child|last-of-type)/g)) {
            throw Error('You cannot use Pseudo-elements like ":nth-of-type()"');
        }
        var sp = sel.split(/( ?> ?|(?<=[a-zA-Z0-9\]\_\-]) (?=[a-zA-Z\[\.\#\_]))/g);
        var a = [], nxt = false;
        sp.forEach(function(s, idx) {
            if(s.match(/^ $/g)) {
                return;
            }
            else if(s.match(/^ ?> ?$/g)) {
                nxt = true;
                return;
            }
            var _o = {"tag": null, "class": [], "id": [], "attr": [], "next": false};
            if(nxt) {
                _o.next = true;
                nxt = false;
            }
            var _s = s.split(/(\[.*?\]|(?<=(?:[a-zA-Z\]]|^))(?:\.|\#)[a-zA-Z\_][a-zA-Z0-9\_\-]*)/g).filter(function(r) {return r;});
            _s.forEach(function(p) {
                if(p.match(/^\#/)) {
                    _o.id.push(p.replace(/\#/, ""));
                }
                else if(p.match(/^\./)) {
                    _o.class.push(p.replace(/\./, ""));
                }
                else if(p.match(/^[a-zA-Z]/)) {
                    _o.tag = p;
                }
                else if(p.match(/^\[(.*?)(?:\=(?:\"(.*?)\"|\'(.*?)\')|)\]/)) {
                    var _m = p.match(/^\[(.*?)(?:\=(?:\"(.*?)\"|\'(.*?)\')|)\]/);
                    _o.attr.push({"atr": _m[1], "que": _m[2] ? _m[2] : ""});
                }
            });
            a.unshift(_o);
        });
        return a;
    }
}

ちなみに、HTMLparserオブジェクトの中身は以下の通り。
dfdgfh.png
treeにはその要素の子要素のリストが、parentにはその要素の親要素への参照が渡されている(図では一番外側のタグのため参照が無い)。下のattrとtagsは検索用の参照のリストが詰まっている。

777_happ
I wanna eat a pizza: basil-flavored, and of course, is a *green* one.
http://ja.scp-wiki.net/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away