@sovovs/bycli 2.1.60 → 2.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.zh-CN.md +13 -0
- package/cli-manifest.json +198 -0
- package/clis/iwhalecloud/browser-download.js +83 -0
- package/clis/iwhalecloud/client.js +97 -0
- package/clis/iwhalecloud/download.js +77 -0
- package/clis/iwhalecloud/list.js +59 -0
- package/clis/iwhalecloud/read.js +25 -0
- package/package.json +1 -1
- package/scripts/recorder.sh +0 -186
package/README.zh-CN.md
CHANGED
|
@@ -50,6 +50,19 @@ bycli 12306 stations 北京 # 公开命令,无需登录
|
|
|
50
50
|
bycli juejin search bycli # 掘金搜索
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
### 浩鲸邮箱
|
|
54
|
+
|
|
55
|
+
在 Chrome 中登录 `mail.iwhalecloud.com` 并连接 Browser Bridge 后:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bycli iwhalecloud list --sort date --order desc --limit 50 -f yaml
|
|
59
|
+
bycli iwhalecloud read "<emailId>" -f yaml
|
|
60
|
+
bycli iwhalecloud download "<emailId>" --output ./mail-downloads -f yaml
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
支持默认顺序和 9 个字段的服务端排序、自动分页、邮件正文与附件下载。
|
|
64
|
+
详细参数见[浩鲸邮箱适配器文档](./docs/adapters/browser/iwhalecloud.md)。
|
|
65
|
+
|
|
53
66
|
### 搜索适配器
|
|
54
67
|
|
|
55
68
|
当前可用的搜索命令包括:
|
package/cli-manifest.json
CHANGED
|
@@ -14674,6 +14674,204 @@
|
|
|
14674
14674
|
"sourceFile": "instagram/user.js",
|
|
14675
14675
|
"navigateBefore": "https://www.instagram.com"
|
|
14676
14676
|
},
|
|
14677
|
+
{
|
|
14678
|
+
"site": "iwhalecloud",
|
|
14679
|
+
"name": "download",
|
|
14680
|
+
"description": "下载浩鲸邮件附件到本地目录,同名文件自动编号",
|
|
14681
|
+
"access": "read",
|
|
14682
|
+
"example": "bycli iwhalecloud download \"<emailId>\" --attachment all --output ./mail-downloads -f yaml",
|
|
14683
|
+
"domain": "mail.iwhalecloud.com",
|
|
14684
|
+
"strategy": "cookie",
|
|
14685
|
+
"browser": true,
|
|
14686
|
+
"args": [
|
|
14687
|
+
{
|
|
14688
|
+
"name": "emailId",
|
|
14689
|
+
"type": "str",
|
|
14690
|
+
"required": true,
|
|
14691
|
+
"positional": true,
|
|
14692
|
+
"help": "list 返回的 emailId(请加引号)"
|
|
14693
|
+
},
|
|
14694
|
+
{
|
|
14695
|
+
"name": "attachment",
|
|
14696
|
+
"type": "str",
|
|
14697
|
+
"default": "all",
|
|
14698
|
+
"required": false,
|
|
14699
|
+
"help": "all(不含内嵌图片)、附件 ID 或 read 中的序号(从 1 开始)"
|
|
14700
|
+
},
|
|
14701
|
+
{
|
|
14702
|
+
"name": "output",
|
|
14703
|
+
"type": "str",
|
|
14704
|
+
"default": ".",
|
|
14705
|
+
"required": false,
|
|
14706
|
+
"help": "本地输出目录"
|
|
14707
|
+
}
|
|
14708
|
+
],
|
|
14709
|
+
"columns": [
|
|
14710
|
+
"emailId",
|
|
14711
|
+
"attachmentId",
|
|
14712
|
+
"name",
|
|
14713
|
+
"path",
|
|
14714
|
+
"size",
|
|
14715
|
+
"contentType"
|
|
14716
|
+
],
|
|
14717
|
+
"type": "js",
|
|
14718
|
+
"modulePath": "iwhalecloud/download.js",
|
|
14719
|
+
"sourceFile": "iwhalecloud/download.js",
|
|
14720
|
+
"navigateBefore": false,
|
|
14721
|
+
"siteSession": "persistent"
|
|
14722
|
+
},
|
|
14723
|
+
{
|
|
14724
|
+
"site": "iwhalecloud",
|
|
14725
|
+
"name": "list",
|
|
14726
|
+
"description": "浩鲸邮箱邮件列表:服务端分页、默认排序及 9 种字段排序",
|
|
14727
|
+
"access": "read",
|
|
14728
|
+
"example": "bycli iwhalecloud list --sort date --order desc --limit 50 -f yaml",
|
|
14729
|
+
"domain": "mail.iwhalecloud.com",
|
|
14730
|
+
"strategy": "cookie",
|
|
14731
|
+
"browser": true,
|
|
14732
|
+
"args": [
|
|
14733
|
+
{
|
|
14734
|
+
"name": "folder",
|
|
14735
|
+
"type": "str",
|
|
14736
|
+
"default": "inbox",
|
|
14737
|
+
"required": false,
|
|
14738
|
+
"help": "邮件文件夹",
|
|
14739
|
+
"choices": [
|
|
14740
|
+
"inbox",
|
|
14741
|
+
"sent",
|
|
14742
|
+
"drafts",
|
|
14743
|
+
"trash",
|
|
14744
|
+
"spam"
|
|
14745
|
+
]
|
|
14746
|
+
},
|
|
14747
|
+
{
|
|
14748
|
+
"name": "folder-id",
|
|
14749
|
+
"type": "str",
|
|
14750
|
+
"required": false,
|
|
14751
|
+
"help": "自定义文件夹的 OWA FolderId,优先于 --folder"
|
|
14752
|
+
},
|
|
14753
|
+
{
|
|
14754
|
+
"name": "limit",
|
|
14755
|
+
"type": "int",
|
|
14756
|
+
"default": 20,
|
|
14757
|
+
"required": false,
|
|
14758
|
+
"help": "返回数量,1–10000;自动分页"
|
|
14759
|
+
},
|
|
14760
|
+
{
|
|
14761
|
+
"name": "offset",
|
|
14762
|
+
"type": "int",
|
|
14763
|
+
"default": 0,
|
|
14764
|
+
"required": false,
|
|
14765
|
+
"help": "服务端起始偏移,0 表示第一封"
|
|
14766
|
+
},
|
|
14767
|
+
{
|
|
14768
|
+
"name": "sort",
|
|
14769
|
+
"type": "str",
|
|
14770
|
+
"default": "default",
|
|
14771
|
+
"required": false,
|
|
14772
|
+
"help": "default 保留服务端默认顺序",
|
|
14773
|
+
"choices": [
|
|
14774
|
+
"default",
|
|
14775
|
+
"date",
|
|
14776
|
+
"from",
|
|
14777
|
+
"to",
|
|
14778
|
+
"subject",
|
|
14779
|
+
"attachments",
|
|
14780
|
+
"importance",
|
|
14781
|
+
"size",
|
|
14782
|
+
"sent",
|
|
14783
|
+
"created"
|
|
14784
|
+
]
|
|
14785
|
+
},
|
|
14786
|
+
{
|
|
14787
|
+
"name": "order",
|
|
14788
|
+
"type": "str",
|
|
14789
|
+
"default": "default",
|
|
14790
|
+
"required": false,
|
|
14791
|
+
"help": "默认:姓名/主题升序,其余降序;显式方向须指定 --sort",
|
|
14792
|
+
"choices": [
|
|
14793
|
+
"default",
|
|
14794
|
+
"asc",
|
|
14795
|
+
"desc"
|
|
14796
|
+
]
|
|
14797
|
+
}
|
|
14798
|
+
],
|
|
14799
|
+
"columns": [
|
|
14800
|
+
"emailId",
|
|
14801
|
+
"subject",
|
|
14802
|
+
"fromName",
|
|
14803
|
+
"fromEmail",
|
|
14804
|
+
"toDisplay",
|
|
14805
|
+
"date",
|
|
14806
|
+
"times",
|
|
14807
|
+
"unread",
|
|
14808
|
+
"hasAttachments",
|
|
14809
|
+
"importance",
|
|
14810
|
+
"size",
|
|
14811
|
+
"url"
|
|
14812
|
+
],
|
|
14813
|
+
"type": "js",
|
|
14814
|
+
"modulePath": "iwhalecloud/list.js",
|
|
14815
|
+
"sourceFile": "iwhalecloud/list.js",
|
|
14816
|
+
"navigateBefore": false,
|
|
14817
|
+
"siteSession": "persistent"
|
|
14818
|
+
},
|
|
14819
|
+
{
|
|
14820
|
+
"site": "iwhalecloud",
|
|
14821
|
+
"name": "read",
|
|
14822
|
+
"description": "读取浩鲸邮件完整正文、收件人和附件列表(不标记已读)",
|
|
14823
|
+
"access": "read",
|
|
14824
|
+
"example": "bycli iwhalecloud read \"<emailId>\" -f yaml",
|
|
14825
|
+
"domain": "mail.iwhalecloud.com",
|
|
14826
|
+
"strategy": "cookie",
|
|
14827
|
+
"browser": true,
|
|
14828
|
+
"args": [
|
|
14829
|
+
{
|
|
14830
|
+
"name": "emailId",
|
|
14831
|
+
"type": "str",
|
|
14832
|
+
"required": true,
|
|
14833
|
+
"positional": true,
|
|
14834
|
+
"help": "list 返回的 emailId(请加引号)"
|
|
14835
|
+
},
|
|
14836
|
+
{
|
|
14837
|
+
"name": "body-type",
|
|
14838
|
+
"type": "str",
|
|
14839
|
+
"default": "text",
|
|
14840
|
+
"required": false,
|
|
14841
|
+
"help": "正文格式",
|
|
14842
|
+
"choices": [
|
|
14843
|
+
"text",
|
|
14844
|
+
"html"
|
|
14845
|
+
]
|
|
14846
|
+
}
|
|
14847
|
+
],
|
|
14848
|
+
"columns": [
|
|
14849
|
+
"emailId",
|
|
14850
|
+
"subject",
|
|
14851
|
+
"fromName",
|
|
14852
|
+
"fromEmail",
|
|
14853
|
+
"toDisplay",
|
|
14854
|
+
"date",
|
|
14855
|
+
"times",
|
|
14856
|
+
"unread",
|
|
14857
|
+
"hasAttachments",
|
|
14858
|
+
"importance",
|
|
14859
|
+
"size",
|
|
14860
|
+
"url",
|
|
14861
|
+
"to",
|
|
14862
|
+
"cc",
|
|
14863
|
+
"bcc",
|
|
14864
|
+
"recipientCounts",
|
|
14865
|
+
"bodyType",
|
|
14866
|
+
"body",
|
|
14867
|
+
"attachments"
|
|
14868
|
+
],
|
|
14869
|
+
"type": "js",
|
|
14870
|
+
"modulePath": "iwhalecloud/read.js",
|
|
14871
|
+
"sourceFile": "iwhalecloud/read.js",
|
|
14872
|
+
"navigateBefore": false,
|
|
14873
|
+
"siteSession": "persistent"
|
|
14874
|
+
},
|
|
14677
14875
|
{
|
|
14678
14876
|
"site": "jd",
|
|
14679
14877
|
"name": "add-cart",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { CommandExecutionError, TimeoutError } from '@sovovs/bycli/errors';
|
|
3
|
+
|
|
4
|
+
// Stream through Chrome's authenticated connection and certificate trust store.
|
|
5
|
+
// Only a 192 KiB chunk crosses the Browser Bridge at once; no cookie export.
|
|
6
|
+
export async function browserDownloadResponse(page, attachmentId) {
|
|
7
|
+
const key = `__bycliAttachment_${randomUUID().replaceAll('-', '')}`;
|
|
8
|
+
const start = await page.evaluate(async (id, stateKey) => {
|
|
9
|
+
if (location.origin !== 'https://mail.iwhalecloud.com') return { status: 401 };
|
|
10
|
+
const cookie = document.cookie.split(';').map(x => x.trim()).find(x => x.startsWith('X-OWA-CANARY='));
|
|
11
|
+
if (!cookie) return { status: 401 };
|
|
12
|
+
if (window[stateKey]) return window[stateKey].start;
|
|
13
|
+
const state = { pending: new Uint8Array(0), sequence: -1 };
|
|
14
|
+
window[stateKey] = state;
|
|
15
|
+
state.start = (async () => {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`/owa/service.svc/s/GetFileAttachment?id=${encodeURIComponent(id)}`, {
|
|
18
|
+
credentials: 'same-origin', redirect: 'manual', signal: AbortSignal.timeout(120000),
|
|
19
|
+
headers: { 'X-OWA-CANARY': decodeURIComponent(cookie.slice('X-OWA-CANARY='.length)) },
|
|
20
|
+
});
|
|
21
|
+
// Cross-origin/manual redirects are opaque in browsers (status 0).
|
|
22
|
+
if (response.status !== 200 || !response.body) {
|
|
23
|
+
await response.body?.cancel();
|
|
24
|
+
delete window[stateKey];
|
|
25
|
+
return { status: response.status || 401 };
|
|
26
|
+
}
|
|
27
|
+
state.reader = response.body.getReader();
|
|
28
|
+
return { status: response.status, contentType: response.headers.get('content-type') ?? 'application/octet-stream' };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
delete window[stateKey];
|
|
31
|
+
return { failed: true, timeout: error.name === 'TimeoutError' || error.name === 'AbortError' };
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
return state.start;
|
|
35
|
+
}, attachmentId, key);
|
|
36
|
+
if (start?.timeout) throw new TimeoutError('OWA attachment request', 120);
|
|
37
|
+
if (!start || start.failed) throw new CommandExecutionError('Could not open the OWA attachment download');
|
|
38
|
+
if (start.status !== 200) return new Response(null, { status: start.status });
|
|
39
|
+
const cleanup = async () => {
|
|
40
|
+
await page.evaluate(async stateKey => {
|
|
41
|
+
const state = window[stateKey];
|
|
42
|
+
delete window[stateKey];
|
|
43
|
+
if (state?.reader) await state.reader.cancel().catch(() => {});
|
|
44
|
+
}, key);
|
|
45
|
+
};
|
|
46
|
+
let sequence = 0;
|
|
47
|
+
return new Response(new ReadableStream({
|
|
48
|
+
async pull(controller) {
|
|
49
|
+
try {
|
|
50
|
+
const chunk = await page.evaluate(async (stateKey, requestedSequence) => {
|
|
51
|
+
const state = window[stateKey];
|
|
52
|
+
if (!state) return { missing: true };
|
|
53
|
+
// Bridge retries must return the same in-flight or completed chunk.
|
|
54
|
+
if (state.sequence === requestedSequence) return state.chunk;
|
|
55
|
+
if (requestedSequence !== state.sequence + 1) return { missing: true };
|
|
56
|
+
state.sequence = requestedSequence;
|
|
57
|
+
state.chunk = (async () => {
|
|
58
|
+
if (!state.pending.length) {
|
|
59
|
+
const result = await state.reader.read();
|
|
60
|
+
if (result.done) return { done: true };
|
|
61
|
+
state.pending = result.value;
|
|
62
|
+
}
|
|
63
|
+
const bytes = state.pending.subarray(0, 196608);
|
|
64
|
+
state.pending = state.pending.subarray(bytes.length);
|
|
65
|
+
let binary = '';
|
|
66
|
+
for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
|
67
|
+
return { base64: btoa(binary) };
|
|
68
|
+
})();
|
|
69
|
+
return state.chunk;
|
|
70
|
+
}, key, sequence++);
|
|
71
|
+
if (chunk?.done) {
|
|
72
|
+
await cleanup();
|
|
73
|
+
controller.close();
|
|
74
|
+
} else if (typeof chunk?.base64 === 'string') controller.enqueue(Buffer.from(chunk.base64, 'base64'));
|
|
75
|
+
else throw new CommandExecutionError('OWA attachment transfer was interrupted; retry download');
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await cleanup().catch(() => {});
|
|
78
|
+
controller.error(error);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
cancel: cleanup,
|
|
82
|
+
}, { highWaterMark: 0 }), { headers: { 'content-type': start.contentType } });
|
|
83
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Strategy } from '@sovovs/bycli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError, TimeoutError } from '@sovovs/bycli/errors';
|
|
3
|
+
|
|
4
|
+
export const HOST = 'mail.iwhalecloud.com';
|
|
5
|
+
export const BASE = `https://${HOST}`;
|
|
6
|
+
export const metadata = { site: 'iwhalecloud', domain: HOST, access: 'read', strategy: Strategy.COOKIE,
|
|
7
|
+
browser: true, navigateBefore: false, siteSession: 'persistent' };
|
|
8
|
+
export const idArg = { name: 'emailId', positional: true, required: true, help: 'list 返回的 emailId(请加引号)' };
|
|
9
|
+
export const SORT_FIELDS = Object.freeze({ date: 'DateTimeReceived', from: 'From', to: 'DisplayTo',
|
|
10
|
+
subject: 'Subject', attachments: 'HasAttachments', importance: 'Importance', size: 'Size',
|
|
11
|
+
sent: 'DateTimeSent', created: 'DateTimeCreated' });
|
|
12
|
+
const FOLDERS = Object.freeze({ inbox: 'inbox', sent: 'sentitems', drafts: 'drafts', trash: 'deleteditems', spam: 'junkemail' });
|
|
13
|
+
export function integer(value, name, min, max) {
|
|
14
|
+
const n = Number(value);
|
|
15
|
+
if (!Number.isSafeInteger(n) || n < min || n > max) throw new ArgumentError(`${name} must be an integer between ${min} and ${max}`);
|
|
16
|
+
return n;
|
|
17
|
+
}
|
|
18
|
+
export function requiredId(value) {
|
|
19
|
+
const id = String(value ?? '').trim();
|
|
20
|
+
if (!id) throw new ArgumentError('emailId is required; use an emailId returned by list');
|
|
21
|
+
return id;
|
|
22
|
+
}
|
|
23
|
+
export function folderId(args) {
|
|
24
|
+
if (args['folder-id']) return { __type: 'FolderId:#Exchange', Id: String(args['folder-id']) };
|
|
25
|
+
const key = String(args.folder ?? 'inbox');
|
|
26
|
+
if (!Object.hasOwn(FOLDERS, key)) throw new ArgumentError(`Unknown folder; choose ${Object.keys(FOLDERS).join(', ')} or --folder-id`);
|
|
27
|
+
return { __type: 'DistinguishedFolderId:#Exchange', Id: FOLDERS[key] };
|
|
28
|
+
}
|
|
29
|
+
export async function connect(page) {
|
|
30
|
+
await page.goto(`${BASE}/owa/`, { waitUntil: 'load' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Keep the session token inside the browser; never include it in CLI output or errors.
|
|
34
|
+
export async function request(page, action, body) {
|
|
35
|
+
const result = await page.evaluate(async (actionName, requestBody) => {
|
|
36
|
+
if (location.origin !== 'https://mail.iwhalecloud.com' || /\/auth\//i.test(location.pathname)) return { auth: true };
|
|
37
|
+
const cookie = document.cookie.split(';').map(x => x.trim()).find(x => x.startsWith('X-OWA-CANARY='));
|
|
38
|
+
if (!cookie) return { auth: true };
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(`/owa/service.svc?action=${actionName}`, {
|
|
41
|
+
method: 'POST', credentials: 'same-origin', redirect: 'error', signal: AbortSignal.timeout(30000),
|
|
42
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', Action: actionName,
|
|
43
|
+
'X-OWA-CANARY': decodeURIComponent(cookie.slice('X-OWA-CANARY='.length)) },
|
|
44
|
+
body: JSON.stringify({ __type: `${actionName}JsonRequest:#Exchange`,
|
|
45
|
+
Header: { __type: 'JsonRequestHeaders:#Exchange', RequestServerVersion: 'Exchange2013' }, Body: requestBody }),
|
|
46
|
+
});
|
|
47
|
+
if ([401, 403, 440].includes(response.status)) return { auth: true };
|
|
48
|
+
if (!response.ok) return { status: response.status };
|
|
49
|
+
const text = await response.text();
|
|
50
|
+
if (/^\s*</.test(text)) return { auth: true };
|
|
51
|
+
try { return { data: JSON.parse(text) }; } catch { return { malformed: true }; }
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { timeout: error.name === 'TimeoutError' || error.name === 'AbortError', failed: true };
|
|
54
|
+
}
|
|
55
|
+
}, action, body);
|
|
56
|
+
if (result?.auth) throw new AuthRequiredError(HOST);
|
|
57
|
+
if (result?.timeout) throw new TimeoutError(`iwhalecloud ${action}`, 30);
|
|
58
|
+
if (!result?.data) throw new CommandExecutionError(`iwhalecloud ${action} request failed${result?.status ? ` (HTTP ${result.status})` : ''}`);
|
|
59
|
+
const messages = result.data.Body?.ResponseMessages?.Items;
|
|
60
|
+
if (!Array.isArray(messages) || !messages.length) throw new CommandExecutionError(`iwhalecloud ${action}: invalid response envelope`);
|
|
61
|
+
for (const message of messages) {
|
|
62
|
+
if (message.ResponseClass !== 'Success' || message.ResponseCode !== 'NoError') {
|
|
63
|
+
const code = String(message.ResponseCode ?? 'UnknownError');
|
|
64
|
+
if (['ErrorInvalidClientAccessToken', 'ErrorInvalidUserSid'].includes(code)) throw new AuthRequiredError(HOST);
|
|
65
|
+
if (code === 'ErrorItemNotFound') throw new EmptyResultError(`iwhalecloud ${action}`, 'The mail may have been moved or deleted; refresh list');
|
|
66
|
+
throw new CommandExecutionError(`iwhalecloud ${action}: ${code}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return messages[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mailRow(item) {
|
|
73
|
+
if (!item?.ItemId?.Id) throw new CommandExecutionError('OWA returned an item without an emailId');
|
|
74
|
+
const sender = item.From?.Mailbox ?? item.Sender?.Mailbox ?? {};
|
|
75
|
+
return { emailId: item.ItemId.Id, subject: item.Subject ?? '', fromName: sender.Name ?? '', fromEmail: sender.EmailAddress ?? '',
|
|
76
|
+
toDisplay: item.DisplayTo ?? '', date: item.DateTimeReceived ?? '', times: { sentAt: item.DateTimeSent ?? '', createdAt: item.DateTimeCreated ?? '' },
|
|
77
|
+
unread: typeof item.IsRead === 'boolean' ? !item.IsRead : null, hasAttachments: item.HasAttachments ?? false,
|
|
78
|
+
importance: item.Importance ?? '', size: item.Size ?? 0,
|
|
79
|
+
url: `${BASE}/owa/#path=/mail/item/${encodeURIComponent(item.ItemId.Id)}` };
|
|
80
|
+
}
|
|
81
|
+
export const listColumns = ['emailId', 'subject', 'fromName', 'fromEmail', 'toDisplay', 'date', 'times', 'unread', 'hasAttachments', 'importance', 'size', 'url'];
|
|
82
|
+
|
|
83
|
+
export async function getMail(page, emailId, bodyType = 'text') {
|
|
84
|
+
const message = await request(page, 'GetItem', { __type: 'GetItemRequest:#Exchange', ShapeName: 'ItemPart',
|
|
85
|
+
ItemShape: { __type: 'ItemResponseShape:#Exchange', BaseShape: 'AllProperties', BodyType: bodyType === 'html' ? 'HTML' : 'Text',
|
|
86
|
+
MaximumBodySize: 0, MaximumRecipientsToReturn: 10000 }, ItemIds: [{ __type: 'ItemId:#Exchange', Id: emailId }] });
|
|
87
|
+
if (!Array.isArray(message.Items) || message.Items.length !== 1 || message.Items[0]?.ItemId?.Id !== emailId) {
|
|
88
|
+
throw new CommandExecutionError('OWA GetItem did not return the requested mail');
|
|
89
|
+
}
|
|
90
|
+
return message.Items[0];
|
|
91
|
+
}
|
|
92
|
+
export function attachmentsOf(item) {
|
|
93
|
+
if (item.Attachments != null && !Array.isArray(item.Attachments)) throw new CommandExecutionError('OWA returned invalid attachments');
|
|
94
|
+
return (item.Attachments ?? []).map((a, index) => ({ index: index + 1, attachmentId: a.AttachmentId?.Id ?? '',
|
|
95
|
+
name: a.Name ?? '', contentType: a.ContentType ?? '', size: a.Size ?? 0, inline: a.IsInline ?? false,
|
|
96
|
+
kind: a.__type?.split(':')[0] ?? 'Unknown' }));
|
|
97
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { cli } from '@sovovs/bycli/registry';
|
|
6
|
+
import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError, EmptyResultError, TimeoutError } from '@sovovs/bycli/errors';
|
|
7
|
+
import { metadata, idArg, requiredId, connect, getMail, attachmentsOf, BASE, HOST } from './client.js';
|
|
8
|
+
import { browserDownloadResponse } from './browser-download.js';
|
|
9
|
+
|
|
10
|
+
function safeName(name) {
|
|
11
|
+
const leaf = String(name).replaceAll('\\', '/').split('/').pop() ?? '';
|
|
12
|
+
const clean = leaf.replace(/[<>:"/\\|?*\x00-\x1f\x7f]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
|
|
13
|
+
// Keep UTF-8 filenames below filesystem byte limits, preserving the extension.
|
|
14
|
+
const ext = path.extname(clean).slice(0, 20);
|
|
15
|
+
let stem = clean.slice(0, clean.length - ext.length) || 'attachment';
|
|
16
|
+
while (Buffer.byteLength(stem + ext) > 220) stem = Array.from(stem).slice(0, -1).join('');
|
|
17
|
+
return stem + ext;
|
|
18
|
+
}
|
|
19
|
+
async function reserveFile(directory, name) {
|
|
20
|
+
const ext = path.extname(name); const stem = name.slice(0, name.length - ext.length);
|
|
21
|
+
for (let index = 0; index < 10000; index++) {
|
|
22
|
+
const file = path.join(directory, index ? `${stem} (${index})${ext}` : name);
|
|
23
|
+
try { return { file, handle: await fs.open(file, 'wx', 0o600) }; }
|
|
24
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
25
|
+
}
|
|
26
|
+
throw new CommandExecutionError('Too many files with the same attachment name');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runDownload(page, args, deps = {}) {
|
|
30
|
+
const id = requiredId(args.emailId);
|
|
31
|
+
const output = path.resolve(String(args.output ?? '.'));
|
|
32
|
+
await connect(page);
|
|
33
|
+
const item = await getMail(page, id);
|
|
34
|
+
const attachments = attachmentsOf(item);
|
|
35
|
+
const selector = String(args.attachment ?? 'all');
|
|
36
|
+
const selected = selector === 'all' ? attachments.filter(a => !a.inline)
|
|
37
|
+
: attachments.filter(a => a.attachmentId === selector || String(a.index) === selector);
|
|
38
|
+
if (!selected.length) throw new EmptyResultError('iwhalecloud download', 'No matching attachments; use read to inspect attachment IDs and indexes');
|
|
39
|
+
if (selected.length > 1 && selector !== 'all') throw new ArgumentError('Attachment selector is ambiguous; use the attachment ID');
|
|
40
|
+
if (selected.some(a => !a.attachmentId || a.kind !== 'FileAttachment')) {
|
|
41
|
+
throw new CommandExecutionError('Only file attachments can be downloaded; select a FileAttachment ID shown by read');
|
|
42
|
+
}
|
|
43
|
+
try { await fs.mkdir(output, { recursive: true }); }
|
|
44
|
+
catch { throw new CommandExecutionError('Cannot create attachment output directory', 'Choose a writable directory with --output'); }
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (const attachment of selected) {
|
|
47
|
+
let reserved;
|
|
48
|
+
let response;
|
|
49
|
+
try {
|
|
50
|
+
response = deps.fetch
|
|
51
|
+
? await deps.fetch(`${BASE}/owa/service.svc/s/GetFileAttachment?id=${encodeURIComponent(attachment.attachmentId)}`)
|
|
52
|
+
: await browserDownloadResponse(page, attachment.attachmentId);
|
|
53
|
+
if ([301, 302, 303, 307, 308, 401, 403, 440].includes(response.status)) throw new AuthRequiredError(HOST);
|
|
54
|
+
if (!response.ok || !response.body) throw new CommandExecutionError(`Attachment download failed (HTTP ${response.status})`);
|
|
55
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
56
|
+
if (contentType.includes('text/html') && !attachment.contentType.includes('text/html')) throw new AuthRequiredError(HOST);
|
|
57
|
+
reserved = await reserveFile(output, safeName(attachment.name));
|
|
58
|
+
await pipeline(Readable.fromWeb(response.body), reserved.handle.createWriteStream());
|
|
59
|
+
const stat = await fs.stat(reserved.file);
|
|
60
|
+
rows.push({ emailId: id, attachmentId: attachment.attachmentId, name: attachment.name,
|
|
61
|
+
path: reserved.file, size: stat.size, contentType: attachment.contentType });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (reserved) { await reserved.handle.close().catch(() => {}); await fs.unlink(reserved.file).catch(() => {}); }
|
|
64
|
+
if (response?.body && !response.body.locked) await response.body.cancel().catch(() => {});
|
|
65
|
+
const saved = rows.length ? `; ${rows.length} earlier attachment(s) were saved in ${output}` : '';
|
|
66
|
+
if (error instanceof CliError) { error.message += saved; throw error; }
|
|
67
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') throw new TimeoutError(`Attachment download${saved}`, 120);
|
|
68
|
+
throw new CommandExecutionError(`Attachment download failed${saved}`, 'Check connectivity and retry; existing files will not be overwritten');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
cli({ ...metadata, name: 'download', description: '下载浩鲸邮件附件到本地目录,同名文件自动编号',
|
|
74
|
+
example: 'bycli iwhalecloud download "<emailId>" --attachment all --output ./mail-downloads -f yaml',
|
|
75
|
+
args: [idArg, { name: 'attachment', default: 'all', help: 'all(不含内嵌图片)、附件 ID 或 read 中的序号(从 1 开始)' },
|
|
76
|
+
{ name: 'output', default: '.', help: '本地输出目录' }],
|
|
77
|
+
columns: ['emailId', 'attachmentId', 'name', 'path', 'size', 'contentType'], func: runDownload });
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { cli } from '@sovovs/bycli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@sovovs/bycli/errors';
|
|
3
|
+
import { metadata, SORT_FIELDS, integer, folderId, connect, request, mailRow, listColumns } from './client.js';
|
|
4
|
+
|
|
5
|
+
export async function runList(page, args) {
|
|
6
|
+
const limit = integer(args.limit ?? 20, 'limit', 1, 10000);
|
|
7
|
+
let offset = integer(args.offset ?? 0, 'offset', 0, 2147483647);
|
|
8
|
+
const sort = String(args.sort ?? 'default');
|
|
9
|
+
const order = String(args.order ?? 'default');
|
|
10
|
+
if (sort !== 'default' && !Object.hasOwn(SORT_FIELDS, sort)) throw new ArgumentError(`Unknown sort: ${sort}`);
|
|
11
|
+
if (!['default', 'asc', 'desc'].includes(order)) throw new ArgumentError('order must be default, asc or desc');
|
|
12
|
+
if (sort === 'default' && order !== 'default') throw new ArgumentError('Specify --sort to use --order; default preserves server ordering');
|
|
13
|
+
const folder = folderId(args);
|
|
14
|
+
await connect(page);
|
|
15
|
+
const rows = [];
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
while (rows.length < limit) {
|
|
18
|
+
// Split the validated total into server pages; do not cap the caller's total.
|
|
19
|
+
const remaining = limit - rows.length;
|
|
20
|
+
const batchSize = remaining > 100 ? 100 : remaining;
|
|
21
|
+
const body = { __type: 'FindItemRequest:#Exchange',
|
|
22
|
+
ItemShape: { __type: 'ItemResponseShape:#Exchange', BaseShape: 'AllProperties' },
|
|
23
|
+
ParentFolderIds: [folder], Traversal: 'Shallow',
|
|
24
|
+
Paging: { __type: 'IndexedPageView:#Exchange', BasePoint: 'Beginning', Offset: offset, MaxEntriesReturned: batchSize } };
|
|
25
|
+
if (sort !== 'default') {
|
|
26
|
+
const ascending = order === 'asc' || (order === 'default' && ['from', 'to', 'subject'].includes(sort));
|
|
27
|
+
body.SortOrder = [{ __type: 'SortResults:#Exchange', Order: ascending ? 'Ascending' : 'Descending',
|
|
28
|
+
Path: { __type: 'PropertyUri:#Exchange', FieldURI: SORT_FIELDS[sort] } }];
|
|
29
|
+
if (sort !== 'date') body.SortOrder.push({ __type: 'SortResults:#Exchange', Order: 'Descending',
|
|
30
|
+
Path: { __type: 'PropertyUri:#Exchange', FieldURI: 'DateTimeReceived' } });
|
|
31
|
+
}
|
|
32
|
+
const message = await request(page, 'FindItem', body);
|
|
33
|
+
const root = message.RootFolder;
|
|
34
|
+
if (!root || !Array.isArray(root.Items)) throw new CommandExecutionError('OWA FindItem returned invalid items');
|
|
35
|
+
for (const item of root.Items) {
|
|
36
|
+
const row = mailRow(item);
|
|
37
|
+
if (seen.has(row.emailId)) throw new CommandExecutionError('Mailbox changed while paging; repeat list to avoid incomplete results');
|
|
38
|
+
seen.add(row.emailId); rows.push(row);
|
|
39
|
+
if (rows.length === limit) break;
|
|
40
|
+
}
|
|
41
|
+
if (root.IncludesLastItemInRange === true || rows.length >= limit) break;
|
|
42
|
+
const next = root.IndexedPagingOffset;
|
|
43
|
+
if (!root.Items.length || !Number.isSafeInteger(next) || next <= offset) throw new CommandExecutionError('OWA pagination did not advance');
|
|
44
|
+
offset = next;
|
|
45
|
+
}
|
|
46
|
+
if (!rows.length) throw new EmptyResultError('iwhalecloud list', 'No mail at this folder/offset');
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cli({ ...metadata, name: 'list', description: '浩鲸邮箱邮件列表:服务端分页、默认排序及 9 种字段排序',
|
|
51
|
+
example: 'bycli iwhalecloud list --sort date --order desc --limit 50 -f yaml',
|
|
52
|
+
args: [
|
|
53
|
+
{ name: 'folder', default: 'inbox', choices: ['inbox', 'sent', 'drafts', 'trash', 'spam'], help: '邮件文件夹' },
|
|
54
|
+
{ name: 'folder-id', help: '自定义文件夹的 OWA FolderId,优先于 --folder' },
|
|
55
|
+
{ name: 'limit', type: 'int', default: 20, help: '返回数量,1–10000;自动分页' },
|
|
56
|
+
{ name: 'offset', type: 'int', default: 0, help: '服务端起始偏移,0 表示第一封' },
|
|
57
|
+
{ name: 'sort', default: 'default', choices: ['default', ...Object.keys(SORT_FIELDS)], help: 'default 保留服务端默认顺序' },
|
|
58
|
+
{ name: 'order', default: 'default', choices: ['default', 'asc', 'desc'], help: '默认:姓名/主题升序,其余降序;显式方向须指定 --sort' },
|
|
59
|
+
], columns: listColumns, func: runList });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cli } from '@sovovs/bycli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
|
|
3
|
+
import { metadata, idArg, requiredId, connect, getMail, mailRow, listColumns, attachmentsOf } from './client.js';
|
|
4
|
+
|
|
5
|
+
export async function runRead(page, args) {
|
|
6
|
+
const id = requiredId(args.emailId);
|
|
7
|
+
const bodyType = String(args['body-type'] ?? 'text');
|
|
8
|
+
if (!['text', 'html'].includes(bodyType)) throw new ArgumentError('body-type must be text or html');
|
|
9
|
+
await connect(page);
|
|
10
|
+
const item = await getMail(page, id, bodyType);
|
|
11
|
+
if (typeof item.Body?.Value !== 'string' || item.Body.IsTruncated === true) {
|
|
12
|
+
throw new CommandExecutionError('OWA did not return a complete mail body', 'Open the mail in Outlook to check content restrictions');
|
|
13
|
+
}
|
|
14
|
+
const recipients = entries => (entries ?? []).map(entry => {
|
|
15
|
+
const mailbox = entry.Mailbox ?? entry;
|
|
16
|
+
return { name: mailbox.Name ?? '', email: mailbox.EmailAddress ?? '' };
|
|
17
|
+
});
|
|
18
|
+
return [{ ...mailRow(item), to: recipients(item.ToRecipients), cc: recipients(item.CcRecipients), bcc: recipients(item.BccRecipients),
|
|
19
|
+
recipientCounts: item.RecipientCounts ?? {}, bodyType: item.Body?.BodyType ?? bodyType, body: item.Body?.Value ?? '',
|
|
20
|
+
attachments: attachmentsOf(item) }];
|
|
21
|
+
}
|
|
22
|
+
cli({ ...metadata, name: 'read', description: '读取浩鲸邮件完整正文、收件人和附件列表(不标记已读)',
|
|
23
|
+
example: 'bycli iwhalecloud read "<emailId>" -f yaml',
|
|
24
|
+
args: [idArg, { name: 'body-type', default: 'text', choices: ['text', 'html'], help: '正文格式' }],
|
|
25
|
+
columns: [...listColumns, 'to', 'cc', 'bcc', 'recipientCounts', 'bodyType', 'body', 'attachments'], func: runRead });
|
package/package.json
CHANGED
package/scripts/recorder.sh
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# 录制三端管理脚本
|
|
3
|
-
# daemon : 浏览器底座(19825),由 bycli 管理;扩展连这口
|
|
4
|
-
# be : Recorder Local Service(19826),同源托管真实工作台 UI(dashboard/dist)
|
|
5
|
-
# web : Umi dev server(8000),mock 模式,仅前端开发用(无真实录制)
|
|
6
|
-
#
|
|
7
|
-
# 用法:
|
|
8
|
-
# scripts/recorder.sh start [daemon|be|all] # 默认=真实录制环境(daemon+be,自动停 mock)
|
|
9
|
-
# scripts/recorder.sh start --mock # 仅此参数才起 mock 前端(web :8000,假数据)
|
|
10
|
-
# scripts/recorder.sh stop [daemon|be|web|all]
|
|
11
|
-
# scripts/recorder.sh restart [daemon|be|vnc|all] # restart all=daemon+be(不含 mock);改了 .env/dist 后用;vnc=删旧容器换新镜像
|
|
12
|
-
# scripts/recorder.sh status # 看三端
|
|
13
|
-
# scripts/recorder.sh build [core|be|ui|ext|all] # 重建 dist(改源码后;all 含扩展,需手动重载)
|
|
14
|
-
#
|
|
15
|
-
# 真实录制(带 LLM)启动:scripts/recorder.sh start → 打开 http://127.0.0.1:19826/workbench
|
|
16
|
-
#
|
|
17
|
-
# embedded_iframe 录制模式(P2,公开站页内嵌入;**本机默认开**):起 be 默认带 flag——
|
|
18
|
-
# EMBEDDED=0 scripts/recorder.sh start # 显式关闭页内嵌入模式
|
|
19
|
-
# IFRAME_FRAME_SRC=https://juejin.cn scripts/recorder.sh restart be # 只放该 origin(hardened)
|
|
20
|
-
# inline env 经 `env VAR=…` 注入,优先级高于 --env-file(.env 不覆盖已存在的 process env)。
|
|
21
|
-
#
|
|
22
|
-
# vnc 录制模式(容器内 Chromium+扩展+daemon,noVNC 投画面;**本机默认开**,需 podman + 镜像):
|
|
23
|
-
# scripts/recorder.sh build vnc # 构建容器镜像 bycli-verify:latest(需先 build ext + npm run build)
|
|
24
|
-
# scripts/recorder.sh restart vnc # 重启镜像:删旧容器(bycli-vnc),be 下次 bind 用新镜像重建(改镜像后用)
|
|
25
|
-
# VNC=0 scripts/recorder.sh restart be # 显式关闭 vnc 模式
|
|
26
|
-
# 选 VNC 模式后 be 自动 podman run 起容器、前端 iframe 投 noVNC 画面;录的数据走容器网关→be→合成链。
|
|
27
|
-
set -uo pipefail
|
|
28
|
-
|
|
29
|
-
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
30
|
-
RUN="$ROOT/.recorder-run"; mkdir -p "$RUN"
|
|
31
|
-
DAEMON_PORT=19825; BE_PORT=19826; WEB_PORT=8000
|
|
32
|
-
|
|
33
|
-
port_pid() { lsof -ti "tcp:$1" -sTCP:LISTEN 2>/dev/null | head -1; }
|
|
34
|
-
alive() { [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null; }
|
|
35
|
-
|
|
36
|
-
# ───────────────────────── daemon(交给 bycli 管) ─────────────────────────
|
|
37
|
-
need_bycli() { command -v bycli >/dev/null || { echo "✗ bycli 不在 PATH(在仓库根 npm link)"; return 1; }; }
|
|
38
|
-
daemon_start() { need_bycli || return 1; bycli daemon start 2>&1 | tail -1; }
|
|
39
|
-
daemon_stop() { need_bycli && { bycli daemon stop 2>&1 | tail -1; } || true; }
|
|
40
|
-
daemon_restart() { need_bycli || return 1; bycli daemon restart 2>&1 | tail -1; }
|
|
41
|
-
daemon_status() { local p; p="$(port_pid $DAEMON_PORT)"; [ -n "$p" ] && echo "● daemon RUNNING :$DAEMON_PORT pid=$p" || echo "○ daemon stopped :$DAEMON_PORT"; }
|
|
42
|
-
|
|
43
|
-
# ───────────────────────── vnc(podman 容器,be 自动编排) ────────────────
|
|
44
|
-
# 容器名与 vncOrchestrator.ts 保持一致(BYCLI_VNC_CONTAINER 覆盖,默认 bycli-vnc)。
|
|
45
|
-
VNC_CONTAINER="${BYCLI_VNC_CONTAINER:-bycli-vnc}"
|
|
46
|
-
VNC_IMAGE="${BYCLI_VNC_IMAGE:-bycli-verify:latest}"
|
|
47
|
-
need_podman() { command -v podman >/dev/null || { echo "✗ podman 不在 PATH(VNC 模式需 podman)"; return 1; }; }
|
|
48
|
-
# 重启镜像:删旧容器,be 下次 bind 时用当前镜像重建(build vnc 换镜像后调用)。
|
|
49
|
-
vnc_restart() {
|
|
50
|
-
need_podman || return 1
|
|
51
|
-
[ -n "$(podman images -q "$VNC_IMAGE" 2>/dev/null)" ] || { echo "✗ 镜像 $VNC_IMAGE 不存在 → scripts/recorder.sh build vnc"; return 1; }
|
|
52
|
-
if [ -n "$(podman ps -aq -f "name=^${VNC_CONTAINER}$" 2>/dev/null)" ]; then
|
|
53
|
-
podman rm -f "$VNC_CONTAINER" >/dev/null 2>&1 && echo "✓ 已删旧容器 $VNC_CONTAINER(be 下次 bind 用新镜像 $VNC_IMAGE 重建)"
|
|
54
|
-
else
|
|
55
|
-
echo "○ 容器 $VNC_CONTAINER 未运行(be 下次 bind 会用新镜像 $VNC_IMAGE 新建)"
|
|
56
|
-
fi
|
|
57
|
-
}
|
|
58
|
-
vnc_stop() {
|
|
59
|
-
need_podman || return 1
|
|
60
|
-
if [ -n "$(podman ps -aq -f "name=^${VNC_CONTAINER}$" 2>/dev/null)" ]; then
|
|
61
|
-
podman rm -f "$VNC_CONTAINER" >/dev/null 2>&1 && echo "✓ vnc 容器 $VNC_CONTAINER 已删"
|
|
62
|
-
else echo "○ vnc 容器未运行"; fi
|
|
63
|
-
}
|
|
64
|
-
vnc_status() {
|
|
65
|
-
command -v podman >/dev/null || { echo "○ vnc (podman 未装)"; return; }
|
|
66
|
-
local st; st="$(podman inspect "$VNC_CONTAINER" --format '{{.State.Status}}' 2>/dev/null)"
|
|
67
|
-
[ -n "$st" ] && echo "● vnc $st container=$VNC_CONTAINER image=$VNC_IMAGE" || echo "○ vnc no container ($VNC_CONTAINER)"
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
# ───────────────────────── be(node 进程,PID 文件) ──────────────────────
|
|
71
|
-
be_start() {
|
|
72
|
-
[ -f "$ROOT/dashboard-be/dist/server.js" ] || { echo "✗ be 未构建 → scripts/recorder.sh build be"; return 1; }
|
|
73
|
-
[ -f "$ROOT/dashboard-be/.env" ] || { echo "✗ 缺 dashboard-be/.env → cp dashboard-be/.env.example dashboard-be/.env 并填值"; return 1; }
|
|
74
|
-
[ -d "$ROOT/dashboard/dist" ] || echo "⚠ dashboard/dist 不存在,be 将 API-only(无 UI)→ scripts/recorder.sh build ui"
|
|
75
|
-
if [ -n "$(port_pid $BE_PORT)" ]; then echo "● be 已在 :$BE_PORT(先 stop/restart)"; return 0; fi
|
|
76
|
-
# embedded_iframe 模式(P2):EMBEDDED=1 → 注入 flag 开 frame-src + 前端模式选项。
|
|
77
|
-
# 经 `env VAR=…` 内联注入,优先级高于 --env-file(.env 不覆盖已存在的 process env)。
|
|
78
|
-
# 三种录制模式默认全开(本机录制工作台);显式 EMBEDDED=0 / VNC=0 可单独关。
|
|
79
|
-
# 注:只在本机 be 启动注入,不动 recorder-core 的 fail-closed 发布默认(全局 CSP 安全底线不变)。
|
|
80
|
-
local envv=()
|
|
81
|
-
if [ "${EMBEDDED:-1}" = 1 ]; then
|
|
82
|
-
envv+=(FEATURE_EMBEDDED_IFRAME_RECORDING=1)
|
|
83
|
-
[ -n "${IFRAME_FRAME_SRC:-}" ] && envv+=("RECORDER_IFRAME_FRAME_SRC=$IFRAME_FRAME_SRC")
|
|
84
|
-
echo " ⚙ embedded_iframe 模式 ON${IFRAME_FRAME_SRC:+(frame-src=$IFRAME_FRAME_SRC)}"
|
|
85
|
-
fi
|
|
86
|
-
if [ "${VNC:-1}" = 1 ]; then
|
|
87
|
-
envv+=(FEATURE_VNC_RECORDING=1)
|
|
88
|
-
echo " ⚙ vnc 容器模式 ON(be 自动 podman run bycli-verify:latest;需先 build vnc)"
|
|
89
|
-
fi
|
|
90
|
-
( cd "$ROOT" && nohup env ${envv[@]+"${envv[@]}"} node --env-file=dashboard-be/.env dashboard-be/dist/server.js >"$RUN/be.log" 2>&1 & echo $! >"$RUN/be.pid" )
|
|
91
|
-
sleep 1; be_status; echo " 日志: $RUN/be.log"
|
|
92
|
-
}
|
|
93
|
-
be_stop() {
|
|
94
|
-
# 端口权威:pid 文件 + 实际占 19826 的进程都杀(防重复实例残留)
|
|
95
|
-
local stopped=0 pf; pf="$(cat "$RUN/be.pid" 2>/dev/null)"
|
|
96
|
-
for pid in "$pf" "$(port_pid $BE_PORT)"; do
|
|
97
|
-
if alive "$pid"; then kill "$pid" 2>/dev/null; echo "✓ be 已停(pid=$pid)"; stopped=1; fi
|
|
98
|
-
done
|
|
99
|
-
[ "$stopped" = 0 ] && echo "○ be 未运行"
|
|
100
|
-
rm -f "$RUN/be.pid"
|
|
101
|
-
}
|
|
102
|
-
be_restart() { be_stop; sleep 1; be_start; }
|
|
103
|
-
be_status() { local p; p="$(port_pid $BE_PORT)"; [ -n "$p" ] && echo "● be RUNNING http://127.0.0.1:$BE_PORT/workbench pid=$p" || echo "○ be stopped :$BE_PORT"; }
|
|
104
|
-
|
|
105
|
-
# ───────────────────────── web(Umi dev,mock) ───────────────────────────
|
|
106
|
-
web_start() {
|
|
107
|
-
if [ -n "$(port_pid $WEB_PORT)" ]; then echo "● web 已在 :$WEB_PORT"; return 0; fi
|
|
108
|
-
( cd "$ROOT/dashboard" && nohup npm run dev >"$RUN/web.log" 2>&1 & echo $! >"$RUN/web.pid" )
|
|
109
|
-
echo "✓ web 启动中(mock,http://127.0.0.1:$WEB_PORT) 日志: $RUN/web.log"
|
|
110
|
-
}
|
|
111
|
-
web_stop() {
|
|
112
|
-
local pid; pid="$(cat "$RUN/web.pid" 2>/dev/null)"; [ -z "$pid" ] && pid="$(port_pid $WEB_PORT)"
|
|
113
|
-
if alive "$pid"; then pkill -P "$pid" 2>/dev/null; kill "$pid" 2>/dev/null; echo "✓ web 已停"; else echo "○ web 未运行"; fi
|
|
114
|
-
rm -f "$RUN/web.pid"
|
|
115
|
-
}
|
|
116
|
-
web_restart() { web_stop; sleep 1; web_start; }
|
|
117
|
-
web_status() { local p; p="$(port_pid $WEB_PORT)"; [ -n "$p" ] && echo "● web RUNNING http://127.0.0.1:$WEB_PORT (mock) pid=$p" || echo "○ web stopped :$WEB_PORT (mock dev)"; }
|
|
118
|
-
|
|
119
|
-
# ───────────────────────── build ────────────────────────────────────────
|
|
120
|
-
do_build() {
|
|
121
|
-
case "${1:-all}" in
|
|
122
|
-
core) npm --prefix "$ROOT/packages/recorder-core" run build ;;
|
|
123
|
-
be) npm --prefix "$ROOT/dashboard-be" run build ;;
|
|
124
|
-
ui) ( cd "$ROOT/dashboard" && npm run build ) ;;
|
|
125
|
-
ext) ( cd "$ROOT/extension" && npm run build ) ;;
|
|
126
|
-
vnc) # VNC 录制模式容器镜像(Chromium+扩展+daemon+x11vnc+websockify+网关);be 起容器时复用 bycli-verify:latest。
|
|
127
|
-
command -v podman >/dev/null || { echo "✗ podman 不在 PATH(VNC 模式需 podman)"; return 1; }
|
|
128
|
-
[ -f "$ROOT/extension/dist/background.js" ] || { echo "✗ 扩展未构建 → scripts/recorder.sh build ext"; return 1; }
|
|
129
|
-
[ -f "$ROOT/dist/src/daemon.js" ] || { echo "✗ dist 未构建 → npm run build"; return 1; }
|
|
130
|
-
echo "▶ 构建 VNC 容器镜像 bycli-verify:latest(首次装 chromium 较慢)…"
|
|
131
|
-
( cd "$ROOT" && podman build -f podman-verify/Dockerfile -t bycli-verify:latest . ) ;;
|
|
132
|
-
all) npm --prefix "$ROOT/packages/recorder-core" run build \
|
|
133
|
-
&& npm --prefix "$ROOT/dashboard-be" run build \
|
|
134
|
-
&& ( cd "$ROOT/dashboard" && npm run build ) \
|
|
135
|
-
&& ( cd "$ROOT/extension" && npm run build ) \
|
|
136
|
-
&& echo "↻ 扩展已重建 → chrome://extensions 重载 byCLI(确认版本号刷新)" ;;
|
|
137
|
-
*) echo "build: core|be|ui|ext|vnc|all"; return 1 ;;
|
|
138
|
-
esac
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
# ───────────────────────── dispatch ─────────────────────────────────────
|
|
142
|
-
action="${1:-}"; shift || true
|
|
143
|
-
case "$action" in
|
|
144
|
-
start)
|
|
145
|
-
# mock 仅在显式 --mock 时启动;其余参数视作服务名
|
|
146
|
-
mock=0; svcs=()
|
|
147
|
-
for a in "$@"; do if [ "$a" = "--mock" ]; then mock=1; else svcs+=("$a"); fi; done
|
|
148
|
-
if [ "$mock" = 1 ]; then
|
|
149
|
-
echo "▶ 启动【mock 前端】(web :$WEB_PORT,假数据,无真实录制)"
|
|
150
|
-
web_start
|
|
151
|
-
elif [ ${#svcs[@]} -eq 0 ]; then
|
|
152
|
-
# 默认 = 真实录制环境:停掉 mock web(防 :8000 误测)→ 起 daemon + be
|
|
153
|
-
echo "▶ 启动【真实录制环境】(daemon + be);mock web 若在跑将被停掉以免混淆"
|
|
154
|
-
[ -n "$(port_pid $WEB_PORT)" ] && web_stop
|
|
155
|
-
daemon_start; be_start
|
|
156
|
-
echo; echo "✅ 真实录制 → http://127.0.0.1:$BE_PORT/workbench(mock 需 start --mock)"
|
|
157
|
-
else
|
|
158
|
-
[ "${svcs[0]}" = "all" ] && svcs=(daemon be)
|
|
159
|
-
for t in "${svcs[@]}"; do
|
|
160
|
-
case "$t" in
|
|
161
|
-
daemon|be) "${t}_start" ;;
|
|
162
|
-
web) echo "✗ web 是 mock,请用:scripts/recorder.sh start --mock" ;;
|
|
163
|
-
*) echo "未知服务: $t(daemon|be|all,mock 用 --mock)" ;;
|
|
164
|
-
esac
|
|
165
|
-
done
|
|
166
|
-
fi ;;
|
|
167
|
-
stop|restart)
|
|
168
|
-
# all 语义:restart 只起真实环境(daemon+be,不复活 mock,与 start 默认一致);
|
|
169
|
-
# stop 则全停(含 mock web,teardown)。mock 启停一律显式 web/--mock。
|
|
170
|
-
if [ $# -eq 0 ]; then targets=(daemon be)
|
|
171
|
-
elif [ "${1:-}" = all ]; then
|
|
172
|
-
[ "$action" = stop ] && targets=(daemon be web) || targets=(daemon be)
|
|
173
|
-
else targets=("$@"); fi
|
|
174
|
-
[ "$action" = stop ] && targets=($(printf '%s\n' "${targets[@]}" | tail -r 2>/dev/null || printf '%s\n' "${targets[@]}"))
|
|
175
|
-
for t in "${targets[@]}"; do
|
|
176
|
-
case "$t" in daemon|be|web|vnc) "${t}_${action}" ;; *) echo "未知服务: $t(daemon|be|web|vnc|all)";; esac
|
|
177
|
-
done ;;
|
|
178
|
-
status)
|
|
179
|
-
daemon_status; be_status; web_status; vnc_status ;;
|
|
180
|
-
build)
|
|
181
|
-
do_build "${1:-all}" ;;
|
|
182
|
-
""|-h|--help|help)
|
|
183
|
-
awk 'NR>1 && /^#/{sub(/^# ?/,"");print;next} NR>1{exit}' "${BASH_SOURCE[0]}" ;;
|
|
184
|
-
*)
|
|
185
|
-
echo "未知命令: $action(start|stop|restart|status|build)"; exit 1 ;;
|
|
186
|
-
esac
|