@nextclaw/x-harness 0.1.0 → 0.2.0

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.
Files changed (113) hide show
  1. package/LICENSE +231 -6
  2. package/README.md +17 -3
  3. package/SOURCE.md +9 -0
  4. package/cli/main.js +232 -188
  5. package/dist/api.d.ts +56 -2
  6. package/dist/api.js +178 -75
  7. package/dist/boundary.js +1 -0
  8. package/dist/browser-common.d.ts +11 -0
  9. package/dist/browser-common.js +34 -18
  10. package/dist/browser-dom-capabilities.d.ts +9 -0
  11. package/dist/browser-dom-capabilities.js +131 -0
  12. package/dist/browser-dom-dependencies.d.ts +16 -0
  13. package/dist/browser-dom-dependencies.js +1 -0
  14. package/dist/browser-dom-editing.d.ts +166 -0
  15. package/dist/browser-dom-editing.js +418 -0
  16. package/dist/browser-dom-navigation.d.ts +18 -0
  17. package/dist/browser-dom-navigation.js +181 -0
  18. package/dist/browser-dom-network.d.ts +1 -0
  19. package/dist/browser-dom-network.js +128 -0
  20. package/dist/browser-dom-submission.d.ts +7 -0
  21. package/dist/browser-dom-submission.js +101 -0
  22. package/dist/browser-dom.d.ts +14 -2
  23. package/dist/browser-dom.js +120 -824
  24. package/dist/browser-native.d.ts +14 -1
  25. package/dist/browser-native.js +180 -143
  26. package/dist/browser.js +6 -2
  27. package/dist/creator-native.d.ts +3 -0
  28. package/dist/creator-native.js +113 -0
  29. package/dist/creator.js +1 -1
  30. package/dist/diagnostics.d.ts +11 -0
  31. package/dist/diagnostics.js +10 -0
  32. package/dist/growth-batch.d.ts +8 -0
  33. package/dist/growth-batch.js +190 -0
  34. package/dist/growth-constants.d.ts +5 -0
  35. package/dist/growth-constants.js +13 -0
  36. package/dist/growth-dependencies.d.ts +33 -0
  37. package/dist/growth-dependencies.js +1 -0
  38. package/dist/growth-execution.d.ts +17 -0
  39. package/dist/growth-execution.js +346 -0
  40. package/dist/growth-parallel.d.ts +8 -0
  41. package/dist/growth-parallel.js +332 -0
  42. package/dist/growth-plan.d.ts +9 -0
  43. package/dist/growth-plan.js +229 -0
  44. package/dist/growth-policy.d.ts +18 -0
  45. package/dist/growth-policy.js +208 -0
  46. package/dist/growth-publishing.d.ts +10 -0
  47. package/dist/growth-publishing.js +266 -0
  48. package/dist/growth-reading.d.ts +8 -0
  49. package/dist/growth-reading.js +157 -0
  50. package/dist/growth-recovery.d.ts +9 -0
  51. package/dist/growth-recovery.js +187 -0
  52. package/dist/growth-state.d.ts +16 -0
  53. package/dist/growth-state.js +220 -0
  54. package/dist/growth-tasks.d.ts +20 -0
  55. package/dist/growth-tasks.js +164 -0
  56. package/dist/growth.d.ts +26 -1
  57. package/dist/growth.js +211 -1908
  58. package/dist/platform-capabilities.d.ts +8 -0
  59. package/dist/platform-capabilities.js +29 -0
  60. package/dist/platform-collections.d.ts +14 -0
  61. package/dist/platform-collections.js +250 -0
  62. package/dist/platform-dependencies.d.ts +43 -0
  63. package/dist/platform-dependencies.js +1 -0
  64. package/dist/platform-diagnostics.d.ts +14 -0
  65. package/dist/platform-diagnostics.js +63 -0
  66. package/dist/platform-observation.d.ts +16 -0
  67. package/dist/platform-observation.js +96 -0
  68. package/dist/platform-rules.d.ts +17 -0
  69. package/dist/platform-rules.js +41 -0
  70. package/dist/platform-targets.d.ts +14 -0
  71. package/dist/platform-targets.js +154 -0
  72. package/dist/platform-writing.d.ts +16 -0
  73. package/dist/platform-writing.js +202 -0
  74. package/dist/platform.d.ts +23 -2
  75. package/dist/platform.js +125 -678
  76. package/dist/readers-actions.d.ts +2 -0
  77. package/dist/readers-actions.js +66 -0
  78. package/dist/readers-ax-host.d.ts +12 -0
  79. package/dist/readers-ax-host.js +134 -0
  80. package/dist/readers-ax-parser.d.ts +1 -0
  81. package/dist/readers-ax-parser.js +69 -0
  82. package/dist/readers-ax-program.d.ts +1 -0
  83. package/dist/readers-ax-program.js +5 -0
  84. package/dist/readers-ax-tree.d.ts +19 -0
  85. package/dist/readers-ax-tree.js +111 -0
  86. package/dist/readers-ax-views.d.ts +87 -0
  87. package/dist/readers-ax-views.js +247 -0
  88. package/dist/readers-cli.d.ts +6 -0
  89. package/dist/readers-cli.js +79 -0
  90. package/dist/readers-composer.d.ts +2 -0
  91. package/dist/readers-composer.js +131 -0
  92. package/dist/readers-navigation.d.ts +2 -0
  93. package/dist/readers-navigation.js +66 -0
  94. package/dist/readers-posts.d.ts +2 -0
  95. package/dist/readers-posts.js +141 -0
  96. package/dist/readers-profile.d.ts +3 -0
  97. package/dist/readers-profile.js +157 -0
  98. package/dist/readers.d.ts +9 -20
  99. package/dist/readers.js +9 -1045
  100. package/dist/results.js +1 -1
  101. package/dist/round-state.js +20 -3
  102. package/dist/store.d.ts +22 -4
  103. package/dist/store.js +173 -151
  104. package/dist/types.d.ts +10 -0
  105. package/dist/types.js +1 -0
  106. package/generated/observation.mjs +3 -2
  107. package/package.json +23 -9
  108. package/skill/SKILL.md +17 -36
  109. package/skill/references/api.md +7 -34
  110. package/skill/references/reads.md +23 -0
  111. package/skill/references/recovery.md +25 -0
  112. package/skill/references/setup.md +23 -0
  113. package/skill/references/writes.md +51 -0
package/dist/readers.js CHANGED
@@ -1,1045 +1,9 @@
1
- // Self-contained, read-only DOM function for cua_repl's documented evaluate.
2
- export function readPosts({ offset = 0, limit = 5, targetId = "", } = {}) {
3
- if (!Number.isInteger(offset) ||
4
- offset < 0 ||
5
- !Number.isInteger(limit) ||
6
- limit < 1 ||
7
- limit > 5)
8
- throw new Error("offset>=0;limit为1~5");
9
- if (targetId && !/^\d+$/.test(targetId))
10
- throw new Error("targetId必须是数字公开ID");
11
- const visible = (el) => el.getClientRects().length > 0;
12
- const primaryTime = (a) => Array.from(a.querySelectorAll("time")).find((t) => visible(t) &&
13
- /\/status\/\d+(?:$|[/?#])/.test(t.closest("a")?.getAttribute("href") ?? "")) ?? null;
14
- const all = Array.from(document.querySelectorAll("article")).filter(visible);
15
- const candidates = targetId
16
- ? all.filter((a) => {
17
- const primary = primaryTime(a)?.closest("a")?.getAttribute("href");
18
- return (primary && new RegExp(`/status/${targetId}(?:$|[/?#])`).test(primary));
19
- })
20
- : all;
21
- const posts = candidates.slice(offset, offset + limit).map((a) => {
22
- const time = primaryTime(a);
23
- const primary = time?.closest("a")?.getAttribute("href") ?? null;
24
- const links = [
25
- ...new Set(Array.from(a.querySelectorAll("a[href]"))
26
- .filter(visible)
27
- .map((l) => l.getAttribute("href"))),
28
- ];
29
- const buttons = Array.from(a.querySelectorAll("button"))
30
- .filter(visible)
31
- .map((b) => ({
32
- label: b.getAttribute("aria-label") || b.innerText,
33
- disabled: b.hasAttribute("disabled"),
34
- }));
35
- const text = a.innerText;
36
- const datetime = time?.getAttribute("datetime") ?? null;
37
- const id = primary?.match(/\/status\/(\d+)/)?.[1] ?? null;
38
- const author = primary?.match(/\/([^/]+)\/status\//)?.[1] ?? null;
39
- const images = Array.from(a.querySelectorAll('[data-testid="tweetPhoto"] img'))
40
- .filter(visible)
41
- .map((img) => ({
42
- alt: img.getAttribute("alt"),
43
- src: img.getAttribute("src"),
44
- }));
45
- const media = Array.from(a.querySelectorAll('[data-testid="videoPlayer"],video'))
46
- .filter(visible)
47
- .map((el) => ({
48
- kind: "video",
49
- label: el.getAttribute("aria-label"),
50
- poster: el.getAttribute("poster"),
51
- requiresViewing: true,
52
- }));
53
- return {
54
- id,
55
- author,
56
- datetime,
57
- primary,
58
- text,
59
- links,
60
- buttons,
61
- // View/quote counts may be visible links rather than buttons; collect in this same read.
62
- metrics: [
63
- ...buttons,
64
- ...Array.from(a.querySelectorAll('a[href$="/analytics"],a[href$="/quotes"]'))
65
- .filter(visible)
66
- .map((link) => ({
67
- label: link.getAttribute("aria-label") ||
68
- `${link.getAttribute("href").endsWith("/analytics") ? "观看" : "引用"} ${link.innerText}`,
69
- })),
70
- ],
71
- images,
72
- media,
73
- needsExpansion: /显示更多|Show more/i.test(text),
74
- missing: [
75
- !id && "id",
76
- !author && "author",
77
- !datetime && "datetime",
78
- !text && "text",
79
- ].filter(Boolean),
80
- };
81
- });
82
- const versionLinks = Array.from(document.querySelectorAll("a[href]"))
83
- .filter(visible)
84
- .filter((a) => /最新版本|查看最新帖子|Latest version|View latest/i.test(a.innerText))
85
- .slice(0, 5)
86
- .map((a) => ({ label: a.innerText, href: a.getAttribute("href") }));
87
- const emptyEvidence = all.length === 0
88
- ? [
89
- ...new Set(Array.from(document.querySelectorAll("main span"))
90
- .filter(visible)
91
- .map((e) => e.innerText?.trim())
92
- .filter((text) => text &&
93
- text.length <= 400 &&
94
- !text.includes("\n") &&
95
- /^(?:没有.*结果|未找到.*结果|No results for)/i.test(text))),
96
- ].slice(0, 2)
97
- : [];
98
- return {
99
- renderedArticles: all.length,
100
- matched: candidates.length,
101
- offset,
102
- moreInDOM: candidates.length > offset + posts.length,
103
- pageComplete: false,
104
- needsStateCheck: all.length === 0 && emptyEvidence.length === 0,
105
- posts,
106
- versionLinks,
107
- emptyEvidence,
108
- alerts: Array.from(document.querySelectorAll('[role="alert"]'))
109
- .filter(visible)
110
- .map((e) => e.innerText),
111
- };
112
- }
113
- export function changes(result, cache, page, fresh = false) {
114
- const unchangedIds = [];
115
- const posts = result.posts.filter((post) => {
116
- if (!post.id)
117
- return true;
118
- const key = `${page}|${post.id}`;
119
- const value = JSON.stringify(post);
120
- const changed = cache[key] !== value;
121
- cache[key] = value;
122
- if (!fresh && !changed)
123
- unchangedIds.push(post.id);
124
- return fresh || changed;
125
- });
126
- const outputChars = JSON.stringify({ ...result, posts, unchangedIds }).length;
127
- const optimizationHint = outputChars > 8000
128
- ? posts.length > 1
129
- ? "缩小读取窗口或按已观察ID定向读取;保留完整正文"
130
- : "单篇证据本身长,保留全文,不截断"
131
- : null;
132
- return {
133
- ...result,
134
- posts,
135
- unchanged: result.posts.length - posts.length,
136
- unchangedIds,
137
- fresh,
138
- outputChars,
139
- optimizationHint,
140
- };
141
- }
142
- // Native Chrome fallback: process a suppressed full AX observation in the REPL.
143
- // Keeps visible leaf evidence and action indexes; never reconstructs a diff as a full page.
144
- export function readAX(state, { mode = "posts", offset = 0, limit = 1, targetId = "", targetHandle = "", } = {}) {
145
- if (!["posts", "composer", "profile", "people", "navigation"].includes(mode))
146
- throw new Error("AX mode必须为posts/composer/profile/people/navigation");
147
- if (typeof state !== "string")
148
- throw new Error("AX state必须为完整字符串");
149
- if (!Number.isInteger(offset) ||
150
- offset < 0 ||
151
- !Number.isInteger(limit) ||
152
- limit < 1 ||
153
- limit > 5 ||
154
- (targetId && !/^\d+$/.test(targetId)))
155
- throw new Error("无效AX读取窗口或ID");
156
- if (targetHandle && (mode !== "people" || !/^\w{1,15}$/.test(targetHandle)))
157
- throw new Error("targetHandle必须为people模式已观察的handle");
158
- const alertLines = state
159
- .split("\n")
160
- .filter((line) => /抱歉.*限制|速度限制|请求异常|访问受限|验证码|验证挑战|登录以继续|captcha|rate limit/i.test(line));
161
- const unknown = (reason) => ({
162
- mode,
163
- posts: [],
164
- evidence: [],
165
- alerts: alertLines,
166
- missing: [reason],
167
- needsStateCheck: true,
168
- pageComplete: false,
169
- });
170
- if (/following is a diff|Removed element IDs:|no change in the accessibility tree/i.test(state))
171
- return unknown("full-ax-required");
172
- const page = state.match(/URL:\s*((?:https?:\/\/)?x\.com\/[^\s,]*)/)?.[1];
173
- if (!page && mode !== "navigation")
174
- return unknown("x-page-unconfirmed");
175
- const nodes = [], stack = [];
176
- for (const line of state.split("\n")) {
177
- const match = line.match(/^(\s*)(\d+) (.*)$/);
178
- // Multiline post text such as "37 位中文主样本" is not an AX node.
179
- if (!match ||
180
- (!match[1] && !/^(?:标准窗口|window|menu bar)(?: |$)/i.test(match[3]))) {
181
- if (nodes.length &&
182
- line.trim() &&
183
- !/^The focused UI|Window:|Wall time:|Output:/.test(line))
184
- nodes.at(-1).label += "\n" + line;
185
- continue;
186
- }
187
- const depth = match[1].replace(/\t/g, " ").length;
188
- while (stack.length && nodes[stack.at(-1)].depth >= depth)
189
- stack.pop();
190
- // Chrome tab AX uses English role names; native app AX uses the canonical labels below.
191
- const label = match[3]
192
- .replace(/^link Description: /, "link ")
193
- .replace(/^combo box(?: \([^)]*\))? (?:Description: )?/, "组合框 ")
194
- .replace(/^button(?: \((?![^)]*disabled)[^)]*\))? /, "按钮 ")
195
- .replace(/^text entry area(?: \(settable\))? Description: /, "文本输入区域 ");
196
- const node = {
197
- index: Number(match[2]),
198
- label,
199
- depth,
200
- parent: stack.at(-1) ?? null,
201
- };
202
- nodes.push(node);
203
- stack.push(nodes.length - 1);
204
- }
205
- const descendants = (i) => {
206
- let end = i + 1;
207
- while (end < nodes.length && nodes[end].depth > nodes[i].depth)
208
- end++;
209
- return nodes.slice(i + 1, end);
210
- };
211
- const leaf = (n) => /^(?:文本(?: |$)|text |link |按钮 |弹出式按钮 |切换按钮 |图像 |文本输入区(?:域)? |文本区域 |文本栏 |组合框 |单选按钮 |复选框 )/.test(n.label) || /^container [\d,.万亿KMB]+ 次(?:观看|查看)$/.test(n.label);
212
- const evidence = (ns) => ns.filter(leaf).map(({ index, label }) => ({ index, label }));
213
- // Missing business evidence must not force callers to print the entire AX tree.
214
- const navigation = nodes
215
- .filter((n) => /^(?:标准窗口|window|HTML 内容) .*URL:/.test(n.label) ||
216
- /^文本栏 .*地址和搜索栏/.test(n.label) ||
217
- /^按钮 (?:重新加载|新标签页)$/.test(n.label) ||
218
- n.label === "窗口" ||
219
- /^标签 \(selected/.test(n.label) ||
220
- /^(?:tab|标签)(?: \([^)]*\))? (?:帖子|回复|转帖|媒体|文章|Posts|Replies|Reposts|Media|Articles)(?:,|$)/.test(n.label) ||
221
- /^组合框 (?:查询词条|Search)(?:,|$)/.test(n.label) ||
222
- /^link (?:主页|搜索和发现|通知|个人资料), Value: x\.com\//.test(n.label))
223
- .map(({ index, label }) => ({ index, label }));
224
- const roots = [];
225
- const within = (node, root) => {
226
- let p = node.parent;
227
- while (p !== null) {
228
- if (p === root)
229
- return true;
230
- p = nodes[p].parent;
231
- }
232
- return false;
233
- };
234
- nodes.forEach((n, i) => {
235
- // Chrome's window container also contains @handle and every post link.
236
- if (!/^(?:container |article ).*@\w+/.test(n.label) ||
237
- /\bURL:/.test(n.label))
238
- return;
239
- if (roots.some((root) => within(n, root)))
240
- return;
241
- if (descendants(i).some((d) => /^link /.test(d.label) && /x\.com\/\w+\/status\/\d+/.test(d.label)))
242
- roots.push(i);
243
- });
244
- const peopleTimeline = nodes.findIndex((n) => /^container 时间线:(认证关注者|关注者|正在关注)$/.test(n.label));
245
- const peopleRoots = peopleTimeline < 0
246
- ? []
247
- : nodes
248
- .map((n, i) => ({ n, i }))
249
- .filter(({ n, i }) => within(n, peopleTimeline) &&
250
- /^按钮 .*@\w+(?: |$)/.test(n.label) &&
251
- descendants(i).some((d) => /^link @\w+, Value: x\.com\/\w+(?:,|$)/.test(d.label)))
252
- .map(({ i }) => i);
253
- const warning = /抱歉.*限制|速度限制|请求异常|访问受限|验证码|验证挑战|登录以继续|captcha|rate limit/i;
254
- const explicitAlert = (n) => {
255
- let current = n;
256
- while (current) {
257
- if (/^(?:alert|警告|对话框|dialog|status|状态)(?: |$)/i.test(current.label))
258
- return true;
259
- current = current.parent === null ? null : nodes[current.parent];
260
- }
261
- return /^(?:文本输入区(?:域)?|文本区域|文本栏|按钮) /i.test(n.label);
262
- };
263
- const alerts = nodes
264
- .filter((n, i) => warning.test(n.label) &&
265
- (![...roots, ...peopleRoots].some((root) => i === root || within(n, root)) ||
266
- explicitAlert(n)) &&
267
- !descendants(i).some((d) => warning.test(d.label)))
268
- .map((n) => ({ index: n.index, label: n.label }));
269
- if (mode === "navigation")
270
- return {
271
- mode,
272
- page,
273
- posts: [],
274
- evidence: navigation,
275
- alerts,
276
- pageComplete: false,
277
- needsStateCheck: !navigation.length,
278
- missing: navigation.length ? [] : ["navigation-not-found"],
279
- };
280
- if (mode === "people") {
281
- const all = peopleRoots.map((i) => ({
282
- handle: nodes[i].label.match(/@(\w+)/)[1],
283
- evidence: evidence(descendants(i)),
284
- }));
285
- const matches = targetHandle
286
- ? all.filter((p) => p.handle.toLowerCase() === targetHandle.toLowerCase())
287
- : all;
288
- const missing = peopleTimeline < 0
289
- ? ["people-timeline-not-found"]
290
- : !matches.length
291
- ? ["person-not-found"]
292
- : targetHandle && matches.length > 1
293
- ? ["person-ambiguous"]
294
- : [];
295
- return {
296
- mode,
297
- page,
298
- posts: [],
299
- people: missing.length ? [] : matches.slice(offset, offset + limit),
300
- renderedPeople: all.length,
301
- matched: matches.length,
302
- offset,
303
- moreInDOM: matches.length > offset + limit,
304
- alerts,
305
- navigation: missing.length ? navigation : undefined,
306
- missing,
307
- needsStateCheck: !!missing.length,
308
- pageComplete: false,
309
- };
310
- }
311
- if (mode === "posts") {
312
- const all = roots.map((i) => {
313
- const postNodes = descendants(i);
314
- const items = evidence(postNodes);
315
- const authorHandle = nodes[i].label.match(/@(\w+)/)?.[1];
316
- const primary = items.find((n) => /^link /.test(n.label) &&
317
- n.label
318
- .match(/x\.com\/(\w+)\/status\/\d+(?:[?#][^\s,]*)?(?:,|$)/)?.[1]
319
- ?.toLowerCase() === authorHandle?.toLowerCase());
320
- const edited = postNodes.filter((n) => /^link 这是该帖子的最新版本。/.test(n.label) &&
321
- n.label
322
- .match(/x\.com\/(\w+)\/status\/\d+\/history(?:,|$)/)?.[1]
323
- ?.toLowerCase() === authorHandle?.toLowerCase() &&
324
- !postNodes.some((q) => /^(?:container |article )引用(?: |$)/.test(q.label) &&
325
- within(n, nodes.indexOf(q))));
326
- const source = primary ?? (edited.length === 1 ? edited[0] : null);
327
- const match = source?.label.match(/x\.com\/(\w+)\/status\/(\d+)/);
328
- const text = items
329
- .filter((n) => /^(?:文本 |text )/.test(n.label))
330
- .map((n) => n.label.replace(/^(?:文本 |text )/, ""))
331
- .join("\n");
332
- return {
333
- id: match?.[2] ?? null,
334
- author: match?.[1] ?? null,
335
- primary: source?.label ?? null,
336
- datetime: null,
337
- timeEvidence: source?.label ?? null,
338
- text,
339
- evidence: items,
340
- needsExpansion: /显示更多|Show more/i.test(text) ||
341
- items.some((n) => /显示更多|Show more/i.test(n.label)),
342
- missing: [!match && "id", !text && "text", "datetime"].filter(Boolean),
343
- };
344
- });
345
- const candidates = targetId
346
- ? all.filter((p) => p.id === targetId)
347
- : all;
348
- return {
349
- mode,
350
- page,
351
- renderedArticles: all.length,
352
- matched: candidates.length,
353
- offset,
354
- moreInDOM: candidates.length > offset + limit,
355
- pageComplete: false,
356
- needsStateCheck: candidates.length === 0,
357
- missing: candidates.length ? [] : ["post-not-found"],
358
- posts: candidates.slice(offset, offset + limit),
359
- alerts,
360
- navigation: candidates.length ? undefined : navigation,
361
- };
362
- }
363
- if (mode === "profile") {
364
- const start = nodes.findIndex((n) => n.label === "container 主页时间线");
365
- if (start < 0)
366
- return { ...unknown("profile-not-found"), page, alerts, navigation };
367
- const main = descendants(start), end = main.findIndex((n) => /container 个人资料时间线/.test(n.label));
368
- if (end < 0)
369
- return {
370
- ...unknown("profile-boundary-not-found"),
371
- page,
372
- alerts,
373
- navigation,
374
- };
375
- return {
376
- mode,
377
- page,
378
- posts: [],
379
- evidence: evidence(main.slice(0, end)),
380
- alerts,
381
- pageComplete: false,
382
- needsStateCheck: false,
383
- };
384
- }
385
- const dialog = nodes.findIndex((n) => /^(?:对话框|dialog)(?: |$)/.test(n.label));
386
- if (dialog < 0) {
387
- const editors = nodes.filter((n) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(n.label));
388
- const submits = nodes.filter((n) => /^按钮 (?:回复|Reply)(?:,|$)/i.test(n.label));
389
- if (/x\.com\/\w+\/status\/\d+(?:[?#]|$)/.test(page ?? "") &&
390
- editors.length === 1 &&
391
- submits.length === 1) {
392
- return {
393
- mode,
394
- page,
395
- inlineComposer: true,
396
- posts: [],
397
- evidence: evidence([...editors, ...submits]),
398
- alerts,
399
- pageComplete: false,
400
- needsStateCheck: false,
401
- };
402
- }
403
- return {
404
- ...unknown("composer-dialog-not-found"),
405
- page,
406
- alerts,
407
- navigation,
408
- };
409
- }
410
- const items = evidence(descendants(dialog));
411
- if (!items.some((n) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(n.label)) ||
412
- !items.some((n) => /^按钮 (?:发帖|发布|回复|Post|Reply)(?:,|$)/i.test(n.label))) {
413
- return {
414
- ...unknown("composer-boundary-unconfirmed"),
415
- page,
416
- alerts,
417
- navigation,
418
- };
419
- }
420
- return {
421
- mode,
422
- page,
423
- posts: [],
424
- evidence: items,
425
- alerts,
426
- pageComplete: false,
427
- needsStateCheck: false,
428
- };
429
- }
430
- // Native operations share one observation boundary. No raw AX leaves this session.
431
- // Browser Tab input takes (index, value), while native App input takes (value).
432
- export function createAXTabHarness(tab) {
433
- return createAXHarness({
434
- getAXState: (options) => tab.getAXState(options),
435
- click: (index) => tab.click(index),
436
- pressKey: (key) => tab.pressKey(null, key),
437
- paste: (value) => tab.paste(null, value),
438
- scroll: (...args) => tab.scroll(...args),
439
- getScreenshot: (options) => tab.getScreenshot(options),
440
- });
441
- }
442
- export function createAXHarness(app) {
443
- let controls = new Map(), readable = false, busy = false;
444
- const requireMethods = (methods) => {
445
- const missing = methods.filter((name) => typeof app?.[name] !== "function");
446
- if (missing.length) {
447
- controls.clear();
448
- readable = false;
449
- throw new Error(`harness能力不足:${missing.join("/")};先补原入口并验证,未执行操作`);
450
- }
451
- };
452
- const indexes = (value) => {
453
- if (!value || typeof value !== "object")
454
- return;
455
- if (Number.isInteger(value.index) && typeof value.label === "string")
456
- controls.set(value.index, value.label);
457
- for (const child of Object.values(value))
458
- indexes(child);
459
- };
460
- const observe = async (options = {}) => {
461
- controls.clear();
462
- readable = false;
463
- readAX("", options);
464
- requireMethods(["getAXState"]);
465
- const result = readAX(await app.getAXState({ emit: false, disableDiffing: true }), options);
466
- indexes(result);
467
- readable = true;
468
- return result;
469
- };
470
- return {
471
- async observe(options = {}) {
472
- if (busy)
473
- throw new Error("AX操作进行中,禁止并发读取");
474
- busy = true;
475
- try {
476
- return await observe(options);
477
- }
478
- finally {
479
- busy = false;
480
- }
481
- },
482
- async act({ index, operation = "click", value, read = {}, } = {}) {
483
- if (busy)
484
- throw new Error("AX操作进行中,禁止并发写入");
485
- if (!readable || !controls.has(index))
486
- throw new Error("必须先observe;index须来自最新定向证据");
487
- if (!["click", "fill", "paste", "pressKey", "scroll", "secondary"].includes(operation))
488
- throw new Error("未封装的AX操作,先补能力并验证");
489
- // Validate before any side effect; a malformed follow-up read must never submit.
490
- readAX("", read);
491
- if (["fill", "paste", "pressKey", "secondary"].includes(operation) &&
492
- typeof value !== "string")
493
- throw new Error("AX操作value须为字符串");
494
- if (["fill", "paste", "pressKey"].includes(operation) &&
495
- !/^(?:文本输入区(?:域)?|文本区域|文本栏|组合框) /.test(controls.get(index)))
496
- throw new Error("键盘操作须定位最新证据中的编辑控件");
497
- if (operation === "secondary" &&
498
- !controls.get(index).includes(`Secondary Actions: ${value}`))
499
- throw new Error("未观察到该辅助动作");
500
- if (operation === "scroll" &&
501
- !["up", "down", "left", "right"].includes(value))
502
- throw new Error("无效滚动方向");
503
- const methods = operation === "fill"
504
- ? ["click", "pressKey", "paste"]
505
- : ["paste", "pressKey"].includes(operation)
506
- ? ["click", operation]
507
- : [
508
- operation === "secondary"
509
- ? "performSecondaryAction"
510
- : operation,
511
- ];
512
- requireMethods([...methods, "getAXState"]);
513
- busy = true;
514
- controls.clear();
515
- readable = false;
516
- try {
517
- if (operation === "click")
518
- await app.click(index);
519
- else if (["fill", "paste", "pressKey"].includes(operation)) {
520
- await app.click(index);
521
- if (operation === "fill") {
522
- await app.pressKey("super+a");
523
- await app.paste(value);
524
- }
525
- else
526
- await app[operation](value);
527
- }
528
- else if (operation === "scroll")
529
- await app.scroll(index, value, 1);
530
- else
531
- await app.performSecondaryAction(index, value);
532
- return await observe(read);
533
- }
534
- finally {
535
- busy = false;
536
- }
537
- },
538
- async screenshot() {
539
- if (busy)
540
- throw new Error("AX操作进行中,禁止并发截图");
541
- requireMethods(["getScreenshot"]);
542
- busy = true;
543
- controls.clear();
544
- readable = false;
545
- try {
546
- return await app.getScreenshot({ emit: false });
547
- }
548
- finally {
549
- busy = false;
550
- }
551
- },
552
- };
553
- }
554
- export function setupCode() {
555
- return `var xReadPosts = ${readPosts.toString()};\nvar xReadChanges = ${changes.toString()};\nvar xReadProfile = ${readProfile.toString()};\nvar xScreenCandidates = ${screenCandidates.toString()};\nvar xReadAX = ${readAX.toString()};\nvar xCreateAXHarness = (function(readAX) { return ${createAXHarness.toString()}; })(xReadAX);\nvar xAXSessions = {};\nvar xReadCache = {};\n`;
556
- }
557
- // Profile selectors are the visible controls observed in the live X workflow.
558
- // Missing/ambiguous counters remain unknown; rounded counts carry their precision.
559
- export function readProfile({ handle } = {}) {
560
- if (!/^[A-Za-z0-9_]{1,15}$/.test(handle ?? ""))
561
- throw new Error("需要已观察的X handle");
562
- const visible = (el) => el.getClientRects().length > 0;
563
- const main = document.querySelector("main");
564
- if (!main)
565
- return {
566
- handle,
567
- blue: null,
568
- relationship: "unknown",
569
- alerts: ["missing-main"],
570
- observedAt: new Date().toISOString(),
571
- };
572
- const buttons = Array.from(main.querySelectorAll("button")).filter(visible);
573
- const labels = buttons.map((b) => b.getAttribute("aria-label") || b.innerText);
574
- const relationship = labels.includes(`正在关注 @${handle}`) ||
575
- labels.includes(`取消关注 @${handle}`)
576
- ? "following"
577
- : labels.includes(`回关 @${handle}`)
578
- ? "follows-you"
579
- : labels.includes(`关注 @${handle}`)
580
- ? "not-following"
581
- : "unknown";
582
- const badges = buttons
583
- .filter((b) => b.getAttribute("aria-label") === "提供已验证账号的详细信息。")
584
- .map((b) => b.querySelector("svg"))
585
- .filter(Boolean);
586
- const blue = badges.length === 1
587
- ? [
588
- getComputedStyle(badges[0]).color,
589
- getComputedStyle(badges[0]).fill,
590
- ].includes("rgb(29, 155, 240)")
591
- : null;
592
- const links = Array.from(main.querySelectorAll("a[href]")).filter(visible);
593
- const counter = (suffixes) => {
594
- const matched = links.filter((a) => suffixes.some((s) => a.getAttribute("href")?.toLowerCase() ===
595
- `/${handle}/${s}`.toLowerCase()));
596
- const texts = [...new Set(matched.map((a) => a.innerText.trim()))];
597
- if (texts.length !== 1)
598
- return { value: null, text: texts, precision: "unknown" };
599
- const match = texts[0].match(/^([\d,.]+)\s*(万|亿|[KMB])?/i);
600
- const factors = {
601
- 万: 1e4,
602
- 亿: 1e8,
603
- K: 1e3,
604
- M: 1e6,
605
- B: 1e9,
606
- };
607
- const value = match
608
- ? Number(match[1].replaceAll(",", "")) *
609
- (factors[match[2]?.toUpperCase()] ?? 1)
610
- : null;
611
- const hrefs = [...new Set(matched.map((a) => a.getAttribute("href")))];
612
- return {
613
- value: Number.isFinite(value) ? value : null,
614
- text: texts[0],
615
- precision: match?.[2] ? "rounded" : "exact",
616
- url: hrefs.length === 1 ? "https://x.com" + hrefs[0] : null,
617
- };
618
- };
619
- return {
620
- handle,
621
- blue,
622
- relationship,
623
- relationshipControls: labels
624
- .filter((label) => label.includes("@" + handle) || /取消关注|订阅/.test(label))
625
- .slice(0, 5),
626
- biography: main.querySelector('[data-testid="UserDescription"]')
627
- ?.innerText ?? null,
628
- following: counter(["following"]),
629
- followers: counter(["followers", "verified_followers"]),
630
- alerts: Array.from(document.querySelectorAll('[role="alert"]'))
631
- .filter(visible)
632
- .map((a) => a.innerText),
633
- observedAt: new Date().toISOString(),
634
- };
635
- }
636
- // Bounded visible person cells for a currently loaded follower/following list.
637
- export function readPeople({ offset = 0, limit = 5 } = {}) {
638
- if (!Number.isInteger(offset) ||
639
- offset < 0 ||
640
- !Number.isInteger(limit) ||
641
- limit < 1 ||
642
- limit > 5)
643
- throw new Error("人物窗口须为非负offset及1–5 limit");
644
- const visible = (el) => el.getClientRects().length > 0;
645
- const cells = Array.from(document.querySelectorAll('main [data-testid="UserCell"]')).filter(visible);
646
- const people = cells.slice(offset, offset + limit).map((cell) => {
647
- const links = Array.from(cell.querySelectorAll("a[href]"))
648
- .filter(visible)
649
- .map((a) => ({ text: a.innerText, href: a.getAttribute("href") }));
650
- const buttons = Array.from(cell.querySelectorAll("button"))
651
- .filter(visible)
652
- .map((b) => ({
653
- label: b.getAttribute("aria-label") || b.innerText,
654
- disabled: b.disabled,
655
- }));
656
- return { text: cell.innerText, links, buttons };
657
- });
658
- return {
659
- page: location.href,
660
- people,
661
- offset,
662
- renderedPeople: cells.length,
663
- moreInDOM: offset + limit < cells.length,
664
- pageComplete: false,
665
- needsStateCheck: !people.length,
666
- missing: people.length ? [] : ["people-cells-not-found"],
667
- alerts: Array.from(document.querySelectorAll('[role="alert"]'))
668
- .filter(visible)
669
- .map((a) => a.innerText),
670
- observedAt: new Date().toISOString(),
671
- };
672
- }
673
- export function readComposer() {
674
- const visible = (el) => el.getClientRects().length > 0 && !el.closest('[aria-hidden="true"]');
675
- const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter(visible);
676
- const root = dialogs.length === 1
677
- ? dialogs[0]
678
- : dialogs.length === 0
679
- ? document.querySelector("main")
680
- : null;
681
- if (location.pathname.startsWith("/compose/post/unsent/")) {
682
- const text = root?.innerText ?? "";
683
- return {
684
- page: location.href,
685
- unsent: true,
686
- text: text.slice(0, 8000),
687
- truncated: text.length > 8000,
688
- controls: root
689
- ? Array.from(root.querySelectorAll('button,a,[role="tab"]'))
690
- .filter(visible)
691
- .slice(0, 30)
692
- .map((el) => ({
693
- label: el.getAttribute("aria-label") || el.innerText,
694
- href: el.getAttribute("href"),
695
- }))
696
- : [],
697
- needsStateCheck: !root || text.length > 8000,
698
- };
699
- }
700
- const navigation = Array.from(document.querySelectorAll('a[href="/compose/post"],button[data-testid="SideNav_AccountSwitcher_Button"]'))
701
- .filter(visible)
702
- .map((el) => ({
703
- role: el.tagName === "A" ? "link" : "button",
704
- label: el.getAttribute("aria-label") || el.innerText,
705
- }));
706
- if (Array.from(document.querySelectorAll('[role="textbox"],textarea,[contenteditable="true"]')).filter(visible).length !== 1)
707
- return {
708
- page: location.href,
709
- navigation,
710
- dialog: root &&
711
- (dialogs.length === 1 || location.pathname === "/compose/post/media")
712
- ? {
713
- buttons: Array.from(root.querySelectorAll('button,[role="tab"]'))
714
- .filter(visible)
715
- .map((el) => ({
716
- role: el.getAttribute("role") || "button",
717
- label: el.getAttribute("aria-label") || el.innerText,
718
- })),
719
- fields: Array.from(root.querySelectorAll("textarea,input"))
720
- .filter(visible)
721
- .map((el) => ({
722
- label: el.getAttribute("aria-label"),
723
- placeholder: el.getAttribute("placeholder"),
724
- value: el.value,
725
- })),
726
- }
727
- : null,
728
- needsStateCheck: true,
729
- missing: ["composer-editor-not-found"],
730
- editorCandidates: Array.from(document.querySelectorAll('[role="textbox"],textarea,[contenteditable="true"]'))
731
- .filter(visible)
732
- .map((el) => ({
733
- label: el.getAttribute("aria-label"),
734
- testId: el.getAttribute("data-testid"),
735
- tag: el.tagName,
736
- })),
737
- };
738
- const editors = Array.from(document.querySelectorAll('[role="textbox"],textarea,[contenteditable="true"]')).filter(visible);
739
- let scope = editors[0];
740
- while (scope.parentElement &&
741
- !Array.from(scope.querySelectorAll("button")).some((el) => visible(el) &&
742
- /^(发帖|回复|更新)$/.test(el.getAttribute("aria-label") || el.innerText)))
743
- scope = scope.parentElement;
744
- if (scope === document.body || scope === document.documentElement)
745
- return {
746
- page: location.href,
747
- needsStateCheck: true,
748
- missing: ["composer-submit-scope-not-found"],
749
- };
750
- return {
751
- page: location.href,
752
- navigation,
753
- editors: editors.map((el) => ({
754
- label: el.getAttribute("aria-label"),
755
- testId: el.getAttribute("data-testid"),
756
- text: el.innerText,
757
- })),
758
- buttons: Array.from(scope.querySelectorAll("button"))
759
- .filter(visible)
760
- .map((el) => ({
761
- label: el.getAttribute("aria-label") || el.innerText,
762
- disabled: el.disabled,
763
- testId: el.getAttribute("data-testid"),
764
- ariaDisabled: el.getAttribute("aria-disabled"),
765
- ariaBusy: el.getAttribute("aria-busy"),
766
- })),
767
- images: Array.from(scope.querySelectorAll("img"))
768
- .filter(visible)
769
- .map((el) => ({ alt: el.alt })),
770
- needsStateCheck: false,
771
- };
772
- }
773
- export function readMenu() {
774
- const visible = (el) => el.getClientRects().length > 0;
775
- const roots = Array.from(document.querySelectorAll('[role="menu"],[role="dialog"],[data-testid="Dropdown"]')).filter(visible);
776
- const items = [
777
- ...new Set(roots.flatMap((root) => Array.from(root.querySelectorAll('a[href],button,[role="menuitem"]')))),
778
- ].filter(visible);
779
- return {
780
- items: items.map((el) => ({
781
- role: el.getAttribute("role") || (el.tagName === "A" ? "link" : "button"),
782
- label: el.getAttribute("aria-label") || el.innerText,
783
- href: el.getAttribute("href"),
784
- })),
785
- needsStateCheck: !items.length,
786
- missing: items.length ? [] : ["menu-not-found"],
787
- };
788
- }
789
- // Bounded visible navigation only. Never include article links as navigation controls.
790
- export function readNavigation({ targetLabel } = {}) {
791
- const visible = (el) => el.getClientRects().length > 0 && !el.closest('[aria-hidden="true"]');
792
- const primary = document.querySelector('[data-testid="primaryColumn"]') ??
793
- document.querySelector("main");
794
- const nodes = [
795
- ...new Set([
796
- ...Array.from(document.querySelectorAll("nav a[href],nav button")).filter((el) => !el.closest("main")),
797
- ...Array.from(primary?.querySelectorAll('a[href],button,[role="button"],[role="tab"]') ?? []),
798
- ...Array.from(document.querySelectorAll('[role="menu"] a[href], [role="menu"] button, [data-testid="Dropdown"] a[href], [data-testid="Dropdown"] button')),
799
- ]),
800
- ].filter((el) => visible(el) && !el.closest("article"));
801
- const controls = nodes.map((el) => ({
802
- role: el.getAttribute("role") || (el.tagName === "A" ? "link" : "button"),
803
- label: el.getAttribute("aria-label") || el.innerText.trim(),
804
- href: el.getAttribute("href"),
805
- selected: el.getAttribute("aria-selected") === "true",
806
- disabled: el.disabled === true,
807
- }));
808
- // Some folder cards are plain clickable text, with no ARIA role or href.
809
- // Only the explicitly requested, visible exact label can be promoted to a semantic control.
810
- if (targetLabel &&
811
- !controls.some((c) => c.label.split("\n")[0] === targetLabel)) {
812
- const labels = Array.from(document.querySelectorAll("span,div")).filter((el) => visible(el) &&
813
- !el.closest("article") &&
814
- el.innerText.trim() === targetLabel &&
815
- !Array.from(el.children).some((child) => child.innerText?.trim() === targetLabel));
816
- controls.push(...labels.map((el) => ({
817
- role: "text",
818
- label: el.innerText.trim(),
819
- href: null,
820
- disabled: false,
821
- source: "exact-visible-folder-label",
822
- })));
823
- }
824
- return {
825
- page: location.href,
826
- controls: controls.slice(0, 40),
827
- headings: Array.from(primary?.querySelectorAll('h1,h2,[role="heading"]') ?? [])
828
- .filter(visible)
829
- .map((el) => el.innerText.trim())
830
- .slice(0, 10),
831
- needsStateCheck: !controls.length || controls.length > 40,
832
- missing: controls.length > 40
833
- ? ["navigation-window-overflow"]
834
- : controls.length
835
- ? []
836
- : ["navigation-not-found"],
837
- };
838
- }
839
- export function readMediaEditor() {
840
- if (location.pathname !== "/compose/post/media")
841
- return { needsStateCheck: true, missing: ["media-route-not-found"] };
842
- const visible = (el) => el.getClientRects().length > 0 && !el.closest('[aria-hidden="true"]');
843
- const controls = Array.from(document.querySelectorAll('button,[role="tab"]'))
844
- .filter(visible)
845
- .map((el) => ({
846
- role: el.getAttribute("role") || "button",
847
- label: el.getAttribute("aria-label") || el.innerText,
848
- disabled: el.disabled === true,
849
- }));
850
- const fields = Array.from(document.querySelectorAll('textarea,input,[role="textbox"]'))
851
- .filter(visible)
852
- .map((el) => ({
853
- label: el.getAttribute("aria-label"),
854
- placeholder: el.getAttribute("placeholder"),
855
- value: el.value ?? el.innerText,
856
- }));
857
- const ready = controls.some((c) => c.role === "button" && c.label === "保存") &&
858
- controls.some((c) => c.role === "tab" && c.label === "编辑图片描述");
859
- return {
860
- page: location.href,
861
- controls: controls.slice(0, 20),
862
- fields: fields.slice(0, 5),
863
- needsStateCheck: !ready || controls.length > 20 || fields.length > 5,
864
- missing: ready ? [] : ["media-editor-controls-not-ready"],
865
- alerts: Array.from(document.querySelectorAll('[role="alert"]'))
866
- .filter(visible)
867
- .map((el) => el.innerText),
868
- };
869
- }
870
- // Upload only through the observed image button and browser file chooser.
871
- export async function attachImage(tab, { path } = {}) {
872
- if (!/^\/.*\.(?:png|jpe?g|webp)$/i.test(path ?? ""))
873
- throw new Error("需要绝对图片路径");
874
- if (typeof tab?.playwright?.evaluate !== "function" ||
875
- typeof tab?.playwright?.getByRole !== "function" ||
876
- typeof tab?.playwright?.waitForEvent !== "function")
877
- throw new Error("harness能力不足:DOM图片选择器");
878
- const before = await tab.playwright.evaluate(readComposer);
879
- if (before.needsStateCheck || before.alerts?.length)
880
- throw new Error("图片上传前编辑器未确认");
881
- const buttons = before.buttons.filter((b) => /^(添加照片或视频|Add photos or video)$/i.test(b.label) && !b.disabled);
882
- if (buttons.length !== 1)
883
- throw new Error("图片按钮未唯一观察");
884
- const button = tab.playwright.getByRole("button", {
885
- name: buttons[0].label,
886
- exact: true,
887
- });
888
- if ((await button.count()) !== 1 || typeof button.click !== "function")
889
- throw new Error("图片按钮未唯一定位");
890
- const chooserPromise = tab.playwright.waitForEvent("filechooser", {
891
- timeoutMs: 10000,
892
- });
893
- await button.click();
894
- const chooser = await chooserPromise;
895
- if (typeof chooser?.setFiles !== "function")
896
- throw new Error("harness能力不足:setFiles");
897
- await chooser.setFiles([path]);
898
- return tab.playwright.evaluate(readComposer);
899
- }
900
- export async function openReply(tab, { postId } = {}) {
901
- if (!/^\d+$/.test(postId ?? ""))
902
- throw new Error("需要已观察公开ID");
903
- if (typeof tab?.playwright?.evaluate !== "function" ||
904
- typeof tab?.playwright?.locator !== "function")
905
- throw new Error("harness能力不足:精准回复范围");
906
- const state = await tab.playwright.evaluate(readPosts, {
907
- targetId: postId,
908
- limit: 1,
909
- });
910
- const post = state.posts[0];
911
- if (state.needsStateCheck ||
912
- state.alerts.length ||
913
- state.posts.length !== 1 ||
914
- post.missing.length ||
915
- post.needsExpansion)
916
- throw new Error("回复根帖证据未完整");
917
- const buttons = post.buttons.filter((b) => /回复|Reply/i.test(b.label) && !b.disabled);
918
- if (buttons.length !== 1)
919
- throw new Error("回复按钮未唯一观察");
920
- const article = tab.playwright
921
- .locator("article")
922
- .filter({ has: tab.playwright.locator(`a[href="${post.primary}"]`) });
923
- if ((await article.count()) !== 1)
924
- throw new Error("回复文章范围不唯一");
925
- const button = article.getByRole("button", {
926
- name: buttons[0].label,
927
- exact: true,
928
- });
929
- if ((await button.count()) !== 1)
930
- throw new Error("回复控件不唯一");
931
- await button.click();
932
- return tab.playwright.evaluate(readComposer);
933
- }
934
- // This ranks observed clues, never a predicted follow-back probability.
935
- export function screenCandidates(profiles, { minRatio = 0.8 } = {}) {
936
- if (!Array.isArray(profiles) || !Number.isFinite(minRatio) || minRatio <= 0)
937
- throw new Error("profiles数组和正minRatio必填");
938
- const seen = new Set();
939
- return profiles
940
- .map((p) => {
941
- const key = String(p.handle ?? "").toLowerCase();
942
- const duplicate = seen.has(key);
943
- seen.add(key);
944
- const following = p.following?.value, followers = p.followers?.value;
945
- const ratio = Number.isFinite(following) &&
946
- Number.isFinite(followers) &&
947
- followers > 0
948
- ? following / followers
949
- : null;
950
- const clues = [];
951
- if (typeof p.mutualEvidence === "string" && p.mutualEvidence.trim())
952
- clues.push({ kind: "explicit-mutual", evidence: p.mutualEvidence });
953
- if (ratio !== null && ratio >= minRatio)
954
- clues.push({
955
- kind: "following-ratio",
956
- ratio,
957
- following: p.following,
958
- followers: p.followers,
959
- evidence: "关注/粉丝比例线索,非回关概率",
960
- });
961
- const rejected = [
962
- duplicate && "duplicate",
963
- p.blue !== true && "blue-unconfirmed",
964
- p.relationship !== "not-following" && "relationship-ineligible",
965
- p.alerts?.length && "page-alert",
966
- !clues.length && "no-followback-clue",
967
- !/^[A-Za-z0-9_]{1,15}$/.test(p.handle ?? "") && "invalid-handle",
968
- ].filter(Boolean);
969
- return { ...p, ratio, clues, eligible: rejected.length === 0, rejected };
970
- })
971
- .sort((a, b) => Number(b.eligible) - Number(a.eligible) ||
972
- Number(b.clues.some((c) => c.kind === "explicit-mutual")) -
973
- Number(a.clues.some((c) => c.kind === "explicit-mutual")));
974
- }
975
- export function profileCode({ tab = "xtab", handle } = {}) {
976
- return profilesCode([{ tab, handle }]);
977
- }
978
- export function profilesCode(entries) {
979
- if (!Array.isArray(entries) || !entries.length || entries.length > 5)
980
- throw new Error("一次读取1~5位");
981
- for (const { tab, handle } of entries) {
982
- if (!/^[A-Za-z_$][\w$]*$/.test(tab ?? ""))
983
- throw new Error("tab必须是已有Tab变量名");
984
- if (!/^[A-Za-z0-9_]{1,15}$/.test(handle ?? ""))
985
- throw new Error("需要已观察的X handle");
986
- }
987
- const inputs = entries
988
- .map(({ tab, handle }) => `{tab:${tab},handle:${JSON.stringify(handle)}}`)
989
- .join(",");
990
- return `var xProfiles = [];\nfor (var entry of [${inputs}]) {\n var page = new URL(await entry.tab.url());\n if (page.origin !== 'https://x.com' || page.pathname.toLowerCase() !== '/' + entry.handle.toLowerCase()) { xProfiles.push({handle:entry.handle,blue:null,relationship:'unknown',alerts:['profile-url-mismatch']}); continue; }\n xProfiles.push(await entry.tab.playwright.evaluate(xReadProfile, {handle:entry.handle}));\n}\nnodeRepl.write(xScreenCandidates(xProfiles));\n`;
991
- }
992
- export function browserCode({ handle = "xtab", offset = 0, limit = 5, targetId = "", fresh = false, } = {}) {
993
- if (!/^[A-Za-z_$][\w$]*$/.test(handle))
994
- throw new Error("handle必须是当前REPL已有Tab变量名");
995
- if (targetId && !/^\d+$/.test(targetId))
996
- throw new Error("targetId必须是已观察的数字公开ID");
997
- if (!Number.isInteger(offset) ||
998
- offset < 0 ||
999
- !Number.isInteger(limit) ||
1000
- limit < 1 ||
1001
- limit > 5)
1002
- throw new Error("无效读取窗口");
1003
- return `var xReadResult = await ${handle}.playwright.evaluate(xReadPosts, ${JSON.stringify({ offset, limit, targetId })});\nnodeRepl.write(xReadChanges(xReadResult, xReadCache, await ${handle}.url(), ${fresh}));\n`;
1004
- }
1005
- export function axCode({ handle = "chrome", mode = "posts", offset = 0, limit = 1, targetId = "", targetHandle = "", fresh = false, } = {}) {
1006
- if (!/^[A-Za-z_$][\w$]*$/.test(handle))
1007
- throw new Error("handle必须是已有原生App/Tab变量名");
1008
- // Share validation with the pure parser, without requiring a browser.
1009
- readAX("", { mode, offset, limit, targetId, targetHandle });
1010
- return `if (xAXSessions.${handle}?.app !== ${handle}) xAXSessions.${handle} = {app:${handle}, harness:xCreateAXHarness(${handle})};\nvar xAXResult = await xAXSessions.${handle}.harness.observe(${JSON.stringify({ mode, offset, limit, targetId, targetHandle })});\nnodeRepl.write(xReadChanges(xAXResult, xReadCache, xAXResult.page ?? 'unknown', ${fresh}));\n`;
1011
- }
1012
- export function runReaderCLI() {
1013
- try {
1014
- const args = process.argv.slice(2);
1015
- const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback;
1016
- process.stdout.write(args.includes("--setup")
1017
- ? setupCode()
1018
- : args.includes("--ax")
1019
- ? axCode({
1020
- handle: value("--handle", "chrome"),
1021
- mode: value("--mode", "posts"),
1022
- offset: Number(value("--offset", 0)),
1023
- limit: Number(value("--limit", 1)),
1024
- targetId: value("--id", ""),
1025
- targetHandle: value("--person", ""),
1026
- fresh: args.includes("--fresh"),
1027
- })
1028
- : args.includes("--profile")
1029
- ? profileCode({
1030
- tab: value("--handle", "xtab"),
1031
- handle: value("--profile", ""),
1032
- })
1033
- : browserCode({
1034
- handle: value("--handle", "xtab"),
1035
- offset: Number(value("--offset", 0)),
1036
- limit: Number(value("--limit", 5)),
1037
- targetId: value("--id", ""),
1038
- fresh: args.includes("--fresh"),
1039
- }));
1040
- }
1041
- catch (error) {
1042
- console.error(error.message);
1043
- process.exitCode = 1;
1044
- }
1045
- }
1
+ /** Compatibility exports only. Implementation owners are task-specific modules. */
2
+ export { readPosts, changes } from "./readers-posts.js";
3
+ export { readAX } from "./readers-ax-parser.js";
4
+ export { createAXTabHarness, createAXHarness } from "./readers-ax-host.js";
5
+ export { readProfile, readPeople, screenCandidates, } from "./readers-profile.js";
6
+ export { readComposer, readMediaEditor } from "./readers-composer.js";
7
+ export { readMenu, readNavigation } from "./readers-navigation.js";
8
+ export { attachImage, openReply } from "./readers-actions.js";
9
+ export { setupCode, profileCode, profilesCode, browserCode, axCode, runReaderCLI, } from "./readers-cli.js";