エンジニアのはしがき

プログラミングの日々の知見を書き連ねているブログです

JavaでJsoupとSeleniumでWebサイトをスクレイピングしてみた

最近IT系のニュースサイトをスクレイピングするプログラムを走らせて、ネットサーフィンする時間を節約するようになりました。 スクレイピングにはJavaのJsoup, Seleniumを使っているのですが、今回はその実装内容について書き残してみます!

JsoupとSeleniumの特徴

理解している範囲で特徴をまとめました。

Jsoup

Overview (jsoup Java HTML Parser 1.14.3 API)

  • 静的ページのスクレイピングが出来る。
  • 動的ページのスクレイピングをすると、動的に生成されるDOM要素は取得できない。
    • 厳密には他のツールと組み合わせることで出来る模様。私の方では未検証です。

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.gradledependenciesに追記します。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 bashJavaのコンテナ内に入ってビルド・実行する等をして動作確認すれば良いかと思います。

なおここでは書いてませんが、取得後のデータは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()でプロセスを終了させます。これを忘れるとメモリリークの元となります。