|
#!/usr/bin/env python3 |
|
""" |
|
Download transaction history from LinqConnect. |
|
|
|
Supports two data sources: |
|
- Browser login via Playwright (handles Auth0 OAuth2 + AWS WAF) |
|
- HAR file captured from Chrome DevTools |
|
|
|
Usage: |
|
# Download all via browser login |
|
python3 linqconnect_download.py |
|
|
|
# Extract from a HAR file (no login needed) |
|
python3 linqconnect_download.py --har linqconnect.com.har |
|
|
|
# Only transactions after a date |
|
python3 linqconnect_download.py --since 2026-01-01 |
|
|
|
# Incremental: download only new records since last saved file |
|
python3 linqconnect_download.py --after-last linqconnect_transactions.json |
|
|
|
# Save as CSV instead of JSON |
|
python3 linqconnect_download.py --format csv |
|
|
|
# Headless browser mode (reads credentials from env) |
|
python3 linqconnect_download.py --headless |
|
""" |
|
|
|
import argparse |
|
import csv |
|
import json |
|
import os |
|
import sys |
|
from datetime import datetime, timezone |
|
from pathlib import Path |
|
|
|
API_BASE = "https://api.linqconnect.com/api" |
|
LOGIN_URL = "https://linqconnect.com" |
|
TRANSACTION_ENDPOINT = f"{API_BASE}/FamilyTransactionSearch?pageSize=0" |
|
|
|
|
|
def parse_date(date_str: str) -> datetime: |
|
"""Parse ISO date strings from the API.""" |
|
date_str = date_str.rstrip("Z") |
|
if "." in date_str: |
|
date_str = date_str.split(".")[0] |
|
return datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) |
|
|
|
|
|
def get_last_date_from_file(filepath: str) -> datetime | None: |
|
"""Read existing data file and return the most recent transaction date.""" |
|
path = Path(filepath) |
|
if not path.exists(): |
|
print(f"File {filepath} not found, downloading all transactions.") |
|
return None |
|
|
|
if filepath.endswith(".json"): |
|
with open(filepath) as f: |
|
data = json.load(f) |
|
if not data: |
|
return None |
|
dates = [parse_date(r["Date"]) for r in data] |
|
latest = max(dates) |
|
print(f"Last transaction in {filepath}: {latest.isoformat()}") |
|
return latest |
|
elif filepath.endswith(".csv"): |
|
with open(filepath) as f: |
|
reader = csv.DictReader(f) |
|
dates = [parse_date(row["Date"]) for row in reader] |
|
if not dates: |
|
return None |
|
latest = max(dates) |
|
print(f"Last transaction in {filepath}: {latest.isoformat()}") |
|
return latest |
|
else: |
|
print(f"Unsupported file format: {filepath}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
|
|
def extract_from_har(har_path: str) -> list[dict]: |
|
"""Extract transaction records from a Chrome HAR file.""" |
|
path = Path(har_path) |
|
if not path.exists(): |
|
print(f"HAR file not found: {har_path}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
print(f"Reading HAR file: {har_path}") |
|
with open(path) as f: |
|
har = json.load(f) |
|
|
|
for entry in har["log"]["entries"]: |
|
url = entry["request"]["url"] |
|
if "FamilyTransactionSearch" in url: |
|
resp_text = entry["response"]["content"].get("text", "") |
|
if not resp_text: |
|
continue |
|
data = json.loads(resp_text) |
|
records = data.get("Data", []) |
|
print(f"Extracted {len(records)} transactions from HAR file.") |
|
return records |
|
|
|
print( |
|
"No FamilyTransactionSearch response found in the HAR file.\n" |
|
"Make sure you navigated to the transaction history page before " |
|
"saving the HAR.", |
|
file=sys.stderr, |
|
) |
|
sys.exit(1) |
|
|
|
|
|
def login_and_fetch(headless: bool = False) -> list[dict]: |
|
"""Log in via Playwright and fetch all transactions.""" |
|
try: |
|
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout |
|
except ImportError: |
|
print( |
|
"Playwright is required for browser login.\n" |
|
"Install it with: pip install playwright && playwright install chromium\n" |
|
"Or use --har to extract from a HAR file instead.", |
|
file=sys.stderr, |
|
) |
|
sys.exit(1) |
|
|
|
with sync_playwright() as p: |
|
browser = p.chromium.launch(headless=headless) |
|
context = browser.new_context() |
|
page = context.new_page() |
|
|
|
captured_transactions = [] |
|
|
|
def handle_response(response): |
|
if "FamilyTransactionSearch" in response.url: |
|
try: |
|
data = response.json() |
|
captured_transactions.append(data) |
|
except Exception: |
|
pass |
|
|
|
page.on("response", handle_response) |
|
|
|
print("Navigating to LinqConnect...") |
|
page.goto(LOGIN_URL, wait_until="networkidle") |
|
|
|
if headless: |
|
username = os.environ.get("LINQCONNECT_USERNAME", "") |
|
password = os.environ.get("LINQCONNECT_PASSWORD", "") |
|
if not username or not password: |
|
print( |
|
"Error: --headless requires LINQCONNECT_USERNAME and " |
|
"LINQCONNECT_PASSWORD environment variables.", |
|
file=sys.stderr, |
|
) |
|
browser.close() |
|
sys.exit(1) |
|
|
|
page.wait_for_selector('input[name="username"]', timeout=15000) |
|
page.fill('input[name="username"]', username) |
|
page.click('button[type="submit"]') |
|
|
|
page.wait_for_selector('input[name="password"]', timeout=15000) |
|
page.fill('input[name="password"]', password) |
|
page.click('button[type="submit"]') |
|
else: |
|
print("Please log in manually in the browser window...") |
|
|
|
print("Waiting for login to complete...") |
|
try: |
|
page.wait_for_url("**/dashboard**", timeout=120000) |
|
except PlaywrightTimeout: |
|
try: |
|
page.wait_for_url("**/home**", timeout=10000) |
|
except PlaywrightTimeout: |
|
if "linqconnect.com" in page.url and "login" not in page.url: |
|
pass |
|
else: |
|
print( |
|
"Login timed out. Current URL: " + page.url, |
|
file=sys.stderr, |
|
) |
|
browser.close() |
|
sys.exit(1) |
|
|
|
print(f"Logged in. Current URL: {page.url}") |
|
|
|
print("Navigating to transaction history...") |
|
page.goto( |
|
f"{LOGIN_URL}/dashboard/transaction-history", |
|
wait_until="networkidle", |
|
) |
|
|
|
print("Waiting for transaction data to load...") |
|
page.wait_for_timeout(5000) |
|
|
|
if captured_transactions: |
|
data = captured_transactions[0] |
|
records = data.get("Data", []) |
|
print(f"Captured {len(records)} transactions from API response.") |
|
browser.close() |
|
return records |
|
|
|
print("API intercept missed, fetching via page context...") |
|
result = page.evaluate( |
|
"""async () => { |
|
const resp = await fetch( |
|
'https://api.linqconnect.com/api/FamilyTransactionSearch?pageSize=0', |
|
{ credentials: 'include' } |
|
); |
|
return await resp.json(); |
|
}""" |
|
) |
|
records = result.get("Data", []) |
|
print(f"Fetched {len(records)} transactions via page context.") |
|
browser.close() |
|
return records |
|
|
|
|
|
def flatten_record(record: dict) -> dict: |
|
"""Flatten a transaction record for CSV output.""" |
|
items = record.get("Items", []) |
|
item_names = "; ".join(i.get("ItemName", "") for i in items) |
|
item_types = "; ".join(i.get("TransactionType", "") for i in items) |
|
item_amounts = "; ".join(str(i.get("Amount", "")) for i in items) |
|
item_quantities = "; ".join(str(i.get("Quantity", "")) for i in items) |
|
person_names = "; ".join( |
|
i.get("PersonName", "") for i in items if i.get("PersonName") |
|
) |
|
account_balance = items[0].get("AccountBalance", "") if items else "" |
|
|
|
return { |
|
"Date": record.get("Date", ""), |
|
"DistrictMemberName": record.get("DistrictMemberName", ""), |
|
"Category": record.get("Category", ""), |
|
"FeeItem": record.get("FeeItem", ""), |
|
"Status": record.get("Status", ""), |
|
"TransactionId": record.get("TransactionId", ""), |
|
"Total": record.get("Total", ""), |
|
"Type": record.get("Type", ""), |
|
"PaymentMethod": record.get("PaymentMethod", ""), |
|
"CurrentMealAccountBalance": record.get( |
|
"CurrentMealAccountBalance", "" |
|
), |
|
"ItemNames": item_names, |
|
"ItemTransactionTypes": item_types, |
|
"ItemAmounts": item_amounts, |
|
"ItemQuantities": item_quantities, |
|
"PersonNames": person_names, |
|
"AccountBalance": account_balance, |
|
} |
|
|
|
|
|
def save_json(records: list[dict], filepath: str): |
|
with open(filepath, "w") as f: |
|
json.dump(records, f, indent=2) |
|
print(f"Saved {len(records)} transactions to {filepath}") |
|
|
|
|
|
def save_csv(records: list[dict], filepath: str): |
|
if not records: |
|
print("No records to save.") |
|
return |
|
flat = [flatten_record(r) for r in records] |
|
fieldnames = list(flat[0].keys()) |
|
with open(filepath, "w", newline="") as f: |
|
writer = csv.DictWriter(f, fieldnames=fieldnames) |
|
writer.writeheader() |
|
writer.writerows(flat) |
|
print(f"Saved {len(records)} transactions to {filepath}") |
|
|
|
|
|
def merge_records( |
|
existing: list[dict], new: list[dict] |
|
) -> list[dict]: |
|
"""Merge new records into existing, deduplicating by TransactionId+Date.""" |
|
seen = set() |
|
for r in existing: |
|
key = (r.get("TransactionId", ""), r.get("Date", "")) |
|
seen.add(key) |
|
|
|
added = 0 |
|
for r in new: |
|
key = (r.get("TransactionId", ""), r.get("Date", "")) |
|
if key not in seen: |
|
existing.append(r) |
|
seen.add(key) |
|
added += 1 |
|
|
|
existing.sort(key=lambda r: r.get("Date", ""), reverse=True) |
|
print(f"Merged {added} new transactions (total: {len(existing)}).") |
|
return existing |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser( |
|
description="Download LinqConnect transaction history." |
|
) |
|
parser.add_argument( |
|
"--har", |
|
help="Path to a Chrome HAR file to extract transactions from " |
|
"(skips browser login).", |
|
) |
|
parser.add_argument( |
|
"--since", |
|
help="Only download transactions on or after this date (YYYY-MM-DD).", |
|
) |
|
parser.add_argument( |
|
"--after-last", |
|
help="Path to existing data file; download only newer transactions.", |
|
) |
|
parser.add_argument( |
|
"--format", |
|
choices=["json", "csv"], |
|
default="json", |
|
help="Output format (default: json).", |
|
) |
|
parser.add_argument( |
|
"--output", |
|
"-o", |
|
help="Output file path (default: linqconnect_transactions.{format}).", |
|
) |
|
parser.add_argument( |
|
"--headless", |
|
action="store_true", |
|
help="Run headless; requires LINQCONNECT_USERNAME and " |
|
"LINQCONNECT_PASSWORD env vars.", |
|
) |
|
args = parser.parse_args() |
|
|
|
output = args.output or f"linqconnect_transactions.{args.format}" |
|
|
|
since_date = None |
|
if args.since: |
|
since_date = datetime.fromisoformat(args.since).replace( |
|
tzinfo=timezone.utc |
|
) |
|
print(f"Filtering transactions since {since_date.date()}") |
|
elif args.after_last: |
|
since_date = get_last_date_from_file(args.after_last) |
|
if since_date: |
|
print(f"Will download transactions after {since_date.isoformat()}") |
|
|
|
if args.har: |
|
records = extract_from_har(args.har) |
|
else: |
|
records = login_and_fetch(headless=args.headless) |
|
|
|
if since_date: |
|
before = len(records) |
|
records = [r for r in records if parse_date(r["Date"]) > since_date] |
|
print(f"Filtered to {len(records)} transactions (from {before} total).") |
|
|
|
if args.after_last and Path(args.after_last).exists(): |
|
if args.after_last.endswith(".json"): |
|
with open(args.after_last) as f: |
|
existing = json.load(f) |
|
records = merge_records(existing, records) |
|
output = args.output or args.after_last |
|
elif args.after_last.endswith(".csv"): |
|
with open(args.after_last) as f: |
|
reader = csv.DictReader(f) |
|
existing_flat = list(reader) |
|
new_flat = [flatten_record(r) for r in records] |
|
seen = set() |
|
for row in existing_flat: |
|
seen.add((row.get("TransactionId", ""), row.get("Date", ""))) |
|
added = 0 |
|
for row in new_flat: |
|
key = (row.get("TransactionId", ""), row.get("Date", "")) |
|
if key not in seen: |
|
existing_flat.append(row) |
|
seen.add(key) |
|
added += 1 |
|
existing_flat.sort(key=lambda r: r.get("Date", ""), reverse=True) |
|
print(f"Merged {added} new rows (total: {len(existing_flat)}).") |
|
output_path = args.output or args.after_last |
|
if existing_flat: |
|
fieldnames = list(existing_flat[0].keys()) |
|
with open(output_path, "w", newline="") as f: |
|
writer = csv.DictWriter(f, fieldnames=fieldnames) |
|
writer.writeheader() |
|
writer.writerows(existing_flat) |
|
print(f"Saved {len(existing_flat)} transactions to {output_path}") |
|
return |
|
|
|
if args.format == "csv": |
|
save_csv(records, output) |
|
else: |
|
save_json(records, output) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |