Files
be-my-anchor/tools/fix_multiline_json.py
T

203 lines
6.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Tool: fix_multiline_json.py
Description:
Fixes JSON files that contain invalid raw multiline strings (e.g. pasted articles
or multiline text in "content" or "title" fields) without altering any content,
newlines, spaces, or HTML tags.
Usage:
python3 tools/fix_multiline_json.py <file1.json> [file2.json ...]
python3 tools/fix_multiline_json.py test/data/
python3 tools/fix_multiline_json.py --check <file.json>
"""
import sys
import os
import glob
import json
import re
from typing import List, Dict, Any, Optional
def is_valid_json(text: str) -> bool:
try:
json.loads(text)
return True
except Exception:
return False
def parse_multiline_json_array(text: str) -> List[Dict[str, Any]]:
"""
Parses a loosely formatted JSON array of objects where string fields
may contain raw unescaped newlines.
"""
lines = text.splitlines(keepends=True)
n = len(lines)
items: List[Dict[str, Any]] = []
i = 0
current_obj: Optional[Dict[str, Any]] = None
while i < n:
line = lines[i]
stripped = line.strip()
# Start of a new object
if stripped == "{" and current_obj is None:
current_obj = {}
i += 1
continue
# End of current object
if stripped in ("},", "}") and current_obj is not None:
items.append(current_obj)
current_obj = None
i += 1
continue
# Field detection: look for `"key":` pattern
if current_obj is not None:
key_match = re.match(r'^\s*"([^"]+)"\s*:\s*(.*)', line)
if key_match:
key = key_match.group(1)
val_start = key_match.group(2).lstrip()
# Case 1: String value
if val_start.startswith('"'):
val_after_quote = val_start[1:]
# Check if single-line closed: e.g. "value", or "value"
# But be careful if it ends with ", or "
if val_after_quote.endswith(',\n') or val_after_quote.endswith(',\r\n'):
trimmed = val_after_quote.rstrip('\r\n')[:-1].rstrip()
else:
trimmed = val_after_quote.rstrip('\r\n')
if trimmed.endswith('"'):
# Single line string
current_obj[key] = trimmed[:-1]
i += 1
continue
else:
# Multiline string starting on this line
val_lines = [val_after_quote]
i += 1
while i < n:
next_line = lines[i]
next_stripped = next_line.strip()
# Check if next line is closing of object or next key
is_obj_end = next_stripped in ("},", "}")
is_next_key = bool(re.match(r'^\s*"[^"]+"\s*:\s*', next_line))
if is_obj_end or is_next_key:
# The previous line had the closing quote
last = val_lines[-1]
last_stripped = last.rstrip('\r\n')
if last_stripped.endswith(','):
last_stripped = last_stripped[:-1].rstrip()
if last_stripped.endswith('"'):
val_lines[-1] = last_stripped[:-1]
break
else:
val_lines.append(next_line)
i += 1
current_obj[key] = "".join(val_lines)
continue
# Case 2: Number, boolean, null
else:
raw_val = val_start.rstrip('\r\n')
if raw_val.endswith(','):
raw_val = raw_val[:-1].strip()
try:
current_obj[key] = json.loads(raw_val)
except Exception:
current_obj[key] = raw_val
i += 1
continue
i += 1
return items
def process_file(filepath: str, check_only: bool = False) -> bool:
print(f"Checking: {filepath}")
try:
with open(filepath, "r", encoding="utf-8") as f:
raw_text = f.read()
except Exception as e:
print(f" [ERROR] Failed to read {filepath}: {e}")
return False
if is_valid_json(raw_text):
print(f" [OK] Already valid JSON syntax.")
return True
print(f" [FIXING] Detected multiline or invalid JSON syntax...")
try:
parsed_items = parse_multiline_json_array(raw_text)
if not parsed_items:
print(f" [WARNING] Could not parse any objects from {filepath}.")
return False
formatted_json = json.dumps(parsed_items, ensure_ascii=False, indent=4) + "\n"
# Verify round-trip syntax
test_load = json.loads(formatted_json)
assert len(test_load) == len(parsed_items)
if check_only:
print(f" [DRY-RUN] Can successfully repair {len(parsed_items)} items.")
return True
with open(filepath, "w", encoding="utf-8") as f:
f.write(formatted_json)
print(f" [SUCCESS] Repaired and formatted {len(parsed_items)} items.")
return True
except Exception as e:
print(f" [ERROR] Failed repairing {filepath}: {e}")
return False
def main():
if len(sys.argv) < 2:
print("Usage: python3 tools/fix_multiline_json.py [--check] <path-to-json-or-directory> [...]")
sys.exit(1)
args = sys.argv[1:]
check_only = False
if "--check" in args:
check_only = True
args.remove("--check")
files_to_process = []
for arg in args:
if os.path.isdir(arg):
files_to_process.extend(glob.glob(os.path.join(arg, "**/*.json"), recursive=True))
elif os.path.isfile(arg):
files_to_process.append(arg)
else:
matches = glob.glob(arg)
if matches:
files_to_process.extend(matches)
else:
print(f"[WARN] No files matched: {arg}")
if not files_to_process:
print("No JSON files found to process.")
sys.exit(0)
success_count = 0
for file_path in files_to_process:
if process_file(file_path, check_only=check_only):
success_count += 1
print(f"\nCompleted: {success_count}/{len(files_to_process)} files processed successfully.")
if __name__ == "__main__":
main()