@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.
- package/LICENSE +231 -6
- package/README.md +17 -3
- package/SOURCE.md +9 -0
- package/cli/main.js +232 -188
- package/dist/api.d.ts +56 -2
- package/dist/api.js +178 -75
- package/dist/boundary.js +1 -0
- package/dist/browser-common.d.ts +11 -0
- package/dist/browser-common.js +34 -18
- package/dist/browser-dom-capabilities.d.ts +9 -0
- package/dist/browser-dom-capabilities.js +131 -0
- package/dist/browser-dom-dependencies.d.ts +16 -0
- package/dist/browser-dom-dependencies.js +1 -0
- package/dist/browser-dom-editing.d.ts +166 -0
- package/dist/browser-dom-editing.js +418 -0
- package/dist/browser-dom-navigation.d.ts +18 -0
- package/dist/browser-dom-navigation.js +181 -0
- package/dist/browser-dom-network.d.ts +1 -0
- package/dist/browser-dom-network.js +128 -0
- package/dist/browser-dom-submission.d.ts +7 -0
- package/dist/browser-dom-submission.js +101 -0
- package/dist/browser-dom.d.ts +14 -2
- package/dist/browser-dom.js +120 -824
- package/dist/browser-native.d.ts +14 -1
- package/dist/browser-native.js +180 -143
- package/dist/browser.js +6 -2
- package/dist/creator-native.d.ts +3 -0
- package/dist/creator-native.js +113 -0
- package/dist/creator.js +1 -1
- package/dist/diagnostics.d.ts +11 -0
- package/dist/diagnostics.js +10 -0
- package/dist/growth-batch.d.ts +8 -0
- package/dist/growth-batch.js +190 -0
- package/dist/growth-constants.d.ts +5 -0
- package/dist/growth-constants.js +13 -0
- package/dist/growth-dependencies.d.ts +33 -0
- package/dist/growth-dependencies.js +1 -0
- package/dist/growth-execution.d.ts +17 -0
- package/dist/growth-execution.js +346 -0
- package/dist/growth-parallel.d.ts +8 -0
- package/dist/growth-parallel.js +332 -0
- package/dist/growth-plan.d.ts +9 -0
- package/dist/growth-plan.js +229 -0
- package/dist/growth-policy.d.ts +18 -0
- package/dist/growth-policy.js +208 -0
- package/dist/growth-publishing.d.ts +10 -0
- package/dist/growth-publishing.js +266 -0
- package/dist/growth-reading.d.ts +8 -0
- package/dist/growth-reading.js +157 -0
- package/dist/growth-recovery.d.ts +9 -0
- package/dist/growth-recovery.js +187 -0
- package/dist/growth-state.d.ts +16 -0
- package/dist/growth-state.js +220 -0
- package/dist/growth-tasks.d.ts +20 -0
- package/dist/growth-tasks.js +164 -0
- package/dist/growth.d.ts +26 -1
- package/dist/growth.js +211 -1908
- package/dist/platform-capabilities.d.ts +8 -0
- package/dist/platform-capabilities.js +29 -0
- package/dist/platform-collections.d.ts +14 -0
- package/dist/platform-collections.js +250 -0
- package/dist/platform-dependencies.d.ts +43 -0
- package/dist/platform-dependencies.js +1 -0
- package/dist/platform-diagnostics.d.ts +14 -0
- package/dist/platform-diagnostics.js +63 -0
- package/dist/platform-observation.d.ts +16 -0
- package/dist/platform-observation.js +96 -0
- package/dist/platform-rules.d.ts +17 -0
- package/dist/platform-rules.js +41 -0
- package/dist/platform-targets.d.ts +14 -0
- package/dist/platform-targets.js +154 -0
- package/dist/platform-writing.d.ts +16 -0
- package/dist/platform-writing.js +202 -0
- package/dist/platform.d.ts +23 -2
- package/dist/platform.js +125 -678
- package/dist/readers-actions.d.ts +2 -0
- package/dist/readers-actions.js +66 -0
- package/dist/readers-ax-host.d.ts +12 -0
- package/dist/readers-ax-host.js +134 -0
- package/dist/readers-ax-parser.d.ts +1 -0
- package/dist/readers-ax-parser.js +69 -0
- package/dist/readers-ax-program.d.ts +1 -0
- package/dist/readers-ax-program.js +5 -0
- package/dist/readers-ax-tree.d.ts +19 -0
- package/dist/readers-ax-tree.js +111 -0
- package/dist/readers-ax-views.d.ts +87 -0
- package/dist/readers-ax-views.js +247 -0
- package/dist/readers-cli.d.ts +6 -0
- package/dist/readers-cli.js +79 -0
- package/dist/readers-composer.d.ts +2 -0
- package/dist/readers-composer.js +131 -0
- package/dist/readers-navigation.d.ts +2 -0
- package/dist/readers-navigation.js +66 -0
- package/dist/readers-posts.d.ts +2 -0
- package/dist/readers-posts.js +141 -0
- package/dist/readers-profile.d.ts +3 -0
- package/dist/readers-profile.js +157 -0
- package/dist/readers.d.ts +9 -20
- package/dist/readers.js +9 -1045
- package/dist/results.js +1 -1
- package/dist/round-state.js +20 -3
- package/dist/store.d.ts +22 -4
- package/dist/store.js +173 -151
- package/dist/types.d.ts +10 -0
- package/dist/types.js +1 -0
- package/generated/observation.mjs +3 -2
- package/package.json +23 -9
- package/skill/SKILL.md +17 -36
- package/skill/references/api.md +7 -34
- package/skill/references/reads.md +23 -0
- package/skill/references/recovery.md +25 -0
- package/skill/references/setup.md +23 -0
- package/skill/references/writes.md +51 -0
package/dist/platform.js
CHANGED
|
@@ -1,681 +1,128 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
125
|
+
}
|
|
126
|
+
export function createXPlatform(...args) {
|
|
127
|
+
return new XPlatform(...args);
|
|
681
128
|
}
|