"""
Extract positional data from Calgary Highlanders War Diary Sep 44 (pages 7-57).
Outputs a JSON array of position objects.
"""
import re
import json
from html.parser import HTMLParser
INPUT_FILE = r"C:\Users\natha\IdeaProjects\AI-Prototype\Inputs\ocr-output\Calgary-Highlanders_War-Diary_Sep44_olmocr.md"
OUTPUT_FILE = r"C:\Users\natha\IdeaProjects\AI-Prototype\outputs\Calgary-Highlanders_Sep44_positions.json"
# ── helpers ──────────────────────────────────────────────────────────────────
def expand_grid(raw: str) -> tuple[str, bool]:
"""
Return (6-figure-string, inferred).
4-figure AABB → centre of 1km square = AA5 BB5 (grid_inferred=True)
6-figure AAABBB → returned as-is (grid_inferred=False)
8-figure AAAABBBB → truncate to AAA BBB (grid_inferred=False)
"""
s = re.sub(r'[^0-9]', '', raw)
if len(s) == 4:
# e.g. "8450" → easting 84, northing 50 → centre 845, 505
e = s[0:2] + '5'
n = s[2:4] + '5'
return e + n, True
if len(s) == 6:
return s, False
if len(s) == 8:
# AAAABBBB: easting = AAAA (take first 3), northing = BBBB (take first 3)
# e.g. 15618050 → E=1561 → 156, N=8050 → 805 → "156805"
e = s[0:3]
n = s[4:7]
return e + n, False
return s, False
_GRID_RE = re.compile(
r'\b'
r'(?:MR\s*|GR\s*)?' # optional prefix
r'([0-9]{3,4}\s*[0-9]{3,4})' # 6 or 4+4 digit grid
r'\b',
re.IGNORECASE
)
def find_grids(text: str):
"""Return list of (raw_match, cleaned_digits)."""
found = []
for m in _GRID_RE.finditer(text):
raw = m.group(1)
digits = re.sub(r'\s', '', raw)
if len(digits) in (4, 6, 8):
found.append((m.group(0).strip(), digits))
# also pick up standalone 6-digit runs not already caught
for m in re.finditer(r'\b([0-9]{6})\b', text):
digits = m.group(1)
already = any(d == digits for _, d in found)
if not already:
found.append((digits, digits))
return found
# ── HTML strip ────────────────────────────────────────────────────────────────
class _Stripper(HTMLParser):
def __init__(self):
super().__init__()
self.parts = []
def handle_data(self, data):
self.parts.append(data)
def get_text(self):
return ' '.join(p for p in self.parts if p.strip())
def strip_html(html_str: str) -> str:
s = _Stripper()
# replace
with spaces for readability
html_str = re.sub(r'
', ' ', html_str, flags=re.IGNORECASE)
s.feed(html_str)
return s.get_text()
# ── categorise ───────────────────────────────────────────────────────────────
_SUBUNIT_RE = re.compile(
r'\b(Able\s+Coy|Baker\s+Coy|Charlie\s+Coy|Dog\s+Coy|'
r'"A"\s+Coy|"B"\s+Coy|"C"\s+Coy|"D"\s+Coy|'
r"'A'\s+Coy|'B'\s+Coy|'C'\s+Coy|'D'\s+Coy|'Able'\s+Coy|'Baker'\s+Coy|'Charlie'\s+Coy|'Dog'\s+Coy|"
r'Carrier\s+Platoon|carriers|Pioneer\s+Platoon|pioneers|scouts?|Scout\s+Platoon|'
r'Support\s+Coy|Anti[-\s]?[Tt]ank\s+Pl(?:atoon)?|mortar\s+platoon|'
r'\d+\s*Pl(?:atoon)?|'
r'\d+\s*Sec(?:tion)?)\b',
re.IGNORECASE
)
_ENEMY_RE = re.compile(
r'\b(enemy|Jerry|Hun|Boche|MG\s*pos|MMG\s*pos|sniper|'
r'counter.attack|strongpoint|mortar\s*pos|machine\s*gun|'
r'block\s*house|pill\s*box|88mm|S\.S\.|SS troop)\b',
re.IGNORECASE
)
_PATROL_RE = re.compile(
r'\b(patrol|recce\s*patrol|fighting\s*patrol|'
r'standing\s*patrol|OP|O\.P\.|observation\s*post)\b',
re.IGNORECASE
)
_FRIENDLY_UNITS = re.compile(
r'\b(R\.H\.C\.|Black\s*Watch|RHC|R\s*de\s*Mais|'
r'Fus\s*M\.R\.|F\.M\.R\.|R\.R\.C\.|'
r'4\s*S\.S\.|Royal\s*Regt|S\.Sask|'
r'Tor\s*Scots|Toronto\s*Scots|'
r'White\s*Brig(?:ade)?|F\.F\.I\.|Maquis)\b',
re.IGNORECASE
)
# Tac HQ and Bn HQ in narrative
_TAC_HQ_RE = re.compile(
r'\bTac\s*H(?:Q)?\b',
re.IGNORECASE
)
_BN_HQ_RE = re.compile(
r'\b(?:Bn\.?\s*H\.?Q\.?|Battalion\s*H\.?Q\.?|'
r'Battle\s*H\.?Q\.?|Command\s*Post|'
r'moved\s+(?:his\s+)?H\.?Q\.|'
r'set\s+up\s+H\.?Q\.|'
r'took\s+up\s+(?:a\s+)?H\.?Q\.)\b',
re.IGNORECASE
)
def categorise(sentence: str, place_name: str | None, subunit: str | None,
friendly: str | None, is_place_col: bool = False) -> str:
"""
Categories (in priority order):
TAC_HQ – Tactical HQ position
BN_HQ – Battalion HQ position (incl. all place-column entries)
FRIENDLY – another friendly unit's position
ENEMY – enemy position / feature
PATROL – patrol route/endpoint
SUBUNIT – company or platoon position
UNIT_MOVEMENT – general battalion move/position
MISC – catch-all
"""
if is_place_col:
# Place column always records where Bn HQ was
if _TAC_HQ_RE.search(sentence):
return "TAC_HQ"
return "BN_HQ"
if _TAC_HQ_RE.search(sentence):
return "TAC_HQ"
if _BN_HQ_RE.search(sentence):
return "BN_HQ"
if friendly:
return "FRIENDLY"
if _ENEMY_RE.search(sentence):
return "ENEMY"
if _PATROL_RE.search(sentence):
return "PATROL"
if subunit:
return "SUBUNIT"
return "UNIT_MOVEMENT"
def extract_subunit(sentence: str) -> str | None:
m = _SUBUNIT_RE.search(sentence)
if m:
return m.group(0).strip()
return None
def extract_friendly(sentence: str) -> str | None:
m = _FRIENDLY_UNITS.search(sentence)
if m:
return m.group(0).strip()
return None
# ── split sentences ──────────────────────────────────────────────────────────
def split_sentences(text: str):
"""Rough sentence splitter – split on '. ' or '
'."""
# normalise
text = re.sub(r'\s+', ' ', text).strip()
parts = re.split(r'(?<=[.!?])\s+(?=[A-Z"\'(])', text)
return [p.strip() for p in parts if p.strip()]
# ── page extractor ────────────────────────────────────────────────────────────
_PAGE_RE = re.compile(r'^## Page (\d+)\s*$', re.MULTILINE)
def extract_pages(text: str, first: int, last: int) -> str:
pages = list(_PAGE_RE.finditer(text))
start_idx = None
end_idx = len(text)
for i, m in enumerate(pages):
n = int(m.group(1))
if n == first and start_idx is None:
start_idx = m.start()
if n == last + 1 and start_idx is not None:
end_idx = m.start()
break
if start_idx is None:
return ""
return text[start_idx:end_idx]
# ── table row parser ──────────────────────────────────────────────────────────
_ROW_RE = re.compile(r'
(.*?)
', re.DOTALL | re.IGNORECASE)
_CELL_RE = re.compile(r']*>(.*?)', re.DOTALL | re.IGNORECASE)
def parse_table_rows(table_html: str) -> list[dict]:
"""Parse a single HTML table into list of {place, date, hour, summary} dicts."""
rows = []
for row_m in _ROW_RE.finditer(table_html):
cells = [strip_html(c.group(1)).strip()
for c in _CELL_RE.finditer(row_m.group(1))]
if len(cells) < 4:
continue
# skip header rows
if re.match(r'place|date|hour|summary|no\.', cells[0], re.IGNORECASE):
continue
place = cells[0] if cells[0] else None
date = cells[1] if len(cells) > 1 else None
hour = cells[2] if len(cells) > 2 else None
summary = cells[3] if len(cells) > 3 else ""
rows.append(dict(place=place, date=date, hour=hour, summary=summary))
return rows
# ── sheet ref extractor ───────────────────────────────────────────────────────
_SHEET_RE = re.compile(
# must start with a digit to avoid matching "Sheet Ste. Foy" etc.
r'Sheet\s+(\d[A-Z0-9]*(?:\s*[&\-]\s*\d[A-Z0-9]*)?)',
re.IGNORECASE
)
def extract_sheet(place_text: str) -> str | None:
if not place_text:
return None
# find the LAST occurrence (most specific sheet reference in column)
matches = list(_SHEET_RE.finditer(place_text))
if matches:
return "Sheet " + matches[-1].group(1).strip()
return None
# also pull MR grid from place column
_PLACE_MR_RE = re.compile(r'MR\s*([0-9]{4,8}|\d{2,3}\s*\d{2,3})', re.IGNORECASE)
def extract_place_name(place_text: str) -> str | None:
"""
Extract the primary place name from the Place column.
Strip country, bare MR tokens, Sheet refs, standalone digits,
and Tac H / Fort coord suffixes.
"""
if not place_text:
return None
s = place_text
# remove country names
s = re.sub(r'\b(France|Belgium|Holland|Netherlands)\b', '', s, flags=re.IGNORECASE)
# remove Sheet + value (digit-led)
s = re.sub(r'\bSheet\s+\d[\w\s&\-]*', ' ', s, flags=re.IGNORECASE)
# remove bare "Sheet" not followed by a digit
s = re.sub(r'\bSheet\b', ' ', s, flags=re.IGNORECASE)
# remove MR + optional digits/spaces
s = re.sub(r'\bMR\b\s*[\d\s]*', ' ', s, flags=re.IGNORECASE)
# remove Tac H + coords
s = re.sub(r'\bTac\s*H\s*[\d]+\b', ' ', s, flags=re.IGNORECASE)
# remove bare 4-8 digit grid refs
s = re.sub(r'\b\d{4,8}\b', ' ', s)
# remove "X Pub" and similar codes
s = re.sub(r'\bX[\s\-]?Pub\b', ' ', s, flags=re.IGNORECASE)
# remove orphan punctuation / collapse whitespace
s = re.sub(r'[/\\|]', ' ', s)
s = re.sub(r'\s+', ' ', s).strip().strip('.,-()')
# If multiple tokens, take first meaningful phrase (up to the first double space separator)
parts = [p.strip() for p in re.split(r'\s{2,}', s) if p.strip()]
result = parts[0] if parts else s.strip()
# Trim trailing stray words like "(outskirts...)" parenthetical noise
result = re.sub(r'\s*\(.*\)\s*$', '', result).strip()
return result if result else None
def clean_date(raw: str) -> str | None:
if not raw:
return None
d = re.sub(r'\s+', ' ', raw).strip()
# Remove parenthetical notes like "(Cont)"
d = re.sub(r'\(.*?\)', '', d).strip()
# Extract DD Mon [YY] — ignore anything else in the cell (e.g. embedded grid refs)
m = re.search(
r'(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s*(44|1944)?',
d, re.IGNORECASE
)
if m:
day = m.group(1)
mon = m.group(2).capitalize()
# always use "44" as year for this diary
return f"{day} {mon} 44"
return None # unrecognisable — don't inherit garbage
def clean_hour(raw: str) -> str | None:
if not raw:
return None
raw = raw.strip()
# Reject empty, the bare year "44", and plain digit-strings ≥4 chars (grids)
if not raw or raw in ('0', '44', '1944'):
return None
if re.match(r'^\d{4,6}$', raw):
return None # grid ref leaked into hour column, not a time
# Extract a valid HHMM time
m = re.search(r'\b([012]\d[0-5]\d)\b', raw)
if m:
return m.group(1)
return None
def hour_col_grid(raw: str) -> str | None:
"""
If the Hour column contains a plain 4-digit grid ref (not a time),
return the digit string so it can be added as a BN_HQ entry.
"""
if not raw:
return None
raw = raw.strip()
if re.match(r'^\d{4}$', raw):
# Confirm it can't be a time (hours > 23 or minutes > 59)
h, m2 = int(raw[:2]), int(raw[2:])
if h > 23 or m2 > 59:
return raw
return None
# ── place-column MR grid ──────────────────────────────────────────────────────
def place_col_positions(place_text: str, date: str, sheet: str) -> list[dict]:
"""
Extract grids from the Place column.
Handles:
(a) NAME + MR + GRID (e.g. 'Ste. Foy MR 2553')
(b) NAME + bare 6-digit grid (e.g. 'Chateau Helleputte 769969')
Returns one entry per unique grid found.
"""
if not place_text:
return []
results = []
seen_digits = set()
def _clean_label(raw: str) -> str | None:
s = raw
s = re.sub(r'\b(France|Belgium|Holland|Netherlands)\b', '', s, flags=re.IGNORECASE)
s = re.sub(r'\bSheet\b[\w\s&\-]*', '', s, flags=re.IGNORECASE)
s = re.sub(r'\bMR\b', '', s, flags=re.IGNORECASE)
s = re.sub(r'\b\d{4,8}\b', '', s)
s = re.sub(r'\s+', ' ', s).strip().strip('.,-()')
# take rightmost meaningful word group
parts = [p.strip() for p in re.split(r'\s{2,}', s) if p.strip()]
r = parts[-1] if parts else s.strip()
return r if r else None
# ── (a) NAME MR GRID ─────────────────────────────────────────────────────
# Require digits immediately after MR (captures 4-digit and 6-digit grids)
mr_pattern = re.compile(
r'([A-Z][A-Za-zÀ-ÿ\s\.\-\']*?)\s+MR\s*(\d[\d\s]+\d)',
re.IGNORECASE
)
for m in mr_pattern.finditer(place_text):
label_raw = m.group(1)
raw_digits = re.sub(r'\s', '', m.group(2))
if len(raw_digits) not in (4, 6, 8):
continue
if raw_digits in seen_digits:
continue
seen_digits.add(raw_digits)
grid, inferred = expand_grid(raw_digits)
if len(grid) != 6:
grid = None
label = _clean_label(label_raw)
# Determine category: place column = BN_HQ unless Tac H explicit
cat = categorise(place_text, label, None, None, is_place_col=True)
results.append(dict(
date=date, hour=None,
grid=grid, grid_inferred=inferred,
place_name=label,
sheet_ref=sheet,
category=cat,
subunit=None, friendly_unit=None, is_end_of_day=False,
context=f"Place column: {label or '?'} MR {raw_digits}"
))
# ── (b) NAME followed directly by bare 6-digit grid ──────────────────────
bare_pattern = re.compile(
r'([A-Z][A-Za-zÀ-ÿ\s\.\-\']+?)\s+(\d{6})\b'
)
for m in bare_pattern.finditer(place_text):
label_raw = m.group(1)
raw_digits = m.group(2)
if raw_digits in seen_digits:
continue
# Skip if label contains only noise words
clean = re.sub(r'\b(France|Belgium|Holland|Netherlands|Sheet|MR)\b', '',
label_raw, flags=re.IGNORECASE).strip()
if not clean:
continue
seen_digits.add(raw_digits)
grid, inferred = expand_grid(raw_digits)
label = _clean_label(label_raw)
cat = categorise(place_text, label, None, None, is_place_col=True)
results.append(dict(
date=date, hour=None,
grid=grid, grid_inferred=inferred,
place_name=label,
sheet_ref=sheet,
category=cat,
subunit=None, friendly_unit=None, is_end_of_day=False,
context=f"Place column: {label or '?'} {raw_digits}"
))
# ── (c) NAME followed by bare 4-digit grid (no MR prefix) ────────────────
bare_4_pattern = re.compile(
r'([A-Z][A-Za-zÀ-ÿ\s\.\-\']+?)\s+(\d{4})\b'
)
for m in bare_4_pattern.finditer(place_text):
label_raw = m.group(1)
raw_digits = m.group(2)
if raw_digits in seen_digits:
continue
# Only treat as grid if NOT a valid time (i.e. HH>23 or MM>59)
h_val, mn_val = int(raw_digits[:2]), int(raw_digits[2:])
if h_val <= 23 and mn_val <= 59:
continue
clean = re.sub(r'\b(France|Belgium|Holland|Netherlands|Sheet|MR)\b', '',
label_raw, flags=re.IGNORECASE).strip()
if not clean:
continue
seen_digits.add(raw_digits)
grid, inferred = expand_grid(raw_digits)
label = _clean_label(label_raw)
cat = categorise(label_raw, label, None, None, is_place_col=True)
results.append(dict(
date=date, hour=None,
grid=grid if len(grid) == 6 else None,
grid_inferred=inferred,
place_name=label,
sheet_ref=sheet,
category=cat,
subunit=None, friendly_unit=None, is_end_of_day=False,
context=f"Place column: {label or '?'} {raw_digits}"
))
return results
# ── main extraction ───────────────────────────────────────────────────────────
def extract_positions(md_text: str) -> list[dict]:
section = extract_pages(md_text, 7, 57)
# find all tables
table_re = re.compile(r'', re.DOTALL | re.IGNORECASE)
all_positions = []
last_date = None
last_sheet = None
for tbl_m in table_re.finditer(section):
tbl_html = tbl_m.group(0)
rows = parse_table_rows(tbl_html)
if not rows:
continue
for row in rows:
raw_date = clean_date(row.get('date', '') or '')
raw_hour = clean_hour(row.get('hour', '') or '')
place_text = row.get('place', '') or ''
summary = row.get('summary', '') or ''
# update tracking state
if raw_date:
last_date = raw_date
cur_date = last_date
sheet = extract_sheet(place_text) or last_sheet
if sheet and re.search(r'\d', sheet):
last_sheet = sheet
# ── place-column positions ──
for pos in place_col_positions(place_text, cur_date, sheet):
pos['hour'] = raw_hour
all_positions.append(pos)
# ── hour-column grid (OCR sometimes puts MR ref here) ──
hr_raw = row.get('hour', '') or ''
hcg = hour_col_grid(hr_raw)
if hcg:
grid, inferred = expand_grid(hcg)
col_place = extract_place_name(place_text)
all_positions.append(dict(
date=cur_date, hour=None,
grid=grid if len(grid) == 6 else None,
grid_inferred=inferred,
place_name=col_place,
sheet_ref=last_sheet,
category="BN_HQ",
subunit=None, friendly_unit=None, is_end_of_day=False,
context=f"Place column (hour field): {col_place or '?'} {hcg}"
))
# ── summary / narrative positions ──
sentences = split_sentences(summary)
# track whether this is the last sentence in the entry
last_sentence_idx = len(sentences) - 1
# collect all (sentence_idx, grid_str, raw_context) tuples for this row
row_matches = []
for s_idx, sent in enumerate(sentences):
grids = find_grids(sent)
if grids:
for raw_match, digits in grids:
row_matches.append((s_idx, sent, digits, raw_match))
else:
# named positions without grids: look for capitalised place names
named = re.findall(
r'\b(Fme\s+\w+|Chateau\s+\w+|Fort\s+\w+|Casino|'
r'Distillery\s+\w+|Brickworks|blockhouse|'
r'Mardick|Dunkerque|Dunkirk|Brecht|Loon\s*Plage|'
r'Bourbourgville|St\.\s*Folquin|Nordamsques|Montreuil|'
r'Wommelg[ea]h?m|Antwerp|Ypres|Pasch[aeo]nd[ae]?le|'
r'Gravelines|Le\s*Clipon|Coppenaxfort|'
r'Lochtenberg|Eindhoven|Sternhoven|Ryckevorsel|'
r'St\.\s*Leonard|Bindhoven|Schilde|Schelde)\b',
sent, re.IGNORECASE
)
for name in named:
row_matches.append((s_idx, sent, None, name))
# determine is_end_of_day: last grid-bearing sentence in last row of date?
for i, (s_idx, sent, digits, raw_match) in enumerate(row_matches):
is_last = (i == len(row_matches) - 1)
# extract hour from sentence if not in column
hour = raw_hour
if not hour:
h_m = re.search(r'\b([012]\d[0-5]\d)\s*h(?:r|our|s)?', sent, re.IGNORECASE)
if h_m:
hour = h_m.group(1)
else:
h_m2 = re.search(r'\b([012]\d[0-5]\d)[A-Z]?\b', sent)
if h_m2:
candidate = h_m2.group(1)
# make sure it looks like a time not a grid
if int(candidate[:2]) <= 23 and int(candidate[2:]) <= 59:
hour = candidate
# grid
if digits:
grid, inferred = expand_grid(digits)
if len(grid) != 6:
grid = None
inferred = False
else:
grid = None
inferred = False
# place name from sentence context
pn_m = re.search(
r'\b(Fme\s+\w+[\w\s]+?(?=\s+\d|\s+MR|\.|,|$)|'
r'Chateau\s+\w+|Fort\s+\d+|Casino|Distillery\s+\w+|'
r'Brickworks|blockhouse|moated\s+farm|'
r'Mardick|Dunkerque|Dunkirk|Brecht|Loon\s*Plage|'
r'Bourbourgville|St\.\s*Folquin|Nordamsques|Montreuil|'
r'Wommelg[ea]h?m|Antwerp|Ypres|Pasch[aeo]nd[ae]?le|'
r'Gravelines|Le\s*Clipon|Coppenaxfort|'
r'Lochtenberg|Eindhoven|Sternhoven|Ryckevorsel|'
r'St\.\s*Leonard|Bindhoven|Schilde|Schelde|'
r'cross.?roads?|road\s+junction|road\s+junc\.?|'
r'start\s+line|bridge|lock\s+gates|railway\s+st[na]|'
r'windmill|windpump|pier|beach|canal)\b',
sent, re.IGNORECASE)
place_name = pn_m.group(0).strip() if pn_m else None
# also check the place column label
col_pn = extract_place_name(place_text)
if not place_name and col_pn:
place_name = col_pn
subunit = extract_subunit(sent)
friendly = extract_friendly(sent)
category = categorise(sent, place_name, subunit, friendly, is_place_col=False)
# shorten context to 2 sentences max
context = sent.strip()
if len(context) > 300:
context = context[:297] + "..."
all_positions.append(dict(
date=cur_date,
hour=hour,
grid=grid,
grid_inferred=inferred,
place_name=place_name,
sheet_ref=last_sheet,
category=category,
subunit=subunit,
friendly_unit=friendly,
is_end_of_day=False, # will be set in post-processing
context=context
))
return all_positions
# ── EOD post-processing ───────────────────────────────────────────────────────
def assign_end_of_day(positions: list[dict]) -> list[dict]:
"""
For each date, set is_end_of_day=True on exactly ONE entry — the last
recorded HQ position for that date (in document order).
Priority (highest first):
1. Last place-column TAC_HQ with grid (context starts "Place column:")
2. Last place-column BN_HQ with grid
3. Last place-column BN_HQ or TAC_HQ without grid
4. Last narrative TAC_HQ with grid
5. Last narrative BN_HQ with grid
6. Last UNIT_MOVEMENT with grid
7. Last any entry with grid
"""
from collections import defaultdict
for p in positions:
p['is_end_of_day'] = False
date_indices: dict[str, list[int]] = defaultdict(list)
for i, p in enumerate(positions):
if p['date']:
date_indices[p['date']].append(i)
def _is_place_col(p):
return (p.get('context') or '').startswith('Place column')
for date, indices in date_indices.items():
def _last(cats, require_grid=True, place_col_only=False):
matches = [
i for i in indices
if positions[i]['category'] in cats
and (not require_grid or positions[i]['grid'])
and (not place_col_only or _is_place_col(positions[i]))
]
return matches[-1] if matches else None
winner = (
_last({'TAC_HQ'}, require_grid=True, place_col_only=True) or
_last({'BN_HQ'}, require_grid=True, place_col_only=True) or
_last({'TAC_HQ', 'BN_HQ'}, require_grid=False, place_col_only=True) or
_last({'TAC_HQ'}, require_grid=True, place_col_only=False) or
_last({'BN_HQ'}, require_grid=True, place_col_only=False) or
_last({'UNIT_MOVEMENT'}, require_grid=True) or
next((i for i in reversed(indices) if positions[i]['grid']), None)
)
if winner is not None:
positions[winner]['is_end_of_day'] = True
return positions
# ── run ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import pathlib
print("Reading source file …")
text = pathlib.Path(INPUT_FILE).read_text(encoding="utf-8")
print("Extracting positions …")
positions = extract_positions(text)
# deduplicate identical (date + grid + context[:60]) entries
seen = set()
unique = []
for p in positions:
key = (p["date"], p["grid"], p["context"][:60])
if key not in seen:
seen.add(key)
unique.append(p)
print("Assigning end-of-day flags …")
unique = assign_end_of_day(unique)
pathlib.Path(OUTPUT_FILE).parent.mkdir(parents=True, exist_ok=True)
pathlib.Path(OUTPUT_FILE).write_text(
json.dumps(unique, indent=2, ensure_ascii=False),
encoding="utf-8"
)
print(f"Done. {len(unique)} positions written to {OUTPUT_FILE}")