Add Telegram finder source
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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", "")
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Binary file not shown.
+23
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user