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
180 lines
7.1 KiB
JavaScript
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;
|
|
},
|
|
});
|