diff --git a/README.md b/README.md index 450ff0d7c..0fbb7b6b2 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ To load the source Browser Bridge extension: | **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` | | **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` | | **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` | +| **douyu** | `search` `watch` `follow` `unfollow` `danmaku` `daily-task` | | **tieba** | `hot` `posts` `search` `read` | | **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | | **twitter** | `trending` `search` `timeline` `tweets` `lists` `list-tweets` `list-add` `list-remove` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` | diff --git a/README.zh-CN.md b/README.zh-CN.md index e5a88cfbe..34e5dc4ca 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -237,6 +237,7 @@ npm link | **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 浏览器 | | **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 桌面端 | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `video` `comments` `dynamic` `ranking` `following` `user-videos` `download` | 浏览器 | +| **douyu** | `search` `watch` `follow` `unfollow` `danmaku` `daily-task` | 公开 / 浏览器 | | **codex** | `status` `send` `read` `new` `dump` `extract-diff` `model` `ask` `screenshot` `projects` `history` `export` | 桌面端 | | **chatwise** | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | 桌面端 | | **doubao** | `status` `new` `send` `read` `ask` `history` `detail` `meeting-summary` `meeting-transcript` | 浏览器 | diff --git a/cli-manifest.json b/cli-manifest.json index 11db2df2b..6f007e2ac 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -8704,6 +8704,328 @@ "sourceFile": "douyin/videos.js", "navigateBefore": "https://creator.douyin.com" }, + { + "site": "douyu", + "name": "daily-task", + "aliases": [ + "task" + ], + "description": "随机打开斗鱼直播间完成关注、弹幕、直播观看和视频观看每日任务", + "access": "write", + "example": "opencli douyu daily-task --window foreground --keep-tab true -f yaml", + "domain": "www.douyu.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "message1", + "type": "str", + "default": "打卡", + "required": false, + "help": "First danmaku text, max 50 chars" + }, + { + "name": "message2", + "type": "str", + "default": "主播加油", + "required": false, + "help": "Second danmaku text, max 50 chars" + }, + { + "name": "source", + "type": "str", + "default": "https://www.douyu.com/directory/all", + "required": false, + "help": "Directory/category URL used as the random room source" + }, + { + "name": "pool", + "type": "int", + "default": 80, + "required": false, + "help": "Maximum candidate rooms to sample from" + }, + { + "name": "delay", + "type": "int", + "default": 3, + "required": false, + "help": "Seconds to wait between the two danmaku messages" + }, + { + "name": "watch-minutes", + "type": "int", + "default": 40, + "required": false, + "help": "Minutes to keep the selected live room playing after task actions" + }, + { + "name": "check-interval", + "type": "int", + "default": 30, + "required": false, + "help": "Seconds between playback keepalive checks" + }, + { + "name": "video-source", + "type": "str", + "default": "https://v.douyu.com/video/videotag/list?tagId=15", + "required": false, + "help": "Douyu video listing URL used as the video source" + }, + { + "name": "video-pool", + "type": "int", + "default": 120, + "required": false, + "help": "Maximum candidate videos to sample from" + }, + { + "name": "video-watch-minutes", + "type": "int", + "default": 15, + "required": false, + "help": "Minutes of actual video playback progress to accumulate after live watching" + }, + { + "name": "video-check-interval", + "type": "int", + "default": 20, + "required": false, + "help": "Seconds between video playback progress checks" + }, + { + "name": "max-videos", + "type": "int", + "default": 5, + "required": false, + "help": "Maximum videos to use if shorter videos end before target time" + }, + { + "name": "timeout", + "type": "int", + "default": 3900, + "required": false, + "help": "Runtime timeout in seconds; must exceed live and video watch time" + }, + { + "name": "verify", + "type": "bool", + "default": true, + "required": false, + "help": "Hover account avatar and collect task/experience evidence after watching" + }, + { + "name": "strict-verify", + "type": "bool", + "default": true, + "required": false, + "help": "Fail when the task/experience panel cannot be verified" + }, + { + "name": "dry-run", + "type": "bool", + "default": false, + "required": false, + "help": "Only choose and open the room; do not follow or send danmaku" + } + ], + "columns": [ + "room", + "streamer", + "title", + "follow_result", + "danmaku1", + "danmaku2", + "live_watch", + "video_watch", + "videos", + "verification", + "verification_evidence", + "status", + "url" + ], + "type": "js", + "modulePath": "douyu/daily-task.js", + "sourceFile": "douyu/daily-task.js", + "navigateBefore": false, + "siteSession": "persistent" + }, + { + "site": "douyu", + "name": "danmaku", + "aliases": [ + "send" + ], + "description": "向斗鱼直播间发送普通弹幕", + "access": "write", + "example": "opencli douyu danmaku 6979222 \"hello\" -f yaml", + "domain": "www.douyu.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "room", + "type": "str", + "required": true, + "positional": true, + "help": "Douyu room id or room URL" + }, + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Danmaku text, max 50 chars" + } + ], + "columns": [ + "room", + "streamer", + "text", + "result", + "url" + ], + "type": "js", + "modulePath": "douyu/danmaku.js", + "sourceFile": "douyu/danmaku.js", + "navigateBefore": false, + "siteSession": "persistent" + }, + { + "site": "douyu", + "name": "follow", + "description": "关注斗鱼直播间主播", + "access": "write", + "example": "opencli douyu follow 6979222 -f yaml", + "domain": "www.douyu.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "room", + "type": "str", + "required": true, + "positional": true, + "help": "Douyu room id or room URL" + } + ], + "columns": [ + "room", + "streamer", + "result", + "url" + ], + "type": "js", + "modulePath": "douyu/follow.js", + "sourceFile": "douyu/follow.js", + "navigateBefore": false, + "siteSession": "persistent" + }, + { + "site": "douyu", + "name": "search", + "description": "搜索斗鱼直播间", + "access": "read", + "example": "opencli douyu search 英雄联盟 --limit 10 -f yaml", + "domain": "www.douyu.com", + "strategy": "public", + "browser": false, + "args": [ + { + "name": "query", + "type": "str", + "required": true, + "positional": true, + "help": "Search keyword" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Number of live room results to return (1-50)" + } + ], + "columns": [ + "rank", + "room", + "streamer", + "title", + "category", + "hot", + "live_status", + "url" + ], + "type": "js", + "modulePath": "douyu/search.js", + "sourceFile": "douyu/search.js" + }, + { + "site": "douyu", + "name": "unfollow", + "description": "取消关注斗鱼直播间主播", + "access": "write", + "example": "opencli douyu unfollow 6979222 -f yaml", + "domain": "www.douyu.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "room", + "type": "str", + "required": true, + "positional": true, + "help": "Douyu room id or room URL" + } + ], + "columns": [ + "room", + "streamer", + "result", + "url" + ], + "type": "js", + "modulePath": "douyu/unfollow.js", + "sourceFile": "douyu/unfollow.js", + "navigateBefore": false, + "siteSession": "persistent" + }, + { + "site": "douyu", + "name": "watch", + "description": "打开斗鱼直播间并返回当前直播状态", + "access": "read", + "example": "opencli douyu watch 6979222 -f yaml", + "domain": "www.douyu.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "room", + "type": "str", + "required": true, + "positional": true, + "help": "Douyu room id or room URL" + } + ], + "columns": [ + "room", + "title", + "streamer", + "category", + "followers", + "live_status", + "live_status_reason", + "video_status", + "url" + ], + "type": "js", + "modulePath": "douyu/watch.js", + "sourceFile": "douyu/watch.js", + "navigateBefore": false, + "siteSession": "persistent" + }, { "site": "eastmoney", "name": "announcement", diff --git a/clis/douyu/daily-task.js b/clis/douyu/daily-task.js new file mode 100644 index 000000000..32c299690 --- /dev/null +++ b/clis/douyu/daily-task.js @@ -0,0 +1,357 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { DOUYU_HOST, extractRoomSummary, requireText } from './utils.js'; +import { followRoom } from './follow.js'; +import { sendDanmaku } from './danmaku.js'; +import { DEFAULT_VIDEO_SOURCE, runVideoWatchTask } from './video-task.js'; + +function buildRoomPickerScript(limit) { + return ` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const seen = new Map(); + for (const a of document.querySelectorAll('a[href]')) { + const href = a.getAttribute('href') || a.href || ''; + const match = href.match(/(?:^\\/|douyu\\.com\\/)(\\d{2,})(?:[?#]|$)/); + if (!match) continue; + + const room = match[1]; + const prev = seen.get(room) || {}; + const text = clean(a.innerText || a.getAttribute('title') || ''); + if (/我的关注|浏览历史|排行榜|全部分类/.test(text) && !/已开播|\\d+(?:\\.\\d+)?万/.test(text)) continue; + seen.set(room, { + room, + title: prev.title || a.getAttribute('title') || text, + text: prev.text || text, + url: ${JSON.stringify(DOUYU_HOST)} + '/' + room, + }); + } + return Array.from(seen.values()) + .filter((item) => item.room && item.room !== '0') + .slice(0, ${Number(limit) || 80}); + })() + `; +} + +function buildRoomPickerDebugScript() { + return ` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const anchors = Array.from(document.querySelectorAll('a[href]')); + return Object.assign(Object.create(null), { + url: location.href, + title: document.title || '', + anchor_count: anchors.length, + room_like_hrefs: anchors + .map((a) => Object.assign(Object.create(null), { href: a.getAttribute('href') || '', text: clean(a.innerText || a.textContent || '').slice(0, 80) })) + .filter((item) => /(?:^\\/|douyu\\.com\\/)(\\d{2,})(?:[?#]|$)/.test(item.href)) + .slice(0, 8), + body: clean(document.body?.innerText || '').slice(0, 240), + }); + })() + `; +} + +async function pickRandomRoom(page, kwargs) { + const sourceUrl = kwargs.source || `${DOUYU_HOST}/directory/all`; + const poolLimit = Math.max(1, Math.min(Number(kwargs.pool || 80), 200)); + await page.goto(sourceUrl, { waitUntil: 'load', settleMs: 3000 }); + await page.wait({ time: 2 }); + await page.autoScroll({ times: 2, delayMs: 800 }); + + let rooms = []; + for (let i = 0; i < 15; i++) { + const result = await page.evaluate(buildRoomPickerScript(poolLimit)); + rooms = Array.isArray(result) ? result : Array.isArray(result?.data) ? result.data : []; + if (Array.isArray(rooms) && rooms.length > 0) break; + await page.wait({ time: 1 }); + await page.autoScroll({ times: 1, delayMs: 300 }); + } + if (!Array.isArray(rooms) || rooms.length === 0) { + const debug = await page.evaluate(buildRoomPickerDebugScript()); + throw new CommandExecutionError(`No live Douyu rooms found from directory page: ${JSON.stringify(debug)}`); + } + + const index = Math.floor(Math.random() * rooms.length); + return { ...rooms[index], pool_size: rooms.length }; +} + +function buildKeepAliveScript() { + return ` + (async () => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const videos = Array.from(document.querySelectorAll('video')); + const video = videos.find((el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 100 && rect.height > 80; + }) || videos[0]; + + let play_attempted = false; + if (video) { + try { + if (video.paused || video.ended || video.readyState < 2) { + await video.play(); + play_attempted = true; + } + } catch { + const rect = video.getBoundingClientRect(); + const target = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + (target || video).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + await sleep(500); + try { + await video.play(); + play_attempted = true; + } catch {} + } + } + + document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 32, clientY: 32 })); + window.dispatchEvent(new Event('focus')); + if (document.hidden && document.title) { + document.title = document.title; + } + + const bodyText = clean(document.body?.innerText || ''); + return Object.assign(Object.create(null), { + url: location.href, + title: document.title || '', + has_video: Boolean(video), + paused: video ? Boolean(video.paused) : null, + ended: video ? Boolean(video.ended) : null, + ready_state: video ? video.readyState : null, + current_time: video ? Math.floor(video.currentTime || 0) : null, + play_attempted, + page_hidden: document.hidden, + live_hint: bodyText.includes('直播中') || bodyText.includes('已播'), + }); + })() + `; +} + +function buildTaskVerificationScript() { + return ` + (async () => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const dispatchHover = (el) => { + const rect = el.getBoundingClientRect(); + const x = Math.max(1, Math.min(window.innerWidth - 1, rect.left + rect.width / 2)); + const y = Math.max(1, Math.min(window.innerHeight - 1, rect.top + rect.height / 2)); + for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) { + el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); + } + }; + const findAvatar = () => { + const selectors = [ + '.avatar-img', + '.avarta-img', + '[class*="avatar-img"]', + '[class*="avarta-img"]', + '[class*="Avatar"] img', + '[class*="avatar"] img', + 'img[src*="avatar"]', + ]; + const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))); + return candidates.find((el) => { + if (!isVisible(el)) return false; + const rect = el.getBoundingClientRect(); + return rect.top < 140 && rect.left > window.innerWidth * 0.45; + }) || candidates.find(isVisible) || null; + }; + const collectPanels = () => Array.from(document.querySelectorAll('body *')) + .filter((el) => { + if (!isVisible(el)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + const text = clean(el.innerText || el.textContent || ''); + if (text.length < 2 || text.length > 500) return false; + const highLayer = ['fixed', 'absolute', 'sticky'].includes(style.position) || Number(style.zIndex || 0) > 1; + const nearAvatar = rect.top < 260 && rect.right > window.innerWidth * 0.45; + const taskText = /经验|任务|亲密度|鱼丸|签到|等级|弹幕|关注|观看|视频|领取|已完成|每日/.test(text); + return taskText && (highLayer || nearAvatar); + }) + .map((el) => clean(el.innerText || el.textContent || '')) + .filter(Boolean) + .slice(0, 8); + + const avatar = findAvatar(); + if (avatar) { + dispatchHover(avatar); + await sleep(1200); + } + + let panels = collectPanels(); + if (panels.length === 0 && avatar) { + avatar.click(); + await sleep(1200); + panels = collectPanels(); + } + + const text = panels.join(' | '); + const completionHints = Array.from(new Set(text.match(/已完成|完成|已领取|今日已|经验\\s*\\+?\\d*|亲密度\\s*\\+?\\d*|弹幕|关注|观看|视频/g) || [])); + return { + has_avatar: Boolean(avatar), + panel_visible: panels.length > 0, + completion_hints: completionHints, + evidence: text.slice(0, 300), + }; + })() + `; +} + +async function keepWatching(page, minutes, intervalSeconds) { + const totalSeconds = Math.max(0, Math.min(Number(minutes ?? 40) * 60, 8 * 60 * 60)); + const interval = Math.max(10, Math.min(Number(intervalSeconds ?? 30), 300)); + const deadline = Date.now() + totalSeconds * 1000; + let checks = 0; + let playingChecks = 0; + let failedChecks = 0; + let lastCurrentTime = null; + let last = null; + + while (Date.now() < deadline) { + last = await page.evaluate(buildKeepAliveScript()); + checks += 1; + const currentTime = Number(last?.current_time ?? 0); + const timeAdvanced = lastCurrentTime !== null && currentTime > lastCurrentTime; + const playing = Boolean(last?.has_video) && !last.paused && !last.ended && (last.ready_state === null || last.ready_state >= 2); + if (playing || timeAdvanced) { + playingChecks += 1; + failedChecks = 0; + } else { + failedChecks += 1; + } + lastCurrentTime = currentTime; + if (checks >= 3 && failedChecks >= 3) { + throw new CommandExecutionError('Douyu video playback could not be kept alive for 3 consecutive checks'); + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + await page.wait({ time: Math.min(interval, Math.ceil(remainingMs / 1000)) }); + } + + if (totalSeconds > 0 && playingChecks === 0) { + throw new CommandExecutionError('Douyu video playback was never confirmed as active'); + } + + return { + watched_seconds: totalSeconds, + checks, + playing_checks: playingChecks, + video_status: last ? ( + last.has_video + ? `video:${last.paused ? 'paused' : 'playing'}:${last.current_time ?? 0}s` + : 'video:not-found' + ) : 'not-checked', + }; +} + +async function verifyDailyTask(page, strictVerify) { + await page.goto(DOUYU_HOST, { waitUntil: 'load', settleMs: 3000 }); + await page.wait({ time: 2 }); + const result = await page.evaluate(buildTaskVerificationScript()); + const status = result.panel_visible + ? `verified:${result.completion_hints.join(',') || 'task-panel-visible'}` + : 'verification:inconclusive'; + if (strictVerify && !result.panel_visible) { + throw new CommandExecutionError('Douyu daily task verification panel did not appear from avatar hover'); + } + return { ...result, status }; +} + +export const command = cli({ + site: 'douyu', + name: 'daily-task', + aliases: ['task'], + description: '随机打开斗鱼直播间完成关注、弹幕、直播观看和视频观看每日任务', + access: 'write', + example: 'opencli douyu daily-task --window foreground --keep-tab true -f yaml', + domain: 'www.douyu.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'message1', default: '打卡', help: 'First danmaku text, max 50 chars' }, + { name: 'message2', default: '主播加油', help: 'Second danmaku text, max 50 chars' }, + { name: 'source', default: `${DOUYU_HOST}/directory/all`, help: 'Directory/category URL used as the random room source' }, + { name: 'pool', type: 'int', default: 80, help: 'Maximum candidate rooms to sample from' }, + { name: 'delay', type: 'int', default: 3, help: 'Seconds to wait between the two danmaku messages' }, + { name: 'watch-minutes', type: 'int', default: 40, help: 'Minutes to keep the selected live room playing after task actions' }, + { name: 'check-interval', type: 'int', default: 30, help: 'Seconds between playback keepalive checks' }, + { name: 'video-source', default: DEFAULT_VIDEO_SOURCE, help: 'Douyu video listing URL used as the video source' }, + { name: 'video-pool', type: 'int', default: 120, help: 'Maximum candidate videos to sample from' }, + { name: 'video-watch-minutes', type: 'int', default: 15, help: 'Minutes of actual video playback progress to accumulate after live watching' }, + { name: 'video-check-interval', type: 'int', default: 20, help: 'Seconds between video playback progress checks' }, + { name: 'max-videos', type: 'int', default: 5, help: 'Maximum videos to use if shorter videos end before target time' }, + { name: 'timeout', type: 'int', default: 3900, help: 'Runtime timeout in seconds; must exceed live and video watch time' }, + { name: 'verify', type: 'bool', default: true, help: 'Hover account avatar and collect task/experience evidence after watching' }, + { name: 'strict-verify', type: 'bool', default: true, help: 'Fail when the task/experience panel cannot be verified' }, + { name: 'dry-run', type: 'bool', default: false, help: 'Only choose and open the room; do not follow or send danmaku' }, + ], + columns: ['room', 'streamer', 'title', 'follow_result', 'danmaku1', 'danmaku2', 'live_watch', 'video_watch', 'videos', 'verification', 'verification_evidence', 'status', 'url'], + func: async (page, kwargs) => { + const message1 = requireText(kwargs.message1, 'message1', 50); + const message2 = requireText(kwargs.message2, 'message2', 50); + const delay = Math.max(0, Math.min(Number(kwargs.delay ?? 3), 30)); + const watchMinutes = Math.max(0, Math.min(Number(kwargs['watch-minutes'] ?? 40), 480)); + const checkInterval = Math.max(10, Math.min(Number(kwargs['check-interval'] ?? 30), 300)); + const picked = await pickRandomRoom(page, kwargs); + + await page.goto(picked.url, { waitUntil: 'load', settleMs: 3000 }); + await page.wait({ time: 2 }); + const summary = await extractRoomSummary(page); + + if (kwargs['dry-run']) { + return [{ + room: summary.room || picked.room, + streamer: summary.streamer, + title: summary.title || picked.title, + follow_result: 'dry-run', + danmaku1: 'dry-run', + danmaku2: 'dry-run', + live_watch: 'dry-run', + video_watch: 'dry-run', + videos: '', + verification: 'dry-run', + verification_evidence: '', + status: `picked from ${picked.pool_size} rooms`, + url: summary.url || picked.url, + }]; + } + + const followRows = await followRoom(page, picked.room); + const firstRows = await sendDanmaku(page, picked.room, message1); + if (delay > 0) { + await page.wait({ time: delay }); + } + const secondRows = await sendDanmaku(page, picked.room, message2); + const watchResult = await keepWatching(page, watchMinutes, checkInterval); + const videoResult = await runVideoWatchTask(page, { ...kwargs, verify: false }); + const verification = kwargs.verify ? await verifyDailyTask(page, kwargs['strict-verify']) : null; + + return [{ + room: summary.room || picked.room, + streamer: summary.streamer, + title: summary.title || picked.title, + follow_result: followRows?.[0]?.result || '', + danmaku1: firstRows?.[0]?.result || '', + danmaku2: secondRows?.[0]?.result || '', + live_watch: watchResult.video_status, + video_watch: videoResult.watch, + videos: videoResult.videos, + verification: verification?.status || 'skipped', + verification_evidence: verification?.evidence || '', + status: `done; live watched ${watchResult.watched_seconds}s with ${watchResult.playing_checks}/${watchResult.checks} active checks; ${videoResult.status}`, + url: videoResult.url || summary.url || picked.url, + }]; + }, +}); + +export const __test__ = { buildRoomPickerScript, buildRoomPickerDebugScript, buildKeepAliveScript, buildTaskVerificationScript }; diff --git a/clis/douyu/danmaku.js b/clis/douyu/danmaku.js new file mode 100644 index 000000000..cd3cac1a5 --- /dev/null +++ b/clis/douyu/danmaku.js @@ -0,0 +1,92 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractRoomSummary, gotoRoom, ensureRoomReady, requireText, wrapUiWriteError } from './utils.js'; + +function buildDanmakuScript(text) { + return ` + (async () => { + const danmakuText = ${JSON.stringify(text)}; + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const findInput = () => Array.from(document.querySelectorAll( + '.ChatSend-txt[contenteditable="true"], [contenteditable="true"][maxlength="50"], [contenteditable="true"]' + )).find(isVisible); + const findSendButton = () => Array.from(document.querySelectorAll('button')) + .find((el) => isVisible(el) && /ChatSend-button|发送/.test(String(el.className || '') + ' ' + clean(el.textContent))); + + const input = findInput(); + if (!input) { + throw new Error('BUTTON_NOT_FOUND: danmaku input not found'); + } + const button = findSendButton(); + if (!button) { + throw new Error('BUTTON_NOT_FOUND: danmaku send button not found'); + } + + input.focus(); + input.textContent = ''; + document.execCommand('insertText', false, danmakuText); + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: danmakuText })); + input.dispatchEvent(new Event('change', { bubbles: true })); + await sleep(250); + + const currentText = clean(input.innerText || input.textContent || ''); + if (!currentText.includes(danmakuText)) { + throw new Error('STATE_VERIFY_FAIL: danmaku text was not accepted by editor'); + } + + button.click(); + await sleep(1000); + + const bodyText = clean(document.body?.innerText || ''); + if (/登录后|请登录|立即登录/.test(bodyText)) { + throw new Error('AUTH_REQUIRED: Douyu login is required'); + } + if (/发送过快|频率|禁言|内容违规|敏感词/.test(bodyText)) { + throw new Error('STATE_VERIFY_FAIL: Douyu rejected the danmaku, page says: ' + bodyText.slice(-120)); + } + + return { result: 'sent' }; + })() + `; +} + +export const command = cli({ + site: 'douyu', + name: 'danmaku', + aliases: ['send'], + description: '向斗鱼直播间发送普通弹幕', + access: 'write', + example: 'opencli douyu danmaku 6979222 "hello" -f yaml', + domain: 'www.douyu.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'room', required: true, positional: true, help: 'Douyu room id or room URL' }, + { name: 'text', required: true, positional: true, help: 'Danmaku text, max 50 chars' }, + ], + columns: ['room', 'streamer', 'text', 'result', 'url'], + func: async (page, kwargs) => sendDanmaku(page, kwargs.room, kwargs.text), +}); + +export async function sendDanmaku(page, room, value) { + const text = requireText(value, 'danmaku text', 50); + try { + await gotoRoom(page, room); + await ensureRoomReady(page); + const action = await page.evaluate(buildDanmakuScript(text)); + const summary = await extractRoomSummary(page); + return [{ room: summary.room, streamer: summary.streamer, text, result: action.result, url: summary.url }]; + } catch (error) { + wrapUiWriteError(error, 'send Douyu danmaku'); + } +} + +export const __test__ = { buildDanmakuScript }; diff --git a/clis/douyu/douyu.test.js b/clis/douyu/douyu.test.js new file mode 100644 index 000000000..e913e4e5f --- /dev/null +++ b/clis/douyu/douyu.test.js @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './watch.js'; +import './search.js'; +import './follow.js'; +import './unfollow.js'; +import './danmaku.js'; +import './daily-task.js'; +import './video-task.js'; +import { normalizeSearchLimit, normalizeSearchQuery, buildSearchUrl, extractSearchResultsFromHtml } from './search.js'; +import { normalizeRoom, requireText, classifyDouyuLiveStatus } from './utils.js'; + +describe('douyu adapter', () => { + it('registers search as a read-only public command', () => { + const search = getRegistry().get('douyu/search'); + expect(search).toBeDefined(); + expect(search?.access).toBe('read'); + expect(search?.browser).toBe(false); + expect(search?.columns).toContain('room'); + expect(search?.columns).toContain('live_status'); + }); + + it('registers daily-task as the only bundled daily workflow', () => { + const dailyTask = getRegistry().get('douyu/daily-task'); + expect(dailyTask).toBeDefined(); + expect(getRegistry().get('douyu/video-task')).toBeUndefined(); + expect(dailyTask?.args.map((arg) => arg.name)).toContain('video-watch-minutes'); + expect(dailyTask?.columns).toContain('video_watch'); + expect(dailyTask?.columns).toContain('videos'); + }); + + it('normalizes room ids from ids and urls', () => { + expect(normalizeRoom('6979222')).toBe('6979222'); + expect(normalizeRoom('https://www.douyu.com/6979222?dyshid=1')).toBe('6979222'); + expect(normalizeRoom('https://www.douyu.com/room/6979222')).toBe('6979222'); + }); + + it('rejects missing rooms and overlong danmaku text', () => { + expect(() => normalizeRoom('')).toThrow(ArgumentError); + expect(() => requireText('x'.repeat(51), 'text', 50)).toThrow(ArgumentError); + }); + + it('does not treat offline room chrome as a live stream', () => { + expect(classifyDouyuLiveStatus({ + hasVideo: true, + videoPaused: true, + videoEnded: false, + videoReadyState: 0, + videoCurrentTime: 0, + bodyText: '小众宝藏结晶直播间 6657 上次开播时间 3小时前 发送弹幕 粉丝牌', + })).toEqual({ live_status: 'offline', live_status_reason: 'offline-text' }); + + expect(classifyDouyuLiveStatus({ + hasVideo: true, + videoPaused: false, + videoEnded: false, + videoReadyState: 2, + videoCurrentTime: 1, + bodyText: '', + })).toEqual({ live_status: 'live', live_status_reason: 'video-playing' }); + }); + + it('normalizes search query and limit', () => { + expect(normalizeSearchQuery(' 英雄 联盟 ')).toBe('英雄 联盟'); + expect(normalizeSearchLimit('10')).toBe(10); + expect(buildSearchUrl('英雄联盟')).toContain('kw=%E8%8B%B1%E9%9B%84%E8%81%94%E7%9B%9F'); + expect(() => normalizeSearchQuery('')).toThrow(ArgumentError); + expect(() => normalizeSearchLimit(0)).toThrow(ArgumentError); + expect(() => normalizeSearchLimit(51)).toThrow(ArgumentError); + }); + + it('extracts live room search cards from Douyu SSR HTML', () => { + const html = ` +

直播间

+ +

动态

+ `; + expect(extractSearchResultsFromHtml(html, 10)).toEqual([{ + rank: 1, + room: '252140', + streamer: '金咕咕金咕咕doinb', + title: '随便播播 希望你们天天开心!!!', + category: '英雄联盟', + hot: '383.9万', + live_status: 'live', + url: 'https://www.douyu.com/252140', + }]); + }); +}); diff --git a/clis/douyu/follow.js b/clis/douyu/follow.js new file mode 100644 index 000000000..06d5fb8bb --- /dev/null +++ b/clis/douyu/follow.js @@ -0,0 +1,124 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractRoomSummary, gotoRoom, ensureRoomReady, wrapUiWriteError } from './utils.js'; + +function buildFollowStateScript() { + return ` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const findFollowButton = () => Array.from(document.querySelectorAll('button')) + .find((el) => isVisible(el) && /followButton|关注|已关注|取消关注/.test(String(el.className || '') + ' ' + clean(el.textContent))); + + const loginText = clean(document.body?.innerText || ''); + if (/登录后|请登录|立即登录/.test(loginText) && !findFollowButton()) { + throw new Error('AUTH_REQUIRED: Douyu login is required'); + } + + const before = findFollowButton(); + if (!before) { + throw new Error('BUTTON_NOT_FOUND: room follow button not found'); + } + + const beforeText = clean(before.textContent); + if (/已关注|取消关注/.test(beforeText)) { + return Object.assign(Object.create(null), { result: 'already-following', before: beforeText, after: beforeText }); + } + + document.querySelectorAll('[data-opencli-douyu-follow-target]').forEach((el) => { + el.removeAttribute('data-opencli-douyu-follow-target'); + }); + before.setAttribute('data-opencli-douyu-follow-target', '1'); + return Object.assign(Object.create(null), { result: 'needs-follow', before: beforeText }); + })() + `; +} + +function buildFollowVerifyScript() { + return ` + (async () => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const findFollowButton = () => Array.from(document.querySelectorAll('button')) + .find((el) => isVisible(el) && /followButton|关注|已关注|取消关注/.test(String(el.className || '') + ' ' + clean(el.textContent))); + const dismissFollowDialog = () => { + const controls = Array.from(document.querySelectorAll('button, [role="button"], span, div')); + const target = controls.find((el) => { + if (!isVisible(el)) return false; + const text = clean(el.innerText || el.textContent || ''); + return /^(保存|确定|知道了|完成)$/.test(text); + }); + if (target) { + target.click(); + return clean(target.innerText || target.textContent || ''); + } + return ''; + }; + + for (let i = 0; i < 40; i++) { + await sleep(250); + const current = findFollowButton(); + const afterText = clean(current?.textContent || ''); + const bodyText = clean(document.body?.innerText || ''); + if (/关注成功|已关注|取消关注/.test(afterText) || /关注成功|已关注/.test(bodyText)) { + const dismissed = dismissFollowDialog(); + return Object.assign(Object.create(null), { result: 'followed', before: beforeText, after: afterText || '已关注' }); + } + if (/登录后|请登录|立即登录/.test(bodyText)) { + throw new Error('AUTH_REQUIRED: Douyu login is required'); + } + if (/验证|安全校验|验证码|滑块/.test(bodyText)) { + throw new Error('STATE_VERIFY_FAIL: Douyu opened a verification challenge'); + } + } + + throw new Error('STATE_VERIFY_FAIL: follow button did not switch to followed state'); + })() + `; +} + +export const command = cli({ + site: 'douyu', + name: 'follow', + description: '关注斗鱼直播间主播', + access: 'write', + example: 'opencli douyu follow 6979222 -f yaml', + domain: 'www.douyu.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'room', required: true, positional: true, help: 'Douyu room id or room URL' }, + ], + columns: ['room', 'streamer', 'result', 'url'], + func: async (page, kwargs) => followRoom(page, kwargs.room), +}); + +export async function followRoom(page, room) { + try { + await gotoRoom(page, room); + await ensureRoomReady(page); + const state = await page.evaluate(buildFollowStateScript()); + if (state.result === 'already-following') { + const summary = await extractRoomSummary(page); + return [{ room: summary.room, streamer: summary.streamer, result: state.result, url: summary.url }]; + } + await page.click('[data-opencli-douyu-follow-target="1"]'); + const action = await page.evaluateWithArgs(buildFollowVerifyScript(), { beforeText: state.before || '' }); + const summary = await extractRoomSummary(page); + return [{ room: summary.room, streamer: summary.streamer, result: action.result, url: summary.url }]; + } catch (error) { + wrapUiWriteError(error, 'follow Douyu streamer'); + } +} + +export const __test__ = { buildFollowStateScript, buildFollowVerifyScript }; diff --git a/clis/douyu/search.js b/clis/douyu/search.js new file mode 100644 index 000000000..8511a69da --- /dev/null +++ b/clis/douyu/search.js @@ -0,0 +1,133 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; + +export function normalizeSearchLimit(raw) { + const parsed = Number(raw ?? 20); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + throw new ArgumentError(`--limit must be an integer between 1 and 50, got ${JSON.stringify(raw)}`); + } + if (parsed < 1 || parsed > 50) { + throw new ArgumentError(`--limit must be between 1 and 50, got ${parsed}`); + } + return parsed; +} + +export function normalizeSearchQuery(raw) { + const query = String(raw || '').replace(/\s+/g, ' ').trim(); + if (!query) { + throw new ArgumentError('douyu search query is required', 'Example: opencli douyu search 英雄联盟 -f yaml'); + } + return query; +} + +export function buildSearchUrl(query) { + return `https://www.douyu.com/search/?kw=${encodeURIComponent(normalizeSearchQuery(query))}`; +} + +function decodeHtml(value) { + return String(value || '') + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +function stripTags(value) { + return decodeHtml(String(value || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim(); +} + +function getAttr(tag, name) { + const match = String(tag || '').match(new RegExp(`${name}="([^"]*)"`, 'i')); + return match ? decodeHtml(match[1]) : ''; +} + +function toRoomUrl(href) { + const room = (String(href || '').match(/(?:https:\/\/www\.douyu\.com)?\/(\d{2,})/) || [])[1] || ''; + return room ? { room, url: `https://www.douyu.com/${room}` } : { room: '', url: '' }; +} + +function findLiveSection(html) { + const marker = html.search(/]*>\s*直播间\s*<\/h3>/); + if (marker < 0) return ''; + const rest = html.slice(marker); + const next = rest.slice(20).search(/]*>\s*(动态|热搜榜单|热门话题|视频|用户|鱼吧|话题)\s*<\/h3>/); + return next >= 0 ? rest.slice(0, next + 20) : rest; +} + +export function extractSearchResultsFromHtml(html, limit) { + const section = findLiveSection(String(html || '')); + if (!section) return []; + + const rows = []; + const seen = new Set(); + const cards = section.match(//gi) || []; + + for (const card of cards) { + const titleTag = (card.match(/]*\btitle=")(?=[^>]*\bhref="(?:https:\/\/www\.douyu\.com)?\/\d{2,})[^>]*>/i) || [])[0] || ''; + const href = getAttr(titleTag, 'href'); + const { room, url } = toRoomUrl(href); + if (!room || seen.has(room)) continue; + + const title = getAttr(titleTag, 'title') || stripTags((card.match(/]*>([\s\S]*?)<\/h3>/i) || [])[1]); + const streamer = stripTags((card.match(/class="[^"]*livingName[^"]*"[^>]*>([\s\S]*?)<\/div>/i) || [])[1]) + || decodeHtml((card.match(/]*\balt="([^"]+)"/i) || [])[1] || ''); + const category = stripTags((card.match(/class="[^"]*cardTagContainer[^"]*"[^>]*>([\s\S]*?)<\/h6>/i) || [])[1]); + const hot = (Array.from(card.matchAll(/class="[^"]*watching[^"]*"[^>]*>([\s\S]*?)<\/div>/gi)) + .map((match) => stripTags(match[1])) + .find(Boolean)) || ''; + + seen.add(room); + rows.push({ + rank: rows.length + 1, + room, + streamer, + title, + category, + hot, + live_status: hot ? 'live' : 'unknown', + url, + }); + if (rows.length >= limit) break; + } + + return rows; +} + +async function fetchSearchHtml(url) { + const response = await fetch(url, { + headers: { + 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36', + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + }); + if (!response.ok) { + throw new CommandExecutionError(`Douyu search request failed: HTTP ${response.status}`); + } + return response.text(); +} + +export const command = cli({ + site: 'douyu', + name: 'search', + description: '搜索斗鱼直播间', + access: 'read', + example: 'opencli douyu search 英雄联盟 --limit 10 -f yaml', + domain: 'www.douyu.com', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'query', required: true, positional: true, help: 'Search keyword' }, + { name: 'limit', type: 'int', default: 20, help: 'Number of live room results to return (1-50)' }, + ], + columns: ['rank', 'room', 'streamer', 'title', 'category', 'hot', 'live_status', 'url'], + func: async (kwargs) => { + const query = normalizeSearchQuery(kwargs.query); + const limit = normalizeSearchLimit(kwargs.limit); + const rows = extractSearchResultsFromHtml(await fetchSearchHtml(buildSearchUrl(query)), limit); + if (!Array.isArray(rows) || rows.length === 0) { + throw new EmptyResultError('douyu search', `No Douyu live rooms found for ${JSON.stringify(query)}`); + } + return rows.slice(0, limit); + }, +}); diff --git a/clis/douyu/unfollow.js b/clis/douyu/unfollow.js new file mode 100644 index 000000000..7216edb94 --- /dev/null +++ b/clis/douyu/unfollow.js @@ -0,0 +1,124 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractRoomSummary, gotoRoom, ensureRoomReady, wrapUiWriteError } from './utils.js'; + +function buildUnfollowStateScript() { + return ` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const findFollowButton = () => Array.from(document.querySelectorAll('button')) + .find((el) => isVisible(el) && /followButton|关注|已关注|取消关注/.test(String(el.className || '') + ' ' + clean(el.textContent))); + + const loginText = clean(document.body?.innerText || ''); + if (/登录后|请登录|立即登录/.test(loginText) && !findFollowButton()) { + throw new Error('AUTH_REQUIRED: Douyu login is required'); + } + + const before = findFollowButton(); + if (!before) { + throw new Error('BUTTON_NOT_FOUND: room follow button not found'); + } + + const beforeText = clean(before.textContent); + if (/^关注$|关注主播/.test(beforeText)) { + return Object.assign(Object.create(null), { result: 'not-following', before: beforeText, after: beforeText }); + } + + document.querySelectorAll('[data-opencli-douyu-follow-target]').forEach((el) => { + el.removeAttribute('data-opencli-douyu-follow-target'); + }); + before.setAttribute('data-opencli-douyu-follow-target', '1'); + return Object.assign(Object.create(null), { result: 'needs-unfollow', before: beforeText }); + })() + `; +} + +function buildUnfollowVerifyScript() { + return ` + (async () => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const findFollowButton = () => Array.from(document.querySelectorAll('button')) + .find((el) => isVisible(el) && /followButton|关注|已关注|取消关注/.test(String(el.className || '') + ' ' + clean(el.textContent))); + const clickConfirm = () => { + const controls = Array.from(document.querySelectorAll('button, [role="button"], span, div')); + const target = controls.find((el) => { + if (!isVisible(el)) return false; + const text = clean(el.innerText || el.textContent || ''); + return /^(确定|确认|取消关注)$/.test(text); + }); + if (target) { + target.click(); + return true; + } + return false; + }; + + for (let i = 0; i < 40; i++) { + await sleep(250); + clickConfirm(); + const current = findFollowButton(); + const afterText = clean(current?.textContent || ''); + const bodyText = clean(document.body?.innerText || ''); + if (/^关注$|关注主播/.test(afterText)) { + return Object.assign(Object.create(null), { result: 'unfollowed', before: beforeText, after: afterText }); + } + if (/登录后|请登录|立即登录/.test(bodyText)) { + throw new Error('AUTH_REQUIRED: Douyu login is required'); + } + if (/验证|安全校验|验证码|滑块/.test(bodyText)) { + throw new Error('STATE_VERIFY_FAIL: Douyu opened a verification challenge'); + } + } + + throw new Error('STATE_VERIFY_FAIL: follow button did not switch to unfollowed state'); + })() + `; +} + +export const command = cli({ + site: 'douyu', + name: 'unfollow', + description: '取消关注斗鱼直播间主播', + access: 'write', + example: 'opencli douyu unfollow 6979222 -f yaml', + domain: 'www.douyu.com', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'room', required: true, positional: true, help: 'Douyu room id or room URL' }, + ], + columns: ['room', 'streamer', 'result', 'url'], + func: async (page, kwargs) => unfollowRoom(page, kwargs.room), +}); + +export async function unfollowRoom(page, room) { + try { + await gotoRoom(page, room); + await ensureRoomReady(page); + const state = await page.evaluate(buildUnfollowStateScript()); + if (state.result === 'not-following') { + const summary = await extractRoomSummary(page); + return [{ room: summary.room, streamer: summary.streamer, result: state.result, url: summary.url }]; + } + await page.click('[data-opencli-douyu-follow-target="1"]'); + const action = await page.evaluateWithArgs(buildUnfollowVerifyScript(), { beforeText: state.before || '' }); + const summary = await extractRoomSummary(page); + return [{ room: summary.room, streamer: summary.streamer, result: action.result, url: summary.url }]; + } catch (error) { + wrapUiWriteError(error, 'unfollow Douyu streamer'); + } +} + +export const __test__ = { buildUnfollowStateScript, buildUnfollowVerifyScript }; diff --git a/clis/douyu/utils.js b/clis/douyu/utils.js new file mode 100644 index 000000000..02bb4ea34 --- /dev/null +++ b/clis/douyu/utils.js @@ -0,0 +1,142 @@ +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; + +export const DOUYU_HOST = 'https://www.douyu.com'; + +export function normalizeRoom(input) { + const raw = String(input || '').trim(); + if (!raw) { + throw new ArgumentError('douyu room is required', 'Example: opencli douyu watch 6979222 -f yaml'); + } + + const match = raw.match(/(?:douyu\.com\/)?(?:room\/)?(\d{2,})/i); + if (!match) { + throw new ArgumentError(`Invalid Douyu room: ${raw}`, 'Use a numeric room id or a https://www.douyu.com/ URL'); + } + + return match[1]; +} + +export function buildRoomUrl(room) { + return `${DOUYU_HOST}/${encodeURIComponent(normalizeRoom(room))}`; +} + +export function requireText(value, label, maxLen = 50) { + const text = String(value || '').replace(/\s+/g, ' ').trim(); + if (!text) { + throw new ArgumentError(`${label} cannot be empty`); + } + if (text.length > maxLen) { + throw new ArgumentError(`${label} is too long (${text.length} > ${maxLen})`); + } + return text; +} + +export async function gotoRoom(page, room) { + const roomId = normalizeRoom(room); + const url = buildRoomUrl(roomId); + await page.goto(url, { waitUntil: 'load', settleMs: 3000 }); + await page.wait({ time: 2 }); + return { roomId, url }; +} + +export async function ensureRoomReady(page) { + const ready = await page.evaluate(` + new Promise((resolve) => { + const isReady = () => Boolean( + document.querySelector('[class*="anchorInfo"]') || + document.querySelector('[class*="ChatSend"]') || + document.querySelector('[class*="followButton"]') + ); + if (isReady()) return resolve(true); + const observer = new MutationObserver(() => { + if (isReady()) { + observer.disconnect(); + resolve(true); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => { + observer.disconnect(); + resolve(false); + }, 8000); + }) + `); + if (!ready) { + throw new CommandExecutionError('Douyu room did not finish rendering within 8s'); + } +} + +export function classifyDouyuLiveStatus({ hasVideo, videoPaused, videoEnded, videoReadyState, videoCurrentTime, bodyText }) { + const text = String(bodyText || ''); + const readyState = Number(videoReadyState || 0); + const currentTime = Number(videoCurrentTime || 0); + const videoPlaying = Boolean(hasVideo && !videoPaused && !videoEnded && readyState >= 2); + const videoStreamReady = Boolean(hasVideo && !videoEnded && readyState >= 2 && currentTime > 0); + const offlineHint = /暂未开播|未开播|开播提醒|上次开播时间|主播正在赶来|主播不在|休息中|已下播|直播已结束|房间不存在/.test(text); + const liveHint = /正在直播|直播中/.test(text); + + if (videoPlaying) return { live_status: 'live', live_status_reason: 'video-playing' }; + if (videoStreamReady) return { live_status: 'live', live_status_reason: 'video-stream-ready' }; + if (offlineHint) return { live_status: 'offline', live_status_reason: 'offline-text' }; + if (hasVideo && liveHint) return { live_status: 'live', live_status_reason: 'live-text' }; + return { live_status: 'unknown', live_status_reason: 'no-strong-signal' }; +} + +export async function extractRoomSummary(page) { + return page.evaluate(` + (() => { + const classifyDouyuLiveStatus = ${classifyDouyuLiveStatus.toString()}; + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const titleText = clean( + document.querySelector('[class*="firstRowContainer"]')?.innerText || + document.querySelector('[class*="title"]')?.innerText || + '' + ); + const subtitleText = clean(document.querySelector('[class*="subTitleContainer"]')?.innerText || ''); + const docTitle = clean(document.title || ''); + const streamerFromDoc = (docTitle.split('_')[1] || '').replace(/直播$/, ''); + const roomId = (location.pathname.match(/\\/(\\d+)/) || [])[1] || ''; + const followerText = clean(document.querySelector('[class*="followNum"]')?.innerText || ''); + const followerMatch = (followerText || titleText || '').match(/([\\d,.]+\\s*[万亿]?)(?:\\s*关注)?/); + const title = titleText.replace(/\\s*[\\d,.]+\\s*[万亿]?\\s*关注.*/, ''); + const category = clean(document.querySelector('[class*="subTitleContainer"] a[href^="/g_"]')?.innerText || ''); + const bodyText = clean(document.body?.innerText || ''); + const videos = Array.from(document.querySelectorAll('video')); + const video = videos.find((el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 100 && rect.height > 80; + }) || videos[0] || null; + const hasVideo = Boolean(video); + const live = classifyDouyuLiveStatus({ + hasVideo, + videoPaused: video?.paused, + videoEnded: video?.ended, + videoReadyState: video?.readyState, + videoCurrentTime: video?.currentTime, + bodyText, + }); + + return { + room: roomId, + title: title || docTitle.split('_')[0] || '', + streamer: streamerFromDoc || subtitleText.replace(/\\s+\\d+.*/, ''), + category, + followers: followerMatch ? followerMatch[1] : '', + live_status: live.live_status, + live_status_reason: live.live_status_reason, + video_status: hasVideo + ? \`video:\${video.paused ? 'paused' : 'playing'}:\${Math.floor(video.currentTime || 0)}s:ready\${video.readyState}\` + : 'video:not-found', + url: location.href, + }; + })() + `); +} + +export function wrapUiWriteError(error, action) { + const message = error instanceof Error ? error.message : String(error); + if (/login|登录|AUTH_REQUIRED/i.test(message)) { + throw new AuthRequiredError('www.douyu.com', `Douyu login is required to ${action}`); + } + throw new CommandExecutionError(`Failed to ${action}: ${message}`); +} diff --git a/clis/douyu/video-task.js b/clis/douyu/video-task.js new file mode 100644 index 000000000..a8c3ba207 --- /dev/null +++ b/clis/douyu/video-task.js @@ -0,0 +1,435 @@ +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { DOUYU_HOST } from './utils.js'; + +export const DOUYU_VIDEO_HOST = 'https://v.douyu.com'; +export const DEFAULT_VIDEO_SOURCE = `${DOUYU_VIDEO_HOST}/video/videotag/list?tagId=15`; + +function unwrapArray(result) { + return Array.isArray(result) ? result : Array.isArray(result?.data) ? result.data : []; +} + +function unwrapObject(result) { + return result && typeof result === 'object' && 'data' in result + ? result.data + : result; +} + +function buildVideoPickerScript(limit) { + return ` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const parseDuration = (text) => { + const matches = Array.from(String(text || '').matchAll(/(?:^|\\s)(\\d{1,3}):(\\d{2})(?::(\\d{2}))?(?:\\s|$)/g)); + if (matches.length === 0) return 0; + const match = matches[matches.length - 1]; + return match[3] + ? Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + : Number(match[1]) * 60 + Number(match[2]); + }; + const titleFromText = (text) => clean(String(text || '') + .replace(/^\\d+(?:\\.\\d+)?万?\\s+\\d+\\s+\\d{1,3}:\\d{2}(?::\\d{2})?\\s+/, '') + .replace(/\\d+(?:\\.\\d+)?万?播放.*/, '') + ).slice(0, 120); + const seen = new Map(); + const visit = (root) => { + for (const a of root.querySelectorAll('a[href*="/show/"], demand-router-link[href*="/show/"]')) { + const rawHref = a.href || a.getAttribute('href') || ''; + let href = ''; + try { + href = new URL(rawHref, location.href).href.replace(/#.*$/, ''); + } catch { + continue; + } + const match = href.match(/\\/show\\/([A-Za-z0-9]+)/); + if (!match) continue; + const card = a.closest('li, demand-card, div') || a.parentElement || a; + const text = clean(card.innerText || card.textContent || a.innerText || a.textContent || a.getAttribute('title') || ''); + const prev = seen.get(match[1]) || {}; + const duration = parseDuration(text) || prev.duration || 0; + seen.set(match[1], { + id: match[1], + url: href, + title: prev.title || a.getAttribute('title') || titleFromText(text), + duration_seconds: duration, + duration: duration > 0 ? String(Math.floor(duration / 60)).padStart(2, '0') + ':' + String(duration % 60).padStart(2, '0') : '', + text: prev.text || text.slice(0, 180), + }); + } + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) visit(el.shadowRoot); + } + }; + visit(document); + return Array.from(seen.values()) + .filter((item) => item.url && item.id) + .sort((a, b) => (b.duration_seconds || 0) - (a.duration_seconds || 0)) + .slice(0, ${Number(limit) || 120}); + })() + `; +} + +function buildPickerDebugScript() { + return ` + (() => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const anchors = Array.from(document.querySelectorAll('a[href], demand-router-link[href]')); + return Object.assign(Object.create(null), { + url: location.href, + title: document.title || '', + anchor_count: anchors.length, + show_like_hrefs: anchors + .map((a) => Object.assign(Object.create(null), { href: a.href || a.getAttribute('href') || '', text: clean(a.innerText || a.textContent || '').slice(0, 100) })) + .filter((item) => /\\/show\\//.test(item.href)) + .slice(0, 12), + body: clean(document.body?.innerText || '').slice(0, 260), + }); + })() + `; +} + +function buildVideoStatusScript({ attemptPlay = true } = {}) { + return ` + (async () => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const findVideos = (root, out = []) => { + for (const el of root.querySelectorAll('*')) { + if (el.tagName === 'VIDEO') out.push(el); + if (el.shadowRoot) findVideos(el.shadowRoot, out); + } + return out; + }; + const videos = findVideos(document); + const video = videos.find((el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 100 && rect.height > 80; + }) || videos[0] || null; + let play_error = ''; + let play_attempted = false; + + if (video && ${attemptPlay ? 'true' : 'false'}) { + try { + if (video.paused || video.ended || video.readyState < 2) { + await video.play(); + play_attempted = true; + await sleep(600); + } + } catch (error) { + play_error = error?.message || String(error); + } + } + + document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 36, clientY: 36 })); + window.dispatchEvent(new Event('focus')); + + const bodyText = clean(document.body?.innerText || ''); + return Object.assign(Object.create(null), { + url: location.href, + title: document.title || '', + has_video: Boolean(video), + paused: video ? Boolean(video.paused) : null, + ended: video ? Boolean(video.ended) : null, + ready_state: video ? video.readyState : null, + current_time: video ? Math.floor(video.currentTime || 0) : null, + duration_seconds: video && Number.isFinite(video.duration) ? Math.floor(video.duration || 0) : 0, + play_attempted, + play_error, + page_hidden: document.hidden, + body_hint: bodyText.slice(0, 160), + }); + })() + `; +} + +function buildTaskVerificationScript() { + return ` + (async () => { + const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const dispatchHover = (el) => { + const rect = el.getBoundingClientRect(); + const x = Math.max(1, Math.min(window.innerWidth - 1, rect.left + rect.width / 2)); + const y = Math.max(1, Math.min(window.innerHeight - 1, rect.top + rect.height / 2)); + for (const type of ['pointerover', 'mouseover', 'mouseenter', 'mousemove']) { + el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y })); + } + }; + const findAvatar = () => { + const selectors = [ + '.avatar-img', + '.avarta-img', + '[class*="avatar-img"]', + '[class*="avarta-img"]', + '[class*="Avatar"] img', + '[class*="avatar"] img', + 'img[src*="avatar"]', + ]; + const candidates = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))); + return candidates.find((el) => { + if (!isVisible(el)) return false; + const rect = el.getBoundingClientRect(); + return rect.top < 140 && rect.left > window.innerWidth * 0.45; + }) || candidates.find(isVisible) || null; + }; + const collectPanels = () => Array.from(document.querySelectorAll('body *')) + .filter((el) => { + if (!isVisible(el)) return false; + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + const text = clean(el.innerText || el.textContent || ''); + if (text.length < 2 || text.length > 700) return false; + const highLayer = ['fixed', 'absolute', 'sticky'].includes(style.position) || Number(style.zIndex || 0) > 1; + const nearAvatar = rect.top < 300 && rect.right > window.innerWidth * 0.45; + const taskText = /经验|任务|亲密度|鱼丸|签到|等级|弹幕|关注|观看|视频|领取|已完成|每日/.test(text); + return taskText && (highLayer || nearAvatar); + }) + .map((el) => clean(el.innerText || el.textContent || '')) + .filter(Boolean) + .slice(0, 10); + + const avatar = findAvatar(); + if (avatar) { + dispatchHover(avatar); + await sleep(1200); + } + + let panels = collectPanels(); + if (panels.length === 0 && avatar) { + avatar.click(); + await sleep(1200); + panels = collectPanels(); + } + + const text = panels.join(' | '); + const completionHints = Array.from(new Set(text.match(/已完成|完成|已领取|今日已|经验\\s*\\+?\\d*|亲密度\\s*\\+?\\d*|弹幕|关注|观看|视频/g) || [])); + return { + has_avatar: Boolean(avatar), + panel_visible: panels.length > 0, + completion_hints: completionHints, + evidence: text.slice(0, 380), + }; + })() + `; +} + +function pickBestVideo(videos, targetSeconds, seenUrls) { + const unseen = videos.filter((item) => item.url && !seenUrls.has(item.url)); + const withDuration = unseen.filter((item) => Number(item.duration_seconds || 0) > 0); + const longEnough = withDuration.filter((item) => Number(item.duration_seconds) >= targetSeconds + 30); + const pool = longEnough.length > 0 + ? longEnough + : withDuration.length > 0 + ? withDuration + : unseen; + if (pool.length === 0) return null; + const top = pool + .sort((a, b) => Number(b.duration_seconds || 0) - Number(a.duration_seconds || 0)) + .slice(0, Math.min(pool.length, 20)); + return top[Math.floor(Math.random() * top.length)]; +} + +async function pickVideoFromSource(page, kwargs, targetSeconds, seenUrls) { + const sourceUrl = kwargs['video-source'] || DEFAULT_VIDEO_SOURCE; + const poolLimit = Math.max(1, Math.min(Number(kwargs['video-pool'] || 120), 300)); + await page.goto(sourceUrl, { waitUntil: 'load', settleMs: 3000 }); + await page.wait({ time: 2 }); + + const merged = new Map(); + for (let i = 0; i < 8; i++) { + if (i > 0) { + await page.autoScroll({ times: 1, delayMs: 600 }); + await page.evaluate(` + (() => { + window.scrollBy(0, Math.max(window.innerHeight * 0.9, 700)); + document.scrollingElement?.scrollBy?.(0, Math.max(window.innerHeight * 0.9, 700)); + })() + `); + await page.wait({ time: 1 }); + } + const result = await page.evaluate(buildVideoPickerScript(poolLimit)); + for (const item of unwrapArray(result)) { + const prev = merged.get(item.url) || {}; + merged.set(item.url, { + ...prev, + ...item, + duration_seconds: Number(item.duration_seconds || prev.duration_seconds || 0), + }); + } + const videos = Array.from(merged.values()); + const hasTimedCandidates = videos.some((item) => Number(item.duration_seconds || 0) > 0 && !seenUrls.has(item.url)); + if (hasTimedCandidates || i === 7) { + const picked = pickBestVideo(videos, targetSeconds, seenUrls); + if (picked && (hasTimedCandidates || i === 7)) return { ...picked, pool_size: videos.length }; + } + } + + const debug = await page.evaluate(buildPickerDebugScript()); + throw new CommandExecutionError(`No Douyu videos found from video source page: ${JSON.stringify(debug)}`); +} + +async function ensureVideoPlaying(page) { + let status = unwrapObject(await page.evaluate(buildVideoStatusScript({ attemptPlay: true }))); + if (!status?.has_video) { + await page.wait({ time: 2 }); + status = unwrapObject(await page.evaluate(buildVideoStatusScript({ attemptPlay: true }))); + } + if (status?.has_video && status.paused) { + await page.click('demand-video'); + await page.wait({ time: 2 }); + status = unwrapObject(await page.evaluate(buildVideoStatusScript({ attemptPlay: true }))); + } + if (!status?.has_video) { + throw new CommandExecutionError(`Douyu video element not found: ${JSON.stringify(status)}`); + } + if (status.paused || status.ended) { + throw new CommandExecutionError(`Douyu video did not start playing: ${JSON.stringify(status)}`); + } + return status; +} + +async function watchCurrentVideo(page, remainingSeconds, intervalSeconds) { + const interval = Math.max(5, Math.min(Number(intervalSeconds ?? 20), 120)); + const wallDeadline = Date.now() + Math.max(remainingSeconds * 3, remainingSeconds + 180) * 1000; + let watchedSeconds = 0; + let checks = 0; + let activeChecks = 0; + let failedChecks = 0; + let lastCurrentTime = null; + let last = null; + + while (watchedSeconds < remainingSeconds && Date.now() < wallDeadline) { + last = unwrapObject(await page.evaluate(buildVideoStatusScript({ attemptPlay: true }))); + checks += 1; + + const currentTime = Number(last?.current_time ?? 0); + const delta = lastCurrentTime === null ? 0 : currentTime - lastCurrentTime; + const timeAdvanced = delta > 0 && delta <= interval + 5; + const playing = Boolean(last?.has_video) && !last.paused && !last.ended && (last.ready_state === null || last.ready_state >= 2); + + if (timeAdvanced) { + watchedSeconds += Math.min(delta, interval); + } + if (playing || timeAdvanced) { + activeChecks += 1; + failedChecks = 0; + } else { + failedChecks += 1; + if (failedChecks >= 2 && last?.has_video) { + await page.click('demand-video'); + } + } + + lastCurrentTime = currentTime; + if (last?.ended) break; + if (checks >= 3 && failedChecks >= 3) break; + + const remaining = remainingSeconds - watchedSeconds; + if (remaining <= 0) break; + await page.wait({ time: Math.min(interval, Math.ceil(remaining)) }); + } + + return { + watched_seconds: Math.floor(watchedSeconds), + checks, + active_checks: activeChecks, + ended: Boolean(last?.ended), + failed: watchedSeconds < remainingSeconds && failedChecks >= 3, + video_status: last ? ( + last.has_video + ? `video:${last.paused ? 'paused' : 'playing'}:${last.current_time ?? 0}s/${last.duration_seconds || 0}s` + : 'video:not-found' + ) : 'not-checked', + }; +} + +async function verifyVideoTask(page, strictVerify) { + await page.goto(DOUYU_HOST, { waitUntil: 'load', settleMs: 3000 }); + await page.wait({ time: 2 }); + const result = unwrapObject(await page.evaluate(buildTaskVerificationScript())); + const hints = result.completion_hints.join(',') || 'task-panel-visible'; + const specific = /观看|视频/.test(`${hints},${result.evidence || ''}`); + const status = result.panel_visible + ? specific + ? `verified:${hints}` + : `panel-visible:${hints}` + : 'verification:inconclusive'; + if (strictVerify && !result.panel_visible) { + throw new CommandExecutionError('Douyu task verification panel did not appear from avatar hover'); + } + return { ...result, status }; +} + +export async function runVideoWatchTask(page, kwargs) { + const targetSeconds = Math.max(0, Math.min(Number(kwargs['video-watch-minutes'] ?? 15) * 60, 4 * 60 * 60)); + const checkInterval = Math.max(5, Math.min(Number(kwargs['video-check-interval'] ?? 20), 120)); + const maxVideos = Math.max(1, Math.min(Number(kwargs['max-videos'] ?? 5), 20)); + const seenUrls = new Set(); + const watchedVideos = []; + let totalWatched = 0; + let lastUrl = ''; + + if (targetSeconds === 0) { + return { + videos: '', + watch: 'skipped', + verification: 'skipped', + verification_evidence: '', + status: 'skipped; video-watch-minutes is 0', + url: '', + }; + } + + while (totalWatched < targetSeconds && watchedVideos.length < maxVideos) { + const remaining = Math.max(0, targetSeconds - totalWatched); + const picked = await pickVideoFromSource(page, kwargs, remaining || targetSeconds || 60, seenUrls); + seenUrls.add(picked.url); + lastUrl = picked.url; + + await page.goto(picked.url, { waitUntil: 'load', settleMs: 3000 }); + await page.wait({ time: 3 }); + + if (kwargs['dry-run']) { + return { + videos: `${picked.title || picked.id} (${picked.duration || `${picked.duration_seconds || 0}s`})`, + watch: 'dry-run', + verification: 'dry-run', + verification_evidence: '', + status: `picked from ${picked.pool_size} videos`, + url: picked.url, + }; + } + + await ensureVideoPlaying(page); + const segment = await watchCurrentVideo(page, remaining, checkInterval); + totalWatched += segment.watched_seconds; + watchedVideos.push(`${picked.title || picked.id}:${segment.watched_seconds}s:${segment.video_status}`); + lastUrl = unwrapObject(await page.evaluate('(() => location.href)()')) || picked.url; + + if (totalWatched >= targetSeconds) break; + if (segment.failed && !segment.ended) { + continue; + } + } + + if (totalWatched < targetSeconds) { + throw new CommandExecutionError(`Only accumulated ${totalWatched}s of Douyu video playback, target ${targetSeconds}s`); + } + + const verification = kwargs.verify ? await verifyVideoTask(page, kwargs['strict-verify']) : null; + + return { + videos: watchedVideos.join(' | '), + watch: `video-progress:${Math.floor(totalWatched)}s`, + verification: verification?.status || 'skipped', + verification_evidence: verification?.evidence || '', + status: `done; watched ${Math.floor(totalWatched)}s across ${watchedVideos.length} video(s)`, + url: lastUrl, + }; +} + +export const __test__ = { buildVideoPickerScript, buildVideoStatusScript, buildTaskVerificationScript, pickBestVideo }; diff --git a/clis/douyu/watch.js b/clis/douyu/watch.js new file mode 100644 index 000000000..771519dee --- /dev/null +++ b/clis/douyu/watch.js @@ -0,0 +1,24 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { extractRoomSummary, gotoRoom, ensureRoomReady } from './utils.js'; + +export const command = cli({ + site: 'douyu', + name: 'watch', + description: '打开斗鱼直播间并返回当前直播状态', + access: 'read', + example: 'opencli douyu watch 6979222 -f yaml', + domain: 'www.douyu.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: false, + siteSession: 'persistent', + args: [ + { name: 'room', required: true, positional: true, help: 'Douyu room id or room URL' }, + ], + columns: ['room', 'title', 'streamer', 'category', 'followers', 'live_status', 'live_status_reason', 'video_status', 'url'], + func: async (page, kwargs) => { + await gotoRoom(page, kwargs.room); + await ensureRoomReady(page); + return [await extractRoomSummary(page)]; + }, +}); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 1c767bcb9..83a7138f4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -87,6 +87,7 @@ export default defineConfig({ { text: 'NotebookLM', link: '/adapters/browser/notebooklm' }, { text: 'WeRead', link: '/adapters/browser/weread' }, { text: 'Douban', link: '/adapters/browser/douban' }, + { text: 'Douyu', link: '/adapters/browser/douyu' }, { text: 'Sina Blog', link: '/adapters/browser/sinablog' }, { text: 'Substack', link: '/adapters/browser/substack' }, { text: 'Pixiv', link: '/adapters/browser/pixiv' }, diff --git a/docs/adapters/browser/douyu.md b/docs/adapters/browser/douyu.md new file mode 100644 index 000000000..39b7c6f60 --- /dev/null +++ b/docs/adapters/browser/douyu.md @@ -0,0 +1,46 @@ +# Douyu (斗鱼) + +**Mode**: 🌐 Public (`search`) / 🔐 Browser · **Domain**: `www.douyu.com` / `v.douyu.com` + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli douyu search` | 搜索斗鱼直播间 | +| `opencli douyu watch` | 打开斗鱼直播间并返回当前直播状态 | +| `opencli douyu follow` | 关注斗鱼直播间主播 | +| `opencli douyu unfollow` | 取消关注斗鱼直播间主播 | +| `opencli douyu danmaku` | 向斗鱼直播间发送普通弹幕 | +| `opencli douyu daily-task` | 随机打开直播间,关注主播,发送两条普通弹幕,保持观看直播,并累计视频分站观看任务时长 | + +## Usage Examples + +```bash +# 搜索直播间 +opencli douyu search 英雄联盟 --limit 10 -f yaml + +# 直播间状态 +opencli douyu watch 6979222 -f yaml + +# 关注与弹幕 +opencli douyu follow 6979222 -f yaml +opencli douyu unfollow 6979222 -f yaml +opencli douyu danmaku 6979222 "hello" -f yaml + +# 每日任务:直播关注/弹幕/观看 + 视频分站观看 +opencli douyu daily-task --window foreground --keep-tab true -f yaml +opencli douyu daily-task --dry-run true -f yaml +opencli douyu daily-task --video-watch-minutes 15 --max-videos 5 -f yaml +``` + +## Prerequisites + +- Chrome running and **logged into** `www.douyu.com` +- [Browser Bridge extension](/guide/browser-bridge) installed + +## Notes + +- `search` reads Douyu's public search result HTML and returns live room candidates that can be passed to `watch`, `follow`, or `danmaku`. +- `daily-task` also searches the Douyu video subsite (`v.douyu.com`) for candidate videos, including links rendered inside shadow DOM. +- Playback credit is accumulated from actual `