ナード戦隊データマン

データサイエンスを用いて悪と戦うぞ

domextract: コンテンツ抽出のpythonパッケージ

スクレイピングの自動化とは、抽出箇所の選択等で人手を介さず、URL(またはhtmlのファイルパス)を渡すだけで抽出する技術です。今回は、コンテンツ抽出のdomベースモデルをパッケージ化したので、紹介します。

仕様概要

urlまたはhtmlのファイルパスを渡すと、その記事の本文(タイトルを除く)を抽出する。 対応しているWebページの種類は、ニュース記事、ブログ記事、Wikipediaなど。

事前準備

まず、事前準備としてMeCabをインストールしておいてください。

git clone https://github.com/taku910/mecab && \
    cd mecab/mecab && \
    ./configure --enable-utf8-only && \
    make && \
    make check && \
    make install && \
    pip install --no-cache-dir mecab-python3 && \
    ldconfig && \
    cd ../mecab-ipadic && \
    ./configure --with-charset=utf8 && \
    make && \
    make install

インストール

git clone https://github.com/sugiyamath/domextract
cd domextract
python setup.py install

使い方

urlを指定して抽出します。(extractメソッドにis_url=Falseというパラメータを渡せばhtmlファイルでもできる)

from domextract import Extractor

ext = Extractor()
print(ext.extract("https://mainichi.jp/articles/20181020/k00/00e/030/229000c"))

出力

' 【ワシントン会川晴之】トランプ米大統領は19日夜、サウジの発表について「大きな第一歩だ」と述べ、サウジが真相究明に向けた努力を続けていることを評価した。滞在先の西部アリゾナ州で記者団の質問に答えた。一方、サウジへの制裁実施の可能性を記者団に問われたトランプ氏は「多分そうなるだろう」と答え、早急に議会と調整する考えも表明した。  【画像】監視カメラが捉えた皇太子の警護担当とみられる男性 <王室知り尽くしたカショギ氏一部王族の助言役も> <「王室の関与」米主要紙報道で広がる波紋> <サウジ不明記者「最後のコラム」で訴えたこと> <反政府記者「失踪」の闇サウジ政府は真相を語れ> <不明記者、アップルウオッチで録音か殺害時の音声>  トランプ氏は、2日の事件発生以来、サウジが武器購入や米国への投資など450億ドル(約5兆円)の商談を約束していることを理由に、サウジへの批判を慎重に避け続けてきた。人権よりもビジネスを優先する形だったが、サウジとトルコ両国を訪問したポンペオ国務長官から詳細な報告を受けた18日以後に発言のトーンを変えた。   トランプ氏は、米国も国際的な批判を招く可能性があることを考慮し、サウジ政府の事件への関与が認められた場合には「非常に厳しい措置を取る」と制裁実施を明言した。ただ、記者団に対する発言の中で武器売却の中止は望まない意向も示している。   議会では超党派の議員が、サウジへの厳しい対応を求めている。米国製武器の売却停止のほか、戦闘機への空中給油や情報提供などイエメン空爆を実施中のサウジ軍への支援打ち切りなどを求める声が強まっている。   これに対しトランプ氏は19日も「サウジは偉大な同盟国だ。米国に巨額の投資をしている。エネルギー供給面でも世界2位。こうしたことを考慮しなければならない」と述べた。厳しい制裁を実施した場合、サウジが中国やロシア製武器を購入したり、原油輸出量の削減に踏み切ったりすれば、世界経済に混乱を招く可能性があると警戒しているからだ。   トランプ政権は昨年5月の初外遊先にサウジを選ぶなど、サウジと密接な関係を築いてきた。ビジネス面だけでなく、敵視するイランの封じ込めを実現するにはサウジが欠かせないパートナーと位置づけている。  文字サイズ 小 中 大 印刷 シェア'

不要なデータが含まれることがありますが、それなりの精度です。

リンク

https://github.com/sugiyamath/domextract <- バグがあったらissueを書いてください。暇があったら対応します。

コンテンツ抽出のdom-basedモデルをvision-basedモデルのデータから学習した

コンテンツ抽出のdom-basedモデルとは、domの構造を特徴量として利用するコンテンツ抽出の手法です。今回は、web2textというツールで使われている特徴量を、RandomForestで実行します。

特徴量一覧

Screenshot_2018-10-12_20-54-56.png

上記特徴量のうち、いくつかを利用します。

データの準備

記事urlの一覧から取得したhtmlファイルから、以下を取り出します。

  1. テキスト要素を持つノードのテキスト
  2. テキスト要素を持つノードのxpath
  3. そのテキスト要素が抽出したいコンテンツか否か

以下がcsvの例です。(ただし、このcsv以前の記事Pascal VOCデータから生成しているため、抽出したくないコンテンツも若干含まれています。)

#text,label,xpath
"We use cookies to ensure that we give you the best experience on our website and to ensure we show advertising that is relevant to you. By continuing to use our website, you agree to our use of such cookies. You can change this and find out more by following: ",False,/html/body/div[1]/div/p
 クッキーポリシー ,False,/html/body/div[1]/div/p/a
継続,False,/html/body/div[1]/div/div/div
" $(document).ready(function(){ $('#cookiesLegal .continue').on('click', function(){ $('#cookiesLegal').remove(); $('body').removeClass('cookies_on'); //alert('off'); }) }); ",False,/html/body/div[1]/script
 日本語 ,False,/html/body/header/div[1]/div/div[2]/div/a
English,False,/html/body/header/div[1]/div/div[2]/div/ul/li[1]/a
Español,False,/html/body/header/div[1]/div/div[2]/div/ul/li[2]/a
Italiano,False,/html/body/header/div[1]/div/div[2]/div/ul/li[3]/a
Français,False,/html/body/header/div[1]/div/div[2]/div/ul/li[4]/a
日本語,False,/html/body/header/div[1]/div/div[2]/div/ul/li[5]/a
Deutsch,False,/html/body/header/div[1]/div/div[2]/div/ul/li[6]/a
ログイン,False,/html/body/header/div[1]/div/div[2]/span/a[1]/span
登録,False,/html/body/header/div[1]/div/div[2]/span/a[2]
ログイン,False,/html/body/header/div[1]/div/div[3]/div/a
初めてのアクセスですか?,False,/html/body/header/div[1]/div/div[3]/div/p/span
登録,False,/html/body/header/div[1]/div/div[3]/div/p/a
 Tickets purchase ,False,/html/body/header/div[3]/div/div[1]
 VideoPass purchase ,False,/html/body/header/div[3]/div/div[2]
ホーム,False,/html/body/header/div[4]/div/div/ul[1]/li[1]/a
Live,False,/html/body/header/div[4]/div/div/ul[1]/li[2]/a
ビデオ,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/a/span
ベスト・オブ,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/nav/ul/li[1]/a
Live,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/nav/ul/li[2]/a
2018 年シーズン,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/nav/ul/li[3]/a
スポイラー,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/nav/ul/li[4]/a
ショー,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/nav/ul/li[5]/a
過去のシーズン,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/nav/ul/li[6]/a
オールビデオ,False,/html/body/header/div[4]/div/div/ul[1]/li[3]/nav/ul/li[7]/a
フォトギャラリー,False,/html/body/header/div[4]/div/div/ul[1]/li[4]/a/span
ベスト・オブ,False,/html/body/header/div[4]/div/div/ul[1]/li[4]/nav/ul/li[1]/a
Grand Prix,False,/html/body/header/div[4]/div/div/ul[1]/li[4]/nav/ul/li[2]/a
ライダー,False,/html/body/header/div[4]/div/div/ul[1]/li[4]/nav/ul/li[3]/a
チーム,False,/html/body/header/div[4]/div/div/ul[1]/li[4]/nav/ul/li[4]/a
リザルト,False,/html/body/header/div[4]/div/div/ul[1]/li[5]/a
カレンダー,False,/html/body/header/div[4]/div/div/ul[1]/li[6]/a
インサイド ,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/a/span
チーム&ライダー,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[1]/li/a
MotoGP VIP Village™,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[2]/li[1]/a
スポンサー,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[2]/li[2]/a
MotoGP Buzz,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[2]/li[3]/a
Red Bull MotoGP™ Rookies Cup,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[2]/li[4]/a
FIM Enel MotoE™ World Cup,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[2]/li[5]/a
Two Wheels For Life,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[2]/li[6]/a
MotoGP™リーグ,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[3]/li[1]/a
ビデオゲーム,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[3]/li[2]/a
eSport,False,/html/body/header/div[4]/div/div/ul[1]/li[7]/nav/ul[3]/li[3]/a
タイミングパス,False,/html/body/header/div[4]/div/div/div[2]/a[1]
ビデオパス,False,/html/body/header/div[4]/div/div/div[2]/a[2]
チケット,False,/html/body/header/div[4]/div/div/ul[2]/li[1]/a
アプリ,False,/html/body/header/div[4]/div/div/ul[2]/li[2]/a
オンラインショップ,False,/html/body/header/div[4]/div/div/ul[2]/li[3]/a
よくある質問,False,/html/body/header/div[4]/div/div/ul[2]/li[4]/a
Contact,False,/html/body/header/div[4]/div/div/ul[2]/li[5]/a
ビデオパス,False,/html/body/header/div[4]/div/div/ul[2]/li[6]/a
News ,False,/html/body/div[4]/div/div/div[3]/div[2]/div[1]/div[1]
14 days 前,False,/html/body/div[4]/div/div/div[3]/div[2]/div[1]/div[2]/div
Author,False,/html/body/div[4]/div/div/div[3]/div[2]/dl/dt[1]
motogp.com,False,/html/body/div[4]/div/div/div[3]/div[2]/dl/dd[1]
Published,False,/html/body/div[4]/div/div/div[3]/div[2]/dl/dt[2]
14 days ago,False,/html/body/div[4]/div/div/div[3]/div[2]/dl/dd[2]/span
By,False,/html/body/div[4]/div/div/div[3]/div[2]/div[2]/div[1]/span
 motogp.com,False,/html/body/div[4]/div/div/div[3]/div[2]/div[2]/div[1]
ユーロスポーツがオランダでの中継を継続,True,/html/body/div[4]/div/div/div[3]/div[3]/h1
フランス北部とベルギー西部のフランダース地域にも21年まで生中継を提供。,True,/html/body/div[4]/div/div/div[3]/div[3]/h2
Tags ,True,/html/body/div[4]/div/div/div[3]/div[3]/div[1]
MotoGP,True,/html/body/div[4]/div/div/div[3]/div[3]/div[1]/a[1]
2018,True,/html/body/div[4]/div/div/div[3]/div[3]/div[1]/a[2]
ドルナスポーツは19日、ユーロスポーツとの間で、オランダとフランダース地域におけるテレビ放送に関して、21年まで契約を延長することで合意。3クラスのフリー走行1から決勝レースまでの中継に加え、パソコンやスマートフォン、タブレッド向けのサービスを展開するヨーロスポーツプレイヤーでは、ライブタイミングやライブトラッキングも配信する。,True,/html/body/div[4]/div/div/div[3]/div[3]/div[2]/div/p
推奨記事,False,/html/body/div[4]/div/div/div[4]/h2
MotoGP,False,/html/body/div[4]/div/div/div[4]/div/article[1]/header/div/span/a
14 hours ago ,False,/html/body/div[4]/div/div/div[4]/div/article[1]/div/div[2]/p
Jump onboard with Marc Márquez as the #MotoGP Champ takes,False,/html/body/div[4]/div/div/div[4]/div/article[1]/div/a/h2
Jump onboard with Marc Márquez as the #MotoGP Champ takes a tuk tuk for a spin around the streets of Bangkok! ,False,/html/body/div[4]/div/div/div[4]/div/article[1]/div/p/a
Thailand,False,/html/body/div[4]/div/div/div[4]/div/article[1]/footer/div/div[2]/section/dl/dd/a[1]
" , ",False,/html/body/div[4]/div/div/div[4]/div/article[1]/footer/div/div[2]/section/dl/dd
Marc Marquez,False,/html/body/div[4]/div/div/div[4]/div/article[1]/footer/div/div[2]/section/dl/dd/a[2]
123,False,/html/body/div[4]/div/div/div[4]/div/article[1]/footer/div/div[3]/div[1]/span
 motogp.com ,False,/html/body/div[4]/div/div/div[4]/div/article[2]/header/div/span
1 week ago ,False,/html/body/div[4]/div/div/div[4]/div/article[2]/div/div/p
360度パノラマ映像~ピットレーンのセレブレーション,False,/html/body/div[4]/div/div/div[4]/div/article[2]/div/a[2]/h2
Aragon,False,/html/body/div[4]/div/div/div[4]/div/article[2]/footer/dl/dd/a[1]
" , ",False,/html/body/div[4]/div/div/div[4]/div/article[2]/footer/dl/dd
360,False,/html/body/div[4]/div/div/div[4]/div/article[2]/footer/dl/dd/a[2]
 motogp.com ,False,/html/body/div[4]/div/div/div[4]/div/article[3]/header/div/span
1 week ago ,False,/html/body/div[4]/div/div/div[4]/div/article[3]/div/div/p
After the Flag: エピソード13,False,/html/body/div[4]/div/div/div[4]/div/article[3]/div/a[2]/h2
Aragon,False,/html/body/div[4]/div/div/div[4]/div/article[3]/footer/dl/dd/a
 motogp.com ,False,/html/body/div[4]/div/div/div[4]/div/article[4]/header/div/span
2 weeks ago ,False,/html/body/div[4]/div/div/div[4]/div/article[4]/div/div/p
マルチオンボードスタート,False,/html/body/div[4]/div/div/div[4]/div/article[4]/div/a[2]/h2
Aragon,False,/html/body/div[4]/div/div/div[4]/div/article[4]/footer/dl/dd/a[1]
" , ",False,/html/body/div[4]/div/div/div[4]/div/article[4]/footer/dl/dd
#AragonGP,False,/html/body/div[4]/div/div/div[4]/div/article[4]/footer/dl/dd/a[2]
2 weeks ago ,False,/html/body/div[4]/div/div/div[4]/div/article[5]/div[1]/div/span
MotoGP™クラス‐決勝レースハイライト,False,/html/body/div[4]/div/div/div[4]/div/article[5]/div[1]/a/h2
Aragon,False,/html/body/div[4]/div/div/div[4]/div/article[5]/footer/dl/dd/a[1]
" , ",False,/html/body/div[4]/div/div/div[4]/div/article[5]/footer/dl/dd
#AragonGP,False,/html/body/div[4]/div/div/div[4]/div/article[5]/footer/dl/dd/a[2]
@MotoGP,False,/html/body/div[4]/div/div/div[4]/div/article[6]/header/div/span[1]/a
2 weeks ago ,False,/html/body/div[4]/div/div/div[4]/div/article[6]/div/div[2]/p
We think he's happy with that one... We're not sure,False,/html/body/div[4]/div/div/div[4]/div/article[6]/div/a/h2
We think he's happy with that one... We're not sure about the dance moves though...,False,/html/body/div[4]/div/div/div[4]/div/article[6]/div/p/a
Aragon,False,/html/body/div[4]/div/div/div[4]/div/article[6]/footer/div/div[2]/section/dl/dd/a[1]
" , ",False,/html/body/div[4]/div/div/div[4]/div/article[6]/footer/div/div[2]/section/dl/dd
Marc Marquez,False,/html/body/div[4]/div/div/div[4]/div/article[6]/footer/div/div[2]/section/dl/dd/a[2]
369,False,/html/body/div[4]/div/div/div[4]/div/article[6]/footer/div/div[3]/div[2]/span
757,False,/html/body/div[4]/div/div/div[4]/div/article[6]/footer/div/div[3]/div[3]/span
 motogp.com ,False,/html/body/div[4]/div/div/div[4]/div/article[7]/header/div/span
2 weeks ago ,False,/html/body/div[4]/div/div/div[4]/div/article[7]/div/div/p
ラスト3分間のポールポジションバトル,False,/html/body/div[4]/div/div/div[4]/div/article[7]/div/a[2]/h2
Aragon,False,/html/body/div[4]/div/div/div[4]/div/article[7]/footer/dl/dd/a
More motogp.com,False,/html/body/nav[1]/div/div/div/p
ソーシャルネットワーク,False,/html/body/nav[1]/div/ul/li[1]/header/p
アバウト,False,/html/body/nav[1]/div/ul/li[2]/header/p
dorna.com,False,/html/body/nav[1]/div/ul/li[2]/ul/li[1]/a
クッキーポリシー,False,/html/body/nav[1]/div/ul/li[2]/ul/li[2]/a
Terms & Conditions,False,/html/body/nav[1]/div/ul/li[2]/ul/li[3]/a
コンタクト,False,/html/body/nav[1]/div/ul/li[3]/header/p
コンタクト,False,/html/body/nav[1]/div/ul/li[3]/ul/li[1]/a
FAQ,False,/html/body/nav[1]/div/ul/li[3]/ul/li[2]/a
アドバタイズ,False,/html/body/nav[1]/div/ul/li[3]/ul/li[3]/a
motogp.com,False,/html/body/nav[1]/div/ul/li[4]/header/p
ビデオパス,False,/html/body/nav[1]/div/ul/li[4]/ul/li[1]/a
MotoGP™ Tickets,False,/html/body/nav[1]/div/ul/li[4]/ul/li[2]/a
MotoGP™ League,False,/html/body/nav[1]/div/ul/li[4]/ul/li[3]/a
TV Broadcast,False,/html/body/nav[1]/div/ul/li[4]/ul/li[4]/a
MotoGP™ Apps,False,/html/body/nav[1]/div/ul/li[4]/ul/li[5]/a
MotoGP VIP Village™,False,/html/body/nav[1]/div/ul/li[4]/ul/li[6]/a
MotoGP™ Store,False,/html/body/nav[1]/div/ul/li[4]/ul/li[7]/a
スポイラー,False,/html/body/nav[1]/div/ul/li[4]/ul/li[8]/a
MotoGP™ Cashback,False,/html/body/nav[1]/div/ul/li[4]/ul/li[9]/a
© 2016 Dorna Sports SL. All rights reserved. All trademarks are the property of their respective owners.,False,/html/body/nav[1]/div/p
ログイン,False,/html/body/nav[2]/div/nav/a[1]
登録,False,/html/body/nav[2]/div/nav/a[2]
ホーム,False,/html/body/nav[2]/div/i/div/ul[1]/li[1]/a
Live,False,/html/body/nav[2]/div/i/div/ul[1]/li[2]/a
ビデオ,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/a/span
ベスト・オブ,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/nav/ul/li[1]/a
Live,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/nav/ul/li[2]/a
2018 年シーズン,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/nav/ul/li[3]/a
スポイラー,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/nav/ul/li[4]/a
ショー,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/nav/ul/li[5]/a
過去のシーズン,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/nav/ul/li[6]/a
オールビデオ,False,/html/body/nav[2]/div/i/div/ul[1]/li[3]/nav/ul/li[7]/a
フォトギャラリー,False,/html/body/nav[2]/div/i/div/ul[1]/li[4]/a/span
ベスト・オブ,False,/html/body/nav[2]/div/i/div/ul[1]/li[4]/nav/ul/li[1]/a
Grand Prix,False,/html/body/nav[2]/div/i/div/ul[1]/li[4]/nav/ul/li[2]/a
ライダー,False,/html/body/nav[2]/div/i/div/ul[1]/li[4]/nav/ul/li[3]/a
チーム,False,/html/body/nav[2]/div/i/div/ul[1]/li[4]/nav/ul/li[4]/a
リザルト,False,/html/body/nav[2]/div/i/div/ul[1]/li[5]/a
カレンダー,False,/html/body/nav[2]/div/i/div/ul[1]/li[6]/a
チーム&ライダー,False,/html/body/nav[2]/div/i/div/ul[1]/li[7]/a
インサイド ,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/a/span
MotoGP VIP Village™,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[1]/li[1]/a
スポンサー,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[1]/li[2]/a
MotoGP Buzz,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[1]/li[3]/a
Red Bull MotoGP™ Rookies Cup,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[1]/li[4]/a
FIM Enel MotoE™ World Cup,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[1]/li[5]/a
Two Wheels For Life,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[1]/li[6]/a
MotoGP™リーグ,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[2]/li[1]/a
ビデオゲーム,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[2]/li[2]/a
eSport,False,/html/body/nav[2]/div/i/div/ul[1]/li[8]/nav/ul[2]/li[3]/a
タイミングパス,False,/html/body/nav[2]/div/i/div/div[2]/a[1]
ビデオパス,False,/html/body/nav[2]/div/i/div/div[2]/a[2]
チケット,False,/html/body/nav[2]/div/i/div/ul[2]/li[1]/a
アプリ,False,/html/body/nav[2]/div/i/div/ul[2]/li[2]/a
オンラインショップ,False,/html/body/nav[2]/div/i/div/ul[2]/li[3]/a
よくある質問,False,/html/body/nav[2]/div/i/div/ul[2]/li[4]/a
Contact,False,/html/body/nav[2]/div/i/div/ul[2]/li[5]/a
ビデオパス,False,/html/body/nav[2]/div/i/div/ul[2]/li[6]/a
 日本語 ,False,/html/body/div[8]/nav/span/a
English,False,/html/body/div[8]/nav/span/ul/li[1]/a
Español,False,/html/body/div[8]/nav/span/ul/li[2]/a
Italiano,False,/html/body/div[8]/nav/span/ul/li[3]/a
Français,False,/html/body/div[8]/nav/span/ul/li[4]/a
日本語,False,/html/body/div[8]/nav/span/ul/li[5]/a
Deutsch,False,/html/body/div[8]/nav/span/ul/li[6]/a
ログイン,False,/html/body/div[8]/nav/a[1]/span
登録,False,/html/body/div[8]/nav/a[2]
Share options:,False,/html/body/div[11]/div/div
Twitter,False,/html/body/div[11]/div/a[1]
Google+,False,/html/body/div[11]/div/a[2]

特徴量設計用のコード

それぞれの特徴量設計を行います。たまにゼロ除算エラーが出て直していませんが、手持ちのデータではゼロ除算が発生する回数が少なかったのでとりあえず実行しました。

import re
import pandas as pd
import MeCab
import string
import numpy as np
from nltk.corpus import stopwords


def prepare_df(filepath):
    df = pd.read_csv(filepath)
    reg = re.compile(r"\[[0-9]+\]")
    df['xpath_fixed'] = list(map(lambda x: re.sub(reg,"",x),df['xpath'].tolist()))
    df = df[list(map(lambda x: "script" not in x, df['xpath_fixed']))]
    return df


def b1(df, column="#text"):
    return df[[column]].duplicated()


def b3(df, column="xpath_fixed"):
    return [sum(df[column] == x)/df.shape[0] for x in df[column]]


def b5(df, tagger, column="#text"):
    return [np.log(len(tagger.parse(x).split())) for x in df[column]]


def b6(df, tagger, column="#text"):
    return [np.mean([len(y) for y in tagger.parse(x).split()]) for x in df[column]]


def b7(df, tagger, column="#text"):
    jpstps = stopwords.words('japanese')
    enstps = stopwords.words('english')
    
    stopword_ratio = []
    for x in df[column]:
        tmp = np.isin(tagger.parse(x).split(), jpstps+enstps)
        if len(tmp) == 0:
            stopword_ratio.append(0)
        else:
            stopword_ratio.append(float(sum(tmp))/float(len(tmp)))
        
    return stopword_ratio


def b9(df, column="#text"):
    return [np.log(len(list(x))) for x in df[column]]


def b10_b24(df, column="#text"):
    plist = list(".,?!。!?、")
    punkt_ratio = []
    n_punkt = []
    for x in df[column]:
        tmp = np.isin(list(x), plist)
        t = float(sum(tmp))
        n_punkt.append(t)
        if t == 0:
            punkt_ratio.append(0.0)
        else:   
            punkt_ratio.append(np.log(
                float(t) / float(len(tmp))
            ))

    return punkt_ratio, n_punkt


def b11(df, column="#text"):
    nums = list("0123456789")
    num_ratio = []
    for x in df["#text"]:
        tmp = list(x)
        num_ratio.append(float(sum(np.isin(tmp,nums)))/float(len(tmp)))
    return num_ratio


def b14(df, column="#text"):
    endmark = list(".,?!。!?、")
    return [np.any([x.strip().endswith(y) for y in endmark]) for x in df[column]]


def b26(df, column="#text"):
    return [float(n)/float(df.shape[0]) for n, x in enumerate(df[column])]


def b29_b30(df, col1="xpath", col2="#text", col3="xpath_fixed", parent_level=1):
    body_percentage = []
    link_density = []
    for x, text in zip(df[col1], df[col2]):
        if parent_level==None:
            parent = "/html/body"
        else:
            parent = '/'.join(x.split("/")[:-parent_level])
        target = df[list(map(lambda y: parent in y, df[col1]))]
        body_percentage.append(float(len(list(text)))/float(sum(len(list(y)) for y in target[col2])))
        atags = [y.endswith("a") for y in target[col3]]
        link_density.append(float(sum(atags))/float(len(atags)))
    return body_percentage, link_density


def b31(df, tagger, col1="xpath", col2="#text", col3="xpath_fixed", parent_level=1):
    b6_p = []
    b7_p = []
    b9_p = []
    b10_p = []
    b11_p = []
    b14_p = []
    pf = {}
    plist = list(".,?!。!?、")
    nums = list("0123456789")
    endmark = list(".,?!。!?、")
    jpstps = stopwords.words('japanese')
    enstps = stopwords.words('english')

    for x, text in zip(df[col1], df[col2]):
        if parent_level==None:
            parent = "/html/body"
        else:
            parent = '/'.join(x.split("/")[:-parent_level])
        if parent in pf:
            b6_p.append(pf[parent]['b6'])
            b7_p.append(pf[parent]['b7'])
            b9_p.append(pf[parent]['b9'])
            b10_p.append(pf[parent]['b10'])
            b11_p.append(pf[parent]['b11'])
            b14_p.append(pf[parent]['b14'])
            continue

        target = df[list(map(lambda y: parent in y, df[col1]))]
        b6 = np.mean(np.concatenate([[len(y) for y in tagger.parse(x).split()] for x in target[col2]]))
        b7 = []
        for y in target[col2]:
            tmp = np.isin(tagger.parse(y).split(), jpstps+enstps)
            b7.append(tmp)
        b7 = np.concatenate(b7)
        if len(b7) == 0:
            b7 = 0
        else:
            b7 = float(sum(b7))/float(len(b7))
        b9 = np.log(len(list(' '.join([y for y in target[col2]]))))

        punkt_ratio = []
        tmp = []
        for y in target[col2]:
            tmp.append(np.isin(list(y), plist))
        t = sum(np.concatenate(tmp))
        if t == 0:
            punkt_ratio = 0.0
        else:   
            punkt_ratio = np.log(
                float(t) / float(len(tmp))
            )
        b10 = punkt_ratio
        
        num_ratio = []
        tmp = []
        for y in target[col2]:
            tmp += list(y)
        num_ratio = float(sum(np.isin(tmp,nums)))/float(len(tmp))
        b11 = num_ratio
        b14 = np.any([target[col2].tolist()[-1].endswith(y) for y in endmark])
    
        pf[parent] = {'b6':b6, 'b7':b7, 'b9':b9, 'b10': b10, 'b11':b11, 'b14':b14}
        b6_p.append(pf[parent]['b6'])
        b7_p.append(pf[parent]['b7'])
        b9_p.append(pf[parent]['b9'])
        b10_p.append(pf[parent]['b10'])
        b11_p.append(pf[parent]['b11'])
        b14_p.append(pf[parent]['b14'])
                     
    return b6_p, b7_p, b9_p, b10_p, b11_p, b14_p


def b49(df, column="xpath_fixed", parent_level=1):
    tags = "td div p tr table body ul span li blockquote b small a ol ul i form dl strong pre".split()
    ptag_features = []

    for x in df[column]:
        tmp = np.zeros(len(tags))
        t = x.split("/")[-(parent_level+1)]
        try:
            ind = tags.index(t)
            tmp[ind] = 1.0
        except:
            pass
        ptag_features.append(tmp)
    return pd.DataFrame(ptag_features, columns=list(map(str, list(range(49, 49+len(tags))))))


def b110(df, column="xpath_fixed"):
    tags = "a p td b li span i tr div strong em h3 h2 table h4 small sup h1 blockquote".split()
    tag_features = []

    for x in df[column]:
        tmp = np.zeros(len(tags))
        t = x.split("/")[-1]
        try:
            ind = tags.index(t)
            tmp[ind] = 1.0
        except:
            pass
        tag_features.append(tmp)
    return pd.DataFrame(tag_features, columns=list(map(str, list(range(110, 110+len(tags))))))


def build(filepath):
    print(filepath, end=" ", flush=True)
    tagger = MeCab.Tagger("-Owakati")
    try:
        df = prepare_df(filepath)
        out = pd.concat([b49(df), b110(df)], axis=1)
        out["b1"] = b1(df)
        out["b3"] = b3(df)
        out["b5"] = b5(df,tagger)
        out["b6"] = b6(df, tagger)
        out["b7"] = b7(df, tagger)
        out["b9"] = b9(df)
        out["b10"], out["b24"] = b10_b24(df)
        out["b11"] = b11(df)
        out["b14"] = b14(df)
        out["b26"] = b26(df)
        out["b29"], out["b30"] = b29_b30(df)
        out["b31"], out["b32"], out["b34"], out["b35"], out["b36"], out["b39"] = b31(df, tagger)
        out["b70"], out["b71"] = b29_b30(df, parent_level=2)
        out["b72"], out["b73"], out["b75"], out["b76"], out["b77"], out["b80"] = b31(df, tagger, parent_level=2)
        out["b90"], out["b91"] = b29_b30(df, parent_level=None)
        out["b92"], out["b93"], out["b95"], out["b96"], out["b97"], out["b100"] = b31(df, tagger, parent_level=None)
        out["label"] = df["label"]
        return True, out
    except Exception as e:
        print(e)
        return False, e
import os
from multiprocessing import Pool

pool = Pool(8)
path = "candidates_text/"
out = None
filepathes = [os.path.join(path,filename) for filename in os.listdir(path)]
out = pool.map(build, filepathes)
data = pd.concat([o[1] for o in out if o[0] == True])

特徴量設計済みデータはこちら -> https://github.com/sugiyamath/information_extraction_experiments/blob/master/model4/example_data/data.7z

訓練・評価

Jupyterで訓練・評価を行います。

import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report

data = data.replace([np.inf, -np.inf], np.nan)
data = data.fillna(0)

y = data['label'] == True
X = data.iloc[:,:-1]

X_train, X_test, y_train, y_test = train_test_split(X, y, shuffle=True, random_state=42)

clf = RandomForestClassifier().fit(X_train, y_train)
y_preds = clf.predict(X_test)
print(classification_report(y_test, y_preds))
             precision    recall  f1-score   support

      False       0.98      1.00      0.99     93130
       True       0.97      0.88      0.93     19292

avg / total       0.98      0.98      0.98    112422

参考

[1] https://arxiv.org/abs/1801.02607

補足: Webコンテンツ抽出モデルのために作成したモジュール

Webコンテンツ抽出のvision-based CNNという記事では、前処理段階で省略した部分がある・モジュールが若干汚い・url入力からコンテンツ抽出までのパイプラインが明確でない、という3つの点で読みにくいので、今回はその3つの点を補足します。

1.事前の前処理モジュール

dataprocessor.pyから、事前の前処理のための部分だけを切り出しました。

preprocess.py

#!/usr/bin/env python3
import os
from bs4 import BeautifulSoup
import pickle
from multiprocessing import Pool

def difficult_check(soup):
    for o in soup.find_all("object"):
        if int(o.find('difficult').text) == 1:
            return True
    return False


def extract_positions(soup, min_width=500, min_height=500):
    targets = {'ps':[], 'labels':[]}
    for o in soup.find_all("object"):
        xmax = int(o.find('xmax').text)
        xmin = int(o.find('xmin').text)
        ymax = int(o.find('ymax').text)
        ymin = int(o.find('ymin').text)
        width = xmax - xmin
        height = ymax - ymin
        if width < min_width or height < min_height:
            continue
        else:
            targets['ps'].append([xmin, ymin, xmax, ymax])
            targets['labels'].append(o.find('name').text == "content")
    return targets


def data_preparation(keyname, rootpath):
    path = os.path.join(rootpath,keyname)
    files = [f.split(".")[0] for f in os.listdir(path) if f.endswith("xml")]
    return path, files


def get_and_check_targets(soup):
    if difficult_check(soup):
        return False, None
    targets = extract_positions(soup)
    if sum(targets['labels']) != 1:
        return False, None
    return (True, targets)


def save_batch(d, keyname, path, outpath):
    i = d[0]
    file = d[1]
    print(i, end=" ", flush=True)
    pic = "{}.jpeg".format(file)
    xml = "{}.xml".format(file)
    outfile = "{}_{}".format(keyname, file)

    with open(os.path.join(path, xml)) as f:
        soup = BeautifulSoup(f.read(), "xml")

    check_flag, targets = get_and_check_targets(soup)

    if check_flag is False:
        return (i, False)
 
    with open(os.path.join(outpath, outfile+".pkl"), "wb") as f:
        pickle.dump(targets, f)
        return (i, True)
    

if __name__ == "__main__":
    from functools import partial
    
    path_length = 3
    datapath = "data"
    outpath = "batch"
    
    for i in range(path_length):
        idx = str(i+1)
        path, files = data_preparation(idx, datapath)
        fixed_data = list(enumerate(files))

        func = partial(save_batch, keyname=idx, path=path, outpath=outpath)
        with Pool(10) as pool:
            print(pool.map(func, fixed_data))

この前処理モジュールは、Pascal VOC形式のデータを読み込み、候補となる要素と正解ラベルを切り出してpklで保存するものです。

2. 画像読み込みの高速化、ジェネレータの簡素化

ジェネレータを使ってKerasを訓練しますが、画像読み込みを高速化したものが以下です。

bindetector.py

import numpy as np
import cv2


def load_image(image_file):
    return cv2.imread(image_file)


def processing(im, ps, size=(905, 905)):
    out = []
    for p in ps:
        alpha = np.zeros([im.shape[0], im.shape[1]])
        alpha[p[1]:p[3], p[0]:p[2]] = 255.
        target = np.dstack((im, alpha))
        target = cv2.resize(target, size)/255.0
        out.append(target)
    return out

skimageよりも、cv2を使ったほうが早いです。

次に、dataprocessor.pyを以下のように簡素化します。

dataprocessor.py

from bs4 import BeautifulSoup
import bindetector
import numpy as np
import pickle
import os
import random
from sklearn.utils import shuffle


def transform_img(index, ps, size=(905, 905), data_path="data", img_format="jpeg", batch_path="batch"):
    X = []
    keyname, fileid = index.split("_")[:2]
    pic = "{}.{}".format(fileid.split(".")[0], img_format)
    path = os.path.join(data_path, keyname)
    im = bindetector.load_image(os.path.join(path, pic))
    return bindetector.processing(im, ps, size)


def sampling(targets, sample_size=4, batch_path="batch"):
    assert len(targets['ps']) == len(targets['labels'])
    data = [p for p, label in zip(targets['ps'], targets['labels']) if label is True]
    assert len(data) == 1
    tmp_targets = shuffle(list(zip(targets['ps'], targets['labels'])))
    data += [p for p, label in tmp_targets if label is False][:sample_size]
    return data


def get_indices(batch_path="batch"):
    return [f for f in os.listdir(batch_path) if f.endswith("pkl")]
    

def generate_data(indices, data_path="data", batch_path="batch", sample_size=4, batch_size=5):
    while(True):
        X = []
        labels = []
        for index in shuffle(indices)[:batch_size]:
            with open(os.path.join(batch_path, index), "rb") as f:
                targets = pickle.load(f)
            data = sampling(targets, sample_size, batch_path=batch_path)
            label = [True] + [False for _ in data[1:]]
            labels += label
            assert len(data) == len(label)
            X += transform_img(index, data, data_path=data_path, batch_path=batch_path)
        yield np.array(X), np.array(labels)

以前は、画像に候補矩形のアルファ値を加えたものをnpyで保存したものにも対応していましたが、npyファイルのロードに時間がかかるため、結局、ジェネレータで逐一画像を読み込んだほうが楽なので、npyのコードを除外しました。また、preprocess.pyへコードを分離ししたため、不要な依存関係を排除しました。

3. 実行のパイプラインを定義するモジュール

どのようにコンテンツ抽出を実行するのかイメージがわかないと思うので、訓練したモデルを使ってURL入力->コンテンツ抽出の流れをモジュール化しました。

extractor.py

import os
import re
import copy
import tempfile
import time
import base64
import json
import numpy as np
import math
from subprocess import call
from keras.models import model_from_yaml
import sys
import bindetector as bd
import tensorflow as tf


def save_png(tmp_dir):
    with open(os.path.join(tmp_dir, "tmp.json")) as f:
        snapshot = json.load(f)
    target = snapshot['SnapshotResponse']['Page']['CaptureScreenshot']
    pngtxt = target['content']
    with open(os.path.join(tmp_dir,"screenshot.{}".format(target["format"])), "wb") as f:
        f.write(base64.b64decode(pngtxt))
        

def download_page(url, opts=" --full --delay=1 --timeout=5 "):
    tmp_dir = tempfile.mkdtemp()
    tmp_file = os.path.join(tmp_dir, "tmp.json")
    out_file = os.path.join(tmp_dir, "dom.json")
    commands = [
        ["cdp-utils", "dom-snapshot"] + opts.strip().split() + [url, tmp_file],
        ["cdp-utils", "dom-tree", tmp_file, out_file]
    ]
    for command in commands:
        try:
            call(command, shell=False)
        except Exception as e:
            print(e, "cdp-utils Error.")
            return False, None
    try:
        save_png(tmp_dir)
    except Exception as e:
        print(e)
        return False, None
    return True, tmp_dir


def search_positions(tmp_dir, min_width=500, min_height=500):
    with open(os.path.join(tmp_dir, "dom.json")) as f:
        parent = json.load(f)
    ps = []
    childs = []
    stack = [parent['childNodes']]
    while(True):
        if len(stack) == 0:
            break
        children = stack.pop(0)
        for child in children:
            if "position" in child:
                p = child["position"]
                if p[2]-p[0] > min_width and p[3]-p[1] > min_height:
                    ps.append(child['position'])
                    childs.append(child)
            if "childNodes" in child:
                stack.append(child['childNodes'])
        assert len(ps) == len(childs)
    return ps, childs


def load_model(model_path, weight_path):
    with open(model_path) as f:
        model = model_from_yaml(f.read())
    model.load_weights(weight_path)
    graph = tf.get_default_graph()
    return model, graph


def generator_from_array(X_test):
    while 1:
        for i in range(1000):
            yield X_test[i:i+1]


def extract(url, model, graph, opts=" --full --delay=3 --timeout=60 ", img_format="jpeg", min_width=500, min_height=500, pred_size=10):
    from functools import partial
    check_flag, tmp_dir = download_page(url, opts)
    if check_flag:
        ps, childs = search_positions(tmp_dir, min_width, min_height)
    im = bd.load_image(os.path.join(tmp_dir, "screenshot.{}".format(img_format)))
    candidates = np.array(bd.processing(im, ps))
    with graph.as_default():
        preds = model.predict_generator(generator_from_array(candidates), candidates.shape[0])
    trueone = np.argmax([x[0] for x in preds])
    return childs[trueone]


def get_content_from_block(child):
    import copy
    child_copy = copy.deepcopy(child)
    past_nodes = []
    current_node = child_copy["childNodes"]
    values = []
    imgs = []

    while(True):
        if len(current_node) == 0:
            if len(past_nodes) == 0:
                return values, imgs
            else:
                current_node = past_nodes.pop(-1)
                if len(current_node) == 0:
                    continue
        target = current_node.pop(0)
        if 'attrs' in target:
            hasjs = False
            for attr in target['attrs']:
                if 'value' in attr and 'javascript' in attr['value']:
                    hasjs = True
                    break
            if hasjs:
                continue
        if "childNodes" in target:
            past_nodes.append(current_node)
            current_node = target['childNodes']
        if "name" in target:
            if target['name'] == "#text":
                values.append(target)
            elif target['name'] == "IMG":
                imgs.append(target)


if __name__ == "__main__":
    start = time.time()
    model, graph = load_model(
        "/root/work/dataprocessor/model3/model3.yml",
        "/root/work/dataprocessor/model3/old/weights.37-0.33.h5"
    )
    opts = " --full --width=1280 --delay=0 --timeout=90 --format=jpeg --quality=40 "
    
    child = extract("http://jbpress.ismedia.jp/articles/-/54187", model, graph, opts=opts)
    values, imgs = get_content_from_block(child)
    regex = re.compile(r"^[ \n]+$")
    print(''.join([value['value'] for value in values if re.match(regex, value['value']) is None]))
    print("executed time: {}".format(time.time() - start))

抽出されたコンテンツの出力:

「夢の技術」量子コンピューター、実用化まであと一歩!大手企業が開発を急ぐ背景には多分野での応用を見据えた戦略が松ヶ枝優佳/2018.9.26 9月19日、理化学研究所がNTTやNEC、東芝などと共同で次世代の高速計算機である「量子コンピューター」の開発に乗り出すと報じられた。研究は文部科学省の事業として実施され、年間約8億円規模のプロジェクトとなる予定だ。 IBMやGoogleが積極的に投資を行ない、開発を進めていることでも知られる量子コンピューター。スーパーコンピューターにも答えが出せないような問題も一瞬で解いてしまうとされ、かつては実現性に乏しい「夢の技術」とされていたが、今や実用化直前と言われるまでに研究が進み、開発競争も激化する一方だ。 様々な企業や研究機関が一丸となって実用化を急ぐ量子コンピューターとは一体どんな技術なのだろうか。量子コンピューターとは 量子コンピューターとは、簡単に言えば「スーパーコンピューターを大幅に上回る処理速度を持つ、次世代のコンピューター」のことだ。量子力学という、従来のコンピューターとは全く違う原理を採用することで、圧倒的な情報処理能力を持つ。 私たちが知る通常のコンピューターは「ビット」という単位を用いて演算を行なうが、量子コンピューターは「量子ビット」という量子力学上の単位を使う。情報を扱う際、ビットでは「0と1のどちらの状態にあるのか」を基礎とするが、量子ビットでは量子力学特有の「重ね合わせ」という概念を用いる。これにより、複数の計算を同時に進めることができるのだ。 「0であり、1でもある」という量子の性質を活用することで、従来のスーパーコンピューターでは何年もかかる計算を一瞬で終わらせることができる。 スーパーコンピューターをはじめとする従来型コンピューターは、技術革新の限界が近付いている。1年半でコンピューターの性能が2倍になっていく「ムーアの法則」も近く通用しなくなると言われる今、根本から異なる原理、異なるハードウェアで動く量子コンピューターに期待が集まっているのだ。 さらに、量子コンピューターは従来型コンピューターに比べて圧倒的に低コストで運用できると言われており、エネルギー問題の観点からも注目されている。事実、後述の「D-WaveSystems」が開発した量子コンピューターは、現在のスーパーコンピューターの100分の1の電力で稼働させられるという。並べると良いことづくめのようにも思えるが、現状は従来型のように何でもこなせるわけではない。 量子コンピューターは、大別すると「量子ゲート」モデルと呼ばれる汎用タイプと「量子イジング」モデルと呼ばれるタイプの2種類がある。現在のスーパーコンピューターの上位互換と言える「万能選手」は量子ゲート型であり、古くから量子コンピューターとして研究されてきたのもこちらだ。実用化が切望されているが、技術的な問題をクリアして実用化されるにはもう少しかかるだろう。 ちなみに量子コンピューターと言えば、クレジットカード等の情報保護等に使われている「暗号化技術の解除」を簡単にできるもの、というイメージを持っている読者もいるかもしれないが、それができるとされるのも量子ゲート型だ。 一方、量子イジングモデルの中でも数種類あるうち「量子アニーリング型」と呼ばれる量子コンピューターは、用途は絞られるものの2011年にカナダのベンチャー企業、D-WaveSystemsによって既に商用化されている。こちらについて詳しく見てみよう。123»

なぞのコマンド"cdp-utils"がありますが、こちらは都合上、公開できません。このツールは指定したurlのスクリーンショットとdomツリーを出力するツールです。

パイプラインの流れは、if __name__=="__main__": 内に書かれています。

  1. モデルのロード。
  2. cdp-utilsのオプション指定。
  3. コンテンツだけを含んでいるdom要素を予測。
  4. dom要素から#textとIMGだけを取得。
  5. textを結合して出力。

ちなみに、dom要素から#textを取得する部分では、domツリーから正しい順番で取得する必要がありますが、単純な非再帰深さ優先探索で対応しており、この部分は非常に高速に処理されます。

処理の中で時間が最もかかるのはextract関数内部ですが、平均的なニュース記事(例えば毎日新聞)であれば、1つのGPUを使うだけで6〜7秒程度で予測ができます。この速度は、diffbotよりも高速です。

次の課題

vision-basedモデルはなんとなく実行できたので、次の課題は「dom-basedモデル」を作成することです。アノテーション済みのPascal VOCデータと取得済みのdomツリーデータを利用して、dom-basedモデルへつなげようと考えています。

Webコンテンツ抽出のCNNモデル

Webコンテンツ抽出のvision-based手法とは、Webページのスクリーンショットを解析し、コンテンツ抽出の特徴量として使う手法です。TextMapsというオープンソースの手法もありますが、今回はモデル自体を自作します。

事前準備

Webページのスクリーンショットとdomツリーを取得する

この部分は本質ではないので詳細は省きますが、以下の仕様を満たすCLIツールを作成してください。

urlを指定すると、そのurlのスクリーンショットスクリーンショット画像内のdom要素の位置を保存したdomツリーを出力する。

訓練データの収集とアノテーション

前述のツールを使ってWeb記事を収集します。取得されたスクリーンショットとdomツリーは、pascal voc形式に変換し、labelImgで読み込むことでアノテーションします。

以下を参考にしてください。 https://qiita.com/sugiyamath/items/968463b26c0b9b0d0c40

アノテーション時のルールは以下を採用します。 1. アノテーションの候補要素は、dom.json内のpositionをもつすべての要素。 2. ニュースやブログの記事を対象とする。 3. 記事内のコンテンツ(本文)全体を含み、かつ余分な要素をできるだけ含まない候補位置を1つだけ選び、ラベル付けする。

ルール3により、アノテーション作業が簡素化され、効率的に作業ができます。

データの切り出し

アノテーション済みデータから、以下のルールでデータを切り出します。

  1. 幅と高さが500以下の要素を除外。
  2. アノテーションで"difficult"ラベルがつけられていたら画像自体を除外。
  3. ラベル付けした1つの要素以外はFalse, ラベル付けした要素はTrueとしてラベルを定義。
  4. dom要素のポジションとラベルを辞書として保存。

以下は辞書の形式です。

targets = {
    "ps":[ターゲット画像の切り出されたポジション一覧], 
    "labels": [ターゲット画像の切り出されたラベル一覧]
}

assert len(targets['ps']) == len(targets['labels'])

この辞書をpkl形式で保存しておきます。

2つのモジュールを作成

モジュール1: bindetector.py

import numpy as np
from skimage.transform import resize
from skimage import io

def load_image(image_file, p, size=(905,905)):
    im = io.imread(image_file)
    alpha = np.zeros([im.shape[0], im.shape[1]])
    alpha[p[1]:p[3], p[0]:p[2]] = 255.
    im = np.dstack((im, alpha))
    im = resize(im, size)/255.0
    return im

モジュール2: dataprocessor.py

from bs4 import BeautifulSoup
import bindetector
import numpy as np
import pickle
import os
import random
from sklearn.utils import shuffle


def difficult_check(soup):
    for o in soup.find_all("object"):
        if int(o.find('difficult').text) == 1:
            return True
    return False


def extract_positions(soup, min_width=500, min_height=500):
    targets = {'ps':[], 'labels':[]}
    for o in soup.find_all("object"):
        xmax = int(o.find('xmax').text)
        xmin = int(o.find('xmin').text)
        ymax = int(o.find('ymax').text)
        ymin = int(o.find('ymin').text)
        width = xmax - xmin
        height = ymax - ymin
        if width < min_width or height < min_height:
            continue
        else:
            targets['ps'].append([xmin, ymin, xmax, ymax])
            targets['labels'].append(o.find('name').text == "content")
    return targets


def data_preparation(keyname, rootpath):
    path = os.path.join(rootpath,keyname)
    files = [f.split(".")[0] for f in os.listdir(path) if f.endswith("xml")]
    return path, files


def get_and_check_targets(soup):
    if difficult_check(soup):
        return False, None
    targets = extract_positions(soup)
    if sum(targets['labels']) != 1:
        return False, None
    return (True, targets)


def transform_img(index, ps, size=(905, 905), data_path="data", img_format="jpeg", batch_path="batch"):
    X = []
    keyname, fileid = index.split("_")[:2]
    pic = "{}.{}".format(fileid.split(".")[0], img_format)
    path = os.path.join(data_path, keyname)
    for p in ps:
        X.append(bindetector.load_image(os.path.join(path, pic), p))
    return X


def sampling(targets, sample_size=4, index=False, batch_path="batch"):
    assert len(targets['ps']) == len(targets['labels'])
    if index is not False:
        npy_file = os.path.join(batch_path, index.split(".")[0]+".npy")
        if os.path.isfile(npy_file):
            imgs = np.load(npy_file, mmap_mode='r')
            data = [img for img, label in zip(imgs, targets['labels']) if label is True]
            assert len(data) == 1
            tmp_targets = shuffle(list(zip(imgs, targets['labels'])))
            data += [img for img, label in tmp_targets if label is False][:sample_size]
            return data
    data = [p for p, label in zip(targets['ps'], targets['labels']) if label is True]
    assert len(data) == 1
    tmp_targets = shuffle(list(zip(targets['ps'], targets['labels'])))
    data += [p for p, label in tmp_targets if label is False][:sample_size]
    return data


def get_indices(batch_path="batch"):
    return [f for f in os.listdir(batch_path) if f.endswith("pkl")]
    

def generate_data(indices, data_path="data", batch_path="batch", batch_size=5, npy_exists=False):
    while(True):
        X = []
        labels = []
        for index in shuffle(indices)[:batch_size]:
            with open(os.path.join(batch_path, index), "rb") as f:
                targets = pickle.load(f)
            if npy_exists:
                data = sampling(targets, index=index, batch_path=batch_path)
            else:
                data = sampling(targets, index=False, batch_path=batch_path)
            label = [True] + [False for _ in data[1:]]
            labels += label
            assert len(data) == len(label)
            if npy_exists:
                X += data
            else:
                X += transform_img(index, data, data_path=data_path, batch_path=batch_path)
        yield np.array(X), np.array(labels)

CNNモデルの概要

Untitled drawing (2).jpg

入力: WebページのスクリーンショットのRGBに対し、「候補dom要素の位置を255、それ以外を0としたalpha値」を加え、全体を255で割ってリサイズしたもの。

出力: 各dom要素に対し、「その要素がコンテンツを含む確率」を出力。

モデル: Sequential CNN

jupyter notebookで実行

最初に、検証データを切り出します。

import dataprocessor as dp
indices = dp.get_indices()

for data in dp.generate_data(indices[0:50], batch_size=50, npy_exists=False):
    eval_data = data
    break
    
for data in dp.generate_data(indices[50:150], batch_size=100, npy_exists=False):
    eval_data2 = data
    break

fit_generatorに渡すジェネレータをdataprocessorから定義します。

from functools import partial
generator_batch = partial(dp.generate_data, indices=indices[150:], batch_size=1)

モデルを定義します。

from keras.models import Sequential
from keras.layers import Activation, Dropout, Flatten, Dense, SeparableConv2D, MaxPooling2D
from keras.callbacks import EarlyStopping, TensorBoard, ModelCheckpoint
from sklearn.metrics import accuracy_score, f1_score


model = Sequential()
model.add(SeparableConv2D(32, (3, 3), input_shape=(905, 905, 4)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(SeparableConv2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(SeparableConv2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Flatten())
model.add(Dense(64))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(1))
model.add(Activation('sigmoid'))

model.compile(loss='binary_crossentropy',
              optimizer='rmsprop',
              metrics=['accuracy'])

mcp_save = ModelCheckpoint('.mdl_wts.h5', save_best_only=True, monitor='val_loss', mode='min')

訓練します。

model.fit_generator(
    generator_batch(), 
    steps_per_epoch=100,
    epochs=50,
    validation_data=eval_data,
    callbacks=[mcp_save]
)

テストデータで精度を見ます。(テストデータ件数は500件)

preds = []
for i in range(50):
    preds += model.predict(eval_data2[0][i*10:i*10+10]).tolist()

results = []
prev = 0
for i, (p, e) in enumerate(zip(preds, eval_data2[1])):
    if (e == True and i!=0) or i == 499:
        if i==499:
            i=500
        results.append([x[0] for x in preds[prev:i]])
        prev = i

tmp_pred = [np.argmax(result) for result in results]

pred_labels1 = []
for x, r in zip(tmp_pred, results):
    for i in range(len(r)):
        if i == x:
            pred_labels1.append(True)
        else:
            pred_labels1.append(False)

assert len(pred_labels1) == len(eval_data2[1])
from sklearn.metrics import classification_report
from sklearn.metrics import roc_auc_score
print(roc_auc_score(eval_data2[1], pred_labels1))
print(classification_report(eval_data2[1], pred_labels1))

精度の出力:

0.8874999999999998

             precision    recall  f1-score   support

      False       0.95      0.95      0.95       400
       True       0.82      0.82      0.82       100

avg / total       0.93      0.93      0.93       500

補足

予測の際には、「候補要素の予測値の中で最大のものをTrue, ほかをFalseにする」という処理を行っています。

TextMapsのアノテーション環境をlabelImgで構築

TextMapsは、ディープラーニングを用いた視覚ベースのウェブコンテンツ抽出ツールです。このツールのためのアノテーション構築環境をします。

labelImg

labelImgは、Pascal VOCフォーマットに対応した画像認識のためのアノテーション環境です。このアノテーション環境が使えるので、問題は以下のように定義できます。

"Wepページのスクリーンショットと、Webページ内の候補要素の矩形位置をPascal VOCで定義し、候補要素に対して要素の種類をアノテーションする。"

labelImgの使い方は以下でも見てください。 https://qiita.com/wakaba130/items/e86109b3cbd1b0dde902

TextMapsのダウンローダを使う

TextMapsではダウンローダが用意されていますが、それを用いれば、1)Webのスクリーンショット, 2)domツリーの2つを取得できます。(phantomjsが必要です。)

def download_page(url):
    print "Downloading:",url
    temp_dir = tempfile.mkdtemp()
    result = subprocess.check_output(["phantomjs", "download_page.js",url,temp_dir])
    return temp_dir


if __name__ == "__main__":
    try:
        download_dir = download_page(url)
    except subprocess.CalledProcessError:
        print "Download was not succesfull"
        sys.exit(1)

これにより、スクリーンショット(jpeg)とdomツリー(json)のファイルが生成されます。

ほしいのは、各要素のポジションなので、それを以下のような方法で取得します。

with open("tmp/dom.json") as f:
    data = eval(f.read())

def search_content(parent):
    results = []
    stack = [parent['childNodes']]
    while(True):
        children = stack.pop(0)
        for child in children:
            if "name" in child and child["name"] in ["#text", "IMG"]:
                results.append(child['position'])
            if "childNodes" in child:
                stack.append(child['childNodes'])
    return results

positions = search_content(parent)

Pascal VOCへ変換するモジュール

候補の矩形位置からPascal VOCへ変換する以下のようなモジュールを作成します。ただし、python2.7です。

import dicttoxml
import re
import lxml.etree as etree
from os.path import join


def dict_transformer(dict_data):
    regex = re.compile(r'<\?xml version="1.0" encoding="UTF-8" \?><root>(.*)</root>')
    return re.sub(regex, r'\1', dicttoxml.dicttoxml(dict_data, attr_type=False))


def dict_builder(fileid, positions, fileformat="jpeg", default_label="none"):
    objs = [{"name":default_label, "bndbox":{"xmin":x[0], "ymin":x[1], "xmax":x[2], "ymax":x[3]}} for x in positions]
    out = {"annotation":{"filename": str(fileid)+".{}".format(fileformat),"object": objs}}
    return out


def fixer(transformed_xml, remove=["object"], item_rep=("item","object")):
    c = transformed_xml[:]
    for r in remove:
        c = c.replace("<"+r+">", "").replace("</"+r+">","")
    return c.replace(item_rep[0], item_rep[1])


def prettify(xml_str):
    root = etree.fromstring(xml_str)
    return etree.tostring(root, pretty_print=True)


def annotate(fileid, positions, outpath=".", fileformat="jpeg", default_label="none"):
    result = dict_builder(fileid, positions, fileformat, default_label)
    outfile = str(fileid)+".xml"
    with open(join(outpath, outfile), "w") as f:
        funcs = [dict_transformer, fixer, prettify, f.write]
        for func in funcs:
            result = func(result)
    return result

要件は以下です。

  1. id.jpegという形式でスクリーンショットを大量保存。
  2. id.jsonという形式でdom treeファイルをスクリーンショットに対応させて保存。
  3. annotate関数にスクリーンショットidとそのスクリーンショット内の候補ポジション一覧を渡すと、pascal voc形式のxmlファイルができる。
max_fileid = 1000
for i in range(max_fileid+1):
    with open("{}.json".format(i)) as f:
        positions = search_content(json.load(f))
    annotate(i, positions)

すると、スクリーンショットPascal VOCファイルが1対1で対応している状態なので、これらを同じディレクトリに格納します。

mkdir example
mv *.xml example
mv *.jpeg example

あとはlabelImgでOpenDirをクリックし、exampleを開くだけです。

参考

[0] https://github.com/tzutalin/labelImg [1] https://github.com/gogartom/TextMaps

diffbotのようにコンテンツ抽出したい

Webコンテンツ抽出におけるvision-based手法とは、Webコンテンツのスクリーンショットの画像を用いて、ターゲットのコンテンツを自動的に抽出する手法です。ここでは、TextMapsというプロジェクトを見つけたので、その理論の概要と、デモの実行を行います。

モチベーション

Webコンテンツ抽出のvision-based手法を探していた目的は、diffbotという有料のコンテンツ抽出APIに似たことをしたいと思ったからです。diffbotの技術的詳細は公開されていませんが、Quoraでは以下の質問・回答があります:

What is the algorithm used by diffbot for extracting web data? - https://www.quora.com/What-is-the-algorithm-used-by-Diffbot-for-extracting-web-data

Our approach relies on computer-vision techniques (in conjunction with machine learning, NLP and markup inspection) as the primary engine in identifying the proper content to extract from a page. What this means: when analyzing a web document, our system renders the page fully, as it appears in a browser -- including images, CSS, even Ajax-delivered content -- and then analyzes its visual layout.

訳: 我々のアプローチはコンピュータビジョンテクニック(機械学習NLPマークアップを合わせた) をページから適切なコンテンツを抽出する主要なエンジンとして使っています。どういう意味か: ウェブドキュメントを解析するとき、システムはページ全体をブラウザに見える形態でレンダリングします(イメージ, CSS, Ajaxでさえも)。そして、その視覚的なレイアウトを解析します。

これを知り、視覚ベースのウェブコンテンツ抽出技術を探しましたが、有料の論文やらばかりでオープンソースはなかなか見つかりませんでした。TextMapsは、見つけた唯一のオープンソースです。

実は、TextMapsの詳細も、有料の論文でまとめられています。しかし、以下のプレゼン動画を見つけました:

https://youtu.be/hSIKsjrD46s https://youtu.be/hSIKsjrD46s

ここで使われている資料を以下で発見しました。 http://www.mlmu.cz/wp-content/uploads/2016/06/MLMU-Web-Page-Information-Extraction.pdf

TextMapsの理論

TextMapsは3つの入力が必要です。Webページのスクリーンショット、候補コンテンツのテキストマッピング、そして候補要素のポジションです。

テキストマッピングとは、ハッシュ化された候補テキストを圧縮された次元の画像に写像するための行列です。 Screenshot_2018-09-15_13-35-56.png

これは、特徴量に候補要素のポジション情報を効果的に利用するための特徴量設計だと考えることができます。候補要素とは、ルールベースで抽出されたWeb内のテキストのスクリーンショット内の位置情報(ピクセル)です。

これは、分類問題として捉えることができ、入力した候補要素がどのクラスの要素に属するかを意味します。例えば、商品情報をページから取得したい場合、クラスは「商品名」「商品画像」「商品価格」「取得しないコンテンツ」のどれかになります。

つまりネットワークは以下のようになります。 Screenshot_2018-09-15_13-35-07.png

デモを動かす

TextMapsは以下で公開されています。 https://github.com/gogartom/TextMaps

デモを動かすためには6つのステップを取ります。

  1. caffe-textmapsをビルドする。https://github.com/gogartom/caffe-textmaps
  2. phantomjsをダウンロードする。http://phantomjs.org/
  3. PYTHONPATHに、caffe-textmaps/python/caffeを追加する。
  4. PATHにphantonjsのバイナリを格納したパスを追加する。
  5. qtをインストール。
  6. python demo.py --url ターゲットURL を実行する。

実行結果

デモは、商品ページを対象とするので、アマゾンの商品ページを対象に試しました。

python demo.py --url http://a.co/d/8ELQYof

68747470733a2f2f692e696d6775722e636f6d2f3244646d4d4d472e676966.gif

矩形で囲われた部分が予測された部分です。

矩形位置からコンテンツを取得する

矩形位置からコンテンツを取得するためには、TextMapsがどのようにして入力データをダウンロードし、保存しているかを知る必要があります。

TextMapsは、download_page.jsというスクリプトでコンテンツを取得し、それを screenshot.jpegとdom.jsonという2つの出力に出します。

dom.jsonでは、すでに候補要素の位置とコンテンツの情報が格納されているため、矩形位置とdom.json内のポジションが対応しているものを探し、コンテンツを取得すれば良いことになります。

まず、demo.py内のshow関数を書き換え、予測された位置を保存するようにします。

def show(net, position_maps, temp_dir):
    pred_boxes = []
.
.
.
    for cls in range(1,4):
        ind = max_boxes[cls]
        print probs[ind]
    
        pred_box = boxes[ind,:]
        pred_boxes.append(pred_box)
.
.
.
    plt.show()
    with open(join(temp_dir, "pred_boxes.json", "w") as f:
        json.dump([p.tolist() for p in pred_boxes], f)
    return pred_boxes

次に、dom.jsonと予測位置(pred_boxes)を使って、深さ優先探索でコンテンツを探します。

with open("tmp/dom.json") as f:
    data = eval(f.read())

with open("tmp/pred_boxes.json") as f:
    preds = eval(f.read())

def search_content(parent, preds):
    results = []
    stack = [parent['childNodes']]
    while(True):
        if len(stack) == 0 or len(preds) == len(results):
            break
        children = stack.pop(0)
        for child in children:
            if "name" in child and child["name"] in ["#text", "IMG"]:
                for pred in preds:
                    coor_flag = True
                    for p,q in zip(child['position'], pred):
                        if int(p) != int(q):
                            coor_flag = False
                            break
                    if coor_flag:
                        results.append(child)
            if "childNodes" in child:
                stack.append(child['childNodes'])
    return results

results = search_content(parent)
print(results)

出力は以下です。

[{'name': '#text',
  'position': [1198, 331, 1242, 352],
  'type': 3,
  'value': '$9.60'},
 {'attrs': {'alt': '',
   'class': 'a-dynamic-image image-stretch-vertical frontImage',
   'data-a-dynamic-image': '{"https://images-na.ssl-images-amazon.com/images/I/41AWqUSdKTL._SY344_BO1,204,203,200_.jpg":[225,346],"https://images-na.ssl-images-amazon.com/images/I/41AWqUSdKTL._SX322_BO1,204,203,200_.jpg":[324,499]}',
   'id': 'imgBlkFront',
   'onload': "this.onload='';setCSMReq('af');if(typeof addlongPoleTag === 'function'){ addlongPoleTag('af','desktop-image-atf-marker');};setCSMReq('cf');",
   'src': 'https://images-na.ssl-images-amazon.com/images/I/41AWqUSdKTL._SX322_BO1,204,203,200_.jpg',
   'style': 'max-width: 225px; max-height: 346px; position: absolute; top: 0px; left: 0px;'},
  'computed_style': {'background-image': 'none',
   'content': '',
   'display': 'block',
   'opacity': '1',
   'visibility': 'visible',
   'z-index': 'auto'},
  'name': 'IMG',
  'position': [36, 316, 261, 662],
  'type': 1},
 {'name': '#text',
  'position': [328, 288, 765, 313],
  'type': 3,
  'value': 'Crazy Rich Asians (Crazy Rich Asians Trilogy)'}]

参考

[0] https://github.com/gogartom/TextMaps [1] http://www.mlmu.cz/wp-content/uploads/2016/06/MLMU-Web-Page-Information-Extraction.pdf [2] https://youtu.be/hSIKsjrD46s [3] https://www.quora.com/What-is-the-algorithm-used-by-Diffbot-for-extracting-web-data