Compare commits
3 commits
ffb4ecda18
...
df6b6c7f3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df6b6c7f3f | ||
|
|
11400c01cb | ||
|
|
88f8729840 |
24 changed files with 1153 additions and 44 deletions
46
alembic/versions/20261012_0055_survey_responses.py
Normal file
46
alembic/versions/20261012_0055_survey_responses.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""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")
|
||||||
205
app/api/survey.py
Normal file
205
app/api/survey.py
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"""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()},
|
||||||
|
)
|
||||||
|
|
@ -40,7 +40,8 @@ _BASE = "https://hentaimama.io"
|
||||||
_EPISODE_HREF_RE = re.compile(r'href="(https://hentaimama\.io/episodes/[^"]+)"')
|
_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
|
# Sam listing `/episodes/` i jego paginacja też pasują do wzorca wyżej — bez tego
|
||||||
# filtra wchodziły jako „odcinek" o tytule „Recent Episodes".
|
# filtra wchodziły jako „odcinek" o tytule „Recent Episodes".
|
||||||
_NOT_EPISODE_RE = re.compile(r"/episodes/(?:page/\d+/)?$")
|
# `/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/)?$")
|
||||||
# `og:title` zamiast <h1>: na stronie są trzy <h1>, dwa to logo serwisu, a trzeci bywa
|
# `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".
|
# nagłówkiem sekcji. og:title daje czysty „Shoujo Ramune Episode 7".
|
||||||
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})")
|
_OG_TITLE_RE = re.compile(r"property=['\"]og:title['\"][^>]*content=['\"]([^'\"]{3,150})")
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,12 @@ _BASE = "https://pornbusy.com"
|
||||||
_PAGE_SIZE = 20
|
_PAGE_SIZE = 20
|
||||||
|
|
||||||
_SITEMAP_INDEX = f"{_BASE}/sitemap_index.xml"
|
_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>")
|
_LOC_RE = re.compile(r"<loc>\s*([^<]+?)\s*</loc>")
|
||||||
_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.DOTALL | re.IGNORECASE)
|
_URL_BLOCK_RE = re.compile(r"<url>(.*?)</url>", re.DOTALL | re.IGNORECASE)
|
||||||
_LASTMOD_RE = re.compile(r"<lastmod>\s*([^<]+?)\s*</lastmod>")
|
_LASTMOD_RE = re.compile(r"<lastmod>\s*([^<]+?)\s*</lastmod>")
|
||||||
|
|
@ -139,8 +145,21 @@ class PornBusyScraper(BaseBrowseScraper):
|
||||||
if not entries:
|
if not entries:
|
||||||
return None
|
return None
|
||||||
entries.sort(key=lambda e: e[0], reverse=True)
|
entries.sort(key=lambda e: e[0], reverse=True)
|
||||||
seen: set[str] = set()
|
# 2026-10-13: sitemapa WordPressa aktualizuje sie z opoznieniem. Najnowszy lastmod
|
||||||
catalog: list[str] = []
|
# 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)
|
||||||
for _, url in entries:
|
for _, url in entries:
|
||||||
if url in seen:
|
if url in seen:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import re
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.extractors import browser_get
|
||||||
from app.connectors.base import (
|
from app.connectors.base import (
|
||||||
RawFingerprint,
|
RawFingerprint,
|
||||||
RawPerformer,
|
RawPerformer,
|
||||||
|
|
@ -54,6 +55,13 @@ _BASE = "https://porndoe.com"
|
||||||
|
|
||||||
# Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
|
# Scene listing, `<a href="/watch/pd7a3o0e8v2b">`. Id = `pd` + alfanum.
|
||||||
_SCENE_URL_RE = re.compile(r'href="(/watch/[a-z0-9]+)"', re.IGNORECASE)
|
_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)
|
_WATCH_ID_RE = re.compile(r"/watch/([a-z0-9]+)", re.IGNORECASE)
|
||||||
|
|
||||||
# JSON-LD <script> bloki.
|
# JSON-LD <script> bloki.
|
||||||
|
|
@ -155,13 +163,77 @@ class PornDoeScraper(BaseBrowseScraper):
|
||||||
self._proxy = get_settings().brightdata_proxy_url
|
self._proxy = get_settings().brightdata_proxy_url
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
log.warning("porndoe: BRIGHTDATA_PROXY_URL nieustawione, scraper wylaczony")
|
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):
|
def crawl_page(self, page: int):
|
||||||
# Bez proxy nie ma po co uderzac (gwarantowane 403). Pusta lista = "wyczerpany",
|
# Bez proxy nie ma po co uderzac (gwarantowane 403). Pusta lista = "wyczerpany",
|
||||||
# dzieki czemu deep-crawl nie ponawia w kolko.
|
# dzieki czemu deep-crawl nie ponawia w kolko.
|
||||||
if not self._proxy:
|
if not self._proxy:
|
||||||
return []
|
return []
|
||||||
return super().crawl_page(page)
|
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
|
||||||
|
|
||||||
def _listing_url(self, page: int) -> str:
|
def _listing_url(self, page: int) -> str:
|
||||||
if page <= 1:
|
if page <= 1:
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,14 @@ class SuperpornScraper(BaseBrowseScraper):
|
||||||
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
|
log.warning("superporn: BRIGHTDATA_PROXY_URL unset — scraper disabled")
|
||||||
|
|
||||||
def _listing_url(self, page: int) -> str:
|
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:
|
if page <= 1:
|
||||||
return f"{_BASE}/"
|
return f"{_BASE}/videos/latest"
|
||||||
return f"{_BASE}/?page={page}"
|
return f"{_BASE}/videos/latest/{page}"
|
||||||
|
|
||||||
def crawl_page(self, page: int):
|
def crawl_page(self, page: int):
|
||||||
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj
|
# Bez proxy nie ma sensu uderzać (gwarantowany CF 403), sygnalizuj
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ mógł wybrać czyim embedem chce odpalić scenę.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -59,10 +61,14 @@ USER_AGENT = (
|
||||||
# 2026-10-02 mangoporn przeszedl na `<a class="thumb" href=...>` (klasa PRZED href) i
|
# 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
|
# 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.
|
# 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(
|
_LIST_ITEM_RE = re.compile(
|
||||||
r'<a\s+href="(?P<url>https?://[^"]+)"[^>]*\bclass="ml-mask\b[^"]*"'
|
r'<a\s+href="(?P<url>https?://[^"]+)"[^>]*\bclass="ml-mask\b[^"]*"'
|
||||||
r"|"
|
r"|"
|
||||||
r'<a\s+(?:[^>]*?\s)?href="(?P<url2>https?://[^"]+/movies/(?!category/)[a-z0-9-]+/)"',
|
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?://[^"]+)"',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
|
# Tolerantny title, mangoporn (dooplay) używa <h1> w class="data", streamporn/pandamovies
|
||||||
|
|
@ -155,6 +161,40 @@ _POSTER_OG_RE = re.compile(
|
||||||
)
|
)
|
||||||
_DURATION_MINS_RE = re.compile(r"(\d+)\s*min", re.IGNORECASE)
|
_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):
|
class DooplayConnector(BaseMovieConnector):
|
||||||
"""Generic dooplay scraper. Instantiated per-site via subclasses below."""
|
"""Generic dooplay scraper. Instantiated per-site via subclasses below."""
|
||||||
|
|
@ -169,13 +209,23 @@ class DooplayConnector(BaseMovieConnector):
|
||||||
if not getattr(self, "name", None):
|
if not getattr(self, "name", None):
|
||||||
raise RuntimeError(f"{type(self).__name__} requires class-level `name`")
|
raise RuntimeError(f"{type(self).__name__} requires class-level `name`")
|
||||||
self._timeout = timeout
|
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:
|
def close(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _fetch(self, url: str) -> str:
|
def _fetch(self, url: str) -> str:
|
||||||
"""browser_get z chrome120 impersonation, psyplay sites czasem blokują
|
"""browser_get z chrome120 impersonation, psyplay sites czasem blokują
|
||||||
czysty httpx (Python TLS fingerprint) zwracając 500/403. curl_cffi fixuje to."""
|
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ść."""
|
||||||
if not url.startswith("http"):
|
if not url.startswith("http"):
|
||||||
url = self.base_url.rstrip("/") + url
|
url = self.base_url.rstrip("/") + url
|
||||||
headers = {
|
headers = {
|
||||||
|
|
@ -184,7 +234,23 @@ class DooplayConnector(BaseMovieConnector):
|
||||||
"Accept": "text/html,application/xhtml+xml",
|
"Accept": "text/html,application/xhtml+xml",
|
||||||
"Referer": self.base_url + "/",
|
"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)
|
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:
|
if r.status_code >= 400:
|
||||||
raise httpx.HTTPStatusError(
|
raise httpx.HTTPStatusError(
|
||||||
f"{r.status_code} for {url}",
|
f"{r.status_code} for {url}",
|
||||||
|
|
@ -251,7 +317,7 @@ class DooplayConnector(BaseMovieConnector):
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
site_host = urlparse(self.base_url).hostname
|
site_host = urlparse(self.base_url).hostname
|
||||||
for m in _LIST_ITEM_RE.finditer(text):
|
for m in _LIST_ITEM_RE.finditer(text):
|
||||||
url = m.group("url") or m.group("url2")
|
url = m.group("url") or m.group("url2") or m.group("url3")
|
||||||
if not url:
|
if not url:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|
@ -278,14 +344,30 @@ class DooplayConnector(BaseMovieConnector):
|
||||||
def _parse_dooplay_detail(
|
def _parse_dooplay_detail(
|
||||||
*, slug: str, html: str, source_name: str, base_url: str, page_url: str | None = None
|
*, slug: str, html: str, source_name: str, base_url: str, page_url: str | None = None
|
||||||
) -> RawMovie | 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)
|
m_title = _TITLE_RE.search(html) or _H1_TITLE_RE.search(html)
|
||||||
if not m_title:
|
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:
|
||||||
log.warning("%s: no title in %s", source_name, slug)
|
log.warning("%s: no title in %s", source_name, slug)
|
||||||
return None
|
return None
|
||||||
title = _decode_html(next(g for g in m_title.groups() if g).strip())
|
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
|
||||||
|
|
||||||
m_year = _YEAR_RE.search(html)
|
m_year = _YEAR_RE.search(html) or _YEAR_LOOSE_RE.search(html)
|
||||||
release_year = int(m_year.group(1)) if m_year else None
|
release_year = int(m_year.group(1)) if m_year else vid_year
|
||||||
|
|
||||||
studio: RawStudio | None = None
|
studio: RawStudio | None = None
|
||||||
m_studio = _STUDIO_RE.search(html)
|
m_studio = _STUDIO_RE.search(html)
|
||||||
|
|
@ -297,6 +379,14 @@ def _parse_dooplay_detail(
|
||||||
name=studio_name,
|
name=studio_name,
|
||||||
slug=studio_slug,
|
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
|
duration_sec: int | None = None
|
||||||
m_dur = _DURATION_SPAN_RE.search(html) or _DURATION_TEXT_RE.search(html)
|
m_dur = _DURATION_SPAN_RE.search(html) or _DURATION_TEXT_RE.search(html)
|
||||||
|
|
@ -307,6 +397,15 @@ def _parse_dooplay_detail(
|
||||||
m_m = re.search(r"(\d+)\s*min", text, re.IGNORECASE)
|
m_m = re.search(r"(\d+)\s*min", text, re.IGNORECASE)
|
||||||
if m_h or m_m:
|
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)
|
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
|
release_date: date | None = None
|
||||||
m_rd = _RELEASE_DATE_SPAN_RE.search(html) or _RELEASE_DATE_TEXT_RE.search(html)
|
m_rd = _RELEASE_DATE_SPAN_RE.search(html) or _RELEASE_DATE_TEXT_RE.search(html)
|
||||||
|
|
@ -318,6 +417,13 @@ def _parse_dooplay_detail(
|
||||||
break
|
break
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
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
|
description: str | None = None
|
||||||
m_desc = _DESCRIPTION_RE.search(html)
|
m_desc = _DESCRIPTION_RE.search(html)
|
||||||
|
|
@ -360,6 +466,16 @@ def _parse_dooplay_detail(
|
||||||
)
|
)
|
||||||
for m in _GENRE_TAG_RE.finditer(html)
|
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:
|
if page_url is None:
|
||||||
page_url = f"{base_url}/movies/{slug}/"
|
page_url = f"{base_url}/movies/{slug}/"
|
||||||
|
|
@ -416,6 +532,22 @@ def _parse_dooplay_detail(
|
||||||
source = (src_match.group(1).strip() if src_match else "") or None
|
source = (src_match.group(1).strip() if src_match else "") or None
|
||||||
_emit_host_entry(href, source)
|
_emit_host_entry(href, source)
|
||||||
|
|
||||||
|
# Motyw streamporn.li 2026-10 (host-links-manager): hostery w JSON-ie atrybutu
|
||||||
|
# data-servers, HTML-escapowanym ("). "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:
|
if not playback_sources:
|
||||||
# Brak sub-hosters znalezionych, fallback do landing page (mobile otworzy
|
# Brak sub-hosters znalezionych, fallback do landing page (mobile otworzy
|
||||||
# w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest
|
# w WebView). Robimy to TYLKO gdy nie ma alternatyw, inaczej landing jest
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from app.api.admin_html import mount_static
|
||||||
from app.api.admin_html import router as admin_html_router
|
from app.api.admin_html import router as admin_html_router
|
||||||
from app.api.blacklist import router as blacklist_router
|
from app.api.blacklist import router as blacklist_router
|
||||||
from app.api.bug_reports import router as bug_reports_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.clips import router as clips_router
|
||||||
from app.api.expo_updates import router as expo_updates_router
|
from app.api.expo_updates import router as expo_updates_router
|
||||||
from app.api.favorites import router as favorites_router
|
from app.api.favorites import router as favorites_router
|
||||||
|
|
@ -83,6 +84,7 @@ app.include_router(taxonomies_router)
|
||||||
app.include_router(favorites_router)
|
app.include_router(favorites_router)
|
||||||
app.include_router(blacklist_router)
|
app.include_router(blacklist_router)
|
||||||
app.include_router(bug_reports_router)
|
app.include_router(bug_reports_router)
|
||||||
|
app.include_router(survey_router)
|
||||||
app.include_router(clips_router)
|
app.include_router(clips_router)
|
||||||
app.include_router(saved_searches_router)
|
app.include_router(saved_searches_router)
|
||||||
app.include_router(expo_updates_router)
|
app.include_router(expo_updates_router)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from app.models.phash_backfill_queue import PhashBackfillQueue
|
||||||
from app.models.phash_blacklist import PhashBlacklist
|
from app.models.phash_blacklist import PhashBlacklist
|
||||||
from app.models.saved_search import SavedSearch
|
from app.models.saved_search import SavedSearch
|
||||||
from app.models.source_stats import SourceStats
|
from app.models.source_stats import SourceStats
|
||||||
|
from app.models.survey_response import SurveyResponse
|
||||||
from app.models.scene import (
|
from app.models.scene import (
|
||||||
Scene,
|
Scene,
|
||||||
SceneExternalRef,
|
SceneExternalRef,
|
||||||
|
|
@ -85,6 +86,7 @@ __all__ = [
|
||||||
"Source",
|
"Source",
|
||||||
"Studio",
|
"Studio",
|
||||||
"StudioAlias",
|
"StudioAlias",
|
||||||
|
"SurveyResponse",
|
||||||
"StudioExternalRef",
|
"StudioExternalRef",
|
||||||
"Tag",
|
"Tag",
|
||||||
"TagVerticalCount",
|
"TagVerticalCount",
|
||||||
|
|
|
||||||
37
app/models/survey_response.py
Normal file
37
app/models/survey_response.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""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
|
||||||
|
)
|
||||||
|
|
@ -59,6 +59,15 @@ def _bucket(age_h: float) -> tuple[str, str]:
|
||||||
return "swiezo", "warning"
|
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(
|
def run_ingest_freshness_watchdog(
|
||||||
*,
|
*,
|
||||||
max_age_hours: int = 48,
|
max_age_hours: int = 48,
|
||||||
|
|
@ -86,7 +95,10 @@ def run_ingest_freshness_watchdog(
|
||||||
|
|
||||||
# (origin, tabela, próg_h, klasa)
|
# (origin, tabela, próg_h, klasa)
|
||||||
checks: list[tuple[str, str, int, str]] = (
|
checks: list[tuple[str, str, int, str]] = (
|
||||||
[(f"tube:{t}", "playback_sources", max_age_hours, "browse") for t in sorted(browse_tags)]
|
[
|
||||||
|
(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", search_max_age_hours, "search")
|
+ [(f"tube:{t}", "playback_sources", search_max_age_hours, "search")
|
||||||
for t in sorted(search_tags)]
|
for t in sorted(search_tags)]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { ClientProvider } from './src/ClientContext';
|
||||||
import { PreferencesProvider } from './src/PreferencesContext';
|
import { PreferencesProvider } from './src/PreferencesContext';
|
||||||
import { SceneActionsProvider } from './src/SceneActionsContext';
|
import { SceneActionsProvider } from './src/SceneActionsContext';
|
||||||
import { OnboardingModal } from './src/components/OnboardingModal';
|
import { OnboardingModal } from './src/components/OnboardingModal';
|
||||||
|
import { SurveyModal } from './src/components/SurveyModal';
|
||||||
import { WhatsNewModal } from './src/components/WhatsNewModal';
|
import { WhatsNewModal } from './src/components/WhatsNewModal';
|
||||||
import { ErrorBoundary } from './src/ErrorBoundary';
|
import { ErrorBoundary } from './src/ErrorBoundary';
|
||||||
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
||||||
|
|
@ -514,6 +515,7 @@ export default function App() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<WhatsNewModal />
|
<WhatsNewModal />
|
||||||
|
<SurveyModal />
|
||||||
<OnboardingModal />
|
<OnboardingModal />
|
||||||
</SceneActionsProvider>
|
</SceneActionsProvider>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,33 @@ 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")
|
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 {
|
android {
|
||||||
ndkVersion rootProject.ext.ndkVersion
|
ndkVersion rootProject.ext.ndkVersion
|
||||||
|
|
||||||
|
|
@ -104,8 +131,8 @@ android {
|
||||||
applicationId 'com.goon.mobile'
|
applicationId 'com.goon.mobile'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 16
|
versionCode 17
|
||||||
versionName "0.4.2"
|
versionName "0.4.3"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,9 @@
|
||||||
gdy Chromium probowal podpiac skaner kodow (GOON-3B, 3 crashe od lipca). -->
|
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"/>
|
<meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
|
||||||
<activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/>
|
<activity android:name="com.reactnative.googlecast.RNGCExpandedControllerActivity"/>
|
||||||
<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">
|
<!-- 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">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
|
@ -63,12 +65,14 @@
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Android TV. Bez wpisu LEANBACK_LAUNCHER apka nie pojawia sie na ekranie glownym
|
<!-- 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>.
|
telewizora, mimo ze instaluje sie bez bledu. Banner (320x180) jest w <application>.
|
||||||
Osobna aktywnosc, bo tu NIE MA blokady pionu, patrz TvMainActivity. -->
|
Alias tej samej aktywnosci (nie osobna klasa): dwie ReactActivity naraz walily
|
||||||
<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">
|
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">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity-alias>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
package com.goon.mobile
|
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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
@ -13,9 +18,28 @@ import com.reactnative.googlecast.api.RNGCCastContext
|
||||||
|
|
||||||
import expo.modules.ReactActivityDelegateWrapper
|
import expo.modules.ReactActivityDelegateWrapper
|
||||||
|
|
||||||
// open, bo dziedziczy z niej TvMainActivity (to samo, tylko bez blokady pionu).
|
/**
|
||||||
|
* 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 class MainActivity : ReactActivity() {
|
open class MainActivity : ReactActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
if (!isTelevision()) {
|
||||||
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
// Set the theme to AppTheme BEFORE onCreate to support
|
// Set the theme to AppTheme BEFORE onCreate to support
|
||||||
// coloring the background, status bar, and navigation bar.
|
// coloring the background, status bar, and navigation bar.
|
||||||
// This is required for expo-splash-screen.
|
// This is required for expo-splash-screen.
|
||||||
|
|
@ -33,6 +57,12 @@ 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
|
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||||
* rendering of the component.
|
* rendering of the component.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -23,6 +23,10 @@ buildscript {
|
||||||
classpath('com.android.tools.build:gradle')
|
classpath('com.android.tools.build:gradle')
|
||||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||||
classpath('org.jetbrains.kotlin:kotlin-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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "goon",
|
"name": "goon",
|
||||||
"slug": "goon",
|
"slug": "goon",
|
||||||
"version": "0.4.2",
|
"version": "0.4.3",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": false,
|
"newArchEnabled": false,
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,26 @@ 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<{
|
async listMyBugReports(): Promise<{
|
||||||
items: {
|
items: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,13 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-10-12b',
|
||||||
date: 'October 2026',
|
date: 'October 2026',
|
||||||
|
|
|
||||||
464
mobile/src/components/SurveyModal.tsx
Normal file
464
mobile/src/components/SurveyModal.tsx
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
unseenEntries,
|
unseenEntries,
|
||||||
type ChangelogEntry,
|
type ChangelogEntry,
|
||||||
} from '../changelog';
|
} from '../changelog';
|
||||||
|
import { emitWhatsNewClosed } from '../lib/popupBus';
|
||||||
import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage';
|
import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
|
|
||||||
|
|
@ -45,6 +46,8 @@ export function WhatsNewModal() {
|
||||||
const dismiss = React.useCallback(() => {
|
const dismiss = React.useCallback(() => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
if (NEWEST_CHANGELOG_ID) setLastSeenChangelog(NEWEST_CHANGELOG_ID).catch(() => {});
|
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
|
// Nagłówek miesiąca RAZ na grupę, nie przy każdym wpisie. Wcześniej 17 sierpniowych
|
||||||
|
|
|
||||||
18
mobile/src/lib/popupBus.ts
Normal file
18
mobile/src/lib/popupBus.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,22 @@ export async function setLastSeenChangelog(id: string): Promise<void> {
|
||||||
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
|
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,
|
// Onboarding/tutorial, pokazany raz przy pierwszym odpaleniu (carousel: tabs,
|
||||||
// funkcje, longpressy, gesty playera). Wersjonowany klucz → bump gdy tutorial
|
// funkcje, longpressy, gesty playera). Wersjonowany klucz → bump gdy tutorial
|
||||||
// istotnie się zmieni i chcemy pokazać ponownie. Replay z ustawień przez bus.
|
// istotnie się zmieni i chcemy pokazać ponownie. Replay z ustawień przez bus.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue