From 628099b4430768ad10725144fd880123901caea7 Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:37:16 +0300 Subject: [PATCH 01/11] delete govnshe ebanoe --- games/uwu.py | 87 ---------------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 games/uwu.py diff --git a/games/uwu.py b/games/uwu.py deleted file mode 100644 index 634e4f1..0000000 --- a/games/uwu.py +++ /dev/null @@ -1,87 +0,0 @@ -import aiohttp -from aiogram import types -import config -import logging -import random - -logger = logging.getLogger(__name__) - -CAPTIONS = [ - "Ня :3", - "uwu" -] - -async def handle_uwu_cmd(message: types.Message): - args = message.text.split(maxsplit=1) - - if len(args) > 1 and args[1].strip() == "-h": - help_msg = ( - "🐈 Справка по тегам e621:\n\n" - "Перечислите любые теги через пробел после команды.\n" - "• rating: rating:safe (безопасные), rating:questionable, rating:explicit (NSFW)\n" - "• score: score:>500 (искать популярные посты с рейтингом больше 500)\n" - "• Исключить тег: поставьте минус, например -fox\n\n" - "Пример: /uwu wolf -rating:explicit score:>100\n" - "По умолчанию: rating:safe score:>500 -animated" - ) - await message.reply(help_msg, parse_mode="HTML") - return - - tags = "rating:safe score:>500 -animated" - if len(args) > 1: - tags = args[1].strip() - - tags += " order:random" - - url = "https://e621.net/posts.json" - params = { - "tags": tags, - "limit": 1 - } - - auth = None - if config.E621_LOGIN and config.E621_API_KEY: - auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY) - - headers = { - "User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)" - } - - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, params=params, headers=headers, auth=auth) as resp: - if resp.status != 200: - logger.error(f"e621 API Error: {resp.status}") - await message.reply("Не удалось получить картинку от сервера :(") - return - data = await resp.json() - - posts = data.get("posts", []) - if not posts: - await message.reply("Ничего не найдено по этим тегам :(") - return - - post = posts[0] - file_url = post.get("file", {}).get("url") - ext = post.get("file", {}).get("ext", "png") - if not file_url: - await message.reply("У найденного поста нет прямого URL изображения :(") - return - - caption = random.choice(CAPTIONS) - if ext in ("gif", "webm", "mp4"): - # Для анимаций: отправляем caption отдельным сообщением, - # а саму гифку/видео через URL напрямую — так Telegram - # корректно показывает её как анимацию, а не как файл - await message.answer(caption) - await message.answer_animation(animation=file_url) - elif ext in ("png", "jpg", "jpeg", "webp"): - input_file = types.URLInputFile(file_url) - await message.answer_photo(photo=input_file, caption=caption) - else: - input_file = types.URLInputFile(file_url) - await message.answer_document(document=input_file, caption=caption) - - except Exception as e: - logger.exception("Error in uwu command") - await message.reply("Произошла ошибка при обращении к API :(") From 2740e848e4759737c842e35477ef7d4cb74f428b Mon Sep 17 00:00:00 2001 From: Hitoshi-Hub Date: Thu, 23 Apr 2026 13:01:38 +0300 Subject: [PATCH 02/11] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?= =?UTF-8?q?=D1=82=D0=B0=D0=BA=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BE=D1=82=D0=B2=D0=B5=D1=87=D0=B0=D0=BB=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE=20=D0=B1=D0=B5=D0=B7=20/watch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex | 0 AI/talk_handler.py | 7 +++++-- games/nude.py | 1 + main.py | 13 ++++++++++++- 4 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/AI/talk_handler.py b/AI/talk_handler.py index 2e99a10..bd98c3f 100644 --- a/AI/talk_handler.py +++ b/AI/talk_handler.py @@ -10,6 +10,7 @@ import re import sqlite3 import time from io import BytesIO +import re from pathlib import Path from typing import Any @@ -237,6 +238,7 @@ AUTONOMOUS_SIGNAL_RE = re.compile( re.IGNORECASE, ) LETTER_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9]") +WATCH_COMMAND_RE = re.compile(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL) _reply_lock = asyncio.Lock() _summary_lock = asyncio.Lock() @@ -903,12 +905,13 @@ async def handle_photo_message(message: Message) -> bool: is_image_document = bool(message.document and (message.document.mime_type or "").startswith("image/")) if not (message.photo or is_image_document) or not message.from_user or message.from_user.is_bot: return False - if message.caption and message.caption.startswith("/"): + watch_match = WATCH_COMMAND_RE.match((message.caption or "").strip()) + if not watch_match: return False chat_id = message.chat.id user_name = message.from_user.first_name or "кент" - caption = _clean_text(message.caption or "") + caption = _clean_text(watch_match.group(1) or "") logger.warning( "Received image message for analysis. chat_id=%s user=%s has_photo=%s has_image_document=%s caption=%s", diff --git a/games/nude.py b/games/nude.py index b1c024d..fec2b61 100644 --- a/games/nude.py +++ b/games/nude.py @@ -18,6 +18,7 @@ CAPTIONS = [ "Шо ты блять псюнчик решил свой подергать?", "Решил подрочить сука?", "гнойный писюн у тебя кнч, да", + "Ух нихуя, это ШПАЧИХА!?", ] LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts" diff --git a/main.py b/main.py index 02b60ef..633e026 100644 --- a/main.py +++ b/main.py @@ -58,6 +58,12 @@ def _is_supported_image_message(message: Message) -> bool: ) ) +def _is_watch_image_message(message: Message) -> bool: + if not _is_supported_image_message(message): + return False + caption = (message.caption or "").strip() + return bool(re.match(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s|$)", caption, flags=re.IGNORECASE)) + async def handle_unmatched_message_debug(message: Message): logger.warning( @@ -1199,6 +1205,10 @@ async def handle_keywords(message: Message): chat_id = message.chat.id user_name = (message.from_user.first_name or "кент") if message.from_user else "кент" + # /ebalnik должен глушить все авто-реакции в чате, включая медиа/фото-ветки. + if chat_id in _autoreply_disabled_chats: + return + if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS): audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3" if audio_path.exists(): @@ -1456,7 +1466,7 @@ def main(): dp.message.register(handle_gen_mem, Command("gen_mem")) dp.message.register(handle_uwu_cmd, Command("uwu")) dp.message.register(handle_nude_cmd, Command("nude")) - dp.message.register(handle_photo_message, _is_supported_image_message) + dp.message.register(handle_photo_message, _is_watch_image_message) dp.message.register(handle_keywords, F.text) dp.message.register(handle_unmatched_message_debug) @@ -1477,6 +1487,7 @@ def main(): BotCommand(command="fetch", description="alias для /prices"), BotCommand(command="zvetok", description="Цветянский бля"), BotCommand(command="talk", description="Побазарить"), + BotCommand(command="watch", description="Коммент фото по подписи /watch"), BotCommand(command="ebalnik", description="вкл/выкл автответов"), BotCommand(command="penis_casino", description="казино на размер"), BotCommand(command="gadanie", description="гадание на фене"), From 3cee61ab2c9d2f8947acc055b38fdf291e0941fe Mon Sep 17 00:00:00 2001 From: Hitoshi-Hub Date: Thu, 23 Apr 2026 12:50:26 +0300 Subject: [PATCH 03/11] =?UTF-8?q?=D1=85=D1=83=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index 5ad177a..fe834d2 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc +BOT_TOKEN=8505447901:AAHFkaDG0fT0tAixuJ4QP91IwMgO6cZndRM #8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc TZ=Europe/Moscow ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/ From 1979ca3193bcc6b86a084af03c63e6fe676d2b17 Mon Sep 17 00:00:00 2001 From: Hitoshi-Hub 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 = `
-
🐺 FurTok
+
${furtokMode === 'shorties' ? '🔥 Shorties' : '🐺 FurTok'}
+
+ + +