Files
wehub-resource-sync 9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

180 lines
7.1 KiB
JavaScript

import * as os from 'node:os';
import * as path from 'node:path';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { saveBase64ToFile } from '@jackwener/opencli/utils';
import { ArgumentError, CommandExecutionError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors';
import {
QIANWEN_DOMAIN,
authRequired,
dismissLoginModal,
ensureOnQianwen,
getMessageBubbles,
hasLoginGate,
normalizeBooleanFlag,
sendMessage,
setFeatureToggle,
startNewChat,
} from './utils.js';
function displayPath(filePath) {
const home = os.homedir();
return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
}
function extFromMime(mime) {
if (!mime) return '.jpg';
if (mime.includes('png')) return '.png';
if (mime.includes('webp')) return '.webp';
if (mime.includes('gif')) return '.gif';
return '.jpg';
}
async function collectImageUrls(page, sinceAssistantId) {
return await page.evaluate(`(() => {
const scope = ${JSON.stringify(sinceAssistantId || '')};
const bubbles = Array.from(document.querySelectorAll('[data-msgid$="-answer"]'));
const target = scope
? bubbles.find((b) => b.getAttribute('data-msgid') === scope)
: bubbles[bubbles.length - 1];
if (!target) return [];
const imgs = Array.from(target.querySelectorAll('img'))
.map((node) => node.getAttribute('src') || '')
.filter((src) => src
&& !src.startsWith('data:')
&& !/\\.(svg)$/i.test(src)
&& !src.includes('alicdn.com/imgextra'));
return Array.from(new Set(imgs));
})()`);
}
async function waitForImageUrls(page, sinceAssistantId, timeoutSeconds) {
const startTime = Date.now();
let lastUrls = [];
while (Date.now() - startTime < timeoutSeconds * 1000) {
await page.wait(2);
if (await hasLoginGate(page)) return { status: 'auth_required', urls: [] };
const urls = await collectImageUrls(page, sinceAssistantId);
if (urls.length && urls.length === lastUrls.length && urls.every((u, i) => u === lastUrls[i])) {
return { status: 'ok', urls };
}
if (urls.length) {
await page.wait(2);
const urls2 = await collectImageUrls(page, sinceAssistantId);
if (urls2.length === urls.length && urls2.every((u, i) => u === urls[i])) {
return { status: 'ok', urls: urls2 };
}
lastUrls = urls2;
continue;
}
lastUrls = urls;
}
return lastUrls.length ? { status: 'partial', urls: lastUrls } : { status: 'timeout', urls: [] };
}
async function fetchImageAsset(page, url) {
return await page.evaluate(`(async () => {
try {
const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
if (!res.ok) return { ok: false, status: res.status };
const mime = res.headers.get('content-type') || '';
const buf = await res.arrayBuffer();
const bytes = new Uint8Array(buf);
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return { ok: true, mime, base64: btoa(binary) };
} catch (error) {
return { ok: false, status: 0, error: String(error?.message || error) };
}
})()`);
}
cli({
site: 'qwen',
name: 'image',
access: 'write',
description: 'Generate images with Qianwen (AI生图) and save them locally',
domain: QIANWEN_DOMAIN,
strategy: Strategy.COOKIE,
browser: true,
siteSession: 'persistent',
navigateBefore: false,
defaultFormat: 'plain',
args: [
{ name: 'prompt', required: true, positional: true, help: 'Image prompt to send' },
{ name: 'op', default: '~/Pictures/qianwen', help: 'Output directory' },
{ name: 'new', type: 'boolean', default: true, help: 'Start a new chat before generating (default: true)' },
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download; only show the Qianwen link' },
{ name: 'timeout', type: 'int', default: 180, help: 'Max seconds to wait for the image response' },
],
columns: ['Status', 'File', 'Link'],
func: async (page, kwargs) => {
const prompt = String(kwargs.prompt || '').trim();
if (!prompt) throw new ArgumentError('prompt is required');
const outputDir = String(kwargs.op || '~/Pictures/qianwen').replace(/^~\//, `${os.homedir()}/`);
const startFresh = normalizeBooleanFlag(kwargs.new, true);
const skipDownload = normalizeBooleanFlag(kwargs.sd, false);
const timeout = Number(kwargs.timeout ?? 180);
if (!Number.isInteger(timeout) || timeout <= 0) {
throw new ArgumentError('timeout must be a positive integer');
}
await ensureOnQianwen(page);
await dismissLoginModal(page);
if (startFresh) {
await startNewChat(page);
await dismissLoginModal(page);
}
await setFeatureToggle(page, 'image', true);
await page.wait(0.5);
const send = await sendMessage(page, prompt);
if (!send?.ok) {
if (await hasLoginGate(page)) throw authRequired();
throw new CommandExecutionError(send?.reason || 'Failed to send Qianwen image prompt');
}
// Grab the newest assistant bubble id after send by polling briefly
let targetId = '';
for (let i = 0; i < 5; i += 1) {
await page.wait(1);
const bubbles = await getMessageBubbles(page);
const lastAnswer = [...bubbles].reverse().find((b) => b.role === 'Assistant');
if (lastAnswer) { targetId = lastAnswer.id; break; }
}
const waitResult = await waitForImageUrls(page, targetId, timeout);
const link = await page.evaluate('window.location.href').catch(() => 'https://www.qianwen.com/');
if (waitResult.status === 'auth_required') throw authRequired();
if (waitResult.status === 'timeout') {
throw new TimeoutError('qianwen image', timeout, 'No generated images observed before timeout.');
}
const urls = waitResult.urls;
if (skipDownload) {
return [{ Status: '🎨 generated', File: null, Link: link }];
}
const stamp = Date.now();
const results = [];
for (let i = 0; i < urls.length; i += 1) {
const url = urls[i];
const asset = await fetchImageAsset(page, url);
if (!asset?.ok) {
throw new CommandExecutionError(`Failed to fetch generated Qianwen image ${i + 1}: status=${asset?.status || '?'}`);
}
const suffix = urls.length > 1 ? `_${i + 1}` : '';
const ext = extFromMime(asset.mime);
const filePath = path.join(outputDir, `qianwen_${stamp}${suffix}${ext}`);
await saveBase64ToFile(asset.base64, filePath);
results.push({ Status: '✅ saved', File: displayPath(filePath), Link: link });
}
if (!results.length) {
throw new EmptyResultError('qwen image', 'No generated images were available to download.');
}
return results;
},
});