Files
rdapper/src/whois/normalize.ts
T
jake be8e88592f 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
2026-09-19 00:51:55 -04:00

386 lines
12 KiB
TypeScript

import { toISOFromTokens } from "../lib/dates";
import { isPrivacyName } from "../lib/privacy";
import { parseKeyValueLines, uniq } from "../lib/text";
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
// Common WHOIS availability phrases seen across registries/registrars
const WHOIS_AVAILABLE_PATTERNS: RegExp[] = [
/\bno match\b/i,
/\bnot found\b/i,
/\bno entries found\b/i,
/\bno data found\b/i,
/\bno information available\b/i,
/\bno information was found\b/i,
/\bno data was found\b/i,
/\bavailable for registration\b/i,
/\bdomain\s+available\b/i,
/\bdomain status[:\s]+available\b/i,
/\bobject does not exist\b/i,
/\bthe queried object does not exist\b/i,
/\bqueried object does not exist\b/i,
/\bdoes not exist\b/i,
/\breturned 0 objects\b/i,
/\bnot been registered\b/i,
/\bunassignable\b/i,
/\bis free\b/i,
// Common variants across ccTLDs/registrars
/\bstatus:\s*free\b/i,
/\bstatus:\s*available\b/i,
/\bno object found\b/i,
/\bobject_not_found\b/i,
/\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
];
/**
* Best-effort heuristic to determine if a WHOIS response indicates the domain is available.
*/
export function isAvailableByWhois(text: string | undefined): boolean {
if (!text) return false;
return WHOIS_AVAILABLE_PATTERNS.some((re) => re.test(text));
}
/**
* Convert raw WHOIS text into our normalized DomainRecord.
* Heuristics cover many gTLD and ccTLD formats; exact fields vary per registry.
*/
export function normalizeWhois(
domain: string,
tld: string,
whoisText: string,
whoisServer: string | undefined,
includeRaw = false,
): DomainRecord {
const map = parseKeyValueLines(whoisText);
// Date extraction across common synonyms
// .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",
"last updated",
"last updated on", // .mx
"last update", // .co.jp
"last-update", // .fr
"last modified",
"modified",
"changed",
"modification date",
"domain record last updated", // .edu
]);
const expirationDate = anyValue(map, [
"registry expiry date",
"registry expiration date",
"registrar registration expiration date",
"registrar registration expiry date",
"registrar expiration date",
"registrar expiry date",
"expiry date",
"expiration date",
"expiry",
"expire date", // .it
"expire",
"expired", // .ly
"expires on",
"expires",
"expiration time", // .cn
"domain expires", // .edu
"paid-till",
"renewal date", // .pl
"validity", // .il
"record will expire on",
]);
// Registrar info (thin registries like .com/.net require referral follow for full data)
const registrar: RegistrarInfo | undefined = (() => {
const name = anyValue(map, [
"registrar",
"registrar name",
"registrar organization",
"registrar organization name", // .tr
"sponsoring registrar",
"organisation",
"record maintained by",
]);
const ianaId = anyValue(map, ["registrar iana id", "sponsoring registrar iana id", "iana id"]);
const url = anyValue(map, [
"registrar url",
"registrar website",
"registrar web", // .it
"url of the registrar",
"referrer",
]);
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,
ianaId: ianaId || undefined,
url: url || undefined,
email: abuseEmail || undefined,
phone: abusePhone || undefined,
};
})();
// Statuses: multiple entries are expected; keep raw
const statusLines =
map["domain status"] ||
map.status ||
map.flags ||
map.state || // .ru
map["registration status"] ||
map.eppstatus || // .fr
[];
const statuses = statusLines.length
? statusLines
.map((line) => {
const status = line.split(/\s+/)[0];
return status ? { status, raw: line } : null;
})
.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"] || []),
...(map.nameserver || []),
...(map["name servers"] || []),
...(map.nserver || []),
...(map["name server information"] || []),
...(map.dns || []),
...(map.hostname || []),
...(map["domain nameservers"] || []),
...(map["domain servers in listed order"] || []), // .ly
...(map["domain servers"] || []), // .tr
...(map["name servers dns"] || []), // .mx
...(map["ns 1"] || []),
...(map["ns 2"] || []),
...(map["ns 3"] || []),
...(map["ns 4"] || []),
];
const nameservers: Nameserver[] | undefined = nsLines.length
? (uniq(
nsLines
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
// Common formats: "ns1.example.com" or "ns1.example.com 192.0.2.1" or "ns1.example.com 2001:db8::1"
const parts = line.split(/\s+/);
const host = parts.shift()?.toLowerCase() || "";
const ipv4: string[] = [];
const ipv6: string[] = [];
for (const p of parts) {
if (/^\d+\.\d+\.\d+\.\d+$/.test(p)) ipv4.push(p);
else if (/^[0-9a-f:]+$/i.test(p)) ipv6.push(p);
}
if (!host) return undefined;
const ns: Nameserver = { host };
if (ipv4.length) ns.ipv4 = ipv4;
if (ipv6.length) ns.ipv6 = ipv6;
return ns;
})
.filter((x): x is Nameserver => !!x),
) as Nameserver[])
: undefined;
// Contacts: best-effort parse common keys
const contacts = collectContacts(map);
// Derive privacy flag from registrant name/org keywords
const registrant = contacts?.find((c) => c.type === "registrant");
const privacyEnabled = !!(
registrant &&
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
);
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : undefined;
// Simple lock derivation from statuses
const transferLock = !!statuses?.some((s) =>
/transfer[-\s]*prohibited/i.test(s.raw || s.status || ""),
);
const record: DomainRecord = {
domain,
tld,
isRegistered: !isAvailableByWhois(whoisText),
isIDN: /(^|\.)xn--/i.test(domain),
unicodeName: undefined,
punycodeName: undefined,
registry: undefined,
registrar,
reseller: anyValue(map, ["reseller"]) || undefined,
statuses,
creationDate: toISOFromTokens(creationDate),
updatedDate: toISOFromTokens(updatedDate),
expirationDate: toISOFromTokens(expirationDate) ?? toISOFromTokens(okUntil),
deletionDate: undefined,
transferLock,
dnssec,
nameservers,
contacts,
privacyEnabled: privacyEnabled ? true : undefined,
whoisServer,
rdapServers: undefined,
rawRdap: undefined,
rawWhois: includeRaw ? whoisText : undefined,
source: "whois",
warnings: undefined,
};
return record;
}
function anyValue(map: Record<string, string[]>, keys: string[]): string | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v[0];
}
return undefined;
}
function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
const roles: Array<{
role: Contact["type"];
prefixes: string[];
}> = [
{
role: "registrant",
prefixes: ["registrant", "owner", "holder"],
},
{
role: "admin",
prefixes: ["admin", "administrative"],
},
{
role: "tech",
prefixes: ["tech", "technical"],
},
{
role: "billing",
prefixes: ["billing"],
},
{
role: "abuse",
prefixes: ["abuse"],
},
];
const contacts: Contact[] = [];
for (const r of roles) {
const nameKeys: string[] = [];
const orgKeys: string[] = [];
const emailKeys: string[] = [];
const phoneKeys: string[] = [];
const faxKeys: string[] = [];
const streetKeys: string[] = [];
const cityKeys: string[] = [];
const stateKeys: string[] = [];
const postalCodeKeys: string[] = [];
const countryKeys: string[] = [];
for (const prefix of r.prefixes) {
nameKeys.push(`${prefix} name`, `${prefix} contact name`, `${prefix}`);
if (prefix === "registrant") {
nameKeys.push("registrant person"); // .ua
}
if (prefix === "owner") {
nameKeys.push("owner name"); // .tm
}
orgKeys.push(`${prefix} organization`, `${prefix} organisation`, `${prefix} org`);
if (prefix === "registrant") {
orgKeys.push("trading as"); // .uk, .co.uk
orgKeys.push("org"); // .ru
}
if (prefix === "owner") {
orgKeys.push("owner orgname"); // .tm
}
emailKeys.push(`${prefix} email`, `${prefix} contact email`, `${prefix} e-mail`);
phoneKeys.push(`${prefix} phone`, `${prefix} contact phone`, `${prefix} telephone`);
faxKeys.push(`${prefix} fax`, `${prefix} facsimile`);
streetKeys.push(`${prefix} street`, `${prefix} address`, `${prefix}'s address`);
if (prefix === "owner") {
streetKeys.push("owner addr"); // .tm
}
cityKeys.push(`${prefix} city`);
stateKeys.push(`${prefix} state`, `${prefix} province`, `${prefix} state/province`);
postalCodeKeys.push(`${prefix} postal code`, `${prefix} postcode`, `${prefix} zip`);
countryKeys.push(`${prefix} country`);
}
const name = anyValue(map, nameKeys);
const org = anyValue(map, orgKeys);
const email = anyValue(map, emailKeys);
const phone = anyValue(map, phoneKeys);
const fax = anyValue(map, faxKeys);
const street = multi(map, streetKeys);
const city = anyValue(map, cityKeys);
const state = anyValue(map, stateKeys);
const postalCode = anyValue(map, postalCodeKeys);
const country = anyValue(map, countryKeys);
if (name || org || email || phone || street?.length) {
contacts.push({
type: r.role,
name: name || undefined,
organization: org || undefined,
email: email || undefined,
phone: phone || undefined,
fax: fax || undefined,
street: street,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
});
}
}
return contacts.length ? contacts : undefined;
}
function multi(map: Record<string, string[]>, keys: string[]): string[] | undefined {
for (const k of keys) {
const v = map[k];
if (v?.length) return v;
}
return undefined;
}