ExtractContentモジュールをJavaScriptに移植
Webページのコンテンツを抽出するためのExtractContent
モジュールをJavaScriptに移植し、Google Apps Scriptから利用できるようにしました。この記事では、その使用方法を紹介します。
ExtractContentモジュールとは
ExtractContent
モジュールは、HTMLから本文とタイトルを抽出します。2007年にRuby版がBSDライセンスで公開され(extractcontent.rb)、それを移植したPython3版が存在します(python3-extractcontent3)。
Google Apps Scriptで動かす
ExtractContent
モジュールを、Apps Scriptでも動作するようJavaScriptに移植し、さらにアイキャッチ画像(og:image)をパースする機能を追加しました。python3-extractcontent3
を移植しています。元のRuby版と同じく、BSDライセンスで公開します。
Apps ScriptのUrlFetchApp
(URL Fetch Appの公式ドキュメント)でWebページのHTMLを取得し、ExtractContent
でHTMLから本文、タイトル、アイキャッチ画像を抽出します。
なお、WebサイトによってはApps Scriptからのアクセスを受け付けていません1。ご注意ください。
使い方
例えば、Webページの内容をAIに要約させるのに、本文だけを抽出する時に使います。こちらの記事でpython3-extractcontent
の存在を知り、Apps Scriptでも動かしたくなって移植しました!
ExtractContent
のコードは末尾にあります。Apps Scriptのスクリプトエディターにコピペしてご利用ください。
ExtracrContent
の使い方は次の通りです。次の本文を多めに抽出するようオプション(後述)を変更しています。
function extractContent(url) {
const opt = { threshold: 80, continuous_factor: 1.0 };
const html = UrlFetchApp.fetch(url).getContentText();
return new ExtractContent().analyse(html, opt).asText();
}
function test() {
const url = 'https://zenn.dev/sigmai_tech/articles/368533f22feb7f';
const content = extractContent(url);
console.log(content);
// -> content.title タイトル
// -> content.image アイキャッチ画像のURL
// -> content.body 本文
}
指定できるオプション
ExtractContent
はHTMLをプロックに分け、ブロックごとに有用かどうかを判定します。指定できるオプションは次の通りです。
-
threshold
: そのブロックを有用だと判定するしきい値です。値が小さければ、有用だと判定されることが増えるので、抽出される本文は多くなります。デフォルト値は100。 -
continuous_factor
: 連続したブロックのスコア計算に影響を与える因子です。この値が大きいほど、連続したブロックのスコアが低くなり、連続するテキストの抽出が難しくなります。デフォルト値は1.63。 -
min_length
: 評価されるブロックの最小文字数です。この値未満のブロックは無視されます。デフォルト値は80。 -
decay_factor
: ブロックのスコア計算における減衰因子です。この値は1に近いほど、先頭に近いブロックのスコアが高くなり、テキストの抽出が広がります。デフォルト値は0.73。 -
punctuation_weight
: 句読点のスコアへの重み付けです。句読点が多いほど、ブロックのスコアが高くなります。デフォルト値は10。 -
punctuations
: 句読点の正規表現パターンです。 -
waste_expressions
: コンテンツとして排除されるべきキーワードやフレーズの正規表現パターンです。この例では、"Copyright"および"All Rights Reserved"というフレーズが含まれています。 -
debug
: デバッグモードが有効の場合、デバッグ情報がコンソールに出力されます。
デバッグモードの出力
デバッグモードが有効の場合、ブロックごとに次のように出力されます。
----- c*continuous=c1 notlinked_length
stripped_text
-
c
: 現在のブロックのスコアです。このスコアはブロック内の文字数と句読点の数に基づいて計算されます。 -
continuous
: 連続したブロックのクラスタリングに影響を与える連続係数です。この値は1.0から始まり、各連続したブロックに対してcontinuous_factor
で除算されます。 -
c1
:c
とcontinuous
を掛けた値です。この値がthreshold
よりも大きい場合、現在のブロックは連続したブロックのクラスタに追加されます。 -
notlinked_length
: リンクを除去した後のブロックの文字数です。 -
stripped_text
: 現在のブロックからHTMLタグを取り除いたテキストの最初の100文字です。
ソースコード
ExtractContent.gs
です。ライブラリー化はしていないので、各自でコピーしてご利用ください。
// Original Code:: https://github.com/kanjirz50/python-extractcontent3
// Fork of:: https://github.com/petitviolet/python-extractcontent
// Fork of:: https://github.com/yono/python-extractcontent
// Fork of:: extractcontent.rb https://labs.cybozu.co.jp/blog/nakatani/2007/09/web_1.html
// Original extractcontent.rb's Author:: Nakatani Shuyo
// Original extractcontent.rb's Copyright:: (c)2007 Cybozu Labs Inc. All rights reserved.
// License:: BSD
class ExtractContent {
constructor(opt = null) {
// Default option parameters.
this.option = {
threshold: 100,
min_length: 80,
decay_factor: 0.73,
continuous_factor: 1.62,
punctuation_weight: 10,
punctuations: /[\u3001\u3002\uff01\uff0c\uff0e\uff1f]|\.[^A-Za-z0-9]|,[^0-9]|!|\?/g,
waste_expressions: /Copyright|All Rights Reserved/i,
debug: false,
};
if (opt !== null) {
this.setOption(opt);
}
this.title = '';
this.body = '';
this.charRef = {
nbsp: ' ',
lt: '<',
gt: '<',
amp: '&',
laquo: '\x00\xab',
raquo: '\x00\xbb',
};
}
setOption(opt) {
// Merge the current options with the provided options
this.option = { ...this.option, ...opt };
}
_eliminateUselessTags(html) {
// Eliminate useless symbols
html = html.replace(/[\u2018-\u201d\u2190-\u2193\u25a0-\u25bd\u25c6-\u25ef\u2605-\u2606]/g, '');
// Eliminate useless html tags
html = html.replace(/<(script|style|select|noscript)[^>]*>.*?<\/\1\s*>/gis, '');
html = html.replace(/<!--.*?-->/gs, '');
html = html.replace(/<![A-Za-z].*?>/gs, '');
html = html.replace(/<(?:div|center|td)[^>]*>|<p\s*[^>]*class\s*=\s*["']?(?:posted|plugin-\w+)['"]?[^>]*>/gis, '');
return html;
}
_extractTitle(st) {
const result = /<title[^>]*>\s*(.*?)\s*<\/title\s*>/is.exec(st);
if (result !== null) {
return this._stripTags(result[0]);
} else {
return "";
}
}
_extractOgImage(html) {
const ogImageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["'](.*?)["']/i);
return ogImageMatch ? ogImageMatch[1] : '';
}
_splitToBlocks(html) {
return html.split(/<\/?(?:div|center|td)[^>]*>|<p\s*[^>]*class\s*=\s*["']?(?:posted|plugin-\w+)['"]?[^>]*>/gi);
}
_countPattern(text, pattern) {
const result = text.match(pattern);
if (result === null) {
return 0;
} else {
return result.length;
}
}
_estimateTitle(match) {
const stripped = this._stripTags(match[2]);
if (stripped.length >= 3 && this.title.includes(stripped)) {
return `<div>${stripped}</div>`;
} else {
return match[1];
}
}
_hasOnlyTags(st) {
st = st.replace(/<[^>]*>/gis, '');
st = st.replace(/ /g, '');
st = st.trim();
return st.length === 0;
}
_eliminateLink(html) {
let count = 0;
const notLinked = html.replace(/<a\s[^>]*>.*?<\/a\s*>/gis, (match) => {
count++;
return '';
});
const notLinkedFinal = notLinked.replace(/<form\s[^>]*>.*?<\/form\s*>/gis, '');
const notLinkedStripped = this._stripTags(notLinkedFinal);
// returns empty string when html contains many links or list of links
if (notLinkedStripped.length < 20 * count || this._isLinkList(html)) {
return '';
}
return notLinkedStripped;
}
_isLinkList(st) {
const result = /<(?:ul|dl|ol)(.+?)<\/(?:ul|dl|ol)>/is.exec(st);
if (result !== null) {
const listPart = result[1];
const outside = st.replace(/<(?:ul|dl)(.+?)<\/(?:ul|dl)>/gis, '');
const strippedOutside = outside.replace(/<.+?>/gis, '').replace(/\s+/g, '');
const list = listPart.split(/<li[^>]*>/gi);
const rate = this._evaluateList(list);
return strippedOutside.length <= st.length / (45 / rate);
}
return false;
}
_evaluateList(list) {
if (list.length === 0) {
return 1;
}
let hit = 0;
const href = /<a\s+href=(['"]?)([^"'\s]+)\1/gi;
for (let line of list) {
if (href.test(line)) {
hit++;
}
}
return 9 * Math.pow((1.0 * hit / list.length), 2) + 1;
}
_stripTags(html) {
let st = html.replace(/<.+?>/gs, '');
// Convert from wide character to ascii
st = st.normalize('NFKC');
st = st.replace(/[\u2500-\u253f\u2540-\u257f]/g, ''); // 罫線(keisen)
st = st.replace(/&(.*?);/g, (match, p1) => {
return this.charRef[p1] || match;
});
st = st.replace(/[ \t]+/g, ' ');
st = st.replace(/\n\s*/g, '\n');
return st;
}
asHtml() {
return { body: this.body, title: this.title, image: this.image };
}
asText() {
return { body: this._stripTags(this.body), title: this.title, image: this.image };
}
analyse(html, opt = null) {
// frameset or redirect
if (/<\/frameset>|<meta\s+http-equiv\s*=\s*["']?refresh['"]?[^>]*url/i.test(html)) {
return ["", this._extractTitle(html)];
}
// option parameters
if (opt) {
this.setOption(opt);
}
// header & title
const header = /<\/head\s*>/i.exec(html);
if (header !== null) {
const headContent = html.slice(0, header.index);
this.title = this._extractTitle(headContent);
this.image = this._extractOgImage(headContent);
} else {
this.title = this._extractTitle(html);
this.image = this._extractOgImage(html);
}
// Google AdSense Section Target
html = html.replace(/<!--\s*google_ad_section_start\(weight=ignore\)\s*-->.*?<!--\s*google_ad_section_end.*?-->/gis, '');
if (/<!--\s*google_ad_section_start[^>]*-->/i.test(html)) {
const result = html.match(/<!--\s*google_ad_section_start[^>]*-->.*?<!--\s*google_ad_section_end.*?-->/gis);
html = result.join('\n');
}
// eliminate useless text
html = this._eliminateUselessTags(html);
// heading tags including title
html = html.replace(/(<h\d\s*>\s*(.*?)\s*<\/h\d\s*>)/gis, this._estimateTitle.bind(this));
// extract text blocks
let factor = 1.0;
let continuous = 1.0;
let body = '';
let score = 0;
let bodylist = [];
const blockList = this._splitToBlocks(html);
for (let block of blockList) {
if (this._hasOnlyTags(block)) {
continue;
}
if (body.length > 0) {
continuous /= this.option.continuous_factor;
}
// ignore link list block
const notlinked = this._eliminateLink(block);
if (notlinked.length < this.option.min_length) {
continue;
}
// calculate score of block
let c = (notlinked.length + (notlinked.match(this.option.punctuations) || []).length * this.option.punctuation_weight) * factor;
factor *= this.option.decay_factor;
const notBodyRate = (block.match(this.option.waste_expressions) || []).length + (block.match(/amazon[a-z0-9\.\/\-\?&]+-22/gi) || []).length / 2.0;
if (notBodyRate > 0) {
c *= Math.pow(0.72, notBodyRate);
}
const c1 = c * continuous;
if (this.option.debug) {
console.log(`----- ${c}*${continuous}=${c1} ${notlinked.length} \n${this._stripTags(block).slice(0, 100)}`);
}
// tread continuous blocks as a cluster
if (c1 > this.option.threshold) {
body += block + '\n';
score += c1;
continuous = this.option.continuous_factor;
} else if (c > this.option.threshold) { // continuous block end
bodylist.push({ body, score });
body = block + '\n';
score = c;
continuous = this.option.continuous_factor;
}
}
bodylist.push({ body, score });
body = bodylist.reduce((prev, current) => (prev.score >= current.score) ? prev : current);
this.body = body.body;
return this;
}
}
-
なお、UrlFetchApp.fetch()ではUserAgentヘッダーを変更してWebサイトにアクセスさせることができません。 https://stackoverflow.com/questions/56099139/urlfetchapp-fetch-doesnt-seem-to-change-user-agent ↩