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

>![efgrhj.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532858/a46d2a5d-c1f7-888e-ff87-74f1a98ff812.png)
ウガアッ！

#作るに至った経緯
GASでのWEBスクレイピングといえば、送られてきたhtmlソースを**ただの文字列として**受け取り、正規表現やmatch、split、indexOfなどで無理矢理目的の要素・テキストを削り出してくるもの。一応**[スクレイピング用のライブラリ](https://script.google.com/d/1Mc8BthYthXx6CoIz90-JiSzSafVnT6U3t0z_W3hLTAX5ek4w0G_EIrNw/edit?usp=drive_web)**もあるが、やっていることは対して変わらない。

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

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

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

###呼び出し

```javascript
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: {…}}]
```

###呼び出し元のコード
```javascript
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532858/4e035476-da89-0fb2-ecd0-d02c2347e8d1.png)
treeにはその要素の子要素のリストが、parentにはその要素の親要素への参照が渡されている（図では一番外側のタグのため参照が無い）。下のattrとtagsは検索用の参照のリストが詰まっている。