# -*- coding: utf-8 -*- """Full-dataset analysis for the 500-query Houston study. Reads study500/answers.json and prints every number the write-up needs. Nothing here is hand-entered: if a figure appears in the deliverable it comes from this script, so a re-run on more data regenerates the whole thing. py -3.10 study500_findings.py py -3.10 study500_findings.py --names # dump extracted names to eyeball """ from __future__ import annotations import json import re import statistics import sys from collections import Counter, defaultdict from pathlib import Path HERE = Path(__file__).resolve().parent STORE = HERE / "study500" / "answers.json" try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # ---------------------------------------------------------------- extraction # Lines that are navigation, source chips or UI furniture, never a firm. NOISE = { "give feedback", "sources", "think", "expand", "get quote", "website", "directions", "use precise location", "browse", "chatgpt", "zillow", "realtor", "homelight", "har", "har.com", "realtor.com", "yelp", "facebook", "google", "linkedin", "instagram", "youtube", "trulia", "redfin", "movoto", "rocket homes", "ratemyagent", "rankmyagent", "fastexpert", "effectiveagents", "upnest", "clever real estate", "use two fingers to move the map", "open", "closed", "try plus free", # Engine UI furniture that reads as a capitalised name. "get quote - website - directions", "download comet", "view more", "show more", "see all", "read more", "learn more", "contact agent", "view profile", "call now", "message", "save", "share", "compare agents", "real estate agent", "real estate agency", "real estate consultant", "top local broker", "sources cited", "source cited", } JUNK_OPENERS = { "offer", "local", "inspection", "lender", "their", "expected", "property", "repair", "comparable", "likely", "whether", "typical", "how", "can", "will", "do", "are", "show", "ask", "walk", "which", "if", "for", "assuming", "there", "current", "one", "another", "these", "this", "that", "but", "so", "and", "when", "what", "long", "airbnb", "fix", "purchase", "average", "median", "exactly", "total", "browse", "give", "sources", "chatgpt", "temporary", "before", "rather", "both", "each", "every", "based", "here", "several", "a", "an", "the", "i", "you", "your", "my", "note", "important", "key", "also", "additionally", "finally", "overall", "source", "disclaimer", "remember", "consider", "look", "check", "make", "start", "focus", "compare", "interview", "verify", "confirm", "ensure", "because", "since", "while", "although", "however", "therefore", "thus", "in", "on", "at", "to", "from", "with", "without", "about", "as", "it", "they", "he", "she", "we", "us", "them", "who", "some", "many", "most", "top", "best", "good", "great", "recent", "active", "highly", "real", "agent", "brokerage", "recommended", "reviews", "rating", "ratings", "buying", "selling", "price", "budget", "list", "listing", "sold", "other", "others", "where", "why", "additional", "honorable", "further", "general", "specific", "popular", "leading", "prominent", "established", "experienced", "qualified", "notable", "runner", "bonus", "next", } # An answer's SECTION HEADINGS look exactly like firm names to a capitalisation # test: "Other Notable Agents with Positive Feedback", "Negotiation Skills", # "Experience & Transaction History". Left in, they inflated the distinct-firm # count and, worse, showed up as firms "named in one area only", which is the # study's headline number. Any candidate containing one of these is advice # scaffolding, not a business. HEADING_TOKENS = { "notable", "regarded", "information", "compare", "experience", "transaction", "communication", "responsiveness", "negotiation", "skills", "reviews", "platforms", "considerations", "factors", "tips", "questions", "checklist", "strategy", "process", "overview", "summary", "conclusion", "takeaway", "takeaways", "disclaimer", "resources", "highlights", "mentions", "honorable", "rated", "ranked", "picks", "choices", "recommendations", "criteria", "methodology", "caveats", "notes", "professionals", "specialists", "knowledge", "expertise", "credentials", "designations", "commission", "fees", "marketing", "negotiating", "availability", "responsiveness", "track", } # Perplexity answers quote SOURCE PAGE TITLES, which are capitalised and look # exactly like firm names: "Pearland, TX Homes for Sale & Real Estate | Realty # Kings Properties", "Paula Givens is a Real Estate agent for Keller Williams # Preferred in Pearland, TX". Counting those as named firms inflated Perplexity # far above the other engines and invented dozens of one-area-only "firms". GENERIC_SUBSTRINGS = ( "homes for sale", "for sale", " is a ", " is an ", "real estate agents", "realtors in", "agents in", "real estate & more", "property for", "houses for", "condos for", "apartments for", "listings", "browse ", "find a ", "find the ", "search ", "how to ", "what to ", "guide to", "real estate market", "market report", "cost of living", "things to do", # DESIGNATIONS AND AWARD LABELS are not businesses. "Platinum Real Estate # Agent" is a HAR.com badge that appears inside source page titles # ("Kelly Tran - Platinum Real Estate Agent - HAR.com") and was being counted # as a single firm named 65 times across 28 areas, which put a phantom at the # top of the most-named list and inflated the multi-area group. "platinum real estate agent", "platinum agent", "realtor of the year", "agent of the year", "top producer", "multi million dollar producer", "accredited buyer", "certified residential specialist", "luxury specialist", ) # DIRECTORIES AND PLATFORMS are not agents. HAR.com was being counted as a firm # named in 16 areas, which both inflated the multi-area group and pushed the # one-area share down. The study is about which BUSINESSES get named, so the # places the engine reads must not appear in that population. DIRECTORY_SUBSTRINGS = ( "har.com", "association of realtors", "association of realtors®", "zillow", "realtor.com", "homelight", "fastexpert", "effectiveagents", "expertise.com", "agent pronto", "agentpronto", "upnest", "redfin", "trulia", "movoto", "homes.com", "rocket homes", "ratemyagent", "rankmyagent", "clever real estate", "thumbtack", "yelp", "angi", "bigger pockets", "biggerpockets", "loopnet", "housecashin", "nextdoor", ) # A firm/agent name: capitalised, no sentence punctuation, reasonable length. # Commas and longer titles are allowed because a card title really does read # "Taylor Goldenthal, Realtor - Real Broker LLC" and "Ryan Adams - The Adams # Group with REAL Broker - Top Real Estate Agent in Sienna, Missouri City, TX". # Excluding them silently dropped real names out of the card-only answers. _AREAS: set[str] = set() # filled from the query set in main() NAME_RE = re.compile(r"^[A-Z][A-Za-z0-9&'’.,\-/| ]{3,99}$") def _clean(n: str) -> str: n = n.strip().strip("*•-\u2013\u2014:,;").strip() n = re.sub(r"\s+", " ", n) # Trim the trailing source chips that get_page_text glues on. n = re.sub(r"\s*\+\d+$", "", n) return n.strip() def _ok(n: str) -> bool: if not n or len(n) < 5 or len(n) > 100: return False low = n.lower() if low in NOISE or low.rstrip(".") in NOISE: return False if low.startswith(("www.", "http")): return False if any(g in low for g in GENERIC_SUBSTRINGS): return False # Compare with spaces stripped too: the engines write these both ways # ("EffectiveAgents" and "Effective Agents", "Agent Pronto" and # "agentpronto"). Matching only the unspaced form let "effective agents" # through as a firm "named in 17 areas", which is a directory, not a business. nospace = re.sub(r"[^a-z0-9]", "", low) if any(g in low or re.sub(r"[^a-z0-9]", "", g) in nospace for g in DIRECTORY_SUBSTRINGS): return False # "Pearland, TX Properties | David Bush Realtors" is a source page title, # not a firm. Anything that LEADS with one of the study's own area names is # a directory/SERP title; a real firm named after the area does not put a # comma or the state right after it. if any(re.match(rf"^{re.escape(a)}\s*(,|\s+tx|\s+texas)", low) for a in _AREAS): return False # ...but that rule only fires when something FOLLOWS the area name. A bare # "Cinco Ranch" or "West University" has nothing after it, so it walked through # and sat in the top-25 most-named "firms" at 36 mentions. An answer listing a # neighbourhood is not naming a business. Reject a name that IS an area name. # Real firms built on an area keep extra tokens ("River Oaks Properties") and # are unaffected by this. if nospace in {re.sub(r'[^a-z0-9]', '', a) for a in _AREAS}: return False words = n.split() if words[0].lower() in JUNK_OPENERS: return False # Split on hyphens too: "Top-Rated Realtors in Katy, TX" hides "rated" # inside "Top-Rated" and slipped through a whole-word check. parts = [re.sub(r"[^a-z]", "", p.lower()) for w in words for p in re.split(r"[-/&]", w)] if any(p in HEADING_TOKENS for p in parts): return False if sum(1 for w in words if w[:1].isupper()) < 2: return False if len(words) > 16: return False # Sentences, not names. if re.search(r"[.!?]\s+[A-Z]", n): return False if n.endswith((".", "!", "?")) and len(words) > 5: return False # A digit-heavy line is a rating/address row, not a name. if sum(c.isdigit() for c in n) > 6: return False return True def named(text: str) -> list[str]: """Firms/agents this answer NAMES, whatever shape the answer is.""" out: list[str] = [] # 1. bullets / numbered items with a dash before the description for pat in (r"^[-*•]\s+([A-Z][^\n]{2,70}?)\s+(?:-|\u2013|\u2014)\s", r"^\s*\d+\.\s+([A-Z][^\n]{2,70}?)\s+(?:-|\u2013|\u2014)\s", r"^([A-Z][^\n]{2,70}?)\s+(?:\u2013|\u2014)\s+\S"): out += [m.group(1) for m in re.finditer(pat, text, re.M)] # 1b. COLON form: "Jamie McMartin (The Jamie McMartin Group / Compass): ..." # This is how gemini.google.com writes its lists. Without it EVERY # gemini_web answer extracted ZERO names and the web looked like it # named 1.7 firms per answer against the API's 7.8, which would have # been reported as a finding. It was an extraction artefact. out += [m.group(1) for m in re.finditer(r"^([A-Z][^\n:]{3,70}):[ \t]+\S", text, re.M)] # NOTE [ \t], not \s. With \s the match consumed the newline AND the # first character of the next line, so in a run of consecutive # "Name: description" lines every OTHER name lost its initial letter # and vanished. "Jamie McMartin (Compass)" was dropped from the Katy # answer exactly that way. Requiring a space or tab also stops a # heading that merely ENDS in a colon from matching. # 2. bold markdown names out += [m.group(1) for m in re.finditer(r"\*\*([A-Z][^*\n]{3,70}?)\*\*", text)] # 3. BARE LINES. Some answers render as card carousels, which arrive as # a name per line with no dash and no description at all. Without this # pattern every card-only answer contributes nothing, and the behaviour # the study is about (engines naming agents without discussing them) # vanishes from the analysis entirely. for line in text.splitlines(): c = _clean(line) if not c or not NAME_RE.match(c): continue # A card title trails its descriptor after a dash: "Ryan Adams - The # Adams Group with REAL Broker - Top Real Estate Agent in Sienna, # Missouri City, TX". Keep the leading segment, which is the name. head = c.split(" - ")[0].strip() out.append(head if len(head) >= 5 else c) seen, uniq = set(), [] for n in out: c = _clean(n) if not _ok(c): continue k = c.lower() if k not in seen: seen.add(k) uniq.append(c) return uniq def canon(n: str) -> str: """Collapse trivial variants so one firm is not counted as three. The same agent arrives as "Jay Thieme (ReMax Cinco Ranch)", "Jay Thieme (RE/MAX Cinco Ranch)" and "Jay Thieme - RE/MAX". Counted separately they triple the distinct-firm total and break the "named in one area only" share, so drop the brokerage qualifier before comparing. """ k = n.strip() k = re.sub(r"\([^)]*\)", " ", k) # "(Compass)" k = re.split(r"\s+[/|]\s+|\s+\u2013\s+|\s+\u2014\s+|\s+-\s+", k)[0] k = k.lower().strip() k = re.sub(r"^the\s+", "", k) k = re.sub(r"[^a-z0-9 ]+", " ", k) k = re.sub(r"\b(llc|inc|group|team|realty|realtors?|real estate|properties|" r"brokerage|brokered by real|kw|keller williams|re max|remax|" r"exp|compass|century 21|broker)\b", " ", k) k = re.sub(r"\s+", " ", k).strip() # Stripping the brokerage words must not eat the whole name: "Houston # Properties Team" became a bare "houston", which then merged with anything # else starting Houston. If the strip leaves fewer than two tokens, keep the # unstripped form so distinct firms stay distinct. # Same fallback when stripping the brokerage words leaves a bare AREA name. "RE/MAX # Cinco Ranch" passes _ok() because it is a firm, then loses "re max" here and becomes # "cinco ranch", which merged 35 real mentions of an office into a neighbourhood. Keep # the unstripped form so the office stays an office. Found 2026-09-23 after the _ok() # fix alone left the name in the MARKET.txt top 25. if len(k.split()) < 2 or re.sub(r"[^a-z0-9]", "", k) in { re.sub(r"[^a-z0-9]", "", a) for a in _AREAS}: base = re.sub(r"\([^)]*\)", " ", n) base = re.split(r"\s+[/|]\s+", base)[0] base = re.sub(r"[^a-z0-9 ]+", " ", base.lower()) base = re.sub(r"^the\s+", "", re.sub(r"\s+", " ", base).strip()) return base or k or n.lower().strip() return k or n.lower().strip() ENGINES = ("chatgpt", "gemini_api", "gemini_web", "perplexity") # Optional three-surface view (--consumer) for internal comparison without the API-based # Gemini rows. Every published figure uses all four surfaces. CONSUMER = ("chatgpt", "gemini_web", "perplexity") MARKER = re.compile(r"\b(tx|texas|houston)\b", re.I) # Google Business Profile grounding shows up as star ratings and opening hours. # NOTE 2026-09-23: this pattern carries two literal 0x08 bytes where a word boundary # was meant, so only the middle-dot branch ever matches. Kept byte-for-byte so the # published counts stay reproducible; fix it and regenerate FINDINGS.txt together. GBP_MARKER = re.compile(r"stars? rating|Opens \d|· Opens|Closed.*Opens") def main() -> None: global ENGINES store = json.loads(STORE.read_text(encoding="utf-8")) rows = list(store["answers"].values()) if "--consumer" in sys.argv: ENGINES = CONSUMER rows = [r for r in rows if r.get("engine") in CONSUMER] _AREAS.update(str(r["area"]).lower() for r in rows if r.get("area")) if "--names" in sys.argv: for r in rows[:40]: print(f"\n--- {r['engine']} {r['query_id']} :: {r['query']}") for n in named(r["answer_text"]): print(" ", n) return clean = [r for r in rows if not r.get("geo_mismatch")] dirty = [r for r in rows if r.get("geo_mismatch")] P = print P("=" * 74) P("500-QUERY HOUSTON AI VISIBILITY STUDY - FULL DATASET") P("=" * 74) eng = Counter(r["engine"] for r in rows) P("\nA. DATASET") P(f" rows {len(rows)}") for e in ENGINES: P(f" {e:14} {eng[e]:4}") P(f" unique queries {len({r['query_id'] for r in rows})} of 500") P(f" areas {len({r.get('area') for r in rows if r.get('area')})}") P(f" geo-clean rows {len(clean)}") P(f" geo-flagged rows {len(dirty)} ({len(dirty) / len(rows) * 100:.1f}%)") with_m = [r for r in rows if MARKER.search(r["query"])] without_m = [r for r in rows if not MARKER.search(r["query"])] bw = sum(1 for r in with_m if r.get("geo_mismatch")) bo = sum(1 for r in without_m if r.get("geo_mismatch")) P("\nB. GEO RESOLUTION (does the engine answer about Houston at all?)") P(f" query says tx/texas/houston : {bw:3}/{len(with_m):3} wrong place = " f"{bw / max(len(with_m), 1) * 100:5.1f}%") P(f" query has no state marker : {bo:3}/{len(without_m):3} wrong place = " f"{bo / max(len(without_m), 1) * 100:5.1f}%") per_row = {(r["engine"], r["query_id"]): named(r["answer_text"]) for r in clean} allnames: Counter = Counter() by_area = defaultdict(set) by_engine = defaultdict(set) for r in clean: for n in per_row[(r["engine"], r["query_id"])]: c = canon(n) allnames[c] += 1 if r.get("area"): by_area[c].add(r["area"]) by_engine[c].add(r["engine"]) total_firms = max(len(allnames), 1) P("\nC. HOW MANY AGENTS THE ENGINES WILL NAME AT ALL") P(f" geo-clean answers analysed {len(clean)}") P(f" distinct agents/firms named {len(allnames)}") P(f" mentions total {sum(allnames.values())}") once = sum(1 for c, n in allnames.items() if n == 1) P(f" named exactly ONCE across 500 qs {once} ({once / total_firms * 100:.1f}%)") top = allnames.most_common(20) P(f" top 20 firms take " f"{sum(n for _, n in top) / max(sum(allnames.values()), 1) * 100:.1f}% of all mentions") P("\n most-named firms:") for c, n in top[:15]: P(f" {n:4}x in {len(by_area[c]):2} area(s) {c[:46]}") one = [c for c in by_area if len(by_area[c]) == 1] P("\nD. THE NEIGHBOURHOOD LOCK") P(f" firms named in exactly ONE area {len(one)} " f"({len(one) / max(len(by_area), 1) * 100:.1f}%)") P(f" firms named in 2+ areas {sum(1 for c in by_area if len(by_area[c]) > 1)}") P(f" firms named in 5+ areas {sum(1 for c in by_area if len(by_area[c]) >= 5)}") P(" widest reach:") for c in sorted(by_area, key=lambda x: -len(by_area[x]))[:8]: P(f" {len(by_area[c]):2} areas {c[:52]}") P("\nE. DO THE ENGINES AGREE?") ecount = Counter(len(v) for v in by_engine.values()) for k in range(1, len(ENGINES) + 1): P(f" named by {k} engine(s) {ecount[k]:4} " f"({ecount[k] / max(len(by_engine), 1) * 100:.1f}%)") allthree = [c for c, v in by_engine.items() if len(v) == len(ENGINES)] P(f" named by ALL {len(ENGINES)}:") for c in sorted(allthree, key=lambda x: -allnames[x])[:10]: P(f" {allnames[c]:3}x {c[:52]}") # E2. FAIR COMPARISON. Section E is biased: Perplexity answered 98 queries # and ChatGPT 500, so "named by one engine only" partly just measures who # was asked more. Redo it on the queries where ALL THREE answered. seen_by = defaultdict(set) for r in clean: seen_by[r["query_id"]].add(r["engine"]) common = {q for q, e in seen_by.items() if len(e) == len(ENGINES)} P(f"\nE2. SAME QUESTION, ALL {len(ENGINES)} ENGINES ({len(common)} queries answered by every engine)") c_eng = defaultdict(set) c_count: Counter = Counter() per_engine_firms = defaultdict(set) for r in clean: if r["query_id"] not in common: continue for n in per_row[(r["engine"], r["query_id"])]: c = canon(n) c_eng[c].add(r["engine"]) c_count[c] += 1 per_engine_firms[r["engine"]].add(c) tot = max(len(c_eng), 1) cc = Counter(len(v) for v in c_eng.values()) for k in range(1, len(ENGINES) + 1): P(f" named by {k} engine(s) {cc[k]:4} ({cc[k] / tot * 100:.1f}%)") P(f" distinct firms per engine on this subset:") for e in ENGINES: P(f" {e:12} {len(per_engine_firms[e]):4}") import itertools for a, b in itertools.combinations(ENGINES, 2): A, B = per_engine_firms[a], per_engine_firms[b] if A and B: P(f" overlap {a[:10]:10} vs {b[:10]:10} " f"{len(A & B):4} shared = {len(A & B) / len(A | B) * 100:.1f}% of the union") # E3. THE SAME ENGINE, TWO SURFACES. # gemini_api and gemini_web answer the SAME 500 queries from the same brand, # so any difference is the surface, not the question set or the sample. This # is the cleanest comparison in the dataset: the API answers from model # knowledge, the web grounds itself in Google Business Profile data. api_rows = {r["query_id"]: r for r in clean if r["engine"] == "gemini_api"} web_rows = {r["query_id"]: r for r in clean if r["engine"] == "gemini_web"} both = sorted(set(api_rows) & set(web_rows)) if both: P(f"\nE3. GEMINI API vs GEMINI WEB ({len(both)} queries answered by both)") api_f = [set(canon(n) for n in per_row[("gemini_api", q)]) for q in both] web_f = [set(canon(n) for n in per_row[("gemini_web", q)]) for q in both] na = sum(len(x) for x in api_f) nw = sum(len(x) for x in web_f) shared = sum(len(a & w) for a, w in zip(api_f, web_f)) union = sum(len(a | w) for a, w in zip(api_f, web_f)) P(f" firms named, API {na}") P(f" firms named, WEB {nw}") P(f" mean per answer, API / WEB {na / len(both):.1f} / {nw / len(both):.1f}") P(f" same firm named by BOTH {shared} " f"({shared / max(union, 1) * 100:.1f}% of the union)") # Per-query agreement is the number a client actually feels. agree = sum(1 for a, w in zip(api_f, web_f) if a and w and (a & w)) nonempty = sum(1 for a, w in zip(api_f, web_f) if a and w) if nonempty: P(f" queries where they share >=1 firm {agree}/{nonempty} " f"({agree / nonempty * 100:.1f}%)") # The mechanism: GBP grounding shows up as star ratings and opening hours. gbp = GBP_MARKER ga = sum(1 for q in both if gbp.search(api_rows[q]["answer_text"])) gw = sum(1 for q in both if gbp.search(web_rows[q]["answer_text"])) P(f" answers carrying Google Business Profile markers" f" (ratings/hours):") P(f" gemini_api {ga:4}/{len(both)}") P(f" gemini_web {gw:4}/{len(both)}") # How often does an answer name NOBODY at all? empty = sum(1 for r in clean if not per_row[(r["engine"], r["query_id"])]) P(f"\n answers naming ZERO firms {empty} " f"({empty / max(len(clean), 1) * 100:.1f}% of geo-clean answers)") dom: Counter = Counter() for r in clean: for u in (r.get("cited_urls") or []): d = re.sub(r"^www\.", "", str(u).lower().split("/")[0]) if d: dom[d] += 1 P(f"\nF. WHICH SOURCES THE ANSWERS LEAN ON ({sum(dom.values())} citations)") for d, n in dom.most_common(15): P(f" {n:5} {d} ({n / max(sum(dom.values()), 1) * 100:.1f}%)") # G. ANSWER SHAPE, measured STRUCTURALLY. # An earlier version counted my own --note annotations, which existed on # only 24 rows, so it reported 9.1% against a denominator of 231. Measure it # from the text instead: an answer is "name-only" when it names at least one # firm and contains no sentence of 12+ words, i.e. the engine hands over # businesses with no reasoning a reader could weigh. def longest_sentence(t): best = 0 # Split on EVERY newline, not just blank lines. A card answer is eight # names on eight consecutive lines; joined, they look like one very long # sentence and the answer was misread as prose. That put name-only at # 0.1% when those answers were plainly nothing but names. for sent in re.split(r"[.!?]+\s+|\n+", t): best = max(best, len(sent.split())) return best P("\nG. ANSWER SHAPE (does the answer REASON, or just hand over names?)") P(f" {'engine':12} {'answers':>8} {'name-only':>11} {'share':>8} {'med chars':>10}") for e in ENGINES + ("ALL",): sub = [r for r in clean if e == "ALL" or r["engine"] == e] withnames = [r for r in sub if per_row[(r["engine"], r["query_id"])]] if not withnames: continue nameonly = [r for r in withnames if longest_sentence(r["answer_text"]) < 12] med = statistics.median([r["answer_chars"] for r in nameonly]) if nameonly else 0 P(f" {e:12} {len(withnames):8} {len(nameonly):11} " f"{len(nameonly) / len(withnames) * 100:7.1f}% {med:10.0f}") allw = [r for r in clean if per_row[(r["engine"], r["query_id"])]] allp = [r for r in allw if longest_sentence(r["answer_text"]) >= 12] if allp: P(f" median chars, answers that DO reason " f"{statistics.median([r['answer_chars'] for r in allp]):.0f}") P("\nH. INTENT DISPLACEMENT (does an intent query return DIFFERENT agents?)") area_tier = defaultdict(lambda: defaultdict(set)) for r in clean: if not r.get("area"): continue for n in per_row[(r["engine"], r["query_id"])]: area_tier[r["area"]][r["tier"]].add(canon(n)) P(f" {'intent':12} {'areas':>6} {'names':>7} {'absent from generic':>21}") for t in ("firsttime", "sell", "invest", "relocate", "luxury", "newbuild", "condo"): tot = new = areas = 0 for _a, d in area_tier.items(): gen = d.get("discovery", set()) | d.get("metro", set()) cur = d.get(t, set()) if not cur or not gen: continue areas += 1 tot += len(cur) new += len(cur - gen) if tot: P(f" {t:12} {areas:6} {tot:7} {new / tot * 100:20.1f}%") P("\n" + "=" * 74) if __name__ == "__main__": main()