@stephenyang/stephen 0.1.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/LICENSE +21 -0
- package/README.md +377 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.js +4239 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { mkdirSync as mkdirSync2, realpathSync } from "fs";
|
|
5
|
+
import { dirname as dirname2 } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { createInterface } from "readline/promises";
|
|
8
|
+
import { stdin as input, stdout as output } from "process";
|
|
9
|
+
import envPaths from "env-paths";
|
|
10
|
+
|
|
11
|
+
// src/ak/command.ts
|
|
12
|
+
import { Command } from "commander";
|
|
13
|
+
import { ZodError, z as z8 } from "zod";
|
|
14
|
+
import { table as table7 } from "table";
|
|
15
|
+
|
|
16
|
+
// src/ak/output.ts
|
|
17
|
+
import { table } from "table";
|
|
18
|
+
function renderAkRecordsAsJson(records, limit) {
|
|
19
|
+
const serializedRecords = records.map((record) => ({
|
|
20
|
+
id: record.id,
|
|
21
|
+
env: record.env,
|
|
22
|
+
userId: record.userId,
|
|
23
|
+
userName: record.userName,
|
|
24
|
+
email: record.email,
|
|
25
|
+
phone: record.phone,
|
|
26
|
+
key: record.key,
|
|
27
|
+
createdAt: record.createdAt,
|
|
28
|
+
updatedAt: record.updatedAt
|
|
29
|
+
}));
|
|
30
|
+
return JSON.stringify(
|
|
31
|
+
{
|
|
32
|
+
ok: true,
|
|
33
|
+
data: serializedRecords,
|
|
34
|
+
meta: {
|
|
35
|
+
count: serializedRecords.length,
|
|
36
|
+
limit
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
null,
|
|
40
|
+
2
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
function renderAkErrorAsJson(code, message, details) {
|
|
44
|
+
return JSON.stringify(
|
|
45
|
+
{
|
|
46
|
+
ok: false,
|
|
47
|
+
error: {
|
|
48
|
+
code,
|
|
49
|
+
...details === void 0 ? {} : { details },
|
|
50
|
+
message
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
null,
|
|
54
|
+
2
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
function renderAkRecordsAsTable(records) {
|
|
58
|
+
return table([
|
|
59
|
+
["id", "env", "userId", "userName", "email", "phone", "key", "updatedAt"],
|
|
60
|
+
...records.map((record) => [
|
|
61
|
+
record.id,
|
|
62
|
+
record.env,
|
|
63
|
+
record.userId ?? "",
|
|
64
|
+
record.userName ?? "",
|
|
65
|
+
record.email ?? "",
|
|
66
|
+
record.phone ?? "",
|
|
67
|
+
record.key,
|
|
68
|
+
record.updatedAt
|
|
69
|
+
])
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/ak/crypto.ts
|
|
74
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
|
75
|
+
var ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
|
76
|
+
var IV_LENGTH = 12;
|
|
77
|
+
var AUTH_TAG_LENGTH = 16;
|
|
78
|
+
var PREFIX_LENGTH = 12;
|
|
79
|
+
function createAkId(key) {
|
|
80
|
+
return createHash("sha1").update(key).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
function deriveAkSearchPrefix(key, length = PREFIX_LENGTH) {
|
|
83
|
+
return key.slice(0, length);
|
|
84
|
+
}
|
|
85
|
+
function encryptAkKey(masterKey, key) {
|
|
86
|
+
const iv = randomBytes(IV_LENGTH);
|
|
87
|
+
const cipher = createCipheriv(ENCRYPTION_ALGORITHM, masterKey, iv, {
|
|
88
|
+
authTagLength: AUTH_TAG_LENGTH
|
|
89
|
+
});
|
|
90
|
+
const encrypted = Buffer.concat([cipher.update(key, "utf8"), cipher.final()]);
|
|
91
|
+
const tag = cipher.getAuthTag();
|
|
92
|
+
return [iv.toString("base64url"), tag.toString("base64url"), encrypted.toString("base64url")].join(".");
|
|
93
|
+
}
|
|
94
|
+
function decryptAkKey(masterKey, payload) {
|
|
95
|
+
const [ivPart, tagPart, encryptedPart] = payload.split(".");
|
|
96
|
+
if (!ivPart || !tagPart || !encryptedPart) {
|
|
97
|
+
throw new Error("Invalid encrypted API key payload.");
|
|
98
|
+
}
|
|
99
|
+
const decipher = createDecipheriv(
|
|
100
|
+
ENCRYPTION_ALGORITHM,
|
|
101
|
+
masterKey,
|
|
102
|
+
Buffer.from(ivPart, "base64url"),
|
|
103
|
+
{
|
|
104
|
+
authTagLength: AUTH_TAG_LENGTH
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
decipher.setAuthTag(Buffer.from(tagPart, "base64url"));
|
|
108
|
+
return Buffer.concat([
|
|
109
|
+
decipher.update(Buffer.from(encryptedPart, "base64url")),
|
|
110
|
+
decipher.final()
|
|
111
|
+
]).toString("utf8");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/ak/schema.ts
|
|
115
|
+
import { z } from "zod";
|
|
116
|
+
|
|
117
|
+
// src/ak/types.ts
|
|
118
|
+
var AK_RECOMMENDED_ENVS = [
|
|
119
|
+
"bzy-pre",
|
|
120
|
+
"bzy-prod",
|
|
121
|
+
"op-pre",
|
|
122
|
+
"op-prod",
|
|
123
|
+
"gitee",
|
|
124
|
+
"github",
|
|
125
|
+
"gitlab"
|
|
126
|
+
];
|
|
127
|
+
var AK_QUERY_FIELDS = [
|
|
128
|
+
"userId",
|
|
129
|
+
"userName",
|
|
130
|
+
"email",
|
|
131
|
+
"phone",
|
|
132
|
+
"key"
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// src/ak/schema.ts
|
|
136
|
+
var envRecommendationMessage = `Recommended values: ${AK_RECOMMENDED_ENVS.join(", ")}.`;
|
|
137
|
+
var akEnvSchema = z.string().transform((value, context) => {
|
|
138
|
+
try {
|
|
139
|
+
return normalizeAkEnv(value);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
context.addIssue({
|
|
142
|
+
code: "custom",
|
|
143
|
+
message: error.message
|
|
144
|
+
});
|
|
145
|
+
return z.NEVER;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
function normalizeAkKey(value) {
|
|
149
|
+
const normalized = value.trim();
|
|
150
|
+
if (normalized.length === 0) {
|
|
151
|
+
throw new Error("API key cannot be empty.");
|
|
152
|
+
}
|
|
153
|
+
return normalized;
|
|
154
|
+
}
|
|
155
|
+
function normalizeAkEnv(value) {
|
|
156
|
+
const normalized = value.trim();
|
|
157
|
+
if (normalized.length === 0) {
|
|
158
|
+
throw new Error(`Environment is required. ${envRecommendationMessage}`);
|
|
159
|
+
}
|
|
160
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Environment must use letters, numbers, ".", "_" or "-". ${envRecommendationMessage}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return normalized;
|
|
166
|
+
}
|
|
167
|
+
function maskKey(value) {
|
|
168
|
+
if (value.length <= 4) {
|
|
169
|
+
return "*".repeat(value.length);
|
|
170
|
+
}
|
|
171
|
+
if (value.length <= 8) {
|
|
172
|
+
return `${value.slice(0, 2)}${"*".repeat(value.length - 4)}${value.slice(-2)}`;
|
|
173
|
+
}
|
|
174
|
+
return `${value.slice(0, 4)}${"*".repeat(value.length - 8)}${value.slice(-4)}`;
|
|
175
|
+
}
|
|
176
|
+
var nullableTextField = z.string().trim().min(1).optional().transform((value) => value ?? void 0);
|
|
177
|
+
var addAkRecordInputSchema = z.object({
|
|
178
|
+
email: nullableTextField,
|
|
179
|
+
env: akEnvSchema,
|
|
180
|
+
key: z.string().transform(normalizeAkKey),
|
|
181
|
+
phone: nullableTextField,
|
|
182
|
+
userId: nullableTextField,
|
|
183
|
+
userName: nullableTextField
|
|
184
|
+
});
|
|
185
|
+
var listAkRecordsInputSchema = z.object({
|
|
186
|
+
env: akEnvSchema.optional(),
|
|
187
|
+
field: z.string().trim().optional(),
|
|
188
|
+
limit: z.number().int().positive().max(100).default(50),
|
|
189
|
+
query: z.string().trim().min(1).optional()
|
|
190
|
+
});
|
|
191
|
+
function parseAkQueryFields(value) {
|
|
192
|
+
if (!value) {
|
|
193
|
+
return [...AK_QUERY_FIELDS];
|
|
194
|
+
}
|
|
195
|
+
return value.split(",").map((field) => {
|
|
196
|
+
const normalized = field.trim();
|
|
197
|
+
if (!AK_QUERY_FIELDS.includes(normalized)) {
|
|
198
|
+
throw new Error(`Unsupported query field: ${normalized}.`);
|
|
199
|
+
}
|
|
200
|
+
return normalized;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/ak/service.ts
|
|
205
|
+
var AkServiceError = class extends Error {
|
|
206
|
+
code;
|
|
207
|
+
exitCode;
|
|
208
|
+
constructor(code, message, exitCode) {
|
|
209
|
+
super(message);
|
|
210
|
+
this.code = code;
|
|
211
|
+
this.exitCode = exitCode;
|
|
212
|
+
this.name = "AkServiceError";
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
var AkService = class {
|
|
216
|
+
#masterKey;
|
|
217
|
+
#now;
|
|
218
|
+
#repository;
|
|
219
|
+
constructor(dependencies) {
|
|
220
|
+
this.#masterKey = dependencies.masterKey;
|
|
221
|
+
this.#now = dependencies.now;
|
|
222
|
+
this.#repository = dependencies.repository;
|
|
223
|
+
}
|
|
224
|
+
add(input2) {
|
|
225
|
+
const parsed = addAkRecordInputSchema.parse(input2);
|
|
226
|
+
const key = normalizeAkKey(parsed.key);
|
|
227
|
+
const id = createAkId(key);
|
|
228
|
+
const now = this.#now();
|
|
229
|
+
this.#repository.insert({
|
|
230
|
+
createdAt: now,
|
|
231
|
+
email: normalizeNullableFieldForInsert(parsed.email),
|
|
232
|
+
env: parsed.env,
|
|
233
|
+
id,
|
|
234
|
+
keyCiphertext: encryptAkKey(this.#masterKey, key),
|
|
235
|
+
keySearchPrefix: deriveAkSearchPrefix(key),
|
|
236
|
+
phone: normalizeNullableFieldForInsert(parsed.phone),
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
userId: normalizeNullableFieldForInsert(parsed.userId),
|
|
239
|
+
userName: normalizeNullableFieldForInsert(parsed.userName)
|
|
240
|
+
});
|
|
241
|
+
const record = this.#repository.getById(id);
|
|
242
|
+
if (!record) {
|
|
243
|
+
throw new AkServiceError("STORAGE_ERROR", "Failed to load the newly created record.", 6);
|
|
244
|
+
}
|
|
245
|
+
return this.#toView(record, Boolean(input2.rawKey));
|
|
246
|
+
}
|
|
247
|
+
get(input2) {
|
|
248
|
+
const record = this.#findRecord(input2);
|
|
249
|
+
if (!record) {
|
|
250
|
+
throw new AkServiceError("RECORD_NOT_FOUND", "No API key record matched the query.", 3);
|
|
251
|
+
}
|
|
252
|
+
return this.#toView(record, Boolean(input2.rawKey));
|
|
253
|
+
}
|
|
254
|
+
list(input2) {
|
|
255
|
+
const fields = parseFields(input2.field);
|
|
256
|
+
const limit = input2.limit ?? 50;
|
|
257
|
+
const filters = {
|
|
258
|
+
fields,
|
|
259
|
+
limit,
|
|
260
|
+
...input2.env ? { env: input2.env } : {},
|
|
261
|
+
...input2.query ? { query: input2.query } : {}
|
|
262
|
+
};
|
|
263
|
+
const results = this.#repository.list(filters);
|
|
264
|
+
return results.map((record) => this.#toView(record, Boolean(input2.rawKey)));
|
|
265
|
+
}
|
|
266
|
+
update(input2) {
|
|
267
|
+
const record = this.#findRecord(input2);
|
|
268
|
+
if (!record) {
|
|
269
|
+
throw new AkServiceError("RECORD_NOT_FOUND", "No API key record matched the query.", 3);
|
|
270
|
+
}
|
|
271
|
+
const normalizedEmail = normalizeNullableField(input2.email);
|
|
272
|
+
const normalizedPhone = normalizeNullableField(input2.phone);
|
|
273
|
+
const normalizedUserId = normalizeNullableField(input2.userId);
|
|
274
|
+
const normalizedUserName = normalizeNullableField(input2.userName);
|
|
275
|
+
const updated = this.#repository.updateMetadata({
|
|
276
|
+
...normalizedEmail === void 0 ? {} : { email: normalizedEmail },
|
|
277
|
+
id: record.id,
|
|
278
|
+
...normalizedPhone === void 0 ? {} : { phone: normalizedPhone },
|
|
279
|
+
updatedAt: this.#now(),
|
|
280
|
+
...normalizedUserId === void 0 ? {} : { userId: normalizedUserId },
|
|
281
|
+
...normalizedUserName === void 0 ? {} : { userName: normalizedUserName }
|
|
282
|
+
});
|
|
283
|
+
if (!updated) {
|
|
284
|
+
throw new AkServiceError("STORAGE_ERROR", "Failed to update the API key record.", 6);
|
|
285
|
+
}
|
|
286
|
+
return this.#toView(updated, Boolean(input2.rawKey));
|
|
287
|
+
}
|
|
288
|
+
delete(input2) {
|
|
289
|
+
const record = this.#findRecord(input2);
|
|
290
|
+
if (!record) {
|
|
291
|
+
throw new AkServiceError("RECORD_NOT_FOUND", "No API key record matched the query.", 3);
|
|
292
|
+
}
|
|
293
|
+
return this.#repository.deleteById(record.id);
|
|
294
|
+
}
|
|
295
|
+
#findRecord(input2) {
|
|
296
|
+
if (input2.id) {
|
|
297
|
+
return this.#repository.getById(input2.id);
|
|
298
|
+
}
|
|
299
|
+
if (input2.env && input2.key) {
|
|
300
|
+
const normalizedKey = normalizeAkKey(input2.key);
|
|
301
|
+
return this.#repository.getByEnvAndId(input2.env, createAkId(normalizedKey));
|
|
302
|
+
}
|
|
303
|
+
throw new AkServiceError(
|
|
304
|
+
"INVALID_ARGUMENT",
|
|
305
|
+
"Provide either --id or the pair of --env and --key.",
|
|
306
|
+
2
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
#toView(record, rawKey) {
|
|
310
|
+
const key = decryptAkKey(this.#masterKey, record.keyCiphertext);
|
|
311
|
+
return {
|
|
312
|
+
createdAt: record.createdAt,
|
|
313
|
+
email: record.email,
|
|
314
|
+
env: record.env,
|
|
315
|
+
id: record.id,
|
|
316
|
+
key: rawKey ? key : maskKey(key),
|
|
317
|
+
phone: record.phone,
|
|
318
|
+
updatedAt: record.updatedAt,
|
|
319
|
+
userId: record.userId,
|
|
320
|
+
userName: record.userName
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
function normalizeNullableField(value) {
|
|
325
|
+
if (value === void 0) {
|
|
326
|
+
return void 0;
|
|
327
|
+
}
|
|
328
|
+
if (value === null) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
const normalized = value.trim();
|
|
332
|
+
return normalized.length === 0 ? null : normalized;
|
|
333
|
+
}
|
|
334
|
+
function normalizeNullableFieldForInsert(value) {
|
|
335
|
+
return normalizeNullableField(value) ?? null;
|
|
336
|
+
}
|
|
337
|
+
function parseFields(value) {
|
|
338
|
+
try {
|
|
339
|
+
return parseAkQueryFields(value);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const message = error instanceof Error ? error.message : "Invalid query fields.";
|
|
342
|
+
throw new AkServiceError("INVALID_ARGUMENT", message, 2);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/ak/runtime.ts
|
|
347
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
348
|
+
import { dirname, join } from "path";
|
|
349
|
+
import { z as z2 } from "zod";
|
|
350
|
+
var AK_DB_PATH_ENV_VAR = "STEPHEN_AK_DB_PATH";
|
|
351
|
+
var LEGACY_AK_DB_PATH_ENV_VAR = "STEPHEN_CLI_AK_DB_PATH";
|
|
352
|
+
var AK_CONFIG_FILE_NAME = "config.json";
|
|
353
|
+
var CONFIG_KEYS = ["ak.dbPath"];
|
|
354
|
+
var AkConfigError = class extends Error {
|
|
355
|
+
code = "CONFIG_ERROR";
|
|
356
|
+
exitCode = 2;
|
|
357
|
+
constructor(message) {
|
|
358
|
+
super(message);
|
|
359
|
+
this.name = "AkConfigError";
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
var AkStorageInitError = class extends Error {
|
|
363
|
+
code = "STORAGE_ERROR";
|
|
364
|
+
exitCode = 6;
|
|
365
|
+
constructor(path, cause) {
|
|
366
|
+
const detail = cause instanceof Error ? cause.message : "Unknown storage error.";
|
|
367
|
+
super(
|
|
368
|
+
`Failed to open the ak database at "${path}". Check ${formatAkDbPathEnvVarHelp()}, the local config file, and the parent directory permissions. Details: ${detail}`
|
|
369
|
+
);
|
|
370
|
+
this.name = "AkStorageInitError";
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
var akConfigSchema = z2.object({
|
|
374
|
+
ak: z2.object({
|
|
375
|
+
dbPath: z2.string().optional()
|
|
376
|
+
}).optional()
|
|
377
|
+
}).passthrough();
|
|
378
|
+
function getAkConfigFilePath(configDir) {
|
|
379
|
+
return join(configDir, AK_CONFIG_FILE_NAME);
|
|
380
|
+
}
|
|
381
|
+
function loadAkConfig(configDir) {
|
|
382
|
+
const configFilePath = getAkConfigFilePath(configDir);
|
|
383
|
+
if (!existsSync(configFilePath)) {
|
|
384
|
+
return {};
|
|
385
|
+
}
|
|
386
|
+
return parseAkConfig(readFileSync(configFilePath, "utf8"), configFilePath);
|
|
387
|
+
}
|
|
388
|
+
function saveAkConfig(configDir, config) {
|
|
389
|
+
const configFilePath = getAkConfigFilePath(configDir);
|
|
390
|
+
mkdirSync(dirname(configFilePath), { recursive: true });
|
|
391
|
+
writeFileSync(`${configFilePath}`, `${JSON.stringify(config, null, 2)}
|
|
392
|
+
`, "utf8");
|
|
393
|
+
}
|
|
394
|
+
function parseAkConfig(contents, configFilePath = AK_CONFIG_FILE_NAME) {
|
|
395
|
+
let parsedJson;
|
|
396
|
+
try {
|
|
397
|
+
parsedJson = JSON.parse(contents);
|
|
398
|
+
} catch {
|
|
399
|
+
throw new AkConfigError(
|
|
400
|
+
`Failed to parse the local config file at "${configFilePath}". Fix the JSON or set ${formatAkDbPathEnvVarHelp()} to the full ak.db path on this machine.`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
const parsed = akConfigSchema.safeParse(parsedJson);
|
|
404
|
+
if (!parsed.success) {
|
|
405
|
+
throw new AkConfigError(
|
|
406
|
+
`Invalid ak config in "${configFilePath}". Expected ak.dbPath to be a string when provided. Fix the config file or set ${formatAkDbPathEnvVarHelp()} to the full ak.db path on this machine.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return parsed.data;
|
|
410
|
+
}
|
|
411
|
+
function resolveAkDatabasePath(input2) {
|
|
412
|
+
const configPath = normalizeConfiguredPath(input2.config.ak?.dbPath, "ak.dbPath");
|
|
413
|
+
if (configPath) {
|
|
414
|
+
return {
|
|
415
|
+
path: configPath,
|
|
416
|
+
source: "config"
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const envPath = getConfiguredAkDbPathFromEnv(input2.env);
|
|
420
|
+
if (envPath) {
|
|
421
|
+
return {
|
|
422
|
+
path: normalizeConfiguredPath(envPath.value, envPath.source),
|
|
423
|
+
source: "env"
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
path: join(input2.defaultDataDir, "ak.db"),
|
|
428
|
+
source: "default"
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function getConfigEntry(key, input2) {
|
|
432
|
+
assertSupportedConfigKey(key);
|
|
433
|
+
const config = loadAkConfig(input2.configDir);
|
|
434
|
+
const resolved = resolveAkDatabasePath({
|
|
435
|
+
config,
|
|
436
|
+
defaultDataDir: input2.dataDir,
|
|
437
|
+
env: input2.env
|
|
438
|
+
});
|
|
439
|
+
const envValue = normalizeResolvedValue(getConfiguredAkDbPathFromEnv(input2.env)?.value);
|
|
440
|
+
const fileValue = normalizeResolvedValue(config.ak?.dbPath);
|
|
441
|
+
return {
|
|
442
|
+
configFilePath: getAkConfigFilePath(input2.configDir),
|
|
443
|
+
defaultValue: join(input2.dataDir, "ak.db"),
|
|
444
|
+
envValue,
|
|
445
|
+
fileValue,
|
|
446
|
+
key,
|
|
447
|
+
source: resolved.source,
|
|
448
|
+
value: resolved.path
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function listConfigEntries(input2) {
|
|
452
|
+
return CONFIG_KEYS.map((key) => getConfigEntry(key, input2));
|
|
453
|
+
}
|
|
454
|
+
function setConfigValue(key, value, input2) {
|
|
455
|
+
assertSupportedConfigKey(key);
|
|
456
|
+
const normalizedValue = normalizeConfiguredPath(value, key);
|
|
457
|
+
const config = loadAkConfig(input2.configDir);
|
|
458
|
+
const nextConfig = {
|
|
459
|
+
...config,
|
|
460
|
+
ak: {
|
|
461
|
+
...config.ak ?? {},
|
|
462
|
+
dbPath: normalizedValue
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
saveAkConfig(input2.configDir, nextConfig);
|
|
466
|
+
return nextConfig;
|
|
467
|
+
}
|
|
468
|
+
function assertSupportedConfigKey(key) {
|
|
469
|
+
if (!CONFIG_KEYS.includes(key)) {
|
|
470
|
+
throw new AkConfigError(
|
|
471
|
+
`Unsupported config key: ${key}. Supported keys: ${CONFIG_KEYS.join(", ")}.`
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function normalizeConfiguredPath(value, source) {
|
|
476
|
+
if (value === void 0) {
|
|
477
|
+
return void 0;
|
|
478
|
+
}
|
|
479
|
+
const normalized = value.trim();
|
|
480
|
+
if (normalized.length === 0) {
|
|
481
|
+
throw new AkConfigError(
|
|
482
|
+
`${source} is configured but empty. Set it to the full ak.db path, or remove it to fall back to the next storage path source.`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return normalized;
|
|
486
|
+
}
|
|
487
|
+
function normalizeResolvedValue(value) {
|
|
488
|
+
if (value === void 0) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
return value.trim();
|
|
492
|
+
}
|
|
493
|
+
function getConfiguredAkDbPathFromEnv(env) {
|
|
494
|
+
const primaryValue = env[AK_DB_PATH_ENV_VAR];
|
|
495
|
+
if (primaryValue !== void 0) {
|
|
496
|
+
return {
|
|
497
|
+
source: AK_DB_PATH_ENV_VAR,
|
|
498
|
+
value: primaryValue
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const legacyValue = env[LEGACY_AK_DB_PATH_ENV_VAR];
|
|
502
|
+
if (legacyValue !== void 0) {
|
|
503
|
+
return {
|
|
504
|
+
source: LEGACY_AK_DB_PATH_ENV_VAR,
|
|
505
|
+
value: legacyValue
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
return void 0;
|
|
509
|
+
}
|
|
510
|
+
function formatAkDbPathEnvVarHelp() {
|
|
511
|
+
return `${AK_DB_PATH_ENV_VAR} (preferred) or ${LEGACY_AK_DB_PATH_ENV_VAR} (legacy)`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/disk/command.ts
|
|
515
|
+
import { z as z3 } from "zod";
|
|
516
|
+
|
|
517
|
+
// src/disk/output.ts
|
|
518
|
+
import { table as table2 } from "table";
|
|
519
|
+
function renderDiskCleanupReportAsJson(report) {
|
|
520
|
+
return JSON.stringify(
|
|
521
|
+
{
|
|
522
|
+
ok: true,
|
|
523
|
+
data: report,
|
|
524
|
+
meta: {
|
|
525
|
+
count: report.targets.length,
|
|
526
|
+
estimatedReclaimBytes: report.estimatedReclaimBytes,
|
|
527
|
+
estimatedReclaimGB: report.estimatedReclaimGB
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
null,
|
|
531
|
+
2
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
function renderDiskCleanupReportAsTable(report) {
|
|
535
|
+
const targetTable = table2([
|
|
536
|
+
["label", "status", "sizeGB", "exists", "requiresAdmin", "path"],
|
|
537
|
+
...report.targets.map((target) => [
|
|
538
|
+
target.label,
|
|
539
|
+
target.status,
|
|
540
|
+
String(target.sizeGB),
|
|
541
|
+
target.exists ? "yes" : "no",
|
|
542
|
+
target.requiresAdministrator ? "yes" : "no",
|
|
543
|
+
target.path
|
|
544
|
+
])
|
|
545
|
+
]);
|
|
546
|
+
if (!report.downloads) {
|
|
547
|
+
return targetTable;
|
|
548
|
+
}
|
|
549
|
+
return `${targetTable}
|
|
550
|
+
Downloads top entries
|
|
551
|
+
${table2([
|
|
552
|
+
["kind", "sizeGB", "path"],
|
|
553
|
+
...report.downloads.topEntries.map((entry) => [entry.kind, String(entry.sizeGB), entry.path])
|
|
554
|
+
])}`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/disk/command.ts
|
|
558
|
+
var diskCleanupOptionsSchema = z3.object({
|
|
559
|
+
apply: z3.boolean().default(false),
|
|
560
|
+
confirm: z3.boolean().default(false),
|
|
561
|
+
disableHibernate: z3.boolean().default(false),
|
|
562
|
+
format: z3.enum(["json", "table"]).default("json"),
|
|
563
|
+
level: z3.enum(["safe", "dev", "system", "deep"]).default("safe")
|
|
564
|
+
});
|
|
565
|
+
function registerDiskCommands(program, dependencies) {
|
|
566
|
+
const disk = program.command("disk").description("Inspect and clean conservative disk caches.");
|
|
567
|
+
disk.command("cleanup").option("--apply", "execute cleanup instead of previewing it").option("--confirm", "confirm apply for system or deep cleanup levels").option("--disable-hibernate", "disable Windows hibernation during apply mode").option("--format <format>", "output format", "json").option("--level <level>", "cleanup level: safe, dev, system, or deep", "safe").option("-t, --table", "render as a table").action(async (options) => {
|
|
568
|
+
const parsed = diskCleanupOptionsSchema.parse(applyDiskTableShortcut(options));
|
|
569
|
+
const report = await dependencies.createDiskCleanupService().cleanup({
|
|
570
|
+
apply: parsed.apply,
|
|
571
|
+
confirm: parsed.confirm,
|
|
572
|
+
disableHibernate: parsed.disableHibernate,
|
|
573
|
+
level: parsed.level
|
|
574
|
+
});
|
|
575
|
+
if (parsed.format === "table") {
|
|
576
|
+
dependencies.stdout(`${renderDiskCleanupReportAsTable(report)}
|
|
577
|
+
`);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
dependencies.stdout(`${renderDiskCleanupReportAsJson(report)}
|
|
581
|
+
`);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function applyDiskTableShortcut(options) {
|
|
585
|
+
if (options.table) {
|
|
586
|
+
return {
|
|
587
|
+
...options,
|
|
588
|
+
format: "table"
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (options.format) {
|
|
592
|
+
return {
|
|
593
|
+
...options,
|
|
594
|
+
format: options.format
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
throw new Error("Output format is required for this command.");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/disk/service.ts
|
|
601
|
+
import { win32 } from "path";
|
|
602
|
+
var DiskCleanupServiceError = class extends Error {
|
|
603
|
+
code;
|
|
604
|
+
details;
|
|
605
|
+
exitCode;
|
|
606
|
+
constructor(code, message, exitCode = 2, details) {
|
|
607
|
+
super(message);
|
|
608
|
+
this.code = code;
|
|
609
|
+
if (details) {
|
|
610
|
+
this.details = details;
|
|
611
|
+
}
|
|
612
|
+
this.exitCode = exitCode;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
var DiskCleanupService = class {
|
|
616
|
+
runtime;
|
|
617
|
+
systemRoot;
|
|
618
|
+
userProfileRoot;
|
|
619
|
+
constructor(dependencies) {
|
|
620
|
+
this.runtime = dependencies.runtime;
|
|
621
|
+
this.systemRoot = dependencies.systemRoot;
|
|
622
|
+
this.userProfileRoot = dependencies.userProfileRoot;
|
|
623
|
+
}
|
|
624
|
+
async cleanup(options) {
|
|
625
|
+
if (requiresConfirmation(options.level) && options.apply && !options.confirm) {
|
|
626
|
+
throw new DiskCleanupServiceError(
|
|
627
|
+
"DISK_CLEANUP_CONFIRMATION_REQUIRED",
|
|
628
|
+
"Disk cleanup level requires --confirm before apply."
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const targetDefinitions = this.getTargets(options.level);
|
|
632
|
+
const targets = [];
|
|
633
|
+
const failures = [];
|
|
634
|
+
let estimatedReclaimBytes = 0;
|
|
635
|
+
for (const target of targetDefinitions) {
|
|
636
|
+
const inspection = target.action === "run-command" ? { exists: true, isDirectory: false, sizeBytes: 0 } : await this.runtime.inspectPath(target.path);
|
|
637
|
+
estimatedReclaimBytes += inspection.sizeBytes;
|
|
638
|
+
let status = inspection.exists ? options.apply ? "cleaned" : "planned" : "missing";
|
|
639
|
+
let error;
|
|
640
|
+
if (options.apply && inspection.exists) {
|
|
641
|
+
try {
|
|
642
|
+
if (target.action === "clear-directory-contents") {
|
|
643
|
+
await this.runtime.clearDirectoryContents(target.path);
|
|
644
|
+
}
|
|
645
|
+
if (target.action === "run-command") {
|
|
646
|
+
await this.runtime.runCommand(target.command, target.args ?? []);
|
|
647
|
+
}
|
|
648
|
+
if (target.action === "inspect-only") {
|
|
649
|
+
status = "planned";
|
|
650
|
+
}
|
|
651
|
+
} catch (cause) {
|
|
652
|
+
error = getErrorMessage(cause);
|
|
653
|
+
failures.push(`${target.label}: ${error}`);
|
|
654
|
+
status = "failed";
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
targets.push({
|
|
658
|
+
action: target.action,
|
|
659
|
+
...target.command ? { command: target.command } : {},
|
|
660
|
+
...target.args ? { args: target.args } : {},
|
|
661
|
+
exists: inspection.exists,
|
|
662
|
+
label: target.label,
|
|
663
|
+
path: target.path,
|
|
664
|
+
requiresAdministrator: target.requiresAdministrator,
|
|
665
|
+
sizeBytes: inspection.sizeBytes,
|
|
666
|
+
sizeGB: bytesToGb(inspection.sizeBytes),
|
|
667
|
+
status,
|
|
668
|
+
...error ? { error } : {}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
const hibernation = {
|
|
672
|
+
requested: options.disableHibernate,
|
|
673
|
+
status: "skipped"
|
|
674
|
+
};
|
|
675
|
+
if (options.apply && options.disableHibernate) {
|
|
676
|
+
try {
|
|
677
|
+
await this.runtime.disableHibernation();
|
|
678
|
+
hibernation.status = "disabled";
|
|
679
|
+
} catch (error) {
|
|
680
|
+
hibernation.status = "failed";
|
|
681
|
+
hibernation.error = getErrorMessage(error);
|
|
682
|
+
failures.push(`hibernation: ${hibernation.error}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const report = {
|
|
686
|
+
...options.level === "deep" ? { downloads: await this.getDownloadsReport() } : {},
|
|
687
|
+
estimatedReclaimBytes,
|
|
688
|
+
estimatedReclaimGB: bytesToGb(estimatedReclaimBytes),
|
|
689
|
+
hibernation,
|
|
690
|
+
level: options.level,
|
|
691
|
+
mode: options.apply ? "apply" : "preview",
|
|
692
|
+
systemRoot: this.systemRoot,
|
|
693
|
+
targets,
|
|
694
|
+
userProfileRoot: this.userProfileRoot
|
|
695
|
+
};
|
|
696
|
+
if (failures.length > 0) {
|
|
697
|
+
throw new DiskCleanupServiceError(
|
|
698
|
+
"DISK_CLEANUP_ERROR",
|
|
699
|
+
"Disk cleanup encountered failures.",
|
|
700
|
+
2,
|
|
701
|
+
{
|
|
702
|
+
report
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
;
|
|
707
|
+
return report;
|
|
708
|
+
}
|
|
709
|
+
async getDownloadsReport() {
|
|
710
|
+
const path = win32.join(this.userProfileRoot, "Downloads");
|
|
711
|
+
return {
|
|
712
|
+
path,
|
|
713
|
+
topEntries: await this.runtime.listTopEntriesBySize(path, 100)
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
getTargets(level) {
|
|
717
|
+
if (level === "safe") {
|
|
718
|
+
return this.getSafeTargets();
|
|
719
|
+
}
|
|
720
|
+
if (level === "dev") {
|
|
721
|
+
return dedupeTargets([...this.getSafeTargets(), ...this.getDevTargets()]);
|
|
722
|
+
}
|
|
723
|
+
if (level === "system") {
|
|
724
|
+
return dedupeTargets([...this.getSafeTargets(), ...this.getSystemTargets()]);
|
|
725
|
+
}
|
|
726
|
+
return dedupeTargets([...this.getSafeTargets(), ...this.getDevTargets(), ...this.getSystemTargets()]);
|
|
727
|
+
}
|
|
728
|
+
getSafeTargets() {
|
|
729
|
+
return [
|
|
730
|
+
{
|
|
731
|
+
action: "clear-directory-contents",
|
|
732
|
+
label: "npm cache",
|
|
733
|
+
path: win32.join(this.userProfileRoot, "AppData", "Local", "npm-cache"),
|
|
734
|
+
requiresAdministrator: false
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
action: "clear-directory-contents",
|
|
738
|
+
label: "NuGet cache",
|
|
739
|
+
path: win32.join(this.userProfileRoot, "AppData", "Local", "NuGet"),
|
|
740
|
+
requiresAdministrator: false
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
action: "clear-directory-contents",
|
|
744
|
+
label: "Generic cache",
|
|
745
|
+
path: win32.join(this.userProfileRoot, ".cache"),
|
|
746
|
+
requiresAdministrator: false
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
action: "clear-directory-contents",
|
|
750
|
+
label: "Maven repository cache",
|
|
751
|
+
path: win32.join(this.userProfileRoot, ".m2"),
|
|
752
|
+
requiresAdministrator: false
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
action: "clear-directory-contents",
|
|
756
|
+
label: "User temp",
|
|
757
|
+
path: win32.join(this.userProfileRoot, "AppData", "Local", "Temp"),
|
|
758
|
+
requiresAdministrator: false
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
action: "clear-directory-contents",
|
|
762
|
+
label: "Windows Update download cache",
|
|
763
|
+
path: win32.join(this.systemRoot, "SoftwareDistribution", "Download"),
|
|
764
|
+
requiresAdministrator: true
|
|
765
|
+
}
|
|
766
|
+
];
|
|
767
|
+
}
|
|
768
|
+
getDevTargets() {
|
|
769
|
+
return [
|
|
770
|
+
{
|
|
771
|
+
action: "clear-directory-contents",
|
|
772
|
+
label: "Gradle cache",
|
|
773
|
+
path: win32.join(this.userProfileRoot, ".gradle", "caches"),
|
|
774
|
+
requiresAdministrator: false
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
action: "clear-directory-contents",
|
|
778
|
+
label: "pnpm store",
|
|
779
|
+
path: win32.join(this.userProfileRoot, ".pnpm-store"),
|
|
780
|
+
requiresAdministrator: false
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
action: "clear-directory-contents",
|
|
784
|
+
label: "pnpm local store",
|
|
785
|
+
path: win32.join(this.userProfileRoot, "AppData", "Local", "pnpm", "store"),
|
|
786
|
+
requiresAdministrator: false
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
action: "clear-directory-contents",
|
|
790
|
+
label: "Yarn cache",
|
|
791
|
+
path: win32.join(this.userProfileRoot, "AppData", "Local", "Yarn", "Cache"),
|
|
792
|
+
requiresAdministrator: false
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
action: "clear-directory-contents",
|
|
796
|
+
label: "pip cache",
|
|
797
|
+
path: win32.join(this.userProfileRoot, "AppData", "Local", "pip", "Cache"),
|
|
798
|
+
requiresAdministrator: false
|
|
799
|
+
}
|
|
800
|
+
];
|
|
801
|
+
}
|
|
802
|
+
getSystemTargets() {
|
|
803
|
+
return [
|
|
804
|
+
{
|
|
805
|
+
action: "clear-directory-contents",
|
|
806
|
+
label: "Windows temp",
|
|
807
|
+
path: win32.join(this.systemRoot, "Temp"),
|
|
808
|
+
requiresAdministrator: true
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
action: "run-command",
|
|
812
|
+
args: ["/Online", "/Cleanup-Image", "/StartComponentCleanup"],
|
|
813
|
+
command: "dism.exe",
|
|
814
|
+
label: "DISM component cleanup",
|
|
815
|
+
path: "dism.exe /Online /Cleanup-Image /StartComponentCleanup",
|
|
816
|
+
requiresAdministrator: true
|
|
817
|
+
}
|
|
818
|
+
];
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
function bytesToGb(value) {
|
|
822
|
+
return Number((value / 1024 ** 3).toFixed(2));
|
|
823
|
+
}
|
|
824
|
+
function getErrorMessage(error) {
|
|
825
|
+
return error instanceof Error ? error.message : "Unknown error";
|
|
826
|
+
}
|
|
827
|
+
function requiresConfirmation(level) {
|
|
828
|
+
return level === "system" || level === "deep";
|
|
829
|
+
}
|
|
830
|
+
function dedupeTargets(targets) {
|
|
831
|
+
const seen = /* @__PURE__ */ new Set();
|
|
832
|
+
const result = [];
|
|
833
|
+
for (const target of targets) {
|
|
834
|
+
const key = `${target.action}:${target.command ?? target.path}`;
|
|
835
|
+
if (seen.has(key)) {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
seen.add(key);
|
|
839
|
+
result.push(target);
|
|
840
|
+
}
|
|
841
|
+
return result;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/video/command.ts
|
|
845
|
+
import { z as z4 } from "zod";
|
|
846
|
+
|
|
847
|
+
// src/video/sniff/browser-provider.ts
|
|
848
|
+
var BrowserVideoSniffProvider = class {
|
|
849
|
+
runtime;
|
|
850
|
+
constructor(dependencies) {
|
|
851
|
+
this.runtime = dependencies.runtime;
|
|
852
|
+
}
|
|
853
|
+
sniff(sourceUrl, options) {
|
|
854
|
+
return this.runtime.launchBrowserSniffer(sourceUrl, options);
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// src/video/sniff/candidate.ts
|
|
859
|
+
function createVideoCandidate(type, url, origin, confidence = 0.5) {
|
|
860
|
+
return {
|
|
861
|
+
confidence,
|
|
862
|
+
mimeType: type === "m3u8" ? "application/vnd.apple.mpegurl" : "video/mp4",
|
|
863
|
+
origin,
|
|
864
|
+
type,
|
|
865
|
+
url
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
function rankVideoCandidates(candidates) {
|
|
869
|
+
const deduped = /* @__PURE__ */ new Map();
|
|
870
|
+
for (const candidate of candidates) {
|
|
871
|
+
const existing = deduped.get(candidate.url);
|
|
872
|
+
if (!existing || (candidate.confidence ?? 0) > (existing.confidence ?? 0)) {
|
|
873
|
+
deduped.set(candidate.url, candidate);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return [...deduped.values()].sort((left, right) => {
|
|
877
|
+
const confidenceDelta = (right.confidence ?? 0) - (left.confidence ?? 0);
|
|
878
|
+
if (confidenceDelta !== 0) {
|
|
879
|
+
return confidenceDelta;
|
|
880
|
+
}
|
|
881
|
+
if (left.type === right.type) {
|
|
882
|
+
return 0;
|
|
883
|
+
}
|
|
884
|
+
return left.type === "m3u8" ? -1 : 1;
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
function extractVideoCandidatesFromText(text, origin) {
|
|
888
|
+
const matches = text.matchAll(/https?:\/\/[^\s"'<>]+?\.(m3u8|mp4)(?:\?[^\s"'<>]*)?/gi);
|
|
889
|
+
return rankVideoCandidates(
|
|
890
|
+
[...matches].map(
|
|
891
|
+
(match) => createVideoCandidate(match[1]?.toLowerCase() === "m3u8" ? "m3u8" : "mp4", match[0], origin, origin === "html" ? 0.75 : 0.65)
|
|
892
|
+
)
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/video/utils.ts
|
|
897
|
+
function optional(key, value) {
|
|
898
|
+
return value !== void 0 && value !== null && value !== "" ? { [key]: value } : {};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// src/video/sniff/service.ts
|
|
902
|
+
var VideoSniffService = class {
|
|
903
|
+
browserProvider;
|
|
904
|
+
httpProvider;
|
|
905
|
+
constructor(dependencies) {
|
|
906
|
+
this.browserProvider = dependencies.browserProvider;
|
|
907
|
+
this.httpProvider = dependencies.httpProvider;
|
|
908
|
+
}
|
|
909
|
+
async sniff(options) {
|
|
910
|
+
const providerOptions = buildProviderOptions(options);
|
|
911
|
+
if (options.mode === "http") {
|
|
912
|
+
return this.createResult("http", options.sourceUrl, await this.httpProvider(options.sourceUrl, providerOptions));
|
|
913
|
+
}
|
|
914
|
+
if (options.mode === "browser") {
|
|
915
|
+
return this.createResult(
|
|
916
|
+
"browser",
|
|
917
|
+
options.sourceUrl,
|
|
918
|
+
await this.browserProvider(options.sourceUrl, providerOptions)
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
return this.createResult(
|
|
923
|
+
"browser",
|
|
924
|
+
options.sourceUrl,
|
|
925
|
+
await this.browserProvider(options.sourceUrl, providerOptions)
|
|
926
|
+
);
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (error instanceof Error && "recoverable" in error && error.recoverable === true) {
|
|
929
|
+
return this.createResult("http", options.sourceUrl, await this.httpProvider(options.sourceUrl, providerOptions));
|
|
930
|
+
}
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
createResult(mode, sourceUrl, candidates) {
|
|
935
|
+
return {
|
|
936
|
+
candidates: rankVideoCandidates(candidates),
|
|
937
|
+
mode,
|
|
938
|
+
sourceUrl
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
function buildProviderOptions(options) {
|
|
943
|
+
return {
|
|
944
|
+
...optional("noProxy", options.noProxy),
|
|
945
|
+
...optional("proxyUrl", options.proxyUrl)
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/video/types.ts
|
|
950
|
+
var VideoCommandError = class extends Error {
|
|
951
|
+
code;
|
|
952
|
+
details;
|
|
953
|
+
exitCode;
|
|
954
|
+
recoverable;
|
|
955
|
+
constructor(code, message, exitCode = 2, details, recoverable = false) {
|
|
956
|
+
super(message);
|
|
957
|
+
this.code = code;
|
|
958
|
+
if (details !== void 0) {
|
|
959
|
+
this.details = details;
|
|
960
|
+
}
|
|
961
|
+
this.exitCode = exitCode;
|
|
962
|
+
this.recoverable = recoverable;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// src/video/sniff/http-provider.ts
|
|
967
|
+
var HttpVideoSniffProvider = class {
|
|
968
|
+
runtime;
|
|
969
|
+
constructor(dependencies) {
|
|
970
|
+
this.runtime = dependencies.runtime;
|
|
971
|
+
}
|
|
972
|
+
async sniff(sourceUrl, options) {
|
|
973
|
+
const response = await this.runtime.fetch(sourceUrl);
|
|
974
|
+
if (!response.ok) {
|
|
975
|
+
throw new VideoCommandError(
|
|
976
|
+
"VIDEO_SNIFF_FAILED",
|
|
977
|
+
`Failed to inspect ${sourceUrl}. HTTP ${response.status}.`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
const text = await response.text();
|
|
981
|
+
return extractVideoCandidatesFromText(text, "html");
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
// src/video/download/direct-driver.ts
|
|
986
|
+
import { basename, win32 as win322 } from "path";
|
|
987
|
+
import { SingleBar } from "cli-progress";
|
|
988
|
+
|
|
989
|
+
// src/video/download/media-request.ts
|
|
990
|
+
function createMediaRequestInit(sourceUrl) {
|
|
991
|
+
const url = new URL(sourceUrl);
|
|
992
|
+
return {
|
|
993
|
+
headers: {
|
|
994
|
+
referer: `${url.protocol}//${url.host}/`,
|
|
995
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// src/video/download/direct-driver.ts
|
|
1001
|
+
async function fetchWithProxy(url, runtime, options) {
|
|
1002
|
+
const init = createMediaRequestInit(url);
|
|
1003
|
+
if (options?.noProxy) {
|
|
1004
|
+
return await runtime.fetch(url, init);
|
|
1005
|
+
}
|
|
1006
|
+
const proxyUrl = options?.proxyUrl ?? process.env.HTTP_PROXY ?? process.env.HTTPS_PROXY;
|
|
1007
|
+
if (!proxyUrl) {
|
|
1008
|
+
return await runtime.fetch(url, init);
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
const mod = await import("https-proxy-agent");
|
|
1012
|
+
const response = await runtime.fetch(url, { ...init, agent: new mod.HttpsProxyAgent(proxyUrl) });
|
|
1013
|
+
return response;
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
console.warn(`[stephen] Proxy connection failed (${proxyUrl}), falling back to direct connection: ${error instanceof Error ? error.message : String(error)}`);
|
|
1016
|
+
return await runtime.fetch(url, init);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
var DirectVideoDownloadDriver = class {
|
|
1020
|
+
runtime;
|
|
1021
|
+
constructor(dependencies) {
|
|
1022
|
+
this.runtime = dependencies.runtime;
|
|
1023
|
+
}
|
|
1024
|
+
async download(options) {
|
|
1025
|
+
let response;
|
|
1026
|
+
try {
|
|
1027
|
+
response = await fetchWithProxy(
|
|
1028
|
+
options.sourceUrl,
|
|
1029
|
+
this.runtime,
|
|
1030
|
+
buildProxyOptions(options)
|
|
1031
|
+
);
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
throw new VideoCommandError(
|
|
1034
|
+
"VIDEO_DOWNLOAD_FAILED",
|
|
1035
|
+
`Failed to download ${options.sourceUrl}.`,
|
|
1036
|
+
2,
|
|
1037
|
+
{
|
|
1038
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (!response.ok) {
|
|
1043
|
+
throw new VideoCommandError(
|
|
1044
|
+
"VIDEO_DOWNLOAD_FAILED",
|
|
1045
|
+
`Failed to download ${options.sourceUrl}. HTTP ${response.status}.`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const outputPath = options.outputPath ?? win322.join(options.outputDir ?? ".", inferFileName(options.sourceUrl));
|
|
1049
|
+
const contentLength = response.headers.get("content-length");
|
|
1050
|
+
const totalBytes = contentLength ? parseInt(contentLength, 10) : void 0;
|
|
1051
|
+
let buffer;
|
|
1052
|
+
if (response.body && totalBytes && totalBytes > 0) {
|
|
1053
|
+
buffer = await this.downloadWithProgress(response.body, totalBytes, outputPath);
|
|
1054
|
+
} else {
|
|
1055
|
+
buffer = await this.downloadAsBuffer(response);
|
|
1056
|
+
}
|
|
1057
|
+
await this.runtime.writeFile(outputPath, buffer);
|
|
1058
|
+
const downloadedBytes = buffer.byteLength;
|
|
1059
|
+
if (downloadedBytes > 0) {
|
|
1060
|
+
console.error(`Downloaded ${formatBytes(downloadedBytes)} to ${outputPath}`);
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
bytesWritten: buffer.byteLength,
|
|
1064
|
+
mediaType: "mp4",
|
|
1065
|
+
outputPath,
|
|
1066
|
+
sourceUrl: options.sourceUrl
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/* c8 ignore start */
|
|
1070
|
+
async downloadWithProgress(body, totalBytes, _outputPath) {
|
|
1071
|
+
const reader = body.getReader();
|
|
1072
|
+
const chunks = [];
|
|
1073
|
+
let receivedBytes = 0;
|
|
1074
|
+
const startTime = Date.now();
|
|
1075
|
+
const progressBar = new SingleBar({
|
|
1076
|
+
format: createProgressFormatter(startTime, 24),
|
|
1077
|
+
barCompleteChar: "\u2588",
|
|
1078
|
+
barIncompleteChar: "\u2591",
|
|
1079
|
+
hideCursor: true,
|
|
1080
|
+
fps: 10,
|
|
1081
|
+
barsize: 24
|
|
1082
|
+
});
|
|
1083
|
+
progressBar.start(totalBytes, 0);
|
|
1084
|
+
try {
|
|
1085
|
+
while (true) {
|
|
1086
|
+
const { done, value } = await reader.read();
|
|
1087
|
+
if (done) break;
|
|
1088
|
+
chunks.push(value);
|
|
1089
|
+
receivedBytes += value.length;
|
|
1090
|
+
progressBar.update(receivedBytes);
|
|
1091
|
+
}
|
|
1092
|
+
} finally {
|
|
1093
|
+
progressBar.stop();
|
|
1094
|
+
reader.releaseLock();
|
|
1095
|
+
}
|
|
1096
|
+
return Buffer.concat(chunks);
|
|
1097
|
+
}
|
|
1098
|
+
async downloadAsBuffer(response) {
|
|
1099
|
+
const payload = await response.arrayBuffer();
|
|
1100
|
+
return payload instanceof Uint8Array ? Buffer.from(payload) : Buffer.from(payload);
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
function createProgressFormatter(startTime, barsize) {
|
|
1104
|
+
return (_options, params) => {
|
|
1105
|
+
const elapsed = (Date.now() - startTime) / 1e3;
|
|
1106
|
+
const speed = elapsed > 0 ? params.value / elapsed : 0;
|
|
1107
|
+
const completeCount = Math.round(params.progress * barsize);
|
|
1108
|
+
const bar = "\u2588".repeat(completeCount) + "\u2591".repeat(barsize - completeCount);
|
|
1109
|
+
const percentage = (params.progress * 100).toFixed(1);
|
|
1110
|
+
return `Downloading ${bar} ${percentage}% | ${formatBytes(speed)}/s | ETA ${formatETA(params.eta)} | ${formatBytes(params.value)} / ${formatBytes(params.total)}`;
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
function inferFileName(sourceUrl) {
|
|
1114
|
+
const name = basename(new URL(sourceUrl).pathname);
|
|
1115
|
+
return name || "video.mp4";
|
|
1116
|
+
}
|
|
1117
|
+
function formatBytes(bytes) {
|
|
1118
|
+
if (bytes === 0) return "0 B";
|
|
1119
|
+
const k = 1024;
|
|
1120
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
1121
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1122
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
1123
|
+
}
|
|
1124
|
+
function formatETA(seconds) {
|
|
1125
|
+
if (!isFinite(seconds) || seconds <= 0) return "--:--";
|
|
1126
|
+
const s = Math.round(seconds);
|
|
1127
|
+
if (s >= 3600) {
|
|
1128
|
+
const h = Math.floor(s / 3600);
|
|
1129
|
+
const m2 = Math.floor(s % 3600 / 60);
|
|
1130
|
+
const sec2 = s % 60;
|
|
1131
|
+
return `${h}:${String(m2).padStart(2, "0")}:${String(sec2).padStart(2, "0")}`;
|
|
1132
|
+
}
|
|
1133
|
+
const m = Math.floor(s / 60);
|
|
1134
|
+
const sec = s % 60;
|
|
1135
|
+
return `${m}:${String(sec).padStart(2, "0")}`;
|
|
1136
|
+
}
|
|
1137
|
+
function buildProxyOptions(options) {
|
|
1138
|
+
return {
|
|
1139
|
+
...options.noProxy === void 0 ? {} : { noProxy: options.noProxy },
|
|
1140
|
+
...options.proxyUrl === void 0 ? {} : { proxyUrl: options.proxyUrl }
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/video/download/hls-driver.ts
|
|
1145
|
+
import { basename as basename2, extname, win32 as win323 } from "path";
|
|
1146
|
+
var HlsVideoDownloadDriver = class {
|
|
1147
|
+
runtime;
|
|
1148
|
+
constructor(dependencies) {
|
|
1149
|
+
this.runtime = dependencies.runtime;
|
|
1150
|
+
}
|
|
1151
|
+
async download(options) {
|
|
1152
|
+
const playlistResponse = await this.runtime.fetch(
|
|
1153
|
+
options.sourceUrl,
|
|
1154
|
+
createMediaRequestInit(options.sourceUrl)
|
|
1155
|
+
);
|
|
1156
|
+
if (!playlistResponse.ok) {
|
|
1157
|
+
throw new VideoCommandError(
|
|
1158
|
+
"VIDEO_DOWNLOAD_FAILED",
|
|
1159
|
+
`Failed to download ${options.sourceUrl}. HTTP ${playlistResponse.status}.`
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
const playlist = await playlistResponse.text();
|
|
1163
|
+
const segmentUrls = parsePlaylistResourceUrls(playlist, options.sourceUrl);
|
|
1164
|
+
const chunks = [];
|
|
1165
|
+
const segmentResponses = await Promise.all(
|
|
1166
|
+
segmentUrls.map((segmentUrl) => this.runtime.fetch(segmentUrl, createMediaRequestInit(segmentUrl)))
|
|
1167
|
+
);
|
|
1168
|
+
for (let i = 0; i < segmentResponses.length; i++) {
|
|
1169
|
+
const segmentResponse = segmentResponses[i];
|
|
1170
|
+
const segmentUrl = segmentUrls[i];
|
|
1171
|
+
if (!segmentResponse || !segmentUrl) {
|
|
1172
|
+
throw new VideoCommandError(
|
|
1173
|
+
"VIDEO_DOWNLOAD_FAILED",
|
|
1174
|
+
`Failed to download segment ${i}.`
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
if (!segmentResponse.ok) {
|
|
1178
|
+
throw new VideoCommandError(
|
|
1179
|
+
"VIDEO_DOWNLOAD_FAILED",
|
|
1180
|
+
`Failed to download ${segmentUrl}. HTTP ${segmentResponse.status}.`
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
const payload = await segmentResponse.arrayBuffer();
|
|
1184
|
+
chunks.push(payload instanceof Uint8Array ? Buffer.from(payload) : Buffer.from(payload));
|
|
1185
|
+
}
|
|
1186
|
+
const merged = Buffer.concat(chunks);
|
|
1187
|
+
const outputPath = options.outputPath ?? win323.join(options.outputDir ?? ".", `${stripExtension(basename2(new URL(options.sourceUrl).pathname))}.ts`);
|
|
1188
|
+
await this.runtime.writeFile(outputPath, merged);
|
|
1189
|
+
return {
|
|
1190
|
+
bytesWritten: merged.byteLength,
|
|
1191
|
+
mediaType: "m3u8",
|
|
1192
|
+
outputPath,
|
|
1193
|
+
sourceUrl: options.sourceUrl
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
function stripExtension(value) {
|
|
1198
|
+
const extension = extname(value);
|
|
1199
|
+
return extension.length === 0 ? value : value.slice(0, -extension.length);
|
|
1200
|
+
}
|
|
1201
|
+
function parsePlaylistResourceUrls(playlist, sourceUrl) {
|
|
1202
|
+
const urls = [];
|
|
1203
|
+
for (const rawLine of playlist.split(/\r?\n/u)) {
|
|
1204
|
+
const line = rawLine.trim();
|
|
1205
|
+
if (line.length === 0) {
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
const mapUri = /^#EXT-X-MAP:.*URI="([^"]+)"/u.exec(line)?.[1];
|
|
1209
|
+
if (mapUri) {
|
|
1210
|
+
urls.push(new URL(mapUri, sourceUrl).toString());
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
if (!line.startsWith("#")) {
|
|
1214
|
+
urls.push(new URL(line, sourceUrl).toString());
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return urls;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// src/video/download/browser-driver.ts
|
|
1221
|
+
import { join as join2 } from "path";
|
|
1222
|
+
var BrowserDownloadDriver = class {
|
|
1223
|
+
async download(options) {
|
|
1224
|
+
const outputPath = options.outputPath ?? join2(options.outputDir ?? ".", this.inferFileName(options.sourceUrl));
|
|
1225
|
+
const { chromium } = await this.loadPlaywright();
|
|
1226
|
+
if (!chromium) {
|
|
1227
|
+
throw new VideoCommandError(
|
|
1228
|
+
"VIDEO_BROWSER_UNAVAILABLE",
|
|
1229
|
+
"Browser download requires Playwright Chromium support.",
|
|
1230
|
+
2,
|
|
1231
|
+
void 0,
|
|
1232
|
+
true
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
const browser = await chromium.launch({ headless: true });
|
|
1236
|
+
try {
|
|
1237
|
+
const context = await browser.newContext();
|
|
1238
|
+
const page = await context.newPage();
|
|
1239
|
+
const downloadPromise = page.waitForEvent("download");
|
|
1240
|
+
await page.goto(options.sourceUrl, { waitUntil: "load" });
|
|
1241
|
+
await page.waitForTimeout(2e3);
|
|
1242
|
+
const download = await downloadPromise;
|
|
1243
|
+
await download.saveAs(outputPath);
|
|
1244
|
+
return {
|
|
1245
|
+
mediaType: "mp4",
|
|
1246
|
+
outputPath,
|
|
1247
|
+
sourceUrl: options.sourceUrl
|
|
1248
|
+
};
|
|
1249
|
+
} finally {
|
|
1250
|
+
await browser.close();
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
async loadPlaywright() {
|
|
1254
|
+
try {
|
|
1255
|
+
return await loadOptionalModule("playwright");
|
|
1256
|
+
} catch {
|
|
1257
|
+
throw new VideoCommandError(
|
|
1258
|
+
"VIDEO_BROWSER_UNAVAILABLE",
|
|
1259
|
+
"Browser download requires Playwright. Install it with: npm install playwright",
|
|
1260
|
+
2,
|
|
1261
|
+
void 0,
|
|
1262
|
+
true
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
inferFileName(sourceUrl) {
|
|
1267
|
+
try {
|
|
1268
|
+
const url = new URL(sourceUrl);
|
|
1269
|
+
const name = url.pathname.split("/").pop();
|
|
1270
|
+
return name && name.length > 0 ? name : "video.mp4";
|
|
1271
|
+
} catch {
|
|
1272
|
+
return "video.mp4";
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
var loadOptionalModule = new Function(
|
|
1277
|
+
"specifier",
|
|
1278
|
+
"return import(specifier);"
|
|
1279
|
+
);
|
|
1280
|
+
|
|
1281
|
+
// src/video/download/resolver.ts
|
|
1282
|
+
function encodeUrlPath(pathname) {
|
|
1283
|
+
const segments = pathname.split("/");
|
|
1284
|
+
return segments.map((segment) => {
|
|
1285
|
+
if (segment.length > 0 && segment.charCodeAt(0) > 127) {
|
|
1286
|
+
return encodeURIComponent(segment);
|
|
1287
|
+
}
|
|
1288
|
+
return segment;
|
|
1289
|
+
}).join("/");
|
|
1290
|
+
}
|
|
1291
|
+
function classifyVideoInput(input2) {
|
|
1292
|
+
let url;
|
|
1293
|
+
try {
|
|
1294
|
+
url = new URL(input2);
|
|
1295
|
+
} catch {
|
|
1296
|
+
const urlObj = new URL(input2.startsWith("http") ? input2 : `https://${input2}`);
|
|
1297
|
+
urlObj.pathname = encodeUrlPath(urlObj.pathname);
|
|
1298
|
+
url = urlObj;
|
|
1299
|
+
}
|
|
1300
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
1301
|
+
throw new VideoCommandError("VIDEO_UNSUPPORTED_INPUT", `Unsupported video input: ${input2}`);
|
|
1302
|
+
}
|
|
1303
|
+
if (/\.m3u8(?:\?|$)/i.test(url.href)) {
|
|
1304
|
+
return {
|
|
1305
|
+
kind: "m3u8",
|
|
1306
|
+
url: url.href
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
if (/\.mp4(?:\?|$)/i.test(url.href)) {
|
|
1310
|
+
return {
|
|
1311
|
+
kind: "mp4",
|
|
1312
|
+
url: url.href
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
kind: "page",
|
|
1317
|
+
url: url.href
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// src/video/download/service.ts
|
|
1322
|
+
var VideoDownloadService = class {
|
|
1323
|
+
directDriver;
|
|
1324
|
+
hlsDriver;
|
|
1325
|
+
sniffService;
|
|
1326
|
+
browserDriver;
|
|
1327
|
+
constructor(dependencies) {
|
|
1328
|
+
this.directDriver = dependencies.directDriver;
|
|
1329
|
+
this.hlsDriver = dependencies.hlsDriver;
|
|
1330
|
+
this.sniffService = dependencies.sniffService;
|
|
1331
|
+
this.browserDriver = dependencies.browserDriver;
|
|
1332
|
+
}
|
|
1333
|
+
async download(options) {
|
|
1334
|
+
const classified = classifyVideoInput(options.input);
|
|
1335
|
+
if (classified.kind === "page" && this.browserDriver) {
|
|
1336
|
+
const sniffed2 = await this.sniffService.sniff({
|
|
1337
|
+
mode: options.mode,
|
|
1338
|
+
sourceUrl: classified.url,
|
|
1339
|
+
...buildProxyOptions2(options)
|
|
1340
|
+
});
|
|
1341
|
+
const candidate2 = sniffed2.candidates[0];
|
|
1342
|
+
if (!candidate2) {
|
|
1343
|
+
throw new VideoCommandError(
|
|
1344
|
+
"VIDEO_NO_CANDIDATE",
|
|
1345
|
+
"No supported media candidate was detected for the provided input."
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
if (candidate2.type === "m3u8") {
|
|
1349
|
+
return this.hlsDriver.download(buildDownloadTargetOptions(options, candidate2.url));
|
|
1350
|
+
}
|
|
1351
|
+
return this.browserDriver.download(buildDownloadTargetOptions(options, candidate2.url));
|
|
1352
|
+
}
|
|
1353
|
+
if (classified.kind === "m3u8") {
|
|
1354
|
+
return this.hlsDriver.download(buildDownloadTargetOptions(options, classified.url));
|
|
1355
|
+
}
|
|
1356
|
+
if (this.browserDriver) {
|
|
1357
|
+
try {
|
|
1358
|
+
return await this.browserDriver.download(buildDownloadTargetOptions(options, classified.url));
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
if (error instanceof VideoCommandError && error.code === "VIDEO_BROWSER_UNAVAILABLE") {
|
|
1361
|
+
} else if (!isBrowserDownloadTimeout(error)) {
|
|
1362
|
+
throw error;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
if (classified.kind === "mp4") {
|
|
1367
|
+
return this.directDriver.download(buildDownloadTargetOptions(options, classified.url));
|
|
1368
|
+
}
|
|
1369
|
+
const sniffed = await this.sniffService.sniff({
|
|
1370
|
+
mode: options.mode,
|
|
1371
|
+
sourceUrl: classified.url,
|
|
1372
|
+
...buildProxyOptions2(options)
|
|
1373
|
+
});
|
|
1374
|
+
const candidate = sniffed.candidates[0];
|
|
1375
|
+
if (!candidate) {
|
|
1376
|
+
throw new VideoCommandError(
|
|
1377
|
+
"VIDEO_NO_CANDIDATE",
|
|
1378
|
+
"No supported media candidate was detected for the provided input."
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
if (candidate.type === "mp4") {
|
|
1382
|
+
return this.directDriver.download(buildDownloadTargetOptions(options, candidate.url));
|
|
1383
|
+
}
|
|
1384
|
+
return this.hlsDriver.download(buildDownloadTargetOptions(options, candidate.url));
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
function isBrowserDownloadTimeout(error) {
|
|
1388
|
+
return error instanceof Error && error.message.includes('waiting for event "download"');
|
|
1389
|
+
}
|
|
1390
|
+
function buildProxyOptions2(options) {
|
|
1391
|
+
return {
|
|
1392
|
+
...options.noProxy === void 0 ? {} : { noProxy: options.noProxy },
|
|
1393
|
+
...options.proxyUrl === void 0 ? {} : { proxyUrl: options.proxyUrl }
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function buildDownloadTargetOptions(options, sourceUrl) {
|
|
1397
|
+
return {
|
|
1398
|
+
...optional("outputDir", options.outputDir),
|
|
1399
|
+
...optional("outputPath", options.outputPath),
|
|
1400
|
+
...optional("noProxy", options.noProxy),
|
|
1401
|
+
...optional("proxyUrl", options.proxyUrl),
|
|
1402
|
+
sourceUrl
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// src/video/compress/service.ts
|
|
1407
|
+
import { extname as extname2 } from "path";
|
|
1408
|
+
|
|
1409
|
+
// src/video/compress/preset.ts
|
|
1410
|
+
var VIDEO_COMPRESSION_DEFAULTS = {
|
|
1411
|
+
audioBitrate: "64k",
|
|
1412
|
+
audioCodec: "aac",
|
|
1413
|
+
container: "mp4",
|
|
1414
|
+
videoCodec: "libx265"
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
// src/video/compress/ffmpeg.ts
|
|
1418
|
+
function buildFfmpegArgs(options) {
|
|
1419
|
+
const args = [
|
|
1420
|
+
"-i",
|
|
1421
|
+
options.inputPath,
|
|
1422
|
+
"-c:v",
|
|
1423
|
+
VIDEO_COMPRESSION_DEFAULTS.videoCodec
|
|
1424
|
+
];
|
|
1425
|
+
if (options.videoBitrate) {
|
|
1426
|
+
args.push("-b:v", options.videoBitrate);
|
|
1427
|
+
}
|
|
1428
|
+
if (options.resolution) {
|
|
1429
|
+
const [width, height] = options.resolution.split("x");
|
|
1430
|
+
args.push("-vf", `scale=${width}:${height}`);
|
|
1431
|
+
}
|
|
1432
|
+
args.push(
|
|
1433
|
+
"-c:a",
|
|
1434
|
+
VIDEO_COMPRESSION_DEFAULTS.audioCodec,
|
|
1435
|
+
"-b:a",
|
|
1436
|
+
options.audioBitrate ?? VIDEO_COMPRESSION_DEFAULTS.audioBitrate,
|
|
1437
|
+
options.outputPath
|
|
1438
|
+
);
|
|
1439
|
+
return args;
|
|
1440
|
+
}
|
|
1441
|
+
function probeFfmpegResult(result) {
|
|
1442
|
+
return result.code === 0 && result.stdout.includes("ffmpeg version");
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/video/compress/service.ts
|
|
1446
|
+
var VideoCompressionService = class {
|
|
1447
|
+
runtime;
|
|
1448
|
+
constructor(dependencies) {
|
|
1449
|
+
this.runtime = dependencies.runtime;
|
|
1450
|
+
}
|
|
1451
|
+
async compress(options) {
|
|
1452
|
+
const probe = await this.runtime.execFile("ffmpeg", ["-version"]);
|
|
1453
|
+
if (!probeFfmpegResult(probe)) {
|
|
1454
|
+
throw new VideoCommandError(
|
|
1455
|
+
"VIDEO_FFMPEG_MISSING",
|
|
1456
|
+
"ffmpeg is required for video compression."
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
const outputPath = options.outputPath ?? replaceExtension(options.inputPath, ".mp4");
|
|
1460
|
+
const execResult = await this.runtime.execFile(
|
|
1461
|
+
"ffmpeg",
|
|
1462
|
+
buildFfmpegArgs({
|
|
1463
|
+
inputPath: options.inputPath,
|
|
1464
|
+
outputPath,
|
|
1465
|
+
...options.audioBitrate ? { audioBitrate: options.audioBitrate } : {},
|
|
1466
|
+
...options.resolution ? { resolution: options.resolution } : {},
|
|
1467
|
+
...options.videoBitrate ? { videoBitrate: options.videoBitrate } : {}
|
|
1468
|
+
})
|
|
1469
|
+
);
|
|
1470
|
+
if (execResult.code !== 0) {
|
|
1471
|
+
throw new VideoCommandError(
|
|
1472
|
+
"VIDEO_COMPRESSION_FAILED",
|
|
1473
|
+
execResult.stderr || "Video compression failed."
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
return {
|
|
1477
|
+
audioBitrate: options.audioBitrate ?? VIDEO_COMPRESSION_DEFAULTS.audioBitrate,
|
|
1478
|
+
audioCodec: VIDEO_COMPRESSION_DEFAULTS.audioCodec,
|
|
1479
|
+
codec: VIDEO_COMPRESSION_DEFAULTS.videoCodec,
|
|
1480
|
+
inputPath: options.inputPath,
|
|
1481
|
+
outputPath,
|
|
1482
|
+
...options.resolution ? { resolution: options.resolution } : {},
|
|
1483
|
+
...options.videoBitrate ? { videoBitrate: options.videoBitrate } : {}
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
function replaceExtension(path, nextExtension) {
|
|
1488
|
+
const currentExtension = extname2(path);
|
|
1489
|
+
return currentExtension.length === 0 ? `${path}${nextExtension}` : `${path.slice(0, -currentExtension.length)}${nextExtension}`;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// src/video/output.ts
|
|
1493
|
+
import { table as table3 } from "table";
|
|
1494
|
+
function renderVideoCandidatesAsJson(result) {
|
|
1495
|
+
return JSON.stringify(
|
|
1496
|
+
{
|
|
1497
|
+
ok: true,
|
|
1498
|
+
data: result,
|
|
1499
|
+
meta: {
|
|
1500
|
+
count: result.candidates.length
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
null,
|
|
1504
|
+
2
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
function renderVideoDownloadResultAsJson(result) {
|
|
1508
|
+
return JSON.stringify(
|
|
1509
|
+
{
|
|
1510
|
+
ok: true,
|
|
1511
|
+
data: result
|
|
1512
|
+
},
|
|
1513
|
+
null,
|
|
1514
|
+
2
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
function renderVideoCompressionResultAsJson(result) {
|
|
1518
|
+
return JSON.stringify(
|
|
1519
|
+
{
|
|
1520
|
+
ok: true,
|
|
1521
|
+
data: result
|
|
1522
|
+
},
|
|
1523
|
+
null,
|
|
1524
|
+
2
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
function renderVideoCommandErrorAsJson(code, message, details) {
|
|
1528
|
+
return JSON.stringify(
|
|
1529
|
+
{
|
|
1530
|
+
ok: false,
|
|
1531
|
+
error: {
|
|
1532
|
+
code,
|
|
1533
|
+
message,
|
|
1534
|
+
...details === void 0 ? {} : { details }
|
|
1535
|
+
}
|
|
1536
|
+
},
|
|
1537
|
+
null,
|
|
1538
|
+
2
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
function renderVideoCandidatesAsTable(result) {
|
|
1542
|
+
return renderVideoOperationAsTable([
|
|
1543
|
+
["type", "url", "origin", "mode"],
|
|
1544
|
+
...result.candidates.map((candidate) => [
|
|
1545
|
+
candidate.type,
|
|
1546
|
+
candidate.url,
|
|
1547
|
+
candidate.origin,
|
|
1548
|
+
result.mode
|
|
1549
|
+
])
|
|
1550
|
+
]);
|
|
1551
|
+
}
|
|
1552
|
+
function renderVideoOperationAsTable(rows) {
|
|
1553
|
+
return table3(rows);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/video/command.ts
|
|
1557
|
+
var sniffOptionsSchema = z4.object({
|
|
1558
|
+
format: z4.enum(["json", "table"]).default("json"),
|
|
1559
|
+
mode: z4.enum(["auto", "browser", "http"]).default("auto"),
|
|
1560
|
+
proxy: z4.string().optional(),
|
|
1561
|
+
skipProxy: z4.boolean().default(false)
|
|
1562
|
+
});
|
|
1563
|
+
var downloadOptionsSchema = z4.object({
|
|
1564
|
+
format: z4.enum(["json", "table"]).default("json"),
|
|
1565
|
+
mode: z4.enum(["auto", "browser", "http"]).default("auto"),
|
|
1566
|
+
outputDir: z4.string().optional(),
|
|
1567
|
+
proxy: z4.string().optional(),
|
|
1568
|
+
skipProxy: z4.boolean().default(false)
|
|
1569
|
+
});
|
|
1570
|
+
var compressOptionsSchema = z4.object({
|
|
1571
|
+
audioBitrate: z4.string().optional(),
|
|
1572
|
+
format: z4.enum(["json", "table"]).default("json"),
|
|
1573
|
+
outputPath: z4.string().optional(),
|
|
1574
|
+
resolution: z4.string().optional(),
|
|
1575
|
+
videoBitrate: z4.string().optional()
|
|
1576
|
+
});
|
|
1577
|
+
function registerVideoCommands(program, dependencies) {
|
|
1578
|
+
const video = program.command("video").description("Inspect, download, and compress video media.");
|
|
1579
|
+
const browserProvider = new BrowserVideoSniffProvider({
|
|
1580
|
+
runtime: dependencies.runtime
|
|
1581
|
+
});
|
|
1582
|
+
const httpProvider = new HttpVideoSniffProvider({
|
|
1583
|
+
runtime: dependencies.runtime
|
|
1584
|
+
});
|
|
1585
|
+
const sniffService = new VideoSniffService({
|
|
1586
|
+
browserProvider: (sourceUrl, opts) => browserProvider.sniff(sourceUrl, opts),
|
|
1587
|
+
httpProvider: (sourceUrl, opts) => httpProvider.sniff(sourceUrl, opts)
|
|
1588
|
+
});
|
|
1589
|
+
const browserDriver = new BrowserDownloadDriver();
|
|
1590
|
+
const downloadService = new VideoDownloadService({
|
|
1591
|
+
directDriver: new DirectVideoDownloadDriver({
|
|
1592
|
+
runtime: dependencies.runtime
|
|
1593
|
+
}),
|
|
1594
|
+
hlsDriver: new HlsVideoDownloadDriver({
|
|
1595
|
+
runtime: dependencies.runtime
|
|
1596
|
+
}),
|
|
1597
|
+
sniffService,
|
|
1598
|
+
browserDriver
|
|
1599
|
+
});
|
|
1600
|
+
const compressionService = new VideoCompressionService({
|
|
1601
|
+
runtime: dependencies.runtime
|
|
1602
|
+
});
|
|
1603
|
+
video.command("sniff").argument("<input>", "page url or media url").option("--mode <mode>", "sniff mode", "auto").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").option("--proxy <proxy>", "proxy URL").option("--skip-proxy", "disable proxy").action(async (input2, options) => {
|
|
1604
|
+
const parsed = sniffOptionsSchema.parse(applyVideoTableShortcut(options));
|
|
1605
|
+
const result = await sniffService.sniff({
|
|
1606
|
+
mode: parsed.mode,
|
|
1607
|
+
sourceUrl: input2,
|
|
1608
|
+
...parsed.proxy ? { proxyUrl: parsed.proxy } : {},
|
|
1609
|
+
...parsed.skipProxy ? { noProxy: true } : {}
|
|
1610
|
+
});
|
|
1611
|
+
if (parsed.format === "table") {
|
|
1612
|
+
dependencies.stdout(`${renderVideoCandidatesAsTable(result)}
|
|
1613
|
+
`);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
dependencies.stdout(`${renderVideoCandidatesAsJson(result)}
|
|
1617
|
+
`);
|
|
1618
|
+
});
|
|
1619
|
+
video.command("download").argument("<input>", "page url, m3u8 url, or mp4 url").option("--mode <mode>", "sniff mode", "auto").option("--output-dir <outputDir>", "output directory").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").option("--proxy <proxy>", "proxy URL").option("--skip-proxy", "disable proxy").action(async (input2, options) => {
|
|
1620
|
+
const parsed = downloadOptionsSchema.parse(applyVideoTableShortcut(options));
|
|
1621
|
+
const result = await downloadService.download({
|
|
1622
|
+
input: input2,
|
|
1623
|
+
mode: parsed.mode,
|
|
1624
|
+
...parsed.outputDir ? { outputDir: parsed.outputDir } : {},
|
|
1625
|
+
...parsed.proxy ? { proxyUrl: parsed.proxy } : {},
|
|
1626
|
+
...parsed.skipProxy ? { noProxy: true } : {}
|
|
1627
|
+
});
|
|
1628
|
+
if (parsed.format === "table") {
|
|
1629
|
+
dependencies.stdout(
|
|
1630
|
+
`${renderVideoOperationAsTable([
|
|
1631
|
+
["field", "value"],
|
|
1632
|
+
["mediaType", result.mediaType],
|
|
1633
|
+
["outputPath", result.outputPath]
|
|
1634
|
+
])}
|
|
1635
|
+
`
|
|
1636
|
+
);
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
dependencies.stdout(`${renderVideoDownloadResultAsJson(result)}
|
|
1640
|
+
`);
|
|
1641
|
+
});
|
|
1642
|
+
video.command("compress").argument("<input>", "local input video path").option("--output-path <outputPath>", "output path").option("--resolution <resolution>", "target resolution, such as 1280x720").option("--video-bitrate <videoBitrate>", "target video bitrate").option("--audio-bitrate <audioBitrate>", "target audio bitrate").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (input2, options) => {
|
|
1643
|
+
const parsed = compressOptionsSchema.parse(applyVideoTableShortcut(options));
|
|
1644
|
+
const result = await compressionService.compress({
|
|
1645
|
+
inputPath: input2,
|
|
1646
|
+
...parsed.audioBitrate ? { audioBitrate: parsed.audioBitrate } : {},
|
|
1647
|
+
...parsed.outputPath ? { outputPath: parsed.outputPath } : {},
|
|
1648
|
+
...parsed.resolution ? { resolution: parsed.resolution } : {},
|
|
1649
|
+
...parsed.videoBitrate ? { videoBitrate: parsed.videoBitrate } : {}
|
|
1650
|
+
});
|
|
1651
|
+
if (parsed.format === "table") {
|
|
1652
|
+
dependencies.stdout(
|
|
1653
|
+
`${renderVideoOperationAsTable([
|
|
1654
|
+
["field", "value"],
|
|
1655
|
+
["codec", result.codec],
|
|
1656
|
+
["outputPath", result.outputPath]
|
|
1657
|
+
])}
|
|
1658
|
+
`
|
|
1659
|
+
);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
dependencies.stdout(`${renderVideoCompressionResultAsJson(result)}
|
|
1663
|
+
`);
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
function handleVideoCommandError(error, dependencies) {
|
|
1667
|
+
if (error instanceof VideoCommandError) {
|
|
1668
|
+
const details = error.details;
|
|
1669
|
+
dependencies.stderr(
|
|
1670
|
+
`${renderVideoCommandErrorAsJson(
|
|
1671
|
+
error.code,
|
|
1672
|
+
error.message,
|
|
1673
|
+
details
|
|
1674
|
+
)}
|
|
1675
|
+
`
|
|
1676
|
+
);
|
|
1677
|
+
return error.exitCode;
|
|
1678
|
+
}
|
|
1679
|
+
return void 0;
|
|
1680
|
+
}
|
|
1681
|
+
function applyVideoTableShortcut(options) {
|
|
1682
|
+
if (options.table) {
|
|
1683
|
+
return {
|
|
1684
|
+
...options,
|
|
1685
|
+
format: "table"
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
if (options.format) {
|
|
1689
|
+
return {
|
|
1690
|
+
...options,
|
|
1691
|
+
format: options.format
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
throw new Error("Output format is required for this command.");
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// src/36kr/command.ts
|
|
1698
|
+
import { z as z5 } from "zod";
|
|
1699
|
+
|
|
1700
|
+
// src/36kr/output.ts
|
|
1701
|
+
import { table as table4 } from "table";
|
|
1702
|
+
function renderKr36ArticleAsJson(article) {
|
|
1703
|
+
return JSON.stringify(
|
|
1704
|
+
{
|
|
1705
|
+
ok: true,
|
|
1706
|
+
data: article
|
|
1707
|
+
},
|
|
1708
|
+
null,
|
|
1709
|
+
2
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
function renderKr36InformationListAsJson(list) {
|
|
1713
|
+
return JSON.stringify(
|
|
1714
|
+
{
|
|
1715
|
+
ok: true,
|
|
1716
|
+
data: list
|
|
1717
|
+
},
|
|
1718
|
+
null,
|
|
1719
|
+
2
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
function renderKr36InformationListAsTable(list) {
|
|
1723
|
+
return table4([
|
|
1724
|
+
["id", "title", "author", "publishTime", "url"],
|
|
1725
|
+
...list.items.map((item) => [
|
|
1726
|
+
String(item.id),
|
|
1727
|
+
item.title,
|
|
1728
|
+
item.authorName ?? "",
|
|
1729
|
+
item.publishTime?.local ?? "",
|
|
1730
|
+
item.url
|
|
1731
|
+
])
|
|
1732
|
+
]);
|
|
1733
|
+
}
|
|
1734
|
+
function renderKr36CommandErrorAsJson(code, message, details) {
|
|
1735
|
+
return JSON.stringify(
|
|
1736
|
+
{
|
|
1737
|
+
ok: false,
|
|
1738
|
+
error: {
|
|
1739
|
+
code,
|
|
1740
|
+
message,
|
|
1741
|
+
...details === void 0 ? {} : { details }
|
|
1742
|
+
}
|
|
1743
|
+
},
|
|
1744
|
+
null,
|
|
1745
|
+
2
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// src/36kr/types.ts
|
|
1750
|
+
var Kr36CommandError = class extends Error {
|
|
1751
|
+
constructor(code, message, exitCode, details) {
|
|
1752
|
+
super(message);
|
|
1753
|
+
this.code = code;
|
|
1754
|
+
this.exitCode = exitCode;
|
|
1755
|
+
this.details = details;
|
|
1756
|
+
this.name = "Kr36CommandError";
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
// src/36kr/service.ts
|
|
1761
|
+
var KR36_BROWSER_HEADERS = {
|
|
1762
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
1763
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
1764
|
+
"Cache-Control": "no-cache",
|
|
1765
|
+
Pragma: "no-cache",
|
|
1766
|
+
Referer: "https://36kr.com/",
|
|
1767
|
+
"Sec-Fetch-Dest": "document",
|
|
1768
|
+
"Sec-Fetch-Mode": "navigate",
|
|
1769
|
+
"Sec-Fetch-Site": "same-origin",
|
|
1770
|
+
"Sec-Fetch-User": "?1",
|
|
1771
|
+
"Upgrade-Insecure-Requests": "1",
|
|
1772
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
1773
|
+
};
|
|
1774
|
+
var KR36_INFORMATION_ENDPOINT = "https://gateway.36kr.com/api/mis/nav/ifm/subNav/flow";
|
|
1775
|
+
var KR36_INFORMATION_PAGE_SIZE = 30;
|
|
1776
|
+
var KR36_SUPPORTED_CHANNELS = /* @__PURE__ */ new Set(["AI", "technology"]);
|
|
1777
|
+
var KR36_JSON_HEADERS = {
|
|
1778
|
+
Accept: "application/json, text/plain, */*",
|
|
1779
|
+
"Accept-Language": KR36_BROWSER_HEADERS["Accept-Language"],
|
|
1780
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
1781
|
+
Origin: "https://36kr.com",
|
|
1782
|
+
"User-Agent": KR36_BROWSER_HEADERS["User-Agent"]
|
|
1783
|
+
};
|
|
1784
|
+
var Kr36ArticleService = class {
|
|
1785
|
+
constructor(runtime) {
|
|
1786
|
+
this.runtime = runtime;
|
|
1787
|
+
}
|
|
1788
|
+
async getArticle(articleId) {
|
|
1789
|
+
assertArticleId(articleId);
|
|
1790
|
+
const request = buildKr36ArticleRequest(articleId);
|
|
1791
|
+
const html = await this.runtime.fetchArticleHtml(request);
|
|
1792
|
+
const initialState = parseInitialState(html);
|
|
1793
|
+
const articleDetail = initialState.articleDetail;
|
|
1794
|
+
const rawArticle = articleDetail?.articleDetailData?.data;
|
|
1795
|
+
if (!rawArticle) {
|
|
1796
|
+
throw new Kr36CommandError(
|
|
1797
|
+
"KR36_PARSE_ERROR",
|
|
1798
|
+
"36kr article data was not found in window.initialState.",
|
|
1799
|
+
2
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
const contentHtml = rawArticle.widgetContent ?? "";
|
|
1803
|
+
const recommend = articleDetail?.articleRecommendData ?? {};
|
|
1804
|
+
const publishTime = rawArticle.publishTime ?? 0;
|
|
1805
|
+
const article = {
|
|
1806
|
+
author: buildAuthor(rawArticle, recommend),
|
|
1807
|
+
content: {
|
|
1808
|
+
html: contentHtml,
|
|
1809
|
+
paragraphs: extractParagraphs(contentHtml)
|
|
1810
|
+
},
|
|
1811
|
+
id: String(rawArticle.itemId ?? articleId),
|
|
1812
|
+
imageSources: rawArticle.imgSources ?? [],
|
|
1813
|
+
images: extractImages(contentHtml),
|
|
1814
|
+
latestArticles: (articleDetail?.latestArticle?.articleLatestList ?? []).map((item) => ({
|
|
1815
|
+
id: item.id,
|
|
1816
|
+
title: item.title
|
|
1817
|
+
})),
|
|
1818
|
+
newestArticles: (recommend.newestItemList ?? []).map((item) => {
|
|
1819
|
+
const listItem = {
|
|
1820
|
+
id: item.itemId,
|
|
1821
|
+
title: item.itemTitle
|
|
1822
|
+
};
|
|
1823
|
+
if (item.itemContent !== void 0) {
|
|
1824
|
+
listItem.content = item.itemContent;
|
|
1825
|
+
}
|
|
1826
|
+
if (item.publishTime !== void 0) {
|
|
1827
|
+
listItem.publishTime = item.publishTime;
|
|
1828
|
+
}
|
|
1829
|
+
if (item.itemRoute !== void 0) {
|
|
1830
|
+
listItem.route = item.itemRoute;
|
|
1831
|
+
}
|
|
1832
|
+
return listItem;
|
|
1833
|
+
}),
|
|
1834
|
+
organizations: articleDetail?.organArticleData?.data?.organizationList ?? [],
|
|
1835
|
+
publishTime: {
|
|
1836
|
+
iso: publishTime ? new Date(publishTime).toISOString() : "",
|
|
1837
|
+
local: publishTime ? formatChinaTime(publishTime) : "",
|
|
1838
|
+
ms: publishTime
|
|
1839
|
+
},
|
|
1840
|
+
relatedArticles: (recommend.relateArticleList ?? []).map((item) => {
|
|
1841
|
+
const listItem = {
|
|
1842
|
+
id: item.itemId,
|
|
1843
|
+
title: item.widgetTitle
|
|
1844
|
+
};
|
|
1845
|
+
const author = item.author ?? item.authorName;
|
|
1846
|
+
if (author !== void 0) {
|
|
1847
|
+
listItem.author = author;
|
|
1848
|
+
}
|
|
1849
|
+
if (item.widgetImage !== void 0) {
|
|
1850
|
+
listItem.image = item.widgetImage;
|
|
1851
|
+
}
|
|
1852
|
+
if (item.route !== void 0) {
|
|
1853
|
+
listItem.route = item.route;
|
|
1854
|
+
}
|
|
1855
|
+
return listItem;
|
|
1856
|
+
}),
|
|
1857
|
+
request,
|
|
1858
|
+
stats: buildStats(articleDetail?.likeCount, articleDetail?.favoriteCount, recommend),
|
|
1859
|
+
summary: rawArticle.summary ?? "",
|
|
1860
|
+
title: rawArticle.widgetTitle ?? "",
|
|
1861
|
+
url: request.url
|
|
1862
|
+
};
|
|
1863
|
+
if (rawArticle.companyCertifyNick !== void 0) {
|
|
1864
|
+
article.companyCertifyNick = rawArticle.companyCertifyNick;
|
|
1865
|
+
}
|
|
1866
|
+
if (rawArticle.popinImage !== void 0) {
|
|
1867
|
+
article.coverImage = rawArticle.popinImage;
|
|
1868
|
+
}
|
|
1869
|
+
if (recommend.nextItem) {
|
|
1870
|
+
article.nextArticle = {
|
|
1871
|
+
id: recommend.nextItem.itemId,
|
|
1872
|
+
title: recommend.nextItem.itemTitle
|
|
1873
|
+
};
|
|
1874
|
+
if (recommend.nextItem.itemContent !== void 0) {
|
|
1875
|
+
article.nextArticle.content = recommend.nextItem.itemContent;
|
|
1876
|
+
}
|
|
1877
|
+
if (recommend.nextItem.publishTime !== void 0) {
|
|
1878
|
+
article.nextArticle.publishTime = recommend.nextItem.publishTime;
|
|
1879
|
+
}
|
|
1880
|
+
if (recommend.nextItem.itemRoute !== void 0) {
|
|
1881
|
+
article.nextArticle.route = recommend.nextItem.itemRoute;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
if (rawArticle.sourceType !== void 0) {
|
|
1885
|
+
article.sourceType = rawArticle.sourceType;
|
|
1886
|
+
}
|
|
1887
|
+
return article;
|
|
1888
|
+
}
|
|
1889
|
+
async getInformationList(options) {
|
|
1890
|
+
const channel = parseInformationChannel(options.channel);
|
|
1891
|
+
const pageLimit = normalizePages(options.pages);
|
|
1892
|
+
const firstPageRequest = buildKr36InformationFirstPageRequest(channel);
|
|
1893
|
+
const firstPageHtml = await this.runtime.fetchArticleHtml(firstPageRequest);
|
|
1894
|
+
const firstPageState = parseInitialState(firstPageHtml);
|
|
1895
|
+
const firstPageList = firstPageState.information?.informationList;
|
|
1896
|
+
if (!firstPageList) {
|
|
1897
|
+
throw new Kr36CommandError(
|
|
1898
|
+
"KR36_PARSE_ERROR",
|
|
1899
|
+
"36kr information list data was not found in window.initialState.",
|
|
1900
|
+
2
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
const items = [...mapInformationItems(firstPageList.itemList ?? [])];
|
|
1904
|
+
let pageCallback = firstPageList.pageCallback ?? "";
|
|
1905
|
+
let hasNextPage = firstPageList.hasNextPage ?? 0;
|
|
1906
|
+
let fetchedPages = 1;
|
|
1907
|
+
while (fetchedPages < pageLimit && hasNextPage && pageCallback) {
|
|
1908
|
+
const request = buildKr36InformationNextPageRequest(channel, pageCallback);
|
|
1909
|
+
const response = parseInformationFlowResponse(await this.runtime.fetchJson(request));
|
|
1910
|
+
const nextList = response.data ?? {};
|
|
1911
|
+
items.push(...mapInformationItems(nextList.itemList ?? []));
|
|
1912
|
+
pageCallback = nextList.pageCallback ?? "";
|
|
1913
|
+
hasNextPage = nextList.hasNextPage ?? 0;
|
|
1914
|
+
fetchedPages += 1;
|
|
1915
|
+
}
|
|
1916
|
+
return {
|
|
1917
|
+
channel,
|
|
1918
|
+
items,
|
|
1919
|
+
meta: {
|
|
1920
|
+
fetchedPages,
|
|
1921
|
+
hasNextPage,
|
|
1922
|
+
nextPageCallback: pageCallback,
|
|
1923
|
+
pageSize: KR36_INFORMATION_PAGE_SIZE,
|
|
1924
|
+
totalItems: items.length
|
|
1925
|
+
},
|
|
1926
|
+
request: {
|
|
1927
|
+
firstPage: firstPageRequest,
|
|
1928
|
+
nextPageEndpoint: KR36_INFORMATION_ENDPOINT
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
function buildKr36ArticleUrl(articleId) {
|
|
1934
|
+
assertArticleId(articleId);
|
|
1935
|
+
return `https://36kr.com/p/${articleId}?f=rss`;
|
|
1936
|
+
}
|
|
1937
|
+
function buildKr36ArticleRequest(articleId) {
|
|
1938
|
+
return {
|
|
1939
|
+
headers: KR36_BROWSER_HEADERS,
|
|
1940
|
+
url: buildKr36ArticleUrl(articleId)
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
function buildKr36InformationFirstPageRequest(channel) {
|
|
1944
|
+
return {
|
|
1945
|
+
headers: KR36_BROWSER_HEADERS,
|
|
1946
|
+
url: `https://36kr.com/information/${channel}/`
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function buildKr36InformationNextPageRequest(channel, pageCallback, timestamp = Date.now()) {
|
|
1950
|
+
return {
|
|
1951
|
+
body: {
|
|
1952
|
+
partner_id: "web",
|
|
1953
|
+
timestamp,
|
|
1954
|
+
param: {
|
|
1955
|
+
subnavType: 1,
|
|
1956
|
+
subnavNick: channel,
|
|
1957
|
+
pageSize: KR36_INFORMATION_PAGE_SIZE,
|
|
1958
|
+
pageEvent: 1,
|
|
1959
|
+
pageCallback,
|
|
1960
|
+
siteId: 1,
|
|
1961
|
+
platformId: 2
|
|
1962
|
+
}
|
|
1963
|
+
},
|
|
1964
|
+
headers: {
|
|
1965
|
+
...KR36_JSON_HEADERS,
|
|
1966
|
+
Referer: `https://36kr.com/information/${channel}/`
|
|
1967
|
+
},
|
|
1968
|
+
url: KR36_INFORMATION_ENDPOINT
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
function assertArticleId(articleId) {
|
|
1972
|
+
if (!/^\d+$/.test(articleId)) {
|
|
1973
|
+
throw new Kr36CommandError(
|
|
1974
|
+
"KR36_INVALID_ARTICLE_ID",
|
|
1975
|
+
"36kr article id must contain digits only.",
|
|
1976
|
+
2,
|
|
1977
|
+
{
|
|
1978
|
+
articleId
|
|
1979
|
+
}
|
|
1980
|
+
);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
function parseInformationChannel(channel) {
|
|
1984
|
+
if (KR36_SUPPORTED_CHANNELS.has(channel)) {
|
|
1985
|
+
return channel;
|
|
1986
|
+
}
|
|
1987
|
+
throw new Kr36CommandError(
|
|
1988
|
+
"KR36_INVALID_CHANNEL",
|
|
1989
|
+
"36kr list only supports AI and technology channels.",
|
|
1990
|
+
2,
|
|
1991
|
+
{
|
|
1992
|
+
channel,
|
|
1993
|
+
supportedChannels: [...KR36_SUPPORTED_CHANNELS]
|
|
1994
|
+
}
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
function normalizePages(pages) {
|
|
1998
|
+
const value = pages ?? 1;
|
|
1999
|
+
if (!Number.isInteger(value) || value < 1 || value > 20) {
|
|
2000
|
+
throw new Kr36CommandError(
|
|
2001
|
+
"KR36_INVALID_PAGES",
|
|
2002
|
+
"36kr list pages must be an integer between 1 and 20.",
|
|
2003
|
+
2,
|
|
2004
|
+
{
|
|
2005
|
+
pages: value
|
|
2006
|
+
}
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
return value;
|
|
2010
|
+
}
|
|
2011
|
+
function parseInitialState(html) {
|
|
2012
|
+
const match = /window\.initialState=(\{[\s\S]*?\})\s*;?\s*<\/script>/.exec(html);
|
|
2013
|
+
if (!match?.[1]) {
|
|
2014
|
+
throw new Kr36CommandError(
|
|
2015
|
+
"KR36_PARSE_ERROR",
|
|
2016
|
+
"window.initialState was not found in the 36kr article page.",
|
|
2017
|
+
2
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
try {
|
|
2021
|
+
return JSON.parse(match[1]);
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
throw new Kr36CommandError(
|
|
2024
|
+
"KR36_PARSE_ERROR",
|
|
2025
|
+
"Failed to parse window.initialState from the 36kr article page.",
|
|
2026
|
+
2,
|
|
2027
|
+
{
|
|
2028
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
2029
|
+
}
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
function extractImages(html) {
|
|
2034
|
+
return [...html.matchAll(/<img\b[^>]*>/gi)].map((match, index) => {
|
|
2035
|
+
const tag = match[0];
|
|
2036
|
+
return {
|
|
2037
|
+
index: index + 1,
|
|
2038
|
+
size: getAttribute(tag, "data-img-size-val"),
|
|
2039
|
+
url: getAttribute(tag, "src") ?? ""
|
|
2040
|
+
};
|
|
2041
|
+
}).filter((image) => image.url);
|
|
2042
|
+
}
|
|
2043
|
+
function parseInformationFlowResponse(jsonText) {
|
|
2044
|
+
try {
|
|
2045
|
+
const response = JSON.parse(jsonText);
|
|
2046
|
+
if (response.code !== 0) {
|
|
2047
|
+
throw new Kr36CommandError(
|
|
2048
|
+
"KR36_REQUEST_FAILED",
|
|
2049
|
+
response.msg || "36kr information flow request failed.",
|
|
2050
|
+
1,
|
|
2051
|
+
{
|
|
2052
|
+
code: response.code
|
|
2053
|
+
}
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
return response;
|
|
2057
|
+
} catch (error) {
|
|
2058
|
+
if (error instanceof Kr36CommandError) {
|
|
2059
|
+
throw error;
|
|
2060
|
+
}
|
|
2061
|
+
throw new Kr36CommandError(
|
|
2062
|
+
"KR36_PARSE_ERROR",
|
|
2063
|
+
"Failed to parse 36kr information flow response.",
|
|
2064
|
+
2,
|
|
2065
|
+
{
|
|
2066
|
+
cause: error instanceof Error ? error.message : String(error)
|
|
2067
|
+
}
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
function mapInformationItems(items) {
|
|
2072
|
+
return items.map((item) => {
|
|
2073
|
+
const material = item.templateMaterial ?? {};
|
|
2074
|
+
const mapped = {
|
|
2075
|
+
id: item.itemId,
|
|
2076
|
+
title: material.widgetTitle ?? "",
|
|
2077
|
+
url: `https://36kr.com/p/${item.itemId}`
|
|
2078
|
+
};
|
|
2079
|
+
if (material.authorName !== void 0) {
|
|
2080
|
+
mapped.authorName = material.authorName;
|
|
2081
|
+
}
|
|
2082
|
+
if (material.authorRoute !== void 0) {
|
|
2083
|
+
mapped.authorRoute = material.authorRoute;
|
|
2084
|
+
}
|
|
2085
|
+
if (material.widgetImage !== void 0) {
|
|
2086
|
+
mapped.image = material.widgetImage;
|
|
2087
|
+
}
|
|
2088
|
+
if (material.publishTime !== void 0) {
|
|
2089
|
+
mapped.publishTime = {
|
|
2090
|
+
iso: new Date(material.publishTime).toISOString(),
|
|
2091
|
+
local: formatChinaTime(material.publishTime),
|
|
2092
|
+
ms: material.publishTime
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
if (item.route !== void 0) {
|
|
2096
|
+
mapped.route = item.route;
|
|
2097
|
+
}
|
|
2098
|
+
if (material.summary !== void 0) {
|
|
2099
|
+
mapped.summary = material.summary;
|
|
2100
|
+
}
|
|
2101
|
+
return mapped;
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
function extractParagraphs(html) {
|
|
2105
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, "\n").split(/\n+/).map((value) => decodeHtmlEntities(value).trim()).filter(Boolean);
|
|
2106
|
+
}
|
|
2107
|
+
function getAttribute(tag, name) {
|
|
2108
|
+
const match = new RegExp(`${name}=["']([^"']+)["']`, "i").exec(tag);
|
|
2109
|
+
return match?.[1] ?? null;
|
|
2110
|
+
}
|
|
2111
|
+
function decodeHtmlEntities(value) {
|
|
2112
|
+
return value.replace(/ /g, " ").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
2113
|
+
}
|
|
2114
|
+
function buildStats(likeCount, favoriteCount, recommend) {
|
|
2115
|
+
return {
|
|
2116
|
+
authorArticleCount: recommend.statArticle ?? 0,
|
|
2117
|
+
collect: recommend.statCollect ?? 0,
|
|
2118
|
+
comment: recommend.statComment ?? 0,
|
|
2119
|
+
favoriteCount: favoriteCount ?? 0,
|
|
2120
|
+
likeCount: likeCount ?? 0,
|
|
2121
|
+
praise: recommend.statPraise ?? 0
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
function buildAuthor(rawArticle, recommend) {
|
|
2125
|
+
const author = {
|
|
2126
|
+
name: rawArticle.author ?? recommend.authorName ?? ""
|
|
2127
|
+
};
|
|
2128
|
+
const face = rawArticle.authorFace ?? recommend.authorFace;
|
|
2129
|
+
if (face !== void 0) {
|
|
2130
|
+
author.face = face;
|
|
2131
|
+
}
|
|
2132
|
+
if (rawArticle.authorId !== void 0) {
|
|
2133
|
+
author.id = rawArticle.authorId;
|
|
2134
|
+
}
|
|
2135
|
+
if (rawArticle.authorRoute !== void 0) {
|
|
2136
|
+
author.route = rawArticle.authorRoute;
|
|
2137
|
+
}
|
|
2138
|
+
if (recommend.authorSummary !== void 0) {
|
|
2139
|
+
author.summary = recommend.authorSummary;
|
|
2140
|
+
}
|
|
2141
|
+
if (recommend.authorTitle !== void 0) {
|
|
2142
|
+
author.title = recommend.authorTitle;
|
|
2143
|
+
}
|
|
2144
|
+
return author;
|
|
2145
|
+
}
|
|
2146
|
+
function formatChinaTime(timestampMs) {
|
|
2147
|
+
const formatter = new Intl.DateTimeFormat("sv-SE", {
|
|
2148
|
+
day: "2-digit",
|
|
2149
|
+
hour: "2-digit",
|
|
2150
|
+
hour12: false,
|
|
2151
|
+
minute: "2-digit",
|
|
2152
|
+
month: "2-digit",
|
|
2153
|
+
second: "2-digit",
|
|
2154
|
+
timeZone: "Asia/Shanghai",
|
|
2155
|
+
year: "numeric"
|
|
2156
|
+
});
|
|
2157
|
+
return formatter.format(new Date(timestampMs));
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
// src/36kr/command.ts
|
|
2161
|
+
var articleOptionsSchema = z5.object({
|
|
2162
|
+
format: z5.enum(["json"]).default("json")
|
|
2163
|
+
});
|
|
2164
|
+
var informationListOptionsSchema = z5.object({
|
|
2165
|
+
format: z5.enum(["json", "table"]).default("json"),
|
|
2166
|
+
pages: z5.number().int().min(1).max(20).default(1),
|
|
2167
|
+
table: z5.boolean().optional()
|
|
2168
|
+
});
|
|
2169
|
+
function registerKr36Commands(program, dependencies) {
|
|
2170
|
+
const kr36 = program.command("36kr").description("Fetch and parse 36kr content.");
|
|
2171
|
+
const articleService = new Kr36ArticleService(dependencies.runtime);
|
|
2172
|
+
kr36.command("article").argument("<articleId>", "36kr article id").option("--format <format>", "output format", "json").action(async (articleId, options) => {
|
|
2173
|
+
articleOptionsSchema.parse(options);
|
|
2174
|
+
const article = await articleService.getArticle(articleId);
|
|
2175
|
+
dependencies.stdout(`${renderKr36ArticleAsJson(article)}
|
|
2176
|
+
`);
|
|
2177
|
+
});
|
|
2178
|
+
kr36.command("list").argument("<channel>", "36kr information channel: AI or technology").option("--pages <pages>", "number of pages to fetch", parsePages, 1).option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (channel, options) => {
|
|
2179
|
+
const parsed = informationListOptionsSchema.parse(applyTableShortcut(options));
|
|
2180
|
+
const list = await articleService.getInformationList({
|
|
2181
|
+
channel,
|
|
2182
|
+
pages: parsed.pages
|
|
2183
|
+
});
|
|
2184
|
+
dependencies.stdout(
|
|
2185
|
+
parsed.format === "table" ? `${renderKr36InformationListAsTable(list)}
|
|
2186
|
+
` : `${renderKr36InformationListAsJson(list)}
|
|
2187
|
+
`
|
|
2188
|
+
);
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
function handleKr36CommandError(error, dependencies) {
|
|
2192
|
+
if (error instanceof Kr36CommandError) {
|
|
2193
|
+
dependencies.stderr(
|
|
2194
|
+
`${renderKr36CommandErrorAsJson(error.code, error.message, error.details)}
|
|
2195
|
+
`
|
|
2196
|
+
);
|
|
2197
|
+
return error.exitCode;
|
|
2198
|
+
}
|
|
2199
|
+
return void 0;
|
|
2200
|
+
}
|
|
2201
|
+
function parsePages(value) {
|
|
2202
|
+
const parsed = Number(value);
|
|
2203
|
+
if (!Number.isInteger(parsed)) {
|
|
2204
|
+
throw new Kr36CommandError(
|
|
2205
|
+
"KR36_INVALID_PAGES",
|
|
2206
|
+
"36kr list pages must be an integer between 1 and 20.",
|
|
2207
|
+
2,
|
|
2208
|
+
{
|
|
2209
|
+
pages: value
|
|
2210
|
+
}
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
return parsed;
|
|
2214
|
+
}
|
|
2215
|
+
function applyTableShortcut(options) {
|
|
2216
|
+
if (options.table) {
|
|
2217
|
+
return {
|
|
2218
|
+
...options,
|
|
2219
|
+
format: "table"
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
return {
|
|
2223
|
+
...options,
|
|
2224
|
+
format: options.format ?? "json"
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// src/toutiao/command.ts
|
|
2229
|
+
import { z as z6 } from "zod";
|
|
2230
|
+
|
|
2231
|
+
// src/toutiao/output.ts
|
|
2232
|
+
import { table as table5 } from "table";
|
|
2233
|
+
function renderToutiaoListAsJson(result) {
|
|
2234
|
+
return JSON.stringify(
|
|
2235
|
+
{
|
|
2236
|
+
ok: true,
|
|
2237
|
+
data: result
|
|
2238
|
+
},
|
|
2239
|
+
null,
|
|
2240
|
+
2
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
function renderToutiaoListAsTable(result) {
|
|
2244
|
+
return table5([
|
|
2245
|
+
["id", "title", "author", "publishTime", "comments", "url"],
|
|
2246
|
+
...result.items.map((item) => [
|
|
2247
|
+
item.id,
|
|
2248
|
+
item.title,
|
|
2249
|
+
item.authorName ?? "",
|
|
2250
|
+
item.publishTime?.local ?? "",
|
|
2251
|
+
item.commentCount === void 0 ? "" : String(item.commentCount),
|
|
2252
|
+
item.url
|
|
2253
|
+
])
|
|
2254
|
+
]);
|
|
2255
|
+
}
|
|
2256
|
+
function renderToutiaoArticleAsJson(article) {
|
|
2257
|
+
return JSON.stringify(
|
|
2258
|
+
{
|
|
2259
|
+
ok: true,
|
|
2260
|
+
data: article
|
|
2261
|
+
},
|
|
2262
|
+
null,
|
|
2263
|
+
2
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
function renderToutiaoAuthorAsJson(result) {
|
|
2267
|
+
return JSON.stringify(
|
|
2268
|
+
{
|
|
2269
|
+
ok: true,
|
|
2270
|
+
data: result
|
|
2271
|
+
},
|
|
2272
|
+
null,
|
|
2273
|
+
2
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
function renderToutiaoAuthorAsTable(result) {
|
|
2277
|
+
return table5([
|
|
2278
|
+
["id", "title", "author", "publishTime", "comments", "url"],
|
|
2279
|
+
...result.items.map((item) => [
|
|
2280
|
+
item.id,
|
|
2281
|
+
item.title,
|
|
2282
|
+
item.authorName ?? "",
|
|
2283
|
+
item.publishTime?.local ?? "",
|
|
2284
|
+
item.commentCount === void 0 ? "" : String(item.commentCount),
|
|
2285
|
+
item.url
|
|
2286
|
+
])
|
|
2287
|
+
]);
|
|
2288
|
+
}
|
|
2289
|
+
function renderToutiaoCommandErrorAsJson(code, message, details) {
|
|
2290
|
+
return JSON.stringify(
|
|
2291
|
+
{
|
|
2292
|
+
ok: false,
|
|
2293
|
+
error: {
|
|
2294
|
+
code,
|
|
2295
|
+
message,
|
|
2296
|
+
...details === void 0 ? {} : { details }
|
|
2297
|
+
}
|
|
2298
|
+
},
|
|
2299
|
+
null,
|
|
2300
|
+
2
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// src/toutiao/types.ts
|
|
2305
|
+
var ToutiaoCommandError = class extends Error {
|
|
2306
|
+
constructor(code, message, exitCode, details) {
|
|
2307
|
+
super(message);
|
|
2308
|
+
this.code = code;
|
|
2309
|
+
this.exitCode = exitCode;
|
|
2310
|
+
this.details = details;
|
|
2311
|
+
this.name = "ToutiaoCommandError";
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2314
|
+
|
|
2315
|
+
// src/toutiao/service.ts
|
|
2316
|
+
var SUPPORTED_SOURCES = /* @__PURE__ */ new Set(["tech", "AI", "\u5149\u523B\u673A", "\u82AF\u7247", "\u534A\u5BFC\u4F53"]);
|
|
2317
|
+
var ToutiaoService = class {
|
|
2318
|
+
constructor(dependencies) {
|
|
2319
|
+
this.dependencies = dependencies;
|
|
2320
|
+
}
|
|
2321
|
+
async article(input2) {
|
|
2322
|
+
const url = buildToutiaoArticleUrl(input2);
|
|
2323
|
+
return this.dependencies.runtime.fetchArticle({
|
|
2324
|
+
input: input2,
|
|
2325
|
+
url
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
async author(input2, options = {}) {
|
|
2329
|
+
const authorToken = buildToutiaoAuthorToken(input2);
|
|
2330
|
+
const pages = normalizePages2(options.pages);
|
|
2331
|
+
const url = buildToutiaoAuthorUrl(authorToken);
|
|
2332
|
+
const runtimeResult = await this.dependencies.runtime.fetchAuthorArticles({
|
|
2333
|
+
authorToken,
|
|
2334
|
+
pages,
|
|
2335
|
+
url
|
|
2336
|
+
});
|
|
2337
|
+
const withContent = options.withContent ?? false;
|
|
2338
|
+
const result = {
|
|
2339
|
+
...runtimeResult,
|
|
2340
|
+
meta: {
|
|
2341
|
+
fetchedPages: pages,
|
|
2342
|
+
totalItems: runtimeResult.items.length,
|
|
2343
|
+
withContent
|
|
2344
|
+
},
|
|
2345
|
+
request: {
|
|
2346
|
+
input: input2,
|
|
2347
|
+
url
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
if (withContent) {
|
|
2351
|
+
const articles = [];
|
|
2352
|
+
for (const item of runtimeResult.items) {
|
|
2353
|
+
articles.push(await this.dependencies.runtime.fetchArticle({
|
|
2354
|
+
input: item.id,
|
|
2355
|
+
url: buildToutiaoArticleUrl(item.url || item.id)
|
|
2356
|
+
}));
|
|
2357
|
+
}
|
|
2358
|
+
result.articles = articles;
|
|
2359
|
+
result.meta.totalArticles = articles.length;
|
|
2360
|
+
}
|
|
2361
|
+
return result;
|
|
2362
|
+
}
|
|
2363
|
+
async list(options) {
|
|
2364
|
+
const source = parseSource(options.source);
|
|
2365
|
+
const pages = normalizePages2(options.pages);
|
|
2366
|
+
const runtimeResult = source === "tech" ? await this.dependencies.runtime.fetchTechnologyChannel({ pages }) : await this.dependencies.runtime.fetchKeywordInformation({
|
|
2367
|
+
keyword: source,
|
|
2368
|
+
pages,
|
|
2369
|
+
source
|
|
2370
|
+
});
|
|
2371
|
+
return {
|
|
2372
|
+
...runtimeResult,
|
|
2373
|
+
meta: {
|
|
2374
|
+
fetchedPages: pages,
|
|
2375
|
+
totalItems: runtimeResult.items.length
|
|
2376
|
+
}
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
function buildToutiaoAuthorToken(input2) {
|
|
2381
|
+
const trimmed = input2.trim();
|
|
2382
|
+
if (/^[A-Za-z0-9._-]+$/.test(trimmed) && trimmed.length >= 12) {
|
|
2383
|
+
return trimmed;
|
|
2384
|
+
}
|
|
2385
|
+
try {
|
|
2386
|
+
const url = new URL(trimmed);
|
|
2387
|
+
const token = /\/c\/user\/token\/([^/?#]+)/.exec(url.pathname)?.[1];
|
|
2388
|
+
if (token && isToutiaoHost(url.hostname)) {
|
|
2389
|
+
return decodeURIComponent(token);
|
|
2390
|
+
}
|
|
2391
|
+
} catch {
|
|
2392
|
+
}
|
|
2393
|
+
throw new ToutiaoCommandError(
|
|
2394
|
+
"TOUTIAO_INVALID_AUTHOR",
|
|
2395
|
+
"Toutiao author must be a user token or a /c/user/token/<token>/ homepage URL.",
|
|
2396
|
+
2,
|
|
2397
|
+
{
|
|
2398
|
+
input: input2
|
|
2399
|
+
}
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
function buildToutiaoAuthorUrl(authorToken) {
|
|
2403
|
+
return `https://www.toutiao.com/c/user/token/${encodeURIComponent(authorToken)}/`;
|
|
2404
|
+
}
|
|
2405
|
+
function buildToutiaoArticleUrl(input2) {
|
|
2406
|
+
const trimmed = input2.trim();
|
|
2407
|
+
if (/^\d+$/.test(trimmed)) {
|
|
2408
|
+
return `https://www.toutiao.com/article/${trimmed}/`;
|
|
2409
|
+
}
|
|
2410
|
+
try {
|
|
2411
|
+
const url = new URL(trimmed);
|
|
2412
|
+
const id = /(?:article|group)\/(\d+)/.exec(url.pathname)?.[1] ?? /^\/a(\d+)/.exec(url.pathname)?.[1];
|
|
2413
|
+
if (id && isToutiaoHost(url.hostname)) {
|
|
2414
|
+
return `https://www.toutiao.com/article/${id}/`;
|
|
2415
|
+
}
|
|
2416
|
+
const nextUrl = url.searchParams.get("url");
|
|
2417
|
+
if (nextUrl) {
|
|
2418
|
+
return buildToutiaoArticleUrl(nextUrl);
|
|
2419
|
+
}
|
|
2420
|
+
} catch {
|
|
2421
|
+
}
|
|
2422
|
+
throw new ToutiaoCommandError(
|
|
2423
|
+
"TOUTIAO_INVALID_ARTICLE",
|
|
2424
|
+
"Toutiao article must be a numeric id or a toutiao article/group URL.",
|
|
2425
|
+
2,
|
|
2426
|
+
{
|
|
2427
|
+
input: input2
|
|
2428
|
+
}
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
function isToutiaoHost(hostname) {
|
|
2432
|
+
return hostname === "www.toutiao.com" || hostname === "toutiao.com";
|
|
2433
|
+
}
|
|
2434
|
+
function parseSource(source) {
|
|
2435
|
+
if (SUPPORTED_SOURCES.has(source)) {
|
|
2436
|
+
return source;
|
|
2437
|
+
}
|
|
2438
|
+
throw new ToutiaoCommandError(
|
|
2439
|
+
"TOUTIAO_INVALID_SOURCE",
|
|
2440
|
+
"Toutiao list only supports tech, AI, \u5149\u523B\u673A, \u82AF\u7247, and \u534A\u5BFC\u4F53.",
|
|
2441
|
+
2,
|
|
2442
|
+
{
|
|
2443
|
+
source,
|
|
2444
|
+
supportedSources: [...SUPPORTED_SOURCES]
|
|
2445
|
+
}
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
function normalizePages2(pages) {
|
|
2449
|
+
const value = pages ?? 1;
|
|
2450
|
+
if (!Number.isInteger(value) || value < 1 || value > 5) {
|
|
2451
|
+
throw new ToutiaoCommandError(
|
|
2452
|
+
"TOUTIAO_INVALID_PAGES",
|
|
2453
|
+
"Toutiao list pages must be an integer between 1 and 5.",
|
|
2454
|
+
2,
|
|
2455
|
+
{
|
|
2456
|
+
pages: value
|
|
2457
|
+
}
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
return value;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/toutiao/command.ts
|
|
2464
|
+
var listOptionsSchema = z6.object({
|
|
2465
|
+
format: z6.enum(["json", "table"]).default("json"),
|
|
2466
|
+
pages: z6.number().int().min(1).max(5).default(1),
|
|
2467
|
+
table: z6.boolean().optional()
|
|
2468
|
+
});
|
|
2469
|
+
var articleOptionsSchema2 = z6.object({
|
|
2470
|
+
format: z6.enum(["json"]).default("json")
|
|
2471
|
+
});
|
|
2472
|
+
var authorOptionsSchema = z6.object({
|
|
2473
|
+
format: z6.enum(["json", "table"]).default("json"),
|
|
2474
|
+
pages: z6.number().int().min(1).max(5).default(1),
|
|
2475
|
+
table: z6.boolean().optional(),
|
|
2476
|
+
withContent: z6.boolean().default(false)
|
|
2477
|
+
});
|
|
2478
|
+
function registerToutiaoCommands(program, dependencies) {
|
|
2479
|
+
const toutiao = program.command("toutiao").description("Fetch Toutiao channel and search content.");
|
|
2480
|
+
const service = new ToutiaoService({ runtime: dependencies.runtime });
|
|
2481
|
+
toutiao.command("article").argument("<article>", "Toutiao article id or URL").option("--format <format>", "output format", "json").action(async (article, options) => {
|
|
2482
|
+
articleOptionsSchema2.parse(options);
|
|
2483
|
+
const result = await service.article(article);
|
|
2484
|
+
dependencies.stdout(`${renderToutiaoArticleAsJson(result)}
|
|
2485
|
+
`);
|
|
2486
|
+
});
|
|
2487
|
+
toutiao.command("list").argument("<source>", "Toutiao source: tech, AI, \u5149\u523B\u673A, \u82AF\u7247, or \u534A\u5BFC\u4F53").option("--pages <pages>", "number of pages to fetch", parsePages2, 1).option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (source, options) => {
|
|
2488
|
+
const parsed = listOptionsSchema.parse(applyTableShortcut2(options));
|
|
2489
|
+
const result = await service.list({
|
|
2490
|
+
pages: parsed.pages,
|
|
2491
|
+
source
|
|
2492
|
+
});
|
|
2493
|
+
dependencies.stdout(
|
|
2494
|
+
parsed.format === "table" ? `${renderToutiaoListAsTable(result)}
|
|
2495
|
+
` : `${renderToutiaoListAsJson(result)}
|
|
2496
|
+
`
|
|
2497
|
+
);
|
|
2498
|
+
});
|
|
2499
|
+
toutiao.command("author").argument("<author>", "Toutiao author token or /c/user/token/<token>/ homepage URL").option("--pages <pages>", "number of pages to fetch", parsePages2, 1).option("--with-content", "also fetch detail content for each author article").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (author, options) => {
|
|
2500
|
+
const parsed = authorOptionsSchema.parse(applyTableShortcut2(options));
|
|
2501
|
+
const result = await service.author(author, {
|
|
2502
|
+
pages: parsed.pages,
|
|
2503
|
+
withContent: parsed.withContent
|
|
2504
|
+
});
|
|
2505
|
+
dependencies.stdout(
|
|
2506
|
+
parsed.format === "table" ? `${renderToutiaoAuthorAsTable(result)}
|
|
2507
|
+
` : `${renderToutiaoAuthorAsJson(result)}
|
|
2508
|
+
`
|
|
2509
|
+
);
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
function handleToutiaoCommandError(error, dependencies) {
|
|
2513
|
+
if (error instanceof ToutiaoCommandError) {
|
|
2514
|
+
dependencies.stderr(
|
|
2515
|
+
`${renderToutiaoCommandErrorAsJson(error.code, error.message, error.details)}
|
|
2516
|
+
`
|
|
2517
|
+
);
|
|
2518
|
+
return error.exitCode;
|
|
2519
|
+
}
|
|
2520
|
+
return void 0;
|
|
2521
|
+
}
|
|
2522
|
+
function parsePages2(value) {
|
|
2523
|
+
const parsed = Number(value);
|
|
2524
|
+
if (!Number.isInteger(parsed)) {
|
|
2525
|
+
throw new ToutiaoCommandError(
|
|
2526
|
+
"TOUTIAO_INVALID_PAGES",
|
|
2527
|
+
"Toutiao list pages must be an integer between 1 and 5.",
|
|
2528
|
+
2,
|
|
2529
|
+
{
|
|
2530
|
+
pages: value
|
|
2531
|
+
}
|
|
2532
|
+
);
|
|
2533
|
+
}
|
|
2534
|
+
return parsed;
|
|
2535
|
+
}
|
|
2536
|
+
function applyTableShortcut2(options) {
|
|
2537
|
+
if (options.table) {
|
|
2538
|
+
return {
|
|
2539
|
+
...options,
|
|
2540
|
+
format: "table"
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
return {
|
|
2544
|
+
...options,
|
|
2545
|
+
format: options.format ?? "json"
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
// src/hn/command.ts
|
|
2550
|
+
import { z as z7 } from "zod";
|
|
2551
|
+
|
|
2552
|
+
// src/hn/output.ts
|
|
2553
|
+
import { table as table6 } from "table";
|
|
2554
|
+
function renderHackerNewsStoriesAsJson(result) {
|
|
2555
|
+
return JSON.stringify(
|
|
2556
|
+
{
|
|
2557
|
+
ok: true,
|
|
2558
|
+
data: result
|
|
2559
|
+
},
|
|
2560
|
+
null,
|
|
2561
|
+
2
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
function renderHackerNewsStoriesAsTable(result) {
|
|
2565
|
+
return table6([
|
|
2566
|
+
["id", "title", "author", "score", "comments", "time", "url"],
|
|
2567
|
+
...result.items.map((item) => [
|
|
2568
|
+
String(item.id),
|
|
2569
|
+
item.title,
|
|
2570
|
+
item.author ?? "",
|
|
2571
|
+
item.score === void 0 ? "" : String(item.score),
|
|
2572
|
+
item.commentCount === void 0 ? "" : String(item.commentCount),
|
|
2573
|
+
item.time?.iso ?? "",
|
|
2574
|
+
item.url
|
|
2575
|
+
])
|
|
2576
|
+
]);
|
|
2577
|
+
}
|
|
2578
|
+
function renderHackerNewsSearchAsJson(result) {
|
|
2579
|
+
return JSON.stringify(
|
|
2580
|
+
{
|
|
2581
|
+
ok: true,
|
|
2582
|
+
data: result
|
|
2583
|
+
},
|
|
2584
|
+
null,
|
|
2585
|
+
2
|
|
2586
|
+
);
|
|
2587
|
+
}
|
|
2588
|
+
function renderHackerNewsSearchAsTable(result) {
|
|
2589
|
+
return table6([
|
|
2590
|
+
["id", "title", "author", "score", "comments", "time", "url"],
|
|
2591
|
+
...result.items.map((item) => [
|
|
2592
|
+
String(item.id),
|
|
2593
|
+
item.title,
|
|
2594
|
+
item.author ?? "",
|
|
2595
|
+
item.score === void 0 ? "" : String(item.score),
|
|
2596
|
+
item.commentCount === void 0 ? "" : String(item.commentCount),
|
|
2597
|
+
item.time?.iso ?? "",
|
|
2598
|
+
item.url
|
|
2599
|
+
])
|
|
2600
|
+
]);
|
|
2601
|
+
}
|
|
2602
|
+
function renderHackerNewsCommandErrorAsJson(code, message, details) {
|
|
2603
|
+
return JSON.stringify(
|
|
2604
|
+
{
|
|
2605
|
+
ok: false,
|
|
2606
|
+
error: {
|
|
2607
|
+
code,
|
|
2608
|
+
message,
|
|
2609
|
+
...details === void 0 ? {} : { details }
|
|
2610
|
+
}
|
|
2611
|
+
},
|
|
2612
|
+
null,
|
|
2613
|
+
2
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// src/hn/types.ts
|
|
2618
|
+
var HackerNewsCommandError = class extends Error {
|
|
2619
|
+
constructor(code, message, exitCode, details) {
|
|
2620
|
+
super(message);
|
|
2621
|
+
this.code = code;
|
|
2622
|
+
this.exitCode = exitCode;
|
|
2623
|
+
this.details = details;
|
|
2624
|
+
this.name = "HackerNewsCommandError";
|
|
2625
|
+
}
|
|
2626
|
+
};
|
|
2627
|
+
|
|
2628
|
+
// src/hn/service.ts
|
|
2629
|
+
var SUPPORTED_SOURCES2 = /* @__PURE__ */ new Set(["top", "new", "best"]);
|
|
2630
|
+
var HackerNewsService = class {
|
|
2631
|
+
constructor(dependencies) {
|
|
2632
|
+
this.dependencies = dependencies;
|
|
2633
|
+
}
|
|
2634
|
+
async stories(options) {
|
|
2635
|
+
const source = parseSource2(options.source);
|
|
2636
|
+
const limit = normalizeLimit(options.limit);
|
|
2637
|
+
const runtimeResult = await this.dependencies.runtime.fetchStories({
|
|
2638
|
+
limit,
|
|
2639
|
+
source
|
|
2640
|
+
});
|
|
2641
|
+
return {
|
|
2642
|
+
...runtimeResult,
|
|
2643
|
+
meta: {
|
|
2644
|
+
limit,
|
|
2645
|
+
totalItems: runtimeResult.items.length
|
|
2646
|
+
}
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
async search(options) {
|
|
2650
|
+
const query = normalizeQuery(options.query);
|
|
2651
|
+
const limit = normalizeLimit(options.limit);
|
|
2652
|
+
const sort = options.sort ?? "relevance";
|
|
2653
|
+
const runtimeResult = await this.dependencies.runtime.fetchSearch({
|
|
2654
|
+
limit,
|
|
2655
|
+
query,
|
|
2656
|
+
sort
|
|
2657
|
+
});
|
|
2658
|
+
return {
|
|
2659
|
+
...runtimeResult,
|
|
2660
|
+
meta: {
|
|
2661
|
+
limit,
|
|
2662
|
+
sort,
|
|
2663
|
+
totalItems: runtimeResult.items.length
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
};
|
|
2668
|
+
function parseSource2(source) {
|
|
2669
|
+
if (SUPPORTED_SOURCES2.has(source)) {
|
|
2670
|
+
return source;
|
|
2671
|
+
}
|
|
2672
|
+
throw new HackerNewsCommandError(
|
|
2673
|
+
"HN_INVALID_SOURCE",
|
|
2674
|
+
"Hacker News list only supports top, new, and best.",
|
|
2675
|
+
2,
|
|
2676
|
+
{
|
|
2677
|
+
source,
|
|
2678
|
+
supportedSources: [...SUPPORTED_SOURCES2]
|
|
2679
|
+
}
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
function normalizeLimit(limit) {
|
|
2683
|
+
const value = limit ?? 30;
|
|
2684
|
+
if (!Number.isInteger(value) || value < 1 || value > 100) {
|
|
2685
|
+
throw new HackerNewsCommandError(
|
|
2686
|
+
"HN_INVALID_LIMIT",
|
|
2687
|
+
"Hacker News limit must be an integer between 1 and 100.",
|
|
2688
|
+
2,
|
|
2689
|
+
{
|
|
2690
|
+
limit: value
|
|
2691
|
+
}
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2694
|
+
return value;
|
|
2695
|
+
}
|
|
2696
|
+
function normalizeQuery(query) {
|
|
2697
|
+
const value = query.trim();
|
|
2698
|
+
if (value.length === 0) {
|
|
2699
|
+
throw new HackerNewsCommandError(
|
|
2700
|
+
"HN_INVALID_QUERY",
|
|
2701
|
+
"Hacker News search query must not be empty.",
|
|
2702
|
+
2
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
return value;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
// src/hn/command.ts
|
|
2709
|
+
var storiesOptionsSchema = z7.object({
|
|
2710
|
+
format: z7.enum(["json", "table"]).default("json"),
|
|
2711
|
+
limit: z7.number().int().default(30),
|
|
2712
|
+
table: z7.boolean().optional()
|
|
2713
|
+
});
|
|
2714
|
+
var searchOptionsSchema = z7.object({
|
|
2715
|
+
format: z7.enum(["json", "table"]).default("json"),
|
|
2716
|
+
limit: z7.number().int().default(30),
|
|
2717
|
+
sort: z7.enum(["relevance", "date"]).default("relevance"),
|
|
2718
|
+
table: z7.boolean().optional()
|
|
2719
|
+
});
|
|
2720
|
+
function registerHackerNewsCommands(program, dependencies) {
|
|
2721
|
+
const hn = program.command("hn").description("Fetch Hacker News stories and search results.");
|
|
2722
|
+
const service = new HackerNewsService({ runtime: dependencies.runtime });
|
|
2723
|
+
for (const source of ["top", "new", "best"]) {
|
|
2724
|
+
hn.command(source).option("--limit <limit>", "number of stories to fetch", parseLimit, 30).option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (options) => {
|
|
2725
|
+
const parsed = storiesOptionsSchema.parse(applyTableShortcut3(options));
|
|
2726
|
+
const result = await service.stories({
|
|
2727
|
+
limit: parsed.limit,
|
|
2728
|
+
source
|
|
2729
|
+
});
|
|
2730
|
+
dependencies.stdout(
|
|
2731
|
+
parsed.format === "table" ? `${renderHackerNewsStoriesAsTable(result)}
|
|
2732
|
+
` : `${renderHackerNewsStoriesAsJson(result)}
|
|
2733
|
+
`
|
|
2734
|
+
);
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
hn.command("search").argument("<query>", "search query").option("--limit <limit>", "number of search results to fetch", parseLimit, 30).option("--sort <sort>", "search sort: relevance or date", "relevance").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (query, options) => {
|
|
2738
|
+
const parsed = searchOptionsSchema.parse(applyTableShortcut3(options));
|
|
2739
|
+
const result = await service.search({
|
|
2740
|
+
limit: parsed.limit,
|
|
2741
|
+
query,
|
|
2742
|
+
sort: parsed.sort
|
|
2743
|
+
});
|
|
2744
|
+
dependencies.stdout(
|
|
2745
|
+
parsed.format === "table" ? `${renderHackerNewsSearchAsTable(result)}
|
|
2746
|
+
` : `${renderHackerNewsSearchAsJson(result)}
|
|
2747
|
+
`
|
|
2748
|
+
);
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2751
|
+
function handleHackerNewsCommandError(error, dependencies) {
|
|
2752
|
+
if (error instanceof HackerNewsCommandError) {
|
|
2753
|
+
dependencies.stderr(
|
|
2754
|
+
`${renderHackerNewsCommandErrorAsJson(error.code, error.message, error.details)}
|
|
2755
|
+
`
|
|
2756
|
+
);
|
|
2757
|
+
return error.exitCode;
|
|
2758
|
+
}
|
|
2759
|
+
return void 0;
|
|
2760
|
+
}
|
|
2761
|
+
function parseLimit(value) {
|
|
2762
|
+
const parsed = Number(value);
|
|
2763
|
+
if (!Number.isInteger(parsed)) {
|
|
2764
|
+
throw new HackerNewsCommandError(
|
|
2765
|
+
"HN_INVALID_LIMIT",
|
|
2766
|
+
"Hacker News limit must be an integer between 1 and 100.",
|
|
2767
|
+
2,
|
|
2768
|
+
{
|
|
2769
|
+
limit: value
|
|
2770
|
+
}
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
return parsed;
|
|
2774
|
+
}
|
|
2775
|
+
function applyTableShortcut3(options) {
|
|
2776
|
+
if (options.table) {
|
|
2777
|
+
return {
|
|
2778
|
+
...options,
|
|
2779
|
+
format: "table"
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
return {
|
|
2783
|
+
...options,
|
|
2784
|
+
format: options.format ?? "json"
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
// src/ak/command.ts
|
|
2789
|
+
var envSchema = z8.string().transform((value, context) => {
|
|
2790
|
+
try {
|
|
2791
|
+
return normalizeAkEnv(value);
|
|
2792
|
+
} catch (error) {
|
|
2793
|
+
context.addIssue({
|
|
2794
|
+
code: "custom",
|
|
2795
|
+
message: error.message
|
|
2796
|
+
});
|
|
2797
|
+
return z8.NEVER;
|
|
2798
|
+
}
|
|
2799
|
+
});
|
|
2800
|
+
var envDescription = `environment (recommended: ${AK_RECOMMENDED_ENVS.join(", ")}; custom values allowed)`;
|
|
2801
|
+
var addOptionsSchema = z8.object({
|
|
2802
|
+
email: z8.string().optional(),
|
|
2803
|
+
env: envSchema,
|
|
2804
|
+
key: z8.string(),
|
|
2805
|
+
phone: z8.string().optional(),
|
|
2806
|
+
rawKey: z8.boolean().optional(),
|
|
2807
|
+
userId: z8.string().optional(),
|
|
2808
|
+
userName: z8.string().optional()
|
|
2809
|
+
});
|
|
2810
|
+
var listOptionsSchema2 = z8.object({
|
|
2811
|
+
env: envSchema.optional(),
|
|
2812
|
+
field: z8.string().optional(),
|
|
2813
|
+
format: z8.enum(["json", "table"]).default("json"),
|
|
2814
|
+
limit: z8.number().int().positive().max(100).default(50),
|
|
2815
|
+
query: z8.string().optional(),
|
|
2816
|
+
rawKey: z8.boolean().optional()
|
|
2817
|
+
});
|
|
2818
|
+
var getOptionsSchema = z8.object({
|
|
2819
|
+
env: envSchema.optional(),
|
|
2820
|
+
format: z8.enum(["json", "table"]).default("json"),
|
|
2821
|
+
id: z8.string().optional(),
|
|
2822
|
+
key: z8.string().optional(),
|
|
2823
|
+
rawKey: z8.boolean().optional()
|
|
2824
|
+
});
|
|
2825
|
+
var updateOptionsSchema = z8.object({
|
|
2826
|
+
email: z8.string().optional(),
|
|
2827
|
+
env: envSchema.optional(),
|
|
2828
|
+
format: z8.enum(["json", "table"]).default("json"),
|
|
2829
|
+
id: z8.string().optional(),
|
|
2830
|
+
key: z8.string().optional(),
|
|
2831
|
+
phone: z8.string().optional(),
|
|
2832
|
+
rawKey: z8.boolean().optional(),
|
|
2833
|
+
userId: z8.string().optional(),
|
|
2834
|
+
userName: z8.string().optional()
|
|
2835
|
+
});
|
|
2836
|
+
var deleteOptionsSchema = z8.object({
|
|
2837
|
+
env: envSchema.optional(),
|
|
2838
|
+
format: z8.enum(["json", "table"]).default("json"),
|
|
2839
|
+
id: z8.string().optional(),
|
|
2840
|
+
key: z8.string().optional(),
|
|
2841
|
+
yes: z8.boolean().default(false)
|
|
2842
|
+
});
|
|
2843
|
+
var configFormatSchema = z8.object({
|
|
2844
|
+
format: z8.enum(["json", "table"]).default("json")
|
|
2845
|
+
});
|
|
2846
|
+
var configGetOptionsSchema = z8.object({
|
|
2847
|
+
format: z8.enum(["json", "table"]).default("json"),
|
|
2848
|
+
key: z8.string()
|
|
2849
|
+
});
|
|
2850
|
+
var configSetOptionsSchema = z8.object({
|
|
2851
|
+
format: z8.enum(["json", "table"]).default("json"),
|
|
2852
|
+
key: z8.string(),
|
|
2853
|
+
value: z8.string()
|
|
2854
|
+
});
|
|
2855
|
+
function createAkCli(dependencies) {
|
|
2856
|
+
const program = new Command();
|
|
2857
|
+
const createService = () => new AkService({
|
|
2858
|
+
masterKey: dependencies.masterKey,
|
|
2859
|
+
now: dependencies.now,
|
|
2860
|
+
repository: dependencies.getRepository()
|
|
2861
|
+
});
|
|
2862
|
+
const createDiskCleanupService = () => {
|
|
2863
|
+
const roots = dependencies.resolveDiskCleanupRoots();
|
|
2864
|
+
return new DiskCleanupService({
|
|
2865
|
+
runtime: dependencies.diskRuntime,
|
|
2866
|
+
systemRoot: roots.systemRoot,
|
|
2867
|
+
userProfileRoot: roots.userProfileRoot
|
|
2868
|
+
});
|
|
2869
|
+
};
|
|
2870
|
+
program.name("stephen").description("A personal TypeScript CLI for agent-friendly workflows.").showHelpAfterError().exitOverride();
|
|
2871
|
+
program.configureOutput({
|
|
2872
|
+
outputError: (value) => dependencies.stderr(value),
|
|
2873
|
+
writeErr: (value) => dependencies.stderr(value),
|
|
2874
|
+
writeOut: (value) => dependencies.stdout(value)
|
|
2875
|
+
});
|
|
2876
|
+
const ak = program.command("ak").description("Manage API key records.");
|
|
2877
|
+
const config = program.command("config").description("Manage local CLI configuration.");
|
|
2878
|
+
registerDiskCommands(program, {
|
|
2879
|
+
createDiskCleanupService,
|
|
2880
|
+
stdout: dependencies.stdout
|
|
2881
|
+
});
|
|
2882
|
+
registerVideoCommands(program, {
|
|
2883
|
+
runtime: dependencies.videoRuntime,
|
|
2884
|
+
stderr: dependencies.stderr,
|
|
2885
|
+
stdout: dependencies.stdout
|
|
2886
|
+
});
|
|
2887
|
+
registerKr36Commands(program, {
|
|
2888
|
+
runtime: dependencies.kr36Runtime,
|
|
2889
|
+
stderr: dependencies.stderr,
|
|
2890
|
+
stdout: dependencies.stdout
|
|
2891
|
+
});
|
|
2892
|
+
registerToutiaoCommands(program, {
|
|
2893
|
+
runtime: dependencies.toutiaoRuntime,
|
|
2894
|
+
stderr: dependencies.stderr,
|
|
2895
|
+
stdout: dependencies.stdout
|
|
2896
|
+
});
|
|
2897
|
+
registerHackerNewsCommands(program, {
|
|
2898
|
+
runtime: dependencies.hackerNewsRuntime,
|
|
2899
|
+
stderr: dependencies.stderr,
|
|
2900
|
+
stdout: dependencies.stdout
|
|
2901
|
+
});
|
|
2902
|
+
ak.command("add").requiredOption("-e, --env <env>", envDescription).requiredOption("-k, --key <key>", "api key").option("-u, --user-id <userId>", "user id").option("-n, --user-name <userName>", "user name").option("-m, --email <email>", "email").option("-p, --phone <phone>", "phone").option("--raw-key", "show the raw key in the output").action((options) => {
|
|
2903
|
+
const parsed = addOptionsSchema.parse(options);
|
|
2904
|
+
writeRecords([createService().add(parsed)], "json", parsed.rawKey ?? false, 50, dependencies);
|
|
2905
|
+
});
|
|
2906
|
+
ak.command("get").option("--id <id>", "record id").option("-e, --env <env>", envDescription).option("-k, --key <key>", "api key").option("--raw-key", "show the raw key in the output").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action((options) => {
|
|
2907
|
+
const parsed = getOptionsSchema.parse(applyTableShortcut4(options));
|
|
2908
|
+
writeRecords(
|
|
2909
|
+
[createService().get(parsed)],
|
|
2910
|
+
parsed.format,
|
|
2911
|
+
parsed.rawKey ?? false,
|
|
2912
|
+
1,
|
|
2913
|
+
dependencies
|
|
2914
|
+
);
|
|
2915
|
+
});
|
|
2916
|
+
ak.command("list").option("-e, --env <env>", envDescription).option("-q, --query <query>", "fuzzy query").option("-f, --field <field>", "query fields").option("--limit <limit>", "result limit", parseLimit2, 50).option("--raw-key", "show the raw key in the output").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action((options) => {
|
|
2917
|
+
const parsed = listOptionsSchema2.parse(applyTableShortcut4(options));
|
|
2918
|
+
writeRecords(
|
|
2919
|
+
createService().list(parsed),
|
|
2920
|
+
parsed.format,
|
|
2921
|
+
parsed.rawKey ?? false,
|
|
2922
|
+
parsed.limit,
|
|
2923
|
+
dependencies
|
|
2924
|
+
);
|
|
2925
|
+
});
|
|
2926
|
+
ak.command("update").option("--id <id>", "record id").option("-e, --env <env>", envDescription).option("-k, --key <key>", "api key").option("-u, --user-id <userId>", "user id").option("-n, --user-name <userName>", "user name").option("-m, --email <email>", "email").option("-p, --phone <phone>", "phone").option("--raw-key", "show the raw key in the output").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action((options) => {
|
|
2927
|
+
const parsed = updateOptionsSchema.parse(applyTableShortcut4(options));
|
|
2928
|
+
writeRecords(
|
|
2929
|
+
[createService().update(parsed)],
|
|
2930
|
+
parsed.format,
|
|
2931
|
+
parsed.rawKey ?? false,
|
|
2932
|
+
1,
|
|
2933
|
+
dependencies
|
|
2934
|
+
);
|
|
2935
|
+
});
|
|
2936
|
+
ak.command("delete").option("--id <id>", "record id").option("-e, --env <env>", envDescription).option("-k, --key <key>", "api key").option("--yes", "skip confirmation").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action(async (options) => {
|
|
2937
|
+
const parsed = deleteOptionsSchema.parse(applyTableShortcut4(options));
|
|
2938
|
+
if (!parsed.yes) {
|
|
2939
|
+
const confirmed = await dependencies.confirm("Delete the API key record?");
|
|
2940
|
+
if (!confirmed) {
|
|
2941
|
+
throw new AkServiceError("ABORTED", "Delete operation cancelled.", 2);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
const service = createService();
|
|
2945
|
+
const record = service.get(parsed);
|
|
2946
|
+
const deleted = service.delete(parsed);
|
|
2947
|
+
if (!deleted) {
|
|
2948
|
+
throw new AkServiceError("STORAGE_ERROR", "Failed to delete the API key record.", 6);
|
|
2949
|
+
}
|
|
2950
|
+
writeRecords([record], parsed.format, false, 1, dependencies);
|
|
2951
|
+
});
|
|
2952
|
+
config.command("list").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action((options) => {
|
|
2953
|
+
const parsed = configFormatSchema.parse(applyTableShortcut4(options));
|
|
2954
|
+
const entries = listConfigEntries({
|
|
2955
|
+
configDir: dependencies.paths.config,
|
|
2956
|
+
dataDir: dependencies.paths.data,
|
|
2957
|
+
env: dependencies.env
|
|
2958
|
+
});
|
|
2959
|
+
writeConfigEntries(entries, parsed.format, dependencies);
|
|
2960
|
+
});
|
|
2961
|
+
config.command("get").argument("<key>", "config key").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action((key, options) => {
|
|
2962
|
+
const parsed = configGetOptionsSchema.parse({
|
|
2963
|
+
...applyTableShortcut4(options),
|
|
2964
|
+
key
|
|
2965
|
+
});
|
|
2966
|
+
assertSupportedConfigKey(parsed.key);
|
|
2967
|
+
const entry = getConfigEntry(parsed.key, {
|
|
2968
|
+
configDir: dependencies.paths.config,
|
|
2969
|
+
dataDir: dependencies.paths.data,
|
|
2970
|
+
env: dependencies.env
|
|
2971
|
+
});
|
|
2972
|
+
writeConfigEntries([entry], parsed.format, dependencies);
|
|
2973
|
+
});
|
|
2974
|
+
config.command("set").argument("<key>", "config key").argument("<value>", "config value").option("--format <format>", "output format", "json").option("-t, --table", "render as a table").action((key, value, options) => {
|
|
2975
|
+
const parsed = configSetOptionsSchema.parse({
|
|
2976
|
+
...applyTableShortcut4(options),
|
|
2977
|
+
key,
|
|
2978
|
+
value
|
|
2979
|
+
});
|
|
2980
|
+
assertSupportedConfigKey(parsed.key);
|
|
2981
|
+
setConfigValue(parsed.key, parsed.value, {
|
|
2982
|
+
configDir: dependencies.paths.config
|
|
2983
|
+
});
|
|
2984
|
+
const entry = getConfigEntry(parsed.key, {
|
|
2985
|
+
configDir: dependencies.paths.config,
|
|
2986
|
+
dataDir: dependencies.paths.data,
|
|
2987
|
+
env: dependencies.env
|
|
2988
|
+
});
|
|
2989
|
+
writeConfigEntries([entry], parsed.format, dependencies);
|
|
2990
|
+
});
|
|
2991
|
+
return {
|
|
2992
|
+
run: async (args) => {
|
|
2993
|
+
try {
|
|
2994
|
+
await program.parseAsync(["node", "stephen", ...args], {
|
|
2995
|
+
from: "node"
|
|
2996
|
+
});
|
|
2997
|
+
return 0;
|
|
2998
|
+
} catch (error) {
|
|
2999
|
+
if (error instanceof Error && "code" in error && error.code === "commander.helpDisplayed") {
|
|
3000
|
+
return 0;
|
|
3001
|
+
}
|
|
3002
|
+
if (isCliError(error)) {
|
|
3003
|
+
dependencies.stderr(
|
|
3004
|
+
`${renderAkErrorAsJson(error.code, error.message, "details" in error ? error.details : void 0)}
|
|
3005
|
+
`
|
|
3006
|
+
);
|
|
3007
|
+
return error.exitCode;
|
|
3008
|
+
}
|
|
3009
|
+
const videoExitCode = handleVideoCommandError(error, {
|
|
3010
|
+
stderr: dependencies.stderr
|
|
3011
|
+
});
|
|
3012
|
+
if (videoExitCode !== void 0) {
|
|
3013
|
+
return videoExitCode;
|
|
3014
|
+
}
|
|
3015
|
+
const kr36ExitCode = handleKr36CommandError(error, {
|
|
3016
|
+
stderr: dependencies.stderr
|
|
3017
|
+
});
|
|
3018
|
+
if (kr36ExitCode !== void 0) {
|
|
3019
|
+
return kr36ExitCode;
|
|
3020
|
+
}
|
|
3021
|
+
const toutiaoExitCode = handleToutiaoCommandError(error, {
|
|
3022
|
+
stderr: dependencies.stderr
|
|
3023
|
+
});
|
|
3024
|
+
if (toutiaoExitCode !== void 0) {
|
|
3025
|
+
return toutiaoExitCode;
|
|
3026
|
+
}
|
|
3027
|
+
const hackerNewsExitCode = handleHackerNewsCommandError(error, {
|
|
3028
|
+
stderr: dependencies.stderr
|
|
3029
|
+
});
|
|
3030
|
+
if (hackerNewsExitCode !== void 0) {
|
|
3031
|
+
return hackerNewsExitCode;
|
|
3032
|
+
}
|
|
3033
|
+
if (error instanceof ZodError) {
|
|
3034
|
+
dependencies.stderr(
|
|
3035
|
+
`${renderAkErrorAsJson("INVALID_ARGUMENT", error.issues[0].message)}
|
|
3036
|
+
`
|
|
3037
|
+
);
|
|
3038
|
+
return 2;
|
|
3039
|
+
}
|
|
3040
|
+
if (error instanceof Error && "exitCode" in error && typeof error.exitCode === "number") {
|
|
3041
|
+
return error.exitCode;
|
|
3042
|
+
}
|
|
3043
|
+
const message = error instanceof Error ? error.message : "Unexpected error.";
|
|
3044
|
+
dependencies.stderr(`${renderAkErrorAsJson("UNEXPECTED_ERROR", message)}
|
|
3045
|
+
`);
|
|
3046
|
+
return 1;
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
function isCliError(error) {
|
|
3052
|
+
return error instanceof Error && "code" in error && typeof error.code === "string" && "exitCode" in error && typeof error.exitCode === "number";
|
|
3053
|
+
}
|
|
3054
|
+
function parseLimit2(value) {
|
|
3055
|
+
const parsed = Number.parseInt(value, 10);
|
|
3056
|
+
if (Number.isNaN(parsed)) {
|
|
3057
|
+
throw new Error(`Invalid limit: '${value}' is not a number`);
|
|
3058
|
+
}
|
|
3059
|
+
return parsed;
|
|
3060
|
+
}
|
|
3061
|
+
function applyTableShortcut4(options) {
|
|
3062
|
+
if (options.table) {
|
|
3063
|
+
return {
|
|
3064
|
+
...options,
|
|
3065
|
+
format: "table"
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
if (options.format) {
|
|
3069
|
+
return {
|
|
3070
|
+
...options,
|
|
3071
|
+
format: options.format
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
throw new Error("Output format is required for this command.");
|
|
3075
|
+
}
|
|
3076
|
+
function writeRecords(records, format, _rawKey, limit, dependencies) {
|
|
3077
|
+
if (format === "table") {
|
|
3078
|
+
dependencies.stdout(`${renderAkRecordsAsTable(records)}
|
|
3079
|
+
`);
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
dependencies.stdout(`${renderAkRecordsAsJson(records, limit)}
|
|
3083
|
+
`);
|
|
3084
|
+
}
|
|
3085
|
+
function writeConfigEntries(entries, format, dependencies) {
|
|
3086
|
+
if (format === "table") {
|
|
3087
|
+
dependencies.stdout(`${renderConfigEntriesAsTable(entries)}
|
|
3088
|
+
`);
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
dependencies.stdout(`${renderConfigEntriesAsJson(entries)}
|
|
3092
|
+
`);
|
|
3093
|
+
}
|
|
3094
|
+
function renderConfigEntriesAsJson(entries) {
|
|
3095
|
+
return JSON.stringify(
|
|
3096
|
+
{
|
|
3097
|
+
ok: true,
|
|
3098
|
+
data: entries,
|
|
3099
|
+
meta: {
|
|
3100
|
+
count: entries.length
|
|
3101
|
+
}
|
|
3102
|
+
},
|
|
3103
|
+
null,
|
|
3104
|
+
2
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
function renderConfigEntriesAsTable(entries) {
|
|
3108
|
+
return table7([
|
|
3109
|
+
["key", "value", "source", "fileValue", "envValue", "defaultValue"],
|
|
3110
|
+
...entries.map((entry) => [
|
|
3111
|
+
entry.key,
|
|
3112
|
+
entry.value,
|
|
3113
|
+
entry.source,
|
|
3114
|
+
entry.fileValue ?? "",
|
|
3115
|
+
entry.envValue ?? "",
|
|
3116
|
+
entry.defaultValue
|
|
3117
|
+
])
|
|
3118
|
+
]);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// src/ak/database.ts
|
|
3122
|
+
import Database from "better-sqlite3";
|
|
3123
|
+
function createAkDatabase(filename = ":memory:") {
|
|
3124
|
+
const database = new Database(filename);
|
|
3125
|
+
database.exec(`
|
|
3126
|
+
CREATE TABLE IF NOT EXISTS ak_records (
|
|
3127
|
+
id TEXT PRIMARY KEY,
|
|
3128
|
+
env TEXT NOT NULL,
|
|
3129
|
+
user_id TEXT,
|
|
3130
|
+
user_name TEXT,
|
|
3131
|
+
email TEXT,
|
|
3132
|
+
phone TEXT,
|
|
3133
|
+
key_ciphertext TEXT NOT NULL,
|
|
3134
|
+
key_search_prefix TEXT NOT NULL,
|
|
3135
|
+
created_at TEXT NOT NULL,
|
|
3136
|
+
updated_at TEXT NOT NULL
|
|
3137
|
+
);
|
|
3138
|
+
|
|
3139
|
+
CREATE INDEX IF NOT EXISTS idx_ak_env ON ak_records(env);
|
|
3140
|
+
CREATE INDEX IF NOT EXISTS idx_ak_user_id ON ak_records(user_id);
|
|
3141
|
+
CREATE INDEX IF NOT EXISTS idx_ak_user_name ON ak_records(user_name);
|
|
3142
|
+
CREATE INDEX IF NOT EXISTS idx_ak_email ON ak_records(email);
|
|
3143
|
+
CREATE INDEX IF NOT EXISTS idx_ak_phone ON ak_records(phone);
|
|
3144
|
+
CREATE INDEX IF NOT EXISTS idx_ak_key_search_prefix ON ak_records(key_search_prefix);
|
|
3145
|
+
CREATE INDEX IF NOT EXISTS idx_ak_updated_at ON ak_records(updated_at);
|
|
3146
|
+
`);
|
|
3147
|
+
return database;
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
// src/ak/repository.ts
|
|
3151
|
+
import Database2 from "better-sqlite3";
|
|
3152
|
+
var AkDuplicateRecordError = class extends Error {
|
|
3153
|
+
constructor(id) {
|
|
3154
|
+
super(`API key record already exists: ${id}.`);
|
|
3155
|
+
this.name = "AkDuplicateRecordError";
|
|
3156
|
+
}
|
|
3157
|
+
};
|
|
3158
|
+
var AkRepository = class {
|
|
3159
|
+
#database;
|
|
3160
|
+
constructor(database) {
|
|
3161
|
+
this.#database = database;
|
|
3162
|
+
}
|
|
3163
|
+
insert(record) {
|
|
3164
|
+
try {
|
|
3165
|
+
this.#database.prepare(
|
|
3166
|
+
`INSERT INTO ak_records (
|
|
3167
|
+
id,
|
|
3168
|
+
env,
|
|
3169
|
+
user_id,
|
|
3170
|
+
user_name,
|
|
3171
|
+
email,
|
|
3172
|
+
phone,
|
|
3173
|
+
key_ciphertext,
|
|
3174
|
+
key_search_prefix,
|
|
3175
|
+
created_at,
|
|
3176
|
+
updated_at
|
|
3177
|
+
) VALUES (
|
|
3178
|
+
@id,
|
|
3179
|
+
@env,
|
|
3180
|
+
@userId,
|
|
3181
|
+
@userName,
|
|
3182
|
+
@email,
|
|
3183
|
+
@phone,
|
|
3184
|
+
@keyCiphertext,
|
|
3185
|
+
@keySearchPrefix,
|
|
3186
|
+
@createdAt,
|
|
3187
|
+
@updatedAt
|
|
3188
|
+
)`
|
|
3189
|
+
).run(record);
|
|
3190
|
+
} catch (error) {
|
|
3191
|
+
if (error instanceof Database2.SqliteError && error.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
3192
|
+
throw new AkDuplicateRecordError(record.id);
|
|
3193
|
+
}
|
|
3194
|
+
throw error;
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
getById(id) {
|
|
3198
|
+
const row = this.#database.prepare("SELECT * FROM ak_records WHERE id = ?").get(id);
|
|
3199
|
+
return row ? mapRowToRecord(row) : null;
|
|
3200
|
+
}
|
|
3201
|
+
getByEnvAndId(env, id) {
|
|
3202
|
+
const row = this.#database.prepare("SELECT * FROM ak_records WHERE env = ? AND id = ?").get(env, id);
|
|
3203
|
+
return row ? mapRowToRecord(row) : null;
|
|
3204
|
+
}
|
|
3205
|
+
list(filters) {
|
|
3206
|
+
const conditions = [];
|
|
3207
|
+
const params = [];
|
|
3208
|
+
if (filters.env) {
|
|
3209
|
+
conditions.push("env = ?");
|
|
3210
|
+
params.push(filters.env);
|
|
3211
|
+
}
|
|
3212
|
+
if (filters.query && filters.fields && filters.fields.length > 0) {
|
|
3213
|
+
const normalizedQuery = filters.query.toLowerCase();
|
|
3214
|
+
const fieldConditions = filters.fields.map((field) => {
|
|
3215
|
+
if (field === "key") {
|
|
3216
|
+
params.push(`${filters.query}%`);
|
|
3217
|
+
return "key_search_prefix LIKE ?";
|
|
3218
|
+
}
|
|
3219
|
+
const column = fieldToColumn(field);
|
|
3220
|
+
params.push(`%${normalizedQuery}%`);
|
|
3221
|
+
return `LOWER(COALESCE(${column}, '')) LIKE ?`;
|
|
3222
|
+
});
|
|
3223
|
+
conditions.push(`(${fieldConditions.join(" OR ")})`);
|
|
3224
|
+
}
|
|
3225
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
3226
|
+
params.push(filters.limit);
|
|
3227
|
+
const rows = this.#database.prepare(
|
|
3228
|
+
`SELECT * FROM ak_records
|
|
3229
|
+
${whereClause}
|
|
3230
|
+
ORDER BY updated_at DESC
|
|
3231
|
+
LIMIT ?`
|
|
3232
|
+
).all(...params);
|
|
3233
|
+
return rows.map(mapRowToRecord);
|
|
3234
|
+
}
|
|
3235
|
+
updateMetadata(input2) {
|
|
3236
|
+
const current = this.getById(input2.id);
|
|
3237
|
+
if (!current) {
|
|
3238
|
+
return null;
|
|
3239
|
+
}
|
|
3240
|
+
const row = this.#database.prepare(
|
|
3241
|
+
`UPDATE ak_records
|
|
3242
|
+
SET user_id = @userId,
|
|
3243
|
+
user_name = @userName,
|
|
3244
|
+
email = @email,
|
|
3245
|
+
phone = @phone,
|
|
3246
|
+
updated_at = @updatedAt
|
|
3247
|
+
WHERE id = @id
|
|
3248
|
+
RETURNING *`
|
|
3249
|
+
).get({
|
|
3250
|
+
email: input2.email === void 0 ? current.email : input2.email,
|
|
3251
|
+
id: input2.id,
|
|
3252
|
+
phone: input2.phone === void 0 ? current.phone : input2.phone,
|
|
3253
|
+
updatedAt: input2.updatedAt,
|
|
3254
|
+
userId: input2.userId === void 0 ? current.userId : input2.userId,
|
|
3255
|
+
userName: input2.userName === void 0 ? current.userName : input2.userName
|
|
3256
|
+
});
|
|
3257
|
+
if (!row) {
|
|
3258
|
+
return null;
|
|
3259
|
+
}
|
|
3260
|
+
return mapRowToRecord(row);
|
|
3261
|
+
}
|
|
3262
|
+
deleteById(id) {
|
|
3263
|
+
const result = this.#database.prepare("DELETE FROM ak_records WHERE id = ?").run(id);
|
|
3264
|
+
return result.changes > 0;
|
|
3265
|
+
}
|
|
3266
|
+
};
|
|
3267
|
+
function fieldToColumn(field) {
|
|
3268
|
+
switch (field) {
|
|
3269
|
+
case "userId":
|
|
3270
|
+
return "user_id";
|
|
3271
|
+
case "userName":
|
|
3272
|
+
return "user_name";
|
|
3273
|
+
case "email":
|
|
3274
|
+
return "email";
|
|
3275
|
+
case "phone":
|
|
3276
|
+
return "phone";
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
function mapRowToRecord(row) {
|
|
3280
|
+
return {
|
|
3281
|
+
createdAt: row.created_at,
|
|
3282
|
+
email: row.email,
|
|
3283
|
+
env: row.env,
|
|
3284
|
+
id: row.id,
|
|
3285
|
+
keyCiphertext: row.key_ciphertext,
|
|
3286
|
+
keySearchPrefix: row.key_search_prefix,
|
|
3287
|
+
phone: row.phone,
|
|
3288
|
+
updatedAt: row.updated_at,
|
|
3289
|
+
userId: row.user_id,
|
|
3290
|
+
userName: row.user_name
|
|
3291
|
+
};
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
// src/disk/runtime.ts
|
|
3295
|
+
import { readdir, rm, stat } from "fs/promises";
|
|
3296
|
+
import { execFile } from "child_process";
|
|
3297
|
+
import { promisify } from "util";
|
|
3298
|
+
import { join as join3, win32 as win325 } from "path";
|
|
3299
|
+
var execFileAsync = promisify(execFile);
|
|
3300
|
+
function resolveDiskCleanupRoots(env) {
|
|
3301
|
+
const userProfileRoot = env.USERPROFILE ?? resolveHomeDrivePath(env);
|
|
3302
|
+
const systemRoot = env.SystemRoot ?? "C:\\Windows";
|
|
3303
|
+
if (!userProfileRoot) {
|
|
3304
|
+
throw new Error(
|
|
3305
|
+
"Windows user profile could not be resolved. Set USERPROFILE or HOMEDRIVE and HOMEPATH."
|
|
3306
|
+
);
|
|
3307
|
+
}
|
|
3308
|
+
return {
|
|
3309
|
+
systemRoot,
|
|
3310
|
+
userProfileRoot
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
function createDiskCleanupRuntime(dependencies = {}) {
|
|
3314
|
+
const executeCommand = dependencies.executeCommand ?? /* v8 ignore next 3 */
|
|
3315
|
+
(async (file, args) => {
|
|
3316
|
+
await execFileAsync(file, args);
|
|
3317
|
+
});
|
|
3318
|
+
return {
|
|
3319
|
+
clearDirectoryContents: async (path) => {
|
|
3320
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
3321
|
+
await Promise.all(
|
|
3322
|
+
entries.map(
|
|
3323
|
+
(entry) => rm(join3(path, entry.name), {
|
|
3324
|
+
force: true,
|
|
3325
|
+
recursive: true
|
|
3326
|
+
})
|
|
3327
|
+
)
|
|
3328
|
+
);
|
|
3329
|
+
},
|
|
3330
|
+
disableHibernation: async () => {
|
|
3331
|
+
await executeCommand("powercfg", ["-h", "off"]);
|
|
3332
|
+
},
|
|
3333
|
+
inspectPath: async (path) => inspectPath(path),
|
|
3334
|
+
listTopEntriesBySize: async (path, limit) => listTopEntriesBySize(path, limit),
|
|
3335
|
+
runCommand: async (file, args) => {
|
|
3336
|
+
await executeCommand(file, args);
|
|
3337
|
+
}
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
async function inspectPath(path) {
|
|
3341
|
+
try {
|
|
3342
|
+
const stats = await stat(path);
|
|
3343
|
+
if (!stats.isDirectory()) {
|
|
3344
|
+
return {
|
|
3345
|
+
exists: true,
|
|
3346
|
+
isDirectory: false,
|
|
3347
|
+
sizeBytes: stats.size
|
|
3348
|
+
};
|
|
3349
|
+
}
|
|
3350
|
+
const sizeBytes = await getDirectorySize(path);
|
|
3351
|
+
return {
|
|
3352
|
+
exists: true,
|
|
3353
|
+
isDirectory: true,
|
|
3354
|
+
sizeBytes
|
|
3355
|
+
};
|
|
3356
|
+
} catch (error) {
|
|
3357
|
+
if (isNotFoundError(error)) {
|
|
3358
|
+
return {
|
|
3359
|
+
exists: false,
|
|
3360
|
+
isDirectory: false,
|
|
3361
|
+
sizeBytes: 0
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
throw error;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
async function getDirectorySize(path) {
|
|
3368
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
3369
|
+
let total = 0;
|
|
3370
|
+
for (const entry of entries) {
|
|
3371
|
+
const entryPath = join3(path, entry.name);
|
|
3372
|
+
if (entry.isDirectory()) {
|
|
3373
|
+
total += await getDirectorySize(entryPath);
|
|
3374
|
+
continue;
|
|
3375
|
+
}
|
|
3376
|
+
if (entry.isFile()) {
|
|
3377
|
+
total += (await stat(entryPath)).size;
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return total;
|
|
3381
|
+
}
|
|
3382
|
+
async function listTopEntriesBySize(path, limit) {
|
|
3383
|
+
try {
|
|
3384
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
3385
|
+
const measured = await Promise.all(
|
|
3386
|
+
entries.map(async (entry) => {
|
|
3387
|
+
const entryPath = join3(path, entry.name);
|
|
3388
|
+
if (entry.isDirectory()) {
|
|
3389
|
+
return {
|
|
3390
|
+
kind: "directory",
|
|
3391
|
+
name: entry.name,
|
|
3392
|
+
path: entryPath,
|
|
3393
|
+
sizeBytes: await getDirectorySize(entryPath),
|
|
3394
|
+
sizeGB: 0
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
if (entry.isFile()) {
|
|
3398
|
+
const stats = await stat(entryPath);
|
|
3399
|
+
return {
|
|
3400
|
+
kind: "file",
|
|
3401
|
+
name: entry.name,
|
|
3402
|
+
path: entryPath,
|
|
3403
|
+
sizeBytes: stats.size,
|
|
3404
|
+
sizeGB: 0
|
|
3405
|
+
};
|
|
3406
|
+
}
|
|
3407
|
+
return null;
|
|
3408
|
+
})
|
|
3409
|
+
);
|
|
3410
|
+
return measured.filter((entry) => entry !== null).map((entry) => ({
|
|
3411
|
+
...entry,
|
|
3412
|
+
sizeGB: bytesToGb2(entry.sizeBytes)
|
|
3413
|
+
})).sort((left, right) => right.sizeBytes - left.sizeBytes).slice(0, limit);
|
|
3414
|
+
} catch (error) {
|
|
3415
|
+
if (isNotFoundError(error)) {
|
|
3416
|
+
return [];
|
|
3417
|
+
}
|
|
3418
|
+
throw error;
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
function bytesToGb2(value) {
|
|
3422
|
+
return Number((value / 1024 ** 3).toFixed(2));
|
|
3423
|
+
}
|
|
3424
|
+
function resolveHomeDrivePath(env) {
|
|
3425
|
+
if (!env.HOMEDRIVE || !env.HOMEPATH) {
|
|
3426
|
+
return null;
|
|
3427
|
+
}
|
|
3428
|
+
return win325.join(env.HOMEDRIVE, env.HOMEPATH);
|
|
3429
|
+
}
|
|
3430
|
+
function isNotFoundError(error) {
|
|
3431
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
// src/video/runtime.ts
|
|
3435
|
+
import { execFile as nodeExecFile } from "child_process";
|
|
3436
|
+
import { writeFile as writeFileOnDisk } from "fs/promises";
|
|
3437
|
+
import { promisify as promisify2 } from "util";
|
|
3438
|
+
var execFileAsync2 = promisify2(nodeExecFile);
|
|
3439
|
+
function createDefaultVideoRuntime() {
|
|
3440
|
+
return {
|
|
3441
|
+
execFile: async (file, args) => {
|
|
3442
|
+
try {
|
|
3443
|
+
const result = await execFileAsync2(file, args, { encoding: "utf8" });
|
|
3444
|
+
return {
|
|
3445
|
+
code: 0,
|
|
3446
|
+
stderr: result.stderr,
|
|
3447
|
+
stdout: result.stdout
|
|
3448
|
+
};
|
|
3449
|
+
} catch (error) {
|
|
3450
|
+
const execError = error;
|
|
3451
|
+
return {
|
|
3452
|
+
code: typeof execError.code === "number" ? execError.code : 1,
|
|
3453
|
+
stderr: execError.stderr ?? execError.message,
|
|
3454
|
+
stdout: execError.stdout ?? ""
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
},
|
|
3458
|
+
fetch: async (input2, init) => {
|
|
3459
|
+
const response = await fetch(input2, init);
|
|
3460
|
+
return response;
|
|
3461
|
+
},
|
|
3462
|
+
launchBrowserSniffer: async (url) => sniffWithBrowserRuntime(url),
|
|
3463
|
+
writeFile: async (path, data) => {
|
|
3464
|
+
await writeFileOnDisk(path, data);
|
|
3465
|
+
}
|
|
3466
|
+
};
|
|
3467
|
+
}
|
|
3468
|
+
async function sniffWithBrowserRuntime(url, loadModule = loadOptionalModule2) {
|
|
3469
|
+
let playwright;
|
|
3470
|
+
try {
|
|
3471
|
+
playwright = await loadModule("playwright");
|
|
3472
|
+
} catch {
|
|
3473
|
+
throw new VideoCommandError(
|
|
3474
|
+
"VIDEO_BROWSER_UNAVAILABLE",
|
|
3475
|
+
"Browser sniff mode requires Playwright to be installed.",
|
|
3476
|
+
2,
|
|
3477
|
+
void 0,
|
|
3478
|
+
true
|
|
3479
|
+
);
|
|
3480
|
+
}
|
|
3481
|
+
const chromium = playwright.chromium;
|
|
3482
|
+
if (!chromium) {
|
|
3483
|
+
throw new VideoCommandError(
|
|
3484
|
+
"VIDEO_BROWSER_UNAVAILABLE",
|
|
3485
|
+
"Browser sniff mode requires Playwright Chromium support.",
|
|
3486
|
+
2,
|
|
3487
|
+
void 0,
|
|
3488
|
+
true
|
|
3489
|
+
);
|
|
3490
|
+
}
|
|
3491
|
+
const browser = await chromium.launch({ headless: true });
|
|
3492
|
+
const candidates = [];
|
|
3493
|
+
try {
|
|
3494
|
+
const context = await browser.newContext();
|
|
3495
|
+
const page = await context.newPage();
|
|
3496
|
+
page.on("response", (response) => {
|
|
3497
|
+
const candidate = createCandidateFromBrowserResponse(response.url(), response.headers()["content-type"]);
|
|
3498
|
+
if (candidate) {
|
|
3499
|
+
candidates.push(candidate);
|
|
3500
|
+
}
|
|
3501
|
+
});
|
|
3502
|
+
await page.goto(url, { waitUntil: "load" });
|
|
3503
|
+
await page.waitForTimeout(6e4);
|
|
3504
|
+
await context.close();
|
|
3505
|
+
} finally {
|
|
3506
|
+
await browser.close();
|
|
3507
|
+
}
|
|
3508
|
+
return rankVideoCandidates(candidates);
|
|
3509
|
+
}
|
|
3510
|
+
var loadOptionalModule2 = new Function(
|
|
3511
|
+
"specifier",
|
|
3512
|
+
"return import(specifier);"
|
|
3513
|
+
);
|
|
3514
|
+
var VIDEO_RES_RE = /(?:^|\/)(?:avc1\/|vid\/)(?:\d+\/){0,2}(\d+)x(\d+)/i;
|
|
3515
|
+
var AUDIO_BITRATE_RE = /\/mp4a\/(\d+)/i;
|
|
3516
|
+
var AUDIO_RE = /\/mp4a\//i;
|
|
3517
|
+
function createCandidateFromBrowserResponse(url, contentType) {
|
|
3518
|
+
const isAudio = AUDIO_RE.test(url);
|
|
3519
|
+
let qualityBonus = 0;
|
|
3520
|
+
if (isAudio) {
|
|
3521
|
+
const bitrateMatch = AUDIO_BITRATE_RE.exec(url);
|
|
3522
|
+
if (bitrateMatch && bitrateMatch[1]) {
|
|
3523
|
+
const bitrate = parseInt(bitrateMatch[1], 10);
|
|
3524
|
+
qualityBonus = Math.min(bitrate / 192e3, 1) * 0.05;
|
|
3525
|
+
}
|
|
3526
|
+
} else {
|
|
3527
|
+
const resMatch = VIDEO_RES_RE.exec(url);
|
|
3528
|
+
if (resMatch && resMatch[1] && resMatch[2]) {
|
|
3529
|
+
const width = parseInt(resMatch[1], 10);
|
|
3530
|
+
const height = parseInt(resMatch[2], 10);
|
|
3531
|
+
const pixels = width * height;
|
|
3532
|
+
qualityBonus = Math.min(pixels / (1920 * 1080), 1) * 0.05;
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
if (/\.m4s(?:\?|$)/i.test(url)) {
|
|
3536
|
+
return null;
|
|
3537
|
+
}
|
|
3538
|
+
if (/\.m3u8(?:\?|$)/i.test(url)) {
|
|
3539
|
+
if (isAudio) {
|
|
3540
|
+
return null;
|
|
3541
|
+
}
|
|
3542
|
+
return createVideoCandidate("m3u8", url, "network", qualityBonus);
|
|
3543
|
+
}
|
|
3544
|
+
if (/\.mp4(?:\?|$)/i.test(url) || contentType?.includes("video/mp4")) {
|
|
3545
|
+
if (isAudio) {
|
|
3546
|
+
return null;
|
|
3547
|
+
}
|
|
3548
|
+
return createVideoCandidate("mp4", url, "network", qualityBonus);
|
|
3549
|
+
}
|
|
3550
|
+
return null;
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
// src/36kr/runtime.ts
|
|
3554
|
+
import { execFile as nodeExecFile2 } from "child_process";
|
|
3555
|
+
import { promisify as promisify3 } from "util";
|
|
3556
|
+
var execFileAsync3 = promisify3(nodeExecFile2);
|
|
3557
|
+
function createDefaultKr36Runtime() {
|
|
3558
|
+
return {
|
|
3559
|
+
fetchArticleHtml: async (request) => fetchWithCurl(request, "KR36_REQUEST_FAILED"),
|
|
3560
|
+
fetchJson: async (request) => fetchWithCurl(request, "KR36_REQUEST_FAILED")
|
|
3561
|
+
};
|
|
3562
|
+
}
|
|
3563
|
+
async function fetchWithCurl(request, errorCode) {
|
|
3564
|
+
const args = [
|
|
3565
|
+
"--silent",
|
|
3566
|
+
"--show-error",
|
|
3567
|
+
"--location",
|
|
3568
|
+
"--compressed",
|
|
3569
|
+
...Object.entries(request.headers).flatMap(([name, value]) => ["--header", `${name}: ${value}`]),
|
|
3570
|
+
..."body" in request ? ["--data-raw", JSON.stringify(request.body)] : [],
|
|
3571
|
+
request.url
|
|
3572
|
+
];
|
|
3573
|
+
try {
|
|
3574
|
+
const result = await execFileAsync3("curl", args, {
|
|
3575
|
+
encoding: "utf8",
|
|
3576
|
+
maxBuffer: 20 * 1024 * 1024
|
|
3577
|
+
});
|
|
3578
|
+
return result.stdout;
|
|
3579
|
+
} catch (error) {
|
|
3580
|
+
const execError = error;
|
|
3581
|
+
throw new Kr36CommandError(
|
|
3582
|
+
errorCode,
|
|
3583
|
+
`Failed to fetch 36kr page: ${execError.stderr || execError.message}`,
|
|
3584
|
+
1,
|
|
3585
|
+
{
|
|
3586
|
+
curlExitCode: execError.code,
|
|
3587
|
+
url: request.url
|
|
3588
|
+
}
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
// src/toutiao/runtime.ts
|
|
3594
|
+
var TOUTIAO_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
|
|
3595
|
+
function createDefaultToutiaoRuntime() {
|
|
3596
|
+
return {
|
|
3597
|
+
fetchArticle: async (request) => fetchArticleWithBrowser(request),
|
|
3598
|
+
fetchAuthorArticles: async (options) => fetchAuthorArticlesWithBrowser(options),
|
|
3599
|
+
fetchKeywordInformation: async (options) => fetchKeywordInformationWithBrowser(options),
|
|
3600
|
+
fetchTechnologyChannel: async (options) => fetchTechnologyChannelWithBrowser(options)
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
3603
|
+
async function fetchArticleWithBrowser(request) {
|
|
3604
|
+
return withToutiaoPage(async (page) => {
|
|
3605
|
+
await page.goto(request.url, {
|
|
3606
|
+
timeout: 45e3,
|
|
3607
|
+
waitUntil: "domcontentloaded"
|
|
3608
|
+
});
|
|
3609
|
+
await page.waitForTimeout(5e3);
|
|
3610
|
+
const title = await page.locator("h1").first().innerText({ timeout: 5e3 }).catch(() => "");
|
|
3611
|
+
const paragraphs = await page.locator("article p").evaluateAll(
|
|
3612
|
+
(nodes) => nodes.map((node) => (node.textContent ?? "").trim()).filter(Boolean)
|
|
3613
|
+
);
|
|
3614
|
+
const bodyText = await page.locator("body").innerText({ timeout: 5e3 });
|
|
3615
|
+
const metadata = extractArticleMetadata(bodyText, title);
|
|
3616
|
+
const id = extractArticleId(request.url) ?? extractArticleId(request.input) ?? "";
|
|
3617
|
+
if (!id || !title || paragraphs.length === 0) {
|
|
3618
|
+
throw new ToutiaoCommandError(
|
|
3619
|
+
"TOUTIAO_PARSE_ERROR",
|
|
3620
|
+
"Toutiao article page did not expose title and content.",
|
|
3621
|
+
2,
|
|
3622
|
+
{
|
|
3623
|
+
input: request.input,
|
|
3624
|
+
url: request.url
|
|
3625
|
+
}
|
|
3626
|
+
);
|
|
3627
|
+
}
|
|
3628
|
+
const article = {
|
|
3629
|
+
content: {
|
|
3630
|
+
paragraphs,
|
|
3631
|
+
text: paragraphs.join("\n")
|
|
3632
|
+
},
|
|
3633
|
+
id,
|
|
3634
|
+
request,
|
|
3635
|
+
title,
|
|
3636
|
+
url: page.url()
|
|
3637
|
+
};
|
|
3638
|
+
if (metadata.authorName !== void 0) {
|
|
3639
|
+
article.authorName = metadata.authorName;
|
|
3640
|
+
}
|
|
3641
|
+
if (metadata.publishTimeText !== void 0) {
|
|
3642
|
+
article.publishTimeText = metadata.publishTimeText;
|
|
3643
|
+
}
|
|
3644
|
+
return article;
|
|
3645
|
+
});
|
|
3646
|
+
}
|
|
3647
|
+
async function fetchTechnologyChannelWithBrowser(options) {
|
|
3648
|
+
return withToutiaoPage(async (page) => {
|
|
3649
|
+
const feedResponses = [];
|
|
3650
|
+
page.on("response", (response) => {
|
|
3651
|
+
if (!response.url().includes("/api/pc/list/feed")) {
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
void response.json().then((json) => {
|
|
3655
|
+
feedResponses.push(json);
|
|
3656
|
+
}).catch(() => void 0);
|
|
3657
|
+
});
|
|
3658
|
+
await page.goto("https://www.toutiao.com/?channel=tech&source=ch", {
|
|
3659
|
+
timeout: 45e3,
|
|
3660
|
+
waitUntil: "domcontentloaded"
|
|
3661
|
+
});
|
|
3662
|
+
await waitForFeedResponses(page, feedResponses, options.pages);
|
|
3663
|
+
const items = feedResponses.flatMap((response) => mapFeedItems(response.data ?? []));
|
|
3664
|
+
const lastResponse = feedResponses.at(-1);
|
|
3665
|
+
if (items.length === 0) {
|
|
3666
|
+
throw new ToutiaoCommandError(
|
|
3667
|
+
"TOUTIAO_PARSE_ERROR",
|
|
3668
|
+
"Toutiao technology channel did not return feed items.",
|
|
3669
|
+
2
|
|
3670
|
+
);
|
|
3671
|
+
}
|
|
3672
|
+
const result = {
|
|
3673
|
+
hasMore: lastResponse?.has_more ?? false,
|
|
3674
|
+
items,
|
|
3675
|
+
source: "tech"
|
|
3676
|
+
};
|
|
3677
|
+
const next = {};
|
|
3678
|
+
if (lastResponse?.next?.max_behot_time !== void 0) {
|
|
3679
|
+
next.maxBehotTime = lastResponse.next.max_behot_time;
|
|
3680
|
+
}
|
|
3681
|
+
if (lastResponse?.offset !== void 0) {
|
|
3682
|
+
next.offset = lastResponse.offset;
|
|
3683
|
+
}
|
|
3684
|
+
if (Object.keys(next).length > 0) {
|
|
3685
|
+
result.next = next;
|
|
3686
|
+
}
|
|
3687
|
+
return result;
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
async function fetchAuthorArticlesWithBrowser(options) {
|
|
3691
|
+
return withToutiaoPage(async (page) => {
|
|
3692
|
+
const feedResponses = [];
|
|
3693
|
+
page.on("response", (response) => {
|
|
3694
|
+
if (!isAuthorFeedResponseUrl(response.url(), options.authorToken)) {
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
void response.json().then((json) => {
|
|
3698
|
+
feedResponses.push(json);
|
|
3699
|
+
}).catch(() => void 0);
|
|
3700
|
+
});
|
|
3701
|
+
await page.goto(options.url, {
|
|
3702
|
+
timeout: 45e3,
|
|
3703
|
+
waitUntil: "domcontentloaded"
|
|
3704
|
+
});
|
|
3705
|
+
await waitForFeedResponses(page, feedResponses, options.pages);
|
|
3706
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3707
|
+
const selectedResponses = feedResponses.slice(0, options.pages);
|
|
3708
|
+
const items = selectedResponses.flatMap((response) => mapFeedItems(response.data ?? [])).filter((item) => {
|
|
3709
|
+
if (seen.has(item.id)) {
|
|
3710
|
+
return false;
|
|
3711
|
+
}
|
|
3712
|
+
seen.add(item.id);
|
|
3713
|
+
return true;
|
|
3714
|
+
});
|
|
3715
|
+
const lastResponse = selectedResponses.at(-1);
|
|
3716
|
+
if (items.length === 0) {
|
|
3717
|
+
throw new ToutiaoCommandError(
|
|
3718
|
+
"TOUTIAO_PARSE_ERROR",
|
|
3719
|
+
"Toutiao author homepage did not return article feed items.",
|
|
3720
|
+
2,
|
|
3721
|
+
{
|
|
3722
|
+
authorToken: options.authorToken,
|
|
3723
|
+
url: options.url
|
|
3724
|
+
}
|
|
3725
|
+
);
|
|
3726
|
+
}
|
|
3727
|
+
const result = {
|
|
3728
|
+
authorToken: options.authorToken,
|
|
3729
|
+
hasMore: lastResponse?.has_more ?? false,
|
|
3730
|
+
items
|
|
3731
|
+
};
|
|
3732
|
+
const next = {};
|
|
3733
|
+
if (lastResponse?.next?.max_behot_time !== void 0) {
|
|
3734
|
+
next.maxBehotTime = lastResponse.next.max_behot_time;
|
|
3735
|
+
}
|
|
3736
|
+
if (lastResponse?.offset !== void 0) {
|
|
3737
|
+
next.offset = lastResponse.offset;
|
|
3738
|
+
}
|
|
3739
|
+
if (Object.keys(next).length > 0) {
|
|
3740
|
+
result.next = next;
|
|
3741
|
+
}
|
|
3742
|
+
return result;
|
|
3743
|
+
});
|
|
3744
|
+
}
|
|
3745
|
+
async function fetchKeywordInformationWithBrowser(options) {
|
|
3746
|
+
return withToutiaoPage(async (page) => {
|
|
3747
|
+
const items = [];
|
|
3748
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3749
|
+
let blocked = false;
|
|
3750
|
+
for (let pageIndex = 0; pageIndex < options.pages; pageIndex += 1) {
|
|
3751
|
+
const url = buildSearchUrl(options.keyword, pageIndex);
|
|
3752
|
+
await page.goto(url, {
|
|
3753
|
+
timeout: 45e3,
|
|
3754
|
+
waitUntil: "domcontentloaded"
|
|
3755
|
+
});
|
|
3756
|
+
await page.waitForTimeout(5e3);
|
|
3757
|
+
blocked = blocked || await page.locator("#pc_captcha").count() > 0;
|
|
3758
|
+
for (const item of await extractSearchItemsFromPage(page, options.keyword)) {
|
|
3759
|
+
if (seen.has(item.id)) {
|
|
3760
|
+
continue;
|
|
3761
|
+
}
|
|
3762
|
+
seen.add(item.id);
|
|
3763
|
+
items.push(item);
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
if (blocked && items.length === 0) {
|
|
3767
|
+
throw new ToutiaoCommandError(
|
|
3768
|
+
"TOUTIAO_VERIFICATION_REQUIRED",
|
|
3769
|
+
"Toutiao search requires browser verification before returning usable results.",
|
|
3770
|
+
1,
|
|
3771
|
+
{
|
|
3772
|
+
keyword: options.keyword
|
|
3773
|
+
}
|
|
3774
|
+
);
|
|
3775
|
+
}
|
|
3776
|
+
if (items.length === 0) {
|
|
3777
|
+
throw new ToutiaoCommandError(
|
|
3778
|
+
"TOUTIAO_PARSE_ERROR",
|
|
3779
|
+
"Toutiao search page did not expose result items.",
|
|
3780
|
+
2,
|
|
3781
|
+
{
|
|
3782
|
+
keyword: options.keyword
|
|
3783
|
+
}
|
|
3784
|
+
);
|
|
3785
|
+
}
|
|
3786
|
+
return {
|
|
3787
|
+
hasMore: true,
|
|
3788
|
+
items,
|
|
3789
|
+
keyword: options.keyword,
|
|
3790
|
+
source: options.source
|
|
3791
|
+
};
|
|
3792
|
+
});
|
|
3793
|
+
}
|
|
3794
|
+
function buildSearchUrl(keyword, pageIndex) {
|
|
3795
|
+
const params = new URLSearchParams({
|
|
3796
|
+
action_type: pageIndex === 0 ? "search" : "pagination",
|
|
3797
|
+
cur_tab_title: "news",
|
|
3798
|
+
dvpf: "pc",
|
|
3799
|
+
from: "news",
|
|
3800
|
+
keyword,
|
|
3801
|
+
page_num: String(pageIndex),
|
|
3802
|
+
pd: "information",
|
|
3803
|
+
source: pageIndex === 0 ? "input" : "pagination"
|
|
3804
|
+
});
|
|
3805
|
+
return `https://so.toutiao.com/search?${params.toString()}`;
|
|
3806
|
+
}
|
|
3807
|
+
async function withToutiaoPage(callback) {
|
|
3808
|
+
let browser;
|
|
3809
|
+
let context;
|
|
3810
|
+
try {
|
|
3811
|
+
const { chromium } = await loadPlaywright();
|
|
3812
|
+
browser = await chromium.launch({ headless: true });
|
|
3813
|
+
context = await browser.newContext({
|
|
3814
|
+
locale: "zh-CN",
|
|
3815
|
+
userAgent: TOUTIAO_USER_AGENT
|
|
3816
|
+
});
|
|
3817
|
+
const page = await context.newPage();
|
|
3818
|
+
return await callback(page);
|
|
3819
|
+
} finally {
|
|
3820
|
+
await context?.close().catch(() => void 0);
|
|
3821
|
+
await browser?.close().catch(() => void 0);
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
async function waitForFeedResponses(page, responses, pages) {
|
|
3825
|
+
const started = Date.now();
|
|
3826
|
+
while (responses.length < pages && Date.now() - started < 45e3) {
|
|
3827
|
+
await page.waitForTimeout(1e3);
|
|
3828
|
+
if (responses.length < pages) {
|
|
3829
|
+
await page.mouse.wheel(0, 4e3);
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
async function loadPlaywright() {
|
|
3834
|
+
try {
|
|
3835
|
+
return await loadOptionalModule3("playwright");
|
|
3836
|
+
} catch {
|
|
3837
|
+
throw new ToutiaoCommandError(
|
|
3838
|
+
"TOUTIAO_BROWSER_UNAVAILABLE",
|
|
3839
|
+
"Toutiao collection requires Playwright to be installed.",
|
|
3840
|
+
2
|
|
3841
|
+
);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
function mapFeedItems(items) {
|
|
3845
|
+
return items.flatMap((item) => {
|
|
3846
|
+
const id = String(item.group_id ?? item.item_id ?? item.id ?? "");
|
|
3847
|
+
const title = item.title ?? item.Title ?? "";
|
|
3848
|
+
if (!id || !title) {
|
|
3849
|
+
return [];
|
|
3850
|
+
}
|
|
3851
|
+
const mapped = {
|
|
3852
|
+
id,
|
|
3853
|
+
title,
|
|
3854
|
+
url: normalizeArticleUrl(item.article_url ?? item.display_url ?? item.source_url, id)
|
|
3855
|
+
};
|
|
3856
|
+
const abstract = item.Abstract ?? item.abstract;
|
|
3857
|
+
if (abstract !== void 0) {
|
|
3858
|
+
mapped.abstract = abstract;
|
|
3859
|
+
}
|
|
3860
|
+
if (item.comment_count !== void 0) {
|
|
3861
|
+
mapped.commentCount = item.comment_count;
|
|
3862
|
+
}
|
|
3863
|
+
if (item.image_url !== void 0) {
|
|
3864
|
+
mapped.image = item.image_url;
|
|
3865
|
+
}
|
|
3866
|
+
const authorName = item.media_name ?? item.source;
|
|
3867
|
+
if (authorName !== void 0) {
|
|
3868
|
+
mapped.authorName = authorName;
|
|
3869
|
+
}
|
|
3870
|
+
if (item.source_url !== void 0) {
|
|
3871
|
+
mapped.sourceUrl = item.source_url;
|
|
3872
|
+
}
|
|
3873
|
+
if (item.behot_time !== void 0) {
|
|
3874
|
+
mapped.publishTime = {
|
|
3875
|
+
iso: new Date(item.behot_time * 1e3).toISOString(),
|
|
3876
|
+
local: formatChinaTime2(item.behot_time),
|
|
3877
|
+
seconds: item.behot_time
|
|
3878
|
+
};
|
|
3879
|
+
}
|
|
3880
|
+
return [mapped];
|
|
3881
|
+
});
|
|
3882
|
+
}
|
|
3883
|
+
function normalizeArticleUrl(value, id) {
|
|
3884
|
+
if (!value) {
|
|
3885
|
+
return `https://www.toutiao.com/article/${id}/`;
|
|
3886
|
+
}
|
|
3887
|
+
try {
|
|
3888
|
+
const url = new URL(value, "https://www.toutiao.com");
|
|
3889
|
+
return parseToutiaoArticleLink(url.toString())?.url ?? `https://www.toutiao.com/article/${id}/`;
|
|
3890
|
+
} catch {
|
|
3891
|
+
return `https://www.toutiao.com/article/${id}/`;
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
async function extractSearchItemsFromPage(page, keyword) {
|
|
3895
|
+
const anchors = await page.locator("a").evaluateAll(
|
|
3896
|
+
(nodes) => nodes.map((node) => ({
|
|
3897
|
+
href: node.href,
|
|
3898
|
+
text: (node.textContent ?? "").trim().replace(/\s+/g, " ")
|
|
3899
|
+
}))
|
|
3900
|
+
);
|
|
3901
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3902
|
+
const items = [];
|
|
3903
|
+
for (const anchor of anchors) {
|
|
3904
|
+
const articleLink = parseToutiaoArticleLink(anchor.href);
|
|
3905
|
+
if (!articleLink || !anchor.text || anchor.text.length < 6 || seen.has(articleLink.id)) {
|
|
3906
|
+
continue;
|
|
3907
|
+
}
|
|
3908
|
+
seen.add(articleLink.id);
|
|
3909
|
+
items.push({
|
|
3910
|
+
id: articleLink.id,
|
|
3911
|
+
title: anchor.text.slice(0, 180),
|
|
3912
|
+
url: articleLink.url
|
|
3913
|
+
});
|
|
3914
|
+
if (items.length >= 30) {
|
|
3915
|
+
break;
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
if (items.length > 0) {
|
|
3919
|
+
return items;
|
|
3920
|
+
}
|
|
3921
|
+
return extractSearchItemsFromText(await page.locator("body").innerText({ timeout: 5e3 }), keyword);
|
|
3922
|
+
}
|
|
3923
|
+
function extractSearchItemsFromText(text, keyword) {
|
|
3924
|
+
const blockedPrefixes = /* @__PURE__ */ new Set([
|
|
3925
|
+
"\u65E0\u969C\u788D \u4ECA\u65E5\u5934\u6761\u9996\u9875 \u767B\u5F55 \u7EFC\u5408 \u8D44\u8BAF \u89C6\u9891 \u56FE\u7247 \u7528\u6237 \u5C0F\u89C6\u9891 \u5FAE\u5934\u6761 \u97F3\u4E50 \u53BB\u6296\u97F3\u641C"
|
|
3926
|
+
]);
|
|
3927
|
+
const lines = text.split(/\n+/).map((line) => line.trim()).filter((line) => line.length >= 12 && !blockedPrefixes.has(line));
|
|
3928
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3929
|
+
const items = [];
|
|
3930
|
+
for (const line of lines) {
|
|
3931
|
+
const normalized = line.replace(/\s+/g, " ");
|
|
3932
|
+
if (seen.has(normalized)) {
|
|
3933
|
+
continue;
|
|
3934
|
+
}
|
|
3935
|
+
seen.add(normalized);
|
|
3936
|
+
items.push({
|
|
3937
|
+
id: createSearchItemId(keyword, normalized, items.length),
|
|
3938
|
+
title: normalized.slice(0, 180),
|
|
3939
|
+
url: `https://so.toutiao.com/search?keyword=${encodeURIComponent(keyword)}&pd=information&dvpf=pc&source=input`
|
|
3940
|
+
});
|
|
3941
|
+
if (items.length >= 30) {
|
|
3942
|
+
break;
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
return items;
|
|
3946
|
+
}
|
|
3947
|
+
function parseToutiaoArticleLink(href, depth = 0) {
|
|
3948
|
+
if (depth > 3) {
|
|
3949
|
+
return void 0;
|
|
3950
|
+
}
|
|
3951
|
+
const directId = extractArticleId(href);
|
|
3952
|
+
if (directId) {
|
|
3953
|
+
return {
|
|
3954
|
+
id: directId,
|
|
3955
|
+
url: `https://www.toutiao.com/article/${directId}/`
|
|
3956
|
+
};
|
|
3957
|
+
}
|
|
3958
|
+
try {
|
|
3959
|
+
const url = new URL(href);
|
|
3960
|
+
const nextUrl = url.searchParams.get("url");
|
|
3961
|
+
if (nextUrl) {
|
|
3962
|
+
return parseToutiaoArticleLink(nextUrl, depth + 1);
|
|
3963
|
+
}
|
|
3964
|
+
} catch {
|
|
3965
|
+
return void 0;
|
|
3966
|
+
}
|
|
3967
|
+
return void 0;
|
|
3968
|
+
}
|
|
3969
|
+
function extractArticleMetadata(bodyText, title) {
|
|
3970
|
+
const lines = bodyText.split(/\n+/).map((line) => line.trim()).filter(Boolean);
|
|
3971
|
+
const titleIndex = lines.findIndex((line) => line === title);
|
|
3972
|
+
const metadataLine = titleIndex >= 0 ? lines[titleIndex + 1] : void 0;
|
|
3973
|
+
const match = metadataLine?.match(/^(.+?)·(.+)$/);
|
|
3974
|
+
if (!match?.[1] || !match[2]) {
|
|
3975
|
+
return {};
|
|
3976
|
+
}
|
|
3977
|
+
return {
|
|
3978
|
+
authorName: match[2],
|
|
3979
|
+
publishTimeText: match[1]
|
|
3980
|
+
};
|
|
3981
|
+
}
|
|
3982
|
+
function extractArticleId(value) {
|
|
3983
|
+
return /(?:article|group)\/(\d+)/.exec(value)?.[1] ?? /\/a(\d+)/.exec(value)?.[1] ?? (/^\d+$/.test(value) ? value : void 0);
|
|
3984
|
+
}
|
|
3985
|
+
function isAuthorFeedResponseUrl(value, authorToken) {
|
|
3986
|
+
try {
|
|
3987
|
+
const url = new URL(value);
|
|
3988
|
+
const isProfileArticleFeed = url.pathname.includes("/api/pc/list/feed") && (url.searchParams.get("category") === "pc_profile_article" || url.searchParams.get("author_token") === authorToken);
|
|
3989
|
+
const isProfileAllFeed = url.pathname.includes("/api/pc/list/user/feed") && url.searchParams.get("category") === "profile_all" && url.searchParams.get("token") === authorToken;
|
|
3990
|
+
return isProfileArticleFeed || isProfileAllFeed;
|
|
3991
|
+
} catch {
|
|
3992
|
+
return value.includes("/api/pc/list/feed") && (value.includes("pc_profile_article") || value.includes(authorToken)) || value.includes("/api/pc/list/user/feed") && value.includes("profile_all") && value.includes(authorToken);
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
function createSearchItemId(keyword, value, index) {
|
|
3996
|
+
let hash = 2166136261;
|
|
3997
|
+
const input2 = `${keyword}:${index}:${value}`;
|
|
3998
|
+
for (const char of input2) {
|
|
3999
|
+
hash ^= char.charCodeAt(0);
|
|
4000
|
+
hash = Math.imul(hash, 16777619);
|
|
4001
|
+
}
|
|
4002
|
+
return `search-${(hash >>> 0).toString(16)}`;
|
|
4003
|
+
}
|
|
4004
|
+
function formatChinaTime2(timestampSeconds) {
|
|
4005
|
+
return new Intl.DateTimeFormat("sv-SE", {
|
|
4006
|
+
day: "2-digit",
|
|
4007
|
+
hour: "2-digit",
|
|
4008
|
+
hour12: false,
|
|
4009
|
+
minute: "2-digit",
|
|
4010
|
+
month: "2-digit",
|
|
4011
|
+
second: "2-digit",
|
|
4012
|
+
timeZone: "Asia/Shanghai",
|
|
4013
|
+
year: "numeric"
|
|
4014
|
+
}).format(new Date(timestampSeconds * 1e3));
|
|
4015
|
+
}
|
|
4016
|
+
var loadOptionalModule3 = new Function(
|
|
4017
|
+
"specifier",
|
|
4018
|
+
"return import(specifier);"
|
|
4019
|
+
);
|
|
4020
|
+
|
|
4021
|
+
// src/hn/runtime.ts
|
|
4022
|
+
var FIREBASE_BASE_URL = "https://hacker-news.firebaseio.com/v0";
|
|
4023
|
+
var ALGOLIA_BASE_URL = "https://hn.algolia.com/api/v1";
|
|
4024
|
+
var SOURCE_TO_ENDPOINT = {
|
|
4025
|
+
best: "beststories",
|
|
4026
|
+
new: "newstories",
|
|
4027
|
+
top: "topstories"
|
|
4028
|
+
};
|
|
4029
|
+
function createDefaultHackerNewsRuntime() {
|
|
4030
|
+
return {
|
|
4031
|
+
fetchSearch: async (options) => fetchSearch(options),
|
|
4032
|
+
fetchStories: async (options) => fetchStories(options)
|
|
4033
|
+
};
|
|
4034
|
+
}
|
|
4035
|
+
async function fetchStories(options) {
|
|
4036
|
+
const endpoint = SOURCE_TO_ENDPOINT[options.source];
|
|
4037
|
+
const ids = await fetchJson(`${FIREBASE_BASE_URL}/${endpoint}.json`);
|
|
4038
|
+
const selectedIds = ids.slice(0, Math.max(options.limit * 2, options.limit));
|
|
4039
|
+
const rawItems = await Promise.all(
|
|
4040
|
+
selectedIds.map((id) => fetchJson(`${FIREBASE_BASE_URL}/item/${id}.json`))
|
|
4041
|
+
);
|
|
4042
|
+
const items = rawItems.flatMap((item) => mapFirebaseItem(item)).slice(0, options.limit);
|
|
4043
|
+
return {
|
|
4044
|
+
items,
|
|
4045
|
+
source: options.source
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
async function fetchSearch(options) {
|
|
4049
|
+
const params = new URLSearchParams({
|
|
4050
|
+
hitsPerPage: String(options.limit),
|
|
4051
|
+
query: options.query,
|
|
4052
|
+
tags: "story"
|
|
4053
|
+
});
|
|
4054
|
+
const endpoint = options.sort === "date" ? "search_by_date" : "search";
|
|
4055
|
+
const response = await fetchJson(`${ALGOLIA_BASE_URL}/${endpoint}?${params.toString()}`);
|
|
4056
|
+
return {
|
|
4057
|
+
items: response.hits.flatMap(mapAlgoliaHit).slice(0, options.limit),
|
|
4058
|
+
query: options.query
|
|
4059
|
+
};
|
|
4060
|
+
}
|
|
4061
|
+
async function fetchJson(url) {
|
|
4062
|
+
let response;
|
|
4063
|
+
try {
|
|
4064
|
+
response = await fetch(url, {
|
|
4065
|
+
headers: {
|
|
4066
|
+
accept: "application/json",
|
|
4067
|
+
"user-agent": "stephen-cli/0.1 HackerNews collector"
|
|
4068
|
+
}
|
|
4069
|
+
});
|
|
4070
|
+
} catch (error) {
|
|
4071
|
+
throw new HackerNewsCommandError(
|
|
4072
|
+
"HN_FETCH_FAILED",
|
|
4073
|
+
"Failed to fetch Hacker News data.",
|
|
4074
|
+
2,
|
|
4075
|
+
{
|
|
4076
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
4077
|
+
url
|
|
4078
|
+
}
|
|
4079
|
+
);
|
|
4080
|
+
}
|
|
4081
|
+
if (!response.ok) {
|
|
4082
|
+
throw new HackerNewsCommandError(
|
|
4083
|
+
"HN_FETCH_FAILED",
|
|
4084
|
+
`Failed to fetch Hacker News data. HTTP ${response.status}.`,
|
|
4085
|
+
2,
|
|
4086
|
+
{
|
|
4087
|
+
url
|
|
4088
|
+
}
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
return await response.json();
|
|
4092
|
+
}
|
|
4093
|
+
function mapFirebaseItem(item) {
|
|
4094
|
+
if (!item || item.deleted || item.dead || item.type !== "story" || !item.id || !item.title) {
|
|
4095
|
+
return [];
|
|
4096
|
+
}
|
|
4097
|
+
return [
|
|
4098
|
+
createItem({
|
|
4099
|
+
id: item.id,
|
|
4100
|
+
title: item.title,
|
|
4101
|
+
url: item.url ?? `https://news.ycombinator.com/item?id=${item.id}`,
|
|
4102
|
+
...optional("author", item.by),
|
|
4103
|
+
...optional("commentCount", item.descendants),
|
|
4104
|
+
...optional("score", item.score),
|
|
4105
|
+
...optional("text", item.text),
|
|
4106
|
+
...optional("timeSeconds", item.time)
|
|
4107
|
+
})
|
|
4108
|
+
];
|
|
4109
|
+
}
|
|
4110
|
+
function mapAlgoliaHit(hit) {
|
|
4111
|
+
const id = hit.story_id ?? parseNumericId(hit.objectID);
|
|
4112
|
+
if (!id || !hit.title) {
|
|
4113
|
+
return [];
|
|
4114
|
+
}
|
|
4115
|
+
return [
|
|
4116
|
+
createItem({
|
|
4117
|
+
id,
|
|
4118
|
+
title: hit.title,
|
|
4119
|
+
url: hit.url ?? `https://news.ycombinator.com/item?id=${id}`,
|
|
4120
|
+
...optional("author", hit.author),
|
|
4121
|
+
...optional("commentCount", hit.num_comments),
|
|
4122
|
+
...optional("score", hit.points),
|
|
4123
|
+
...optional("timeSeconds", hit.created_at_i)
|
|
4124
|
+
})
|
|
4125
|
+
];
|
|
4126
|
+
}
|
|
4127
|
+
function createItem(input2) {
|
|
4128
|
+
const item = {
|
|
4129
|
+
id: input2.id,
|
|
4130
|
+
title: input2.title,
|
|
4131
|
+
type: "story",
|
|
4132
|
+
url: input2.url
|
|
4133
|
+
};
|
|
4134
|
+
if (input2.author !== void 0) {
|
|
4135
|
+
item.author = input2.author;
|
|
4136
|
+
}
|
|
4137
|
+
if (input2.commentCount !== void 0) {
|
|
4138
|
+
item.commentCount = input2.commentCount;
|
|
4139
|
+
}
|
|
4140
|
+
if (input2.score !== void 0) {
|
|
4141
|
+
item.score = input2.score;
|
|
4142
|
+
}
|
|
4143
|
+
if (input2.text !== void 0) {
|
|
4144
|
+
item.text = input2.text;
|
|
4145
|
+
}
|
|
4146
|
+
if (input2.timeSeconds !== void 0) {
|
|
4147
|
+
item.time = {
|
|
4148
|
+
iso: new Date(input2.timeSeconds * 1e3).toISOString(),
|
|
4149
|
+
seconds: input2.timeSeconds
|
|
4150
|
+
};
|
|
4151
|
+
}
|
|
4152
|
+
return item;
|
|
4153
|
+
}
|
|
4154
|
+
function parseNumericId(value) {
|
|
4155
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
4156
|
+
return void 0;
|
|
4157
|
+
}
|
|
4158
|
+
return Number(value);
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
// src/index.ts
|
|
4162
|
+
var createDefaultReadline = () => createInterface({ input, output });
|
|
4163
|
+
function createCli(overrides = {}) {
|
|
4164
|
+
const paths = overrides.paths ?? envPaths("stephen");
|
|
4165
|
+
const runtimeEnv = overrides.env ?? process.env;
|
|
4166
|
+
const masterKey = overrides.masterKey ?? Buffer.from("0123456789abcdef0123456789abcdef", "utf8");
|
|
4167
|
+
const diskRuntime = overrides.diskRuntime ?? createDiskCleanupRuntime();
|
|
4168
|
+
const hackerNewsRuntime = overrides.hackerNewsRuntime ?? createDefaultHackerNewsRuntime();
|
|
4169
|
+
const kr36Runtime = overrides.kr36Runtime ?? createDefaultKr36Runtime();
|
|
4170
|
+
const toutiaoRuntime = overrides.toutiaoRuntime ?? createDefaultToutiaoRuntime();
|
|
4171
|
+
const videoRuntime = overrides.videoRuntime ?? createDefaultVideoRuntime();
|
|
4172
|
+
let repositoryCache = overrides.repository ? { promise: Promise.resolve(overrides.repository), repository: overrides.repository } : null;
|
|
4173
|
+
const getRepository = () => {
|
|
4174
|
+
if (repositoryCache && overrides.repository) {
|
|
4175
|
+
return overrides.repository;
|
|
4176
|
+
}
|
|
4177
|
+
if (!repositoryCache) {
|
|
4178
|
+
const config = loadAkConfig(paths.config);
|
|
4179
|
+
const resolvedDatabasePath = resolveAkDatabasePath({
|
|
4180
|
+
config,
|
|
4181
|
+
defaultDataDir: paths.data,
|
|
4182
|
+
env: runtimeEnv
|
|
4183
|
+
});
|
|
4184
|
+
mkdirSync2(dirname2(resolvedDatabasePath.path), { recursive: true });
|
|
4185
|
+
let repository;
|
|
4186
|
+
try {
|
|
4187
|
+
repository = new AkRepository(createAkDatabase(resolvedDatabasePath.path));
|
|
4188
|
+
} catch (error) {
|
|
4189
|
+
throw new AkStorageInitError(resolvedDatabasePath.path, error);
|
|
4190
|
+
}
|
|
4191
|
+
repositoryCache = { promise: Promise.resolve(repository), repository };
|
|
4192
|
+
}
|
|
4193
|
+
return repositoryCache.repository;
|
|
4194
|
+
};
|
|
4195
|
+
return createAkCli({
|
|
4196
|
+
confirm: overrides.confirm ?? defaultConfirm,
|
|
4197
|
+
diskRuntime,
|
|
4198
|
+
env: runtimeEnv,
|
|
4199
|
+
getRepository,
|
|
4200
|
+
hackerNewsRuntime,
|
|
4201
|
+
kr36Runtime,
|
|
4202
|
+
masterKey,
|
|
4203
|
+
now: overrides.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()),
|
|
4204
|
+
paths,
|
|
4205
|
+
resolveDiskCleanupRoots: () => resolveDiskCleanupRoots(runtimeEnv),
|
|
4206
|
+
stderr: overrides.stderr ?? ((value) => process.stderr.write(value)),
|
|
4207
|
+
stdout: overrides.stdout ?? ((value) => process.stdout.write(value)),
|
|
4208
|
+
toutiaoRuntime,
|
|
4209
|
+
videoRuntime
|
|
4210
|
+
});
|
|
4211
|
+
}
|
|
4212
|
+
async function defaultConfirm(message, createReadline = createDefaultReadline) {
|
|
4213
|
+
const readline = createReadline();
|
|
4214
|
+
try {
|
|
4215
|
+
const answer = await readline.question(`${message} [y/N] `);
|
|
4216
|
+
return answer.trim().toLowerCase() === "y";
|
|
4217
|
+
} finally {
|
|
4218
|
+
readline.close();
|
|
4219
|
+
}
|
|
4220
|
+
}
|
|
4221
|
+
function isMainEntrypoint(moduleUrl, argv) {
|
|
4222
|
+
const scriptPath = argv[1];
|
|
4223
|
+
if (!scriptPath) {
|
|
4224
|
+
return false;
|
|
4225
|
+
}
|
|
4226
|
+
return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(scriptPath);
|
|
4227
|
+
}
|
|
4228
|
+
if (isMainEntrypoint(import.meta.url, process.argv)) {
|
|
4229
|
+
const cli = createCli();
|
|
4230
|
+
void cli.run(process.argv.slice(2)).then((exitCode) => {
|
|
4231
|
+
process.exitCode = exitCode;
|
|
4232
|
+
});
|
|
4233
|
+
}
|
|
4234
|
+
export {
|
|
4235
|
+
createCli,
|
|
4236
|
+
defaultConfirm,
|
|
4237
|
+
isMainEntrypoint
|
|
4238
|
+
};
|
|
4239
|
+
//# sourceMappingURL=index.js.map
|