diff --git a/clis/xueqiu/earnings-date.js b/clis/xueqiu/earnings-date.js index c83043aaa..743560a17 100644 --- a/clis/xueqiu/earnings-date.js +++ b/clis/xueqiu/earnings-date.js @@ -1,6 +1,6 @@ import { cli } from '@jackwener/opencli/registry'; import { EmptyResultError } from '@jackwener/opencli/errors'; -import { fetchXueqiuJson } from './utils.js'; +import { fetchXueqiuJson, formatChinaDate } from './utils.js'; cli({ site: 'xueqiu', name: 'earnings-date', @@ -32,7 +32,7 @@ cli({ .filter((item) => item.subtype === 2) .map((item) => { const ts = item.timestamp; - const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null; + const dateStr = ts ? formatChinaDate(ts) : null; const isFuture = ts && ts > now; return { date: dateStr, report: item.message, status: isFuture ? '⏳ 未发布' : '✅ 已发布', _ts: ts, _future: isFuture }; }); diff --git a/clis/xueqiu/kline.js b/clis/xueqiu/kline.js index 0fe8cabc2..7f44d4d38 100644 --- a/clis/xueqiu/kline.js +++ b/clis/xueqiu/kline.js @@ -1,6 +1,6 @@ import { cli } from '@jackwener/opencli/registry'; import { EmptyResultError } from '@jackwener/opencli/errors'; -import { fetchXueqiuJson } from './utils.js'; +import { fetchXueqiuJson, formatChinaDate } from './utils.js'; cli({ site: 'xueqiu', name: 'kline', @@ -31,7 +31,7 @@ cli({ const colIdx = {}; columns.forEach((name, i) => { colIdx[name] = i; }); return d.data.item.map(row => ({ - date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null, + date: colIdx.timestamp != null ? formatChinaDate(row[colIdx.timestamp]) : null, open: row[colIdx.open] ?? null, high: row[colIdx.high] ?? null, low: row[colIdx.low] ?? null, diff --git a/clis/xueqiu/utils.js b/clis/xueqiu/utils.js index 5986b0555..f80f0ed7d 100644 --- a/clis/xueqiu/utils.js +++ b/clis/xueqiu/utils.js @@ -1,4 +1,23 @@ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; + +const CHINA_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +/** Format a Unix ms timestamp as the matching `YYYY-MM-DD` in Asia/Shanghai (xueqiu's canonical user timezone for all markets). */ +export function formatChinaDate(ts) { + if (ts == null) return null; + const parts = Object.fromEntries( + CHINA_DATE_FORMATTER.formatToParts(new Date(ts)) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]), + ); + return `${parts.year}-${parts.month}-${parts.day}`; +} + /** * Fetch a xueqiu JSON API from inside the browser context (credentials included). * Page must already be navigated to xueqiu.com before calling this function. diff --git a/clis/xueqiu/utils.test.js b/clis/xueqiu/utils.test.js new file mode 100644 index 000000000..3bc6c2ae2 --- /dev/null +++ b/clis/xueqiu/utils.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { formatChinaDate } from './utils.js'; + +describe('formatChinaDate', () => { + it('returns the Asia/Shanghai date for a UTC ms at China midnight', () => { + expect(formatChinaDate(Date.UTC(2026, 4, 7, 16, 0, 0))).toBe('2026-05-08'); + }); + it('returns the same China date for a moment late in the day', () => { + expect(formatChinaDate(Date.UTC(2026, 4, 8, 14, 0, 0))).toBe('2026-05-08'); + }); + it('formats representative A-share and US-market bars on xueqiu Beijing dates', () => { + expect(formatChinaDate(Date.UTC(2026, 4, 7, 16, 0, 0))).toBe('2026-05-08'); + expect(formatChinaDate(Date.UTC(2026, 4, 10, 16, 0, 0))).toBe('2026-05-11'); + }); + it('crosses the China day boundary at 16:00 UTC', () => { + expect(formatChinaDate(Date.UTC(2026, 0, 1, 15, 59, 59))).toBe('2026-01-01'); + expect(formatChinaDate(Date.UTC(2026, 0, 1, 16, 0, 0))).toBe('2026-01-02'); + }); + it('always returns an ISO calendar date string, not a locale-shaped slash date', () => { + expect(formatChinaDate(Date.UTC(2026, 0, 1, 16, 0, 0))).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + it('returns null for nullish input', () => { + expect(formatChinaDate(null)).toBeNull(); + expect(formatChinaDate(undefined)).toBeNull(); + }); +});