feat: add ccTLD-specific date formats, noisy-token parsing, and .gg/.je/.br/.ua normalization, closes #20 and #25

- Add `toISOFromTokens` to extract ISO dates from noisy values (e.g. ".ua" `0-UANIC 20111004161638`, `OK-UNTIL 20261004161638`)
- Parse compact `YYYYMMDDHHMMSS` (.ua) and `YYYYMMDD` (.br, including ticket suffix like `20260319 #31066859`) date formats
- Parse ordinal/prose dates used by .gg/.je (`28th December 2018 at 05:54:43.861`)
- Teach `parseKeyValueLines` to collect indented continuation lines for header-style blocks (.gg/.je) without mistaking URLs or times as key separators
- Extract .gg/.je expiry from `Relevant dates:` sentences and split inline registrar URL (`epag (http://www.epag.de)`)
- Map .ua `status: OK-UNTIL <timestamp>` to `expirationDate` when no explicit expiry field is present
- Treat RDAP `pending release` / `release process: waiting` statuses as `isRegistered: false` (previously always `true`)
- Mark WHOIS `.br` `release process: waiting` as available
This commit is contained in:
2026-09-19 00:51:55 -04:00
parent e736045de1
commit be8e88592f
7 changed files with 232 additions and 23 deletions
+73
View File
@@ -207,3 +207,76 @@ test("isAvailableByWhois correctly identifies availability patterns", () => {
const rec = normalizeWhois("example.com", "com", text, "whois.example.com");
expect(rec.isRegistered).toBe(false);
});
test("WHOIS .gov.ua extracts dates from noisy values and OK-UNTIL", () => {
const text = `
domain: agrex.gov.ua
status: OK-UNTIL 20261004161638
created: 0-UANIC 20111004161638
modified: UARR149-UANIC 20251004050127
`;
const rec = normalizeWhois("agrex.gov.ua", "gov.ua", text, "whois.gov.ua");
expect(rec.creationDate).toBe("2011-10-04T16:16:38Z");
expect(rec.updatedDate).toBe("2025-10-04T05:01:27Z");
expect(rec.expirationDate).toBe("2026-10-04T16:16:38Z");
});
test("WHOIS .br 'release process: waiting' is not registered", () => {
const text = `
domain: iba.com.br
release process: waiting
`;
expect(isAvailableByWhois(text)).toBe(true);
expect(normalizeWhois("iba.com.br", "com.br", text, "whois.registro.br").isRegistered).toBe(
false,
);
});
test("WHOIS .br published domain is registered with compact dates", () => {
const text = `
domain: iba.com.br
nserver: ns11.cloudns.net
created: 20260319 #31066859
changed: 20260414
expires: 20270319
status: published
`;
const rec = normalizeWhois("iba.com.br", "com.br", text, "whois.registro.br");
expect(rec.isRegistered).toBe(true);
expect(rec.creationDate).toBe("2026-03-19T00:00:00Z");
expect(rec.updatedDate).toBe("2026-04-14T00:00:00Z");
expect(rec.expirationDate).toBe("2027-03-19T00:00:00Z");
});
test("WHOIS .gg header-style blocks with colons in values", () => {
const text = `
Domain:
t3.gg
Domain Status:
Active
Registrant:
T3 Tools Inc
Registrar:
epag (http://www.epag.de)
Relevant dates:
Registered on 28th December 2018 at 05:54:43.861
Registry fee due on 28th December each year
Registration status:
Registered until cancelled
Name servers:
ns1.vercel-dns.com
ns2.vercel-dns.com
`;
const rec = normalizeWhois("t3.gg", "gg", text, "whois.gg");
expect(rec.isRegistered).toBe(true);
expect(rec.creationDate).toBe("2018-12-28T05:54:43Z");
expect(rec.expirationDate).toBeUndefined();
expect(rec.registrar).toEqual({ name: "epag", url: "http://www.epag.de" });
expect(rec.nameservers?.map((n) => n.host)).toEqual(["ns1.vercel-dns.com", "ns2.vercel-dns.com"]);
});
+33 -20
View File
@@ -1,4 +1,4 @@
import { toISO } from "../lib/dates";
import { toISOFromTokens } from "../lib/dates";
import { isPrivacyName } from "../lib/privacy";
import { parseKeyValueLines, uniq } from "../lib/text";
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
@@ -31,6 +31,7 @@ const WHOIS_AVAILABLE_PATTERNS: RegExp[] = [
/\bno se encuentra registrado\b/i, // Spanish: "not found registered"
/\bnicht gefunden\b/i, // German: "not found"
/\bpending release\b/i, // often signals not registered/being deleted
/\brelease process:\s*waiting\b/i, // .br: expired, awaiting release
];
/**
@@ -55,22 +56,26 @@ export function normalizeWhois(
const map = parseKeyValueLines(whoisText);
// Date extraction across common synonyms
const creationDate = anyValue(map, [
"creation date",
"created on",
"created",
"registered on",
"registered",
"registration date",
"domain registration date",
"domain create date",
"domain name commencement date",
"registration time", // .cn
"domain record activated", // .edu
"domain registered",
"registered date", // .co.jp
"assigned", // .il
]);
// .gg/.je list dates as sentences under "Relevant dates:", e.g. "Registered on 28th December 2018 at 05:54:43.861"
const relevantDate = (label: RegExp) =>
map["relevant dates"]?.find((l) => label.test(l))?.replace(label, "");
const creationDate =
anyValue(map, [
"creation date",
"created on",
"created",
"registered on",
"registered",
"registration date",
"domain registration date",
"domain create date",
"domain name commencement date",
"registration time", // .cn
"domain record activated", // .edu
"domain registered",
"registered date", // .co.jp
"assigned", // .il
]) ?? relevantDate(/^registered on\s+/i);
const updatedDate = anyValue(map, [
"updated date",
"updated",
@@ -128,6 +133,11 @@ export function normalizeWhois(
]);
const abuseEmail = anyValue(map, ["registrar abuse contact email", "abuse contact email"]);
const abusePhone = anyValue(map, ["registrar abuse contact phone", "abuse contact phone"]);
// .gg/.je: "epag (http://www.epag.de)"
const inlineUrl = name?.match(/^(.*?)\s*\((https?:\/\/[^)\s]+)\)$/);
if (inlineUrl?.[1] && !url) {
return { name: inlineUrl[1], url: inlineUrl[2] };
}
if (!name && !ianaId && !url && !abuseEmail && !abusePhone) return undefined;
return {
name: name || undefined,
@@ -156,6 +166,9 @@ export function normalizeWhois(
.filter((s): s is { status: string; raw: string } => s !== null)
: undefined;
// Some registries (.ua) publish expiry only as a status, e.g. "OK-UNTIL 20261004161638"
const okUntil = statuses?.find((s) => /^ok-until$/i.test(s.status))?.raw;
// Nameservers: also appear as "nserver" on some ccTLDs (.de, .ru) and as "name server"
const nsLines: string[] = [
...(map["name server"] || []),
@@ -228,9 +241,9 @@ export function normalizeWhois(
registrar,
reseller: anyValue(map, ["reseller"]) || undefined,
statuses,
creationDate: toISO(creationDate || undefined),
updatedDate: toISO(updatedDate || undefined),
expirationDate: toISO(expirationDate || undefined),
creationDate: toISOFromTokens(creationDate),
updatedDate: toISOFromTokens(updatedDate),
expirationDate: toISOFromTokens(expirationDate) ?? toISOFromTokens(okUntil),
deletionDate: undefined,
transferLock,
dnssec,