#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
САМОАУДИТ САЙТА — 20 проверок за 30 секунд.

Запуск:   python3 audit.py mysite.ru
Нужен:    только Python 3.8+ (ничего ставить не надо)

Что делает: смотрит на твой сайт теми же глазами, что поисковик, и сравнивает
с медианой по 83 сайтам малого бизнеса, которые присылали мне на разбор
в июле-августе 2026. Ничего никуда не отправляет, работает локально.

Васин Константин · t.me/vasin_launch · vasinki.ru
"""

import sys, re, ssl, gzip, zlib, socket, time
import urllib.request, urllib.error

# ── БЕНЧМАРК: медианы и доли по выборке из 83 сайтов малого бизнеса ────────────
BENCH = {
    "sample": 83,
    "pages_median": 84,        # страниц в sitemap
    "ttfb_median": 436,        # мс до первого байта
    "text_median": 7869,       # знаков текста на главной
    "share": {                 # у скольких % выборки эта же проблема
        "no_https_redirect": 11, "no_robots": 4, "no_sitemap": 12,
        "robots_closed": 7, "no_desc": 11, "no_h1": 19, "bad_title": 39,
        "no_schema": 30, "no_canonical": 18, "no_og": 28, "no_analytics": 19,
        "no_blog": 33, "slow": 10, "no_viewport": 4, "soft404": 4,
        "few_pages": 24,
    },
}

UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
socket.setdefaulttimeout(20)

OK, WARN, BAD, INFO = "✅", "⚠️ ", "❌", "  "


def get(url, timeout=20, limit=2_000_000):
    """Скачать страницу. Возвращает (тело_bytes, статус, финальный_url, мс, заголовки)."""
    req = urllib.request.Request(url, headers={
        "User-Agent": UA,
        "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
        "Accept-Language": "ru-RU,ru;q=0.9",
        "Accept-Encoding": "gzip, deflate",
    })
    t0 = time.time()
    try:
        with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
            raw = r.read(limit)
            enc = (r.headers.get("Content-Encoding") or "").lower()
            if "gzip" in enc:
                try: raw = gzip.decompress(raw)
                except Exception: pass
            elif "deflate" in enc:
                try: raw = zlib.decompress(raw, -zlib.MAX_WBITS)
                except Exception: pass
            return raw, r.status, r.geturl(), int((time.time() - t0) * 1000), dict(r.headers)
    except urllib.error.HTTPError as e:
        return b"", e.code, url, int((time.time() - t0) * 1000), {}
    except Exception:
        return b"", None, url, int((time.time() - t0) * 1000), {}


def to_text(raw, headers):
    ct = headers.get("Content-Type", "") if headers else ""
    m = re.search(r"charset=([\w\-]+)", ct, re.I)
    for c in ([m.group(1)] if m else []) + ["utf-8", "windows-1251"]:
        try: return raw.decode(c)
        except Exception: continue
    return raw.decode("utf-8", errors="replace")


def strip_tags(html):
    s = re.sub(r"<(script|style|noscript)[^>]*>.*?</\1>", " ", html, flags=re.S | re.I)
    s = re.sub(r"<[^>]+>", " ", s)
    return re.sub(r"\s+", " ", s).strip()


def clean_host(arg):
    h = re.sub(r"^https?://", "", (arg or "").strip(), flags=re.I).split("/")[0].lower()
    return h[4:] if h.startswith("www.") else h


def count_sitemap(host, robots_txt):
    """Разворачиваем sitemap-индексы и считаем реальное число страниц."""
    starts = [s.decode(errors="ignore") for s in re.findall(rb"(?im)^\s*sitemap:\s*(\S+)", robots_txt)]
    starts = starts or [f"https://{host}/sitemap.xml"]
    seen, pages, files, lastmods = set(), 0, 0, []
    queue, depth = list(dict.fromkeys(starts))[:5], 0
    while queue and depth < 3:
        nxt = []
        for u in queue[:40]:
            if u in seen:
                continue
            seen.add(u)
            body, st, _, _, _ = get(u, timeout=15, limit=8_000_000)
            if not body or st != 200:
                continue
            if body[:2] == b"\x1f\x8b":
                try: body = gzip.decompress(body)
                except Exception: pass
            if b"<urlset" not in body[:3000] and b"<sitemapindex" not in body[:3000]:
                continue
            files += 1
            lastmods += [x.decode(errors="ignore")[:10] for x in re.findall(rb"<lastmod>([^<]+)</lastmod>", body)]
            if b"<sitemapindex" in body[:3000]:
                nxt += [x.decode(errors="ignore") for x in re.findall(rb"<loc>\s*([^<\s]+)", body)]
            else:
                pages += len(re.findall(rb"<loc>", body))
        queue = [u for u in dict.fromkeys(nxt) if u not in seen]
        depth += 1
    lastmods = [d for d in lastmods if re.match(r"^\d{4}-\d{2}-\d{2}$", d)]
    return pages, files, (max(lastmods) if lastmods else "")


def line(mark, title, detail="", share=None):
    tail = ""
    if share is not None and mark != OK:
        tail = f"   · то же у {share}% выборки"
    print(f"  {mark} {title:<44}{detail}{tail}")


def head(t):
    print(f"\n\033[1m{t}\033[0m" if sys.stdout.isatty() else f"\n{t}")


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        sys.exit(1)

    host = clean_host(sys.argv[1])
    if "." not in host:
        print(f"Не похоже на адрес сайта: {sys.argv[1]}")
        sys.exit(1)

    print("=" * 72)
    print(f"  САМОАУДИТ · {host}")
    print(f"  норма = медиана по {BENCH['sample']} сайтам малого бизнеса (июль 2026)")
    print("=" * 72)

    raw, status, final, ms, headers = get(f"https://{host}/")
    https_ok = bool(raw)
    if not https_ok:
        raw, status, final, ms, headers = get(f"http://{host}/")
        if not raw:
            print(f"\n{BAD} Сайт не открылся ни по https, ни по http.")
            print("   Проверь адрес. Если сайт живой — возможно, он блокирует ботов;")
            print("   тогда все проверки ниже надо делать руками.")
            sys.exit(2)

    html = to_text(raw, headers)
    low = html.lower()
    text = strip_tags(html)
    fails, checks = [], 0

    def check(cond_ok, key, title, detail_ok="", detail_bad="", critical=False):
        """cond_ok=True → зачёт. Возвращает результат, копит проблемы."""
        nonlocal checks
        checks += 1
        if cond_ok:
            line(OK, title, detail_ok)
        else:
            line(BAD if critical else WARN, title, detail_bad, BENCH["share"].get(key))
            fails.append((key, title, critical))
        return cond_ok

    # ── 1. Доступность ───────────────────────────────────────────────────────
    head("ДОСТУПНОСТЬ")
    check(https_ok, "no_https", "Работает по https", "", "сайт только по http — браузер ругается", critical=True)
    if https_ok:
        _, _, hurl, _, _ = get(f"http://{host}/", timeout=12)
        check(hurl.startswith("https://"), "no_https_redirect", "http перебрасывает на https",
              "", "две копии сайта: http и https")
    check(ms <= 1500, "slow", "Скорость ответа сервера", f"{ms} мс (медиана {BENCH['ttfb_median']} мс)",
          f"{ms} мс — долго, норма до 1500")
    check(bool(re.search(r'name=["\']viewport["\']', html, re.I)), "no_viewport",
          "Адаптирован под телефон", "", "нет meta viewport — на мобильном всё поедет", critical=True)

    # ── 2. Индексация ────────────────────────────────────────────────────────
    head("ИНДЕКСАЦИЯ · пустит ли поисковик")
    rb_raw, rb_st, _, _, _ = get(f"https://{host}/robots.txt", timeout=12)
    rb_txt = to_text(rb_raw, {}) if rb_raw else ""
    has_robots = bool(rb_raw and rb_st == 200 and "user-agent" in rb_txt.lower())
    check(has_robots, "no_robots", "Есть robots.txt", "", "нет файла robots.txt")

    closed = bool(re.search(r"(?im)^\s*disallow:\s*/\s*$", rb_txt))
    check(not closed, "robots_closed", "robots.txt не закрывает сайт", "",
          "!! robots.txt ЗАПРЕЩАЕТ индексировать весь сайт", critical=True)

    noindex = bool(re.search(r'name=["\']robots["\'][^>]*content=["\'][^"\']*noindex', html, re.I))
    check(not noindex, "robots_closed", "Главная не закрыта в noindex", "",
          "!! на главной стоит noindex — она не попадёт в поиск", critical=True)

    pages, sm_files, newest = count_sitemap(host, rb_raw or b"")
    check(pages > 0, "no_sitemap", "Есть карта сайта (sitemap.xml)",
          f"{pages} страниц в {sm_files} файл(ах)", "поисковик обходит сайт вслепую", critical=True)
    check(bool(re.search(r"(?im)^\s*sitemap:", rb_txt)), "no_sitemap",
          "Карта указана в robots.txt", "", "не указана — робот может её не найти")

    _, s404, _, _, _ = get(f"https://{host}/nesuschestvuyuschaya-stranica-proverka-404/", timeout=12)
    check(s404 != 200, "soft404", "Несуществующие страницы дают 404",
          "", "любой мусорный адрес отдаёт 200 — плодятся дубли")

    # ── 3. Мета ──────────────────────────────────────────────────────────────
    head("КАК ВЫГЛЯДИТ В ВЫДАЧЕ")
    m = re.search(r"<title[^>]*>(.*?)</title>", html, re.S | re.I)
    title = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip() if m else ""
    check(30 <= len(title) <= 70, "bad_title", "Заголовок title по длине",
          f"{len(title)} симв — ок",
          (f"{len(title)} симв — " + ("пусто" if not title else "коротко, мало ключевых слов"
           if len(title) < 30 else "длинно, в выдаче обрежется")))
    if title:
        print(f"     └ {title[:90]}")

    m = re.search(r'<meta[^>]+name=["\']description["\'][^>]*>', html, re.I)
    desc = ""
    if m:
        d = re.search(r'content=["\'](.*?)["\']', m.group(0), re.S | re.I)
        desc = re.sub(r"\s+", " ", d.group(1)).strip() if d else ""
    check(len(desc) >= 50, "no_desc", "Описание description",
          f"{len(desc)} симв", "пусто или слишком коротко — сниппет соберётся сам")

    h1s = re.findall(r"<h1[^>]*>(.*?)</h1>", html, re.S | re.I)
    check(len(h1s) == 1, "no_h1", "Ровно один заголовок H1",
          "", f"{len(h1s)} шт — " + ("нет главного заголовка" if not h1s else "несколько H1, тема размыта"))

    check(bool(re.search(r'rel=["\']canonical["\']', html, re.I)), "no_canonical",
          "Указан canonical", "", "нет — риск склейки дублей не в ту сторону")
    check(bool(re.search(r'property=["\']og:image["\']', html, re.I)), "no_og",
          "Картинка при репосте (og:image)", "", "ссылка в мессенджере будет без картинки")
    check(bool(re.search(r"application/ld\+json|itemscope", html, re.I)), "no_schema",
          "Микроразметка schema.org", "", "нет — поиск не понимает, что ты за организация")

    # ── 4. Содержание ────────────────────────────────────────────────────────
    head("СОДЕРЖАНИЕ · есть ли чему ранжироваться")
    check(len(text) >= 1500, "thin", "Текста на главной",
          f"{len(text)} знаков (медиана {BENCH['text_median']})",
          f"{len(text)} знаков — пусто, ранжировать нечего")

    links = re.findall(r'href=["\']([^"\']+)["\']', html, re.I)
    has_blog = any(re.search(r"/(blog|stati|articles?|news|novosti|poleznoe)", l, re.I) for l in links)
    check(has_blog, "no_blog", "Есть блог / раздел статей", "",
          "нет — весь трафик держится на паре коммерческих страниц")

    imgs = re.findall(r"<img\b[^>]*>", html, re.I)
    no_alt = sum(1 for i in imgs if not re.search(r"\balt\s*=", i, re.I))
    if imgs:
        check(no_alt / len(imgs) < 0.3, "alt", "Картинки подписаны (alt)",
              f"{len(imgs) - no_alt} из {len(imgs)}", f"{no_alt} из {len(imgs)} без alt")

    check(bool(re.search(r"mc\.yandex\.ru|googletagmanager|gtag\(|google-analytics", low)), "no_analytics",
          "Стоит счётчик аналитики", "", "нет ни Метрики, ни GA — решения принимаются вслепую", critical=True)

    # ── 5. Объём ─────────────────────────────────────────────────────────────
    head("ОБЪЁМ · главный рычаг в поиске")
    if pages:
        if pages >= BENCH["pages_median"]:
            line(OK, "Страниц на сайте", f"{pages} (медиана по выборке {BENCH['pages_median']})")
        else:
            checks += 1
            line(WARN, "Страниц на сайте",
                 f"{pages} — меньше медианы ({BENCH['pages_median']})", BENCH["share"]["few_pages"])
            fails.append(("few_pages", "Мало страниц", False))
        if newest:
            print(f"     └ последнее обновление в карте сайта: {newest}")
    else:
        print(f"  {INFO} Число страниц не посчитать — нет карты сайта")

    # ── ИТОГ ─────────────────────────────────────────────────────────────────
    crit = [f for f in fails if f[2]]
    score = checks - len(fails)
    head("=" * 66)
    print(f"  ИТОГ: {score} из {checks} пунктов в норме")
    if crit:
        print(f"\n  {BAD} СНАЧАЛА ПОЧИНИ ЭТО — без него остальное не имеет смысла:")
        for _, t, _ in crit:
            print(f"     · {t}")
    other = [f for f in fails if not f[2]]
    if other:
        print(f"\n  {WARN}Потом:")
        for _, t, _ in other:
            print(f"     · {t}")
    if not fails:
        print("\n  Технически всё чисто. Значит упор — на объём и содержание:")
        print("  сколько запросов ты реально закрываешь страницами.")

    print(f"\n  Что с этим делать: vasinki.ru/guides/samoaudit/")
    print(f"  Разбор ниши бесплатно: t.me/vasin_launch_bot?start=nisha")
    print("=" * 72)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\nПрервано.")
