Add Telegram finder source

This commit is contained in:
2026-07-14 08:59:05 +05:00
parent 37991ddce6
commit a73ff54140
10 changed files with 454 additions and 0 deletions
+3
View File
@@ -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/
+94
View File
@@ -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", "")
+31
View File
@@ -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 # хватает с запасом, чтобы дочитать до </head>
+49
View File
@@ -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}
+97
View File
@@ -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())
+21
View File
@@ -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
+28
View File
@@ -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()
BIN
View File
Binary file not shown.
+23
View File
@@ -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()
+108
View File
@@ -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:
"""Читает страницу кусками и останавливается сразу после </head> -
сам текст страницы не нужен, только 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"</head>" 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