Date: Thu, 23 Apr 2026 14:47:52 +0300
Subject: [PATCH 04/11] =?UTF-8?q?=D0=A1=D0=95=D0=9A=D0=A1=D0=90=D0=9F?=
=?UTF-8?q?=D0=94=D0=95=D0=99=D0=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env | 2 +
docker-compose.webapp-test.yml | 38 ++
scripts/set_telegram_webapp_button.py | 72 ++++
scripts/start_webapp_ngrok_test.sh | 97 ++++++
scripts/stop_webapp_ngrok_test.sh | 9 +
webapp/api.py | 91 ++++-
webapp/frontend/css/style.css | 65 +++-
webapp/frontend/index.html | 1 +
webapp/frontend/js/api.js | 2 +
webapp/frontend/js/app.js | 196 ++++++++++-
webapp/requirements-api.txt | 1 +
webapp/shorties.py | 479 ++++++++++++++++++++++++++
12 files changed, 1042 insertions(+), 11 deletions(-)
create mode 100644 docker-compose.webapp-test.yml
create mode 100755 scripts/set_telegram_webapp_button.py
create mode 100755 scripts/start_webapp_ngrok_test.sh
create mode 100755 scripts/stop_webapp_ngrok_test.sh
create mode 100644 webapp/shorties.py
diff --git a/.env b/.env
index fe834d2..b7916fa 100644
--- a/.env
+++ b/.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
diff --git a/docker-compose.webapp-test.yml b/docker-compose.webapp-test.yml
new file mode 100644
index 0000000..43ea2ee
--- /dev/null
+++ b/docker-compose.webapp-test.yml
@@ -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
diff --git a/scripts/set_telegram_webapp_button.py b/scripts/set_telegram_webapp_button.py
new file mode 100755
index 0000000..a310a81
--- /dev/null
+++ b/scripts/set_telegram_webapp_button.py
@@ -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())
diff --git a/scripts/start_webapp_ngrok_test.sh b/scripts/start_webapp_ngrok_test.sh
new file mode 100755
index 0000000..01d432b
--- /dev/null
+++ b/scripts/start_webapp_ngrok_test.sh
@@ -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"
diff --git a/scripts/stop_webapp_ngrok_test.sh b/scripts/stop_webapp_ngrok_test.sh
new file mode 100755
index 0000000..3d3a908
--- /dev/null
+++ b/scripts/stop_webapp_ngrok_test.sh
@@ -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
diff --git a/webapp/api.py b/webapp/api.py
index 83ca6b2..60362ad 100644
--- a/webapp/api.py
+++ b/webapp/api.py
@@ -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")
diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css
index 47d4970..b78ee7e 100644
--- a/webapp/frontend/css/style.css
+++ b/webapp/frontend/css/style.css
@@ -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;
diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html
index 02a7753..31497d3 100644
--- a/webapp/frontend/index.html
+++ b/webapp/frontend/index.html
@@ -10,6 +10,7 @@
+
diff --git a/webapp/frontend/js/api.js b/webapp/frontend/js/api.js
index 2d5522f..330203a 100644
--- a/webapp/frontend/js/api.js
+++ b/webapp/frontend/js/api.js
@@ -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 } }),
};
diff --git a/webapp/frontend/js/app.js b/webapp/frontend/js/app.js
index 7bc14be..55c7045 100644
--- a/webapp/frontend/js/app.js
+++ b/webapp/frontend/js/app.js
@@ -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 = `