@sovovs/bycli 2.1.38 → 2.1.40

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/cli-manifest.json CHANGED
@@ -13546,6 +13546,83 @@
13546
13546
  "sourceFile": "hupu/unlike.js",
13547
13547
  "navigateBefore": false
13548
13548
  },
13549
+ {
13550
+ "site": "ima",
13551
+ "name": "knowledge",
13552
+ "description": "按名称或 ID 获取 ima 知识库中的文章标题、URL 与文件夹路径",
13553
+ "access": "read",
13554
+ "domain": "ima.qq.com",
13555
+ "strategy": "cookie",
13556
+ "browser": true,
13557
+ "args": [
13558
+ {
13559
+ "name": "knowledgeBase",
13560
+ "type": "string",
13561
+ "required": true,
13562
+ "positional": true,
13563
+ "help": "知识库的完整名称或 ima 页面中的 knowledgeBaseId"
13564
+ }
13565
+ ],
13566
+ "columns": [
13567
+ "knowledgeBaseId",
13568
+ "knowledgeBase",
13569
+ "folderPath",
13570
+ "title",
13571
+ "url",
13572
+ "contentType",
13573
+ "addedDate",
13574
+ "mediaId",
13575
+ "mediaType",
13576
+ "mediaState",
13577
+ "mediaAuditStatus",
13578
+ "mediaTypeInfo",
13579
+ "sourcePath",
13580
+ "jumpUrl",
13581
+ "createTime",
13582
+ "updateTime",
13583
+ "lastModifyTime",
13584
+ "lastOpenTime",
13585
+ "fileSize",
13586
+ "abstract",
13587
+ "introduction",
13588
+ "tags",
13589
+ "isTop",
13590
+ "accessStatus",
13591
+ "accessStatusUpdateTs",
13592
+ "parseProgress",
13593
+ "parseErrInfo",
13594
+ "summaryState",
13595
+ "coverUrls",
13596
+ "logo"
13597
+ ],
13598
+ "defaultFormat": "json",
13599
+ "type": "js",
13600
+ "modulePath": "ima/knowledge.js",
13601
+ "sourceFile": "ima/knowledge.js",
13602
+ "navigateBefore": false
13603
+ },
13604
+ {
13605
+ "site": "ima",
13606
+ "name": "knowledge-list",
13607
+ "description": "获取全部 ima 知识库的常用字段与原始元数据",
13608
+ "access": "read",
13609
+ "domain": "ima.qq.com",
13610
+ "strategy": "cookie",
13611
+ "browser": true,
13612
+ "args": [],
13613
+ "columns": [
13614
+ "id",
13615
+ "name",
13616
+ "type",
13617
+ "typeName",
13618
+ "raw"
13619
+ ],
13620
+ "defaultFormat": "json",
13621
+ "type": "js",
13622
+ "modulePath": "ima/knowledge-list.js",
13623
+ "sourceFile": "ima/knowledge-list.js",
13624
+ "navigateBefore": false
13625
+ },
13549
13626
  {
13550
13627
  "site": "imdb",
13551
13628
  "name": "person",
package/clis/ima/ax.js ADDED
@@ -0,0 +1,656 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ import { normalizeArticleUrl } from './utils.js';
4
+
5
+ const OPEN_KNOWLEDGE_APPLESCRIPT = [
6
+ 'tell application "System Events" to tell process "ima.copilot"',
7
+ 'set frontmost to true',
8
+ 'if exists front window then',
9
+ 'repeat with itemRef in (entire contents of front window)',
10
+ 'try',
11
+ 'if description of itemRef is "知识库" then click itemRef',
12
+ 'end try',
13
+ 'end repeat',
14
+ 'end if',
15
+ 'end tell',
16
+ 'delay 1',
17
+ ];
18
+
19
+ const AX_KNOWLEDGE_SCRIPT = String.raw`
20
+ import Cocoa
21
+ import ApplicationServices
22
+
23
+ struct DriverError: Error {
24
+ let code: String
25
+ let message: String
26
+ }
27
+
28
+ struct Candidate {
29
+ let name: String
30
+ let id: String
31
+ let element: AXUIElement
32
+ }
33
+
34
+ let query = CommandLine.arguments.dropFirst().first ?? ""
35
+ let bundleID = "com.tencent.imamac"
36
+
37
+ func attribute(_ element: AXUIElement, _ name: CFString) -> AnyObject? {
38
+ var value: CFTypeRef?
39
+ guard AXUIElementCopyAttributeValue(element, name, &value) == .success else { return nil }
40
+ return value
41
+ }
42
+
43
+ func stringAttribute(_ element: AXUIElement, _ name: CFString) -> String? {
44
+ if let value = attribute(element, name) as? String { return value }
45
+ if let value = attribute(element, name) as? URL { return value.absoluteString }
46
+ return nil
47
+ }
48
+
49
+ func elementAttribute(_ element: AXUIElement, _ name: CFString) -> AXUIElement? {
50
+ guard let value = attribute(element, name) else { return nil }
51
+ return (value as! AXUIElement)
52
+ }
53
+
54
+ func children(_ element: AXUIElement) -> [AXUIElement] {
55
+ return attribute(element, kAXChildrenAttribute as CFString) as? [AXUIElement] ?? []
56
+ }
57
+
58
+ func role(_ element: AXUIElement) -> String {
59
+ return stringAttribute(element, kAXRoleAttribute as CFString) ?? ""
60
+ }
61
+
62
+ func label(_ element: AXUIElement) -> String? {
63
+ for key in [kAXTitleAttribute, kAXDescriptionAttribute, kAXValueAttribute, kAXHelpAttribute] {
64
+ if let value = stringAttribute(element, key as CFString), !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
65
+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
66
+ }
67
+ }
68
+ return nil
69
+ }
70
+
71
+ func descendants(_ root: AXUIElement, limit: Int = 12000) -> [AXUIElement] {
72
+ var result: [AXUIElement] = []
73
+ var queue = children(root)
74
+ var seen = Set<CFHashCode>()
75
+ while !queue.isEmpty && result.count < limit {
76
+ let item = queue.removeFirst()
77
+ let hash = CFHash(item)
78
+ if seen.contains(hash) { continue }
79
+ seen.insert(hash)
80
+ result.append(item)
81
+ queue.append(contentsOf: children(item))
82
+ }
83
+ return result
84
+ }
85
+
86
+ func actions(_ element: AXUIElement) -> [String] {
87
+ var names: CFArray?
88
+ guard AXUIElementCopyActionNames(element, &names) == .success else { return [] }
89
+ return names as? [String] ?? []
90
+ }
91
+
92
+ func pause(_ seconds: TimeInterval) {
93
+ RunLoop.current.run(until: Date().addingTimeInterval(seconds))
94
+ }
95
+
96
+ func clickCenter(_ element: AXUIElement) -> Bool {
97
+ guard let positionValue = attribute(element, kAXPositionAttribute as CFString),
98
+ let sizeValue = attribute(element, kAXSizeAttribute as CFString) else { return false }
99
+ let rawPosition = positionValue as! AXValue
100
+ let rawSize = sizeValue as! AXValue
101
+ var position = CGPoint.zero
102
+ var size = CGSize.zero
103
+ guard AXValueGetValue(rawPosition, .cgPoint, &position),
104
+ AXValueGetValue(rawSize, .cgSize, &size), size.width > 1, size.height > 1 else { return false }
105
+ let point = CGPoint(x: position.x + size.width / 2, y: position.y + size.height / 2)
106
+ guard let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
107
+ let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) else { return false }
108
+ down.post(tap: CGEventTapLocation.cghidEventTap)
109
+ up.post(tap: CGEventTapLocation.cghidEventTap)
110
+ return true
111
+ }
112
+
113
+ func frame(_ element: AXUIElement) -> CGRect? {
114
+ guard let positionValue = attribute(element, kAXPositionAttribute as CFString),
115
+ let sizeValue = attribute(element, kAXSizeAttribute as CFString) else { return nil }
116
+ var position = CGPoint.zero
117
+ var size = CGSize.zero
118
+ guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &position),
119
+ AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
120
+ return CGRect(origin: position, size: size)
121
+ }
122
+
123
+ func actionable(_ element: AXUIElement) -> AXUIElement? {
124
+ var current: AXUIElement? = element
125
+ for _ in 0..<7 {
126
+ guard let item = current else { return nil }
127
+ if actions(item).contains(kAXPressAction as String) { return item }
128
+ current = elementAttribute(item, kAXParentAttribute as CFString)
129
+ }
130
+ return nil
131
+ }
132
+
133
+ @discardableResult
134
+ func press(_ element: AXUIElement) -> Bool {
135
+ if let target = actionable(element), AXUIElementPerformAction(target, kAXPressAction as CFString) == .success {
136
+ return true
137
+ }
138
+ var current: AXUIElement? = element
139
+ for _ in 0..<5 {
140
+ guard let item = current else { return false }
141
+ if role(item) == (kAXGroupRole as String), clickCenter(item) { return true }
142
+ current = elementAttribute(item, kAXParentAttribute as CFString)
143
+ }
144
+ return clickCenter(element)
145
+ }
146
+
147
+ func waitUntil(_ timeout: TimeInterval, _ condition: () -> Bool) -> Bool {
148
+ let deadline = Date().addingTimeInterval(timeout)
149
+ repeat {
150
+ if condition() { return true }
151
+ RunLoop.current.run(until: Date().addingTimeInterval(0.15))
152
+ } while Date() < deadline
153
+ return false
154
+ }
155
+
156
+ func windows(_ app: AXUIElement) -> [AXUIElement] {
157
+ let all = attribute(app, kAXWindowsAttribute as CFString) as? [AXUIElement] ?? []
158
+ return all.filter { !children($0).isEmpty }
159
+ .sorted { descendants($0, limit: 300).count > descendants($1, limit: 300).count }
160
+ }
161
+
162
+ func knowledgeWindow(_ app: AXUIElement) -> AXUIElement? {
163
+ return windows(app).first(where: { !pageKnowledgeID($0).isEmpty })
164
+ }
165
+
166
+ func publicURL(in root: AXUIElement) -> String? {
167
+ func acceptable(_ value: String?) -> String? {
168
+ guard let value, value.hasPrefix("http://") || value.hasPrefix("https://") else { return nil }
169
+ return value.contains("ima.qq.com") || value.contains("ima.copilot") ? nil : value
170
+ }
171
+ if let value = acceptable(stringAttribute(root, kAXURLAttribute as CFString)) { return value }
172
+ for element in descendants(root, limit: 4000) {
173
+ let elementRole = role(element)
174
+ let description = stringAttribute(element, kAXDescriptionAttribute as CFString) ?? ""
175
+ let isDocumentLocation = elementRole == "AXWebArea"
176
+ || (elementRole == (kAXTextFieldRole as String) && description.contains("地址"))
177
+ guard isDocumentLocation else { continue }
178
+ if let value = acceptable(stringAttribute(element, kAXURLAttribute as CFString)) { return value }
179
+ if let value = acceptable(stringAttribute(element, kAXValueAttribute as CFString)) { return value }
180
+ }
181
+ return nil
182
+ }
183
+
184
+ func windowSignature(_ window: AXUIElement) -> String {
185
+ let title = label(window) ?? ""
186
+ let document = stringAttribute(window, kAXDocumentAttribute as CFString) ?? ""
187
+ return "\(title)|\(document)"
188
+ }
189
+
190
+ func readPublicURL(
191
+ app: AXUIElement,
192
+ excluding known: Set<String>,
193
+ knowledgeBeforeOpen: AXUIElement,
194
+ expectsURL: Bool
195
+ ) -> (AXUIElement, String?, Bool)? {
196
+ var fallback: (AXUIElement, String?, Bool)? = nil
197
+ let deadline = Date().addingTimeInterval(expectsURL ? 12 : 2)
198
+ repeat {
199
+ for window in windows(app).reversed()
200
+ where !known.contains(windowSignature(window)) && pageKnowledgeID(window).isEmpty {
201
+ if let url = publicURL(in: window) {
202
+ return (window, url, CFEqual(window, knowledgeBeforeOpen))
203
+ }
204
+ let title = label(window) ?? ""
205
+ if !title.isEmpty { fallback = (window, nil, CFEqual(window, knowledgeBeforeOpen)) }
206
+ }
207
+ pause(0.15)
208
+ } while Date() < deadline
209
+ return fallback
210
+ }
211
+
212
+ func closeArticlePage(app: AXUIElement, _ window: AXUIElement, reusedKnowledgeWindow: Bool) -> Bool {
213
+ let openedSignature = windowSignature(window)
214
+ _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString)
215
+ if let owner = elementAttribute(window, kAXParentAttribute as CFString) {
216
+ _ = AXUIElementSetAttributeValue(owner, kAXFocusedWindowAttribute as CFString, window)
217
+ }
218
+ pause(0.2)
219
+ let keyCode: CGKeyCode = reusedKnowledgeWindow ? 33 : 13 // Command-[ goes back; Command-W closes a new tab/window.
220
+ guard let down = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true),
221
+ let up = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false) else { return false }
222
+ down.flags = .maskCommand
223
+ up.flags = .maskCommand
224
+ down.post(tap: .cghidEventTap)
225
+ up.post(tap: .cghidEventTap)
226
+ return waitUntil(6) {
227
+ knowledgeWindow(app) != nil && !windows(app).contains(where: { windowSignature($0) == openedSignature })
228
+ }
229
+ }
230
+
231
+ func pageKnowledgeID(_ window: AXUIElement) -> String {
232
+ 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 != "#" })
237
+ }
238
+ return ""
239
+ }
240
+
241
+ func pageKnowledgeName(_ window: AXUIElement) -> String {
242
+ for element in descendants(window, limit: 4000) {
243
+ guard let value = stringAttribute(element, kAXURLAttribute as CFString),
244
+ value.contains("knowledgeBaseId="), let name = label(element) else { continue }
245
+ return name
246
+ }
247
+ return ""
248
+ }
249
+
250
+ func exactElements(_ root: AXUIElement, _ text: String) -> [AXUIElement] {
251
+ return descendants(root).filter { label($0) == text }
252
+ }
253
+
254
+ func sidebarTextElements(_ window: AXUIElement) -> [AXUIElement] {
255
+ let windowFrame = frame(window) ?? .zero
256
+ return descendants(window).filter { element in
257
+ guard role(element) == (kAXStaticTextRole as String),
258
+ let text = label(element), text.count > 0, text.count < 100,
259
+ let itemFrame = frame(element) else { return false }
260
+ let inSidebar = itemFrame.midX < windowFrame.minX + min(430, windowFrame.width * 0.35)
261
+ return inSidebar && !["个人知识库", "共享知识库", "订阅知识库"].contains(text)
262
+ }
263
+ }
264
+
265
+ func navigateToKnowledge(app: AXUIElement) throws -> AXUIElement {
266
+ if let existing = knowledgeWindow(app) { return existing }
267
+ guard !windows(app).isEmpty else {
268
+ throw DriverError(code: "IMA_NOT_RUNNING", message: "ima is running but has no visible window")
269
+ }
270
+ var knowledgeTab: AXUIElement? = nil
271
+ var navigationWindow: AXUIElement? = nil
272
+ _ = waitUntil(10) {
273
+ for window in windows(app) {
274
+ if let tab = exactElements(window, "知识库").first {
275
+ knowledgeTab = tab
276
+ navigationWindow = window
277
+ return true
278
+ }
279
+ }
280
+ return false
281
+ }
282
+ if let tab = knowledgeTab {
283
+ if let window = navigationWindow {
284
+ _ = AXUIElementPerformAction(window, kAXRaiseAction as CFString)
285
+ _ = AXUIElementSetAttributeValue(app, kAXFocusedWindowAttribute as CFString, window)
286
+ pause(0.2)
287
+ }
288
+ _ = clickCenter(tab)
289
+ _ = waitUntil(6) { knowledgeWindow(app) != nil }
290
+ }
291
+ if let selected = knowledgeWindow(app) { return selected }
292
+ throw DriverError(code: "KNOWLEDGE_NOT_FOUND", message: "ima did not open its knowledge-base page")
293
+ }
294
+
295
+ func selectKnowledge(app: AXUIElement, window: AXUIElement) throws -> Candidate {
296
+ let currentID = pageKnowledgeID(window)
297
+ let currentName = pageKnowledgeName(window)
298
+ if query == currentID {
299
+ return Candidate(name: currentName, id: currentID, element: window)
300
+ }
301
+ // Exact visible-name lookup is restricted to knowledge-base rows.
302
+ let exactMatches = sidebarTextElements(window).filter { label($0) == query }
303
+ if exactMatches.count > 1 {
304
+ throw DriverError(code: "AMBIGUOUS_KNOWLEDGE", message: "Multiple knowledge bases are named '\(query)'; use an ID")
305
+ }
306
+ if query == currentName {
307
+ return Candidate(name: currentName, id: currentID, element: window)
308
+ }
309
+ if let element = exactMatches.first, press(element) {
310
+ _ = waitUntil(4) {
311
+ guard let current = knowledgeWindow(app) else { return false }
312
+ return pageKnowledgeName(current) == query
313
+ }
314
+ let current = knowledgeWindow(app) ?? window
315
+ let candidate = Candidate(name: pageKnowledgeName(current), id: pageKnowledgeID(current), element: element)
316
+ if query == candidate.name { return candidate }
317
+ }
318
+
319
+ // ID lookup: visit sidebar rows and compare the ID exposed by ima's page URL.
320
+ let visible = sidebarTextElements(window)
321
+ var checked = Set<String>()
322
+ for element in visible {
323
+ guard let name = label(element) else { continue }
324
+ let nodeID = (attribute(element, "ChromeAXNodeId" as CFString) as? NSNumber)?.stringValue
325
+ let identity = nodeID ?? "\(CFHash(element)):\(name)"
326
+ guard !checked.contains(identity), press(element) else { continue }
327
+ checked.insert(identity)
328
+ pause(0.8)
329
+ let current = knowledgeWindow(app) ?? window
330
+ let candidate = Candidate(name: pageKnowledgeName(current), id: pageKnowledgeID(current), element: element)
331
+ if query == candidate.name || query == candidate.id { return candidate }
332
+ }
333
+ throw DriverError(code: "KNOWLEDGE_NOT_FOUND", message: "No knowledge base exactly matches '\(query)'")
334
+ }
335
+
336
+ let datePattern = try! NSRegularExpression(pattern: #"(?:\d{4}[-/.])?\d{1,2}[-/.]\d{1,2}|\d{1,2}/\d{1,2}"#)
337
+ let folderPattern = try! NSRegularExpression(pattern: #"^\d+\s*项(?:\s|$)"#)
338
+ let knownTypes = ["公众号", "网页", "PDF", "文档", "文件", "笔记", "视频", "音频", "图片"]
339
+
340
+ func firstMatch(_ regex: NSRegularExpression, _ value: String) -> Bool {
341
+ return regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) != nil
342
+ }
343
+
344
+ func textValues(_ element: AXUIElement) -> [String] {
345
+ var values: [String] = []
346
+ for item in [element] + descendants(element, limit: 100) {
347
+ if let value = label(item), !values.contains(value) { values.append(value) }
348
+ }
349
+ return values
350
+ }
351
+
352
+ struct Row {
353
+ let element: AXUIElement
354
+ let identity: String
355
+ let bounds: CGRect
356
+ let title: String
357
+ let metadata: String
358
+ let isFolder: Bool
359
+ }
360
+
361
+ struct FolderTarget {
362
+ let title: String
363
+ let identity: String
364
+ let ordinal: Int
365
+ }
366
+
367
+ func rows(in window: AXUIElement) -> [Row] {
368
+ var result: [Row] = []
369
+ let groups = descendants(window)
370
+ .filter { role($0) == (kAXGroupRole as String) }
371
+ .sorted { descendants($0, limit: 100).count < descendants($1, limit: 100).count }
372
+ for element in groups {
373
+ let values = textValues(element).filter { $0.count < 240 }
374
+ guard values.count >= 2 && values.count <= 7 else { continue }
375
+ let folderMetadata = values.first(where: { firstMatch(folderPattern, $0) })
376
+ let rowType = values.first(where: { value in knownTypes.contains(where: { value.hasPrefix($0) }) })
377
+ let rowDate = values.first(where: { firstMatch(datePattern, $0) })
378
+ let metadata: String
379
+ let isFolder: Bool
380
+ if let folderMetadata {
381
+ metadata = folderMetadata
382
+ isFolder = true
383
+ } else if let rowType, let rowDate {
384
+ metadata = "\(rowType) \(rowDate)"
385
+ isFolder = false
386
+ } else {
387
+ continue
388
+ }
389
+ guard let title = values.first(where: {
390
+ $0 != metadata && !knownTypes.contains($0) && !firstMatch(datePattern, $0)
391
+ && !$0.hasPrefix("/") && $0 != "没有更多内容了"
392
+ }) else { continue }
393
+ let bounds = frame(element) ?? .zero
394
+ let overlapsAcceptedRow = result.contains { accepted in
395
+ guard accepted.title == title && accepted.metadata == metadata,
396
+ !accepted.bounds.isEmpty && !bounds.isEmpty else { return false }
397
+ return accepted.bounds.contains(CGPoint(x: bounds.midX, y: bounds.midY))
398
+ || bounds.contains(CGPoint(x: accepted.bounds.midX, y: accepted.bounds.midY))
399
+ }
400
+ if overlapsAcceptedRow { continue }
401
+ let nodeID = (attribute(element, "ChromeAXNodeId" as CFString) as? NSNumber)?.stringValue
402
+ let identity = nodeID ?? "\(CFHash(element)):\(title):\(metadata)"
403
+ result.append(Row(
404
+ element: element,
405
+ identity: identity,
406
+ bounds: bounds,
407
+ title: title,
408
+ metadata: metadata,
409
+ isFolder: isFolder
410
+ ))
411
+ }
412
+ return result
413
+ }
414
+
415
+ func scrollPage(_ window: AXUIElement) -> Bool {
416
+ for element in descendants(window).reversed() where role(element) == (kAXScrollAreaRole as String) {
417
+ let action = "AXScrollDownByPage" as CFString
418
+ if actions(element).contains(action as String), AXUIElementPerformAction(element, action) == .success { return true }
419
+ if let bar = elementAttribute(element, kAXVerticalScrollBarAttribute as CFString),
420
+ let current = attribute(bar, kAXValueAttribute as CFString) as? NSNumber {
421
+ let next = min(1, current.doubleValue + 0.72)
422
+ if next > current.doubleValue + 0.001 {
423
+ return AXUIElementSetAttributeValue(bar, kAXValueAttribute as CFString, NSNumber(value: next)) == .success
424
+ }
425
+ }
426
+ }
427
+ return false
428
+ }
429
+
430
+ func scrollToTop(_ window: AXUIElement) {
431
+ for element in descendants(window) where role(element) == (kAXScrollAreaRole as String) {
432
+ if let bar = elementAttribute(element, kAXVerticalScrollBarAttribute as CFString) {
433
+ _ = AXUIElementSetAttributeValue(bar, kAXValueAttribute as CFString, NSNumber(value: 0))
434
+ }
435
+ }
436
+ pause(0.5)
437
+ }
438
+
439
+ func findFolderRow(app: AXUIElement, target: FolderTarget) -> Row? {
440
+ guard let initial = knowledgeWindow(app) else { return nil }
441
+ scrollToTop(initial)
442
+ var visitedPages = Set<String>()
443
+ var fallbackSeen = Set<String>()
444
+ var matchingTitleSeen = 0
445
+ while let window = knowledgeWindow(app) {
446
+ let folders = rows(in: window).filter { $0.isFolder }
447
+ let pageIdentity = rows(in: window).map(\.identity).sorted().joined(separator: "|")
448
+ if visitedPages.contains(pageIdentity) { return nil }
449
+ visitedPages.insert(pageIdentity)
450
+ if let match = folders.first(where: { $0.identity == target.identity }) { return match }
451
+ let newTitleMatches = folders.filter {
452
+ $0.title == target.title && !fallbackSeen.contains($0.identity)
453
+ }
454
+ if target.ordinal >= matchingTitleSeen
455
+ && target.ordinal < matchingTitleSeen + newTitleMatches.count {
456
+ return newTitleMatches[target.ordinal - matchingTitleSeen]
457
+ }
458
+ for match in newTitleMatches { fallbackSeen.insert(match.identity) }
459
+ matchingTitleSeen += newTitleMatches.count
460
+ if !scrollPage(window) { return nil }
461
+ pause(0.8)
462
+ }
463
+ return nil
464
+ }
465
+
466
+ func scrapeCurrentFolder(
467
+ app: AXUIElement,
468
+ knowledge: Candidate,
469
+ folderPath: [String],
470
+ folderTargets: inout [FolderTarget]
471
+ ) throws -> [[String: Any]] {
472
+ var output: [[String: Any]] = []
473
+ var seen = Set<String>()
474
+ var idleRounds = 0
475
+ while idleRounds < 2 {
476
+ guard let window = knowledgeWindow(app) else { break }
477
+ let pageRows = rows(in: window)
478
+ var foldersAdded = 0
479
+ for folder in pageRows where folder.isFolder
480
+ && !folderTargets.contains(where: { $0.identity == folder.identity }) {
481
+ let ordinal = folderTargets.filter { $0.title == folder.title }.count
482
+ folderTargets.append(FolderTarget(title: folder.title, identity: folder.identity, ordinal: ordinal))
483
+ foldersAdded += 1
484
+ }
485
+ var added = 0
486
+ for row in pageRows where !row.isFolder && !seen.contains(row.identity) {
487
+ seen.insert(row.identity)
488
+ added += 1
489
+ let parts = row.metadata.split(separator: " ").map(String.init)
490
+ let contentType = parts.first ?? ""
491
+ let addedDate = parts.first(where: { firstMatch(datePattern, $0) }) ?? ""
492
+ let before = Set(windows(app).map(windowSignature))
493
+ guard let knowledgeBeforeOpen = knowledgeWindow(app) else {
494
+ throw DriverError(code: "KNOWLEDGE_WINDOW_LOST", message: "Knowledge-base window disappeared")
495
+ }
496
+ var url: String? = nil
497
+ let expectsURL = contentType == "公众号" || contentType == "网页"
498
+ if press(row.element), let opened = readPublicURL(
499
+ app: app,
500
+ excluding: before,
501
+ knowledgeBeforeOpen: knowledgeBeforeOpen,
502
+ expectsURL: expectsURL
503
+ ) {
504
+ url = opened.1
505
+ if !closeArticlePage(app: app, opened.0, reusedKnowledgeWindow: opened.2) {
506
+ throw DriverError(code: "TAB_CLOSE_FAILED", message: "Could not close article tab '\(row.title)' safely")
507
+ }
508
+ }
509
+ output.append([
510
+ "knowledgeBaseId": knowledge.id,
511
+ "knowledgeBase": knowledge.name,
512
+ "folderPath": folderPath,
513
+ "title": row.title,
514
+ "url": url ?? NSNull(),
515
+ "contentType": contentType,
516
+ "addedDate": addedDate
517
+ ])
518
+ }
519
+ if added == 0 && foldersAdded == 0 { idleRounds += 1 } else { idleRounds = 0 }
520
+ guard let current = knowledgeWindow(app), scrollPage(current) else { break }
521
+ pause(0.8)
522
+ }
523
+ return output
524
+ }
525
+
526
+ func scrapeFolderRecursive(app: AXUIElement, knowledge: Candidate, folderPath: [String], depth: Int = 0) throws -> [[String: Any]] {
527
+ guard depth < 20, knowledgeWindow(app) != nil else { return [] }
528
+ var folderTargets: [FolderTarget] = []
529
+ var output = try scrapeCurrentFolder(
530
+ app: app,
531
+ knowledge: knowledge,
532
+ folderPath: folderPath,
533
+ folderTargets: &folderTargets
534
+ )
535
+ for folderTarget in folderTargets {
536
+ guard let folder = findFolderRow(app: app, target: folderTarget), press(folder.element) else {
537
+ throw DriverError(code: "FOLDER_NAVIGATION_FAILED", message: "Could not open folder '\(folderTarget.title)'")
538
+ }
539
+ pause(1)
540
+ output.append(contentsOf: try scrapeFolderRecursive(
541
+ app: app,
542
+ knowledge: knowledge,
543
+ folderPath: folderPath + [folderTarget.title],
544
+ depth: depth + 1
545
+ ))
546
+ let parentName = folderPath.last ?? knowledge.name
547
+ guard let childWindow = knowledgeWindow(app),
548
+ let parentCrumb = exactElements(childWindow, parentName).last,
549
+ press(parentCrumb) else {
550
+ throw DriverError(code: "FOLDER_NAVIGATION_FAILED", message: "Could not return to folder '\(parentName)'")
551
+ }
552
+ pause(1)
553
+ }
554
+ return output
555
+ }
556
+
557
+ func scrapeKnowledge(app: AXUIElement, knowledge: Candidate) throws -> [[String: Any]] {
558
+ return try scrapeFolderRecursive(app: app, knowledge: knowledge, folderPath: [])
559
+ }
560
+
561
+ func emit(_ value: [String: Any]) {
562
+ let data = try! JSONSerialization.data(withJSONObject: value, options: [])
563
+ print(String(data: data, encoding: .utf8)!)
564
+ }
565
+
566
+ do {
567
+ guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
568
+ throw DriverError(code: "EMPTY_QUERY", message: "Knowledge-base name or ID is required")
569
+ }
570
+ guard AXIsProcessTrusted() else {
571
+ throw DriverError(code: "ACCESSIBILITY_PERMISSION_REQUIRED", message: "Enable Accessibility access for the terminal running bycli")
572
+ }
573
+ let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
574
+ guard !runningApps.isEmpty else {
575
+ throw DriverError(code: "IMA_NOT_RUNNING", message: "Open ima before running this command")
576
+ }
577
+ var selected: (NSRunningApplication, AXUIElement)? = nil
578
+ for running in runningApps where !running.isTerminated {
579
+ let candidate = AXUIElementCreateApplication(running.processIdentifier)
580
+ if !windows(candidate).isEmpty { selected = (running, candidate); break }
581
+ }
582
+ guard let (running, app) = selected else {
583
+ throw DriverError(code: "IMA_NOT_RUNNING", message: "ima is running but has no visible window")
584
+ }
585
+ running.activate()
586
+ let window = try navigateToKnowledge(app: app)
587
+ let knowledge = try selectKnowledge(app: app, window: window)
588
+ guard let contentWindow = knowledgeWindow(app),
589
+ descendants(contentWindow).contains(where: { role($0) == "AXWebArea" }) else {
590
+ throw DriverError(
591
+ code: "ACCESSIBILITY_CONTENT_UNAVAILABLE",
592
+ message: "ima's article list is not exposed to macOS Accessibility; quit ima and reopen it with --force-renderer-accessibility"
593
+ )
594
+ }
595
+ let items = try scrapeKnowledge(app: app, knowledge: knowledge)
596
+ emit(["ok": true, "knowledgeBaseId": knowledge.id, "knowledgeBase": knowledge.name, "items": items])
597
+ } catch let error as DriverError {
598
+ emit(["ok": false, "code": error.code, "message": error.message])
599
+ } catch {
600
+ emit(["ok": false, "code": "AX_DRIVER_FAILED", "message": String(describing: error)])
601
+ }
602
+ `;
603
+
604
+ export function parseDriverEnvelope(output) {
605
+ const lines = String(output).trim().split(/\r?\n/).reverse();
606
+ let envelope;
607
+ for (const line of lines) {
608
+ try {
609
+ envelope = JSON.parse(line);
610
+ break;
611
+ } catch {
612
+ // Swift may print compiler diagnostics around the single JSON result.
613
+ }
614
+ }
615
+ if (!envelope || typeof envelope !== 'object') throw new Error('ima driver returned invalid JSON');
616
+ if (typeof envelope.ok !== 'boolean') throw new Error('ima driver response is missing ok flag');
617
+ return envelope;
618
+ }
619
+
620
+ export function readKnowledgeBase(query) {
621
+ if (process.platform !== 'darwin') {
622
+ return { ok: false, code: 'UNSUPPORTED_PLATFORM', message: 'The ima adapter currently requires macOS' };
623
+ }
624
+ try {
625
+ execFileSync('pgrep', ['-x', 'ima.copilot'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
626
+ } catch {
627
+ execFileSync('open', ['-a', 'ima.copilot', '--args', '--force-renderer-accessibility'], {
628
+ encoding: 'utf8',
629
+ stdio: ['ignore', 'pipe', 'pipe'],
630
+ });
631
+ execFileSync('osascript', ['-e', 'delay 2'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
632
+ }
633
+ try {
634
+ execFileSync('osascript', OPEN_KNOWLEDGE_APPLESCRIPT.flatMap((line) => ['-e', line]), {
635
+ encoding: 'utf8',
636
+ timeout: 15_000,
637
+ stdio: ['ignore', 'pipe', 'pipe'],
638
+ });
639
+ } catch {
640
+ // The Swift driver below returns the actionable typed error.
641
+ }
642
+ const output = execFileSync('swift', ['-', String(query)], {
643
+ input: AX_KNOWLEDGE_SCRIPT,
644
+ encoding: 'utf8',
645
+ timeout: 30 * 60 * 1000,
646
+ maxBuffer: 50 * 1024 * 1024,
647
+ stdio: ['pipe', 'pipe', 'pipe'],
648
+ });
649
+ const envelope = parseDriverEnvelope(output);
650
+ if (Array.isArray(envelope.items)) {
651
+ envelope.items = envelope.items.map((item) => ({ ...item, url: normalizeArticleUrl(item.url) }));
652
+ }
653
+ return envelope;
654
+ }
655
+
656
+ export const __test__ = { AX_KNOWLEDGE_SCRIPT, OPEN_KNOWLEDGE_APPLESCRIPT };
@@ -0,0 +1,48 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import { CommandExecutionError, ConfigError } from '@sovovs/bycli/errors';
3
+
4
+ import { readKnowledgeBasesFromChrome } from './native-client.js';
5
+
6
+ const COMMAND = 'ima knowledge-list';
7
+
8
+ function throwDriverError(error) {
9
+ if (error?.code === 'IMA_CHROME_AUTH_REQUIRED') {
10
+ throw new ConfigError(
11
+ error.message || 'ima reader authentication is required',
12
+ 'Open https://ima.qq.com/wikis in Chrome, sign in, then retry with the latest bycli Browser Bridge extension.',
13
+ );
14
+ }
15
+ throw new CommandExecutionError(
16
+ error?.message || `${COMMAND} failed to read knowledge bases`,
17
+ );
18
+ }
19
+
20
+ export async function runKnowledgeListCommand(read) {
21
+ try {
22
+ const items = await read();
23
+ if (!Array.isArray(items)) {
24
+ throw new CommandExecutionError(`${COMMAND} returned malformed knowledge-base rows`);
25
+ }
26
+ return items;
27
+ } catch (error) {
28
+ if (error instanceof ConfigError || error instanceof CommandExecutionError) throw error;
29
+ throwDriverError(error);
30
+ }
31
+ }
32
+
33
+ export const knowledgeListCommand = cli({
34
+ site: 'ima',
35
+ name: 'knowledge-list',
36
+ access: 'read',
37
+ description: '获取全部 ima 知识库的常用字段与原始元数据',
38
+ domain: 'ima.qq.com',
39
+ defaultFormat: 'json',
40
+ args: [],
41
+ columns: ['id', 'name', 'type', 'typeName', 'raw'],
42
+ strategy: Strategy.COOKIE,
43
+ browser: true,
44
+ navigateBefore: false,
45
+ func: async (page) => runKnowledgeListCommand(
46
+ () => readKnowledgeBasesFromChrome(page),
47
+ ),
48
+ });
@@ -0,0 +1,118 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import {
3
+ ArgumentError,
4
+ CommandExecutionError,
5
+ ConfigError,
6
+ EmptyResultError,
7
+ } from '@sovovs/bycli/errors';
8
+
9
+ import { readKnowledgeBaseFromChrome } from './native-client.js';
10
+ import { toKnowledgeRow } from './utils.js';
11
+
12
+ const COMMAND = 'ima knowledge';
13
+
14
+ function throwDriverError(envelope) {
15
+ const message = envelope?.message || 'ima reader failed';
16
+ switch (envelope?.code) {
17
+ case 'EMPTY_QUERY':
18
+ case 'AMBIGUOUS_KNOWLEDGE':
19
+ throw new ArgumentError(message);
20
+ case 'KNOWLEDGE_NOT_FOUND':
21
+ throw new EmptyResultError(COMMAND, message);
22
+ case 'IMA_CHROME_AUTH_REQUIRED':
23
+ throw new ConfigError(
24
+ message,
25
+ 'Open https://ima.qq.com/wikis in Chrome, sign in, then retry with the latest bycli Browser Bridge extension.',
26
+ );
27
+ default:
28
+ throw new CommandExecutionError(message);
29
+ }
30
+ }
31
+
32
+ export async function runKnowledgeCommand(kwargs, read) {
33
+ const query = String(kwargs?.knowledgeBase ?? '').trim();
34
+ if (!query) {
35
+ throw new ArgumentError('knowledge-base name or ID is required');
36
+ }
37
+
38
+ let envelope;
39
+ try {
40
+ envelope = await read(query);
41
+ } catch (error) {
42
+ if (error instanceof ArgumentError || error instanceof ConfigError
43
+ || error instanceof EmptyResultError || error instanceof CommandExecutionError) {
44
+ throw error;
45
+ }
46
+ if (error && typeof error === 'object' && typeof error.code === 'string') {
47
+ throwDriverError({ code: error.code, message: error.message });
48
+ }
49
+ throw new CommandExecutionError(
50
+ `ima knowledge reader failed: ${error instanceof Error ? error.message : String(error)}`,
51
+ );
52
+ }
53
+ if (!envelope?.ok) throwDriverError(envelope);
54
+ if (!Array.isArray(envelope.items)) {
55
+ throw new CommandExecutionError('ima knowledge reader returned malformed items');
56
+ }
57
+ if (envelope.items.length === 0) {
58
+ throw new EmptyResultError(COMMAND, `Knowledge base "${query}" contains no readable articles.`);
59
+ }
60
+ return envelope.items.map(toKnowledgeRow);
61
+ }
62
+
63
+ export const knowledgeCommand = cli({
64
+ site: 'ima',
65
+ name: 'knowledge',
66
+ access: 'read',
67
+ description: '按名称或 ID 获取 ima 知识库中的文章标题、URL 与文件夹路径',
68
+ domain: 'ima.qq.com',
69
+ defaultFormat: 'json',
70
+ args: [
71
+ {
72
+ name: 'knowledgeBase',
73
+ type: 'string',
74
+ required: true,
75
+ positional: true,
76
+ help: '知识库的完整名称或 ima 页面中的 knowledgeBaseId',
77
+ },
78
+ ],
79
+ columns: [
80
+ 'knowledgeBaseId',
81
+ 'knowledgeBase',
82
+ 'folderPath',
83
+ 'title',
84
+ 'url',
85
+ 'contentType',
86
+ 'addedDate',
87
+ 'mediaId',
88
+ 'mediaType',
89
+ 'mediaState',
90
+ 'mediaAuditStatus',
91
+ 'mediaTypeInfo',
92
+ 'sourcePath',
93
+ 'jumpUrl',
94
+ 'createTime',
95
+ 'updateTime',
96
+ 'lastModifyTime',
97
+ 'lastOpenTime',
98
+ 'fileSize',
99
+ 'abstract',
100
+ 'introduction',
101
+ 'tags',
102
+ 'isTop',
103
+ 'accessStatus',
104
+ 'accessStatusUpdateTs',
105
+ 'parseProgress',
106
+ 'parseErrInfo',
107
+ 'summaryState',
108
+ 'coverUrls',
109
+ 'logo',
110
+ ],
111
+ strategy: Strategy.COOKIE,
112
+ browser: true,
113
+ navigateBefore: false,
114
+ func: async (page, kwargs) => runKnowledgeCommand(
115
+ kwargs,
116
+ (query) => readKnowledgeBaseFromChrome(page, query),
117
+ ),
118
+ });
@@ -0,0 +1,226 @@
1
+ const MEDIA_TYPE_NAMES = new Map([
2
+ [0, '未知'], [1, 'PDF'], [2, '网址'], [3, 'WORD'], [4, 'PPT'],
3
+ [5, 'EXCEL'], [6, '公众号'], [7, 'MD'], [9, '图片'], [11, '笔记'],
4
+ [12, '问答'], [13, 'TXT'], [14, 'XMIND'], [15, '音频'], [16, '视频网站'],
5
+ [19, '播客'], [20, 'HTML'], [21, 'EPUB'], [98, '源代码'], [99, '文件夹'],
6
+ ]);
7
+ const KNOWLEDGE_BASE_TYPE_NAMES = new Map([
8
+ [1001, '我的知识库'], [1002, '共享知识库'],
9
+ [1004, '团队知识库'], [1005, '其他知识库'],
10
+ ]);
11
+ function field(value, camelName, snakeName) {
12
+ return value?.[camelName] ?? value?.[snakeName];
13
+ }
14
+
15
+ function codedError(code, message) {
16
+ return Object.assign(new Error(message), { code });
17
+ }
18
+
19
+ function knowledgeBaseFromRaw(raw) {
20
+ const basicInfo = field(raw, 'basicInfo', 'basic_info') || {};
21
+ return {
22
+ id: String(field(raw, 'id', 'id') || ''),
23
+ name: String(field(basicInfo, 'name', 'name') || ''),
24
+ };
25
+ }
26
+
27
+ function knowledgeBaseRow(raw, type) {
28
+ return {
29
+ ...knowledgeBaseFromRaw(raw),
30
+ type,
31
+ typeName: KNOWLEDGE_BASE_TYPE_NAMES.get(type) ?? String(type),
32
+ raw,
33
+ };
34
+ }
35
+
36
+ function basesFromGroups(response) {
37
+ const groups = field(response, 'results', 'results');
38
+ if (!Array.isArray(groups)) throw new Error('ima API returned malformed knowledge-base groups');
39
+ return groups.flatMap((group) => {
40
+ const list = field(group, 'knowledgeBaseList', 'knowledge_base_list');
41
+ return Array.isArray(list) ? list.map(knowledgeBaseFromRaw) : [];
42
+ });
43
+ }
44
+
45
+ function entriesFromGroups(response) {
46
+ const groups = field(response, 'results', 'results');
47
+ if (!Array.isArray(groups)) throw new Error('ima API returned malformed knowledge-base groups');
48
+ return groups.flatMap((group) => {
49
+ const list = field(group, 'knowledgeBaseList', 'knowledge_base_list');
50
+ const type = Number(field(group, 'type', 'type'));
51
+ return Array.isArray(list) ? list.map((raw) => ({ raw, type })) : [];
52
+ });
53
+ }
54
+
55
+ async function collectKnowledgeBaseEntries(request) {
56
+ const initialGroups = [
57
+ { type: 1001, limit: 20 },
58
+ { type: 1002, limit: 20 },
59
+ { type: 1004, limit: 20 },
60
+ { type: 1005, limit: 50 },
61
+ ];
62
+ let response = await request('/get_knowledge_base_list', {
63
+ params: initialGroups.map(({ type, limit }) => ({ type, cursor: '', limit })),
64
+ });
65
+ const all = [];
66
+ const pendingPages = [];
67
+ const queuedPages = new Set();
68
+
69
+ for (;;) {
70
+ if (Number(response?.code) !== 0) {
71
+ throw new Error(response?.msg || `ima API error ${response?.code ?? 'unknown'}`);
72
+ }
73
+ all.push(...entriesFromGroups(response));
74
+ const groups = field(response, 'results', 'results');
75
+ for (const group of groups) {
76
+ const cursor = field(group, 'nextCursor', 'next_cursor');
77
+ if (field(group, 'isEnd', 'is_end') === false) {
78
+ const type = Number(field(group, 'type', 'type'));
79
+ if (!cursor) {
80
+ throw new Error(`ima API returned a missing cursor for knowledge-base group ${type}`);
81
+ }
82
+ const pageKey = `${type}:${cursor}`;
83
+ if (queuedPages.has(pageKey)) {
84
+ throw new Error(`ima API returned a repeated cursor for knowledge-base group ${type}`);
85
+ }
86
+ queuedPages.add(pageKey);
87
+ pendingPages.push({ type, cursor: String(cursor), limit: 10 });
88
+ }
89
+ }
90
+ const next = pendingPages.shift();
91
+ if (!next) break;
92
+ response = await request('/get_knowledge_base_list', { params: [next] });
93
+ }
94
+ return all;
95
+ }
96
+
97
+ export async function listKnowledgeBases(request) {
98
+ const entries = await collectKnowledgeBaseEntries(request);
99
+ const unique = new Map();
100
+ for (const { raw, type } of entries) {
101
+ const row = knowledgeBaseRow(raw, type);
102
+ if (!unique.has(row.id)) unique.set(row.id, row);
103
+ }
104
+ return [...unique.values()];
105
+ }
106
+
107
+ export async function findKnowledgeBase(query, request) {
108
+ const all = (await collectKnowledgeBaseEntries(request))
109
+ .map(({ raw }) => knowledgeBaseFromRaw(raw));
110
+
111
+ const matches = all.filter((base) => base.id === query || base.name === query);
112
+ if (matches.length === 0) {
113
+ throw codedError('KNOWLEDGE_NOT_FOUND', `Knowledge base "${query}" was not found`);
114
+ }
115
+ const unique = [...new Map(matches.map((base) => [base.id, base])).values()];
116
+ if (unique.length > 1) {
117
+ throw codedError('AMBIGUOUS_KNOWLEDGE', `Knowledge base name "${query}" is ambiguous`);
118
+ }
119
+ return unique[0];
120
+ }
121
+
122
+ function articleFromRaw(raw, knowledgeBaseId, knowledgeBaseName, folderPath) {
123
+ const mediaType = Number(field(raw, 'mediaType', 'media_type') ?? 0);
124
+ const jumpUrl = field(raw, 'jumpUrl', 'jump_url');
125
+ const sourcePath = field(raw, 'sourcePath', 'source_path');
126
+ return {
127
+ knowledgeBaseId,
128
+ knowledgeBase: knowledgeBaseName,
129
+ folderPath,
130
+ title: field(raw, 'title', 'title') || '',
131
+ url: jumpUrl || (/^https?:\/\//i.test(sourcePath || '') ? sourcePath : null),
132
+ contentType: MEDIA_TYPE_NAMES.get(mediaType) ?? String(mediaType),
133
+ addedDate: field(raw, 'timeWording', 'time_wording')
134
+ || field(raw, 'createTime', 'create_time')
135
+ || null,
136
+ mediaId: field(raw, 'mediaId', 'media_id') ?? null,
137
+ mediaType,
138
+ mediaState: field(raw, 'mediaState', 'media_state') ?? null,
139
+ mediaAuditStatus: field(raw, 'mediaAuditStatus', 'media_audit_status') ?? null,
140
+ mediaTypeInfo: field(raw, 'mediaTypeInfo', 'media_type_info') ?? null,
141
+ sourcePath: sourcePath ?? null,
142
+ jumpUrl: jumpUrl ?? null,
143
+ createTime: field(raw, 'createTime', 'create_time') ?? null,
144
+ updateTime: field(raw, 'updateTime', 'update_time') ?? null,
145
+ lastModifyTime: field(raw, 'lastModifyTime', 'last_modify_time') ?? null,
146
+ lastOpenTime: field(raw, 'lastOpenTime', 'last_open_time') ?? null,
147
+ fileSize: field(raw, 'fileSize', 'file_size') ?? null,
148
+ abstract: field(raw, 'abstract', 'abstract') ?? null,
149
+ introduction: field(raw, 'introduction', 'introduction') ?? null,
150
+ tags: Array.isArray(field(raw, 'tags', 'tags')) ? field(raw, 'tags', 'tags') : [],
151
+ isTop: field(raw, 'isTop', 'is_top') ?? null,
152
+ accessStatus: field(raw, 'accessStatus', 'access_status') ?? null,
153
+ accessStatusUpdateTs: field(raw, 'accessStatusUpdateTs', 'access_status_update_ts') ?? null,
154
+ parseProgress: field(raw, 'parseProgress', 'parse_progress') ?? null,
155
+ parseErrInfo: field(raw, 'parseErrInfo', 'parse_err_info') ?? null,
156
+ summaryState: field(raw, 'summaryState', 'summary_state') ?? null,
157
+ coverUrls: Array.isArray(field(raw, 'coverUrls', 'cover_urls'))
158
+ ? field(raw, 'coverUrls', 'cover_urls') : [],
159
+ logo: field(raw, 'logo', 'logo') ?? null,
160
+ };
161
+ }
162
+
163
+ export async function collectKnowledgeTree({ knowledgeBaseId, knowledgeBaseName, request }) {
164
+ const articles = [];
165
+ const pendingFolders = [{ id: '', path: [] }];
166
+ const visitedFolders = new Set();
167
+
168
+ while (pendingFolders.length > 0) {
169
+ const folder = pendingFolders.shift();
170
+ if (visitedFolders.has(folder.id)) continue;
171
+ visitedFolders.add(folder.id);
172
+ let cursor = '';
173
+ const visitedCursors = new Set();
174
+
175
+ do {
176
+ if (visitedCursors.has(cursor)) {
177
+ throw new Error(`ima API returned a repeated cursor for folder ${folder.id || 'root'}`);
178
+ }
179
+ visitedCursors.add(cursor);
180
+ const response = await request('/get_knowledge_list', {
181
+ cursor,
182
+ limit: 20,
183
+ knowledge_base_id: knowledgeBaseId,
184
+ need_default_cover: true,
185
+ ...(folder.id ? { folder_id: folder.id } : {}),
186
+ ext_info: { share_id: '' },
187
+ });
188
+ if (Number(response?.code) !== 0) {
189
+ throw new Error(response?.msg || `ima API error ${response?.code ?? 'unknown'}`);
190
+ }
191
+
192
+ const items = field(response, 'knowledgeList', 'knowledge_list');
193
+ if (!Array.isArray(items)) throw new Error('ima API returned malformed knowledge_list');
194
+ for (const item of items) {
195
+ const mediaType = Number(field(item, 'mediaType', 'media_type') ?? 0);
196
+ if (mediaType === 99) {
197
+ const info = field(item, 'folderInfo', 'folder_info') || {};
198
+ const id = field(info, 'folderId', 'folder_id');
199
+ const name = field(info, 'name', 'name');
200
+ if (id && name) pendingFolders.push({ id, path: [...folder.path, name] });
201
+ continue;
202
+ }
203
+ articles.push(articleFromRaw(item, knowledgeBaseId, knowledgeBaseName, folder.path));
204
+ }
205
+
206
+ const isEnd = field(response, 'isEnd', 'is_end') !== false;
207
+ cursor = String(field(response, 'nextCursor', 'next_cursor') || '');
208
+ if (!isEnd && !cursor) {
209
+ throw new Error(`ima API returned a missing cursor for folder ${folder.id || 'root'}`);
210
+ }
211
+ if (isEnd) cursor = '';
212
+ } while (cursor);
213
+ }
214
+
215
+ return articles;
216
+ }
217
+
218
+ export async function readKnowledgeBaseFromApi(query, request) {
219
+ const knowledgeBase = await findKnowledgeBase(query, request);
220
+ const items = await collectKnowledgeTree({
221
+ knowledgeBaseId: knowledgeBase.id,
222
+ knowledgeBaseName: knowledgeBase.name,
223
+ request,
224
+ });
225
+ return { ok: true, items };
226
+ }
@@ -0,0 +1,86 @@
1
+ import { listKnowledgeBases, readKnowledgeBaseFromApi } from './native-api.js';
2
+
3
+ const IMA_WIKIS_URL = 'https://ima.qq.com/wikis';
4
+
5
+ function codedError(code, message) {
6
+ return Object.assign(new Error(message), { code });
7
+ }
8
+
9
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
10
+
11
+ async function waitForImaAuth(page, timeoutMs, sleep) {
12
+ const deadline = Date.now() + timeoutMs;
13
+ do {
14
+ const auth = await page.readImaAuth();
15
+ if (auth?.authId) return auth.authId;
16
+ if (Date.now() >= deadline) break;
17
+ await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
18
+ } while (true);
19
+ throw codedError(
20
+ 'IMA_CHROME_AUTH_REQUIRED',
21
+ 'ima reader authentication was not observed in the Chrome session',
22
+ );
23
+ }
24
+
25
+ async function triggerImaAuthRequest(page, query) {
26
+ await page.evaluate((knowledgeBase) => {
27
+ const candidates = [...document.querySelectorAll('._knowledgeListItem_xfmpc_1')];
28
+ const target = candidates.find((element) => element.innerText?.trim() === knowledgeBase)
29
+ ?? candidates[0];
30
+ if (!target) return false;
31
+ target.click();
32
+ return true;
33
+ }, query);
34
+ }
35
+
36
+ async function acquireImaChromeAuth(page, query, dependencies = {}) {
37
+ if (!page || typeof page.startImaAuthCapture !== 'function'
38
+ || typeof page.readImaAuth !== 'function' || typeof page.requestImaReader !== 'function'
39
+ || typeof page.evaluate !== 'function') {
40
+ throw codedError(
41
+ 'IMA_CHROME_AUTH_REQUIRED',
42
+ 'The installed bycli Browser Bridge does not support private ima reader authentication',
43
+ );
44
+ }
45
+ const timeoutMs = dependencies.timeoutMs ?? 30_000;
46
+ const sleep = dependencies.sleep ?? wait;
47
+ try {
48
+ await page.startImaAuthCapture();
49
+ await page.goto(IMA_WIKIS_URL);
50
+ await triggerImaAuthRequest(page, query);
51
+ return await waitForImaAuth(page, timeoutMs, sleep);
52
+ } catch (error) {
53
+ if (error?.code === 'IMA_CHROME_AUTH_REQUIRED') throw error;
54
+ throw codedError(
55
+ 'IMA_CHROME_AUTH_REQUIRED',
56
+ `Chrome Browser Bridge could not acquire ima reader authentication: ${error instanceof Error ? error.message : String(error)}`,
57
+ );
58
+ }
59
+ }
60
+
61
+ async function releaseImaAuth(page, authId) {
62
+ if (typeof page.releaseImaAuth === 'function') {
63
+ await page.releaseImaAuth(authId).catch(() => {});
64
+ }
65
+ }
66
+
67
+ export async function readKnowledgeBasesFromChrome(page, dependencies = {}) {
68
+ const authId = await acquireImaChromeAuth(page, undefined, dependencies);
69
+ try {
70
+ return await listKnowledgeBases((path, body) => page.requestImaReader(authId, path, body));
71
+ } finally {
72
+ await releaseImaAuth(page, authId);
73
+ }
74
+ }
75
+
76
+ export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}) {
77
+ const authId = await acquireImaChromeAuth(page, query, dependencies);
78
+ try {
79
+ return await readKnowledgeBaseFromApi(
80
+ query,
81
+ (path, body) => page.requestImaReader(authId, path, body),
82
+ );
83
+ } finally {
84
+ await releaseImaAuth(page, authId);
85
+ }
86
+ }
@@ -0,0 +1,49 @@
1
+ const VOLATILE_QUERY_KEYS = new Set([
2
+ 'sessionid',
3
+ 'pass_ticket',
4
+ 'exportkey',
5
+ 'scene',
6
+ 'ascene',
7
+ 'devicetype',
8
+ 'version',
9
+ 'nettype',
10
+ 'abtest_cookie',
11
+ 'lang',
12
+ 'countrycode',
13
+ 'fontscale',
14
+ 'wx_header',
15
+ ]);
16
+
17
+ export function normalizeArticleUrl(value) {
18
+ if (typeof value !== 'string' || !value.trim()) return null;
19
+
20
+ try {
21
+ const raw = value.trim();
22
+ const parsed = new URL(raw.includes('://') ? raw : `https://${raw}`);
23
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
24
+
25
+ parsed.hash = '';
26
+ for (const key of [...parsed.searchParams.keys()]) {
27
+ const normalizedKey = key.toLowerCase();
28
+ if (VOLATILE_QUERY_KEYS.has(normalizedKey) || normalizedKey.startsWith('utm_')) {
29
+ parsed.searchParams.delete(key);
30
+ }
31
+ }
32
+ return parsed.toString();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function toKnowledgeRow(item) {
39
+ return {
40
+ ...item,
41
+ knowledgeBaseId: item.knowledgeBaseId || null,
42
+ knowledgeBase: item.knowledgeBase,
43
+ folderPath: Array.isArray(item.folderPath) ? item.folderPath : [],
44
+ title: item.title,
45
+ url: normalizeArticleUrl(item.url),
46
+ contentType: item.contentType || null,
47
+ addedDate: item.addedDate || null,
48
+ };
49
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export interface DaemonCommand {
7
7
  id: string;
8
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
8
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'ima-auth-start' | 'ima-auth-read' | 'ima-reader-request' | 'ima-auth-release' | 'wait-download' | 'cdp' | 'frames';
9
9
  /** Target page identity (targetId). Cross-layer contract with the extension. */
10
10
  page?: string;
11
11
  code?: string;
@@ -32,6 +32,9 @@ export interface DaemonCommand {
32
32
  text?: string;
33
33
  /** URL substring filter pattern for network capture */
34
34
  pattern?: string;
35
+ authId?: string;
36
+ readerPath?: string;
37
+ readerBody?: Record<string, unknown>;
35
38
  /** Download wait timeout in milliseconds */
36
39
  timeoutMs?: number;
37
40
  cdpMethod?: string;
@@ -58,6 +58,12 @@ export declare class Page extends BasePage {
58
58
  screenshot(options?: ScreenshotOptions): Promise<string>;
59
59
  startNetworkCapture(pattern?: string): Promise<boolean>;
60
60
  readNetworkCapture(): Promise<unknown[]>;
61
+ startImaAuthCapture(): Promise<void>;
62
+ readImaAuth(): Promise<{
63
+ authId: string;
64
+ } | null>;
65
+ requestImaReader(authId: string, path: string, body: Record<string, unknown>): Promise<unknown>;
66
+ releaseImaAuth(authId: string): Promise<void>;
61
67
  waitForDownload(pattern?: string, timeoutMs?: number, options?: BrowserDownloadWaitOptions): Promise<BrowserDownloadWaitResult>;
62
68
  /**
63
69
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
@@ -305,6 +305,26 @@ export class Page extends BasePage {
305
305
  return [];
306
306
  }
307
307
  }
308
+ async startImaAuthCapture() {
309
+ await sendCommand('ima-auth-start', this._cmdOpts());
310
+ }
311
+ async readImaAuth() {
312
+ const result = await sendCommand('ima-auth-read', this._cmdOpts());
313
+ if (!result || typeof result !== 'object' || typeof result.authId !== 'string')
314
+ return null;
315
+ return { authId: result.authId };
316
+ }
317
+ async requestImaReader(authId, path, body) {
318
+ return sendCommand('ima-reader-request', {
319
+ authId,
320
+ readerPath: path,
321
+ readerBody: body,
322
+ ...this._cmdOpts(),
323
+ });
324
+ }
325
+ async releaseImaAuth(authId) {
326
+ await sendCommand('ima-auth-release', { authId, ...this._cmdOpts() });
327
+ }
308
328
  async waitForDownload(pattern = '', timeoutMs = 30_000, options) {
309
329
  const result = await sendCommand('wait-download', {
310
330
  pattern,
@@ -203,6 +203,12 @@ export interface IPage {
203
203
  annotatedScreenshot?(options?: ScreenshotOptions): Promise<string>;
204
204
  startNetworkCapture?(pattern?: string): Promise<boolean>;
205
205
  readNetworkCapture?(): Promise<unknown[]>;
206
+ startImaAuthCapture?(): Promise<void>;
207
+ readImaAuth?(): Promise<{
208
+ authId: string;
209
+ } | null>;
210
+ requestImaReader?(authId: string, path: string, body: Record<string, unknown>): Promise<unknown>;
211
+ releaseImaAuth?(authId: string): Promise<void>;
206
212
  /**
207
213
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
208
214
  * Chrome reads the files directly — no base64 encoding or payload size limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.38",
3
+ "version": "2.1.40",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },