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.
This commit is contained in:
jackwener committed 2026-03-20 17:13:00 +08:00
1 parent 7e973ca592
commit 03f067d907
2 files changed
+124 -26

No files matched your search

+121 -24
View File
@@ -87,7 +87,11 @@ async function getConversationText(page: IPage): Promise<string> {
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<void> {
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<IPage> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
// 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<void> {
// Keep alive
await new Promise(() => {});
}
+3 -2
View File
@@ -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[] {