@northmoon-labs/social-cli 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 +17 -0
- package/README.md +478 -0
- package/dist/bin/nmsocial.js +14 -0
- package/dist/cli/args.js +103 -0
- package/dist/cli/config.js +69 -0
- package/dist/cli/help.js +21 -0
- package/dist/cli/output.js +386 -0
- package/dist/cli/router.js +82 -0
- package/dist/commands/accounts.js +19 -0
- package/dist/commands/auth.js +4 -0
- package/dist/commands/auto-replies.js +88 -0
- package/dist/commands/comments.js +49 -0
- package/dist/commands/materials.js +19 -0
- package/dist/commands/posts.js +40 -0
- package/dist/commands/publish.js +69 -0
- package/dist/commands/session.js +40 -0
- package/dist/commands/stats.js +65 -0
- package/dist/commands/tasks.js +17 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands/update.js +138 -0
- package/dist/commands/webhook.js +16 -0
- package/dist/commands/youtube.js +16 -0
- package/dist/social/client.js +95 -0
- package/dist/social/types.js +47 -0
- package/dist/utils/query.js +30 -0
- package/dist/utils/stdin.js +17 -0
- package/dist/utils/validation.js +76 -0
- package/package.json +50 -0
- package/skills/nmsocial-cli/SKILL.md +155 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { intOption, value } from '../cli/args.js';
|
|
2
|
+
import { pageQuery } from '../utils/query.js';
|
|
3
|
+
import { platformOption, timeOption } from '../utils/validation.js';
|
|
4
|
+
export function postsCommand(client, action, options) {
|
|
5
|
+
if (action === 'list')
|
|
6
|
+
return client.get('/api/bs/post/page', postsQuery(options));
|
|
7
|
+
if (action === 'stats')
|
|
8
|
+
return client.get('/api/bs/post/published-stats', postsStatsQuery(options));
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function postsQuery(options) {
|
|
12
|
+
return pageQuery(options, 100, {
|
|
13
|
+
platform: platformOption(options),
|
|
14
|
+
openId: value(options, 'open-id'),
|
|
15
|
+
shareId: value(options, 'share-id'),
|
|
16
|
+
postId: value(options, 'post-id'),
|
|
17
|
+
postType: intOption(options, 'post-type', [1, 2]),
|
|
18
|
+
status: intOption(options, 'status'),
|
|
19
|
+
statusEvent: value(options, 'status-event'),
|
|
20
|
+
title: value(options, 'title'),
|
|
21
|
+
caption: value(options, 'caption'),
|
|
22
|
+
mainTaskId: value(options, 'main-task-id'),
|
|
23
|
+
createTimeBegin: timeOption(options, 'create-time-begin'),
|
|
24
|
+
createTimeEnd: timeOption(options, 'create-time-end'),
|
|
25
|
+
publishTimeBegin: timeOption(options, 'publish-time-begin'),
|
|
26
|
+
publishTimeEnd: timeOption(options, 'publish-time-end')
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function postsStatsQuery(options) {
|
|
30
|
+
return pageQuery(options, 100, {
|
|
31
|
+
platform: platformOption(options),
|
|
32
|
+
mainTaskId: value(options, 'main-task-id'),
|
|
33
|
+
openId: value(options, 'open-id'),
|
|
34
|
+
postId: value(options, 'post-id'),
|
|
35
|
+
postType: intOption(options, 'post-type', [1, 2]),
|
|
36
|
+
title: value(options, 'title'),
|
|
37
|
+
publishTimeBegin: timeOption(options, 'publish-time-begin'),
|
|
38
|
+
publishTimeEnd: timeOption(options, 'publish-time-end')
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { intOption, value, values } from '../cli/args.js';
|
|
2
|
+
import { CliError } from '../cli/output.js';
|
|
3
|
+
import { dryRunResult, pickDefined } from '../utils/query.js';
|
|
4
|
+
import { parseDateTime, validateHttpUrl } from '../utils/validation.js';
|
|
5
|
+
export function publishCommand(client, kind, options, dryRun) {
|
|
6
|
+
const body = publishBody(kind, options);
|
|
7
|
+
return dryRunResult(dryRun, body) || client.post('/api/bs/post-task/create', body);
|
|
8
|
+
}
|
|
9
|
+
function publishBody(kind, options) {
|
|
10
|
+
if (kind !== 'video' && kind !== 'image') {
|
|
11
|
+
throw new CliError('VALIDATION_INVALID_OPTION', 'publish command must be video or image');
|
|
12
|
+
}
|
|
13
|
+
const accounts = values(options, 'account');
|
|
14
|
+
const contents = values(options, 'content');
|
|
15
|
+
if (!accounts.length)
|
|
16
|
+
throw new CliError('VALIDATION_REQUIRED_OPTION', 'missing required option --account');
|
|
17
|
+
if (!contents.length)
|
|
18
|
+
throw new CliError('VALIDATION_REQUIRED_OPTION', 'missing required option --content');
|
|
19
|
+
for (const content of contents)
|
|
20
|
+
validateHttpUrl(content, '--content');
|
|
21
|
+
const at = value(options, 'at');
|
|
22
|
+
const start = intOption(options, 'start');
|
|
23
|
+
const runTime = timeOptionForPublish(options, 'run-time');
|
|
24
|
+
if (at && (start !== undefined || runTime !== undefined)) {
|
|
25
|
+
throw new CliError('VALIDATION_INVALID_OPTION', '--at cannot be used with --start or --run-time');
|
|
26
|
+
}
|
|
27
|
+
const body = {
|
|
28
|
+
taskType: kind === 'video' ? 1 : 2,
|
|
29
|
+
openIds: accounts.join(','),
|
|
30
|
+
postContents: contents.join(','),
|
|
31
|
+
videoAssignType: intOption(options, 'video-assign-type', [1, 2]),
|
|
32
|
+
title: value(options, 'title'),
|
|
33
|
+
caption: value(options, 'caption'),
|
|
34
|
+
thumbnailOffset: intOption(options, 'thumbnail-offset'),
|
|
35
|
+
customThumbnailUrl: value(options, 'custom-thumbnail-url'),
|
|
36
|
+
playlistIds: values(options, 'playlist-id').join(',') || undefined,
|
|
37
|
+
privacyStatus: privacyOption(options)
|
|
38
|
+
};
|
|
39
|
+
if (at) {
|
|
40
|
+
const ms = parseDateTime(at, '--at');
|
|
41
|
+
if (ms <= Date.now())
|
|
42
|
+
throw new CliError('VALIDATION_INVALID_OPTION', '--at must be a future time');
|
|
43
|
+
body.start = 2;
|
|
44
|
+
body.runTime = ms;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
body.start = start;
|
|
48
|
+
body.runTime = runTime;
|
|
49
|
+
}
|
|
50
|
+
return pickDefined(body);
|
|
51
|
+
}
|
|
52
|
+
function privacyOption(options) {
|
|
53
|
+
const raw = value(options, 'privacy');
|
|
54
|
+
if (raw === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (raw === 'public' || raw === '1')
|
|
57
|
+
return '1';
|
|
58
|
+
if (raw === 'private' || raw === '0')
|
|
59
|
+
return '0';
|
|
60
|
+
throw new CliError('VALIDATION_INVALID_OPTION', '--privacy must be public, private, 1, or 0');
|
|
61
|
+
}
|
|
62
|
+
function timeOptionForPublish(options, key) {
|
|
63
|
+
const raw = value(options, key);
|
|
64
|
+
if (raw === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (/^\d+$/.test(String(raw)))
|
|
67
|
+
return Number(raw);
|
|
68
|
+
return parseDateTime(raw, `--${key}`);
|
|
69
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { value } from '../cli/args.js';
|
|
2
|
+
import { configFileMode, defaultApiUrl, deleteLocalConfig, getConfigPath, readLocalConfig, validateApiUrl, writeLocalConfig } from '../cli/config.js';
|
|
3
|
+
import { CliError, maskSecret } from '../cli/output.js';
|
|
4
|
+
import { readStdin } from '../utils/stdin.js';
|
|
5
|
+
export async function login(options) {
|
|
6
|
+
const apiKeyFromOption = value(options, 'api-key');
|
|
7
|
+
const useStdin = Boolean(options['api-key-stdin']);
|
|
8
|
+
if (apiKeyFromOption && useStdin) {
|
|
9
|
+
throw new CliError('VALIDATION_INVALID_OPTION', 'use either --api-key or --api-key-stdin, not both');
|
|
10
|
+
}
|
|
11
|
+
if (!apiKeyFromOption && !useStdin) {
|
|
12
|
+
throw new CliError('VALIDATION_REQUIRED_OPTION', 'login requires --api-key or --api-key-stdin');
|
|
13
|
+
}
|
|
14
|
+
const apiKey = (apiKeyFromOption ?? await readStdin()).trim();
|
|
15
|
+
if (!apiKey)
|
|
16
|
+
throw new CliError('VALIDATION_REQUIRED_OPTION', 'api key is empty');
|
|
17
|
+
const apiUrl = value(options, 'api-url') || defaultApiUrl();
|
|
18
|
+
validateApiUrl(apiUrl);
|
|
19
|
+
await writeLocalConfig({ apiUrl, apiKey, updatedAt: new Date().toISOString() });
|
|
20
|
+
return { data: { apiUrl, apiKey: maskSecret(apiKey), configPath: getConfigPath() } };
|
|
21
|
+
}
|
|
22
|
+
export async function logout() {
|
|
23
|
+
await deleteLocalConfig();
|
|
24
|
+
return { data: { ok: true } };
|
|
25
|
+
}
|
|
26
|
+
export async function status(options) {
|
|
27
|
+
const local = await readLocalConfig();
|
|
28
|
+
const envApiKey = process.env.NMSOCIAL_API_KEY;
|
|
29
|
+
const apiUrl = value(options, 'api-url') || process.env.NMSOCIAL_API_URL || local.apiUrl || defaultApiUrl();
|
|
30
|
+
return {
|
|
31
|
+
data: {
|
|
32
|
+
apiUrl,
|
|
33
|
+
hasApiKey: Boolean(envApiKey || local.apiKey),
|
|
34
|
+
apiKeySource: envApiKey ? 'env' : local.apiKey ? 'config' : null,
|
|
35
|
+
apiKey: maskSecret(envApiKey || local.apiKey),
|
|
36
|
+
configPath: getConfigPath(),
|
|
37
|
+
configMode: await configFileMode()
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { intOption, requireOption, value, values } from '../cli/args.js';
|
|
2
|
+
import { CliError } from '../cli/output.js';
|
|
3
|
+
import { ACCOUNT_METRICS, POST_METRICS } from '../social/types.js';
|
|
4
|
+
import { pageQuery, pickQuery } from '../utils/query.js';
|
|
5
|
+
import { dateOption, platformOption, validateMetric } from '../utils/validation.js';
|
|
6
|
+
export function statsCommand(client, scope, action, options) {
|
|
7
|
+
if (scope === 'overview')
|
|
8
|
+
return client.get('/api/bs/stat/overview', statBaseQuery(options, { postId: value(options, 'post-id'), postType: intOption(options, 'post-type', [1, 2]) }));
|
|
9
|
+
if (scope === 'account' && action === 'trend') {
|
|
10
|
+
validateMetric(value(options, 'metric') || 'followers', ACCOUNT_METRICS);
|
|
11
|
+
return client.get('/api/bs/stat/account/trend', statBaseQuery(options, { metric: value(options, 'metric') }));
|
|
12
|
+
}
|
|
13
|
+
if (scope === 'account' && action === 'rank') {
|
|
14
|
+
validateMetric(value(options, 'metric') || 'followerIncrease', ACCOUNT_METRICS);
|
|
15
|
+
return client.get('/api/bs/stat/account/rank', pageQuery(options, 100, statBaseQuery(options, { metric: value(options, 'metric'), order: orderOption(options) })));
|
|
16
|
+
}
|
|
17
|
+
if (scope === 'post' && action === 'trend') {
|
|
18
|
+
validateMetric(value(options, 'metric') || 'viewCount', POST_METRICS);
|
|
19
|
+
return client.get('/api/bs/stat/post/trend', statBaseQuery(options, postFilterQuery(options, { metric: value(options, 'metric') })));
|
|
20
|
+
}
|
|
21
|
+
if (scope === 'post' && action === 'rank') {
|
|
22
|
+
validateMetric(value(options, 'metric') || 'viewIncrease', POST_METRICS);
|
|
23
|
+
return client.get('/api/bs/stat/post/rank', pageQuery(options, 100, statBaseQuery(options, postFilterQuery(options, { metric: value(options, 'metric'), order: orderOption(options) }))));
|
|
24
|
+
}
|
|
25
|
+
if (scope === 'post' && action === 'detail-trend') {
|
|
26
|
+
const metrics = values(options, 'metric');
|
|
27
|
+
for (const metric of metrics)
|
|
28
|
+
validateMetric(metric, POST_METRICS);
|
|
29
|
+
return client.get('/api/bs/stat/post/detailTrend', statBaseQuery(options, {
|
|
30
|
+
platform: platformOption(options),
|
|
31
|
+
postId: requireOption(options, 'post-id'),
|
|
32
|
+
metrics: metrics.length ? metrics.join(',') : undefined
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
if (scope === 'platform' && action === 'summary') {
|
|
36
|
+
return client.get('/api/bs/stat/platform/summary', statDateQuery(options));
|
|
37
|
+
}
|
|
38
|
+
throw new CliError('VALIDATION_INVALID_OPTION', 'unknown stats command');
|
|
39
|
+
}
|
|
40
|
+
function statBaseQuery(options, extra = {}) {
|
|
41
|
+
return pickQuery({ platform: platformOption(options), openId: value(options, 'open-id'), ...statDateQuery(options), ...extra });
|
|
42
|
+
}
|
|
43
|
+
function statDateQuery(options) {
|
|
44
|
+
const startDate = dateOption(options, 'start-date');
|
|
45
|
+
const endDate = dateOption(options, 'end-date');
|
|
46
|
+
if (startDate && endDate && startDate > endDate)
|
|
47
|
+
throw new CliError('VALIDATION_INVALID_OPTION', '--start-date cannot be after --end-date');
|
|
48
|
+
return pickQuery({ startDate, endDate });
|
|
49
|
+
}
|
|
50
|
+
function postFilterQuery(options, extra = {}) {
|
|
51
|
+
return pickQuery({
|
|
52
|
+
openId: value(options, 'open-id'),
|
|
53
|
+
mainTaskId: value(options, 'main-task-id'),
|
|
54
|
+
postType: intOption(options, 'post-type', [1, 2]),
|
|
55
|
+
...extra
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function orderOption(options) {
|
|
59
|
+
const raw = value(options, 'order');
|
|
60
|
+
if (raw === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (raw === 'asc' || raw === 'desc')
|
|
63
|
+
return raw;
|
|
64
|
+
throw new CliError('VALIDATION_INVALID_OPTION', '--order must be asc or desc');
|
|
65
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { intOption, value } from '../cli/args.js';
|
|
2
|
+
import { pageQuery } from '../utils/query.js';
|
|
3
|
+
import { platformOption, timeOption } from '../utils/validation.js';
|
|
4
|
+
export function listTasks(client, options) {
|
|
5
|
+
return client.get('/api/bs/post-task/page', pageQuery(options, 200, {
|
|
6
|
+
taskName: value(options, 'task-name'),
|
|
7
|
+
platform: platformOption(options),
|
|
8
|
+
openId: value(options, 'open-id'),
|
|
9
|
+
videoAssignType: intOption(options, 'video-assign-type', [1, 2]),
|
|
10
|
+
taskType: intOption(options, 'task-type', [1, 2]),
|
|
11
|
+
start: intOption(options, 'start'),
|
|
12
|
+
createTimeBegin: timeOption(options, 'create-time-begin'),
|
|
13
|
+
createTimeEnd: timeOption(options, 'create-time-end'),
|
|
14
|
+
runTimeBegin: timeOption(options, 'run-time-begin'),
|
|
15
|
+
runTimeEnd: timeOption(options, 'run-time-end')
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { value } from '../cli/args.js';
|
|
6
|
+
import { CliError } from '../cli/output.js';
|
|
7
|
+
const UPDATE_CHECK_TIMEOUT_MS = 15000;
|
|
8
|
+
const UPDATE_INSTALL_TIMEOUT_MS = 300000;
|
|
9
|
+
export async function updateCommand(action, options, dryRun) {
|
|
10
|
+
if (action !== undefined && action !== 'check')
|
|
11
|
+
return null;
|
|
12
|
+
const metadata = await packageMetadata();
|
|
13
|
+
const latest = await latestVersion(metadata.name);
|
|
14
|
+
const updateAvailable = isVersionGreater(latest, metadata.version);
|
|
15
|
+
const manager = managerOption(options);
|
|
16
|
+
const command = updateCommandText(manager, metadata.name);
|
|
17
|
+
if (action === 'check' || dryRun || !updateAvailable) {
|
|
18
|
+
return {
|
|
19
|
+
data: {
|
|
20
|
+
package: metadata.name,
|
|
21
|
+
current: metadata.version,
|
|
22
|
+
latest,
|
|
23
|
+
updateAvailable,
|
|
24
|
+
command: updateAvailable ? command.join(' ') : undefined
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
31
|
+
timeout: UPDATE_INSTALL_TIMEOUT_MS
|
|
32
|
+
});
|
|
33
|
+
if (result.error) {
|
|
34
|
+
throw new CliError('UPDATE_FAILED', result.error.message, {
|
|
35
|
+
payload: { command: command.join(' ') }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (result.status !== 0) {
|
|
39
|
+
throw new CliError('UPDATE_FAILED', result.stderr || result.stdout || `update exited with code ${result.status}`, {
|
|
40
|
+
status: result.status,
|
|
41
|
+
payload: {
|
|
42
|
+
command: command.join(' '),
|
|
43
|
+
stdout: result.stdout,
|
|
44
|
+
stderr: result.stderr
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
data: {
|
|
50
|
+
updated: true,
|
|
51
|
+
package: metadata.name,
|
|
52
|
+
previous: metadata.version,
|
|
53
|
+
current: latest,
|
|
54
|
+
command: command.join(' ')
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function packageMetadata() {
|
|
59
|
+
const packagePath = resolve(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
60
|
+
const raw = await readFile(packagePath, 'utf8');
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (!parsed.name || !parsed.version)
|
|
63
|
+
throw new CliError('PACKAGE_METADATA_INVALID', 'package metadata is missing name or version');
|
|
64
|
+
return { name: parsed.name, version: parsed.version };
|
|
65
|
+
}
|
|
66
|
+
async function latestVersion(packageName) {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
|
|
71
|
+
headers: { accept: 'application/json' },
|
|
72
|
+
signal: controller.signal
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok)
|
|
75
|
+
throw new CliError('UPDATE_CHECK_FAILED', `npm registry returned HTTP ${response.status}`);
|
|
76
|
+
const metadata = await response.json();
|
|
77
|
+
const latest = metadata['dist-tags']?.latest;
|
|
78
|
+
if (!latest)
|
|
79
|
+
throw new CliError('UPDATE_CHECK_FAILED', 'npm registry response does not include dist-tags.latest');
|
|
80
|
+
return latest;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
84
|
+
throw new CliError('UPDATE_CHECK_FAILED', 'npm registry request timed out');
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function managerOption(options) {
|
|
93
|
+
const raw = value(options, 'manager');
|
|
94
|
+
if (raw === undefined)
|
|
95
|
+
return detectedPackageManager();
|
|
96
|
+
if (raw === 'npm' || raw === 'pnpm' || raw === 'bun')
|
|
97
|
+
return raw;
|
|
98
|
+
throw new CliError('VALIDATION_INVALID_OPTION', '--manager must be npm, pnpm, or bun');
|
|
99
|
+
}
|
|
100
|
+
function detectedPackageManager() {
|
|
101
|
+
const userAgent = process.env.npm_config_user_agent || '';
|
|
102
|
+
if (userAgent.startsWith('pnpm/'))
|
|
103
|
+
return 'pnpm';
|
|
104
|
+
if (userAgent.startsWith('bun/'))
|
|
105
|
+
return 'bun';
|
|
106
|
+
return 'npm';
|
|
107
|
+
}
|
|
108
|
+
function updateCommandText(manager, packageName) {
|
|
109
|
+
if (manager === 'pnpm')
|
|
110
|
+
return ['pnpm', 'add', '-g', `${packageName}@latest`];
|
|
111
|
+
if (manager === 'bun')
|
|
112
|
+
return ['bun', 'add', '-g', `${packageName}@latest`];
|
|
113
|
+
return ['npm', 'install', '-g', `${packageName}@latest`];
|
|
114
|
+
}
|
|
115
|
+
export function isVersionGreater(candidate, current) {
|
|
116
|
+
const left = parseVersion(candidate);
|
|
117
|
+
const right = parseVersion(current);
|
|
118
|
+
if (!left || !right)
|
|
119
|
+
return candidate !== current;
|
|
120
|
+
for (let i = 0; i < 3; i += 1) {
|
|
121
|
+
if (left.parts[i] > right.parts[i])
|
|
122
|
+
return true;
|
|
123
|
+
if (left.parts[i] < right.parts[i])
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (!left.prerelease && right.prerelease)
|
|
127
|
+
return true;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
function parseVersion(version) {
|
|
131
|
+
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
132
|
+
if (!match)
|
|
133
|
+
return null;
|
|
134
|
+
return {
|
|
135
|
+
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
136
|
+
prerelease: match[4] || null
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { requireOption } from '../cli/args.js';
|
|
2
|
+
import { dryRunResult } from '../utils/query.js';
|
|
3
|
+
import { validateHttpUrl } from '../utils/validation.js';
|
|
4
|
+
export function webhookCommand(client, action, options, dryRun) {
|
|
5
|
+
if (action === 'set') {
|
|
6
|
+
const url = requireOption(options, 'url');
|
|
7
|
+
validateHttpUrl(url, '--url');
|
|
8
|
+
const body = { webhookUrl: url };
|
|
9
|
+
return dryRunResult(dryRun, body) || client.post('/api/bs/webhook-url/update', body);
|
|
10
|
+
}
|
|
11
|
+
if (action === 'disable') {
|
|
12
|
+
const body = { webhookUrl: '' };
|
|
13
|
+
return dryRunResult(dryRun, body) || client.post('/api/bs/webhook-url/update', body);
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { requireOption, value } from '../cli/args.js';
|
|
2
|
+
import { dryRunResult, pickDefined } from '../utils/query.js';
|
|
3
|
+
export function youtubeSeriesCommand(client, action, options, dryRun) {
|
|
4
|
+
if (action === 'list') {
|
|
5
|
+
return client.get('/api/bs/youtube/series/list', { openId: requireOption(options, 'open-id') });
|
|
6
|
+
}
|
|
7
|
+
if (action === 'create') {
|
|
8
|
+
const body = pickDefined({
|
|
9
|
+
openId: requireOption(options, 'open-id'),
|
|
10
|
+
title: requireOption(options, 'title'),
|
|
11
|
+
description: value(options, 'description')
|
|
12
|
+
});
|
|
13
|
+
return dryRunResult(dryRun, body) || client.post('/api/bs/youtube/series/create', body);
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { CliError } from '../cli/output.js';
|
|
4
|
+
const MAX_BUFFERED_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
5
|
+
export class SocialClient {
|
|
6
|
+
apiUrl;
|
|
7
|
+
apiKey;
|
|
8
|
+
timeout;
|
|
9
|
+
constructor(config, options = {}) {
|
|
10
|
+
this.apiUrl = config.apiUrl.replace(/\/+$/, '');
|
|
11
|
+
this.apiKey = config.apiKey;
|
|
12
|
+
this.timeout = options.timeout || 30000;
|
|
13
|
+
}
|
|
14
|
+
async get(path, query = {}) {
|
|
15
|
+
const url = new URL(`${this.apiUrl}${path}`);
|
|
16
|
+
for (const [key, value] of Object.entries(query)) {
|
|
17
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
18
|
+
url.searchParams.set(key, String(value));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return this.request(url, { method: 'GET' });
|
|
22
|
+
}
|
|
23
|
+
async post(path, body = {}) {
|
|
24
|
+
return this.request(new URL(`${this.apiUrl}${path}`), {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
27
|
+
body: JSON.stringify(body)
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async upload(path, filePath) {
|
|
31
|
+
const fileInfo = await stat(filePath);
|
|
32
|
+
if (fileInfo.size > MAX_BUFFERED_UPLOAD_BYTES) {
|
|
33
|
+
throw new CliError('FILE_TOO_LARGE', 'file is too large for the current uploader; please upload a file smaller than 100MB');
|
|
34
|
+
}
|
|
35
|
+
const form = new FormData();
|
|
36
|
+
const file = await import('node:fs/promises').then(({ readFile }) => readFile(filePath));
|
|
37
|
+
form.set('file', new Blob([file]), basename(filePath));
|
|
38
|
+
return this.request(new URL(`${this.apiUrl}${path}`), {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
body: form
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async request(url, init) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(url, {
|
|
48
|
+
...init,
|
|
49
|
+
headers: {
|
|
50
|
+
apiKey: this.apiKey,
|
|
51
|
+
...(init.headers || {})
|
|
52
|
+
},
|
|
53
|
+
signal: controller.signal
|
|
54
|
+
});
|
|
55
|
+
const text = await response.text();
|
|
56
|
+
let payload;
|
|
57
|
+
try {
|
|
58
|
+
payload = text ? JSON.parse(text) : { c: response.status, d: null };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new CliError('RESPONSE_INVALID_JSON', 'server returned invalid JSON', { status: response.status, payload: text });
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new CliError('NETWORK_ERROR', payload?.m || `HTTP ${response.status}`, { status: response.status, payload });
|
|
65
|
+
}
|
|
66
|
+
if (payload.c !== 200) {
|
|
67
|
+
throw new CliError('SOCIAL_API_ERROR', payload.m || 'Social API returned an error', {
|
|
68
|
+
status: payload.c,
|
|
69
|
+
payload,
|
|
70
|
+
meta: providerMeta(payload)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { data: payload.d, provider: payload };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
77
|
+
throw new CliError('NETWORK_TIMEOUT', 'request timed out');
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function providerMeta(payload) {
|
|
87
|
+
if (!payload)
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
providerCode: payload.c,
|
|
91
|
+
providerMessage: payload.m,
|
|
92
|
+
providerTimestamp: payload.t,
|
|
93
|
+
requestId: payload.lb
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const PLATFORM_MAP = {
|
|
2
|
+
tiktok: 1,
|
|
3
|
+
youtube: 2,
|
|
4
|
+
instagram: 3,
|
|
5
|
+
facebook: 4
|
|
6
|
+
};
|
|
7
|
+
export const ACCOUNT_METRICS = [
|
|
8
|
+
'followers',
|
|
9
|
+
'followerIncrease',
|
|
10
|
+
'following',
|
|
11
|
+
'totalLikes',
|
|
12
|
+
'totalLikesIncrease',
|
|
13
|
+
'videosCount',
|
|
14
|
+
'videosIncrease'
|
|
15
|
+
];
|
|
16
|
+
export const POST_METRICS = [
|
|
17
|
+
'viewCount',
|
|
18
|
+
'viewIncrease',
|
|
19
|
+
'likeCount',
|
|
20
|
+
'commentCount',
|
|
21
|
+
'shareCount',
|
|
22
|
+
'favoriteCount',
|
|
23
|
+
'reachCount',
|
|
24
|
+
'impressionCount',
|
|
25
|
+
'totalInteractions'
|
|
26
|
+
];
|
|
27
|
+
export const MATERIAL_EXTENSIONS = new Set([
|
|
28
|
+
'mp4',
|
|
29
|
+
'mov',
|
|
30
|
+
'avi',
|
|
31
|
+
'mkv',
|
|
32
|
+
'webm',
|
|
33
|
+
'flv',
|
|
34
|
+
'wmv',
|
|
35
|
+
'm4v',
|
|
36
|
+
'3gp',
|
|
37
|
+
'jpg',
|
|
38
|
+
'jpeg',
|
|
39
|
+
'png',
|
|
40
|
+
'gif',
|
|
41
|
+
'bmp',
|
|
42
|
+
'webp',
|
|
43
|
+
'heic',
|
|
44
|
+
'heif',
|
|
45
|
+
'tif',
|
|
46
|
+
'tiff'
|
|
47
|
+
]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { intOption } from '../cli/args.js';
|
|
2
|
+
import { CliError } from '../cli/output.js';
|
|
3
|
+
export function pageQuery(options, maxPageSize, extra = {}) {
|
|
4
|
+
const pageNum = intOption(options, 'page') ?? 1;
|
|
5
|
+
const pageSize = intOption(options, 'page-size') ?? 20;
|
|
6
|
+
if (pageNum < 1)
|
|
7
|
+
throw new CliError('VALIDATION_INVALID_OPTION', '--page must be greater than 0');
|
|
8
|
+
if (pageSize < 1 || pageSize > maxPageSize)
|
|
9
|
+
throw new CliError('VALIDATION_INVALID_OPTION', `--page-size must be between 1 and ${maxPageSize}`);
|
|
10
|
+
return pickQuery({ pageNum, pageSize, ...extra });
|
|
11
|
+
}
|
|
12
|
+
export function pickDefined(input) {
|
|
13
|
+
const output = {};
|
|
14
|
+
for (const [key, val] of Object.entries(input)) {
|
|
15
|
+
if (val !== undefined && val !== null && val !== '')
|
|
16
|
+
output[key] = val;
|
|
17
|
+
}
|
|
18
|
+
return output;
|
|
19
|
+
}
|
|
20
|
+
export function pickQuery(input) {
|
|
21
|
+
const output = {};
|
|
22
|
+
for (const [key, val] of Object.entries(input)) {
|
|
23
|
+
if (val !== undefined && val !== null && val !== '')
|
|
24
|
+
output[key] = val;
|
|
25
|
+
}
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
export function dryRunResult(dryRun, body) {
|
|
29
|
+
return dryRun ? { data: { dryRun: true, body } } : null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stderr as output } from 'node:process';
|
|
3
|
+
export async function readStdin() {
|
|
4
|
+
if (process.stdin.isTTY) {
|
|
5
|
+
const rl = createInterface({ input, output });
|
|
6
|
+
try {
|
|
7
|
+
return await rl.question('API Key: ');
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
rl.close();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const chunks = [];
|
|
14
|
+
for await (const chunk of process.stdin)
|
|
15
|
+
chunks.push(chunk);
|
|
16
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
17
|
+
}
|