@sovovs/bycli 2.1.38 → 2.1.39
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 +32 -0
- package/clis/ima/ax.js +656 -0
- package/clis/ima/knowledge.js +95 -0
- package/clis/ima/native-api.js +169 -0
- package/clis/ima/native-client.js +70 -0
- package/clis/ima/utils.js +48 -0
- package/dist/src/browser/daemon-client.d.ts +4 -1
- package/dist/src/browser/page.d.ts +6 -0
- package/dist/src/browser/page.js +20 -0
- package/dist/src/types.d.ts +6 -0
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -13546,6 +13546,38 @@
|
|
|
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
|
+
],
|
|
13575
|
+
"defaultFormat": "json",
|
|
13576
|
+
"type": "js",
|
|
13577
|
+
"modulePath": "ima/knowledge.js",
|
|
13578
|
+
"sourceFile": "ima/knowledge.js",
|
|
13579
|
+
"navigateBefore": false
|
|
13580
|
+
},
|
|
13549
13581
|
{
|
|
13550
13582
|
"site": "imdb",
|
|
13551
13583
|
"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,95 @@
|
|
|
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
|
+
],
|
|
88
|
+
strategy: Strategy.COOKIE,
|
|
89
|
+
browser: true,
|
|
90
|
+
navigateBefore: false,
|
|
91
|
+
func: async (page, kwargs) => runKnowledgeCommand(
|
|
92
|
+
kwargs,
|
|
93
|
+
(query) => readKnowledgeBaseFromChrome(page, query),
|
|
94
|
+
),
|
|
95
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
function field(value, camelName, snakeName) {
|
|
8
|
+
return value?.[camelName] ?? value?.[snakeName];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function codedError(code, message) {
|
|
12
|
+
return Object.assign(new Error(message), { code });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function knowledgeBaseFromRaw(raw) {
|
|
16
|
+
const basicInfo = field(raw, 'basicInfo', 'basic_info') || {};
|
|
17
|
+
return {
|
|
18
|
+
id: String(field(raw, 'id', 'id') || ''),
|
|
19
|
+
name: String(field(basicInfo, 'name', 'name') || ''),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function basesFromGroups(response) {
|
|
24
|
+
const groups = field(response, 'results', 'results');
|
|
25
|
+
if (!Array.isArray(groups)) throw new Error('ima API returned malformed knowledge-base groups');
|
|
26
|
+
return groups.flatMap((group) => {
|
|
27
|
+
const list = field(group, 'knowledgeBaseList', 'knowledge_base_list');
|
|
28
|
+
return Array.isArray(list) ? list.map(knowledgeBaseFromRaw) : [];
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function findKnowledgeBase(query, request) {
|
|
33
|
+
const initialGroups = [
|
|
34
|
+
{ type: 1001, limit: 20 },
|
|
35
|
+
{ type: 1002, limit: 20 },
|
|
36
|
+
{ type: 1004, limit: 20 },
|
|
37
|
+
{ type: 1005, limit: 50 },
|
|
38
|
+
];
|
|
39
|
+
let response = await request('/get_knowledge_base_list', {
|
|
40
|
+
params: initialGroups.map(({ type, limit }) => ({ type, cursor: '', limit })),
|
|
41
|
+
});
|
|
42
|
+
const all = [];
|
|
43
|
+
const pendingPages = [];
|
|
44
|
+
const queuedPages = new Set();
|
|
45
|
+
|
|
46
|
+
for (;;) {
|
|
47
|
+
if (Number(response?.code) !== 0) {
|
|
48
|
+
throw new Error(response?.msg || `ima API error ${response?.code ?? 'unknown'}`);
|
|
49
|
+
}
|
|
50
|
+
all.push(...basesFromGroups(response));
|
|
51
|
+
const groups = field(response, 'results', 'results');
|
|
52
|
+
for (const group of groups) {
|
|
53
|
+
const cursor = field(group, 'nextCursor', 'next_cursor');
|
|
54
|
+
if (field(group, 'isEnd', 'is_end') === false) {
|
|
55
|
+
const type = Number(field(group, 'type', 'type'));
|
|
56
|
+
if (!cursor) {
|
|
57
|
+
throw new Error(`ima API returned a missing cursor for knowledge-base group ${type}`);
|
|
58
|
+
}
|
|
59
|
+
const pageKey = `${type}:${cursor}`;
|
|
60
|
+
if (queuedPages.has(pageKey)) {
|
|
61
|
+
throw new Error(`ima API returned a repeated cursor for knowledge-base group ${type}`);
|
|
62
|
+
}
|
|
63
|
+
queuedPages.add(pageKey);
|
|
64
|
+
pendingPages.push({
|
|
65
|
+
type,
|
|
66
|
+
cursor: String(cursor),
|
|
67
|
+
limit: 10,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const next = pendingPages.shift();
|
|
72
|
+
if (!next) break;
|
|
73
|
+
response = await request('/get_knowledge_base_list', {
|
|
74
|
+
params: [next],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const matches = all.filter((base) => base.id === query || base.name === query);
|
|
79
|
+
if (matches.length === 0) {
|
|
80
|
+
throw codedError('KNOWLEDGE_NOT_FOUND', `Knowledge base "${query}" was not found`);
|
|
81
|
+
}
|
|
82
|
+
const unique = [...new Map(matches.map((base) => [base.id, base])).values()];
|
|
83
|
+
if (unique.length > 1) {
|
|
84
|
+
throw codedError('AMBIGUOUS_KNOWLEDGE', `Knowledge base name "${query}" is ambiguous`);
|
|
85
|
+
}
|
|
86
|
+
return unique[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function articleFromRaw(raw, knowledgeBaseId, knowledgeBaseName, folderPath) {
|
|
90
|
+
const mediaType = Number(field(raw, 'mediaType', 'media_type') ?? 0);
|
|
91
|
+
const jumpUrl = field(raw, 'jumpUrl', 'jump_url');
|
|
92
|
+
const sourcePath = field(raw, 'sourcePath', 'source_path');
|
|
93
|
+
return {
|
|
94
|
+
knowledgeBaseId,
|
|
95
|
+
knowledgeBase: knowledgeBaseName,
|
|
96
|
+
folderPath,
|
|
97
|
+
title: field(raw, 'title', 'title') || '',
|
|
98
|
+
url: jumpUrl || (/^https?:\/\//i.test(sourcePath || '') ? sourcePath : null),
|
|
99
|
+
contentType: MEDIA_TYPE_NAMES.get(mediaType) ?? String(mediaType),
|
|
100
|
+
addedDate: field(raw, 'timeWording', 'time_wording')
|
|
101
|
+
|| field(raw, 'createTime', 'create_time')
|
|
102
|
+
|| null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function collectKnowledgeTree({ knowledgeBaseId, knowledgeBaseName, request }) {
|
|
107
|
+
const articles = [];
|
|
108
|
+
const pendingFolders = [{ id: '', path: [] }];
|
|
109
|
+
const visitedFolders = new Set();
|
|
110
|
+
|
|
111
|
+
while (pendingFolders.length > 0) {
|
|
112
|
+
const folder = pendingFolders.shift();
|
|
113
|
+
if (visitedFolders.has(folder.id)) continue;
|
|
114
|
+
visitedFolders.add(folder.id);
|
|
115
|
+
let cursor = '';
|
|
116
|
+
const visitedCursors = new Set();
|
|
117
|
+
|
|
118
|
+
do {
|
|
119
|
+
if (visitedCursors.has(cursor)) {
|
|
120
|
+
throw new Error(`ima API returned a repeated cursor for folder ${folder.id || 'root'}`);
|
|
121
|
+
}
|
|
122
|
+
visitedCursors.add(cursor);
|
|
123
|
+
const response = await request('/get_knowledge_list', {
|
|
124
|
+
cursor,
|
|
125
|
+
limit: 20,
|
|
126
|
+
knowledge_base_id: knowledgeBaseId,
|
|
127
|
+
need_default_cover: true,
|
|
128
|
+
...(folder.id ? { folder_id: folder.id } : {}),
|
|
129
|
+
ext_info: { share_id: '' },
|
|
130
|
+
});
|
|
131
|
+
if (Number(response?.code) !== 0) {
|
|
132
|
+
throw new Error(response?.msg || `ima API error ${response?.code ?? 'unknown'}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const items = field(response, 'knowledgeList', 'knowledge_list');
|
|
136
|
+
if (!Array.isArray(items)) throw new Error('ima API returned malformed knowledge_list');
|
|
137
|
+
for (const item of items) {
|
|
138
|
+
const mediaType = Number(field(item, 'mediaType', 'media_type') ?? 0);
|
|
139
|
+
if (mediaType === 99) {
|
|
140
|
+
const info = field(item, 'folderInfo', 'folder_info') || {};
|
|
141
|
+
const id = field(info, 'folderId', 'folder_id');
|
|
142
|
+
const name = field(info, 'name', 'name');
|
|
143
|
+
if (id && name) pendingFolders.push({ id, path: [...folder.path, name] });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
articles.push(articleFromRaw(item, knowledgeBaseId, knowledgeBaseName, folder.path));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isEnd = field(response, 'isEnd', 'is_end') !== false;
|
|
150
|
+
cursor = String(field(response, 'nextCursor', 'next_cursor') || '');
|
|
151
|
+
if (!isEnd && !cursor) {
|
|
152
|
+
throw new Error(`ima API returned a missing cursor for folder ${folder.id || 'root'}`);
|
|
153
|
+
}
|
|
154
|
+
if (isEnd) cursor = '';
|
|
155
|
+
} while (cursor);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return articles;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function readKnowledgeBaseFromApi(query, request) {
|
|
162
|
+
const knowledgeBase = await findKnowledgeBase(query, request);
|
|
163
|
+
const items = await collectKnowledgeTree({
|
|
164
|
+
knowledgeBaseId: knowledgeBase.id,
|
|
165
|
+
knowledgeBaseName: knowledgeBase.name,
|
|
166
|
+
request,
|
|
167
|
+
});
|
|
168
|
+
return { ok: true, items };
|
|
169
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { 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
|
+
export async function readKnowledgeBaseFromChrome(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
|
+
let authId;
|
|
48
|
+
try {
|
|
49
|
+
await page.startImaAuthCapture();
|
|
50
|
+
await page.goto(IMA_WIKIS_URL);
|
|
51
|
+
await triggerImaAuthRequest(page, query);
|
|
52
|
+
authId = await waitForImaAuth(page, timeoutMs, sleep);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error?.code === 'IMA_CHROME_AUTH_REQUIRED') throw error;
|
|
55
|
+
throw codedError(
|
|
56
|
+
'IMA_CHROME_AUTH_REQUIRED',
|
|
57
|
+
`Chrome Browser Bridge could not acquire ima reader authentication: ${error instanceof Error ? error.message : String(error)}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return await readKnowledgeBaseFromApi(
|
|
62
|
+
query,
|
|
63
|
+
(path, body) => page.requestImaReader(authId, path, body),
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
if (typeof page.releaseImaAuth === 'function') {
|
|
67
|
+
await page.releaseImaAuth(authId).catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
knowledgeBaseId: item.knowledgeBaseId || null,
|
|
41
|
+
knowledgeBase: item.knowledgeBase,
|
|
42
|
+
folderPath: Array.isArray(item.folderPath) ? item.folderPath : [],
|
|
43
|
+
title: item.title,
|
|
44
|
+
url: normalizeArticleUrl(item.url),
|
|
45
|
+
contentType: item.contentType || null,
|
|
46
|
+
addedDate: item.addedDate || null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -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.
|
package/dist/src/browser/page.js
CHANGED
|
@@ -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,
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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.
|