#作るに至った経緯
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オブジェクトの中身は以下の通り。
treeにはその要素の子要素のリストが、parentにはその要素の親要素への参照が渡されている(図では一番外側のタグのため参照が無い)。下のattrとtagsは検索用の参照のリストが詰まっている。