domainstorm 0.2.1 → 0.2.3
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 +53 -5
- package/check-domains.mjs +476 -89
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ It is built for AI and agent products where naming cycles happen fast and every
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npm i -g domainstorm
|
|
22
|
+
npm i -g domainstorm@latest
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
Run instantly without global install:
|
|
@@ -28,26 +28,41 @@ Run instantly without global install:
|
|
|
28
28
|
npx --yes domainstorm --help
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
Update to latest:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm update -g domainstorm
|
|
35
|
+
```
|
|
36
|
+
|
|
31
37
|
## Quick Wins
|
|
32
38
|
|
|
33
39
|
Brainstorm + check + return only likely available:
|
|
34
40
|
|
|
35
41
|
```bash
|
|
36
|
-
domainstorm --brainstorm "agent orchestration" "mcp broker" --tld md --server whois.nic.md --only-available
|
|
42
|
+
domainstorm --brainstorm "agent orchestration" "mcp broker" --tld md --server whois.nic.md --dns-prefilter --only-available --table
|
|
37
43
|
```
|
|
38
44
|
|
|
39
45
|
Check exact domains:
|
|
40
46
|
|
|
41
47
|
```bash
|
|
42
|
-
domainstorm
|
|
48
|
+
domainstorm broker.md --server whois.nic.md --dns-prefilter
|
|
43
49
|
```
|
|
44
50
|
|
|
51
|
+
Default output is plain English (for example: `✅ broker.md is available.`).
|
|
52
|
+
Use `--table` if you want a structured table view.
|
|
53
|
+
|
|
45
54
|
Check from file and export CSV:
|
|
46
55
|
|
|
47
56
|
```bash
|
|
48
57
|
domainstorm --input domains.txt --output /tmp/domainstorm.csv
|
|
49
58
|
```
|
|
50
59
|
|
|
60
|
+
Try immediately with the bundled candidate list:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
domainstorm --input agent-candidates.txt --tld md --server whois.nic.md --only-available --table
|
|
64
|
+
```
|
|
65
|
+
|
|
51
66
|
## Built For Agent Workflows
|
|
52
67
|
|
|
53
68
|
Use Domainstorm when your coding/research agent needs to propose names and validate them in the same run.
|
|
@@ -56,6 +71,7 @@ Use Domainstorm when your coding/research agent needs to propose names and valid
|
|
|
56
71
|
- Works with shell pipelines
|
|
57
72
|
- No external AI API keys required
|
|
58
73
|
- Easy to trigger in CI on release or branch workflows
|
|
74
|
+
- No seed ideas? Ask your agent of choice (Codex, Claude, etc.) for seed phrases and pass them to `--brainstorm`
|
|
59
75
|
|
|
60
76
|
Compatibility alias:
|
|
61
77
|
|
|
@@ -63,11 +79,29 @@ Compatibility alias:
|
|
|
63
79
|
domain-check --help
|
|
64
80
|
```
|
|
65
81
|
|
|
82
|
+
## Local Dev Test (npm)
|
|
83
|
+
|
|
84
|
+
Run the current repo version without publishing:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm run check
|
|
88
|
+
npm link
|
|
89
|
+
domainstorm --help
|
|
90
|
+
domainstorm broker.md --table
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Remove local link when done:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm unlink -g domainstorm
|
|
97
|
+
```
|
|
98
|
+
|
|
66
99
|
## Example Output
|
|
67
100
|
|
|
68
101
|
```txt
|
|
69
|
-
|
|
70
|
-
agentops.md
|
|
102
|
+
✅ broker.md is available.
|
|
103
|
+
❌ agentops.md is not available (already registered).
|
|
104
|
+
Summary: ✅ 1 available, ❌ 1 taken, ⚠️ 0 unknown.
|
|
71
105
|
```
|
|
72
106
|
|
|
73
107
|
Output columns:
|
|
@@ -75,6 +109,7 @@ Output columns:
|
|
|
75
109
|
- `domain`
|
|
76
110
|
- `status` (`registered`, `likely_available`, `unknown`)
|
|
77
111
|
- `reason`
|
|
112
|
+
- `state` (registry/DNS state when available)
|
|
78
113
|
- `legal_risk` (`low`, `high_tm_risk`)
|
|
79
114
|
- `story` (brainstorm narrative tag)
|
|
80
115
|
- `error`
|
|
@@ -83,10 +118,13 @@ Output columns:
|
|
|
83
118
|
|
|
84
119
|
- `--brainstorm` / `--storm`
|
|
85
120
|
- `--max-suggestions <n>` (default: `120`)
|
|
121
|
+
- `--table`
|
|
122
|
+
- `--plain`
|
|
86
123
|
- `--input <file>`
|
|
87
124
|
- `--output <file>`
|
|
88
125
|
- `--tld <tld>` (default: `md`)
|
|
89
126
|
- `--server <whois-host>`
|
|
127
|
+
- `--dns-prefilter`
|
|
90
128
|
- `--only-available`
|
|
91
129
|
- `--concurrency <n>`
|
|
92
130
|
- `--timeout-ms <n>`
|
|
@@ -132,8 +170,18 @@ npm login
|
|
|
132
170
|
npm publish --access public
|
|
133
171
|
```
|
|
134
172
|
|
|
173
|
+
If local provenance is unsupported in your shell/provider, use:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm publish --access public --provenance=false
|
|
177
|
+
```
|
|
178
|
+
|
|
135
179
|
## Notes
|
|
136
180
|
|
|
137
181
|
- WHOIS formats vary by registry; treat `likely_available` as a pre-check, not final registrar checkout.
|
|
138
182
|
- Registries can rate-limit bulk lookups; retry `unknown` rows after cooldown.
|
|
183
|
+
- Domainstorm includes registry adapters for `.md`, `.com`, `.org`, and `.ai` to improve state parsing.
|
|
184
|
+
- Domainstorm auto-throttles WHOIS to concurrency `2` across TLDs (override with `--concurrency`).
|
|
185
|
+
- For `.md`, `Domain state` is parsed explicitly (`Inactive`, `OK`, `OK Delegated`, and no-match states).
|
|
186
|
+
- `--dns-prefilter` runs DNS first and marks resolving domains as taken before WHOIS (faster and lighter on WHOIS quota).
|
|
139
187
|
- Install `whois` locally (`brew install whois` on macOS).
|
package/check-domains.mjs
CHANGED
|
@@ -5,9 +5,11 @@ import path from "node:path";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_CONCURRENCY = 6;
|
|
8
|
+
const SAFE_WHOIS_CONCURRENCY = 2;
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 12000;
|
|
9
10
|
const DEFAULT_TLD = "md";
|
|
10
11
|
const DEFAULT_MAX_SUGGESTIONS = 120;
|
|
12
|
+
const MD_WHOIS_SERVER = "whois.nic.md";
|
|
11
13
|
const AVAILABILITY_PATTERNS = [
|
|
12
14
|
"no match for",
|
|
13
15
|
"not found",
|
|
@@ -75,59 +77,6 @@ const STOP_WORDS = new Set([
|
|
|
75
77
|
"agents",
|
|
76
78
|
]);
|
|
77
79
|
|
|
78
|
-
const DEFAULT_STORM_SEEDS = [
|
|
79
|
-
"agent",
|
|
80
|
-
"mcp",
|
|
81
|
-
"ai",
|
|
82
|
-
"broker",
|
|
83
|
-
"router",
|
|
84
|
-
"runtime",
|
|
85
|
-
"ops",
|
|
86
|
-
"stack",
|
|
87
|
-
"forge",
|
|
88
|
-
"mesh",
|
|
89
|
-
"pilot",
|
|
90
|
-
"flow",
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
const STORM_SUFFIXES = [
|
|
94
|
-
"storm",
|
|
95
|
-
"ops",
|
|
96
|
-
"hub",
|
|
97
|
-
"mesh",
|
|
98
|
-
"forge",
|
|
99
|
-
"stack",
|
|
100
|
-
"pilot",
|
|
101
|
-
"route",
|
|
102
|
-
"router",
|
|
103
|
-
"broker",
|
|
104
|
-
"runtime",
|
|
105
|
-
"engine",
|
|
106
|
-
"works",
|
|
107
|
-
"cloud",
|
|
108
|
-
"grid",
|
|
109
|
-
"terminal",
|
|
110
|
-
"dock",
|
|
111
|
-
"labs",
|
|
112
|
-
"studio",
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
const STORM_PREFIXES = [
|
|
116
|
-
"agent",
|
|
117
|
-
"ai",
|
|
118
|
-
"mcp",
|
|
119
|
-
"task",
|
|
120
|
-
"flow",
|
|
121
|
-
"auto",
|
|
122
|
-
"tool",
|
|
123
|
-
"prompt",
|
|
124
|
-
"context",
|
|
125
|
-
"trace",
|
|
126
|
-
"guard",
|
|
127
|
-
"secure",
|
|
128
|
-
"fleet",
|
|
129
|
-
];
|
|
130
|
-
|
|
131
80
|
// Small, non-authoritative heuristic list for quick legal-risk triage.
|
|
132
81
|
const TRADEMARK_KEYWORDS = [
|
|
133
82
|
"openai",
|
|
@@ -165,16 +114,22 @@ function printUsage() {
|
|
|
165
114
|
" --output <file> Optional CSV output path",
|
|
166
115
|
" --tld <tld> Default TLD for bare labels (default: md)",
|
|
167
116
|
" --server <host> Optional WHOIS server (ex: whois.nic.md)",
|
|
168
|
-
" --concurrency <n> Parallel WHOIS requests (default: 6)",
|
|
117
|
+
" --concurrency <n> Parallel WHOIS requests (default: 6, auto-throttled to 2 unless set)",
|
|
169
118
|
" --timeout-ms <n> WHOIS timeout per domain (default: 12000)",
|
|
119
|
+
" --dns-prefilter Run DNS pre-check (A/AAAA/CNAME); skip WHOIS for resolving domains",
|
|
170
120
|
" --brainstorm Generate domain ideas from seeds, then check availability",
|
|
171
121
|
" --max-suggestions <n> Max brainstormed candidates (default: 120)",
|
|
122
|
+
" --table Render results in a readable terminal table",
|
|
123
|
+
" --plain Emit machine-friendly TSV output",
|
|
172
124
|
" --only-available Print only likely available domains",
|
|
173
125
|
" --raw Include WHOIS snippet in console output",
|
|
174
126
|
" --help Show this help",
|
|
175
127
|
"",
|
|
176
128
|
"Notes:",
|
|
177
129
|
" - Labels without a dot are auto-converted to <label>.<tld>",
|
|
130
|
+
" - WHOIS is auto-throttled to concurrency=2 unless you set --concurrency",
|
|
131
|
+
" - Brainstorm mode uses your seed words only (no hardcoded keyword packs)",
|
|
132
|
+
" - No seed ideas? Ask your agent (Codex, Claude, etc.) and pass them to --brainstorm",
|
|
178
133
|
" - WHOIS-based checks are heuristic; always confirm at registrar checkout",
|
|
179
134
|
].join("\n"),
|
|
180
135
|
);
|
|
@@ -185,11 +140,15 @@ function parseArgs(argv) {
|
|
|
185
140
|
input: null,
|
|
186
141
|
output: null,
|
|
187
142
|
concurrency: DEFAULT_CONCURRENCY,
|
|
143
|
+
concurrencyExplicit: false,
|
|
188
144
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
189
145
|
tld: DEFAULT_TLD,
|
|
190
146
|
server: null,
|
|
147
|
+
dnsPrefilter: false,
|
|
191
148
|
brainstorm: false,
|
|
192
149
|
maxSuggestions: DEFAULT_MAX_SUGGESTIONS,
|
|
150
|
+
table: false,
|
|
151
|
+
plain: false,
|
|
193
152
|
onlyAvailable: false,
|
|
194
153
|
raw: false,
|
|
195
154
|
labels: [],
|
|
@@ -209,6 +168,7 @@ function parseArgs(argv) {
|
|
|
209
168
|
case "--concurrency":
|
|
210
169
|
case "-c":
|
|
211
170
|
options.concurrency = Number.parseInt(argv[++i], 10);
|
|
171
|
+
options.concurrencyExplicit = true;
|
|
212
172
|
break;
|
|
213
173
|
case "--tld":
|
|
214
174
|
options.tld = argv[++i];
|
|
@@ -219,6 +179,9 @@ function parseArgs(argv) {
|
|
|
219
179
|
case "--timeout-ms":
|
|
220
180
|
options.timeoutMs = Number.parseInt(argv[++i], 10);
|
|
221
181
|
break;
|
|
182
|
+
case "--dns-prefilter":
|
|
183
|
+
options.dnsPrefilter = true;
|
|
184
|
+
break;
|
|
222
185
|
case "--brainstorm":
|
|
223
186
|
case "--storm":
|
|
224
187
|
options.brainstorm = true;
|
|
@@ -226,6 +189,12 @@ function parseArgs(argv) {
|
|
|
226
189
|
case "--max-suggestions":
|
|
227
190
|
options.maxSuggestions = Number.parseInt(argv[++i], 10);
|
|
228
191
|
break;
|
|
192
|
+
case "--table":
|
|
193
|
+
options.table = true;
|
|
194
|
+
break;
|
|
195
|
+
case "--plain":
|
|
196
|
+
options.plain = true;
|
|
197
|
+
break;
|
|
229
198
|
case "--only-available":
|
|
230
199
|
options.onlyAvailable = true;
|
|
231
200
|
break;
|
|
@@ -249,7 +218,9 @@ function parseArgs(argv) {
|
|
|
249
218
|
}
|
|
250
219
|
|
|
251
220
|
if (!options.input && options.labels.length === 0 && !options.brainstorm) {
|
|
252
|
-
throw new Error(
|
|
221
|
+
throw new Error(
|
|
222
|
+
"Provide --input <file>, one/more labels/domains, or use --brainstorm. Tip: ask your agent (Codex, Claude, etc.) for seed phrases.",
|
|
223
|
+
);
|
|
253
224
|
}
|
|
254
225
|
if (!Number.isInteger(options.concurrency) || options.concurrency < 1 || options.concurrency > 50) {
|
|
255
226
|
throw new Error("--concurrency must be an integer between 1 and 50.");
|
|
@@ -349,7 +320,12 @@ function tokenizeSeeds(rawValues) {
|
|
|
349
320
|
|
|
350
321
|
function brainstormCandidates(rawSeeds, maxSuggestions) {
|
|
351
322
|
const seedTokens = tokenizeSeeds(rawSeeds);
|
|
352
|
-
|
|
323
|
+
if (seedTokens.length === 0) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
"Brainstorm mode needs seed words. Ask your agent of choice (Codex, Claude, etc.) for naming seeds, then run: --brainstorm \"seed one\" \"seed two\"",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const seedPool = Array.from(new Set(seedTokens)).slice(0, 24);
|
|
353
329
|
const scored = new Map();
|
|
354
330
|
|
|
355
331
|
function add(label, story, bonus = 0) {
|
|
@@ -368,24 +344,67 @@ function brainstormCandidates(rawSeeds, maxSuggestions) {
|
|
|
368
344
|
}
|
|
369
345
|
}
|
|
370
346
|
|
|
347
|
+
function shortForm(token) {
|
|
348
|
+
if (token.length < 5) {
|
|
349
|
+
return token;
|
|
350
|
+
}
|
|
351
|
+
const withoutVowels = token[0] + token.slice(1).replace(/[aeiou]/g, "");
|
|
352
|
+
if (withoutVowels.length >= 3 && withoutVowels.length < token.length) {
|
|
353
|
+
return withoutVowels;
|
|
354
|
+
}
|
|
355
|
+
return token;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const formsBySeed = new Map();
|
|
371
359
|
for (const seed of seedPool) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
add(
|
|
360
|
+
const forms = new Set([seed, shortForm(seed)]);
|
|
361
|
+
if (seed.length > 8) {
|
|
362
|
+
forms.add(seed.slice(0, 7));
|
|
375
363
|
}
|
|
376
|
-
|
|
377
|
-
|
|
364
|
+
formsBySeed.set(seed, Array.from(forms));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const seed of seedPool) {
|
|
368
|
+
for (const form of formsBySeed.get(seed)) {
|
|
369
|
+
add(form, form === seed ? "Seed keyword" : "Compressed variant", form === seed ? 18 : 10);
|
|
378
370
|
}
|
|
379
371
|
}
|
|
380
372
|
|
|
381
373
|
for (let i = 0; i < seedPool.length; i += 1) {
|
|
382
|
-
for (let j =
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
374
|
+
for (let j = 0; j < seedPool.length; j += 1) {
|
|
375
|
+
if (i === j) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const aForms = formsBySeed.get(seedPool[i]);
|
|
379
|
+
const bForms = formsBySeed.get(seedPool[j]);
|
|
380
|
+
for (const a of aForms) {
|
|
381
|
+
for (const b of bForms) {
|
|
382
|
+
add(`${a}${b}`, "Two-seed compound", 20);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (let i = 0; i < seedPool.length; i += 1) {
|
|
389
|
+
for (let j = 0; j < seedPool.length; j += 1) {
|
|
390
|
+
for (let k = 0; k < seedPool.length; k += 1) {
|
|
391
|
+
if (i === j || j === k || i === k) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const a = formsBySeed.get(seedPool[i])[0];
|
|
395
|
+
const b = formsBySeed.get(seedPool[j])[0];
|
|
396
|
+
const c = formsBySeed.get(seedPool[k])[0];
|
|
397
|
+
add(`${a}${b}${c}`, "Three-seed compound", 10);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const acronym = seedPool.map((token) => token[0]).join("");
|
|
403
|
+
if (acronym.length >= 3) {
|
|
404
|
+
add(acronym, "Seed acronym", 16);
|
|
405
|
+
for (const seed of seedPool) {
|
|
406
|
+
add(`${acronym}${seed}`, "Acronym + seed", 12);
|
|
407
|
+
add(`${seed}${acronym}`, "Seed + acronym", 12);
|
|
389
408
|
}
|
|
390
409
|
}
|
|
391
410
|
|
|
@@ -396,11 +415,102 @@ function brainstormCandidates(rawSeeds, maxSuggestions) {
|
|
|
396
415
|
return {
|
|
397
416
|
labels: sorted.map((item) => item.label),
|
|
398
417
|
storyByLabel: new Map(sorted.map((item) => [item.label, item.story])),
|
|
399
|
-
usedSeeds:
|
|
418
|
+
usedSeeds: seedPool,
|
|
400
419
|
};
|
|
401
420
|
}
|
|
402
421
|
|
|
403
|
-
function
|
|
422
|
+
function isMdDomain(domain) {
|
|
423
|
+
return domain.endsWith(".md");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function getDomainTld(domain) {
|
|
427
|
+
const idx = domain.lastIndexOf(".");
|
|
428
|
+
if (idx < 0 || idx === domain.length - 1) {
|
|
429
|
+
return "";
|
|
430
|
+
}
|
|
431
|
+
return domain.slice(idx + 1).toLowerCase();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function shouldUseMdRules(domain, server) {
|
|
435
|
+
if (isMdDomain(domain)) {
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
if (!server) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
return server === MD_WHOIS_SERVER || server.endsWith(".nic.md") || server.endsWith(".md");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function classifyMdWhois(raw) {
|
|
445
|
+
const text = raw.toLowerCase();
|
|
446
|
+
if (
|
|
447
|
+
text.includes("no entries found [ no match for ]") ||
|
|
448
|
+
(text.includes("no entries found") && text.includes("no match for"))
|
|
449
|
+
) {
|
|
450
|
+
return {
|
|
451
|
+
status: "likely_available",
|
|
452
|
+
reason: "md_no_match",
|
|
453
|
+
state: "No match",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const stateMatch = raw.match(/domain state:\s*([^\r\n]+)/i);
|
|
458
|
+
if (!stateMatch) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const state = stateMatch[1].trim();
|
|
463
|
+
const lower = state.toLowerCase();
|
|
464
|
+
if (lower.includes("inactive")) {
|
|
465
|
+
return { status: "registered", reason: "md_state_inactive", state };
|
|
466
|
+
}
|
|
467
|
+
if (lower.includes("ok delegated")) {
|
|
468
|
+
return { status: "registered", reason: "md_state_ok_delegated", state };
|
|
469
|
+
}
|
|
470
|
+
if (lower === "ok" || lower.startsWith("ok ")) {
|
|
471
|
+
return { status: "registered", reason: "md_state_ok", state };
|
|
472
|
+
}
|
|
473
|
+
return { status: "registered", reason: "md_state_registered", state };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function cleanDomainStatus(value) {
|
|
477
|
+
return value
|
|
478
|
+
.replace(/\s+https?:\/\/\S+$/i, "")
|
|
479
|
+
.replace(/\s+\(https?:\/\/\S+\)$/i, "")
|
|
480
|
+
.trim();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function classifyComOrgAiWhois(raw, domain) {
|
|
484
|
+
const tld = getDomainTld(domain);
|
|
485
|
+
if (!["com", "org", "ai"].includes(tld)) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const text = raw.toLowerCase();
|
|
490
|
+
if (tld === "com" && text.includes('no match for domain "')) {
|
|
491
|
+
return { status: "likely_available", reason: "com_no_match", state: "No match" };
|
|
492
|
+
}
|
|
493
|
+
if ((tld === "org" || tld === "ai") && text.includes("domain not found")) {
|
|
494
|
+
return { status: "likely_available", reason: `${tld}_domain_not_found`, state: "No match" };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const statusMatch = raw.match(/Domain Status:\s*([^\r\n]+)/i);
|
|
498
|
+
if (statusMatch) {
|
|
499
|
+
return {
|
|
500
|
+
status: "registered",
|
|
501
|
+
reason: `${tld}_domain_status`,
|
|
502
|
+
state: cleanDomainStatus(statusMatch[1]),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (/Domain Name:\s*[^\r\n]+/i.test(raw) || /Registry Domain ID:\s*[^\r\n]+/i.test(raw)) {
|
|
507
|
+
return { status: "registered", reason: `${tld}_registered`, state: "" };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function classifyWhois(raw, domain, server) {
|
|
404
514
|
const text = raw.toLowerCase();
|
|
405
515
|
if (!text.trim()) {
|
|
406
516
|
return { status: "unknown", reason: "empty_whois_response" };
|
|
@@ -411,6 +521,16 @@ function classifyWhois(raw) {
|
|
|
411
521
|
if (RATE_LIMIT_PATTERNS.some((p) => text.includes(p))) {
|
|
412
522
|
return { status: "unknown", reason: "rate_limited_or_blocked" };
|
|
413
523
|
}
|
|
524
|
+
if (shouldUseMdRules(domain, server)) {
|
|
525
|
+
const md = classifyMdWhois(raw);
|
|
526
|
+
if (md) {
|
|
527
|
+
return md;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const common = classifyComOrgAiWhois(raw, domain);
|
|
531
|
+
if (common) {
|
|
532
|
+
return common;
|
|
533
|
+
}
|
|
414
534
|
if (AVAILABILITY_PATTERNS.some((p) => text.includes(p))) {
|
|
415
535
|
return { status: "likely_available", reason: "availability_pattern_match" };
|
|
416
536
|
}
|
|
@@ -420,6 +540,75 @@ function classifyWhois(raw) {
|
|
|
420
540
|
return { status: "unknown", reason: "unrecognized_whois_format" };
|
|
421
541
|
}
|
|
422
542
|
|
|
543
|
+
function runDigType(domain, type, timeoutMs) {
|
|
544
|
+
return new Promise((resolve) => {
|
|
545
|
+
const child = spawn("dig", ["+short", domain, type], {
|
|
546
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
let stdout = "";
|
|
550
|
+
let stderr = "";
|
|
551
|
+
let done = false;
|
|
552
|
+
const timer = setTimeout(() => {
|
|
553
|
+
if (done) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
done = true;
|
|
557
|
+
child.kill("SIGKILL");
|
|
558
|
+
resolve({ values: [], error: `dig ${type} timeout` });
|
|
559
|
+
}, Math.min(timeoutMs, 5000));
|
|
560
|
+
|
|
561
|
+
child.stdout.on("data", (chunk) => {
|
|
562
|
+
stdout += chunk.toString();
|
|
563
|
+
});
|
|
564
|
+
child.stderr.on("data", (chunk) => {
|
|
565
|
+
stderr += chunk.toString();
|
|
566
|
+
});
|
|
567
|
+
child.on("error", (err) => {
|
|
568
|
+
if (done) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
done = true;
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
resolve({ values: [], error: err.message });
|
|
574
|
+
});
|
|
575
|
+
child.on("close", () => {
|
|
576
|
+
if (done) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
done = true;
|
|
580
|
+
clearTimeout(timer);
|
|
581
|
+
const values = stdout
|
|
582
|
+
.split(/\r?\n/)
|
|
583
|
+
.map((line) => line.trim())
|
|
584
|
+
.filter(Boolean);
|
|
585
|
+
resolve({ values, error: stderr.trim() || null });
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function runDnsPrefilter(domain, timeoutMs) {
|
|
591
|
+
const a = await runDigType(domain, "A", timeoutMs);
|
|
592
|
+
if (a.values.length > 0) {
|
|
593
|
+
return { domain, resolves: true, recordType: "A", values: a.values, error: null };
|
|
594
|
+
}
|
|
595
|
+
const aaaa = await runDigType(domain, "AAAA", timeoutMs);
|
|
596
|
+
if (aaaa.values.length > 0) {
|
|
597
|
+
return { domain, resolves: true, recordType: "AAAA", values: aaaa.values, error: null };
|
|
598
|
+
}
|
|
599
|
+
const cname = await runDigType(domain, "CNAME", timeoutMs);
|
|
600
|
+
if (cname.values.length > 0) {
|
|
601
|
+
return { domain, resolves: true, recordType: "CNAME", values: cname.values, error: null };
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
domain,
|
|
605
|
+
resolves: false,
|
|
606
|
+
recordType: "",
|
|
607
|
+
values: [],
|
|
608
|
+
error: a.error || aaaa.error || cname.error || null,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
423
612
|
function runWhois(domain, timeoutMs, server) {
|
|
424
613
|
return new Promise((resolve) => {
|
|
425
614
|
const args = server ? ["-h", server, domain] : [domain];
|
|
@@ -474,12 +663,13 @@ function runWhois(domain, timeoutMs, server) {
|
|
|
474
663
|
done = true;
|
|
475
664
|
clearTimeout(timer);
|
|
476
665
|
const merged = [stdout, stderr].filter(Boolean).join("\n");
|
|
477
|
-
const classification = classifyWhois(merged);
|
|
666
|
+
const classification = classifyWhois(merged, domain, server);
|
|
478
667
|
const stderrText = stderr.trim();
|
|
479
668
|
resolve({
|
|
480
669
|
domain,
|
|
481
670
|
status: classification.status,
|
|
482
671
|
reason: classification.reason,
|
|
672
|
+
state: classification.state ?? "",
|
|
483
673
|
raw: merged.trim(),
|
|
484
674
|
error: classification.reason === "whois_lookup_failed" ? stderrText || "WHOIS lookup failed" : null,
|
|
485
675
|
});
|
|
@@ -519,7 +709,7 @@ async function maybeWriteCsv(results, outputPath) {
|
|
|
519
709
|
if (!outputPath) {
|
|
520
710
|
return;
|
|
521
711
|
}
|
|
522
|
-
const header = ["domain", "status", "reason", "legal_risk", "story", "error"];
|
|
712
|
+
const header = ["domain", "status", "reason", "state", "legal_risk", "story", "error"];
|
|
523
713
|
const lines = [header.join(",")];
|
|
524
714
|
for (const row of results) {
|
|
525
715
|
lines.push(
|
|
@@ -527,6 +717,7 @@ async function maybeWriteCsv(results, outputPath) {
|
|
|
527
717
|
row.domain,
|
|
528
718
|
row.status,
|
|
529
719
|
row.reason,
|
|
720
|
+
row.state ?? "",
|
|
530
721
|
row.legalRisk,
|
|
531
722
|
row.story ?? "",
|
|
532
723
|
row.error ?? "",
|
|
@@ -558,6 +749,150 @@ function snippet(raw) {
|
|
|
558
749
|
return raw.replace(/\s+/g, " ").slice(0, 120);
|
|
559
750
|
}
|
|
560
751
|
|
|
752
|
+
function verdictForStatus(status) {
|
|
753
|
+
switch (status) {
|
|
754
|
+
case "likely_available":
|
|
755
|
+
return "✅ AVAILABLE";
|
|
756
|
+
case "registered":
|
|
757
|
+
return "❌ TAKEN";
|
|
758
|
+
default:
|
|
759
|
+
return "⚠️ UNKNOWN";
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function sentenceForRow(row) {
|
|
764
|
+
if (row.status === "likely_available") {
|
|
765
|
+
return `✅ ${row.domain} is available.`;
|
|
766
|
+
}
|
|
767
|
+
if (row.status === "registered") {
|
|
768
|
+
if (row.reason === "dns_resolves_prefilter") {
|
|
769
|
+
return `❌ ${row.domain} is not available (DNS resolves).`;
|
|
770
|
+
}
|
|
771
|
+
if (row.reason === "md_state_inactive") {
|
|
772
|
+
return `❌ ${row.domain} is not available (registered, inactive).`;
|
|
773
|
+
}
|
|
774
|
+
if (row.reason === "md_state_ok") {
|
|
775
|
+
return `❌ ${row.domain} is not available (registered, state: OK).`;
|
|
776
|
+
}
|
|
777
|
+
if (row.reason === "md_state_ok_delegated") {
|
|
778
|
+
return `❌ ${row.domain} is not available (registered, state: OK Delegated).`;
|
|
779
|
+
}
|
|
780
|
+
if (row.state) {
|
|
781
|
+
return `❌ ${row.domain} is not available (registered, state: ${row.state}).`;
|
|
782
|
+
}
|
|
783
|
+
return `❌ ${row.domain} is not available (already registered).`;
|
|
784
|
+
}
|
|
785
|
+
return `⚠️ ${row.domain} availability is unknown (whois could not confirm).`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function toDisplayRow(row, options) {
|
|
789
|
+
return {
|
|
790
|
+
verdict: verdictForStatus(row.status),
|
|
791
|
+
domain: row.domain,
|
|
792
|
+
whois: row.status,
|
|
793
|
+
state: row.state ?? "",
|
|
794
|
+
reason: row.reason,
|
|
795
|
+
risk: row.legalRisk,
|
|
796
|
+
story: row.story ?? "",
|
|
797
|
+
error: row.error ?? "",
|
|
798
|
+
raw: options.raw ? snippet(row.raw) : "",
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function truncateCell(value, maxWidth) {
|
|
803
|
+
const text = String(value ?? "");
|
|
804
|
+
if (text.length <= maxWidth) {
|
|
805
|
+
return text;
|
|
806
|
+
}
|
|
807
|
+
if (maxWidth <= 1) {
|
|
808
|
+
return text.slice(0, maxWidth);
|
|
809
|
+
}
|
|
810
|
+
return `${text.slice(0, maxWidth - 1)}…`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function printTable(rows, options) {
|
|
814
|
+
const displayRows = rows.map((row) => toDisplayRow(row, options));
|
|
815
|
+
const hasState = displayRows.some((row) => row.state);
|
|
816
|
+
const hasStory = displayRows.some((row) => row.story);
|
|
817
|
+
const hasError = displayRows.some((row) => row.error);
|
|
818
|
+
const columns = [
|
|
819
|
+
{ key: "verdict", label: "Verdict", maxWidth: 14 },
|
|
820
|
+
{ key: "domain", label: "Domain", maxWidth: 44 },
|
|
821
|
+
{ key: "whois", label: "Whois", maxWidth: 18 },
|
|
822
|
+
...(hasState ? [{ key: "state", label: "State", maxWidth: 22 }] : []),
|
|
823
|
+
{ key: "reason", label: "Reason", maxWidth: 34 },
|
|
824
|
+
{ key: "risk", label: "Risk", maxWidth: 14 },
|
|
825
|
+
...(hasStory ? [{ key: "story", label: "Story", maxWidth: 36 }] : []),
|
|
826
|
+
...(hasError ? [{ key: "error", label: "Error", maxWidth: 44 }] : []),
|
|
827
|
+
...(options.raw ? [{ key: "raw", label: "Raw", maxWidth: 44 }] : []),
|
|
828
|
+
];
|
|
829
|
+
|
|
830
|
+
const widths = new Map();
|
|
831
|
+
for (const col of columns) {
|
|
832
|
+
const maxValueLen = displayRows.reduce((max, row) => Math.max(max, String(row[col.key] ?? "").length), 0);
|
|
833
|
+
widths.set(col.key, Math.min(Math.max(col.label.length, maxValueLen), col.maxWidth));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const formatCell = (value, width) => truncateCell(value, width).padEnd(width, " ");
|
|
837
|
+
const header = columns.map((col) => formatCell(col.label, widths.get(col.key))).join(" | ");
|
|
838
|
+
const separator = columns.map((col) => "-".repeat(widths.get(col.key))).join("-+-");
|
|
839
|
+
console.log(header);
|
|
840
|
+
console.log(separator);
|
|
841
|
+
for (const row of displayRows) {
|
|
842
|
+
const line = columns.map((col) => formatCell(row[col.key], widths.get(col.key))).join(" | ");
|
|
843
|
+
console.log(line);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function printDecoratedResults(rows, options) {
|
|
848
|
+
const title = " DOMAINSTORM RESULTS ";
|
|
849
|
+
const border = "=".repeat(Math.max(68, title.length + 8));
|
|
850
|
+
console.log(border);
|
|
851
|
+
console.log(title);
|
|
852
|
+
console.log(border);
|
|
853
|
+
if (rows.length === 0) {
|
|
854
|
+
console.log("No domains matched your filters.");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
printTable(rows, options);
|
|
858
|
+
if (rows.length === 1) {
|
|
859
|
+
const row = rows[0];
|
|
860
|
+
console.log("");
|
|
861
|
+
console.log(`Result: ${row.domain} is ${verdictForStatus(row.status)}.`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function printNarrativeResults(rows, options) {
|
|
866
|
+
if (rows.length === 0) {
|
|
867
|
+
console.log("No domains matched your filters.");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
for (const row of rows) {
|
|
872
|
+
console.log(sentenceForRow(row));
|
|
873
|
+
if (row.status === "unknown" || row.error) {
|
|
874
|
+
let detail = ` reason: ${row.reason}`;
|
|
875
|
+
if (row.error) {
|
|
876
|
+
detail += ` | error: ${row.error}`;
|
|
877
|
+
}
|
|
878
|
+
console.log(detail);
|
|
879
|
+
}
|
|
880
|
+
if (row.story) {
|
|
881
|
+
console.log(` story: ${row.story}`);
|
|
882
|
+
}
|
|
883
|
+
if (options.raw) {
|
|
884
|
+
console.log(` raw: ${snippet(row.raw)}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function shouldAutoThrottleWhois(domains) {
|
|
890
|
+
if (domains.length === 0) {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
|
|
561
896
|
async function main() {
|
|
562
897
|
const options = parseArgs(process.argv.slice(2));
|
|
563
898
|
let rawLabels = await loadLabels(options);
|
|
@@ -592,11 +927,56 @@ async function main() {
|
|
|
592
927
|
throw new Error("No valid domains found after normalization.");
|
|
593
928
|
}
|
|
594
929
|
|
|
930
|
+
const effectiveConcurrency =
|
|
931
|
+
!options.concurrencyExplicit && shouldAutoThrottleWhois(domains)
|
|
932
|
+
? Math.min(options.concurrency, SAFE_WHOIS_CONCURRENCY)
|
|
933
|
+
: options.concurrency;
|
|
934
|
+
|
|
935
|
+
if (effectiveConcurrency !== options.concurrency) {
|
|
936
|
+
console.error(
|
|
937
|
+
`Auto-throttle: using concurrency=${effectiveConcurrency} for WHOIS (override with --concurrency).`,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
595
941
|
const serverLabel = options.server ? `server=${options.server}` : "server=auto";
|
|
596
|
-
console.error(`Checking ${domains.length} domain(s) (${serverLabel}) with concurrency=${
|
|
942
|
+
console.error(`Checking ${domains.length} domain(s) (${serverLabel}) with concurrency=${effectiveConcurrency}`);
|
|
943
|
+
|
|
944
|
+
const dnsPrefilterResults = [];
|
|
945
|
+
let whoisTargets = domains;
|
|
946
|
+
if (options.dnsPrefilter) {
|
|
947
|
+
const dnsConcurrency = options.concurrencyExplicit ? options.concurrency : Math.min(20, domains.length || 1);
|
|
948
|
+
const dnsChecks = await mapLimit(domains, dnsConcurrency, async (domain) =>
|
|
949
|
+
runDnsPrefilter(domain, options.timeoutMs),
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
const dnsTaken = dnsChecks.filter((item) => item.resolves);
|
|
953
|
+
if (dnsTaken.length > 0) {
|
|
954
|
+
console.error(`DNS prefilter: ${dnsTaken.length} domain(s) resolve; marked as taken without WHOIS.`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
for (const item of dnsTaken) {
|
|
958
|
+
dnsPrefilterResults.push({
|
|
959
|
+
domain: item.domain,
|
|
960
|
+
status: "registered",
|
|
961
|
+
reason: "dns_resolves_prefilter",
|
|
962
|
+
state: `DNS ${item.recordType}`,
|
|
963
|
+
raw: "",
|
|
964
|
+
error: null,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
whoisTargets = dnsChecks.filter((item) => !item.resolves).map((item) => item.domain);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const whoisResults = await mapLimit(whoisTargets, effectiveConcurrency, async (domain) =>
|
|
971
|
+
runWhois(domain, options.timeoutMs, options.server),
|
|
972
|
+
);
|
|
597
973
|
|
|
598
|
-
const
|
|
599
|
-
|
|
974
|
+
const mergedByDomain = new Map();
|
|
975
|
+
for (const result of [...dnsPrefilterResults, ...whoisResults]) {
|
|
976
|
+
mergedByDomain.set(result.domain, result);
|
|
977
|
+
}
|
|
978
|
+
const checked = domains.map((domain) => {
|
|
979
|
+
const result = mergedByDomain.get(domain);
|
|
600
980
|
const label = domain.slice(0, domain.lastIndexOf("."));
|
|
601
981
|
return {
|
|
602
982
|
...result,
|
|
@@ -609,26 +989,33 @@ async function main() {
|
|
|
609
989
|
? checked.filter((row) => row.status === "likely_available")
|
|
610
990
|
: checked;
|
|
611
991
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
992
|
+
if (options.plain) {
|
|
993
|
+
for (const row of filtered) {
|
|
994
|
+
let line = `${row.domain}\t${row.status}\t${row.reason}\t${row.legalRisk}`;
|
|
995
|
+
if (row.state) {
|
|
996
|
+
line += `\tstate=${row.state}`;
|
|
997
|
+
}
|
|
998
|
+
if (row.story) {
|
|
999
|
+
line += `\tstory=${row.story}`;
|
|
1000
|
+
}
|
|
1001
|
+
if (row.error) {
|
|
1002
|
+
line += `\terror=${row.error}`;
|
|
1003
|
+
}
|
|
1004
|
+
if (options.raw) {
|
|
1005
|
+
line += `\t${snippet(row.raw)}`;
|
|
1006
|
+
}
|
|
1007
|
+
console.log(line);
|
|
622
1008
|
}
|
|
623
|
-
|
|
1009
|
+
} else if (options.table) {
|
|
1010
|
+
printDecoratedResults(filtered, options);
|
|
1011
|
+
} else {
|
|
1012
|
+
printNarrativeResults(filtered, options);
|
|
624
1013
|
}
|
|
625
1014
|
|
|
626
1015
|
await maybeWriteCsv(checked, options.output);
|
|
627
1016
|
|
|
628
1017
|
const counts = summarize(checked);
|
|
629
|
-
console.error(
|
|
630
|
-
`Summary: likely_available=${counts.likelyAvailable}, registered=${counts.registered}, unknown=${counts.unknown}`,
|
|
631
|
-
);
|
|
1018
|
+
console.error(`Summary: ✅ ${counts.likelyAvailable} available, ❌ ${counts.registered} taken, ⚠️ ${counts.unknown} unknown.`);
|
|
632
1019
|
if (options.output) {
|
|
633
1020
|
console.error(`CSV written: ${path.resolve(options.output)}`);
|
|
634
1021
|
}
|