@nextclaw/x-harness 0.1.0 → 0.1.1

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 (94) hide show
  1. package/README.md +2 -2
  2. package/cli/main.js +23 -11
  3. package/dist/api.js +53 -1
  4. package/dist/browser-dom-capabilities.d.ts +4 -0
  5. package/dist/browser-dom-capabilities.js +110 -0
  6. package/dist/browser-dom-dependencies.d.ts +16 -0
  7. package/dist/browser-dom-editing.d.ts +148 -0
  8. package/dist/browser-dom-editing.js +386 -0
  9. package/dist/browser-dom-navigation.d.ts +9 -0
  10. package/dist/browser-dom-navigation.js +165 -0
  11. package/dist/browser-dom-network.d.ts +1 -0
  12. package/dist/browser-dom-network.js +128 -0
  13. package/dist/browser-dom-submission.d.ts +2 -0
  14. package/dist/browser-dom-submission.js +82 -0
  15. package/dist/browser-dom.d.ts +1 -1
  16. package/dist/browser-dom.js +51 -802
  17. package/dist/browser-native.js +32 -28
  18. package/dist/browser.js +6 -2
  19. package/dist/creator.js +1 -1
  20. package/dist/growth-batch.d.ts +5 -0
  21. package/dist/growth-batch.js +168 -0
  22. package/dist/growth-constants.d.ts +5 -0
  23. package/dist/growth-constants.js +13 -0
  24. package/dist/growth-dependencies.d.ts +33 -0
  25. package/dist/growth-execution.d.ts +11 -0
  26. package/dist/growth-execution.js +315 -0
  27. package/dist/growth-parallel.d.ts +4 -0
  28. package/dist/growth-parallel.js +301 -0
  29. package/dist/growth-plan.d.ts +4 -0
  30. package/dist/growth-plan.js +207 -0
  31. package/dist/growth-policy.d.ts +9 -0
  32. package/dist/growth-policy.js +181 -0
  33. package/dist/growth-publishing.d.ts +5 -0
  34. package/dist/growth-publishing.js +240 -0
  35. package/dist/growth-reading.d.ts +4 -0
  36. package/dist/growth-reading.js +135 -0
  37. package/dist/growth-recovery.d.ts +6 -0
  38. package/dist/growth-recovery.js +158 -0
  39. package/dist/growth-state.d.ts +8 -0
  40. package/dist/growth-state.js +183 -0
  41. package/dist/growth-tasks.d.ts +17 -0
  42. package/dist/growth-tasks.js +101 -0
  43. package/dist/growth.d.ts +1 -0
  44. package/dist/growth.js +122 -1902
  45. package/dist/platform-capabilities.d.ts +4 -0
  46. package/dist/platform-capabilities.js +19 -0
  47. package/dist/platform-collections.d.ts +7 -0
  48. package/dist/platform-collections.js +223 -0
  49. package/dist/platform-dependencies.d.ts +43 -0
  50. package/dist/platform-diagnostics.d.ts +9 -0
  51. package/dist/platform-diagnostics.js +51 -0
  52. package/dist/platform-observation.d.ts +8 -0
  53. package/dist/platform-observation.js +75 -0
  54. package/dist/platform-rules.d.ts +17 -0
  55. package/dist/platform-rules.js +41 -0
  56. package/dist/platform-targets.d.ts +7 -0
  57. package/dist/platform-targets.js +129 -0
  58. package/dist/platform-writing.d.ts +8 -0
  59. package/dist/platform-writing.js +171 -0
  60. package/dist/platform.d.ts +1 -1
  61. package/dist/platform.js +56 -677
  62. package/dist/readers-actions.d.ts +2 -0
  63. package/dist/readers-actions.js +66 -0
  64. package/dist/readers-ax-host.d.ts +2 -0
  65. package/dist/readers-ax-host.js +125 -0
  66. package/dist/readers-ax-parser.d.ts +1 -0
  67. package/dist/readers-ax-parser.js +69 -0
  68. package/dist/readers-ax-program.d.ts +1 -0
  69. package/dist/readers-ax-tree.d.ts +19 -0
  70. package/dist/readers-ax-tree.js +110 -0
  71. package/dist/readers-ax-views.d.ts +87 -0
  72. package/dist/readers-ax-views.js +247 -0
  73. package/dist/readers-cli.d.ts +6 -0
  74. package/dist/readers-composer.d.ts +2 -0
  75. package/dist/readers-composer.js +131 -0
  76. package/dist/readers-navigation.d.ts +2 -0
  77. package/dist/readers-navigation.js +66 -0
  78. package/dist/readers-posts.d.ts +2 -0
  79. package/dist/readers-posts.js +141 -0
  80. package/dist/readers-profile.d.ts +3 -0
  81. package/dist/readers-profile.js +157 -0
  82. package/dist/readers.d.ts +9 -20
  83. package/dist/results.js +1 -1
  84. package/dist/round-state.js +2 -2
  85. package/dist/types.d.ts +10 -0
  86. package/generated/observation.mjs +2 -1
  87. package/package.json +1 -1
  88. package/skill/SKILL.md +17 -36
  89. package/skill/references/api.md +7 -34
  90. package/skill/references/reads.md +23 -0
  91. package/skill/references/recovery.md +25 -0
  92. package/skill/references/setup.md +23 -0
  93. package/skill/references/writes.md +51 -0
  94. package/dist/readers.js +0 -1045
@@ -1,10 +1,14 @@
1
- import { createAXHarness, attachImage } from "./readers.js";
1
+ import { createAXHarness } from "./readers-ax-host.js";
2
2
  import { readXObservation } from "./observation.js";
3
- import { xURL } from "./platform.js";
4
3
  import { collectCreatorDOM } from "./creator.js";
5
- import { serialized, missingMethods, valueOf } from "./browser-common.js";
4
+ import { serialized } from "./browser-common.js";
5
+ import { createDOMCapabilities } from "./browser-dom-capabilities.js";
6
+ import { createDOMNavigation } from "./browser-dom-navigation.js";
7
+ import { createDOMEditing } from "./browser-dom-editing.js";
8
+ import { createDOMSubmission } from "./browser-dom-submission.js";
9
+ export { readXNetworkDiagnostics } from "./browser-dom-network.js";
6
10
  export function createDOMXBrowser(tab, { readDOM = readXObservation, nativeSubmit = false } = {}) {
7
- let current, replyBinding, refreshNotice, editBinding;
11
+ const session = {};
8
12
  const submitAX = nativeSubmit
9
13
  ? createAXHarness({
10
14
  getAXState: (options) => tab.getAXState(options),
@@ -12,7 +16,7 @@ export function createDOMXBrowser(tab, { readDOM = readXObservation, nativeSubmi
12
16
  })
13
17
  : null;
14
18
  async function observe(query) {
15
- current = null;
19
+ session.current = null;
16
20
  const evaluate = tab?.playwright?.evaluate?.bind(tab.playwright);
17
21
  if (!evaluate)
18
22
  throw new Error("缺playwright.evaluate能力");
@@ -23,9 +27,9 @@ export function createDOMXBrowser(tab, { readDOM = readXObservation, nativeSubmi
23
27
  const raw = result;
24
28
  // This observed disclosure confirms AI provenance; preserve it without treating it as a rate/login failure.
25
29
  result.notices = (result.alerts ?? []).filter((text) => text.trim() === "检测到 AI 生成内容");
26
- if (refreshNotice) {
27
- result.notices.push(refreshNotice);
28
- refreshNotice = null;
30
+ if (session.refreshNotice) {
31
+ result.notices.push(session.refreshNotice);
32
+ session.refreshNotice = null;
29
33
  }
30
34
  result.alerts = (result.alerts ?? []).filter((text) => text.trim() !== "检测到 AI 生成内容");
31
35
  if (query.view === "composer") {
@@ -33,22 +37,23 @@ export function createDOMXBrowser(tab, { readDOM = readXObservation, nativeSubmi
33
37
  raw.editors?.length === 1 ? raw.editors[0].text : null;
34
38
  if (result.inlineComposer &&
35
39
  raw.editors?.length === 1 &&
36
- replyBinding?.page === result.page &&
37
- result.page.match(/\/status\/(\d+)$/)?.[1] === replyBinding.postId)
38
- result.replyToId = replyBinding.postId;
40
+ session.replyBinding?.page === result.page &&
41
+ result.page.match(/\/status\/(\d+)$/)?.[1] ===
42
+ session.replyBinding.postId)
43
+ result.replyToId = session.replyBinding.postId;
39
44
  if (!result.inlineComposer &&
40
45
  raw.editors?.length === 1 &&
41
- replyBinding?.body &&
42
- replyBinding?.author &&
46
+ session.replyBinding?.body &&
47
+ session.replyBinding?.author &&
43
48
  result.replyContexts?.length === 1 &&
44
- (result.replyContexts[0].includes(replyBinding.body) ||
49
+ (result.replyContexts[0].includes(session.replyBinding.body) ||
45
50
  (result.replyQuotes?.length === 1 &&
46
51
  result.replyQuotes[0].length >= 80 &&
47
- replyBinding.body.startsWith(result.replyQuotes[0]))) &&
48
- result.replyContexts[0].includes(`回复 @${replyBinding.author}`))
49
- result.replyToId = replyBinding.postId;
52
+ session.replyBinding.body.startsWith(result.replyQuotes[0]))) &&
53
+ result.replyContexts[0].includes(`回复 @${session.replyBinding.author}`))
54
+ result.replyToId = session.replyBinding.postId;
50
55
  }
51
- current = { query, result };
56
+ session.current = { query, result };
52
57
  return result;
53
58
  }
54
59
  const locator = (role, label = undefined) => tab.playwright.getByRole(role, label ? { name: label, exact: true } : {});
@@ -57,798 +62,42 @@ export function createDOMXBrowser(tab, { readDOM = readXObservation, nativeSubmi
57
62
  throw new Error("DOM语义控件不唯一");
58
63
  return loc;
59
64
  };
65
+ const navigation = createDOMNavigation({ session, tab, observe });
66
+ const editing = createDOMEditing({ session, unique, locator, tab, observe });
67
+ const finishDOMAction = createDOMSubmission({
68
+ submitAX,
69
+ session,
70
+ tab,
71
+ observe,
72
+ unique,
73
+ locator,
74
+ });
60
75
  return serialized({
61
- capabilities(operation) {
62
- const base = missingMethods(tab?.playwright, ["evaluate"]);
63
- if (operation === "readCreator") {
64
- base.push(...missingMethods(tab, [
65
- "url",
66
- "goto",
67
- "reload",
68
- "getAXState",
69
- "scroll",
70
- ]));
71
- base.push(...missingMethods(tab.playwright, ["locator"]));
72
- if (typeof tab.playwright?.locator === "function") {
73
- const article = tab.playwright.locator("article");
74
- base.push(...missingMethods(article, ["first", "getByRole"]));
75
- if (typeof article.first === "function")
76
- base.push(...missingMethods(article.first(), ["waitFor"]));
77
- if (typeof article.getByRole === "function")
78
- base.push(...missingMethods(article.getByRole("button", { name: "显示更多", exact: true }), ["all"]));
79
- }
80
- return base;
81
- }
82
- if (["readBookmarks", "searchSources", "readFeedback"].includes(operation)) {
83
- base.push(...missingMethods(tab, ["goto"]));
84
- base.push(...missingMethods(tab?.playwright, ["getByRole", "waitForURL"]));
85
- if (typeof tab?.playwright?.getByRole === "function")
86
- base.push(...missingMethods(tab.playwright.getByRole("article"), [
87
- "first",
88
- "waitFor",
89
- ]));
90
- if (operation !== "readFeedback") {
91
- base.push(...missingMethods(tab?.playwright, [
92
- "getByRole",
93
- "getByText",
94
- "locator",
95
- "waitForURL",
96
- ]));
97
- if (typeof tab?.playwright?.getByRole === "function")
98
- base.push(...missingMethods(tab.playwright.getByRole("main"), [
99
- "count",
100
- "click",
101
- "first",
102
- "waitFor",
103
- ]));
104
- if (typeof tab?.playwright?.locator === "function")
105
- base.push(...missingMethods(tab.playwright.locator("a"), [
106
- "count",
107
- "filter",
108
- "press",
109
- ]));
110
- if (typeof tab?.playwright?.getByText === "function")
111
- base.push(...missingMethods(tab.playwright.getByText(""), ["waitFor"]));
112
- }
113
- return base;
114
- }
115
- if (operation !== "read")
116
- base.push(...missingMethods(tab?.playwright, ["getByRole"]), ...missingMethods(tab, ["goto"]));
117
- if (operation !== "read" &&
118
- typeof tab?.playwright?.getByRole === "function") {
119
- base.push(...missingMethods(tab.playwright.getByRole("button"), [
120
- "count",
121
- "click",
122
- ]));
123
- if (operation !== "submitFollow")
124
- base.push(...missingMethods(tab.playwright.getByRole("textbox"), [
125
- "count",
126
- "fill",
127
- ]));
128
- if (["submitPost", "submitReply"].includes(operation)) {
129
- base.push(...missingMethods(tab.playwright, ["locator", "waitForLoadState"]));
130
- base.push(...missingMethods(tab.playwright.getByRole("button"), [
131
- "and",
132
- "waitFor",
133
- ]));
134
- }
135
- }
136
- if (operation === "refreshPerson" || operation === "refreshPosts") {
137
- base.push(...missingMethods(tab, ["reload"]));
138
- base.push(...missingMethods(tab?.playwright, ["waitForLoadState"]));
139
- if (typeof tab?.playwright?.getByRole === "function")
140
- base.push(...missingMethods(tab.playwright.getByRole("button"), ["waitFor"]));
141
- }
142
- if (operation === "openOwnPostMenu") {
143
- base.push(...missingMethods(tab?.playwright, ["locator"]));
144
- if (typeof tab?.playwright?.getByRole === "function")
145
- base.push(...missingMethods(tab.playwright.getByRole("article"), [
146
- "filter",
147
- "getByRole",
148
- ]));
149
- }
150
- if (operation === "submitEditedPost") {
151
- base.push(...missingMethods(tab?.playwright, ["waitForLoadState"]));
152
- if (typeof tab?.playwright?.getByRole === "function")
153
- base.push(...missingMethods(tab.playwright.getByRole("button"), ["press"]));
154
- }
155
- if (operation === "attachAssets")
156
- base.push(...missingMethods(tab?.playwright, [
157
- "waitForEvent",
158
- "getByLabel",
159
- "getByPlaceholder",
160
- ]));
161
- if (nativeSubmit && ["submitPost", "submitReply"].includes(operation))
162
- base.push(...missingMethods(tab, ["getAXState", "click"]));
163
- return base;
164
- },
76
+ ...createDOMCapabilities({ tab, nativeSubmit }),
165
77
  observe,
166
78
  async collectCreator({ handle, ...options }) {
167
- current = null;
79
+ session.current = null;
168
80
  return collectCreatorDOM(tab, handle, options);
169
81
  },
82
+ screenshot: () => {
83
+ session.current = null;
84
+ return tab.screenshot();
85
+ },
170
86
  async act(command) {
171
- if (command.operation === "searchSources") {
172
- if (typeof command.query !== "string" ||
173
- !command.query.trim() ||
174
- command.query.length > 300)
175
- throw new Error("需一条有效搜索词");
176
- current = null;
177
- const destination = `https://x.com/search?q=${encodeURIComponent(command.query)}&src=typed_query&f=live`;
178
- if (typeof tab.url !== "function" || (await tab.url()) !== destination)
179
- await tab.goto(destination);
180
- return;
181
- }
182
- if (command.operation === "waitForFolder") {
183
- await tab.playwright
184
- .getByText(command.folder, { exact: true })
185
- .waitFor({ state: "visible", timeoutMs: 2000 });
186
- return;
187
- }
188
- if (command.operation === "waitForPosts") {
189
- await tab.playwright
190
- .getByRole("article")
191
- .first()
192
- .waitFor({ state: "visible", timeoutMs: 5000 });
193
- return;
194
- }
195
- if (command.operation === "openObserved") {
196
- xURL(command.url);
197
- if (!command.evidenceRef)
198
- throw new Error("导航缺已观察证据");
199
- // A direct adapter caller must also preserve a page already at the target.
200
- // Explicit recovery uses forceNavigation; durable verification uses refreshPerson.
201
- if (!command.forceNavigation &&
202
- typeof tab.url === "function" &&
203
- (await tab.url()) === xURL(command.url).href)
204
- return;
205
- const originalPage = current?.result?.page;
206
- replyBinding = null;
207
- current = null;
208
- // Keep the loaded X client when the exact observed permalink is already visible.
209
- // An ambiguous/missing link retains the existing navigation path, never an arbitrary click.
210
- const path = xURL(command.url).pathname + xURL(command.url).search;
211
- let link;
212
- if (!command.forceNavigation &&
213
- typeof tab.playwright?.locator === "function") {
214
- link = tab.playwright
215
- .locator(`a[href=${JSON.stringify(path)}]`)
216
- .filter({ visible: true });
217
- if (command.view === "person" &&
218
- /^[A-Za-z0-9_]{1,15}$/.test(command.handle ?? "") &&
219
- (await link.count()) > 1) {
220
- // Avatar and name often share a profile href. Use the exact observed
221
- // handle token to select the name link, never the arbitrary first match.
222
- const named = link.filter({
223
- hasText: new RegExp(`@${command.handle}(?![A-Za-z0-9_])`, "i"),
224
- });
225
- if ((await named.count()) === 1)
226
- link = named;
227
- }
228
- }
229
- const clickedLink = link && (await link.count()) === 1;
230
- if (clickedLink)
231
- await link.click();
232
- else
233
- await tab.goto(command.url);
234
- try {
235
- await tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
236
- }
237
- catch (error) {
238
- // One inert client-link timeout may use the same observed URL directly.
239
- // A changed route, warning, unknown UI or interrupted connection never falls back.
240
- if (!clickedLink ||
241
- !originalPage ||
242
- !/Timed out waiting for URL/.test(error instanceof Error ? error.message : ""))
243
- throw error;
244
- const check = await observe({
245
- view: command.view,
246
- handle: command.handle,
247
- limit: 1,
248
- });
249
- if (check.page !== originalPage ||
250
- check.needsStateCheck ||
251
- check.alerts?.length)
252
- throw error;
253
- await tab.goto(command.url);
254
- await tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
255
- }
256
- if (command.view === "person") {
257
- // Profile readiness belongs to profile content; self profiles have no Follow button.
258
- await tab.playwright
259
- .locator('[data-testid="UserName"]')
260
- .waitFor({ state: "visible", timeoutMs: 5000 });
261
- }
262
- else if (command.view === "composer") {
263
- await tab.playwright
264
- .getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
265
- .waitFor({ state: "visible", timeoutMs: 5000 });
266
- }
267
- else if (command.view === "posts") {
268
- await tab.playwright
269
- .getByRole("article")
270
- .first()
271
- .waitFor({ state: "visible", timeoutMs: 5000 });
272
- }
273
- return;
274
- }
275
- if (command.operation === "refreshPerson") {
276
- const at = xURL(await tab.url());
277
- replyBinding = null;
278
- if (at.href !== xURL(command.url).href ||
279
- at.pathname.toLowerCase() !== `/${command.handle}`.toLowerCase())
280
- throw new Error("持久核验页面不匹配");
281
- current = null;
282
- // Let the actual UI request settle before reloading; optimistic Following is not durable proof.
283
- let idleTimedOut = false;
284
- try {
285
- await tab.playwright.waitForLoadState({
286
- state: "networkidle",
287
- timeoutMs: 8000,
288
- });
289
- }
290
- catch (error) {
291
- if (!/Timed out waiting for networkidle/i.test(error.message ?? ""))
292
- throw error;
293
- // Long-lived X requests can prevent idle; the following reload still
294
- // must expose the target relationship before any fresh observation.
295
- idleTimedOut = true;
296
- }
297
- await tab.reload();
298
- await tab.playwright
299
- .getByRole("button", {
300
- name: new RegExp(`^(?:正在关注|取消关注|关注|回关) @${command.handle}$`, "i"),
301
- })
302
- .waitFor({ state: "visible", timeoutMs: 5000 });
303
- if (idleTimedOut)
304
- refreshNotice =
305
- "networkidle超时,已沿真实reload与目标关系控件恢复核验";
306
- return;
307
- }
308
- if (command.operation === "refreshPosts") {
309
- replyBinding = null;
310
- if (!command.evidenceRef ||
311
- xURL(await tab.url()).href !== xURL(command.url).href)
312
- throw new Error("公开核验页面不匹配");
313
- current = null;
314
- await tab.reload();
315
- // Native observation settles the real loading transition when the tab exposes it.
316
- if (typeof tab.getAXState === "function")
317
- await tab.getAXState({ emit: false });
318
- await tab.playwright
319
- .getByRole("article")
320
- .first()
321
- .waitFor({ state: "visible", timeoutMs: 5000 });
322
- return;
323
- }
324
- if (!current)
87
+ if (Object.hasOwn(navigation, command.operation))
88
+ return navigation[command.operation](command);
89
+ if (!session.current)
325
90
  throw new Error("DOM语义操作必须先observe");
326
- const { query, result } = current;
327
- let loc, submittedEditor;
328
- if (command.operation === "dismissMenu") {
329
- if (query.view !== "menu" ||
330
- result.needsStateCheck ||
331
- !result.items?.length)
332
- throw new Error("关闭菜单需已有菜单证据");
333
- const item = result.items[0];
334
- current = null;
335
- await (await unique(locator(item.role, item.label))).press("Escape");
336
- return;
337
- }
338
- else if (command.operation === "openControl") {
339
- if (!["navigation", "menu"].includes(query.view) ||
340
- result.needsStateCheck)
341
- throw new Error("导航控件须先唯一观察");
342
- const matches = (result.controls ?? result.items ?? []).filter((c) => c.role === command.role &&
343
- c.label === command.label &&
344
- c.href === command.href &&
345
- !c.disabled);
346
- if (matches.length !== 1)
347
- throw new Error("导航控件未唯一观察");
348
- current = null;
349
- await (await unique(command.role === "text"
350
- ? tab.playwright.getByText(command.label, { exact: true })
351
- : locator(command.role, command.label))).click();
352
- if (command.href?.startsWith("/"))
353
- await tab.playwright.waitForURL(`https://x.com${command.href}`, {
354
- timeoutMs: 5000,
355
- });
356
- return;
357
- }
358
- else if (command.operation === "openOwnPostMenu") {
359
- const post = result.posts?.[0];
360
- if (query.view !== "posts" ||
361
- result.needsStateCheck ||
362
- result.posts?.length !== 1 ||
363
- post.id !== command.postId ||
364
- post.author?.toLowerCase() !== result.accountHandle?.toLowerCase() ||
365
- post.primary?.match(/\/status\/(\d+)$/)?.[1] !== command.postId)
366
- throw new Error("编辑需唯一本人原帖");
367
- const more = post.buttons.filter((b) => /^(更多|More)$/.test(b.label) && !b.disabled);
368
- if (more.length !== 1)
369
- throw new Error("原帖更多按钮未唯一观察");
370
- const article = locator("article").filter({
371
- has: tab.playwright.locator(`a[href=${JSON.stringify(post.primary)}]`),
372
- visible: true,
373
- });
374
- editBinding = {
375
- postId: post.id,
376
- accountHandle: result.accountHandle,
377
- body: post.body,
378
- };
379
- current = null;
380
- await (await unique((await unique(article)).getByRole("button", {
381
- name: more[0].label,
382
- exact: true,
383
- }))).click();
384
- return;
385
- }
386
- else if (command.operation === "submitEditedPost") {
387
- if (query.view !== "composer" ||
388
- result.needsStateCheck ||
389
- result.alerts?.length ||
390
- !editBinding ||
391
- editBinding.postId !== command.postId ||
392
- result.accountHandle !== editBinding.accountHandle ||
393
- !command.evidenceRef ||
394
- !command.approvedText?.trim() ||
395
- result.editorText !== command.approvedText ||
396
- result.editors?.length !== 1)
397
- throw new Error("编辑提交缺本人原帖绑定或精确审稿证据");
398
- const updates = result.buttons.filter((b) => /^(更新|Update)$/.test(b.label) && !b.disabled);
399
- if (updates.length !== 1)
400
- throw new Error("编辑更新按钮未唯一观察");
401
- const update = await unique(locator("button", updates[0].label));
402
- await command.beforeSubmit?.();
403
- current = null;
404
- editBinding = null;
405
- if (command.activation === "keyboard")
406
- await update.press("Enter");
407
- else
408
- await update.click();
409
- await tab.playwright.waitForLoadState({
410
- state: "networkidle",
411
- timeoutMs: 8000,
412
- });
413
- return;
414
- }
415
- else if (command.operation === "expandPost") {
416
- if (query.view !== "posts" ||
417
- result.posts?.length !== 1 ||
418
- result.posts[0].id !== command.postId)
419
- throw new Error("展开需唯一原帖");
420
- const primary = result.posts[0].primary;
421
- const article = locator("article").filter({
422
- has: tab.playwright.locator(`a[href=${JSON.stringify(primary)}]`),
423
- visible: true,
424
- });
425
- const control = (await unique(article)).getByText(/^(显示更多|Show more)$/i);
426
- current = null;
427
- await (await unique(control)).click();
428
- return;
429
- }
430
- else if (command.operation === "nextPosts") {
431
- if (query.view !== "posts" || result.needsStateCheck)
432
- throw new Error("翻页须先观察原列表");
433
- const primary = result.posts?.at(-1)?.primary;
434
- if (!primary)
435
- throw new Error("翻页须有已观察原帖链接");
436
- const anchor = await unique(tab.playwright
437
- .locator(`a[href=${JSON.stringify(primary)}]`)
438
- .filter({ visible: true }));
439
- current = null;
440
- await anchor.press("PageDown");
441
- return;
442
- }
443
- else if (command.operation === "clearPreparedText") {
444
- if (query.view !== "composer" ||
445
- result.needsStateCheck ||
446
- !command.evidenceRef ||
447
- typeof command.text !== "string" ||
448
- result.editorText?.replace(/\n{3,}/g, "\n\n") !==
449
- command.text.replace(/\n{3,}/g, "\n\n") ||
450
- result.editors?.length !== 1)
451
- throw new Error("仅恢复本次已知稿件准备");
452
- const editor = await unique(locator("textbox", result.editors[0].label));
453
- await editor.press("Meta+A");
454
- await editor.press("Backspace");
455
- return observe({ view: "composer" });
456
- }
457
- else if (command.operation === "closePreparedReply") {
458
- if (query.view !== "composer" ||
459
- result.needsStateCheck ||
460
- result.editorText?.trim() !== "" ||
461
- !command.evidenceRef)
462
- throw new Error("仅关闭有准备失败证据的空回复");
463
- await (await unique(locator("button", "关闭"))).click();
464
- replyBinding = null;
465
- return observe({ view: "posts", limit: 5 });
466
- }
467
- else if (command.operation === "closePreparedMedia") {
468
- if (query.view !== "media" ||
469
- result.needsStateCheck ||
470
- !command.evidenceRef)
471
- throw new Error("媒体恢复须有准备失败证据");
472
- const backs = result.controls.filter((b) => b.role === "button" &&
473
- /^(返回|Back)$/i.test(b.label) &&
474
- !b.disabled);
475
- if (backs.length !== 1)
476
- throw new Error("媒体返回入口不唯一");
477
- await (await unique(locator("button", backs[0].label))).click();
478
- await tab.playwright
479
- .getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
480
- .waitFor({ state: "visible", timeoutMs: 5000 });
481
- return observe({ view: "composer" });
482
- }
483
- else if (command.operation === "removePreparedMedia") {
484
- if (query.view !== "composer" ||
485
- result.needsStateCheck ||
486
- result.editorText?.trim() !== "" ||
487
- !command.evidenceRef)
488
- throw new Error("仅恢复有来源证据的空正文媒体准备");
489
- const removes = result.buttons.filter((b) => /^(移除媒体|Remove media)$/i.test(b.label) && !b.disabled);
490
- if (removes.length !== 1)
491
- throw new Error("待恢复媒体不唯一");
492
- await (await unique(locator("button", removes[0].label))).click();
493
- return observe({ view: "composer" });
494
- }
495
- else if (command.operation === "attachAssets") {
496
- if (query.view !== "composer" ||
497
- result.needsStateCheck ||
498
- command.assets?.length !== 1)
499
- throw new Error("仅上传已观察编辑器的一张带ALT图片");
500
- const asset = command.assets[0];
501
- if (!asset.alt?.trim())
502
- throw new Error("图片须有完整ALT");
503
- current = null;
504
- await attachImage(tab, { path: asset.path });
505
- await tab.playwright
506
- .getByRole("button", {
507
- name: /^(?:编辑|Edit)(?:图片|照片|媒体| image| photo| media)?(?: \d+)?$/i,
508
- })
509
- .waitFor({ state: "visible", timeoutMs: 5000 });
510
- const composer = await observe({ view: "composer" });
511
- const edits = composer.buttons.filter((b) => /^(?:编辑|Edit)(?:图片|照片|媒体| image| photo| media)?(?: \d+)?$/i.test(b.label) && !b.disabled);
512
- if (edits.length !== 1)
513
- throw new Error("图片编辑入口未唯一观察");
514
- await (await unique(locator("button", edits[0].label))).click();
515
- await tab.playwright
516
- .getByRole("tab", {
517
- name: /^(编辑图片描述|Edit image description)$/i,
518
- })
519
- .waitFor({ state: "visible", timeoutMs: 5000 });
520
- let media = await observe({ view: "media" });
521
- const tabControl = media.controls.find((c) => c.role === "tab" &&
522
- /^(编辑图片描述|Edit image description)$/i.test(c.label));
523
- if (!tabControl)
524
- throw new Error("ALT页签未观察");
525
- await (await unique(locator("tab", tabControl.label))).click();
526
- media = await observe({ view: "media" });
527
- if (media.needsStateCheck || media.fields.length !== 1)
528
- throw new Error("ALT输入未唯一观察");
529
- const field = media.fields[0];
530
- const input = field.label
531
- ? tab.playwright.getByLabel(field.label, { exact: true })
532
- : field.placeholder
533
- ? tab.playwright.getByPlaceholder(field.placeholder, {
534
- exact: true,
535
- })
536
- : locator("textbox");
537
- await (await unique(input)).fill(asset.alt);
538
- media = await observe({ view: "media" });
539
- if (media.fields[0]?.value !== asset.alt)
540
- throw new Error("ALT全文不匹配");
541
- const save = media.controls.filter((c) => c.role === "button" &&
542
- /^(保存|Save)$/i.test(c.label) &&
543
- !c.disabled);
544
- if (save.length !== 1)
545
- throw new Error("ALT保存入口不唯一");
546
- await (await unique(locator("button", save[0].label))).click();
547
- const after = await observe({ view: "composer" });
548
- if (after.needsStateCheck || !after.images?.length)
549
- throw new Error("上传图片未回到编辑器");
550
- return {
551
- assets: [{ path: asset.path, alt: asset.alt }],
552
- mediaEvidence: media,
553
- composer: after,
554
- };
555
- }
556
- else if (command.operation === "follow") {
557
- if (query.view !== "person" ||
558
- query.handle !== command.handle ||
559
- result.needsStateCheck)
560
- throw new Error("人物身份未绑定");
561
- const name = result.relationship === "follows-you"
562
- ? `回关 @${command.handle}`
563
- : `关注 @${command.handle}`;
564
- loc = await unique(locator("button", name));
565
- }
566
- else if (command.operation === "openReply") {
567
- replyBinding = null;
568
- if (query.targetId !== command.postId || result.posts?.length !== 1)
569
- throw new Error("回复主帖未唯一绑定");
570
- const names = result.posts[0].buttons.filter((b) => /回复|Reply/i.test(b.label));
571
- if (names.length !== 1)
572
- throw new Error("回复按钮未唯一观察");
573
- const primary = result.posts[0].primary;
574
- if (!primary ||
575
- primary.match(/\/status\/(\d+)$/)?.[1] !== command.postId)
576
- throw new Error("回复主帖永久链接未绑定");
577
- const article = locator("article").filter({
578
- has: tab.playwright.locator(`a[href=${JSON.stringify(primary)}]`),
579
- visible: true,
580
- });
581
- loc = await unique((await unique(article)).getByRole("button", {
582
- name: names[0].label,
583
- exact: true,
584
- }));
585
- }
586
- else if (command.operation === "fillComposer") {
587
- if (query.view !== "composer" || result.editors?.length !== 1)
588
- throw new Error("编辑器未唯一观察");
589
- loc = await unique(locator("textbox", result.editors[0].label));
590
- current = null;
591
- await loc.fill(command.text);
592
- const submits = result.buttons.filter((b) => /^(回复|Reply|发帖|发布|Post|更新|Update)$/i.test(b.label));
593
- if (submits.length !== 1)
594
- throw new Error("编辑后提交入口不唯一");
595
- await locator("button", submits[0].label)
596
- .and(tab.playwright.locator(':not([disabled]):not([aria-disabled="true"])'))
597
- .waitFor({ state: "visible", timeoutMs: 5000 });
598
- return;
599
- }
600
- else if (["submitPost", "submitReply"].includes(command.operation)) {
601
- if (query.view !== "composer")
602
- throw new Error("未观察编辑器");
603
- const pattern = command.operation === "submitReply"
604
- ? /^(回复|Reply)$/i
605
- : /^(发帖|发布|Post)$/i;
606
- const buttons = result.buttons?.filter((b) => pattern.test(b.label) && !b.disabled) ?? [];
607
- if (buttons.length !== 1)
608
- throw new Error("提交按钮未唯一观察");
609
- loc = await unique(locator("button", buttons[0].label));
610
- if (command.activation !== undefined &&
611
- !["click", "keyboard"].includes(command.activation))
612
- throw new Error("提交激活方式无效");
613
- if (command.activation === "keyboard" &&
614
- typeof loc.press !== "function")
615
- throw new Error("缺语义按钮键盘提交能力");
616
- if (command.operation === "submitReply") {
617
- if (result.editors?.length !== 1 || !result.editorText?.trim())
618
- throw new Error("回复提交缺唯一完整编辑器");
619
- submittedEditor = locator("textbox", result.editors[0].label).filter({
620
- hasText: new RegExp(result.editorText
621
- .trim()
622
- .split(/\s+/)
623
- .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
624
- .join("\\s*")),
625
- });
626
- await unique(submittedEditor);
627
- }
628
- else {
629
- if (result.editors?.length !== 1 || !result.editorText?.trim())
630
- throw new Error("发帖提交缺唯一完整编辑器");
631
- // Publication with media may stay pending after the click. Do not let
632
- // receipt navigation interrupt it while the submitted text is retained.
633
- submittedEditor = locator("textbox", result.editors[0].label).filter({
634
- hasText: new RegExp(result.editorText
635
- .trim()
636
- .split(/\s+/)
637
- .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
638
- .join("\\s*")),
639
- });
640
- await unique(submittedEditor);
641
- }
642
- }
643
- else
91
+ const { query, result } = session.current;
92
+ const handler = Object.hasOwn(editing, command.operation)
93
+ ? editing[command.operation]
94
+ : undefined;
95
+ if (!handler)
644
96
  throw new Error("未封装的DOM动作");
645
- const binding = command.operation === "openReply"
646
- ? {
647
- page: result.page,
648
- postId: command.postId,
649
- body: result.posts[0].body,
650
- author: result.posts[0].author,
651
- }
652
- : null;
653
- let nativeSubmitIndex;
654
- if (submitAX &&
655
- ["submitPost", "submitReply"].includes(command.operation)) {
656
- const native = await submitAX.observe({ mode: "composer" });
657
- const editors = native.evidence?.filter((e) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(e.label)) ?? [];
658
- const buttons = native.evidence?.filter((e) => new RegExp(command.operation === "submitReply"
659
- ? "^按钮 (回复|Reply)(?:,|$)"
660
- : "^按钮 (发帖|发布|Post)(?:,|$)", "i").test(e.label)) ?? [];
661
- if (native.needsStateCheck ||
662
- native.alerts?.length ||
663
- xURL(native.page).href !== xURL(result.page).href ||
664
- editors.length !== 1 ||
665
- valueOf(editors[0].label)?.trim() !== result.editorText.trim() ||
666
- buttons.length !== 1)
667
- throw new Error("原生提交缺同页、全文及唯一按钮证据");
668
- nativeSubmitIndex = buttons[0].index;
669
- }
670
- if (["submitPost", "submitReply"].includes(command.operation))
671
- await command.beforeSubmit?.();
672
- current = null;
673
- if (nativeSubmitIndex !== undefined)
674
- await submitAX.act({
675
- index: nativeSubmitIndex,
676
- operation: "click",
677
- read: { mode: "composer" },
678
- });
679
- else if (command.activation === "keyboard")
680
- await loc.press("Enter");
681
- else
682
- await loc.click();
683
- if (binding) {
684
- replyBinding = binding;
685
- await tab.playwright
686
- .getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
687
- .waitFor({ state: "visible", timeoutMs: 5000 });
688
- if (xURL(await tab.url()).pathname === "/compose/post")
689
- await tab.playwright
690
- .getByText(`回复 @${binding.author}`, { exact: true })
691
- .waitFor({ state: "visible", timeoutMs: 5000 });
692
- const composer = await observe({ view: "composer" });
693
- const quote = composer.replyQuotes?.length === 1 ? composer.replyQuotes[0] : null;
694
- if (!composer.replyToId &&
695
- !composer.needsStateCheck &&
696
- !composer.alerts?.length &&
697
- composer.editorText?.trim() === "" &&
698
- composer.replyContexts?.length === 1 &&
699
- composer.replyContexts[0].includes(`回复 @${binding.author}`) &&
700
- /显示更多|Show more/.test(composer.replyContexts[0]) &&
701
- quote?.length >= 30 &&
702
- binding.body?.startsWith(quote)) {
703
- // Expand the observed quote; the unchanged full-context binding gate must still pass.
704
- const dialog = await unique(locator("dialog").filter({ visible: true }));
705
- const more = await unique(dialog.getByRole("button", { name: /^(显示更多|Show more)$/i }));
706
- current = null;
707
- await more.click();
708
- await observe({ view: "composer" });
709
- }
710
- }
711
- // Inline replies keep the editor mounted. Wait for this submitted text to clear before
712
- // receipt navigation/reload can interrupt the actual submission request.
713
- if (submittedEditor)
714
- await submittedEditor.waitFor({ state: "hidden", timeoutMs: 10000 });
715
- if (["submitPost", "submitReply"].includes(command.operation))
716
- await tab.playwright.waitForLoadState({
717
- state: "networkidle",
718
- timeoutMs: 8000,
719
- });
97
+ const prepared = await handler(command);
98
+ if (prepared.completed)
99
+ return prepared.value;
100
+ return finishDOMAction(command, prepared, query, result);
720
101
  },
721
- screenshot: () => {
722
- current = null;
723
- return tab.screenshot();
724
- },
725
- });
726
- }
727
- // Read-only transport diagnostics through the same real Chrome tab capability.
728
- // Never return credential headers, cookies or bodies; only numeric rate metadata is allowed.
729
- export async function readXNetworkDiagnostics(tab, { afterSequence = 0, startObserving = false } = {}) {
730
- if (typeof tab?.capabilities?.get !== "function")
731
- throw new Error("缺真实Chrome诊断能力");
732
- const cdp = await tab.capabilities.get("cdp");
733
- if (typeof cdp?.readEvents !== "function")
734
- throw new Error("缺只读网络事件能力");
735
- if (startObserving)
736
- await cdp.send("Network.enable");
737
- const result = await cdp.readEvents({
738
- afterSequence,
739
- limit: 1000,
740
- methods: [
741
- "Network.requestWillBeSent",
742
- "Network.responseReceived",
743
- "Network.loadingFailed",
744
- ],
745
102
  });
746
- const traffic = new Map();
747
- const times = [];
748
- for (const event of result.events) {
749
- const p = event.params ?? {};
750
- let u;
751
- try {
752
- u = new URL(p.response?.url ?? p.request?.url);
753
- }
754
- catch {
755
- continue;
756
- }
757
- if (!((u.hostname === "x.com" && u.pathname.startsWith("/i/api/")) ||
758
- u.hostname === "api.x.com"))
759
- continue;
760
- const location = u.origin + u.pathname;
761
- const row = traffic.get(location) ?? {
762
- location,
763
- requests: 0,
764
- responses: 0,
765
- statuses: {},
766
- };
767
- if (event.method === "Network.requestWillBeSent")
768
- row.requests++;
769
- if (event.method === "Network.responseReceived") {
770
- row.responses++;
771
- const status = p.response?.status;
772
- if (Number.isInteger(status))
773
- row.statuses[status] = (row.statuses[status] ?? 0) + 1;
774
- }
775
- if (Number.isFinite(p.timestamp))
776
- times.push(p.timestamp);
777
- traffic.set(location, row);
778
- }
779
- const rates = (headers = {}) => {
780
- const allowed = {};
781
- for (const [key, value] of Object.entries(headers)) {
782
- const name = key.toLowerCase();
783
- if (![
784
- "x-rate-limit-limit",
785
- "x-rate-limit-remaining",
786
- "x-rate-limit-reset",
787
- "retry-after",
788
- ].includes(name))
789
- continue;
790
- if (!/^\d{1,12}$/.test(String(value).trim()))
791
- continue;
792
- allowed[name] = Number(value);
793
- }
794
- const out = {};
795
- if (allowed["x-rate-limit-limit"] !== undefined)
796
- out.limit = allowed["x-rate-limit-limit"];
797
- if (allowed["x-rate-limit-remaining"] !== undefined)
798
- out.remaining = allowed["x-rate-limit-remaining"];
799
- if (allowed["x-rate-limit-reset"] !== undefined) {
800
- const date = new Date(allowed["x-rate-limit-reset"] * 1000);
801
- if (Number.isFinite(date.getTime()))
802
- out.resetAt = date.toISOString();
803
- }
804
- if (allowed["retry-after"] !== undefined)
805
- out.retryAfterSeconds = allowed["retry-after"];
806
- return Object.keys(out).length ? out : undefined;
807
- };
808
- return {
809
- cursor: result.cursor,
810
- hasMore: result.hasMore,
811
- truncated: result.truncated,
812
- traffic: {
813
- complete: !result.truncated && !result.hasMore,
814
- requests: [...traffic.values()].reduce((n, r) => n + r.requests, 0),
815
- responses: [...traffic.values()].reduce((n, r) => n + r.responses, 0),
816
- durationSeconds: times.length > 1 ? Math.max(...times) - Math.min(...times) : null,
817
- endpointCount: traffic.size,
818
- endpoints: [...traffic.values()]
819
- .sort((a, b) => b.requests - a.requests)
820
- .slice(0, 10),
821
- },
822
- events: result.events
823
- .map((e) => {
824
- const p = e.params ?? {};
825
- const response = p.response ?? {};
826
- let location;
827
- try {
828
- const u = new URL(response.url ?? p.request?.url);
829
- location = ["http:", "https:"].includes(u.protocol)
830
- ? u.origin + u.pathname
831
- : null;
832
- }
833
- catch {
834
- location = null;
835
- }
836
- return {
837
- method: e.method,
838
- requestId: p.requestId,
839
- location,
840
- requestMethod: p.request?.method,
841
- status: response.status,
842
- errorText: p.errorText,
843
- canceled: p.canceled,
844
- timestamp: Number.isFinite(p.timestamp) ? p.timestamp : undefined,
845
- rateLimit: rates(response.headers),
846
- };
847
- })
848
- .filter((e) => e.method === "Network.loadingFailed" ||
849
- (e.location &&
850
- (e.status >= 400 ||
851
- e.requestMethod === "POST" ||
852
- /\/(EditTweet|CreateTweet)$/.test(e.location)))),
853
- };
854
103
  }