JavaでJsoupとSeleniumでWebサイトをスクレイピングしてみた
目次
最近IT系のニュースサイトをスクレイピングするプログラムを走らせて、ネットサーフィンする時間を節約するようになりました。 スクレイピングにはJavaのJsoup, Seleniumを使っているのですが、今回はその実装内容について書き残してみます!
JsoupとSeleniumの特徴
理解している範囲で特徴をまとめました。
Jsoup
- 静的ページのスクレイピングが出来る。
- 動的ページのスクレイピングをすると、動的に生成されるDOM要素は取得できない。
- 厳密には他のツールと組み合わせることで出来る模様。私の方では未検証です。
Selenium
https://www.selenium.dev/ja/documentation/
- 静的ページのスクレイピングが出来る。
- 動的ページのスクレイピングも出来る。
- 動的コンテンツのロード時間を考慮して、Seleniumをウェイトさせる処理を挟む必要がある。
- Java以外にも幅広い言語(Python, C#, Ruby, JavaScript, Kotlin)がサポートされている。
動作環境
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)
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)
Element.text()でHTML要素からタグ等の文字列を除いた文字列を取得できます。
https://jsoup.org/apidocs/org/jsoup/nodes/Element.html#text()
記事のリンク先の内容も取得してみたかったので、リンク先へもスクレイピングして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(=コメント)を取得しています。
https://www.seleniumqref.com/api/java/element_infoget/Java_getInnerText.html
スクレイピングが終わったらdriver.quit()でプロセスを終了させます。これを忘れるとメモリリークの元となります。