fix: parse WHOIS dates independently of the host timezone

The native Date fallback read values without a zone in local time, so the same
WHOIS text produced different timestamps on different hosts (every .jp date,
"YYYY/MM/DD", went through it) and day-first dates were read in US order or
dropped. Replace the content-sniffing parser with explicit per-format builders:

- year-first and day-first dates with -, /, . separators (.jp, .kr, .rs, .mk, .pt),
  rejecting out-of-range fields instead of rolling over
- zone suffixes "(JST)", "(UTC+8)", "CLST"/"CLT", and "+0000Z"
- anything left for the native parser is pinned to UTC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-03 14:41:10 -04:00
1 parent 525ef4b7b1
commit 9fe583a97a
2 files changed
+183 -119

No files matched your search

+65
View File
@@ -57,3 +57,68 @@ test("toISO parses compact YYYYMMDD, including .br 'created' values with a ticke
expect(toISO("20260319")).toBe("2026-03-19T00:00:00Z");
expect(toISOFromTokens("20260319 #31066859")).toBe("2026-03-19T00:00:00Z");
});
test("toISO reads registry zone suffixes", () => {
// .jp (JPRS)
expect(toISO("2026/03/01 01:05:03 (JST)")).toBe("2026-02-28T16:05:03Z");
// .tw (TWNIC)
expect(toISO("2099-12-31 23:59:59 (UTC+8)")).toBe("2099-12-31T15:59:59Z");
// .cl appends CLST (UTC-3) year-round; CLT is UTC-4
expect(toISO("2030-02-08 21:00:00 CLST")).toBe("2030-02-09T00:00:00Z");
expect(toISO("1997-08-29 00:00:00 CLT")).toBe("1997-08-29T04:00:00Z");
// A stray Z after an explicit offset
expect(toISO("2020-01-01T00:00:00+0000Z")).toBe("2020-01-01T00:00:00Z");
expect(toISO("2023-01-02 03:04:05 UTC")).toBe("2023-01-02T03:04:05Z");
});
test("toISO reads day-first dates with dots and slashes", () => {
// .rs / .mk
expect(toISO("10.03.2008 12:00:00")).toBe("2008-03-10T12:00:00Z");
expect(toISO("22.05.2032")).toBe("2032-05-22T00:00:00Z");
// .pt: day first even when both fields could be a month
expect(toISO("03/10/1991 00:00:00")).toBe("1991-10-03T00:00:00Z");
expect(toISO("31/12/2026 23:59:00")).toBe("2026-12-31T23:59:00Z");
// US order only when the second field can't be a month
expect(toISO("12/31/2026")).toBe("2026-12-31T00:00:00Z");
// No rollover into the next month
expect(toISO("31.02.2026")).toBeUndefined();
});
test("toISO reads year-first dates with other separators", () => {
expect(toISO("2001/02/02")).toBe("2001-02-02T00:00:00Z"); // .jp
expect(toISO("1996. 07. 20.")).toBe("1996-07-20T00:00:00Z"); // .kr
expect(toISO("2004.08.18 13:30:03")).toBe("2004-08-18T13:30:03Z");
expect(toISO("2023-01-02T03:04:05.123Z")).toBe("2023-01-02T03:04:05Z");
expect(toISO("2023-13-02")).toBeUndefined();
});
test("toISO results do not depend on the host timezone", () => {
const original = process.env.TZ;
const inputs = [
"2001/02/02",
"1996. 07. 20.",
"Tue Jan 01 2000",
"2001-Jan-03.",
"Mon, 01 Jan 2024 10:00:00",
"2024-01-15 10:00",
];
const inUtc = inputs.map((i) => toISO(i));
try {
process.env.TZ = "Asia/Tokyo";
expect(new Date(2000, 0, 1).getTimezoneOffset()).toBe(-540);
expect(inputs.map((i) => toISO(i))).toEqual(inUtc);
process.env.TZ = "America/Los_Angeles";
expect(inputs.map((i) => toISO(i))).toEqual(inUtc);
} finally {
if (original === undefined) delete process.env.TZ;
else process.env.TZ = original;
}
expect(inUtc).toEqual([
"2001-02-02T00:00:00Z",
"1996-07-20T00:00:00Z",
"2000-01-01T00:00:00Z",
"2001-01-03T00:00:00Z",
"2024-01-01T10:00:00Z",
"2024-01-15T10:00:00Z",
]);
});
+118 -119
View File
@@ -1,59 +1,63 @@
// Lightweight date parsing helpers to avoid external dependencies.
// We aim to parse common RDAP and WHOIS date representations and return a UTC ISO string.
// A value without a timezone is read as UTC, never in the host's local time.
type Field = string | number | undefined;
// Explicit formats seen in RDAP/WHOIS output, each with how to build the timestamp
const FORMATS: Array<[RegExp, (m: RegExpMatchArray) => number | undefined]> = [
// Year first: 2023-01-02, 2023/01/02 03:04:05 (.jp), 2023-01-02T03:04:05.123+05:30,
// and "1996. 07. 20." (.kr)
[
/^(\d{4})([-/.])\s?(\d{1,2})\2\s?(\d{1,2})\.?(?:[ T]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?\s*(Z|UTC|GMT|[+-]\d{2}(?::?\d{2})?)?$/i,
(m) => utc(m[1], m[3], m[4], m[5], m[6], m[7], m[8]),
],
// Day first: 21-07-2026 (.il, .hk), 10.03.2008 12:00:00 (.rs, .mk), 03/10/1991 00:00:00 (.pt).
// US order is assumed only when the second field can't be a month.
[
/^(\d{1,2})([-/.])(\d{1,2})\2(\d{4})(?:[ T]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?\s*(Z|UTC|GMT|[+-]\d{2}(?::?\d{2})?)?$/i,
(m) =>
Number(m[3]) > 12 && Number(m[1]) <= 12
? utc(m[4], m[1], m[3], m[5], m[6], m[7], m[8])
: utc(m[4], m[3], m[1], m[5], m[6], m[7], m[8]),
],
// 02-Jan-2023
[/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/, (m) => utc(m[3], monthOf(m[2]), m[1])],
// Jan 02 2023
[/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/, (m) => utc(m[3], monthOf(m[1]), m[2])],
// 20261004161638 (compact YYYYMMDDHHMMSS, used by .ua)
[/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/, (m) => utc(m[1], m[2], m[3], m[4], m[5], m[6])],
// 20260319 (compact YYYYMMDD, used by .br)
[/^(\d{4})(\d{2})(\d{2})$/, (m) => utc(m[1], m[2], m[3])],
// 28th December 2018 [at 05:54:43[.861]] (used by .gg/.je)
[
/^(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3})[A-Za-z]*\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?)?$/,
(m) => utc(m[3], monthOf(m[2]), m[1], m[4], m[5], m[6]),
],
];
// Zone names the formats above and the native parser don't know: "(JST)" (.jp), "CLST"/"CLT" (.cl)
const ZONE_OFFSETS: Record<string, string> = { JST: "+0900", CLT: "-0400", CLST: "-0300" };
export function toISO(dateLike: string | number | Date | undefined | null): string | undefined {
if (dateLike == null) return undefined;
if (dateLike instanceof Date) return toIsoFromDate(dateLike);
if (typeof dateLike === "number") return toIsoFromDate(new Date(dateLike));
const raw = String(dateLike).trim();
const raw = normalizeZone(String(dateLike).trim());
if (!raw) return undefined;
// Try several structured formats seen in WHOIS outputs (treat as UTC when no TZ provided)
const tryFormats = [
// 2023-01-02 03:04:05Z or without Z
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
// 2023/01/02 03:04:05
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
// 02-Jan-2023
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
// 21-07-2026 (DD-MM-YYYY used by .il, .hk)
/^(\d{2})-(\d{2})-(\d{4})$/,
// Jan 02 2023
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/,
// 20261004161638 (compact YYYYMMDDHHMMSS, used by .ua)
/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/,
// 20260319 (compact YYYYMMDD, used by .br)
/^(\d{4})(\d{2})(\d{2})$/,
];
for (const re of tryFormats) {
for (const [re, build] of FORMATS) {
const m = raw.match(re);
if (!m) continue;
const d = parseDateWithRegex(m, re);
if (d) return toIsoFromDate(d);
const ms = build(m);
if (ms !== undefined) return toIsoFromDate(new Date(ms));
}
// 28th December 2018 [at 05:54:43[.861]] (used by .gg/.je)
const ordinal = raw.match(
/^(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3})[A-Za-z]*\s+(\d{4})(?:\s+at\s+(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?)?$/,
);
if (ordinal) {
const [, dd, mon, yyyy, hh, mm, ss] = ordinal;
const monthIdx = MONTHS[mon?.toLowerCase() ?? ""];
if (monthIdx !== undefined) {
return toIsoFromDate(
new Date(
Date.UTC(
Number(yyyy),
monthIdx,
Number(dd),
Number(hh ?? 0),
Number(mm ?? 0),
Number(ss ?? 0),
),
),
);
}
// Anything else goes to the native parser (RFC 2822, "Tue Jan 01 2000", ...), which reads a
// value without a zone as local time: pin those to UTC.
const hasZone = /\b(?:UTC?|GMT|[ECMP][SD]T)\b|(?:\dZ|[+-]\d{2}:?\d{2})$/i.test(raw);
for (const candidate of hasZone ? [raw] : [`${raw} UTC`, `${raw}Z`]) {
const native = new Date(candidate);
if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
}
// Fallback to native Date parsing (handles ISO and RFC2822 with TZ)
const native = new Date(raw);
if (!Number.isNaN(native.getTime())) return toIsoFromDate(native);
return undefined;
}
@@ -74,21 +78,78 @@ export function toISOFromTokens(value: string | undefined | null): string | unde
return undefined;
}
/**
* Rewrite zone suffixes as numeric offsets ("(UTC+8)" from .tw, the names in ZONE_OFFSETS), and
* drop the stray "Z" after an explicit offset ("+0000Z", seen in RDAP).
*/
function normalizeZone(raw: string): string {
return raw
.replace(
/\s*\((?:UTC|GMT)([+-])(\d{1,2})(?::?(\d{2}))?\)$/i,
(_m, sign: string, hh: string, mm: string | undefined) =>
` ${sign}${hh.padStart(2, "0")}${mm ?? "00"}`,
)
.replace(/\s*\(?\b([A-Z]{3,4})\)?$/, (whole, name: string) => {
const offset = ZONE_OFFSETS[name];
return offset ? ` ${offset}` : whole;
})
.replace(/([+-]\d{2}:?\d{2})Z$/i, "$1");
}
const MONTHS: Record<string, number> = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
};
function monthOf(name: string | undefined): number | undefined {
return MONTHS[name?.toLowerCase() ?? ""];
}
/**
* Epoch milliseconds for a UTC calendar time, shifted by an optional zone ("Z", "+0530", "-05").
* Out-of-range fields (month 13, 31 February) give undefined rather than rolling over.
*/
function utc(
year: Field,
month: Field,
day: Field,
hours: Field = 0,
minutes: Field = 0,
seconds: Field = 0,
zone?: string,
): number | undefined {
const y = Number(year);
const mo = Number(month);
const d = Number(day);
const h = Number(hours);
const mi = Number(minutes);
const s = Number(seconds);
if (![y, mo, d, h, mi, s].every(Number.isFinite) || h > 23 || mi > 59 || s > 59) {
return undefined;
}
const ms = Date.UTC(y, mo - 1, d, h, mi, s);
const check = new Date(ms);
if (check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) return undefined;
return ms - offsetMs(zone);
}
function offsetMs(zone: string | undefined): number {
const m = zone?.match(/^([+-])(\d{2}):?(\d{2})?$/);
if (!m) return 0; // no zone, "Z", "UTC", "GMT"
const sign = m[1] === "-" ? -1 : 1;
return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0)) * 60 * 1000;
}
function toIsoFromDate(d: Date): string | undefined {
try {
return new Date(
@@ -108,65 +169,3 @@ function toIsoFromDate(d: Date): string | undefined {
return undefined;
}
}
function parseDateWithRegex(m: RegExpMatchArray, _re: RegExp): Date | undefined {
const monthMap: Record<string, number> = {
jan: 0,
feb: 1,
mar: 2,
apr: 3,
may: 4,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
oct: 9,
nov: 10,
dec: 11,
};
try {
// If the matched string contains time components, parse as Y-M-D H:M:S
if (m[0].includes(":") || /^\d{14}$/.test(m[0])) {
const [_, y, mo, d, hh, mm, ss, offH, offM] = m;
if (!y || !mo || !d || !hh || !mm || !ss) return undefined;
// Base time as UTC
let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
// Apply timezone offset if present (e.g., +0000, -0500, +05:30)
if (offH) {
const sign = offH.startsWith("-") ? -1 : 1;
const hours = Math.abs(Number(offH));
const minutes = offM ? Number(offM) : 0;
const offsetMs = sign * (hours * 60 + minutes) * 60 * 1000;
// The captured time is local with an explicit offset; convert to UTC
dt -= offsetMs;
}
return new Date(dt);
}
// Compact YYYYMMDD
if (/^\d{8}$/.test(m[0])) {
const [_, y, mo, d] = m;
return new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
}
// If the matched string contains hyphens, check if numeric (DD-MM-YYYY) or alpha (DD-MMM-YYYY)
if (m[0].includes("-")) {
const [_, dd, monStr, yyyy] = m;
if (!monStr || !dd || !yyyy) return undefined;
// Check if month component is numeric (DD-MM-YYYY) or alphabetic (DD-MMM-YYYY)
if (/^\d+$/.test(monStr)) {
// DD-MM-YYYY format (e.g., 21-07-2026)
return new Date(Date.UTC(Number(yyyy), Number(monStr) - 1, Number(dd)));
}
// DD-MMM-YYYY format (e.g., 02-Jan-2023)
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
}
// Otherwise treat as MMM DD YYYY
const [_, monStr, dd, yyyy] = m;
if (!monStr || !dd || !yyyy) return undefined;
const mon = monthMap[monStr.toLowerCase()];
return new Date(Date.UTC(Number(yyyy), mon, Number(dd)));
} catch {
// fall through to undefined
}
return undefined;
}