Files
vtube/docs/ADS.md

413 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tubev — Ads Integration
Реклама через **adspyglass.com**. Основне джерело монетизації — балансуємо CTR vs UX/PSI.
## Status
🟢 **Working knowledge** — Skeleton наповнюємо.
## ⚙️ Automated regression test — `ad-test`
Швидкий smoke перевірити що ad flow не поламано після зміни.
```bash
# default — 3 URLs з 8148 (швидкий smoke)
ad-test
# конкретні URLs
ad-test "https://www.xn--3dsq7teoyo9d.com/v/box/123/foo" \
"https://t1.atube.sex/v/box/456/bar"
# з screenshots + JSON report
ad-test --screenshot --json "https://..." "https://..."
```
**Що перевіряє per URL:**
1. ✓ ad-bundle loaded (`window._adConfig` set, spot IDs читаються)
2. ✓ popunder SDK loads
3.`/api/users/<vast_spot>` повертає `<Ad>` (не empty no-fill)
4. ✓ mode = "vast" після popunder cooldown
5.`.asg-container` injects на click `pjs_play_btn` (правильний `data-spot-id`)
6. ✓ console clean (no `[ASGB LOADER]` errors, no `pageerror`)
**Exit code:** 0 = всі pass, 1 = ≥1 fail. Можна chain у CI / git hook.
**Реалізація:**
- Wrapper: `/home/w4/bin/ad-test`
- Script: `/home/w4/playwright-tests/ad-regression.mjs` (Playwright + Chromium headless)
- Repo copy (versioned): `scripts/ad-regression.mjs` + `scripts/ad-test.sh`
**Обмеження headless:**
- Real video playback не verifies (autoplay restrictions, VPAID не fully supported у chromium-headless-shell)
- Network-level VAST chain validation (через `<Ad>/<InLine>/<Wrapper>` детекцію) cover 95% regressions без візуальної перевірки
- Якщо треба видеоплей — `ad-test --full` (full chromium, але VPAID все ще incomplete)
- Альтернатива для full visual: запусти у real browser з `?debug=1` + дивись HUD
## Architecture
### Mirrors (зеркала)
Реклама вантажиться через **зеркала-домени**, не прямо `adspyglass.com`:
- `a5.g--o.info` — ad orchestration (preconnect target у modern setup)
- `surstrom.com` — inline ad-spot loader (legacy pattern)
- `agego.com` — verifycdn (`verifycdn.agego.com/v1/verify.js`)
- `g--o.info` — base domain
- (інші додаються на потребу)
**Чому зеркала:** anti-block — adblockers баняють відомі ad-домени. Зеркала маскують та ротуються.
**Mirror lifecycle:**
- Зеркала **періодично баняться** (adblock list updates)
- Заміна — **mass-operation через Адміна** (одразу на всі сайти)
- Ми не керуємо вибором зеркал — це адмінська функція
### Three architecture patterns coexist (audit 2026-05-02)
Site-level audit показав **3 patterns** — не uniform:
| Pattern | Sites | Як виглядає | Примітка |
|---------|-------|-------------|----------|
| **A) Modern bundle** | 1 (тільки 8148) | `<script src="/static/js/ad-bundle.min.js?v=<md5>" defer>` + `views/modules/banners/*adspy*.etlua` partials | Canonical — є build pipeline, source JS у `views/static/js/` |
| **B) Modern partials** | ~23 | `preconnect``a5.g--o.info` + adspy `.etlua` partials, але **без** ad-bundle.min.js | Modules orchestrate, no central bundle |
| **C) Legacy inline** | 31 (8112-style) | Inline `<script async src="//surstrom.com/<random>.js">` + iframes до `surstrom.com/api/spots/<id>` + `tb.load_frame_baner_v2()` API | Pre-modular epoch |
**77 із 94 сайтів** мають adspy banner modules у `views/modules/banners/*adspy*.etlua`. Решта (17) — legacy без banner partials або special structure.
### Build pipeline (тільки 8148)
`/home/nosfortube/frontend_8148/views/static/js/build-ad-bundle.sh` концатує+minify-їть 5 source JS-файлів у `ad-bundle.min.js`:
1. `ad-config.js` — config / placement IDs / mirror domains
2. `ad-core.js` — ядро ad-orchestration
3. `ad-mute.js` — mute / autoplay control
4. `vast-preroll.js` — pre-roll реклама перед video
5. `ad-bootstrap.js` — entry-point / init
**Source files:** усі у `/home/nosfortube/frontend_8148/views/static/js/` (унікально для 8148).
Tool: **terser** (concat + minify). Triggered by `~/git-save-all.sh` коли source новіший за bundle.
Cache-bust: md5 → `?v=...` у `layout.etlua` (детально у [DEPLOY.md](DEPLOY.md#cache-bust-механізм)).
### Banner partial loader — `tb.load_frame_baner_v2`
Source: `views/static/js/lib/common/js/tbanner.etlua` (shared lib з кешуючою етлуа-обгорткою) + `tbanner_min.etlua` (minified inline). Підключається у layout.etlua через `<% render("static.js.lib.common.js.tbanner_min") %>`.
**Сигнатура:** `tb.load_frame_baner_v2(spot_url, dom_target, timeouts, attrs [, callback])`
- `spot_url``//a5.g--o.info/api/spots/<spot_id>?p=1`
- `dom_target``#selector` куди append iframe
- `timeouts``{event_min_timeout, event_max_timeout}` ms
- `attrs``{height, width, ...}` iframe attributes / style
- `callback` — викликається після append (для native — інсталяція postMessage listener-у)
**Lazy-load logic:**
- Чекає user event (`scroll`, `mousemove`, `touchstart`, `resize`, `mouseenter`, `click`) АБО `event_min_timeout` (раніше)
- Якщо tab hidden → відкладає до `visibilitychange`
- `event_max_timeout` — fallback hard limit
- Counter per banner — окрема черга для кожного instance
- iframe attrs default: `sandbox="allow-scripts allow-popups allow-forms allow-same-origin"`, `loading="lazy"`, `class="na"`
### Native banners — auto-height через postMessage
Native (`tbn1`, `tbn2`, `tbn3`) — adaptive ads з невідомою висотою. Pattern:
1. **На стороні ASG** користувач прописує код банера (TrafficStars або ExoClick) + кастом-CSS для responsive grid + кастом-JS який postMessage-ить розмір.
2. **На стороні сервера** ASG обертає у iframe (через `/api/spots/<id>` endpoint).
3. **Frontend** ловить `window.message` event від iframe → парсить JSON `{url, height}` → встановлює `height` attribute на iframe з `transition: all .3s`.
**Network providers за ASG (приклад інтеграції на adспy admin side):**
- **TrafficStars** — `cdn.tsyndicate.com/sdk/v1/n.js` + `NativeAd({element_id, spot, type: "label-under", cols:4, rows:1, ...})`
- **ExoClick** — `a.magsrv.com/ad-provider.js` + `<ins class="eas..." data-zoneid="..." data-keywords="%KW%" data-sub="%SUB1%"></ins>` + `(AdProvider).push({"serve":{}})`
Обидва вкладають кастомний `<style>` з 4-col responsive grid (≥739px: 4 cols, 450-738: 2, <450: 1). Плюс postMessage скрипт що шле parent розмір на load/resize/mutation.
ASG керує цими SDK runtime-no — ми бачимо тільки iframe.
### Spot ID location matrix (8148 — 16 spots)
| Тип | Файл | Spot ID location |
|-----|------|-----------------|
| popunder | `views/static/js/ad-config.js` | `popunder.spot` |
| VAST/in-video | `views/static/js/ad-config.js` | `vast.spot` |
| popunder cooldown matcher | `views/layout.etlua` | inline regex `(?:^|\|)<id>=` (must sync з ad-config) |
| desktop footer ×4 | `views/modules/banners/footer_descktop_adspy.etlua` | hardcoded URL `spots/<id>?p=1` |
| mobile footer/header/middle | `views/modules/banners/{footer,header,middle}_mobile_adspy.etlua` | same |
| desktop sidebar ×2 | `views/modules/banners/embed_sidebar_adspy.etlua` | same |
| mobile sidebar ×2 | `views/modules/banners/embed_mobile_sidebar_adspy.etlua` | same |
| native (3 partials) | `views/modules/banners/native_*adspy.etlua` | same |
**При зміні spot IDs (rotation / re-creation):** оновити ВСІ перелічені місця, потім `bash /home/w4/git-save-all.sh "msg"` — auto-rebuild bundle + cache-bust.
⚠️ **Не забути `layout.etlua` cooldown regex** — окрема hardcoded reference на popunder spot ID (для localStorage `asgsl` cookie matching).
### Native 1thumb_a..f — single-banner-per-thumb pattern
`views/modules/banners/native_allpg_1thumb_{a,b,c,d,e,f}.etlua`**6 окремих banner модулів**, кожен:
- ОДИН `<div id="thmb<N>">` (унікальний DOM ID `#thmb1``#thmb6`)
- ОДИН spot URL `//a5.g--o.info/api/spots/<id>?p=1`
- script `tb.load_frame_baner_v2(spotURL, "#thmb<N>", ...)` injects iframe всередину
**Призначення:** заміняти thumbnail у grid-листингах (`cat_list_thumb`, `video.etlua`, `related_video`, `recommended_video`) на native banner imp що виглядає як thumb.
**Render умова:** if `i == 8 and not isMobile()` (desktop) **OR** `i == 5 and isMobile()` (mobile) — позиція у grid де banner injects.
⚠️ **Унікальність DOM ID per page:** один render = один module = один `#thmbN`. Якщо рендериш 1thumb_a у двох різних модулях (наприклад related_video AND recommended_video) на одній сторінці — отримаєш **DUPLICATE `#thmb1`** (HTML5 violation, ad iframe injects only into first match). Fix: використовуй РІЗНІ модулі (`1thumb_a` + `1thumb_b`) у різних блоках.
### Per-site spot inventory
#### 8148 (xn--3dsq7teoyo9d.com — pilot site)
Канонічний reference. Spot IDs у git history; перелік типів — у matrix вище.
#### 8161 (adultmovz.com)
Migrated 2026-05-03 (videojs4 → PlayerJS+ad-bundle). URL pattern `/v-arch/`.
| Spot ID | Тип | DOM target | File |
|---------|-----|------------|------|
| **514125** | popunder | — | `static/js/ad-config.js` + cooldown regex у `layout.etlua` |
| **514120** | VAST in-video | `pjs_play_btn` click | `static/js/ad-config.js` |
| 514116 | desktop footer A | `#da_a` | `modules/banners/footer_descktop_adspy.etlua` |
| 514117 | desktop footer B | `#da_b` | same |
| 514118 | desktop footer C | `#da_c` | same |
| 514119 | desktop footer D | `#da_d` | same |
| 514123 | mobile header | `#hdm` | `modules/banners/header_mobile_adspy.etlua` |
| 514124 | mobile footer | `#ftm` | `modules/banners/footer_mobile_adspy.etlua` |
| 514138 | mobile middle | `#mdm` | `modules/banners/middle_mobile_adspy.etlua` |
| 514121 | desktop sidebar A | `#sdd_a` | `modules/banners/embed_sidebar_adspy.etlua` |
| 514122 | desktop sidebar B | `#sdd_b` | same |
| 514139 | mobile sidebar A | `#dsd_a` | `modules/banners/embed_mobile_sidebar_adspy.etlua` |
| 514140 | mobile sidebar B | `#dsd_b` | same |
| 514295 | native desktop allpg | `#tbn1` | `modules/banners/native_allpg_desktop_adspy.etlua` |
| 514297 | native mobile allpg | `#tbn2` | `modules/banners/native_allpg_mobile_adspy.etlua` |
| 514296 | native embed | `#tbn3` | `modules/banners/native_embed_adspy.etlua` |
| 514371 | native 1thumb A | `#thmb1` | `modules/banners/native_allpg_1thumb_a.etlua` (use: related_video, cat_list_thumb, video) |
| 514372 | native 1thumb B | `#thmb2` | `1thumb_b.etlua` (use: recommended_video, cat_list_thumb, video) |
| 514373 | native 1thumb C | `#thmb3` | `1thumb_c.etlua` (cat_list_thumb, video) |
| 514374 | native 1thumb D | `#thmb4` | `1thumb_d.etlua` (cat_list_thumb, video) |
| 514375 | native 1thumb E | `#thmb5` | `1thumb_e.etlua` (cat_list_thumb, video) |
| 514376 | native 1thumb F | `#thmb6` | `1thumb_f.etlua` (cat_list_thumb, video) |
## Migration checklist — legacy (videojs4) → modern (PlayerJS + ad-bundle)
Контрольні чекпоінти при міграції наступного site (template після 8148/8161 досвіду). Кожен ✓ — окремий atomic commit.
### Pre-flight
- [ ] **Identify URL pattern.** `/v/` (default), `/v-arch/`, `/video/`, etc. Це йде у `ad-bootstrap.js` path checks (2 occurrences) і у тестових URL-ах.
- [ ] **Get spot IDs from ASG admin.** Мінімум 16 spots (popunder + VAST + 4 desktop footer + 1 mobile header + 1 mobile footer + 1 mobile middle + 2 desktop sidebar + 2 mobile sidebar + 3 native + опціонально 6 1thumb_a-f). Записати mapping.
- [ ] **Verify ASG spot config:** **frequency capping ENABLED** на popunder + VAST (інакше cooldown поломається — див. `## ⚠️ ASG spot config gotcha` вище).
### Banner modules
- [ ] **Replace banner spot IDs** у всіх `views/modules/banners/*adspy*.etlua` + `1thumb_*.etlua` (per inventory). Sed-batch friendly.
- [ ] **Verify zero stale IDs** після replace: `grep -r '<old_id>' views/` повинен returnати empty (test/comments OK).
### JS bundle (port from 8148)
- [ ] **Copy 5 source files**`views/static/js/`: `ad-config.js`, `ad-bootstrap.js`, `ad-core.js`, `ad-mute.js`, `vast-preroll.js` + `build-ad-bundle.sh`.
- [ ] **Edit `ad-config.js`:** set `popunder.spot`, `vast.spot`, `_adConfigBuildId` (унікальний marker для cache-bust verify).
- [ ] **Edit `ad-bootstrap.js`:** додати site URL pattern до **обох** path checks (HUD interval + `_adCtx` init), напр. `||location.pathname.indexOf('/v-arch/')===0`.
- [ ] **Build bundle:** `bash views/static/js/build-ad-bundle.sh` (concat + terser).
### layout.etlua
- [ ] **Add `class="no-js"` на `<html>`** + inline script `<head>` swap до `.js` (для iOS Safari `.js .vi-limiter video::-webkit-media-controls{display:none}` selector).
- [ ] **Add `<link rel="preconnect" href="https://a5.g--o.info" crossorigin>`.**
- [ ] **Add `_adSel` inline config** у `mysettings.location_css == "id"` block з selector mapping (native classes — `vi-limiter` у 8161, `vdo-blk-lmtr` у 8148).
- [ ] **Tabunder pre-detect script** з popunder spot ID regex `(?:^|\|)<popunder_id>=` (синхронізовано з ad-config!).
- [ ] **`<script src="/static/js/ad-bundle.min.js?v=<md5>" defer></script>`** з cache-bust md5.
- [ ] **Inline `lazyLoadFunc` definition**`views/static/js/lazysizes.min.js` content) ПІСЛЯ inline lazysizes core block — інакше `timeline-pjs.min.js` кине `ReferenceError: lazyLoadFunc is not defined` (8161 fix 2026-05-04).
- [ ] **Body inline scripts** (port 8148 lines 184-434): `_initPjs()`, `_revealPjs()`, `_playAt()`, click handler на `.<lmtr_class>` capture phase, VAST overlay click forwarder.
- [ ] **Guard counter calls:** `tb.start_events_v2(() => { if(typeof c==="function") c(<%-video.video_id%>,0,"click",0); }, ...)``c()` defined у async-loaded `counters.v2.min.js`, без guard race-error (`c is not defined`).
### id_index.etlua
- [ ] **Replace videojs4 DOM** з PlayerJS structure: `<div class="<lmtr_class> is-loading"><div class="<vdo_class> js-cleanVideo" id="pjs_container">` + `<video>` + `<div id="pjs_poster">` + `<img id="pjs_poster_img">` + `<div id="pjs_play_btn">` SVG + `<div id="pjs_loader">` SVG.
- [ ] **Use site-NATIVE classes** (не copy 8148 `vdo-blk-lmtr`/`vdo-blk-vdo`). Map до існуючих сайту: 8161 → `vi-limiter`/`vi-player`. Foreign classes створюють **footprint** і CSS дублі.
- [ ] **Inline styles target NATIVE classes** теж (`<style>.vi-limiter ...</style>`). Без дзеркал sed.
- [ ] **Timeline click handler:** `<a onClick="_playAt(_SECOND_)">` (НЕ legacy `player.setTime`).
- [ ] **Poster touch handler script:** localStorage `_pw` mark на `touchstart`/`mousedown` для tabunder pre-detect.
### CSS site-side (адаптивні правки)
- [ ] **Tooltip overlap:** `.<actions>{position:relative;z-index:5001}` — підняти actions row над `.asg-container{z-index:5000}`. Plus `.<msg-class>{z-index:10000}` як defense.
- [ ] **Timeline thumb hover-play:** `.<thumbs-block> .<item>.<can-play> a:hover .<img-class>:after{display:block}` + mobile `@media(max-width:768px){.<thumbs> ... :after{transform:translate(-50%,-50%) scale(.6)}}`.
- [ ] **Title wrap (`.heading-2` truncation):** `.<title-head> .<heading-2>{white-space:normal;overflow-wrap:anywhere;...}` — override будь-який single-line ellipsis.
- [ ] **`.<thumbs-block>` mobile scaling** — adapt 8148 patterns до native classes site-у.
### Verification
- [ ] **`ad-test "<test_url>"`** → 6/6 PASS.
- [ ] **Empirical Playwright probe:** capture `pageerror` array — має бути empty. Перевірити specifically `c is not defined`, `lazyLoadFunc is not defined`, `c2.min.js Unexpected identifier 'o'`.
- [ ] **Validate `c2.min.js` syntax:** `node --check views/static/js/c2.min.js` — passing. Якщо source `c2.js` edited без re-minify → broken `let` declarations у comma-sequence; fix: `terser views/static/js/c2.js -c -m -o views/static/js/c2.min.js`.
- [ ] **Banner DOM uniqueness:** `await page.evaluate(() => Array.from(document.querySelectorAll('iframe[src*="api/spots"]')))` collect → no duplicate parent IDs (`#thmb1` 2× = bug).
- [ ] **VAST endpoint probe:** `curl 'https://a5.g--o.info/api/users/<vast_spot>?v2=1&fill=0&url=<page-url>'` returns `<Ad>` (не empty `<VAST/>` — no-fill).
- [ ] **Popunder endpoint probe:** `curl 'https://a5.g--o.info/api/users/<popunder_spot>?host=<domain>&...'` returns `window.__NA.renderSpot({...})`.
- [ ] **t1 CDN serves fresh bundle:** `curl -sL "https://t1.<domain>/static/js/ad-bundle.min.js" | grep -oE 'spot:"[0-9]+"'` — bundle reflects новий config.
- [ ] **Real-browser smoke:** click play → VAST shows (after popunder cooldown set), click like → tooltip opens above player chrome, hover related thumb → play icon, hover timeline scene → smaller play icon (mobile) / regular (desktop).
### Post-deploy
- [ ] **Monitor adspyglass dashboard** після prod deploy — graph має продовжувати ріст; падіння = регресія.
- [ ] **Update site row у [SITES.md](SITES.md)** з generation tag (`v3 PlayerJS`).
- [ ] **Update memory `project_player_roadmap.md`** — додати site до v3 list.
## ASG SDK filename patterns (anti-footprint)
`a5.g--o.info/<filename>.js` server tolerates **будь-який filename matching pattern per type** — content однаковий. Use unique per site щоб diluteти footprint (adblock fingerprints).
**Patterns** (verified 2026-05-05):
- **POPUNDER**: 7 chars total, `^[A-Za-z0-9]{6}[0-9]$` (last char digit)
- **VAST**: 7 chars total, `^[A-Za-z0-9]{6}[a-hA-H]$` (last char `a-h` case-ins; letters i+ → 404)
- **BANNER `tb_config.banner_source`**: локальний файл у `views/static/js/<name>.js` per site, не ASG endpoint — окремий footprint layer (rename файла + update reference)
**Workflow:** generate per-site filenames → verify via `curl -sI` → apply mass-replace у `ad-config.js` (popunder.sdk, vast.sdk) → rebuild bundle → update `?v=<md5>` cache-bust у layout.
Detail: memory `reference_ad_filename_patterns` (15-site mapping applied 2026-05-05).
## Monitoring
### adspyglass dashboard — best diagnostic signal
`https://app.adspyglass.com/dashboard`
**Чому це best signal:**
- Live графік показу реклами per site
- **Падіння графіка** = реклама не показується = ймовірно поломка (frontend/template/JS error / banned mirror / CDN cache stale)
- **Ріст** = трафік підріс або UX покращився
Юзер каже: "більший показник саме для мене це лайв графік реклами від АСГ" — реклама падає миттєво коли щось не так, ще до того як прийдуть алерти.
### Інші alerts
- **Telegram + email** — критичні (downtime, errors)
- Billing dashboard `https://billing.g--o.info/cs/oursites/dashboard/` — статистика (зазвичай не дивимось)
### Template patterns
**Pattern A (8148 modern):**
```etlua
<link rel="preconnect" href="https://a5.g--o.info" crossorigin>
<script src="/static/js/ad-bundle.min.js?v=<%= ad_bundle_v %>" defer></script>
...
<% render("views.modules.banners.header_mobile_adspy") %>
<% render("views.modules.banners.footer_mobile_adspy") %>
<% render("views.modules.banners.footer_descktop_adspy") %>
```
**Pattern C (8112 legacy inline):**
```etlua
<link rel="dns-prefetch" href="//surstrom.com">
...
<script async src="//surstrom.com/qDap9.js"></script>
<iframe class="na" src="//surstrom.com/api/spots/72437?p=1" sandbox="..." loading="lazy"></iframe>
...
tb.load_frame_baner_v2("//surstrom.com/api/spots/72437?p=1","#tb0",{...},{...});
```
## Що треба з'ясувати + задокументувати
### Architecture migration
- [ ] Чи планується мігрувати pattern C → B/A (legacy → modern)?
- [ ] 8148 — pilot для bundle-architecture, чи поширюватиметься?
### CSP / Security
- [ ] CSP exceptions для зеркал — як підтримуються?
- [ ] Mirror swap — чи треба update CSP при заміні?
### Performance impact
- [ ] PSI вплив реклами — % LCP / TBT (per pattern)?
- [ ] Lazy-load ads below-fold — реалізовано (видно `loading="lazy"` на iframes)?
## VAST flow (8148 detailed)
### User experience
1. Користувач клацає play на `pjs_play_btn` (PlayerJS player)
2. `asgInVideoImpression` event від ASG SDK → `_decideAndApply` ловить → запускає `VastPreroll.show()`
3. ASG іnject-ить `.asg-container` з video preroll
4. `MutationObserver` ловить container → pin pointer + z-index 5000, добавляє progress bar (`.vast-progress` yellow #ffd000)
5. Coordinated mute: video у container синхронізується з `_vastMuted` localStorage (per-tab persist)
6. Після VAST done (video.ended OR `currentTime >= duration - 0.2`) → callback `window._asgAdDone()` → main video starts via `window._revealPjs()`
7. `enableMediaPlayHijack: true` блокує race у HTMLMediaElement.play() якщо `_adsLocked` — попереджає 1-frame audio artifact коли VAST стартує над main video
### Cooldown management
- ASG локальне сховище: `localStorage.asgsl` — pipe-separated `<spot_id>=<key>:<value>` записи. Ключі що використовуються нашим bundle:
- `global_rr:<timestamp>``_readCooldowns` parse-ить це як cooldown end time
- `n:<timestamp>` — impression handler копіює це у `_popRr` / `_vastRr` cookies+LS
- Дублюючі cookies: `_popRr`, `_vastRr`. Реплікуються з asgsl при impression event.
- Декрипт пам'яті: дві ключові події з ASG — `asgPopunderImpression`, `asgInVideoImpression` — тригерять запис cooldown.
### ⚠️ ASG spot config gotcha — frequency-capping mandatory
**Bug 2026-05-02 (8148, after account recreation):** popunder spot 514208 у новому ASG account-і був налаштований з `shows_limit:1` без frequency-capping rotation. Симптоми:
- popunder СПрацьовує (видно `asgPopunderImpression`)
- Але `asgsl` локально вигляд: `<spot>=keep_looping:false,tabunder:false,uuid:...,noloop:true,shows_limit:1`
- **Нема `n:<timestamp>`** → impression handler виходить без write `_popRr`
- **Нема `global_rr:<timestamp>`** → `_readCooldowns` повертає `popActive=false`
- → mode завжди "pop" → popunder fires кожен pageload → VAST SDK ніколи не loadиться
**Fix у ASG admin** (на стороні юзера, не у нас):
- Відкрити spot config → знайти "Global rotation" / "Frequency capping" / "Cooldown" / "Time-based capping" / "Show frequency"
- Встановити, наприклад, "once per 2 hours per user" — це додасть `n:` / `global_rr:` поля в asgsl
- **Те саме для VAST spot** — інакше після популярного pop firing, VAST mode triggered але cooldown поломаний
**Як перевірити:** після popunder fire відкрий DevTools → `localStorage.getItem('asgsl')` → шукай `global_rr:` або `n:`. Якщо нема → не налаштовано.
### ⚠️ ASG no-fill diagnostic — VAST returns empty `<VAST/>`
**Bug 2026-05-02 (8148, VAST never visible):** Mode перемикався у "vast", VAST SDK loaded, але реклама не з'являлась візуально.
Перевірка endpoint напряму:
```bash
curl 'https://a5.g--o.info/api/users/<vast_spot>?v2=1&fill=0&url=<page-url>'
```
Якщо response:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<VAST version="3.0">
</VAST>
```
— це **VAST no-fill**. ASG не має inventory для цього spot.
**Порівняй з popunder endpoint** який повертає повний JSON-like config з `creative_id`, `behavior`, тощо:
```js
window.__NA.renderSpot({ spot_id: ..., creative_id: 83660, config: {...} });
```
**Можливі причини no-fill (по пріоритету):**
1. Spot **type ≠ "In-video VAST"** — створено як Banner/Popunder/etc → VAST endpoint не має чого serve
2. Spot **Status pending review** — створено, чекає approval
3. **Creative не attached** — VAST потребує специфічний ад чи bidder pool
4. **Bidder / network не linked** — VAST джерело мережі (ExoClick VAST / TS / etc) не призначено
5. Budget / cap exhausted
**Як остаточно verify:** через playwright headless + manual inject `_popRr` форсити VAST mode → click play → дивитися чи з'являється `.asg-container`. Якщо ні + curl показує empty VAST → no-fill.
### Mode decision (`AdCore._decide`)
```
mode = popActive ? (vastActive || skipPattern ? "none" : "vast") : "pop"
```
| popunder | vast | mode |
|----------|------|------|
| available (cooldown OFF) | — | **pop** (priority) |
| у cooldown (recent fire) | available + не skip-pattern | **vast** |
| у cooldown | у cooldown OR skip-pattern | none |
**Implicit dependency:** VAST показується **тільки після того як popunder уже спрацював** і поставив cooldown. На свіжій сесії (нема `_popRr` у localStorage / cookies) → mode завжди "pop", VAST SDK навіть не завантажується.
⚠️ Це **gotcha при тестуванні**: якщо щойно фікснули popunder spot — VAST не з'явиться поки popunder не спрацює хоча б раз. Юзер має думати що "VAST зламаний", а він просто заблокований mode logic.
**Force VAST testing**: `?clearAds=1` URL param очищає `_popRr/_vastRr/_vastPatternIdx/_pw/asgsl/_pjsLog` — фрешний state. Тоді: page load → pop fires → cooldown set → refresh → mode=vast → VAST SDK loaded.
`vastPolicy "show-1-skip-1"` додатково: idx=0 → SHOW, idx=1 → SKIP (incremented per VAST impression). На свіжій сесії idx=0 → перша VAST показується, друга skip-неться.
### Policy choice
`pop-priority` policy + `show-1-skip-1` для VAST — **навмисний баланс ревеню vs UX**:
- Popunder показується відносно рідко (cap від ASG високий)
- VAST спрацьовує часто, тому штучно скачуємо до 50% (`show-1-skip-1`) — менше тиску на юзера, не вбиває engagement
- Якщо колись треба максимізувати ревеню → `vastPolicy: 'show-always'`
## Open questions for developer
1. Як здійснюється mirror swap — sed-replace у всіх template/JS файлах через Адміна, runtime config, чи інший механізм?
2. Які pattern використовується для НЕ-77 сайтів (17 без adspy banner modules) — special-case structure, "no ads" сайти, чи щось інше?
3. SDK URLs (`nDNVal3.js`, `9iO21Eb.js`) — чи стабільні чи можуть змінитися при mirror rotation?