#!/usr/bin/env python3
"""Проверка позиций сайта в Google по списку запросов. Три источника на выбор.

  xmlriver  снимок выдачи через платный SERP-API XMLRiver: любой запрос, любой
            город, конкуренты. ~25 ₽ за 1000 запросов. Только стандартная библиотека.
  gsc       Google Search Console API: бесплатно, но только свой подтверждённый сайт,
            средняя позиция за период и только по запросам, где сайт уже показывался.
            Нужно: pip install google-auth requests
  browser   настоящий браузер открывает google.com. Бесплатно, но Google отвечает
            капчей на обычный автоматизированный Chromium; рабочий вариант —
            stealth-браузер CloakBrowser. Годится для десятков запросов в день.
            Нужно: pip install cloakbrowser && cloakbrowser install

Прямой разбор google.com через requests не работает с начала 2025 года: без
JavaScript Google выдачу не отдаёт. Custom Search JSON API закрыт для новых
клиентов и работает у старых до 1 января 2027 года, поэтому здесь не используется.

Настройки пользователя (ключи, пути) — в файле .env рядом со скриптом или в
текущей папке; образец — env.example. Флаги командной строки важнее .env.

Примеры:
    python google_positions.py -k keywords.txt -s example.ru --dry-run
    python google_positions.py -k keywords.txt -s example.ru --loc 1011969 --lang ru --gdomain 143 --pages 3
    python google_positions.py -k keywords.txt -s example.ru --source gsc \\
        --gsc-property sc-domain:example.ru --gsc-credentials key.json --gsc-country rus
    python google_positions.py -k keywords.txt -s example.ru --source browser \\
        --city "Moscow,Moscow,Russia" --pages 2

Автор: Станислав Кириченко, sk-seo.ru. Лицензия MIT.
"""

from __future__ import annotations

import argparse
import base64
import csv
import datetime as dt
import json
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path

RESULTS_PER_PAGE = 10  # с сентября 2025 Google отдаёт через API ровно 10 результатов на страницу

OUT_FIELDS = [
    "checked_at", "source", "keyword", "position", "delta", "url", "page",
    "other_site_urls", "top3_domains", "impressions", "clicks", "device", "loc", "status",
]


# --- настройки из .env --------------------------------------------------------

def load_env(path: Path | None) -> Path | None:
    """Читает KEY=VALUE из .env: переданный файл, иначе ./.env, иначе .env рядом со скриптом.
    Уже заданные переменные окружения не перезаписываются."""
    candidates = [path] if path else [Path.cwd() / ".env", Path(__file__).resolve().parent / ".env"]
    for candidate in candidates:
        if candidate and candidate.is_file():
            for line in candidate.read_text(encoding="utf-8-sig").splitlines():
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                name, value = line.split("=", 1)
                os.environ.setdefault(name.strip(), value.strip().strip("'\""))
            return candidate
    if path:
        raise SystemExit(f"Файл настроек не найден: {path}")
    return None


# --- общие функции -----------------------------------------------------------

def read_keywords(path: Path) -> list[str]:
    seen, result = set(), []
    for line in path.read_text(encoding="utf-8-sig").splitlines():
        kw = " ".join(line.split())
        if kw and not kw.startswith("#") and kw.casefold() not in seen:
            seen.add(kw.casefold())
            result.append(kw)
    return result


def normalize_host(value: str) -> str:
    value = value.strip().lower()
    if "://" in value:
        value = urllib.parse.urlsplit(value).hostname or ""
    value = value.split("/")[0].split(" ")[0].rstrip(".")
    return value[4:] if value.startswith("www.") else value


def belongs(url: str, site: str, with_subdomains: bool) -> bool:
    host = normalize_host(url)
    return host == site or (with_subdomains and host.endswith("." + site))


def empty_row(kw: str) -> dict:
    return {"keyword": kw, "position": "", "url": "", "page": "", "other_site_urls": "",
            "top3_domains": "", "impressions": "", "clicks": "", "status": "ok"}


def match_site(results: list[str], site: str, subdomains: bool, offset: int, row: dict) -> None:
    """results — URL или хосты органики на одной странице по порядку. Заполняет row."""
    for index, url in enumerate(results, start=1):
        if belongs(url, site, subdomains):
            if not row["position"]:
                row["position"], row["url"] = str(offset + index), url
            else:
                row["other_site_urls"] = (row["other_site_urls"] + " " + url).strip()


# --- источник 1: XMLRiver ----------------------------------------------------

XMLRIVER_ENDPOINT = "https://xmlriver.com/search/xml"
TRANSIENT_CODES = {"500", "501", "502", "503"}  # «Выполните перезапрос»
NO_RESULTS_CODE = "15"


def fetch_page(user: str, key: str, query: str, page: int, params: dict[str, str],
               timeout: float, attempts: int) -> tuple[str, str]:
    """Возвращает (xml, статус). Повторяет запрос при сетевых сбоях, 429/5xx
    и при ошибке 500–503 внутри XML: XMLRiver отдаёт её с HTTP 200."""
    query_string = urllib.parse.urlencode(
        {"user": user, "key": key, "query": query, "page": str(page), **params})
    request = urllib.request.Request(
        f"{XMLRIVER_ENDPOINT}?{query_string}", headers={"User-Agent": "google-positions/1.1"})
    status = "not_attempted"
    for attempt in range(attempts):
        try:
            with urllib.request.urlopen(request, timeout=timeout) as response:
                body = response.read().decode("utf-8", errors="replace")
            code, message = api_error(body)
            if code in TRANSIENT_CODES:
                status = f"api_error {code}: {message}"
            elif code == NO_RESULTS_CODE:
                return body, "no_results"
            elif code:
                return body, f"api_error {code}: {message}"  # неверный ключ, нет денег и т. п.
            else:
                return body, "ok"
        except urllib.error.HTTPError as exc:
            status = f"http_{exc.code}"
            if exc.code != 429 and exc.code < 500:
                return "", status
        except (urllib.error.URLError, TimeoutError) as exc:
            status = f"network_error: {type(exc).__name__}"
        if attempt + 1 < attempts:
            time.sleep(2 * 2 ** attempt)  # 2 с, 4 с, 8 с
    return "", status


def api_error(body: str) -> tuple[str, str]:
    try:
        root = ET.fromstring(body.encode("utf-8"))
    except ET.ParseError:
        return "parse", "ответ не XML"
    for node in root.iter():
        if node.tag.rsplit("}", 1)[-1].lower() == "error":
            return node.attrib.get("code", "?"), " ".join(node.itertext()).strip()
    return "", ""


def child_text(node: ET.Element, name: str) -> str:
    for child in node.iter():
        if child is not node and child.tag.rsplit("}", 1)[-1].lower() == name:
            return " ".join(child.itertext()).strip()
    return ""


def unwrap_google_url(url: str) -> str:
    """Снимает обёртку google.com/url?q=…, если в ней открытый адрес."""
    parts = urllib.parse.urlsplit(url)
    if (parts.hostname or "").startswith(("www.google.", "google.")):
        qs = urllib.parse.parse_qs(parts.query)
        for name in ("q", "url"):
            if qs.get(name) and qs[name][0].startswith("http"):
                return qs[name][0]
    return url


def organic_urls(body: str) -> list[str]:
    root = ET.fromstring(body.encode("utf-8"))
    urls = []
    for doc in root.iter():
        if doc.tag.rsplit("}", 1)[-1].lower() != "doc":
            continue
        content_type = child_text(doc, "contenttype") or "organic"
        url = child_text(doc, "url")
        if url and content_type.lower() == "organic":
            urls.append(unwrap_google_url(url))
    return urls


def run_xmlriver(keywords, site, args) -> tuple[list[dict], int]:
    user, key = os.environ.get("XMLRIVER_USER"), os.environ.get("XMLRIVER_KEY")
    if not user or not key:
        raise SystemExit("Нет XMLRIVER_USER / XMLRIVER_KEY: впишите их в .env (см. env.example).")
    params = {"device": args.device}
    for name, value in (("loc", args.loc), ("country", args.country),
                        ("lr", args.lang), ("domain", args.gdomain)):
        if value:
            params[name] = value
    rows, spent = [], 0
    for kw in keywords:
        row = empty_row(kw)
        for page in range(1, args.pages + 1):
            body, status = fetch_page(user, key, kw, page, params, args.timeout, args.attempts)
            spent += 1
            if status == "no_results":
                break
            if status != "ok":
                row["status"] = status
                break
            urls = organic_urls(body)
            if page == 1:
                row["top3_domains"] = " ".join(normalize_host(u) for u in urls[:3])
            had = bool(row["position"])
            match_site(urls, site, not args.no_subdomains, (page - 1) * RESULTS_PER_PAGE, row)
            if row["position"] and not had:
                row["page"] = str(page)
            if (row["position"] and not args.all_pages) or not urls:
                break
        rows.append(row)
        report(row, args)
    return rows, spent


# --- источник 2: Google Search Console ---------------------------------------

def gsc_session(credentials_path: Path):
    """Сервисный ключ (type=service_account) или OAuth-токен пользователя (refresh_token)."""
    try:
        from google.auth.transport.requests import AuthorizedSession
        from google.oauth2 import credentials as user_creds, service_account
    except ImportError:
        raise SystemExit("Для --source gsc: pip install google-auth requests")
    scopes = ["https://www.googleapis.com/auth/webmasters.readonly"]
    info = json.loads(credentials_path.read_text(encoding="utf-8"))
    if info.get("type") == "service_account":
        creds = service_account.Credentials.from_service_account_info(info, scopes=scopes)
    else:
        creds = user_creds.Credentials.from_authorized_user_info(info, scopes)
    return AuthorizedSession(creds)


def run_gsc(keywords, site, args) -> tuple[list[dict], int]:
    if not args.gsc_property or not args.gsc_credentials:
        raise SystemExit("Для --source gsc нужны GSC_PROPERTY и GSC_CREDENTIALS в .env "
                         "или флаги --gsc-property и --gsc-credentials.")
    session = gsc_session(args.gsc_credentials)
    end = dt.date.today() - dt.timedelta(days=3)  # у Search Console лаг данных 2–3 дня
    start = end - dt.timedelta(days=args.gsc_days - 1)
    filters = []
    if args.gsc_country:
        filters.append({"dimension": "country", "operator": "equals", "expression": args.gsc_country})
    if args.device:
        filters.append({"dimension": "device", "operator": "equals", "expression": args.device.upper()})
    url = ("https://searchconsole.googleapis.com/webmasters/v3/sites/"
           f"{urllib.parse.quote(args.gsc_property, safe='')}/searchAnalytics/query")
    wanted = {kw.casefold(): kw for kw in keywords}
    found: dict[str, dict] = {}
    start_row, calls = 0, 0
    while True:
        body = {"startDate": start.isoformat(), "endDate": end.isoformat(), "dimensions": ["query"],
                "type": "web", "rowLimit": 25000, "startRow": start_row}
        if filters:
            body["dimensionFilterGroups"] = [{"filters": filters}]
        response = session.post(url, json=body, timeout=args.timeout)
        calls += 1
        if response.status_code != 200:
            raise SystemExit(f"Search Console: HTTP {response.status_code} {response.text[:300]}")
        batch = response.json().get("rows", [])
        for r in batch:
            q = r["keys"][0].casefold()
            if q in wanted:
                found[q] = r
        if len(batch) < 25000:
            break
        start_row += 25000
    rows = []
    for kw in keywords:
        row = empty_row(kw)
        r = found.get(kw.casefold())
        if r:
            row.update(position=f"{r['position']:.1f}", impressions=str(r["impressions"]),
                       clicks=str(r["clicks"]))
        rows.append(row)
        report(row, args)
    print(f"Период Search Console: {start} — {end}.")
    return rows, calls


# --- источник 3: браузер -----------------------------------------------------

UULE_KEYS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"

# Органические результаты с заголовком в основной колонке, по порядку. Адрес в href
# Google заворачивает в /goto?url=<токен>, поэтому хост берём из строки адреса (cite).
EXTRACT_JS = """() => [...document.querySelectorAll('#rso a')].filter(a => a.querySelector('h3'))
  .map(a => { const box = a.closest('[data-hveid]') || a.parentElement;
              const cite = box ? box.querySelector('cite') : null;
              return {href: a.href, cite: cite ? cite.innerText : ''}; })"""


def uule(canonical_name: str) -> str:
    """Параметр uule из канонического имени геотаргета Google, например Moscow,Moscow,Russia."""
    raw = canonical_name.encode("utf-8")
    return "w+CAIQICI" + UULE_KEYS[len(raw) % len(UULE_KEYS)] + base64.b64encode(raw).decode()


def cite_to_url(cite: str) -> str:
    """'https://www.reg.ru › blog › …' -> 'https://www.reg.ru'."""
    return cite.split("›")[0].strip()


def is_captcha(page) -> bool:
    return "/sorry/" in page.url or page.locator("form#captcha-form, #recaptcha").count() > 0


def resolve_goto(context, href: str, fallback: str) -> str:
    """Полный адрес найденного результата: один запрос к /goto без перехода по редиректу."""
    if "/goto?" not in href and "/url?" not in href:
        return href
    try:
        response = context.request.get(href, max_redirects=0, timeout=15000)
        location = response.headers.get("location", "")
        return urllib.parse.unquote(unwrap_google_url(location)) if location.startswith("http") else fallback
    except Exception:
        return fallback


def run_browser(keywords, site, args) -> tuple[list[dict], int]:
    try:
        from cloakbrowser import launch_persistent_context
        stealth = True
    except ImportError:
        try:
            from playwright.sync_api import sync_playwright
        except ImportError:
            raise SystemExit("Для --source browser: pip install cloakbrowser && cloakbrowser install")
        stealth = False
        print("CloakBrowser не найден, работаю через обычный Playwright: капча вероятна с первого запроса.")
    viewport = {"width": 390, "height": 844} if args.device == "mobile" else {"width": 1366, "height": 900}
    options = dict(headless=not args.headed, locale=f"{args.lang or 'ru'}-RU", viewport=viewport)
    if stealth:
        context = launch_persistent_context(str(args.profile), humanize=True, **options)
        closer = context.close
    else:
        pw = sync_playwright().start()
        context = pw.chromium.launch_persistent_context(str(args.profile), **options)
        closer = lambda: (context.close(), pw.stop())
    page = context.pages[0] if context.pages else context.new_page()
    rows, calls, blocked = [], 0, False
    try:
        for kw in keywords:
            row = empty_row(kw)
            if blocked:
                row["status"] = "skipped_after_captcha"
                rows.append(row)
                continue
            for page_no in range(1, args.pages + 1):
                params = {"q": kw, "hl": args.lang or "ru", "pws": "0",
                          "start": str((page_no - 1) * RESULTS_PER_PAGE)}
                if args.gl:
                    params["gl"] = args.gl
                url = f"https://www.{args.google_host}/search?" + urllib.parse.urlencode(params)
                if args.city:
                    url += "&uule=" + uule(args.city)
                page.goto(url, wait_until="domcontentloaded", timeout=45000)
                calls += 1
                page.wait_for_timeout(2500)
                if is_captcha(page):
                    row["status"], blocked = "captcha", True
                    break
                items = page.evaluate(EXTRACT_JS)
                hosts = [cite_to_url(i["cite"]) for i in items if i["cite"]]
                if page_no == 1:
                    row["top3_domains"] = " ".join(normalize_host(h) for h in hosts[:3])
                had = bool(row["position"])
                match_site(hosts, site, not args.no_subdomains, (page_no - 1) * RESULTS_PER_PAGE, row)
                if row["position"] and not had:
                    row["page"] = str(page_no)
                    hit = next(i for i in items if i["cite"] and belongs(cite_to_url(i["cite"]), site,
                                                                         not args.no_subdomains))
                    row["url"] = resolve_goto(context, hit["href"], row["url"])
                if (row["position"] and not args.all_pages) or not items:
                    break
                time.sleep(random.uniform(args.delay, args.delay * 1.8))
            rows.append(row)
            report(row, args)
            if not blocked:
                time.sleep(random.uniform(args.delay, args.delay * 1.8))
    finally:
        closer()
    if blocked:
        print("Google показал капчу. Остальные запросы пропущены: повторите позже, "
              "увеличьте --delay или запустите с --headed и решите капчу вручную в том же профиле.")
    return rows, calls


# --- история и вывод ---------------------------------------------------------

def last_positions(history: Path, args) -> dict[str, str]:
    """Последняя известная позиция по каждому запросу для того же источника, устройства и региона."""
    if not history.exists():
        return {}
    last: dict[str, str] = {}
    with history.open(encoding="utf-8-sig", newline="") as f:
        for row in csv.DictReader(f, delimiter=";"):
            if row.get("status") == "ok" and row.get("source", "xmlriver") == args.source \
                    and row.get("device") == args.device and row.get("loc") == (args.loc or args.city or args.gsc_country):
                last[row["keyword"].casefold()] = row.get("position", "")
    return last


def delta(prev: str | None, now: str) -> str:
    if prev is None:
        return "new"
    if not prev and not now:
        return ""
    if not prev:
        return "entered"
    if not now:
        return "dropped"
    change = float(prev) - float(now)  # плюс — поднялись
    if abs(change) < 0.05:
        return "0"
    return f"{change:+.0f}" if change == int(change) else f"{change:+.1f}"


def report(row: dict, args) -> None:
    depth = "" if args.source == "gsc" else f">{args.pages * RESULTS_PER_PAGE}"
    shown = row["position"] or depth or "—"
    tail = "" if row["status"] == "ok" else f"  [{row['status']}]"
    print(f"{shown:>5}  {row['keyword']}{tail}", flush=True)


def main() -> int:
    ap = argparse.ArgumentParser(description="Позиции сайта в Google: XMLRiver, Search Console или браузер")
    ap.add_argument("-k", "--keywords", required=True, type=Path, help="файл: один запрос на строку")
    ap.add_argument("-s", "--site", required=True, help="домен сайта, например example.ru")
    ap.add_argument("--source", default="xmlriver", choices=["xmlriver", "gsc", "browser"])
    ap.add_argument("--device", default="", choices=["desktop", "tablet", "mobile"],
                    help="по умолчанию: desktop для xmlriver/browser, все устройства для gsc")
    ap.add_argument("--pages", type=int, default=1, help="страниц выдачи по 10 результатов (ТОП-30 — 3)")
    ap.add_argument("--no-subdomains", action="store_true", help="не засчитывать поддомены сайта")
    ap.add_argument("--all-pages", action="store_true", help="листать все страницы, даже если сайт найден")
    ap.add_argument("-o", "--out", type=Path, default=Path("positions_history.csv"), help="CSV-история")
    ap.add_argument("--timeout", type=float, default=60.0)
    ap.add_argument("--dry-run", action="store_true", help="только посчитать запросы (и стоимость для xmlriver)")
    ap.add_argument("--env", type=Path, help="файл настроек; по умолчанию .env в текущей папке или рядом со скриптом")
    g = ap.add_argument_group("xmlriver")
    g.add_argument("--loc", default="", help="ID места из xmlriver.com/files/geo.csv (Москва — 1011969)")
    g.add_argument("--country", default="", help="ID страны по справочнику XMLRiver")
    g.add_argument("--lang", default="", help="язык выдачи (ru, en…); для browser — hl")
    g.add_argument("--gdomain", default="", help="ID домена Google по справочнику XMLRiver (google.ru — 143)")
    g.add_argument("--price", type=float, default=25.0, help="цена за 1000 запросов, ₽ (базовый тариф 25)")
    g.add_argument("--attempts", type=int, default=3)
    g = ap.add_argument_group("gsc")
    g.add_argument("--gsc-property", default="", help="ресурс: sc-domain:example.ru или https://example.ru/")
    g.add_argument("--gsc-credentials", type=Path, help="JSON сервисного аккаунта или OAuth-токена пользователя")
    g.add_argument("--gsc-days", type=int, default=28, help="длина периода, дней")
    g.add_argument("--gsc-country", default="", help="страна ISO-3 строчными: rus, blr, kaz…")
    g = ap.add_argument_group("browser")
    g.add_argument("--city", default="", help="каноническое имя геотаргета Google: Moscow,Moscow,Russia")
    g.add_argument("--gl", default="", help="страна интерфейса Google (ru, by, kz…)")
    g.add_argument("--google-host", default="google.ru", help="домен Google: google.ru, google.com…")
    g.add_argument("--delay", type=float, default=8.0, help="пауза между страницами, с (случайно от delay до 1,8×delay)")
    g.add_argument("--profile", type=Path, default=Path("google_profile"), help="папка профиля браузера (cookies)")
    g.add_argument("--headed", action="store_true", help="показать окно браузера (можно решить капчу вручную)")
    args = ap.parse_args()
    env_file = load_env(args.env)
    if env_file:
        print(f"Настройки: {env_file}")
    args.gsc_property = args.gsc_property or os.environ.get("GSC_PROPERTY", "")
    if not args.gsc_credentials and os.environ.get("GSC_CREDENTIALS"):
        args.gsc_credentials = Path(os.environ["GSC_CREDENTIALS"]).expanduser()
        # относительный путь из .env считаем от папки, где лежит сам .env
        if env_file and not args.gsc_credentials.is_absolute() and not args.gsc_credentials.is_file():
            args.gsc_credentials = env_file.parent / args.gsc_credentials
    if args.gsc_credentials and not args.gsc_credentials.is_file():
        if args.source == "gsc":
            raise SystemExit(f"Нет файла ключа Search Console: {args.gsc_credentials}")

    if not args.device and args.source != "gsc":
        args.device = "desktop"
    keywords = read_keywords(args.keywords)
    site = normalize_host(args.site)
    if not keywords or not site:
        ap.error("пустой список запросов или домен")
    if args.source == "gsc":
        print(f"Запросов: {len(keywords)}. Search Console — бесплатно, 1–2 обращения к API.")
    else:
        top = len(keywords) * args.pages
        cost = f", ≈ {top * args.price / 1000:.2f} ₽ по {args.price:g} ₽ за 1000" if args.source == "xmlriver" else ""
        print(f"Запросов: {len(keywords)}, страниц на запрос до {args.pages}, обращений не больше {top}{cost}.")
    if args.dry_run:
        return 0

    previous = last_positions(args.out, args)
    runner = {"xmlriver": run_xmlriver, "gsc": run_gsc, "browser": run_browser}[args.source]
    rows, calls = runner(keywords, site, args)

    checked_at = dt.datetime.now().strftime("%Y-%m-%d %H:%M")
    loc = args.loc or args.city or args.gsc_country
    for row in rows:
        row.update(checked_at=checked_at, source=args.source, device=args.device, loc=loc,
                   delta=delta(previous.get(row["keyword"].casefold()), row["position"])
                   if row["status"] == "ok" else "")
    new_file = not args.out.exists()
    with args.out.open("a", encoding="utf-8-sig", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=OUT_FIELDS, delimiter=";")
        if new_file:
            writer.writeheader()
        writer.writerows(rows)

    in_top10 = sum(1 for r in rows if r["position"] and float(r["position"]) <= 10)
    failed = sum(1 for r in rows if r["status"] != "ok")
    cost = f" (≈ {calls * args.price / 1000:.2f} ₽)" if args.source == "xmlriver" else ""
    print(f"\nВ ТОП-10: {in_top10} из {len(rows)}. Ошибок и пропусков: {failed}. "
          f"Обращений: {calls}{cost}. Записано в {args.out}")
    return 1 if failed == len(rows) else 0


if __name__ == "__main__":
    sys.exit(main())
