@sovovs/bycli 2.1.59 → 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 +34 -0
- package/cli-manifest.json +201 -3
- package/clis/ima/ax.js +41 -10
- package/clis/ima/download.js +57 -10
- package/clis/ima/native-client.js +55 -4
- 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/dist/src/browser/cdp.js +8 -0
- package/dist/src/browser/page.js +12 -0
- package/extension/README.md +26 -0
- package/extension/dist/background.js +3380 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +41 -0
- package/extension/popup.html +146 -0
- package/extension/popup.js +77 -0
- package/package.json +9 -2
- package/scripts/postinstall.js +5 -2
package/README.zh-CN.md
CHANGED
|
@@ -22,6 +22,27 @@ bycli <site> <command> --help # 查看命令的参数和选项
|
|
|
22
22
|
bycli <site> <command> -f yaml # 结构化输出(适合 agent)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
### Linux 下载 IMA 知识库 PDF
|
|
26
|
+
|
|
27
|
+
Linux 不需要安装 IMA 桌面客户端。安装 bycli 后,把 npm 包内的
|
|
28
|
+
`extension/` 目录加载到 Chrome/Chromium 的开发者模式,启动 daemon,并在浏览器中登录
|
|
29
|
+
`ima.qq.com`。Browser Bridge 会复用浏览器会话获取文件地址:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bycli daemon start
|
|
33
|
+
bycli ima download "知识库名称" "文件名.pdf" --output ./downloads -f json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
全局安装时扩展目录可用 `npm root -g` 定位:
|
|
37
|
+
`$(npm root -g)/@sovovs/bycli/extension`。如果浏览器会话需要跨机器提供,可在受控浏览器端
|
|
38
|
+
导出完整 `x-ima-cookie` 后设置 `BYCLI_IMA_COOKIE`,下载命令会切换到 Node 直连模式,Linux
|
|
39
|
+
主机无需安装 IMA App 或运行 Chrome:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export BYCLI_IMA_COOKIE='完整的 x-ima-cookie'
|
|
43
|
+
bycli ima download "知识库名称" "文件名.pdf" --output ./downloads -f json
|
|
44
|
+
```
|
|
45
|
+
|
|
25
46
|
示例:
|
|
26
47
|
|
|
27
48
|
```bash
|
|
@@ -29,6 +50,19 @@ bycli 12306 stations 北京 # 公开命令,无需登录
|
|
|
29
50
|
bycli juejin search bycli # 掘金搜索
|
|
30
51
|
```
|
|
31
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
|
+
|
|
32
66
|
### 搜索适配器
|
|
33
67
|
|
|
34
68
|
当前可用的搜索命令包括:
|
package/cli-manifest.json
CHANGED
|
@@ -13549,11 +13549,11 @@
|
|
|
13549
13549
|
{
|
|
13550
13550
|
"site": "ima",
|
|
13551
13551
|
"name": "download",
|
|
13552
|
-
"description": "下载 ima
|
|
13552
|
+
"description": "下载 ima 知识库中的完整原始文件(Linux 无需 IMA App)",
|
|
13553
13553
|
"access": "read",
|
|
13554
13554
|
"domain": "ima.qq.com",
|
|
13555
13555
|
"strategy": "cookie",
|
|
13556
|
-
"browser":
|
|
13556
|
+
"browser": "conditional",
|
|
13557
13557
|
"args": [
|
|
13558
13558
|
{
|
|
13559
13559
|
"name": "knowledgeBase",
|
|
@@ -13586,7 +13586,7 @@
|
|
|
13586
13586
|
"name": "viewerUrl",
|
|
13587
13587
|
"type": "str",
|
|
13588
13588
|
"required": false,
|
|
13589
|
-
"help": "已打开的 IMA 查看器 URL(含 originUrl
|
|
13589
|
+
"help": "已打开的 IMA 查看器 URL(含 originUrl 签名参数;Linux 可省略)"
|
|
13590
13590
|
}
|
|
13591
13591
|
],
|
|
13592
13592
|
"columns": [
|
|
@@ -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",
|
package/clis/ima/ax.js
CHANGED
|
@@ -163,12 +163,15 @@ func knowledgeWindow(_ app: AXUIElement) -> AXUIElement? {
|
|
|
163
163
|
return windows(app).first(where: { !pageKnowledgeID($0).isEmpty })
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
func publicURL(in root: AXUIElement) -> String? {
|
|
166
|
+
func publicURL(in root: AXUIElement, allowViewer: Bool = false) -> String? {
|
|
167
167
|
func acceptable(_ value: String?) -> String? {
|
|
168
|
-
guard let value
|
|
168
|
+
guard let value else { return nil }
|
|
169
|
+
if allowViewer && value.hasPrefix("chrome-extension://") && value.contains("originUrl=") { return value }
|
|
170
|
+
guard value.hasPrefix("http://") || value.hasPrefix("https://") else { return nil }
|
|
169
171
|
return value.contains("ima.qq.com") || value.contains("ima.copilot") ? nil : value
|
|
170
172
|
}
|
|
171
173
|
if let value = acceptable(stringAttribute(root, kAXURLAttribute as CFString)) { return value }
|
|
174
|
+
if let value = acceptable(stringAttribute(root, kAXDocumentAttribute as CFString)) { return value }
|
|
172
175
|
for element in descendants(root, limit: 4000) {
|
|
173
176
|
let elementRole = role(element)
|
|
174
177
|
let description = stringAttribute(element, kAXDescriptionAttribute as CFString) ?? ""
|
|
@@ -176,6 +179,7 @@ func publicURL(in root: AXUIElement) -> String? {
|
|
|
176
179
|
|| (elementRole == (kAXTextFieldRole as String) && description.contains("地址"))
|
|
177
180
|
guard isDocumentLocation else { continue }
|
|
178
181
|
if let value = acceptable(stringAttribute(element, kAXURLAttribute as CFString)) { return value }
|
|
182
|
+
if let value = acceptable(stringAttribute(element, kAXDocumentAttribute as CFString)) { return value }
|
|
179
183
|
if let value = acceptable(stringAttribute(element, kAXValueAttribute as CFString)) { return value }
|
|
180
184
|
}
|
|
181
185
|
return nil
|
|
@@ -197,8 +201,9 @@ func readPublicURL(
|
|
|
197
201
|
let deadline = Date().addingTimeInterval(expectsURL ? 12 : 2)
|
|
198
202
|
repeat {
|
|
199
203
|
for window in windows(app).reversed()
|
|
200
|
-
where !known.contains(windowSignature(window))
|
|
201
|
-
|
|
204
|
+
where (expectsURL || !known.contains(windowSignature(window)))
|
|
205
|
+
&& (expectsURL || pageKnowledgeID(window).isEmpty) {
|
|
206
|
+
if let url = publicURL(in: window, allowViewer: expectsURL) {
|
|
202
207
|
return (window, url, CFEqual(window, knowledgeBeforeOpen))
|
|
203
208
|
}
|
|
204
209
|
let title = label(window) ?? ""
|
|
@@ -230,10 +235,12 @@ func closeArticlePage(app: AXUIElement, _ window: AXUIElement, reusedKnowledgeWi
|
|
|
230
235
|
|
|
231
236
|
func pageKnowledgeID(_ window: AXUIElement) -> String {
|
|
232
237
|
for element in [window] + descendants(window, limit: 4000) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
238
|
+
for key in [kAXURLAttribute, kAXDocumentAttribute] {
|
|
239
|
+
guard let value = stringAttribute(element, key as CFString),
|
|
240
|
+
let range = value.range(of: "knowledgeBaseId=") else { continue }
|
|
241
|
+
let suffix = value[range.upperBound...]
|
|
242
|
+
return String(suffix.prefix { $0 != "&" && $0 != "#" })
|
|
243
|
+
}
|
|
237
244
|
}
|
|
238
245
|
return ""
|
|
239
246
|
}
|
|
@@ -494,7 +501,7 @@ func scrapeCurrentFolder(
|
|
|
494
501
|
throw DriverError(code: "KNOWLEDGE_WINDOW_LOST", message: "Knowledge-base window disappeared")
|
|
495
502
|
}
|
|
496
503
|
var url: String? = nil
|
|
497
|
-
let expectsURL = contentType == "公众号" || contentType == "网页"
|
|
504
|
+
let expectsURL = contentType == "公众号" || contentType == "网页" || contentType == "PDF"
|
|
498
505
|
if press(row.element), let opened = readPublicURL(
|
|
499
506
|
app: app,
|
|
500
507
|
excluding: before,
|
|
@@ -502,7 +509,7 @@ func scrapeCurrentFolder(
|
|
|
502
509
|
expectsURL: expectsURL
|
|
503
510
|
) {
|
|
504
511
|
url = opened.1
|
|
505
|
-
if !closeArticlePage(app: app, opened.0, reusedKnowledgeWindow: opened.2) {
|
|
512
|
+
if !closeArticlePage(app: app, opened.0, reusedKnowledgeWindow: opened.2 && contentType != "PDF") {
|
|
506
513
|
throw DriverError(code: "TAB_CLOSE_FAILED", message: "Could not close article tab '\(row.title)' safely")
|
|
507
514
|
}
|
|
508
515
|
}
|
|
@@ -653,4 +660,28 @@ export function readKnowledgeBase(query) {
|
|
|
653
660
|
return envelope;
|
|
654
661
|
}
|
|
655
662
|
|
|
663
|
+
const CURRENT_VIEWER_SCRIPT = String.raw`
|
|
664
|
+
import Cocoa
|
|
665
|
+
import ApplicationServices
|
|
666
|
+
func attr(_ e: AXUIElement, _ key: CFString) -> AnyObject? { var v: CFTypeRef?; guard AXUIElementCopyAttributeValue(e, key, &v) == .success else { return nil }; return v }
|
|
667
|
+
func children(_ e: AXUIElement) -> [AXUIElement] { attr(e, kAXChildrenAttribute as CFString) as? [AXUIElement] ?? [] }
|
|
668
|
+
func string(_ e: AXUIElement, _ key: CFString) -> String? { if let v = attr(e, key) as? String { return v }; if let v = attr(e, key) as? URL { return v.absoluteString }; return nil }
|
|
669
|
+
func all(_ root: AXUIElement) -> [AXUIElement] { var out = [root], q = [root], seen = Set<CFHashCode>(); while let e = q.popLast() { let h = CFHash(e); if seen.contains(h) { continue }; seen.insert(h); for c in children(e) { out.append(c); q.append(c) } }; return out }
|
|
670
|
+
let apps = NSRunningApplication.runningApplications(withBundleIdentifier: "com.tencent.imamac")
|
|
671
|
+
var urls: [String] = []
|
|
672
|
+
for app in apps where !app.isTerminated { let root = AXUIElementCreateApplication(app.processIdentifier); let windows = attr(root, kAXWindowsAttribute as CFString) as? [AXUIElement] ?? []; for e in windows.flatMap(all) { for key in [kAXURLAttribute, kAXDocumentAttribute, kAXValueAttribute] { if let v = string(e, key as CFString), v.hasPrefix("chrome-extension://"), v.contains("originUrl=") && !urls.contains(v) { urls.append(v) } } } }
|
|
673
|
+
let data = try! JSONSerialization.data(withJSONObject: urls, options: []); print(String(data: data, encoding: .utf8)!)
|
|
674
|
+
`;
|
|
675
|
+
|
|
676
|
+
export function readCurrentViewerUrl(title = '') {
|
|
677
|
+
if (process.platform !== 'darwin') return null;
|
|
678
|
+
try {
|
|
679
|
+
const output = execFileSync('swift', ['-'], { input: CURRENT_VIEWER_SCRIPT, encoding: 'utf8', timeout: 20_000, maxBuffer: 8 * 1024 * 1024, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
680
|
+
const urls = String(output).trim().split(/\r?\n/).reverse().reduce((found, line) => {
|
|
681
|
+
try { const parsed = JSON.parse(line); return Array.isArray(parsed) ? parsed : found; } catch { return found; }
|
|
682
|
+
}, []);
|
|
683
|
+
return urls.find((url) => { try { const origin = new URL(url).searchParams.get('originUrl'); return !title || (origin && new URL(origin).searchParams.get('media_title') === title); } catch { return false; } }) || null;
|
|
684
|
+
} catch { return null; }
|
|
685
|
+
}
|
|
686
|
+
|
|
656
687
|
export const __test__ = { AX_KNOWLEDGE_SCRIPT, OPEN_KNOWLEDGE_APPLESCRIPT };
|
package/clis/ima/download.js
CHANGED
|
@@ -4,6 +4,7 @@ import { httpDownload } from '@sovovs/bycli/download';
|
|
|
4
4
|
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
5
5
|
import { CommandExecutionError, EmptyResultError } from '@sovovs/bycli/errors';
|
|
6
6
|
import { readImaMediaUrl, readKnowledgeBaseFromChrome } from './native-client.js';
|
|
7
|
+
import { readCurrentViewerUrl } from './ax.js';
|
|
7
8
|
import { extractOriginUrl, resolveOutputPath, sha256File } from './download-utils.js';
|
|
8
9
|
|
|
9
10
|
function countPdfPages(filePath) {
|
|
@@ -16,29 +17,75 @@ export async function runDownloadCommand(page, kwargs, deps = {}) {
|
|
|
16
17
|
const kb = String(kwargs.knowledgeBase ?? '').trim();
|
|
17
18
|
const title = String(kwargs.file ?? '').trim();
|
|
18
19
|
if (!kb || !title) throw new CommandExecutionError('knowledge base and file title are required');
|
|
19
|
-
const
|
|
20
|
-
|
|
20
|
+
const currentViewer = deps.readCurrentViewerUrl?.(title)
|
|
21
|
+
?? (process.platform === 'darwin' ? readCurrentViewerUrl(title) : null);
|
|
22
|
+
const effectivePage = page ?? (process.env.BYCLI_IMA_COOKIE ? {
|
|
23
|
+
fetchJson: async (url, options = {}) => {
|
|
24
|
+
const response = await fetch(url, { method: options.method ?? 'GET', headers: options.headers, body: options.body === undefined ? undefined : JSON.stringify(options.body) });
|
|
25
|
+
const text = await response.text();
|
|
26
|
+
try { return JSON.parse(text); } catch { throw new Error(`IMA HTTP ${response.status}: invalid JSON response`); }
|
|
27
|
+
},
|
|
28
|
+
} : page);
|
|
29
|
+
const reader = deps.read ?? ((query) => readKnowledgeBaseFromChrome(effectivePage, query));
|
|
30
|
+
let envelope;
|
|
31
|
+
if (currentViewer) {
|
|
32
|
+
const origin = extractOriginUrl(currentViewer);
|
|
33
|
+
const parsed = new URL(origin);
|
|
34
|
+
envelope = {
|
|
35
|
+
knowledgeBase: kb,
|
|
36
|
+
items: [{ title, contentType: 'PDF', mediaId: parsed.searchParams.get('media_id') || '', url: currentViewer }],
|
|
37
|
+
};
|
|
38
|
+
} else {
|
|
39
|
+
try {
|
|
40
|
+
envelope = await reader(kb);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (process.platform !== 'darwin') throw error;
|
|
43
|
+
const { readKnowledgeBase: readKnowledgeBaseFromDesktop } = await import('./ax.js');
|
|
44
|
+
envelope = await Promise.resolve(readKnowledgeBaseFromDesktop(kb));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
21
47
|
const items = (envelope?.items ?? []).filter((item) => item.title === title
|
|
22
48
|
&& (!kwargs.mediaId || item.mediaId === kwargs.mediaId));
|
|
23
49
|
if (items.length === 0) throw new EmptyResultError('ima download', `File "${title}" was not found`);
|
|
24
50
|
if (items.length > 1) throw new CommandExecutionError('Multiple files matched; specify --media-id');
|
|
25
51
|
const item = items[0];
|
|
26
52
|
let originUrl;
|
|
27
|
-
if (
|
|
53
|
+
if (currentViewer) originUrl = extractOriginUrl(currentViewer);
|
|
54
|
+
if (item.url) {
|
|
55
|
+
originUrl = String(item.url).startsWith('chrome-extension://')
|
|
56
|
+
? extractOriginUrl(item.url)
|
|
57
|
+
: item.url;
|
|
58
|
+
}
|
|
28
59
|
if (!originUrl && kwargs.viewerUrl) originUrl = extractOriginUrl(kwargs.viewerUrl);
|
|
29
|
-
if (!originUrl && page
|
|
60
|
+
if (!originUrl && page?.getCurrentUrl) {
|
|
30
61
|
const currentUrl = await page.getCurrentUrl();
|
|
31
62
|
if (currentUrl?.includes('originUrl=')) originUrl = extractOriginUrl(currentUrl);
|
|
32
63
|
}
|
|
33
|
-
|
|
34
|
-
if (!originUrl &&
|
|
64
|
+
let mediaError;
|
|
65
|
+
if (!originUrl && deps.readOriginalUrl) {
|
|
66
|
+
try {
|
|
67
|
+
originUrl = await deps.readOriginalUrl(item);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
mediaError = error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!originUrl && (effectivePage?.requestImaMedia || effectivePage?.fetchJson)) {
|
|
73
|
+
try {
|
|
74
|
+
originUrl = await readImaMediaUrl(effectivePage, { ...item, knowledgeBaseId: item.knowledgeBaseId || envelope.knowledgeBaseId });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
mediaError = error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
35
79
|
if (!originUrl && process.platform === 'darwin') {
|
|
36
80
|
const { readKnowledgeBase: readKnowledgeBaseFromDesktop } = await import('./ax.js');
|
|
37
81
|
const desktop = await Promise.resolve(readKnowledgeBaseFromDesktop(kb));
|
|
38
82
|
const desktopItem = desktop?.items?.find((entry) => entry.title === title);
|
|
39
83
|
if (desktopItem?.url) originUrl = desktopItem.url;
|
|
40
84
|
}
|
|
41
|
-
if (!originUrl)
|
|
85
|
+
if (!originUrl) {
|
|
86
|
+
const message = mediaError instanceof Error ? mediaError.message : 'IMA original file URL is unavailable; open the file in the IMA client first';
|
|
87
|
+
throw new CommandExecutionError(message);
|
|
88
|
+
}
|
|
42
89
|
const outputPath = resolveOutputPath(kwargs.output || '.', item.title);
|
|
43
90
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
44
91
|
const download = deps.httpDownload ?? httpDownload;
|
|
@@ -51,14 +98,14 @@ export async function runDownloadCommand(page, kwargs, deps = {}) {
|
|
|
51
98
|
}
|
|
52
99
|
|
|
53
100
|
cli({
|
|
54
|
-
site: 'ima', name: 'download', access: 'read', description: '下载 ima
|
|
55
|
-
domain: 'ima.qq.com', strategy: Strategy.COOKIE, browser:
|
|
101
|
+
site: 'ima', name: 'download', access: 'read', description: '下载 ima 知识库中的完整原始文件(Linux 无需 IMA App)',
|
|
102
|
+
domain: 'ima.qq.com', strategy: Strategy.COOKIE, browser: () => !process.env.BYCLI_IMA_COOKIE, navigateBefore: false,
|
|
56
103
|
args: [
|
|
57
104
|
{ name: 'knowledgeBase', positional: true, required: true, help: '知识库名称或 ID' },
|
|
58
105
|
{ name: 'file', positional: true, required: true, help: '文件标题' },
|
|
59
106
|
{ name: 'output', flags: '--output <path>', default: '.', help: '输出文件或目录' },
|
|
60
107
|
{ name: 'mediaId', flags: '--media-id <id>', help: 'IMA mediaId,用于精确定位' },
|
|
61
|
-
{ name: 'viewerUrl', flags: '--viewer-url <url>', help: '已打开的 IMA 查看器 URL(含 originUrl
|
|
108
|
+
{ name: 'viewerUrl', flags: '--viewer-url <url>', help: '已打开的 IMA 查看器 URL(含 originUrl 签名参数;Linux 可省略)' },
|
|
62
109
|
],
|
|
63
110
|
columns: ['path', 'title', 'contentType', 'size', 'sha256', 'pages'],
|
|
64
111
|
func: (page, kwargs) => runDownloadCommand(page, kwargs),
|
|
@@ -2,6 +2,30 @@ import { listKnowledgeBases, readKnowledgeBaseFromApi } from './native-api.js';
|
|
|
2
2
|
|
|
3
3
|
const IMA_WIKIS_URL = 'https://ima.qq.com/wikis';
|
|
4
4
|
const IMA_GET_MEDIA_URL = 'https://ima.qq.com/cgi-bin/file_manager/get_media';
|
|
5
|
+
const IMA_READER_URL = 'https://ima.qq.com/cgi-bin/knowledge_tab_reader';
|
|
6
|
+
|
|
7
|
+
function imaBknFromCookie(cookie) {
|
|
8
|
+
const token = String(cookie).split(';').map((part) => part.trim())
|
|
9
|
+
.find((part) => part.startsWith('IMA-TOKEN='))?.slice('IMA-TOKEN='.length);
|
|
10
|
+
if (!token) return '';
|
|
11
|
+
let hash = 5381;
|
|
12
|
+
for (const character of token) hash += (hash << 5) + character.charCodeAt(0);
|
|
13
|
+
return String(hash & 0x7fffffff);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function directImaRequest(page, dependencies = {}) {
|
|
17
|
+
const imaCookie = dependencies.imaCookie ?? process.env.BYCLI_IMA_COOKIE;
|
|
18
|
+
if (!imaCookie || !page || typeof page.fetchJson !== 'function') return null;
|
|
19
|
+
const headers = {
|
|
20
|
+
'x-ima-cookie': String(imaCookie),
|
|
21
|
+
'x-ima-bkn': dependencies.imaBkn ?? imaBknFromCookie(imaCookie),
|
|
22
|
+
from_browser_ima: '1',
|
|
23
|
+
extension_version: String(dependencies.extensionVersion ?? process.env.BYCLI_IMA_EXTENSION_VERSION ?? '2.1.23'),
|
|
24
|
+
};
|
|
25
|
+
return (path, body) => page.fetchJson(`${IMA_READER_URL}${path}`, {
|
|
26
|
+
method: 'POST', headers, body,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
5
29
|
|
|
6
30
|
function codedError(code, message) {
|
|
7
31
|
return Object.assign(new Error(message), { code });
|
|
@@ -66,6 +90,8 @@ async function releaseImaAuth(page, authId) {
|
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
export async function readKnowledgeBasesFromChrome(page, dependencies = {}) {
|
|
93
|
+
const directRequest = directImaRequest(page, dependencies);
|
|
94
|
+
if (directRequest) return listKnowledgeBases(directRequest);
|
|
69
95
|
const authId = await acquireImaChromeAuth(page, undefined, dependencies);
|
|
70
96
|
try {
|
|
71
97
|
return await listKnowledgeBases((path, body) => page.requestImaReader(authId, path, body));
|
|
@@ -75,6 +101,8 @@ export async function readKnowledgeBasesFromChrome(page, dependencies = {}) {
|
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}) {
|
|
104
|
+
const directRequest = directImaRequest(page, dependencies);
|
|
105
|
+
if (directRequest) return readKnowledgeBaseFromApi(query, directRequest);
|
|
78
106
|
const authId = await acquireImaChromeAuth(page, query, dependencies);
|
|
79
107
|
try {
|
|
80
108
|
return await readKnowledgeBaseFromApi(
|
|
@@ -87,15 +115,38 @@ export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}
|
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
export async function readImaMediaUrl(page, item, dependencies = {}) {
|
|
118
|
+
const imaCookie = dependencies.imaCookie ?? process.env.BYCLI_IMA_COOKIE;
|
|
119
|
+
if (imaCookie && page && typeof page.fetchJson === 'function') {
|
|
120
|
+
const response = await page.fetchJson(IMA_GET_MEDIA_URL, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'x-ima-cookie': String(imaCookie),
|
|
124
|
+
'x-ima-bkn': dependencies.imaBkn ?? imaBknFromCookie(imaCookie),
|
|
125
|
+
from_browser_ima: '1',
|
|
126
|
+
extension_version: String(dependencies.extensionVersion ?? process.env.BYCLI_IMA_EXTENSION_VERSION ?? '2.1.23'),
|
|
127
|
+
},
|
|
128
|
+
body: {
|
|
129
|
+
knowledge_base_id: String(item?.knowledgeBaseId ?? ''),
|
|
130
|
+
media_id: String(item?.mediaId ?? ''),
|
|
131
|
+
scene: dependencies.scene ?? 4,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const data = response && typeof response === 'object' ? response : {};
|
|
135
|
+
const url = data.jumpUrlInfo?.url ?? data.jump_url_info?.url;
|
|
136
|
+
if (Number(data.action) !== 1 || typeof url !== 'string' || !url) {
|
|
137
|
+
throw codedError('IMA_ORIGINAL_URL_UNAVAILABLE', data.toastText || data.toast_text || 'IMA did not return an exportable file URL');
|
|
138
|
+
}
|
|
139
|
+
return url;
|
|
140
|
+
}
|
|
90
141
|
if (typeof page.requestImaMedia === 'function' && typeof page.startImaAuthCapture === 'function') {
|
|
91
142
|
const authId = await acquireImaChromeAuth(page, item?.knowledgeBaseId, dependencies);
|
|
92
143
|
try {
|
|
93
144
|
const response = await page.requestImaMedia(authId, {
|
|
94
|
-
|
|
95
|
-
|
|
145
|
+
knowledge_base_id: String(item?.knowledgeBaseId ?? ''),
|
|
146
|
+
media_id: String(item?.mediaId ?? ''),
|
|
96
147
|
scene: dependencies.scene ?? 4,
|
|
97
|
-
...(item?.shareId ? {
|
|
98
|
-
...(item?.sourceKnowledgeBaseId ? {
|
|
148
|
+
...(item?.shareId ? { share_id: String(item.shareId) } : {}),
|
|
149
|
+
...(item?.sourceKnowledgeBaseId ? { source_knowledge_base_id: String(item.sourceKnowledgeBaseId) } : {}),
|
|
99
150
|
});
|
|
100
151
|
const data = response && typeof response === 'object' ? response : {};
|
|
101
152
|
const url = data.jumpUrlInfo?.url ?? data.jump_url_info?.url;
|
|
@@ -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
|
+
}
|