仮想WebブラウザをStreamlitで実装してみた

目次

この記事では、以下の内容についてご紹介します:

はじめに

東京ガスiネットアジャイル推進ユニットに所属している髙橋です。
当社が参画する電力トレーディング案件にて、Streamlitを用いてデータベースの内容を画面表示する機能を実装しました。 その経験から、私が大学で研究していた仮想Webブラウザを、Streamlit上で簡易的に再現できると考え、今回プロトタイプを作成しました。
本記事では、仮想Webブラウザの説明、実装の工夫点、および今後の展望について紹介します。

仮想Webブラウザとは

仮想Webブラウザとは、利用者の端末からWeb閲覧機能を分離する方法の一つです。
仕組みとしては、別のコンピュータでWebブラウザを起動し、その表示画面のみを利用者の端末に転送します。そして、利用者による操作は仮想Webブラウザに送信され、遠隔で操作することができます。
参考: 仮想Webブラウザ

仮想Webブラウザのメリット

  1. セキュリティ
    • 仮想Webブラウザは、利用者の端末から隔離された環境で動作するため、マルウェアやウイルスの感染リスクを低減します。
    • 仮想Webブラウザとのセッションが終了すると情報やデータが消去されるため、個人情報の漏洩リスクが減少します。
  2. リソースの効率化
    • 仮想Webブラウザを使用することで、ローカルマシンのリソースを節約できます。

仮想Webブラウザのデメリット

  1. パフォーマンス
    • 仮想環境のオーバーヘッドにより、ネイティブのブラウザと比べて動作が遅くなることがあります。
    • インターネットの通信速度に依存するため、ネットワーク遅延が影響することがあります。
  2. 互換性
    • 一部のウェブサイトやアプリケーションが仮想Webブラウザで正しく動作しないことがあります。
    • 特定のブラウザ固有の機能やプラグインが利用できない場合があります。
  3. ユーザビリティ
    • 仮想Webブラウザの操作に慣れるまで時間がかかることがあります。
    • ローカルマシンのブラウザよりもインターフェースが使いにくい場合があります。

実装イメージ図

実装コード

app.py

import io
import os
import time
from datetime import datetime

import psutil
from PIL import Image
from PIL.PngImagePlugin import PngImageFile
from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service
from streamlit_image_coordinates import streamlit_image_coordinates

import const
import streamlit as st


def set_selenium_driver():
    """ドライバ設定
    """
    options = Options()
    options.add_argument("--headless")
    service = Service(const.DRIVER_PATH)
    st.session_state.driver = webdriver.Firefox(service=service, options=options)
    st.session_state.actions = ActionChains(st.session_state.driver)
    print("Firefoxドライバ起動時刻:", datetime.now().strftime(const.TIME_FORMAT))


def remove_screenshot_file():
    """スクリーンショットの画像ファイルを削除する
    """
    if os.path.isfile(const.SCREENSHOT_PNG_PATH):
        os.remove(const.SCREENSHOT_PNG_PATH)
        print("PNG形式の画像ファイル削除時刻:", datetime.now().strftime(const.TIME_FORMAT))
    if os.path.isfile(const.SCREENSHOT_WEBP_PATH):
        os.remove(const.SCREENSHOT_WEBP_PATH)
        print("WebP形式の画像ファイル削除時刻:", datetime.now().strftime(const.TIME_FORMAT))


def close_firefox():
    """Firefoxを閉じる
    """
    for proc in psutil.process_iter(["pid", "name"]):
        if proc.info["name"] == const.PROCESS_NAME:
            proc.terminate()
            try:
                proc.wait(timeout=3)
            except psutil.NoSuchProcess:
                pass
    print("Firefox閉じ時刻:", datetime.now().strftime(const.TIME_FORMAT))


def take_screenshot_url(target_url: str) -> bytes:
    """対象URLのスクリーンショットをとる

    Args:
        target_url (str): 対象URL

    Returns:
        bytes: png形式の画像データ
    """
    st.session_state.driver.get(target_url)
    print("対象URLへのアクセス時刻:", datetime.now().strftime(const.TIME_FORMAT))
    st.session_state.actions.send_keys(Keys.END).perform()
    st.session_state.actions.send_keys(Keys.HOME).perform()
    time.sleep(3)
    screenshot_png = st.session_state.driver.get_full_page_screenshot_as_png()
    print("スクリーンショット撮影時刻:", datetime.now().strftime(const.TIME_FORMAT))
    return screenshot_png


def take_screenshot_click(value: dict):
    """クリックされた場所のスクリーンショットをとる

    Args:
        value (str): 画像に対する座標などのクリック情報
    """
    image_size = {"width": value["width"], "height": value["height"]}
    window_size = st.session_state.driver.get_window_size()
    x = round(value["x"] * window_size["width"] / image_size["width"])
    scroll_height = window_size["height"] * (value["y"] // window_size["height"])
    y = value["y"] - scroll_height
    st.session_state.driver.execute_script(f"window.scrollBy(0, {scroll_height});")
    st.session_state.actions.move_by_offset(x, y).click().perform()
    print("Firefoxドライバでのクリック時刻:", datetime.now().strftime(const.TIME_FORMAT))
    time.sleep(3)
    st.session_state.actions.send_keys(Keys.END).perform()
    st.session_state.actions.send_keys(Keys.HOME).perform()
    time.sleep(3)
    screenshot_png = st.session_state.driver.get_full_page_screenshot_as_png()
    print("スクリーンショット撮影時刻:", datetime.now().strftime(const.TIME_FORMAT))
    with Image.open(io.BytesIO(screenshot_png)) as png_data:
        if st.session_state.screenshot_path == const.SCREENSHOT_PNG_PATH:
            save_png(png_data)
        elif st.session_state.screenshot_path == const.SCREENSHOT_WEBP_PATH:
            save_webp(png_data)


def save_png(png_data: PngImageFile):
    """PNG形式で画像ファイルを保存する

    Args:
        png_data (PngImageFile): スクリーンショットの画像データ
    """
    png_data.save(const.SCREENSHOT_PNG_PATH, format=const.IMAGE_FORMAT_PNG)
    print("PNG形式の画像ファイル保存時刻:", datetime.now().strftime(const.TIME_FORMAT))
    st.session_state.screenshot_path = const.SCREENSHOT_PNG_PATH
    st.rerun()


def save_webp(png_data: PngImageFile):
    """WebP形式で画像ファイルを保存する

    Args:
        png_data (PngImageFile): スクリーンショットの画像データ
    """
    png_data = png_data.resize((png_data.width // const.IMAGE_RESIZE_LEVEL, png_data.height // const.IMAGE_RESIZE_LEVEL))
    png_data.convert("RGB").save(const.SCREENSHOT_WEBP_PATH, format=const.IMAGE_FORMAT_WEBP, quality=const.IMAGE_QUALITY)
    print("WebP形式の画像ファイル保存時刻:", datetime.now().strftime(const.TIME_FORMAT))
    st.session_state.screenshot_path = const.SCREENSHOT_WEBP_PATH
    st.rerun()


@st.dialog("仮想Webブラウザ")
def virtual_web_browser_dialog():
    """ダイアログ
    """
    print("ダイアログ表示時刻:", datetime.now().strftime(const.TIME_FORMAT))
    url = st.text_input(
        label="対象URL",
        value=const.DEFAULT_URL,
        placeholder=const.PLACEHOLDER_URL)
    if st.button("実行"):
        if url:
            screenshot_png = take_screenshot_url(url)
            with Image.open(io.BytesIO(screenshot_png)) as png_data:
                save_png(png_data)
    elif st.button("実行(画像圧縮)"):
        if url:
            screenshot_png = take_screenshot_url(url)
            with Image.open(io.BytesIO(screenshot_png)) as png_data:
                save_webp(png_data)


def app():
    """メイン
    """
    st.set_page_config(**const.SET_PAGE_CONFIG)
    st.markdown(const.ST_STYLE, unsafe_allow_html=True)

    if "screenshot_path" not in st.session_state:
        virtual_web_browser_dialog()
    else:
        value = streamlit_image_coordinates(st.session_state.screenshot_path, use_column_width=True)
        if value:
            print("ユーザの画面クリック時刻:", datetime.now().strftime(const.TIME_FORMAT))
            take_screenshot_click(value)


if __name__ == "__main__":
    if "driver" not in st.session_state:
        print("========================= start =========================")
        remove_screenshot_file()
        close_firefox()
        set_selenium_driver()
    app()

const.py

DRIVER_PATH = r"driver\geckodriver.exe"
PLACEHOLDER_URL = "https://example.com/"
DEFAULT_URL = "https://www.google.co.jp/"
SCREENSHOT_PNG_PATH = r"src\screenshot\screenshot.png"
SCREENSHOT_WEBP_PATH = r"src\screenshot\screenshot.webp"
PROCESS_NAME = "firefox.exe"
IMAGE_FORMAT_PNG = "PNG"
IMAGE_FORMAT_WEBP = "WebP"
IMAGE_QUALITY = 30
IMAGE_RESIZE_LEVEL = 2
TIME_FORMAT = "%Y/%m/%d %H:%M:%S"

SET_PAGE_CONFIG = {
    "page_title": "My App",
    "layout": "wide",
    "initial_sidebar_state": "collapsed",
}

ST_STYLE = """
    <style>
        header[data-testid="stHeader"] {
            visibility: hidden;
            height: 0%;
        }
        div[data-testid="stToolbar"] {
            visibility: hidden;
            height: 0%;
        }
        div[data-testid="stDecoration"] {
            visibility: hidden;
            height: 0%;
        }
        div[data-testid="stVerticalBlock"] {
            gap: 0;
        }
        div[data-testid="stButton"] {
            padding-top: 1rem;
        }
        div[data-testid="stMainBlockContainer"] {
            padding: 0;
        }
        div[data-testid="stElementToolbar"]{
            visibility: hidden;
        }
    </style>
"""

実行コマンド

streamlit run app.py

実装の工夫点

  • 通常パターンと画像圧縮パターンの2パターンで動作するようにしたこと
  • ドライバで上下にスクロールする処理を追加して、動的なサイトになるべく対応したこと
  • ダイアログを利用して、実際に対象のWebページにアクセスしたかのように見せかけたこと

デモ画像

初期起動画面 GoogleのURLを指定して、実行(画像圧縮)ボタンをクリック後 表示された画像の左上部の「Googleについて」の部分をクリック後 表示された画像のタグの「日本におけるGoogle」の部分をクリック後

今回利用した技術

Streamlitサンプルコード

表示系

# テキスト
st.write("タイトル")

# 注釈
st.caption("注釈")

# 画像
st.image("https://picsum.photos/100")

df = pd.DataFrame({"カラム1": [1, 2, 3], "カラム2": [10, 20, 30]})
# テーブル
st.table(df)

# チャート
st.line_chart(df)

入力系

# テキスト入力
name = st.text_input("名前")
st.write(name)

# 数値入力
age = st.number_input("年齢", step=1)
st.write(age)

# ボタン
if st.button("ボタン"):
    st.write("ボタンを押しました")

# プルダウン
select = st.selectbox("好きな果物", options=["りんご", "ばなな", "いちご"])
st.write(select)

# プルダウン(複数選択)
multi_select = st.multiselect("好きな色", options=["赤", "青", "黄"])
st.write(multi_select)

# チェックボックス
check = st.checkbox("OK")
st.write(f"チェック:{check}")

# ラジオボタン
radio = st.radio("選択", ["猫", "犬"])
st.write(f"選択結果: {radio}")

# ファイルアップロード
uploaded_file = st.file_uploader("アップロード", type=["csv"])
if uploaded_file:
    df = pd.read_csv(uploaded_file)
    st.table(df)

レイアウト系

# ワイドレイアウト
st.set_page_config(layout="wide")

# 横並び
cols = st.columns(2)
with cols[0]:
    st.write("列1")
with cols[1]:
    st.write("列2")

# タブ
tabs = st.tabs(["タブ1", "タブ2"])
with tabs[0]:
    st.write("タブ1")
with tabs[1]:
    st.write("タブ2")

# アコーディオン
with st.expander("開きます"):
    st.write("開きました")

# サイドバー
with st.sidebar:
    st.write("サイドバー")

Seleniumサンプルコード

スクリーンショット保存

from selenium import webdriver
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.firefox.service import Service

# Firefoxのオプションを設定
options = Options()
# ヘッドレスモードで実行(ブラウザのUIを表示しない)
options.add_argument("--headless")
# GeckoDriverのパスを指定
service = Service(executable_path=r"driver\geckodriver.exe")
# Firefoxドライバを起動
driver = webdriver.Firefox(service=service, options=options)

# GoogleのWebページを開く
driver.get("https://www.google.com")
# スクリーンショットを保存
driver.save_screenshot(r"screenshot\screenshot.png")

# ドライバを終了
driver.quit()

Pillowサンプルコード

PNG形式の画像ファイルをJPEG形式の画像ファイルに変換

from PIL import Image

# PNG形式の画像ファイルを開く
input_image = Image.open("input_image.png")

# JPEG形式で保存する
input_image.convert("RGB").save("output_image.jpg", "JPEG")

venvサンプルコマンド

# 新しい仮想環境を作成(ここでは.venv)
python -m venv .venv

# Windowsでの仮想環境の有効化
source .venv/Scripts/activate

# 仮想環境に必要なライブラリをpipでインストール
pip install streamlit selenium pillow ... etc.

# requirements.txtにインストールしたライブラリ情報を保存
# 2回目からは「pip install -r requirements.txt」で必要なライブラリをインストールできる
pip freeze > requirements.txt

... pythonコマンドを実行(仮想環境で実行される)

# 仮想環境の無効化
deactivate

Gitサンプルコマンド

# リモートリポジトリをローカルにクローン
git clone {リモートリポジトリのURL}

# リモート(mainブランチ)の最新情報をローカルに反映
git pull origin main

# ローカルブランチの一覧を表示(チェックアウト中のブランチに「*」が付く)
git branch

# リモートのブランチから指定したブランチ名でブランチを切る
git switch -c {ブランチ名} origin/{リモートのブランチ名}

# 変更したファイルの一覧を表示
git status

# 変更した全ファイルをコミット対象に追加
git add --all

# コミットメッセージを付けてコミット
git commit -m '{コミットメッセージ}'

# ブランチをリモートにプッシュ
git push origin {ブランチ名}

# ブランチを削除
git branch -D {ブランチ名}

# メッセージ付きで変更内容をスタッシュ(レビュー時に便利)
git stash save -u {メッセージ}

# スタッシュの一覧を表示(レビュー時に便利)
git stash list

# スタッシュを復元(レビュー時に便利)
git stash pop stash@{対象の数字}

# リモートのブランチを対象に一時的にブランチを切る(レビュー時に便利)
git switch -d origin/{リモートのブランチ名}

感想

Streamlitは数行のPythonコードを書くだけですぐにWebページを作成できるため、直観的で実装していて楽しかったです。 また、業務で使っている技術や今まで扱ったことのない技術を利用したことでとても勉強になりました。 しかし、今回の実装では画像クリックの座標を完全には扱えなかったため、今後修正していきたいと思います。
Streamlitを調査していてst.data_editor()というメソッドを見つけて、 Excelライクな表を画面に表示できたので電力トレーディング案件で実施しているExcelからPythonへの移行に使えるのではないかと考えています。