Google Apps ScriptでWebコンテンツを抽出する（ExtractContentをJavaScriptに移植してみた）

# ExtractContentモジュールをJavaScriptに移植

Webページのコンテンツを抽出するための`ExtractContent`モジュールをJavaScriptに移植し、Google Apps Scriptから利用できるようにしました。この記事では、その使用方法を紹介します。

## ExtractContentモジュールとは

`ExtractContent`モジュールは、HTMLから本文とタイトルを抽出します。2007年にRuby版がBSDライセンスで公開され（[extractcontent.rb](https://labs.cybozu.co.jp/blog/nakatani/2007/09/web_1.html
)）、それを移植したPython3版が存在します（[python3-extractcontent3](https://github.com/kanjirz50/python-extractcontent3)）。

## Google Apps Scriptで動かす

`ExtractContent`モジュールを、Apps Scriptでも動作するようJavaScriptに移植し、さらにアイキャッチ画像（og:image）をパースする機能を追加しました。`python3-extractcontent3`を移植しています。元のRuby版と同じく、BSDライセンスで公開します。

Apps Scriptの`UrlFetchApp`（[URL Fetch Appの公式ドキュメント](https://developers.google.com/apps-script/reference/url-fetch?hl=ja)）でWebページのHTMLを取得し、`ExtractContent`でHTMLから本文、タイトル、アイキャッチ画像を抽出します。

なお、WebサイトによってはApps Scriptからのアクセスを受け付けていません[^1]。ご注意ください。

[^1]: なお、UrlFetchApp.fetch()ではUserAgentヘッダーを変更してWebサイトにアクセスさせることができません。 https://stackoverflow.com/questions/56099139/urlfetchapp-fetch-doesnt-seem-to-change-user-agent

## 使い方

例えば、Webページの内容をAIに要約させるのに、本文だけを抽出する時に使います。こちらの記事で`python3-extractcontent`の存在を知り、Apps Scriptでも動かしたくなって移植しました！

https://zenn.dev/sigmai_tech/articles/368533f22feb7f

`ExtractContent`のコードは末尾にあります。Apps Scriptのスクリプトエディターにコピペしてご利用ください。

`ExtracrContent`の使い方は次の通りです。次の本文を多めに抽出するようオプション（後述）を変更しています。

```javascript
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`です。ライブラリー化はしていないので、各自でコピーしてご利用ください。

```javascript: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(/&nbsp;/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;
  }
}
```