DropBoxの権限調査用スクリプト

 もはや自分でコードを書くことも少なく生成AIに任せきってます。DropBoxでディレクトリを確認する必要が出てきて、ひとまず必要になるかなということでGoogleColabから実行するためにpythonスクリプトをGeminiに生成してもらったやつ。

ディレクトリを再帰的にたどって全体取得するパターンと、階層を指定してその階層分取ってくるパターンにしてます。使い捨てスクリプトなので、かなり雑いです。


Dropbox_Permissions_Collector.ipynb Python / Google Colab
## 1. APIKEYの発行 ブラウザで Dropbox App Console(公式) にアクセスします。 右上の 「Create app」 ボタンを押します。 設定を以下のように選びます: 1. Choose an API: Scoped access commons を選択 2. Choose the type of access: Full Dropbox(または必要に応じて App folder)を選択 3. Name your app: 適当な名前(例:colab-folder-get など)を入力 下の 「Create app」 をクリックします。 アプリの管理画面が開いたら、上部タブの 「Permissions(権限)」 をクリックし、以下の項目にチェックを入れて一番下の「Submit」を押します: files.metadata.read(ファイルやフォルダの情報を読み取る権限) 上部タブの 「Settings」 に戻り、少し下にスクロールして 「Generated access token」 という項目にある 「Generate」 ボタンを押します。 表示された長い英数字の文字列(これがAPIキーです)をコピーします。 💡 コピーしたAPIキーは、Colabの左メニューにある「鍵マーク(シークレット)」に、名前 DROPBOX_API_KEY としてバリューに貼り付けて保存してください。 -------------------------------------------------- ## 2. ツールインストール !pip install dropbox -------------------------------------------------- ## 3. APIキー処理_OAUTH import dropbox from google.colab import userdata APP_KEY = userdata.get('DROPBOX_APP_KEY') APP_SECRET = userdata.get('DROPBOX_APP_SECRET') # 永続トークン(リフレッシュトークン)を要求する認証URLを生成 auth_flow = dropbox.DropboxOAuth2FlowNoRedirect( APP_KEY, APP_SECRET, token_access_type='offline' # ←これが「永続(リフレッシュトークン)」を指定するキーワードです ) authorize_url = auth_flow.start() print("1. 以下のURLをブラウザで開き、会社のDropboxアカウントへのアクセスを『許可』してください:") print(authorize_url) print("\n2. 画面に表示された「アクセスコード」をコピーして、下の入力欄に貼り付けて Enter を押してください。") auth_code = input("アクセスコードを入力: ").strip() try: oauth_result = auth_flow.finish(auth_code) print("\n🎉 成功しました!【永続トークン(Refresh Token)】を取得しました。") print("--------------------------------------------------") print(oauth_result.refresh_token) print("--------------------------------------------------") print("上記の長い文字列をすべてコピーし、Colabの左メニュー(鍵マーク)に") print("名前:【DROPBOX_REFRESH_TOKEN】として新規追加し、値を貼り付けて保存してください。") except Exception as e: print(f"❌ エラーが発生しました: {e}") -------------------------------------------------- ## 4. フォルダのみ抽出処理プログラム # 💡 実行すると完了まで1時間程度が必要となるので、実行時には要注意 !pip install dropbox -q import dropbox from dropbox.exceptions import ApiError, RateLimitError from google.colab import userdata import pandas as pd import time import re import os import pickle import logging import threading from contextlib import contextmanager from concurrent.futures import ThreadPoolExecutor, as_completed from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter from tqdm.auto import tqdm # 接続プール満杯の警告(動作には影響しない)を非表示にする logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR) # ========================================== # ⚙️ 設定項目 # ========================================== # ※ご自身の管理者メールアドレスに打ち替えてご使用ください TARGET_ADMIN_EMAIL = "admin@example.com" TARGET_FOLDERS = [ "Dropbox フォルダー", ] EXCEL_FILE = "dropbox_folder_permissions_recursive.xlsx" # --- 取得する範囲 --- # None : 全階層を再帰的に取得(従来どおり) # 整数N: 対象フォルダの直下を1階層目として、N階層目までだけ取得(例: 3 → 3階層目まで) MAX_DEPTH = None # 再帰で全階層取得 # MAX_DEPTH = 5 # 5階層目まで取得 # --- 権限の出力 --- PERM_CHECK_DEPTHS = list(range(1, 31)) # 権限列を付ける階層(実際の最大階層まで作られる) SHOW_PERMISSION_DETAILS = True # True: メンバー詳細まで取得 / False: 「あり/なし」のみ # --- フォルダ一覧の取得(高速化まわり)--- PARALLEL_LISTING = True # True: 並列で取得 / False: 単一スレッドで取得 LIST_WORKERS = 8 # 並列数(Dropbox SDKの接続プールが8のため、8以下を推奨) PERM_WORKERS = 8 # 権限取得の並列数(同上) LIST_PAGE_LIMIT = 2000 # 1ページあたりの取得件数(APIの上限は2000) SPLIT_DEPTH = 3 # 【全階層取得のとき】何階層目までを非再帰で展開してから、再帰取得を並列化するか MAX_SPLIT_TASKS = 3000 # 【全階層取得のとき】分割後のサブツリー数がこれを超えたら、それ以上深く展開しない SLOW_TOP_N = 10 # 【全階層取得のとき】所要時間が長かったサブツリーを何件表示するか # --- キャッシュ(フォルダ構造のみ)--- USE_CACHE = True # True: 前回取得したフォルダ構造があれば再利用 / False: 取り直す CACHE_FILE = "dropbox_scan_cache.pkl" # ========================================== def validate_settings(): if MAX_DEPTH is not None and (isinstance(MAX_DEPTH, bool) or not isinstance(MAX_DEPTH, int) or MAX_DEPTH < 1): raise ValueError("MAX_DEPTH は None(全階層)または 1 以上の整数で指定してください。") validate_settings() try: APP_KEY = userdata.get('DROPBOX_APP_KEY') APP_SECRET = userdata.get('DROPBOX_APP_SECRET') REFRESH_TOKEN = userdata.get('DROPBOX_REFRESH_TOKEN') except Exception as e: print("❌ Colabの鍵マークから APP_KEY, APP_SECRET, REFRESH_TOKEN が正しく設定されているか確認してください。") raise e # ------------------------------------------ # 共通ユーティリティ # ------------------------------------------ def clean_for_excel(text): if not isinstance(text, str): return text return re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text) def call_with_retry(fn, *args, max_retries=6, **kwargs): """レート制限(429)は指定秒数待って再試行。通信系の一時エラーも指数バックオフで再試行。""" backoff = 1.0 for attempt in range(max_retries): try: return fn(*args, **kwargs) except RateLimitError as e: time.sleep((e.backoff or backoff) + 0.5) backoff *= 1.5 except ApiError: raise # パス不正・権限なし等は再試行しても無駄 except Exception: if attempt == max_retries - 1: raise time.sleep(backoff) backoff *= 1.5 raise RuntimeError("リトライ上限に達しました(レート制限が続いています)") @contextmanager def heartbeat(msg, interval=15): """応答待ちの間、一定間隔で経過時間を表示(固まって見えないように)""" stop = threading.Event() t0 = time.time() def run(): while not stop.wait(interval): print(f" ⏳ {msg} ... {int(time.time() - t0)}秒経過", flush=True) th = threading.Thread(target=run, daemon=True) th.start() try: yield finally: stop.set() # ------------------------------------------ # キャッシュ(取得した範囲も一緒に記録する) # ------------------------------------------ def load_cache(): if USE_CACHE and os.path.exists(CACHE_FILE): try: with open(CACHE_FILE, "rb") as f: data = pickle.load(f) folders = data.get("folders", {}) saved = time.strftime('%Y-%m-%d %H:%M', time.localtime(os.path.getmtime(CACHE_FILE))) print(f"💾 キャッシュを読み込みました({saved} 保存 / {len(folders)} 件のスキャン結果): {CACHE_FILE}") print(" ※ フォルダ構造は保存時点の内容です。最新化したい場合は USE_CACHE=False にしてください。") return {"folders": folders} except Exception: print("⚠️ キャッシュの読み込みに失敗したため、新規取得します。") return {"folders": {}} def save_cache(cache): try: with open(CACHE_FILE, "wb") as f: pickle.dump({"folders": cache["folders"]}, f) except Exception as e: print(f"⚠️ キャッシュ保存に失敗: {e}") CACHE = load_cache() perm_cache = {} # 権限情報はこの実行中だけ保持(毎回取得し直す) def _cache_entry(entry): """(取得した階層[None=全階層], フォルダ一覧) を返す。旧形式(listそのもの)は全階層として扱う。""" if isinstance(entry, dict): return entry.get("depth"), entry.get("folders", []) return None, entry def _coverage(depth): return float("inf") if depth is None else depth def cache_lookup(display_name, path_lower): """今回の MAX_DEPTH を満たすキャッシュがあればフォルダ一覧を返す。なければ None。""" entry = CACHE["folders"].get(display_name) if entry is None: return None depth, folders = _cache_entry(entry) if _coverage(depth) < _coverage(MAX_DEPTH): return None # キャッシュの方が浅い → 使えない if MAX_DEPTH is None: return folders base = path_lower.count('/') return [f for f in folders if f[1].count('/') - base <= MAX_DEPTH] def cache_store(display_name, folders): """既存キャッシュの方が広い範囲(深い)なら上書きしない。""" old = CACHE["folders"].get(display_name) if old is not None: old_depth, _ = _cache_entry(old) if _coverage(old_depth) > _coverage(MAX_DEPTH): print(" ℹ️ 既存のキャッシュの方が深い範囲を含むため、キャッシュは上書きしません。") return CACHE["folders"][display_name] = {"depth": MAX_DEPTH, "folders": folders} save_cache(CACHE) # ------------------------------------------ # フォルダ一覧の取得 # ------------------------------------------ def to_tuple(entry): sid = entry.sharing_info.shared_folder_id if entry.sharing_info else None return (entry.path_lower, entry.path_display, sid) def list_children(dbx, path_lower): """path_lower 直下のフォルダだけ返す(非再帰)。""" out = [] res = call_with_retry(dbx.files_list_folder, path_lower, recursive=False, limit=LIST_PAGE_LIMIT) while True: for e in res.entries: if isinstance(e, dropbox.files.FolderMetadata): out.append(to_tuple(e)) if not res.has_more: break res = call_with_retry(dbx.files_list_folder_continue, res.cursor) return out def list_limited(dbx, folder, remaining): """folder 配下を remaining 階層分だけ、非再帰で順に展開する(単一スレッド)。""" out = [] if remaining <= 0: return out kids = list_children(dbx, folder[0]) out.extend(kids) for k in kids: out.extend(list_limited(dbx, k, remaining - 1)) return out def list_subtree(dbx, path_lower, pbar=None): """path_lower 配下を再帰的に列挙し、フォルダだけ返す(path自体は含まない)。""" out = [] res = call_with_retry(dbx.files_list_folder, path_lower, recursive=True, limit=LIST_PAGE_LIMIT) while True: n = 0 for e in res.entries: if isinstance(e, dropbox.files.FolderMetadata): out.append(to_tuple(e)) n += 1 if pbar is not None and n: pbar.update(n) if not res.has_more: break res = call_with_retry(dbx.files_list_folder_continue, res.cursor) return out def timed_subtree(dbx, folder, pbar): t0 = time.time() res = list_subtree(dbx, folder[0], pbar) return res, time.time() - t0 def collect_folders(dbx, root_lower): """ 戻り値: (フォルダ一覧, 取得に失敗したフォルダ[(表示パス, エラー内容)]) """ limited = MAX_DEPTH is not None root = (root_lower, root_lower, None) if not PARALLEL_LISTING: if limited: print(f" 🔍 フォルダ構造を取得中(単一スレッド / {MAX_DEPTH}階層目まで)...") with heartbeat("Dropbox側で走査中"): return list_limited(dbx, root, MAX_DEPTH), [] print(" 🔍 フォルダ構造を取得中(単一スレッド)...") with heartbeat("Dropbox側で走査中"): with tqdm(desc=" 検出フォルダ数", unit="件") as pbar: return list_subtree(dbx, root_lower, pbar), [] all_folders, failed, timings = [], [], [] t_start = time.time() levels = MAX_DEPTH if limited else SPLIT_DEPTH if limited: print(f" 🔍 {MAX_DEPTH}階層目までを {LIST_WORKERS} 並列で取得します...") else: print(f" 🔍 サブツリーに分割して {LIST_WORKERS} 並列で取得します(分割階層: {SPLIT_DEPTH})...") with tqdm(desc=" 検出フォルダ数", unit="件") as pbar: # ---- 浅い階層を非再帰で1階層ずつ展開 ---- frontier = [root] for lvl in range(1, levels + 1): if not frontier or (not limited and len(frontier) > MAX_SPLIT_TASKS): break next_frontier = [] with ThreadPoolExecutor(max_workers=LIST_WORKERS) as ex: futs = {ex.submit(list_children, dbx, f[0]): f for f in frontier} for fut in as_completed(futs): f = futs[fut] try: kids = fut.result() except Exception as e: failed.append((f, e, (levels - lvl + 1) if limited else None)) continue all_folders.extend(kids) next_frontier.extend(kids) pbar.update(len(kids)) frontier = next_frontier t1 = time.time() - t_start if limited: pbar.write(f" ✅ {MAX_DEPTH}階層目までの取得が完了({len(all_folders):,}件 / {t1:.0f}秒)") else: pbar.write(f" ✅ 浅い階層の展開が完了({len(all_folders):,}件 / {t1:.0f}秒)" f" → {len(frontier):,} 個のサブツリーを並列で再帰取得します") # ---- 各サブツリーを再帰取得 ---- done = 0 with ThreadPoolExecutor(max_workers=LIST_WORKERS) as ex: futs = {ex.submit(timed_subtree, dbx, f, pbar): f for f in frontier} for fut in as_completed(futs): f = futs[fut] try: sub, sec = fut.result() all_folders.extend(sub) timings.append((sec, len(sub), f[1])) except Exception as e: failed.append((f, e, None)) done += 1 pbar.set_postfix(サブツリー=f"{done}/{len(frontier)}") # ---- 失敗分を1回だけ単独で再試行 ---- still_failed = [] if failed: print(f" 🔁 取得に失敗した {len(failed)} 件を再試行します...") for f, _, remaining in failed: try: if remaining is None: all_folders.extend(list_subtree(dbx, f[0])) else: all_folders.extend(list_limited(dbx, f, remaining)) except Exception as e: still_failed.append((f[1], str(e))) # 重複除去 uniq = {} for f in all_folders: uniq[f[0]] = f all_folders = list(uniq.values()) total = time.time() - t_start print(f" ✅ フォルダ構造の検出が完了しました(合計: {len(all_folders):,} 件 / {total / 60:.1f}分)") if timings: timings.sort(reverse=True) print(f" 🐢 所要時間が長かったサブツリー上位{min(SLOW_TOP_N, len(timings))}件:") for sec, n, disp in timings[:SLOW_TOP_N]: print(f" {sec / 60:6.1f}分 {n:>8,}件 {disp}") print(f" ※ 最長サブツリーが全体の所要時間に近い場合は、そこで待たされています。" f"SPLIT_DEPTH を増やすと分割が細かくなります。") return all_folders, still_failed # ------------------------------------------ # 権限情報の取得 # ------------------------------------------ def fetch_permission(dbx, shared_id): if shared_id in perm_cache: return try: info = call_with_retry(dbx.sharing_list_folder_members, shared_id, limit=1000) groups, users = list(info.groups), list(info.users) while info.cursor: info = call_with_retry(dbx.sharing_list_folder_members_continue, info.cursor) groups.extend(info.groups) users.extend(info.users) targets = [f"[グループ]{g.group.group_name}({g.access_type._tag})" for g in groups] targets += [f"[ユーザー]{u.user.display_name}({u.access_type._tag})" for u in users] perm_cache[shared_id] = ("あり: " + ", ".join(targets)) if targets else "あり(詳細なし)" except Exception: perm_cache[shared_id] = "あり(照会不可)" def batch_fetch_permissions(dbx, items): if not SHOW_PERMISSION_DETAILS: return todo = {it['shared_id'] for it in items if it['depth'] in PERM_CHECK_DEPTHS and it['shared_id'] and it['shared_id'] not in perm_cache} if not todo: return print(f" ⚡ 権限情報を並列取得中... ({len(todo)} 件の shared_id)") with ThreadPoolExecutor(max_workers=PERM_WORKERS) as ex: futures = [ex.submit(fetch_permission, dbx, sid) for sid in todo] for _ in tqdm(as_completed(futures), total=len(futures), desc=" 権限情報取得進捗"): pass def perm_text(item): if item['depth'] not in PERM_CHECK_DEPTHS: return None if not item['shared_id']: return "なし(継承)" if not SHOW_PERMISSION_DETAILS: return "あり" return perm_cache.get(item['shared_id'], "あり(照会不可)") # ------------------------------------------ # スキャン本体 # ------------------------------------------ def scan_target_zone(dbx, path_lower, display_name): scope = "全階層" if MAX_DEPTH is None else f"{MAX_DEPTH}階層目まで" print(f"\n📂 【{display_name}】のスキャンを開始します(取得範囲: {scope})...") folders = cache_lookup(display_name, path_lower) if USE_CACHE else None if folders is not None: print(f" 💾 キャッシュを使用します({len(folders):,} 件のフォルダ)") else: try: folders, failed = collect_folders(dbx, path_lower) except Exception as e: print(f"\n ※アクセスエラー: {e}") return [], 0 if failed: print(f"\n ⚠️ {len(failed)} 件のフォルダ配下を取得できませんでした(結果に含まれません):") for disp, err in failed[:10]: print(f" - {disp}: {err}") print(" ⚠️ 不完全なデータのため、キャッシュには保存しません。") else: cache_store(display_name, folders) if not folders: return [], 0 print(" ⚙️ 階層の計算と構造化を実行中...") folders = sorted(folders, key=lambda x: x[0].split('/')) base_depth = path_lower.count('/') items = [] max_depth = 0 for _, disp, sid in folders: parts = [clean_for_excel(p) for p in disp.strip('/').split('/') if p][base_depth:] depth = len(parts) max_depth = max(max_depth, depth) items.append({'parts': parts, 'depth': depth, 'shared_id': sid}) n_shared = sum(1 for it in items if it['shared_id']) deep_shared = sum(1 for it in items if it['shared_id'] and it['depth'] > 7) print(f" 📏 最大階層: {max_depth} / フォルダ: {len(items):,} 件 / " f"共有フォルダ: {n_shared:,} 件(うち8階層目以降: {deep_shared:,} 件)") batch_fetch_permissions(dbx, items) for it in items: it['perm'] = perm_text(it) return items, max_depth def build_table(items, max_depth): """行データ・重複判定フラグ・白字にするセルを、Excelに触れる前に純Pythonで一括計算する。""" rows, flags, dups, depths = [], [], [], [] prev = [""] * max_depth for it in items: vals = it['parts'] + [""] * (max_depth - len(it['parts'])) k = 0 while k < max_depth and vals[k] == prev[k]: k += 1 flags.append([1 if (vals[d] and d >= k) else 0 for d in range(max_depth)]) dups.append([d + 1 for d in range(k) if vals[d]]) depths.append(it['depth']) row = [] for d in range(1, max_depth + 1): row.append(vals[d - 1]) if d in PERM_CHECK_DEPTHS: row.append(it['perm'] if d == it['depth'] else "") rows.append(row) prev = vals return rows, flags, dups, depths def write_sheet(writer, sheet_name, items, max_depth): max_depth = max(max_depth, 1) columns, folder_col, perm_cols = [], {}, [] col = 1 for i in range(1, max_depth + 1): columns.append(f"{i}階層目") folder_col[i] = col col += 1 if i in PERM_CHECK_DEPTHS: columns.append(f"{i}階層目の権限") perm_cols.append(col) col += 1 data_cols = len(columns) flag_names = [f"_flag_{i}" for i in range(1, max_depth + 1)] if items: rows, flags, dups, depths = build_table(items, max_depth) table = [r + f for r, f in zip(rows, flags)] else: dummy = [] for d in range(1, max_depth + 1): dummy.append("対象データなし") if d in PERM_CHECK_DEPTHS: dummy.append("-") rows, dups, depths = [dummy], [[]], [1] table = [dummy + [0] * max_depth] clean_name = re.sub(r'[\\*?:/\[\]]', '', sheet_name)[:31] pd.DataFrame(table, columns=columns + flag_names).to_excel( writer, sheet_name=clean_name, index=False, startrow=1) ws = writer.book[clean_name] last_row = len(table) + 2 for d in range(1, max_depth + 1): letter = get_column_letter(data_cols + d) c = ws.cell(row=1, column=folder_col[d], value=f"=SUBTOTAL(109,{letter}3:{letter}{last_row})") c.font = Font(bold=True, color="002060") c.fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") c.alignment = Alignment(horizontal="center") header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") perm_fill = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") for c in range(1, data_cols + 1): h = ws.cell(row=2, column=c) h.font = Font(bold=True) h.fill = perm_fill if c in perm_cols else header_fill ws.auto_filter.ref = f"A2:{get_column_letter(data_cols)}{last_row}" ws.freeze_panes = "A3" white = Font(color="FFFFFF") for i, ds in enumerate(dups): r = 3 + i for d in ds: ws.cell(row=r, column=folder_col[d]).font = white if items: for i, depth in enumerate(depths): if depth > 1: ws.row_dimensions[3 + i].outlineLevel = min(depth - 1, 7) ws.sheet_properties.outlinePr.summaryBelow = False for c in range(1, data_cols + 1): longest = len(str(columns[c - 1])) for r in rows: v = r[c - 1] if v: longest = max(longest, len(str(v))) ws.column_dimensions[get_column_letter(c)].width = min(longest * 1.6 + 2, 60) for d in range(1, max_depth + 1): idx = data_cols + d ws.cell(row=2, column=idx, value="") ws.column_dimensions[get_column_letter(idx)].hidden = True def output_file(): if MAX_DEPTH is None: return EXCEL_FILE root, ext = os.path.splitext(EXCEL_FILE) return f"{root}_depth{MAX_DEPTH}{ext}" # ------------------------------------------ # メイン処理 # ------------------------------------------ try: start_time = time.time() dbx_team = dropbox.DropboxTeam( app_key=APP_KEY, app_secret=APP_SECRET, oauth2_refresh_token=REFRESH_TOKEN ) members_res = dbx_team.team_members_list() all_members = list(members_res.members) while members_res.has_more: members_res = dbx_team.team_members_list_continue(members_res.cursor) all_members.extend(members_res.members) target_member_id, matched_email = None, None clean_email = (TARGET_ADMIN_EMAIL or "").strip().lower() for m in all_members: if not m.profile.status.is_active(): continue if not clean_email or m.profile.email.lower() == clean_email: target_member_id = m.profile.team_member_id matched_email = m.profile.email break if not target_member_id: raise ValueError(f"指定されたメールアドレス '{TARGET_ADMIN_EMAIL}' を持つアクティブなチームメンバーが見つかりませんでした。") print(f"👤 実行ユーザー: {matched_email} (ID: {target_member_id})") dbx_user = dbx_team.as_user(target_member_id) root_id = dbx_user.users_get_current_account().root_info.root_namespace_id dbx = dbx_user.with_path_root(dropbox.common.PathRoot('namespace_id', root_id)) print("🔄 最上位ルートに正常接続しました。") # ルート直下から対象フォルダを検索 target_paths = {} res_root = call_with_retry(dbx.files_list_folder, '', recursive=False) while True: for entry in res_root.entries: if isinstance(entry, dropbox.files.FolderMetadata): name = entry.path_display.strip("/").split("/")[-1] for target_name in TARGET_FOLDERS: if target_name.lower() in name.lower(): target_paths[target_name] = (entry.path_lower, name) if not res_root.has_more: break res_root = call_with_retry(dbx.files_list_folder_continue, res_root.cursor) all_sheet_data = {} for target_name in TARGET_FOLDERS: if target_name not in target_paths: print(f"⚠️ 対象フォルダが見つかりません: {target_name}") continue path_lower, display_name = target_paths[target_name] items, max_d = scan_target_zone(dbx, path_lower, display_name) all_sheet_data[display_name] = (items, max_d) out_file = output_file() print("\n📊 データをExcelファイルに書き込み、フォーマットを行っています...") with pd.ExcelWriter(out_file, engine='openpyxl') as writer: for sheet_name, (items, max_d) in all_sheet_data.items(): write_sheet(writer, sheet_name, items, max_d) elapsed = time.time() - start_time print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print("🎉 【処理完了】階層別権限列付きExcelが完成しました!") print(f"⏱️ 総処理時間: {int(elapsed // 60)} 分 {int(elapsed % 60)} 秒") print(f"📁 出力ファイル: {out_file}") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") except Exception as e: print(f"\n❌ エラーが発生しました: {e}")




あまり需要はないかもしれないけどDropBoxのフォルダを整理するときに使えるんじゃないかとは思います。

コメント

このブログの人気の投稿

GASでGoogleDriveのサブフォルダとファイル一覧を出力する

マクロ経済学(IS-LM分析)

ローカルの掲示板作ってみました