プロフィール

髭山髭人(ひげひと)

自分の書いた記事が、一人でも誰かの役に立てば...
活動信条の一つとして「貴方のメモは、誰かのヒント」というのがあります。

このサイトについて

本家HP packetroom.net から切り離した いわゆる技術メモ用のブログで、無料レンタルサーバーにて運用しています。広告表示はその義務なのでご容赦。
XREA さんには長年お世話になっています

js+ブラウザだけでディレクトリ階層復元を伴うファイルDLを実行

概要

ブラウザ上の html (というかJavaScript) から、フォルダを作成しつつファイルをDL配置するような処理を実現したかった。
「あ~ブラウザ上のJavaScriptでもフォルダ作れね~かな~」とか調べてたら、
(執筆時点で)Chromeで実装されている 実験的機能 を使えば可能っぽかったので記事に。

※ Node.js とか Deno さんの話はしてないです(´・ω・`)

前提注意

  • File System Access API の 🧪 実験的機能
    https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
  • 動作は HTTPS 上限定
    file:///http:// スキーマでは動作しません
    ローカルなら、オレオレ証明書を咬ませた仮想ホスト,ドメインなり、
    フツーにSSL(TLS)証明書OKなクラウドなりレンタル鯖なりで動かしてください
    え? 適当なサイトの Console上から動かす..? まぁあなたが良ければそれでも。

その他留意

  • 実行にユーザーアクションが必要
    即時実行は許可されておらず、ボタンクリックして動作...等を挟まないと以下の様に注意されます。

    Uncaught (in promise) DOMException: Failed to execute 'showDirectoryPicker' on 'Window': Must be handling a user gesture to show a file picker.
    ※ ↓ 訳
    Uncaught (in promise) DOMException: Window' で 'showDirectoryPicker' の実行に失敗しました。ファイル ピッカーを表示するためのユーザー ジェスチャを処理する必要があります。

  • ユーザー許可作業が必要
    「このサイト、お宅のフォルダ(とその配下を全て)操作したい言うてはります。どないしま?」
    と、ブラウザから尋ねられるので、(初回の)実行毎に許可 & 権限を渡すフォルダ先を指定する必要が出てきます。

  • ちゃんとDLできるかは相手サーバーのオリジン許可設定次第
    当たり前の話ですが「お前どっからアクセスしとんねん!許さへんで!」
    と、蹴られるサーバー設定になっていればアクセス(DL)できません(´・ω・`)

    Response Headers 部分で access-control-allow-origin: * みたいになってれば許されるハズ。
    普通、他のドメイン(というかオリジン?リファラー系?)から参照される事って想定していない所の方が多そうなので...

    ちな fetch 部分オプションを mode:"no-cors" ってやっても、
    虚無が返って来るだけ(露骨に失敗していないように振る舞うだけで結局実態は来ない)なので意味ないです。
    ( 0KB のファイルが生成されるだけ )

このスクリプトで出来る事

// ※ あくまで例なので、存在しないファイルです。
const _dlUrlList = [
    "https://example.com/somefile.txt",
    "https://example.com/somedir/hoge.txt",
    "https://foo.example.com/somedir/hoge.txt",
];

上記のようなDL対象ファイルURLを、配列で定義させて実行。
すると、ブラウザで許可/指定したフォルダの配下に、

なんちゃら/example.com/somefile.txt
なんちゃら/example.com/somedir/hoge.txt
なんちゃら/foo.example.com/somedir/hoge.txt

のように、DLされたファイルがフォルダ階層を伴った状態で保存される
処理進捗は DevTools の Console でも眺めててください。

失敗したものが分かるように _dlFailedInfo 配列に情報入れてます。
これも同じく Console とかで確認してください

組んだ実処理

html 部分

以下の様な、テキトーに作った .html へ読み込ませてください

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>FileAPI-Fecth-DL</title>
    <script src="./main.js"></script>
</head>
<body>
</body>
</html>

js 部分

./main.js 部分はこちら

ソース内で example.com ドメインの存在しないファイルをDL対象に指定しているので、
なんかテキトーにオリジン許可されているサーバーのファイルに置き換えてください。

// ページ上のどこかでもクリックしたら一度だけ動作するようにした ( 雑 )
// 適宜、ボタンのクリックイベとかから読むように改造したほうが良いかも?
window.onload = ()=>{
    document.addEventListener("click",()=>{
        if(!window.started){
            mainProcess();
            window.started = true;
        }
    });
};

// 何らかの理由で(DL/保存)に失敗したファイルURLをメモるだけの配列
const _dlFailedInfo = [];

// メイン処理
// DLする為の定義を1アイテムずつ作成
async function mainProcess(){
    const _fHandle = await window.showDirectoryPicker({startIn:"downloads"}).catch((_err)=>{ console.warn("許可されんかった"); });
    console.log(_fHandle);
    if(!_fHandle){ return console.log("権限を得られませんでした ~おわり~");  }

    // 本例では、ここでURL定義を羅列
    const _dlUrlList = [
        "https://example.com/somefile.txt",
        "https://example.com/somedir/hoge.txt",
        "https://foo.example.com/somedir/hoge.txt",
    ];

    const _prefixDirName = "なんちゃら"; // 何か目印となる統括フォルダ名付けたければ
    const _dlTargetInfoList = [];

    // DL用関数に放り投げるために以下3つを構築
    // "handle"     ファイルハンドル(権限)
    // "file_path"  DLしたフォルダ階層とファイル名を司るPath的文字列
    // "url"        対象ファイルのURL
    _dlUrlList.forEach(_url=>{
        const _baseWritePath = _url.split(/https?:\/\//).pop();
        const _dlPath = _prefixDirName + "/" + _baseWritePath;
        console.log(_dlPath);
        _dlTargetInfoList.push({
            "handle" : _fHandle ,
            "file_path" : _dlPath ,
            "url" : _url
        });
    });

    (async()=>{
        console.log("===作業開始===");
        await _dlTargetInfoList.reduce((promise, _dlInfo , _currentIndex) => {
            return promise.then(async () => {
                await _fileDonwload( _dlInfo , _currentIndex);
            });
        }, Promise.resolve());
        console.log("===作業終了===");
        console.log("DL保存失敗件数" , _dlFailedInfo.length );
    })();

}

// 1件ずつDLする Promiseを返す。
// 単純なDLではなく ファイルハンドルを介する(利用する)事で、その権限範囲内で指定階層+指定ファイル名にて配置される。
// サーバー側が他オリジン(実行元)からのアクセスを許可していなければダメなのでサイトによっては注意
function _fileDonwload( _dlInfo , _currentIndex ){

    const _fHandle = _dlInfo["handle"];
    const _savefilePath = _dlInfo["file_path"];
    // ※ File System Access API 自体が https でないと動かない ≒ http だとMix mixed content warning になるので。
    const _url = _dlInfo["url"].replace("http:","https:");
    // === 保存先のパスおよびファイル名の分解精査
    let _dirPaths = [];
    let _fileName = _savefilePath;
    const _removeCharRegExp = new RegExp(/[\\\:\*\?"<>\|]/);
    if(_savefilePath.includes("/")){
        const _paths = _savefilePath.split("/").filter(_str=> _str.length > 0).map(_str=>_str.replace( _removeCharRegExp , "" ));
        _fileName = _paths.pop();
        if(_fileName.length < 1){ throw `${_savefilePath} ファイル名が不正です`; }
        _dirPaths = _paths;
    }
    // === ファイル(ディレクトリ)ハンドル先の指定階層にファイルをDL保存
    return new Promise((resolve,reject)=>{
        setTimeout(async()=>{
            let _sucess = false;
            try{
                let _handle = _fHandle;
                for (let _i = 0; _i < _dirPaths.length; _i++) {
                    const _pathStr = _dirPaths[_i];
                    _handle = await _handle.getDirectoryHandle(_pathStr, {create: true});
                }
                // 保存先フォルダのハンドルが出そろう
                const _writable = await _handle.getFileHandle(_fileName , {create:true}).then(_fh=>_fh.createWritable());
                const _res = await fetch(_url , { mode:"cors" }).then((_res)=>{
                    if(!_res.ok){ console.warn( "失敗" , _res ); }
                    else{ return _res; }
                });
                if(_res){
                    await _res.body.pipeTo(_writable);
                    console.log("DL保存" , _currentIndex , _url );
                    _sucess = true;
                }
            }catch(_err){
                console.warn(_err);
                console.warn( _dirPaths );
            }
            if(!_sucess){
                // 失敗したアイテムを記録
                console.warn("DL/保存失敗判定" , _currentIndex , _url )
                _dlFailedInfo.push( _url );
            }
            resolve();
        },100);
    });
};

ほか技術的な事

権限を渡すフォルダの開始位置

showDirectoryPicker(){startIn:"downloads"} オプションを与えています。
ブラウザから尋ねられる権限を渡すフォルダ指定用ダイアログの初期位置に相当します。

処理例だと名前の通り「ブラウザのDLフォルダ」になっています。

https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker

開始位置は以下の物を設定できる様です。

"desktop"
"documents"
"downloads"
"music"
"pictures"
"videos"

保存ファイル,フォルダ名

フォルダファイル名は、一部記号が許可されていません
: とか ? とかアウトなので、事前に以下正規表現で消し飛ばしています。

const _removeCharRegExp = new RegExp(/[\\\:\*\?"<>\|]/);

http は https に強制

一番最初に書きましたが、主要処理で使っているファイル操作APIは https:// でのみ動作します。
ブラウザ(セキュリティ)的に https:// コンテンツ内部から http:// にアクセスする事は好ましくなく、
Mix mixed content warning で注意されてしまいます。
これらの理由から、http://https:// へ強制変換+アクセスしています。