Chanomic Blog

Pythonを使った(静的)ページの画像のURL取得

(last modified:
)

categories:

Webページの画像だけを手っ取り早く取得したい場合にどうすれば良いのかを考えた。 これを行うプログラムをPythonで取得する。

この記事で作成したプログラムはGitHubのRepositoryに公開した。

前提

方針

やることは案外単純である。

  1. WebページのHTMLデータを取ってくる。
  2. img要素を探して、そのsrc属性を取ってくる。
  3. scheme、netlocが無かったらそれを付加して、完全なURLにする。

1はRequests、2はBeautiful Soupを使えば良いだろう。 3は思ったより複雑である。src属性に入っているパスには、

など色々ある。 これらのフォーマットを統一して完全なURLにするのは面倒であるが、幸運にもurllib.parse.urljoinという関数があったのでこれを使う (余談: 初め、urljoinの存在を知らずに自前でURLの変換機能を実装してしまった。学びにはなったが時間を費やした…)。

ついでの機能として、「特定の要素の中に含まれているimg要素のURLを取得する」ことも考える。 これはCSSセレクタとして指定できるようにする。

まとめると、画像のURLを取得する関数は以下のようなインターフェースとなる。

def get_img_urls(url: str, selector: Optional[str]=None) -> list[str]:
  pass # これから実装する

URLとセレクタを引数にとり、img要素のURLのリストを返す関数である。

ついでに画像ダウンロードのためのCLIや、画像を閲覧するWebアプリなどが作れたら良い。

プロジェクトの構造

Pythonでモジュールを作ったことがないため、正しい作り方が分からないが、とりあえず以下のような構成にしてみる。 細かいディレクトリの構成は各節で述べる。

/project
|
+--+ getimg/
|
+--+ commandline/
|
+--+ viewer/
|
+--+ tests/
     +-- __init__.py
     +-- test_getimg.py
     +-- test_commandline.py

CLI

CLIの書式は以下のようにする。取得したい画像のあるページのURL、及び画像のダウンロード先を指定する。

> python -m commandline "http://foo.com/bar/" output/

CLIのコマンドライン引数の取扱いにはargparseが便利なのでそれを使う。

画像ビューワー

Webアプリについては、Flaskが一番手軽かなと思ったのでこれを使う。

以下のコマンドでWebサーバーが起動するようにする。

> python -m viewer

ここではとりあえず一番手軽なFlask.run関数でサーバーを動かす。Flask.runで動作するのは開発用サーバーなのだが、個人的に利用することしか考えていないのでこれで良い。

補足: pyrightを使った開発

LSPにpyrightを使うとき、例えばcommandline/__init__.pyからgetimgモジュールを参照しようとすると、「モジュールが無い」と怒られる。 これはプロジェクトの場所をpyrightが認識していないのが原因(多分)。なのでプロジェクトの位置をpyrightに教えてあげる必要がある。 そのために、pyrightconfig.jsonを作成し、内容を以下のようにする。

{
  "executionEnvironments":[
    {
      "root": "."
    }
  ]
}

画像のURLを取得する関数の作成

getimgディレクトリの構成。

/project
|
+-- getimg/
    +-- __init__.py

getimg/__init__.pyを編集。必要モジュールは以下の通り。

from typing import Optional, TypeGuard
from bs4 import BeautifulSoup
import requests
import urllib.parse

大まかな処理

まず、画像の取得はrequestsモジュールのget関数を使う。

response = requests.get(url)

手に入れたHTML文書からimg要素を探し、そのパスを取得する処理はget_img_srcsという関数に任せる。

paths = get_img_srcs(response.text, selector)

get_img_srcsは次のようなシグネチャとし,後で実装する。

def get_img_srcs(html_text: str, selector: Optional[str]=None) -> list[str]:
  pass

取得したパスに対し、urllib.parse.urljoin関数を使ってURLに変換する。 引数に指定しているのはurlではなくresponse.urlである。このようにすれば、リダイレクトされたケースに対応できる。

[urllib.parse.urljoin(response.url, path) for path in paths]

これらをまとめると、以下のようになる。

def get_img_urls(url: str, selector: Optional[str]=None) -> list[str]:
    response = requests.get(url)
    paths = get_img_srcs(response.text, selector)
    return [urllib.parse.urljoin(response.url, path) for path in paths]

img要素を探し、そのパスを取得する処理

まずはBeautifulSoupでHTML文書をパースする。

soup = BeautifulSoup(html_text, 'html.parser')

soup.selectを使ってimg要素を探す。その際、引数selectorが指定されていればsoup.select('[selector] img') とし、指定されていなければsoup.select('img')とする。

new_selector = f'{selector} img' if selector is not None else 'img'
imgs = soup.select(new_selector)

getメソッドでsrc要素を読み取り、パスを取得する。

paths = [img.get('src') for img in imgs]

型を気にしないのであればこのままreturn pathsしても良いが、pyrightなりmypyなりの型チェックをした際に怒られる。 これはなぜかというと、bs4モジュールのTag.get関数の返り値がstr | list[str] | Noneであるから。 これはコード中のimg.get('src')に対応している。img要素なのに属性値が無かったり複数値とったりすることなんで普通は無いのだが、Tagの実装上こうなってしまっている。

解決策は、以下のようにTypeGuardを使って、リストの中にstr型のものしかないことを保証してやる。

def is_attr_str(obj: str | list[str] | None) -> TypeGuard[str]:
    return isinstance(obj, str)

[path for path in paths if is_attr_str(path)]

ここまでをまとめると、関数は以下のようになる。

def is_attr_str(obj: str | list[str] | None) -> TypeGuard[str]:
    return isinstance(obj, str)

def get_img_srcs(html_text: str, selector: Optional[str]=None) -> list[str]:
    soup = BeautifulSoup(html_text, 'html.parser')
    new_selector = f'{selector} img' if selector is not None else 'img'
    imgs = soup.select(new_selector)
    paths = [img.get('src') for img in imgs]
    return [path for path in paths if is_attr_str(path)]

テスト

tests/test_getimg.pyを編集。pytestを使ってテストを書く

getimg/tests/test_getimg.pyの内容を以下のようにする。 get_img_srcs関数とget_img_urls関数のテストをここで行う。

まずはテストの対象であるHTML文書を書いておく。

from getimg import get_img_srcs, get_img_urls
from typing import Final
from pytest import MonkeyPatch

html_text: Final = """
<html>
    <body>
        <div class="container1">
            <img src="http://loc1.com/foo/img1.png">
            <img src="/img2.png">
        </div>
        <div class="container2">
            <img src="../img3.png">
            <img src="//loc2.com/img4.png">
            <img src="data:image/png;base64,BINIMAGE">
        </div>
    </body>
</html>
"""

get_src関数がうまくいくかどうかのテスト。

def test_get_src():
    expect = [
        "http://loc1.com/foo/img1.png",
        "/img2.png",
        "../img3.png",
        "//loc2.com/img4.png",
        "data:image/png;base64,BINIMAGE"
    ]

    assert get_img_srcs(html_text) == expect

get_img_urls関数がうまくいくかどうかのテスト。pytestのmonkeypatch fixtureを使い、requests.getの処理をfake_getに挿げ替えているところがポイント。 CSSセレクタが機能しているかどうかのテストも行う。

class Result:
    def __init__(self, url: str):
        self.text = html_text
        self.url = url

def fake_get(url: str) -> Result:
    return Result(url)


def test_get_url(monkeypatch: MonkeyPatch):
    monkeypatch.setattr('requests.get', fake_get)


    url = "http://loc.com/foo/"
    expect = [
        "http://loc1.com/foo/img1.png",
        "http://loc.com/img2.png",
        "http://loc.com/img3.png",
        "http://loc2.com/img4.png",
        "data:image/png;base64,BINIMAGE",
    ]

    assert get_img_urls(url) == expect


def test_get_url_with_selector(monkeypatch: MonkeyPatch):
    monkeypatch.setattr('requests.get', fake_get)

    url = "http://loc.com/foo/"
    expect = [
        "http://loc.com/img3.png",
        "http://loc2.com/img4.png",
        "data:image/png;base64,BINIMAGE",
    ]

    assert get_img_urls(url, ".container2") == expect

これでpytestを実行すると、テストに成功することが確かめられる。

CLIの作成

仕様

commandlineディレクトリの構成。

/project
|
+-- commandline/
    +-- __init__.py
    +-- __main__.py

CLIの機能及び実装上の注意については以下の通り。

また、実行するときは以下のような書式にする。

> python -m commandline "http://foo.com/bar/" output/

また、コマンドライン引数を指定できるようにする。

準備

commandline/__init__.pyを編集。まず、必要モジュールをインポート。

from getimg import get_img_urls
from typing import Optional
import requests
from requests.exceptions import RequestException
import urllib.parse
import os.path
from tqdm import tqdm

ログを出力する関数を作っておく。

def print_log(text: str, need_log: bool):
    if need_log:
        print(text)

メインとなる処理

URLからHTML文書を取得し、そこからimgタグのsrcを読み取り、そのダウンロードを行う関数はdownload_imgs_from_pageとする。 返り値は画像のダウンロードの失敗情報のリストである。これは(URL, 例外)のタプルのリストとする。

download_imgs_from_urlsは次の項で実装する。

FailInfo = tuple[str, RequestException | ValueError]


def download_imgs_from_page(url: str, output_dir: str, selector: Optional[str]=None, need_log: bool=False) -> list[FailInfo]:
    urls = get_img_urls(url, selector)
    return download_imgs_from_urls(urls, output_dir, need_log)

画像をダウンロードする処理

1つの画像をダウンロードする処理、複数の画像をダウンロードする処理を別々の関数に分ける。 例外が発生した場合は、その結果をfailInfoに入れる。Data URLか否かの分岐はここで行っている。

プログレスバーを出力するのにはtqdmを使う。

def is_dataurl(url: str) -> bool:
    return url.startswith('data:')


def download_imgs_from_urls(urls: list[str], output_dir: str, need_log: bool=False) -> list[FailInfo]:
    failInfo = []
    for url in tqdm(urls):
        if is_dataurl(url):
            e = save_img_from_data_url(url, output_dir, need_log)
            if e is not None:
                failInfo.append((url[0:100] + '...', e)) # 長すぎる場合が多いので100文字までで切る
        else:
            e = download_img_from_url(url, output_dir, need_log)
            if e is not None:
                failInfo.append((url, e))
    return failInfo

データURLでないURLについては、普通にrequests.get関数でダウンロードする。 make_path関数とsave_img関数はこの後実装する。

def download_img_from_url(url: str, output_dir: str, need_log: bool=False) -> Optional[RequestException]:
    path = make_path(url, output_dir)

    try:
        res = requests.get(url)
    except RequestException as e:
        return e

    save_img(path, res.content)

    print_log(f'{url} -> {path}', need_log)

続いてデータURL形式の処理。主なパース処理はw3lib.url.parse_data_uriに任せ、拡張子はextract_ext_from_mimeという関数を作って処理している。 svg形式の画像についてはMIMEタイプがimage/svg+xmlとなるため、例外的に扱っている。

def extract_ext_from_mime(mime: str) -> str:
    [_, type] = mime.split('/')
    if type == 'svg+xml':
        return 'svg'
    else:
        return type


def save_img_from_data_url(url: str, output_dir: str, need_log: bool=False) -> Optional[ValueError]:
    try:
        result = w3lib.url.parse_data_uri(url)
    except ValueError as e:
        return e

    ext = extract_ext_from_mime(result.media_type)

    path = f'{output_dir}/dara_url.{ext}'
    save_img(path, result.data)

    print_log(f'(Data URL) -> {path}', need_log)

パスの解決処理

与えられた画像のURLからファイル名を取り出して、画像を出力するディレクトリoutput_dirと組み合わせて保存先パスを作成。

output_dirについて、dir/dirは同じものとみなす。そのためにrstrip関数を用いている。 別にこれをしなくても保存自体はできるのだが、ログにhttp://foo.com/img.png -> output_dir//img.pngと二重の//が現れてしまい、少し汚い。

urllib.parse.urlparseをわざわざ呼び出しているのは、クエリ文字列を省くため。

def make_path(url: str, output_dir: str) -> str:
    output_dir = output_dir.rstrip('/')
    path = f'{output_dir}/{extract_filename(url)}'
    return path


def extract_filename(url: str) -> str:
    urlinfo = urllib.parse.urlparse(url)
    return os.path.basename(urlinfo.path)

画像の保存処理

rename_if_exists関数で行っている処理は、出力先パスに同名のファイルが無いかどうか調べ、存在した場合はname_1.pngname_2.pngのように数字をつけること。

def save_img(path: str, content: bytes):
    path = rename_if_exists(path)
    with open(path, 'wb') as f:
        f.write(content)


def rename_if_exists(path) -> str:
    if not os.path.exists(path):
        return path
    else:
        root, ext = os.path.splitext(path)
        i = 1
        while True:
            new_path = f'{root}_{i}{ext}'
            if not os.path.exists(new_path):
                return new_path
            i += 1

テスト

tests/test_commandline.pyを編集。使う関数を読み込んでおく。

from commandline import extract_filename, make_path, rename_if_exists, download_imgs_from_urls
import pytest
from pytest import MonkeyPatch
from typing import Final
from requests.exceptions import HTTPError

関数extract_filenameのテスト。クエリ文字列がちゃんと省かれるかどうか見ている。

test_cases_extract_filename: Final = [
    ("http://foo.com/img.png", "img.png"),
    ("http://foo.com/bar/img.png", "img.png"),
    ("http://foo.com/bar/img.png?q=123", "img.png"),
]

@pytest.mark.parametrize("url, expect", test_cases_extract_filename)
def test_extract_filename(url: str, expect: str):
    assert extract_filename(url) == expect

関数make_pathのテスト。

test_cases_make_path: Final = [
    ("http://foo.com/img.png", "imgout", "imgout/img.png"),
    ("http://foo.com/img.png", "imgout/", "imgout/img.png"),
    ("http://foo.com/bar/fuga/img.png", "imgout", "imgout/img.png"),
]

@pytest.mark.parametrize("url, output_dir, expect", test_cases_make_path)
def test_make_path(url: str, output_dir: str, expect: str):
    assert make_path(url, output_dir) == expect

関数rename_if_existsのテスト。

test_cases_rename_if_exists: Final = [
    (["output/img.png"], "output/img.png", "output/img_1.png"),
    (["output/img.png", "output/img_1.png"], "output/img.png", "output/img_2.png"),
    (["output/img_1.png"], "output/img.png", "output/img.png"),
    (["output/img_1.png"], "output/img_1.png", "output/img_1_1.png"),
    (["output/img.png", "output/img_2.png"], "output/img.png", "output/img_1.png"),
]

@pytest.mark.parametrize("paths_exist, target, expect", test_cases_rename_if_exists)
def test_rename_if_exists(paths_exist: list[str], target: str, expect: str, monkeypatch: MonkeyPatch):
    def fake_exists(path: str):
        return path in paths_exist

    monkeypatch.setattr('os.path.exists', fake_exists)

    assert rename_if_exists(target) == expect

関数download_imgs_from_urlsのテスト。失敗した場合にその情報を返すかどうかを見ている。

def test_download_imgs_from_urls(monkeypatch: MonkeyPatch):
    def fake_save_img(*_):
        pass

    class Response:
        def __init__(self):
            self.content = b'succeed'

    def fake_get(url: str) -> Response:
        if url == 'fail':
            raise HTTPError
        else:
            return Response()

    monkeypatch.setattr('commandline.save_img', fake_save_img)
    monkeypatch.setattr('requests.get', fake_get)

    urls = ["fail", "success", "fail", "fail"]
    failInfo = download_imgs_from_urls(urls, '.')
    assert len(failInfo) == 3

これでpytestが通ることを確認する。

インターフェースの作成

__main__.pyを編集。argparseモジュールを使って、コマンドライン引数のパースを行う。

from . import download_imgs_from_page
import argparse

parser = argparse.ArgumentParser(prog="commandline")
parser.add_argument('url', type=str)
parser.add_argument('path', type=str)
parser.add_argument('-v', nargs='?', default=False, const=True, help="print log verbosely")
parser.add_argument('-s', type=str, help="CSS selector")

args = parser.parse_args()

failInfo = download_imgs_from_page(args.url, args.path, args.s, args.v)
for info in failInfo:
    print(info[0], info[1])

これでpython -m commandline URL 出力先ディレクトリとすると画像がダウンロードされるはず。 python -m commandline -hとするとコマンドの説明が出力される。

画像ビューワーWebアプリの作成

仕様

viewerディレクトリの構成。

/project
|
+-- viewer/
    +-- __init__.py
    +-- __main__.py
    +-- templates/
    |   +-- index.html
    +-- static/
        +-- style.css

以下のような仕様を持つアプリを作る。

雛形作成

__init__.pyを以下のようにする。

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello():
    return 'Hello'

__main__.pyを以下のようにする。一応、-p [port]でポートを指定できるようにしておく。

デバックのしやすさのため、app.config['ENV'] = 'development'を指定しておく (本番環境の場合は使ってはいけないので注意。参考)。

from viewer import app
import argparse

parser = argparse.ArgumentParser(prog="viewer")
parser.add_argument('-p', type=str, default="8000")

args = parser.parse_args()

app.config['ENV'] = 'development'
app.run(debug=True, port=args.p)

これでpython -m viewerとすると、開発用サーバーが起動する。ポートを変えたい場合はpython -m viewer -p ポート番号とする。以下、ポートはデフォルトの8000で話を進める。http://localhost:8000にアクセスすると、現時点ではHelloとだけ表示されたページが出力される。

templateの作成

仕様的に、作るWebページは1ページだけでよい。それをviewer/templates/index.htmlとし、以下のようにする。

CSSを後で書くので、適当にクラスを付与しておく。 .get-img-formの中にあるのが入力フォーム、.received-imagesの中にあるのが、表示された画像を表す。

<html>
  <head>
    <meta charset="utf-8">
    <title>Image Viewer</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
  </head>
  <body>
    <div class="get-img-form">
      <form method="POST">
        <div>
          <label for="url">URL</label>
          <input name="url" required>
        </div>
        <div>
          <label for="selector">Selector</label>
          <input name="selector">
        </div>
        <div>
          <input type="submit" value="submit">
        </div>
      </form>
    </div>
    <div class="received-images">
      {% for url in urls %}
        <img src="{{ url }}">
      {% endfor %}
    </div>
  </body>
</html>

ビューの作成

viewer/__init__.pyを編集。今回作るFlaskアプリはこれだけでよい。

POSTメソッドが来たとき、フォームからURLとセレクタの情報を取得。 それを使ってget_img_urlsを呼び出し、画像URLを取得。 それをrender_templateの引数に指定すればよい。

from flask import Flask, render_template, request
from getimg import get_img_urls

app = Flask(__name__)

@app.route('/', methods=('GET', 'POST'))
def index():
    if request.method == 'POST':
        url = request.form['url']
        selector = request.form['selector']

        if url is not None:
            urls = get_img_urls(url, selector)
            return render_template('index.html', urls = urls)

    return render_template('index.html')

こんな感じのページが表示される。ここにURLとセレクタ(任意)を入力しsubmitボタンを押すと、URL先の画像が下に表示される。

画像の並びの調整

このままだと画像の並びがやや汚いため、getimg/viewer/static/style.cssを編集。columnsプロパティを利用。

.received-images {
  columns: 4;
}

.received-images img {
  width: 100%;
}

これで最低限のWebアプリができた。

今後の課題