From 03f067d90764487f0439705df36e1a5c969a7f98 Mon Sep 17 00:00:00 2001 From: jackwener Date: Fri, 20 Mar 2026 17:12:48 +0800 Subject: [PATCH] fix: use UTC+8 for XHS timestamp formatting (CI timezone fix) formatPostTime() used local timezone methods, causing test failure on UTC CI servers. XHS API timestamps are Beijing time (UTC+8), so use explicit UTC offset with getUTC*() methods. --- src/clis/antigravity/serve.ts | 145 +++++++++++++++++++++----- src/clis/xiaohongshu/creator-notes.ts | 5 +- 2 files changed, 124 insertions(+), 26 deletions(-) diff --git a/src/clis/antigravity/serve.ts b/src/clis/antigravity/serve.ts index 7808d34..7241fed 100644 --- a/src/clis/antigravity/serve.ts +++ b/src/clis/antigravity/serve.ts @@ -87,7 +87,11 @@ async function getConversationText(page: IPage): Promise { const text = await page.evaluate(` (() => { const container = document.getElementById('conversation'); - return container ? container.innerText : ''; + if (!container) return ''; + // Read only the first child div (actual chat content), + // skipping UI chrome like file change panels, model selectors, etc. + const chatContent = container.children[0]; + return chatContent ? chatContent.innerText : container.innerText; })() `); return String(text ?? ''); @@ -118,26 +122,36 @@ async function waitForReply( const pollInterval = opts.pollInterval ?? 500; // 500ms polling const stableThreshold = opts.stableThreshold ?? 6; // 6 × 500ms = 3s stable + const beforeLen = beforeText.length; const deadline = Date.now() + timeout; let lastText = beforeText; let stableCount = 0; + let hasChanged = false; // Wait a bit for the model to start generating - await sleep(1000); + await sleep(2000); while (Date.now() < deadline) { const current = await getConversationText(page); - if (current.length > beforeText.length) { - // New content appeared + // Detect ANY change from the pre-send state + if (current !== beforeText && current.length > 0) { + hasChanged = true; + } + + if (hasChanged) { if (current === lastText) { stableCount++; if (stableCount >= stableThreshold) { - // Text has been stable — reply is complete - return current.slice(beforeText.length).trim(); + // Text has been stable for N polls — reply is complete + // Extract only the new content (if text grew) or the full tail + if (current.length > beforeLen) { + return current.slice(beforeLen).trim(); + } + // DOM re-rendered: try to extract the last block of text + return extractLastReply(current); } } else { - // Still generating stableCount = 0; lastText = current; } @@ -148,12 +162,35 @@ async function waitForReply( // Timeout — return whatever we have const finalText = await getConversationText(page); - if (finalText.length > beforeText.length) { - return finalText.slice(beforeText.length).trim(); + if (finalText !== beforeText) { + if (finalText.length > beforeLen) { + return finalText.slice(beforeLen).trim(); + } + return extractLastReply(finalText); } throw new Error('Timeout waiting for Antigravity reply'); } +/** + * Extract the last assistant reply from conversation text. + * Used as fallback when the DOM re-renders and simple length-diff doesn't work. + */ +function extractLastReply(text: string): string { + // Try to find the last chunk after common markers + const lines = text.split('\n'); + // Take the last non-empty substantial block (at least 2 chars) + const result: string[] = []; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.length > 1) { + result.unshift(line); + } else if (result.length > 0) { + break; // Hit a gap, stop collecting + } + } + return result.join('\n') || text.slice(-2000); +} + // ─── Request Handlers ──────────────────────────────────────────────── async function handleMessages( @@ -204,18 +241,75 @@ async function handleMessages( export async function startServe(opts: { port?: number } = {}): Promise { const port = opts.port ?? 8082; - // Establish persistent CDP connection - console.error('[serve] Connecting to Antigravity via CDP...'); - const cdp = new CDPBridge(); - const page = await cdp.connect({ timeout: 15_000 }); - console.error('[serve] CDP connected successfully.'); - - // Verify we can read conversation - const testText = await getConversationText(page); - console.error(`[serve] Conversation element found (${testText.length} chars).`); - + // Lazy CDP connection — connect when first request comes in + let cdp: CDPBridge | null = null; + let page: IPage | null = null; let requestInFlight = false; + async function ensureConnected(): Promise { + if (page) { + try { + await page.evaluate('1+1'); + return page; + } catch { + console.error('[serve] CDP connection lost, reconnecting...'); + cdp?.close().catch(() => {}); + cdp = null; + page = null; + } + } + + const endpoint = process.env.OPENCLI_CDP_ENDPOINT; + if (!endpoint) { + throw new Error( + 'OPENCLI_CDP_ENDPOINT is not set.\n' + + 'Usage: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve' + ); + } + + // Note: Antigravity chat panel lives inside editor windows, not in Launchpad. + // If multiple editor windows are open, set OPENCLI_CDP_TARGET to the window title. + if (process.env.OPENCLI_CDP_TARGET) { + console.error(`[serve] Using OPENCLI_CDP_TARGET=${process.env.OPENCLI_CDP_TARGET}`); + } + + // List available targets for debugging + try { + const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`); + const targets = await res.json() as Array<{ title?: string; type?: string }>; + const pages = targets.filter(t => t.type === 'page'); + console.error(`[serve] Available targets: ${pages.map(t => `"${t.title}"`).join(', ')}`); + } catch { /* ignore */ } + + console.error(`[serve] Connecting via CDP (target pattern: "${process.env.OPENCLI_CDP_TARGET}")...`); + cdp = new CDPBridge(); + try { + page = await cdp.connect({ timeout: 15_000 }); + } catch (err: any) { + cdp = null; + const isRefused = err?.cause?.code === 'ECONNREFUSED' || err?.message?.includes('ECONNREFUSED'); + throw new Error( + isRefused + ? `Cannot connect to Antigravity at ${endpoint}.\n` + + ' 1. Make sure Antigravity is running\n' + + ' 2. Launch with: --remote-debugging-port=9224' + : `CDP connection failed: ${err.message}` + ); + } + + console.error('[serve] ✅ CDP connected.'); + + // Quick verification + const hasUI = await page.evaluate(` + (() => !!document.getElementById('conversation') || !!document.getElementById('antigravity.agentSidePanelInputBox'))() + `); + if (!hasUI) { + console.error('[serve] ⚠️ Warning: chat UI elements not found in this target. Try setting OPENCLI_CDP_TARGET to the correct window title.'); + } + + return page; + } + const server = createServer(async (req, res) => { // CORS preflight if (req.method === 'OPTIONS') { @@ -266,7 +360,6 @@ export async function startServe(opts: { port?: number } = {}): Promise { const body = JSON.parse(rawBody) as AnthropicRequest; if (body.stream) { - // We don't support streaming — return error jsonResponse(res, 400, { type: 'error', error: { @@ -277,7 +370,9 @@ export async function startServe(opts: { port?: number } = {}): Promise { return; } - const response = await handleMessages(body, page); + // Lazy connect on first request + const activePage = await ensureConnected(); + const response = await handleMessages(body, activePage); jsonResponse(res, 200, response); } finally { requestInFlight = false; @@ -287,7 +382,7 @@ export async function startServe(opts: { port?: number } = {}): Promise { // Health check if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) { - jsonResponse(res, 200, { ok: true, status: 'connected' }); + jsonResponse(res, 200, { ok: true, cdpConnected: page !== null }); return; } @@ -296,7 +391,7 @@ export async function startServe(opts: { port?: number } = {}): Promise { error: { type: 'not_found_error', message: `Not found: ${pathname}` }, }); } catch (err) { - console.error('[serve] Error:', err); + console.error('[serve] Error:', err instanceof Error ? err.message : err); jsonResponse(res, 500, { type: 'error', error: { @@ -310,6 +405,7 @@ export async function startServe(opts: { port?: number } = {}): Promise { server.listen(port, '127.0.0.1', () => { console.error(`\n[serve] ✅ Antigravity API proxy running at http://127.0.0.1:${port}`); console.error(`[serve] Compatible with Anthropic /v1/messages API`); + console.error(`[serve] CDP connection will be established on first request.`); console.error(`\n[serve] Usage with Claude Code:`); console.error(` ANTHROPIC_BASE_URL=http://localhost:${port} claude\n`); }); @@ -317,7 +413,7 @@ export async function startServe(opts: { port?: number } = {}): Promise { // Graceful shutdown const shutdown = () => { console.error('\n[serve] Shutting down...'); - cdp.close().catch(() => {}); + cdp?.close().catch(() => {}); server.close(); process.exit(0); }; @@ -327,3 +423,4 @@ export async function startServe(opts: { port?: number } = {}): Promise { // Keep alive await new Promise(() => {}); } + diff --git a/src/clis/xiaohongshu/creator-notes.ts b/src/clis/xiaohongshu/creator-notes.ts index 4757a99..d72a6da 100644 --- a/src/clis/xiaohongshu/creator-notes.ts +++ b/src/clis/xiaohongshu/creator-notes.ts @@ -54,9 +54,10 @@ function buildNoteDetailUrl(noteId?: string): string { function formatPostTime(ts?: number): string { if (!ts) return ''; - const date = new Date(ts); + // XHS API timestamps are Beijing time (UTC+8) + const date = new Date(ts + 8 * 3600_000); const pad = (value: number) => String(value).padStart(2, '0'); - return `${date.getFullYear()}年${pad(date.getMonth() + 1)}月${pad(date.getDate())}日 ${pad(date.getHours())}:${pad(date.getMinutes())}`; + return `${date.getUTCFullYear()}年${pad(date.getUTCMonth() + 1)}月${pad(date.getUTCDate())}日 ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; } export function parseCreatorNotesText(bodyText: string): CreatorNoteRow[] {