@dench.com/cli 2.7.8 → 2.7.10-staging.100
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/dench.ts +103 -21
- package/email.ts +186 -4
- package/lib/api-schemas.ts +23 -0
- package/lib/command-registry.ts +139 -4
- package/package.json +2 -1
- package/tools.ts +382 -59
- package/web.ts +356 -0
package/web.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dench web` — brand, scrape, sitemap, crawl, screenshot, and extract
|
|
3
|
+
* through the Dench Cloud Gateway (`POST /v1/context/{op}`).
|
|
4
|
+
*
|
|
5
|
+
* Agents and humans use this instead of writing their own scrapers.
|
|
6
|
+
* Auth matches `dench search`: org DENCH_API_KEY / paid-workspace gateway key.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { assertNoUnknownFlags, getFlag, hasFlag } from "./lib/cli-args";
|
|
10
|
+
|
|
11
|
+
type WebCliContext = {
|
|
12
|
+
args: string[];
|
|
13
|
+
jsonOutput: boolean;
|
|
14
|
+
bearerToken?: string;
|
|
15
|
+
gatewayBaseUrl?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class WebCliError extends Error {}
|
|
19
|
+
|
|
20
|
+
const CRAWL_DEFAULT_PAGES = 15;
|
|
21
|
+
const CRAWL_MAX_PAGES = 50;
|
|
22
|
+
|
|
23
|
+
function parsePositiveInt(name: string, raw: string | undefined): number | undefined {
|
|
24
|
+
if (raw === undefined) return undefined;
|
|
25
|
+
const parsed = Number(raw);
|
|
26
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
27
|
+
throw new WebCliError(`Invalid ${name} value: ${raw}`);
|
|
28
|
+
}
|
|
29
|
+
return Math.floor(parsed);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeGatewayBaseUrl(value: string): string {
|
|
33
|
+
return value.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveGatewayBaseUrl(
|
|
37
|
+
ctx?: Pick<WebCliContext, "gatewayBaseUrl">,
|
|
38
|
+
): string {
|
|
39
|
+
if (ctx?.gatewayBaseUrl?.trim()) {
|
|
40
|
+
return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim());
|
|
41
|
+
}
|
|
42
|
+
const explicit =
|
|
43
|
+
process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
|
|
44
|
+
if (explicit) return normalizeGatewayBaseUrl(explicit);
|
|
45
|
+
const host = process.env.DENCH_HOST?.trim();
|
|
46
|
+
if (host && /localhost|127\.0\.0\.1/.test(host)) {
|
|
47
|
+
return "http://localhost:8787";
|
|
48
|
+
}
|
|
49
|
+
return "https://gateway.merseoriginals.com";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireBearerToken(ctx?: Pick<WebCliContext, "bearerToken">): string {
|
|
53
|
+
const bearerToken =
|
|
54
|
+
ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim();
|
|
55
|
+
if (!bearerToken) {
|
|
56
|
+
throw new WebCliError(
|
|
57
|
+
"No Dench gateway credential found. Set DENCH_API_KEY or run `dench signin` for a paid workspace.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return bearerToken;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function callWebOp(
|
|
64
|
+
ctx: WebCliContext,
|
|
65
|
+
op: string,
|
|
66
|
+
input: Record<string, unknown>,
|
|
67
|
+
): Promise<unknown> {
|
|
68
|
+
const bearerToken = requireBearerToken(ctx);
|
|
69
|
+
const baseUrl = resolveGatewayBaseUrl(ctx);
|
|
70
|
+
const response = await fetch(`${baseUrl}/v1/context/${op}`, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: {
|
|
73
|
+
"content-type": "application/json",
|
|
74
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({ input }),
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
let detail = "";
|
|
80
|
+
try {
|
|
81
|
+
detail = await response.text();
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
const truncated = detail.length > 500 ? `${detail.slice(0, 500)}…` : detail;
|
|
86
|
+
throw new WebCliError(
|
|
87
|
+
`Gateway /v1/context/${op} returned ${response.status}: ${truncated || response.statusText}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return response.json();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function asString(value: unknown): string | undefined {
|
|
94
|
+
return typeof value === "string" ? value : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
98
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
99
|
+
? (value as Record<string, unknown>)
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function out(ctx: WebCliContext, payload: unknown, formatted: string): void {
|
|
104
|
+
if (ctx.jsonOutput) {
|
|
105
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
console.log(formatted);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatBrand(data: unknown): string {
|
|
112
|
+
const record = asRecord(data);
|
|
113
|
+
if (!record) return "(empty brand)";
|
|
114
|
+
const lines = [
|
|
115
|
+
asString(record.title) ?? asString(record.name) ?? "(untitled brand)",
|
|
116
|
+
asString(record.domain) ? ` ${asString(record.domain)}` : "",
|
|
117
|
+
asString(record.description) ? ` ${asString(record.description)}` : "",
|
|
118
|
+
].filter((line) => line.length > 0);
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatMarkdown(data: unknown): string {
|
|
123
|
+
const record = asRecord(data);
|
|
124
|
+
return asString(record?.markdown) ?? JSON.stringify(data, null, 2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function webHelp(): void {
|
|
128
|
+
console.log(`Dench web
|
|
129
|
+
|
|
130
|
+
Read pages, brand kits, sitemaps, screenshots, and structured extracts
|
|
131
|
+
through the Dench gateway. Prefer these over writing your own scraper.
|
|
132
|
+
|
|
133
|
+
dench web scrape <url> [--json]
|
|
134
|
+
dench web brand <domain|email> [--json]
|
|
135
|
+
dench web sitemap <domain> [--limit N] [--json]
|
|
136
|
+
dench web crawl <url> [--max-pages N] [--json]
|
|
137
|
+
dench web screenshot <url> [--full-page] [--json]
|
|
138
|
+
dench web extract <url> --schema '<json>' [--json]
|
|
139
|
+
dench web product <url> [--json]
|
|
140
|
+
dench web competitors <domain> [--json]
|
|
141
|
+
dench web styleguide <domain> [--json]
|
|
142
|
+
dench web fonts <domain> [--json]
|
|
143
|
+
dench web classify <domain> [--scheme naics|sic] [--json]
|
|
144
|
+
dench web brand-search <query> [--json]
|
|
145
|
+
|
|
146
|
+
Crawl defaults to 15 pages and is hard-capped at 50. Search stays on
|
|
147
|
+
\`dench search\` (Exa). \`dench context\` is workspace orientation, not this.
|
|
148
|
+
`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function runWebSubcommand(ctx: WebCliContext): Promise<void> {
|
|
152
|
+
const sub = ctx.args[0];
|
|
153
|
+
const rest = ctx.args.slice(1);
|
|
154
|
+
|
|
155
|
+
if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
|
|
156
|
+
webHelp();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (sub === "scrape") {
|
|
161
|
+
assertNoUnknownFlags(rest, "web scrape");
|
|
162
|
+
const url = rest[0];
|
|
163
|
+
if (!url) throw new WebCliError("Usage: dench web scrape <url> [--json]");
|
|
164
|
+
const data = await callWebOp(ctx, "web-scrape-markdown", {
|
|
165
|
+
url,
|
|
166
|
+
useMainContentOnly: true,
|
|
167
|
+
});
|
|
168
|
+
out(ctx, data, formatMarkdown(data));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (sub === "brand") {
|
|
173
|
+
assertNoUnknownFlags(rest, "web brand");
|
|
174
|
+
const identifier = rest[0];
|
|
175
|
+
if (!identifier) {
|
|
176
|
+
throw new WebCliError("Usage: dench web brand <domain|email> [--json]");
|
|
177
|
+
}
|
|
178
|
+
const input = identifier.includes("@")
|
|
179
|
+
? { email: identifier }
|
|
180
|
+
: { domain: identifier };
|
|
181
|
+
const data = await callWebOp(ctx, "brand-retrieve", input);
|
|
182
|
+
out(ctx, data, formatBrand(data));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (sub === "brand-search") {
|
|
187
|
+
assertNoUnknownFlags(rest, "web brand-search");
|
|
188
|
+
const query = rest.join(" ").trim();
|
|
189
|
+
if (!query) {
|
|
190
|
+
throw new WebCliError("Usage: dench web brand-search <query> [--json]");
|
|
191
|
+
}
|
|
192
|
+
const data = await callWebOp(ctx, "brand-search", { query });
|
|
193
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (sub === "sitemap") {
|
|
198
|
+
const limit = parsePositiveInt("--limit", getFlag(rest, "--limit"));
|
|
199
|
+
assertNoUnknownFlags(rest, "web sitemap");
|
|
200
|
+
const domain = rest[0];
|
|
201
|
+
if (!domain) {
|
|
202
|
+
throw new WebCliError("Usage: dench web sitemap <domain> [--limit N] [--json]");
|
|
203
|
+
}
|
|
204
|
+
const data = await callWebOp(ctx, "web-sitemap", {
|
|
205
|
+
domain,
|
|
206
|
+
...(limit != null ? { maxLinks: limit } : {}),
|
|
207
|
+
});
|
|
208
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (sub === "crawl") {
|
|
213
|
+
const maxPages =
|
|
214
|
+
parsePositiveInt("--max-pages", getFlag(rest, "--max-pages")) ??
|
|
215
|
+
CRAWL_DEFAULT_PAGES;
|
|
216
|
+
if (maxPages > CRAWL_MAX_PAGES) {
|
|
217
|
+
throw new WebCliError(`--max-pages cannot exceed ${CRAWL_MAX_PAGES}.`);
|
|
218
|
+
}
|
|
219
|
+
assertNoUnknownFlags(rest, "web crawl");
|
|
220
|
+
const url = rest[0];
|
|
221
|
+
if (!url) {
|
|
222
|
+
throw new WebCliError(
|
|
223
|
+
"Usage: dench web crawl <url> [--max-pages N] [--json]",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const data = await callWebOp(ctx, "web-crawl", {
|
|
227
|
+
url,
|
|
228
|
+
maxPages,
|
|
229
|
+
useMainContentOnly: true,
|
|
230
|
+
});
|
|
231
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (sub === "screenshot") {
|
|
236
|
+
const fullPage = hasFlag(rest, "--full-page");
|
|
237
|
+
assertNoUnknownFlags(rest, "web screenshot");
|
|
238
|
+
const url = rest[0];
|
|
239
|
+
if (!url) {
|
|
240
|
+
throw new WebCliError(
|
|
241
|
+
"Usage: dench web screenshot <url> [--full-page] [--json]",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const data = await callWebOp(ctx, "web-screenshot", {
|
|
245
|
+
directUrl: url,
|
|
246
|
+
fullScreenshot: fullPage,
|
|
247
|
+
});
|
|
248
|
+
const record = asRecord(data);
|
|
249
|
+
out(ctx, data, asString(record?.screenshot) ?? JSON.stringify(data, null, 2));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (sub === "extract") {
|
|
254
|
+
const schemaRaw = getFlag(rest, "--schema");
|
|
255
|
+
assertNoUnknownFlags(rest, "web extract");
|
|
256
|
+
const url = rest[0];
|
|
257
|
+
if (!url || !schemaRaw) {
|
|
258
|
+
throw new WebCliError(
|
|
259
|
+
"Usage: dench web extract <url> --schema '<json>' [--json]",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
let schema: unknown;
|
|
263
|
+
try {
|
|
264
|
+
schema = JSON.parse(schemaRaw);
|
|
265
|
+
} catch {
|
|
266
|
+
throw new WebCliError("--schema must be valid JSON.");
|
|
267
|
+
}
|
|
268
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
269
|
+
throw new WebCliError("--schema must be a JSON object.");
|
|
270
|
+
}
|
|
271
|
+
const data = await callWebOp(ctx, "web-extract", { url, schema });
|
|
272
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (sub === "product") {
|
|
277
|
+
assertNoUnknownFlags(rest, "web product");
|
|
278
|
+
const url = rest[0];
|
|
279
|
+
if (!url) throw new WebCliError("Usage: dench web product <url> [--json]");
|
|
280
|
+
const data = await callWebOp(ctx, "web-product", { url });
|
|
281
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (sub === "competitors") {
|
|
286
|
+
assertNoUnknownFlags(rest, "web competitors");
|
|
287
|
+
const domain = rest[0];
|
|
288
|
+
if (!domain) {
|
|
289
|
+
throw new WebCliError("Usage: dench web competitors <domain> [--json]");
|
|
290
|
+
}
|
|
291
|
+
const data = await callWebOp(ctx, "web-competitors", {
|
|
292
|
+
domain,
|
|
293
|
+
numCompetitors: 5,
|
|
294
|
+
});
|
|
295
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (sub === "styleguide") {
|
|
300
|
+
assertNoUnknownFlags(rest, "web styleguide");
|
|
301
|
+
const domain = rest[0];
|
|
302
|
+
if (!domain) {
|
|
303
|
+
throw new WebCliError("Usage: dench web styleguide <domain> [--json]");
|
|
304
|
+
}
|
|
305
|
+
const data = await callWebOp(ctx, "brand-styleguide", { domain });
|
|
306
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (sub === "fonts") {
|
|
311
|
+
assertNoUnknownFlags(rest, "web fonts");
|
|
312
|
+
const domain = rest[0];
|
|
313
|
+
if (!domain) throw new WebCliError("Usage: dench web fonts <domain> [--json]");
|
|
314
|
+
const data = await callWebOp(ctx, "brand-fonts", { domain });
|
|
315
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (sub === "classify") {
|
|
320
|
+
const scheme = getFlag(rest, "--scheme") ?? "naics";
|
|
321
|
+
if (scheme !== "naics" && scheme !== "sic") {
|
|
322
|
+
throw new WebCliError("Invalid --scheme. Allowed: naics, sic");
|
|
323
|
+
}
|
|
324
|
+
assertNoUnknownFlags(rest, "web classify");
|
|
325
|
+
const domain = rest[0];
|
|
326
|
+
if (!domain) {
|
|
327
|
+
throw new WebCliError(
|
|
328
|
+
"Usage: dench web classify <domain> [--scheme naics|sic] [--json]",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const data = await callWebOp(
|
|
332
|
+
ctx,
|
|
333
|
+
scheme === "sic" ? "classify-sic" : "classify-naics",
|
|
334
|
+
{ input: domain },
|
|
335
|
+
);
|
|
336
|
+
out(ctx, data, JSON.stringify(data, null, 2));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
throw new WebCliError(
|
|
341
|
+
`Unknown dench web command "${sub}". Run \`dench web --help\`.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function runWebCommand(ctx: WebCliContext): Promise<void> {
|
|
346
|
+
try {
|
|
347
|
+
await runWebSubcommand(ctx);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (error instanceof WebCliError) {
|
|
350
|
+
console.error(error.message);
|
|
351
|
+
process.exitCode = 1;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|