324 lines
12 KiB
Python
324 lines
12 KiB
Python
#!/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() |