@sovovs/bycli 2.1.37 → 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/README.md +22 -0
- package/README.zh-CN.md +21 -0
- package/cli-manifest.json +626 -0
- package/clis/52pojie/search.js +32 -0
- package/clis/baidu/search.js +68 -0
- package/clis/bing/search.js +58 -0
- package/clis/csdn/search.js +34 -0
- package/clis/gitlab/search.js +36 -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/clis/so/search.js +36 -0
- package/clis/sogou/search.js +35 -0
- package/clis/threads/search.js +33 -0
- package/clis/yandex/search.js +43 -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/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 };
|