Compare commits

..

No commits in common. "df6b6c7f3f33c3d3af7481b3e7886c0a6ebb7424" and "ffb4ecda1825cd5094c82506a1d93316fe7735f6" have entirely different histories.

24 changed files with 44 additions and 1153 deletions

View file

@ -1,46 +0,0 @@
"""Odpowiedzi na jednorazowa ankiete in-app (skad uzytkownicy, czego uzywaja, za co zaplaca).
Jedna odpowiedz na urzadzenie i ankiete, tresc odpowiedzi w JSONB (pytania zyja w apce,
backend pilnuje tylko dozwolonych kluczy). Patrz app/api/survey.py.
Revision ID: 0055_survey_responses
Revises: 0054_hidden_sites
Create Date: 2026-10-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision = "0055_survey_responses"
down_revision: str | None = "0054_hidden_sites"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"survey_responses",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("survey_id", sa.String(32), nullable=False),
sa.Column("device_id", sa.String(64), nullable=False),
sa.Column("answers", JSONB, nullable=False),
sa.Column("free_text", sa.Text, nullable=True),
sa.Column("app_version", sa.String(32), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
),
sa.UniqueConstraint("survey_id", "device_id", name="uq_survey_responses_survey_device"),
)
op.create_index(
"ix_survey_responses_survey_created", "survey_responses", ["survey_id", "created_at"]
)
def downgrade() -> None:
op.drop_index("ix_survey_responses_survey_created", table_name="survey_responses")
op.drop_table("survey_responses")

View file

@ -1,205 +0,0 @@
"""Jednorazowa ankieta in-app: skad uzytkownicy, czego uzywaja, za co by zaplacili.
Pytania i etykiety sa w apce (mobile/src/components/SurveyModal.tsx, ten sam SURVEY_ID),
backend trzyma tylko liste dozwolonych kluczy i limity rozmiaru, zeby nie przyjmowac smieci.
- Jedna odpowiedz na urzadzenie (UNIQUE survey_id + device_id), ponowne wyslanie nadpisuje.
- Bramka: pytamy tylko urzadzenia, ktore apki realnie uzywaja, czyli maja co najmniej
SURVEY_MIN_SCENES wpisow w scene_play_progress. Klucz glowny tej tabeli to
(device_id, scene_id), wiec liczenie z LIMIT to krotki skan indeksu. GET /survey/current
wola kazdy launch, dopoki apka nie zapisze lokalnie "odpowiedziane/pominiete".
- Podsumowanie (GET /survey/{id}/summary) liczy w Pythonie po wszystkich wierszach ankiety;
przy tysiacach odpowiedzi to ulamek sekundy, bez osobnych agregatow w SQL.
"""
from __future__ import annotations
import uuid
from collections import Counter, defaultdict
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app.api.device import LEGACY_DEVICE, get_device_id
from app.auth import require_api_key
from app.db import get_session
from app.models.play_progress import ScenePlayProgress
from app.models.survey_response import SurveyResponse
router = APIRouter(prefix="/survey", tags=["survey"], dependencies=[Depends(require_api_key)])
CURRENT_SURVEY_ID = "2026-10"
SURVEY_MIN_SCENES = 10
# Klucze pytan dozwolone w `answers` (wartosci: string albo lista stringow).
# `*_text` to wolne dopowiedzenie do opcji "inne" w pytaniach jednokrotnego wyboru.
_ALLOWED_KEYS: frozenset[str] = frozenset(
{
"source",
"source_text",
"use",
"pain",
"pain_text",
"pay_for",
"pay_how",
"price",
}
)
_MAX_VALUE_LEN = 200
_MAX_LIST_ITEMS = 12
class SurveyStatusOut(BaseModel):
id: str
eligible: bool
answered: bool
class SurveySubmitIn(BaseModel):
answers: dict[str, str | list[str]]
free_text: str | None = Field(default=None, max_length=2000)
app_version: str | None = Field(default=None, max_length=32)
class SurveySummaryOut(BaseModel):
survey_id: str
total: int
first_at: datetime | None
last_at: datetime | None
counts: dict[str, dict[str, int]]
by_app_version: dict[str, int]
texts: dict[str, list[str]]
def _played_scenes(session: Session, device_id: str, cap: int) -> int:
sub = (
select(ScenePlayProgress.scene_id)
.where(ScenePlayProgress.device_id == device_id)
.limit(cap)
.subquery()
)
return int(session.scalar(select(func.count()).select_from(sub)) or 0)
def _answered(session: Session, survey_id: str, device_id: str) -> bool:
return (
session.scalar(
select(SurveyResponse.id).where(
SurveyResponse.survey_id == survey_id,
SurveyResponse.device_id == device_id,
)
)
is not None
)
@router.get("/current", response_model=SurveyStatusOut)
def current_survey(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> SurveyStatusOut:
if device_id == LEGACY_DEVICE:
return SurveyStatusOut(id=CURRENT_SURVEY_ID, eligible=False, answered=False)
answered = _answered(session, CURRENT_SURVEY_ID, device_id)
eligible = answered or _played_scenes(session, device_id, SURVEY_MIN_SCENES) >= SURVEY_MIN_SCENES
return SurveyStatusOut(id=CURRENT_SURVEY_ID, eligible=eligible, answered=answered)
def _clean_answers(raw: dict[str, str | list[str]]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in raw.items():
if key not in _ALLOWED_KEYS:
raise HTTPException(status_code=422, detail=f"unknown question: {key}")
if isinstance(value, str):
v = value.strip()
if len(v) > _MAX_VALUE_LEN:
raise HTTPException(status_code=422, detail=f"value too long: {key}")
if v:
out[key] = v
else:
items = [s.strip() for s in value if isinstance(s, str) and s.strip()]
if len(items) > _MAX_LIST_ITEMS or any(len(s) > _MAX_VALUE_LEN for s in items):
raise HTTPException(status_code=422, detail=f"list too long: {key}")
if items:
out[key] = items
if not out:
raise HTTPException(status_code=422, detail="empty answers")
return out
@router.post("/{survey_id}", status_code=status.HTTP_201_CREATED)
def submit_survey(
survey_id: str,
payload: SurveySubmitIn,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> dict[str, str]:
if survey_id != CURRENT_SURVEY_ID:
raise HTTPException(status_code=404, detail="no such survey")
if device_id == LEGACY_DEVICE:
raise HTTPException(status_code=400, detail="device id required")
answers = _clean_answers(payload.answers)
free_text = (payload.free_text or "").strip() or None
stmt = pg_insert(SurveyResponse).values(
id=uuid.uuid4(),
survey_id=survey_id,
device_id=device_id,
answers=answers,
free_text=free_text,
app_version=payload.app_version,
)
stmt = stmt.on_conflict_do_update(
constraint="uq_survey_responses_survey_device",
set_={
"answers": stmt.excluded.answers,
"free_text": stmt.excluded.free_text,
"app_version": stmt.excluded.app_version,
"updated_at": func.now(),
},
)
session.execute(stmt)
session.commit()
return {"status": "ok"}
@router.get("/{survey_id}/summary", response_model=SurveySummaryOut)
def survey_summary(
survey_id: str,
session: Annotated[Session, Depends(get_session)],
) -> SurveySummaryOut:
"""Podsumowanie dla admina: liczniki per pytanie i opcja, wersje apki, wolne teksty."""
rows = session.scalars(
select(SurveyResponse)
.where(SurveyResponse.survey_id == survey_id)
.order_by(SurveyResponse.created_at)
).all()
counts: dict[str, Counter[str]] = defaultdict(Counter)
versions: Counter[str] = Counter()
texts: dict[str, list[str]] = defaultdict(list)
for r in rows:
versions[r.app_version or "?"] += 1
if r.free_text:
texts["free_text"].append(r.free_text)
for key, value in (r.answers or {}).items():
if key.endswith("_text"):
if isinstance(value, str):
texts[key].append(value)
continue
if isinstance(value, list):
for v in value:
counts[key][str(v)] += 1
else:
counts[key][str(value)] += 1
return SurveySummaryOut(
survey_id=survey_id,
total=len(rows),
first_at=rows[0].created_at if rows else None,
last_at=rows[-1].created_at if rows else None,
counts={k: dict(c.most_common()) for k, c in counts.items()},
by_app_version=dict(versions.most_common()),
texts={k: v[-300:] for k, v in texts.items()},
)

View file

@ -40,8 +40,7 @@ _BASE = "https://hentaimama.io"
_EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"')
# Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej — bez tego
# filtra wchodziły jako „odcinek" o tytule „Recent Episodes".
# `/episodes/feed/` (RSS) tez wpada w href-regex i konczyl jako pusty detail z None.
_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/|feed/)?$")
_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/)?$")
# `og:title` zamiast <h1>: na stronie są trzy <h1>, dwa to logo serwisu, a trzeci bywa
# nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7".
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})")

View file

@ -44,12 +44,6 @@ _BASE = "https://pornbusy.com"
_PAGE_SIZE = 20
_SITEMAP_INDEX = f"{_BASE}/sitemap_index.xml"
# Karta posta na stronie glownej: `<article class="... post-750 post type-post ..."
# data-post-id="750"> <a href="https://pornbusy.com/<slug>/" title=...>`.
_HOME_CARD_RE = re.compile(
r'<article[^>]*\bpost-\d+\b[^>]*>\s*<a\s+href="(https://pornbusy\.com/[a-z0-9-]+/)"',
re.IGNORECASE,
)
_LOC_RE = re.compile(r"<loc>\s*([^<]+?)\s*</loc>")
_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.DOTALL | re.IGNORECASE)
_LASTMOD_RE = re.compile(r"<lastmod>\s*([^<]+?)\s*</lastmod>")
@ -145,21 +139,8 @@ class PornBusyScraper(BaseBrowseScraper):
if not entries:
return None
entries.sort(key=lambda e: e[0], reverse=True)
# 2026-10-13: sitemapa WordPressa aktualizuje sie z opoznieniem. Najnowszy lastmod
# w indeksie mial 9.10, a strona glowna 14 postow z 13.10, ktorych nie bylo w zadnej
# podmapie, wiec browse przez kilka dni widzial "nic nowego". Karty ze strony
# glownej (jedyna dzialajaca strona listingu, paginacja jest zepsuta) ida na
# poczatek katalogu, sitemapa dalej daje glebie.
fresh: list[str] = []
try:
home = browser_get(f"{_BASE}/", timeout=self._timeout)
home.raise_for_status()
fresh = list(dict.fromkeys(_HOME_CARD_RE.findall(home.text)))
log.info("pornbusy: %d kart na stronie glownej", len(fresh))
except Exception as e:
log.warning("pornbusy: homepage fetch failed: %s", e)
seen: set[str] = set(fresh)
catalog: list[str] = list(fresh)
seen: set[str] = set()
catalog: list[str] = []
for _, url in entries:
if url in seen:
continue

View file

@ -35,7 +35,6 @@ import re
from datetime import date, datetime
from app.config import get_settings
from app.extractors import browser_get
from app.connectors.base import (
RawFingerprint,
RawPerformer,
@ -55,13 +54,6 @@ _BASE = "https://porndoe.com"
# Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
# Listing z sitemapy wideo (patrz crawl_page): blok <url>, jego <loc> i data publikacji.
_SITEMAP_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.DOTALL | re.IGNORECASE)
_SITEMAP_LOC_RE = re.compile(r"<loc>\s*([^<]+?)\s*</loc>", re.IGNORECASE)
_SITEMAP_PUBDATE_RE = re.compile(
r"<video:publication_date>\s*([^<]+?)\s*</video:publication_date>", re.IGNORECASE
)
_SITEMAP_PAGE_SIZE = 31
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
# JSON-LD <script> bloki.
@ -163,77 +155,13 @@ class PornDoeScraper(BaseBrowseScraper):
self._proxy = get_settings().brightdata_proxy_url
if not self._proxy:
log.warning("porndoe: BRIGHTDATA_PROXY_URL nieustawione, scraper wylaczony")
self._catalog: list[str] | None = None
# 2026-10-13: porndoe to aplikacja Angular. HTML `/videos/most-recent` (i `?page=N`,
# `/videos/newest`, nawet z naglowkiem XHR) to statyczna atrapa z tymi samymi 31
# scenami z 11.09, prawdziwa lista laduje sie skryptem. Browse mial przez to new=0
# od 11.09 (watchdog 755 h). Zrodlem listingu jest `sitemap_videos.xml`: plaski urlset
# z 1000 najnowszych /watch/ i `<video:publication_date>`, w probie 13.10 wszystkie
# 1000 nieznane w bazie. Katalog ladowany raz na instancje, stronicowany po
# _SITEMAP_PAGE_SIZE, szczegoly jak dotad z JSON-LD strony sceny (przez proxy).
def _load_catalog(self) -> list[str] | None:
if self._catalog is not None:
return self._catalog
try:
res = browser_get(f"{_BASE}/sitemap_videos.xml", timeout=90, proxy=self._proxy)
res.raise_for_status()
except Exception as e:
log.warning("porndoe: sitemap_videos fetch failed: %s", e)
return None
entries: list[tuple[str, str]] = []
for block in _SITEMAP_URL_BLOCK_RE.findall(res.text):
loc = _SITEMAP_LOC_RE.search(block)
if not loc or "/watch/" not in loc.group(1):
continue
pub = _SITEMAP_PUBDATE_RE.search(block)
entries.append((pub.group(1) if pub else "", loc.group(1).strip()))
if not entries:
log.warning("porndoe: sitemap_videos bez wpisow /watch/")
return None
entries.sort(key=lambda e: e[0], reverse=True)
catalog = list(dict.fromkeys(url for _, url in entries))
log.info("porndoe: katalog z sitemapy, %d scen", len(catalog))
self._catalog = catalog
return catalog
def crawl_page(self, page: int):
# Bez proxy nie ma po co uderzac (gwarantowane 403). Pusta lista = "wyczerpany",
# dzieki czemu deep-crawl nie ponawia w kolko.
if not self._proxy:
return []
catalog = self._load_catalog()
if catalog is None:
return None
chunk = catalog[(page - 1) * _SITEMAP_PAGE_SIZE: page * _SITEMAP_PAGE_SIZE]
if not chunk:
return []
out = []
for scene_url in chunk:
try:
res = browser_get(scene_url, timeout=self._timeout, proxy=self._proxy)
detail_html = res.text
except Exception as e:
log.info("porndoe detail fetch failed %s: %s", scene_url, e)
continue
try:
raw = self._parse_detail(scene_url, detail_html)
except Exception as e:
log.warning("porndoe detail parse failed %s: %s", scene_url, e)
continue
if raw is not None:
out.append(raw)
if not out:
# Wszystkie strony z tej paczki bez VideoObject. Sprawdzone 2026-10-13: dla IP
# proxy (Bright Data ISP, wyjscie w Londynie) kazdy nowy /watch/ przekierowuje
# na /videos, a listing stoi na 11.09, takze na de./fr./br. subdomenach; stare
# sceny (sprzed wrzesnia) otwieraja sie normalnie. Wyglada na ograniczenie
# katalogu dla Wielkiej Brytanii (Online Safety Act), nie na blad parsera.
# None = "przejsciowo nie da sie", zeby deep-crawl nie oznaczyl zrodla jako
# wyczerpanego. Fix wymaga wyjscia proxy spoza UK.
log.warning("porndoe: 0 z %d stron sceny sparsowanych (geo-blokada UK?)", len(chunk))
return None
return out
return super().crawl_page(page)
def _listing_url(self, page: int) -> str:
if page <= 1:

View file

@ -101,14 +101,9 @@ class SuperpornScraper(BaseBrowseScraper):
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
def _listing_url(self, page: int) -> str:
# 2026-10-13: strona glowna `/` to rotujaca karuzela (48 scen, te same na
# `?page=2`, `?sort=latest` itd.), przez co browse mial new=0 od 30.09 mimo
# swiezych publikacji. Prawdziwy feed po dacie to `/videos/latest`, stronicowany
# SCIEZKA `/videos/latest/N` (`?page=N` jest ignorowane i oddaje strone 1).
# Ta sama klasa bledu co freshporno, patrz pamiec "KVS root rotuje".
if page <= 1:
return f"{_BASE}/videos/latest"
return f"{_BASE}/videos/latest/{page}"
return f"{_BASE}/"
return f"{_BASE}/?page={page}"
def crawl_page(self, page: int):
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj

View file

@ -21,10 +21,8 @@ mógł wybrać czyim embedem chce odpalić scenę.
"""
from __future__ import annotations
import json
import logging
import re
import time
from collections.abc import Iterator
from datetime import date, datetime
from typing import Any
@ -61,14 +59,10 @@ USER_AGENT = (
# 2026-10-02 mangoporn przeszedl na `<a class="thumb" href=...>` (klasa PRZED href) i
# wzorzec z href na poczatku nie lapal juz nic: 0 filmow od 2026-09-23. Teraz href moze
# stac w dowolnym miejscu znacznika; linki menu do /movies/category/ odpadaja.
# 2026-10-13 streamporn.li zmienil motyw: karta to `<article class="card"><a class="card__th"
# href=...>` (klasa PRZED href, slug bez /movies/), stary wzorzec dawal 0 linkow od 2.10.
_LIST_ITEM_RE = re.compile(
r'<a\s+href="(?P<url>https?://[^"]+)"[^>]*\bclass="ml-mask\b[^"]*"'
r"|"
r'<a\s+(?:[^>]*?\s)?href="(?P<url2>https?://[^"]+/movies/(?!category/)[a-z0-9-]+/)"'
r"|"
r'<a\s+class="(?:card__th|ml-mask)[^"]*"\s+href="(?P<url3>https?://[^"]+)"',
r'<a\s+(?:[^>]*?\s)?href="(?P<url2>https?://[^"]+/movies/(?!category/)[a-z0-9-]+/)"',
re.IGNORECASE,
)
# Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
@ -161,40 +155,6 @@ _POSTER_OG_RE = re.compile(
)
_DURATION_MINS_RE = re.compile(r"(\d+)\s*min", re.IGNORECASE)
# Motyw streamporn.li od 2026-10 (wtyczka host-links-manager). Tytul siedzi w
# `<h1 class="vid__t"><span>Watch TYTUL ROK by STUDIO Porn Movie Online Free</span></h1>`,
# pasek `st st--duration` ma MM:SS, data w `<time datetime=...>`, gatunki to
# `<a class="chip" href=".../genre/<slug>/">` bez rel="tag", a hostery nie sa linkami
# w HTML, tylko JSON-em w atrybucie `data-servers` sekcji `<section class="hlm">`:
# [{"u": "https://playmogo.com/e/..", "t": "iframe", "l": "DoodStream"}, ...].
_VID_TITLE_RE = re.compile(
r'<h1[^>]*\bclass="vid__t[^"]*"[^>]*>\s*(?:<span[^>]*>)?\s*(.+?)\s*(?:</span>)?\s*</h1>',
re.IGNORECASE | re.DOTALL,
)
_VID_TITLE_PARTS_RE = re.compile(
r"^(?:Watch\s+)?(?P<title>.+?)"
r"(?:\s+(?P<year>(?:19|20)\d{2})\s+by\s+(?P<studio>.+?))?"
r"(?:\s+Porn\s+Movie\s+Online\s+Free)?\s*$",
re.IGNORECASE | re.DOTALL,
)
_ST_DURATION_RE = re.compile(
r'class="st st--duration"[^>]*>[^<]*(?:<svg.*?</svg>)?\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*<',
re.IGNORECASE | re.DOTALL,
)
_ST_DATE_RE = re.compile(
r'class="st st--date"[^>]*>.*?<time\s+datetime="(\d{4}-\d{2}-\d{2})', re.IGNORECASE | re.DOTALL
)
_YEAR_LOOSE_RE = re.compile(r'/(?:year|release-year)/(\d{4})/"', re.IGNORECASE)
_GENRE_CHIP_RE = re.compile(
r'<a\s+class="chip"\s+href="https?://[^/]+/genres?/([a-z0-9-]+)/"[^>]*>([^<]+)</a>',
re.IGNORECASE,
)
_DATA_SERVERS_RE = re.compile(r'data-servers="([^"]*)"', re.IGNORECASE)
def _slug(name: str) -> str:
return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", name.lower())).strip("-")
class DooplayConnector(BaseMovieConnector):
"""Generic dooplay scraper. Instantiated per-site via subclasses below."""
@ -209,23 +169,13 @@ class DooplayConnector(BaseMovieConnector):
if not getattr(self, "name", None):
raise RuntimeError(f"{type(self).__name__} requires class-level `name`")
self._timeout = timeout
self._last_fetch_at = 0.0
# Odstep miedzy zapytaniami do jednej strony. streamporn.li (2026-10-13) odpowiada
# na pojedyncze zapytanie w 0,2 s, ale drugie zapytanie zaraz po pierwszym wisialo
# do timeoutu (30 s, curl 28), a wyjatek curl_cffi nie jest httpx.HTTPError, wiec
# wywracal caly przebieg ingestu po pierwszym filmie.
_pace_sec = 1.0
_fetch_attempts = 3
def close(self) -> None:
pass
def _fetch(self, url: str) -> str:
"""browser_get z chrome120 impersonation, psyplay sites czasem blokują
czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to.
Timeout i bledy transportu: do `_fetch_attempts` prob z rosnacym odstepem, potem
httpx.TransportError, zeby petla w fetch_movies pominela film zamiast paść."""
czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to."""
if not url.startswith("http"):
url = self.base_url.rstrip("/") + url
headers = {
@ -234,23 +184,7 @@ class DooplayConnector(BaseMovieConnector):
"Accept": "text/html,application/xhtml+xml",
"Referer": self.base_url + "/",
}
last_exc: Exception | None = None
r = None
for attempt in range(self._fetch_attempts):
wait = self._pace_sec - (time.monotonic() - self._last_fetch_at)
if wait > 0:
time.sleep(wait)
try:
r = browser_get(url, headers=headers, timeout=self._timeout, follow_redirects=True)
self._last_fetch_at = time.monotonic()
break
except Exception as e: # noqa: BLE001 - curl_cffi/httpx, rozne klasy
last_exc = e
self._last_fetch_at = time.monotonic()
log.info("%s: fetch attempt %d failed for %s: %s", self.name, attempt + 1, url, e)
time.sleep(2.0 * (attempt + 1))
if r is None:
raise httpx.TransportError(f"fetch failed after {self._fetch_attempts} attempts: {url}: {last_exc}")
if r.status_code >= 400:
raise httpx.HTTPStatusError(
f"{r.status_code} for {url}",
@ -317,7 +251,7 @@ class DooplayConnector(BaseMovieConnector):
from urllib.parse import urlparse
site_host = urlparse(self.base_url).hostname
for m in _LIST_ITEM_RE.finditer(text):
url = m.group("url") or m.group("url2") or m.group("url3")
url = m.group("url") or m.group("url2")
if not url:
continue
try:
@ -344,30 +278,14 @@ class DooplayConnector(BaseMovieConnector):
def _parse_dooplay_detail(
*, slug: str, html: str, source_name: str, base_url: str, page_url: str | None = None
) -> RawMovie | None:
vid_year: int | None = None
vid_studio: str | None = None
m_title = _TITLE_RE.search(html) or _H1_TITLE_RE.search(html)
if m_title:
title = _decode_html(next(g for g in m_title.groups() if g).strip())
else:
# Motyw streamporn.li 2026-10: h1 z zagniezdzonym <span>, tytul, rok i studio w
# jednym napisie ("Watch TYTUL 2026 by STUDIO Porn Movie Online Free").
m_vid = _VID_TITLE_RE.search(html)
if not m_vid:
if not m_title:
log.warning("%s: no title in %s", source_name, slug)
return None
raw_title = _decode_html(_strip_tags(m_vid.group(1))).strip()
m_parts = _VID_TITLE_PARTS_RE.match(raw_title)
title = (m_parts.group("title") if m_parts else raw_title).strip()
if m_parts and m_parts.group("year"):
vid_year = int(m_parts.group("year"))
if m_parts and m_parts.group("studio"):
vid_studio = m_parts.group("studio").strip()
if not title:
return None
title = _decode_html(next(g for g in m_title.groups() if g).strip())
m_year = _YEAR_RE.search(html) or _YEAR_LOOSE_RE.search(html)
release_year = int(m_year.group(1)) if m_year else vid_year
m_year = _YEAR_RE.search(html)
release_year = int(m_year.group(1)) if m_year else None
studio: RawStudio | None = None
m_studio = _STUDIO_RE.search(html)
@ -379,14 +297,6 @@ def _parse_dooplay_detail(
name=studio_name,
slug=studio_slug,
)
elif vid_studio:
studio_slug = _slug(vid_studio)
if studio_slug:
studio = RawStudio(
external_id=f"{source_name}:{studio_slug}",
name=vid_studio,
slug=studio_slug,
)
duration_sec: int | None = None
m_dur = _DURATION_SPAN_RE.search(html) or _DURATION_TEXT_RE.search(html)
@ -397,15 +307,6 @@ def _parse_dooplay_detail(
m_m = re.search(r"(\d+)\s*min", text, re.IGNORECASE)
if m_h or m_m:
duration_sec = (int(m_h.group(1)) * 3600 if m_h else 0) + (int(m_m.group(1)) * 60 if m_m else 0)
if duration_sec is None:
# streamporn.li 2026-10: pasek "29:00" (MM:SS) albo "1:29:00" (H:MM:SS).
m_st = _ST_DURATION_RE.search(html)
if m_st:
a, b, c = m_st.group(1), m_st.group(2), m_st.group(3)
if c is not None:
duration_sec = int(a) * 3600 + int(b) * 60 + int(c)
else:
duration_sec = int(a) * 60 + int(b)
release_date: date | None = None
m_rd = _RELEASE_DATE_SPAN_RE.search(html) or _RELEASE_DATE_TEXT_RE.search(html)
@ -417,13 +318,6 @@ def _parse_dooplay_detail(
break
except ValueError:
continue
if release_date is None:
m_sd = _ST_DATE_RE.search(html)
if m_sd:
try:
release_date = datetime.strptime(m_sd.group(1), "%Y-%m-%d").date()
except ValueError:
pass
description: str | None = None
m_desc = _DESCRIPTION_RE.search(html)
@ -466,16 +360,6 @@ def _parse_dooplay_detail(
)
for m in _GENRE_TAG_RE.finditer(html)
]
if not tags:
# streamporn.li 2026-10: chipy gatunkow bez rel="tag".
tags = [
RawTag(
external_id=f"{source_name}:{m.group(1)}",
name=_decode_html(m.group(2).strip()),
slug=m.group(1),
)
for m in _GENRE_CHIP_RE.finditer(html)
]
if page_url is None:
page_url = f"{base_url}/movies/{slug}/"
@ -532,22 +416,6 @@ def _parse_dooplay_detail(
source = (src_match.group(1).strip() if src_match else "") or None
_emit_host_entry(href, source)
# Motyw streamporn.li 2026-10 (host-links-manager): hostery w JSON-ie atrybutu
# data-servers, HTML-escapowanym (&quot;). "u" = adres embed, "l" = etykieta.
for m in _DATA_SERVERS_RE.finditer(html):
try:
servers = json.loads(_decode_html(m.group(1)))
except (ValueError, TypeError):
continue
if not isinstance(servers, list):
continue
for entry in servers:
if not isinstance(entry, dict):
continue
embed = str(entry.get("u") or "").strip()
if embed.startswith("http"):
_emit_host_entry(embed, embed)
if not playback_sources:
# Brak sub-hosters znalezionych, fallback do landing page (mobile otworzy
# w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest

View file

@ -12,7 +12,6 @@ from app.api.admin_html import mount_static
from app.api.admin_html import router as admin_html_router
from app.api.blacklist import router as blacklist_router
from app.api.bug_reports import router as bug_reports_router
from app.api.survey import router as survey_router
from app.api.clips import router as clips_router
from app.api.expo_updates import router as expo_updates_router
from app.api.favorites import router as favorites_router
@ -84,7 +83,6 @@ app.include_router(taxonomies_router)
app.include_router(favorites_router)
app.include_router(blacklist_router)
app.include_router(bug_reports_router)
app.include_router(survey_router)
app.include_router(clips_router)
app.include_router(saved_searches_router)
app.include_router(expo_updates_router)

View file

@ -29,7 +29,6 @@ from app.models.phash_backfill_queue import PhashBackfillQueue
from app.models.phash_blacklist import PhashBlacklist
from app.models.saved_search import SavedSearch
from app.models.source_stats import SourceStats
from app.models.survey_response import SurveyResponse
from app.models.scene import (
Scene,
SceneExternalRef,
@ -86,7 +85,6 @@ __all__ = [
"Source",
"Studio",
"StudioAlias",
"SurveyResponse",
"StudioExternalRef",
"Tag",
"TagVerticalCount",

View file

@ -1,37 +0,0 @@
"""Odpowiedzi na jednorazowa ankiete in-app (pytania sa w apce, patrz app/api/survey.py).
Jedna odpowiedz na urzadzenie i ankiete (UNIQUE survey_id + device_id, ponowne wyslanie
nadpisuje). `answers` to JSON {klucz_pytania: wartosc | [wartosci]}, bez danych osobowych.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class SurveyResponse(Base):
__tablename__ = "survey_responses"
__table_args__ = (
UniqueConstraint("survey_id", "device_id", name="uq_survey_responses_survey_device"),
Index("ix_survey_responses_survey_created", "survey_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
survey_id: Mapped[str] = mapped_column(String(32), nullable=False)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
answers: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
free_text: Mapped[str | None] = mapped_column(Text, nullable=True)
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View file

@ -59,15 +59,6 @@ def _bucket(age_h: float) -> tuple[str, str]:
return "swiezo", "warning"
# Serwisy, ktore publikuja rzadziej niz co dwa dni: prog 48 h robil z nich staly alarm
# (sprawdzone 2026-10-13: xanimeporn 1 do 3 wpisow na tydzien z RSS, hentaimama wydaje
# odcinki raz w tygodniu, obie listy w calosci znane w bazie). Prog w godzinach.
_BROWSE_MAX_AGE_OVERRIDE_H: dict[str, int] = {
"xanimeporn": 168,
"hentaimama": 240,
}
def run_ingest_freshness_watchdog(
*,
max_age_hours: int = 48,
@ -95,10 +86,7 @@ def run_ingest_freshness_watchdog(
# (origin, tabela, próg_h, klasa)
checks: list[tuple[str, str, int, str]] = (
[
(f"tube:{t}", "playback_sources", _BROWSE_MAX_AGE_OVERRIDE_H.get(t, max_age_hours), "browse")
for t in sorted(browse_tags)
]
[(f"tube:{t}", "playback_sources", max_age_hours, "browse") for t in sorted(browse_tags)]
+ [(f"tube:{t}", "playback_sources", search_max_age_hours, "search")
for t in sorted(search_tags)]
)

View file

@ -29,7 +29,6 @@ import { ClientProvider } from './src/ClientContext';
import { PreferencesProvider } from './src/PreferencesContext';
import { SceneActionsProvider } from './src/SceneActionsContext';
import { OnboardingModal } from './src/components/OnboardingModal';
import { SurveyModal } from './src/components/SurveyModal';
import { WhatsNewModal } from './src/components/WhatsNewModal';
import { ErrorBoundary } from './src/ErrorBoundary';
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
@ -515,7 +514,6 @@ export default function App() {
}}
/>
<WhatsNewModal />
<SurveyModal />
<OnboardingModal />
</SceneActionsProvider>
</PreferencesProvider>

View file

@ -93,33 +93,6 @@ def jscFlavor = 'org.webkit:android-jsc:+'
apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle")
// Sentry Android Gradle Plugin: wysylka symboli natywnych (.so z build intermediates) i mappingu
// ProGuard. Bez tego natywne crashe SIGABRT (abort w JNI) maja same <unknown> w stosie.
// Wysylka tylko gdy build dostal SENTRY_AUTH_TOKEN (Infisical goon-app przez iclaude) i nie ma
// SENTRY_DISABLE_AUTO_UPLOAD=true; inaczej plugin jest bierny i build dziala offline.
// autoInstallation wylaczone: sentry-android przychodzi z @sentry/react-native, drugi egzemplarz
// w innej wersji psulby runtime. Instrumentacja bytecode tez wylaczona (nie potrzebujemy spanow
// OkHttp/Room, a zmienia kod bibliotek).
apply plugin: "io.sentry.android.gradle"
def sentryUploadEnabled = System.getenv("SENTRY_AUTH_TOKEN") &&
System.getenv("SENTRY_DISABLE_AUTO_UPLOAD") != "true"
sentry {
org = System.getenv("SENTRY_ORG")
projectName = System.getenv("SENTRY_PROJECT")
authToken = System.getenv("SENTRY_AUTH_TOKEN")
uploadNativeSymbols = sentryUploadEnabled
includeNativeSources = true
autoUploadProguardMapping = sentryUploadEnabled
includeProguardMapping = true
includeSourceContext = false
ignoredBuildTypes = ["debug"]
autoInstallation { enabled = false }
tracingInstrumentation { enabled = false }
telemetry = false
}
android {
ndkVersion rootProject.ext.ndkVersion
@ -131,8 +104,8 @@ android {
applicationId 'com.goon.mobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 17
versionName "0.4.3"
versionCode 16
versionName "0.4.2"
}
signingConfigs {
debug {

View file

@ -49,9 +49,7 @@
gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). -->
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
<activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/>
<!-- Bez android:screenOrientation: pion wymusza MainActivity.onCreate (tylko poza TV),
bo wpis w manifescie dotyczylby tez aliasu telewizyjnego ponizej. -->
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:resizeableActivity="true">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait" android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@ -65,14 +63,12 @@
</activity>
<!-- Android TV. Bez wpisu LEANBACK_LAUNCHER apka nie pojawia sie na ekranie glownym
telewizora, mimo ze instaluje sie bez bledu. Banner (320x180) jest w <application>.
Alias tej samej aktywnosci (nie osobna klasa): dwie ReactActivity naraz walily
asercja przy pauzie (crash na TV), a nazwa .TvMainActivity zostaje, zeby skroty
na ekranach telewizorow dalej dzialaly. -->
<activity-alias android:name=".TvMainActivity" android:targetActivity=".MainActivity" android:exported="true">
Osobna aktywnosc, bo tu NIE MA blokady pionu, patrz TvMainActivity. -->
<activity android:name=".TvMainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
</activity-alias>
</activity>
</application>
</manifest>

View file

@ -1,10 +1,5 @@
package com.goon.mobile
import android.app.UiModeManager
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Log
@ -18,28 +13,9 @@ import com.reactnative.googlecast.api.RNGCCastContext
import expo.modules.ReactActivityDelegateWrapper
/**
* Jedyna aktywnosc Reacta w aplikacji. Wejscie dla Android TV (`.TvMainActivity`,
* LEANBACK_LAUNCHER) to w manifescie activity-alias na te klase, a nie osobna klasa.
*
* Dlaczego (najczestszy crash na telewizorach do 2026-10): osobna `TvMainActivity :
* MainActivity()` byla DRUGA ReactActivity na tym samym ReactInstanceManager. Na telewizorze
* obie potrafily zyc naraz (launcher TV startuje Tv, a intent MAIN/VIEW, Cast albo Recents
* wchodzi w MainActivity) i pauza tej, ktora nie byla "current", walila asercja
* `Unable to pause activity TvMainActivity`. Jedna klasa + `singleTask` = jedna instancja.
*
* Blokada pionu: nie w manifescie (alias nie moze jej nadpisac, a telewizor ma ekran na stale
* poziomy i pionowa aplikacja wychodzi waskim paskiem), tylko tutaj, przed `super.onCreate`,
* i TYLKO poza telewizorem. Na TV nie wolno wolac `setRequestedOrientation` (relaunch tuz po
* starcie i bialy ekran, sprawdzone 2026-09-17). Telefon startuje niemal zawsze w pionie,
* wiec zadanie pionu nie zmienia konfiguracji; gdyby zmienilo, `configChanges` w manifescie
* obsluguje orientation|screenSize bez odtwarzania aktywnosci.
*/
// open, bo dziedziczy z niej TvMainActivity (to samo, tylko bez blokady pionu).
open class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
if (!isTelevision()) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
@ -57,12 +33,6 @@ open class MainActivity : ReactActivity() {
}
}
private fun isTelevision(): Boolean {
val uiMode = getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
if (uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) return true
return packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.

View file

@ -0,0 +1,19 @@
package com.goon.mobile
/**
* Wejscie dla Android TV. Ta sama aktywnosc co na telefonie, rozni sie WYLACZNIE wpisem
* w manifescie: telefon ma blokade pionu, telewizor jej nie ma, bo ekran telewizora jest
* na stale poziomy i pionowa aplikacja wychodzi tam waskim paskiem na srodku.
*
* Dlaczego osobna klasa, a nie jedna aktywnosc:
* - `setRequestedOrientation` w `onCreate` to zmiana konfiguracji, wiec system
* relaunchuje aktywnosc tuz po starcie i na TV zostawal bialy ekran (widok Reacta nie
* wracal; emulator android-34 android-tv, 2026-09-17),
* - orientacja z zasobu (`values-television`) nie przechodzi, bo manifest czyta zasoby
* bez kontekstu konfiguracji (lint ManifestResource),
* - `activity-alias` nie pozwala nadpisac `screenOrientation`.
*
* Telewizorowy launcher pyta o `LEANBACK_LAUNCHER`, wiec na telefonie ta klasa jest
* niewidoczna, a na TV widoczna jest tylko ona.
*/
class TvMainActivity : MainActivity()

View file

@ -23,10 +23,6 @@ buildscript {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
// Symbole natywne (.so) i mapping ProGuard do Sentry. sentry.gradle z @sentry/react-native
// wysyla tylko bundle JS i source mapy; natywne crashe SIGABRT byly przez to
// nieczytelne. Wersja 4.14.x dziala z AGP 8.x i sentry-android 7.x z RN SDK 6.10.
classpath('io.sentry:sentry-android-gradle-plugin:4.14.1')
}
}

View file

@ -2,7 +2,7 @@
"expo": {
"name": "goon",
"slug": "goon",
"version": "0.4.3",
"version": "0.4.2",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": false,

View file

@ -584,26 +584,6 @@ export class GoonClient {
});
}
// Jednorazowa ankieta in-app (SurveyModal). Backend decyduje, czy pytac to urzadzenie
// (bramka po obejrzanych scenach) i czy juz odpowiedzialo.
async getCurrentSurvey(): Promise<{ id: string; eligible: boolean; answered: boolean }> {
return this.request('/survey/current');
}
async submitSurvey(
surveyId: string,
payload: {
answers: Record<string, string | string[]>;
free_text?: string | null;
app_version?: string | null;
},
): Promise<{ status: string }> {
return this.request(`/survey/${encodeURIComponent(surveyId)}`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
async listMyBugReports(): Promise<{
items: {
id: string;

View file

@ -35,13 +35,6 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-10-13a',
date: 'October 2026',
items: [
'A one-minute survey will pop up once, after this update. Six questions about how you found Goon, what you use and what you would pay for. Your answers decide what gets built next. You can skip it.',
],
},
{
id: '2026-10-12b',
date: 'October 2026',

View file

@ -1,464 +0,0 @@
/**
* Jednorazowa ankieta (6 pytan + jedno pole wolne), pomijalna, raz na urzadzenie.
*
* Kiedy: po tutorialu, tylko dla urzadzen, ktore apki realnie uzywaja (backend liczy
* obejrzane sceny, GET /survey/current), nigdy razem z popupem "What's new" (czeka na jego
* zamkniecie przez popupBus). "Not now" i wyslanie zapisuja flage lokalnie, wiec ankieta
* nie wraca. Blad sieci przy wysylce nie zapisuje flagi, apka sprobuje przy nastepnym
* uruchomieniu. Klucze pytan i opcji musza zgadzac sie z app/api/survey.py.
*/
import React from 'react';
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useClient } from '../ClientContext';
import { unseenEntries } from '../changelog';
import { APP_VERSION } from '../lib/appVersion';
import { onWhatsNewClosed } from '../lib/popupBus';
import {
getLastSeenChangelog,
getOnboardingSeen,
getSurveyDone,
setSurveyDone,
} from '../storage';
import { theme } from '../theme';
export const SURVEY_ID = '2026-10';
type Option = { value: string; label: string; text?: boolean };
type Question = {
key: string;
title: string;
hint?: string;
multi?: boolean;
/** Maksymalna liczba zaznaczen przy multi (wymusza priorytety). */
max?: number;
options: Option[];
};
const QUESTIONS: Question[] = [
{
key: 'source',
title: 'How did you find Goon?',
options: [
{ value: 'mobilism', label: 'Mobilism forum' },
{ value: 'f95zone', label: 'F95zone' },
{ value: 'stash', label: 'Stash community (forum, GitHub, Reddit)' },
{ value: 'google', label: 'Google search (a performer or studio page)' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'reddit', label: 'Reddit (other)' },
{ value: 'friend', label: 'A friend' },
{ value: 'list', label: 'A list of sites or apps (FMHY and similar)' },
{ value: 'other', label: 'Somewhere else', text: true },
],
},
{
key: 'use',
title: 'What do you use most?',
hint: 'Pick up to three.',
multi: true,
max: 3,
options: [
{ value: 'scenes', label: 'Browsing scenes' },
{ value: 'performers_studios', label: 'Performers and studios' },
{ value: 'movies', label: 'Movies' },
{ value: 'edge', label: 'Edge (clips)' },
{ value: 'jav', label: 'JAV' },
{ value: 'favorites', label: 'Favorites and new-scene badges' },
{ value: 'saved_searches', label: 'Saved searches' },
{ value: 'cast_tv', label: 'Chromecast or Android TV' },
],
},
{
key: 'pain',
title: 'What annoys you most?',
options: [
{ value: 'playback_fails', label: 'Videos that fail to play on some sites' },
{ value: 'slow', label: 'Slow loading' },
{ value: 'missing_sites', label: 'Sites that are missing' },
{ value: 'missing_performers', label: 'Performers or scenes that are missing' },
{ value: 'missing_orientation', label: 'Too little gay or trans content, or too much of it' },
{ value: 'updates', label: 'Updating the app' },
{ value: 'nothing', label: 'Nothing, it is fine' },
{ value: 'other', label: 'Something else', text: true },
],
},
{
key: 'pay_for',
title: 'What would you pay a few dollars a year for?',
hint: 'Pick any.',
multi: true,
options: [
{ value: 'notifications', label: 'Alerts when favorite performers have new scenes' },
{ value: 'instant_play', label: 'Instant play and video previews' },
{ value: 'cast_tv', label: 'Chromecast and Android TV' },
{ value: 'early_access', label: 'Early access and a vote on what gets built' },
{ value: 'community', label: 'A private community' },
{ value: 'nothing', label: 'Nothing' },
{ value: 'donate', label: 'I would rather donate' },
],
},
{
key: 'pay_how',
title: 'If you paid, how?',
options: [
{ value: 'have_crypto', label: 'Crypto, I already have some' },
{ value: 'crypto_if_easy', label: 'Crypto, if I can buy it right in the app' },
{ value: 'card_only', label: 'Card only' },
{ value: 'wont_pay', label: 'I would not pay' },
],
},
{
key: 'price',
title: 'A fair price per year?',
options: [
{ value: '0', label: 'Free only' },
{ value: '5', label: '$5' },
{ value: '10', label: '$10' },
{ value: '20', label: '$20' },
{ value: '30', label: '$30 or more' },
],
},
];
// Ostatni krok to pole wolne, stad +1.
const TOTAL_STEPS = QUESTIONS.length + 1;
export function SurveyModal() {
const client = useClient();
const [visible, setVisible] = React.useState(false);
const [step, setStep] = React.useState(0);
const [answers, setAnswers] = React.useState<Record<string, string[]>>({});
const [otherText, setOtherText] = React.useState<Record<string, string>>({});
const [freeText, setFreeText] = React.useState('');
const [sending, setSending] = React.useState(false);
const [sent, setSent] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
let unsub: (() => void) | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
(async () => {
try {
if (!(await getOnboardingSeen())) return;
if (await getSurveyDone(SURVEY_ID)) return;
const cur = await client.getCurrentSurvey();
if (cancelled || cur.id !== SURVEY_ID || !cur.eligible) return;
if (cur.answered) {
setSurveyDone(SURVEY_ID).catch(() => {});
return;
}
const show = () => {
if (!cancelled) setVisible(true);
};
const lastSeen = await getLastSeenChangelog();
if (unseenEntries(lastSeen).length > 0) {
// "What's new" wyskoczy przy tym uruchomieniu, ankieta dopiero po nim.
unsub = onWhatsNewClosed(() => {
timer = setTimeout(show, 700);
});
} else {
timer = setTimeout(show, 1500);
}
} catch {
// brak sieci / SecureStore: po prostu nie pytamy
}
})();
return () => {
cancelled = true;
if (unsub) unsub();
if (timer) clearTimeout(timer);
};
}, [client]);
const question = step < QUESTIONS.length ? QUESTIONS[step] : null;
const selected = question ? answers[question.key] ?? [] : [];
const toggle = React.useCallback(
(q: Question, value: string) => {
setAnswers((prev) => {
const cur = prev[q.key] ?? [];
if (!q.multi) return { ...prev, [q.key]: [value] };
if (cur.includes(value)) return { ...prev, [q.key]: cur.filter((v) => v !== value) };
if (q.max && cur.length >= q.max) return prev;
return { ...prev, [q.key]: [...cur, value] };
});
},
[],
);
const canAdvance = question ? selected.length > 0 : true;
const dismiss = React.useCallback(() => {
setVisible(false);
setSurveyDone(SURVEY_ID).catch(() => {});
}, []);
const submit = React.useCallback(async () => {
if (sending) return;
setSending(true);
setError(null);
const payload: Record<string, string | string[]> = {};
for (const q of QUESTIONS) {
const vals = answers[q.key] ?? [];
if (vals.length === 0) continue;
payload[q.key] = q.multi ? vals : vals[0];
const txt = (otherText[q.key] ?? '').trim();
if (!q.multi && vals[0] === 'other' && txt) payload[`${q.key}_text`] = txt;
}
try {
await client.submitSurvey(SURVEY_ID, {
answers: payload,
free_text: freeText.trim() || null,
app_version: APP_VERSION,
});
await setSurveyDone(SURVEY_ID).catch(() => {});
setSent(true);
setTimeout(() => setVisible(false), 1400);
} catch {
setError('Could not send. Check your connection and try again.');
} finally {
setSending(false);
}
}, [answers, client, freeText, otherText, sending]);
if (!visible) return null;
const showOtherInput =
question && !question.multi && selected[0] === 'other' && question.options.some((o) => o.text);
return (
<Modal visible transparent animationType="fade" onRequestClose={dismiss}>
<KeyboardAvoidingView
style={styles.backdrop}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.card}>
{sent ? (
<View style={styles.thanks}>
<Text style={styles.title}>Thank you</Text>
<Text style={styles.hint}>Your answers decide what gets built next.</Text>
</View>
) : (
<>
<View style={styles.header}>
<Text style={styles.title}>{step === 0 ? 'One-minute survey' : 'Survey'}</Text>
<Text style={styles.progress}>
{step + 1}/{TOTAL_STEPS}
</Text>
</View>
{step === 0 ? (
<Text style={styles.intro}>
Six quick questions. They decide what we build next and how Goon stays
online. No account, no tracking, just your answers.
</Text>
) : null}
<ScrollView
style={styles.scroll}
contentContainerStyle={{ paddingBottom: 4 }}
keyboardShouldPersistTaps="handled"
>
{question ? (
<>
<Text style={styles.question}>{question.title}</Text>
{question.hint ? <Text style={styles.hint}>{question.hint}</Text> : null}
{question.options.map((o) => {
const on = selected.includes(o.value);
return (
<Pressable
key={o.value}
onPress={() => toggle(question, o.value)}
style={({ pressed }) => [
styles.option,
on && styles.optionOn,
pressed && styles.optionPressed,
]}
accessibilityRole={question.multi ? 'checkbox' : 'radio'}
accessibilityState={{ checked: on }}
>
<View
style={[
styles.mark,
question.multi ? styles.markSquare : styles.markRound,
on && styles.markOn,
]}
>
{on ? <Text style={styles.markTick}>✓</Text> : null}
</View>
<Text style={[styles.optionText, on && styles.optionTextOn]}>
{o.label}
</Text>
</Pressable>
);
})}
{showOtherInput ? (
<TextInput
value={otherText[question.key] ?? ''}
onChangeText={(t) =>
setOtherText((prev) => ({ ...prev, [question.key]: t }))
}
placeholder="Tell us where or what (optional)"
placeholderTextColor={theme.mutedDim}
style={styles.input}
maxLength={200}
/>
) : null}
</>
) : (
<>
<Text style={styles.question}>One thing you would add or change?</Text>
<Text style={styles.hint}>Optional.</Text>
<TextInput
value={freeText}
onChangeText={setFreeText}
placeholder="A feature, a site, anything"
placeholderTextColor={theme.mutedDim}
style={[styles.input, styles.inputTall]}
multiline
maxLength={2000}
textAlignVertical="top"
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
</>
)}
</ScrollView>
<View style={styles.buttons}>
{step === 0 ? (
<Pressable style={styles.btnGhost} onPress={dismiss}>
<Text style={styles.btnGhostText}>Not now</Text>
</Pressable>
) : (
<Pressable style={styles.btnGhost} onPress={() => setStep((s) => s - 1)}>
<Text style={styles.btnGhostText}>Back</Text>
</Pressable>
)}
{question ? (
<Pressable
style={[styles.btn, !canAdvance && styles.btnDisabled]}
disabled={!canAdvance}
onPress={() => setStep((s) => s + 1)}
>
<Text style={styles.btnText}>Next</Text>
</Pressable>
) : (
<Pressable
style={[styles.btn, sending && styles.btnDisabled]}
disabled={sending}
onPress={submit}
>
<Text style={styles.btnText}>{sending ? 'Sending' : 'Send'}</Text>
</Pressable>
)}
</View>
</>
)}
</View>
</KeyboardAvoidingView>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
card: {
width: '100%',
maxHeight: '88%',
backgroundColor: theme.bgElevated,
borderRadius: 16,
borderColor: theme.border,
borderWidth: 1,
padding: 20,
},
header: {
flexDirection: 'row',
alignItems: 'baseline',
justifyContent: 'space-between',
marginBottom: 8,
},
title: { color: theme.fg, fontSize: 20, fontWeight: '800' },
progress: { color: theme.mutedDim, fontSize: 13, fontWeight: '700' },
intro: { color: theme.muted, fontSize: 14, lineHeight: 20, marginBottom: 12 },
scroll: { flexGrow: 0 },
question: { color: theme.fg, fontSize: 17, fontWeight: '700', marginBottom: 4 },
hint: { color: theme.muted, fontSize: 13, lineHeight: 18, marginBottom: 10 },
option: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 11,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.card,
marginBottom: 8,
},
optionOn: { borderColor: theme.accent, backgroundColor: theme.bg },
optionPressed: { opacity: 0.8 },
mark: {
width: 22,
height: 22,
borderWidth: 2,
borderColor: theme.borderStrong,
alignItems: 'center',
justifyContent: 'center',
},
markRound: { borderRadius: 11 },
markSquare: { borderRadius: 5 },
markOn: { borderColor: theme.accent, backgroundColor: theme.accent },
markTick: { color: theme.fg, fontSize: 13, fontWeight: '900', lineHeight: 16 },
optionText: { color: theme.fg, fontSize: 15, lineHeight: 20, flex: 1 },
optionTextOn: { fontWeight: '700' },
input: {
marginTop: 4,
marginBottom: 8,
color: theme.fg,
fontSize: 15,
backgroundColor: theme.bg,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
inputTall: { minHeight: 110 },
error: { color: theme.bad, fontSize: 13, marginTop: 6 },
buttons: { flexDirection: 'row', gap: 10, marginTop: 10 },
btn: {
flex: 1,
backgroundColor: theme.accent,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
btnDisabled: { opacity: 0.45 },
btnText: { color: theme.fg, fontSize: 15, fontWeight: '700' },
btnGhost: {
flex: 1,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
borderWidth: 1,
borderColor: theme.border,
},
btnGhostText: { color: theme.muted, fontSize: 15, fontWeight: '700' },
thanks: { paddingVertical: 12 },
});

View file

@ -12,7 +12,6 @@ import {
unseenEntries,
type ChangelogEntry,
} from '../changelog';
import { emitWhatsNewClosed } from '../lib/popupBus';
import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage';
import { theme } from '../theme';
@ -46,8 +45,6 @@ export function WhatsNewModal() {
const dismiss = React.useCallback(() => {
setVisible(false);
if (NEWEST_CHANGELOG_ID) setLastSeenChangelog(NEWEST_CHANGELOG_ID).catch(() => {});
// SurveyModal czeka na to zamkniecie, zeby nie wyskoczyc na "What's new".
emitWhatsNewClosed();
}, []);
// Nagłówek miesiąca RAZ na grupę, nie przy każdym wpisie. Wcześniej 17 sierpniowych

View file

@ -1,18 +0,0 @@
/**
* Mini szyna zdarzen miedzy popupami montowanymi nad nawigacja (App.tsx).
* WhatsNewModal po zamknieciu woła `emitWhatsNewClosed()`, SurveyModal czeka na to,
* zeby dwa popupy nie wyskoczyly jeden na drugim przy tym samym uruchomieniu.
* Ten sam wzorzec co onboardingBus: zero zaleznosci, listenerzy w module scope.
*/
type Listener = () => void;
const whatsNewClosed = new Set<Listener>();
export function onWhatsNewClosed(cb: Listener): () => void {
whatsNewClosed.add(cb);
return () => whatsNewClosed.delete(cb);
}
export function emitWhatsNewClosed(): void {
whatsNewClosed.forEach((cb) => cb());
}

View file

@ -63,22 +63,6 @@ export async function setLastSeenChangelog(id: string): Promise<void> {
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
}
// Ankieta in-app: flaga "odpowiedziane albo pominiete" per ankieta (SurveyModal).
// Klucz z id ankiety, wiec kolejna ankieta (nowy id) sama zapyta od nowa.
const SURVEY_DONE_PREFIX = 'goon.survey_done.';
export async function getSurveyDone(surveyId: string): Promise<boolean> {
try {
return (await SecureStore.getItemAsync(SURVEY_DONE_PREFIX + surveyId)) === '1';
} catch {
return true; // brak SecureStore: nie meczyc ankieta w kolko
}
}
export async function setSurveyDone(surveyId: string): Promise<void> {
await SecureStore.setItemAsync(SURVEY_DONE_PREFIX + surveyId, '1');
}
// Onboarding/tutorial, pokazany raz przy pierwszym odpaleniu (carousel: tabs,
// funkcje, longpressy, gesty playera). Wersjonowany klucz → bump gdy tutorial
// istotnie się zmieni i chcemy pokazać ponownie. Replay z ustawień przez bus.