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 `