Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

462 lines
18 KiB
JavaScript

import { htmlToMarkdown } from '@jackwener/opencli/utils';
import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
export const QIANWEN_DOMAIN = 'www.qianwen.com';
export const QIANWEN_URL = 'https://www.qianwen.com/';
export const QIANWEN_API_DOMAIN = 'chat2-api.qianwen.com';
export const IS_VISIBLE_JS = `
const isVisible = (node) => {
if (!(node instanceof Element)) return false;
const style = window.getComputedStyle(node);
if (style.visibility === 'hidden' || style.display === 'none') return false;
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
`;
const POLL_INTERVAL_SECONDS = 2;
const MIN_WAIT_MS = 6_000;
const STABLE_POLLS_REQUIRED = 2;
export function authRequired(detail) {
return new AuthRequiredError(QIANWEN_DOMAIN, detail || '请在浏览器里用千问 APP 扫码登录 qianwen.com 后再重试。');
}
export function normalizeBooleanFlag(value, fallback = false) {
if (typeof value === 'boolean') return value;
if (value == null || value === '') return fallback;
const normalized = String(value).trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
export async function isOnQianwen(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
return typeof url === 'string' && url.includes('qianwen.com');
}
export async function ensureOnQianwen(page) {
if (await isOnQianwen(page)) return;
await page.goto(QIANWEN_URL);
await page.wait(2);
}
export async function dismissLoginModal(page) {
return await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const modal = document.querySelector('[role=alert-biz-modal]');
if (!modal || !isVisible(modal)) return { dismissed: false };
const close = modal.querySelector('[data-opencli-ref]:last-of-type')
|| modal.querySelector('svg')?.closest('[role=button], button, div[class*="close"]');
const closeCandidates = Array.from(modal.querySelectorAll('div, button, span'))
.filter((node) => node instanceof HTMLElement && isVisible(node))
.filter((node) => {
const cls = node.className || '';
if (typeof cls !== 'string') return false;
return /close|dismiss|cancel/i.test(cls) || node.getAttribute('aria-label') === '关闭';
});
const target = closeCandidates[0] || modal.querySelector('svg')?.parentElement;
if (target instanceof HTMLElement) {
target.click();
return { dismissed: true };
}
// Last resort: synth ESC key on document
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }));
return { dismissed: true, method: 'escape' };
})()`);
}
export async function hasLoginGate(page) {
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const modal = document.querySelector('[role=alert-biz-modal]');
if (modal && isVisible(modal)) {
const iframe = modal.querySelector('iframe');
const src = iframe?.getAttribute('src') || '';
if (src.includes('passport.qianwen.com') || src.includes('login')) return true;
}
return false;
})()`);
return Boolean(result);
}
export async function isLoggedIn(page) {
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const loginBtn = Array.from(document.querySelectorAll('button'))
.find((node) => (node.textContent || '').trim() === '登录' && isVisible(node));
if (loginBtn) return false;
const hint = Array.from(document.querySelectorAll('p'))
.find((node) => (node.textContent || '').includes('登录可同步历史对话'));
if (hint && isVisible(hint)) return false;
return true;
})()`);
return Boolean(result);
}
export async function getCurrentSessionId(page) {
const url = await page.evaluate('window.location.href').catch(() => '');
if (typeof url !== 'string') return '';
const match = url.match(/\/chat\/([A-Za-z0-9_-]+)/);
return match ? match[1] : '';
}
const QIANWEN_SESSION_ID_RE = /^[a-f0-9]{32}$/i;
export function parseQianwenSessionId(input) {
const raw = String(input ?? '').trim();
if (!raw) {
throw new ArgumentError('id', 'must be a non-empty session ID or qianwen.com chat URL');
}
// Anchor the right-hand side so a 33+ hex URL does not silently truncate
// to its first 32 chars. Acceptable terminators: end-of-string, path slash,
// query string, or fragment. Without the boundary,
// `https://www.qianwen.com/chat/<33 hex>` would parse as a valid 32-char
// ID instead of being rejected — opening the wrong conversation is a
// worse failure mode than throwing.
const urlMatch = raw.match(/qianwen\.com\/chat\/([a-f0-9]{32})(?:[/?#]|$)/i);
const candidate = urlMatch ? urlMatch[1] : raw;
if (!QIANWEN_SESSION_ID_RE.test(candidate)) {
throw new ArgumentError(
'id',
`not a valid Qianwen session ID (got "${input}"); expected a 32-char hex ID like "abcd1234ef567890abcd1234ef567890" or a full https://www.qianwen.com/chat/<id> URL`,
);
}
return candidate.toLowerCase();
}
export async function getModelLabel(page) {
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const trigger = Array.from(document.querySelectorAll('[aria-haspopup=dialog]'))
.find((node) => isVisible(node) && (node.innerText || '').includes('Qwen'));
if (!trigger) return '';
const label = (trigger.innerText || '').split('\\n')[0].trim();
return label;
})()`);
return typeof result === 'string' ? result : '';
}
export async function startNewChat(page) {
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const button = Array.from(document.querySelectorAll('button'))
.find((node) => isVisible(node) && (node.innerText || '').trim() === '新建对话');
if (button instanceof HTMLElement) {
button.click();
return { ok: true, method: 'button' };
}
return { ok: false };
})()`);
if (result?.ok) {
await page.wait(1.5);
return true;
}
await page.goto(QIANWEN_URL);
await page.wait(2);
return true;
}
export async function getComposer(page) {
return await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const editor = Array.from(document.querySelectorAll('[role=textbox][contenteditable=true]'))
.find((node) => isVisible(node));
return { found: !!editor, text: editor?.textContent || '' };
})()`);
}
export async function sendMessage(page, prompt) {
return await page.evaluate(`(async () => {
${IS_VISIBLE_JS}
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const editor = Array.from(document.querySelectorAll('[role=textbox][contenteditable=true]'))
.find((node) => isVisible(node));
if (!(editor instanceof HTMLElement)) {
return { ok: false, reason: 'Qianwen composer (contenteditable) not found.' };
}
editor.focus();
// Clear existing content first
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editor);
selection?.removeAllRanges();
selection?.addRange(range);
document.execCommand('delete', false);
await waitFor(100);
// Slate editor accepts content via beforeinput InputEvent
editor.dispatchEvent(new InputEvent('beforeinput', {
inputType: 'insertText',
data: ${JSON.stringify(prompt)},
bubbles: true,
cancelable: true,
}));
await waitFor(400);
const sendBtn = document.querySelector('button[aria-label="发送消息"]');
if (sendBtn instanceof HTMLElement && !sendBtn.disabled) {
sendBtn.click();
return { ok: true, action: 'click' };
}
// Fallback: dispatch Enter key
editor.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
editor.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
return { ok: true, action: 'enter-fallback' };
})()`);
}
export async function getMessageBubbles(page) {
// Qianwen's chat DOM marks each turn with two siblings:
// [data-chat-question-wrap] - user message
// [data-chat-answers-wrap] - assistant message
// The earlier `[data-msgid]` selector matched citation cards inside
// assistant responses (which use `data-message-id`), so it would silently
// miss the actual chat turns after Qianwen reshipped its frontend.
//
// We walk both wrap selectors in DOM order to interleave Q/A correctly,
// and use the nearest sibling `data-req-id` (anchored on
// `.chat-msg-bottom-anchor`) as the stable per-turn identifier so polling
// dedupe + waitForAnswer's seenAssistantId tracking still work.
const result = await page.evaluate(`(() => {
${IS_VISIBLE_JS}
const wraps = Array.from(document.querySelectorAll('[data-chat-question-wrap], [data-chat-answers-wrap]'))
.filter((node) => node instanceof HTMLElement && isVisible(node));
const findTurnReqId = (node) => {
let parent = node.parentElement;
while (parent && parent !== document.body) {
const reqEl = parent.querySelector('[data-req-id]');
if (reqEl && reqEl.getAttribute('data-req-id')) {
return reqEl.getAttribute('data-req-id');
}
parent = parent.parentElement;
}
return '';
};
const out = [];
let positional = 0;
for (const node of wraps) {
const isAnswer = node.hasAttribute('data-chat-answers-wrap');
const reqId = findTurnReqId(node);
const baseId = reqId || ('pos-' + positional);
const id = baseId + (isAnswer ? '-answer' : '-question');
positional += 1;
const contentNode = isAnswer
? (node.querySelector('#qk-markdown-react') || node.querySelector('[class*="markdown"]') || node)
: node;
const html = (contentNode instanceof HTMLElement) ? (contentNode.innerHTML || '') : '';
const text = (contentNode instanceof HTMLElement) ? (contentNode.innerText || contentNode.textContent || '') : '';
const role = isAnswer ? 'Assistant' : 'User';
out.push({ id, role, text: (text || '').replace(/\\s+/g, ' ').trim(), html });
}
return out;
})()`);
if (!Array.isArray(result)) return [];
return result
.map((item) => ({
id: String(item?.id || ''),
role: item?.role === 'Assistant' ? 'Assistant' : 'User',
text: String(item?.text || '').trim(),
html: String(item?.html || ''),
}))
.filter((item) => item.id && item.text);
}
export function bubbleHtmlToMarkdown(html) {
try {
return htmlToMarkdown(html).trim();
} catch {
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
}
function stripNoise(text) {
return (text || '')
.replace(/\u00a0/g, ' ')
.replace(/复制\s*$/g, '')
.replace(/重新生成\s*$/g, '')
.trim();
}
function normalizePromptText(text) {
return stripNoise(text).replace(/\s+/g, ' ').trim();
}
function normalizeWaitAnchor(anchor) {
if (typeof anchor === 'string') {
return { lastBubbleId: '', lastAssistantId: anchor };
}
return {
lastBubbleId: String(anchor?.lastBubbleId || ''),
lastAssistantId: String(anchor?.lastAssistantId || ''),
};
}
function findAssistantAfterPrompt(bubbles, prompt, anchor) {
const normalizedPrompt = normalizePromptText(prompt);
let startIndex = -1;
const hasAnchor = Boolean(anchor.lastBubbleId || anchor.lastAssistantId);
let foundAnchor = false;
if (anchor.lastBubbleId) {
const lastBubbleIndex = bubbles.findIndex((bubble) => bubble.id === anchor.lastBubbleId);
if (lastBubbleIndex >= 0) {
startIndex = lastBubbleIndex;
foundAnchor = true;
}
}
if (anchor.lastAssistantId) {
const lastAssistantIndex = bubbles.findIndex((bubble) => bubble.id === anchor.lastAssistantId);
if (lastAssistantIndex >= 0) {
startIndex = Math.max(startIndex, lastAssistantIndex);
foundAnchor = true;
}
}
if (hasAnchor && !foundAnchor) return null;
let promptIndex = -1;
for (let i = startIndex + 1; i < bubbles.length; i += 1) {
if (bubbles[i].role === 'User' && normalizePromptText(bubbles[i].text) === normalizedPrompt) {
promptIndex = i;
}
}
if (promptIndex < 0) return null;
for (let i = promptIndex + 1; i < bubbles.length; i += 1) {
if (bubbles[i].role === 'Assistant') return bubbles[i];
}
return null;
}
export async function waitForAnswer(page, prompt, timeoutSeconds, baselineAnchor) {
const anchor = normalizeWaitAnchor(baselineAnchor);
const startTime = Date.now();
let previousText = '';
let stableCount = 0;
let lastCandidate = '';
let lastAssistantCandidate = null;
while (Date.now() - startTime < timeoutSeconds * 1000) {
await page.wait(POLL_INTERVAL_SECONDS);
if (await hasLoginGate(page)) {
return { status: 'auth_required' };
}
const bubbles = await getMessageBubbles(page);
const lastAssistant = findAssistantAfterPrompt(bubbles, prompt, anchor);
if (!lastAssistant) continue;
// Guard against stale assistant turns that existed before our send even
// if the DOM briefly reorders or rehydrates while the new turn loads.
if (anchor.lastAssistantId && lastAssistant.id === anchor.lastAssistantId) continue;
const text = stripNoise(lastAssistant.text);
if (!text || text === prompt) continue;
lastCandidate = text;
lastAssistantCandidate = lastAssistant;
const waitedLongEnough = Date.now() - startTime >= MIN_WAIT_MS;
if (text === previousText) {
stableCount += 1;
if (waitedLongEnough && stableCount >= STABLE_POLLS_REQUIRED) {
return { status: 'ok', assistant: lastAssistant };
}
} else {
previousText = text;
stableCount = 0;
}
}
if (lastCandidate) {
return { status: 'partial', assistant: lastAssistantCandidate };
}
return { status: 'timeout' };
}
const FEATURE_LABELS = {
think: '深度思考',
research: '深度研究',
task: '任务助理',
image: 'AI生图',
ppt: 'PPT创作',
};
export async function setFeatureToggle(page, feature, enabled) {
const label = FEATURE_LABELS[feature];
if (!label) return false;
const result = await page.evaluate(`(async () => {
${IS_VISIBLE_JS}
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const label = ${JSON.stringify(label)};
const button = Array.from(document.querySelectorAll('button[aria-label]'))
.find((node) => isVisible(node) && node.getAttribute('aria-label') === label);
if (!(button instanceof HTMLElement)) return { found: false };
const selected = button.getAttribute('aria-pressed') === 'true'
|| /active|selected|bg-primary/.test(button.className || '');
if (selected === ${Boolean(enabled)}) return { found: true, changed: false, selected };
button.click();
await waitFor(300);
return { found: true, changed: true };
})()`);
return Boolean(result?.found);
}
export async function getSessionListFromApi(page, limit = 30) {
const pageSize = Number(limit ?? 30);
if (!Number.isInteger(pageSize) || pageSize <= 0 || pageSize > 100) {
throw new CommandExecutionError('Qianwen history page_size must be an integer between 1 and 100');
}
const result = await page.evaluate(`(async () => {
try {
const utdid = (document.cookie.match(/(?:^|;\\s*)b-user-id=([^;]+)/)?.[1])
|| (document.cookie.match(/(?:^|;\\s*)utdid=([^;]+)/)?.[1])
|| '';
const query = new URLSearchParams({
biz_id: 'ai_qwen',
chat_client: 'h5',
device: 'pc',
fr: 'pc',
pr: 'qwen',
ut: utdid,
la: 'zh-CN',
tz: 'Asia/Shanghai',
ve: '2.4.9',
}).toString();
const res = await fetch('https://${QIANWEN_API_DOMAIN}/api/v2/session/page/list?' + query, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_num: 1, page_size: ${pageSize}, page_no: 1 }),
});
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
return { ok: res.ok, status: res.status, body, utdid };
} catch (error) {
return { ok: false, status: 0, error: String(error?.message || error) };
}
})()`);
if (!result || !result.ok) {
return { ok: false, status: result?.status || 0, error: result?.error || '', sessions: [] };
}
const data = result.body?.data || result.body?.result || {};
const rawList = Array.isArray(data?.list) ? data.list
: Array.isArray(data?.items) ? data.items
: Array.isArray(data?.page_list) ? data.page_list
: Array.isArray(result.body?.list) ? result.body.list
: [];
const sessions = rawList.map((item) => ({
id: String(item?.session_id || item?.sessionId || item?.id || ''),
title: String(item?.title || item?.name || item?.summary || '').trim(),
updated_at: Number(item?.updated_at || item?.last_req_timestamp || item?.updatedAt || item?.gmt_modified || item?.update_time || 0),
})).filter((item) => item.id);
return { ok: true, status: result.status, sessions };
}