- Add STREAM_BROWSER (.env) gating screen-share/browser mode. false => the /자비스 stream command stays voice + API/MCP only (no Go-Live); true (default) => screen share as before. (Browser-driven info retrieval in true mode is a follow-up build; the bot has no browser-control tools yet.) - Make the two test-time fixes broadcast-wide defaults via broadcast-helper.mjs: it now also watches every tab for HTML5 fullscreen and toggles Chrome window fullscreen so the address bar is hidden for ANY video (xfwm4 won't hide it on 'f' alone), restoring on exit. Subtitles were already enforced per video. scenario.mjs drops its own fullscreen toggle and relies on the helper. - Revert the test-settings env vars from .env.example (not wanted). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
152 lines
7.2 KiB
JavaScript
152 lines
7.2 KiB
JavaScript
// Browse scenario driven ENTIRELY with real mouse/keyboard input via xdotool
|
|
// (see human.mjs). Connects to a Chrome already running with
|
|
// --remote-debugging-port (default 9222) on the streamed X display.
|
|
//
|
|
// All ACTIONS are real input: address-bar navigation (Ctrl+L + typing),
|
|
// search typing, clicking the video, the settings gear -> 화질 -> 1080p menu,
|
|
// the autoplay toggle, the play button, fullscreen via the 'f' key, scrolling,
|
|
// and entering 나무위키. The CDP/DOM API is used ONLY to read state for
|
|
// verification (paused/quality/fullscreen) and as a rare click fallback when an
|
|
// element has no on-screen box.
|
|
//
|
|
// One environment workaround: on the streamed VNC desktop (xfwm4) Chrome does
|
|
// NOT hide its toolbar when a video enters HTML5 fullscreen ('f'), so the
|
|
// address bar bleeds into the broadcast. We therefore toggle the BROWSER window
|
|
// into Chrome-initiated fullscreen via CDP (Browser.setWindowBounds) around the
|
|
// 'f' step - that reliably hides the toolbar (innerHeight 1080 vs 988) - then
|
|
// restore it. This is a window-chrome action, not a page interaction.
|
|
import { chromium } from 'playwright';
|
|
import { humanClick, humanType, humanKey, humanHover, navigateOmnibox, humanScroll, sleep } from './human.mjs';
|
|
|
|
const CDP = process.env.CDP_PORT || '9222';
|
|
const VID = process.env.TEST_VIDEO_ID || 'X_am71G6Vy4';
|
|
const SEARCH = process.env.TEST_YT_QUERY || '내손을잡아';
|
|
const NAVER_Q = process.env.TEST_NAVER_QUERY || '아이유';
|
|
// MV_QUERY mode: search this query and auto-pick the first result that actually
|
|
// reports >=60fps (instead of clicking the fixed TEST_VIDEO_ID). WATCH_SECONDS
|
|
// is how long to watch windowed and fullscreen (default 20).
|
|
const MV_QUERY = process.env.MV_QUERY || '';
|
|
const WATCH_MS = parseInt(process.env.WATCH_SECONDS || '20', 10) * 1000;
|
|
|
|
// Subtitles (off-by-default, Korean-on) and YouTube ad-skipping are applied
|
|
// broadcast-wide by broadcast-helper.mjs, not here.
|
|
|
|
const b = await chromium.connectOverCDP(`http://localhost:${CDP}`);
|
|
const ctx = b.contexts()[0];
|
|
const page = ctx.pages()[0];
|
|
page.setDefaultTimeout(25000);
|
|
const read = (fn) => page.evaluate(fn);
|
|
const playerLoc = () => page.locator('#movie_player');
|
|
|
|
const fpsNow = () => read(() => {
|
|
try { const s = document.getElementById('movie_player').getStatsForNerds(); const m = (s.resolution || '').match(/@(\d+)/); return m ? +m[1] : null; } catch { return null; }
|
|
});
|
|
async function skipAdsQuick() {
|
|
for (let i = 0; i < 8; i++) {
|
|
const ad = page.locator('.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button');
|
|
if (await ad.count().catch(() => 0)) { await humanClick(page, ad.first()); await sleep(1200); } else break;
|
|
}
|
|
}
|
|
|
|
// 1) open YouTube by typing the URL in the address bar
|
|
await navigateOmnibox('https://www.youtube.com'); await sleep(3000);
|
|
|
|
// 2) really type the search and submit (fixed query, or the MV query)
|
|
await humanClick(page, page.locator('input#search, input[name=search_query]').first());
|
|
await humanType(MV_QUERY || SEARCH);
|
|
await humanKey('Return');
|
|
await sleep(3800);
|
|
|
|
// open a result with the real mouse, wait for the player, skip ads, ensure playing
|
|
async function openAndPlay(link) {
|
|
await humanClick(page, link);
|
|
await sleep(3500);
|
|
await page.waitForSelector('#movie_player', { timeout: 25000 }); await sleep(2000);
|
|
await skipAdsQuick();
|
|
if (await read(() => document.querySelector('video')?.paused)) {
|
|
await humanClick(page, page.locator('.ytp-large-play-button, .ytp-play-button').first());
|
|
}
|
|
await sleep(1500);
|
|
}
|
|
|
|
// 3) pick the video: in MV mode auto-pick the first result that really reports
|
|
// >=60fps; otherwise click the fixed concert clip.
|
|
if (MV_QUERY) {
|
|
const resultsUrl = `https://www.youtube.com/results?search_query=${encodeURIComponent(MV_QUERY)}&sp=EgQQARgD`;
|
|
let picked = false;
|
|
for (let i = 0; i < 5 && !picked; i++) {
|
|
const results = page.locator('ytd-video-renderer a#video-title, ytd-rich-item-renderer a#video-title');
|
|
if (!(await results.nth(i).count().catch(() => 0))) break;
|
|
await openAndPlay(results.nth(i));
|
|
const fps = await fpsNow();
|
|
console.log(`MV candidate ${i} fps=${fps}`);
|
|
if (fps && fps >= 60) { picked = true; break; }
|
|
await navigateOmnibox(resultsUrl); await sleep(3000);
|
|
}
|
|
if (!picked) console.log('MV: no >=60fps result found, using last opened');
|
|
} else {
|
|
let link = page.locator(`a#video-title[href*="${VID}"], a[href*="${VID}"]`).first();
|
|
if (!(await link.count().catch(() => 0))) link = page.locator('ytd-video-renderer a#video-title, ytd-rich-item-renderer a#video-title').first();
|
|
await openAndPlay(link);
|
|
}
|
|
|
|
// 5) set 1080p through the real settings menu (gear -> 화질 -> 1080p), verify
|
|
async function setQuality1080() {
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
await humanHover(page, playerLoc());
|
|
await humanClick(page, page.locator('.ytp-settings-button')); await sleep(900);
|
|
let qrow = page.locator('.ytp-menuitem', { hasText: /화질|Quality/ }).first();
|
|
if (!(await qrow.count().catch(() => 0))) qrow = page.locator('.ytp-panel-menu .ytp-menuitem').last();
|
|
await humanClick(page, qrow); await sleep(900);
|
|
const item = page.locator('.ytp-menuitem', { hasText: /1080/ }).first();
|
|
if (await item.count().catch(() => 0)) await humanClick(page, item);
|
|
await sleep(2000);
|
|
const q = await read(() => document.getElementById('movie_player')?.getPlaybackQuality?.());
|
|
if (q && /1080/.test(q)) return q;
|
|
}
|
|
return null;
|
|
}
|
|
console.log('QUALITY', await setQuality1080());
|
|
|
|
// 6) turn off autoplay with a real click if it is on
|
|
const auto = page.locator('.ytp-autonav-toggle-button');
|
|
if ((await auto.count().catch(() => 0)) && (await auto.getAttribute('aria-checked').catch(() => null)) === 'true') {
|
|
await humanHover(page, playerLoc());
|
|
await humanClick(page, auto);
|
|
}
|
|
console.log('STEP watch-1080-windowed'); await sleep(WATCH_MS);
|
|
|
|
// 7) fullscreen with the real 'f' key. broadcast-helper.mjs detects the HTML5
|
|
// fullscreen and hides Chrome's toolbar (window fullscreen) broadcast-wide, so
|
|
// the address bar stays off the stream.
|
|
await humanHover(page, playerLoc());
|
|
await humanKey('f'); await sleep(1800);
|
|
if (!(await read(() => !!document.fullscreenElement))) { await humanHover(page, playerLoc()); await humanKey('f'); await sleep(1500); }
|
|
console.log('STEP fullscreen', await read(() => ({ full: !!document.fullscreenElement, h: window.innerHeight }))); await sleep(WATCH_MS);
|
|
|
|
// 8) exit video fullscreen ('f'); the helper restores the toolbar
|
|
await humanKey('f'); await sleep(1500);
|
|
|
|
// 9) Naver via the address bar, then really type the query
|
|
await navigateOmnibox('https://www.naver.com'); await sleep(2800);
|
|
await humanClick(page, page.locator('input#query').first());
|
|
await humanType(NAVER_Q);
|
|
await humanKey('Return');
|
|
await sleep(2800);
|
|
await humanScroll(page, +1, 18);
|
|
console.log('STEP naver-scrolled');
|
|
|
|
// 10) enter 나무위키 with a real click, then scroll
|
|
const namu = page.locator('a[href*="namu.wiki"]').first();
|
|
if (await namu.count().catch(() => 0)) {
|
|
await humanClick(page, namu);
|
|
await sleep(3000);
|
|
await humanScroll(page, +1, 14);
|
|
await humanScroll(page, -1, 8);
|
|
await humanScroll(page, +1, 10);
|
|
console.log('STEP namu-scrolled');
|
|
} else console.log('STEP namu-not-found');
|
|
|
|
console.log('SCENARIO_DONE');
|
|
await b.close();
|