mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-10-26 08:26:22 -04:00
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:
2 files changed
+183
-119
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user