Compare commits
No commits in common. "5847e0e06ad56a7f954fd4eb9556059604267074" and "41b5c001dc973d914d7f86c217d6b8c121c834c6" have entirely different histories.
5847e0e06a
...
41b5c001dc
12 changed files with 11 additions and 1042 deletions
2
.env
2
.env
|
|
@ -5,5 +5,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
#!/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())
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
#!/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,7 +10,6 @@ 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
|
||||||
|
|
@ -18,7 +17,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, StreamingResponse
|
from fastapi.responses import Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -37,7 +36,6 @@ 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__)
|
||||||
|
|
@ -407,23 +405,6 @@ 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."""
|
||||||
|
|
@ -452,76 +433,6 @@ 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,8 +912,7 @@ 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;
|
||||||
|
|
@ -921,7 +920,6 @@ body::before {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
border: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.furtok-overlay {
|
.furtok-overlay {
|
||||||
|
|
@ -938,36 +936,6 @@ 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;
|
||||||
|
|
@ -1021,37 +989,6 @@ 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,7 +10,6 @@
|
||||||
<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,7 +42,5 @@ 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,12 +33,6 @@ 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();
|
||||||
// Убираем оверлеи при смене страницы
|
// Убираем оверлеи при смене страницы
|
||||||
|
|
@ -72,82 +66,6 @@ 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 = $(`
|
||||||
|
|
@ -589,7 +507,6 @@ 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 = [
|
||||||
|
|
@ -614,7 +531,6 @@ 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-переключателем
|
||||||
|
|
@ -630,29 +546,6 @@ 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();
|
||||||
|
|
@ -666,12 +559,8 @@ 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" id="furtok-title">${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}</div>
|
<div class="furtok-title">🐺 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' : ''}>
|
||||||
|
|
@ -699,23 +588,6 @@ 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', () => {
|
||||||
|
|
@ -736,7 +608,7 @@ function renderFurtok() {
|
||||||
haptic();
|
haptic();
|
||||||
furtokCustomTags = tagsInput.value.trim();
|
furtokCustomTags = tagsInput.value.trim();
|
||||||
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
|
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
|
||||||
_updateFurtokUiMode();
|
safeWrap.style.display = furtokCustomTags ? 'none' : '';
|
||||||
tagsPanel.style.display = 'none';
|
tagsPanel.style.display = 'none';
|
||||||
_furtokReload(feed);
|
_furtokReload(feed);
|
||||||
});
|
});
|
||||||
|
|
@ -855,9 +727,7 @@ async function loadFurtokPage(feedEl) {
|
||||||
feedEl.appendChild(loader);
|
feedEl.appendChild(loader);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = furtokMode === 'shorties'
|
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
|
||||||
? 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) {
|
||||||
|
|
@ -871,42 +741,23 @@ 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" alt="">`;
|
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
||||||
} else {
|
} else {
|
||||||
const poster = post.sample ? ` poster="${post.sample}"` : '';
|
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
|
||||||
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 title = post.title ? escapeHtml(post.title) : '';
|
const caption = randomCaption();
|
||||||
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>${scoreLabel}</span>
|
<span>⭐ ${post.score}</span>
|
||||||
<span>${favLabel}</span>
|
<span>❤️ ${post.fav_count}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -914,15 +765,6 @@ 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();
|
||||||
|
|
@ -930,23 +772,6 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
@ -954,10 +779,7 @@ 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) {
|
if (firstVideo) firstVideo.play().catch(() => {});
|
||||||
firstVideo.muted = true;
|
|
||||||
firstVideo.play().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
haptic('success');
|
haptic('success');
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
fastapi>=0.111.0
|
fastapi>=0.111.0
|
||||||
uvicorn[standard]>=0.29.0
|
uvicorn[standard]>=0.29.0
|
||||||
aiohttp>=3.9.0
|
|
||||||
|
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
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