373 lines
13 KiB
Python
Executable file
373 lines
13 KiB
Python
Executable file
#!/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()
|