最近IT系のニュースサイトをスクレイピングするプログラムを走らせて、ネットサーフィンする時間を節約するようになりました。 スクレイピングにはJavaのJsoup, Seleniumを使っているのですが、今回はその実装内容について書き残してみます!
JsoupとSeleniumの特徴
理解している範囲で特徴をまとめました。
Jsoup
Overview (jsoup Java HTML Parser 1.14.3 API)
Selenium
Seleniumブラウザー自動化プロジェクト | Selenium
動作環境
Dockerfile
FROM openjdk:17-buster RUN apt update && \ apt install -y libnss3-dev libgdk-pixbuf2.0-dev libgtk-3-dev libxss-dev
docker-compose.yml
selenium
は4444番ポートで動作するSeleniumサーバです。Javaでスクレイピングするために立ち上げておきます。
java
の環境変数SELENIUM_URL
は、JavaからSeleniumサーバへ接続する為のエンドポイント情報を持たせるために指定をしています。
version: '3' services: selenium: image: selenium/standalone-firefox-debug:3.141.59 ports: - 4444 volumes: - /dev/shm:/dev/shm restart: always java: build: . ports: - 8080 tty: true volumes: - .:/project restart: always working_dir: /project environment: - SELENIUM_URL=http://selenium:4444/wd/hub depends_on: - selenium
build.gradle
JsoupとSeleniumを使うためにbuild.gradle
のdependencies
に追記します。Mavenを使っている場合は適宜読み替えてpom.xml
に追記してください。
... dependencies { implementation group: 'org.jsoup', name: 'jsoup', version: '1.14.3' implementation group: 'org.seleniumhq.selenium', name: 'selenium-java', version: '4.1.3' ... } ...
Jsoupでスクレイピング
Jsoupでは、はてなブックマークのテクノロジーの人気エントリー(https://b.hatena.ne.jp/hotentry/it)をスクレイピングし、記事のタイトルやURL等を抽出してみました。
スクレイピングした結果のリストであるList<News>
を戻り値とするextractNews()
というメソッドがメインの処理となります。
package com.birdseyeapi.birdseyeapi.ScrapingNews; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.io.IOException; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import com.birdseyeapi.birdseyeapi.News; public class ScrapeHatena { private final Logger LOG = LogManager.getLogger(); private final String SOURCE_BY = "hatena"; private final String SOURCE_URL = "https://b.hatena.ne.jp/hotentry/it"; public String getSourceBy() { return SOURCE_BY; } public List<News> extractNews() throws IOException { List<News> newsList = new ArrayList<News>(); // jsoupで解析 Document doc = Jsoup.connect(SOURCE_URL).get(); Elements newsAreaList = doc.select("#container > div.wrapper > div > div.entrylist-main > section > ul > li"); for (Element newsArea : newsAreaList) { Elements newsTitle = newsArea.select("div > div.entrylist-contents-main > h3 > a"); ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC")); String linkHref = newsTitle.attr("href"); String description = null; if (linkHref != null) { Elements metaTags = Jsoup.connect(linkHref).get().getElementsByTag("meta"); for (Element metaTag : metaTags) { String name = metaTag.attr("name"); String content = metaTag.attr("content"); if("description".equals(name)) { description = content; } } } News news = new News(); news.title = newsTitle.text(); news.description = description; news.sourceBy = SOURCE_BY; news.scrapedUrl = SOURCE_URL; news.scrapedDateTime = now; news.articleUrl = linkHref; news.articleImageUrl = null; newsList.add(news); } return newsList; } }
Jsoup.connect(SOURCE_URL).get();
でJsoupで対象サイトへ接続を開始しています。
doc.select()
の引数にCSSセレクタを指定することで対象サイトのHTML要素を取得し、Elements
型のオブジェクトとして一時変数に代入しています。今回、はてなブックマークの各記事のDOM要素を指定しています。
https://jsoup.org/apidocs/org/jsoup/nodes/Element.html#select(java.lang.String):url
Elements
型にはCSSセレクタに一致する複数のHTML要素が格納されていますので、for文を使うことで1つずつElement
型を取り出すことが可能です。
単一の記事のHTML要素が取り出せたら、さらに記事のタイトルやリンク等の情報を、Element.select()
で取り出していきます。
href
などのattributeを参照したい時は、Element.attr("{attribute名}")
を使います。
https://jsoup.org/apidocs/org/jsoup/nodes/Node.html#attr(java.lang.String):url
Element.text()
でHTML要素からタグ等の文字列を除いた文字列を取得できます。
https://jsoup.org/apidocs/org/jsoup/nodes/Element.html#text():url
記事のリンク先の内容も取得してみたかったので、リンク先へもスクレイピングしてmetaタグのdescriptionの値も取得する形で実現してみました。
コードが書けたらdocker-compose exec java bash
でJavaのコンテナ内に入ってビルド・実行する等をして動作確認すれば良いかと思います。
なおここでは書いてませんが、取得後のデータはDBにINSERTして後から使えるようにしてます。
Seleniumでスクレイピング
Seleniumでは動的コンテンツの取得が可能なので、こちらでははてなブックマークのブックマークに対するコメントを取得してみました。ちなみにコメントは動的に生成されるコンテンツなので、Jsoup単体では取得ができません。
List<NewsReaction>
を戻り値とするextractReactions(String url)
を定義し、引数のurl
で指定したWebサイトのブックマークに対するコメントを取得しました。
package com.birdseyeapi.birdseyeapi.ScrapingReaction; import com.birdseyeapi.birdseyeapi.NewsReaction; import java.net.MalformedURLException; import java.net.URL; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.remote.DesiredCapabilities; import org.openqa.selenium.remote.RemoteWebDriver; public class ScrapeReactionsByHatena { private static final Logger LOG = LogManager.getLogger(); public static List<NewsReaction> extractReactions(String url) throws InterruptedException, MalformedURLException { List<NewsReaction> reactions = new ArrayList<>(); ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC")); DesiredCapabilities firefox = DesiredCapabilities.firefox(); WebDriver driver = new RemoteWebDriver(new URL(System.getenv("SELENIUM_URL")), firefox); try { LOG.info("selenium is ready."); url = url.replace("http://", ""); url = url.replace("https://", ""); driver.get("https://b.hatena.ne.jp/entry/s/" + url); LOG.info("selenium is requesting hatena."); Thread.sleep(1000); LOG.info("request completed."); List<WebElement> articles = driver.findElements(By.cssSelector("#container > div > div.entry-contents > div.entry-main > div.entry-comments > div > div.bookmarks-sort-panels.js-bookmarks-sort-panels > div.is-active.bookmarks-sort-panel.js-bookmarks-sort-panel > div > div > div.entry-comment-contents-main > span.entry-comment-text.js-bookmark-comment")); LOG.info("articles.size(): " + articles.size()); for (WebElement article : articles) { String text = article.getText(); if (text == null || text.trim().isEmpty() || text.equals(title)) { continue; } LOG.info("-------------------------"); LOG.info(text); NewsReaction reaction = new NewsReaction(); reaction.author = "hatena user"; reaction.comment = text; reaction.scrapedDateTime = now; reactions.add(reaction); } } finally { driver.quit(); LOG.info("selenium quit."); } return reactions; } }
DesiredCapabilities.firefox()
でスクレイピングに使用するブラウザを明示的に指定しています。
new RemoteWebDriver(new URL(System.getenv("SELENIUM_URL")), firefox)
でdockerで立ち上げるSeleniumサーバのエンドポイントと、ブラウザ情報を渡したRemoteWebDriverインスタンスを生成しています。
driver.get()
でWebサイトへの接続を開始します。本記事執筆時点ではブックマークコメントの画面へのURLはhttps://b.hatena.ne.jp/entry/s/
とプロトコルを除いた記事のURLを連結させることで表示ができるようです。
接続直後にThread.sleep(1000)
を挟んでいるのは、コメントのDOM要素が描写されるまで時間がかかる為です。
あとはJsoupとやることはほぼ同じです。
driver.findElements(By.cssSelector("{CSSセレクタ}"))
でCSSセレクタに一致するHTML要素をList<WebElement>
型で取得できます。WebElement
にはコメントのHTML要素が格納されているはずです。
このList
をfor文で順番に取り出し、WebElement.getText()
でHTML要素のinnerText(=コメント)を取得しています。
getText(Inner)-Java
スクレイピングが終わったらdriver.quit()
でプロセスを終了させます。これを忘れるとメモリリークの元となります。