generated from john/python-template
166 lines
6.0 KiB
Python
166 lines
6.0 KiB
Python
import csv
|
|
from pathlib import Path
|
|
import re
|
|
import unicodedata
|
|
import pdfplumber
|
|
|
|
# Configuration
|
|
SOURCE_DIR = Path(r"C:\Proton Drive\bbchops\My files\Archive\FamilySearch")
|
|
OUTPUT_FILE = SOURCE_DIR / "familysearch_people_with_links.csv"
|
|
|
|
# Regex pattern for FamilySearch Person IDs
|
|
FS_ID_PATTERN = re.compile(r"([A-Z0-9]{4}-[A-Z0-9]{3,4})")
|
|
|
|
# Badge/Artifact labels to strip
|
|
BADGE_LABELS = {
|
|
"MAYFLOWER",
|
|
"DESCENDANT",
|
|
"PATRIOT",
|
|
"DEAD",
|
|
"END",
|
|
"FAMILYSEARCH",
|
|
}
|
|
|
|
|
|
def clean_text(text: str) -> str:
|
|
"""Normalizes dashes, unicode artifacts, and collapses whitespace."""
|
|
if not text:
|
|
return ""
|
|
# Replace non-breaking spaces, en-dashes, em-dashes, and special hyphens
|
|
text = unicodedata.normalize("NFKD", text)
|
|
text = re.sub(r"[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]", "-", text)
|
|
return " ".join(text.split()).strip()
|
|
|
|
|
|
def extract_people_from_pdf(pdf_path: Path):
|
|
extracted_records = []
|
|
|
|
try:
|
|
with pdfplumber.open(str(pdf_path)) as pdf:
|
|
for page in pdf.pages:
|
|
hyperlinks = []
|
|
for annot in page.hyperlinks:
|
|
uri = annot.get("uri", "")
|
|
match = FS_ID_PATTERN.search(uri)
|
|
if match:
|
|
hyperlinks.append(
|
|
{
|
|
"fs_id": match.group(1),
|
|
"uri": uri,
|
|
"x0": annot.get("x0", 0),
|
|
"top": annot.get("top", 0),
|
|
"x1": annot.get("x1", 0),
|
|
"bottom": annot.get("bottom", 0),
|
|
}
|
|
)
|
|
|
|
words = page.extract_words(
|
|
keep_blank_chars=False, x_tolerance=2, y_tolerance=2
|
|
)
|
|
|
|
for link in hyperlinks:
|
|
lx_center = (link["x0"] + link["x1"]) / 2
|
|
|
|
# Narrow horizontal span (+/- 28px) to isolate this specific node column
|
|
# Search from link top down to +75px
|
|
matched_words = [
|
|
w
|
|
for w in words
|
|
if abs(((w["x0"] + w["x1"]) / 2) - lx_center) <= 28
|
|
and (link["top"] - 5) <= w["top"] <= (link["bottom"] + 75)
|
|
]
|
|
|
|
if not matched_words:
|
|
continue
|
|
|
|
# Group words into lines by vertical position
|
|
matched_words.sort(key=lambda w: (w["top"], w["x0"]))
|
|
lines = []
|
|
current_line = []
|
|
current_y = None
|
|
|
|
for w in matched_words:
|
|
if current_y is None or abs(w["top"] - current_y) <= 4:
|
|
current_line.append(w["text"])
|
|
current_y = w["top"]
|
|
else:
|
|
lines.append(" ".join(current_line))
|
|
current_line = [w["text"]]
|
|
current_y = w["top"]
|
|
if current_line:
|
|
lines.append(" ".join(current_line))
|
|
|
|
# Parse dates and clean names line-by-line
|
|
name_parts = []
|
|
date_range = ""
|
|
|
|
for line in lines:
|
|
cleaned_line = clean_text(line)
|
|
|
|
# Filter out badge text
|
|
if (
|
|
cleaned_line.upper() in BADGE_LABELS
|
|
or "MAYFLOWER" in cleaned_line.upper()
|
|
):
|
|
continue
|
|
|
|
# Check for Date Range: e.g. 1890-1975 or 1715-Deceased
|
|
date_match = re.search(
|
|
r"(\b\d{4}\s*-\s*(?:\d{4}|Deceased)\b)",
|
|
cleaned_line,
|
|
re.IGNORECASE,
|
|
)
|
|
if date_match:
|
|
date_range = date_match.group(1).replace(" ", "")
|
|
# If text exists before the date on the same line, retain it
|
|
prefix = cleaned_line[: date_match.start()].strip()
|
|
if prefix:
|
|
name_parts.append(prefix)
|
|
else:
|
|
name_parts.append(cleaned_line)
|
|
|
|
person_name = clean_text(" ".join(name_parts))
|
|
|
|
extracted_records.append(
|
|
{
|
|
"File": pdf_path.name,
|
|
"Name": person_name,
|
|
"Date_Range": date_range,
|
|
"FS_ID": link["fs_id"],
|
|
"URL": link["uri"],
|
|
}
|
|
)
|
|
|
|
except Exception as err:
|
|
print(f"[ERROR] Failed reading {pdf_path.name}: {err}")
|
|
|
|
return extracted_records
|
|
|
|
|
|
def batch_extract(source_dir: Path, output_csv: Path):
|
|
pdf_files = sorted(source_dir.glob("*.pdf"))
|
|
if not pdf_files:
|
|
print(f"No PDF files found in: {source_dir}")
|
|
return
|
|
|
|
print(f"Processing {len(pdf_files)} PDF(s)...")
|
|
|
|
all_records = []
|
|
for pdf_path in pdf_files:
|
|
records = extract_people_from_pdf(pdf_path)
|
|
all_records.extend(records)
|
|
print(f" - {pdf_path.name}: {len(records)} person/link pair(s)")
|
|
|
|
output_csv.parent.mkdir(parents=True, exist_ok=True)
|
|
# Use utf-8-sig so Excel automatically reads hyphens and Unicode without Mojibake
|
|
with open(output_csv, "w", newline="", encoding="utf-8-sig") as f:
|
|
fieldnames = ["File", "Name", "Date_Range", "FS_ID", "URL"]
|
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
writer.writerows(all_records)
|
|
|
|
print(f"\nDone. Saved to:\n{output_csv}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
batch_extract(SOURCE_DIR, OUTPUT_FILE) |