victor34593993's picture
system-review backend fixes (judge floor, client_ip, billing match, XSS, whatsapp, audit caps, RAG, og async)
069d2cd verified
Raw
History Blame
29.6 kB
"""Free public "store support audit" tool — logic + rendering.
For a crawled Shopify store we score how many of the 12 universal high-intent
buyer questions its OWN public site answers (reusing the honest gap report), grade
it, and persist it (AuditResult, keyed by domain) so /audit/<domain> is a PERMANENT,
shareable, crawlable page — the build-as-marketing / programmatic-SEO wedge.
Everything here is honest by construction: real buyer questions, HIT/MISS from the
store's own indexed pages, conservative MISS threshold, no invented figures.
"""
# ruff: noqa: E501 - this module embeds HTML/CSS page templates
from __future__ import annotations
import asyncio
import html
import io
import json as _json
import logging
import re as _re
from datetime import UTC, datetime
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.demo_gap import GAP_QUESTIONS
from app.models import AuditResult
from app.rag import extract
from app.shopify.policies import fetch_shop_policies
log = logging.getLogger(__name__)
PUBLIC_BASE = "https://atendyo.com"
BACKEND_BASE = "https://victor34593993-flexigo-support-bot.hf.space"
def grade_for(answered: int, total: int) -> str:
"""A clear letter grade from the answered ratio (honest, not punitive)."""
if total <= 0:
return "F"
pct = answered / total
if pct >= 0.9:
return "A"
if pct >= 0.75:
return "B"
if pct >= 0.6:
return "C"
if pct >= 0.4:
return "D"
return "F"
def _norm_domain(raw: str) -> str:
d = (raw or "").strip().lower()
d = d.split("//")[-1].split("/")[0].split("?")[0].removeprefix("www.")
# #17: a domain is later interpolated into HTML attributes + inline JS. Restrict
# to real hostname characters so a store-controlled value can never carry
# quotes/<>/angle brackets that would break out into an XSS/attribute injection.
import re
d = re.sub(r"[^a-z0-9.-]", "", d)
return d[:255]
async def save_audit(
db: AsyncSession, domain: str, report: dict, lang: str = "es"
) -> AuditResult:
"""Upsert the audit result for a domain (re-audits update the same row)."""
domain = _norm_domain(domain)
answered = int(report.get("answered", 0))
total = int(report.get("total", 12))
row = (
await db.execute(select(AuditResult).where(AuditResult.domain == domain))
).scalar_one_or_none()
now = datetime.now(UTC)
if row is None:
row = AuditResult(domain=domain, created_at=now)
db.add(row)
row.brand = (report.get("brand") or domain)[:255]
row.answered = answered
row.total = total
row.grade = grade_for(answered, total)
row.questions = report.get("questions", [])
row.lang = "en" if lang == "en" else "es"
row.updated_at = now
await db.flush()
return row
async def get_audit(db: AsyncSession, domain: str) -> AuditResult | None:
return (
await db.execute(
select(AuditResult).where(AuditResult.domain == _norm_domain(domain))
)
).scalar_one_or_none()
async def get_or_audit(db: AsyncSession, domain: str, lang: str = "es") -> AuditResult:
"""Cached audit if we have one, else audit it now (used by the compare view)."""
row = await get_audit(db, domain)
if row is None:
row = await audit_store(db, domain, lang)
return row
# Common Shopify info pages worth reading (beyond /policies/*). Best-effort.
_INFO_PATHS = [
"", "pages/faq", "pages/faqs", "pages/about", "pages/about-us", "pages/contact",
"pages/size-guide", "pages/sizing", "pages/shipping", "pages/shipping-returns",
"pages/returns", "pages/help", "pages/frequently-asked-questions",
]
async def _gather_store_content(domain: str, *, cap: int = 14000) -> str:
"""Reliable, server-rendered content for the 12 questions: the store's REAL
/policies/* pages (refund/shipping/privacy/terms) + a few common info pages.
Independent of the flaky JS crawl, so a JS-heavy storefront is still scored
from its real policy text. Best-effort: failures are skipped."""
domain = _norm_domain(domain)
base = "https://" + domain
parts: list[str] = []
try:
for pol in await fetch_shop_policies(domain):
parts.append(f"[{pol['title']}]\n{pol['text']}")
except Exception: # noqa: BLE001
log.warning("audit: policy fetch failed for %s", domain, exc_info=True)
async def _one(client: httpx.AsyncClient, path: str) -> str:
url = f"{base}/{path}".rstrip("/")
try:
txt = (await extract.extract_url(url, client=client) or "").strip()
return f"[{path or 'home'}]\n{txt[:3000]}" if len(txt) >= 40 else ""
except Exception: # noqa: BLE001
return ""
try:
async with httpx.AsyncClient(
timeout=extract.CRAWL_TIMEOUT, follow_redirects=True,
event_hooks={"request": [extract._ssrf_guard_hook]},
) as client:
results = await asyncio.gather(*[_one(client, p) for p in _INFO_PATHS])
parts.extend(r for r in results if r)
except Exception: # noqa: BLE001
log.warning("audit: page fetch failed for %s", domain, exc_info=True)
# Strip boilerplate: a line that repeats across 3+ of the fetched pages is the
# nav/menu/footer, not real policy/info content — dropping it lets the judge
# see the actual answers instead of menu noise. Keep the [label] markers.
from collections import Counter
lines = "\n\n".join(parts).split("\n")
freq = Counter(ln.strip() for ln in lines if len(ln.strip()) > 3)
cleaned = [ln for ln in lines if ln.startswith("[") or freq.get(ln.strip(), 0) < 3]
return "\n".join(cleaned)[:cap]
def _parse_verdicts(raw: str) -> list[bool]:
"""Parse the LLM's verdicts robustly: strip markdown fences, try a bare JSON
array, else any [...] block, else extract the run of true/false tokens."""
s = (raw or "").strip().strip("`")
s = _re.sub(r"^json", "", s, flags=_re.I).strip()
candidates = [s]
block = _re.search(r"\[[\s\S]*?\]", s)
if block:
candidates.append(block.group(0))
for cand in candidates:
try:
arr = _json.loads(cand)
if isinstance(arr, list):
return [bool(x) for x in arr][:12]
except (ValueError, TypeError):
pass
# last resort: read the ordered true/false words the model emitted
toks = _re.findall(r"\b(true|false|sí|si|no|yes)\b", s, flags=_re.I)
if toks:
return [t.lower() in ("true", "sí", "si", "yes") for t in toks][:12]
return []
async def _judge_raw(content: str, lang: str) -> tuple[list[bool], str]:
"""Returns (verdicts, raw_llm_text) — raw exposed for debugging."""
from app.config import get_settings
from app.llm.router import build_router_from_settings
qs = GAP_QUESTIONS.get("en" if lang == "en" else "es", GAP_QUESTIONS["es"])
numbered = "\n".join(f"{i + 1}. {q}" for i, q in enumerate(qs))
sys = (
"You audit an online store's PUBLIC content against 12 questions shoppers ask "
"before buying. For EACH question, decide if the content gives a usable answer "
"(a brief mention that actually answers counts; pure navigation/menu text does "
"not). Reply with ONLY a JSON array of exactly 12 values true or false, in order "
"(true = answered). No prose, no keys, just the array."
)
user = (
f"STORE CONTENT:\n{content or '(no content found)'}\n\n"
f"QUESTIONS:\n{numbered}\n\nJSON array of 12 true/false:"
)
router = build_router_from_settings(get_settings())
result = await router.chat(
[{"role": "system", "content": sys}, {"role": "user", "content": user}],
# gpt-oss-120b is a REASONING model: it spends tokens "thinking" before the
# answer, so a small max_tokens leaves the content EMPTY. Give it room.
tools=[], temperature=0.0, max_tokens=2000,
)
raw = (result.content or "").strip()
verdicts = _parse_verdicts(raw)
# #7: do NOT pad an empty/unparseable judge to a fake 0/12. gpt-oss-120b is a
# reasoning model that can spend all tokens "thinking" and return empty/prose;
# padding would publish a permanent, SEO-indexed, DEFAMATORY "0/12 grade F" for
# a store that may be excellent. Require at least half the verdicts parsed,
# else signal failure so audit_store -> 422 and no page is persisted.
if len(verdicts) < 6:
raise ValueError("judge_unparseable")
verdicts = verdicts[:12] + [False] * (12 - len(verdicts))
return verdicts, raw
async def _judge_questions(content: str, lang: str) -> list[bool]:
verdicts, _ = await _judge_raw(content, lang)
return verdicts
async def audit_store(db: AsyncSession, domain: str, lang: str = "es") -> AuditResult:
"""Fetch the store's REAL public content + LLM-judge the 12 buyer questions,
then persist. Independent of the demo crawl (reliable + no lead-email noise).
Raises ValueError('no_content') when the store can't be read — we never show a
fake 0/12 just because a probe failed."""
domain = _norm_domain(domain)
content = await _gather_store_content(domain)
if len(content) < 80:
raise ValueError("no_content")
verdicts = await _judge_questions(content, lang)
qs = GAP_QUESTIONS.get("en" if lang == "en" else "es", GAP_QUESTIONS["es"])
questions = [
{"q": q, "status": "hit" if v else "miss"}
for q, v in zip(qs, verdicts, strict=False)
]
report = {
"brand": domain, "total": len(qs),
"answered": sum(1 for v in verdicts if v), "questions": questions,
}
return await save_audit(db, domain, report, lang=lang)
# ── rendering ───────────────────────────────────────────────────────────────
_T = {
"es": {
"title": "{brand}: responde {a}/{t} preguntas de compra | Auditoría Atendyo",
"desc": "{brand} responde {a} de {t} preguntas clave que tus clientes hacen antes de comprar. Mira cuáles fallan (gratis).",
"h1": "{brand} responde {a}/{t} preguntas de compra",
"sub": "Estas son las preguntas que tus clientes hacen ANTES de comprar. Cada una sin respuesta clara en tu web = una venta que se escapa o un ticket de soporte.",
"answers": "Tu web responde",
"gaps_h": "No responde con claridad:",
"ok_h": "Sí responde:",
"cta_h": "El asistente de IA de Atendyo responde TODAS estas, 24/7, con la información de tu propia tienda.",
"cta_btn": "Probar gratis con mi tienda",
"share": "Comparte tu resultado",
"copy": "Copiar enlace",
"audit_another": "Auditar otra tienda",
"powered": "Auditoría gratis de Atendyo · atendyo.com",
"compare_h": "Compáralo con un competidor",
"compare_btn": "Comparar",
"badge_h": "Muestra tu nota en tu web (insignia)",
},
"en": {
"title": "{brand}: answers {a}/{t} shopper questions | Atendyo Audit",
"desc": "{brand} answers {a} of {t} key questions your customers ask before buying. See which ones fail (free).",
"h1": "{brand} answers {a}/{t} shopper questions",
"sub": "These are the questions your customers ask BEFORE buying. Each one your site doesn't clearly answer = a lost sale or a support ticket.",
"answers": "Your site answers",
"gaps_h": "Not clearly answered:",
"ok_h": "Answered:",
"cta_h": "Atendyo's AI assistant answers ALL of these, 24/7, from your own store's info.",
"cta_btn": "Try free on my store",
"share": "Share your result",
"copy": "Copy link",
"audit_another": "Audit another store",
"powered": "Free audit by Atendyo · atendyo.com",
"compare_h": "Compare it with a competitor",
"compare_btn": "Compare",
"badge_h": "Show your score on your site (badge)",
},
}
_GRADE_COLOR = {"A": "#16a34a", "B": "#65a30d", "C": "#ca8a04", "D": "#ea580c", "F": "#dc2626"}
def _question_text(q: dict, lang: str) -> str:
return str(q.get("q") or "")
def render_audit_page(row: AuditResult, *, base: str = PUBLIC_BASE) -> str:
lang = "en" if row.lang == "en" else "es"
t = _T[lang]
brand = html.escape(row.brand or row.domain)
a, tot = row.answered, row.total
grade = row.grade or grade_for(a, tot)
color = _GRADE_COLOR.get(grade, "#dc2626")
qs = row.questions or []
gaps = [q for q in qs if q.get("status") == "miss"]
oks = [q for q in qs if q.get("status") == "hit"]
page_url = f"{base}/audit/{row.domain}"
og = f"{base}/audit/{row.domain}/og.png"
def _li(items):
return "".join(f"<li>{html.escape(_question_text(q, lang))}</li>" for q in items)
title = t["title"].format(brand=brand, a=a, t=tot)
desc = t["desc"].format(brand=brand, a=a, t=tot)
try_url = f"{base}/demo" # "try the bot that answers these" -> the instant demo
badge_embed = html.escape(
f'<a href="{page_url}"><img src="{base}/audit/{row.domain}/badge.svg" alt="Atendyo"></a>'
)
return f"""<!doctype html>
<html lang="{lang}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="{html.escape(desc)}" />
<link rel="canonical" href="{page_url}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{title}" />
<meta property="og:description" content="{html.escape(desc)}" />
<meta property="og:image" content="{og}" />
<meta property="og:url" content="{page_url}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="{og}" />
<style>
:root {{ --c:{color}; }}
*{{box-sizing:border-box}} body{{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#0f172a;background:#f8fafc}}
.wrap{{max-width:680px;margin:0 auto;padding:28px 20px 60px}}
.logo{{font-weight:800;font-size:18px;color:#1c59e9;text-decoration:none}}
.card{{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:26px;margin-top:18px;box-shadow:0 8px 30px rgba(2,8,20,.05)}}
.score{{display:flex;align-items:center;gap:18px}}
.grade{{flex:none;width:78px;height:78px;border-radius:16px;background:var(--c);color:#fff;font-size:40px;font-weight:800;display:flex;align-items:center;justify-content:center}}
h1{{font-size:24px;margin:0 0 4px}}
.sub{{color:#475569;font-size:15px;margin:14px 0 0;line-height:1.55}}
h3{{font-size:15px;margin:22px 0 8px}}
ul{{margin:0;padding-left:20px;line-height:1.8}}
ul.miss li{{color:#b91c1c}} ul.ok li{{color:#15803d}}
.cta{{background:#0f1830;color:#fff;border-radius:16px;padding:22px;margin-top:22px;text-align:center}}
.cta p{{margin:0 0 14px;font-size:16px;line-height:1.5}}
.btn{{display:inline-block;background:#1c59e9;color:#fff;text-decoration:none;font-weight:700;border-radius:999px;padding:13px 26px}}
.share{{margin-top:20px}} .share label{{font-size:13px;color:#64748b;display:block;margin-bottom:6px}}
.row{{display:flex;gap:8px}} .row input{{flex:1;border:1px solid #cbd5e1;border-radius:10px;padding:10px;font-size:13px}}
.row a,.row button{{border:1px solid #cbd5e1;background:#fff;border-radius:10px;padding:10px 12px;font-size:13px;cursor:pointer;text-decoration:none;color:#0f172a;white-space:nowrap}}
footer{{text-align:center;color:#94a3b8;font-size:12px;margin-top:30px}} footer a{{color:#94a3b8}}
</style></head>
<body><div class="wrap">
<a class="logo" href="{base}/audit">Atendyo</a>
<div class="card">
<div class="score">
<div class="grade">{grade}</div>
<div><h1>{t['h1'].format(brand=brand, a=a, t=tot)}</h1>
<div style="color:#64748b;font-size:14px">{t['answers']} <b>{a}/{tot}</b></div></div>
</div>
<p class="sub">{t['sub']}</p>
{f'<h3>{t["gaps_h"]}</h3><ul class="miss">{_li(gaps)}</ul>' if gaps else ''}
{f'<h3>{t["ok_h"]}</h3><ul class="ok">{_li(oks)}</ul>' if oks else ''}
<div class="cta">
<p>{t['cta_h']}</p>
<a class="btn" href="{try_url}">{t['cta_btn']}</a>
</div>
<div class="share">
<label>{t['share']}</label>
<div class="row">
<input id="u" value="{page_url}" readonly />
<button onclick="navigator.clipboard&&navigator.clipboard.writeText(document.getElementById('u').value);this.textContent='OK'">{t['copy']}</button>
<a href="https://wa.me/?text={html.escape(page_url)}" target="_blank" rel="noopener">WhatsApp</a>
</div>
</div>
<div class="share">
<label>{t['compare_h']}</label>
<div class="row">
<input id="vs" placeholder="competidor.com" />
<button onclick="var v=(document.getElementById('vs').value||'').trim().replace(/^https?:\\/\\//,'').replace(/\\/.*/,'');if(v)location.href='/audit/{row.domain}/vs/'+encodeURIComponent(v)">{t['compare_btn']}</button>
</div>
</div>
<div class="share">
<label>{t['badge_h']}</label>
<div class="row" style="align-items:center">
<img src="{base}/audit/{row.domain}/badge.svg" alt="Atendyo {a}/{tot}" style="flex:none" />
<input id="bdg" value="{badge_embed}" readonly />
<button onclick="navigator.clipboard&&navigator.clipboard.writeText(document.getElementById('bdg').value);this.textContent='OK'">{t['copy']}</button>
</div>
</div>
</div>
<footer><a href="{base}/audit">{t['powered']}</a> · <a href="{base}/audit">{t['audit_another']}</a></footer>
</div></body></html>"""
_LANDING = {
"es": {
"title": "¿Qué preguntas de compra NO responde tu tienda Shopify? Compruébalo gratis (2026) | Atendyo",
"desc": "Pega la URL de tu tienda Shopify y en 60 segundos te decimos cuántas de las 12 preguntas que tus clientes hacen antes de comprar responde tu web. Gratis, sin registro.",
"h1": "¿Qué preguntas de tus clientes NO responde tu tienda?",
"sub": "Pega la URL de tu tienda Shopify. En 60 segundos analizamos tu propia web y te decimos cuántas de las 12 preguntas clave de compra responde — y cuáles se te escapan. Gratis, sin registro.",
"ph": "tutienda.com",
"btn": "Auditar mi tienda gratis",
"working": "Leyendo toda tu tienda y analizando las respuestas… esto puede tardar 1-2 minutos, no cierres la página.",
"err": "No pudimos analizar esa tienda. Revisa la URL e inténtalo otra vez.",
},
"en": {
"title": "Which shopper questions does your Shopify store NOT answer? Check free (2026) | Atendyo",
"desc": "Paste your Shopify store URL and in 60 seconds we tell you how many of the 12 questions customers ask before buying your site answers. Free, no signup.",
"h1": "Which of your customers' questions does your store NOT answer?",
"sub": "Paste your Shopify store URL. In 60 seconds we analyze your own site and tell you how many of the 12 key buying questions it answers — and which ones slip through. Free, no signup.",
"ph": "yourstore.com",
"btn": "Audit my store free",
"working": "Reading your whole store and analyzing the answers… this can take 1-2 minutes, don't close the page.",
"err": "We couldn't analyze that store. Check the URL and try again.",
},
}
def render_audit_landing(lang: str = "es", *, base: str = PUBLIC_BASE) -> str:
lang = "en" if lang == "en" else "es"
t = _LANDING[lang]
return f"""<!doctype html>
<html lang="{lang}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{t['title']}</title>
<meta name="description" content="{html.escape(t['desc'])}" />
<link rel="canonical" href="{base}/audit" />
<meta property="og:title" content="{html.escape(t['title'])}" />
<meta property="og:description" content="{html.escape(t['desc'])}" />
<meta property="og:image" content="{base}/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<style>
*{{box-sizing:border-box}} body{{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#0f172a;background:linear-gradient(180deg,#fff,#eef2ff)}}
.wrap{{max-width:640px;margin:0 auto;padding:64px 20px;text-align:center}}
.logo{{font-weight:800;font-size:18px;color:#1c59e9;text-decoration:none}}
h1{{font-size:32px;margin:26px 0 12px;line-height:1.2}}
p.sub{{color:#475569;font-size:17px;line-height:1.55;margin:0 auto 26px;max-width:520px}}
form{{display:flex;gap:8px;max-width:460px;margin:0 auto}}
input{{flex:1;border:1px solid #cbd5e1;border-radius:12px;padding:14px;font-size:16px;min-width:0}}
button{{background:#1c59e9;color:#fff;border:0;border-radius:12px;padding:14px 22px;font-size:16px;font-weight:700;cursor:pointer;white-space:nowrap}}
.status{{margin-top:18px;color:#475569;min-height:24px}}
.err{{color:#b91c1c}}
@media(max-width:560px){{h1{{font-size:26px}} form{{flex-direction:column}}}}
</style></head>
<body><div class="wrap">
<a class="logo" href="{base}">Atendyo</a>
<h1>{t['h1']}</h1>
<p class="sub">{t['sub']}</p>
<form id="f" onsubmit="return go(event)">
<input id="u" type="text" inputmode="url" placeholder="{t['ph']}" autocomplete="off" />
<button type="submit">{t['btn']}</button>
</form>
<div class="status" id="s"></div>
<script>
var WORKING={t['working']!r}, ERR={t['err']!r};
(function(){{var p=new URLSearchParams(location.search).get('u');if(p){{document.getElementById('u').value=p;}}}})();
function go(e){{
e.preventDefault();
var u=(document.getElementById('u').value||'').trim();
if(!u) return false;
var s=document.getElementById('s'); s.className='status'; s.textContent=WORKING;
fetch('/audit/start',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{url:u}})}})
.then(function(r){{return r.ok?r.json():r.json().then(function(j){{throw new Error(j.detail||'error')}})}})
.then(function(d){{ window.location.href = '/audit/'+encodeURIComponent(d.domain); }})
.catch(function(e){{s.className='status err';s.textContent=(e&&e.message)||ERR}});
return false;
}}
</script>
</div></body></html>"""
def badge_svg(row: AuditResult) -> str:
"""A small shields-style SVG badge a store can embed ('Atendyo · X/12'). Each
embed is a free backlink + distribution to that store's visitors."""
grade = row.grade or grade_for(row.answered, row.total)
color = _GRADE_COLOR.get(grade, "#dc2626")
val = f"{row.answered}/{row.total}"
lw, vw = 64, 44
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{lw + vw}" height="20" role="img" '
f'aria-label="Atendyo {val}">'
'<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/>'
'<stop offset="1" stop-opacity=".1"/></linearGradient>'
f'<rect rx="3" width="{lw + vw}" height="20" fill="#555"/>'
f'<rect rx="3" x="{lw}" width="{vw}" height="20" fill="{color}"/>'
f'<rect rx="3" width="{lw + vw}" height="20" fill="url(#s)"/>'
'<g fill="#fff" font-family="Verdana,Geneva,sans-serif" font-size="11" text-anchor="middle">'
f'<text x="{lw / 2:.0f}" y="14">Atendyo</text>'
f'<text x="{lw + vw / 2:.0f}" y="14">{val}</text>'
'</g></svg>'
)
def render_compare_page(a: AuditResult, b: AuditResult, *, base: str = PUBLIC_BASE) -> str:
"""Head-to-head: two stores side by side, per-question ✓/✗ + a winner. Rides
'X vs Y' search intent and is more shareable than a single score."""
lang = "en" if (a.lang == "en" or b.lang == "en") else "es"
qs = GAP_QUESTIONS.get("en" if lang == "en" else "es", GAP_QUESTIONS["es"])
qa = {q.get("q"): q.get("status") for q in (a.questions or [])}
qb = {q.get("q"): q.get("status") for q in (b.questions or [])}
na, nb = html.escape(a.brand or a.domain), html.escape(b.brand or b.domain)
yes, no = "✓", "✗"
rows = "".join(
f'<tr><td>{html.escape(q)}</td>'
f'<td class="{("y" if qa.get(q) == "hit" else "n")}">{yes if qa.get(q) == "hit" else no}</td>'
f'<td class="{("y" if qb.get(q) == "hit" else "n")}">{yes if qb.get(q) == "hit" else no}</td></tr>'
for q in qs
)
if a.answered > b.answered:
winner = (f"Gana {na}" if lang == "es" else f"{na} wins") + f" ({a.answered} vs {b.answered})"
elif b.answered > a.answered:
winner = (f"Gana {nb}" if lang == "es" else f"{nb} wins") + f" ({b.answered} vs {a.answered})"
else:
winner = ("Empate" if lang == "es" else "Tie") + f" ({a.answered}/{a.total})"
t = _T[lang]
page_url = f"{base}/audit/{a.domain}/vs/{b.domain}"
title = (f"{na} vs {nb}: ¿quién responde mejor a sus clientes? | Atendyo"
if lang == "es" else f"{na} vs {nb}: who answers shoppers better? | Atendyo")
head = ("Preguntas de compra que responde cada tienda"
if lang == "es" else "Buyer questions each store answers")
return f"""<!doctype html>
<html lang="{lang}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="{na} {a.answered}/{a.total} vs {nb} {b.answered}/{b.total}. {head}." />
<link rel="canonical" href="{page_url}" />
<meta property="og:title" content="{title}" /><meta property="og:image" content="{base}/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<style>
*{{box-sizing:border-box}} body{{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#0f172a;background:#f8fafc}}
.wrap{{max-width:680px;margin:0 auto;padding:28px 20px 60px}}
.logo{{font-weight:800;font-size:18px;color:#1c59e9;text-decoration:none}}
.card{{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:22px;margin-top:18px}}
.heads{{display:flex;text-align:center;font-weight:800;font-size:17px}} .heads div{{flex:1}}
.scores{{display:flex;text-align:center;font-size:30px;font-weight:800;margin:6px 0 4px}} .scores div{{flex:1}}
.win{{text-align:center;color:#1c59e9;font-weight:700;margin:8px 0 16px}}
table{{width:100%;border-collapse:collapse;font-size:14px}}
td{{padding:9px 6px;border-top:1px solid #eef2f7}} td:first-child{{width:64%}}
td.y{{color:#15803d;text-align:center;font-weight:800}} td.n{{color:#b91c1c;text-align:center;font-weight:800}}
.cta{{background:#0f1830;color:#fff;border-radius:16px;padding:20px;margin-top:20px;text-align:center}}
.btn{{display:inline-block;background:#1c59e9;color:#fff;text-decoration:none;font-weight:700;border-radius:999px;padding:12px 24px;margin-top:10px}}
footer{{text-align:center;color:#94a3b8;font-size:12px;margin-top:26px}} footer a{{color:#94a3b8}}
</style></head>
<body><div class="wrap">
<a class="logo" href="{base}/audit">Atendyo</a>
<div class="card">
<div class="heads"><div>{na}</div><div>{nb}</div></div>
<div class="scores"><div>{a.answered}/{a.total}</div><div>{b.answered}/{b.total}</div></div>
<div class="win">{winner}</div>
<table><tbody>{rows}</tbody></table>
</div>
<div class="cta"><p>{t['cta_h']}</p><a class="btn" href="{base}/demo">{t['cta_btn']}</a></div>
<footer><a href="{base}/audit">{t['powered']}</a></footer>
</div></body></html>"""
def og_image_bytes(row: AuditResult) -> bytes:
"""A 1200x630 share card with the score. Bulletproof: any failure returns a
minimal valid PNG instead of raising (the page must never break)."""
try:
from PIL import Image, ImageDraw, ImageFont
grade = row.grade or grade_for(row.answered, row.total)
color = _GRADE_COLOR.get(grade, "#dc2626")
img = Image.new("RGB", (1200, 630), "#0f1830")
d = ImageDraw.Draw(img)
def font(sz: int):
try:
return ImageFont.truetype("DejaVuSans-Bold.ttf", sz)
except Exception: # noqa: BLE001
try:
return ImageFont.load_default(sz)
except TypeError:
return ImageFont.load_default()
d.text((70, 70), "Atendyo · Auditoría de tienda", font=font(34), fill="#8aa0c8")
brand = (row.brand or row.domain)[:34]
d.text((70, 180), brand, font=font(64), fill="#ffffff")
d.text((70, 300), f"responde {row.answered}/{row.total}", font=font(58), fill="#ffffff")
d.text((70, 372), "preguntas de compra", font=font(40), fill="#cbd5e1")
# grade chip + centered letter (anchor="mm" centers cleanly; fallback if
# the loaded font doesn't support anchors)
d.rounded_rectangle((900, 180, 1130, 410), radius=28, fill=color)
gf = font(150)
try:
d.text((1015, 295), grade, font=gf, fill="#ffffff", anchor="mm")
except (ValueError, TypeError):
d.text((975, 225), grade, font=gf, fill="#ffffff")
d.text((70, 520), "atendyo.com/audit · pruébalo gratis con tu tienda", font=font(30), fill="#8aa0c8")
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
except Exception: # noqa: BLE001 - never break the page over an image
# 1x1 transparent PNG fallback
import base64
return base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mbut"
"FAAAAAElFTkSuQmCC"
)