python urllib.requestモジュールで実用的な動画ダウンローダーを作る

#### はじめに
Webページや画像などの取得に Requestsモジュールを使った例が、pythonに関する書籍や技術系サイトで数多く紹介されています。データサイエンティストなど非プログラマー系の人がお手軽に使用できるのでいんですが、それでも標準モジュール以外を使うにはそれなりに学習コストがかかり、またそのモジュールに脆弱性がみつかるとモジュールの更新も必要になります。

本格的なアプリケーションでないのであれば python 標準の urllib.requestモジュールでも実用的な動画ダウンローダーを作ることが可能です。

今回は数ギガバイトもあるような動画をダウンロード可能な Pythonスクリプトを urllib.request モジュールのみで実装する方法を紹介いたします。

#### 実行環境

+ OS: Ubuntu Desktop 22.04
+ python 3.10.x
Python仮想環境を作成し、その環境下のpythonでpythonスクリプトを実行する。
※標準のモジュールのみで作りますが、ソースコードの型チェックに mypy ライブラリをインストールしています。

#### 参考URL

Python Docs
下記のドキュメントは実装例も簡潔にまとめられており参考になります。
+ [urllib パッケージを使ってインターネット上のリソースを取得するには](https://docs.python.org/ja/3.13/howto/urllib2.html)
+ [urllib.request --- URL を開くための大規模なライブラリ](https://docs.python.org/ja/3.13/library/urllib.request.html)

手っ取り早くモジュールの使い方を知りたいならここも参考になります。
+ [@IT ［Python入門］urllib.requestモジュールによるWebページの取得](https://atmarkit.itmedia.co.jp/ait/articles/1910/15/news018.html)


### 1. コンテンツのダウンロード

実用的な画像・動画などのダウンローダでは最低限リクエストヘッダーにユーザーエージェントが必要です。

以降の実装ではユーザーエージェントを以下のように定義します。
※実行環境は Linuxですがユーザーエージェントは Windows にしています。

```python
UA: str = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 "
           "Firefox/107.0")
```

(1) モジュールのインポート

主に urllib パッケージのモジュールをインポート

```python
import argparse
import logging
import os

from http.client import HTTPResponse
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse, ParseResult
from urllib.request import Request, urlopen
```

(2) 共通関数の定義

リクエストURLから保存ファイル名を生成する処理
+ 画像ファイルの場合はURLのパスの末尾

```python
# URL中のパス(末尾)から拡張子なしのファイル名を生成する
def basename_in_url(url: str, is_image: Optional[bool] = None) -> str:
    parsed: ParseResult = urlparse(url)
    # check file extention
    lastname: str = os.path.basename(parsed.path)
    if is_image:
        return lastname

    dot_pos: int = lastname.find(".")
    return lastname[:dot_pos] if dot_pos != -1 else lastname
```


#### 1-2. レスポンスを複数回に分割してコンテンツを取得

指定したサイズのバッファサイズで逐次ファイルに保存する

+ (1) 動画取得用のリクエストヘッダー設定
    + 動画コンテンツがHTMLページと異なるドメインに存在する場合
    リクエストヘッダーにリファラーを追加する
+ (2) ダウンロード処理関数の仕様
    + (a) 動画URLを開いてレスポンスオブジェクトを取得
    ※ レスポンスコードが 400、500番台の場合はそのまま呼び出し元にスローする
    + (b) レスポンスコードが200以外の処理
    例えば 300番のリダイレクトはエラーとする
    + (c) Content-Length チェック
    Content-Lengthが取得できない場合エラーとする
    ※Transfer-encodingには対応しない
    + (d) レスポンスの逐次ファイル保存

##### 1-2-(1) 動画ダウンロード用リクエストヘッダーの設定

```python
# ダウンロード用のリクエストヘッダー
REQ_HEADERS: Dict[str, str] = {
    "Accept": "*/*",
    "Accept-Language": "ja,en-US;q=0.7,en;q=0.3",
    "Accept-Encoding": "gzip, deflate, br, zstd",
    "Connection": "keep-alive"
}

# ユーザーエージェントの追加
REQ_HEADERS["User-Agent"] = UA

# 動画URLのドメインがHTMLページのドメインと異なる場合はリファラーを追加
if referer_url is not None:
   REQ_HEADERS["Referer"] = referer_url # HTMLのドメイン or HTMLページのURL 
```

:::note info
ブラウザの動画再生時のリクエストヘッダーの取得方法
４〜５年前ぐらいには動画の中央のプレイボタンの右クリックでダウンロードが可能だったのですが最近ではできなくなっているようです。

私は Firefox ブラウザ に 「**HTTP Header Live**」プラグインをインストールし、目的とするサイトの動画再生時のリクエストヘッダーを取得しています。
※リファラーなどのリクエストヘッダーは動画を公開しているサイトによって違いが有ると思われます。
:::


##### 1-2-2 ダウンロード処理関数

+ 引数
    + 動画コンテンツURL
    + 保存ディレクトリ
    + リクエストヘッダー
+ 戻り値
    + tuple(保存ファイル名, コンテンツサイズ)

###### 1-2-2 (A) URLのオープン

+ コンテンツURLとリクエストヘッダーを指定して Requestオブジェクトを生成
+ urlopen 関数にリクエストオブジェクトとタイムアウトを設定して接続
    + 接続タイムアウトを5秒に設定
    + 戻り値としてレスポンスオブジェクト(HTTPResponse) を取得

```python
def download(url: str,
             save_path: str,
             headers: Dict[str, str]) -> Tuple[str, int]:
    app_logger.debug(f"Download url: {url}")

    req: Request = Request(url, headers=headers)
    app_logger.debug("** Request headers **")
    app_logger.debug(pprint.pformat(req.headers, indent=2))

    resp: HTTPResponse = urlopen(req, timeout=5.)
```

###### 1-2-2 (B) レスポンスコードチェック

200 以外はエラーとし、HTTPError (レスポンスコード) スローする。

```python
    app_logger.info(f"response.code: {resp.status}")
    # python 3.9 で非推奨
    # if resp.getcode() != 200:
    if resp.status != 200:
        # 200以外はエラーとする
        raise HTTPError(
            url, resp.status, "Disable download!",
            resp.info(), None
        )
```

###### 1-2-2 (C) Content-Length ヘッダー有無チェック

Content-Length ヘッダーがない場合は HTTPError (411) をスローする

```python
    app_logger.debug("** Response headers **")
    app_logger.debug(resp.info())
    # Content-Length ヘッダーチェック
    raw_content_len: str = resp.info()["Content-Length"]
    app_logger.debug(f"Content-Length: {raw_content_len}")
    if raw_content_len is None:
        # ダウンロードできない: 411 Length Required
        raise HTTPError(
            url, 411, "Server did not send Content-Length!",
            resp.info(), None
        )

    content_length: int = int(raw_content_len.strip())
    app_logger.info(f"Content-Length: {content_length:,}")
```

###### 1-2-2 (D) レスポンスのファイル保存処理

レスポンスを8KBのバッファで読み込みし、保存ファイルに書き込みする。

IncompleteRead 例外がスローされた場合、ウォーニングを出力してそのまま再スローする。

```python
    # ファイル保存処理
    dl_size: int = 0
    show_cnt: int = 0
    with open(save_path, 'wb') as fp:
        while True:
            try:
                # Read buffer: 8KB
                buff: bytes = resp.read(1024 * 8)
            except IncompleteRead as e:
                app_logger.warning(f"Downloaded: {dl_size}/{content_length}")
                app_logger.error("Read Error: %r", e)
                raise e

            if not buff:
                break

            dl_size += len(buff)
            show_cnt += len(buff)

            # @ 10KB output downloading: xxxxx
            if show_cnt > (1024 * 1024):
                app_logger.debug(f"downloading: {dl_size:,}")
                show_cnt = 0

            fp.write(buff)
            if dl_size >= content_length:
                app_logger.debug(f"Done downloaded: {dl_size:,}")
                break

    return save_path, content_length
```

##### 1-2-2 メインスクリプト

メインスクリプトの全ソースを下記に示します。

```DownloadVideo_main.py
import argparse
import logging
import os
import pprint

from typing import Dict, Optional, Tuple
from http.client import HTTPResponse, IncompleteRead
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse, ParseResult
from urllib.request import Request, urlopen

SAVE_DIR: str = os.path.expanduser("~/Videos/script")

# ユーザーエージェント
UA: str = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 "
           "Firefox/107.0")

# ダウンロード用のリクエストヘッダー
REQ_HEADERS: Dict[str, str] = {
    "Accept": "*/*",
    "Accept-Language": "ja,en-US;q=0.7,en;q=0.3",
    "Accept-Encoding": "gzip, deflate, br, zstd",
    "Connection": "keep-alive"
}

app_logger: logging.Logger = logging.getLogger(__name__)
handler: logging.Handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)s %(message)s'))
app_logger.addHandler(handler)


def basename_in_url(url: str, is_image: Optional[bool] = None) -> str:
    parsed: ParseResult = urlparse(url)
    # check file extention
    lastname: str = os.path.basename(parsed.path)
    if is_image:
        return lastname

    dot_pos: int = lastname.find(".")
    return lastname[:dot_pos] if dot_pos != -1 else lastname


def download(url: str,
             save_path: str,
             headers: Dict[str, str]) -> Tuple[str, int]:
    app_logger.debug(f"Download url: {url}")

    req: Request = Request(url, headers=headers)
    app_logger.debug("** Request headers **")
    app_logger.debug(pprint.pformat(req.headers, indent=2))

    resp: HTTPResponse = urlopen(req, timeout=5.)
    
    app_logger.info(f"response.code: {resp.status}")
    # python 3.9 で非推奨
    # if resp.getcode() != 200:
    if resp.status != 200:
        # 200以外はエラーとする
        raise HTTPError(
            url, resp.status, "Disable download!",
            resp.info(), None
        )

    app_logger.debug("** Response headers **")
    app_logger.debug(resp.info())
    # Content-Length ヘッダーチェック
    raw_content_len: str = resp.info()["Content-Length"]
    app_logger.debug(f"Content-Length: {raw_content_len}")
    if raw_content_len is None:
        # ダウンロードできない: 411 Length Required
        raise HTTPError(
            url, 411, "Server did not send Content-Length!",
            resp.info(), None
        )

    content_length: int = int(raw_content_len.strip())
    app_logger.info(f"Content-Length: {content_length:,}")

    # ファイル保存処理
    dl_size: int = 0
    show_cnt: int = 0
    with open(save_path, 'wb') as fp:
        while True:
            try:
                # Read buffer: 8KB
                buff: bytes = resp.read(1024 * 8)
            except IncompleteRead as e:
                app_logger.warning(f"Downloaded: {dl_size}/{content_length}")
                app_logger.error("Read Error: %r", e)
                raise e

            if not buff:
                break

            dl_size += len(buff)
            show_cnt += len(buff)

            # @10KB (output) downloading: 12,345,678
            if show_cnt > (1024 * 1024):
                app_logger.debug(f"downloading: {dl_size:,}")
                show_cnt = 0

            fp.write(buff)
            if dl_size >= content_length:
                app_logger.debug(f"Done downloaded: {dl_size:,}")
                break

    return save_path, content_length


def main():
    parser: argparse.ArgumentParser = argparse.ArgumentParser()
    # 動画URL
    parser.add_argument("--url", type=str, required=True,
                        help="Download Video URL.")
    # リファラーURL ※任意
    parser.add_argument("--referer-url", type=str,
                        help="Referer URL with video URL, optional.")
    # DEBUG出力するか: 指定があれば出力する
    parser.add_argument("--is-debug", action='store_true',
                        help="Output DEBUG.")
    args: argparse.Namespace = parser.parse_args()
    is_debug: bool = args.is_debug
    if is_debug:
        app_logger.setLevel(logging.DEBUG)
    else:
        app_logger.setLevel(logging.INFO)

    video_url: str = args.url
    # 保存ファイル名: 動画URLのパスの末尾名
    save_name: str = basename_in_url(video_url, is_image=True)
    save_path: str = os.path.join(SAVE_DIR, save_name)

    # リクエストヘッダーの設定
    # User-Agent
    REQ_HEADERS["User-Agent"] = UA
    # リファラーURLが指定されていたらリファラーヘッダーを追加
    if args.referer_url is not None:
        REQ_HEADERS["Referer"] = args.referer_url

    try:
        saved_path, file_size = download(
            video_url, save_path=save_path, headers=REQ_HEADERS
        )
        app_logger.info(f"Saved: {saved_path}")
        app_logger.info(f"FileSize: {file_size:,}")
        app_logger.info("Download finished.")
    except HTTPError as err:
        # エラー時のレスポンスコート
        app_logger.warning(f"{err}\n >> {video_url}")
        app_logger.warning("** Response headers **")
        app_logger.warning(f"{err.headers.as_string()}")
    except URLError as err:
        app_logger.warning(f"{err.reason}\n >> {video_url}")
    except Exception as e:
        app_logger.error(f"Error: {e}\n >>  {video_url}")


if __name__ == '__main__':
    main()
```

#### 1-3. メインスクリプト実行

##### 1-3-1. 動画URLのみのコンテンツ取得

サンプルとして総務省のプロモーションビデオをダウンロード。
「4K・8Kの魅力と新たに始まるBS・110度CSによる4K・8K放送について」(104 MB)

```bash
(py_httpclient_mypy) $ python DownloadVideo_main.py \
> --url https://www.soumu.go.jp/main_content/000487276.mp4 \
> --is-debug
DEBUG Download url: https://www.soumu.go.jp/main_content/000487276.mp4
DEBUG ** Request headers **
DEBUG { 'Accept': '*/*',
  'Accept-encoding': 'gzip, deflate, br, zstd',
  'Accept-language': 'ja,en-US;q=0.7,en;q=0.3',
  'Connection': 'keep-alive',
  'User-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) '
                'Gecko/20100101 Firefox/107.0'}
INFO response.code: 200
DEBUG ** Response headers **
DEBUG Content-Type: video/mp4
Content-Length: 104449994
Connection: close
Server: Apache
Date: Wed, 26 Feb 2025 07:36:37 GMT
X-XSS-Protection: 1; mode=block
Accept-Ranges: bytes
X-Content-Type-Options: nosniff
ETag: "639c7ca-62c4ca579f820"
Last-Modified: Wed, 22 Jan 2025 14:52:45 GMT
X-Frame-Options: deny
X-IIJ-Cache: MISS


DEBUG Content-Length: 104449994
INFO Content-Length: 104,449,994
DEBUG downloading: 1,056,768
DEBUG downloading: 2,113,536
...
DEBUG downloading: 102,506,496
DEBUG downloading: 103,563,264
DEBUG Done downloaded: 104,449,994
INFO Saved: /home/yukio/Videos/script/000487276.mp4
INFO FileSize: 104,449,994
INFO Download finished.
```

保存した動画の再生

![Screenshot_download_mp4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/172fc82b-bdad-48cc-927e-109d254a4d08.png)


##### 1-3-2.リファラーURLの設定が必要な動画サイト

動画コンテンツURLとリファラーURLはすべて架空のものにしています。

###### 1-3-2 (A) リファラーURLが未設定

このサイトでは「403: Forbidden」エラーとなりました。
※リファラーエラー (X-Message: Missing referer) が出力されています。

```bash
(py_httpclient_mypy) $ python DownloadVideo_main.py \
> --url "https://mpeg.abcdn.com/key=AbcefG/referer=force,.abcdn.com,.example.com/720p.h264.mp4" \
> --is-debug
DEBUG Download url: https://mpeg.abcdn.com/key=AbcefG/referer=force,.abcdn.com,.example.com/720p.h264.mp4
DEBUG ** Request headers **
DEBUG { 'Accept': '*/*',
  'Accept-encoding': 'gzip, deflate, br, zstd',
  'Accept-language': 'ja,en-US;q=0.7,en;q=0.3',
  'Connection': 'keep-alive',
  'User-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) '
                'Gecko/20100101 Firefox/107.0'}
WARNING HTTP Error 403: Forbidden
 >> https://mpeg.abcdn.com/key=AbcefG/referer=force,.abcdn.com,.example.com/720p.h264.mp4
WARNING ** Response headers **
WARNING Server: nginx/1.26.2
Date: Thu, 27 Feb 2025 08:11:51 GMT
Content-Type: text/plain
Content-Length: 15
Connection: close
X-Message: Missing referer

```

###### 1-3-2 (B) リファラーURLを設定

```bash
(py_httpclient_mypy) $ python DownloadVideo_main.py \
> --url https://mpeg.abcdn.com/key=AbcefG/referer=force,.abcdn.com,.example.com/720p.h264.mp4 \
> --referer-url https://www.example.com/example_mainABCx \
> --is-debug
DEBUG Download url: https://mpeg.abcdn.com/key=AbcefG/referer=force,.abcdn.com,.example.com/720p.h264.mp4
DEBUG ** Request headers **
DEBUG { 'Accept': '*/*',
  'Accept-encoding': 'gzip, deflate, br, zstd',
  'Accept-language': 'ja,en-US;q=0.7,en;q=0.3',
  'Connection': 'keep-alive',
  'Referer': 'https://www.example.com/example_mainABCx',
  'User-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) '
                'Gecko/20100101 Firefox/107.0'}
INFO response.code: 200
DEBUG ** Response headers **
DEBUG Server: nginx/1.22.0
Date: Thu, 27 Feb 2025 08:14:58 GMT
Content-Type: video/mp4
Content-Length: 54611440
Last-Modified: Mon, 29 Apr 2024 17:29:04 GMT
Connection: close
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, Origin, Accept, Range, Cache-Control
Access-Control-Allow-Methods: HEAD, GET, OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Range, Date, Etag, Timing-Allow-Origin
Access-Control-Max-Age: 31536000
Timing-Allow-Origin: *
ETag: "662fd8e0-3414df0"
Expires: Thu, 27 Feb 2025 10:14:58 GMT
Cache-Control: max-age=7200
Cache-Control: private
Accept-Ranges: bytes


DEBUG Content-Length: 54611440
INFO Content-Length: 54,611,440
DEBUG downloading: 1,056,768
...
DEBUG downloading: 52,838,400
DEBUG downloading: 53,895,168
DEBUG Done downloaded: 54,611,440
INFO Saved: /home/yukio/Videos/script/720p.h264.mp4
INFO FileSize: 54,611,440
INFO Download finished.
```


### 2. ダウンロード処理をモジュール化する

複数のURLからマルチスレッドでダウンロードするときにはダウンロード処理を単独のモジュールとしたほうが扱いやすくなります。

ダウンロードに関するバッファサイズ、ベースのリクエストヘッダーなどはソースコードに直書きせず、設定ファイルを記述するようにします。

#### 2-1. 設定ファイル


(1) 読み込みバッファサイズ等の設定ファイル

```conf/download_spec.json
{
  "bufferSize": "1024*16",
  "debugPrintBreakSize": "1024*1024"
}
```

(2) リクエストヘッダー設定ファイル

```conf/http_client.json
{
  "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0",
  "downloadHeaders": {
    "Accept": "*/*",
    "Accept-Language": "ja,en-US;q=0.7,en;q=0.3",
    "Accept-Encoding": "gzip, deflate, br, zstd",
    "Connection": "keep-alive"
  }
}
```

#### 2-2. 動画ダウンローダーモジュール

##### 2-2-1. モジュールレベル変数と共通関数

```httpclient/movie_client.py
import json
import logging
import os
import pprint

from http.client import HTTPResponse, IncompleteRead
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from urllib.parse import urlparse, ParseResult
from typing import Dict, Optional, Tuple

# 接続タイムアウト
CONN_TIMEOUT: float = 5.
# 設定ファイルで上書き
# conf/download_spec.json
# クラスレベルで上書き可能な設定値
buff_size: int = 1024 * 8
# @ 1MB
debug_print_break_size: int = 1024 * 1024

# リクエストヘッダー
# conf/http_client.json
req_headers: Dict[str, str] = {}


def basename_in_url(url: str, is_image: Optional[bool] = None) -> str:
    parsed: ParseResult = urlparse(url)
    # check file extension
    lastname: str = os.path.basename(parsed.path)
    if is_image:
        return lastname

    dot_pos: int = lastname.find(".")
    return lastname[:dot_pos] if dot_pos != -1 else lastname
```

##### 2-2-2. 動画ダウンローダークラス

+ クラスレベルの初期化処理
(1) ダウンローダー設定ファイルからモジュール変数を上書き
(2) リクエストヘッダー設定ファイル
+ コンストラクタ
    + 動画の保存先ディレクトリ
    + このクラス用のロガーオブジェクト
+ ダウンロード関数
    + 引数 
        + コンテンツURL
        + リファラーURL ※任意
    + 戻り値
      tuple(保存ファイル名, ファイルサイズ)


```python
class MovieDownloadClient(object):
    @classmethod
    def init(cls, conf_dir: str):
        global buff_size, debug_print_break_size
        global req_headers
        # ダウンローダ用設定値
        with open(os.path.join(conf_dir, "download_spec.json")) as fp:
            conf = json.load(fp)
        buff_size = eval(conf["bufferSize"])
        debug_print_break_size = eval(conf["debugPrintBreakSize"])
        # リクエストヘッダーなどの設定値
        with open(os.path.join(conf_dir, "http_client.json")) as fp:
            conf = json.load(fp)
        req_headers = conf["downloadHeaders"]
        req_headers["User-Agent"] = conf["userAgent"]

    def __init__(self, save_dir: str, logger: logging.Logger):
        self.save_dir = save_dir
        self.logger: logging.Logger = logger
        # モジュール変数のリクエストヘッダーをオブジェクトのヘッダーに設定する
        self.headers: Dict[str, str] = req_headers

    def download(self,
                 url: str,
                 referer_url: Optional[str] = None) -> Tuple[str, int]:
        self.logger.debug(f"Download url: {url}")
        if referer_url is not None:
            self.logger.debug(f"Referer url: {referer_url}")

        # リファラーの有無
        if referer_url is not None:
            self.headers['Referer'] = referer_url

        req: Request = Request(url, headers=self.headers)
        self.logger.debug("** Request headers **")
        self.logger.debug(pprint.pformat(req.headers, indent=2))

        resp: HTTPResponse = urlopen(req, timeout=CONN_TIMEOUT)
        self.logger.info(f"response.code: {resp.status}\n >> {url}")
        # python 3.9 で非推奨
        # if resp.getcode() != 200:
        if resp.status != 200:
            # 200以外はエラーとする
            raise HTTPError(
                url, resp.status, "Disable download!",
                resp.info(), None
            )

        self.logger.debug("** Response headers **")
        self.logger.debug(resp.info())
        # Content-Length ヘッダーチェック
        raw_content_len: str = resp.info()["Content-Length"]
        self.logger.debug(f"Content-Length: {raw_content_len}")
        if raw_content_len is None:
            # ダウンロードできない: 411 Length Required
            raise HTTPError(
                url, 411, "Server did not send Content-Length!",
                resp.info(), None
            )

        content_length: int = int(raw_content_len.strip())
        self.logger.info(f"Content-Length: {content_length:,}")

        # ファイル保存処理
        file_name: str = basename_in_url(url, is_image=True)
        save_path: str = os.path.join(self.save_dir, file_name)
        dl_size: int = 0
        show_cnt: int = 0
        with open(save_path, 'wb') as fp:
            while True:
                try:
                    buff: bytes = resp.read(buff_size)
                except IncompleteRead as e:
                    self.logger.warning(f"Downloaded: {dl_size}/{content_length}")
                    self.logger.error("Read Error: %r", e)
                    raise e

                if not buff:
                    break

                dl_size += len(buff)
                show_cnt += len(buff)
                if show_cnt > debug_print_break_size:
                    self.logger.debug(f"downloading: {dl_size:,}")
                    show_cnt = 0

                fp.write(buff)
                if dl_size >= content_length:
                    self.logger.debug(f"Done downloaded: {dl_size:,}")
                    break

        return save_path, content_length
```


#### 2-3. メインスクリプト

モジュール化したダウンローダー用に修正したメインスクリプト

```DownloadVideo.py
import argparse
import logging
import os

from urllib.error import HTTPError, URLError

from httpclient import movie_client

SAVE_DIR: str = os.path.expanduser("~/Videos/script")

app_logger: logging.Logger = logging.getLogger(__name__)
handler: logging.Handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)s %(message)s'))
app_logger.addHandler(handler)


def main():
    parser: argparse.ArgumentParser = argparse.ArgumentParser()
    # 動画URL
    parser.add_argument("--url", type=str, required=True,
                        help="Download Video URL.")
    # リファラーURL ※任意
    parser.add_argument("--referer-url", type=str,
                        help="Referer URL with video URL, optional.")
    # DEBUG出力するか: 指定があれば出力する
    parser.add_argument("--is-debug", action='store_true',
                        help="Output DEBUG.")
    args: argparse.Namespace = parser.parse_args()
    is_debug: bool = args.is_debug
    if is_debug:
        app_logger.setLevel(logging.DEBUG)
    else:
        app_logger.setLevel(logging.INFO)

    # ダウンローダー初期化
    #  (1) 読み込みバッファサイズを設定ファイルから読み込み
    #  (2) リクエストヘッダーを設定ファイルから読み込み
    movie_client.MovieDownloadClient.init(conf_dir="conf")
    # ダウンローダーオブジェクト生成
    client = movie_client.MovieDownloadClient(SAVE_DIR, app_logger)
    video_url: str = args.url
    try:
        saved_path, file_size = client.download(video_url, referer_url=args.referer_url)
        app_logger.info(f"Saved: {saved_path}")
        app_logger.info(f"FileSize: {file_size:,}")
        app_logger.info("Download finished.")
    except HTTPError as err:
        # エラー時のレスポンスコート
        app_logger.warning(f"{err}\n >> {video_url}")
        app_logger.warning("** Response headers **")
        app_logger.warning(f"{err.headers.as_string()}")
    except URLError as err:
        app_logger.warning(f"{err.reason}\n >> {video_url}")
    except Exception as e:
        app_logger.error(f"{e}\n >>  {video_url}")


if __name__ == '__main__':
    main()
```


#### 最後に

今回紹介した動画ダウンローダーはネットで公開されているコンテンツを対象にしています。プラウザでユーザによる認証などの操作無しで動画が再生可能であれば基本的にダウンロードが可能です。

urllib パッケージ、http.clent パッケージ の各モジュールのソースコードを見ればどのような状況でどの種類の例外をスローするのかがわかります。

次回は movie_client モジュールを使いマルチスレッドで複数の動画を一括ダウンロードする python スクリプトを紹介したいと思います。


今回紹介したスクリプトのソースコードを下記 GitHub リポジトリで公開しています。

[(GitHub) pipito-yukio / qiita-posts / python / urllib_http](https://github.com/pipito-yukio/qiita-posts/tree/main/python/urllib_http)

ソース一覧
```
urllib_http/
├── README.md
└── src
    ├── project
    │   ├── DownloadVideo.py
    │   ├── DownloadVideo_main.py
    │   ├── conf
    │   │   ├── download_spec.json
    │   │   └── http_client.json
    │   └── httpclient
    │       ├── __init__.py
    │       └── movie_client.py
    └── requirements.txt
```