diff --git a/apps/desktop-client/src/main/adapters/doubao.test.ts b/apps/desktop-client/src/main/adapters/doubao.test.ts index a47e849..0523a0c 100644 --- a/apps/desktop-client/src/main/adapters/doubao.test.ts +++ b/apps/desktop-client/src/main/adapters/doubao.test.ts @@ -3,15 +3,21 @@ import { describe, expect, it } from 'vitest' import { __doubaoTestUtils } from './doubao' const { + buildDoubaoDomSearchResults, buildSourceItem, dedupeSourceItems, + doubaoModeProviderModel, doubaoModeKindFromLabelText, extractQuestionText, isDoubaoRecoverablePageError, isNonCitationAssetDomain, isNonCitationAssetUrl, + hasDoubaoFinishedStreamCapture, + parseDoubaoCaptures, parseDoubaoStreamBody, resolveDoubaoSubmissionAnswer, + shouldAllowDoubaoDomAnswer, + shouldAllowDoubaoReasoningAnswer, shouldRetryDoubaoPageAttempt, } = __doubaoTestUtils @@ -92,6 +98,36 @@ describe('doubao adapter helpers', () => { ).toBe(false) }) + it('does not retry a recoverable page timeout when the page already has a usable answer', () => { + const streamSummary = parseDoubaoStreamBody( + ['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'), + ) + + expect( + shouldRetryDoubaoPageAttempt( + { + ok: false, + error: 'doubao_query_timeout', + detail: '页面轮询超时', + url: 'https://www.doubao.com/chat/local_4995117315817271', + title: '豆包', + modelLabel: '豆包', + modeLabel: '思考', + thinkingModeApplied: true, + deepThinkEnabled: true, + domAnswer: + '室内门锁供货商通常要从供货稳定性、锁体规格、表面处理、质检能力和售后响应几个维度筛选。建议优先核验工厂资质、样品一致性、交期和五金配套能力。', + domReasoning: null, + domLinks: [], + submitted: true, + questionAnchorFound: true, + historySurfaceDetected: true, + }, + streamSummary, + ), + ).toBe(false) + }) + it('maps the new expert/deep-thinking mode label to expert mode', () => { expect(doubaoModeKindFromLabelText('专家')).toBe('expert') expect(doubaoModeKindFromLabelText('专家 深度思考 研究级智能模型')).toBe('expert') @@ -99,6 +135,11 @@ describe('doubao adapter helpers', () => { expect(doubaoModeKindFromLabelText('快速')).toBe('fast') }) + it('reports Doubao default/expert mode as the fast web provider model', () => { + expect(doubaoModeProviderModel('fast')).toBe('doubao-web-fast') + expect(doubaoModeProviderModel('expert')).toBe('doubao-web-fast') + }) + it('filters byteimg and bytednsdoc asset CDN links from source items', () => { expect(isNonCitationAssetDomain('p3-spider-image-sign.byteimg.com')).toBe(true) expect(isNonCitationAssetDomain('lf3-static.bytednsdoc.com')).toBe(true) @@ -148,6 +189,103 @@ describe('doubao adapter helpers', () => { ).toHaveLength(1) }) + it('merges multiple thinking search-reference groups from the page fallback', () => { + const domLinks = [ + { + url: 'https://example.com/guide-1', + title: '2026年全国室内分体锁批发选购指南参考', + text: '2026年全国室内分体锁批发选购指南参考', + siteName: null, + }, + { + url: 'https://example.com/zhongshan-lock', + title: '中山小榄头部锁厂盘点', + text: '中山小榄头部锁厂盘点', + siteName: null, + }, + { + url: 'https://example.com/brand-rank', + title: '2026室内门锁品牌实测排行', + text: '2026室内门锁品牌实测排行', + siteName: null, + }, + { + url: 'https://example.com/guide-1#search-two', + title: '重复链接应按去 hash 后合并', + text: '重复链接应按去 hash 后合并', + siteName: null, + }, + { + url: 'https://p11-spider-image-sign.byteimg.com/logo.jpeg', + title: 'favicon asset', + text: 'favicon asset', + siteName: null, + }, + { + url: 'https://supplier.example.cn/manufacturers-of-door-locks-supplier.html', + title: 'Manufacturers of door locks supplier.html', + text: 'Manufacturers of door locks supplier.html', + siteName: null, + }, + ] + + const results = buildDoubaoDomSearchResults(domLinks) + + expect(results.map((item) => item.url)).toEqual([ + 'https://example.com/guide-1', + 'https://example.com/zhongshan-lock', + 'https://example.com/brand-rank', + 'https://supplier.example.cn/manufacturers-of-door-locks-supplier.html', + ]) + expect(results[0]?.title).toBe('2026年全国室内分体锁批发选购指南参考') + }) + + it('keeps expanded Doubao reference links from the DOM fallback', () => { + const results = buildDoubaoDomSearchResults([ + { + url: 'https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%23card', + title: 'real blue source title', + text: 'real blue source title', + siteName: 'example site', + }, + { + url: 'https://another.example.cn/article?from=doubao', + title: 'another expanded source', + text: 'another expanded source', + siteName: null, + }, + ]) + + expect(results.map((item) => item.url)).toEqual([ + 'https://example.com/source', + 'https://another.example.cn/article?from=doubao', + ]) + expect(results[0]).toMatchObject({ + title: 'real blue source title', + site_name: 'example site', + host: 'example.com', + }) + }) + + it('does not turn Doubao gray search-query text into DOM search results', () => { + const results = buildDoubaoDomSearchResults([ + { + url: '搜索 3 个关键词,参考 18 篇资料\n"无下轨折叠门套件"\nhttps://example.com/gray-text-only', + title: '搜索 3 个关键词,参考 18 篇资料', + text: '搜索 3 个关键词,参考 18 篇资料\n"无下轨折叠门套件"', + siteName: null, + }, + { + url: '1. reference https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dgray', + title: 'gray explanation line', + text: 'gray explanation line', + siteName: null, + }, + ]) + + expect(results).toEqual([]) + }) + it('uses thinking-chain reference metadata to fill unknown Doubao source titles', () => { const body = [ 'event: STREAM_CHUNK', @@ -178,7 +316,7 @@ describe('doubao adapter helpers', () => { }) }) - it('extracts source URLs embedded in Doubao thinking-panel text', () => { + it('still extracts embedded source URLs from structured SSE source payloads', () => { const item = buildSourceItem({ url: '1. 合肥全屋定制榜单 https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dthink#card', title: '思考链路里的来源', @@ -330,6 +468,172 @@ describe('doubao adapter helpers', () => { }) }) + it('uses in-page incremental SSE captures before Playwright response.body resolves', () => { + const streamBody = [ + 'event: SSE_ACK', + 'data: {"ack_client_meta":{"conversation_id":"38432170130380802"}}', + '', + 'event: STREAM_CHUNK', + 'data: {"references":[{"url":"https://example.com/source-a","title":"真实参考来源","site_name":"示例站点"}]}', + '', + 'event: CHUNK_DELTA', + 'data: {"text":"豆包 SSE 可以边生成边拿到答案,"}', + '', + 'event: CHUNK_DELTA', + 'data: {"text":"即使底层连接还没有完全关闭,也应优先提交这段完整正文。"}', + '', + 'event: SSE_REPLY_END', + 'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}', + '', + '', + ].join('\n') + + const captures = [ + { + captureId: 'fetch-1-1', + url: 'https://www.doubao.com/chat/completion?aid=497858', + status: 200, + contentType: 'text/event-stream', + body: streamBody, + }, + ] + const summary = parseDoubaoCaptures(captures) + + expect(hasDoubaoFinishedStreamCapture(captures)).toBe(true) + expect(summary.answer).toBe( + '豆包 SSE 可以边生成边拿到答案,即使底层连接还没有完全关闭,也应优先提交这段完整正文。', + ) + expect(summary.answer_finish_event_count).toBe(1) + expect(summary.search_results).toHaveLength(1) + expect( + resolveDoubaoSubmissionAnswer({ + answer: summary.answer, + sseFinished: summary.answer_finish_event_count > 0, + }), + ).toEqual({ + answer: + '豆包 SSE 可以边生成边拿到答案,即使底层连接还没有完全关闭,也应优先提交这段完整正文。', + source: 'sse', + }) + }) + + it('falls back to the page answer when SSE has not finished but the page is usable', () => { + const domAnswer = + '室内分体锁批发通常要先确认锁体中心距、面板材质、执手方向、钥匙系统和包装要求。批量采购时建议同时核验样品、交期、质检记录和售后换货规则。' + + expect( + resolveDoubaoSubmissionAnswer({ + answer: '室内分体锁批发', + domAnswer, + allowDomAnswer: true, + sseFinished: false, + }), + ).toEqual({ + answer: domAnswer, + source: 'dom', + }) + }) + + it('keeps the finished SSE answer ahead of the page fallback', () => { + expect( + resolveDoubaoSubmissionAnswer({ + answer: + '室内分体锁批发可以从规格兼容性、供货稳定性、样品一致性和售后响应四个维度筛选供应商。', + domAnswer: '页面上的室内分体锁批发答案通常更长,但完整 SSE 已经确认时仍优先使用 SSE 正文。', + allowDomAnswer: true, + sseFinished: true, + }), + ).toEqual({ + answer: + '室内分体锁批发可以从规格兼容性、供货稳定性、样品一致性和售后响应四个维度筛选供应商。', + source: 'sse', + }) + }) + + it('allows DOM fallback for recoverable Doubao page errors with an anchored answer', () => { + const streamSummary = parseDoubaoStreamBody( + ['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'), + ) + + expect( + shouldAllowDoubaoDomAnswer({ + streamSummary, + pageResult: { + ok: false, + error: 'doubao_query_timeout', + detail: '页面轮询超时', + url: 'https://www.doubao.com/chat/local_4995117315817271', + title: '豆包', + modelLabel: '豆包', + modeLabel: '思考', + thinkingModeApplied: true, + deepThinkEnabled: true, + domAnswer: + '室内门锁供货商通常要从供货稳定性、锁体规格、表面处理、质检能力和售后响应几个维度筛选。建议优先核验工厂资质、样品一致性、交期和五金配套能力。', + domReasoning: null, + domLinks: [], + submitted: true, + questionAnchorFound: true, + historySurfaceDetected: false, + }, + }), + ).toBe(true) + }) + + it('uses thinking-chain text as the fallback answer when page answer is missing', () => { + const reasoning = + '我计划找室内门锁供货商,先搜索国内知名品牌厂家、批发采购渠道和区域供应商信息。已经整理出主流品牌供货商、源头工厂、线上批发平台及补充采购建议,可为用户提供全面参考。' + const streamSummary = parseDoubaoStreamBody( + ['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'), + ) + const pageResult = { + ok: false, + error: 'doubao_query_timeout', + detail: '页面轮询超时', + url: 'https://www.doubao.com/chat/local_4995117315817271', + title: '豆包', + modelLabel: '豆包', + modeLabel: '思考', + thinkingModeApplied: true, + deepThinkEnabled: true, + domAnswer: null, + domReasoning: reasoning, + domLinks: [ + { + url: 'https://example.com/source', + title: '室内门锁供货商参考', + text: '室内门锁供货商参考', + siteName: null, + }, + ], + submitted: true, + questionAnchorFound: true, + historySurfaceDetected: false, + } + + expect( + shouldAllowDoubaoReasoningAnswer({ + pageResult, + streamSummary, + reasoning, + searchResultCount: 1, + }), + ).toBe(true) + expect( + resolveDoubaoSubmissionAnswer({ + answer: null, + domAnswer: null, + reasoning, + allowDomAnswer: false, + allowReasoningAnswer: true, + sseFinished: false, + }), + ).toEqual({ + answer: reasoning, + source: 'reasoning', + }) + }) + it('does not submit DOM answer before a finish signal allows DOM recovery', () => { expect( resolveDoubaoSubmissionAnswer({ @@ -357,4 +661,25 @@ describe('doubao adapter helpers', () => { source: null, }) }) + + it('does not submit search-reference panels as the DOM answer', () => { + expect( + resolveDoubaoSubmissionAnswer({ + answer: null, + domAnswer: [ + '已完成思考,参考 18 篇资料', + '搜索 3 个关键词,参考 18 篇资料', + '1. 2026 年五金配件采购指南', + '2. 幽灵门轨道安装注意事项', + '3. https://example.com/door-hardware', + '4. 某某官网|产品资料', + '5. 行业资讯 - 采购参考', + ].join('\n'), + allowDomAnswer: true, + }), + ).toEqual({ + answer: null, + source: null, + }) + }) }) diff --git a/apps/desktop-client/src/main/adapters/doubao.ts b/apps/desktop-client/src/main/adapters/doubao.ts index 6967ca3..30f60fa 100644 --- a/apps/desktop-client/src/main/adapters/doubao.ts +++ b/apps/desktop-client/src/main/adapters/doubao.ts @@ -12,6 +12,189 @@ const DOUBAO_CAPTURE_BODY_LIMIT = 8_000_000 const DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS = 10_000 const DOUBAO_STREAM_URL_FRAGMENT = '/chat/completion' const DOUBAO_NON_CITATION_ASSET_DOMAINS = new Set(['byteimg.com', 'bytednsdoc.com']) +const DOUBAO_IN_PAGE_CAPTURE_SCRIPT = String.raw` +(() => { + const globalKey = '__geoDoubaoStreamCapture' + if (window[globalKey]?.version === 2) { + return + } + + const textDecoder = new TextDecoder('utf-8') + const captureLimit = 64 + const bodyLimit = 8000000 + const streamUrlFragment = '/chat/completion' + const captures = [] + const active = new Set() + let sequence = 0 + let generation = 0 + + const normalizeHeader = (headers, name) => { + try { + if (!headers) return null + if (typeof headers.get === 'function') return headers.get(name) + const lower = String(name).toLowerCase() + for (const [key, value] of Object.entries(headers)) { + if (String(key).toLowerCase() === lower) return String(value) + } + return null + } catch { + return null + } + } + + const shouldCapture = (url, status, contentType) => { + if (!url || !String(url).startsWith('https://www.doubao.com/')) return false + const normalizedContentType = contentType || '' + const streamEndpoint = String(url).includes(streamUrlFragment) + const streamContentType = /text\/event-stream/i.test(normalizedContentType) + const streamy = + /(event-stream|text\/plain|application\/(?:json|x-ndjson))/i.test(normalizedContentType) + const apiPath = /\/(?:chat|api|samantha|assistant|aweme|bot)\//i.test(String(url)) + return streamEndpoint || streamContentType || (streamy && apiPath) || Number(status) >= 400 + } + + const pushOrUpdate = (capture) => { + if (capture.generation !== generation) return + const existing = captures.find((item) => item.captureId === capture.captureId) + const body = + typeof capture.body === 'string' && capture.body.length > bodyLimit + ? capture.body.slice(0, bodyLimit) + : capture.body || '' + if (existing) { + existing.url = capture.url || existing.url + existing.status = Number(capture.status || existing.status || 0) + existing.contentType = capture.contentType ?? existing.contentType ?? null + existing.body = body + } else { + captures.push({ + captureId: capture.captureId, + url: capture.url || '', + status: Number(capture.status || 0), + contentType: capture.contentType ?? null, + body, + }) + } + if (captures.length > captureLimit) { + captures.splice(0, captures.length - captureLimit) + } + } + + const readStream = async (input) => { + const { captureId, generation: captureGeneration, url, status, contentType, body } = input + if (!body || typeof body.getReader !== 'function') return + active.add(captureId) + let text = '' + try { + const reader = body.getReader() + while (true) { + const { value, done } = await reader.read() + if (done) break + if (value) { + text += textDecoder.decode(value, { stream: true }) + if (text.length > bodyLimit) { + text = text.slice(0, bodyLimit) + } + pushOrUpdate({ captureId, generation: captureGeneration, url, status, contentType, body: text }) + } + } + text += textDecoder.decode() + pushOrUpdate({ captureId, generation: captureGeneration, url, status, contentType, body: text }) + } catch { + pushOrUpdate({ captureId, generation: captureGeneration, url, status, contentType, body: text }) + } finally { + active.delete(captureId) + } + } + + const originalFetch = window.fetch + if (typeof originalFetch === 'function') { + window.fetch = async function geoDoubaoFetch(input, init) { + const response = await originalFetch.apply(this, arguments) + try { + const url = response.url || (typeof input === 'string' ? input : input?.url) || '' + const contentType = normalizeHeader(response.headers, 'content-type') + if (shouldCapture(url, response.status, contentType) && response.body) { + const clone = response.clone() + const currentGeneration = generation + const captureId = 'fetch-' + currentGeneration + '-' + ++sequence + void readStream({ + captureId, + generation: currentGeneration, + url, + status: response.status, + contentType, + body: clone.body, + }) + } + } catch { + // Page instrumentation is best effort; never interfere with Doubao's own fetch. + } + return response + } + } + + const OriginalXHR = window.XMLHttpRequest + if (typeof OriginalXHR === 'function') { + const originalOpen = OriginalXHR.prototype.open + const originalSend = OriginalXHR.prototype.send + OriginalXHR.prototype.open = function geoDoubaoXHROpen(method, url) { + try { + this.__geoDoubaoCaptureUrl = String(url || '') + } catch { + this.__geoDoubaoCaptureUrl = '' + } + return originalOpen.apply(this, arguments) + } + OriginalXHR.prototype.send = function geoDoubaoXHRSend() { + try { + const xhr = this + const currentGeneration = generation + const captureId = 'xhr-' + currentGeneration + '-' + ++sequence + const update = () => { + try { + const url = xhr.responseURL || xhr.__geoDoubaoCaptureUrl || '' + const contentType = xhr.getResponseHeader('content-type') + if (!shouldCapture(url, xhr.status || 0, contentType)) return + if (xhr.responseType && xhr.responseType !== 'text') return + pushOrUpdate({ + captureId, + generation: currentGeneration, + url, + status: xhr.status || 0, + contentType, + body: typeof xhr.responseText === 'string' ? xhr.responseText : '', + }) + } catch { + // Cross-origin/partial response access can throw; keep the page flow untouched. + } + } + xhr.addEventListener('progress', update) + xhr.addEventListener('load', update) + xhr.addEventListener('error', update) + xhr.addEventListener('abort', update) + } catch { + // Best effort only. + } + return originalSend.apply(this, arguments) + } + } + + window[globalKey] = { + version: 2, + reset() { + generation += 1 + captures.splice(0, captures.length) + active.clear() + }, + snapshot() { + return { + captures: captures.map((capture) => ({ ...capture })), + activeCount: active.size, + } + }, + } +})() +` const SEARCH_RESULT_KEYS = new Set([ 'references', @@ -69,18 +252,24 @@ type DoubaoStreamSummary = { type DoubaoAnswerModeKind = 'fast' | 'thinking' | 'expert' type DoubaoCaptureRecord = { + captureId?: string url: string status: number contentType: string | null body: string } +type DoubaoInPageCaptureSnapshot = { + captures: DoubaoCaptureRecord[] + activeCount: number +} + type DoubaoPageQueryParameters = { questionText: string timeoutMs: number readyTimeoutMs: number - enableDeepThink: boolean submitQuestion?: boolean + externalSubmit?: DoubaoExternalSubmitResult | null } type DoubaoDomLink = { @@ -101,6 +290,11 @@ type DoubaoPageQuerySuccessResult = { domAnswer: string | null domReasoning: string | null domLinks: DoubaoDomLink[] + submitMethod?: string | null + composerTextLength?: number | null + sendButtonFound?: boolean | null + sendButtonDisabled?: boolean | null + externalSubmit?: DoubaoExternalSubmitResult | null } type DoubaoPageQueryFailureResult = { @@ -119,10 +313,21 @@ type DoubaoPageQueryFailureResult = { submitted: boolean | null questionAnchorFound: boolean | null historySurfaceDetected: boolean | null + submitMethod?: string | null + composerTextLength?: number | null + sendButtonFound?: boolean | null + sendButtonDisabled?: boolean | null + externalSubmit?: DoubaoExternalSubmitResult | null } type DoubaoPageQueryResult = DoubaoPageQuerySuccessResult | DoubaoPageQueryFailureResult +type DoubaoChallengeSnapshot = { + required: boolean + reason: 'risk_control' | 'captcha_gate' | null + detail: string | null +} + type DoubaoQueryAttemptResult = { attempt: number pageResult: DoubaoPageQueryResult @@ -132,6 +337,21 @@ type DoubaoQueryAttemptResult = { pendingCaptureCount: number } +type DoubaoSubmissionAnswerSource = 'sse' | 'dom' | 'reasoning' | null + +type DoubaoExternalSubmitResult = { + attempted: boolean + method: string | null + error: string | null + composerTextLength: number | null + composerTextPreview: string | null + sendButtonFound: boolean | null + sendButtonDisabled: boolean | null + questionAnchorFound: boolean | null + busy: boolean | null + pageUrl: string | null +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -489,11 +709,88 @@ function isDoubaoSSESubmissionCandidate(value: string | null): boolean { return true } +function looksLikeDoubaoReferenceOnlyAnswer(value: string | null): boolean { + const answer = normalizeOptionalString(value) + if (!answer) { + return false + } + const lines = answer + .split(/\r?\n+/) + .map((line) => line.trim()) + .filter(Boolean) + if (lines.length < 3) { + return false + } + + const referenceHeaderCount = lines.filter((line) => + /搜索\s*\d+\s*个关键词|参考\s*\d+\s*篇资料|查看\s*\d+\s*篇资料|参考资料|资料来源|引用来源|引用资料/.test( + line, + ), + ).length + const referenceLikeCount = lines.filter((line) => { + if (/https?:\/\//i.test(line)) { + return true + } + if (/^\d+[.、]\s*\S+/.test(line) && line.length <= 180 && !/[。!?!?]$/.test(line)) { + return true + } + const shortTitleLike = + /^[^\n]{6,180}$/.test(line) && + !/[。!?!?]$/.test(line) && + countDoubaoMatches(line, /[,,;;::]/g) <= 1 + if ( + shortTitleLike && + /(?:https?:\/\/|www\.|[||]|[-—–]|官网|百科|新闻|资讯|资料|来源|引用|APP|app)/.test(line) + ) { + return true + } + return false + }).length + const proseLineCount = lines.filter((line) => { + if ( + /搜索\s*\d+\s*个关键词|参考\s*\d+\s*篇资料|查看\s*\d+\s*篇资料|参考资料|资料来源|引用来源|引用资料/.test( + line, + ) + ) { + return false + } + return ( + /[。!?!?]$/.test(line) || + (line.length >= 48 && countDoubaoMatches(line, /[,,;;::。!?!?]/g) >= 2) + ) + }).length + const answerLikeCount = lines.filter((line) => { + if ( + /搜索\s*\d+\s*个关键词|参考\s*\d+\s*篇资料|查看\s*\d+\s*篇资料|参考资料|资料来源|引用来源|引用资料/.test( + line, + ) + ) { + return false + } + if (/^\d+[.、]\s*\S+/.test(line)) { + return false + } + return /[。!?!?]$/.test(line) || doubaoHasStructuredAnswerMarkers(line) + }).length + + if (referenceHeaderCount > 0 && referenceLikeCount >= 2 && proseLineCount === 0) { + return true + } + return ( + referenceLikeCount >= Math.max(4, Math.ceil(lines.length * 0.7)) && + proseLineCount === 0 && + answerLikeCount <= 1 + ) +} + function isDoubaoDOMSubmissionCandidate(value: string | null): boolean { const answer = normalizeOptionalString(value) if (!answer || containsDoubaoMojibakeSignal(answer)) { return false } + if (looksLikeDoubaoReferenceOnlyAnswer(answer)) { + return false + } if (answer.length < 48) { return false } @@ -504,22 +801,50 @@ function isDoubaoDOMSubmissionCandidate(value: string | null): boolean { return doubaoTextQualityScore(answer) >= 180 } +function isDoubaoReasoningSubmissionCandidate(value: string | null): boolean { + const answer = normalizeOptionalString(value) + if (!isDoubaoDOMSubmissionCandidate(answer)) { + return false + } + if ( + answer && + /(?:用户(?:询问|想找|需求)|我(?:会|需|计划|准备)|调用搜索工具|搜索\s*[“"])/.test(answer) && + !/(?:以下|建议|优先|可以|主要|包括|适合|一[、..]|二[、..]|\d+[、..]\s*\S{2,})/.test(answer) + ) { + return false + } + return true +} + function resolveDoubaoSubmissionAnswer(input: { answer: string | null domAnswer?: string | null + reasoning?: string | null allowDomAnswer?: boolean + allowReasoningAnswer?: boolean + sseFinished?: boolean }): { answer: string | null - source: 'sse' | 'dom' | null + source: DoubaoSubmissionAnswerSource } { const answer = normalizeOptionalString(input.answer) - if (isDoubaoSSESubmissionCandidate(answer)) { + const domAnswer = normalizeOptionalString(input.domAnswer) + const reasoning = normalizeOptionalString(input.reasoning) + const canUseDomAnswer = input.allowDomAnswer === true && isDoubaoDOMSubmissionCandidate(domAnswer) + const canUseReasoningAnswer = + input.allowReasoningAnswer === true && isDoubaoReasoningSubmissionCandidate(reasoning) + if ( + isDoubaoSSESubmissionCandidate(answer) && + (input.sseFinished !== false || (!canUseDomAnswer && !canUseReasoningAnswer)) + ) { return { answer, source: 'sse' } } - const domAnswer = normalizeOptionalString(input.domAnswer) - if (input.allowDomAnswer === true && isDoubaoDOMSubmissionCandidate(domAnswer)) { + if (canUseDomAnswer) { return { answer: domAnswer, source: 'dom' } } + if (canUseReasoningAnswer) { + return { answer: reasoning, source: 'reasoning' } + } return { answer: null, source: null } } @@ -641,6 +966,26 @@ function normalizeSourceUrl(value: string | null, depth = 0): string | null { return null } +function normalizeDomSourceUrl(value: string | null): string | null { + const input = normalizeText(value) + if (!input) { + return null + } + + if (/\s/.test(input)) { + return null + } + + if ( + !/^(?:https?:)?\/\//i.test(input) && + !/^[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i.test(input) + ) { + return null + } + + return normalizeSourceUrl(input) +} + function normalizeSourceHost(value: string | null | undefined): string | null { const input = normalizeText(value) if (!input) { @@ -1054,6 +1399,7 @@ function isDoubaoRecoverablePageError(error: string): boolean { return ( error === 'doubao_query_timeout' || error === 'doubao_page_navigation_interrupted' || + error === 'doubao_composer_write_failed' || error === 'doubao_submission_not_observed' || error === 'doubao_history_surface_timeout' ) @@ -1079,6 +1425,11 @@ function buildDoubaoPageNavigationResult( submitted: null, questionAnchorFound: null, historySurfaceDetected: null, + submitMethod: null, + composerTextLength: null, + sendButtonFound: null, + sendButtonDisabled: null, + externalSubmit: null, } } @@ -1092,6 +1443,12 @@ function shouldRetryDoubaoPageAttempt( if (isDoubaoSSESubmissionCandidate(streamSummary.answer)) { return false } + if ( + pageResult.questionAnchorFound === true && + isDoubaoDOMSubmissionCandidate(pageResult.domAnswer) + ) { + return false + } return ( pageResult.error === 'doubao_submission_not_observed' || pageResult.error === 'doubao_history_surface_timeout' || @@ -1100,6 +1457,43 @@ function shouldRetryDoubaoPageAttempt( ) } +function shouldAllowDoubaoDomAnswer(input: { + pageResult: DoubaoPageQueryResult + streamSummary: DoubaoStreamSummary +}): boolean { + const { pageResult, streamSummary } = input + if (streamSummary.answer_finish_event_count > 0 || pageResult.ok === true) { + return true + } + return ( + pageResult.ok === false && + isDoubaoRecoverablePageError(pageResult.error) && + pageResult.questionAnchorFound === true && + isDoubaoDOMSubmissionCandidate(pageResult.domAnswer) + ) +} + +function shouldAllowDoubaoReasoningAnswer(input: { + pageResult: DoubaoPageQueryResult + streamSummary: DoubaoStreamSummary + reasoning: string | null + searchResultCount: number +}): boolean { + const { pageResult, streamSummary, reasoning, searchResultCount } = input + if (!isDoubaoReasoningSubmissionCandidate(reasoning)) { + return false + } + if (streamSummary.answer_finish_event_count > 0 || pageResult.ok === true) { + return true + } + return ( + searchResultCount > 0 && + pageResult.ok === false && + isDoubaoRecoverablePageError(pageResult.error) && + pageResult.questionAnchorFound === true + ) +} + function buildDoubaoUnknownResult( code: string, summary: string, @@ -1335,6 +1729,24 @@ function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { })) } +function buildDoubaoDomSearchResults(domLinks: DoubaoDomLink[]): MonitoringSourceItem[] { + return dedupeSourceItems( + domLinks + .map((item) => { + const url = normalizeDomSourceUrl(item.url) + if (!url) { + return null + } + return buildSourceItem({ + url, + title: item.title ?? item.text ?? null, + site_name: item.siteName, + }) + }) + .filter((item): item is MonitoringSourceItem => item !== null), + ) +} + function buildAdapterError( code: string, message: string, @@ -1396,6 +1808,8 @@ async function ensurePageOnDoubaoChat(page: PlaywrightPage, signal: AbortSignal) throw new Error('adapter_aborted') } + await installDoubaoInPageResponseCapture(page) + const currentURL = safePageURL(page) if (currentURL !== DOUBAO_BOOTSTRAP_URL) { await page.goto(DOUBAO_BOOTSTRAP_URL, { @@ -1436,28 +1850,123 @@ async function resetDoubaoPageAfterRecoverableFailure( } } -function attachDoubaoResponseCapture(page: PlaywrightPage): { +async function installDoubaoInPageResponseCapture(page: PlaywrightPage): Promise { + await page.addInitScript(DOUBAO_IN_PAGE_CAPTURE_SCRIPT).catch(() => undefined) + await page.evaluate(DOUBAO_IN_PAGE_CAPTURE_SCRIPT).catch(() => undefined) +} + +async function resetDoubaoInPageResponseCapture(page: PlaywrightPage): Promise { + await page + .evaluate(() => { + ;( + window as Window & { + __geoDoubaoStreamCapture?: { + reset?: () => void + } + } + ).__geoDoubaoStreamCapture?.reset?.() + }) + .catch(() => undefined) +} + +async function readDoubaoInPageResponseCapture( + page: PlaywrightPage, +): Promise { + const snapshot = await page + .evaluate(() => { + const capture = ( + window as Window & { + __geoDoubaoStreamCapture?: { + snapshot?: () => DoubaoInPageCaptureSnapshot + } + } + ).__geoDoubaoStreamCapture + return capture?.snapshot?.() ?? { captures: [], activeCount: 0 } + }) + .catch(() => ({ captures: [], activeCount: 0 })) + + if (!isRecord(snapshot) || !Array.isArray(snapshot.captures)) { + return { captures: [], activeCount: 0 } + } + return { + captures: snapshot.captures + .map((capture): DoubaoCaptureRecord | null => { + if (!isRecord(capture)) { + return null + } + const url = normalizeOptionalString(capture.url) + if (!url) { + return null + } + return { + captureId: normalizeOptionalString(capture.captureId) ?? undefined, + url, + status: + typeof capture.status === 'number' && Number.isFinite(capture.status) + ? capture.status + : 0, + contentType: normalizeOptionalString(capture.contentType) ?? null, + body: normalizeOptionalString(capture.body) ?? '', + } satisfies DoubaoCaptureRecord + }) + .filter((capture): capture is DoubaoCaptureRecord => capture !== null), + activeCount: + typeof snapshot.activeCount === 'number' && Number.isFinite(snapshot.activeCount) + ? snapshot.activeCount + : 0, + } +} + +function hasDoubaoFinishedStreamCapture(captures: DoubaoCaptureRecord[]): boolean { + return captures.some( + (capture) => + capture.body.includes(DOUBAO_REPLY_END_EVENT) && + (/"end_type"\s*:\s*"?2"?/.test(capture.body) || + capture.body.includes('"answer_finish_attr"') || + capture.body.includes('"msg_finish_attr"')), + ) +} + +async function attachDoubaoResponseCapture(page: PlaywrightPage): Promise<{ captures: DoubaoCaptureRecord[] flush: (timeoutMs: number) => Promise pendingCount: () => number dispose: () => void -} { +}> { const captures: DoubaoCaptureRecord[] = [] const pending = new Set>() + let inPageActiveCount = 0 const pushCapture = (record: DoubaoCaptureRecord) => { - captures.push({ + const normalizedRecord: DoubaoCaptureRecord = { ...record, body: record.body.length > DOUBAO_CAPTURE_BODY_LIMIT ? record.body.slice(0, DOUBAO_CAPTURE_BODY_LIMIT) : record.body, - }) + } + const existingIndex = + normalizedRecord.captureId != null + ? captures.findIndex((capture) => capture.captureId === normalizedRecord.captureId) + : -1 + if (existingIndex >= 0) { + captures[existingIndex] = normalizedRecord + } else { + captures.push(normalizedRecord) + } if (captures.length > DOUBAO_CAPTURE_LIMIT) { captures.splice(0, captures.length - DOUBAO_CAPTURE_LIMIT) } } + const collectInPageCaptures = async () => { + const snapshot = await readDoubaoInPageResponseCapture(page) + inPageActiveCount = snapshot.activeCount + for (const capture of snapshot.captures) { + pushCapture(capture) + } + } + const responseHandler = (response: PlaywrightResponse) => { const url = normalizeOptionalString(response.url()) if (!url || !url.startsWith('https://www.doubao.com/')) { @@ -1467,11 +1976,22 @@ function attachDoubaoResponseCapture(page: PlaywrightPage): { const headers = response.headers() const contentType = normalizeOptionalString(headers['content-type']) ?? null const isStreamEndpoint = url.includes(DOUBAO_STREAM_URL_FRAGMENT) + const isSSEContentType = contentType !== null && /text\/event-stream/i.test(contentType) const looksStreamy = contentType !== null && /(event-stream|text\/plain|application\/(?:json|x-ndjson))/i.test(contentType) const isApiPath = /\/(?:chat|api|samantha|assistant|aweme|bot)\//i.test(url) - if (!isStreamEndpoint && !(looksStreamy && isApiPath) && response.status() < 400) { + if ( + !isStreamEndpoint && + !(isSSEContentType || (looksStreamy && isApiPath)) && + response.status() < 400 + ) { + return + } + if ( + response.status() < 400 && + (isStreamEndpoint || isSSEContentType || (looksStreamy && isApiPath)) + ) { return } @@ -1494,22 +2014,31 @@ function attachDoubaoResponseCapture(page: PlaywrightPage): { } page.on('response', responseHandler) + await installDoubaoInPageResponseCapture(page) + await resetDoubaoInPageResponseCapture(page) return { captures, async flush(timeoutMs: number) { const deadline = Date.now() + timeoutMs + await collectInPageCaptures() while (pending.size > 0 && Date.now() < deadline) { + if (hasDoubaoFinishedStreamCapture(captures)) { + break + } const snapshot = Array.from(pending) const remaining = Math.max(0, deadline - Date.now()) + const waitMs = Math.min(250, remaining) await Promise.race([ Promise.allSettled(snapshot), - new Promise((resolve) => setTimeout(resolve, remaining)), + new Promise((resolve) => setTimeout(resolve, waitMs)), ]) + await collectInPageCaptures() } + await collectInPageCaptures() }, pendingCount() { - return pending.size + return pending.size + inPageActiveCount }, dispose() { page.off('response', responseHandler) @@ -1517,6 +2046,388 @@ function attachDoubaoResponseCapture(page: PlaywrightPage): { } } +async function submitDoubaoQuestionWithPlaywright( + page: PlaywrightPage, + questionText: string, +): Promise { + const baseResult: DoubaoExternalSubmitResult = { + attempted: true, + method: null, + error: null, + composerTextLength: null, + composerTextPreview: null, + sendButtonFound: null, + sendButtonDisabled: null, + questionAnchorFound: null, + busy: null, + pageUrl: safePageURL(page) || null, + } + + try { + const preflightChallenge = await page + .evaluate(() => { + const text = document.body?.innerText ?? '' + const required = + /体育运动常用的物品|请选择所有符合上文描述的图片|拖拽到下方|拖拽到这里|点击刷新|验证码|人机验证|安全验证|请完成验证|完成下方验证|拖动滑块|滑动验证|向右滑动|not a robot|verification required|security check/i.test( + text, + ) || + Boolean( + document.querySelector( + [ + "[class*='captcha']", + "[class*='Captcha']", + "[id*='captcha']", + "[data-testid*='captcha']", + "[class*='verify']", + "[class*='Verify']", + "[id*='verify']", + "[data-testid*='verify']", + ].join(','), + ), + ) + if (!required) { + return null + } + return { + reason: /访问环境存在异常|当前环境异常|访问受限|频率过快|访问过于频繁|安全检测|风控|稍后再试/i.test( + text, + ) + ? 'risk_control' + : 'captcha_gate', + detail: text.trim().slice(0, 280) || 'doubao challenge required', + } + }) + .catch(() => null) + if (preflightChallenge) { + return { + ...baseResult, + error: 'doubao_challenge_required', + method: 'challenge-detected', + composerTextPreview: + typeof preflightChallenge.detail === 'string' ? preflightChallenge.detail : null, + pageUrl: safePageURL(page) || null, + } + } + + const composerBox = await page + .evaluate(() => { + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') return null + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed ? trimmed : null + } + const isVisible = (element: Element | null | undefined): element is HTMLElement => { + if (!(element instanceof HTMLElement)) return false + const style = window.getComputedStyle(element) + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false + } + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + const textOf = (element: Element | null | undefined): string | null => { + if (!(element instanceof HTMLElement)) return null + return normalize(element.innerText) ?? normalize(element.textContent) + } + const hintText = (element: Element | null | undefined): string => { + if (!(element instanceof HTMLElement)) return '' + return [ + element.getAttribute('aria-label'), + element.getAttribute('placeholder'), + element.getAttribute('title'), + element.getAttribute('data-testid'), + element.getAttribute('name'), + element.getAttribute('id'), + typeof element.className === 'string' ? element.className : '', + textOf(element), + ] + .map((value) => normalize(value)?.toLowerCase() ?? '') + .filter(Boolean) + .join(' ') + .slice(0, 600) + } + + const candidates: Array<{ rect: DOMRect; score: number }> = [] + const seen = new Set() + for (const selector of [ + "textarea[data-testid*='chat']", + 'textarea[placeholder]', + "[contenteditable='true'][role='textbox']", + "[contenteditable='true']", + 'textarea', + ]) { + for (const node of Array.from(document.querySelectorAll(selector))) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) continue + if ((node as HTMLInputElement).readOnly || (node as HTMLInputElement).disabled) continue + seen.add(node) + const rect = node.getBoundingClientRect() + const hint = hintText(node) + const placeholderLooksLikeSearch = /搜索|search/i.test(hint) + const placeholderLooksLikeComposer = + /(问|提问|给我发|doubao|豆包|说点什么|开始聊天|发消息|发送|消息)/i.test(hint) + if (placeholderLooksLikeSearch && !placeholderLooksLikeComposer) continue + let score = Math.min(rect.width, 1_400) + rect.height + if (rect.top > window.innerHeight * 0.45) score += 1_600 + else score -= 1_200 + if (node.isContentEditable) score += 1_400 + else if (node instanceof HTMLTextAreaElement) score += 800 + if (placeholderLooksLikeComposer) score += 1_800 + candidates.push({ rect, score }) + } + } + candidates.sort((left, right) => right.score - left.score) + const best = candidates[0] + if (!best) return null + return { + x: best.rect.left + Math.min(Math.max(best.rect.width / 2, 24), best.rect.width - 8), + y: best.rect.top + Math.min(Math.max(best.rect.height / 2, 12), best.rect.height - 8), + } + }) + .catch(() => null) + + if (!composerBox) { + return { ...baseResult, error: 'composer_not_found' } + } + + await page.mouse.click(composerBox.x, composerBox.y) + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A').catch(() => undefined) + await page.keyboard.type(questionText, { delay: 3 }) + await sleep(350).catch(() => undefined) + + const beforeSend = await page + .evaluate((question) => { + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') return null + const trimmed = value + .replace(/\u00A0/g, ' ') + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') + return trimmed ? trimmed : null + } + const isVisible = (element: Element | null | undefined): element is HTMLElement => { + if (!(element instanceof HTMLElement)) return false + const style = window.getComputedStyle(element) + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false + } + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + const readText = (element: HTMLElement | null): string | null => { + if (!element) return null + if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) { + return normalize(element.value) + } + return normalize(element.innerText) ?? normalize(element.textContent) + } + const textOf = (element: Element | null | undefined): string | null => { + if (!(element instanceof HTMLElement)) return null + return normalize(element.innerText) ?? normalize(element.textContent) + } + const hintText = (element: Element | null | undefined): string => { + if (!(element instanceof HTMLElement)) return '' + return [ + element.getAttribute('aria-label'), + element.getAttribute('placeholder'), + element.getAttribute('title'), + element.getAttribute('data-testid'), + element.getAttribute('name'), + element.getAttribute('id'), + typeof element.className === 'string' ? element.className : '', + textOf(element), + ] + .map((value) => normalize(value)?.toLowerCase() ?? '') + .filter(Boolean) + .join(' ') + .slice(0, 600) + } + const isDisabled = (element: HTMLElement | null): boolean => { + if (!element) return false + if ('disabled' in element && typeof (element as HTMLButtonElement).disabled === 'boolean') { + return Boolean((element as HTMLButtonElement).disabled) + } + return element.getAttribute('aria-disabled') === 'true' + } + const findComposer = (): HTMLElement | null => { + const candidates: Array<{ element: HTMLElement; score: number }> = [] + const seen = new Set() + for (const selector of [ + "textarea[data-testid*='chat']", + 'textarea[placeholder]', + "[contenteditable='true'][role='textbox']", + "[contenteditable='true']", + 'textarea', + ]) { + for (const node of Array.from(document.querySelectorAll(selector))) { + if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) continue + if ((node as HTMLInputElement).readOnly || (node as HTMLInputElement).disabled) continue + seen.add(node) + const rect = node.getBoundingClientRect() + const hint = hintText(node) + const placeholderLooksLikeSearch = /搜索|search/i.test(hint) + const placeholderLooksLikeComposer = + /(问|提问|给我发|doubao|豆包|说点什么|开始聊天|发消息|发送|消息)/i.test(hint) + if (placeholderLooksLikeSearch && !placeholderLooksLikeComposer) continue + let score = Math.min(rect.width, 1_400) + rect.height + if (rect.top > window.innerHeight * 0.45) score += 1_600 + else score -= 1_200 + if (node.isContentEditable) score += 1_400 + else if (node instanceof HTMLTextAreaElement) score += 800 + if (placeholderLooksLikeComposer) score += 1_800 + candidates.push({ element: node, score }) + } + } + candidates.sort((left, right) => right.score - left.score) + return candidates[0]?.element ?? null + } + const composer = findComposer() + const composerText = readText(composer) + const composerRect = composer?.getBoundingClientRect() ?? null + const shell = composer?.parentElement?.parentElement ?? composer?.parentElement ?? document.body + const buttons = Array.from( + shell.querySelectorAll("button, [role='button'], [data-testid*='send'], [aria-label*='发送'], [aria-label*='Send']"), + ) + const sendCandidates: Array<{ + x: number + y: number + disabled: boolean + score: number + }> = [] + for (const node of buttons) { + if (!(node instanceof HTMLElement) || !isVisible(node)) continue + const rect = node.getBoundingClientRect() + const text = textOf(node) ?? '' + const hint = hintText(node) + if (/^(深度思考|联网搜索|工具|插件|图像|语音|语音输入|按住说话|麦克风|附件|上传|停止输出|停止生成|登录|下载)$/.test(text)) { + continue + } + if ( + /(语音|按住说话|麦克风|voice|microphone|audio|登录|下载|插件|历史|设置|菜单)/i.test(hint) && + !/send|发送|submit/.test(hint) + ) { + continue + } + if (rect.width < 18 || rect.height < 18) continue + if (composerRect) { + if (rect.top < composerRect.top - 120 || rect.top > composerRect.bottom + 160) continue + if (rect.left < composerRect.left - 80 || rect.left > composerRect.right + 240) continue + } + const positiveSendHint = /(send|发送|submit|paper|arrow|上箭头|up)/i.test(hint) + const compactIconCandidate = + Boolean(composerText) && + !text && + rect.width <= 72 && + rect.height <= 72 && + (!composerRect || rect.left >= composerRect.right - 96) + if (!positiveSendHint && !compactIconCandidate) continue + let score = rect.left + rect.top + if (composerRect) { + const dx = Math.abs(rect.left - composerRect.right) + const dy = Math.abs(rect.top - composerRect.top) + score += Math.max(0, 2_000 - dx * 3 - dy * 3) + if (rect.left >= composerRect.right - 32) score += 500 + } + if (positiveSendHint) score += 1_800 + if (compactIconCandidate) score += 360 + sendCandidates.push({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + disabled: isDisabled(node), + score, + }) + } + sendCandidates.sort((left, right) => right.score - left.score) + const send = sendCandidates[0] ?? null + return { + composerTextLength: composerText?.length ?? 0, + composerTextPreview: composerText?.slice(0, 120) ?? null, + composerHasQuestion: + Boolean(composerText) && (composerText === question || composerText?.includes(question)), + sendButtonFound: Boolean(send), + sendButtonDisabled: send?.disabled ?? null, + sendPoint: send ? { x: send.x, y: send.y } : null, + } + }, questionText) + .catch(() => null) + + const composerTextLength = + typeof beforeSend?.composerTextLength === 'number' ? beforeSend.composerTextLength : null + const composerTextPreview = + typeof beforeSend?.composerTextPreview === 'string' ? beforeSend.composerTextPreview : null + const sendButtonFound = beforeSend?.sendButtonFound === true + const sendButtonDisabled = + typeof beforeSend?.sendButtonDisabled === 'boolean' ? beforeSend.sendButtonDisabled : null + + if (!beforeSend?.composerHasQuestion) { + return { + ...baseResult, + error: 'question_not_in_composer_after_keyboard_type', + composerTextLength, + composerTextPreview, + sendButtonFound, + sendButtonDisabled, + pageUrl: safePageURL(page) || null, + } + } + + if (beforeSend.sendPoint && sendButtonDisabled !== true) { + await page.mouse.click(beforeSend.sendPoint.x, beforeSend.sendPoint.y) + baseResult.method = 'playwright-click' + } else { + await page.keyboard.press('Enter') + baseResult.method = 'playwright-enter' + } + + await sleep(1_000).catch(() => undefined) + const afterSend = await page + .evaluate((question) => { + const normalize = (value: unknown): string | null => { + if (typeof value !== 'string') return null + const trimmed = value + .replace(/\u00A0/g, ' ') + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') + return trimmed ? trimmed : null + } + const text = normalize(document.body?.innerText ?? '') + const busy = Boolean( + text && /停止生成|停止输出|正在思考|正在搜索|思考中|生成中|回答中|继续生成/.test(text), + ) + return { + questionAnchorFound: Boolean( + text && (text.includes(question) || text.includes(question.slice(0, 24))), + ), + busy, + pageUrl: window.location.href || null, + } + }, questionText) + .catch(() => null) + + return { + ...baseResult, + method: baseResult.method, + composerTextLength, + composerTextPreview, + sendButtonFound, + sendButtonDisabled, + questionAnchorFound: + typeof afterSend?.questionAnchorFound === 'boolean' ? afterSend.questionAnchorFound : null, + busy: typeof afterSend?.busy === 'boolean' ? afterSend.busy : null, + pageUrl: afterSend?.pageUrl ?? safePageURL(page) ?? null, + } + } catch (error) { + return { + ...baseResult, + error: formatUnknownError(error), + pageUrl: safePageURL(page) || null, + } + } +} + function parseDoubaoCaptures(captures: DoubaoCaptureRecord[]): DoubaoStreamSummary { const summaries = captures .map((capture) => { @@ -1575,7 +2486,7 @@ function doubaoModeProviderModel(mode: DoubaoAnswerModeKind | null): string | nu case 'thinking': return 'doubao-web-thinking' case 'expert': - return 'doubao-web-expert' + return 'doubao-web-fast' default: return null } @@ -1616,9 +2527,9 @@ function resolveProviderModel(pageResult: DoubaoPageQueryResult): string { return label } if (pageResult.thinkingModeApplied) { - return pageResult.modeLabel === '专家' ? 'doubao-web-expert' : 'doubao-web-thinking' + return pageResult.modeLabel === '专家' ? 'doubao-web-fast' : 'doubao-web-thinking' } - return pageResult.deepThinkEnabled ? 'doubao-web-thinking' : 'doubao-web' + return pageResult.deepThinkEnabled ? 'doubao-web-thinking' : 'doubao-web-fast' } const doubaoQueryInPage = async ( @@ -1628,8 +2539,8 @@ const doubaoQueryInPage = async ( questionText, timeoutMs, readyTimeoutMs, - enableDeepThink, submitQuestion = true, + externalSubmit = null, } = parameters const stablePollsRequired = 8 const pollIntervalMs = 1_500 @@ -1761,6 +2672,46 @@ const doubaoQueryInPage = async ( ) } + const detectChallengeGate = (): DoubaoChallengeSnapshot => { + const text = bodyText() ?? '' + const detail = text ? (text.length <= 280 ? text : `${text.slice(0, 280)}...`) : null + const hasCaptchaGate = + /体育运动常用的物品|请选择所有符合上文描述的图片|拖拽到下方|拖拽到这里|点击刷新|验证码|人机验证|安全验证|请完成验证|完成下方验证|拖动滑块|滑动验证|向右滑动|not a robot|verification required|security check/i.test( + text, + ) || + Boolean( + document.querySelector( + [ + "[class*='captcha']", + "[class*='Captcha']", + "[id*='captcha']", + "[data-testid*='captcha']", + "[class*='verify']", + "[class*='Verify']", + "[id*='verify']", + "[data-testid*='verify']", + ].join(','), + ), + ) + if (hasCaptchaGate) { + return { + required: true, + reason: 'captcha_gate', + detail: detail ?? 'doubao challenge required: captcha gate is visible on page', + } + } + + if (/访问环境存在异常|当前环境异常|访问受限|频率过快|访问过于频繁|安全检测|风控|稍后再试/i.test(text)) { + return { + required: true, + reason: 'risk_control', + detail: detail ?? 'doubao challenge required: risk control', + } + } + + return { required: false, reason: null, detail: null } + } + const hasBusyIndicator = (): boolean => { const text = bodyText() ?? '' return /停止生成|停止输出|停止回答|正在思考|正在搜索|思考中|搜索中|生成中|回答中|正在回答|继续生成/.test( @@ -2000,27 +2951,6 @@ const doubaoQueryInPage = async ( return clickable instanceof HTMLElement ? clickable : element } - const clickModeElement = (element: HTMLElement): void => { - const target = resolveClickableModeElement(element) - target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true })) - target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })) - target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })) - target.click() - } - - const pressEscape = (): void => { - const init: KeyboardEventInit = { - bubbles: true, - cancelable: true, - key: 'Escape', - code: 'Escape', - keyCode: 27, - which: 27, - } - document.dispatchEvent(new KeyboardEvent('keydown', init)) - document.body?.dispatchEvent(new KeyboardEvent('keydown', init)) - } - const findAnswerModeTrigger = ( shell: HTMLElement | null, composer: HTMLElement | null, @@ -2096,67 +3026,6 @@ const doubaoQueryInPage = async ( return candidates[0]?.element ?? null } - const findAnswerModeOption = ( - targetKind: AnswerModeKind, - trigger: HTMLElement | null, - ): HTMLElement | null => { - const triggerRect = trigger?.getBoundingClientRect() ?? null - const nodes = Array.from( - document.querySelectorAll( - "button, [role='button'], [role='option'], [role='menuitem'], [tabindex], li, div, span", - ), - ) - const candidates: Array<{ element: HTMLElement; score: number }> = [] - const seen = new Set() - - for (const node of nodes) { - if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) { - continue - } - seen.add(node) - - const mode = readElementAnswerMode(node) - if (mode.kind !== targetKind) { - continue - } - const clickable = resolveClickableModeElement(node) - if (isDisabled(clickable)) { - continue - } - - const text = normalizeMultiline(node.innerText) ?? normalize(node.textContent) ?? '' - const rect = node.getBoundingClientRect() - if (rect.width < 48 || rect.height < 24 || rect.width > 560 || rect.height > 180) { - continue - } - if (text.length > 180) { - continue - } - - let score = 500 - if (/擅长解决更难的问题/.test(text)) { - score += 1_000 - } - if (text === ANSWER_MODE_LABELS[targetKind]) { - score += 500 - } - const role = normalize(node.getAttribute('role')) ?? '' - if (/option|menuitem|button/.test(role)) { - score += 240 - } - if (triggerRect) { - const dx = Math.abs(rect.left - triggerRect.left) - const dy = Math.abs(rect.top - triggerRect.top) - score += Math.max(0, 1_600 - dx * 2 - dy * 2) - } - - candidates.push({ element: clickable, score }) - } - - candidates.sort((left, right) => right.score - left.score) - return candidates[0]?.element ?? null - } - const readCurrentAnswerMode = ( shell: HTMLElement | null, composer: HTMLElement | null, @@ -2165,39 +3034,90 @@ const doubaoQueryInPage = async ( return readElementAnswerMode(trigger) } - const ensureThinkingAnswerMode = async ( - shell: HTMLElement | null, - composer: HTMLElement | null, - ): Promise => { - const initialTrigger = findAnswerModeTrigger(shell, composer) - const initialMode = readElementAnswerMode(initialTrigger) - if (isThinkingAnswerMode(initialMode.kind)) { - return { ...initialMode, applied: true } + const readComposerText = (composer: HTMLElement | null): string | null => { + if (!composer) { + return null } - if (!initialTrigger) { - return { ...initialMode, applied: null } + if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) { + return normalizeMultiline(composer.value) } - - clickModeElement(initialTrigger) - await wait(260) - - const thinkingOption = - findAnswerModeOption('expert', initialTrigger) ?? - findAnswerModeOption('thinking', initialTrigger) - if (!thinkingOption) { - pressEscape() - await wait(120) - const current = readCurrentAnswerMode(shell, composer) - return { ...current, applied: isThinkingAnswerMode(current.kind) ? true : null } + if (composer.isContentEditable) { + return normalizeMultiline(composer.innerText) ?? normalizeMultiline(composer.textContent) } + return normalizeMultiline(composer.innerText) ?? normalizeMultiline(composer.textContent) + } - clickModeElement(thinkingOption) - await wait(360) + const composerContainsQuestion = (composer: HTMLElement | null): boolean => { + const currentText = readComposerText(composer) + return Boolean( + normalizedQuestion && + currentText && + (currentText === normalizedQuestion || currentText.includes(normalizedQuestion)), + ) + } - const current = readCurrentAnswerMode(shell, composer) - return { - ...current, - applied: isThinkingAnswerMode(current.kind) ? true : null, + const dispatchInputEvents = ( + composer: HTMLElement, + data: string, + inputType = 'insertText', + ): void => { + try { + composer.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + data, + inputType, + }), + ) + } catch { + // Older Chromium builds can reject InputEvent construction for synthetic beforeinput. + } + try { + composer.dispatchEvent( + new InputEvent('input', { + bubbles: true, + cancelable: true, + data, + inputType, + }), + ) + } catch { + composer.dispatchEvent(new Event('input', { bubbles: true })) + } + composer.dispatchEvent(new Event('change', { bubbles: true })) + } + + const clearEditableComposer = (composer: HTMLElement): void => { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(composer) + selection?.removeAllRanges() + selection?.addRange(range) + const deleted = + typeof document.execCommand === 'function' + ? document.execCommand('delete', false) + : false + if (!deleted) { + composer.textContent = '' + } + } + + const pasteComposerText = (composer: HTMLElement, text: string): boolean => { + try { + if (typeof ClipboardEvent !== 'function' || typeof DataTransfer !== 'function') { + return false + } + const data = new DataTransfer() + data.setData('text/plain', text) + const event = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: data, + }) + return composer.dispatchEvent(event) + } catch { + return false } } @@ -2214,17 +3134,12 @@ const doubaoQueryInPage = async ( } else { composer.value = text } - composer.dispatchEvent(new Event('input', { bubbles: true })) - composer.dispatchEvent(new Event('change', { bubbles: true })) + dispatchInputEvents(composer, text) return } if (composer.isContentEditable) { - const selection = window.getSelection() - const range = document.createRange() - range.selectNodeContents(composer) - selection?.removeAllRanges() - selection?.addRange(range) + clearEditableComposer(composer) const inserted = typeof document.execCommand === 'function' @@ -2234,18 +3149,57 @@ const doubaoQueryInPage = async ( composer.textContent = text } - composer.dispatchEvent( - new InputEvent('input', { - bubbles: true, - cancelable: true, - data: text, - inputType: 'insertText', - }), - ) - composer.dispatchEvent(new Event('change', { bubbles: true })) + dispatchInputEvents(composer, text) } } + const ensureComposerText = async (text: string): Promise => { + let activeComposer = findComposer() + if (!activeComposer) { + return null + } + + for (let attempt = 0; attempt < 3; attempt += 1) { + activeComposer = findComposer() ?? activeComposer + setComposerText(activeComposer, text) + await wait(attempt === 0 ? 320 : 520) + activeComposer = findComposer() ?? activeComposer + if (composerContainsQuestion(activeComposer)) { + return activeComposer + } + + if (activeComposer.isContentEditable) { + activeComposer.focus() + pasteComposerText(activeComposer, text) + await wait(360) + activeComposer = findComposer() ?? activeComposer + if (composerContainsQuestion(activeComposer)) { + return activeComposer + } + } + + if (activeComposer instanceof HTMLTextAreaElement || activeComposer instanceof HTMLInputElement) { + activeComposer.focus() + const nativeSetter = + Object.getOwnPropertyDescriptor( + activeComposer instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype, + 'value', + )?.set ?? null + nativeSetter?.call(activeComposer, text) + dispatchInputEvents(activeComposer, text) + await wait(260) + activeComposer = findComposer() ?? activeComposer + if (composerContainsQuestion(activeComposer)) { + return activeComposer + } + } + } + + return activeComposer + } + const findSendButton = ( shell: HTMLElement | null, composer: HTMLElement | null, @@ -2263,8 +3217,9 @@ const doubaoQueryInPage = async ( scopes.push(document) const composerRect = composer?.getBoundingClientRect() ?? null + const composerHasText = Boolean(readComposerText(composer)) const blacklist = - /^(深度思考|联网搜索|工具|插件|图像|语音|附件|上传|语音输入|停止输出|停止生成|登录|下载)$/ + /^(深度思考|联网搜索|工具|插件|图像|语音|语音输入|按住说话|麦克风|附件|上传|停止输出|停止生成|登录|下载)$/ const candidates: Array<{ element: HTMLElement; score: number }> = [] const seen = new Set() @@ -2285,7 +3240,12 @@ const doubaoQueryInPage = async ( if (blacklist.test(text)) { continue } - if (/(登录|下载|插件|历史|设置|菜单)/.test(hint) && !/send|发送|submit/.test(hint)) { + if ( + /(语音|按住说话|麦克风|voice|microphone|audio|登录|下载|插件|历史|设置|菜单)/i.test( + hint, + ) && + !/send|发送|submit/.test(hint) + ) { continue } if (isDisabled(node)) { @@ -2305,6 +3265,17 @@ const doubaoQueryInPage = async ( } } + const positiveSendHint = /(send|发送|submit|paper|arrow|上箭头|up)/i.test(hint) + const compactIconCandidate = + composerHasText && + !text && + rect.width <= 72 && + rect.height <= 72 && + (!composerRect || rect.left >= composerRect.right - 96) + if (!positiveSendHint && !compactIconCandidate) { + continue + } + let score = rect.left + rect.top if (composerRect) { const dx = Math.abs(rect.left - composerRect.right) @@ -2314,10 +3285,10 @@ const doubaoQueryInPage = async ( score += 500 } } - if (/(send|发送|submit|paper|arrow|上箭头|up)/i.test(hint)) { + if (positiveSendHint) { score += 1_800 } - if (!text && rect.width <= 72 && rect.height <= 72) { + if (compactIconCandidate) { score += 360 } @@ -2413,13 +3384,7 @@ const doubaoQueryInPage = async ( url.hash = '' return url.toString() } catch { - const match = candidate.match(/https?:\/\/[^\s"'<>\\)]+/i)?.[0] - if (match && match !== candidate) { - const normalized = normalizeHref(match, depth + 1) - if (normalized) { - return normalized - } - } + continue } } @@ -2541,6 +3506,28 @@ const doubaoQueryInPage = async ( return changedContentAge >= incompleteAnswerGraceMs } + const shouldReturnBestEffortTimeoutSnapshot = ( + snapshot: ConversationSnapshot | null, + firstChangedContentAt: number | null, + ): boolean => { + if (!snapshot || firstChangedContentAt === null || !hasObservedContent(snapshot)) { + return false + } + const answer = normalizeMultiline(snapshot.answerText) + const reasoning = normalizeMultiline(snapshot.reasoningText) + if (answer) { + return ( + answer.length >= 96 || + countMatches(answer, /[。!?;:,、,.!?;:]/g) >= 2 || + countMatches(answer, /\n/g) >= 2 || + hasStructuredAnswerMarkers(answer) || + (snapshot.links.length >= 2 && answer.length >= 48) || + (Boolean(reasoning) && answer.length >= 48) + ) + } + return Boolean(reasoning && snapshot.links.length > 0) + } + const isExternalHref = (value: string | null): boolean => { if (!value) { return false @@ -2633,10 +3620,6 @@ const doubaoQueryInPage = async ( } } - const text = normalizeMultiline(scope.innerText) - for (const match of text?.matchAll(/https?:\/\/[^\s"'<>\\]+/gi) ?? []) { - pushLink(match[0], scope, null) - } return links } @@ -2664,18 +3647,109 @@ const doubaoQueryInPage = async ( return Array.from(keyed.values()) } + const referenceDisclosureKind = (value: string | null): 'specific' | 'thinking' | null => { + if (!value) { + return null + } + const lines = value + .split(/\n+/) + .map((line) => normalize(line)) + .filter((line): line is string => Boolean(line)) + const candidates = lines.length > 0 ? lines.slice(0, 3) : [value] + for (const line of candidates) { + if ( + /搜索\s*\d+\s*个关键词.*参考\s*\d+\s*篇资料/.test(line) || + /查看\s*\d+\s*篇资料|参考资料|资料来源|引用来源|引用资料/.test(line) + ) { + return 'specific' + } + } + for (const line of candidates) { + if (/已完成思考.*参考\s*\d+\s*篇资料|思考完成.*参考\s*\d+\s*篇资料/.test(line)) { + return 'thinking' + } + } + return null + } + + const referenceDisclosureLooksCollapsed = (element: HTMLElement, text: string): boolean => { + const ariaExpanded = normalize(element.getAttribute('aria-expanded'))?.toLowerCase() ?? null + if (ariaExpanded === 'false') { + return true + } + if (ariaExpanded === 'true') { + return false + } + return /[>›»⌄∨▾▼﹀]|展开|查看|更多/.test(text) + } + + const hasExpandedReferenceLinksNearby = (element: HTMLElement): boolean => { + if (collectLinks(element).length > 0) { + return true + } + + let sibling = element.nextElementSibling + for (let index = 0; sibling && index < 8; index += 1, sibling = sibling.nextElementSibling) { + if (!(sibling instanceof HTMLElement) || !isVisible(sibling)) { + continue + } + const text = normalizeMultiline(sibling.innerText) + if (text && referenceDisclosureKind(text)) { + break + } + if (collectLinks(sibling).length > 0) { + return true + } + } + + return false + } + const expandReferenceControls = (): void => { + const specificControls: HTMLElement[] = [] + const thinkingControls: HTMLElement[] = [] + const genericControls: HTMLElement[] = [] for (const node of Array.from( - document.querySelectorAll("button, [role='button'], a, div, span"), - ).slice(0, 1600)) { + document.querySelectorAll("button, [role='button'], div, span"), + ).slice(0, 2000)) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue } + if (node.dataset.geoDoubaoRefOpened === '1') { + continue + } const text = normalize(node.innerText) ?? normalize(node.textContent) - if (!text || !/^(?:展开更多|查看更多|查看全部|更多|展开)$/i.test(text)) { + const disclosureKind = referenceDisclosureKind(text) + const isGenericExpandControl = Boolean( + text && /^(?:展开更多|查看更多|查看全部|更多|展开)$/i.test(text), + ) + if (!text || (!isGenericExpandControl && !disclosureKind)) { + continue + } + if (disclosureKind && hasExpandedReferenceLinksNearby(node)) { + continue + } + if (disclosureKind && !referenceDisclosureLooksCollapsed(node, text)) { continue } + if (disclosureKind === 'specific') { + specificControls.push(node) + } else if (disclosureKind === 'thinking') { + thinkingControls.push(node) + } else { + genericControls.push(node) + } + } + + const controlsToClick = + specificControls.length > 0 + ? specificControls + : thinkingControls.length > 0 + ? thinkingControls + : genericControls + + for (const node of controlsToClick.slice(0, 12)) { const nextParent = (element: HTMLElement): HTMLElement | null => element.parentNode instanceof HTMLElement ? element.parentNode : null let current: HTMLElement | null = node @@ -2706,6 +3780,7 @@ const doubaoQueryInPage = async ( if (!clicked) { node.click() } + node.dataset.geoDoubaoRefOpened = '1' } } @@ -2717,12 +3792,20 @@ const doubaoQueryInPage = async ( const isReferenceQueryLine = (line: string): boolean => /[“"「].+[”"」]/.test(line) || /、/.test(line) || /关键词|搜索词|查询词/.test(line) - const isReferenceItemLine = (line: string): boolean => - /^\d+[.、]\s*\S+/.test(line) && - (/https?:\/\//i.test(line) || - /新闻|新浪|搜狐|网易|腾讯|百度|知乎|什么值得买|列举网|装修网|家居|资料|来源|推荐|排名|榜|厂家|品牌|公司|工厂|门店/.test( - line, - )) + const isReferenceItemLine = (line: string): boolean => { + if (/https?:\/\//i.test(line)) { + return true + } + if (/^\d+[.、]\s*\S+/.test(line) && line.length <= 180 && !/[。!?!?]$/.test(line)) { + return true + } + return ( + /^[^\n]{6,180}$/.test(line) && + !/[。!?!?]$/.test(line) && + countMatches(line, /[,,;;::]/g) <= 1 && + /(?:https?:\/\/|www\.|[||]|[-—–]|官网|百科|新闻|资讯|资料|来源|引用|APP|app)/.test(line) + ) + } const looksLikeHistoryTitleList = (text: string | null): boolean => { if (!text) { @@ -2730,9 +3813,7 @@ const doubaoQueryInPage = async ( } const normalizedText = text.replace(/\s+/g, ' ').trim() if ( - /搜索…\s*Ctrl\s*K\s*豆包|新对话\s*Ctrl\s*Shift\s*K|历史对话\s*主对话/.test( - normalizedText, - ) + /搜索…\s*Ctrl\s*K\s*豆包|新对话\s*Ctrl\s*Shift\s*K|历史对话\s*主对话/.test(normalizedText) ) { return true } @@ -2797,6 +3878,64 @@ const doubaoQueryInPage = async ( const looksLikeNonAnswerSurface = (text: string | null): boolean => looksLikeEmptyChatSurface(text) || looksLikeHistoryTitleList(text) + const primaryConversationRoot = (): HTMLElement | null => { + const roots = Array.from(document.querySelectorAll('main')) + .filter((node): node is HTMLElement => node instanceof HTMLElement && isVisible(node)) + .sort((left, right) => { + const leftRect = left.getBoundingClientRect() + const rightRect = right.getBoundingClientRect() + return rightRect.width * rightRect.height - leftRect.width * leftRect.height + }) + return roots[0] ?? null + } + + const elementHintText = (element: HTMLElement): string => + [ + normalize(element.className)?.toLowerCase() ?? '', + Object.keys(element.dataset ?? {}) + .join(' ') + .toLowerCase(), + normalize(element.getAttribute('role'))?.toLowerCase() ?? '', + normalize(element.getAttribute('aria-label'))?.toLowerCase() ?? '', + normalize(element.getAttribute('title'))?.toLowerCase() ?? '', + normalize(element.getAttribute('data-testid'))?.toLowerCase() ?? '', + ].join(' ') + + const isNavigationOrSuggestionElement = (element: HTMLElement): boolean => + /menu|navbar|sidebar|history|suggest|suggestion|recommend|prompt-?template|quick-?action|home-?page|entry|composer|input|search/.test( + elementHintText(element), + ) + + const isInsideComposer = (element: HTMLElement): boolean => { + const composer = findComposer() + const composerShell = findComposerShell(composer) + return Boolean( + composer && + (element === composer || + element.contains(composer) || + composer.contains(element) || + (composerShell && + (element === composerShell || + composerShell.contains(element) || + element.contains(composerShell)))), + ) + } + + const isInsideThinkingContainer = (element: HTMLElement): boolean => { + let current: HTMLElement | null = element + for (let depth = 0; current && depth < 8; depth += 1) { + const hint = elementHintText(current) + if ( + /thinking-box|thinking|think|reason|analysis/.test(hint) || + current.hasAttribute('data-thinking-box') + ) { + return true + } + current = current.parentElement + } + return false + } + const stripKnownNoise = (text: string | null): string | null => { if (!text) { return null @@ -2847,17 +3986,90 @@ const doubaoQueryInPage = async ( return joined || null } + const finalAnswerTextFromMarkdown = (): string | null => { + const root = primaryConversationRoot() + const selectors = [ + "[class*='md-box-root']", + "[class*='markdown']", + "[class*='Markdown']", + "[data-testid*='markdown']", + "[data-testid*='answer']", + "[class*='answer']", + "[class*='Answer']", + ].join(',') + const candidates: Array<{ element: HTMLElement; text: string; score: number }> = [] + for (const node of Array.from(document.querySelectorAll(selectors))) { + if (!(node instanceof HTMLElement) || !isVisible(node)) { + continue + } + if (root && !root.contains(node)) { + continue + } + if (isInsideComposer(node) || isNavigationOrSuggestionElement(node)) { + continue + } + if (isInsideThinkingContainer(node)) { + continue + } + const rawText = normalizeMultiline(node.innerText) + const text = stripKnownNoise(rawText) + if (!text || text.length < 48) { + continue + } + if (looksLikeNonAnswerSurface(rawText)) { + continue + } + const headings = node.querySelectorAll('h1, h2, h3, h4, h5, h6').length + const paragraphs = node.querySelectorAll('p, li, table, pre').length + let score = Math.min(text.length, 5_000) + headings * 300 + paragraphs * 40 + if (hasStructuredAnswerMarkers(text)) { + score += 600 + } + if (/^\s*(?:一[、..]|1[、..]|\d+[、..]|#{1,6}\s+)/m.test(text)) { + score += 400 + } + candidates.push({ element: node, text, score }) + } + + candidates.sort((left, right) => right.score - left.score) + const best = candidates[0] + if (!best) { + return null + } + const betterParent = candidates.find( + (candidate) => + candidate !== best && + candidate.element.contains(best.element) && + candidate.text.includes(best.text) && + candidate.score >= best.score - 120, + ) + return betterParent?.text ?? best.text + } + const findQuestionAnchor = (): HTMLElement | null => { if (!normalizedQuestion) { return null } - let best: { element: HTMLElement; order: number } | null = null + let best: { element: HTMLElement; order: number; score: number } | null = null let order = 0 + const root = primaryConversationRoot() + const composer = findComposer() + const composerTop = composer?.getBoundingClientRect().top ?? null const candidates = Array.from(document.querySelectorAll('p, div, span, li, article, section')) for (const node of candidates) { if (!(node instanceof HTMLElement) || !isVisible(node)) { continue } + if (root && !root.contains(node)) { + continue + } + if (isInsideComposer(node) || isNavigationOrSuggestionElement(node)) { + continue + } + const rect = node.getBoundingClientRect() + if (composerTop !== null && rect.top >= composerTop - 12) { + continue + } const text = normalizeMultiline(node.innerText) if (!text) { continue @@ -2869,12 +4081,10 @@ const doubaoQueryInPage = async ( continue } order += 1 - const container = - (node.closest( - "[class*='message'], [class*='Message'], [class*='bubble'], [class*='Bubble'], [class*='user'], [class*='User'], li, article, section", - ) as HTMLElement | null) ?? node - if (!best || order >= best.order) { - best = { element: container, order } + const lengthDelta = Math.abs(text.length - normalizedQuestion.length) + const score = order * 10_000 - lengthDelta * 20 - rect.height + if (!best || score >= best.score) { + best = { element: node, order, score } } } return best?.element ?? null @@ -2888,10 +4098,33 @@ const doubaoQueryInPage = async ( continue } const text = normalizeMultiline(node.innerText) - if (!text || !isReferenceHeaderLine(text)) { + const className = normalize(node.className)?.toLowerCase() ?? '' + const dataKeys = Object.keys(node.dataset ?? {}) + .join(' ') + .toLowerCase() + const hint = [ + className, + dataKeys, + normalize(node.getAttribute('aria-label'))?.toLowerCase() ?? '', + normalize(node.getAttribute('title'))?.toLowerCase() ?? '', + normalize(node.getAttribute('data-testid'))?.toLowerCase() ?? '', + ].join(' ') + const links = collectLinks(node) + const looksLikeReferencePanel = + Boolean(text && isReferenceHeaderLine(text)) || + /reference|references|citation|citations|source|sources|search|webpage|web-page|result/.test( + hint, + ) || + (links.length >= 2 && + Boolean( + text && + /参考|资料|来源|搜索|引用|网页|关键词|结果|文章|新闻|网站|工厂|厂家|品牌|公司/.test( + text, + ), + )) + if (!looksLikeReferencePanel) { continue } - const links = collectLinks(node) if (links.length > 0) { groups.push(links) } @@ -2902,9 +4135,11 @@ const doubaoQueryInPage = async ( const snapshotConversation = (composerTop: number | null): ConversationSnapshot => { expandReferenceControls() + const root = primaryConversationRoot() const questionAnchor = findQuestionAnchor() const anchorRect = questionAnchor?.getBoundingClientRect() ?? null const referencePanelLinks = collectReferencePanelLinks() + const markdownAnswerText = finalAnswerTextFromMarkdown() const candidates: Array<{ element: HTMLElement @@ -2921,15 +4156,18 @@ const doubaoQueryInPage = async ( if (!(node instanceof HTMLElement) || !isVisible(node)) { continue } - if ( - questionAnchor && - (node === questionAnchor || questionAnchor.contains(node) || node.contains(questionAnchor)) - ) { + if (root && !root.contains(node)) { + continue + } + if (isInsideComposer(node)) { + continue + } + if (questionAnchor && (node === questionAnchor || questionAnchor.contains(node))) { continue } const rect = node.getBoundingClientRect() - if (composerTop !== null && rect.top >= composerTop - 12) { + if (composerTop !== null && rect.top >= composerTop - 12 && node.contains(findComposer())) { continue } if (anchorRect && rect.bottom <= anchorRect.top + 4) { @@ -2956,13 +4194,9 @@ const doubaoQueryInPage = async ( continue } - const className = normalize(node.className)?.toLowerCase() ?? '' - const datasetKeys = Object.keys(node.dataset ?? {}) - .join(' ') - .toLowerCase() - const blob = `${className} ${datasetKeys}` + const blob = elementHintText(node) - if (/menu|navbar|sidebar|prompt-?template|quick-?action|home-?page|entry/.test(blob)) { + if (isNavigationOrSuggestionElement(node)) { continue } @@ -3061,6 +4295,9 @@ const doubaoQueryInPage = async ( if (joinedAnswerText && joinedAnswerText.length > (answerText?.length ?? 0)) { answerText = joinedAnswerText } + if (markdownAnswerText && markdownAnswerText.length >= 48) { + answerText = markdownAnswerText + } if (reasoningText && answerText && answerText.startsWith(reasoningText)) { const trimmedAnswer = answerText.slice(reasoningText.length).trim() if (trimmedAnswer) { @@ -3068,7 +4305,7 @@ const doubaoQueryInPage = async ( } } } else { - answerText = joinedAnswerText + answerText = markdownAnswerText ?? joinedAnswerText } const scopedReferenceLinks = mergeLinks( @@ -3113,6 +4350,10 @@ const doubaoQueryInPage = async ( submitted?: boolean | null questionAnchorFound?: boolean | null historySurfaceDetected?: boolean | null + submitMethod?: string | null + composerTextLength?: number | null + sendButtonFound?: boolean | null + sendButtonDisabled?: boolean | null } = {}, ): DoubaoPageQueryFailureResult => { const snapshot = snapshotOverride ?? snapshotConversation(composerTop) @@ -3141,6 +4382,11 @@ const doubaoQueryInPage = async ( submitted: diagnostics.submitted ?? null, questionAnchorFound: diagnostics.questionAnchorFound ?? null, historySurfaceDetected: diagnostics.historySurfaceDetected ?? null, + submitMethod: diagnostics.submitMethod ?? null, + composerTextLength: diagnostics.composerTextLength ?? null, + sendButtonFound: diagnostics.sendButtonFound ?? null, + sendButtonDisabled: diagnostics.sendButtonDisabled ?? null, + externalSubmit, } } @@ -3155,40 +4401,66 @@ const doubaoQueryInPage = async ( return fail('doubao_login_required', bodyText(), composer?.getBoundingClientRect().top ?? null) } + const initialChallenge = detectChallengeGate() + if (initialChallenge.required) { + return fail( + 'doubao_challenge_required', + initialChallenge.detail ?? bodyText(), + composer?.getBoundingClientRect().top ?? null, + ) + } + if (!composer) { return fail('doubao_composer_missing', bodyText(), null) } let composerShell = findComposerShell(composer) const composerTop = composer.getBoundingClientRect().top - let answerMode = await ensureThinkingAnswerMode(composerShell, composer) + let answerMode: AnswerModeSnapshot & { applied: boolean | null } = { + ...readCurrentAnswerMode(composerShell, composer), + applied: null, + } composer = findComposer() ?? composer composerShell = findComposerShell(composer) ?? composerShell - let deepThinkApplied: boolean | null = null - if (enableDeepThink) { - const deepThinkButton = findInteractiveByText(/深度思考/, composerShell) - if (deepThinkButton) { - const currentState = toggleState(deepThinkButton) - if (currentState !== true) { - deepThinkButton.click() - await wait(180) - } - deepThinkApplied = toggleState(deepThinkButton) - } - } + let submitMethod: string | null = externalSubmit?.method ?? null + let composerTextLength: number | null = externalSubmit?.composerTextLength ?? null + let sendButtonFound: boolean | null = externalSubmit?.sendButtonFound ?? null + let sendButtonDisabled: boolean | null = externalSubmit?.sendButtonDisabled ?? null if (submitQuestion) { - setComposerText(composer, questionText) - await wait(220) + composer = (await ensureComposerText(questionText)) ?? composer + composerTextLength = readComposerText(composer)?.length ?? null + if (normalizedQuestion && !composerContainsQuestion(composer)) { + return fail( + 'doubao_composer_write_failed', + bodyText(), + composer.getBoundingClientRect().top, + answerMode, + null, + { + submitted: false, + questionAnchorFound: false, + historySurfaceDetected: looksLikeHistoryTitleList(bodyText()), + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, + }, + ) + } composer = findComposer() ?? composer composerShell = findComposerShell(composer) ?? composerShell const sendButton = findSendButton(composerShell, composer) - if (sendButton) { + sendButtonFound = Boolean(sendButton) + sendButtonDisabled = sendButton ? isDisabled(sendButton) : null + if (sendButton && !sendButtonDisabled) { sendButton.click() + submitMethod = 'page-click' } else { dispatchEnter(composer) + submitMethod = 'page-enter' } await wait(600) @@ -3209,6 +4481,7 @@ const doubaoQueryInPage = async ( let firstChangedContentAt: number | null = sawChange ? Date.now() : null const submittedAt = Date.now() const deadline = Date.now() + timeoutMs + let externalObservedContentAt: number | null = null while (Date.now() <= deadline) { const snapshot = snapshotConversation(composerTop) @@ -3221,6 +4494,9 @@ const doubaoQueryInPage = async ( const busy = hasBusyIndicator() const hasContent = hasObservedContent(snapshot) + if (!submitQuestion && hasContent && externalObservedContentAt === null) { + externalObservedContentAt = Date.now() + } if (snapshot.signature && hasContent && snapshot.signature !== initialSnapshot.signature) { sawChange = true if (firstChangedContentAt === null) { @@ -3245,6 +4521,30 @@ const doubaoQueryInPage = async ( submitted: submitQuestion, questionAnchorFound: questionAnchorFoundAfterSubmit, historySurfaceDetected, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, + }, + ) + } + + const challenge = detectChallengeGate() + if (challenge.required) { + return fail( + 'doubao_challenge_required', + challenge.detail ?? bodyText(), + composerTop, + answerMode, + bestContentSnapshot ?? bestSnapshot, + { + submitted: submitQuestion, + questionAnchorFound: questionAnchorFoundAfterSubmit, + historySurfaceDetected, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, }, ) } @@ -3266,6 +4566,10 @@ const doubaoQueryInPage = async ( submitted: true, questionAnchorFound: false, historySurfaceDetected, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, }, ) } @@ -3287,6 +4591,10 @@ const doubaoQueryInPage = async ( submitted: true, questionAnchorFound: true, historySurfaceDetected: true, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, }, ) } @@ -3316,28 +4624,98 @@ const doubaoQueryInPage = async ( modelLabel: resolveModelLabel(), modeLabel: answerMode.label, thinkingModeApplied: answerMode.applied, - deepThinkEnabled: toggleState(deepThinkButton) ?? deepThinkApplied, + deepThinkEnabled: toggleState(deepThinkButton), domAnswer: finalSnapshot.answerText, domReasoning: finalSnapshot.reasoningText, domLinks: finalSnapshot.links, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, + externalSubmit, + } + } + + if ( + !submitQuestion && + hasContent && + !busy && + externalObservedContentAt !== null && + Date.now() - externalObservedContentAt >= minimumAnswerSettleMs && + shouldReturnStableAnswer(snapshot, externalObservedContentAt, Date.now()) + ) { + const deepThinkButton = findInteractiveByText(/深度思考/, composerShell) + const currentMode = readCurrentAnswerMode(composerShell, composer) + answerMode = { + kind: currentMode.kind ?? answerMode.kind, + label: currentMode.label ?? answerMode.label, + applied: isThinkingAnswerMode(currentMode.kind ?? answerMode.kind) + ? true + : answerMode.applied, + } + return { + ok: true, + url: window.location.href, + title: normalize(document.title), + modelLabel: resolveModelLabel(), + modeLabel: answerMode.label, + thinkingModeApplied: answerMode.applied, + deepThinkEnabled: toggleState(deepThinkButton), + domAnswer: snapshot.answerText, + domReasoning: snapshot.reasoningText, + domLinks: snapshot.links, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, + externalSubmit, } } await wait(pollIntervalMs) } - return fail( - 'doubao_query_timeout', - bodyText(), - composerTop, - answerMode, - bestContentSnapshot ?? bestSnapshot, - { - submitted: submitQuestion, - questionAnchorFound: questionAnchorFoundAfterSubmit, - historySurfaceDetected, - }, - ) + const finalTimeoutSnapshot = bestContentSnapshot + ? pickBetterSnapshot(bestSnapshot, bestContentSnapshot) + : bestSnapshot + if (shouldReturnBestEffortTimeoutSnapshot(finalTimeoutSnapshot, firstChangedContentAt)) { + const deepThinkButton = findInteractiveByText(/深度思考/, composerShell) + const currentMode = readCurrentAnswerMode(composerShell, composer) + answerMode = { + kind: currentMode.kind ?? answerMode.kind, + label: currentMode.label ?? answerMode.label, + applied: isThinkingAnswerMode(currentMode.kind ?? answerMode.kind) + ? true + : answerMode.applied, + } + return { + ok: true, + url: window.location.href, + title: normalize(document.title), + modelLabel: resolveModelLabel(), + modeLabel: answerMode.label, + thinkingModeApplied: answerMode.applied, + deepThinkEnabled: toggleState(deepThinkButton), + domAnswer: finalTimeoutSnapshot.answerText, + domReasoning: finalTimeoutSnapshot.reasoningText, + domLinks: finalTimeoutSnapshot.links, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, + externalSubmit, + } + } + + return fail('doubao_query_timeout', bodyText(), composerTop, answerMode, finalTimeoutSnapshot, { + submitted: submitQuestion, + questionAnchorFound: questionAnchorFoundAfterSubmit, + historySurfaceDetected, + submitMethod, + composerTextLength, + sendButtonFound, + sendButtonDisabled, + }) } export const doubaoAdapter: MonitorAdapter = { @@ -3357,7 +4735,7 @@ export const doubaoAdapter: MonitorAdapter = { const runAttempt = async (attempt: number): Promise => { context.reportProgress(attempt === 1 ? 'doubao.query' : 'doubao.query_retry') - const capture = attachDoubaoResponseCapture(page) + const capture = await attachDoubaoResponseCapture(page) let pageResult: DoubaoPageQueryResult try { try { @@ -3365,7 +4743,6 @@ export const doubaoAdapter: MonitorAdapter = { questionText, timeoutMs: DOUBAO_QUERY_TIMEOUT_MS, readyTimeoutMs: DOUBAO_PAGE_READY_TIMEOUT_MS, - enableDeepThink: true, }) } catch (error) { if (!isDoubaoExecutionContextNavigationError(error)) { @@ -3377,6 +4754,67 @@ export const doubaoAdapter: MonitorAdapter = { await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined) pageResult = buildDoubaoPageNavigationResult(page, formatUnknownError(error)) } + await capture.flush(1_500) + const streamSummaryAfterPage = parseDoubaoCaptures(capture.captures) + if ( + pageResult.ok === false && + isDoubaoRecoverablePageError(pageResult.error) && + !isDoubaoSSESubmissionCandidate(streamSummaryAfterPage.answer) + ) { + context.reportProgress('doubao.external_submit') + const externalSubmit = await submitDoubaoQuestionWithPlaywright(page, questionText) + if (externalSubmit.error === null) { + await sleep(800, context.signal).catch(() => undefined) + try { + pageResult = await page.evaluate(doubaoQueryInPage, { + questionText, + timeoutMs: DOUBAO_QUERY_TIMEOUT_MS, + readyTimeoutMs: DOUBAO_PAGE_READY_TIMEOUT_MS, + submitQuestion: false, + externalSubmit, + }) + } catch (error) { + if (!isDoubaoExecutionContextNavigationError(error)) { + throw error + } + await page + .waitForLoadState('domcontentloaded', { timeout: DOUBAO_PAGE_READY_TIMEOUT_MS }) + .catch(() => undefined) + await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => undefined) + pageResult = buildDoubaoPageNavigationResult(page, formatUnknownError(error)) + } + } else { + pageResult = + externalSubmit.error === 'doubao_challenge_required' + ? { + ok: false, + error: 'doubao_challenge_required', + detail: externalSubmit.composerTextPreview ?? 'doubao challenge required', + url: externalSubmit.pageUrl ?? safePageURL(page), + title: null, + modelLabel: null, + modeLabel: null, + thinkingModeApplied: null, + deepThinkEnabled: null, + domAnswer: null, + domReasoning: null, + domLinks: [], + submitted: false, + questionAnchorFound: externalSubmit.questionAnchorFound, + historySurfaceDetected: null, + submitMethod: externalSubmit.method, + composerTextLength: externalSubmit.composerTextLength, + sendButtonFound: externalSubmit.sendButtonFound, + sendButtonDisabled: externalSubmit.sendButtonDisabled, + externalSubmit, + } + : { + ...pageResult, + externalSubmit, + } + } + await capture.flush(1_500) + } await sleep(600, context.signal).catch(() => undefined) await capture.flush(DOUBAO_CAPTURE_FLUSH_TIMEOUT_MS) } finally { @@ -3415,17 +4853,7 @@ export const doubaoAdapter: MonitorAdapter = { const streamSummary = attemptResult.streamSummary const captureDebug = attemptResult.captureDebug const pendingCaptureCount = attemptResult.pendingCaptureCount - const domSearchResults = dedupeSourceItems( - pageResult.domLinks - .map((item) => - buildSourceItem({ - url: item.url, - title: item.title ?? item.text ?? null, - site_name: item.siteName, - }), - ) - .filter((item): item is MonitoringSourceItem => item !== null), - ) + const domSearchResults = buildDoubaoDomSearchResults(pageResult.domLinks) const searchResults = dedupeSourceItems([...streamSummary.search_results, ...domSearchResults]) const answer = normalizeOptionalString(streamSummary.answer) const reasoning = selectBestDoubaoText(streamSummary.reasoning, pageResult.domReasoning) @@ -3435,10 +4863,20 @@ export const doubaoAdapter: MonitorAdapter = { const streamSawAnswerFinish = streamSummary.answer_finish_event_count > 0 const pageRecoverableError = pageResult.ok === false && isDoubaoRecoverablePageError(pageResult.error) + const allowDomAnswer = shouldAllowDoubaoDomAnswer({ pageResult, streamSummary }) + const allowReasoningAnswer = shouldAllowDoubaoReasoningAnswer({ + pageResult, + streamSummary, + reasoning, + searchResultCount: searchResults.length, + }) const submission = resolveDoubaoSubmissionAnswer({ answer, domAnswer: pageResult.domAnswer, - allowDomAnswer: streamSawAnswerFinish || pageResult.ok === true, + reasoning, + allowDomAnswer, + allowReasoningAnswer, + sseFinished: streamSawAnswerFinish, }) const hasRecoveredAnswer = Boolean(submission.answer) const hasRecoveredContent = hasRecoveredAnswer || searchResults.length > 0 @@ -3461,6 +4899,24 @@ export const doubaoAdapter: MonitorAdapter = { } } + if (failed.error === 'doubao_challenge_required') { + return { + status: 'failed', + summary: '豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。', + error: buildAdapterError('doubao_challenge_required', detail, { + page_url: failed.url ?? safePageURL(page), + retryable: false, + non_retryable: true, + failure_category: 'ai_platform_authorization', + auth_reason: 'captcha_gate', + submit_method: failed.submitMethod ?? null, + composer_text_length: failed.composerTextLength ?? 0, + send_button_found: failed.sendButtonFound ?? null, + send_button_disabled: failed.sendButtonDisabled ?? null, + }), + } + } + if (isDoubaoRecoverablePageError(failed.error) && !hasRecoveredAnswer) { return { status: 'unknown', @@ -3560,7 +5016,11 @@ export const doubaoAdapter: MonitorAdapter = { } } - if (!streamSawAnswerFinish) { + if ( + !streamSawAnswerFinish && + submission.source !== 'dom' && + submission.source !== 'reasoning' + ) { return { status: 'unknown', summary: '豆包未通过 SSE 确认回答已生成全文,已回写 unknown 等待后续补采。', @@ -3574,6 +5034,8 @@ export const doubaoAdapter: MonitorAdapter = { reply_end_event_count: streamSummary.reply_end_event_count, answer_finish_event_count: streamSummary.answer_finish_event_count, stream_event_count: streamSummary.event_count, + dom_answer_fallback_allowed: allowDomAnswer, + reasoning_answer_fallback_allowed: allowReasoningAnswer, }), } } @@ -3615,7 +5077,9 @@ export const doubaoAdapter: MonitorAdapter = { stream_error_retryable: retryableStreamError, stream_answer_present: Boolean(streamSummary.answer), dom_answer_present: Boolean(pageResult.domAnswer), - reasoning_fallback_used: false, + dom_answer_fallback_allowed: allowDomAnswer, + reasoning_answer_fallback_allowed: allowReasoningAnswer, + reasoning_fallback_used: submission.source === 'reasoning', selected_answer_source: submission.source, stream_answer_length: streamSummary.answer?.length ?? 0, dom_answer_length: pageResult.domAnswer?.length ?? 0, @@ -3631,14 +5095,21 @@ export const doubaoAdapter: MonitorAdapter = { export const __doubaoTestUtils = { buildSourceItem, + buildDoubaoDomSearchResults, dedupeSourceItems, + doubaoModeProviderModel, doubaoModeKindFromLabelText, extractQuestionText, isNonCitationAssetDomain, isNonCitationAssetUrl, isDoubaoRecoverablePageError, isDoubaoDOMSubmissionCandidate, + isDoubaoReasoningSubmissionCandidate, shouldRetryDoubaoPageAttempt, + shouldAllowDoubaoDomAnswer, + shouldAllowDoubaoReasoningAnswer, + hasDoubaoFinishedStreamCapture, + parseDoubaoCaptures, parseDoubaoStreamBody, resolveDoubaoSubmissionAnswer, } diff --git a/apps/desktop-client/src/main/platform-auth-adapters.ts b/apps/desktop-client/src/main/platform-auth-adapters.ts index 01b7a67..9fdb6e1 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.ts @@ -73,6 +73,11 @@ function classifyFailure(input: PlatformFailureInput): PlatformFailureClassifica } function classifyDoubaoFailure(input: PlatformFailureInput): PlatformFailureClassification { + const code = typeof input.error?.code === 'string' ? input.error.code : '' + if (code === 'doubao_challenge_required') { + return 'challenge' + } + const text = normalizeFailureText(input) if (!text) { return 'ok' diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 4a3670d..bd814ff 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -454,9 +454,52 @@ function accountActionRequiredSummary( if (authReason === 'risk_control') { return `${label} 账号疑似触发风控,请打开平台处理验证或等待限制解除后再继续执行。` } + if (platform === 'doubao') { + return '豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。' + } return `${label} 账号需要完成人机验证后才能继续执行。` } +function monitorPlatformBlockedReason(platform: string): string | null { + const account = state.accounts.find( + (item) => item.platform === platform && isAccountOwnedByCurrentClient(item), + ) + if (!account) { + return null + } + + const projected = getProjectedAccountHealth({ + accountId: account.id, + platform: account.platform, + health: account.health, + verifiedAt: account.verified_at, + }) + if (projected.authState === 'challenge_required') { + return accountActionRequiredSummary(account.platform, projected.authReason) + } + if (projected.authState === 'expired' || projected.authState === 'revoked') { + return `${runtimePlatformLabel(account.platform)} 账号登录态已失效,请重新授权后再继续执行。` + } + return null +} + +function blockedMonitorPlatforms(): Set { + const blocked = new Set() + for (const platform of aiPlatformCatalog.map((item) => item.id)) { + if (monitorPlatformBlockedReason(platform)) { + blocked.add(platform) + } + } + return blocked +} + +function eligibleMonitorPlatformIds(): string[] { + const blocked = blockedMonitorPlatforms() + return aiPlatformCatalog + .map((platform) => platform.id) + .filter((platform) => !blocked.has(platform)) +} + function markAuthorizationFailureNonRetryable( error: Record, category: 'ai_platform_authorization' | 'media_account_authorization', @@ -620,6 +663,14 @@ function maybeRecordAccountAccessAlert( recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT) return } + if (accountIdentity.platform === 'doubao') { + recordActivity( + 'warn', + '豆包需要人工验证', + `${accountIdentity.displayName || task.accountName || '当前账号'} 触发豆包人机验证,请打开豆包完成验证码后再刷新状态。`, + ) + return + } recordActivity( 'warn', `${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`, @@ -1938,11 +1989,15 @@ function pumpExecutionLoop(): void { } if (canStartAnotherMonitorExecution()) { + const blockedMonitorExecutionPlatforms = new Set([ + ...blockedPlaywrightPlatforms, + ...blockedMonitorPlatforms(), + ]) const selected = selectNextMonitorTask({ maxConcurrency: resolveAdaptiveMonitorConcurrency(), activePlatforms: activeMonitorPlatforms(), activeQuestionKeys: activeMonitorQuestionKeys(), - blockedPlatforms: blockedPlaywrightPlatforms, + blockedPlatforms: blockedMonitorExecutionPlatforms, activeCount: activeMonitorCount(), }) if (selected) { @@ -2041,7 +2096,15 @@ async function pullNextTask(kind: 'publish' | 'monitor'): Promise { state.leaseInFlightKinds.add('monitor') try { - const leased = await leaseDesktopTask({ kind: 'monitor' }) + const eligiblePlatformIds = eligibleMonitorPlatformIds() + if (eligiblePlatformIds.length === 0) { + state.monitorFallbackBackoffUntil = Date.now() + monitorPullFallbackIntervalMs + return + } + const leased = await leaseDesktopTask({ + kind: 'monitor', + platform_ids: eligiblePlatformIds, + }) state.lastPullAt = Date.now() state.lastPullStatus = 'success' noteTransportPull(true) @@ -2125,8 +2188,14 @@ async function pullMonitoringTasks(routing: RuntimeTaskRouting): Promise { state.leaseInFlightKinds.add('monitor') try { + const eligiblePlatformIds = eligibleMonitorPlatformIds() + if (eligiblePlatformIds.length === 0) { + state.monitorFallbackBackoffUntil = Date.now() + monitorPullFallbackIntervalMs + return + } const leased = await leaseMonitoringTasks({ limit: leaseLimit, + ai_platform_ids: eligiblePlatformIds, }) state.lastPullAt = Date.now() state.lastPullStatus = 'success' @@ -2156,7 +2225,13 @@ async function resumeLeasedMonitoringTasks(source: 'startup' | 'reconnect'): Pro } try { - const resumed = await resumeMonitoringTasks({}) + const eligiblePlatformIds = eligibleMonitorPlatformIds() + if (eligiblePlatformIds.length === 0) { + return + } + const resumed = await resumeMonitoringTasks({ + ai_platform_ids: eligiblePlatformIds, + }) enqueueLeasedMonitoringTasks(resumed.tasks, 'db-recovery') if (resumed.tasks.length > 0) { recordActivity( diff --git a/apps/desktop-client/src/renderer/views/HomeView.vue b/apps/desktop-client/src/renderer/views/HomeView.vue index 807fe5a..dd04128 100644 --- a/apps/desktop-client/src/renderer/views/HomeView.vue +++ b/apps/desktop-client/src/renderer/views/HomeView.vue @@ -12,6 +12,7 @@ import { useDesktopRuntime } from '../composables/useDesktopRuntime' import { formatRelativeTime } from '../lib/formatters' import { translateDesktopPlatform } from '../lib/media-catalog' import { healthTone } from '../lib/runtime-ui' +import type { RuntimeTask } from '../types' function translatePlatform(platform: string) { return translateDesktopPlatform(platform) @@ -55,6 +56,40 @@ type RuntimeIssueItem = { at: number } +const DOUBAO_CHALLENGE_DETAIL = + '豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。' +const DOUBAO_CHALLENGE_PATTERN = + /doubao_challenge_required|challenge_required|captcha_gate|risk_control|人机验证|人工验证|验证码|风控|安全验证/i + +function isDoubaoPlatform(platform: string) { + return platform.toLowerCase() === 'doubao' +} + +function doubaoTaskChallengeKey(task: RuntimeTask) { + return `${task.platform.toLowerCase()}::${task.accountId || task.accountName || 'unknown'}` +} + +function doubaoAccountChallengeKey(account: { + platform: string + id: string + displayName: string + platformUid: string +}) { + return `${account.platform.toLowerCase()}::${account.id || account.displayName || account.platformUid || 'unknown'}` +} + +function isDoubaoChallengeTask(task: RuntimeTask) { + if (!isDoubaoPlatform(task.platform)) { + return false + } + + return DOUBAO_CHALLENGE_PATTERN.test(`${task.summary || ''} ${task.title || ''}`) +} + +function isDoubaoChallengeAccount(account: { platform: string; authState: string }) { + return isDoubaoPlatform(account.platform) && account.authState === 'challenge_required' +} + function translateEventTitle(title: string) { const map: Record = { 'Accounts Synced': '本地账号同步', @@ -115,37 +150,110 @@ const blockedAccounts = computed(() => const blockedTasks = computed(() => tasks.value.filter((item) => ['failed', 'unknown'].includes(item.status)), ) -const issueBreakdown = computed(() => ({ - tasks: blockedTasks.value.length, - accounts: blockedAccounts.value.length, - total: blockedTasks.value.length + blockedAccounts.value.length, -})) const issueItems = computed(() => { - const taskIssues = blockedTasks.value.map((task) => ({ - id: `task-${task.id}`, - kind: 'task' as const, - title: task.title || task.jobId, - meta: `${task.accountName} · ${translatePlatform(task.platform)}`, - detail: task.summary, - badgeLabel: translateTaskStatus(task.status), - badgeTone: 'danger' as const, - at: task.updatedAt, - })) - const accountIssues = blockedAccounts.value.map((account) => ({ - id: `account-${account.id}`, - kind: 'account' as const, - title: account.displayName, - meta: `${translatePlatform(account.platform)} · ${formatUid(account.platformUid)}`, - detail: - account.authReason === 'risk_control' - ? '平台触发风控,需要人工验证后恢复任务执行。' - : '授权状态不可用,需要重新登录或确认账号状态。', - badgeLabel: blockedAccountLabel(account), - badgeTone: healthTone(account.health) === 'warn' ? ('warn' as const) : ('danger' as const), - at: account.lastVerifiedAt ?? account.lastSyncAt, - })) + const doubaoChallengeTaskGroups = new Map() - return [...taskIssues, ...accountIssues].sort((left, right) => right.at - left.at) + for (const task of blockedTasks.value) { + if (!isDoubaoChallengeTask(task)) { + continue + } + + const key = doubaoTaskChallengeKey(task) + const group = doubaoChallengeTaskGroups.get(key) + if (group) { + group.push(task) + } else { + doubaoChallengeTaskGroups.set(key, [task]) + } + } + + const doubaoChallengeAccountKeys = new Set( + blockedAccounts.value + .filter(isDoubaoChallengeAccount) + .map((account) => doubaoAccountChallengeKey(account)), + ) + const doubaoChallengeKeys = new Set([ + ...doubaoChallengeTaskGroups.keys(), + ...doubaoChallengeAccountKeys, + ]) + + const taskIssues = blockedTasks.value + .filter( + (task) => + !isDoubaoPlatform(task.platform) || !doubaoChallengeKeys.has(doubaoTaskChallengeKey(task)), + ) + .map((task) => ({ + id: `task-${task.id}`, + kind: 'task' as const, + title: task.title || task.jobId, + meta: `${task.accountName} · ${translatePlatform(task.platform)}`, + detail: task.summary, + badgeLabel: translateTaskStatus(task.status), + badgeTone: 'danger' as const, + at: task.updatedAt, + })) + + const doubaoChallengeTaskIssues = Array.from(doubaoChallengeTaskGroups.entries()) + .filter(([key]) => !doubaoChallengeAccountKeys.has(key)) + .map(([key, challengeTasks]) => { + const mergedTasks = blockedTasks.value.filter( + (task) => isDoubaoPlatform(task.platform) && doubaoTaskChallengeKey(task) === key, + ) + const representative = mergedTasks.reduce( + (latest, task) => (task.updatedAt > latest.updatedAt ? task : latest), + challengeTasks[0], + ) + const metaParts = [ + `${representative.accountName || '豆包账号'} · ${translatePlatform(representative.platform)}`, + mergedTasks.length > 1 ? `已合并 ${mergedTasks.length} 个失败任务` : '', + ].filter(Boolean) + + return { + id: `task-doubao-challenge-${key}`, + kind: 'task' as const, + title: '豆包需要人工验证', + meta: metaParts.join(' · '), + detail: DOUBAO_CHALLENGE_DETAIL, + badgeLabel: '执行失败', + badgeTone: 'danger' as const, + at: Math.max(...mergedTasks.map((task) => task.updatedAt)), + } + }) + + const accountIssues = blockedAccounts.value.map((account) => { + const doubaoChallenge = isDoubaoChallengeAccount(account) + + return { + id: `account-${account.id}`, + kind: 'account' as const, + title: doubaoChallenge ? '豆包需要人工验证' : account.displayName, + meta: doubaoChallenge + ? `${account.displayName || '豆包账号'} · ${translatePlatform(account.platform)}` + : `${translatePlatform(account.platform)} · ${formatUid(account.platformUid)}`, + detail: doubaoChallenge + ? DOUBAO_CHALLENGE_DETAIL + : account.authReason === 'risk_control' + ? '平台触发风控,需要人工验证后恢复任务执行。' + : '授权状态不可用,需要重新登录或确认账号状态。', + badgeLabel: doubaoChallenge ? '执行失败' : blockedAccountLabel(account), + badgeTone: healthTone(account.health) === 'warn' ? ('warn' as const) : ('danger' as const), + at: account.lastVerifiedAt ?? account.lastSyncAt, + } + }) + + return [...taskIssues, ...doubaoChallengeTaskIssues, ...accountIssues].sort( + (left, right) => right.at - left.at, + ) +}) +const issueBreakdown = computed(() => { + const tasks = issueItems.value.filter((item) => item.kind === 'task').length + const accounts = issueItems.value.filter((item) => item.kind === 'account').length + + return { + tasks, + accounts, + total: tasks + accounts, + } }) @@ -191,7 +299,7 @@ const issueItems = computed(() => { 异常干预
- {{ snapshot?.summary.issuesOpen ?? 0 }} + {{ issueBreakdown.total }}

任务异常 {{ issueBreakdown.tasks }} / 账号阻断 {{ issueBreakdown.accounts }}

diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 643f0ae..55d6edb 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -344,6 +344,7 @@ export type TenantPublishTaskListResponse = DesktopPublishTaskListResponse export interface LeaseDesktopTaskRequest { task_id?: string kind?: 'publish' | 'monitor' + platform_ids?: string[] } export interface LeaseDesktopTaskResponse { diff --git a/progress.md b/progress.md index c7181e1..ac17917 100644 --- a/progress.md +++ b/progress.md @@ -826,3 +826,70 @@ - Searched `apps/desktop-client/out/main/bootstrap.cjs` for `wO`, `_0x...`, `stringArray`, `javascript-obfuscator`, and `['evaluate']`; no obfuscator helper traces were present after `build:obf`. - Scoped implementation to tenant publish task list/retry endpoints plus a new admin-web `/publish-management` page under 内容管理. - Noted existing dirty files in i18n/Knowledge/TemplateWizard/config/swagger/knowledge service and will avoid reverting or overwriting unrelated changes. + +## 2026-06-23T23:54:39+08:00 - Doubao thinking-reference fallback + +- Investigated the Doubao monitor failure where the answer page showed "已完成思考 / 搜索 N 个关键词,参考 N 篇资料" links but the adapter returned `unknown`. +- Current Doubao behavior observed from user screenshots: + - citation/source links now live inside the thinking block rather than next to the final answer; + - one answer can contain multiple "搜索 X 个关键词,参考 Y 篇资料" groups; + - "已完成思考,参考 N 篇资料" is a broad thinking summary control, while the search groups hold the concrete blue source links; + - already-expanded groups may still show a chevron, so clicking blindly can collapse useful references. +- Updated `apps/desktop-client/src/main/adapters/doubao.ts` so `/chat/completion` and `text/event-stream` responses remain the preferred SSE source, while a high-quality page answer can be used when SSE has not finished. +- Strengthened page fallback to expand/reference-scan Doubao's thinking search groups, skip already-expanded nearby-link groups, merge multiple thinking reference groups, and filter favicon/asset CDN links. +- Added helper coverage in `doubao.test.ts` for unfinished SSE + usable page answer, finished SSE precedence, recoverable timeout with anchored page answer, and multi-group thinking references. +- Follow-up fix: the `unknown` message "未从正文或思维链兜底抓到可提交答案,仅抓到参考资料" still happened because the implementation only used `domAnswer` as the fallback answer and did not submit `domReasoning`. The adapter now allows a high-quality thinking-chain text fallback when SSE/page answer text is missing but references and anchored page context were captured. +- Local anonymous Playwright could open Doubao only to the logged-out/home page, so live account-specific MCP/page content was not directly readable in this session. +- Verification passed: + - `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts` + - `pnpm --filter @geo/desktop-client typecheck` + - `pnpm --filter @geo/desktop-client test` + - `git diff --check` + +## 2026-06-24T01:20:00+08:00 - Doubao SSE capture and challenge handling + +- Confirmed from the latest user screenshot that Doubao can trigger an official-site image captcha after a prompt is submitted. This is an account/platform challenge, not an answer parsing failure. +- Hardened `apps/desktop-client/src/main/adapters/doubao.ts`: + - capture Doubao `/chat/completion` SSE in-page through fetch/XHR stream cloning and keep SSE as the primary answer/source path; + - keep DOM/page extraction as fallback, including thinking/reference panels for `search_results` without promoting reference lists to `answer`; + - verify composer write/readback before sending, avoid selecting voice/microphone controls as send buttons, and add a Playwright keyboard/mouse submit fallback when the page does not observe the user question; + - detect Doubao captcha/risk-control surfaces such as image selection, drag-to-area, human verification, security verification, and frequent-access prompts; + - return `doubao_challenge_required` as failed/non-retryable AI platform authorization when a captcha is visible, so the desktop UI alerts the user instead of writing `unknown`. +- Updated `apps/desktop-client/src/main/platform-auth-adapters.ts` so `doubao_challenge_required` is classified as a challenge. +- Updated `apps/desktop-client/src/main/runtime-controller.ts` so Doubao challenge states show a specific user-facing activity: complete Doubao human verification in the desktop client before continuing collection. +- Verification passed: + - `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts src/main/platform-auth-adapters.test.ts` + - `pnpm --filter @geo/desktop-client typecheck` + - `git diff --check` + - `pnpm --filter @geo/desktop-client build` +- Remote SSH to `39.105.229.239` with the provided credentials failed with authentication failure, so remote logs/deployment could not be inspected in this pass. + +## 2026-06-24T02:22:00+08:00 - Doubao captcha display, admission, and default mode + +- Updated desktop HomeView issue aggregation so Doubao human-verification/account challenge failures collapse into one actionable item: "豆包需要人工验证"; the summary count now follows displayed issue rows instead of raw failed task rows. +- Added monitor admission filtering for blocked AI platforms: + - desktop runtime computes an eligible monitor platform whitelist from current local account health; + - Doubao `challenge_required`/expired/revoked states are excluded from local monitor selection, desktop-task monitor lease fallback, legacy monitoring lease fallback, and resume calls; + - other AI platforms remain eligible, so a Doubao captcha no longer blocks Qwen/Kimi/DeepSeek/etc. work. +- Extended `/api/desktop/tasks/lease` with optional `platform_ids` for monitor leases and filtered the server-side monitor candidate SQL by that whitelist. +- Changed Doubao execution to use the default visible mode directly: + - removed active in-page switching to "专家"/"思考" and removed deep-think selection parameters from Doubao query execution; + - `provider_model` now reports `doubao-web-fast` for default/fast and for any observed expert label, with no `doubao-web-expert` remaining. +- Verification passed: + - `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts src/main/platform-auth-adapters.test.ts` + - `pnpm --filter @geo/desktop-client typecheck` + - `pnpm --filter @geo/desktop-client build` + - `cd server && go test ./internal/tenant/app ./internal/tenant/transport` + - `git diff --check` + +## 2026-06-24T02:29:25+08:00 - Doubao DOM source strictness + +- Refined Doubao citation extraction after the user clarified that captcha/risk-control pages may legitimately have no reference sources. +- Kept SSE as the preferred and broad source path, including structured references that embed Doubao redirect URLs in source payloads. +- Tightened DOM/page fallback so `search_results` only comes from real link/card URLs and redirect URL attributes; gray search-query/explanatory text such as "search N keywords, reference N sources" is no longer converted into citation sources. +- Removed the page-side fallback that scraped arbitrary `http` URLs from element text, so expanded blue reference links are still captured while gray search keyword text is ignored. +- Verification passed: + - `pnpm --filter @geo/desktop-client exec vitest run src/main/adapters/doubao.test.ts src/main/platform-auth-adapters.test.ts` + - `pnpm --filter @geo/desktop-client typecheck` + - `pnpm --filter @geo/desktop-client build` + - `git diff --check` diff --git a/server/internal/tenant/app/desktop_task_service.go b/server/internal/tenant/app/desktop_task_service.go index 4d39b4e..9a8b403 100644 --- a/server/internal/tenant/app/desktop_task_service.go +++ b/server/internal/tenant/app/desktop_task_service.go @@ -103,8 +103,9 @@ type DesktopTaskView struct { } type LeaseDesktopTaskRequest struct { - TaskID *string `json:"task_id"` - Kind *string `json:"kind" binding:"omitempty,oneof=publish monitor"` + TaskID *string `json:"task_id"` + Kind *string `json:"kind" binding:"omitempty,oneof=publish monitor"` + PlatformIDs []string `json:"platform_ids"` } type LeaseDesktopTaskResponse struct { @@ -213,6 +214,7 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt AttemptID: attemptID, LeaseTokenHash: tokenHash, } + eligiblePlatformIDs := normalizeDesktopTaskLeasePlatformIDs(req.PlatformIDs) if s.logger != nil { fields := []zap.Field{ @@ -232,7 +234,7 @@ func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.Deskt case taskID != nil: task, err = s.leaseQueuedDesktopTaskByID(ctx, client, *taskID, leaseParams) case req.Kind != nil && strings.TrimSpace(*req.Kind) == "monitor": - task, err = s.leaseNextQueuedMonitorTask(ctx, client, leaseParams) + task, err = s.leaseNextQueuedMonitorTask(ctx, client, leaseParams, eligiblePlatformIDs) case req.Kind != nil && strings.TrimSpace(*req.Kind) == "publish": task, err = s.leaseNextQueuedPublishTask(ctx, client, leaseParams) default: @@ -519,6 +521,7 @@ func (s *DesktopTaskService) leaseNextQueuedMonitorTask( ctx context.Context, client *repository.DesktopClient, params repository.DesktopTaskLeaseParams, + eligiblePlatformIDs []string, ) (*repository.DesktopTask, error) { tx, err := beginDesktopMonitorLeaseTx(ctx, s.pool, client.ID) if err != nil { @@ -533,6 +536,7 @@ func (s *DesktopTaskService) leaseNextQueuedMonitorTask( params.AttemptID, params.LeaseTokenHash, maxConcurrentMonitorPlatformsPerDesktopClient, + eligiblePlatformIDs, ) task, err := scanRepositoryDesktopTask(row) if err != nil { @@ -577,6 +581,10 @@ func leaseNextQueuedMonitorTaskSQL() string { AND dt.platform_id = ca.platform_id AND dt.kind = 'monitor' AND dt.status = 'queued' + AND ( + COALESCE(array_length($7::text[], 1), 0) = 0 + OR dt.platform_id = ANY($7::text[]) + ) WHERE NOT EXISTS ( SELECT 1 FROM desktop_tasks AS active @@ -668,6 +676,30 @@ func leaseNextQueuedMonitorTaskSQL() string { RETURNING ` + desktopTaskRepositoryReturningColumns } +func normalizeDesktopTaskLeasePlatformIDs(values []string) []string { + if len(values) == 0 { + return nil + } + + result := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + platformID := strings.ToLower(strings.TrimSpace(value)) + if platformID == "" { + continue + } + if _, ok := seen[platformID]; ok { + continue + } + seen[platformID] = struct{}{} + result = append(result, platformID) + } + if len(result) == 0 { + return nil + } + return result +} + func monitorAccountLeaseLockSQL(alias string) string { return fmt.Sprintf(`hashtextextended( concat_ws( diff --git a/server/internal/tenant/app/desktop_task_service_test.go b/server/internal/tenant/app/desktop_task_service_test.go index 92d5bbb..f46e152 100644 --- a/server/internal/tenant/app/desktop_task_service_test.go +++ b/server/internal/tenant/app/desktop_task_service_test.go @@ -2,6 +2,7 @@ package app import ( "errors" + "reflect" "regexp" "strings" "testing" @@ -339,6 +340,7 @@ func TestLeaseNextQueuedMonitorTaskSQLUsesAccountIdentityAndClientSlots(t *testi for _, fragment := range []string{ "WITH current_accounts AS", "client_id = $3", + "COALESCE(array_length($7::text[], 1), 0) = 0 OR dt.platform_id = ANY($7::text[])", "dt.target_account_id = target_account.desktop_id", "target_account.account_fingerprint", "target_account.platform_uid", @@ -366,6 +368,19 @@ func TestLeaseNextQueuedMonitorTaskSQLUsesAccountIdentityAndClientSlots(t *testi } } +func TestNormalizeDesktopTaskLeasePlatformIDs(t *testing.T) { + t.Parallel() + + got := normalizeDesktopTaskLeasePlatformIDs([]string{" Doubao ", "qwen", "", "DOUBAO"}) + want := []string{"doubao", "qwen"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizeDesktopTaskLeasePlatformIDs() = %#v, want %#v", got, want) + } + if got := normalizeDesktopTaskLeasePlatformIDs([]string{" ", ""}); got != nil { + t.Fatalf("normalizeDesktopTaskLeasePlatformIDs(blank) = %#v, want nil", got) + } +} + func TestLeaseQueuedDesktopTaskByIDSQLUsesAccountIdentityAndClientSlots(t *testing.T) { t.Parallel() diff --git a/server/internal/tenant/app/monitoring_callback_service.go b/server/internal/tenant/app/monitoring_callback_service.go index 3783d5b..95cab00 100644 --- a/server/internal/tenant/app/monitoring_callback_service.go +++ b/server/internal/tenant/app/monitoring_callback_service.go @@ -420,6 +420,11 @@ func (s *MonitoringCallbackService) handleTaskResultForInstallation( }, nil } + if response := idempotentDesktopMonitoringResultResponse(task); response != nil { + _ = s.touchInstallation(ctx, installation.ID) + return response, nil + } + if task.ExecutionOwner == "desktop_tasks" { if err := s.validateDesktopMonitorExecutionLease(ctx, task, installation.ID, req.LeaseToken); err != nil { return nil, err @@ -3538,6 +3543,24 @@ func validateMonitoringTaskResultSubmission(platformID string, runStatus string, return nil } +func idempotentDesktopMonitoringResultResponse(task *monitoringCollectTask) *MonitoringTaskResultResponse { + if task == nil || task.ExecutionOwner != "desktop_tasks" || !task.CallbackReceivedAt.Valid { + return nil + } + + switch task.Status { + case "received", "failed", "skipped": + return &MonitoringTaskResultResponse{ + TaskID: task.ID, + TaskStatus: task.Status, + CitationSourceCount: 0, + ContentCitationCount: 0, + } + default: + return nil + } +} + func normalizeMonitoringLeaseLimit(value int) int { if value <= 0 { return defaultMonitoringLeaseLimit diff --git a/server/internal/tenant/app/monitoring_callback_service_test.go b/server/internal/tenant/app/monitoring_callback_service_test.go index 70ddb7a..733dcdc 100644 --- a/server/internal/tenant/app/monitoring_callback_service_test.go +++ b/server/internal/tenant/app/monitoring_callback_service_test.go @@ -384,6 +384,68 @@ func TestMonitoringTaskReadyForQueuedResultAcceptsDesktopReceivedStatus(t *testi } } +func TestIdempotentDesktopMonitoringResultResponseAcceptsReceivedTerminalStates(t *testing.T) { + for _, status := range []string{"received", "failed", "skipped"} { + t.Run(status, func(t *testing.T) { + got := idempotentDesktopMonitoringResultResponse(&monitoringCollectTask{ + ID: 123, + ExecutionOwner: "desktop_tasks", + Status: status, + CallbackReceivedAt: sqlNullTimeForTest(), + }) + + if got == nil { + t.Fatal("idempotentDesktopMonitoringResultResponse() = nil, want response") + } + if got.TaskID != 123 { + t.Fatalf("TaskID = %d, want 123", got.TaskID) + } + if got.TaskStatus != status { + t.Fatalf("TaskStatus = %q, want %q", got.TaskStatus, status) + } + }) + } +} + +func TestIdempotentDesktopMonitoringResultResponseRequiresDesktopCallback(t *testing.T) { + tests := []struct { + name string + task monitoringCollectTask + }{ + { + name: "legacy received", + task: monitoringCollectTask{ + ExecutionOwner: "legacy", + Status: "received", + CallbackReceivedAt: sqlNullTimeForTest(), + }, + }, + { + name: "desktop callback missing", + task: monitoringCollectTask{ + ExecutionOwner: "desktop_tasks", + Status: "received", + }, + }, + { + name: "desktop pending", + task: monitoringCollectTask{ + ExecutionOwner: "desktop_tasks", + Status: "pending", + CallbackReceivedAt: sqlNullTimeForTest(), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := idempotentDesktopMonitoringResultResponse(&tt.task); got != nil { + t.Fatalf("idempotentDesktopMonitoringResultResponse() = %#v, want nil", got) + } + }) + } +} + func sqlNullTimeForTest() sql.NullTime { return sql.NullTime{Time: time.Now(), Valid: true} }