324 lines
12 KiB
Python
324 lines
12 KiB
Python
"""Read an Amazon Ads change-history export into typed records.
|
|
|
|
The export carries two independent state machines in the same `Campaign status`
|
|
change type: the budget machine (In budget / Out of budget) and the delivery
|
|
machine (Delivering / Paused). No row ever mixes the two vocabularies, so
|
|
partitioning on membership is lossless -- `QaReport.crossover_violations`
|
|
asserts that holds for every file we read.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import openpyxl
|
|
|
|
BUDGET_STATES = ("In budget", "Out of budget")
|
|
DELIVERY_STATES = ("Delivering", "Paused")
|
|
|
|
CT_CAMPAIGN_STATUS = "Campaign status"
|
|
CT_DAILY_BUDGET = "Campaign daily budget"
|
|
CT_BUDGET_RULE = "Budget rule"
|
|
CT_CAMPAIGN_CREATED = "Campaign created"
|
|
|
|
# Budget rule cells read "Budget: $20.00 - Rule(s) active" with an en-dash.
|
|
_MONEY = re.compile(r"\$\s*([\d,]+(?:\.\d+)?)")
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Event:
|
|
source_index: int # position in the file; the intra-minute tie-break key
|
|
date_key: str # '2026-08-05'
|
|
minute: int # 0..1439 within date_key
|
|
second: int # 0..86399; only used to tell near-simultaneous rows apart
|
|
level_type: str # 'Campaign' | 'Ad group'
|
|
level_name: str # the entity that changed -- an ad group, not its campaign
|
|
campaign: str
|
|
change_type: str
|
|
from_val: str
|
|
to_val: str
|
|
from_num: float | None
|
|
to_num: float | None
|
|
machine: str # budget | delivery | budget_amount | budget_rule | created | other
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class WorkbookMeta:
|
|
path: Path
|
|
account: str = ""
|
|
marketplace: str = ""
|
|
date_range: str = ""
|
|
run_id: str = ""
|
|
status: str = ""
|
|
rows_expected: int | None = None
|
|
rows_exported: int | None = None
|
|
duplicates_skipped: int | None = None
|
|
pages_processed: int | None = None
|
|
spend: float | None = None
|
|
sales: float | None = None
|
|
roas: float | None = None
|
|
impressions: float | None = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class QaReport:
|
|
path: Path
|
|
meta: WorkbookMeta
|
|
rows_parsed: int = 0
|
|
columns: int = 0
|
|
crossover_violations: int = 0
|
|
rows_unparsable_time: int = 0
|
|
rows_no_campaign: int = 0 # account- or portfolio-level rows
|
|
rows_blank: int = 0
|
|
distinct_campaigns: int = 0
|
|
campaigns_with_budget_events: int = 0
|
|
date_keys: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def rows_seen(self) -> int:
|
|
"""Every data row in the sheet, including ones we deliberately drop."""
|
|
return self.rows_parsed + self.rows_no_campaign + self.rows_blank
|
|
|
|
@property
|
|
def row_accounting_ok(self) -> bool:
|
|
"""expected - duplicates == exported, and every exported row accounted for.
|
|
|
|
Rows without a Campaign are real rows we cannot place on a campaign
|
|
timeline -- account- or portfolio-level changes. They are dropped on
|
|
purpose, so they count toward reconciliation rather than against it.
|
|
"""
|
|
m = self.meta
|
|
if None in (m.rows_expected, m.rows_exported, m.duplicates_skipped):
|
|
return False
|
|
return (
|
|
m.rows_expected - m.duplicates_skipped == m.rows_exported
|
|
and m.rows_exported == self.rows_seen
|
|
)
|
|
|
|
@property
|
|
def accounting_detail(self) -> str:
|
|
"""Plain reconciliation line, whichever way the check lands."""
|
|
m = self.meta
|
|
if m.rows_expected is None:
|
|
return "the export carries no row-count metadata to reconcile against"
|
|
parts = [f"{m.rows_exported:,} exported", f"{self.rows_parsed:,} placed on a timeline"]
|
|
if self.rows_no_campaign:
|
|
parts.append(f"{self.rows_no_campaign:,} with no campaign (account or "
|
|
f"portfolio level, not scoreable)")
|
|
if self.rows_blank:
|
|
parts.append(f"{self.rows_blank:,} blank")
|
|
if self.rows_unparsable_time:
|
|
parts.append(f"{self.rows_unparsable_time:,} with an unreadable timestamp")
|
|
gap = m.rows_exported - self.rows_seen
|
|
if gap:
|
|
parts.append(f"{gap:,} UNACCOUNTED")
|
|
return " = ".join([parts[0], " + ".join(parts[1:])])
|
|
|
|
|
|
def _num(value) -> float | None:
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
return float(str(value).replace(",", "").replace("$", "").strip())
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_money(*texts) -> float | None:
|
|
"""First dollar amount found across the given cells, or None."""
|
|
for text in texts:
|
|
if not text:
|
|
continue
|
|
m = _MONEY.search(str(text))
|
|
if m:
|
|
return float(m.group(1).replace(",", ""))
|
|
return None
|
|
|
|
|
|
def _classify(change_type: str, from_val: str, to_val: str) -> str:
|
|
if change_type == CT_CAMPAIGN_STATUS:
|
|
if from_val in BUDGET_STATES or to_val in BUDGET_STATES:
|
|
return "budget"
|
|
if from_val in DELIVERY_STATES or to_val in DELIVERY_STATES:
|
|
return "delivery"
|
|
return "other"
|
|
if change_type == CT_DAILY_BUDGET:
|
|
return "budget_amount"
|
|
if change_type == CT_BUDGET_RULE:
|
|
return "budget_rule"
|
|
if change_type == CT_CAMPAIGN_CREATED:
|
|
return "created"
|
|
return "other"
|
|
|
|
|
|
def _read_meta(wb, path: Path) -> WorkbookMeta:
|
|
meta = WorkbookMeta(path=path)
|
|
|
|
if "Extraction Metadata" in wb.sheetnames:
|
|
pairs = {
|
|
str(r[0]).strip(): r[1]
|
|
for r in wb["Extraction Metadata"].iter_rows(values_only=True)
|
|
if r and r[0]
|
|
}
|
|
meta.account = str(pairs.get("Account", "") or "")
|
|
meta.marketplace = str(pairs.get("Marketplace", "") or "")
|
|
meta.date_range = str(pairs.get("Date range", "") or "")
|
|
meta.run_id = str(pairs.get("Extraction run ID", "") or "")
|
|
meta.status = str(pairs.get("Status", "") or "")
|
|
for attr, key in (
|
|
("rows_expected", "Rows expected"),
|
|
("rows_exported", "Rows exported"),
|
|
("duplicates_skipped", "Duplicate rows skipped"),
|
|
("pages_processed", "Pages processed"),
|
|
):
|
|
v = _num(pairs.get(key))
|
|
if v is not None:
|
|
setattr(meta, attr, int(v))
|
|
|
|
if "Summary Metrics" in wb.sheetnames:
|
|
for row in wb["Summary Metrics"].iter_rows(min_row=2, values_only=True):
|
|
if not row or not row[0]:
|
|
continue
|
|
key, value = str(row[0]).strip().lower(), _num(row[1])
|
|
if key in ("spend", "sales", "roas", "impressions"):
|
|
setattr(meta, key, value)
|
|
|
|
return meta
|
|
|
|
|
|
def load_history(path: str | Path) -> tuple[list[Event], WorkbookMeta, QaReport]:
|
|
"""Parse one change-history workbook."""
|
|
path = Path(path)
|
|
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
|
if "History" not in wb.sheetnames:
|
|
raise ValueError(f"{path.name}: no 'History' sheet -- is this a change-history export?")
|
|
|
|
meta = _read_meta(wb, path)
|
|
qa = QaReport(path=path, meta=meta)
|
|
|
|
rows = wb["History"].iter_rows(values_only=True)
|
|
header = next(rows, None)
|
|
if not header:
|
|
raise ValueError(f"{path.name}: History sheet is empty")
|
|
col = {str(name).strip(): i for i, name in enumerate(header) if name}
|
|
qa.columns = len(header)
|
|
|
|
required = ["Campaign", "Change type", "From", "To", "Date and time (ISO)"]
|
|
missing = [c for c in required if c not in col]
|
|
if missing:
|
|
raise ValueError(f"{path.name}: missing expected column(s): {', '.join(missing)}")
|
|
|
|
def cell(row, name):
|
|
i = col.get(name)
|
|
return row[i] if i is not None and i < len(row) else None
|
|
|
|
events: list[Event] = []
|
|
campaigns: set[str] = set()
|
|
dates: set[str] = set()
|
|
|
|
for idx, row in enumerate(rows):
|
|
if row is None or not any(v is not None for v in row):
|
|
qa.rows_blank += 1
|
|
continue
|
|
campaign = cell(row, "Campaign")
|
|
if not campaign:
|
|
# Account- and portfolio-level rows have no campaign to attach to.
|
|
qa.rows_no_campaign += 1
|
|
continue
|
|
campaign = str(campaign).strip()
|
|
campaigns.add(campaign)
|
|
|
|
iso = cell(row, "Date and time (ISO)")
|
|
try:
|
|
when = datetime.fromisoformat(str(iso))
|
|
except (TypeError, ValueError):
|
|
qa.rows_unparsable_time += 1
|
|
continue
|
|
|
|
from_val = "" if cell(row, "From") is None else str(cell(row, "From")).strip()
|
|
to_val = "" if cell(row, "To") is None else str(cell(row, "To")).strip()
|
|
change_type = str(cell(row, "Change type") or "").strip()
|
|
|
|
# The partition is only lossless if no row straddles both vocabularies.
|
|
if change_type == CT_CAMPAIGN_STATUS:
|
|
in_b = (from_val in BUDGET_STATES, to_val in BUDGET_STATES)
|
|
if in_b[0] != in_b[1]:
|
|
qa.crossover_violations += 1
|
|
|
|
date_key = when.date().isoformat()
|
|
dates.add(date_key)
|
|
events.append(
|
|
Event(
|
|
source_index=idx,
|
|
date_key=date_key,
|
|
minute=when.hour * 60 + when.minute,
|
|
second=when.hour * 3600 + when.minute * 60 + when.second,
|
|
level_type=str(cell(row, "Change level type") or "").strip(),
|
|
level_name=str(cell(row, "Change level name") or "").strip(),
|
|
campaign=campaign,
|
|
change_type=change_type,
|
|
from_val=from_val,
|
|
to_val=to_val,
|
|
from_num=_num(cell(row, "From (numeric)")),
|
|
to_num=_num(cell(row, "To (numeric)")),
|
|
machine=_classify(change_type, from_val, to_val),
|
|
)
|
|
)
|
|
|
|
wb.close()
|
|
|
|
qa.rows_parsed = len(events) + qa.rows_unparsable_time
|
|
qa.distinct_campaigns = len(campaigns)
|
|
qa.campaigns_with_budget_events = len(
|
|
{e.campaign for e in events if e.machine == "budget"}
|
|
)
|
|
qa.date_keys = sorted(dates)
|
|
return events, meta, qa
|
|
|
|
|
|
def event_identity(e: Event) -> tuple:
|
|
"""What makes a change row unique, independent of which export it came from.
|
|
|
|
The entity name and the second matter. One campaign can pause seventeen
|
|
different ad groups in the same minute -- those rows share everything except
|
|
`level_name`, and merging them would silently delete real history.
|
|
"""
|
|
return (e.date_key, e.second, e.level_type, e.level_name, e.campaign,
|
|
e.change_type, e.from_val, e.to_val)
|
|
|
|
|
|
def dedupe_events(events: list[Event]) -> tuple[list[Event], int]:
|
|
"""Drop rows that appear in more than one export.
|
|
|
|
Amazon's exports are date-range based, so loading a week and then a month
|
|
that contains it is normal. Without this the overlap is not double-counted
|
|
-- the state machine collapses the repeats -- but every repeated transition
|
|
registers as a contradiction, burying the handful of genuine ones.
|
|
"""
|
|
seen: set[tuple] = set()
|
|
unique: list[Event] = []
|
|
for e in events:
|
|
identity = event_identity(e)
|
|
if identity in seen:
|
|
continue
|
|
seen.add(identity)
|
|
unique.append(e)
|
|
return unique, len(events) - len(unique)
|
|
|
|
|
|
def discover_exports(*roots: str | Path) -> list[Path]:
|
|
"""Find `amazon-ads-history_*.xlsx` files, newest last, de-duplicated."""
|
|
found: dict[Path, None] = {}
|
|
for root in roots:
|
|
root = Path(root)
|
|
if root.is_file() and root.suffix.lower() in (".xlsx", ".xlsm"):
|
|
found[root.resolve()] = None
|
|
elif root.is_dir():
|
|
for p in sorted(root.glob("*.xlsx")):
|
|
if not p.name.startswith("~$"):
|
|
found[p.resolve()] = None
|
|
return list(found)
|