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_LOG_THINKING=1
|
||||
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 hashlib
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import urlparse
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
|
@ -17,7 +18,7 @@ from typing import Annotated
|
|||
import aiohttp
|
||||
from fastapi import FastAPI, Depends, Header, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ from games.betting import (
|
|||
)
|
||||
from webapp.auth import validate_init_data
|
||||
from games.uwu import fetch_uwu_post, fetch_furtok_feed
|
||||
from webapp.shorties import fetch_shorties_feed
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -405,6 +407,23 @@ async def get_furtok_feed_api(
|
|||
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")
|
||||
async def proxy_image(url: str):
|
||||
"""Проксирует картинку с e621 чтобы обойти hotlink protection."""
|
||||
|
|
@ -433,6 +452,76 @@ async def proxy_image(url: str):
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -912,7 +912,8 @@ body::before {
|
|||
}
|
||||
|
||||
.furtok-card video,
|
||||
.furtok-card img {
|
||||
.furtok-card img,
|
||||
.furtok-card iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
|
@ -920,6 +921,7 @@ body::before {
|
|||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.furtok-overlay {
|
||||
|
|
@ -936,6 +938,36 @@ body::before {
|
|||
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 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
|
|
@ -989,6 +1021,37 @@ body::before {
|
|||
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 {
|
||||
background: rgba(255,255,255,0.15);
|
||||
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 rel="stylesheet" href="css/style.css">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
|
|||
|
|
@ -42,5 +42,7 @@ export const API = {
|
|||
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||
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) : ''}`),
|
||||
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 } }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ const navBtns = document.querySelectorAll('.nav-btn');
|
|||
|
||||
// ── Router ──
|
||||
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;
|
||||
haptic();
|
||||
// Убираем оверлеи при смене страницы
|
||||
|
|
@ -66,6 +72,82 @@ function $(html) {
|
|||
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') {
|
||||
return new Promise(resolve => {
|
||||
const overlay = $(`
|
||||
|
|
@ -507,6 +589,7 @@ let furtokWrapper = null;
|
|||
let furtokCurrentIndex = 0;
|
||||
let furtokCards = [];
|
||||
let furtokCustomTags = '';
|
||||
let furtokMode = 'furtok'; // "furtok" | "shorties"
|
||||
|
||||
// Подписи из uwu.py — рандомно появляются на карточках
|
||||
const FURTOK_CAPTIONS = [
|
||||
|
|
@ -531,6 +614,7 @@ function randomCaption() {
|
|||
}
|
||||
|
||||
function _furtokGetTags() {
|
||||
if (furtokMode === 'shorties') return '';
|
||||
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
|
||||
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
|
||||
// Иначе стандартный запрос с safe-переключателем
|
||||
|
|
@ -546,6 +630,29 @@ function _furtokReload(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() {
|
||||
container.innerHTML = '';
|
||||
document.querySelector('.furtok-wrapper')?.remove();
|
||||
|
|
@ -559,8 +666,12 @@ function renderFurtok() {
|
|||
furtokWrapper.className = 'furtok-wrapper';
|
||||
furtokWrapper.innerHTML = `
|
||||
<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-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"' : ''}>
|
||||
<span>Safe</span>
|
||||
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
||||
|
|
@ -588,6 +699,23 @@ function renderFurtok() {
|
|||
const tagsPanel = document.getElementById('furtok-tags-panel');
|
||||
const tagsInput = document.getElementById('furtok-tags-input');
|
||||
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
|
||||
safeToggle.addEventListener('change', () => {
|
||||
|
|
@ -608,7 +736,7 @@ function renderFurtok() {
|
|||
haptic();
|
||||
furtokCustomTags = tagsInput.value.trim();
|
||||
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
|
||||
safeWrap.style.display = furtokCustomTags ? 'none' : '';
|
||||
_updateFurtokUiMode();
|
||||
tagsPanel.style.display = 'none';
|
||||
_furtokReload(feed);
|
||||
});
|
||||
|
|
@ -727,7 +855,9 @@ async function loadFurtokPage(feedEl) {
|
|||
feedEl.appendChild(loader);
|
||||
|
||||
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();
|
||||
|
||||
if (!res.feed || res.feed.length === 0) {
|
||||
|
|
@ -741,23 +871,42 @@ async function loadFurtokPage(feedEl) {
|
|||
res.feed.forEach(post => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'furtok-card';
|
||||
const isShorties = furtokMode === 'shorties';
|
||||
const shareUrl = getShortiesShareUrl(post);
|
||||
|
||||
let mediaHtml = '';
|
||||
if (post.type === 'image' || post.ext === 'gif') {
|
||||
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
||||
mediaHtml = `<img src="${post.url}" loading="lazy" alt="">`;
|
||||
} 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 = `
|
||||
${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-caption">${caption}</div>
|
||||
<div class="furtok-stats">
|
||||
<span>⭐ ${post.score}</span>
|
||||
<span>❤️ ${post.fav_count}</span>
|
||||
<span>${scoreLabel}</span>
|
||||
<span>${favLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -765,6 +914,15 @@ async function loadFurtokPage(feedEl) {
|
|||
// Тап по видео = unmute + play/pause
|
||||
const video = card.querySelector('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', () => {
|
||||
video.muted = false;
|
||||
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);
|
||||
furtokCards.push(card);
|
||||
});
|
||||
|
|
@ -779,7 +954,10 @@ async function loadFurtokPage(feedEl) {
|
|||
// Автоплей первого видео при первой загрузке
|
||||
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
|
||||
const firstVideo = furtokCards[0].querySelector('video');
|
||||
if (firstVideo) firstVideo.play().catch(() => {});
|
||||
if (firstVideo) {
|
||||
firstVideo.muted = true;
|
||||
firstVideo.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
haptic('success');
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
fastapi>=0.111.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