rdapper 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/index.d.ts +43 -0
- package/dist/index.js +211 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ RDAP‑first domain registration lookups with WHOIS fallback. Produces a single,
|
|
|
4
4
|
|
|
5
5
|
- RDAP discovery via IANA bootstrap (`https://data.iana.org/rdap/dns.json`)
|
|
6
6
|
- WHOIS TCP 43 client with TLD discovery, registrar referral follow, and curated exceptions
|
|
7
|
-
- Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, source metadata
|
|
7
|
+
- Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, privacy flag, source metadata
|
|
8
8
|
- TypeScript types included; ESM‑only; no external HTTP client (uses global `fetch`)
|
|
9
9
|
|
|
10
10
|
### [🦉 See it in action on hoot.sh!](https://hoot.sh)
|
|
@@ -49,6 +49,10 @@ await isAvailable("likely-unregistered-thing-320485230458.com"); // => false
|
|
|
49
49
|
- `rdapOnly?: boolean` – Only attempt RDAP; do not fall back to WHOIS.
|
|
50
50
|
- `whoisOnly?: boolean` – Skip RDAP and query WHOIS directly.
|
|
51
51
|
- `followWhoisReferral?: boolean` – Follow registrar referral from the TLD WHOIS (default `true`).
|
|
52
|
+
- `maxWhoisReferralHops?: number` – Maximum registrar WHOIS referral hops to follow (default `2`).
|
|
53
|
+
- `rdapFollowLinks?: boolean` – Follow related/entity RDAP links to enrich data (default `true`).
|
|
54
|
+
- `maxRdapLinkHops?: number` – Maximum RDAP related link hops to follow (default `2`).
|
|
55
|
+
- `rdapLinkRels?: string[]` – RDAP link rel values to consider (default `["related","entity","registrar","alternate"]`).
|
|
52
56
|
- `customBootstrapUrl?: string` – Override RDAP bootstrap URL.
|
|
53
57
|
- `whoisHints?: Record<string, string>` – Override/add authoritative WHOIS per TLD (keys are lowercase TLDs, values may include or omit `whois://`).
|
|
54
58
|
- `includeRaw?: boolean` – Include `rawRdap`/`rawWhois` in the returned record (default `false`).
|
|
@@ -113,6 +117,7 @@ interface DomainRecord {
|
|
|
113
117
|
country?: string;
|
|
114
118
|
countryCode?: string;
|
|
115
119
|
}>;
|
|
120
|
+
privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics
|
|
116
121
|
whoisServer?: string; // authoritative WHOIS queried (if any)
|
|
117
122
|
rdapServers?: string[]; // RDAP base URLs tried
|
|
118
123
|
rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
|
|
@@ -144,10 +149,11 @@ interface DomainRecord {
|
|
|
144
149
|
- RDAP
|
|
145
150
|
- Discovers base URLs for the TLD via IANA’s RDAP bootstrap JSON.
|
|
146
151
|
- Tries each base until one responds successfully; parses standard RDAP domain JSON.
|
|
152
|
+
- Optionally follows related/entity links to registrar RDAP resources and merges results (bounded by hop limits).
|
|
147
153
|
- Normalizes registrar (from `entities`), contacts (vCard), nameservers (`ipAddresses`), events (created/changed/expiration), statuses, and DNSSEC (`secureDNS`).
|
|
148
154
|
- WHOIS
|
|
149
155
|
- Discovers the authoritative TLD WHOIS via `whois.iana.org` (TCP 43), with curated exceptions for tricky zones and public SLDs.
|
|
150
|
-
- Queries the TLD WHOIS
|
|
156
|
+
- Queries the TLD WHOIS and follows registrar referrals recursively up to `maxWhoisReferralHops` (unless disabled).
|
|
151
157
|
- Normalizes common key/value variants across gTLD/ccTLD formats (dates, statuses, nameservers, contacts). Availability is inferred from common phrases (best‑effort heuristic).
|
|
152
158
|
|
|
153
159
|
Timeouts are enforced per request using a simple race against `timeoutMs` (default 15s). All network I/O is performed with global `fetch` (RDAP) and a raw TCP socket (WHOIS).
|
package/dist/index.d.ts
CHANGED
|
@@ -32,21 +32,37 @@ interface StatusEvent {
|
|
|
32
32
|
raw?: string;
|
|
33
33
|
}
|
|
34
34
|
interface DomainRecord {
|
|
35
|
+
/** Normalized domain name */
|
|
35
36
|
domain: string;
|
|
37
|
+
/** Terminal TLD */
|
|
36
38
|
tld: string;
|
|
39
|
+
/** Whether the domain is registered */
|
|
37
40
|
isRegistered: boolean;
|
|
41
|
+
/** Whether the domain is internationalized (IDN) */
|
|
38
42
|
isIDN?: boolean;
|
|
43
|
+
/** Unicode name */
|
|
39
44
|
unicodeName?: string;
|
|
45
|
+
/** Punycode name */
|
|
40
46
|
punycodeName?: string;
|
|
47
|
+
/** Registry operator */
|
|
41
48
|
registry?: string;
|
|
49
|
+
/** Registrar */
|
|
42
50
|
registrar?: RegistrarInfo;
|
|
51
|
+
/** Reseller (if applicable) */
|
|
43
52
|
reseller?: string;
|
|
53
|
+
/** EPP status codes */
|
|
44
54
|
statuses?: StatusEvent[];
|
|
55
|
+
/** Creation date in ISO 8601 */
|
|
45
56
|
creationDate?: string;
|
|
57
|
+
/** Updated date in ISO 8601 */
|
|
46
58
|
updatedDate?: string;
|
|
59
|
+
/** Expiration date in ISO 8601 */
|
|
47
60
|
expirationDate?: string;
|
|
61
|
+
/** Deletion date in ISO 8601 */
|
|
48
62
|
deletionDate?: string;
|
|
63
|
+
/** Transfer lock */
|
|
49
64
|
transferLock?: boolean;
|
|
65
|
+
/** DNSSEC data (if available) */
|
|
50
66
|
dnssec?: {
|
|
51
67
|
enabled: boolean;
|
|
52
68
|
dsRecords?: Array<{
|
|
@@ -56,24 +72,51 @@ interface DomainRecord {
|
|
|
56
72
|
digest?: string;
|
|
57
73
|
}>;
|
|
58
74
|
};
|
|
75
|
+
/** Nameservers */
|
|
59
76
|
nameservers?: Nameserver[];
|
|
77
|
+
/** Contacts (registrant, admin, tech, billing, abuse, etc.) */
|
|
60
78
|
contacts?: Contact[];
|
|
79
|
+
/** Best guess as to whether registrant is redacted based on keywords */
|
|
80
|
+
privacyEnabled?: boolean;
|
|
81
|
+
/** Authoritative WHOIS queried (if any) */
|
|
61
82
|
whoisServer?: string;
|
|
83
|
+
/** RDAP base URLs tried */
|
|
62
84
|
rdapServers?: string[];
|
|
85
|
+
/** Raw RDAP JSON */
|
|
63
86
|
rawRdap?: unknown;
|
|
87
|
+
/** Raw WHOIS text (last authoritative) */
|
|
64
88
|
rawWhois?: string;
|
|
89
|
+
/** Which source produced data */
|
|
65
90
|
source: LookupSource;
|
|
91
|
+
/** ISO 8601 timestamp at time of lookup */
|
|
66
92
|
fetchedAt: string;
|
|
93
|
+
/** Warnings generated during lookup */
|
|
67
94
|
warnings?: string[];
|
|
68
95
|
}
|
|
69
96
|
interface LookupOptions {
|
|
97
|
+
/** Total timeout budget */
|
|
70
98
|
timeoutMs?: number;
|
|
99
|
+
/** Don't fall back to WHOIS */
|
|
71
100
|
rdapOnly?: boolean;
|
|
101
|
+
/** Don't attempt RDAP */
|
|
72
102
|
whoisOnly?: boolean;
|
|
103
|
+
/** Follow referral server (default true) */
|
|
73
104
|
followWhoisReferral?: boolean;
|
|
105
|
+
/** Maximum registrar WHOIS referral hops (default 2) */
|
|
106
|
+
maxWhoisReferralHops?: number;
|
|
107
|
+
/** Follow RDAP related/entity links (default true) */
|
|
108
|
+
rdapFollowLinks?: boolean;
|
|
109
|
+
/** Maximum RDAP related link fetches (default 2) */
|
|
110
|
+
maxRdapLinkHops?: number;
|
|
111
|
+
/** RDAP link rels to consider (default ["related","entity","registrar","alternate"]) */
|
|
112
|
+
rdapLinkRels?: string[];
|
|
113
|
+
/** Override IANA bootstrap */
|
|
74
114
|
customBootstrapUrl?: string;
|
|
115
|
+
/** Override/add authoritative WHOIS per TLD */
|
|
75
116
|
whoisHints?: Record<string, string>;
|
|
117
|
+
/** Include rawRdap/rawWhois in results (default false) */
|
|
76
118
|
includeRaw?: boolean;
|
|
119
|
+
/** Optional cancellation signal */
|
|
77
120
|
signal?: AbortSignal;
|
|
78
121
|
}
|
|
79
122
|
interface LookupResult {
|
package/dist/index.js
CHANGED
|
@@ -9,14 +9,14 @@ function toISO(dateLike) {
|
|
|
9
9
|
const raw = String(dateLike).trim();
|
|
10
10
|
if (!raw) return void 0;
|
|
11
11
|
for (const re of [
|
|
12
|
-
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z)?$/,
|
|
13
|
-
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})
|
|
12
|
+
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
|
|
13
|
+
/^(\d{4})\/(\d{2})\/(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:Z|([+-]\d{2})(?::?(\d{2}))?)?$/,
|
|
14
14
|
/^(\d{2})-([A-Za-z]{3})-(\d{4})$/,
|
|
15
15
|
/^([A-Za-z]{3})\s+(\d{1,2})\s+(\d{4})$/
|
|
16
16
|
]) {
|
|
17
17
|
const m = raw.match(re);
|
|
18
18
|
if (!m) continue;
|
|
19
|
-
const d =
|
|
19
|
+
const d = parseDateWithRegex(m, re);
|
|
20
20
|
if (d) return toIsoFromDate(d);
|
|
21
21
|
}
|
|
22
22
|
const native = new Date(raw);
|
|
@@ -29,7 +29,7 @@ function toIsoFromDate(d) {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
function
|
|
32
|
+
function parseDateWithRegex(m, _re) {
|
|
33
33
|
const monthMap = {
|
|
34
34
|
jan: 0,
|
|
35
35
|
feb: 1,
|
|
@@ -46,8 +46,16 @@ function parseWithRegex(m, _re) {
|
|
|
46
46
|
};
|
|
47
47
|
try {
|
|
48
48
|
if (m[0].includes(":")) {
|
|
49
|
-
const [_$1, y, mo, d, hh, mm, ss] = m;
|
|
50
|
-
|
|
49
|
+
const [_$1, y, mo, d, hh, mm, ss, offH, offM] = m;
|
|
50
|
+
let dt = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mm), Number(ss));
|
|
51
|
+
if (offH) {
|
|
52
|
+
const sign = offH.startsWith("-") ? -1 : 1;
|
|
53
|
+
const hours = Math.abs(Number(offH));
|
|
54
|
+
const minutes = offM ? Number(offM) : 0;
|
|
55
|
+
const offsetMs = sign * (hours * 60 + minutes) * 60 * 1e3;
|
|
56
|
+
dt -= offsetMs;
|
|
57
|
+
}
|
|
58
|
+
return new Date(dt);
|
|
51
59
|
}
|
|
52
60
|
if (m[0].includes("-")) {
|
|
53
61
|
const [_$1, dd$1, monStr$1, yyyy$1] = m;
|
|
@@ -167,6 +175,149 @@ async function fetchRdapDomain(domain, baseUrl, options) {
|
|
|
167
175
|
};
|
|
168
176
|
}
|
|
169
177
|
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/rdap/links.ts
|
|
180
|
+
/** Extract candidate RDAP link URLs from an RDAP document. */
|
|
181
|
+
function extractRdapRelatedLinks(doc, opts) {
|
|
182
|
+
const rels = (opts?.rdapLinkRels?.length ? opts.rdapLinkRels : [
|
|
183
|
+
"related",
|
|
184
|
+
"entity",
|
|
185
|
+
"registrar",
|
|
186
|
+
"alternate"
|
|
187
|
+
]).map((r) => r.toLowerCase());
|
|
188
|
+
const d = doc ?? {};
|
|
189
|
+
const arr = Array.isArray(d?.links) ? d.links : [];
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const link of arr) {
|
|
192
|
+
const rel = String(link.rel || "").toLowerCase();
|
|
193
|
+
const type = String(link.type || "").toLowerCase();
|
|
194
|
+
if (!rels.includes(rel)) continue;
|
|
195
|
+
if (type && !/application\/rdap\+json/i.test(type)) continue;
|
|
196
|
+
const url = link.href || link.value;
|
|
197
|
+
if (url && /^https?:\/\//i.test(url)) out.push(url);
|
|
198
|
+
}
|
|
199
|
+
return Array.from(new Set(out));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/rdap/merge.ts
|
|
204
|
+
/** Merge RDAP documents with a conservative, additive strategy. */
|
|
205
|
+
function mergeRdapDocs(baseDoc, others) {
|
|
206
|
+
const merged = { ...baseDoc };
|
|
207
|
+
for (const doc of others) {
|
|
208
|
+
const cur = doc ?? {};
|
|
209
|
+
merged.status = uniqStrings([...toStringArray(merged.status), ...toStringArray(cur.status)]);
|
|
210
|
+
merged.events = uniqBy([...toArray(merged.events), ...toArray(cur.events)], (e) => `${String(e?.eventAction ?? "").toLowerCase()}|${String(e?.eventDate ?? "")}`);
|
|
211
|
+
merged.nameservers = uniqBy([...toArray(merged.nameservers), ...toArray(cur.nameservers)], (n) => `${String(n?.ldhName ?? n?.unicodeName ?? "").toLowerCase()}`);
|
|
212
|
+
merged.entities = uniqBy([...toArray(merged.entities), ...toArray(cur.entities)], (e) => `${String(e?.handle ?? "").toLowerCase()}|${String(JSON.stringify(e?.roles || [])).toLowerCase()}|${String(JSON.stringify(e?.vcardArray || [])).toLowerCase()}`);
|
|
213
|
+
if (merged.secureDNS == null && cur.secureDNS != null) merged.secureDNS = cur.secureDNS;
|
|
214
|
+
if (merged.port43 == null && cur.port43 != null) merged.port43 = cur.port43;
|
|
215
|
+
const mergedRemarks = merged.remarks;
|
|
216
|
+
const curRemarks = cur.remarks;
|
|
217
|
+
if (Array.isArray(mergedRemarks) || Array.isArray(curRemarks)) {
|
|
218
|
+
const a = toArray(mergedRemarks);
|
|
219
|
+
const b = toArray(curRemarks);
|
|
220
|
+
merged.remarks = [...a, ...b];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return merged;
|
|
224
|
+
}
|
|
225
|
+
/** Fetch and merge RDAP related documents up to a hop limit. */
|
|
226
|
+
async function fetchAndMergeRdapRelated(domain, baseDoc, opts) {
|
|
227
|
+
const tried = [];
|
|
228
|
+
if (opts?.rdapFollowLinks === false) return {
|
|
229
|
+
merged: baseDoc,
|
|
230
|
+
serversTried: tried
|
|
231
|
+
};
|
|
232
|
+
const maxHops = Math.max(0, opts?.maxRdapLinkHops ?? 2);
|
|
233
|
+
if (maxHops === 0) return {
|
|
234
|
+
merged: baseDoc,
|
|
235
|
+
serversTried: tried
|
|
236
|
+
};
|
|
237
|
+
const visited = /* @__PURE__ */ new Set();
|
|
238
|
+
let current = baseDoc;
|
|
239
|
+
let hops = 0;
|
|
240
|
+
while (hops < maxHops) {
|
|
241
|
+
const nextBatch = extractRdapRelatedLinks(current, { rdapLinkRels: opts?.rdapLinkRels }).filter((u) => !visited.has(u));
|
|
242
|
+
if (nextBatch.length === 0) break;
|
|
243
|
+
const fetchedDocs = [];
|
|
244
|
+
for (const url of nextBatch) {
|
|
245
|
+
visited.add(url);
|
|
246
|
+
try {
|
|
247
|
+
const { json } = await fetchRdapUrl(url, opts);
|
|
248
|
+
tried.push(url);
|
|
249
|
+
const ldh = String(json?.ldhName ?? "").toLowerCase();
|
|
250
|
+
const uni = String(json?.unicodeName ?? "").toLowerCase();
|
|
251
|
+
if (ldh && !sameDomain(ldh, domain)) continue;
|
|
252
|
+
if (uni && !sameDomain(uni, domain)) continue;
|
|
253
|
+
fetchedDocs.push(json);
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
if (fetchedDocs.length === 0) break;
|
|
257
|
+
current = mergeRdapDocs(current, fetchedDocs);
|
|
258
|
+
hops += 1;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
merged: current,
|
|
262
|
+
serversTried: tried
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function fetchRdapUrl(url, options) {
|
|
266
|
+
const res = await withTimeout(fetch(url, {
|
|
267
|
+
method: "GET",
|
|
268
|
+
headers: { accept: "application/rdap+json, application/json" },
|
|
269
|
+
signal: options?.signal
|
|
270
|
+
}), options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, "RDAP link fetch timeout");
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const bodyText = await res.text();
|
|
273
|
+
throw new Error(`RDAP ${res.status}: ${bodyText.slice(0, 500)}`);
|
|
274
|
+
}
|
|
275
|
+
const json = await res.json();
|
|
276
|
+
return {
|
|
277
|
+
url,
|
|
278
|
+
json
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function toArray(val) {
|
|
282
|
+
return Array.isArray(val) ? val : [];
|
|
283
|
+
}
|
|
284
|
+
function toStringArray(val) {
|
|
285
|
+
return Array.isArray(val) ? val.map((v) => String(v)) : [];
|
|
286
|
+
}
|
|
287
|
+
function uniqStrings(arr) {
|
|
288
|
+
return Array.from(new Set(arr));
|
|
289
|
+
}
|
|
290
|
+
function uniqBy(arr, key) {
|
|
291
|
+
const seen = /* @__PURE__ */ new Set();
|
|
292
|
+
const out = [];
|
|
293
|
+
for (const item of arr) {
|
|
294
|
+
const k = key(item);
|
|
295
|
+
if (seen.has(k)) continue;
|
|
296
|
+
seen.add(k);
|
|
297
|
+
out.push(item);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
function sameDomain(a, b) {
|
|
302
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
//#endregion
|
|
306
|
+
//#region src/lib/privacy.ts
|
|
307
|
+
const PRIVACY_NAME_KEYWORDS = [
|
|
308
|
+
"redacted",
|
|
309
|
+
"privacy",
|
|
310
|
+
"private",
|
|
311
|
+
"withheld",
|
|
312
|
+
"not disclosed",
|
|
313
|
+
"protected",
|
|
314
|
+
"protection"
|
|
315
|
+
];
|
|
316
|
+
function isPrivacyName(value) {
|
|
317
|
+
const v = value.toLowerCase();
|
|
318
|
+
return PRIVACY_NAME_KEYWORDS.some((k) => v.includes(k));
|
|
319
|
+
}
|
|
320
|
+
|
|
170
321
|
//#endregion
|
|
171
322
|
//#region src/lib/text.ts
|
|
172
323
|
function uniq(arr) {
|
|
@@ -247,6 +398,8 @@ function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, fetchedAtISO, i
|
|
|
247
398
|
return n;
|
|
248
399
|
}).filter((n) => !!n.host) : void 0;
|
|
249
400
|
const contacts = extractContacts(doc.entities);
|
|
401
|
+
const registrant = contacts?.find((c) => c.type === "registrant");
|
|
402
|
+
const privacyEnabled = !!(registrant && [registrant.name, registrant.organization].filter(Boolean).some(isPrivacyName));
|
|
250
403
|
const statuses = Array.isArray(doc.status) ? doc.status.filter((s) => typeof s === "string").map((s) => ({
|
|
251
404
|
status: s,
|
|
252
405
|
raw: s
|
|
@@ -291,6 +444,7 @@ function normalizeRdap(inputDomain, tld, rdap, rdapServersTried, fetchedAtISO, i
|
|
|
291
444
|
host: n.host.toLowerCase()
|
|
292
445
|
}))) : void 0,
|
|
293
446
|
contacts,
|
|
447
|
+
privacyEnabled: privacyEnabled ? true : void 0,
|
|
294
448
|
whoisServer,
|
|
295
449
|
rdapServers: rdapServersTried,
|
|
296
450
|
rawRdap: includeRaw ? rdap : void 0,
|
|
@@ -592,18 +746,26 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
|
|
|
592
746
|
"domain registration date",
|
|
593
747
|
"domain create date",
|
|
594
748
|
"created",
|
|
595
|
-
"registered"
|
|
749
|
+
"registered",
|
|
750
|
+
"domain record activated"
|
|
596
751
|
]);
|
|
597
752
|
const updatedDate = anyValue(map, [
|
|
598
753
|
"updated date",
|
|
599
754
|
"last updated",
|
|
600
755
|
"last modified",
|
|
601
|
-
"modified"
|
|
756
|
+
"modified",
|
|
757
|
+
"domain record last updated"
|
|
602
758
|
]);
|
|
603
759
|
const expirationDate = anyValue(map, [
|
|
604
760
|
"registry expiry date",
|
|
761
|
+
"registry expiration date",
|
|
605
762
|
"expiry date",
|
|
606
763
|
"expiration date",
|
|
764
|
+
"registrar registration expiration date",
|
|
765
|
+
"registrar registration expiry date",
|
|
766
|
+
"registrar expiration date",
|
|
767
|
+
"registrar expiry date",
|
|
768
|
+
"domain expires",
|
|
607
769
|
"paid-till",
|
|
608
770
|
"expires on",
|
|
609
771
|
"renewal date"
|
|
@@ -656,6 +818,8 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
|
|
|
656
818
|
return ns;
|
|
657
819
|
}).filter((x) => !!x)) : void 0;
|
|
658
820
|
const contacts = collectContacts(map);
|
|
821
|
+
const registrant = contacts?.find((c) => c.type === "registrant");
|
|
822
|
+
const privacyEnabled = !!(registrant && [registrant.name, registrant.organization].filter(Boolean).some(isPrivacyName));
|
|
659
823
|
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
|
|
660
824
|
const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : void 0;
|
|
661
825
|
const transferLock = !!statuses?.some((s) => /transferprohibited/i.test(s.status));
|
|
@@ -678,6 +842,7 @@ function normalizeWhois(domain, tld, whoisText, whoisServer, fetchedAtISO, inclu
|
|
|
678
842
|
dnssec,
|
|
679
843
|
nameservers,
|
|
680
844
|
contacts,
|
|
845
|
+
privacyEnabled: privacyEnabled ? true : void 0,
|
|
681
846
|
whoisServer,
|
|
682
847
|
rdapServers: void 0,
|
|
683
848
|
rawRdap: void 0,
|
|
@@ -771,6 +936,37 @@ function multi(map, keys) {
|
|
|
771
936
|
}
|
|
772
937
|
}
|
|
773
938
|
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region src/whois/referral.ts
|
|
941
|
+
/**
|
|
942
|
+
* Follow registrar WHOIS referrals up to a configured hop limit.
|
|
943
|
+
* Returns the last successful WHOIS response (best-effort; keeps original on failures).
|
|
944
|
+
*/
|
|
945
|
+
async function followWhoisReferrals(initialServer, domain, opts) {
|
|
946
|
+
const maxHops = Math.max(0, opts?.maxWhoisReferralHops ?? 2);
|
|
947
|
+
let current = await whoisQuery(initialServer, domain, opts);
|
|
948
|
+
if (opts?.followWhoisReferral === false || maxHops === 0) return current;
|
|
949
|
+
const visited = new Set([normalize(current.serverQueried)]);
|
|
950
|
+
let hops = 0;
|
|
951
|
+
while (hops < maxHops) {
|
|
952
|
+
const next = extractWhoisReferral(current.text);
|
|
953
|
+
if (!next) break;
|
|
954
|
+
const normalized = normalize(next);
|
|
955
|
+
if (visited.has(normalized)) break;
|
|
956
|
+
visited.add(normalized);
|
|
957
|
+
try {
|
|
958
|
+
current = await whoisQuery(next, domain, opts);
|
|
959
|
+
} catch {
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
hops += 1;
|
|
963
|
+
}
|
|
964
|
+
return current;
|
|
965
|
+
}
|
|
966
|
+
function normalize(server) {
|
|
967
|
+
return server.replace(/^whois:\/\//i, "").toLowerCase();
|
|
968
|
+
}
|
|
969
|
+
|
|
774
970
|
//#endregion
|
|
775
971
|
//#region src/index.ts
|
|
776
972
|
/**
|
|
@@ -792,9 +988,10 @@ async function lookupDomain(domain, opts) {
|
|
|
792
988
|
tried.push(base);
|
|
793
989
|
try {
|
|
794
990
|
const { json } = await fetchRdapDomain(domain, base, opts);
|
|
991
|
+
const rdapEnriched = await fetchAndMergeRdapRelated(domain, json, opts);
|
|
795
992
|
return {
|
|
796
993
|
ok: true,
|
|
797
|
-
record: normalizeRdap(domain, tld,
|
|
994
|
+
record: normalizeRdap(domain, tld, rdapEnriched.merged, [...tried, ...rdapEnriched.serversTried], now, !!opts?.includeRaw)
|
|
798
995
|
};
|
|
799
996
|
} catch {}
|
|
800
997
|
}
|
|
@@ -813,13 +1010,7 @@ async function lookupDomain(domain, opts) {
|
|
|
813
1010
|
error: `No WHOIS server discovered for TLD '${tld}'. This registry may not publish public WHOIS over port 43.${hint}`
|
|
814
1011
|
};
|
|
815
1012
|
}
|
|
816
|
-
|
|
817
|
-
if (opts?.followWhoisReferral !== false) {
|
|
818
|
-
const referral = extractWhoisReferral(res.text);
|
|
819
|
-
if (referral && referral.toLowerCase() !== whoisServer.toLowerCase()) try {
|
|
820
|
-
res = await whoisQuery(referral, domain, opts);
|
|
821
|
-
} catch {}
|
|
822
|
-
}
|
|
1013
|
+
const res = await followWhoisReferrals(whoisServer, domain, opts);
|
|
823
1014
|
if (publicSuffix.includes(".") && /no match|not found/i.test(res.text) && opts?.followWhoisReferral !== false) {
|
|
824
1015
|
const candidates = [];
|
|
825
1016
|
const ps = publicSuffix.toLowerCase();
|
|
@@ -827,10 +1018,10 @@ async function lookupDomain(domain, opts) {
|
|
|
827
1018
|
if (exception) candidates.push(exception);
|
|
828
1019
|
for (const server of candidates) try {
|
|
829
1020
|
const alt = await whoisQuery(server, domain, opts);
|
|
830
|
-
if (alt.text && !/error/i.test(alt.text)) {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
1021
|
+
if (alt.text && !/error/i.test(alt.text)) return {
|
|
1022
|
+
ok: true,
|
|
1023
|
+
record: normalizeWhois(domain, tld, alt.text, alt.serverQueried, now, !!opts?.includeRaw)
|
|
1024
|
+
};
|
|
834
1025
|
} catch {}
|
|
835
1026
|
}
|
|
836
1027
|
return {
|