Add monitoring service and database schema
- Implement monitoring service with heartbeat, lease tasks, resume tasks, and task result handling. - Create monitoring time utilities for business date calculations. - Add unit tests for date window resolution and business day handling. - Define database schema for monitoring-related tables including quotas, daily reports, and task management. - Establish migration scripts for creating and dropping monitoring tables.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { browser } from "wxt/browser";
|
||||
|
||||
import type { StoredMonitoringPlatformState } from "../monitoring-platforms";
|
||||
|
||||
import type { MonitoringAdapterDetectResult } from "./types";
|
||||
|
||||
export type SilentAutomationPageHandle = {
|
||||
tabId: number;
|
||||
groupId: number | null;
|
||||
};
|
||||
|
||||
export async function openSilentAutomationPage(url: string): Promise<SilentAutomationPageHandle> {
|
||||
const createdTab = await browser.tabs.create({
|
||||
url,
|
||||
active: false,
|
||||
});
|
||||
|
||||
if (typeof createdTab.id !== "number") {
|
||||
throw new Error("silent_automation_tab_unavailable");
|
||||
}
|
||||
|
||||
let groupId: number | null = null;
|
||||
try {
|
||||
groupId = await chrome.tabs.group({ tabIds: createdTab.id });
|
||||
await chrome.tabGroups.update(groupId, { collapsed: true, title: "" });
|
||||
} catch {
|
||||
// tabGroups API 不可用时降级为普通 tab
|
||||
}
|
||||
|
||||
return { tabId: createdTab.id, groupId };
|
||||
}
|
||||
|
||||
export async function closeSilentAutomationPage(handle: SilentAutomationPageHandle): Promise<void> {
|
||||
await browser.tabs.remove(handle.tabId).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function normalizeDetectResult(
|
||||
connected: boolean,
|
||||
accessible: boolean,
|
||||
reasonCode: string | null,
|
||||
reasonMessage: string | null,
|
||||
): MonitoringAdapterDetectResult {
|
||||
return {
|
||||
connected,
|
||||
accessible,
|
||||
detected_at: new Date().toISOString(),
|
||||
reason_code: reasonCode,
|
||||
reason_message: reasonMessage,
|
||||
message: reasonMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hasAnyPlatformCookie(platform: StoredMonitoringPlatformState): Promise<boolean> {
|
||||
for (const domain of platform.cookie_domains) {
|
||||
try {
|
||||
const cookies = await browser.cookies.getAll({ domain });
|
||||
if (cookies.length > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function isPlatformReachable(platform: StoredMonitoringPlatformState): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(platform.home_url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
credentials: "include",
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { getExtensionState, patchExtensionState, type DoubaoMonitoringRuntimeState } from "../storage";
|
||||
|
||||
const DOUBAO_HOST_SUFFIX = ".doubao.com";
|
||||
const DOUBAO_SAMANTHA_KEY = "samantha_web_web_id";
|
||||
const DOUBAO_TEA_CACHE_KEY = "__tea_cache_tokens_497858";
|
||||
const DOUBAO_MS_TOKEN_KEY = "xmst";
|
||||
const DOUBAO_FP_COOKIE_NAME = "s_v_web_id";
|
||||
|
||||
function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeOptionalField(value: unknown): string | null | undefined {
|
||||
if (typeof value === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeText(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isDoubaoHost(hostname: string): boolean {
|
||||
return hostname === "doubao.com" || hostname === "www.doubao.com" || hostname.endsWith(DOUBAO_HOST_SUFFIX);
|
||||
}
|
||||
|
||||
function safeParseJSON(raw: string | null): unknown {
|
||||
const normalized = normalizeText(raw);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(normalized);
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedString(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 normalizeText(current);
|
||||
}
|
||||
|
||||
function pickFirstString(source: unknown, paths: string[][]): string | null {
|
||||
for (const path of paths) {
|
||||
const value = getNestedString(source, path);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readCookieValue(cookieHeader: string, name: string): string | null {
|
||||
const segments = cookieHeader.split(";");
|
||||
for (const segment of segments) {
|
||||
const [rawName, ...rest] = segment.trim().split("=");
|
||||
if (rawName !== name) {
|
||||
continue;
|
||||
}
|
||||
return normalizeText(rest.join("="));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasRuntimeStateValue(state: DoubaoMonitoringRuntimeState): boolean {
|
||||
return Boolean(state.fp || state.ms_token || state.device_id || state.web_id || state.tea_uuid);
|
||||
}
|
||||
|
||||
function isSameDoubaoRuntimeState(
|
||||
left: DoubaoMonitoringRuntimeState | null,
|
||||
right: DoubaoMonitoringRuntimeState | null,
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
left.fp === right.fp &&
|
||||
left.ms_token === right.ms_token &&
|
||||
left.device_id === right.device_id &&
|
||||
left.web_id === right.web_id &&
|
||||
left.tea_uuid === right.tea_uuid &&
|
||||
left.source_url === right.source_url
|
||||
);
|
||||
}
|
||||
|
||||
export function readDoubaoMonitoringRuntimeStateFromPage(): DoubaoMonitoringRuntimeState | null {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
if (!isDoubaoHost(window.location.hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localStorageRef = window.localStorage;
|
||||
const samanthaState = safeParseJSON(localStorageRef.getItem(DOUBAO_SAMANTHA_KEY));
|
||||
const teaState = safeParseJSON(localStorageRef.getItem(DOUBAO_TEA_CACHE_KEY));
|
||||
|
||||
const runtimeState: DoubaoMonitoringRuntimeState = {
|
||||
fp: readCookieValue(document.cookie, DOUBAO_FP_COOKIE_NAME),
|
||||
ms_token: normalizeText(localStorageRef.getItem(DOUBAO_MS_TOKEN_KEY)),
|
||||
device_id: pickFirstString(samanthaState, [["web_id"], ["device_id"], ["deviceId"], ["id"]]),
|
||||
web_id: pickFirstString(teaState, [["web_id"], ["webId"], ["id"]]),
|
||||
tea_uuid: pickFirstString(teaState, [["web_id"], ["tea_uuid"], ["teaUuid"], ["user_unique_id"], ["uuid"]]),
|
||||
synced_at: new Date().toISOString(),
|
||||
source_url: window.location.href,
|
||||
};
|
||||
|
||||
if (!hasRuntimeStateValue(runtimeState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runtimeState;
|
||||
}
|
||||
|
||||
export function readDoubaoMonitoringRuntimePatchFromRequestUrl(urlValue: string): Partial<DoubaoMonitoringRuntimeState> | null {
|
||||
try {
|
||||
const url = new URL(urlValue);
|
||||
if (!isDoubaoHost(url.hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patch: Partial<DoubaoMonitoringRuntimeState> = {
|
||||
fp: normalizeOptionalField(url.searchParams.get("fp")),
|
||||
ms_token: normalizeOptionalField(url.searchParams.get("msToken")),
|
||||
device_id: normalizeOptionalField(url.searchParams.get("device_id")),
|
||||
web_id: normalizeOptionalField(url.searchParams.get("web_id")),
|
||||
tea_uuid: normalizeOptionalField(url.searchParams.get("tea_uuid")),
|
||||
synced_at: new Date().toISOString(),
|
||||
source_url: url.toString(),
|
||||
};
|
||||
|
||||
if (!hasRuntimeStateValue({
|
||||
fp: patch.fp ?? null,
|
||||
ms_token: patch.ms_token ?? null,
|
||||
device_id: patch.device_id ?? null,
|
||||
web_id: patch.web_id ?? null,
|
||||
tea_uuid: patch.tea_uuid ?? null,
|
||||
synced_at: patch.synced_at ?? null,
|
||||
source_url: patch.source_url ?? null,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return patch;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchDoubaoMonitoringRuntimeState(
|
||||
patch: Partial<DoubaoMonitoringRuntimeState> | null,
|
||||
): Promise<void> {
|
||||
if (!patch) {
|
||||
return;
|
||||
}
|
||||
|
||||
await patchExtensionState({
|
||||
monitoring_runtime: {
|
||||
doubao: {
|
||||
...patch,
|
||||
synced_at: patch.synced_at ?? new Date().toISOString(),
|
||||
} satisfies Partial<DoubaoMonitoringRuntimeState>,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncDoubaoMonitoringRuntimeStateFromPage(): Promise<void> {
|
||||
const nextRuntimeState = readDoubaoMonitoringRuntimeStateFromPage();
|
||||
const currentState = await getExtensionState();
|
||||
const currentRuntimeState = currentState.monitoring_runtime.doubao;
|
||||
|
||||
if (isSameDoubaoRuntimeState(currentRuntimeState, nextRuntimeState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await patchDoubaoMonitoringRuntimeState(nextRuntimeState);
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
import type { MonitoringSourceItem } from "@geo/shared-types";
|
||||
import { browser } from "wxt/browser";
|
||||
|
||||
import type { StoredMonitoringPlatformState } from "../monitoring-platforms";
|
||||
import { getExtensionState } from "../storage";
|
||||
|
||||
import {
|
||||
closeSilentAutomationPage,
|
||||
hasAnyPlatformCookie,
|
||||
isPlatformReachable,
|
||||
normalizeDetectResult,
|
||||
openSilentAutomationPage,
|
||||
} from "./common";
|
||||
import type { MonitoringAdapter, MonitoringAdapterQueryResult } from "./types";
|
||||
|
||||
const DOUBAO_APP_ID = "497858";
|
||||
const DOUBAO_BOT_ID = "7338286299411103781";
|
||||
const DOUBAO_CHAT_URL = "https://www.doubao.com/chat/completion";
|
||||
const DOUBAO_REFERER = "https://www.doubao.com/chat/";
|
||||
const DOUBAO_BOOTSTRAP_URL = DOUBAO_REFERER;
|
||||
const DOUBAO_FP_COOKIE_NAME = "s_v_web_id";
|
||||
const DOUBAO_COOKIE_URLS = ["https://www.doubao.com/", "https://doubao.com/"];
|
||||
const DOUBAO_TAB_URL_PATTERNS = ["https://www.doubao.com/*", "https://doubao.com/*"];
|
||||
const DOUBAO_RUNTIME_SYNC_MESSAGE = "doubao runtime passive sync pending";
|
||||
const DOUBAO_RUNTIME_SYNC_HINT = "插件正在后台静默拉起豆包并自动同步";
|
||||
const DOUBAO_RUNTIME_BOOTSTRAP_TIMEOUT_MS = 15_000;
|
||||
const DOUBAO_EXISTING_TAB_BOOTSTRAP_TIMEOUT_MS = 5_000;
|
||||
const DOUBAO_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS = 500;
|
||||
const DOUBAO_RUNTIME_BOOTSTRAP_COOLDOWN_MS = 30_000;
|
||||
const DOUBAO_EXISTING_TAB_PROBE_LIMIT = 2;
|
||||
const DOUBAO_REQUIRED_RUNTIME_KEYS: Array<keyof DoubaoRequestRuntimeState> = [
|
||||
"fp",
|
||||
"ms_token",
|
||||
"device_id",
|
||||
"web_id",
|
||||
"tea_uuid",
|
||||
];
|
||||
const SEARCH_RESULT_KEYS = new Set([
|
||||
"references",
|
||||
"reference_list",
|
||||
"referenceList",
|
||||
"reference_cards",
|
||||
"referenceCards",
|
||||
"reference_docs",
|
||||
"referenceDocs",
|
||||
"sources",
|
||||
"source_list",
|
||||
"sourceList",
|
||||
"source_cards",
|
||||
"sourceCards",
|
||||
"search_results",
|
||||
"searchResults",
|
||||
"search_result_list",
|
||||
"searchResultList",
|
||||
"search_refs",
|
||||
"searchRefs",
|
||||
"web_results",
|
||||
"webResults",
|
||||
"grounding",
|
||||
"grounding_results",
|
||||
"groundingResults",
|
||||
"browse_results",
|
||||
"browseResults",
|
||||
"browse_references",
|
||||
"browseReferences",
|
||||
]);
|
||||
const CONTENT_CITATION_KEYS = new Set([
|
||||
"citations",
|
||||
"citation_list",
|
||||
"citationList",
|
||||
"content_citations",
|
||||
"contentCitations",
|
||||
"answer_citations",
|
||||
"answerCitations",
|
||||
]);
|
||||
const SEARCH_RESULT_KEYWORDS = [
|
||||
"reference",
|
||||
"source",
|
||||
"searchresult",
|
||||
"searchref",
|
||||
"webresult",
|
||||
"grounding",
|
||||
"browse",
|
||||
];
|
||||
const CONTENT_CITATION_KEYWORDS = ["citation", "contentcitation", "answercitation"];
|
||||
|
||||
type SourceCollectionBucket = "search" | "citation" | null;
|
||||
|
||||
type DoubaoRequestRuntimeState = {
|
||||
fp: string;
|
||||
ms_token: string;
|
||||
device_id: string;
|
||||
web_id: string;
|
||||
tea_uuid: string;
|
||||
};
|
||||
|
||||
type DoubaoStreamEvent = {
|
||||
event: string;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
type DoubaoStreamSummary = {
|
||||
answer: string | null;
|
||||
provider_request_id: string | null;
|
||||
request_id: string | null;
|
||||
conversation_id: string | null;
|
||||
citations: MonitoringSourceItem[];
|
||||
search_results: MonitoringSourceItem[];
|
||||
events: DoubaoStreamEvent[];
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
type DoubaoRuntimeSyncResponse = {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
let doubaoRuntimeBootstrapInFlight: Promise<boolean> | null = null;
|
||||
let lastDoubaoRuntimeBootstrapAt = 0;
|
||||
|
||||
function normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeAnswerFragment(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.replace(/\r/g, "");
|
||||
return normalized.trim() ? normalized : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getDirectString(record: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = normalizeText(record[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readNestedString(source: unknown, path: string[]): string | null {
|
||||
let current: unknown = source;
|
||||
for (const key of path) {
|
||||
if (!isRecord(current) || !(key in current)) {
|
||||
return null;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return normalizeText(current);
|
||||
}
|
||||
|
||||
function findFirstStringByKey(source: unknown, keys: string[], depth = 0): string | null {
|
||||
if (depth > 8 || source == null) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
for (const item of source) {
|
||||
const found = findFirstStringByKey(item, keys, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(source)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const direct = normalizeText(source[key]);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(source)) {
|
||||
const found = findFirstStringByKey(value, keys, depth + 1);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeAnswerText(current: string | null, nextFragment: string | null): string | null {
|
||||
if (!nextFragment) {
|
||||
return current;
|
||||
}
|
||||
if (!current) {
|
||||
return nextFragment;
|
||||
}
|
||||
if (current === nextFragment || current.includes(nextFragment)) {
|
||||
return current;
|
||||
}
|
||||
if (nextFragment.startsWith(current)) {
|
||||
return nextFragment;
|
||||
}
|
||||
return `${current}${nextFragment}`;
|
||||
}
|
||||
|
||||
function safeParseJSON(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSourceUrl(value: string | null): 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 normalizeSourceKey(key: string): string {
|
||||
return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||||
}
|
||||
|
||||
function resolveSourceBucket(key: string): SourceCollectionBucket {
|
||||
const normalized = normalizeSourceKey(key);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (CONTENT_CITATION_KEYS.has(key) || CONTENT_CITATION_KEYWORDS.some((keyword) => normalized.includes(keyword))) {
|
||||
return "citation";
|
||||
}
|
||||
if (SEARCH_RESULT_KEYS.has(key) || SEARCH_RESULT_KEYWORDS.some((keyword) => normalized.includes(keyword))) {
|
||||
return "search";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSourceItem(input: unknown): MonitoringSourceItem | null {
|
||||
if (typeof input === "string") {
|
||||
const url = normalizeSourceUrl(input);
|
||||
return url ? { url, normalized_url: url } : null;
|
||||
}
|
||||
if (!isRecord(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = normalizeSourceUrl(
|
||||
getDirectString(input, [
|
||||
"url",
|
||||
"link",
|
||||
"uri",
|
||||
"href",
|
||||
"cited_url",
|
||||
"target_url",
|
||||
"targetUrl",
|
||||
"source_url",
|
||||
"sourceUrl",
|
||||
"page_url",
|
||||
"pageUrl",
|
||||
"jump_url",
|
||||
"jumpUrl",
|
||||
]),
|
||||
);
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let host: string | null = null;
|
||||
try {
|
||||
host = new URL(url).hostname || null;
|
||||
} catch {
|
||||
host = null;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
title: getDirectString(input, ["title", "name", "cited_title", "page_title", "display_title", "page_name"]),
|
||||
site_name: getDirectString(input, ["site_name", "siteName", "source", "platform_name", "domain", "site", "host_name"]),
|
||||
site_key: getDirectString(input, ["site_key", "siteKey", "domain_key"]),
|
||||
normalized_url: url,
|
||||
host,
|
||||
resolution_status: getDirectString(input, ["resolution_status", "resolutionStatus"]),
|
||||
resolution_confidence: getDirectString(input, ["resolution_confidence", "resolutionConfidence"]),
|
||||
};
|
||||
}
|
||||
|
||||
function looksExplicitlyLikeSourceRecord(input: Record<string, unknown>): boolean {
|
||||
return (
|
||||
getDirectString(input, [
|
||||
"title",
|
||||
"name",
|
||||
"cited_title",
|
||||
"page_title",
|
||||
"display_title",
|
||||
"page_name",
|
||||
"site_name",
|
||||
"siteName",
|
||||
"source",
|
||||
"platform_name",
|
||||
"domain",
|
||||
"host_name",
|
||||
]) !== null
|
||||
);
|
||||
}
|
||||
|
||||
function isSourceLikeArray(value: unknown): value is unknown[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return value.some((item) => buildSourceItem(item) !== null);
|
||||
}
|
||||
|
||||
function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] {
|
||||
const keyed = new Map<string, MonitoringSourceItem>();
|
||||
for (const item of items) {
|
||||
const key = normalizeSourceUrl(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,
|
||||
site_key: existing.site_key ?? item.site_key ?? null,
|
||||
host: existing.host ?? item.host ?? null,
|
||||
resolution_status: existing.resolution_status ?? item.resolution_status ?? null,
|
||||
resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(keyed.values());
|
||||
}
|
||||
|
||||
function collectSources(
|
||||
payload: unknown,
|
||||
searchResults: MonitoringSourceItem[],
|
||||
citations: MonitoringSourceItem[],
|
||||
bucket: SourceCollectionBucket = null,
|
||||
depth = 0,
|
||||
): void {
|
||||
if (depth > 12 || payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pushToBucket = (item: MonitoringSourceItem, targetBucket: SourceCollectionBucket) => {
|
||||
if (targetBucket === "citation") {
|
||||
citations.push(item);
|
||||
return;
|
||||
}
|
||||
searchResults.push(item);
|
||||
};
|
||||
|
||||
if (typeof payload === "string") {
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
const item = buildSourceItem(payload);
|
||||
if (item) {
|
||||
pushToBucket(item, bucket);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
if (bucket && isSourceLikeArray(payload)) {
|
||||
for (const item of payload) {
|
||||
const sourceItem = buildSourceItem(item);
|
||||
if (sourceItem) {
|
||||
pushToBucket(sourceItem, bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of payload) {
|
||||
collectSources(item, searchResults, citations, bucket, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directSourceItem = buildSourceItem(payload);
|
||||
if (directSourceItem && (bucket || looksExplicitlyLikeSourceRecord(payload))) {
|
||||
pushToBucket(directSourceItem, bucket ?? "search");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
const nextBucket = resolveSourceBucket(key) ?? bucket;
|
||||
|
||||
if (nextBucket && isSourceLikeArray(value)) {
|
||||
const sourceItems = value.map((item) => buildSourceItem(item)).filter((item): item is MonitoringSourceItem => item !== null);
|
||||
for (const item of sourceItems) {
|
||||
pushToBucket(item, nextBucket);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextBucket && typeof value === "string") {
|
||||
const item = buildSourceItem(value);
|
||||
if (item) {
|
||||
pushToBucket(item, nextBucket);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value) || isRecord(value) || typeof value === "string") {
|
||||
collectSources(value, searchResults, citations, nextBucket, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectAnswerFragments(payload: unknown, result: string[] = []): string[] {
|
||||
if (payload == null) {
|
||||
return result;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
collectAnswerFragments(item, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!isRecord(payload)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const textBlockText = readNestedString(payload, ["text_block", "text"]);
|
||||
if (textBlockText) {
|
||||
result.push(textBlockText);
|
||||
}
|
||||
|
||||
const deltaText = normalizeAnswerFragment(payload.delta);
|
||||
if (deltaText) {
|
||||
result.push(deltaText);
|
||||
}
|
||||
|
||||
const nestedDeltaText = readNestedString(payload, ["delta", "text"]);
|
||||
if (nestedDeltaText) {
|
||||
result.push(nestedDeltaText);
|
||||
}
|
||||
|
||||
const assistantText =
|
||||
(normalizeText(payload.role) === "assistant" || normalizeText(payload.message_type) === "assistant") &&
|
||||
normalizeAnswerFragment(payload.text);
|
||||
if (assistantText) {
|
||||
result.push(assistantText);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "text_block" || key === "delta") {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value) || isRecord(value)) {
|
||||
collectAnswerFragments(value, result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatDoubaoStreamError(payload: unknown): string {
|
||||
const errorCode = findFirstStringByKey(payload, ["error_code", "code"]);
|
||||
const errorMessage = findFirstStringByKey(payload, ["error_msg", "message", "detail"]);
|
||||
const verifyScene = findFirstStringByKey(payload, ["verify_scene"]);
|
||||
|
||||
if (verifyScene) {
|
||||
return `doubao verification required (${verifyScene})`;
|
||||
}
|
||||
if (errorCode && errorMessage) {
|
||||
return `doubao stream error ${errorCode}: ${errorMessage}`;
|
||||
}
|
||||
if (errorCode) {
|
||||
return `doubao stream error ${errorCode}`;
|
||||
}
|
||||
if (errorMessage) {
|
||||
return `doubao stream error: ${errorMessage}`;
|
||||
}
|
||||
return "doubao stream error";
|
||||
}
|
||||
|
||||
function parseSSERecord(record: string): DoubaoStreamEvent | null {
|
||||
const lines = record.split(/\r?\n/);
|
||||
let eventName = "message";
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith(":")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.slice(6).trim() || eventName;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
payload: safeParseJSON(dataLines.join("\n")),
|
||||
};
|
||||
}
|
||||
|
||||
async function parseDoubaoStream(response: Response): Promise<DoubaoStreamSummary> {
|
||||
const summary: DoubaoStreamSummary = {
|
||||
answer: null,
|
||||
provider_request_id: null,
|
||||
request_id: null,
|
||||
conversation_id: null,
|
||||
citations: [],
|
||||
search_results: [],
|
||||
events: [],
|
||||
error_message: null,
|
||||
};
|
||||
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error("doubao response stream missing");
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = body.getReader();
|
||||
let buffer = "";
|
||||
|
||||
const consumeRecord = (rawRecord: string) => {
|
||||
const parsed = parseSSERecord(rawRecord);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
summary.events.push(parsed);
|
||||
const upperEvent = parsed.event.toUpperCase();
|
||||
if (upperEvent.includes("ERROR")) {
|
||||
summary.error_message = formatDoubaoStreamError(parsed.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!summary.conversation_id) {
|
||||
summary.conversation_id =
|
||||
readNestedString(parsed.payload, ["ack_client_meta", "conversation_id"]) ??
|
||||
readNestedString(parsed.payload, ["client_meta", "conversation_id"]) ??
|
||||
findFirstStringByKey(parsed.payload, ["conversation_id"]);
|
||||
}
|
||||
if (!summary.provider_request_id) {
|
||||
summary.provider_request_id = findFirstStringByKey(parsed.payload, ["log_id", "request_id", "req_id"]);
|
||||
}
|
||||
if (!summary.request_id) {
|
||||
summary.request_id = findFirstStringByKey(parsed.payload, ["message_id", "local_message_id", "reply_id"]);
|
||||
}
|
||||
|
||||
const fragments = collectAnswerFragments(parsed.payload);
|
||||
for (const fragment of fragments) {
|
||||
summary.answer = mergeAnswerText(summary.answer, fragment);
|
||||
}
|
||||
|
||||
collectSources(parsed.payload, summary.search_results, summary.citations);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
buffer += decoder.decode();
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
|
||||
let separatorIndex = buffer.indexOf("\n\n");
|
||||
while (separatorIndex !== -1) {
|
||||
const rawRecord = buffer.slice(0, separatorIndex).trim();
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
if (rawRecord) {
|
||||
consumeRecord(rawRecord);
|
||||
}
|
||||
separatorIndex = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
consumeRecord(tail);
|
||||
}
|
||||
|
||||
summary.citations = dedupeSourceItems(summary.citations);
|
||||
summary.search_results = dedupeSourceItems(summary.search_results);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function getDoubaoCookieValue(name: string): Promise<string | null> {
|
||||
for (const url of DOUBAO_COOKIE_URLS) {
|
||||
try {
|
||||
const cookie = await browser.cookies.get({ url, name });
|
||||
const value = normalizeText(cookie?.value);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readCurrentDoubaoRequestRuntimeState(): Promise<Partial<DoubaoRequestRuntimeState>> {
|
||||
const state = await getExtensionState();
|
||||
const runtimeState = state.monitoring_runtime.doubao;
|
||||
|
||||
return {
|
||||
fp: runtimeState?.fp ?? (await getDoubaoCookieValue(DOUBAO_FP_COOKIE_NAME)) ?? undefined,
|
||||
ms_token: runtimeState?.ms_token ?? undefined,
|
||||
device_id: runtimeState?.device_id ?? undefined,
|
||||
web_id: runtimeState?.web_id ?? undefined,
|
||||
tea_uuid: runtimeState?.tea_uuid ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getDoubaoMissingRuntimeKeys(
|
||||
runtimeState: Partial<DoubaoRequestRuntimeState>,
|
||||
): Array<keyof DoubaoRequestRuntimeState> {
|
||||
return DOUBAO_REQUIRED_RUNTIME_KEYS.filter((key) => !normalizeText(runtimeState[key]));
|
||||
}
|
||||
|
||||
function finalizeDoubaoRequestRuntimeState(
|
||||
runtimeState: Partial<DoubaoRequestRuntimeState>,
|
||||
): DoubaoRequestRuntimeState {
|
||||
const missingKeys = getDoubaoMissingRuntimeKeys(runtimeState);
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(`${DOUBAO_RUNTIME_SYNC_MESSAGE}: ${missingKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
fp: runtimeState.fp as string,
|
||||
ms_token: runtimeState.ms_token as string,
|
||||
device_id: runtimeState.device_id as string,
|
||||
web_id: runtimeState.web_id as string,
|
||||
tea_uuid: runtimeState.tea_uuid as string,
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isDoubaoTabUrl(urlValue: string | undefined): boolean {
|
||||
if (!urlValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(urlValue);
|
||||
return url.hostname === "doubao.com" || url.hostname === "www.doubao.com" || url.hostname.endsWith(".doubao.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDoubaoRuntimeSyncResponse(value: unknown): value is DoubaoRuntimeSyncResponse {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
async function hasReadyDoubaoRequestRuntimeState(): Promise<boolean> {
|
||||
const runtimeState = await readCurrentDoubaoRequestRuntimeState();
|
||||
return getDoubaoMissingRuntimeKeys(runtimeState).length === 0;
|
||||
}
|
||||
|
||||
async function waitForDoubaoRequestRuntimeState(timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
if (await hasReadyDoubaoRequestRuntimeState()) {
|
||||
return true;
|
||||
}
|
||||
await delay(DOUBAO_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return await hasReadyDoubaoRequestRuntimeState();
|
||||
}
|
||||
|
||||
async function waitForTabComplete(tabId: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const tab = await browser.tabs.get(tabId).catch(() => null);
|
||||
if (!tab) {
|
||||
return false;
|
||||
}
|
||||
if (tab.status === "complete") {
|
||||
return true;
|
||||
}
|
||||
await delay(DOUBAO_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const tab = await browser.tabs.get(tabId).catch(() => null);
|
||||
return tab?.status === "complete";
|
||||
}
|
||||
|
||||
async function requestDoubaoRuntimeSync(tabId: number): Promise<void> {
|
||||
try {
|
||||
const response = await browser.tabs.sendMessage(tabId, {
|
||||
type: "geo.monitoring.runtime.sync",
|
||||
platform: "doubao",
|
||||
});
|
||||
|
||||
if (isDoubaoRuntimeSyncResponse(response) && response.ok === false) {
|
||||
throw new Error(response.error || "monitoring_runtime_sync_failed");
|
||||
}
|
||||
} catch {
|
||||
// The content script also syncs on page load, so a missed message is safe to ignore here.
|
||||
}
|
||||
}
|
||||
|
||||
function sortDoubaoTabs(tabs: chrome.tabs.Tab[]): chrome.tabs.Tab[] {
|
||||
return [...tabs].sort((left, right) => {
|
||||
if (Boolean(left.active) !== Boolean(right.active)) {
|
||||
return left.active ? -1 : 1;
|
||||
}
|
||||
if ((left.status === "complete") !== (right.status === "complete")) {
|
||||
return left.status === "complete" ? -1 : 1;
|
||||
}
|
||||
return (left.id ?? Number.MAX_SAFE_INTEGER) - (right.id ?? Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrapDoubaoRuntimeFromTab(tabId: number, timeoutMs: number): Promise<boolean> {
|
||||
const tabReady = await waitForTabComplete(tabId, timeoutMs);
|
||||
if (!tabReady) {
|
||||
return await waitForDoubaoRequestRuntimeState(timeoutMs);
|
||||
}
|
||||
|
||||
await delay(DOUBAO_RUNTIME_BOOTSTRAP_POLL_INTERVAL_MS);
|
||||
await requestDoubaoRuntimeSync(tabId);
|
||||
return await waitForDoubaoRequestRuntimeState(timeoutMs);
|
||||
}
|
||||
|
||||
async function bootstrapDoubaoRuntimeWithExistingTabs(): Promise<boolean> {
|
||||
const tabs = await browser.tabs.query({ url: DOUBAO_TAB_URL_PATTERNS }).catch(() => []);
|
||||
|
||||
for (const tab of sortDoubaoTabs(tabs).filter((candidate) => typeof candidate.id === "number").slice(0, DOUBAO_EXISTING_TAB_PROBE_LIMIT)) {
|
||||
if (await bootstrapDoubaoRuntimeFromTab(tab.id as number, DOUBAO_EXISTING_TAB_BOOTSTRAP_TIMEOUT_MS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function bootstrapDoubaoRuntimeWithSilentTab(): Promise<boolean> {
|
||||
const page = await openSilentAutomationPage(DOUBAO_BOOTSTRAP_URL).catch(() => null);
|
||||
if (!page) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await bootstrapDoubaoRuntimeFromTab(page.tabId, DOUBAO_RUNTIME_BOOTSTRAP_TIMEOUT_MS);
|
||||
} finally {
|
||||
await closeSilentAutomationPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDoubaoRequestRuntimeStateReady(): Promise<boolean> {
|
||||
if (await hasReadyDoubaoRequestRuntimeState()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (doubaoRuntimeBootstrapInFlight) {
|
||||
return await doubaoRuntimeBootstrapInFlight;
|
||||
}
|
||||
|
||||
if (Date.now() - lastDoubaoRuntimeBootstrapAt < DOUBAO_RUNTIME_BOOTSTRAP_COOLDOWN_MS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastDoubaoRuntimeBootstrapAt = Date.now();
|
||||
doubaoRuntimeBootstrapInFlight = (async () => {
|
||||
if (await bootstrapDoubaoRuntimeWithExistingTabs()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await hasReadyDoubaoRequestRuntimeState()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await bootstrapDoubaoRuntimeWithSilentTab();
|
||||
})().finally(() => {
|
||||
doubaoRuntimeBootstrapInFlight = null;
|
||||
});
|
||||
|
||||
return await doubaoRuntimeBootstrapInFlight;
|
||||
}
|
||||
|
||||
async function loadDoubaoRequestRuntimeState(): Promise<DoubaoRequestRuntimeState> {
|
||||
const currentState = await readCurrentDoubaoRequestRuntimeState();
|
||||
try {
|
||||
return finalizeDoubaoRequestRuntimeState(currentState);
|
||||
} catch {
|
||||
const ready = await ensureDoubaoRequestRuntimeStateReady();
|
||||
if (!ready) {
|
||||
throw new Error(
|
||||
`${DOUBAO_RUNTIME_SYNC_MESSAGE}: ${getDoubaoMissingRuntimeKeys(await readCurrentDoubaoRequestRuntimeState()).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return finalizeDoubaoRequestRuntimeState(await readCurrentDoubaoRequestRuntimeState());
|
||||
}
|
||||
}
|
||||
|
||||
function buildDoubaoRequestUrl(runtimeState: DoubaoRequestRuntimeState): string {
|
||||
const url = new URL(DOUBAO_CHAT_URL);
|
||||
url.searchParams.set("aid", DOUBAO_APP_ID);
|
||||
url.searchParams.set("device_platform", "web");
|
||||
url.searchParams.set("device_id", runtimeState.device_id);
|
||||
url.searchParams.set("web_id", runtimeState.web_id);
|
||||
url.searchParams.set("tea_uuid", runtimeState.tea_uuid);
|
||||
url.searchParams.set("msToken", runtimeState.ms_token);
|
||||
url.searchParams.set("fp", runtimeState.fp);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildDoubaoRequestBody(questionText: string, runtimeState: DoubaoRequestRuntimeState): Record<string, unknown> {
|
||||
return {
|
||||
client_meta: {
|
||||
local_conversation_id: `local_${Date.now()}${Math.floor(Math.random() * 1_000_000)}`,
|
||||
conversation_id: "",
|
||||
bot_id: DOUBAO_BOT_ID,
|
||||
last_section_id: "",
|
||||
last_message_index: null,
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
local_message_id: crypto.randomUUID(),
|
||||
content_block: [
|
||||
{
|
||||
block_type: 10000,
|
||||
content: {
|
||||
text_block: {
|
||||
text: questionText,
|
||||
icon_url: "",
|
||||
icon_url_dark: "",
|
||||
summary: "",
|
||||
},
|
||||
pc_event_block: "",
|
||||
},
|
||||
block_id: crypto.randomUUID(),
|
||||
parent_id: "",
|
||||
meta_info: [],
|
||||
append_fields: [],
|
||||
},
|
||||
],
|
||||
message_status: 0,
|
||||
},
|
||||
],
|
||||
option: {
|
||||
need_deep_think: 1,
|
||||
need_create_conversation: true,
|
||||
conversation_init_option: {
|
||||
need_ack_conversation: true,
|
||||
},
|
||||
sse_recv_event_options: {
|
||||
support_chunk_delta: true,
|
||||
},
|
||||
},
|
||||
ext: {
|
||||
use_deep_think: "1",
|
||||
fp: runtimeState.fp,
|
||||
conversation_init_option: JSON.stringify({
|
||||
need_ack_conversation: true,
|
||||
}),
|
||||
commerce_credit_config_enable: "0",
|
||||
sub_conv_firstmet_type: "1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function queryDoubao(platform: StoredMonitoringPlatformState, questionText: string): Promise<MonitoringAdapterQueryResult> {
|
||||
const runtimeState = await loadDoubaoRequestRuntimeState();
|
||||
const requestBody = buildDoubaoRequestBody(questionText, runtimeState);
|
||||
const response = await fetch(buildDoubaoRequestUrl(runtimeState), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
referrer: DOUBAO_REFERER,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => "");
|
||||
const detail = normalizeText(bodyText)?.slice(0, 200);
|
||||
throw new Error(detail ? `doubao request failed ${response.status}: ${detail}` : `doubao request failed ${response.status}`);
|
||||
}
|
||||
|
||||
const streamSummary = await parseDoubaoStream(response);
|
||||
if (streamSummary.error_message) {
|
||||
throw new Error(streamSummary.error_message);
|
||||
}
|
||||
if (!streamSummary.answer && streamSummary.citations.length === 0 && streamSummary.search_results.length === 0) {
|
||||
throw new Error("doubao empty response");
|
||||
}
|
||||
|
||||
return {
|
||||
provider_model: `${platform.ai_platform_id}-web-thinking`,
|
||||
provider_request_id: streamSummary.provider_request_id,
|
||||
request_id: streamSummary.request_id ?? streamSummary.conversation_id,
|
||||
answer: streamSummary.answer,
|
||||
raw_response_json: {
|
||||
platform: platform.ai_platform_id,
|
||||
mode: "deep_think",
|
||||
conversation_id: streamSummary.conversation_id,
|
||||
provider_request_id: streamSummary.provider_request_id,
|
||||
request_id: streamSummary.request_id,
|
||||
answer: streamSummary.answer,
|
||||
citation_count: streamSummary.citations.length,
|
||||
thought_reference_count: streamSummary.search_results.length,
|
||||
reference_count: streamSummary.search_results.length,
|
||||
search_result_count: streamSummary.search_results.length,
|
||||
event_count: streamSummary.events.length,
|
||||
citations: streamSummary.citations,
|
||||
thought_references: streamSummary.search_results,
|
||||
thinking_references: streamSummary.search_results,
|
||||
references: streamSummary.search_results,
|
||||
search_results: streamSummary.search_results,
|
||||
sources: streamSummary.search_results,
|
||||
events: streamSummary.events,
|
||||
status: "succeeded",
|
||||
},
|
||||
citations: streamSummary.citations,
|
||||
search_results: streamSummary.search_results,
|
||||
};
|
||||
}
|
||||
|
||||
export const doubaoMonitoringAdapter: MonitoringAdapter = {
|
||||
async detect(platform) {
|
||||
const connected = await hasAnyPlatformCookie(platform);
|
||||
const reachable = await isPlatformReachable(platform);
|
||||
|
||||
if (!reachable) {
|
||||
return normalizeDetectResult(connected, false, "unavailable", "平台当前不可访问");
|
||||
}
|
||||
|
||||
let runtimeState = await readCurrentDoubaoRequestRuntimeState();
|
||||
if (getDoubaoMissingRuntimeKeys(runtimeState).length > 0) {
|
||||
await ensureDoubaoRequestRuntimeStateReady();
|
||||
runtimeState = await readCurrentDoubaoRequestRuntimeState();
|
||||
}
|
||||
|
||||
if (getDoubaoMissingRuntimeKeys(runtimeState).length > 0) {
|
||||
return {
|
||||
...normalizeDetectResult(connected, true, "runtime_sync_pending", DOUBAO_RUNTIME_SYNC_HINT),
|
||||
message: DOUBAO_RUNTIME_SYNC_HINT,
|
||||
};
|
||||
}
|
||||
|
||||
return normalizeDetectResult(connected, true, null, null);
|
||||
},
|
||||
async canQuery() {
|
||||
try {
|
||||
await loadDoubaoRequestRuntimeState();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async query(platform, task) {
|
||||
return await queryDoubao(platform, task.question_text);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { StoredMonitoringPlatformState } from "../monitoring-platforms";
|
||||
|
||||
import { hasAnyPlatformCookie, isPlatformReachable, normalizeDetectResult } from "./common";
|
||||
import { doubaoMonitoringAdapter } from "./doubao";
|
||||
import { qwenMonitoringAdapter } from "./qwen";
|
||||
import type { MonitoringAdapter, MonitoringAdapterTaskContext } from "./types";
|
||||
|
||||
function createDefaultMonitoringAdapter(): MonitoringAdapter {
|
||||
return {
|
||||
async detect(platform) {
|
||||
const connected = await hasAnyPlatformCookie(platform);
|
||||
const reachable = await isPlatformReachable(platform);
|
||||
|
||||
if (platform.access_requirement === "login_required") {
|
||||
if (!connected) {
|
||||
return normalizeDetectResult(false, false, "not_logged_in", "当前平台需要登录后才可采集");
|
||||
}
|
||||
if (!reachable) {
|
||||
return normalizeDetectResult(true, false, "unavailable", "平台当前不可访问");
|
||||
}
|
||||
return normalizeDetectResult(true, true, null, null);
|
||||
}
|
||||
|
||||
if (reachable || connected) {
|
||||
return normalizeDetectResult(connected, true, null, null);
|
||||
}
|
||||
|
||||
return normalizeDetectResult(false, false, "unavailable", "平台当前不可访问");
|
||||
},
|
||||
canQuery() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultAdapter = createDefaultMonitoringAdapter();
|
||||
|
||||
const monitoringAdapters: Record<string, MonitoringAdapter> = {
|
||||
deepseek: defaultAdapter,
|
||||
qwen: qwenMonitoringAdapter,
|
||||
doubao: doubaoMonitoringAdapter,
|
||||
};
|
||||
|
||||
export async function detectMonitoringPlatformState(
|
||||
platform: StoredMonitoringPlatformState,
|
||||
): Promise<StoredMonitoringPlatformState> {
|
||||
const adapter = monitoringAdapters[platform.ai_platform_id] ?? defaultAdapter;
|
||||
const detected = await adapter.detect(platform);
|
||||
return {
|
||||
...platform,
|
||||
...detected,
|
||||
};
|
||||
}
|
||||
|
||||
export async function canQueryMonitoringPlatform(platform: StoredMonitoringPlatformState): Promise<boolean> {
|
||||
const adapter = monitoringAdapters[platform.ai_platform_id];
|
||||
if (!adapter?.canQuery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await adapter.canQuery(platform);
|
||||
}
|
||||
|
||||
export async function queryMonitoringTask(
|
||||
platform: StoredMonitoringPlatformState,
|
||||
task: MonitoringAdapterTaskContext,
|
||||
) {
|
||||
const adapter = monitoringAdapters[platform.ai_platform_id];
|
||||
if (!adapter?.query) {
|
||||
throw new Error(`monitoring_adapter_query_not_implemented:${platform.ai_platform_id}`);
|
||||
}
|
||||
|
||||
return await adapter.query(platform, task);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import type { MonitoringSourceItem } from "@geo/shared-types";
|
||||
import { browser } from "wxt/browser";
|
||||
|
||||
import type { StoredMonitoringPlatformState } from "../monitoring-platforms";
|
||||
|
||||
import {
|
||||
closeSilentAutomationPage,
|
||||
hasAnyPlatformCookie,
|
||||
isPlatformReachable,
|
||||
normalizeDetectResult,
|
||||
openSilentAutomationPage,
|
||||
} from "./common";
|
||||
import type { MonitoringAdapter, MonitoringAdapterQueryResult } from "./types";
|
||||
|
||||
const QWEN_BOOTSTRAP_URL = "https://www.qianwen.com/";
|
||||
const QWEN_PAGE_READY_TIMEOUT_MS = 20_000;
|
||||
const QWEN_QUERY_TIMEOUT_MS = 90_000;
|
||||
const QWEN_TAB_POLL_INTERVAL_MS = 500;
|
||||
|
||||
type QwenPageQuestionSnapshot = {
|
||||
model?: string | null;
|
||||
deepSearch?: string | null;
|
||||
enableSearch?: boolean | null;
|
||||
cardText?: string | null;
|
||||
};
|
||||
|
||||
type QwenPageAnswerSnapshot = {
|
||||
reqId?: string | null;
|
||||
status?: string | null;
|
||||
content?: Record<string, unknown> | null;
|
||||
extraInfo?: Record<string, unknown> | null;
|
||||
communication?: Record<string, unknown> | 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 normalizeText(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
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 normalizeText(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<string, MonitoringSourceItem>();
|
||||
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: QwenPageQueryFailureResult | QwenPageQuerySuccessResult): 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 hasQwenScriptingApi(): boolean {
|
||||
return typeof chrome !== "undefined" && Boolean(chrome.scripting?.executeScript);
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForTabComplete(tabId: number, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const tab = await browser.tabs.get(tabId).catch(() => null);
|
||||
if (!tab) {
|
||||
return false;
|
||||
}
|
||||
if (tab.status === "complete") {
|
||||
return true;
|
||||
}
|
||||
await delay(QWEN_TAB_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const tab = await browser.tabs.get(tabId).catch(() => null);
|
||||
return tab?.status === "complete";
|
||||
}
|
||||
|
||||
async function runQwenMainWorldScript<TArgs extends unknown[], TResult>(
|
||||
tabId: number,
|
||||
func: (...args: TArgs) => TResult | Promise<TResult>,
|
||||
args: TArgs,
|
||||
): Promise<TResult> {
|
||||
if (!hasQwenScriptingApi()) {
|
||||
throw new Error("qwen scripting api unavailable");
|
||||
}
|
||||
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
world: "MAIN",
|
||||
func,
|
||||
args,
|
||||
});
|
||||
|
||||
return (results[0]?.result ?? null) as TResult;
|
||||
}
|
||||
|
||||
function qwenQueryInPage(questionText: string, options?: { timeoutMs?: number; deepThink?: boolean }): Promise<QwenPageQueryResult> {
|
||||
const queryTimeoutMs = typeof options?.timeoutMs === "number" ? options.timeoutMs : 90_000;
|
||||
const deepThinkEnabled = options?.deepThink !== false;
|
||||
|
||||
const wait = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
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<string, unknown> | null => {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const locateManagers = () => {
|
||||
const bindingMap = (window as typeof window & { __TONGYI_PORTSL_GLOBAL_CONTAINER__?: { container?: { _bindingDictionary?: { _map?: Map<unknown, unknown[]> } } } })
|
||||
.__TONGYI_PORTSL_GLOBAL_CONTAINER__?.container?._bindingDictionary?._map;
|
||||
|
||||
if (!(bindingMap instanceof Map)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let textAreaManager: Record<string, unknown> | null = null;
|
||||
let chatManager: Record<string, unknown> | 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 protoKeys = Object.getOwnPropertyNames(Object.getPrototypeOf(candidate) ?? {});
|
||||
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<string, unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).__qianwenChatAPI?.sharedValues?.chatAPI?.state;
|
||||
|
||||
if (!textAreaManager || !chatManager || !isObjectRecord(state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
textAreaManager,
|
||||
chatManager,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
const waitForReadyManagers = async (timeoutMs: number) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).__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<QwenPageQueryResult> => {
|
||||
const managers = await waitForReadyManagers(20_000);
|
||||
if (!managers) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "qwen_page_not_ready",
|
||||
url: window.location.href,
|
||||
};
|
||||
}
|
||||
|
||||
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) ?? "input_state_invalid",
|
||||
url: window.location.href,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof chatManager.textAreaSubmit !== "function") {
|
||||
return {
|
||||
ok: false,
|
||||
error: "qwen_submit_method_missing",
|
||||
url: window.location.href,
|
||||
};
|
||||
}
|
||||
|
||||
await chatManager.textAreaSubmit();
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "qwen_submit_failed",
|
||||
detail: error instanceof Error ? error.message : String(error ?? ""),
|
||||
url: window.location.href,
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
url: window.location.href,
|
||||
question: timeoutSnapshot.question,
|
||||
answer: timeoutSnapshot.answer,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
async function queryQwen(platform: StoredMonitoringPlatformState, questionText: string): Promise<MonitoringAdapterQueryResult> {
|
||||
const page = await openSilentAutomationPage(QWEN_BOOTSTRAP_URL);
|
||||
|
||||
try {
|
||||
await waitForTabComplete(page.tabId, QWEN_PAGE_READY_TIMEOUT_MS);
|
||||
const pageResult = await runQwenMainWorldScript(page.tabId, qwenQueryInPage, [
|
||||
questionText,
|
||||
{
|
||||
deepThink: true,
|
||||
timeoutMs: QWEN_QUERY_TIMEOUT_MS,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!pageResult?.ok || !pageResult.answer) {
|
||||
throw new Error(formatQwenQueryError(pageResult));
|
||||
}
|
||||
|
||||
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 === 0) {
|
||||
throw new Error("qwen empty response");
|
||||
}
|
||||
|
||||
return {
|
||||
provider_model: providerModel,
|
||||
provider_request_id: requestId,
|
||||
request_id: requestId ?? sessionId,
|
||||
answer: answerText,
|
||||
raw_response_json: {
|
||||
platform: platform.ai_platform_id,
|
||||
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: pageResult.question ?? null,
|
||||
source_count: dedupedSearchResults.length,
|
||||
search_results: dedupedSearchResults,
|
||||
content: pageResult.answer.content ?? null,
|
||||
extra_info: pageResult.answer.extraInfo ?? null,
|
||||
communication: pageResult.answer.communication ?? null,
|
||||
status: "succeeded",
|
||||
},
|
||||
citations: [],
|
||||
search_results: dedupedSearchResults,
|
||||
};
|
||||
} finally {
|
||||
await closeSilentAutomationPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export const qwenMonitoringAdapter: MonitoringAdapter = {
|
||||
async detect(platform) {
|
||||
const connected = await hasAnyPlatformCookie(platform);
|
||||
|
||||
if (!hasQwenScriptingApi()) {
|
||||
return normalizeDetectResult(connected, false, "unsupported", "当前浏览器不支持千问静默采集");
|
||||
}
|
||||
|
||||
const reachable = await isPlatformReachable(platform);
|
||||
if (!reachable) {
|
||||
return normalizeDetectResult(connected, false, "unavailable", "平台当前不可访问");
|
||||
}
|
||||
|
||||
return normalizeDetectResult(connected, true, null, null);
|
||||
},
|
||||
async canQuery(platform) {
|
||||
if (!hasQwenScriptingApi()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await isPlatformReachable(platform);
|
||||
},
|
||||
async query(platform, task) {
|
||||
return await queryQwen(platform, task.question_text);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MonitoringSourceItem } from "@geo/shared-types";
|
||||
|
||||
import type { StoredMonitoringPlatformState } from "../monitoring-platforms";
|
||||
|
||||
export interface MonitoringAdapterTaskContext {
|
||||
task_id: number;
|
||||
brand_id: number;
|
||||
question_id: number;
|
||||
question_hash: string;
|
||||
question_text: string;
|
||||
ai_platform_id: string;
|
||||
platform_name: string;
|
||||
collector_type: string;
|
||||
run_mode: string;
|
||||
trigger_source: string;
|
||||
business_date: string;
|
||||
lease_token: string;
|
||||
lease_expires_at: string;
|
||||
}
|
||||
|
||||
export interface MonitoringAdapterDetectResult {
|
||||
connected: boolean;
|
||||
accessible: boolean;
|
||||
detected_at: string;
|
||||
reason_code?: string | null;
|
||||
reason_message?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface MonitoringAdapterQueryResult {
|
||||
provider_model?: string | null;
|
||||
provider_request_id?: string | null;
|
||||
request_id?: string | null;
|
||||
answer?: string | null;
|
||||
raw_response_json?: Record<string, unknown>;
|
||||
citations?: MonitoringSourceItem[];
|
||||
search_results?: MonitoringSourceItem[];
|
||||
brand_mentioned?: boolean | null;
|
||||
brand_mention_position?: string | null;
|
||||
first_recommended?: boolean | null;
|
||||
sentiment_label?: string | null;
|
||||
matched_brand_terms?: string[];
|
||||
}
|
||||
|
||||
export interface MonitoringAdapter {
|
||||
detect: (platform: StoredMonitoringPlatformState) => Promise<MonitoringAdapterDetectResult>;
|
||||
canQuery?: (platform: StoredMonitoringPlatformState) => Promise<boolean> | boolean;
|
||||
query?: (
|
||||
platform: StoredMonitoringPlatformState,
|
||||
task: MonitoringAdapterTaskContext,
|
||||
) => Promise<MonitoringAdapterQueryResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user