Python Tips: Python で FTP のダウンロードを自動化したい

Python を使って FTP でファイルのダウンロードを行う方法についてです。以前次のような記事を書きましたが、今回はそのダウンロード版です。

Python には FTP を使うためのそのままずばり ftplib というパッケージが同梱されており、これを使えば Python でかんたんに FTP を使ったファイル送受信が行なえます。しかし、提供されているインタフェースが低レイヤーな感じで、 FTP に詳しい人以外にはあまり直感的ではありません。特にフォルダを扱う処理はかんたんなものでも数行〜のコードを書く必要があります。

今回はそんな ftplib を使ってファイルやフォルダをダウンロードする方法について説明します。アップロードについては上の記事に書いているので興味のある方は参考にしてください。

ファイルをダウンロードする

まずは単一のファイルをダウンロードする方法からです。

from ftplib import FTP_TLS

config = {
    'host': 'xx.xx.xx.xx',
    'user': 'username',
    'passwd': 'password',
}

# sample.txt ファイルをダウンロードする
with FTP_TLS(**config) as ftp:
    with open('sample.txt', 'wb') as fp:
        ftp.retrbinary('RETR sample.txt', fp.write)

ファイルをダウンロードするには ftplib.FTP_TLS (以下 FTP_TLS )のインスタンスを生成してサーバーに接続した上で retrbinary() メソッドを使います。

FTP_TLSftplib.FTP (以下 FTP )のサブクラスで TLS をサポートした FTP (いわゆる「 FTPS 」)を行うためのクラスです。いまどき FTP を使うべき理由も無いので原則 ftplib.FTP_TLS 一択と思ってよいでしょう。 FTP_TLS の初期化時には host user passwd の引数を渡します。

retrbinary() メソッドの第一引数には 'RETR ファイルパス' という形式の文字列を渡します。「ファイルパス」というのはサーバー側でのファイルパスです。パスセパレータには / が使えます。

retrbinary() の第二引数には保存先のファイルオブジェクトの write() メソッドを渡します。 retrbinary() はデータをバイナリモードで取得してくるので、保存先のファイルオブジェクトは open('ファイル名', 'wb') で開いておく必要があります。 ちなみに、 retrbinary() と似たメソッドに retrlines() というものもあり、こちらは ASCII モードでデータを取得します。対象のファイルがテキストデータであることがわかっている場合は retrlines() を使ってもよいかもしれません。

ファイル名に日本語を含むファイルをダウンロードする

ファイル名に日本語を含む(正確には文字コード latin-1 では正しく扱えない文字を含む)ファイルを扱う場合は注意が必要です。なぜなら FTP_TLS は( FTP も同じ)デフォルトで文字エンコーディングに latin-1 を使うからです。ファイル名に日本語を含むファイルを扱うときは適切なエンコーディングを使うように設定する必要があります。

参考:

from ftplib import FTP_TLS

ENCODING = 'utf8'

config = {
    'host': 'xx.xx.xx.xx',
    'user': 'username',
    'passwd': 'password',
}

# サンプル.txt ファイルをダウンロードする
with FTP_TLS(**config) as ftp:
    ftp.encoding = ENCODING
    with open('サンプル.txt', 'wb') as fp:
        ftp.retrbinary('RETR サンプル.txt', fp.write)

ポイントは ftp.encoding = ENCODING の行です。ここでは UTF8 に設定していますがサーバーの設定に合わせて適切なものを使うようにします。

ディレクトリをダウンロードする

続いて特定のディレクトリ(=フォルダ)以下のファイルをまとめてダウンロードしたい場合です。 Python 3.8 の時点ではこの用途にそのまま使えるメソッドは用意されていないため「ファイルをリストアップしてひとつずつダウンロードする」処理を自分で書く必要があります。

次の関数 pull_files() は特定のディレクトリ以下のファイルを一式ダウンロードしてくるものです。

def pull_files(ftp: FTP_TLS, remote_dir: str, local_root: Path) -> None:
    """特定のディレクトリ以下のファイルを指定されたパスに一式ダウンロードする"""
    ftp.cwd(remote_dir)

    def get_full_path(path, filename):
        return '{}/{}'.format(path, filename)

    # BFS ですべてのファイルをダウンロードする
    paths = ['.']
    while paths:
        path = paths.pop(0)
        for filename, info in ftp.mlsd(path):
            # カレントディレクトリ or ペアレントディレクトリ
            if info['type'] in ('cdir', 'pdir'):
                continue

            # ディレクトリ
            if info['type'] == 'dir':
                paths.append(get_full_path(path, filename))

            # ファイル
            if info['type'] == 'file':
                full_path = get_full_path(path, filename)
                print(full_path)
                local_path = local_root / full_path
                local_path.parent.mkdir(parents=True, exist_ok=True)
                with local_path.open('wb') as fp:
                    ftp.retrbinary('RETR {}'.format(full_path), fp.write)

mlsd() は指定されたディレクトリ直下のファイル・ディレクトリ一式を返してくれるメソッドです。 nlst() dir() 等同様のメソッドがいくつか用意されていますが、サーバー側が対応していれば mlsd() が最も豊富な情報を返してくれるので使えるならこれを使うのがよいと思います。 pull_files() は次のように使用します。

from ftplib import FTP_TLS
from pathlib import Path

ENCODING = 'utf8'

config = {
    'host': 'xx.xx.xx.xx',
    'user': 'username',
    'passwd': 'password',
}

with FTP_TLS(**config) as ftp:
    ftp.encoding = ENCODING
    remote_dir = 'home'
    local_root = Path('archive').resolve()
    local_root.mkdir()
    pull_files(ftp, remote_dir, local_root)

以上です。

GitHub Gist に私が実際に作って使ったスクリプトを汎用化したサンプルを置いているので興味のある方はそちらも参考にしてみてください。

当然のことながら、中身を理解せずにコピペで利用するのはお控えください。参考にされる際は自己責任でお願いします :D