#!/usr/bin/env python3 # MIT licence. Copyright (c) 2026 Tuper. Written by an AI agent (Claude); use at your own risk. # Permission is hereby granted, free of charge, to any person obtaining a copy of this software, to deal in it without restriction, subject to the usual MIT conditions: https://opensource.org/license/mit """claims-check: prices, plan limits and claims on our 6 product sites + listing drafts vs live Polar and the code (task #271). Stdlib only; run as: python3 -I tools/claims-check.py [--site HOST] [--all] 1. Expected prices: live Polar products (GET /v1/products, metadata.bet) via bets/_foundation/polar/polar.py; Countwell bills through Shopify, so its prices come from apps/stockcount/app/lib/plans.ts. 2. Expected limits: constants read from the code at run time (CONFIG 'code', each with file + regex; the file:line where it was found is printed, so a changed constant changes the expected set). 3. Every sitemap URL (bot UA + ?nopv=1, rules 65/76): visible text + JSON-LD offers are scanned for '$N', '/month', '/year', 'N ', trial days, superlatives/unverifiable stats, and claims of features that are off or not built (CONFIG 'gated'). 4. Listing drafts (CONFIG 'drafts') get the same scan. A price/limit in a sentence that names a rival (CONFIG 'rivals') or is clearly about someone else is reported as 'rival' (not a mismatch). Output: table site | url | found | expected | source; exit 1 on any MISMATCH/CLAIM finding. --all also prints the OK rows. """ import html, json, os, re, sys, urllib.request, xml.etree.ElementTree as ET ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) UA = "tuper-claims-check-bot/1.0 (+https://tuper.app; our own claims checker)" # 'bot': apps don't count it (rule 76) # ---------------------------------------------------------------- CONFIG # code: (label, file, regex with one group) -> number. noun: regex for the limit noun on pages; its # allowed values are the code numbers of that label(s) (plus 'extra' for numbers that are true but # not a plan limit, each with the reason). CONFIG = { "formwell.tuper.app": { "bet": "discord-bot", "brand": "Formwell", "code": [ ("forms", "apps/formwell/src/limits.js", r"const FREE = Object\.freeze\(\{ forms: (\d+)"), ("forms", "apps/formwell/src/limits.js", r"const PRO = Object\.freeze\(\{ forms: (\d+)"), ("questions", "apps/formwell/src/limits.js", r"const FREE = .*questions: (\d+)"), ("questions", "apps/formwell/src/limits.js", r"const PRO = .*questions: (\d+)"), ], "nouns": {"forms": r"(?:application )?forms?", "questions": r"questions?"}, "extra": {"questions": {5: "Discord modal page = 5 inputs (PAGE_SIZE)"}}, "gated": [ # feature phrase, why it may not be claimed as working, hedge words that make it OK (r"file upload|upload (?:a )?(?:file|screenshot)", "build 5 file upload not live-tested (need #15, bets/discord-bot/build-4.md LIVE TEST 11-15)", r"not yet|coming|soon|no file upload|planned|isn't|is not|pending|live test|prueba real pendiente|teste real pendente|Praxistest steht aus|test réel à faire|google form|google's|max_files|Attach Files"), ], }, "dutyroll.tuper.app": { "bet": "slack-app", "brand": "Dutyroll", "code": [ ("rotations", "apps/dutyroll/src/billing.js", r"const FREE_ROTATIONS = (\d+)"), ("trial", "apps/dutyroll/src/billing.js", r"const TRIAL_DAYS = (\d+)"), ], "nouns": {"rotations": r"(?:active )?rotations?"}, "gated": [ (r"@oncall|user ?group", "build 3 @handle sync is OFF in production (DUTYROLL_USERGROUPS unset until need #16, bets/slack-app/build-3.md)", r"not yet|coming|soon|planned|after|isn't|is not|doesn't|does not|no user|pagerly|rotation app|rosterbird|paid slack|only if|if need|if live|before"), (r"remind(?:s|er)? (?:the next|them) .{0,20}(?:day before|before)", "build 10 day-before reminder NOT BUILT (bets/slack-app/build-10.md)", r"not|no "), ], }, "indexkeeper.tuper.app": { "bet": "chrome-ext", "brand": "Indexkeeper", "code": [ ("properties", "apps/indexkeeper/src/lib/plans.js", r"free: \{ name: 'Free', maxProperties: (\d+)"), ("properties", "apps/indexkeeper/src/lib/plans.js", r"pro: +\{ name: 'Pro', +maxProperties: (\d+)"), ("urls", "apps/indexkeeper/src/lib/plans.js", r"free: .*maxUrlsPerProperty: (\d+)"), ("urls", "apps/indexkeeper/src/lib/plans.js", r"pro: .*maxUrlsPerProperty: (\d+)"), ("history", "apps/indexkeeper/src/lib/plans.js", r"free: .*historyDays: (\d+)"), ], "nouns": {"properties": r"(?:Search Console )?propert(?:y|ies)|sites?", "urls": r"URLs?"}, "gated": [], }, "keepsorted.tuper.app": { "bet": "sheets-sort", "brand": "KeepSorted", "code": [ ("rules", "bets/sheets-sort/addon/Engine.gs", r"free: \{ rulesPerSheet: (\d+)"), ("rules", "bets/sheets-sort/addon/Engine.gs", r"pro: \{ rulesPerSheet: (\d+)"), ("keys", "bets/sheets-sort/addon/Engine.gs", r"free: \{ rulesPerSheet: \d+, keys: (\d+)"), ("keys", "bets/sheets-sort/addon/Engine.gs", r"pro: \{ rulesPerSheet: \d+, keys: (\d+)"), ], "nouns": {"rules": r"(?:sort )?rules?", "keys": r"sort (?:keys|levels)"}, "gated": [], }, "replacekit.tuper.app": { "bet": "sheets-addon", "brand": "ReplaceKit", "code": [ ("sets", "bets/sheets-addon/addon/Code.gs", r"var RK_SETS_FREE = (\d+)"), ("sets", "bets/sheets-addon/addon/Code.gs", r"var RK_SETS_PRO = (\d+)"), ], "nouns": {"sets": r"saved (?:rule )?sets?"}, "gated": [], }, "stockcount.tuper.app": { "bet": "shopify-app", "brand": "Countwell", "billing": "shopify", "extra_bets": ["stocktake-kit"], # r467: /stocktake-kit sells the Polar one-time kit on this host "code": [ ("price", "apps/stockcount/app/lib/plans.ts", r"\[STANDARD\]: \{ price: (\d+)"), ("price", "apps/stockcount/app/lib/plans.ts", r"\[PRO\]: \{ price: (\d+)"), ("items", "apps/stockcount/app/lib/plans.ts", r"Free: \{ price: 0, itemsPerMonth: (\d+)"), ("items", "apps/stockcount/app/lib/plans.ts", r"\[STANDARD\]: \{ price: \d+, itemsPerMonth: (\d+)"), ("trial", "apps/stockcount/app/lib/plans.ts", r"export const TRIAL_DAYS = (\d+)"), ], "nouns": {"items": r"(?:counted )?items"}, "gated": [], }, } # Listing drafts -> site whose expectations they follow. DRAFTS = { "bets/chrome-ext/listing/README.md": "indexkeeper.tuper.app", "bets/sheets-addon/listing/README.md": "replacekit.tuper.app", "bets/sheets-addon/listing.md": "replacekit.tuper.app", "bets/sheets-sort/listing/README.md": "keepsorted.tuper.app", "bets/sheets-sort/listing.md": "keepsorted.tuper.app", "bets/shopify-app/listing/README.md": "stockcount.tuper.app", "bets/shopify-app/listing.md": "stockcount.tuper.app", "bets/discord-bot/listings.md": "formwell.tuper.app", "bets/slack-app/directories/listings.md": "dutyroll.tuper.app", "bets/slack-app/marketplace/README.md": "dutyroll.tuper.app", } # Known-good findings: (site, path or draft, text in the sentence, why it is not a claim about our price). ALLOW = [ ("replacekit.tuper.app", "/google-docs-find-and-replace-in-selection/", "$5.00 , (draft)", "sample text with special characters, not a price"), ("replacekit.tuper.app", "/google-docs-regex-find-and-replace/", "(no $1)", "regex group in an Apps Script example"), ] # A sentence naming one of these is about a rival (comparison pages, research notes in drafts). RIVALS = r"""Appy\b|Appy ?Pie|Formcord|Application Bot|not bought|Ticket ?Tool|Carl-?bot|MEE6|Dyno|Forms ?Bot|Application ?Bot|Apply ?Bot|Helper\.gg|Google Forms|Typeform| Pagerly|Rotation ?App|RosterBird|OrgaNice|Meet For Slack|PagerDuty|Opsgenie|incident\.io|Rootly|Spike|Workast|Geekbot|Slack Workflow| StaySorted|Sort ?Sheet|Sheetgo|Power Tools|Ablebits|Advanced Find|Find and Replace|Coupler|Supermetrics| MiMi|EasyScan|Stocky|Sumtracker|Stock ?Sync|Stockpile|PML|Inventory Planner|Katana|Sortly|Shopify POS|Shopify's|native Stock counts|POS Pro| URL Monitor|IndexCheckr|credits\b|Index ?Checker|IndexGuru|Indexly|Rapid URL Indexer|Omega Indexer|SpeedyIndex|Screaming Frog|Ahrefs|Semrush|Search Console Insights| BetaList|Launchdog|TinyLaunch|Crozdesk|Uneed|Product Hunt|alternative\.me|top\.gg|PeerPush|Neeed|Tiny Startups|Slack Pro|Slack's|Discord Nitro|Nitro| Workspace Marketplace|Chrome Web Store|App Store listing fee|registration fee|developer fee|Hyperwallet|PayPal|Polar's fee|Brevo|Forward Email""".replace("\n", "") RIVAL_RE = re.compile(RIVALS) # case-sensitive: "rotation app" in our own copy is not the rival Rotation App SUPERLATIVE = re.compile(r"\b(?:the (?:first|best|only|leading|fastest|easiest|#1|top) (?:\w+ ){0,2}(?:app|tool|bot|add-?on|extension|solution|form builder|checker)s?\b" r"|(?:best|leading|fastest|easiest|most popular) (?:\w+ ){0,2}(?:app|tool|bot|add-?on|extension)s?\b|#1\b|number one|best[- ]in[- ]class|world'?s (?:best|first|leading)" r"|thousands of (?:users|teams|servers|stores|merchants|customers|sites|people)|millions of (?:users|rows)|trusted by|loved by|used by \d|industry[- ]leading)", re.I) PRICE = re.compile(r"(?:US)?\$\s?(\d+(?:[.,]\d{1,2})?)(?![\dMKk])(\s*(?:/|per |a |an |each )\s*(?:mo(?:nth)?|yr|year|annum|month|week))?", re.I) TRIAL = re.compile(r"(\d+)[- ]day (?:free |Pro )?trial", re.I) NUMWORD = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "ten": 10, "fifty": 50} def code_value(rel, rx): src = open(os.path.join(ROOT, rel)).read().splitlines() for i, line in enumerate(src, 1): m = re.search(rx, line) if m: return int(m.group(1)), f"{rel}:{i}" raise SystemExit(f"CONFIG constant not found: {rel} /{rx}/ (code changed: update CONFIG)") def polar_prices(): sys.dont_write_bytecode = True sys.path.insert(0, os.path.join(ROOT, "bets/_foundation/polar")) import polar # noqa: E402 (our own helper) st, j = polar.call("GET", "/v1/products/?limit=100&is_archived=false") if st != 200: raise SystemExit(f"Polar GET /v1/products -> {st}") out = {} for p in j["items"]: bet = (p.get("metadata") or {}).get("bet") for pr in p["prices"]: if pr.get("is_archived") or pr.get("amount_type") != "fixed": continue out.setdefault(bet, []).append((pr["price_amount"] / 100, pr.get("recurring_interval"), f"Polar '{p['name']}'")) return out def fetch(url): sep = "&" if "?" in url else "?" req = urllib.request.Request(url + sep + "nopv=1", headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=30) as r: return r.read().decode("utf-8", "replace") def sitemap(host): root = ET.fromstring(fetch(f"https://{host}/sitemap.xml").encode()) return [e.text.strip() for e in root.iter() if e.tag.endswith("loc") and e.text] def page_parts(doc): lds = re.findall(r"]+application/ld\+json[^>]*>(.*?)", doc, re.S | re.I) t = re.sub(r"<(script|style|noscript|template)\b.*?", " ", doc, flags=re.S | re.I) t = re.sub(r"", " ", t, flags=re.S) t = re.sub(r"<(?:br|/p|/li|/h\d|/td|/th|/tr|/div|/summary|/dt|/dd|/figcaption)\b[^>]*>", ". ", t, flags=re.I) t = html.unescape(re.sub(r"<[^>]+>", " ", t)) return re.sub(r"\s+", " ", t), lds def sentences(text): for s in re.split(r"(?<=[.!?;])\s+|\s\.\s|\n+", text): s = s.strip() if s: yield s def walk_offers(o): if isinstance(o, dict): if o.get("@type") in ("Offer", "AggregateOffer") or "price" in o: yield o for v in o.values(): yield from walk_offers(v) elif isinstance(o, list): for v in o: yield from walk_offers(v) class Checker: def __init__(self): self.rows = [] # (status, site, where, found, expected, source) prices = polar_prices() self.exp = {} for host, c in CONFIG.items(): lim = {} for label, rel, rx in c["code"]: v, loc = code_value(rel, rx) lim.setdefault(label, []).append((v, loc)) if c.get("billing") == "shopify": pr = [(v, "month", f"Shopify Billing {loc}") for v, loc in lim.pop("price")] else: pr = prices.get(c["bet"], []) if not pr: print(f"note: no Polar product with metadata bet={c['bet']}") for b in c.get("extra_bets", []): pr = pr + prices.get(b, []) pr = pr + [(0.0, None, "Free plan")] self.exp[host] = {"prices": pr, "limits": lim} def add(self, *row): if row[0] in ("MISMATCH", "CLAIM") and any(h == row[1] and w == row[2] and t in row[3] for h, w, t, _ in ALLOW): row = ("allowed",) + row[1:] self.rows.append(row) def check_text(self, host, where, text): c, e = CONFIG[host], self.exp[host] allowed = {(round(a, 2), per) for a, per, _ in e["prices"]} exp_price = ", ".join(sorted({f"${a:g}/{per}" if per else f"${a:g}" for a, per, _ in e["prices"]})) src_price = "; ".join(sorted({s for *_, s in e["prices"]})) subject = None # 'rival' or 'us': sentences naming neither inherit the last one (table rows, "Its free tier ...") for s in sentences(text): r_hit, us_hit = RIVAL_RE.search(s), re.search(re.escape(c["brand"]), s, re.I) if re.match(r"(?:Its|It|They|Their)\b", s): rival = subject == "rival" elif r_hit and us_hit: rival = r_hit.start() < us_hit.start() and not re.search(re.escape(c["brand"]) + r"(?: Pro)? (?:is|costs|charges|gives|keeps|allows|has)", s, re.I) subject = "us" elif r_hit or us_hit: subject = "rival" if r_hit else "us" rival = subject == "rival" else: rival = subject == "rival" for m in PRICE.finditer(s): if (m.start() > 0 and not re.match(r"[\s(]", s[m.start() - 1])) or re.search(r"regex|regular expression|expresiones regulares|express\u00f5es regulares|grupos|\$<|\$&|\(\\d|groups?\b|\^p|\\n|^\W*\$\d\W*$", s, re.I) and "." not in m.group(1): continue # $1/$2 regex groups on ReplaceKit pages, not prices amt = float(m.group(1).replace(",", ".")) g = (m.group(2) or "").lower() per = "month" if re.search(r"mo", g) else "year" if re.search(r"yr|year|annum", g) else "week" if "week" in g else None ok = (round(amt, 2), per) in allowed or (per is None and any(round(amt, 2) == a for a, _ in allowed)) st = "OK" if ok else ("rival" if rival else "MISMATCH") self.add(st, host, where, f"{m.group(0).strip()} :: {s[:110]}", exp_price, src_price) for m in TRIAL.finditer(s): tr = e["limits"].get("trial") if not tr: continue ok = int(m.group(1)) in {v for v, _ in tr} self.add("OK" if ok else ("rival" if rival else "MISMATCH"), host, where, f"{m.group(0)} :: {s[:110]}", "/".join(str(v) for v, _ in tr) + "-day trial", "; ".join(l for _, l in tr)) for label, noun in c.get("nouns", {}).items(): vals = e["limits"].get(label, []) extra = c.get("extra", {}).get(label, {}) for m in re.finditer(r"(?