フォルダ内の複数ファイルに同じ文字列が入っていて、まとめて置換したい

そんなときに使えるのが、今回紹介する「一括置換GUI(Python)」です。

このスクリプトは、.bat/.cmd/.txt/.docx/.xlsx のように拡張子が混在していても、同じ文字列を一括で置換できます。さらにGUI付きでドライラン(プレビュー)で事前に変更点を確認でき、バックアップも自動で作れるため、作業ミスを減らしやすい設計です。

この記事でできること(要点)

  • フォルダ配下を再帰的に走査して、対象ファイルをまとめて置換
  • 対応拡張子:.bat/.cmd/.txt/.docx/.xlsx
  • ドライラン(プレビュー)で「どのファイルのどこが変わるか」を確認(この時点では変更しない)
  • バックアップをフォルダ丸ごと作成(不要ならOFF可)
  • 大文字/小文字を無視して検索(case-insensitive)

注意(先に読んでおくと安心)

  • 一括置換は影響範囲が大きいので、最初は必ずドライラン→問題なければ実行の順で進めてください
  • Word(.docx)は置換方法の都合で装飾が崩れる可能性があります(後述)
  • 業務データに適用する場合は、権限・監査・バックアップなどの社内ルールに従ってください

動作環境・必要なもの

  • Windows(GUIはTkinter)
  • Python 3.x
  • Word対応:python-docx(任意)
  • Excel対応:openpyxl(任意)

インストール(Word/Excel対応を使う場合)

pip install python-docx openpyxl

※未インストールでもスクリプト自体は動きます。該当拡張子はログに [SKIP] と出して処理をスキップします。

使い方(GUIの手順)

1) スクリプトを起動する

例:batch_replace_gui.py として保存し、次で実行します。

python batch_replace_gui.py

2) 旧文字列 / 新文字列を入力

GUI上部で、置換したい「旧文字列」と「新文字列」を入力します。デフォルト値はコード側(OLD / NEW)で変更できます。

3) 置換対象の「フォルダ」を選択

ファイルではなくフォルダを選ぶ点に注意してください。フォルダ配下を再帰的に走査して置換します。

4) 最初は「ドライラン(プレビュー)」を押す(推奨)

ドライランではファイルは変更されません。ログに「どのファイルのどの部分が置換対象か」が表示されます。

たとえば、置換対象フォルダの状態が以下のように「旧文字列」を含んでいる想定です。

置換対象フォルダの例:ファイル内に旧文字列が含まれる

5) 問題なければ「置換を実行」

置換を実行すると、バックアップがONの場合はまずバックアップを作成し、その後に置換処理を行います。

完了後、バックアップフォルダと置換済みファイルが残ります。

バックアップが不要なら、GUIの「置換前にバックアップを作成」のチェックを外してください(ただし最初の運用はON推奨)。

仕様のポイント(事故りにくくする設計)

大文字/小文字を無視して置換

re.IGNORECASE を使い、旧文字列の大文字/小文字違いを吸収します。

バックスラッシュを含む置換でも事故りにくい

re.sub は置換文字列に \ が含まれると解釈が絡んで事故ることがあります。本スクリプトは「関数置換」を使い、エスケープ問題を避けやすくしています。

バックアップフォルダを自動除外

バックアップは _backup_YYYYMMDD_HHMMSS 形式で作成し、その配下は走査対象から除外します(バックアップをバックアップして無限増殖しないため)。

注意点(ここだけは押さえる)

テキスト系(.bat/.cmd/.txt)の文字コード

  • 読み取りは「UTF-8(BOM) / UTF-16(BOM) / UTF-8 / cp932」などを順に試すスマート判定です
  • 書き込みはGUIで ANSI(cp932) or UTF-8(BOMなし) を選択します
  • 古い環境のバッチはUTF-8で動かないことがあるので、迷ったらANSIのままが無難です

Word(.docx)は装飾が崩れる可能性あり

置換は段落単位(paragraph.text)で行うため、段落内の装飾(太字、色、リンクなど)が置換後に失われることがあります。

  • 見た目が重要な文書は、必ずドライラン+バックアップ+少量でテストしてください
  • 装飾を保持したい場合は「run単位での置換」実装が必要になります

Excel(.xlsx)は文字列セルが対象(数式は既定でスキップ)

既定では数式セルはスキップします。数式内文字列まで置換したい場合は、コードの引数(include_formula)側を拡張してください。

置換対象は「テキストとしての一致」

旧文字列はそのまま文字として扱い、正規表現の特殊記号はエスケープしています。つまり「部分一致の単純置換」です。正規表現置換をしたい場合は設計変更が必要です。

スクリプト全文(コピペ用)

以下が本体コードです(そのまま動く形に整形済み)。

# -*- coding: utf-8 -*-
"""
フォルダ内ファイル一括置換(GUI)
対応: .bat/.cmd, .txt, .docx, .xlsx
機能:
- GUIでフォルダ選択
- ドライラン(プレビュー)
- バックアップ(フォルダ丸コピー)
- 文字コード: ANSI(cp932) / UTF-8(BOMなし)(テキスト系のみ)
- 大文字小文字無視
"""

import os
import shutil
import re
import tkinter as tk
from tkinter import filedialog, messagebox
from datetime import datetime

# 追加ライブラリ(Word/Excel)
try:
    from docx import Document
    DOCX_AVAILABLE = True
except Exception:
    DOCX_AVAILABLE = False

try:
    from openpyxl import load_workbook
    XLSX_AVAILABLE = True
except Exception:
    XLSX_AVAILABLE = False

# デフォルト文字列(GUI上で変更可能)
OLD = "てんぷれ"
NEW = "スーパーテンプレート"


def is_under_backup(path, root):
    """ルート直下の _backup_YYYYMMDD_HHMMSS 配下を除外"""
    rel = os.path.relpath(path, root)
    top = rel.split(os.sep, 1)[0].lower()
    return top.startswith("_backup_")


def read_text_smart(file_path):
    """
    テキスト読み取り(スマート版)
    優先度: UTF-8(BOM) / UTF-16(BOM) -> UTF-8(no BOM) -> cp932 -> latin-1
    戻り値: (text, detected_encoding)
    """
    with open(file_path, "rb") as f:
        data = f.read()

    # UTF-8 BOM
    if len(data) >= 3 and data[0:3] == b"\xef\xbb\xbf":
        try:
            return data[3:].decode("utf-8", errors="strict"), "utf8-bom"
        except Exception:
            return data[3:].decode("utf-8", errors="replace"), "utf8-bom(replace)"

    # UTF-16 BOM (LE/BE)
    if len(data) >= 2 and (data[0:2] == b"\xff\xfe" or data[0:2] == b"\xfe\xff"):
        try:
            return data.decode("utf-16", errors="strict"), "utf16-bom"
        except Exception:
            return data.decode("utf-16", errors="replace"), "utf16-bom(replace)"

    # UTF-8(BOMなし)
    try:
        return data.decode("utf-8", errors="strict"), "utf8"
    except Exception:
        pass

    # cp932(Shift-JIS相当)
    try:
        return data.decode("cp932", errors="strict"), "cp932"
    except Exception:
        # 最終保険(絶対に失敗しないが文字化けの可能性あり)
        return data.decode("latin-1"), "latin-1"


def write_text(file_path, text, use_utf8):
    """テキスト書き込み"""
    if use_utf8:
        with open(file_path, "wb") as f:
            f.write(text.encode("utf-8"))  # BOMなしUTF-8
    else:
        with open(file_path, "wb") as f:
            f.write(text.encode("cp932", errors="replace"))


def copy_tree(src, dst):
    """フォルダ丸ごとコピー(バックアップ用)"""
    if not os.path.exists(dst):
        os.makedirs(dst)

    for root, dirs, files in os.walk(src):
        rel = os.path.relpath(root, src)
        target_dir = os.path.join(dst, rel) if rel != "." else dst
        if not os.path.exists(target_dir):
            os.makedirs(target_dir)

        for name in files:
            src_file = os.path.join(root, name)
            dst_file = os.path.join(target_dir, name)
            try:
                shutil.copy2(src_file, dst_file)
            except Exception as e:
                print(f"[BACKUP-ERROR] {src_file} -> {e}")


# ---------- ファイルタイプ別の処理 ----------

def process_text_file(file, pattern, new, dry_run, use_utf8, append_log):
    """BAT/TXTのテキスト置換"""
    text, enc = read_text_smart(file)
    if not pattern.search(text):
        return False

    if dry_run:
        append_log(f"[DRY] {file} (enc={enc})")
        shown = 0
        for line in text.splitlines():
            if pattern.search(line):
                append_log(" - " + line)
                append_log(" + " + pattern.sub(lambda m: new, line))
                shown += 1
                if shown >= 5:
                    break
        return True

    new_text = pattern.sub(lambda m: new, text)
    write_text(file, new_text, use_utf8)
    append_log(f"[CHANGED] {file} (enc={enc} -> {'utf8' if use_utf8 else 'cp932'})")
    return True


def process_docx_file(file, pattern, new, dry_run, append_log):
    """
    Word(.docx)の置換:段落、表、ヘッダ/フッタを対象。
    注意: 段落単位で置換するため装飾が失われる可能性あり
    """
    if not DOCX_AVAILABLE:
        append_log(f"[SKIP] {file} -> python-docx 未導入")
        return False

    try:
        doc = Document(file)
    except Exception as e:
        append_log(f"[ERROR-OPEN-DOCX] {file} -> {e}")
        return False

    changed = False
    preview_shown = 0

    def clip(s, n=120):
        return s if len(s) <= n else s[:n] + "..."

    def replace_in_paragraphs(paragraphs):
        nonlocal changed, preview_shown
        for p in paragraphs:
            txt = p.text
            if not txt:
                continue
            if pattern.search(txt):
                if dry_run:
                    if preview_shown < 5: before = txt after = pattern.sub(lambda m: new, txt) append_log(" - " + clip(before)) append_log(" + " + clip(after)) preview_shown += 1 changed = True else: p.text = pattern.sub(lambda m: new, txt) changed = True # 本文 replace_in_paragraphs(doc.paragraphs) # 表(セル内段落) for table in doc.tables: for row in table.rows: for cell in row.cells: replace_in_paragraphs(cell.paragraphs) # ヘッダー/フッター for section in doc.sections: replace_in_paragraphs(section.header.paragraphs) replace_in_paragraphs(section.footer.paragraphs) if changed and not dry_run: try: doc.save(file) append_log(f"[CHANGED] {file}") except Exception as e: append_log(f"[ERROR-SAVE-DOCX] {file} -> {e}")
            return False
    elif changed and dry_run:
        append_log(f"[DRY] {file}")

    return changed


def process_xlsx_file(file, pattern, new, dry_run, append_log, include_formula=False):
    """
    Excel(.xlsx)の置換:全シートの文字列セル
    既定では数式セルはスキップ(include_formula=False)
    """
    if not XLSX_AVAILABLE:
        append_log(f"[SKIP] {file} -> openpyxl 未導入")
        return False

    try:
        wb = load_workbook(file, data_only=False)  # 数式保持
    except Exception as e:
        append_log(f"[ERROR-OPEN-XLSX] {file} -> {e}")
        return False

    changed = False
    preview_count = 0

    def clip(s, n=80):
        return s if len(s) <= n else s[:n] + "..."

    for ws in wb.worksheets:
        for row in ws.iter_rows(values_only=False):
            for cell in row:
                try:
                    val = cell.value

                    # 数式セルの扱い
                    if getattr(cell, "data_type", None) == "f" and not include_formula:
                        continue

                    if isinstance(val, str) and pattern.search(val):
                        if dry_run:
                            if preview_count < 10: before = val after = pattern.sub(lambda m: new, val) addr = f"{ws.title}!{cell.coordinate}" append_log(f" - {addr}: {clip(before)}") append_log(f" + {addr}: {clip(after)}") preview_count += 1 changed = True else: cell.value = pattern.sub(lambda m: new, val) changed = True except Exception as e: append_log(f"[ERROR-CELL] {file} {ws.title}!{cell.coordinate} -> {e}")

    if changed and not dry_run:
        try:
            wb.save(file)
            append_log(f"[CHANGED] {file}")
        except Exception as e:
            append_log(f"[ERROR-SAVE-XLSX] {file} -> {e}")
            return False
    elif changed and dry_run:
        append_log(f"[DRY] {file}")

    return changed


# ---------- GUI 本体 ----------

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("一括置換 - Python版(拡張)")
        self.geometry("900x640")
        self.resizable(False, False)

        tk.Label(self, text="旧文字列:").place(x=10, y=15)
        self.old_var = tk.StringVar(value=OLD)
        tk.Entry(self, textvariable=self.old_var, width=80).place(x=80, y=12)

        tk.Label(self, text="新文字列:").place(x=10, y=45)
        self.new_var = tk.StringVar(value=NEW)
        tk.Entry(self, textvariable=self.new_var, width=80).place(x=80, y=42)

        tk.Label(self, text="対象フォルダ:").place(x=10, y=75)
        self.folder_var = tk.StringVar()
        tk.Entry(self, textvariable=self.folder_var, width=80).place(x=80, y=72)
        tk.Button(self, text="参照...", command=self.browse).place(x=790, y=68, width=90)

        # オプション
        self.backup_var = tk.BooleanVar(value=True)
        tk.Checkbutton(self, text="置換前にバックアップを作成", variable=self.backup_var).place(x=80, y=100)

        self.utf8_var = tk.BooleanVar(value=False)
        tk.Checkbutton(self, text="テキスト書き込みをUTF-8(BOMなし)にする(既定はANSI)", variable=self.utf8_var).place(x=320, y=100)

        # 対象拡張子
        tk.Label(self, text="対象拡張子:").place(x=10, y=130)
        self.use_bat_var = tk.BooleanVar(value=True)
        self.use_txt_var = tk.BooleanVar(value=True)
        self.use_docx_var = tk.BooleanVar(value=True)
        self.use_xlsx_var = tk.BooleanVar(value=True)

        tk.Checkbutton(self, text=".bat/.cmd", variable=self.use_bat_var).place(x=80, y=128)
        tk.Checkbutton(self, text=".txt", variable=self.use_txt_var).place(x=180, y=128)
        tk.Checkbutton(self, text=".docx", variable=self.use_docx_var).place(x=240, y=128)
        tk.Checkbutton(self, text=".xlsx", variable=self.use_xlsx_var).place(x=310, y=128)

        tk.Button(self, text="ドライラン(プレビュー)", command=lambda: self.execute(False)).place(x=80, y=160, width=180)
        tk.Button(self, text="置換を実行", command=lambda: self.execute(True)).place(x=270, y=160, width=120)

        self.log = tk.Text(self, width=110, height=26)
        self.log.place(x=10, y=200)

        self.status = tk.Label(self, text="準備完了", anchor="w")
        self.status.place(x=10, y=600, width=880)

    def browse(self):
        d = filedialog.askdirectory(title="対象フォルダを選択")
        if d:
            self.folder_var.set(d)

    def append_log(self, text):
        self.log.insert(tk.END, text + "\n")
        self.log.see(tk.END)

    def execute(self, replace):
        self.log.delete("1.0", tk.END)
        self.status.config(text=("置換を実行中..." if replace else "ドライランを実行中..."))

        root = self.folder_var.get().strip()
        old = self.old_var.get()
        new = self.new_var.get()
        use_utf8 = self.utf8_var.get()

        if not root or not os.path.isdir(root):
            messagebox.showerror("エラー", "対象フォルダが不正です。")
            self.status.config(text="フォルダ不正")
            return

        if not old or not new:
            messagebox.showerror("エラー", "旧文字列と新文字列を入力してください。")
            self.status.config(text="文字列未入力")
            return

        # 大文字小文字無視で検索(old内の記号もそのまま扱う)
        pattern = re.compile(re.escape(old), re.IGNORECASE)

        backup_path = None
        if replace and self.backup_var.get():
            backup_path = os.path.join(root, "_backup_" + datetime.now().strftime("%Y%m%d_%H%M%S"))
            try:
                self.append_log(f"バックアップ作成中: {backup_path}")
                copy_tree(root, backup_path)
                self.append_log("バックアップ作成完了。")
            except Exception as e:
                self.append_log(f"[バックアップ失敗] {e}")
                if not messagebox.askyesno("警告", "バックアップに失敗しました。続行しますか?"):
                    self.status.config(text="バックアップ失敗")
                    return

        # 対象ファイル収集
        targets = []
        use_bat = self.use_bat_var.get()
        use_txt = self.use_txt_var.get()
        use_docx = self.use_docx_var.get()
        use_xlsx = self.use_xlsx_var.get()

        for dirpath, _, filenames in os.walk(root):
            for fname in filenames:
                lower = fname.lower()
                ext = os.path.splitext(lower)[1]

                cond = False
                if use_bat and ext in (".bat", ".cmd"):
                    cond = True
                elif use_txt and ext in (".txt",):
                    cond = True
                elif use_docx and ext in (".docx",):
                    cond = True
                elif use_xlsx and ext in (".xlsx",):
                    cond = True

                if cond:
                    full = os.path.join(dirpath, fname)
                    if not is_under_backup(full, root):
                        targets.append(full)

        if not targets:
            self.append_log("対象ファイルが見つかりません。")
            self.status.config(text="対象なし")
            return

        changed = 0
        for file in targets:
            try:
                ext = os.path.splitext(file.lower())[1]
                if ext in (".bat", ".cmd", ".txt"):
                    ch = process_text_file(file, pattern, new, not replace, use_utf8, self.append_log)
                elif ext == ".docx":
                    ch = process_docx_file(file, pattern, new, not replace, self.append_log)
                elif ext == ".xlsx":
                    ch = process_xlsx_file(file, pattern, new, not replace, self.append_log, include_formula=False)
                else:
                    ch = False

                if ch:
                    changed += 1
            except Exception as e:
                self.append_log(f"[ERROR] {file} -> {e}")

        if replace:
            self.append_log(f"=== 置換完了:{changed} 件変更 ===")
            if backup_path:
                self.append_log(f"バックアップ: {backup_path}")
            self.status.config(text=f"置換完了({changed}件)")
        else:
            self.append_log("=== ドライラン完了。問題なければ『置換を実行』を押してください。===")
            self.status.config(text="ドライラン完了")


if __name__ == "__main__":
    # 高DPIでの表示崩れ対策(失敗してもOK)
    try:
        from ctypes import windll
        windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        pass

    app = App()
    app.mainloop()

拡張したいとき(.ini / .csv を追加する例)

テキストとして扱える拡張子なら、対象拡張子の判定に追加するだけで対応できます。たとえば .ini.csv を追加する場合は、拡張子チェックの部分に次を足します。

# 例:.ini / .csv をテキスト扱いで追加する
elif use_txt and ext in (".txt", ".ini", ".csv"):
    cond = True

※ただしCSVは文字コード・改行コードが混在しやすいので、最初は必ずドライランで確認してください。

FAQ(よくある疑問)

Q. バックアップが大きくなります

フォルダ丸ごとコピーなので、対象フォルダが大きいとバックアップも大きくなります。まずは対象を必要最小限のフォルダに絞るのが現実的です。

Q. Wordの見た目が崩れた

段落単位で置換する仕様上、装飾が消えるケースがあります。見た目が重要なdocxは、対象を限定して試すか、run単位の置換が必要です。

Q. Excelの数式の中も置換したい

既定では数式セルはスキップです。数式まで置換する場合は、include_formula=True の設計に合わせて拡張してください(数式破壊のリスクが上がるため、慎重に)。

免責事項

本記事のスクリプトは、ファイル内容を一括で変更する性質上、適用範囲が広くなります。必ずドライランで確認し、バックアップを取ったうえで自己責任で実行してください。業務データに適用する場合は、所属組織のルール(権限・監査・バックアップ手順)に従ってください。