forked from zovos/bot_tg
СЕКСАПДЕЙТ
This commit is contained in:
parent
3cee61ab2c
commit
1979ca3193
12 changed files with 1042 additions and 11 deletions
2
.env
2
.env
|
|
@ -5,3 +5,5 @@ LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/
|
||||||
LLAMA_FORCE_DISABLE_THINKING=0
|
LLAMA_FORCE_DISABLE_THINKING=0
|
||||||
LLAMA_LOG_THINKING=1
|
LLAMA_LOG_THINKING=1
|
||||||
WEBAPP_URL=https://uninterleaved-scrawnily-nicol.ngrok-free.dev
|
WEBAPP_URL=https://uninterleaved-scrawnily-nicol.ngrok-free.dev
|
||||||
|
|
||||||
|
NGROK_AUTHTOKEN=39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg
|
||||||
|
|
|
||||||
38
docker-compose.webapp-test.yml
Normal file
38
docker-compose.webapp-test.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
services:
|
||||||
|
webapp-api-test:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
container_name: fenyabot-webapp-api-test
|
||||||
|
volumes:
|
||||||
|
- ./db:/db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
|
BET_DB_PATH: /db/bets.sqlite3
|
||||||
|
API_PORT: "8080"
|
||||||
|
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
|
||||||
|
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
|
||||||
|
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
|
||||||
|
ports:
|
||||||
|
- "18080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ngrok-test:
|
||||||
|
image: ngrok/ngrok:latest
|
||||||
|
container_name: fenyabot-ngrok-test
|
||||||
|
depends_on:
|
||||||
|
- webapp-api-test
|
||||||
|
environment:
|
||||||
|
NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN}
|
||||||
|
command:
|
||||||
|
- http
|
||||||
|
- webapp-api-test:8080
|
||||||
|
- --log=stdout
|
||||||
|
ports:
|
||||||
|
- "4041:4040"
|
||||||
|
restart: unless-stopped
|
||||||
72
scripts/set_telegram_webapp_button.py
Executable file
72
scripts/set_telegram_webapp_button.py
Executable file
|
|
@ -0,0 +1,72 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file(path: Path) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
# сохраняем первое значение и игнорируем shell-комментарий после пробела
|
||||||
|
value = value.split(" #", 1)[0].strip()
|
||||||
|
if key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def api_call(token: str, method: str, payload: dict) -> dict:
|
||||||
|
url = f"https://api.telegram.org/bot{token}/{method}"
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
load_env_file(root / ".env")
|
||||||
|
|
||||||
|
token = os.getenv("BOT_TOKEN", "").strip()
|
||||||
|
webapp_url = os.getenv("WEBAPP_URL", "").strip()
|
||||||
|
if not token:
|
||||||
|
print("BOT_TOKEN пустой в .env", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not webapp_url:
|
||||||
|
print("WEBAPP_URL пустой в .env", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
parsed = urllib.parse.urlparse(webapp_url)
|
||||||
|
if parsed.scheme != "https":
|
||||||
|
print("WEBAPP_URL должен быть https", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"menu_button": {
|
||||||
|
"type": "web_app",
|
||||||
|
"text": "Mini App",
|
||||||
|
"web_app": {"url": webapp_url},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = api_call(token, "setChatMenuButton", payload)
|
||||||
|
if not result.get("ok"):
|
||||||
|
print(f"setChatMenuButton failed: {result}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Menu button updated: {webapp_url}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
97
scripts/start_webapp_ngrok_test.sh
Executable file
97
scripts/start_webapp_ngrok_test.sh
Executable file
|
|
@ -0,0 +1,97 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
|
||||||
|
ENV_FILE="${ROOT_DIR}/.env"
|
||||||
|
PROJECT_NAME="fenya-webapp-test"
|
||||||
|
|
||||||
|
if [[ ! -f "${COMPOSE_FILE}" ]]; then
|
||||||
|
echo "Не найден ${COMPOSE_FILE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||||
|
echo "Не найден ${ENV_FILE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -qE '^NGROK_AUTHTOKEN=' "${ENV_FILE}"; then
|
||||||
|
fallback_token="$(python - <<'PY'
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
p = Path("webapp/ngrok_tunnel.py")
|
||||||
|
if not p.exists():
|
||||||
|
print("")
|
||||||
|
raise SystemExit
|
||||||
|
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
m = re.search(r'auth_token\s*=\s*"([^"]+)"', txt)
|
||||||
|
print(m.group(1) if m else "")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -n "${fallback_token}" ]]; then
|
||||||
|
printf '\nNGROK_AUTHTOKEN=%s\n' "${fallback_token}" >> "${ENV_FILE}"
|
||||||
|
echo "Добавил NGROK_AUTHTOKEN в .env из webapp/ngrok_tunnel.py"
|
||||||
|
else
|
||||||
|
echo "NGROK_AUTHTOKEN не найден. Добавь в .env строку NGROK_AUTHTOKEN=..." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" up -d --build
|
||||||
|
|
||||||
|
echo "Жду инициализацию ngrok..."
|
||||||
|
public_url=""
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
public_url="$(python - <<'PY'
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen("http://127.0.0.1:4041/api/tunnels", timeout=2) as resp:
|
||||||
|
data = json.load(resp)
|
||||||
|
tunnels = data.get("tunnels", [])
|
||||||
|
for t in tunnels:
|
||||||
|
url = str(t.get("public_url", ""))
|
||||||
|
if url.startswith("https://"):
|
||||||
|
print(url)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -n "${public_url}" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${public_url}" ]]; then
|
||||||
|
echo "Не удалось получить ngrok URL. Проверь логи: docker logs fenyabot-ngrok-test" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python - <<'PY' "${ENV_FILE}" "${public_url}"
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
env_path = Path(sys.argv[1])
|
||||||
|
url = sys.argv[2]
|
||||||
|
text = env_path.read_text(encoding="utf-8")
|
||||||
|
if re.search(r"^WEBAPP_URL=.*$", text, flags=re.MULTILINE):
|
||||||
|
text = re.sub(r"^WEBAPP_URL=.*$", f"WEBAPP_URL={url}", text, flags=re.MULTILINE)
|
||||||
|
else:
|
||||||
|
text = text.rstrip() + f"\nWEBAPP_URL={url}\n"
|
||||||
|
env_path.write_text(text, encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Готово."
|
||||||
|
echo "NGROK URL: ${public_url}"
|
||||||
|
echo "WEBAPP_URL обновлён в .env"
|
||||||
|
echo
|
||||||
|
echo "Для остановки тест-контура:"
|
||||||
|
echo " docker compose -p ${PROJECT_NAME} -f ${COMPOSE_FILE} down"
|
||||||
9
scripts/stop_webapp_ngrok_test.sh
Executable file
9
scripts/stop_webapp_ngrok_test.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
COMPOSE_FILE="${ROOT_DIR}/docker-compose.webapp-test.yml"
|
||||||
|
PROJECT_NAME="fenya-webapp-test"
|
||||||
|
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
docker compose -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" down
|
||||||
|
|
@ -10,6 +10,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from urllib.parse import urlparse
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
@ -17,7 +18,7 @@ from typing import Annotated
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -36,6 +37,7 @@ from games.betting import (
|
||||||
)
|
)
|
||||||
from webapp.auth import validate_init_data
|
from webapp.auth import validate_init_data
|
||||||
from games.uwu import fetch_uwu_post, fetch_furtok_feed
|
from games.uwu import fetch_uwu_post, fetch_furtok_feed
|
||||||
|
from webapp.shorties import fetch_shorties_feed
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -405,6 +407,23 @@ async def get_furtok_feed_api(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/furtok/shorties")
|
||||||
|
async def get_shorties_feed_api(
|
||||||
|
page: int = 1,
|
||||||
|
count: int = 8,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Случайная shorties-лента с Pornhub."""
|
||||||
|
try:
|
||||||
|
# page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов.
|
||||||
|
random_pages = 2 + min(max(page, 1), 6)
|
||||||
|
feed = await fetch_shorties_feed(count=count, random_pages=random_pages)
|
||||||
|
return {"feed": feed}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Shorties API failed")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/proxy-image")
|
@app.get("/api/proxy-image")
|
||||||
async def proxy_image(url: str):
|
async def proxy_image(url: str):
|
||||||
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
|
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
|
||||||
|
|
@ -433,6 +452,76 @@ async def proxy_image(url: str):
|
||||||
raise HTTPException(status_code=502, detail="Failed to fetch image")
|
raise HTTPException(status_code=502, detail="Failed to fetch image")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_shorties_media_host(hostname: str) -> bool:
|
||||||
|
host = (hostname or "").lower()
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}:
|
||||||
|
return True
|
||||||
|
if host.endswith(".phncdn.com"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/proxy-shorties-media")
|
||||||
|
async def proxy_shorties_media(url: str, request: Request):
|
||||||
|
"""Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""):
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden host")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Referer": "https://www.pornhub.com/shorties",
|
||||||
|
"Origin": "https://www.pornhub.com",
|
||||||
|
}
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
if range_header:
|
||||||
|
headers["Range"] = range_header
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
session = aiohttp.ClientSession(timeout=timeout)
|
||||||
|
try:
|
||||||
|
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
await session.close()
|
||||||
|
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||||||
|
|
||||||
|
if resp.status not in {200, 206}:
|
||||||
|
await resp.release()
|
||||||
|
await session.close()
|
||||||
|
raise HTTPException(status_code=resp.status, detail="Upstream error")
|
||||||
|
|
||||||
|
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
||||||
|
passthrough_headers = {
|
||||||
|
"Cache-Control": "public, max-age=600",
|
||||||
|
"Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"),
|
||||||
|
}
|
||||||
|
if resp.headers.get("Content-Length"):
|
||||||
|
passthrough_headers["Content-Length"] = resp.headers["Content-Length"]
|
||||||
|
if resp.headers.get("Content-Range"):
|
||||||
|
passthrough_headers["Content-Range"] = resp.headers["Content-Range"]
|
||||||
|
|
||||||
|
async def _stream():
|
||||||
|
try:
|
||||||
|
async for chunk in resp.content.iter_chunked(64 * 1024):
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await resp.release()
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream(),
|
||||||
|
media_type=content_type,
|
||||||
|
status_code=resp.status,
|
||||||
|
headers=passthrough_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- ИИ Чат ---
|
# --- ИИ Чат ---
|
||||||
|
|
||||||
@app.post("/api/talk")
|
@app.post("/api/talk")
|
||||||
|
|
|
||||||
|
|
@ -912,7 +912,8 @@ body::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.furtok-card video,
|
.furtok-card video,
|
||||||
.furtok-card img {
|
.furtok-card img,
|
||||||
|
.furtok-card iframe {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -920,6 +921,7 @@ body::before {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.furtok-overlay {
|
.furtok-overlay {
|
||||||
|
|
@ -936,6 +938,36 @@ body::before {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.furtok-side-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-share-btn {
|
||||||
|
border: 1px solid rgba(255,255,255,0.35);
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
transition: transform 0.15s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-share-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
background: rgba(0,0,0,0.65);
|
||||||
|
}
|
||||||
|
|
||||||
.furtok-caption {
|
.furtok-caption {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -989,6 +1021,37 @@ body::before {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.furtok-mode-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.14);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-mode-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-mode-btn.active {
|
||||||
|
background: rgba(96, 165, 250, 0.3);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.furtok-mode-btn:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
.furtok-gear-btn {
|
.furtok-gear-btn {
|
||||||
background: rgba(255,255,255,0.15);
|
background: rgba(255,255,255,0.15);
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
|
||||||
|
|
@ -42,5 +42,7 @@ export const API = {
|
||||||
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||||
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
||||||
getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
|
getFurtokFeed: (safe = true, page = 1, tags = '') => api(`/api/furtok?safe=${safe}&page=${page}${tags ? '&tags=' + encodeURIComponent(tags) : ''}`),
|
||||||
|
getShortiesFeed: (page = 1, count = 8) => api(`/api/furtok/shorties?page=${page}&count=${count}`),
|
||||||
|
proxyShortiesMediaUrl: (url) => `${API_BASE}/api/proxy-shorties-media?url=${encodeURIComponent(url || '')}`,
|
||||||
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ const navBtns = document.querySelectorAll('.nav-btn');
|
||||||
|
|
||||||
// ── Router ──
|
// ── Router ──
|
||||||
function navigate(page, data = null) {
|
function navigate(page, data = null) {
|
||||||
|
document.querySelectorAll('.furtok-card video').forEach((video) => {
|
||||||
|
if (video && video._hlsInstance) {
|
||||||
|
try { video._hlsInstance.destroy(); } catch {}
|
||||||
|
video._hlsInstance = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
haptic();
|
haptic();
|
||||||
// Убираем оверлеи при смене страницы
|
// Убираем оверлеи при смене страницы
|
||||||
|
|
@ -66,6 +72,82 @@ function $(html) {
|
||||||
return t.content.firstChild;
|
return t.content.firstChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initShortiesVideoPlayback(video) {
|
||||||
|
if (!video) return;
|
||||||
|
const hlsSrc = video.dataset.hlsSrc || '';
|
||||||
|
if (!hlsSrc) return;
|
||||||
|
|
||||||
|
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
|
||||||
|
if (canPlayNativeHls) {
|
||||||
|
video.src = hlsSrc;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
|
const hls = new window.Hls({
|
||||||
|
maxBufferLength: 30,
|
||||||
|
backBufferLength: 30,
|
||||||
|
enableWorker: true,
|
||||||
|
});
|
||||||
|
hls.loadSource(hlsSrc);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
video._hlsInstance = hls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getShortiesShareUrl(post) {
|
||||||
|
const source = String(post?.source || '').trim();
|
||||||
|
if (source) return source;
|
||||||
|
return String(post?.url || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveShortiesLinkLocally(url) {
|
||||||
|
if (!url) return;
|
||||||
|
const key = 'shorties_saved_links';
|
||||||
|
let links = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
|
if (Array.isArray(parsed)) links = parsed.filter((item) => typeof item === 'string' && item.trim());
|
||||||
|
} catch {}
|
||||||
|
if (!links.includes(url)) {
|
||||||
|
links.unshift(url);
|
||||||
|
localStorage.setItem(key, JSON.stringify(links.slice(0, 200)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text) {
|
||||||
|
if (!text) return false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = text;
|
||||||
|
input.setAttribute('readonly', '');
|
||||||
|
input.style.position = 'absolute';
|
||||||
|
input.style.left = '-9999px';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
return !!ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showResult(icon, title, desc, btnText = 'OK') {
|
function showResult(icon, title, desc, btnText = 'OK') {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const overlay = $(`
|
const overlay = $(`
|
||||||
|
|
@ -507,6 +589,7 @@ let furtokWrapper = null;
|
||||||
let furtokCurrentIndex = 0;
|
let furtokCurrentIndex = 0;
|
||||||
let furtokCards = [];
|
let furtokCards = [];
|
||||||
let furtokCustomTags = '';
|
let furtokCustomTags = '';
|
||||||
|
let furtokMode = 'furtok'; // "furtok" | "shorties"
|
||||||
|
|
||||||
// Подписи из uwu.py — рандомно появляются на карточках
|
// Подписи из uwu.py — рандомно появляются на карточках
|
||||||
const FURTOK_CAPTIONS = [
|
const FURTOK_CAPTIONS = [
|
||||||
|
|
@ -531,6 +614,7 @@ function randomCaption() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function _furtokGetTags() {
|
function _furtokGetTags() {
|
||||||
|
if (furtokMode === 'shorties') return '';
|
||||||
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
|
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
|
||||||
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
|
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
|
||||||
// Иначе стандартный запрос с safe-переключателем
|
// Иначе стандартный запрос с safe-переключателем
|
||||||
|
|
@ -546,6 +630,29 @@ function _furtokReload(feed) {
|
||||||
loadFurtokPage(feed);
|
loadFurtokPage(feed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _updateFurtokUiMode() {
|
||||||
|
const safeWrap = document.getElementById('furtok-safe-wrap');
|
||||||
|
const gearBtn = document.getElementById('furtok-gear');
|
||||||
|
const tagsPanel = document.getElementById('furtok-tags-panel');
|
||||||
|
const title = document.getElementById('furtok-title');
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
title.textContent = furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeWrap || !gearBtn || !tagsPanel) return;
|
||||||
|
|
||||||
|
if (furtokMode === 'shorties') {
|
||||||
|
safeWrap.style.display = 'none';
|
||||||
|
gearBtn.style.display = 'none';
|
||||||
|
tagsPanel.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gearBtn.style.display = '';
|
||||||
|
safeWrap.style.display = furtokCustomTags ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderFurtok() {
|
function renderFurtok() {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
document.querySelector('.furtok-wrapper')?.remove();
|
document.querySelector('.furtok-wrapper')?.remove();
|
||||||
|
|
@ -559,8 +666,12 @@ function renderFurtok() {
|
||||||
furtokWrapper.className = 'furtok-wrapper';
|
furtokWrapper.className = 'furtok-wrapper';
|
||||||
furtokWrapper.innerHTML = `
|
furtokWrapper.innerHTML = `
|
||||||
<div class="furtok-header">
|
<div class="furtok-header">
|
||||||
<div class="furtok-title">🐺 FurTok</div>
|
<div class="furtok-title" id="furtok-title">${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}</div>
|
||||||
<div class="furtok-header-right">
|
<div class="furtok-header-right">
|
||||||
|
<div class="furtok-mode-switch" id="furtok-mode-switch">
|
||||||
|
<button class="furtok-mode-btn ${furtokMode === 'furtok' ? 'active' : ''}" data-mode="furtok">FurTok</button>
|
||||||
|
<button class="furtok-mode-btn ${furtokMode === 'shorties' ? 'active' : ''}" data-mode="shorties">Shorties</button>
|
||||||
|
</div>
|
||||||
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
|
<label class="furtok-toggle" id="furtok-safe-wrap" ${furtokCustomTags.trim() ? 'style="display:none"' : ''}>
|
||||||
<span>Safe</span>
|
<span>Safe</span>
|
||||||
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
||||||
|
|
@ -588,6 +699,23 @@ function renderFurtok() {
|
||||||
const tagsPanel = document.getElementById('furtok-tags-panel');
|
const tagsPanel = document.getElementById('furtok-tags-panel');
|
||||||
const tagsInput = document.getElementById('furtok-tags-input');
|
const tagsInput = document.getElementById('furtok-tags-input');
|
||||||
const tagsApply = document.getElementById('furtok-tags-apply');
|
const tagsApply = document.getElementById('furtok-tags-apply');
|
||||||
|
const modeSwitch = document.getElementById('furtok-mode-switch');
|
||||||
|
|
||||||
|
_updateFurtokUiMode();
|
||||||
|
|
||||||
|
modeSwitch?.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.furtok-mode-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const nextMode = btn.dataset.mode;
|
||||||
|
if (!nextMode || nextMode === furtokMode) return;
|
||||||
|
haptic();
|
||||||
|
furtokMode = nextMode;
|
||||||
|
modeSwitch.querySelectorAll('.furtok-mode-btn').forEach((item) => {
|
||||||
|
item.classList.toggle('active', item.dataset.mode === furtokMode);
|
||||||
|
});
|
||||||
|
_updateFurtokUiMode();
|
||||||
|
_furtokReload(feed);
|
||||||
|
});
|
||||||
|
|
||||||
// Safe toggle
|
// Safe toggle
|
||||||
safeToggle.addEventListener('change', () => {
|
safeToggle.addEventListener('change', () => {
|
||||||
|
|
@ -608,7 +736,7 @@ function renderFurtok() {
|
||||||
haptic();
|
haptic();
|
||||||
furtokCustomTags = tagsInput.value.trim();
|
furtokCustomTags = tagsInput.value.trim();
|
||||||
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
|
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
|
||||||
safeWrap.style.display = furtokCustomTags ? 'none' : '';
|
_updateFurtokUiMode();
|
||||||
tagsPanel.style.display = 'none';
|
tagsPanel.style.display = 'none';
|
||||||
_furtokReload(feed);
|
_furtokReload(feed);
|
||||||
});
|
});
|
||||||
|
|
@ -727,7 +855,9 @@ async function loadFurtokPage(feedEl) {
|
||||||
feedEl.appendChild(loader);
|
feedEl.appendChild(loader);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
|
const res = furtokMode === 'shorties'
|
||||||
|
? await API.getShortiesFeed(furtokPage, 8)
|
||||||
|
: await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
|
||||||
loader.remove();
|
loader.remove();
|
||||||
|
|
||||||
if (!res.feed || res.feed.length === 0) {
|
if (!res.feed || res.feed.length === 0) {
|
||||||
|
|
@ -741,23 +871,42 @@ async function loadFurtokPage(feedEl) {
|
||||||
res.feed.forEach(post => {
|
res.feed.forEach(post => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'furtok-card';
|
card.className = 'furtok-card';
|
||||||
|
const isShorties = furtokMode === 'shorties';
|
||||||
|
const shareUrl = getShortiesShareUrl(post);
|
||||||
|
|
||||||
let mediaHtml = '';
|
let mediaHtml = '';
|
||||||
if (post.type === 'image' || post.ext === 'gif') {
|
if (post.type === 'image' || post.ext === 'gif') {
|
||||||
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
mediaHtml = `<img src="${post.url}" loading="lazy" alt="">`;
|
||||||
} else {
|
} else {
|
||||||
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
|
const poster = post.sample ? ` poster="${post.sample}"` : '';
|
||||||
|
const directMp4 = post.mp4_url || post.url || '';
|
||||||
|
const hasHls = isShorties && Boolean(post.hls_url);
|
||||||
|
const videoSrc = hasHls ? '' : (isShorties ? API.proxyShortiesMediaUrl(directMp4) : post.url);
|
||||||
|
const hlsSrc = isShorties ? escapeHtml(post.hls_url || '') : '';
|
||||||
|
mediaHtml = `<video src="${videoSrc}" data-direct-src="${escapeHtml(directMp4)}" data-hls-src="${hlsSrc}"${poster} loop autoplay playsinline preload="auto" muted></video>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const caption = randomCaption();
|
const title = post.title ? escapeHtml(post.title) : '';
|
||||||
|
const caption = title || randomCaption();
|
||||||
|
const scoreLabel = furtokMode === 'shorties'
|
||||||
|
? `👁️ ${escapeHtml(post.views || '—')}`
|
||||||
|
: `⭐ ${escapeHtml(post.score ?? 0)}`;
|
||||||
|
const favLabel = furtokMode === 'shorties'
|
||||||
|
? `⏱️ ${escapeHtml(post.duration || '—')}`
|
||||||
|
: `❤️ ${escapeHtml(post.fav_count ?? 0)}`;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
${mediaHtml}
|
${mediaHtml}
|
||||||
|
${isShorties ? `
|
||||||
|
<div class="furtok-side-actions">
|
||||||
|
<button class="furtok-share-btn" type="button" aria-label="Share">🔗 Share</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="furtok-overlay">
|
<div class="furtok-overlay">
|
||||||
<div class="furtok-caption">${caption}</div>
|
<div class="furtok-caption">${caption}</div>
|
||||||
<div class="furtok-stats">
|
<div class="furtok-stats">
|
||||||
<span>⭐ ${post.score}</span>
|
<span>${scoreLabel}</span>
|
||||||
<span>❤️ ${post.fav_count}</span>
|
<span>${favLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -765,6 +914,15 @@ async function loadFurtokPage(feedEl) {
|
||||||
// Тап по видео = unmute + play/pause
|
// Тап по видео = unmute + play/pause
|
||||||
const video = card.querySelector('video');
|
const video = card.querySelector('video');
|
||||||
if (video) {
|
if (video) {
|
||||||
|
if (isShorties) {
|
||||||
|
initShortiesVideoPlayback(video);
|
||||||
|
}
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
if (isShorties && video.dataset.directSrc && video.src !== video.dataset.directSrc) {
|
||||||
|
video.src = video.dataset.directSrc;
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
video.muted = false;
|
video.muted = false;
|
||||||
if (video.paused) video.play();
|
if (video.paused) video.play();
|
||||||
|
|
@ -772,6 +930,23 @@ async function loadFurtokPage(feedEl) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shareBtn = card.querySelector('.furtok-share-btn');
|
||||||
|
if (shareBtn) {
|
||||||
|
shareBtn.addEventListener('click', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!shareUrl) return;
|
||||||
|
const copied = await copyToClipboard(shareUrl);
|
||||||
|
saveShortiesLinkLocally(shareUrl);
|
||||||
|
if (copied) haptic('success');
|
||||||
|
else haptic('impact');
|
||||||
|
shareBtn.textContent = copied ? '✅ Saved' : '💾 Saved';
|
||||||
|
setTimeout(() => {
|
||||||
|
shareBtn.textContent = '🔗 Share';
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
feedEl.appendChild(card);
|
feedEl.appendChild(card);
|
||||||
furtokCards.push(card);
|
furtokCards.push(card);
|
||||||
});
|
});
|
||||||
|
|
@ -779,7 +954,10 @@ async function loadFurtokPage(feedEl) {
|
||||||
// Автоплей первого видео при первой загрузке
|
// Автоплей первого видео при первой загрузке
|
||||||
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
||||||
const firstVideo = furtokCards[0].querySelector('video');
|
const firstVideo = furtokCards[0].querySelector('video');
|
||||||
if (firstVideo) firstVideo.play().catch(() => {});
|
if (firstVideo) {
|
||||||
|
firstVideo.muted = true;
|
||||||
|
firstVideo.play().catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
haptic('success');
|
haptic('success');
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
fastapi>=0.111.0
|
fastapi>=0.111.0
|
||||||
uvicorn[standard]>=0.29.0
|
uvicorn[standard]>=0.29.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
|
|
||||||
479
webapp/shorties.py
Normal file
479
webapp/shorties.py
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PH_BASE_URL = "https://rt.pornhub.com"
|
||||||
|
SHORTIES_LIST_URL = f"{PH_BASE_URL}/shorties"
|
||||||
|
REQUEST_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
"Referer": PH_BASE_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
_A_HREF_RE = re.compile(
|
||||||
|
r'<a[^>]+href="(?P<href>/view_video\.php\?viewkey=[^"]+)"(?P<attrs>[^>]*)>(?P<body>.*?)</a>',
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_IMG_URL_RE = re.compile(
|
||||||
|
r'(?:data-mediumthumb|data-path|data-thumb_url|src)="(?P<url>https?://[^"]+)"',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_TITLE_ATTR_RE = re.compile(r'title="(?P<title>[^"]+)"', re.IGNORECASE)
|
||||||
|
_SCRIPT_LD_JSON_RE = re.compile(
|
||||||
|
r'<script[^>]+type="application/ld\+json"[^>]*>(?P<json>.*?)</script>',
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_MEDIA_DEFS_RE = re.compile(r'"mediaDefinitions"\s*:\s*(\[[^\]]+\])', re.IGNORECASE | re.DOTALL)
|
||||||
|
_VIDEO_URL_RE = re.compile(r'"videoUrl"\s*:\s*"(?P<url>https?:\\?/\\?/[^"]+\.mp4[^"]*)"', re.IGNORECASE)
|
||||||
|
_DURATION_RE = re.compile(r'"video_duration"\s*:\s*"?(?P<duration>\d+)"?', re.IGNORECASE)
|
||||||
|
_VIEWS_RE = re.compile(r'"video_views"\s*:\s*"?(?P<views>[0-9,\.]+)"?', re.IGNORECASE)
|
||||||
|
_JSON_SHORTIES_MARKER = "JSON_SHORTIES = insertAfterNthPosition("
|
||||||
|
_EMBED_SRC_RE = re.compile(r'<iframe[^>]+src="([^"]+)"', re.IGNORECASE)
|
||||||
|
_CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]")
|
||||||
|
_RUSSIAN_MARKERS = {
|
||||||
|
"russian",
|
||||||
|
"russia",
|
||||||
|
"russkiy",
|
||||||
|
"russkaya",
|
||||||
|
"russkoe",
|
||||||
|
"русский",
|
||||||
|
"русская",
|
||||||
|
"русское",
|
||||||
|
"россия",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShortiesCandidate:
|
||||||
|
page_url: str
|
||||||
|
title: str
|
||||||
|
thumb_url: str
|
||||||
|
duration: str = ""
|
||||||
|
views: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_html_text(value: str) -> str:
|
||||||
|
no_tags = re.sub(r"<[^>]+>", " ", value)
|
||||||
|
return " ".join(html.unescape(no_tags).split())
|
||||||
|
|
||||||
|
|
||||||
|
def _absolute_url(value: str) -> str:
|
||||||
|
return urljoin(PH_BASE_URL, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ldjson_candidates(page_html: str) -> list[ShortiesCandidate]:
|
||||||
|
out: list[ShortiesCandidate] = []
|
||||||
|
for match in _SCRIPT_LD_JSON_RE.finditer(page_html):
|
||||||
|
raw_json = html.unescape(match.group("json").strip())
|
||||||
|
if not raw_json:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_json)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
nodes: list[object]
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
nodes = parsed
|
||||||
|
else:
|
||||||
|
nodes = [parsed]
|
||||||
|
for node in nodes:
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
continue
|
||||||
|
if str(node.get("@type", "")).lower() != "videoobject":
|
||||||
|
continue
|
||||||
|
page_url = str(node.get("url") or "").strip()
|
||||||
|
thumb = str(node.get("thumbnailUrl") or "").strip()
|
||||||
|
title = str(node.get("name") or "").strip() or "Shorties"
|
||||||
|
if not page_url:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
ShortiesCandidate(
|
||||||
|
page_url=_absolute_url(page_url),
|
||||||
|
title=title,
|
||||||
|
thumb_url=_absolute_url(thumb) if thumb else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_anchor_candidates(page_html: str) -> list[ShortiesCandidate]:
|
||||||
|
out: list[ShortiesCandidate] = []
|
||||||
|
for match in _A_HREF_RE.finditer(page_html):
|
||||||
|
href = match.group("href").strip()
|
||||||
|
attrs = match.group("attrs") or ""
|
||||||
|
body = match.group("body") or ""
|
||||||
|
page_url = _absolute_url(href)
|
||||||
|
|
||||||
|
title_match = _TITLE_ATTR_RE.search(attrs) or _TITLE_ATTR_RE.search(body)
|
||||||
|
title = _clean_html_text(title_match.group("title")) if title_match else _clean_html_text(body)
|
||||||
|
if not title:
|
||||||
|
title = "Shorties"
|
||||||
|
|
||||||
|
thumb_match = _IMG_URL_RE.search(attrs) or _IMG_URL_RE.search(body)
|
||||||
|
thumb_url = _absolute_url(thumb_match.group("url")) if thumb_match else ""
|
||||||
|
|
||||||
|
out.append(ShortiesCandidate(page_url=page_url, title=title, thumb_url=thumb_url))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_candidates(items: list[ShortiesCandidate]) -> list[ShortiesCandidate]:
|
||||||
|
result: list[ShortiesCandidate] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
key = item.page_url.strip()
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_text(session: aiohttp.ClientSession, url: str) -> str:
|
||||||
|
async with session.get(url, headers=REQUEST_HEADERS) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.warning("Shorties request failed: %s status=%s", url, resp.status)
|
||||||
|
return ""
|
||||||
|
return await resp.text()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_best_mp4(page_html: str) -> str:
|
||||||
|
media_match = _MEDIA_DEFS_RE.search(page_html)
|
||||||
|
if media_match:
|
||||||
|
raw = media_match.group(1)
|
||||||
|
try:
|
||||||
|
defs = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
defs = []
|
||||||
|
best_url = ""
|
||||||
|
best_quality = -1
|
||||||
|
for item in defs:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
video_url = item.get("videoUrl")
|
||||||
|
if not isinstance(video_url, str) or ".mp4" not in video_url:
|
||||||
|
continue
|
||||||
|
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||||
|
try:
|
||||||
|
quality = int(quality_raw)
|
||||||
|
except ValueError:
|
||||||
|
quality = 0
|
||||||
|
if quality >= best_quality:
|
||||||
|
best_quality = quality
|
||||||
|
best_url = video_url
|
||||||
|
if best_url:
|
||||||
|
return best_url.replace("\\/", "/")
|
||||||
|
|
||||||
|
for match in _VIDEO_URL_RE.finditer(page_html):
|
||||||
|
candidate = match.group("url").replace("\\/", "/")
|
||||||
|
if ".mp4" in candidate:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_balanced_json_array(page_html: str, marker: str) -> str:
|
||||||
|
marker_idx = page_html.find(marker)
|
||||||
|
if marker_idx < 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
arr_start = page_html.find("[", marker_idx)
|
||||||
|
if arr_start < 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
escaped = False
|
||||||
|
for i in range(arr_start, len(page_html)):
|
||||||
|
ch = page_html[i]
|
||||||
|
if in_string:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == '"':
|
||||||
|
in_string = True
|
||||||
|
continue
|
||||||
|
if ch == "[":
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
if ch == "]":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return page_html[arr_start : i + 1]
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_shorties(page_html: str) -> list[dict]:
|
||||||
|
raw_array = _extract_balanced_json_array(page_html, _JSON_SHORTIES_MARKER)
|
||||||
|
if not raw_array:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_array)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Shorties parser: failed to decode JSON_SHORTIES array")
|
||||||
|
return []
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
return [item for item in parsed if isinstance(item, dict) and item.get("videoTitle")]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_mp4_from_media_defs(media_defs: object) -> str:
|
||||||
|
if not isinstance(media_defs, list):
|
||||||
|
return ""
|
||||||
|
best_url = ""
|
||||||
|
best_quality = -1
|
||||||
|
for item in media_defs:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("format", "")).lower() != "mp4":
|
||||||
|
continue
|
||||||
|
raw_url = item.get("videoUrl")
|
||||||
|
if not isinstance(raw_url, str) or not raw_url:
|
||||||
|
continue
|
||||||
|
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||||
|
try:
|
||||||
|
quality = int(quality_raw)
|
||||||
|
except ValueError:
|
||||||
|
quality = 0
|
||||||
|
if quality >= best_quality:
|
||||||
|
best_quality = quality
|
||||||
|
best_url = raw_url
|
||||||
|
return best_url.replace("\\/", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_hls_from_media_defs(media_defs: object) -> str:
|
||||||
|
if not isinstance(media_defs, list):
|
||||||
|
return ""
|
||||||
|
best_url = ""
|
||||||
|
best_quality = -1
|
||||||
|
for item in media_defs:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("format", "")).lower() != "hls":
|
||||||
|
continue
|
||||||
|
raw_url = item.get("videoUrl")
|
||||||
|
if not isinstance(raw_url, str) or ".m3u8" not in raw_url:
|
||||||
|
continue
|
||||||
|
quality_raw = str(item.get("quality") or "").strip("p ")
|
||||||
|
try:
|
||||||
|
quality = int(quality_raw)
|
||||||
|
except ValueError:
|
||||||
|
quality = 0
|
||||||
|
if quality >= best_quality:
|
||||||
|
best_quality = quality
|
||||||
|
best_url = raw_url
|
||||||
|
return best_url.replace("\\/", "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_russian_item(item: dict) -> bool:
|
||||||
|
title = str(item.get("videoTitle") or item.get("metaTitle") or "")
|
||||||
|
if _CYRILLIC_RE.search(title):
|
||||||
|
return True
|
||||||
|
|
||||||
|
pills = item.get("pillsData")
|
||||||
|
if isinstance(pills, list):
|
||||||
|
for pill in pills:
|
||||||
|
if not isinstance(pill, dict):
|
||||||
|
continue
|
||||||
|
values = [str(pill.get("name") or ""), str(pill.get("slug") or "")]
|
||||||
|
for raw in values:
|
||||||
|
normalized = raw.strip().lower()
|
||||||
|
if any(marker in normalized for marker in _RUSSIAN_MARKERS):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_russian_feed_item(item: dict) -> bool:
|
||||||
|
title = str(item.get("title") or "")
|
||||||
|
lowered = title.lower()
|
||||||
|
return bool(_CYRILLIC_RE.search(title) or any(marker in lowered for marker in _RUSSIAN_MARKERS))
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_from_json_shorties(items: list[dict]) -> list[dict]:
|
||||||
|
feed: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
if not _is_russian_item(item):
|
||||||
|
continue
|
||||||
|
hls_url = _pick_hls_from_media_defs(item.get("mediaDefinitions"))
|
||||||
|
mp4_url = _pick_mp4_from_media_defs(item.get("mediaDefinitions"))
|
||||||
|
if not hls_url and not mp4_url:
|
||||||
|
continue
|
||||||
|
source = str(item.get("linkUrl") or item.get("uniqueUrl") or item.get("shortieUrl") or "").strip()
|
||||||
|
if source and source.startswith("/"):
|
||||||
|
source = _absolute_url(source)
|
||||||
|
key = source or hls_url or mp4_url
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
|
||||||
|
tracking = item.get("trackingTimeWatched")
|
||||||
|
duration_raw = ""
|
||||||
|
if isinstance(tracking, dict):
|
||||||
|
duration_raw = str(tracking.get("video_duration") or "").strip()
|
||||||
|
title = str(item.get("videoTitle") or item.get("metaTitle") or "Shorties").strip()
|
||||||
|
thumb = str(item.get("imageUrl") or "").strip().replace("\\/", "/")
|
||||||
|
views = str(item.get("likeInfo") or item.get("likeNumber") or "").strip()
|
||||||
|
favorites = str(item.get("favoriteInfo") or item.get("favoriteNumber") or "").strip()
|
||||||
|
embed_url = ""
|
||||||
|
embed_raw = item.get("embedUrl")
|
||||||
|
if isinstance(embed_raw, str) and embed_raw.strip():
|
||||||
|
unescaped = html.unescape(embed_raw.replace("\\/", "/"))
|
||||||
|
match = _EMBED_SRC_RE.search(unescaped)
|
||||||
|
if match:
|
||||||
|
embed_url = match.group(1).strip()
|
||||||
|
|
||||||
|
feed.append(
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"url": hls_url or mp4_url,
|
||||||
|
"sample": thumb or hls_url or mp4_url,
|
||||||
|
"ext": "mp4",
|
||||||
|
"score": 0,
|
||||||
|
"fav_count": favorites or "0",
|
||||||
|
"title": title,
|
||||||
|
"duration": duration_raw,
|
||||||
|
"views": views,
|
||||||
|
"source": source or _absolute_url("/shorties"),
|
||||||
|
"hls_url": hls_url,
|
||||||
|
"mp4_url": mp4_url,
|
||||||
|
"embed_url": embed_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return feed
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_meta_fields(page_html: str) -> tuple[str, str]:
|
||||||
|
duration_match = _DURATION_RE.search(page_html)
|
||||||
|
views_match = _VIEWS_RE.search(page_html)
|
||||||
|
duration = duration_match.group("duration") if duration_match else ""
|
||||||
|
views = views_match.group("views") if views_match else ""
|
||||||
|
return duration, views
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_video_candidate(
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
item: ShortiesCandidate,
|
||||||
|
semaphore: asyncio.Semaphore,
|
||||||
|
) -> dict | None:
|
||||||
|
async with semaphore:
|
||||||
|
page_html = await _fetch_text(session, item.page_url)
|
||||||
|
if not page_html:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mp4_url = _extract_best_mp4(page_html)
|
||||||
|
if not mp4_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
duration, views = _extract_meta_fields(page_html)
|
||||||
|
if not item.duration:
|
||||||
|
item.duration = duration
|
||||||
|
if not item.views:
|
||||||
|
item.views = views
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "video",
|
||||||
|
"url": mp4_url,
|
||||||
|
"sample": item.thumb_url or mp4_url,
|
||||||
|
"ext": "mp4",
|
||||||
|
"score": 0,
|
||||||
|
"fav_count": 0,
|
||||||
|
"title": item.title,
|
||||||
|
"duration": item.duration or "",
|
||||||
|
"views": item.views or "",
|
||||||
|
"source": item.page_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_shorties_feed(count: int = 8, random_pages: int = 3) -> list[dict]:
|
||||||
|
count = max(1, min(count, 20))
|
||||||
|
random_pages = max(1, min(random_pages, 2))
|
||||||
|
|
||||||
|
page_numbers = {1}
|
||||||
|
while len(page_numbers) < random_pages:
|
||||||
|
page_numbers.add(random.randint(1, 40))
|
||||||
|
list_urls = [f"{SHORTIES_LIST_URL}?page={page}" for page in sorted(page_numbers)]
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=20)
|
||||||
|
connector = aiohttp.TCPConnector(limit=16)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session:
|
||||||
|
list_pages = await asyncio.gather(*[_fetch_text(session, url) for url in list_urls], return_exceptions=True)
|
||||||
|
|
||||||
|
json_shorties_feed: list[dict] = []
|
||||||
|
for page in list_pages:
|
||||||
|
if isinstance(page, Exception) or not page:
|
||||||
|
continue
|
||||||
|
shorties_items = _extract_json_shorties(page)
|
||||||
|
if not shorties_items:
|
||||||
|
continue
|
||||||
|
json_shorties_feed.extend(_feed_from_json_shorties(shorties_items))
|
||||||
|
|
||||||
|
if json_shorties_feed:
|
||||||
|
russian_only = [item for item in json_shorties_feed if _is_russian_feed_item(item)]
|
||||||
|
# Строго пытаемся отдать russian first; если пусто — отдаём локализованную rt-ленту.
|
||||||
|
selected_feed = russian_only if russian_only else json_shorties_feed
|
||||||
|
random.shuffle(selected_feed)
|
||||||
|
# dedupe by source/url after enrichment
|
||||||
|
dedup: list[dict] = []
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
for item in selected_feed:
|
||||||
|
key = str(item.get("source") or item.get("url") or "").strip()
|
||||||
|
if not key or key in seen_keys:
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
dedup.append(item)
|
||||||
|
return dedup[:count]
|
||||||
|
|
||||||
|
candidates: list[ShortiesCandidate] = []
|
||||||
|
for page in list_pages:
|
||||||
|
if isinstance(page, Exception) or not page:
|
||||||
|
continue
|
||||||
|
candidates.extend(_extract_ldjson_candidates(page))
|
||||||
|
candidates.extend(_extract_anchor_candidates(page))
|
||||||
|
|
||||||
|
candidates = _dedupe_candidates(candidates)
|
||||||
|
if not candidates:
|
||||||
|
logger.warning("Shorties parser: no candidates found")
|
||||||
|
return []
|
||||||
|
|
||||||
|
random.shuffle(candidates)
|
||||||
|
candidates = candidates[: max(count * 3, 12)]
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(5)
|
||||||
|
resolved = await asyncio.gather(
|
||||||
|
*[_resolve_video_candidate(session, item, semaphore) for item in candidates],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
feed: list[dict] = []
|
||||||
|
for item in resolved:
|
||||||
|
if isinstance(item, dict) and item.get("url"):
|
||||||
|
feed.append(item)
|
||||||
|
|
||||||
|
russian_only_fallback = [item for item in feed if _is_russian_feed_item(item)]
|
||||||
|
if russian_only_fallback:
|
||||||
|
feed = russian_only_fallback
|
||||||
|
random.shuffle(feed)
|
||||||
|
return feed[:count]
|
||||||
Loading…
Reference in a new issue