@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 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": true,
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, value.hasPrefix("http://") || value.hasPrefix("https://") else { return nil }
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)) && pageKnowledgeID(window).isEmpty {
201
- if let url = publicURL(in: window) {
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
- guard let value = stringAttribute(element, kAXURLAttribute as CFString),
234
- let range = value.range(of: "knowledgeBaseId=") else { continue }
235
- let suffix = value[range.upperBound...]
236
- return String(suffix.prefix { $0 != "&" && $0 != "#" })
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 };
@@ -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 reader = deps.read ?? ((query) => readKnowledgeBaseFromChrome(page, query));
20
- const envelope = await reader(kb);
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 (item.url) originUrl = item.url;
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.getCurrentUrl) {
60
+ if (!originUrl && page?.getCurrentUrl) {
30
61
  const currentUrl = await page.getCurrentUrl();
31
62
  if (currentUrl?.includes('originUrl=')) originUrl = extractOriginUrl(currentUrl);
32
63
  }
33
- if (!originUrl && deps.readOriginalUrl) originUrl = await deps.readOriginalUrl(item);
34
- if (!originUrl && page.fetchJson) originUrl = await readImaMediaUrl(page, { ...item, knowledgeBaseId: item.knowledgeBaseId || envelope.knowledgeBaseId });
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) throw new CommandExecutionError('IMA original file URL is unavailable; open the file in the IMA client first');
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: true, navigateBefore: false,
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
- knowledgeBaseId: String(item?.knowledgeBaseId ?? ''),
95
- mediaId: String(item?.mediaId ?? ''),
145
+ knowledge_base_id: String(item?.knowledgeBaseId ?? ''),
146
+ media_id: String(item?.mediaId ?? ''),
96
147
  scene: dependencies.scene ?? 4,
97
- ...(item?.shareId ? { shareId: String(item.shareId) } : {}),
98
- ...(item?.sourceKnowledgeBaseId ? { sourceKnowledgeBaseId: String(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
+ }