Merge pull request 'СЕКСАПДЕЙТ' (#31) from SUDOZOVCHIK/bot_tg:main into main

Reviewed-on: #31
This commit is contained in:
q 2026-04-23 11:50:22 +00:00
commit 5847e0e06a
12 changed files with 1042 additions and 11 deletions

2
.env
View file

@ -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

View 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

View 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())

View 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"

View 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

View file

@ -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")

View file

@ -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;

View file

@ -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">

View file

@ -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 } }),
};

View file

@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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');

View file

@ -1,2 +1,3 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
aiohttp>=3.9.0

479
webapp/shorties.py Normal file
View 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]