@bsbofmusic/cdper-doubao 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -0
- package/README.md +59 -0
- package/bin/cdper-doubao.cjs +2 -0
- package/manifest.json +41 -0
- package/package.json +45 -0
- package/src/cli.js +158 -0
- package/src/doubao.js +876 -0
- package/src/public-api.js +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bsbofmusic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The Software is provided "AS IS", without warranty of any kind.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# cdper-doubao
|
|
2
|
+
|
|
3
|
+
Standalone Doubao CLI for controlling the user's real Chrome/Edge through CDP Bridge.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @bsbofmusic/cdper-doubao
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requirements:
|
|
12
|
+
|
|
13
|
+
- Node.js 18+
|
|
14
|
+
- CDP Bridge running on the user's browser host
|
|
15
|
+
- Doubao logged in in that browser profile
|
|
16
|
+
- CDP WS configured through `CDP_WS`, `~/.cdp-auth.json`, or `~/.cdp-bridge/config.json`
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cdper-doubao "1+1等于几?"
|
|
22
|
+
cdper-doubao "请详细解释 TCP keepalive" --json --wait normal
|
|
23
|
+
cdper-doubao doctor --json
|
|
24
|
+
cdper-doubao status --json
|
|
25
|
+
cdper-doubao session --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Save a CDP WebSocket URL locally:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cdper-doubao config set --ws "ws://<host>:<port>/devtools/browser?token=<token>" --json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Query options
|
|
35
|
+
|
|
36
|
+
- `--json` - structured JSON output
|
|
37
|
+
- `--mode expert` - Doubao mode path
|
|
38
|
+
- `--session <id>` - continue a known session
|
|
39
|
+
- `--conversation-policy auto|fresh|followup`
|
|
40
|
+
- `--wait short|normal|long|very_long`
|
|
41
|
+
- `--max-wait-ms <ms>`
|
|
42
|
+
- `--progress-every-ms <ms>`
|
|
43
|
+
- `--max-extends <n>`
|
|
44
|
+
- `--no-check` - skip preflight check
|
|
45
|
+
|
|
46
|
+
## Programmatic API
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
const { queryDoubao } = require('@bsbofmusic/cdper-doubao');
|
|
50
|
+
|
|
51
|
+
const result = await queryDoubao('请用一句话回答:1+1等于几?', {
|
|
52
|
+
conversationPolicy: 'fresh',
|
|
53
|
+
expectedDuration: 'short',
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Security
|
|
58
|
+
|
|
59
|
+
All browser control happens through the user's CDP Bridge. Tokens are redacted from diagnostic output. Do not commit `CDP_WS` or full WebSocket URLs containing `token=`.
|
package/manifest.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"packageType": "cli",
|
|
4
|
+
"name": "@bsbofmusic/cdper-doubao",
|
|
5
|
+
"displayName": "cdper Doubao CLI",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"summary": "Standalone Doubao CLI controlled through a user's real Chrome/Edge via CDP Bridge",
|
|
8
|
+
"stability": "stable",
|
|
9
|
+
"publisher": {
|
|
10
|
+
"type": "official",
|
|
11
|
+
"vendor": "bsbofmusic"
|
|
12
|
+
},
|
|
13
|
+
"capabilities": {
|
|
14
|
+
"provides": [
|
|
15
|
+
"plugin.query.chat"
|
|
16
|
+
],
|
|
17
|
+
"requires": [
|
|
18
|
+
"bridge.browser.tab",
|
|
19
|
+
"bridge.browser.input",
|
|
20
|
+
"bridge.browser.extract",
|
|
21
|
+
"core.run-model"
|
|
22
|
+
],
|
|
23
|
+
"optional": []
|
|
24
|
+
},
|
|
25
|
+
"compatibility": {
|
|
26
|
+
"core": "^1.2.4",
|
|
27
|
+
"bridgeProtocol": "^1.0.0",
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"entrypoints": {
|
|
31
|
+
"main": "./src/public-api.js",
|
|
32
|
+
"api": "./src/public-api.js",
|
|
33
|
+
"bin": "./bin/cdper-doubao.cjs"
|
|
34
|
+
},
|
|
35
|
+
"permissions": [],
|
|
36
|
+
"tags": [
|
|
37
|
+
"official",
|
|
38
|
+
"doubao",
|
|
39
|
+
"cli"
|
|
40
|
+
]
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bsbofmusic/cdper-doubao",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Standalone Doubao CLI controlled through a user's real Chrome/Edge via CDP Bridge",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/public-api.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/public-api.js",
|
|
9
|
+
"./api": "./src/public-api.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"cdper-doubao": "bin/cdper-doubao.cjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"manifest.json",
|
|
16
|
+
"src/",
|
|
17
|
+
"bin/cdper-doubao.cjs"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"cdp",
|
|
21
|
+
"doubao",
|
|
22
|
+
"bytedance",
|
|
23
|
+
"tailscale",
|
|
24
|
+
"ai-agent",
|
|
25
|
+
"cdper",
|
|
26
|
+
"openclaw"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/bsbofmusic/cdper.git",
|
|
35
|
+
"directory": "plugins/doubao"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/bsbofmusic/cdper/tree/main/plugins/doubao#readme",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@bsbofmusic/cdper-core": "^1.2.4",
|
|
43
|
+
"yargs": "^17.7.2"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const yargs = require('yargs/yargs');
|
|
8
|
+
const { hideBin } = require('yargs/helpers');
|
|
9
|
+
const { fullCheck, formatAgentHints, fullReadiness } = require('@bsbofmusic/cdper-core/check');
|
|
10
|
+
const { getRuntimeStatus } = require('@bsbofmusic/cdper-core/runtime_status');
|
|
11
|
+
const { listSessions, cleanupExpired, closeSession } = require('@bsbofmusic/cdper-core/session');
|
|
12
|
+
const { redactValue } = require('@bsbofmusic/cdper-core/redact');
|
|
13
|
+
const { queryDoubao } = require('./doubao');
|
|
14
|
+
const pkg = require('../package.json');
|
|
15
|
+
|
|
16
|
+
function writeJson(value) {
|
|
17
|
+
process.stdout.write(JSON.stringify(redactValue(value), null, 2) + '\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseQueryOptions(argv) {
|
|
21
|
+
return {
|
|
22
|
+
mode: argv.mode || 'expert',
|
|
23
|
+
sessionId: argv.session,
|
|
24
|
+
conversationPolicy: argv.conversationPolicy,
|
|
25
|
+
expectedDuration: argv.wait || argv.expectedDuration,
|
|
26
|
+
maxWaitMs: argv.maxWaitMs ?? argv.maxWait,
|
|
27
|
+
progressEveryMs: argv.progressEveryMs,
|
|
28
|
+
maxExtends: argv.maxExtends,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function runQuery(text, argv) {
|
|
33
|
+
if (!text || !String(text).trim()) throw new Error('Query text is required');
|
|
34
|
+
if (argv.quiet) process.env.QUIET = '1';
|
|
35
|
+
if (argv.debug) process.env.DEBUG = '1';
|
|
36
|
+
if (argv.check !== false) {
|
|
37
|
+
const check = await fullCheck();
|
|
38
|
+
if (!check.allPassed) {
|
|
39
|
+
const failure = { ok: false, error: 'environment_not_ready', check, hints: formatAgentHints(check) };
|
|
40
|
+
if (argv.json) writeJson(failure); else console.error(failure.hints);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const result = await queryDoubao(String(text), parseQueryOptions(argv));
|
|
45
|
+
if (argv.json) {
|
|
46
|
+
writeJson(result);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (result.content && String(result.content).trim()) {
|
|
50
|
+
console.log(result.content);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
throw new Error('No valid Doubao reply received');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function runDoctor(argv) {
|
|
57
|
+
const check = await fullCheck();
|
|
58
|
+
const readiness = await fullReadiness('doubao').catch((error) => ({ ok: false, error: error.message }));
|
|
59
|
+
const result = { ok: check.allPassed && readiness.browser === 'ready', version: pkg.version, check, readiness };
|
|
60
|
+
if (argv.json) writeJson(result);
|
|
61
|
+
else {
|
|
62
|
+
console.log(`cdper-doubao ${pkg.version}`);
|
|
63
|
+
console.log(check.summary);
|
|
64
|
+
if (!check.allPassed) console.log(formatAgentHints(check));
|
|
65
|
+
console.log(`Runtime: bridge=${readiness.bridge || 'unknown'} browser=${readiness.browser || 'unknown'} ws=${readiness.ws || 'unknown'}`);
|
|
66
|
+
}
|
|
67
|
+
if (!result.ok) process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runStatus(argv) {
|
|
71
|
+
const result = await getRuntimeStatus('doubao');
|
|
72
|
+
if (argv.json) writeJson(result); else console.log(JSON.stringify(redactValue(result), null, 2));
|
|
73
|
+
if (!result.ok) process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runSessions(argv) {
|
|
77
|
+
const cleaned = cleanupExpired();
|
|
78
|
+
const sessions = listSessions().filter((s) => s.platform === 'doubao');
|
|
79
|
+
const result = { ok: true, cleaned, sessions };
|
|
80
|
+
if (argv.json) writeJson(result);
|
|
81
|
+
else {
|
|
82
|
+
console.log(`Doubao sessions (cleaned ${cleaned})`);
|
|
83
|
+
for (const s of sessions) console.log(`${s.sessionId} turns=${s.turns} ttlMs=${s.ttl}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function runConfigSet(argv) {
|
|
88
|
+
const ws = String(argv.ws || '').trim();
|
|
89
|
+
if (!/^ws:\/\//i.test(ws) || !/token=/i.test(ws)) throw new Error('Expected --ws "ws://<host>:<port>/devtools/browser?token=<token>"');
|
|
90
|
+
const authPath = path.join(os.homedir(), '.cdp-auth.json');
|
|
91
|
+
fs.writeFileSync(authPath, JSON.stringify({ ws_url: ws }, null, 2) + '\n', { mode: 0o600 });
|
|
92
|
+
const result = { ok: true, path: authPath, message: 'Saved CDP WS config' };
|
|
93
|
+
if (argv.json) writeJson(result); else console.log(`Saved ${authPath}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function addQueryOptions(cmd) {
|
|
97
|
+
return cmd
|
|
98
|
+
.option('json', { type: 'boolean', describe: 'Output JSON' })
|
|
99
|
+
.option('mode', { alias: 'm', default: 'expert', choices: ['expert'], describe: 'Doubao mode' })
|
|
100
|
+
.option('session', { alias: 's', type: 'string', describe: 'Session ID' })
|
|
101
|
+
.option('conversation-policy', { choices: ['auto', 'fresh', 'followup'], default: 'auto', describe: 'Conversation policy' })
|
|
102
|
+
.option('wait', { choices: ['short', 'normal', 'long', 'very_long'], default: 'normal', describe: 'Wait profile' })
|
|
103
|
+
.option('expected-duration', { choices: ['short', 'normal', 'long', 'very_long'], describe: 'Alias for --wait' })
|
|
104
|
+
.option('max-wait-ms', { type: 'number', describe: 'Override max base wait' })
|
|
105
|
+
.option('max-wait', { type: 'number', describe: 'Alias for --max-wait-ms' })
|
|
106
|
+
.option('progress-every-ms', { type: 'number', describe: 'Progress snapshot interval' })
|
|
107
|
+
.option('max-extends', { type: 'number', describe: 'Max 60s wait extensions' })
|
|
108
|
+
.option('check', { type: 'boolean', default: true, describe: 'Run environment pre-check before query' })
|
|
109
|
+
.option('quiet', { alias: 'q', type: 'boolean', describe: 'Quiet logs' })
|
|
110
|
+
.option('debug', { alias: 'd', type: 'boolean', describe: 'Debug logs' });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rawArgs = hideBin(process.argv);
|
|
114
|
+
const cliArgs = rawArgs[0] === 'query' && rawArgs[1] && !String(rawArgs[1]).startsWith('-')
|
|
115
|
+
? rawArgs.slice(1)
|
|
116
|
+
: rawArgs;
|
|
117
|
+
|
|
118
|
+
yargs(cliArgs)
|
|
119
|
+
.scriptName('cdper-doubao')
|
|
120
|
+
.usage('$0 <query> [options]')
|
|
121
|
+
.command('$0 <text>', 'Query Doubao', (cmd) => addQueryOptions(cmd.positional('text', { type: 'string', describe: 'Query text' })), async (argv) => runQuery(argv.text, argv).catch(handleError(argv)))
|
|
122
|
+
.command('doctor', 'Check environment readiness', (cmd) => cmd.option('json', { type: 'boolean' }), async (argv) => runDoctor(argv).catch(handleError(argv)))
|
|
123
|
+
.command('check', 'Alias for doctor', (cmd) => cmd.option('json', { type: 'boolean' }), async (argv) => runDoctor(argv).catch(handleError(argv)))
|
|
124
|
+
.command('status', 'Show runtime status', (cmd) => cmd.option('json', { type: 'boolean' }), async (argv) => runStatus(argv).catch(handleError(argv)))
|
|
125
|
+
.command('session [action] [id]', 'List or close sessions', (cmd) => cmd.positional('action', { type: 'string' }).positional('id', { type: 'string' }).option('json', { type: 'boolean' }), (argv) => {
|
|
126
|
+
if (argv.action === 'close') {
|
|
127
|
+
if (!argv.id) return handleError(argv)(new Error('session id is required'));
|
|
128
|
+
closeSession(argv.id);
|
|
129
|
+
const r = { ok: true, sessionId: argv.id };
|
|
130
|
+
if (argv.json) writeJson(r); else console.log(`Closed ${argv.id}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
runSessions(argv);
|
|
134
|
+
})
|
|
135
|
+
.command('config set', 'Save CDP WS config', (cmd) => cmd.option('ws', { type: 'string', demandOption: true }).option('json', { type: 'boolean' }), (argv) => { try { runConfigSet(argv); } catch (error) { handleError(argv)(error); } })
|
|
136
|
+
.command('version', 'Show version', (cmd) => cmd.option('json', { type: 'boolean' }), (argv) => { const r = { ok: true, name: pkg.name, version: pkg.version }; if (argv.json) writeJson(r); else console.log(`${pkg.name} ${pkg.version}`); })
|
|
137
|
+
.showHelpOnFail(false)
|
|
138
|
+
.fail((msg, err, yargsInstance) => {
|
|
139
|
+
const json = rawArgs.includes('--json');
|
|
140
|
+
const error = err || new Error(msg || 'invalid arguments');
|
|
141
|
+
if (json) writeJson({ ok: false, error: error.message });
|
|
142
|
+
else {
|
|
143
|
+
console.error(error.message);
|
|
144
|
+
yargsInstance.showHelp('error');
|
|
145
|
+
}
|
|
146
|
+
process.exit(1);
|
|
147
|
+
})
|
|
148
|
+
.strict()
|
|
149
|
+
.help()
|
|
150
|
+
.parse();
|
|
151
|
+
|
|
152
|
+
function handleError(argv) {
|
|
153
|
+
return (error) => {
|
|
154
|
+
const result = { ok: false, error: error.message, cdp: error.cdp || null };
|
|
155
|
+
if (argv?.json) writeJson(result); else console.error(JSON.stringify(redactValue(result), null, 2));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/doubao.js
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cdper-doubao — 豆包 CDP 查询逻辑
|
|
3
|
+
* Platform-specific DOM logic only. All shared helpers live in @bsbofmusic/cdper-core/plugin_helpers.
|
|
4
|
+
*/
|
|
5
|
+
const { log } = require('@bsbofmusic/cdper-core/logger');
|
|
6
|
+
const { connectWithWarmReuse, disconnect } = require('@bsbofmusic/cdper-core/cdp');
|
|
7
|
+
const { resolveCdpWs } = require('@bsbofmusic/cdper-core/cdp-ws');
|
|
8
|
+
const { waitForDoubaoReply, captureDoubaoReplySnapshot, detectChallenge } = require('@bsbofmusic/cdper-core/smart_wait');
|
|
9
|
+
const { updateSession } = require('@bsbofmusic/cdper-core/session');
|
|
10
|
+
const { createPluginContext, buildPluginSuccess } = require('@bsbofmusic/cdper-core/plugin_contract');
|
|
11
|
+
const { runState } = require('@bsbofmusic/cdper-core/state_machine');
|
|
12
|
+
const { getRuntimeStatus } = require('@bsbofmusic/cdper-core/runtime_status');
|
|
13
|
+
const { WRAPPER_STATUSES, buildWrapperResult } = require('@bsbofmusic/cdper-core/wrapper_result');
|
|
14
|
+
const {
|
|
15
|
+
randomInt, humanPause, withLocalTimeout, withAbortableTimeout, throwIfAborted, ensureMinimumPageDwell, humanWarmup,
|
|
16
|
+
computeSendTimeoutMs, computeTypingDelay, shouldUseDirectTextInsert, insertTextViaCdp,
|
|
17
|
+
buildWaitPlan, createProgressRecorder,
|
|
18
|
+
resolveConversationPolicy, buildConversationAnchor,
|
|
19
|
+
prefersShortAnswer, handlePluginQueryError,
|
|
20
|
+
verifySubmission,
|
|
21
|
+
} = require('@bsbofmusic/cdper-core/plugin_helpers');
|
|
22
|
+
|
|
23
|
+
const PLATFORM = 'doubao';
|
|
24
|
+
const STATE_TIMEOUTS = { open: 150000, ensure_session: 30000, ensure_mode: 45000, send: 45000, extract: 10000 };
|
|
25
|
+
const FALLBACK_TIMEOUT_MS = 90000;
|
|
26
|
+
const WAIT_PROFILES = {
|
|
27
|
+
short: { minWait: 8000, maxWait: 90000, maxExtends: 2, pollInterval: 2500 },
|
|
28
|
+
normal: { minWait: 12000, maxWait: 300000, maxExtends: 6, pollInterval: 2500 },
|
|
29
|
+
long: { minWait: 20000, maxWait: 20 * 60 * 1000, maxExtends: 8, pollInterval: 2500 },
|
|
30
|
+
very_long: { minWait: 30000, maxWait: 45 * 60 * 1000, maxExtends: 10, pollInterval: 2500 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function directActionMode(mode) {
|
|
34
|
+
return mode === 'quick' ? 'quick' : 'deep';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildDoubaoUrlAction(queryText, mode = 'deep') {
|
|
38
|
+
const payload = { text: String(queryText || '') };
|
|
39
|
+
const action = { pluginId: 'Send_Message', payload };
|
|
40
|
+
if (directActionMode(mode) === 'deep') {
|
|
41
|
+
payload.extraExt = {
|
|
42
|
+
input_skill: JSON.stringify({ skill_type: 20 }),
|
|
43
|
+
use_deep_think: '1',
|
|
44
|
+
};
|
|
45
|
+
action.options = {
|
|
46
|
+
deepThinkingActiveType: '3',
|
|
47
|
+
superTaskStatus: { switchValue: 0, taskMode: 0, safeToken: '' },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const url = new URL('https://www.doubao.com/chat/url-action');
|
|
51
|
+
url.searchParams.set('action', JSON.stringify(action));
|
|
52
|
+
return url.toString();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emptyDoubaoSnapshot() {
|
|
56
|
+
return { text: '', normalized: '', length: 0, candidateCount: 0 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildDoubaoModeState(usedUrlAction, urlActionMode, expertModeConfirmed) {
|
|
60
|
+
if (usedUrlAction) {
|
|
61
|
+
return {
|
|
62
|
+
modePath: urlActionMode === 'quick' ? 'url_action_quick' : 'url_action_deep',
|
|
63
|
+
modeConfirmed: true,
|
|
64
|
+
modeConfidence: 'high',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
modePath: expertModeConfirmed ? 'composer_expert' : 'composer_quick',
|
|
69
|
+
modeConfirmed: Boolean(expertModeConfirmed),
|
|
70
|
+
modeConfidence: expertModeConfirmed ? 'high' : 'low',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Conversation anchor ──────────────────────────────────────────────────────
|
|
75
|
+
function isDoubaoUrl(url) {
|
|
76
|
+
return /doubao\.com/.test(String(url || ''));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getDoubaoConversationId(url) {
|
|
80
|
+
const match = String(url || '').match(/https:\/\/(?:www\.)?doubao\.com\/chat\/([^/?#]+)/);
|
|
81
|
+
if (!match || match[1] === 'url-action') return null;
|
|
82
|
+
return match[1];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hasStrongAnchor(session) {
|
|
86
|
+
const conversation = session?.conversation || {};
|
|
87
|
+
if (conversation.platform !== PLATFORM || !conversation.anchorUrl) return false;
|
|
88
|
+
return conversation.anchorConfidence === 'strong' || !!getDoubaoConversationId(conversation.anchorUrl);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getStoredConversationId(session) {
|
|
92
|
+
const conversation = session?.conversation || {};
|
|
93
|
+
return conversation.conversationId || getDoubaoConversationId(conversation.anchorUrl);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function assertFollowupAnchorActive(page, session) {
|
|
97
|
+
const expectedId = getStoredConversationId(session);
|
|
98
|
+
const actualId = getDoubaoConversationId(page.url());
|
|
99
|
+
if (!expectedId || actualId !== expectedId) {
|
|
100
|
+
throw new Error('Doubao followup anchor was not active after navigation; refusing to send into an unanchored chat');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Page readiness ───────────────────────────────────────────────────────────
|
|
105
|
+
async function findDoubaoInput(page) {
|
|
106
|
+
const handles = await page.$$('textarea, [contenteditable="true"], [role="textbox"]');
|
|
107
|
+
for (const handle of handles) {
|
|
108
|
+
try {
|
|
109
|
+
const box = await handle.boundingBox();
|
|
110
|
+
if (!box || box.width < 80 || box.height < 20) continue;
|
|
111
|
+
const meta = await page.evaluate((el) => ({
|
|
112
|
+
tag: el.tagName, placeholder: el.getAttribute('placeholder') || '',
|
|
113
|
+
contenteditable: el.getAttribute('contenteditable') || '', role: el.getAttribute('role') || '',
|
|
114
|
+
disabled: !!el.disabled, ariaHidden: el.getAttribute('aria-hidden') || '',
|
|
115
|
+
}), handle);
|
|
116
|
+
if (meta.disabled || meta.ariaHidden === 'true') continue;
|
|
117
|
+
return { handle, meta };
|
|
118
|
+
} catch (_) {}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function hasDoubaoInput(page, timeoutMs = 12000) {
|
|
124
|
+
const deadline = Date.now() + timeoutMs;
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
const found = await findDoubaoInput(page);
|
|
127
|
+
if (found) return found;
|
|
128
|
+
await humanPause(250, 500);
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function waitForDoubaoPredicate(page, predicate, options = {}) {
|
|
134
|
+
const { timeoutMs = 12000, intervalMs = 250, stableCount = 1, sessionId, challengeCheck = true } = options;
|
|
135
|
+
const deadline = Date.now() + timeoutMs;
|
|
136
|
+
let hits = 0;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
if (challengeCheck) await assertNoChallenge(page, sessionId);
|
|
139
|
+
const matched = await predicate();
|
|
140
|
+
if (matched) {
|
|
141
|
+
hits += 1;
|
|
142
|
+
if (hits >= stableCount) return true;
|
|
143
|
+
} else hits = 0;
|
|
144
|
+
await humanPause(intervalMs, intervalMs + 60);
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function getDoubaoUiState(page) {
|
|
150
|
+
return page.evaluate(() => {
|
|
151
|
+
const bodyText = document.body?.innerText || '';
|
|
152
|
+
const viewportHeight = window.innerHeight || 0;
|
|
153
|
+
const buttons = Array.from(document.querySelectorAll('button'));
|
|
154
|
+
const visibleButton = (button) => {
|
|
155
|
+
const text = (button.innerText || '').trim();
|
|
156
|
+
const rect = button.getBoundingClientRect();
|
|
157
|
+
return { text, visible: rect.width >= 28 && rect.height >= 20 && rect.y <= viewportHeight && rect.bottom >= 0 };
|
|
158
|
+
};
|
|
159
|
+
const quickButton = buttons.find((button) => {
|
|
160
|
+
const meta = visibleButton(button);
|
|
161
|
+
return ['快速', 'Quick'].includes(meta.text) && meta.visible;
|
|
162
|
+
});
|
|
163
|
+
const expertButton = buttons.find((button) => /^(?:专家|Expert)($|\n)/.test(visibleButton(button).text) && visibleButton(button).visible);
|
|
164
|
+
const menu = document.querySelector('[role="menu"]');
|
|
165
|
+
const menuItems = menu
|
|
166
|
+
? Array.from(menu.querySelectorAll('[role="menuitem"]')).map((node) => ({
|
|
167
|
+
text: (node.innerText || '').trim(),
|
|
168
|
+
visible: (() => {
|
|
169
|
+
const rect = node.getBoundingClientRect();
|
|
170
|
+
return rect.width >= 28 && rect.height >= 20;
|
|
171
|
+
})(),
|
|
172
|
+
}))
|
|
173
|
+
: [];
|
|
174
|
+
const hasInput = Array.from(document.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]')).some((element) => {
|
|
175
|
+
const rect = element.getBoundingClientRect();
|
|
176
|
+
if (rect.width < 80 || rect.height < 20) return false;
|
|
177
|
+
const ariaHidden = element.getAttribute('aria-hidden') || '';
|
|
178
|
+
return ariaHidden !== 'true' && !element.disabled;
|
|
179
|
+
});
|
|
180
|
+
const loading = !!document.querySelector('[class*="loading"], [class*="spinner"], [class*="skeleton"]');
|
|
181
|
+
const unavailable = bodyText.includes('该页面暂时不可用');
|
|
182
|
+
return { url: location.href, title: document.title || '', hasInput, hasQuickButton: !!quickButton, hasExpertButton: !!expertButton, menuVisible: !!menu, menuItems, loading, unavailable };
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function waitForDoubaoReady(page, options = {}) {
|
|
187
|
+
const { timeoutMs = 24000, sessionId } = options;
|
|
188
|
+
return waitForDoubaoPredicate(page, async () => {
|
|
189
|
+
const state = await getDoubaoUiState(page);
|
|
190
|
+
if (state.unavailable) {
|
|
191
|
+
await recoverDoubaoUnavailablePage(page);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return state.hasInput;
|
|
195
|
+
}, { timeoutMs, intervalMs: 300, stableCount: 2, sessionId });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function waitForDoubaoModeSettled(page, options = {}) {
|
|
199
|
+
const { timeoutMs = 7000, sessionId } = options;
|
|
200
|
+
return waitForDoubaoPredicate(page, async () => {
|
|
201
|
+
const state = await getDoubaoUiState(page);
|
|
202
|
+
return state.hasExpertButton && state.hasInput && !state.menuVisible;
|
|
203
|
+
}, { timeoutMs, intervalMs: 220, stableCount: 2, sessionId });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function assertNoChallenge(page, sessionId) {
|
|
207
|
+
const detected = await detectChallenge(page);
|
|
208
|
+
if (detected.detected) {
|
|
209
|
+
log('WARN', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: sessionId, step: 'gate', action: 'challenge_detect', status: 'blocked', message: '命中风控/验证页: ' + detected.hint });
|
|
210
|
+
throw new Error('命中风控/验证页,需要人工接管: ' + detected.hint);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
|
215
|
+
async function adoptExistingDoubaoPage(browser, fallbackPage) {
|
|
216
|
+
try {
|
|
217
|
+
const pages = await browser.pages();
|
|
218
|
+
for (const candidate of pages) {
|
|
219
|
+
try {
|
|
220
|
+
const url = candidate.url();
|
|
221
|
+
if (isDoubaoUrl(url) && candidate !== fallbackPage) {
|
|
222
|
+
await candidate.bringToFront();
|
|
223
|
+
try { await fallbackPage.close(); } catch (_) {}
|
|
224
|
+
candidate.__cdperManagedPage = false;
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
} catch (_) {}
|
|
228
|
+
}
|
|
229
|
+
} catch (_) {}
|
|
230
|
+
return fallbackPage;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function clickHandleWithoutCoordinates(handle, label) {
|
|
234
|
+
try {
|
|
235
|
+
await withLocalTimeout(handle.evaluate((element) => element.click()), label + ' DOM click');
|
|
236
|
+
return true;
|
|
237
|
+
} catch (error) { log('WARN', '[doubao]', label + 'DOM click 失败,尝试键盘激活: ' + error.message); }
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await withLocalTimeout((async () => {
|
|
241
|
+
await handle.focus();
|
|
242
|
+
await humanPause(80, 140);
|
|
243
|
+
await handle.press('Enter');
|
|
244
|
+
})(), label + ' keyboard activate');
|
|
245
|
+
return true;
|
|
246
|
+
} catch (error) { log('WARN', '[doubao]', label + '键盘激活失败,尝试句柄点击: ' + error.message); }
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await withLocalTimeout(handle.click({ delay: 80 }), label + ' handle click');
|
|
250
|
+
return true;
|
|
251
|
+
} catch (error) { log('WARN', '[doubao]', label + '句柄点击失败: ' + error.message); }
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function clickButtonByExactText(page, text, label) {
|
|
256
|
+
try {
|
|
257
|
+
const clicked = await page.evaluate((targetText) => {
|
|
258
|
+
const button = Array.from(document.querySelectorAll('button')).find((node) => {
|
|
259
|
+
const nodeText = (node.innerText || '').trim();
|
|
260
|
+
const rect = node.getBoundingClientRect();
|
|
261
|
+
return nodeText === targetText && rect.width > 0 && rect.height > 0;
|
|
262
|
+
});
|
|
263
|
+
if (!button) return false;
|
|
264
|
+
button.click();
|
|
265
|
+
return true;
|
|
266
|
+
}, text);
|
|
267
|
+
if (clicked) return true;
|
|
268
|
+
} catch (error) { log('WARN', '[doubao]', label + '页面级点击失败: ' + error.message); }
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function clickMenuItemByPrefix(page, prefix, label) {
|
|
273
|
+
try {
|
|
274
|
+
const clicked = await page.evaluate((targetPrefix) => {
|
|
275
|
+
const menu = document.querySelector('[role="menu"]');
|
|
276
|
+
if (!menu) return false;
|
|
277
|
+
const item = Array.from(menu.querySelectorAll('[role="menuitem"]')).find((node) => {
|
|
278
|
+
const text = (node.innerText || '').trim();
|
|
279
|
+
const rect = node.getBoundingClientRect();
|
|
280
|
+
return text.startsWith(targetPrefix) && rect.width > 0 && rect.height > 0;
|
|
281
|
+
});
|
|
282
|
+
if (!item) return false;
|
|
283
|
+
item.click();
|
|
284
|
+
return true;
|
|
285
|
+
}, prefix);
|
|
286
|
+
if (clicked) return true;
|
|
287
|
+
} catch (error) { log('WARN', '[doubao]', label + '页面级点击失败: ' + error.message); }
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function recoverDoubaoUnavailablePage(page) {
|
|
292
|
+
try {
|
|
293
|
+
const refreshState = await page.evaluate(() => {
|
|
294
|
+
const bodyText = document.body?.innerText || '';
|
|
295
|
+
if (!bodyText.includes('该页面暂时不可用')) return { found: false, reload: false };
|
|
296
|
+
const hasRefreshButton = Array.from(document.querySelectorAll('button')).some((button) => {
|
|
297
|
+
const text = (button.innerText || '').trim();
|
|
298
|
+
const rect = button.getBoundingClientRect();
|
|
299
|
+
return text.includes('刷新页面') && rect.width > 0 && rect.height > 0;
|
|
300
|
+
});
|
|
301
|
+
return { found: true, reload: !hasRefreshButton };
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (!refreshState.found) return false;
|
|
305
|
+
if (refreshState.reload) {
|
|
306
|
+
await page.reload({ timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
307
|
+
} else {
|
|
308
|
+
const buttons = await page.$$('button');
|
|
309
|
+
let clicked = false;
|
|
310
|
+
for (const button of buttons) {
|
|
311
|
+
const text = await button.evaluate((element) => (element.innerText || '').trim()).catch(() => '');
|
|
312
|
+
if (!text.includes('刷新页面')) continue;
|
|
313
|
+
clicked = await clickHandleWithoutCoordinates(button, '刷新页面按钮');
|
|
314
|
+
if (clicked) break;
|
|
315
|
+
}
|
|
316
|
+
if (!clicked) await page.reload({ timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
317
|
+
}
|
|
318
|
+
await humanPause(2500, 5000);
|
|
319
|
+
return true;
|
|
320
|
+
} catch (_) { return false; }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function openDoubaoHome(page, sessionId) {
|
|
324
|
+
let lastError = null;
|
|
325
|
+
if (isDoubaoUrl(page.url())) {
|
|
326
|
+
await humanWarmup(page);
|
|
327
|
+
await recoverDoubaoUnavailablePage(page);
|
|
328
|
+
if (await waitForDoubaoReady(page, { timeoutMs: 5000, sessionId })) return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const url of ['https://www.doubao.com/', 'https://www.doubao.com/chat/']) {
|
|
332
|
+
try {
|
|
333
|
+
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
334
|
+
await humanWarmup(page);
|
|
335
|
+
await recoverDoubaoUnavailablePage(page);
|
|
336
|
+
if (await waitForDoubaoReady(page, { timeoutMs: 24000, sessionId })) return true;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
lastError = error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (lastError) throw lastError;
|
|
343
|
+
throw new Error('Doubao 页面未准备好:输入框未出现');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function getDoubaoDirectState(page) {
|
|
347
|
+
return page.evaluate(() => {
|
|
348
|
+
const bodyText = document.body?.innerText || '';
|
|
349
|
+
const inputs = Array.from(document.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]'));
|
|
350
|
+
const input = inputs.find((element) => {
|
|
351
|
+
const rect = element.getBoundingClientRect();
|
|
352
|
+
return rect.width >= 80 && rect.height >= 20 && element.getAttribute('aria-hidden') !== 'true' && !element.disabled;
|
|
353
|
+
});
|
|
354
|
+
const inputText = String(input?.value || input?.innerText || input?.textContent || '').trim();
|
|
355
|
+
const stopButton = Array.from(document.querySelectorAll('button')).find((button) => {
|
|
356
|
+
const text = button.innerText || '';
|
|
357
|
+
const aria = button.getAttribute('aria-label') || '';
|
|
358
|
+
return /停止|stop/i.test(`${text} ${aria}`);
|
|
359
|
+
});
|
|
360
|
+
const loading = document.querySelector('[class*="loading"], [class*="spinner"], [class*="skeleton"]');
|
|
361
|
+
const replyContainers = Array.from(document.querySelectorAll('[class*="flow-markdown-body"], [class*="container-P2rR72"], [class*="mdbox-theme"], [class*="message"], [class*="response"], [class*="answer"], .prose'))
|
|
362
|
+
.filter((element) => {
|
|
363
|
+
const cls = String(element.className || '');
|
|
364
|
+
if (/message-list|suggest|recommend|bg-g-send/i.test(cls)) return false;
|
|
365
|
+
const rect = element.getBoundingClientRect();
|
|
366
|
+
const text = (element.innerText || '').trim();
|
|
367
|
+
return rect.width > 0 && rect.height > 0 && text && !/历史对话|内容由豆包 AI 生成/.test(text);
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
url: location.href,
|
|
371
|
+
title: document.title || '',
|
|
372
|
+
bodySample: bodyText.slice(0, 1200),
|
|
373
|
+
hasInput: !!input,
|
|
374
|
+
inputText,
|
|
375
|
+
isGenerating: !!stopButton || !!loading,
|
|
376
|
+
candidateCount: replyContainers.length,
|
|
377
|
+
unavailable: bodyText.includes('该页面暂时不可用'),
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function openDoubaoUrlAction(page, queryText, mode, sessionId) {
|
|
383
|
+
const targetUrl = buildDoubaoUrlAction(queryText, mode);
|
|
384
|
+
await page.goto(targetUrl, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
385
|
+
await humanPause(1800, 3800);
|
|
386
|
+
await recoverDoubaoUnavailablePage(page);
|
|
387
|
+
await assertNoChallenge(page, sessionId);
|
|
388
|
+
const deadline = Date.now() + 30000;
|
|
389
|
+
let state = null;
|
|
390
|
+
while (Date.now() < deadline) {
|
|
391
|
+
state = await getDoubaoDirectState(page);
|
|
392
|
+
if (state.unavailable) await recoverDoubaoUnavailablePage(page);
|
|
393
|
+
if (state.candidateCount > 0 || state.isGenerating) return { accepted: true, needsManualSubmit: false, state, url: targetUrl };
|
|
394
|
+
if (state.hasInput && state.inputText) return { accepted: false, needsManualSubmit: true, state, url: targetUrl };
|
|
395
|
+
await humanPause(400, 800);
|
|
396
|
+
}
|
|
397
|
+
state = state || await getDoubaoDirectState(page).catch(() => null);
|
|
398
|
+
return { accepted: false, needsManualSubmit: true, state, url: targetUrl };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function hasDoubaoConversationContent(page) {
|
|
402
|
+
const snapshot = await captureDoubaoReplySnapshot(page).catch(() => null);
|
|
403
|
+
return Number(snapshot?.length || 0) > 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function ensureFreshDoubaoHome(page, sessionId) {
|
|
407
|
+
await openDoubaoHome(page, sessionId);
|
|
408
|
+
const hasExistingContent = await hasDoubaoConversationContent(page);
|
|
409
|
+
const isConversationUrl = !!getDoubaoConversationId(page.url());
|
|
410
|
+
if (hasExistingContent || isConversationUrl) {
|
|
411
|
+
await clickNewChat(page, { strict: true, sessionId });
|
|
412
|
+
await humanPause(900, 1800);
|
|
413
|
+
}
|
|
414
|
+
await assertNoChallenge(page, sessionId);
|
|
415
|
+
if (!(await waitForDoubaoReady(page, { timeoutMs: 12000, sessionId }))) {
|
|
416
|
+
throw new Error('Doubao fresh conversation input was not ready');
|
|
417
|
+
}
|
|
418
|
+
if (await hasDoubaoConversationContent(page)) {
|
|
419
|
+
throw new Error('Doubao fresh conversation could not be confirmed clean');
|
|
420
|
+
}
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Input helpers ────────────────────────────────────────────────────────────
|
|
425
|
+
async function typeIntoDoubaoInput(page, inputHandle, text) {
|
|
426
|
+
await inputHandle.focus();
|
|
427
|
+
await humanPause(160, 420);
|
|
428
|
+
const value = String(text || '');
|
|
429
|
+
if (shouldUseDirectTextInsert(value)) {
|
|
430
|
+
await insertTextViaCdp(page, value);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
await page.keyboard.type(value, { delay: computeTypingDelay(value) });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function clickDoubaoSend(page) {
|
|
437
|
+
const candidates = await page.$$('button,[role="button"]');
|
|
438
|
+
for (const handle of candidates) {
|
|
439
|
+
const meta = await handle.evaluate((element) => {
|
|
440
|
+
const text = (element.innerText || '').trim();
|
|
441
|
+
const aria = element.getAttribute('aria-label') || '';
|
|
442
|
+
const disabled = element.disabled || element.getAttribute('aria-disabled') === 'true';
|
|
443
|
+
const rect = element.getBoundingClientRect();
|
|
444
|
+
return { text, aria, disabled, visible: rect.width > 0 && rect.height > 0 };
|
|
445
|
+
}).catch(() => null);
|
|
446
|
+
if (!meta || meta.disabled || !meta.visible) continue;
|
|
447
|
+
const looksLikeSend = [meta.text, meta.aria].some((value) => /发送|提交|send/i.test(value));
|
|
448
|
+
if (looksLikeSend) return clickHandleWithoutCoordinates(handle, '发送按钮');
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function clearDoubaoInput(page, inputHandle) {
|
|
454
|
+
await inputHandle.focus();
|
|
455
|
+
await humanPause(100, 220);
|
|
456
|
+
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
|
457
|
+
try { await page.keyboard.down(modifier); await page.keyboard.press('A'); await page.keyboard.up(modifier); } catch (_) {}
|
|
458
|
+
await humanPause(60, 160);
|
|
459
|
+
try { await page.keyboard.press('Backspace'); } catch (_) {}
|
|
460
|
+
await humanPause(80, 180);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function clickNewChat(page, options = {}) {
|
|
464
|
+
const { strict = false, sessionId } = options;
|
|
465
|
+
try {
|
|
466
|
+
const handles = await page.$$('button,[role="button"],div,span,a');
|
|
467
|
+
let target = null;
|
|
468
|
+
for (const handle of handles) {
|
|
469
|
+
const meta = await handle.evaluate((el) => {
|
|
470
|
+
const text = (el.innerText || '').trim();
|
|
471
|
+
const rect = el.getBoundingClientRect();
|
|
472
|
+
return { text, x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
|
473
|
+
}).catch(() => null);
|
|
474
|
+
if (!meta || !['新对话', '新聊天', 'New chat'].includes(meta.text)) continue;
|
|
475
|
+
if (meta.w <= 0 || meta.h <= 0) continue;
|
|
476
|
+
target = handle;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (target) {
|
|
481
|
+
await clickHandleWithoutCoordinates(target, '新对话按钮');
|
|
482
|
+
log('DEBUG', '[doubao]', '已点击新对话');
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
if (strict) {
|
|
486
|
+
log('WARN', '[doubao]', '未找到新对话按钮,严格 fresh 模式下重新打开首页');
|
|
487
|
+
await page.goto('https://www.doubao.com/', { timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
488
|
+
await humanPause(900, 1800);
|
|
489
|
+
return waitForDoubaoReady(page, { timeoutMs: 12000, sessionId });
|
|
490
|
+
}
|
|
491
|
+
const input = await hasDoubaoInput(page, 4000);
|
|
492
|
+
if (input) {
|
|
493
|
+
log('WARN', '[doubao]', '未找到新对话按钮,但输入框可用,保留当前页面继续');
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
log('WARN', '[doubao]', '未找到新对话按钮,重新打开首页');
|
|
497
|
+
await openDoubaoHome(page, sessionId);
|
|
498
|
+
return false;
|
|
499
|
+
} catch (e) { log('WARN', '[doubao]', '新建对话失败: ' + e.message); return false; }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function switchToExpert(page, sessionId) {
|
|
503
|
+
const isExpertButton = async () => (await getDoubaoUiState(page)).hasExpertButton;
|
|
504
|
+
|
|
505
|
+
if (await isExpertButton()) { log('INFO', '[doubao]', '专家按钮已存在,模式已就绪'); return true; }
|
|
506
|
+
|
|
507
|
+
const findQuickButtons = async () => {
|
|
508
|
+
const btns = await page.$$('button');
|
|
509
|
+
let trigger = null;
|
|
510
|
+
let fallback = null;
|
|
511
|
+
for (const btn of btns) {
|
|
512
|
+
const meta = await btn.evaluate((el) => ({
|
|
513
|
+
text: (el.innerText || '').trim(), ariaHasPopup: el.getAttribute('aria-haspopup') || '',
|
|
514
|
+
dataSlot: el.getAttribute('data-slot') || '', dataState: el.getAttribute('data-state') || '',
|
|
515
|
+
})).catch(() => null);
|
|
516
|
+
if (!meta || meta.text !== '快速') continue;
|
|
517
|
+
if (meta.ariaHasPopup === 'menu' || meta.dataSlot === 'dropdown-menu-trigger') {
|
|
518
|
+
trigger = btn;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
if (!fallback) fallback = btn;
|
|
522
|
+
}
|
|
523
|
+
return { trigger, fallback };
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const waitForExpertMenu = () => waitForDoubaoPredicate(page, async () => {
|
|
527
|
+
const state = await getDoubaoUiState(page);
|
|
528
|
+
return state.menuVisible && state.menuItems.some((item) => /^专家($|\n)/.test(item.text));
|
|
529
|
+
}, { timeoutMs: 5000, intervalMs: 180, stableCount: 1, challengeCheck: false });
|
|
530
|
+
|
|
531
|
+
const openQuickMenu = async (attempt) => {
|
|
532
|
+
const { trigger, fallback } = await findQuickButtons();
|
|
533
|
+
const candidates = [
|
|
534
|
+
{ handle: trigger, label: '快速菜单触发器(第' + attempt + '次)' },
|
|
535
|
+
{ handle: fallback, label: '快速按钮候选(第' + attempt + '次)' },
|
|
536
|
+
].filter((item) => item.handle);
|
|
537
|
+
const activate = async (handle, key) => {
|
|
538
|
+
try { await handle.focus(); await humanPause(80, 140); await handle.press(key); return waitForExpertMenu().catch(() => false); } catch (_) { return false; }
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
for (const candidate of candidates) {
|
|
542
|
+
const clicked = await clickHandleWithoutCoordinates(candidate.handle, candidate.label);
|
|
543
|
+
if (clicked && await waitForExpertMenu().catch(() => false)) return true;
|
|
544
|
+
if (await activate(candidate.handle, 'ArrowDown')) return true;
|
|
545
|
+
if (await activate(candidate.handle, 'Enter')) return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return (await clickButtonByExactText(page, '快速', '快速按钮(第' + attempt + '次)')) && await waitForExpertMenu().catch(() => false);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const clickExpertInMenu = async () => {
|
|
552
|
+
if (await clickMenuItemByPrefix(page, '专家', '专家菜单项')) return true;
|
|
553
|
+
const menu = await page.$('[role="menu"]');
|
|
554
|
+
if (!menu) return false;
|
|
555
|
+
const items = await menu.$$('[role="menuitem"]');
|
|
556
|
+
for (const item of items) {
|
|
557
|
+
const text = await item.evaluate((el) => (el.innerText || '').trim()).catch(() => '');
|
|
558
|
+
if (!/^专家($|\n)/.test(text)) continue;
|
|
559
|
+
if (await clickHandleWithoutCoordinates(item, '专家菜单项')) return true;
|
|
560
|
+
}
|
|
561
|
+
return false;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
565
|
+
const { trigger, fallback } = await findQuickButtons();
|
|
566
|
+
if (!trigger && !fallback) {
|
|
567
|
+
log('WARN', '[doubao]', '未找到快速按钮');
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (!(await openQuickMenu(attempt))) {
|
|
572
|
+
log('WARN', '[doubao]', '点击快速后菜单未出现,第' + attempt + '次重试');
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (!(await clickExpertInMenu())) {
|
|
577
|
+
const state = await getDoubaoUiState(page).catch(() => ({ menuItems: [] }));
|
|
578
|
+
log('WARN', '[doubao]', '未在菜单中找到专家项: ' + JSON.stringify((state.menuItems || []).map((item) => item.text)));
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const settled = await waitForDoubaoModeSettled(page, { timeoutMs: 7000, sessionId }).catch(() => false);
|
|
583
|
+
if (settled) {
|
|
584
|
+
log('INFO', '[doubao]', '专家模式切换成功');
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const inputStable = await waitForDoubaoReady(page, { timeoutMs: 5000, sessionId }).catch(() => false);
|
|
589
|
+
if (inputStable && await isExpertButton()) {
|
|
590
|
+
log('INFO', '[doubao]', '专家模式切换成功,输入区稳定');
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
log('WARN', '[doubao]', '专家模式切换未能确认成功');
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ─── Main query ───────────────────────────────────────────────────────────────
|
|
600
|
+
async function queryDoubao(queryText, opts = {}) {
|
|
601
|
+
const { mode = 'expert', sessionId, _retried = false, _quickFallback = false } = opts;
|
|
602
|
+
const abortSignal = opts.abortSignal || null;
|
|
603
|
+
const fallbackMeta = opts._fallbackMeta || null;
|
|
604
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
605
|
+
const hasExplicitSession = Boolean(sessionId);
|
|
606
|
+
const queryPreview = String(queryText || '').slice(0, 50);
|
|
607
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: sessionId || '', step: 'query', action: 'start', status: 'ok', message: '查询: "' + queryPreview + '..." [mode=' + mode + ']' });
|
|
608
|
+
|
|
609
|
+
const { session, flow } = createPluginContext('doubao', PLATFORM, sessionId);
|
|
610
|
+
const conversationPlan = resolveConversationPolicy(opts, session, hasExplicitSession, hasStrongAnchor);
|
|
611
|
+
if (conversationPlan.effective === 'followup' && !hasStrongAnchor(session)) {
|
|
612
|
+
throw new Error('Doubao followup requires an existing conversation anchor for this sessionId');
|
|
613
|
+
}
|
|
614
|
+
const waitPlan = buildWaitPlan(opts, WAIT_PROFILES);
|
|
615
|
+
const progressRecorder = createProgressRecorder('doubao', opts);
|
|
616
|
+
let activeSession = session;
|
|
617
|
+
let initialReplySnapshot = null;
|
|
618
|
+
let inputReadyAfterOpen = false;
|
|
619
|
+
let promptSubmitted = false;
|
|
620
|
+
let usedUrlAction = false;
|
|
621
|
+
let urlActionNeedsManualSubmit = false;
|
|
622
|
+
let urlActionMode = directActionMode(mode);
|
|
623
|
+
let expertModeConfirmed = false;
|
|
624
|
+
let page, browser;
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
628
|
+
await runState(flow, 'open', async () => {
|
|
629
|
+
throwIfAborted(abortSignal, 'Doubao open');
|
|
630
|
+
const wantsFreshPage = conversationPlan.effective === 'fresh';
|
|
631
|
+
const connected = await connectWithWarmReuse({
|
|
632
|
+
wsUrl: resolveCdpWs(),
|
|
633
|
+
preferHost: 'doubao.com',
|
|
634
|
+
prewarmUrl: 'https://www.doubao.com/',
|
|
635
|
+
titleHint: '豆包',
|
|
636
|
+
mode: 'advanced',
|
|
637
|
+
profile: 'Default',
|
|
638
|
+
forceNewPage: wantsFreshPage,
|
|
639
|
+
waitMs: randomInt(800, 1600),
|
|
640
|
+
});
|
|
641
|
+
page = connected.page;
|
|
642
|
+
browser = connected.browser;
|
|
643
|
+
// Fresh queries should start from a fresh tab instead of adopting a stale
|
|
644
|
+
// conversation that happens to share the same host.
|
|
645
|
+
if (!connected.reusedExistingPage && !wantsFreshPage) page = await adoptExistingDoubaoPage(browser, page);
|
|
646
|
+
|
|
647
|
+
if (conversationPlan.effective === 'followup') {
|
|
648
|
+
inputReadyAfterOpen = await openDoubaoHome(page, session.sessionId);
|
|
649
|
+
const anchorUrl = session.conversation?.anchorUrl;
|
|
650
|
+
if (anchorUrl && page.url() !== anchorUrl) {
|
|
651
|
+
await page.goto(anchorUrl, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
|
652
|
+
await humanPause(900, 1800);
|
|
653
|
+
await assertNoChallenge(page, session.sessionId);
|
|
654
|
+
assertFollowupAnchorActive(page, session);
|
|
655
|
+
inputReadyAfterOpen = await waitForDoubaoReady(page, { timeoutMs: 24000, sessionId: session.sessionId });
|
|
656
|
+
if (!inputReadyAfterOpen) throw new Error('Doubao followup anchor opened but input was not ready');
|
|
657
|
+
}
|
|
658
|
+
assertFollowupAnchorActive(page, session);
|
|
659
|
+
} else if (conversationPlan.effective === 'fresh') {
|
|
660
|
+
// Fresh Doubao queries use the official URL action entrypoint. It
|
|
661
|
+
// submits the prompt server-side and enables deep thinking without
|
|
662
|
+
// brittle menu switching. The legacy composer path remains for
|
|
663
|
+
// anchored followups and rare manual-submit fallback.
|
|
664
|
+
initialReplySnapshot = emptyDoubaoSnapshot();
|
|
665
|
+
const direct = await openDoubaoUrlAction(page, queryText, urlActionMode, session.sessionId);
|
|
666
|
+
usedUrlAction = true;
|
|
667
|
+
urlActionNeedsManualSubmit = direct.needsManualSubmit;
|
|
668
|
+
inputReadyAfterOpen = Boolean(direct.state?.hasInput || direct.accepted);
|
|
669
|
+
promptSubmitted = direct.accepted;
|
|
670
|
+
if (!inputReadyAfterOpen) throw new Error('Doubao url-action did not prepare a prompt or conversation');
|
|
671
|
+
} else {
|
|
672
|
+
inputReadyAfterOpen = await openDoubaoHome(page, session.sessionId);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
page.__cdperManagedPage = false;
|
|
676
|
+
await ensureMinimumPageDwell(Date.now(), 'doubao');
|
|
677
|
+
}, { timeoutMs: STATE_TIMEOUTS.open, retries: 1, retryable: (error) => /页面未准备好|输入框未出现|STATE_TIMEOUT/.test(String(error.message || '') + ' ' + String(error.code || '')) });
|
|
678
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'flow', action: 'stage_complete', stage: 'open', status: 'ok' });
|
|
679
|
+
|
|
680
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
681
|
+
await runState(flow, 'ensure_session', async () => {
|
|
682
|
+
if (usedUrlAction && promptSubmitted) return;
|
|
683
|
+
if (inputReadyAfterOpen && await hasDoubaoInput(page, 1800)) return;
|
|
684
|
+
const existingInput = await hasDoubaoInput(page, 12000);
|
|
685
|
+
if (!existingInput) {
|
|
686
|
+
if (conversationPlan.effective === 'followup') {
|
|
687
|
+
throw new Error('Doubao followup anchor opened but input was not ready; refusing to start a fresh chat');
|
|
688
|
+
}
|
|
689
|
+
await clickNewChat(page, { sessionId: session.sessionId });
|
|
690
|
+
}
|
|
691
|
+
if (!(await waitForDoubaoReady(page, { timeoutMs: 12000, sessionId: session.sessionId }))) {
|
|
692
|
+
throw new Error('Doubao 输入框未就绪');
|
|
693
|
+
}
|
|
694
|
+
}, { timeoutMs: STATE_TIMEOUTS.ensure_session });
|
|
695
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'flow', action: 'stage_complete', stage: 'ensure_session', status: 'ok' });
|
|
696
|
+
|
|
697
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
698
|
+
expertModeConfirmed = await runState(flow, 'ensure_mode', async () => {
|
|
699
|
+
if (usedUrlAction) return false;
|
|
700
|
+
const confirmed = await switchToExpert(page, session.sessionId);
|
|
701
|
+
await assertNoChallenge(page, session.sessionId);
|
|
702
|
+
if (confirmed) await waitForDoubaoModeSettled(page, { timeoutMs: 6000, sessionId: session.sessionId }).catch(() => false);
|
|
703
|
+
return confirmed;
|
|
704
|
+
}, { timeoutMs: STATE_TIMEOUTS.ensure_mode });
|
|
705
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'flow', action: 'stage_complete', stage: 'ensure_mode', status: 'ok', ...buildDoubaoModeState(usedUrlAction, urlActionMode, expertModeConfirmed), ...(usedUrlAction ? {} : { expertModeConfirmed }) });
|
|
706
|
+
if (!usedUrlAction && !expertModeConfirmed) {
|
|
707
|
+
log('WARN', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'mode', action: 'expert_confirm', status: 'blocked', message: '未确认进入专家模式,停止发送以避免错误模式输出' });
|
|
708
|
+
throw new Error('Doubao 专家模式未确认,需要人工接管');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
throwIfAborted(abortSignal, 'Doubao query');
|
|
712
|
+
await runState(flow, 'send', async () => {
|
|
713
|
+
if (usedUrlAction) {
|
|
714
|
+
if (!initialReplySnapshot) initialReplySnapshot = emptyDoubaoSnapshot();
|
|
715
|
+
if (promptSubmitted && !urlActionNeedsManualSubmit) {
|
|
716
|
+
flow.enter('send', { submitVerified: true, submitSignal: 'url_action_accepted', submitElapsed: 0, mode: urlActionMode });
|
|
717
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'query', action: 'submit', status: 'ok', message: '问题已通过 url-action 发送' });
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const input = await hasDoubaoInput(page, 18000);
|
|
722
|
+
if (!input) throw new Error('Doubao 输入框未就绪');
|
|
723
|
+
await assertNoChallenge(page, session.sessionId);
|
|
724
|
+
if (!usedUrlAction) {
|
|
725
|
+
initialReplySnapshot = await captureDoubaoReplySnapshot(page).catch(() => null);
|
|
726
|
+
await clearDoubaoInput(page, input.handle);
|
|
727
|
+
await typeIntoDoubaoInput(page, input.handle, queryText);
|
|
728
|
+
} else {
|
|
729
|
+
await input.handle.focus();
|
|
730
|
+
}
|
|
731
|
+
await assertNoChallenge(page, session.sessionId);
|
|
732
|
+
await humanPause(900, 1800);
|
|
733
|
+
const clicked = await clickDoubaoSend(page);
|
|
734
|
+
if (!clicked) await page.keyboard.press('Enter');
|
|
735
|
+
await assertNoChallenge(page, session.sessionId);
|
|
736
|
+
await humanPause(350, 900);
|
|
737
|
+
promptSubmitted = true;
|
|
738
|
+
|
|
739
|
+
// P1-3: Verify submission was accepted
|
|
740
|
+
const submitVerify = await verifySubmission(page, {
|
|
741
|
+
timeoutMs: 15000,
|
|
742
|
+
pollIntervalMs: 500,
|
|
743
|
+
baselineSnapshot: initialReplySnapshot,
|
|
744
|
+
captureUserTurn: async (page, baseline) => {
|
|
745
|
+
const baselineCount = Number(baseline?.candidateCount || 0);
|
|
746
|
+
return page.evaluate((bc) => {
|
|
747
|
+
// Doubao: check if number of visible reply containers increased
|
|
748
|
+
const replyContainers = document.querySelectorAll('[class*="flow-markdown-body"], [class*="container-P2rR72"]');
|
|
749
|
+
return replyContainers.length > bc;
|
|
750
|
+
}, baselineCount);
|
|
751
|
+
},
|
|
752
|
+
captureInputCleared: async (page) => {
|
|
753
|
+
return page.evaluate(() => {
|
|
754
|
+
const textarea = document.querySelector('textarea') || document.querySelector('[contenteditable="true"]');
|
|
755
|
+
if (!textarea) return false;
|
|
756
|
+
const val = textarea.value || textarea.innerText || '';
|
|
757
|
+
return val.trim().length <= 5; // allow very short residual whitespace
|
|
758
|
+
});
|
|
759
|
+
},
|
|
760
|
+
captureGenerating: async (page) => {
|
|
761
|
+
return page.evaluate(() => {
|
|
762
|
+
const stopBtn = Array.from(document.querySelectorAll('button')).find(
|
|
763
|
+
(b) => b.innerText?.includes('停止') || b.getAttribute('aria-label')?.includes('stop')
|
|
764
|
+
);
|
|
765
|
+
const loading = document.querySelector('[class*="loading"]') || document.querySelector('[class*="skeleton"]');
|
|
766
|
+
return !!stopBtn || !!loading;
|
|
767
|
+
});
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
if (!submitVerify.accepted) {
|
|
772
|
+
flow.enter('send', { submitVerified: false, submitElapsed: submitVerify.elapsed });
|
|
773
|
+
const error = new Error('Doubao 消息未成功提交 (send_not_accepted)');
|
|
774
|
+
error.code = 'SEND_NOT_ACCEPTED';
|
|
775
|
+
throw error;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
flow.enter('send', { submitVerified: true, submitSignal: submitVerify.signal, submitElapsed: submitVerify.elapsed });
|
|
779
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'query', action: 'submit', status: 'ok', message: '问题已发送' });
|
|
780
|
+
}, { timeoutMs: computeSendTimeoutMs(queryText, STATE_TIMEOUTS.send) });
|
|
781
|
+
|
|
782
|
+
const result = await runState(flow, 'wait', () => waitForDoubaoReply(page, {
|
|
783
|
+
query: queryText, abortSignal, minWait: waitPlan.minWait, maxWait: waitPlan.maxWait, pollInterval: waitPlan.pollInterval,
|
|
784
|
+
stableThreshold: 4, enableCircuitBreaker: true, enableExtend: true, maxExtends: waitPlan.maxExtends,
|
|
785
|
+
acceptShortContent: prefersShortAnswer(queryText), minShortContentLength: 1,
|
|
786
|
+
onProgress: progressRecorder.onProgress, challengeCheckIntervalMs: 6000, initialReplySnapshot,
|
|
787
|
+
}), { timeoutMs: waitPlan.stateTimeoutMs });
|
|
788
|
+
|
|
789
|
+
if (!result?.content) throw new Error('未获取到有效回复');
|
|
790
|
+
const wrapperStatus = result.wrapperStatus || result.exitReason;
|
|
791
|
+
if ([WRAPPER_STATUSES.CAPTCHA_REQUIRED, WRAPPER_STATUSES.CIRCUIT_BREAKER, 'challenge', 'circuit_breaker'].includes(wrapperStatus)) {
|
|
792
|
+
throw new Error('查询被阻断,需要人工接管: ' + wrapperStatus + (result.detectedChallenge ? ' (' + result.detectedChallenge + ')' : ''));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const completenessScore = result.completeness?.score || 0;
|
|
796
|
+
const contentMarkedIncomplete = result.completeness?.isComplete === false;
|
|
797
|
+
if (!_quickFallback && conversationPlan.effective === 'fresh' && usedUrlAction && urlActionMode === 'deep' && completenessScore < 70 && contentMarkedIncomplete && !prefersShortAnswer(queryText)) {
|
|
798
|
+
log('WARN', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'query', action: 'fallback', status: 'degraded', message: 'deep url-action produced low-completeness content; retrying quick url-action (score=' + completenessScore + ')' });
|
|
799
|
+
if (page || browser) await disconnect(page, browser).catch(() => undefined);
|
|
800
|
+
page = null;
|
|
801
|
+
browser = null;
|
|
802
|
+
return await withAbortableTimeout(
|
|
803
|
+
(signal) => queryDoubao(queryText, { ...opts, mode: 'quick', conversationPolicy: 'fresh', _quickFallback: true, abortSignal: signal, _fallbackMeta: { attempted: true, fromMode: urlActionMode, toMode: 'quick', reason: 'low_completeness', timeoutMs: FALLBACK_TIMEOUT_MS } }),
|
|
804
|
+
'quick url-action fallback',
|
|
805
|
+
FALLBACK_TIMEOUT_MS
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (wrapperStatus === WRAPPER_STATUSES.PARTIAL_TIMEOUT && result.content) {
|
|
810
|
+
return buildWrapperResult({
|
|
811
|
+
status: WRAPPER_STATUSES.PARTIAL_TIMEOUT,
|
|
812
|
+
content: result.content,
|
|
813
|
+
exitReason: result.exitReason,
|
|
814
|
+
diagnostics: { pillars: result.pillars, completeness: result.completeness },
|
|
815
|
+
platform: 'doubao',
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
activeSession = await runState(flow, 'extract', async () => {
|
|
820
|
+
const conversation = buildConversationAnchor(PLATFORM, page.url(), getDoubaoConversationId, conversationPlan.effective);
|
|
821
|
+
return updateSession(session.sessionId, { turns: session.turns + 1, conversation });
|
|
822
|
+
}, { timeoutMs: STATE_TIMEOUTS.extract });
|
|
823
|
+
|
|
824
|
+
const modeState = buildDoubaoModeState(usedUrlAction, urlActionMode, expertModeConfirmed);
|
|
825
|
+
const contentIsComplete = result.completeness?.isComplete === true;
|
|
826
|
+
const finalStatus = ['complete', 'complete_short'].includes(result.exitReason) && contentIsComplete ? 'ok' : 'degraded';
|
|
827
|
+
log('INFO', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'query', action: 'complete', status: finalStatus, message: '回复完成:' + result.content.length + ' 字,完整度 ' + (result.completeness?.score || 0) + '分,exitReason=' + (result.exitReason || 'unknown') });
|
|
828
|
+
|
|
829
|
+
return buildPluginSuccess({
|
|
830
|
+
pluginName: 'doubao',
|
|
831
|
+
session: activeSession,
|
|
832
|
+
flow,
|
|
833
|
+
runtimeStatus: await getRuntimeStatus('doubao'),
|
|
834
|
+
doneMeta: { status: finalStatus, turn: activeSession.turns, ...modeState, ...(usedUrlAction ? {} : { expertModeConfirmed }), usedUrlAction, urlActionMode: usedUrlAction ? urlActionMode : null, conversationPolicy: conversationPlan.effective },
|
|
835
|
+
payload: {
|
|
836
|
+
content: result.content,
|
|
837
|
+
score: completenessScore,
|
|
838
|
+
completeness: result.completeness || null,
|
|
839
|
+
status: finalStatus,
|
|
840
|
+
fallback: fallbackMeta,
|
|
841
|
+
exitReason: result.exitReason || 'unknown',
|
|
842
|
+
...modeState,
|
|
843
|
+
...(usedUrlAction ? {} : { expertModeConfirmed }),
|
|
844
|
+
usedUrlAction,
|
|
845
|
+
urlActionMode: usedUrlAction ? urlActionMode : null,
|
|
846
|
+
detectedChallenge: result.detectedChallenge || null,
|
|
847
|
+
conversationPolicy: conversationPlan.requested,
|
|
848
|
+
effectiveConversationPolicy: conversationPlan.effective,
|
|
849
|
+
conversation: activeSession.conversation || null,
|
|
850
|
+
waitPlan: { expectedDuration: waitPlan.expectedDuration, minWait: waitPlan.minWait, maxWait: waitPlan.maxWait, maxExtends: waitPlan.maxExtends, pollInterval: waitPlan.pollInterval, elapsed: result.elapsed, extendCount: result.extendCount },
|
|
851
|
+
progress: progressRecorder.events,
|
|
852
|
+
},
|
|
853
|
+
});
|
|
854
|
+
} catch (error) {
|
|
855
|
+
if (!_quickFallback && error.code !== 'FALLBACK_TIMEOUT' && conversationPlan.effective === 'fresh' && usedUrlAction && urlActionMode === 'deep') {
|
|
856
|
+
log('WARN', '[doubao]', { component: 'doubao', plugin: 'cdper-doubao', session_id: session.sessionId, step: 'query', action: 'fallback', status: 'degraded', message: 'deep url-action failed; retrying quick url-action: ' + error.message });
|
|
857
|
+
if (page || browser) await disconnect(page, browser).catch(() => undefined);
|
|
858
|
+
page = null;
|
|
859
|
+
browser = null;
|
|
860
|
+
return await withAbortableTimeout(
|
|
861
|
+
(signal) => queryDoubao(queryText, { ...opts, mode: 'quick', conversationPolicy: 'fresh', _quickFallback: true, abortSignal: signal, _fallbackMeta: { attempted: true, fromMode: urlActionMode, toMode: 'quick', reason: error.message, timeoutMs: FALLBACK_TIMEOUT_MS } }),
|
|
862
|
+
'quick url-action fallback',
|
|
863
|
+
FALLBACK_TIMEOUT_MS
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
return handlePluginQueryError(error, {
|
|
867
|
+
flow, pluginName: 'doubao', retried: _retried,
|
|
868
|
+
retryFn: () => queryDoubao(queryText, { ...opts, _retried: true }),
|
|
869
|
+
failedMeta: { error: error.message, conversationPolicy: conversationPlan.effective, promptSubmitted },
|
|
870
|
+
});
|
|
871
|
+
} finally {
|
|
872
|
+
if (page || browser) await disconnect(page, browser);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
module.exports = { queryDoubao };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const manifest = require('../manifest.json');
|
|
2
|
+
const { queryDoubao } = require('./doubao.js');
|
|
3
|
+
|
|
4
|
+
function getCapabilities() {
|
|
5
|
+
return (manifest.capabilities?.provides || []).map((id) => ({
|
|
6
|
+
id,
|
|
7
|
+
version: '1.0',
|
|
8
|
+
summary: id,
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
manifest,
|
|
14
|
+
getCapabilities,
|
|
15
|
+
queryDoubao,
|
|
16
|
+
};
|