@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/platform.js CHANGED
@@ -1,681 +1,128 @@
1
- const handlePattern = /^[A-Za-z0-9_]{1,15}$/;
2
- export function xURL(value) {
3
- const url = new URL(/^x\.com\//.test(value ?? "") ? `https://${value}` : value);
4
- if (url.protocol !== "https:" ||
5
- url.hostname !== "x.com" ||
6
- url.username ||
7
- url.password ||
8
- url.port)
9
- throw new Error("只接受已观察的https://x.com目标");
10
- return url;
11
- }
12
- const sameHandle = (a, b) => typeof a === "string" &&
13
- typeof b === "string" &&
14
- a.toLowerCase() === b.toLowerCase();
15
- // Draft.js innerText can insert an extra separator for an empty paragraph block.
16
- const sameText = (a, b) => typeof a === "string" &&
17
- typeof b === "string" &&
18
- a.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n") ===
19
- b.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n");
20
- export function createXPlatform(browser, { clock = () => Date.now() } = {}) {
21
- // Bounded semantic diagnostics travel with existing proof captures; no page dump or second state store.
22
- const followDiagnostics = [];
23
- async function followStage(action, stage, run) {
24
- const started = clock();
25
- const event = {
26
- actionId: action.id ?? null,
27
- handle: action.handle,
28
- stage,
29
- startedAt: new Date(started).toISOString(),
30
- };
31
- followDiagnostics.push(event);
32
- if (followDiagnostics.length > 40)
33
- followDiagnostics.shift();
34
- try {
35
- const value = await run();
36
- Object.assign(event, {
37
- status: "returned",
38
- durationMs: clock() - started,
39
- });
40
- if (value && typeof value === "object")
41
- Object.assign(event, {
42
- page: value.page,
43
- accountHandle: value.accountHandle,
44
- relationship: value.relationship,
45
- relationshipControls: value.relationshipControls,
46
- needsStateCheck: value.needsStateCheck,
47
- alerts: value.alerts,
48
- fresh: value.fresh,
49
- });
50
- return value;
51
- }
52
- catch (error) {
53
- Object.assign(event, {
54
- status: "error",
55
- durationMs: clock() - started,
56
- error: String(error.message ?? error).slice(0, 500),
57
- });
58
- throw error;
59
- }
60
- }
61
- const methods = ["observe", "act"];
62
- const missing = (operation) => [
63
- ...methods.filter((name) => typeof browser?.[name] !== "function"),
64
- ...(typeof browser?.capabilities === "function"
65
- ? browser.capabilities(operation)
66
- : ["capabilities"]),
67
- ];
68
- async function observed(query) {
69
- const value = await browser.observe(query);
70
- return {
71
- ...value,
72
- observedAt: new Date(clock()).toISOString(),
73
- fresh: true,
74
- };
75
- }
76
- async function open(target, query) {
77
- const url = xURL(target.url);
78
- if (!target.evidenceRef)
79
- throw new Error("目标URL缺已观察证据");
80
- let value = await observed(query);
81
- let atTarget = false;
82
- try {
83
- atTarget = xURL(value.page).href === url.href;
84
- }
85
- catch {
86
- /* Blank/non-X pages require observed navigation. */
87
- }
88
- if (!atTarget) {
89
- await browser.act({
90
- operation: "openObserved",
91
- url: url.href,
92
- evidenceRef: target.evidenceRef,
93
- view: query.view,
94
- handle: query.handle,
95
- });
96
- value = await ready(query);
97
- }
98
- if (!value.page || xURL(value.page).href !== url.href)
99
- return {
100
- ...value,
101
- needsStateCheck: true,
102
- missing: ["target-page-mismatch"],
103
- };
104
- return value;
105
- }
106
- async function ready(query) {
107
- let value = await observed(query);
108
- if (value.needsStateCheck &&
109
- !value.alerts?.length &&
110
- query.view === "posts") {
111
- try {
112
- await browser.act({ operation: "waitForPosts" });
113
- }
114
- catch (error) {
115
- if (!/timed out|timeout/i.test(error.message))
116
- throw error;
117
- }
118
- value = await observed(query);
119
- }
120
- return value;
121
- }
122
- async function choose(value, pattern, { activateSelected = false } = {}) {
123
- const controls = (value.controls ?? value.items ?? []).filter((c) => !c.disabled && pattern(c));
124
- if (controls.length !== 1)
125
- return false;
126
- if (controls[0].selected && !activateSelected)
127
- return true;
128
- await browser.act({ operation: "openControl", ...controls[0] });
129
- return true;
130
- }
131
- const source = (post, page) => ({
132
- id: post.id,
133
- author: post.author,
134
- datetime: post.datetime,
135
- url: post.primary ? new URL(post.primary, page).href : null,
136
- body: post.body,
137
- images: post.images ?? [],
138
- media: post.media ?? [],
139
- links: post.links ?? [],
140
- needsExpansion: post.needsExpansion,
141
- missing: [
142
- ...(post.missing ?? []),
143
- ...(!post.body ? ["exact-body-unconfirmed"] : []),
144
- ],
145
- });
146
- async function collectSources({ processedIds = [], maxPages = 3, limit = 1, } = {}) {
147
- if (!Array.isArray(processedIds) ||
148
- !Number.isInteger(maxPages) ||
149
- maxPages < 1 ||
150
- maxPages > 10 ||
151
- !Number.isInteger(limit) ||
152
- limit < 1 ||
153
- limit > 5)
154
- throw new Error("来源窗口须为1–5,页面上限1–10及已处理ID数组");
155
- const seen = new Set(processedIds), sources = [], missing = [];
156
- let last, previousIds = "", offset = 0, firstDOMId;
157
- for (let page = 0; page < maxPages && sources.length < limit; page++) {
158
- last = await ready({ view: "posts", offset, limit: 5 });
159
- if (page === 0)
160
- firstDOMId = last.posts?.[0]?.id;
161
- if (last.needsStateCheck || last.alerts?.length)
162
- return {
163
- ...last,
164
- sources,
165
- complete: false,
166
- missing: ["source-page-unconfirmed"],
167
- };
168
- if (offset && !last.posts?.length) {
169
- missing.push("source-window-not-yet-rendered");
170
- break;
171
- }
172
- const ids = (last.posts ?? []).map((p) => p.id).join(",");
173
- if (page && ids === previousIds) {
174
- missing.push("pagination-did-not-advance");
175
- break;
176
- }
177
- previousIds = ids;
178
- for (const candidate of last.posts ?? []) {
179
- if (!candidate.id || seen.has(candidate.id))
180
- continue;
181
- seen.add(candidate.id);
182
- let post = candidate;
183
- if (candidate.needsExpansion) {
184
- const target = await observed({
185
- view: "posts",
186
- targetId: candidate.id,
187
- limit: 1,
188
- });
189
- if (target.posts?.length !== 1) {
190
- missing.push("expansion-target-unconfirmed");
191
- continue;
192
- }
193
- await browser.act({ operation: "expandPost", postId: candidate.id });
194
- const expanded = await ready({
195
- view: "posts",
196
- targetId: candidate.id,
197
- limit: 1,
198
- });
199
- if (expanded.needsStateCheck || expanded.alerts?.length)
200
- return {
201
- ...expanded,
202
- sources,
203
- complete: false,
204
- missing: ["expanded-source-unconfirmed"],
205
- };
206
- post = expanded.posts?.[0];
207
- }
208
- if (!post ||
209
- post.needsExpansion ||
210
- !post.body ||
211
- !post.author ||
212
- !Number.isFinite(Date.parse(post.datetime))) {
213
- missing.push(`incomplete-source:${candidate.id}`);
214
- continue;
215
- }
216
- sources.push(source(post, last.page));
217
- if (sources.length >= limit)
218
- break;
219
- }
220
- if (sources.length < limit && page + 1 < maxPages) {
221
- // Expansion narrowed the semantic window; restore the list before scrolling.
222
- last = await observed({ view: "posts", offset, limit: 5 });
223
- if (last.moreInDOM)
224
- offset += 5;
225
- else {
226
- await browser.act({ operation: "nextPosts" });
227
- const advanced = await ready({ view: "posts", limit: 5 });
228
- // Retained DOM prefixes include a partial final window too; skip
229
- // exactly the consumed cards, while virtualized replacements restart.
230
- offset =
231
- firstDOMId && advanced.posts?.[0]?.id === firstDOMId
232
- ? offset + (last.posts?.length ?? 0)
233
- : 0;
234
- firstDOMId = advanced.posts?.[0]?.id;
235
- }
236
- }
237
- }
238
- return {
239
- page: last?.page,
240
- accountHandle: last?.accountHandle,
241
- observedAt: last?.observedAt,
242
- fresh: true,
243
- sources,
244
- complete: sources.length >= limit,
245
- pageComplete: false,
246
- missing: sources.length >= limit
247
- ? []
248
- : [...new Set([...missing, "bounded-source-window-exhausted"])],
249
- };
250
- }
251
- const api = {
252
- preflight({ operation, input = {} }) {
253
- const absent = missing(operation);
254
- if (operation === "readCreator" &&
255
- typeof browser.collectCreator !== "function")
256
- absent.push("browser.collectCreator");
257
- if (input.kind === "follow" || operation === "submitFollow")
258
- absent.push(...(browser.capabilities?.("refreshPerson") ?? ["refreshPerson"]));
259
- if (operation !== "read" && input.assets?.length)
260
- absent.push(...(browser.capabilities?.("attachAssets") ?? ["attachAssets"]));
261
- return {
262
- covered: absent.length === 0,
263
- missing: [...new Set(absent)],
264
- owner: "scripts/x-harness-browser.mjs",
265
- };
266
- },
267
- async readCreator(args) {
268
- if (typeof browser.collectCreator !== "function")
269
- return {
270
- status: "needsCapability",
271
- complete: false,
272
- missing: ["browser.collectCreator"],
273
- };
274
- const value = await browser.collectCreator(args);
275
- const absent = [
276
- ...new Set([
277
- ...(value.missing ?? []),
278
- ...(value.profile?.alerts ?? []),
279
- ...(value.meta?.missing ?? []),
280
- ]),
281
- ];
282
- return {
283
- ...value,
284
- sources: value.posts,
285
- page: `https://x.com/${args.handle}`,
286
- pageComplete: false,
287
- complete: value.complete === true && !absent.length && !value.alerts?.length,
288
- missing: absent,
289
- };
290
- },
291
- readAccount: () => observed({ view: "account" }),
292
- async readBookmarks({ folder, ...window } = {}) {
293
- if (typeof folder !== "string" || !folder.trim())
294
- throw new Error("需指定收藏文件夹;不以全部书签冒充命名文件夹");
295
- let nav = await ready({ view: "navigation", targetLabel: folder });
296
- const bookmark = (c) => /^(书签|收藏|Bookmarks)$/i.test(c.label);
297
- if (!(await choose(nav, bookmark, { activateSelected: true }))) {
298
- if (await choose(nav, (c) => /^(更多菜单项|More menu items)$/i.test(c.label))) {
299
- nav = await ready({ view: "menu" });
300
- if (!(await choose(nav, bookmark))) {
301
- if (nav.items?.length)
302
- await browser.act({ operation: "dismissMenu" });
303
- nav = await ready({ view: "navigation", targetLabel: folder });
304
- if (!(await choose(nav, (c) => /^(历史|History)$/i.test(c.label))))
305
- return {
306
- status: "needsCapability",
307
- owner: "scripts/x-harness-browser.mjs",
308
- missing: ["bookmarks-entry-not-observed"],
309
- };
310
- }
311
- }
312
- else if (!(await choose(nav, (c) => /^(历史|History)$/i.test(c.label))))
313
- return {
314
- status: "needsCapability",
315
- owner: "scripts/x-harness-browser.mjs",
316
- missing: ["bookmarks-entry-not-observed"],
317
- };
318
- }
319
- nav = await ready({ view: "navigation", targetLabel: folder });
320
- const tabs = (nav.controls ?? []).filter((c) => c.role === "tab" && bookmark(c));
321
- if (tabs.length === 1 &&
322
- (!tabs[0].selected ||
323
- !(nav.controls ?? []).some((c) => c.label.split("\n")[0] === folder))) {
324
- await choose(nav, (c) => c === tabs[0], {
325
- activateSelected: true,
326
- });
327
- nav = await ready({ view: "navigation", targetLabel: folder });
328
- }
329
- if (!(nav.controls ?? []).some((c) => c.label.split("\n")[0] === folder)) {
330
- try {
331
- await browser.act({ operation: "waitForFolder", folder });
332
- }
333
- catch (error) {
334
- if (!/timed out|timeout/i.test(error.message))
335
- throw error;
336
- }
337
- nav = await ready({ view: "navigation", targetLabel: folder });
338
- }
339
- if (!(await choose(nav, (c) => c.label.split("\n")[0] === folder)))
340
- return {
341
- status: "needsCapability",
342
- owner: "scripts/x-harness-browser.mjs",
343
- page: nav.page,
344
- missing: ["named-bookmark-folder-not-observed"],
345
- folder,
346
- controls: nav.controls,
347
- };
348
- const selected = await ready({ view: "navigation" });
349
- if (selected.page === nav.page && !selected.headings?.includes(folder))
350
- return {
351
- status: "needsDecision",
352
- complete: false,
353
- missing: ["folder-selection-unconfirmed"],
354
- folder,
355
- page: selected.page,
356
- };
357
- const value = await collectSources(window);
358
- return {
359
- ...value,
360
- folder,
361
- folderEvidence: {
362
- entryPage: nav.page,
363
- selectedPage: selected.page,
364
- name: folder,
365
- },
366
- };
367
- },
368
- async searchSources({ query, limit = 3, maxPages = 1, processedIds = [], } = {}) {
369
- await browser.act({ operation: "searchSources", query });
370
- const value = await collectSources({ limit, maxPages, processedIds });
371
- if (!value.page ||
372
- xURL(value.page).pathname !== "/search" ||
373
- xURL(value.page).searchParams.get("q") !== query)
374
- return {
375
- ...value,
376
- complete: false,
377
- missing: ["search-query-unconfirmed"],
378
- };
379
- return { ...value, query };
380
- },
381
- async readFeedback({ targets, replyLimit = 3 } = {}) {
382
- if (!Array.isArray(targets) || targets.length < 1 || targets.length > 5)
383
- throw new Error("反馈窗口须为1–5个已发布ID");
384
- if (!Number.isInteger(replyLimit) || replyLimit < 0 || replyLimit > 3)
385
- throw new Error("回复核验窗口须为0–3");
386
- const feedback = [];
387
- for (const target of targets) {
388
- const value = await api.readPost(target), post = value.posts?.find((p) => p.id === target.postId);
389
- if (value.needsStateCheck || value.alerts?.length || !post) {
390
- feedback.push({
391
- id: target.postId,
392
- missing: ["published-post-unconfirmed"],
393
- });
394
- continue;
395
- }
396
- const thread = await observed({ view: "posts", limit: 5 });
397
- const replies = [], unconfirmedReplyIds = [];
398
- const targetIndex = (thread.posts ?? []).findIndex((p) => p.id === post.id);
399
- const candidates = targetIndex < 0
400
- ? []
401
- : thread.posts.slice(targetIndex + 1, targetIndex + 1 + replyLimit);
402
- for (const candidate of candidates) {
403
- if (!candidate.primary || !candidate.id)
404
- continue;
405
- const url = new URL(candidate.primary, thread.page).href;
406
- const detail = await open({
407
- url,
408
- evidenceRef: {
409
- page: thread.page,
410
- observedAt: thread.observedAt,
411
- publicId: candidate.id,
412
- primary: candidate.primary,
413
- },
414
- }, {
415
- view: "posts",
416
- targetId: candidate.id,
417
- replyTargetId: target.postId,
418
- limit: 1,
419
- });
420
- const reply = detail.posts?.find((p) => p.id === candidate.id);
421
- if (!detail.needsStateCheck &&
422
- !detail.alerts?.length &&
423
- reply?.replyToId === target.postId &&
424
- reply.body &&
425
- !reply.needsExpansion)
426
- replies.push(source(reply, detail.page));
427
- else
428
- unconfirmedReplyIds.push(candidate.id);
429
- }
430
- feedback.push({
431
- id: post.id,
432
- author: post.author,
433
- metrics: [...(post.buttons ?? []), ...(post.metrics ?? [])],
434
- replies,
435
- unconfirmedReplyIds,
436
- pageComplete: false,
437
- observedAt: value.observedAt,
438
- });
439
- }
440
- return {
441
- feedback,
442
- fresh: true,
443
- complete: feedback.every((f) => !f.missing),
444
- pageComplete: false,
445
- };
446
- },
447
- async readPerson(target) {
448
- if (!handlePattern.test(target.handle ?? ""))
449
- throw new Error("无效已观察handle");
450
- if (xURL(target.url).pathname.toLowerCase() !==
451
- `/${target.handle}`.toLowerCase())
452
- throw new Error("人物URL与handle不一致");
453
- const value = await open(target, {
454
- view: "person",
455
- handle: target.handle,
456
- });
457
- return sameHandle(value.handle, target.handle)
458
- ? { ...value, url: target.url }
459
- : {
460
- ...value,
461
- needsStateCheck: true,
462
- missing: ["person-identity-mismatch"],
463
- };
464
- },
465
- async readPost(target) {
466
- if (!/^\d+$/.test(target.postId ?? "") ||
467
- xURL(target.url).pathname.match(/\/status\/(\d+)$/)?.[1] !==
468
- target.postId)
469
- throw new Error("帖子URL与公开ID不一致");
470
- let value = await open(target, {
471
- view: "posts",
472
- targetId: target.postId,
473
- });
474
- if (!target.expand)
475
- return value;
476
- let posts = (value.posts ?? []).filter((p) => p.id === target.postId);
477
- if (!value.needsStateCheck &&
478
- !value.alerts?.length &&
479
- posts.length === 1 &&
480
- posts[0].needsExpansion) {
481
- await browser.act({ operation: "expandPost", postId: target.postId });
482
- value = await ready({ view: "posts", targetId: target.postId });
483
- posts = (value.posts ?? []).filter((p) => p.id === target.postId);
484
- }
485
- const missing = [...(value.missing ?? [])];
486
- if (posts.length !== 1)
487
- missing.push("unique-target-post-unconfirmed");
488
- else {
489
- if (!posts[0].body)
490
- missing.push("exact-body-unconfirmed");
491
- if (posts[0].needsExpansion)
492
- missing.push("post-expansion-unconfirmed");
493
- if (!posts[0].author)
494
- missing.push("post-author-unconfirmed");
495
- if (!Number.isFinite(Date.parse(posts[0].datetime)))
496
- missing.push("absolute-time-unconfirmed");
497
- }
498
- return {
499
- ...value,
500
- posts,
501
- complete: !missing.length && !value.needsStateCheck && !value.alerts?.length,
502
- missing,
503
- };
504
- },
505
- async findSubmission(action) {
506
- if (action.kind === "follow") {
507
- let value = await followStage(action, "verify-read", () => api.readPerson(action));
508
- if (!value.needsStateCheck) {
509
- const absent = browser.capabilities?.("refreshPerson") ?? [
510
- "refreshPerson",
511
- ];
512
- if (absent.length)
513
- return {
514
- ...value,
515
- verified: false,
516
- missing: absent,
517
- needsStateCheck: true,
518
- };
519
- await followStage(action, "verify-refresh", () => browser.act({
520
- operation: "refreshPerson",
521
- handle: action.handle,
522
- url: action.url,
523
- evidenceRef: action.evidenceRef,
524
- }));
525
- value = await followStage(action, "verify-after-refresh", () => api.readPerson(action));
526
- }
527
- return {
528
- ...value,
529
- verified: !value.needsStateCheck &&
530
- !value.alerts?.length &&
531
- value.relationship === "following" &&
532
- (!action.accountHandle ||
533
- sameHandle(value.accountHandle, action.accountHandle)),
534
- publicId: null,
535
- };
536
- }
537
- // A bounded window cannot prove absence. Reply receipts use the observed target conversation.
538
- const observedReceipt = action.kind === "reply" &&
539
- action.observedPublicURL &&
540
- action.observedPublicEvidenceRef;
541
- const receiptPath = observedReceipt
542
- ? xURL(action.observedPublicURL).pathname.split("/")
543
- : [];
544
- if (observedReceipt &&
545
- (receiptPath.length !== 4 ||
546
- !sameHandle(receiptPath[1], action.accountHandle) ||
547
- receiptPath[2] !== "status" ||
548
- !/^[0-9]+$/.test(receiptPath[3])))
549
- throw new Error("已观察回复回执须属于原账号");
550
- const receiptTarget = observedReceipt
551
- ? {
552
- url: action.observedPublicURL,
553
- evidenceRef: action.observedPublicEvidenceRef,
554
- }
555
- : action.kind === "reply" && action.url && action.evidenceRef
556
- ? { url: action.url, evidenceRef: action.evidenceRef }
557
- : { url: action.accountURL, evidenceRef: action.accountEvidenceRef };
558
- let value = await open(receiptTarget, { view: "posts", limit: 5 });
559
- if (!value.needsStateCheck &&
560
- !(browser.capabilities?.("refreshPosts") ?? ["refreshPosts"]).length) {
561
- await browser.act({
562
- operation: "refreshPosts",
563
- url: receiptTarget.url,
564
- evidenceRef: receiptTarget.evidenceRef,
565
- });
566
- value = await observed({ view: "posts", limit: 5 });
567
- }
568
- const matchesContent = (post) => sameHandle(post.author, action.accountHandle) &&
569
- sameText(post.body, action.text) &&
570
- /^\d+$/.test(post.id ?? "") &&
571
- (!action.assets?.length ||
572
- action.assets.every((asset) => post.images?.some((image) => image.alt === asset.alt))) &&
573
- !(action.beforeIds ?? []).includes(post.id) &&
574
- Number.isFinite(Date.parse(post.datetime)) &&
575
- Date.parse(post.datetime) >= Date.parse(action.intentAt) &&
576
- Date.parse(post.datetime) <= clock() + 5000 &&
577
- !post.needsExpansion;
578
- let matches = (value.posts ?? []).filter(matchesContent);
579
- if (action.kind === "reply" &&
580
- matches.length === 1 &&
581
- matches[0].replyToId == null &&
582
- matches[0].primary) {
583
- // The candidate URL comes from this fresh public observation, not from a guessed ID path.
584
- const candidate = matches[0];
585
- const url = xURL(new URL(candidate.primary, xURL(value.page).origin).href);
586
- if (url.pathname.toLowerCase() ===
587
- `/${action.accountHandle}/status/${candidate.id}`.toLowerCase()) {
588
- value = await open({
589
- url: url.href,
590
- evidenceRef: {
591
- page: value.page,
592
- observedAt: value.observedAt,
593
- publicId: candidate.id,
594
- primary: candidate.primary,
595
- },
596
- }, {
597
- view: "posts",
598
- targetId: candidate.id,
599
- replyTargetId: action.postId,
600
- });
601
- matches = (value.posts ?? []).filter(matchesContent);
602
- }
603
- }
604
- if (action.kind === "reply")
605
- matches = matches.filter((post) => post.replyToId === action.postId);
606
- return {
607
- ...value,
608
- verified: !value.needsStateCheck &&
609
- !value.alerts?.length &&
610
- matches.length === 1,
611
- publicId: matches.length === 1 ? matches[0].id : null,
612
- missing: matches.length === 1 ? [] : ["unique-public-submission-unconfirmed"],
613
- };
614
- },
615
- async submitFollow(action) {
616
- const value = await followStage(action, "submit-preflight-read", () => api.readPerson(action));
617
- if (value.needsStateCheck ||
618
- value.alerts?.length ||
619
- !["not-following", "follows-you"].includes(value.relationship) ||
620
- (action.accountHandle &&
621
- !sameHandle(value.accountHandle, action.accountHandle)))
622
- throw new Error("fresh账号/人物关系不允许关注");
623
- await followStage(action, "submit-guard", () => action.beforeSubmit?.());
624
- // A returned click is not a receipt from X; durable success still requires fresh verification.
625
- const clicked = await followStage(action, "click", () => browser.act({ operation: "follow", handle: action.handle }));
626
- try {
627
- await followStage(action, "click-ui-observation", () => observed({ view: "person", handle: action.handle }));
628
- }
629
- catch {
630
- /* The click may have committed; retain diagnostic failure and use the original fresh receipt path. */
631
- }
632
- return clicked;
633
- },
634
- async submitPost(action) {
635
- await open({ url: action.composeURL, evidenceRef: action.composeEvidenceRef }, { view: "composer" });
636
- return compose(action);
637
- },
638
- async submitReply(action) {
639
- const value = await api.readPost(action);
640
- if (value.needsStateCheck ||
641
- value.alerts?.length ||
642
- !(value.posts ?? []).some((p) => p.id === action.postId))
643
- throw new Error("回复主帖身份未核实");
644
- await browser.act({ operation: "openReply", postId: action.postId });
645
- return compose(action);
646
- },
647
- captureProof: ({ observation }) => ({
648
- capturedAt: new Date(clock()).toISOString(),
649
- observation,
650
- ...(followDiagnostics.length
651
- ? { followDiagnostics: structuredClone(followDiagnostics) }
652
- : {}),
653
- }),
654
- };
655
- async function compose(action) {
656
- let value = await observed({ view: "composer" });
657
- if (value.needsStateCheck ||
658
- value.alerts?.length ||
659
- typeof value.editorText !== "string" ||
660
- value.editorText.trim() !== "" ||
661
- (action.kind === "reply" && value.replyToId !== action.postId))
662
- throw new Error("编辑器不为空或回复上下文未绑定");
663
- if (action.assets?.length)
664
- await browser.act({ operation: "attachAssets", assets: action.assets });
665
- await browser.act({ operation: "fillComposer", text: action.text });
666
- value = await observed({ view: "composer" });
667
- if (!sameText(value.editorText, action.text) ||
668
- value.needsStateCheck ||
669
- value.alerts?.length ||
670
- (action.kind === "reply" && value.replyToId !== action.postId) ||
671
- (action.accountHandle &&
672
- !sameHandle(value.accountHandle, action.accountHandle)))
673
- throw new Error(`提交前账号/正文/目标未核实 ${JSON.stringify({ textMatches: sameText(value.editorText, action.text), needsStateCheck: value.needsStateCheck, alerts: value.alerts, accountHandle: value.accountHandle, replyToId: value.replyToId })}`);
674
- return browser.act({
675
- operation: action.kind === "reply" ? "submitReply" : "submitPost",
676
- beforeSubmit: action.beforeSubmit,
677
- ...(action.activation ? { activation: action.activation } : {}),
1
+ import { createPlatformObservation } from "./platform-observation.js";
2
+ import { createPlatformTargets } from "./platform-targets.js";
3
+ import { createPlatformCollections } from "./platform-collections.js";
4
+ import { createPlatformDiagnostics } from "./platform-diagnostics.js";
5
+ import { createPlatformWriting } from "./platform-writing.js";
6
+ import { createPlatformCapabilities } from "./platform-capabilities.js";
7
+ export { xURL } from "./platform-rules.js";
8
+ export class XPlatform {
9
+ #clock;
10
+ #createPlatformObservationResult;
11
+ #missing;
12
+ #observed;
13
+ #open;
14
+ #ready;
15
+ #choose;
16
+ #createPlatformTargetsResult;
17
+ #readAccount;
18
+ #readFeedback;
19
+ #readPerson;
20
+ #readPost;
21
+ #createPlatformCollectionsResult;
22
+ #collectSources;
23
+ #readCreator;
24
+ #readBookmarks;
25
+ #searchSources;
26
+ #createPlatformDiagnosticsResult;
27
+ #followStage;
28
+ #captureProof;
29
+ #createPlatformWritingResult;
30
+ #compose;
31
+ #findSubmission;
32
+ #submitFollow;
33
+ #submitPost;
34
+ #submitReply;
35
+ #createPlatformCapabilitiesResult;
36
+ #preflight;
37
+ preflight;
38
+ readCreator;
39
+ readAccount;
40
+ readBookmarks;
41
+ searchSources;
42
+ readFeedback;
43
+ readPerson;
44
+ readPost;
45
+ findSubmission;
46
+ submitFollow;
47
+ submitPost;
48
+ submitReply;
49
+ captureProof;
50
+ constructor(browser, { clock = () => Date.now() } = {}) {
51
+ this.#clock = clock;
52
+ this.#createPlatformObservationResult = createPlatformObservation({
53
+ browser: browser,
54
+ clock: this.#clock,
678
55
  });
56
+ const { missing, observed, open, ready, choose } = this.#createPlatformObservationResult;
57
+ this.#missing = missing;
58
+ this.#observed = observed;
59
+ this.#open = open;
60
+ this.#ready = ready;
61
+ this.#choose = choose;
62
+ this.#createPlatformTargetsResult = createPlatformTargets({
63
+ observed: this.#observed,
64
+ open: this.#open,
65
+ browser: browser,
66
+ ready: this.#ready,
67
+ });
68
+ const { readAccount, readFeedback, readPerson, readPost } = this.#createPlatformTargetsResult;
69
+ this.#readAccount = readAccount;
70
+ this.#readFeedback = readFeedback;
71
+ this.#readPerson = readPerson;
72
+ this.#readPost = readPost;
73
+ this.#createPlatformCollectionsResult = createPlatformCollections({
74
+ ready: this.#ready,
75
+ observed: this.#observed,
76
+ browser: browser,
77
+ choose: this.#choose,
78
+ });
79
+ const { collectSources, readCreator, readBookmarks, searchSources } = this.#createPlatformCollectionsResult;
80
+ this.#collectSources = collectSources;
81
+ this.#readCreator = readCreator;
82
+ this.#readBookmarks = readBookmarks;
83
+ this.#searchSources = searchSources;
84
+ this.#createPlatformDiagnosticsResult = createPlatformDiagnostics({
85
+ clock: this.#clock,
86
+ });
87
+ const { followStage, captureProof } = this.#createPlatformDiagnosticsResult;
88
+ this.#followStage = followStage;
89
+ this.#captureProof = captureProof;
90
+ this.#createPlatformWritingResult = createPlatformWriting({
91
+ observed: this.#observed,
92
+ browser: browser,
93
+ followStage: this.#followStage,
94
+ readPerson: this.#readPerson,
95
+ open: this.#open,
96
+ clock: this.#clock,
97
+ readPost: this.#readPost,
98
+ });
99
+ const { compose, findSubmission, submitFollow, submitPost, submitReply } = this.#createPlatformWritingResult;
100
+ this.#compose = compose;
101
+ this.#findSubmission = findSubmission;
102
+ this.#submitFollow = submitFollow;
103
+ this.#submitPost = submitPost;
104
+ this.#submitReply = submitReply;
105
+ this.#createPlatformCapabilitiesResult = createPlatformCapabilities({
106
+ missing: this.#missing,
107
+ browser: browser,
108
+ });
109
+ const { preflight } = this.#createPlatformCapabilitiesResult;
110
+ this.#preflight = preflight;
111
+ this.preflight = this.#preflight;
112
+ this.readCreator = this.#readCreator;
113
+ this.readAccount = this.#readAccount;
114
+ this.readBookmarks = this.#readBookmarks;
115
+ this.searchSources = this.#searchSources;
116
+ this.readFeedback = this.#readFeedback;
117
+ this.readPerson = this.#readPerson;
118
+ this.readPost = this.#readPost;
119
+ this.findSubmission = this.#findSubmission;
120
+ this.submitFollow = this.#submitFollow;
121
+ this.submitPost = this.#submitPost;
122
+ this.submitReply = this.#submitReply;
123
+ this.captureProof = this.#captureProof;
679
124
  }
680
- return api;
125
+ }
126
+ export function createXPlatform(...args) {
127
+ return new XPlatform(...args);
681
128
  }