diff --git a/.gitignore b/.gitignore index d7f14ff..17e7d89 100644 --- a/.gitignore +++ b/.gitignore @@ -174,6 +174,9 @@ cython_debug/ # PyPI configuration file .pypirc +# Project output +free_telegram.txt + # ---> VirtualEnv # Virtualenv # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ diff --git a/api.py b/api.py new file mode 100644 index 0000000..186654f --- /dev/null +++ b/api.py @@ -0,0 +1,94 @@ +import json + +from config import ( + API_FILTER_COUNT_URL, + API_LIST_URL, + CITY, + CITY_ID, + LIST_URL, + PAGE_SIZE, + HEADERS, +) + + +async def fetch_main(session): + async with session.get(LIST_URL, headers=HEADERS) as response: + if response.status != 200: + raise ValueError(f"Ошибка при получении страницы: {response.status}") + return await response.text() + + +async def fetch_filter_count(session, nonce): + form_data = { + "_wpnonce": nonce, + "filterParams": json.dumps( + { + "metro": [], + "is_indi": False, + "is_salon": False, + "is_elite_indi": False, + "is_elite_salon": False, + "base_price_from": 0, + "base_price_to": 0, + "online": False, + "is_new": False, + "is_visited": False, + "massage_pro": False, + "master_tantra": False, + "popular_blogger": False, + "meet_place": [], + "age_from": "", + "age_to": "", + "body_shape": [], + "height": [], + "breast": [], + "groups_photo": [], + "prefers": [], + "city": CITY, + }, + separators=(",", ":"), + ), + } + + async with session.post( + API_FILTER_COUNT_URL, headers=HEADERS, data=form_data + ) as response: + if response.status != 200: + raise ValueError( + f"Ошибка при получении количества фильтров: {response.status}" + ) + text = json.loads(await response.text()) + return text.get("result", 0) + + +async def fetch_single_page(session, page_id, nonce, args): + form_data = { + "_wpnonce": nonce, + "page": str(page_id), + "number": str(PAGE_SIZE), + "filters": json.dumps( + { + "is_indi_work": True, + "not_statuses": ["nophoto"], + "is_not_hide_salon": True, + "is_not_onhold": True, + "is_active": True, + "city": CITY_ID, + "post_statuses": ["publish"], + }, + separators=(",", ":"), + ), + "queue": "[]", + "isShuffle": "true", + "sort": json.dumps({"order": "DESC", "by": "height"}, separators=(",", ":")), + "args": json.dumps(args, separators=(",", ":")), + } + + async with session.post(API_LIST_URL, headers=HEADERS, data=form_data) as response: + if response.status != 200: + raise ValueError( + f"Ошибка при получении данных страницы {page_id}: {response.status}" + ) + text = json.loads(await response.text()) + print(f"Страница {page_id}: получено {text.get('length', 0)} анкет") + return text.get("result", "") diff --git a/config.py b/config.py new file mode 100644 index 0000000..e0a05c9 --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +BASE_URL = "https://m.don-m.com" +CITY = "spb" +CITY_ID = 50878 +PAGE_SIZE = 48 + +LIST_URL = f"{BASE_URL}/{CITY}/mass/" +API_LIST_URL = f"{BASE_URL}/api/index.php?route=/wp-json/don/v2/girl/list" +API_FILTER_COUNT_URL = ( + f"{BASE_URL}/api/index.php?route=/wp-json/don/v2/girl/filter-count" +) + +# хедеры для запросов к don-m.com +HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", +} + +OUTPUT_FILE = "free_telegram.txt" + +# сколько профилей / тг-проверок крутится одновременно +PROFILE_CONCURRENCY = 30 +TG_CONCURRENCY = 10 + +# пауза между запросами к t.me: TG_MIN_INTERVAL + случайный довесок до TG_JITTER +TG_MIN_INTERVAL = 0.25 +TG_JITTER = 0.25 +TG_RETRIES = 3 +TG_REQUEST_TIMEOUT = 15 +TG_MAX_HEAD_BYTES = 16384 # хватает с запасом, чтобы дочитать до diff --git a/girl_parser.py b/girl_parser.py new file mode 100644 index 0000000..df950ee --- /dev/null +++ b/girl_parser.py @@ -0,0 +1,49 @@ +import re +from urllib.parse import urljoin + +from bs4 import BeautifulSoup + +from config import BASE_URL, CITY + + +def parse_girl_links(html_content): + soup = BeautifulSoup(html_content, "lxml") + links = [] + + for card in soup.select(".list-girls-item-mob[data-girlid]"): + girl_id = card.get("data-girlid") + + a = card.select_one(".list-girls-img-mob a[href]") + if not a: + a = card.select_one(".list-girls-right-info > a[href]") + if not a: + continue + + href = a.get("href") + if not re.match(r"^/{}/\d+/?$".format(CITY), href): + continue + + links.append({"id": girl_id, "url": urljoin(BASE_URL, href)}) + + return links + + +def parse_telegram_href(soup): + link = soup.select_one(".girl-indi-mess.dm-mess-100 a#TELEGRAM_CLICK_MOB[href]") + return link["href"] if link else None + + +async def parse_single_girl(session, link): + async with session.get(link["url"]) as response: + html_content = await response.text() + + soup = BeautifulSoup(html_content, "lxml") + print(f"Профиль: {link['url']}") + + telegram_href = parse_telegram_href(soup) + if not telegram_href: + print(" тг не указан") + return None + + print(f" тг: {telegram_href}") + return {"profile_url": link["url"], "telegram": telegram_href} diff --git a/main.py b/main.py new file mode 100644 index 0000000..2345343 --- /dev/null +++ b/main.py @@ -0,0 +1,97 @@ +import asyncio + +import aiohttp + +from api import fetch_filter_count, fetch_main, fetch_single_page +from config import PAGE_SIZE, PROFILE_CONCURRENCY, TG_CONCURRENCY +from girl_parser import parse_girl_links, parse_single_girl +from page_data import fetch_args, fetch_nonce +from storage import ResultWriter +from telegram_checker import check_telegram + + +async def process_girl(session, link, writer, profile_sem, tg_sem): + try: + async with profile_sem: + girl = await parse_single_girl(session, link) + if not girl: + return None + + tg_link = girl.get("telegram") + if not tg_link: + return None + + async with tg_sem: + free_username = await check_telegram(session, tg_link) + if not free_username: + return None + + await writer.add(girl["profile_url"], free_username) + return free_username + + except Exception as e: + print(f"Профиль {link.get('url')} - ошибка: {type(e).__name__}: {e}") + return None + + +async def collect_links(session): + html_content = await fetch_main(session) + if not html_content: + raise ValueError("Пустой ответ от главной страницы каталога.") + + nonce = fetch_nonce(html_content) + args = fetch_args(html_content) + print("Nonce:", nonce) + + total = await fetch_filter_count(session, nonce) + print("Всего анкет по фильтру:", total) + + pages_count = -(-total // PAGE_SIZE) # деление с округлением вверх без math.ceil + page_ids = range(1, pages_count + 1) + print(f"Страниц к обходу: {pages_count}") + + pages = await asyncio.gather( + *(fetch_single_page(session, page_id, nonce, args) for page_id in page_ids) + ) + + links = [] + seen = set() + for page_html in pages: + for item in parse_girl_links(page_html): + if item["url"] not in seen: + seen.add(item["url"]) + links.append(item) + + print(f"Уникальных анкет: {len(links)}") + return links + + +async def main(): + async with aiohttp.ClientSession() as session: + links = await collect_links(session) + + writer = ResultWriter() + writer.reset() + + profile_sem = asyncio.Semaphore(PROFILE_CONCURRENCY) + tg_sem = asyncio.Semaphore(TG_CONCURRENCY) + + tasks = [ + asyncio.create_task( + process_girl(session, link, writer, profile_sem, tg_sem) + ) + for link in links + ] + + found = [] + for task in asyncio.as_completed(tasks): + username = await task + if username: + found.append(username) + print(f"Свободный юзернейм: @{username}") + + print(f"Итого свободных: {len(found)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/page_data.py b/page_data.py new file mode 100644 index 0000000..672cc95 --- /dev/null +++ b/page_data.py @@ -0,0 +1,21 @@ +import json +import re + + +def fetch_nonce(html_content): + match = re.search(r'"nonce"\s*:\s*"([^"]+)"', html_content) + if not match: + raise ValueError("Nonce не найден на странице.") + return match.group(1) + + +def fetch_args(html_content): + match = re.search( + r"ObjectManagerList\.add\((\{.*?\})\)\.initLazyLoad", html_content, re.S + ) + if not match: + raise ValueError("Данные страниц не найдены на странице.") + + pages = json.loads(match.group(1)) + args = pages.get("args", []) + return json.loads(args) if isinstance(args, str) else args diff --git a/rate_limiter.py b/rate_limiter.py new file mode 100644 index 0000000..7b4f0a8 --- /dev/null +++ b/rate_limiter.py @@ -0,0 +1,28 @@ +import asyncio +import random + + +class RateLimiter: + """Гарантирует минимальный интервал между вызовами wait(), плюс джиттер. + + Semaphore ограничивает только количество одновременных запросов - можно + держать его хоть в 1, но всё равно долбить сайт пачками сразу как + освобождается слот. Этот класс решает именно это: не чаще чем раз + в min_interval секунд, сколько бы корутин ни стояло в очереди. + """ + + def __init__(self, min_interval: float, jitter: float = 0.0): + self.min_interval = min_interval + self.jitter = jitter + self._lock = asyncio.Lock() + self._last_call = 0.0 + + async def wait(self): + async with self._lock: + loop = asyncio.get_event_loop() + now = loop.time() + delay = self.min_interval + random.uniform(0, self.jitter) + sleep_for = delay - (now - self._last_call) + if sleep_for > 0: + await asyncio.sleep(sleep_for) + self._last_call = loop.time() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a8cfbc7 Binary files /dev/null and b/requirements.txt differ diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..01cb2a7 --- /dev/null +++ b/storage.py @@ -0,0 +1,23 @@ +import asyncio + +from config import OUTPUT_FILE + + +class ResultWriter: + """Пишет найденные свободные юзернеймы построчно. Файл открывается на + дозапись при каждом вызове add(), так что если скрипт упадёт на середине + прогона - уже найденное никуда не денется.""" + + def __init__(self, path: str = OUTPUT_FILE): + self.path = path + self._lock = asyncio.Lock() + + def reset(self): + open(self.path, "w", encoding="utf-8").close() + + async def add(self, profile_url: str, username: str): + line = f"{profile_url} - {username}\n" + async with self._lock: + with open(self.path, "a", encoding="utf-8") as f: + f.write(line) + f.flush() diff --git a/telegram_checker.py b/telegram_checker.py new file mode 100644 index 0000000..569c164 --- /dev/null +++ b/telegram_checker.py @@ -0,0 +1,108 @@ +import asyncio +import random +import re + +import aiohttp + +from config import ( + HEADERS, + TG_JITTER, + TG_MAX_HEAD_BYTES, + TG_MIN_INTERVAL, + TG_REQUEST_TIMEOUT, + TG_RETRIES, +) +from rate_limiter import RateLimiter + +OG_TITLE_RE = re.compile(rb'property=["\']og:title["\']\s+content=["\']([^"\']*)["\']') +OG_IMAGE_RE = re.compile(rb'property=["\']og:image["\']\s+content=["\']([^"\']*)["\']') +OG_DESC_RE = re.compile( + rb'property=["\']og:description["\']\s+content=["\']([^"\']*)["\']' +) + +# один общий лимитер на всё приложение: t.me не должен видеть больше одного +# запроса раз в TG_MIN_INTERVAL секунд, сколько бы задач ни ждало своей очереди +_rate_limiter = RateLimiter(min_interval=TG_MIN_INTERVAL, jitter=TG_JITTER) + + +async def _fetch_head(session, url: str, max_bytes: int = TG_MAX_HEAD_BYTES) -> bytes: + """Читает страницу кусками и останавливается сразу после - + сам текст страницы не нужен, только og-теги в шапке.""" + timeout = aiohttp.ClientTimeout(total=TG_REQUEST_TIMEOUT) + async with session.get(url, timeout=timeout, headers=HEADERS) as resp: + if resp.status != 200: + raise aiohttp.ClientResponseError( + resp.request_info, resp.history, status=resp.status + ) + + buf = b"" + async for chunk in resp.content.iter_chunked(1024): + buf += chunk + if b"" in buf or len(buf) >= max_bytes: + break + return buf + + +def _extract(pattern, html: bytes) -> str: + match = pattern.search(html) + return match.group(1).decode() if match else "" + + +def _looks_unregistered( + username: str, title: str, image: str, description: str +) -> bool: + """Для несуществующих/удалённых юзернеймов телега всегда рисует одну и + ту же болванку: заголовок "Telegram: Contact @username", дефолтный + логотип и пустое описание. Если все три совпали - юзернейм свободен.""" + expected_title = f"telegram: contact @{username}".lower() + return ( + title.lower() == expected_title + and image == "https://telegram.org/img/t_logo_2x.png" + and description == "" + ) + + +async def check_telegram(session, tg_url: str) -> str | None: + """None - юзернейм занят или проверить не вышло. Строка - похоже свободен.""" + username = tg_url.rstrip("/").split("/")[-1] + if not username: + print(f"Кривая ссылка на тг: {tg_url}") + return None + + html = None + for attempt in range(1, TG_RETRIES + 1): + await _rate_limiter.wait() + try: + html = await _fetch_head(session, f"https://t.me/{username}") + break + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + backoff = 2**attempt + random.uniform(0, 1) + print( + f" @{username}: попытка {attempt}/{TG_RETRIES} не удалась ({e}), жду {backoff:.1f}с" + ) + await asyncio.sleep(backoff) + + if html is None: + print( + f" @{username}: не смог получить страницу за {TG_RETRIES} попыток, пропускаю" + ) + return None + + title = _extract(OG_TITLE_RE, html) + image = _extract(OG_IMAGE_RE, html) + description = _extract(OG_DESC_RE, html) + + print(f" @{username}: title={title!r} image={image!r} desc={description!r}") + + if not title and not image and not description: + # пустые og-теги почти всегда значат, что нас притормозили, + # а не что страница правда пустая - за "свободно" это не считаем + print(f" @{username}: пустой ответ, похоже попали под лимит - пропускаю") + return None + + if _looks_unregistered(username, title, image, description): + print(f" @{username}: похоже свободен") + return username + + print(f" @{username}: занят") + return None