diff --git a/tools/rank-check.py b/tools/rank-check.py new file mode 100755 index 00000000..a4d975de --- /dev/null +++ b/tools/rank-check.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +""" +rank-check.py — SEO rank checker for transquinnftw.com destination pages. + +For each target city: + 1. Brings up the city's WireGuard config via wg-quick + 2. Searches DuckDuckGo for "trans escort [city]" + 3. Records position of transquinnftw.com in results (None = not in top 30) + 4. Appends results to tools/rank-results.json + +Setup: + 1. Download ProtonVPN WireGuard configs from account.protonvpn.com + → Downloads → WireGuard configuration → select server → download .conf + 2. Drop each .conf in tools/vpn-configs/ named by city slug: + tools/vpn-configs/paris.conf + tools/vpn-configs/london.conf + ... etc. + 3. Run: sudo python3 tools/rank-check.py + (wg-quick requires root) + +Usage: + sudo python3 tools/rank-check.py # all cities + sudo python3 tools/rank-check.py --city paris # single city + sudo python3 tools/rank-check.py --no-vpn # skip VPN (no sudo needed) + python3 tools/rank-check.py --dry-run # print plan only +""" + +import argparse +import json +import os +import random +import subprocess +import sys +import time +from datetime import date, datetime +from pathlib import Path +from typing import Optional +from urllib.parse import unquote, urlparse, parse_qs + +import requests +from bs4 import BeautifulSoup + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +TARGET_DOMAIN = "transquinnftw.com" +TOOLS_DIR = Path(__file__).parent +RESULTS_FILE = TOOLS_DIR / "rank-results.json" +VPN_CONFIGS_DIR = TOOLS_DIR / "vpn-configs" + +DDG_URL = "https://html.duckduckgo.com/html/" + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-US,en;q=0.9", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://duckduckgo.com/", +} + +# slug → (display name, search query) +DESTINATIONS: dict[str, tuple[str, str]] = { + "paris": ("Paris", "trans escort Paris"), + "london": ("London", "trans escort London"), + "venice": ("Venice", "trans escort Venice"), + "reykjavik": ("Reykjavík", "trans escort Reykjavik"), + "new-york-city": ("New York City", "trans escort New York"), + "las-vegas": ("Las Vegas", "trans escort Las Vegas"), + "los-angeles": ("Los Angeles", "trans escort Los Angeles"), + "chicago": ("Chicago", "trans escort Chicago"), + "tokyo": ("Tokyo", "trans escort Tokyo"), + "kyoto": ("Kyoto", "trans escort Kyoto"), + "seoul": ("Seoul", "trans escort Seoul"), + "ibiza": ("Ibiza", "trans escort Ibiza"), + "honolulu": ("Honolulu", "trans escort Honolulu"), + "seattle": ("Seattle", "trans escort Seattle"), + "vancouver": ("Vancouver", "trans escort Vancouver"), + "anchorage": ("Anchorage", "trans escort Anchorage"), + "glasgow": ("Glasgow", "trans escort Glasgow"), + "dublin": ("Dublin", "trans escort Dublin"), +} + +# --------------------------------------------------------------------------- +# WireGuard helpers +# --------------------------------------------------------------------------- + +def conf_path(slug: str) -> Path: + return VPN_CONFIGS_DIR / f"{slug}.conf" + + +def vpn_up(slug: str) -> tuple[bool, str]: + """Bring up WireGuard interface for slug. Returns (ok, exit_ip).""" + conf = conf_path(slug) + if not conf.exists(): + return False, "" + + # Derive interface name from conf filename (wg-quick requirement: ≤15 chars, alphanum+_) + iface = slug.replace("-", "")[:15] + target_conf = Path(f"/tmp/wg-{iface}.conf") + + # Write a copy with a safe interface name + target_conf.write_text(conf.read_text()) + + try: + result = subprocess.run( + ["wg-quick", "up", str(target_conf)], + capture_output=True, text=True, timeout=20, + ) + if result.returncode != 0: + print(f" ✗ wg-quick up failed: {result.stderr.strip()}") + target_conf.unlink(missing_ok=True) + return False, "" + except subprocess.TimeoutExpired: + print(" ✗ wg-quick timed out") + target_conf.unlink(missing_ok=True) + return False, "" + + time.sleep(2) # let routing settle + exit_ip = current_ip() + return True, exit_ip + + +def vpn_down(slug: str) -> None: + iface = slug.replace("-", "")[:15] + target_conf = Path(f"/tmp/wg-{iface}.conf") + if target_conf.exists(): + subprocess.run( + ["wg-quick", "down", str(target_conf)], + capture_output=True, timeout=15, + ) + target_conf.unlink(missing_ok=True) + + +def current_ip() -> str: + try: + return requests.get("https://api.ipify.org", timeout=8, headers={"User-Agent": "curl/8.0"}).text.strip() + except Exception: + return "unknown" + + +def missing_configs(slugs: list[str]) -> list[str]: + return [s for s in slugs if not conf_path(s).exists()] + + +# --------------------------------------------------------------------------- +# Rank checking +# --------------------------------------------------------------------------- + +def search_ddg(query: str) -> list[str]: + """Search DuckDuckGo, return ordered result URLs (up to ~30 across 3 pages).""" + session = requests.Session() + session.headers.update(HEADERS) + urls: list[str] = [] + resp = None + + try: + resp = session.post(DDG_URL, data={"q": query, "b": ""}, timeout=20) + resp.raise_for_status() + urls.extend(_parse_ddg(resp.text)) + except Exception as e: + print(f" ✗ DDG request failed: {e}") + return urls + + for _ in range(2): + if any(TARGET_DOMAIN in u for u in urls): + break + time.sleep(random.uniform(4, 8)) + try: + soup = BeautifulSoup(resp.text, "html.parser") + next_form = soup.find("form", {"class": "nav-link"}) + if not next_form: + break + params = { + inp["name"]: inp.get("value", "") + for inp in next_form.find_all("input") + if inp.get("name") + } + resp = session.post(DDG_URL, data=params, timeout=20) + resp.raise_for_status() + urls.extend(_parse_ddg(resp.text)) + except Exception: + break + + return urls + + +def _parse_ddg(html: str) -> list[str]: + soup = BeautifulSoup(html, "html.parser") + results: list[str] = [] + + for a in soup.select("a.result__url"): + href = a.get("href", "") + if href and "duckduckgo.com" not in href: + results.append(href) + + for a in soup.select("a.result__a"): + href = a.get("href", "") + if "uddg=" in href: + qs = parse_qs(urlparse(href).query) + uddg = qs.get("uddg", []) + if uddg: + results.append(unquote(uddg[0])) + + # Deduplicate preserving order + seen: set[str] = set() + deduped: list[str] = [] + for u in results: + if u not in seen: + seen.add(u) + deduped.append(u) + return deduped + + +def find_rank(urls: list[str]) -> Optional[int]: + for i, url in enumerate(urls, 1): + if TARGET_DOMAIN in url: + return i + return None + + +def check_indexed(slug: str) -> Optional[bool]: + query = f"site:{TARGET_DOMAIN}/destinations/{slug}" + time.sleep(random.uniform(3, 6)) + urls = search_ddg(query) + if not urls: + return None + return any(TARGET_DOMAIN in u for u in urls) + + +# --------------------------------------------------------------------------- +# Results persistence +# --------------------------------------------------------------------------- + +def load_results() -> list[dict]: + if RESULTS_FILE.exists(): + return json.loads(RESULTS_FILE.read_text()) + return [] + + +def save_results(results: list[dict]) -> None: + RESULTS_FILE.write_text(json.dumps(results, indent=2, ensure_ascii=False)) + + +def print_summary(run: dict) -> None: + print(f"\n{'─' * 64}") + print(f" {run['date']} | VPN: {'on' if run['vpn'] else 'off'} | {run['duration_s']}s total") + print(f"{'─' * 64}") + for r in run["cities"]: + indexed_sym = "✓" if r["indexed"] else ("✗" if r["indexed"] is False else "?") + rank_str = f"#{r['rank']}" if r["rank"] else "not in top 30" + ip_str = f" [{r['exit_ip']}]" if r.get("exit_ip") else "" + print(f" {indexed_sym} {r['city']:<20} {rank_str:<16}{ip_str}") + print() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Rank checker — transquinnftw.com") + parser.add_argument("--no-vpn", action="store_true", help="Skip VPN rotation") + parser.add_argument("--city", metavar="SLUG", help="Check a single city by slug") + parser.add_argument("--dry-run", action="store_true", help="Print plan, no requests") + args = parser.parse_args() + + if args.city and args.city not in DESTINATIONS: + print(f"Unknown slug '{args.city}'. Valid slugs:\n {chr(10).join(DESTINATIONS)}") + sys.exit(1) + + targets: dict[str, tuple[str, str]] = ( + {args.city: DESTINATIONS[args.city]} if args.city else DESTINATIONS + ) + + if args.dry_run: + print("Dry run\n") + missing = missing_configs(list(targets)) if not args.no_vpn else [] + for slug, (city, query) in targets.items(): + conf_status = "" + if not args.no_vpn: + conf_status = " ✓ conf" if conf_path(slug).exists() else " ✗ missing conf" + print(f" {slug:<20} \"{query}\"{conf_status}") + if missing: + print(f"\n Missing configs for: {', '.join(missing)}") + print(f" Download from: account.protonvpn.com → Downloads → WireGuard") + print(f" Place in: {VPN_CONFIGS_DIR}/") + return + + if not args.no_vpn and os.geteuid() != 0: + print("wg-quick requires root. Run with sudo, or use --no-vpn.") + sys.exit(1) + + missing = missing_configs(list(targets)) if not args.no_vpn else [] + if missing and not args.no_vpn: + print(f"Missing WireGuard configs for: {', '.join(missing)}") + print(f"Download from account.protonvpn.com → Downloads → WireGuard") + print(f"Place .conf files in: {VPN_CONFIGS_DIR}/") + print(f"\nCities with configs will still run. Continue? [y/N] ", end="") + if input().strip().lower() != "y": + sys.exit(0) + + start = time.time() + run: dict = { + "date": date.today().isoformat(), + "timestamp": datetime.utcnow().isoformat() + "Z", + "vpn": not args.no_vpn, + "cities": [], + "duration_s": 0, + } + + slugs = list(targets) + for i, slug in enumerate(slugs): + city, query = targets[slug] + print(f"\n[{city}]") + + exit_ip: Optional[str] = None + vpn_active = False + + if not args.no_vpn: + if conf_path(slug).exists(): + print(f" → VPN up ({slug}.conf)...", end=" ", flush=True) + ok, exit_ip = vpn_up(slug) + if ok: + vpn_active = True + print(f"connected {exit_ip}") + else: + print("failed — running without VPN for this city") + else: + print(f" ! No conf for {slug} — skipping VPN") + + print(f" → \"{query}\"") + urls = search_ddg(query) + rank = find_rank(urls) + rank_str = f"#{rank}" if rank else "not in top 30" + print(f" → rank: {rank_str} ({len(urls)} results parsed)") + + print(f" → index check...", end=" ", flush=True) + indexed = check_indexed(slug) + print("indexed ✓" if indexed else "not indexed ✗" if indexed is False else "unknown") + + run["cities"].append({ + "slug": slug, + "city": city, + "query": query, + "rank": rank, + "indexed": indexed, + "exit_ip": exit_ip, + "result_count": len(urls), + }) + + if vpn_active: + vpn_down(slug) + + if i < len(slugs) - 1: + delay = random.uniform(8, 15) + print(f" → pause {delay:.0f}s") + time.sleep(delay) + + run["duration_s"] = round(time.time() - start) + + existing = load_results() + existing.append(run) + save_results(existing) + + print_summary(run) + print(f"Saved → {RESULTS_FILE}") + + +if __name__ == "__main__": + main() diff --git a/tools/vpn-configs/.gitkeep b/tools/vpn-configs/.gitkeep new file mode 100644 index 00000000..e69de29b