import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types"; import { normalizeText } from "./common"; import type { MonitorAdapter } from "./base"; const QWEN_BOOTSTRAP_URL = "https://www.qianwen.com/"; const QWEN_PAGE_READY_TIMEOUT_MS = 20_000; const QWEN_QUERY_TIMEOUT_MS = 90_000; type QwenPageQuestionSnapshot = { model: string | null; deepSearch: string | null; enableSearch: boolean | null; cardText: string | null; }; type QwenPageAnswerSnapshot = { reqId: string | null; status: string | null; content: Record | null; extraInfo: Record | null; communication: Record | null; }; type QwenPageQuerySuccessResult = { ok: true; url: string; question: QwenPageQuestionSnapshot | null; answer: QwenPageAnswerSnapshot | null; }; type QwenPageQueryFailureResult = { ok: false; error: string; detail: string | null; url: string | null; question: QwenPageQuestionSnapshot | null; answer: QwenPageAnswerSnapshot | null; }; type QwenPageQueryResult = QwenPageQuerySuccessResult | QwenPageQueryFailureResult; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizeOptionalString(value: unknown): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed ? trimmed : null; } function readNestedString(source: unknown, path: string[]): string | null { let current: unknown = source; for (const segment of path) { if (!isRecord(current) || !(segment in current)) { return null; } current = current[segment]; } return normalizeOptionalString(current); } function normalizeUrl(value: unknown): string | null { const input = normalizeText(value); if (!input) { return null; } try { const url = new URL(input); url.hash = ""; return url.toString(); } catch { return input; } } function buildQwenSourceItem(source: unknown): MonitoringSourceItem | null { if (!isRecord(source)) { return null; } const url = normalizeUrl(source.url ?? source.href ?? source.link); if (!url) { return null; } let host: string | null = null; try { host = new URL(url).hostname || null; } catch { host = null; } return { url, title: normalizeText(source.title), site_name: normalizeText(source.name ?? source.site_name ?? source.authority), normalized_url: url, host, }; } function collectQwenSources(payload: unknown, result: MonitoringSourceItem[], depth = 0): void { if (depth > 12 || payload == null) { return; } if (Array.isArray(payload)) { for (const item of payload) { collectQwenSources(item, result, depth + 1); } return; } if (!isRecord(payload)) { return; } const sourceItem = buildQwenSourceItem(payload); if (sourceItem) { result.push(sourceItem); } for (const value of Object.values(payload)) { if (Array.isArray(value) || isRecord(value)) { collectQwenSources(value, result, depth + 1); } } } function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] { const keyed = new Map(); for (const item of items) { const key = normalizeUrl(item.normalized_url ?? item.url); if (!key) { continue; } const existing = keyed.get(key); if (!existing) { keyed.set(key, { ...item, normalized_url: key, }); continue; } keyed.set(key, { ...existing, title: existing.title ?? item.title ?? null, site_name: existing.site_name ?? item.site_name ?? null, host: existing.host ?? item.host ?? null, }); } return Array.from(keyed.values()); } function resolveQwenProviderModel(pageResult: QwenPageQuerySuccessResult): string | null { return ( readNestedString(pageResult.answer?.extraInfo, ["chat_odps", "model_info", "model"]) ?? normalizeText(pageResult.question?.model) ?? (pageResult.question?.deepSearch === "1" ? "Qwen-deepThink" : "Qwen") ); } function formatQwenQueryError(result: QwenPageQueryResult): string { if (!result.ok) { const detail = normalizeText(result.detail); return detail ? `${result.error}: ${detail}` : result.error; } const status = normalizeText(result.answer?.status); return status ? `qwen query ended with status ${status}` : "qwen query failed"; } function extractQuestionText(payload: Record): string { const candidates = [ payload.question_text, payload.questionText, payload.query, payload.question, payload.prompt, payload.content, payload.title, ]; for (const candidate of candidates) { const text = normalizeOptionalString(candidate); if (text) { return text; } } throw new Error("qwen_question_text_missing"); } function toJsonValue(value: unknown, depth = 0): JsonValue { if (value == null || depth > 16) { return null; } if (typeof value === "string" || typeof value === "boolean") { return value; } if (typeof value === "number") { return Number.isFinite(value) ? value : null; } if (Array.isArray(value)) { return value.map((item) => toJsonValue(item, depth + 1)); } if (!isRecord(value)) { return null; } const result: Record = {}; for (const [key, item] of Object.entries(value)) { result[key] = toJsonValue(item, depth + 1); } return result; } function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] { return items.map((item) => ({ url: item.url, title: item.title ?? null, site_name: item.site_name ?? null, site_key: item.site_key ?? null, normalized_url: item.normalized_url ?? null, host: item.host ?? null, registrable_domain: item.registrable_domain ?? null, subdomain: item.subdomain ?? null, suffix: item.suffix ?? null, article_id: item.article_id ?? null, publish_record_id: item.publish_record_id ?? null, resolution_status: item.resolution_status ?? null, resolution_confidence: item.resolution_confidence ?? null, })); } function buildAdapterError( code: string, message: string, extras: Record = {}, ): Record { return { code, message, ...extras, }; } function qwenQueryInPage(parameters: { questionText: string; timeoutMs: number; readyTimeoutMs: number; deepThink: boolean; }): Promise { const questionText = parameters.questionText; const queryTimeoutMs = parameters.timeoutMs; const readyTimeoutMs = parameters.readyTimeoutMs; const deepThinkEnabled = parameters.deepThink; const wait = (timeMs: number) => new Promise((resolve) => { window.setTimeout(resolve, timeMs); }); const isObjectRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const normalizeInlineText = (value: unknown): string | null => { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed ? trimmed : null; }; const toSerializable = (value: unknown): Record | null => { if (value == null) { return null; } try { return JSON.parse(JSON.stringify(value)) as Record; } catch { return null; } }; const buildPageDetail = () => { const title = normalizeInlineText(document.title); const url = normalizeInlineText(window.location.href); const bodyText = normalizeInlineText(document.body?.innerText)?.slice(0, 120) ?? null; return [title, url, bodyText].filter(Boolean).join(" | ") || null; }; const locateManagers = () => { const bindingMap = ( window as typeof window & { __TONGYI_PORTSL_GLOBAL_CONTAINER__?: { container?: { _bindingDictionary?: { _map?: Map; }; }; }; } ).__TONGYI_PORTSL_GLOBAL_CONTAINER__?.container?._bindingDictionary?._map; if (!(bindingMap instanceof Map)) { return null; } let textAreaManager: Record | null = null; let chatManager: Record | null = null; for (const bindings of bindingMap.values()) { if (!Array.isArray(bindings)) { continue; } for (const binding of bindings) { const candidate = isObjectRecord(binding) && "cache" in binding ? binding.cache : null; if (!isObjectRecord(candidate)) { continue; } const prototype = Object.getPrototypeOf(candidate); const protoKeys = Object.getOwnPropertyNames(prototype ?? {}); if (!textAreaManager && protoKeys.includes("setText") && protoKeys.includes("getWidget") && protoKeys.includes("enterMode")) { textAreaManager = candidate; } if (!chatManager && protoKeys.includes("textAreaSubmit") && protoKeys.includes("sendWithParams") && protoKeys.includes("submit")) { chatManager = candidate; } } } const state = ( window as typeof window & { __qianwenChatAPI?: { sharedValues?: { chatAPI?: { state?: Record; }; }; }; } ).__qianwenChatAPI?.sharedValues?.chatAPI?.state; if (!textAreaManager || !chatManager || !isObjectRecord(state)) { return null; } return { textAreaManager, chatManager, state, }; }; const waitForReadyManagers = async () => { const deadline = Date.now() + readyTimeoutMs; while (Date.now() <= deadline) { const managers = locateManagers(); if (managers) { return managers; } await wait(250); } return locateManagers(); }; const getLatestSnapshot = (minimumRoundCount: number) => { const state = ( window as typeof window & { __qianwenChatAPI?: { sharedValues?: { chatAPI?: { state?: { chatRounds?: Array>; }; }; }; }; } ).__qianwenChatAPI?.sharedValues?.chatAPI?.state; const chatRounds = Array.isArray(state?.chatRounds) ? state.chatRounds : []; const candidateRounds = chatRounds.length > minimumRoundCount ? chatRounds.slice(minimumRoundCount) : chatRounds; for (let index = candidateRounds.length - 1; index >= 0; index -= 1) { const round = candidateRounds[index]; if (!isObjectRecord(round)) { continue; } const questions = Array.isArray(round.questions) ? round.questions : []; const currentQuestionIndex = typeof round.currentQuestionIndex === "number" ? round.currentQuestionIndex : 0; const question = questions[currentQuestionIndex]; if (!isObjectRecord(question)) { continue; } const cards = Array.isArray(question.cards) ? question.cards : []; const firstCard = cards[0]; const cardText = isObjectRecord(firstCard) && "content" in firstCard ? normalizeInlineText(firstCard.content) : null; if (cardText !== questionText) { continue; } const answers = Array.isArray(question.answers) ? question.answers : []; const currentAnswerIndex = typeof question.currentAnswerIndex === "number" ? question.currentAnswerIndex : 0; const answer = answers[currentAnswerIndex]; return { question: { model: normalizeInlineText(question.model), deepSearch: normalizeInlineText(question.deepSearch), enableSearch: typeof question.enableSearch === "boolean" ? question.enableSearch : null, cardText, }, answer: isObjectRecord(answer) ? { reqId: normalizeInlineText(answer.reqId), status: normalizeInlineText(answer.status), content: toSerializable(answer.content), extraInfo: toSerializable(answer.extraInfo), communication: toSerializable(answer.communication), } : null, }; } return { question: null, answer: null, }; }; return (async (): Promise => { const managers = await waitForReadyManagers(); if (!managers) { return { ok: false, error: "qwen_page_not_ready", detail: buildPageDetail(), url: window.location.href || null, question: null, answer: null, }; } const { textAreaManager, chatManager, state } = managers; const initialRoundCount = Array.isArray(state.chatRounds) ? state.chatRounds.length : 0; try { if (typeof textAreaManager.reset === "function") { textAreaManager.reset(); } const widgetContainer = isObjectRecord(textAreaManager.inputWidgetContainer) ? textAreaManager.inputWidgetContainer : null; if (deepThinkEnabled && typeof widgetContainer?.activeWidget === "function") { await widgetContainer.activeWidget("deepThink"); } else if (typeof widgetContainer?.inactiveAllWidget === "function") { await widgetContainer.inactiveAllWidget(); } if (typeof textAreaManager.setText === "function") { textAreaManager.setText(questionText); } if (normalizeInlineText(textAreaManager.text) !== questionText && typeof textAreaManager.setTextImmediately === "function") { textAreaManager.setTextImmediately(questionText); } await wait(50); if (textAreaManager.isSubmitDisabled) { return { ok: false, error: "qwen_submit_disabled", detail: normalizeInlineText(textAreaManager.text) ?? buildPageDetail(), url: window.location.href || null, question: null, answer: null, }; } if (typeof chatManager.textAreaSubmit !== "function") { return { ok: false, error: "qwen_submit_method_missing", detail: buildPageDetail(), url: window.location.href || null, question: null, answer: null, }; } await chatManager.textAreaSubmit(); } catch (error) { return { ok: false, error: "qwen_submit_failed", detail: error instanceof Error ? error.message : String(error ?? ""), url: window.location.href || null, question: null, answer: null, }; } const deadline = Date.now() + queryTimeoutMs; while (Date.now() <= deadline) { const snapshot = getLatestSnapshot(initialRoundCount); const answerStatus = normalizeInlineText(snapshot.answer?.status); if (answerStatus && ["finish", "failed", "interrupted"].includes(answerStatus)) { if (answerStatus === "finish") { return { ok: true, url: window.location.href, question: snapshot.question, answer: snapshot.answer, }; } return { ok: false, error: "qwen_query_finished_without_success", detail: answerStatus, url: window.location.href, question: snapshot.question, answer: snapshot.answer, }; } await wait(500); } const timeoutSnapshot = getLatestSnapshot(initialRoundCount); return { ok: false, error: "qwen_query_timeout", detail: buildPageDetail(), url: window.location.href || null, question: timeoutSnapshot.question, answer: timeoutSnapshot.answer, }; })(); } export const qwenAdapter: MonitorAdapter = { provider: "qwen", executionMode: "playwright", async query(context, payload) { if (!context.playwright?.page) { return { status: "failed", summary: "千问监测缺少 Playwright 页面上下文。", error: buildAdapterError("qwen_playwright_required", "qwen_playwright_required"), }; } const questionText = extractQuestionText(payload); const page = context.playwright.page; context.reportProgress("qwen.page_ready"); await page.waitForLoadState("domcontentloaded", { timeout: QWEN_PAGE_READY_TIMEOUT_MS, }).catch(() => undefined); if (!page.url() || page.url() === "about:blank") { await page.goto(QWEN_BOOTSTRAP_URL, { waitUntil: "domcontentloaded", timeout: QWEN_PAGE_READY_TIMEOUT_MS, }); } context.reportProgress("qwen.query"); const pageResult = await page.evaluate(qwenQueryInPage, { questionText, timeoutMs: QWEN_QUERY_TIMEOUT_MS, readyTimeoutMs: QWEN_PAGE_READY_TIMEOUT_MS, deepThink: true, }); if (!pageResult?.ok || !pageResult.answer) { const message = formatQwenQueryError(pageResult); return { status: "failed", summary: `千问请求失败:${message}`, error: buildAdapterError( "qwen_query_failed", message, { page_url: normalizeText(pageResult?.url) ?? page.url(), reason_code: pageResult?.ok ? "unknown" : pageResult.error, }, ), }; } const answerText = readNestedString(pageResult.answer.content, ["multiLoadIframe", "content"]) ?? readNestedString(pageResult.answer.content, ["barIframe", "content"]) ?? null; const requestId = normalizeText(pageResult.answer.reqId) ?? readNestedString(pageResult.answer.communication, ["reqid"]) ?? null; const sessionId = readNestedString(pageResult.answer.communication, ["sessionid"]); const providerModel = resolveQwenProviderModel(pageResult); const searchResults: MonitoringSourceItem[] = []; collectQwenSources(pageResult.answer.content, searchResults); const dedupedSearchResults = dedupeSourceItems(searchResults); if (!answerText && !dedupedSearchResults.length) { return { status: "unknown", summary: "千问返回为空,已回写 unknown 等待后续对账。", error: buildAdapterError("qwen_empty_response", "qwen returned no answer or sources"), }; } return { status: "succeeded", summary: "千问监控任务执行成功。", payload: { platform: "qwen", provider_model: providerModel, provider_request_id: requestId, request_id: requestId ?? sessionId, session_id: sessionId, answer: answerText, source_count: dedupedSearchResults.length, search_results: toJsonSources(dedupedSearchResults), citations: [], raw_response_json: { platform: "qwen", mode: pageResult.question?.deepSearch === "1" ? "deep_think" : "standard", page_url: pageResult.url, session_id: sessionId, provider_request_id: requestId, provider_model: providerModel, answer: answerText, question: toJsonValue(pageResult.question), source_count: dedupedSearchResults.length, search_results: toJsonSources(dedupedSearchResults), content: toJsonValue(pageResult.answer.content), extra_info: toJsonValue(pageResult.answer.extraInfo), communication: toJsonValue(pageResult.answer.communication), status: "succeeded", }, }, }; }, };