199 lines
6.2 KiB
Python
199 lines
6.2 KiB
Python
import json
|
|
from typing import Optional, Dict, Any, Tuple
|
|
from bs4 import BeautifulSoup
|
|
from pydantic import BaseModel, Field
|
|
from fastapi import APIRouter, Request
|
|
|
|
from httpx import AsyncClient
|
|
from async_lru import alru_cache
|
|
from functools import lru_cache
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
|
|
# ==================== Pydantic модели ====================
|
|
|
|
class LocationModel(BaseModel):
|
|
"""Геоданные"""
|
|
city: Optional[str] = None
|
|
region: Optional[str] = None
|
|
country: Optional[str] = None
|
|
postal_code: Optional[str] = None
|
|
latitude: Optional[float] = None
|
|
longitude: Optional[float] = None
|
|
timezone: Optional[str] = None
|
|
|
|
|
|
class ASNModel(BaseModel):
|
|
"""ASN данные"""
|
|
asn: Optional[str] = None
|
|
name: Optional[str] = None
|
|
route: Optional[str] = None
|
|
|
|
|
|
class CompanyModel(BaseModel):
|
|
"""Данные компании"""
|
|
name: Optional[str] = None
|
|
abuse_contact: Optional[str] = None
|
|
|
|
|
|
class SecurityModel(BaseModel):
|
|
"""Данные безопасности"""
|
|
vpn: bool = False
|
|
proxy: bool = False
|
|
tor: bool = False
|
|
hosting: bool = False
|
|
|
|
|
|
class IPInfoModel(BaseModel):
|
|
"""Главная модель IP информации"""
|
|
ip: str
|
|
url: Optional[str] = None
|
|
location: LocationModel = Field(default_factory=LocationModel)
|
|
asn: ASNModel = Field(default_factory=ASNModel)
|
|
company: CompanyModel = Field(default_factory=CompanyModel)
|
|
security: SecurityModel = Field(default_factory=SecurityModel)
|
|
|
|
|
|
# ==================== Парсер ====================
|
|
|
|
class IPInfo:
|
|
"""Парсер IP информации из JSON-LD"""
|
|
|
|
def __init__(self, html: str):
|
|
self._data = self._parse(html)
|
|
|
|
@lru_cache(maxsize=1000)
|
|
def _parse(self, html: str) -> Optional[IPInfoModel]:
|
|
"""Парсит HTML и возвращает модель"""
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
scripts = soup.find_all('script', type='application/ld+json')
|
|
|
|
if not scripts:
|
|
return None
|
|
|
|
# Ищем первый скрипт с типом DataFeed
|
|
data = None
|
|
for script in scripts:
|
|
if not script.string:
|
|
continue
|
|
|
|
try:
|
|
parsed = json.loads(script.string.strip())
|
|
if parsed.get('@type') == 'DataFeed':
|
|
data = parsed
|
|
break
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
if data is None:
|
|
return None
|
|
|
|
# Базовые поля
|
|
result = IPInfoModel(
|
|
ip=data.get('name', ''),
|
|
url=data.get('url')
|
|
)
|
|
|
|
# Парсим локацию
|
|
location = data.get('contentLocation', {})
|
|
address = location.get('address', {})
|
|
geo = location.get('geo', {})
|
|
|
|
result.location = LocationModel(
|
|
city=address.get('addressLocality'),
|
|
region=address.get('addressRegion'),
|
|
country=address.get('addressCountry'),
|
|
postal_code=address.get('postalCode'),
|
|
latitude=geo.get('latitude'),
|
|
longitude=geo.get('longitude'),
|
|
timezone=self._find_value(data, 'Timezone')
|
|
)
|
|
|
|
# Парсим ASN
|
|
result.asn = ASNModel(
|
|
asn=self._find_value(data, 'ASN'),
|
|
name=self._find_value(data, 'AS Name'),
|
|
route=self._find_value(data, 'AS Route')
|
|
)
|
|
|
|
# Парсим компанию
|
|
result.company = CompanyModel(
|
|
name=self._find_value(data, 'Company Name'),
|
|
abuse_contact=self._find_value(data, 'Abuse Contact')
|
|
)
|
|
|
|
# Парсим безопасность
|
|
result.security = SecurityModel(
|
|
vpn=self._find_bool(data, 'VPN'),
|
|
proxy=self._find_bool(data, 'Proxy'),
|
|
tor=self._find_bool(data, 'Tor'),
|
|
hosting=self._find_bool(data, 'Hosting')
|
|
)
|
|
|
|
return result
|
|
|
|
def _find_value(self, data: Dict, name: str) -> Optional[str]:
|
|
"""Поиск значения в variableMeasured"""
|
|
for item in data.get('variableMeasured', []):
|
|
if item.get('name') == name:
|
|
return item.get('value')
|
|
return None
|
|
|
|
def _find_bool(self, data: Dict, name: str) -> bool:
|
|
"""Поиск булевого значения"""
|
|
value = self._find_value(data, name)
|
|
return value == 'Yes'
|
|
|
|
def dict(self) -> Optional[Dict[str, Any]]:
|
|
"""Возвращает словарь с данными"""
|
|
if self._data:
|
|
return self._data.model_dump(exclude_none=True)
|
|
return None
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
"""Проверяет, удалось ли распарсить данные"""
|
|
return self._data is not None
|
|
|
|
@property
|
|
def coordinates(self) -> Optional[Tuple[float, float]]:
|
|
"""Возвращает координаты как tuple"""
|
|
if self._data and self._data.location.latitude and self._data.location.longitude:
|
|
return (self._data.location.latitude, self._data.location.longitude)
|
|
return None
|
|
|
|
|
|
# ==================== Роутер ====================
|
|
router = APIRouter(prefix='/api/v1', tags=['ip'])
|
|
|
|
|
|
@alru_cache(maxsize=1000, ttl=86400)
|
|
async def get_ip_info(ip: str, user_agent: str):
|
|
"""Асинхронная функция для получения информации об IP с кешированием."""
|
|
async with AsyncClient(headers={'User-Agent': user_agent}) as client:
|
|
response = await client.get(f'https://ipinfo.io/{ip}')
|
|
return response.text
|
|
|
|
|
|
@router.get('/ip')
|
|
async def get_ip(request: Request):
|
|
forwarded = request.headers.get('X-Forwarded-For')
|
|
user_agent = request.headers.get('User-Agent')
|
|
|
|
if forwarded:
|
|
# Берем ПЕРВЫЙ IP из списка (это реальный клиент)
|
|
client_ip = forwarded.split(',')[0].strip()
|
|
else:
|
|
client_ip = request.client.host
|
|
|
|
html = await get_ip_info(client_ip, user_agent)
|
|
|
|
result = await asyncio.to_thread(lambda: IPInfo(html).dict())
|
|
logging.info(result)
|
|
|
|
return result |