スクリプトファイルを追加
This commit is contained in:
324
compare_php/compare_php_folders.py
Normal file
324
compare_php/compare_php_folders.py
Normal file
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
複数フォルダ配下の PHP ファイルを比較し、A/B/C... の内容グループで Excel に結果を書き出すスクリプト。
|
||||
|
||||
- フォルダ一覧はテキストファイル(1行: 「ラベル:/absolute/path」 形式。#始まりと空行は無視)
|
||||
- 比較キーは既定で「ファイル名のみ」(--key name)。相対パスで比較する場合は --key relative。
|
||||
- 改行コード差(CRLF/LF)は既定で無視 (--keep-eol を付けると無視しない)。
|
||||
- --ignore-whitespace で空白/タブ/改行を全無視して比較。
|
||||
- NEW: --ignore-comments で PHP コメント(//, #, /* */, /** */)を除去して比較。
|
||||
- 進捗は「n/m 個目のフォルダ」「現在のファイル」を改行なしで表示。
|
||||
- 出力: Excel (結果シート + 必要に応じて重複シート)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import re
|
||||
from collections import defaultdict, OrderedDict
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# 入力(folders.txt)読み込み
|
||||
# -----------------------------
|
||||
def read_folder_list(list_file: Path):
|
||||
"""
|
||||
folders.txt の形式:
|
||||
ラベル:/absolute/path/to/folder
|
||||
を読み込み、[(label, Path), ...] のリストを返す
|
||||
"""
|
||||
result = []
|
||||
for line in list_file.read_text(encoding="utf-8").splitlines():
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
|
||||
if ":" not in s:
|
||||
raise ValueError(f"ラベル:フォルダパス の形式で記述してください → {s}")
|
||||
|
||||
label, folder = s.split(":", 1)
|
||||
label = label.strip()
|
||||
folder = folder.strip()
|
||||
|
||||
p = Path(folder).expanduser().resolve()
|
||||
if not p.exists() or not p.is_dir():
|
||||
raise FileNotFoundError(f"フォルダが存在しません: {folder}")
|
||||
|
||||
result.append((label, p))
|
||||
|
||||
if not result:
|
||||
raise ValueError("フォルダ一覧が空です。")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# ファイル列挙
|
||||
# -----------------------------
|
||||
def iter_php_files(root: Path, recursive: bool = True):
|
||||
"""root 配下の .php を列挙。戻り値: (ファイル名, 相対フォルダパス, 相対パス, 絶対パス)"""
|
||||
if recursive:
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
dirpath_p = Path(dirpath)
|
||||
for fn in filenames:
|
||||
if fn.lower().endswith(".php"):
|
||||
rel_path = dirpath_p.relative_to(root) / fn if dirpath_p != root else Path(fn)
|
||||
rel_dir = rel_path.parent.as_posix() if str(rel_path) != fn else ""
|
||||
yield (fn, rel_dir, rel_path.as_posix(), (dirpath_p / fn))
|
||||
else:
|
||||
for fn in os.listdir(root):
|
||||
if fn.lower().endswith(".php"):
|
||||
yield (fn, "", fn, (root / fn))
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# コメント除去(--ignore-comments)
|
||||
# -----------------------------
|
||||
_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", flags=re.DOTALL) # /* ... */ と /** ... */
|
||||
_COMMENT_SLASH_SLASH = re.compile(r"//.*?$", flags=re.MULTILINE) # // ...
|
||||
_COMMENT_HASH = re.compile(r"#.*?$", flags=re.MULTILINE) # # ...
|
||||
|
||||
def strip_php_comments_from_bytes(b: bytes) -> bytes:
|
||||
"""
|
||||
PHP のコメント(//, #, /* */, /** */)を削除。
|
||||
- バイナリ→latin1文字列で無損失に扱い、正規表現適用後に再度 bytes 化
|
||||
- PHP タグ外のテキストでもパターンに合致すれば削除されます(必要に応じ高度化可能)
|
||||
"""
|
||||
s = b.decode("latin1")
|
||||
s = _COMMENT_BLOCK.sub("", s)
|
||||
s = _COMMENT_SLASH_SLASH.sub("", s)
|
||||
s = _COMMENT_HASH.sub("", s)
|
||||
return s.encode("latin1")
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# 正規化(EOL/空白/コメント)
|
||||
# -----------------------------
|
||||
def normalize_bytes_for_hash(
|
||||
data: bytes,
|
||||
keep_eol: bool,
|
||||
ignore_whitespace: bool,
|
||||
ignore_comments: bool,
|
||||
) -> bytes:
|
||||
"""比較前のバイト列正規化"""
|
||||
b = data
|
||||
|
||||
# コメント無視(先に行うことで、その後のEOL/空白処理の影響も受ける)
|
||||
if ignore_comments:
|
||||
try:
|
||||
b = strip_php_comments_from_bytes(b)
|
||||
except Exception:
|
||||
# コメ除去中に失敗しても比較を止めない
|
||||
pass
|
||||
|
||||
if not keep_eol:
|
||||
# CRLF/LF/CR の差異を LF に正規化
|
||||
b = b.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
||||
|
||||
if ignore_whitespace:
|
||||
# 全ホワイトスペースを除去(空白・タブ・LF)
|
||||
table = {ord(' '): None, ord('\t'): None, ord('\n'): None, ord('\r'): None}
|
||||
s = b.decode('latin1') # バイト値を壊さないダミーエンコーディング
|
||||
s = s.translate(table)
|
||||
b = s.encode('latin1')
|
||||
|
||||
return b
|
||||
|
||||
|
||||
def file_hash(
|
||||
path: Path,
|
||||
keep_eol: bool,
|
||||
ignore_whitespace: bool,
|
||||
ignore_comments: bool,
|
||||
) -> str:
|
||||
with path.open("rb") as f:
|
||||
data = f.read()
|
||||
data = normalize_bytes_for_hash(
|
||||
data,
|
||||
keep_eol=keep_eol,
|
||||
ignore_whitespace=ignore_whitespace,
|
||||
ignore_comments=ignore_comments,
|
||||
)
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# ユーティリティ
|
||||
# -----------------------------
|
||||
def excel_letter_index(n: int) -> str:
|
||||
"""
|
||||
0 -> 'A', 1 -> 'B', ..., 25 -> 'Z', 26 -> 'AA' ... のようにグループ記号を生成
|
||||
Excel列名と同様のロジック
|
||||
"""
|
||||
s = []
|
||||
n += 1
|
||||
while n > 0:
|
||||
n, r = divmod(n - 1, 26)
|
||||
s.append(chr(65 + r))
|
||||
return "".join(reversed(s))
|
||||
|
||||
def auto_width(ws):
|
||||
col_widths = defaultdict(int)
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
for i, val in enumerate(row, start=1):
|
||||
if val is None:
|
||||
continue
|
||||
length = len(str(val))
|
||||
if length > col_widths[i]:
|
||||
col_widths[i] = length
|
||||
for i, w in col_widths.items():
|
||||
ws.column_dimensions[get_column_letter(i)].width = min(max(w + 2, 10), 80)
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# メイン
|
||||
# -----------------------------
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="複数フォルダの PHP ファイルを内容比較して Excel に出力します。")
|
||||
parser.add_argument("--list", required=True, help="フォルダ一覧テキストファイルのパス(1行: ラベル:/path)")
|
||||
parser.add_argument("--output", default="php_compare.xlsx", help="出力Excelファイル名(既定: php_compare.xlsx)")
|
||||
parser.add_argument("--key", choices=["name", "relative"], default="name",
|
||||
help="比較キー: name=ファイル名, relative=相対パス(既定: name)")
|
||||
parser.add_argument("--no-recursive", action="store_true", help="再帰を無効にして直下のみを対象")
|
||||
parser.add_argument("--keep-eol", action="store_true", help="改行コードの違いを無視しない(厳密一致)")
|
||||
parser.add_argument("--ignore-whitespace", action="store_true", help="空白/タブ/改行等のホワイトスペースを無視して比較")
|
||||
parser.add_argument("--ignore-comments", action="store_true", help="PHPコメント(//, #, /* */, /** */)を無視して比較")
|
||||
args = parser.parse_args()
|
||||
|
||||
list_file = Path(args.list)
|
||||
out_path = Path(args.output).resolve()
|
||||
|
||||
folder_items = read_folder_list(list_file)
|
||||
labels = [item[0] for item in folder_items]
|
||||
folders = [item[1] for item in folder_items]
|
||||
recursive = not args.no_recursive
|
||||
|
||||
# 各フォルダごと: key -> (abs_path, rel_dir, file_name)
|
||||
per_folder_maps: List[Dict[str, Tuple[Path, str, str]]] = []
|
||||
duplicate_report: List[Tuple[int, str, List[str]]] = [] # (folder_idx, key, [abs paths])
|
||||
|
||||
total_folders = len(folders)
|
||||
print(f"対象フォルダ数: {total_folders}")
|
||||
|
||||
# フォルダ走査 & ハッシュ計算
|
||||
per_folder_hashes: List[Dict[str, str]] = [] # key -> hash
|
||||
for idx, root in enumerate(folders, start=1):
|
||||
mapping: Dict[str, Tuple[Path, str, str]] = {}
|
||||
key_to_abslist: Dict[str, List[str]] = defaultdict(list)
|
||||
|
||||
files = list(iter_php_files(root, recursive=recursive))
|
||||
total_files = len(files)
|
||||
for j, (fn, rel_dir, rel_path, abs_path) in enumerate(files, start=1):
|
||||
# 進捗を改行せずに出力
|
||||
print(f"\r[{idx}/{total_folders}] {j}/{total_files} {fn}", end="", flush=True)
|
||||
|
||||
key = fn if args.key == "name" else rel_path
|
||||
key_to_abslist[key].append(str(abs_path))
|
||||
|
||||
# 代表(最初に出会ったもの)を保持
|
||||
if key not in mapping:
|
||||
mapping[key] = (abs_path, rel_dir, fn)
|
||||
|
||||
# 重複(同一フォルダで同じ key が複数)を記録
|
||||
for key, lst in key_to_abslist.items():
|
||||
if len(lst) > 1:
|
||||
duplicate_report.append((idx, key, lst))
|
||||
|
||||
# ハッシュ計算
|
||||
key_hash: Dict[str, str] = {}
|
||||
for key, (abs_path, _rel_dir, _fn) in mapping.items():
|
||||
try:
|
||||
h = file_hash(
|
||||
abs_path,
|
||||
keep_eol=args.keep_eol,
|
||||
ignore_whitespace=args.ignore_whitespace,
|
||||
ignore_comments=args.ignore_comments,
|
||||
)
|
||||
except Exception as e:
|
||||
h = f"__ERROR__:{e}"
|
||||
key_hash[key] = h
|
||||
|
||||
per_folder_maps.append(mapping)
|
||||
per_folder_hashes.append(key_hash)
|
||||
print("") # フォルダ行終端
|
||||
|
||||
# 全キー(ファイル名 or 相対パス)のユニオン
|
||||
all_keys: List[str] = sorted(set().union(*[set(m.keys()) for m in per_folder_maps]))
|
||||
|
||||
# Excel ワークブック作成
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "結果"
|
||||
|
||||
# ヘッダ行
|
||||
headers = ["フォルダパス", "ファイル名"]
|
||||
for label in labels:
|
||||
headers.append(label)
|
||||
ws.append(headers)
|
||||
|
||||
# 各キー(=行)ごとに A/B/C... を割り当て
|
||||
for key in all_keys:
|
||||
group_map: "OrderedDict[str, str]" = OrderedDict() # hash -> letter
|
||||
next_group_idx = 0
|
||||
|
||||
# 代表(最初に見つかった場所の情報)
|
||||
rep_rel_dir: Optional[str] = None
|
||||
rep_file_name: Optional[str] = None
|
||||
|
||||
for m in per_folder_maps:
|
||||
if key in m:
|
||||
_abs, rel_dir, fn = m[key]
|
||||
rep_rel_dir = rel_dir if rel_dir else ""
|
||||
rep_file_name = fn
|
||||
break
|
||||
|
||||
if rep_rel_dir is None:
|
||||
rep_rel_dir = ""
|
||||
if rep_file_name is None:
|
||||
rep_file_name = key if args.key == "name" else Path(key).name
|
||||
|
||||
row = [rep_rel_dir, rep_file_name]
|
||||
|
||||
# 各フォルダ列
|
||||
for (m, hmap) in zip(per_folder_maps, per_folder_hashes):
|
||||
if key not in m:
|
||||
row.append("—")
|
||||
continue
|
||||
h = hmap.get(key)
|
||||
if h is None:
|
||||
row.append("—")
|
||||
continue
|
||||
if h.startswith("__ERROR__"):
|
||||
row.append("ERR")
|
||||
continue
|
||||
|
||||
if h not in group_map:
|
||||
group_map[h] = excel_letter_index(next_group_idx)
|
||||
next_group_idx += 1
|
||||
row.append(group_map[h])
|
||||
|
||||
ws.append(row)
|
||||
|
||||
# 重複シート(同一フォルダ内で同名 or 同相対パスが複数ある)
|
||||
if duplicate_report:
|
||||
ws2 = wb.create_sheet(title="重複")
|
||||
ws2.append(["フォルダ番号(1~)", "キー(name/relative)", "該当ファイル(絶対パス)"])
|
||||
for folder_idx, key, paths in duplicate_report:
|
||||
ws2.append([folder_idx, key, paths[0]])
|
||||
for p in paths[1:]:
|
||||
ws2.append(["", "", p])
|
||||
auto_width(ws2)
|
||||
|
||||
auto_width(ws)
|
||||
wb.save(out_path)
|
||||
print(f"完了: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user