@sovovs/bycli 2.1.60 → 2.1.62
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 +366 -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/clis/zhihu/answer-waiting-draft.js +127 -0
- package/clis/zhihu/draft-shared.js +147 -0
- package/clis/zhihu/waiting.js +100 -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",
|
|
@@ -34190,6 +34388,130 @@
|
|
|
34190
34388
|
"sourceFile": "zhihu/answer-detail.js",
|
|
34191
34389
|
"navigateBefore": "https://www.zhihu.com"
|
|
34192
34390
|
},
|
|
34391
|
+
{
|
|
34392
|
+
"site": "zhihu",
|
|
34393
|
+
"name": "answer-waiting-draft",
|
|
34394
|
+
"description": "保存知乎回答草稿(新回答或已有回答的编辑草稿,不发布)",
|
|
34395
|
+
"access": "write",
|
|
34396
|
+
"domain": "www.zhihu.com",
|
|
34397
|
+
"strategy": "cookie",
|
|
34398
|
+
"browser": "conditional",
|
|
34399
|
+
"args": [
|
|
34400
|
+
{
|
|
34401
|
+
"name": "target",
|
|
34402
|
+
"type": "str",
|
|
34403
|
+
"required": true,
|
|
34404
|
+
"positional": true,
|
|
34405
|
+
"help": "Question ID, question/own-answer URL, question:<id>, or answer:<questionId>:<answerId>"
|
|
34406
|
+
},
|
|
34407
|
+
{
|
|
34408
|
+
"name": "text",
|
|
34409
|
+
"type": "str",
|
|
34410
|
+
"required": false,
|
|
34411
|
+
"positional": true,
|
|
34412
|
+
"help": "Plain-text answer body; use either text or --file"
|
|
34413
|
+
},
|
|
34414
|
+
{
|
|
34415
|
+
"name": "file",
|
|
34416
|
+
"type": "str",
|
|
34417
|
+
"required": false,
|
|
34418
|
+
"help": "UTF-8 text file containing the answer body"
|
|
34419
|
+
},
|
|
34420
|
+
{
|
|
34421
|
+
"name": "mode",
|
|
34422
|
+
"type": "str",
|
|
34423
|
+
"default": "auto",
|
|
34424
|
+
"required": false,
|
|
34425
|
+
"help": "auto detects new/edit; new refuses an existing answer; edit requires your existing answer",
|
|
34426
|
+
"choices": [
|
|
34427
|
+
"auto",
|
|
34428
|
+
"new",
|
|
34429
|
+
"edit"
|
|
34430
|
+
]
|
|
34431
|
+
},
|
|
34432
|
+
{
|
|
34433
|
+
"name": "existing",
|
|
34434
|
+
"type": "str",
|
|
34435
|
+
"default": "error",
|
|
34436
|
+
"required": false,
|
|
34437
|
+
"help": "Policy when the editor already has content; identical text is left unchanged",
|
|
34438
|
+
"choices": [
|
|
34439
|
+
"error",
|
|
34440
|
+
"replace",
|
|
34441
|
+
"append",
|
|
34442
|
+
"prepend"
|
|
34443
|
+
]
|
|
34444
|
+
},
|
|
34445
|
+
{
|
|
34446
|
+
"name": "separator",
|
|
34447
|
+
"type": "str",
|
|
34448
|
+
"default": "\n\n",
|
|
34449
|
+
"required": false,
|
|
34450
|
+
"help": "Literal separator for append/prepend (default: a blank line)"
|
|
34451
|
+
},
|
|
34452
|
+
{
|
|
34453
|
+
"name": "prefix",
|
|
34454
|
+
"type": "str",
|
|
34455
|
+
"required": false,
|
|
34456
|
+
"help": "Literal text before the supplied body (include any desired newline)"
|
|
34457
|
+
},
|
|
34458
|
+
{
|
|
34459
|
+
"name": "suffix",
|
|
34460
|
+
"type": "str",
|
|
34461
|
+
"required": false,
|
|
34462
|
+
"help": "Literal text after the supplied body (include any desired newline)"
|
|
34463
|
+
},
|
|
34464
|
+
{
|
|
34465
|
+
"name": "expected-title",
|
|
34466
|
+
"type": "str",
|
|
34467
|
+
"required": false,
|
|
34468
|
+
"help": "Require an exact question title before editing"
|
|
34469
|
+
},
|
|
34470
|
+
{
|
|
34471
|
+
"name": "timeout",
|
|
34472
|
+
"type": "int",
|
|
34473
|
+
"default": 30,
|
|
34474
|
+
"required": false,
|
|
34475
|
+
"help": "Total editor/save verification wait budget in seconds (1–120)"
|
|
34476
|
+
},
|
|
34477
|
+
{
|
|
34478
|
+
"name": "close-editor",
|
|
34479
|
+
"type": "boolean",
|
|
34480
|
+
"default": true,
|
|
34481
|
+
"required": false,
|
|
34482
|
+
"help": "Close editor after verified save; false leaves it open (combine with --keep-tab true)"
|
|
34483
|
+
},
|
|
34484
|
+
{
|
|
34485
|
+
"name": "dry-run",
|
|
34486
|
+
"type": "boolean",
|
|
34487
|
+
"required": false,
|
|
34488
|
+
"help": "Preview resolved input locally without opening or modifying the editor"
|
|
34489
|
+
},
|
|
34490
|
+
{
|
|
34491
|
+
"name": "execute",
|
|
34492
|
+
"type": "boolean",
|
|
34493
|
+
"required": false,
|
|
34494
|
+
"help": "Actually save the draft; never publishes an answer"
|
|
34495
|
+
}
|
|
34496
|
+
],
|
|
34497
|
+
"columns": [
|
|
34498
|
+
"status",
|
|
34499
|
+
"outcome",
|
|
34500
|
+
"question_id",
|
|
34501
|
+
"answer_id",
|
|
34502
|
+
"mode",
|
|
34503
|
+
"url",
|
|
34504
|
+
"characters",
|
|
34505
|
+
"verified",
|
|
34506
|
+
"published",
|
|
34507
|
+
"editor_open",
|
|
34508
|
+
"message"
|
|
34509
|
+
],
|
|
34510
|
+
"type": "js",
|
|
34511
|
+
"modulePath": "zhihu/answer-waiting-draft.js",
|
|
34512
|
+
"sourceFile": "zhihu/answer-waiting-draft.js",
|
|
34513
|
+
"navigateBefore": false
|
|
34514
|
+
},
|
|
34193
34515
|
{
|
|
34194
34516
|
"site": "zhihu",
|
|
34195
34517
|
"name": "collection",
|
|
@@ -34631,6 +34953,50 @@
|
|
|
34631
34953
|
"sourceFile": "zhihu/search.js",
|
|
34632
34954
|
"navigateBefore": "https://www.zhihu.com"
|
|
34633
34955
|
},
|
|
34956
|
+
{
|
|
34957
|
+
"site": "zhihu",
|
|
34958
|
+
"name": "waiting",
|
|
34959
|
+
"description": "知乎写回答(等你来答)问题列表",
|
|
34960
|
+
"access": "read",
|
|
34961
|
+
"domain": "www.zhihu.com",
|
|
34962
|
+
"strategy": "cookie",
|
|
34963
|
+
"browser": true,
|
|
34964
|
+
"args": [
|
|
34965
|
+
{
|
|
34966
|
+
"name": "type",
|
|
34967
|
+
"type": "str",
|
|
34968
|
+
"default": "recommend",
|
|
34969
|
+
"required": false,
|
|
34970
|
+
"help": "Question category: recommend 为你推荐, invited 邀请回答, latest 最新问题, hot 人气问题",
|
|
34971
|
+
"choices": [
|
|
34972
|
+
"recommend",
|
|
34973
|
+
"invited",
|
|
34974
|
+
"latest",
|
|
34975
|
+
"hot"
|
|
34976
|
+
]
|
|
34977
|
+
},
|
|
34978
|
+
{
|
|
34979
|
+
"name": "limit",
|
|
34980
|
+
"type": "int",
|
|
34981
|
+
"default": 20,
|
|
34982
|
+
"required": false,
|
|
34983
|
+
"help": "Maximum questions to return (1–1000; stops when the page stops loading)"
|
|
34984
|
+
}
|
|
34985
|
+
],
|
|
34986
|
+
"columns": [
|
|
34987
|
+
"rank",
|
|
34988
|
+
"id",
|
|
34989
|
+
"title",
|
|
34990
|
+
"views",
|
|
34991
|
+
"answers",
|
|
34992
|
+
"followers",
|
|
34993
|
+
"url"
|
|
34994
|
+
],
|
|
34995
|
+
"type": "js",
|
|
34996
|
+
"modulePath": "zhihu/waiting.js",
|
|
34997
|
+
"sourceFile": "zhihu/waiting.js",
|
|
34998
|
+
"navigateBefore": "https://www.zhihu.com"
|
|
34999
|
+
},
|
|
34634
35000
|
{
|
|
34635
35001
|
"site": "zlibrary",
|
|
34636
35002
|
"name": "info",
|
|
@@ -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 });
|