Skip to content
73 changes: 72 additions & 1 deletion cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11860,18 +11860,89 @@
"required": true,
"positional": true,
"help": "Conversation ID (numeric or full URL)"
},
{
"name": "max-pages",
"type": "number",
"default": 500,
"required": false,
"help": "Maximum /im/chain/single pages to fetch (1-1000)"
}
],
"columns": [
"Index",
"MessageId",
"Role",
"Text"
"Type",
"Mode",
"CreatedAt",
"Text",
"Metadata"
],
"type": "js",
"modulePath": "doubao/detail.js",
"sourceFile": "doubao/detail.js",
"navigateBefore": false,
"siteSession": "persistent"
},
{
"site": "doubao",
"name": "download",
"description": "Download images and media from a Doubao conversation",
"access": "read",
"domain": "www.doubao.com",
"strategy": "cookie",
"browser": true,
"args": [
{
"name": "id",
"type": "str",
"required": true,
"positional": true,
"help": "Conversation ID (numeric or full URL)"
},
{
"name": "output",
"type": "str",
"default": "./doubao-downloads",
"required": false,
"help": "Output directory"
},
{
"name": "variant",
"type": "str",
"default": "original",
"required": false,
"help": "Image variant: original, raw, preview, or thumb"
},
{
"name": "limit",
"type": "str",
"default": "0",
"required": false,
"help": "Max media files to download; 0 means all"
},
{
"name": "timeout",
"type": "str",
"default": "15000",
"required": false,
"help": "Per-file download timeout in milliseconds"
}
],
"columns": [
"index",
"type",
"status",
"size",
"filename",
"url"
],
"type": "js",
"modulePath": "doubao/download.js",
"sourceFile": "doubao/download.js",
"navigateBefore": false
},
{
"site": "doubao",
"name": "history",
Expand Down
25 changes: 19 additions & 6 deletions clis/doubao/detail.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError } from '@jackwener/opencli/errors';
import { DOUBAO_DOMAIN, getConversationDetail, parseDoubaoConversationId } from './utils.js';
function parseMaxPages(value) {
const pages = Number(value ?? 500);
if (!Number.isInteger(pages) || pages < 1 || pages > 1000) {
throw new ArgumentError('max-pages must be an integer between 1 and 1000');
}
return pages;
}
export const detailCommand = cli({
site: 'doubao',
name: 'detail',
Expand All @@ -12,23 +20,28 @@ export const detailCommand = cli({
navigateBefore: false,
args: [
{ name: 'id', required: true, positional: true, help: 'Conversation ID (numeric or full URL)' },
{ name: 'max-pages', type: 'number', default: 500, help: 'Maximum /im/chain/single pages to fetch (1-1000)' },
],
columns: ['Role', 'Text'],
columns: ['Index', 'MessageId', 'Role', 'Type', 'Mode', 'CreatedAt', 'Text', 'Metadata'],
func: async (page, kwargs) => {
const conversationId = parseDoubaoConversationId(kwargs.id);
const { messages, meeting } = await getConversationDetail(page, conversationId);
if (messages.length === 0 && !meeting) {
return [{ Role: 'System', Text: 'No messages found. Verify the conversation ID.' }];
}
const maxPages = parseMaxPages(kwargs['max-pages']);
const { messages, meeting } = await getConversationDetail(page, conversationId, { maxPages });
const result = [];
if (meeting) {
result.push({
Index: 0,
MessageId: '',
Role: 'Meeting',
Type: 'meeting',
Mode: '',
CreatedAt: null,
Text: `${meeting.title}${meeting.time ? ` (${meeting.time})` : ''}`,
Metadata: '{}',
});
}
for (const m of messages) {
result.push({ Role: m.Role, Text: m.Text });
result.push(m);
}
return result;
},
Expand Down
28 changes: 21 additions & 7 deletions clis/doubao/detail.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,33 @@ describe('doubao detail', () => {
time: '2026-03-28 10:00',
},
});
const result = await detail.func({}, { id: '1234567890' });
const result = await detail.func({}, { id: '1234567890', 'max-pages': 500 });
expect(mockGetConversationDetail).toHaveBeenCalledWith({}, '1234567890', { maxPages: 500 });
expect(result).toEqual([
{ Role: 'Meeting', Text: 'Weekly Sync (2026-03-28 10:00)' },
{
Index: 0,
MessageId: '',
Role: 'Meeting',
Type: 'meeting',
Mode: '',
CreatedAt: null,
Text: 'Weekly Sync (2026-03-28 10:00)',
Metadata: '{}',
},
]);
});
it('still returns an error row for a truly empty conversation', async () => {
it('returns an empty result when the API proves a conversation is complete but has no messages', async () => {
mockGetConversationDetail.mockResolvedValue({
messages: [],
meeting: null,
captureComplete: true,
hasMore: false,
});
await expect(detail.func({}, { id: '1234567890', 'max-pages': 500 })).resolves.toEqual([]);
});
it('rejects invalid max-pages without silently clamping it', async () => {
await expect(detail.func({}, { id: '1234567890', 'max-pages': 0 })).rejects.toMatchObject({
code: 'ARGUMENT',
});
const result = await detail.func({}, { id: '1234567890' });
expect(result).toEqual([
{ Role: 'System', Text: 'No messages found. Verify the conversation ID.' },
]);
});
});
101 changes: 101 additions & 0 deletions clis/doubao/download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as path from 'node:path';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { formatCookieHeader } from '@jackwener/opencli/download';
import { downloadMedia } from '@jackwener/opencli/download/media-download';
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
import { DOUBAO_DOMAIN, getConversationAssets, parseDoubaoConversationId } from './utils.js';

const SUPPORTED_VARIANTS = new Set(['original', 'raw', 'preview', 'thumb']);

function parseIntegerOption(rawValue, fallback, label, allowZero = false) {
const value = rawValue ?? String(fallback);
const parsed = Number(value);
const minimum = allowZero ? 0 : 1;
if (!Number.isInteger(parsed) || parsed < minimum) {
throw new ArgumentError(`Invalid Doubao ${label}: ${value}`, allowZero
? 'Use 0 or a positive integer.'
: 'Use a positive integer.');
}
return parsed;
}

function sanitizeFilenamePart(value) {
return String(value || '')
.replace(/[\\/:*?"<>|]+/g, '_')
.replace(/\s+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 120);
}

function extensionFromAsset(asset) {
const format = String(asset.format || '').toLowerCase().replace(/[^a-z0-9]/g, '');
if (format) {
if (format === 'jpeg')
return '.jpg';
if (format === 'heic')
return '.heic';
return `.${format}`;
}
try {
const ext = path.extname(new URL(asset.url).pathname).toLowerCase();
if (ext)
return ext;
}
catch { }
return asset.type === 'video' ? '.mp4' : '.jpg';
}

function filenameForAsset(conversationId, asset, index) {
const rawStem = String(asset.resourceId || asset.identifier || asset.key || `${conversationId}_${index}`);
const basename = rawStem.split('/').filter(Boolean).pop() || rawStem;
const stem = sanitizeFilenamePart(basename.replace(/\.[a-z0-9]{2,5}$/i, ''));
const ext = extensionFromAsset(asset);
return `${String(index).padStart(3, '0')}_${stem || conversationId}${ext}`;
}

export const downloadCommand = cli({
site: 'doubao',
name: 'download',
access: 'read',
description: 'Download images and media from a Doubao conversation',
domain: DOUBAO_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'id', required: true, positional: true, help: 'Conversation ID (numeric or full URL)' },
{ name: 'output', required: false, default: './doubao-downloads', help: 'Output directory' },
{ name: 'variant', required: false, default: 'original', help: 'Image variant: original, raw, preview, or thumb' },
{ name: 'limit', required: false, default: '0', help: 'Max media files to download; 0 means all' },
{ name: 'timeout', required: false, default: '15000', help: 'Per-file download timeout in milliseconds' },
],
columns: ['index', 'type', 'status', 'size', 'filename', 'url'],
func: async (page, kwargs) => {
const conversationId = parseDoubaoConversationId(String(kwargs.id || ''));
const output = String(kwargs.output || './doubao-downloads');
const variant = String(kwargs.variant || 'original');
if (!SUPPORTED_VARIANTS.has(variant)) {
throw new ArgumentError(`Invalid Doubao image variant: ${variant}`, 'Use original, raw, preview, or thumb.');
}
const limit = parseIntegerOption(kwargs.limit, 0, 'media limit', true);
const timeout = parseIntegerOption(kwargs.timeout, 15000, 'download timeout');
const assets = await getConversationAssets(page, conversationId, { variant });
const selectedAssets = limit > 0 ? assets.slice(0, limit) : assets;
if (selectedAssets.length === 0) {
throw new EmptyResultError('doubao download', `No media was extracted for conversation ${conversationId}.`);
}
const cookies = formatCookieHeader(await page.getCookies({ domain: 'doubao.com' }));
const mediaItems = selectedAssets.map((asset, index) => ({
type: asset.type === 'video' ? 'video' : 'image',
url: asset.url,
filename: filenameForAsset(conversationId, asset, index + 1),
}));
return downloadMedia(mediaItems, {
output,
subdir: conversationId,
cookies,
filenamePrefix: conversationId,
timeout,
});
},
});
Loading
Loading