audit pipeline v1.2: canonical business record as diff baseline (owner-verified truth), website JSON-LD hours, single-command e2e with gate

This commit is contained in:
2026-08-16 19:08:30 +00:00
parent 3b0427985b
commit 3c290680f4
+198 -72
View File
@@ -63,14 +63,12 @@ def _normalize_phone(phone):
def _normalize_address(addr): def _normalize_address(addr):
"""Lowercase, strip extra whitespace, remove suite abbrev variances, drop commas.""" """Lowercase, strip extra whitespace, remove suite abbrev variances."""
if not addr: if not addr:
return "" return ""
s = re.sub(r'[\U000E0000-\U000EFFFF]', '', addr or '') s = re.sub(r'[\U000E0000-\U000EFFFF]', '', addr or '')
s = re.sub(r'\s+', ' ', s.strip().lower()) s = re.sub(r'\s+', ' ', s.strip().lower())
s = re.sub(r'\bst[e]*\.?\b', 'ste', s) s = re.sub(r'\bst[e]*\.?\b', 'ste', s)
s = re.sub(r'\bsuite\b', 'ste', s)
s = s.replace(',', '') # ponytail: comma is pure formatting, not a material diff
return s return s
@@ -215,37 +213,57 @@ def check_nap_website(contract):
def check_hours_mismatch(contract): def check_hours_mismatch(contract):
"""Hours consistency. GBP is authoritative.""" """Hours consistency vs reference: canonical record (owner-verified truth)
findings = [] if the client has one, else GBP (legacy surface-vs-surface).
gbp_hours = _safe_get(contract, "sources", "google_business_profile", "hours", default={})
apple_hours = _safe_get(contract, "sources", "apple_maps", "hours", default={})
bing_hours = _safe_get(contract, "sources", "bing_places", "hours", default={})
if not gbp_hours: Evidence shape: {reference, reference_hours, provenance, deviations[]}
where each deviation day carries the RAW value of every captured surface.
"""
findings = []
from canonical_baseline import resolve_reference, normalize_hours_dict, parse_hours_value
label, ref_hours, prov = resolve_reference(contract, contract.get("_canonical_record"))
ref_norm = normalize_hours_dict(ref_hours or {})
if not ref_norm:
return findings # nothing to compare against return findings # nothing to compare against
mismatched_days = [] sources = contract.get("sources", {})
for other_name, other_hours in [("apple_maps", apple_hours), ("bing_places", bing_hours)]: SURF_KEYS = ("google_business_profile", "apple_maps", "bing_places", "website")
if not other_hours: deviations = []
for day, ref_val in sorted(ref_norm.items()):
raw_vals = {}
for sname in SURF_KEYS:
v = (sources.get(sname) or {}).get("hours") or {}
if v.get(day) is not None:
raw_vals[sname] = v[day]
if not raw_vals:
continue continue
for day, gbp_val in gbp_hours.items(): # ponytail: skip the reference surface itself in legacy mode (it can't deviate from itself anyway)
if day in other_hours: bad = any(
other_val = other_hours[day] parse_hours_value(v) is not None and parse_hours_value(v) != ref_val
if not _hours_overlap(gbp_val, other_val): for v in raw_vals.values()
mismatched_days.append({ )
"day": day, if bad:
"google": gbp_val, entry = {"day": day, "reference": ref_val}
other_name: other_val entry.update(raw_vals)
}) deviations.append(entry)
if mismatched_days: if deviations:
suffix = "owner-verified hours" if prov else "GBP is authoritative"
findings.append({ findings.append({
"id": "hours_mismatch", "id": "hours_mismatch",
"severity": SEVERITY_IMMEDIATE, "severity": SEVERITY_IMMEDIATE,
"title": f"Hours mismatch on {len(mismatched_days)} day(s) — GBP is authoritative", "title": f"Hours mismatch on {len(deviations)} day(s) — {suffix}",
"sub_score": GOOGLE_BUSINESS, "sub_score": GOOGLE_BUSINESS,
"evidence": mismatched_days, "evidence": {
"recommendation": "Correct hours on non-GBP surfaces to match GBP. Mismatched hours cause customer complaints and trust loss." "reference": label,
"reference_hours": ref_norm,
"provenance": prov,
"deviations": deviations,
},
"recommendation": ("Correct hours on the deviating surfaces to match the owner-verified schedule (canonical record)."
if prov else
"Correct hours on non-GBP surfaces to match GBP. Mismatched hours cause customer complaints and trust loss."),
}) })
return findings return findings
@@ -321,6 +339,76 @@ def check_review_count_delta(contract):
return findings return findings
def check_onpage_title_meta(contract):
"""On-page title/meta audit (technical SEO): presence, length, name, phone.
Reads the website fields captured at scrape time (title, description,
phone_on_page) so the audit is reproducible from the capture. Skips
cleanly when no website surface was captured."""
findings = []
ws = _safe_get(contract, "sources", "website", default={}) or {}
title = ws.get("title") or ""
desc = ws.get("description") or ""
if not title and not desc:
return findings # no website surface captured
name = (contract.get("name") or "").lower()
phone_norm = _normalize_phone(contract.get("phone") or "")
page_phone_norm = _normalize_phone(ws.get("phone_on_page") or "")
# <title> presence & length (Google truncates ~60 chars)
if not title:
findings.append({
"id": "missing_title", "severity": SEVERITY_HIGH, "sub_score": TECHNICAL_SEO,
"title": "Website has no <title> tag — search engines fall back to the URL.",
"evidence": {"title": ""},
"recommendation": "Add a descriptive <title> tag including the business name and main service.",
})
else:
if len(title) > 65:
findings.append({
"id": "title_too_long", "severity": SEVERITY_ENHANCEMENT, "sub_score": TECHNICAL_SEO,
"title": f"Title tag is {len(title)} chars (>65) — truncated in Google results.",
"evidence": {"title": title, "length": len(title)},
"recommendation": "Shorten the <title> to ~50-60 characters.",
})
if name and name not in title.lower():
findings.append({
"id": "title_missing_name", "severity": SEVERITY_MEDIUM, "sub_score": TECHNICAL_SEO,
"title": "Title tag does not contain the business name.",
"evidence": {"title": title, "business_name": name},
"recommendation": "Include the business name in the <title> for brand recognition.",
})
# Meta description presence & length (Google truncates ~160 chars)
if not desc:
findings.append({
"id": "missing_meta_description", "severity": SEVERITY_MEDIUM, "sub_score": TECHNICAL_SEO,
"title": "No meta description — Google may use arbitrary page text as the snippet.",
"evidence": {"description": ""},
"recommendation": "Write a 150-160 character meta description naming the business and service.",
})
elif len(desc) > 170:
findings.append({
"id": "meta_description_too_long", "severity": SEVERITY_ENHANCEMENT, "sub_score": TECHNICAL_SEO,
"title": f"Meta description is {len(desc)} chars (>170) — truncated in results.",
"evidence": {"length": len(desc)},
"recommendation": "Shorten the meta description to ~150-160 characters.",
})
# On-page NAP: flag only when a phone WAS captured and it differs from the
# business phone (evidence-based; no capture = can't conclude, don't flag).
if phone_norm and page_phone_norm and phone_norm != page_phone_norm:
findings.append({
"id": "phone_not_on_page", "severity": SEVERITY_MEDIUM, "sub_score": DIGITAL_IDENTITY,
"title": "Phone number found on the website differs from the business phone (on-page NAP gap).",
"evidence": {"business_phone": phone_norm, "phone_on_page": page_phone_norm},
"recommendation": "Make the website phone match the business phone in the header/footer.",
})
return findings
def check_review_velocity(contract): def check_review_velocity(contract):
"""Review recency — are recent reviews flowing?""" """Review recency — are recent reviews flowing?"""
findings = [] findings = []
@@ -333,7 +421,7 @@ def check_review_velocity(contract):
if not dates: if not dates:
findings.append({ findings.append({
"id": "review_dates_unavailable", "id": "review_dates_unavailable",
"severity": SEVERITY_MEDIUM, "severity": SEVERITY_ENHANCEMENT, # tooling limitation, not a business finding (matches classify() FP in report_generate.py)
"title": "Cannot determine review recency — no dates available in samples", "title": "Cannot determine review recency — no dates available in samples",
"sub_score": REVIEWS, "sub_score": REVIEWS,
"evidence": {"sources_with_dates": []}, "evidence": {"sources_with_dates": []},
@@ -364,62 +452,78 @@ def check_review_velocity(contract):
return findings return findings
def _service_keywords(contract):
"""Derive the business's own service/category vocabulary (vertical-agnostic).
Grounded in what the business actually sells — its category, description,
and name — so the specificity signal adapts to any vertical instead of
assuming one (the old version hardcoded salon terms).
"""
words = set()
# Top-level capture fields are the most reliable vocabulary source
for field in ("name", "category", "description"):
val = contract.get(field) or ""
for w in re.findall(r"[a-z]{3,}", val.lower()):
words.add(w)
for src in ["google_business_profile", "apple_maps", "bing_places", "website"]:
data = _safe_get(contract, "sources", src, default={}) or {}
for field in ("category", "description", "name", "title"):
val = data.get(field) or ""
for w in re.findall(r"[a-z]{3,}", val.lower()):
words.add(w)
# Drop generic words that don't indicate a specific service/product
stop = {"the", "and", "for", "with", "this", "that", "your", "you", "our",
"are", "was", "have", "has", "been", "will", "would", "like", "get",
"got", "good", "great", "best", "very", "really", "service",
"services", "business", "place", "store", "shop", "location", "area",
"city", "staff", "customer", "customers", "experience", "quality",
"professional", "professionals", "helpful", "friendly", "recommend",
"recommended", "price", "prices", "paid", "cost", "costs", "time",
"times", "day", "days", "week", "weeks", "month", "months", "year",
"years", "online", "presence", "audit", "veripath"}
return words - stop
def check_review_text_quality(contract): def check_review_text_quality(contract):
"""Check if reviews mention specific services/practitioners (E-E-A-T signal).""" """Check if reviews mention the business's own services/products (E-E-A-T signal).
Vertical-agnostic: the keyword set is derived from the business's own
category/description/name, not a hardcoded vertical.
"""
findings = [] findings = []
all_samples = [] all_samples = []
for src in ["apple_maps", "bing_places"]: for src in ["apple_maps", "bing_places"]:
samples = _safe_get(contract, "sources", src, "reviews_sample", default=[]) samples = _safe_get(contract, "sources", src, "reviews_sample", default=[])
all_samples.extend(samples or []) all_samples.extend(samples or [])
if not all_samples: if len(all_samples) < 3:
return findings return findings
keywords = _service_keywords(contract)
if not keywords:
return findings # no vocabulary to match against; can't judge specificity
specific_signals = 0 specific_signals = 0
for r in all_samples: for r in all_samples:
text = (r.get('text') or '').lower() text = (r.get('text') or '').lower()
if any(word in text for word in ['ashley', 'grace', 'mallory', 'facial', 'hair', 'waxing', 'lashes', 'spray tan', 'massage']): if any(w in text for w in keywords):
specific_signals += 1 specific_signals += 1
ratio = specific_signals / len(all_samples) if all_samples else 0 ratio = specific_signals / len(all_samples)
if ratio < 0.5 and len(all_samples) >= 3: if ratio < 0.5:
findings.append({ findings.append({
"id": "reviews_low_specificity", "id": "reviews_low_specificity",
"severity": SEVERITY_ENHANCEMENT, "severity": SEVERITY_ENHANCEMENT,
"title": f"Only {ratio:.0%} of sample reviews mention specific services or practitioners", "title": f"Only {ratio:.0%} of sample reviews mention specific services or staff",
"sub_score": REVIEWS, "sub_score": REVIEWS,
"evidence": {"sample_size": len(all_samples), "specific_count": specific_signals}, "evidence": {"sample_size": len(all_samples), "specific_count": specific_signals,
"recommendation": "Encourage detailed reviews mentioning services and staff. Specific reviews rank higher and convert better." "keyword_basis": "category+description+name"},
"recommendation": "Encourage reviews that name specific services, products, or staff. Specific reviews rank higher and convert better."
}) })
return findings return findings
# ponytail: small explicit synonym map; extend when new variants appear in the wild
_CATEGORY_SYNONYMS = {
"beauty salon": "beauty salon",
"beauty and spa": "beauty salon",
"beauty & spa": "beauty salon",
"beauty &amp; spa": "beauty salon",
"salon and spa": "beauty salon",
"hair salon": "hair salon",
"hair & beauty salon": "hair salon",
"hair and beauty salon": "hair salon",
"day spa": "day spa",
"spa": "spa",
"medical spa": "medical spa",
"barber shop": "barber shop",
"barbershop": "barber shop",
}
def _normalize_category(cat):
if not cat:
return ""
c = re.sub(r'\s+', ' ', cat.strip().lower()).replace('&amp;', '&')
return _CATEGORY_SYNONYMS.get(c, c)
def check_category_alignment(contract): def check_category_alignment(contract):
"""Category consistency and fragmentation across surfaces.""" """Category consistency and fragmentation across surfaces."""
findings = [] findings = []
@@ -430,7 +534,7 @@ def check_category_alignment(contract):
data = _safe_get(contract, "sources", src_data, default={}) data = _safe_get(contract, "sources", src_data, default={})
cat = data.get('category') cat = data.get('category')
if cat: if cat:
categories[src_name] = _normalize_category(cat) categories[src_name] = cat.lower().strip()
if len(categories) < 2: if len(categories) < 2:
return findings return findings
@@ -567,13 +671,32 @@ def check_website_schema(contract):
}) })
return findings return findings
# Fetch and check for JSON-LD # Prefer capture-time evidence (reproducible audit). Live refetch only for
# pre-fix captures that lack the fields.
ws = _safe_get(contract, "sources", "website", default={}) or {}
if "has_jsonld" in ws:
has_jsonld = bool(ws.get("has_jsonld"))
types = [t.lower() for t in ws.get("schema_types") or []]
has_local_business = any(t in ("localbusiness", "beautysalon", "dayspa", "healthandbeautybusiness") for t in types)
has_canonical = bool(ws.get("canonical"))
has_og = bool(ws.get("has_og"))
else:
# Fetch and check for JSON-LD (legacy captures)
try: try:
import urllib.request import urllib.request
req = urllib.request.Request(website_url, headers={"User-Agent": "Mozilla/5.0"}) req = urllib.request.Request(website_url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
html = resp.read().decode("utf-8", errors="replace") html = resp.read().decode("utf-8", errors="replace")
except Exception as e:
findings.append({
"id": "website_unreachable",
"severity": SEVERITY_HIGH,
"title": f"Website fetch failed: {e}",
"sub_score": TECHNICAL_SEO,
"evidence": {"url": website_url, "error": str(e)},
"recommendation": "Verify website is live and accessible. A broken site kills all local signals."
})
return findings
# Check for JSON-LD # Check for JSON-LD
has_jsonld = bool(re.search(r'<script[^>]*type=["\']application/ld\+json["\']', html, re.IGNORECASE)) has_jsonld = bool(re.search(r'<script[^>]*type=["\']application/ld\+json["\']', html, re.IGNORECASE))
has_local_business = bool(re.search(r'"@type"\s*:\s*"(?:LocalBusiness|BeautySalon|DaySpa|HealthAndBeautyBusiness)', html, re.IGNORECASE)) has_local_business = bool(re.search(r'"@type"\s*:\s*"(?:LocalBusiness|BeautySalon|DaySpa|HealthAndBeautyBusiness)', html, re.IGNORECASE))
@@ -623,16 +746,6 @@ def check_website_schema(contract):
"recommendation": "Add OG tags for proper link preview on social media and messaging apps." "recommendation": "Add OG tags for proper link preview on social media and messaging apps."
}) })
except Exception as e:
findings.append({
"id": "website_unreachable",
"severity": SEVERITY_HIGH,
"title": f"Website fetch failed: {e}",
"sub_score": TECHNICAL_SEO,
"evidence": {"url": website_url, "error": str(e)},
"recommendation": "Verify website is live and accessible. A broken site kills all local signals."
})
return findings return findings
@@ -693,6 +806,7 @@ ALL_CHECKS = [
check_price_level, check_price_level,
check_photos, check_photos,
check_website_schema, check_website_schema,
check_onpage_title_meta,
check_google_maps_url, check_google_maps_url,
check_coordinates_precision, check_coordinates_precision,
] ]
@@ -830,6 +944,18 @@ def main():
with open(filepath) as f: with open(filepath) as f:
contract = json.load(f) contract = json.load(f)
# Canonical baseline: owner-verified record from Gitea (None -> legacy GBP reference)
try:
from canonical_baseline import fetch_canonical_record, canonical_hours
record = fetch_canonical_record(contract.get("name") or "")
if record and canonical_hours(record):
contract["_canonical_record"] = record
print(f" [OK] Canonical baseline: {record['_path']} @ {record['_commit']}", file=sys.stderr)
else:
print(" [INFO] No verified canonical record — legacy surface-vs-surface reference", file=sys.stderr)
except Exception as e:
print(f" [WARN] canonical fetch failed ({e}) — legacy reference", file=sys.stderr)
findings = analyze(contract) findings = analyze(contract)
summary = summarize(findings, contract) summary = summarize(findings, contract)