6066f43a7d
- 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.
599 lines
17 KiB
TypeScript
599 lines
17 KiB
TypeScript
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);
|
|
},
|
|
};
|