@nextclaw/x-harness 0.0.0-stage → 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 (110) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +41 -2
  3. package/cli/main.js +219 -0
  4. package/dist/api.d.ts +10 -0
  5. package/dist/api.js +308 -0
  6. package/dist/boundary.d.ts +2 -0
  7. package/dist/browser-common.d.ts +4 -0
  8. package/dist/browser-common.js +29 -0
  9. package/dist/browser-dom-capabilities.d.ts +4 -0
  10. package/dist/browser-dom-capabilities.js +110 -0
  11. package/dist/browser-dom-dependencies.d.ts +16 -0
  12. package/dist/browser-dom-editing.d.ts +148 -0
  13. package/dist/browser-dom-editing.js +386 -0
  14. package/dist/browser-dom-navigation.d.ts +9 -0
  15. package/dist/browser-dom-navigation.js +165 -0
  16. package/dist/browser-dom-network.d.ts +1 -0
  17. package/dist/browser-dom-network.js +128 -0
  18. package/dist/browser-dom-submission.d.ts +2 -0
  19. package/dist/browser-dom-submission.js +82 -0
  20. package/dist/browser-dom.d.ts +2 -0
  21. package/dist/browser-dom.js +103 -0
  22. package/dist/browser-native.d.ts +1 -0
  23. package/dist/browser-native.js +215 -0
  24. package/dist/browser.d.ts +4 -0
  25. package/dist/browser.js +147 -0
  26. package/dist/creator.d.ts +29 -0
  27. package/dist/creator.js +164 -0
  28. package/dist/growth-batch.d.ts +5 -0
  29. package/dist/growth-batch.js +168 -0
  30. package/dist/growth-constants.d.ts +5 -0
  31. package/dist/growth-constants.js +13 -0
  32. package/dist/growth-dependencies.d.ts +33 -0
  33. package/dist/growth-execution.d.ts +11 -0
  34. package/dist/growth-execution.js +315 -0
  35. package/dist/growth-parallel.d.ts +4 -0
  36. package/dist/growth-parallel.js +301 -0
  37. package/dist/growth-plan.d.ts +4 -0
  38. package/dist/growth-plan.js +207 -0
  39. package/dist/growth-policy.d.ts +9 -0
  40. package/dist/growth-policy.js +181 -0
  41. package/dist/growth-publishing.d.ts +5 -0
  42. package/dist/growth-publishing.js +240 -0
  43. package/dist/growth-reading.d.ts +4 -0
  44. package/dist/growth-reading.js +135 -0
  45. package/dist/growth-recovery.d.ts +6 -0
  46. package/dist/growth-recovery.js +158 -0
  47. package/dist/growth-state.d.ts +8 -0
  48. package/dist/growth-state.js +183 -0
  49. package/dist/growth-tasks.d.ts +17 -0
  50. package/dist/growth-tasks.js +101 -0
  51. package/dist/growth.d.ts +2 -0
  52. package/dist/growth.js +138 -0
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.js +22 -0
  55. package/dist/observation.d.ts +1 -0
  56. package/dist/observation.js +2 -0
  57. package/dist/platform-capabilities.d.ts +4 -0
  58. package/dist/platform-capabilities.js +19 -0
  59. package/dist/platform-collections.d.ts +7 -0
  60. package/dist/platform-collections.js +223 -0
  61. package/dist/platform-dependencies.d.ts +43 -0
  62. package/dist/platform-diagnostics.d.ts +9 -0
  63. package/dist/platform-diagnostics.js +51 -0
  64. package/dist/platform-observation.d.ts +8 -0
  65. package/dist/platform-observation.js +75 -0
  66. package/dist/platform-rules.d.ts +17 -0
  67. package/dist/platform-rules.js +41 -0
  68. package/dist/platform-targets.d.ts +7 -0
  69. package/dist/platform-targets.js +129 -0
  70. package/dist/platform-writing.d.ts +8 -0
  71. package/dist/platform-writing.js +171 -0
  72. package/dist/platform.d.ts +2 -0
  73. package/dist/platform.js +60 -0
  74. package/dist/readers-actions.d.ts +2 -0
  75. package/dist/readers-actions.js +66 -0
  76. package/dist/readers-ax-host.d.ts +2 -0
  77. package/dist/readers-ax-host.js +125 -0
  78. package/dist/readers-ax-parser.d.ts +1 -0
  79. package/dist/readers-ax-parser.js +69 -0
  80. package/dist/readers-ax-program.d.ts +1 -0
  81. package/dist/readers-ax-tree.d.ts +19 -0
  82. package/dist/readers-ax-tree.js +110 -0
  83. package/dist/readers-ax-views.d.ts +87 -0
  84. package/dist/readers-ax-views.js +247 -0
  85. package/dist/readers-cli.d.ts +6 -0
  86. package/dist/readers-composer.d.ts +2 -0
  87. package/dist/readers-composer.js +131 -0
  88. package/dist/readers-navigation.d.ts +2 -0
  89. package/dist/readers-navigation.js +66 -0
  90. package/dist/readers-posts.d.ts +2 -0
  91. package/dist/readers-posts.js +141 -0
  92. package/dist/readers-profile.d.ts +3 -0
  93. package/dist/readers-profile.js +157 -0
  94. package/dist/readers.d.ts +9 -0
  95. package/dist/results.d.ts +6 -0
  96. package/dist/results.js +233 -0
  97. package/dist/round-state.d.ts +3 -0
  98. package/dist/round-state.js +155 -0
  99. package/dist/store.d.ts +30 -0
  100. package/dist/store.js +175 -0
  101. package/dist/types.d.ts +407 -0
  102. package/generated/observation.d.mts +1 -0
  103. package/generated/observation.mjs +515 -0
  104. package/package.json +29 -3
  105. package/skill/SKILL.md +33 -0
  106. package/skill/references/api.md +10 -0
  107. package/skill/references/reads.md +23 -0
  108. package/skill/references/recovery.md +25 -0
  109. package/skill/references/setup.md +23 -0
  110. package/skill/references/writes.md +51 -0
@@ -0,0 +1,19 @@
1
+ export function createPlatformCapabilities(dependencies) {
2
+ const { missing, browser } = dependencies;
3
+ function preflight({ operation, input = {} }) {
4
+ const absent = missing(operation);
5
+ if (operation === "readCreator" &&
6
+ typeof browser.collectCreator !== "function")
7
+ absent.push("browser.collectCreator");
8
+ if (input.kind === "follow" || operation === "submitFollow")
9
+ absent.push(...(browser.capabilities?.("refreshPerson") ?? ["refreshPerson"]));
10
+ if (operation !== "read" && input.assets?.length)
11
+ absent.push(...(browser.capabilities?.("attachAssets") ?? ["attachAssets"]));
12
+ return {
13
+ covered: absent.length === 0,
14
+ missing: [...new Set(absent)],
15
+ owner: "scripts/x-harness-browser.mjs",
16
+ };
17
+ }
18
+ return { preflight };
19
+ }
@@ -0,0 +1,7 @@
1
+ import type { PlatformDependencies } from "./platform-dependencies.js";
2
+ export declare function createPlatformCollections(dependencies: Pick<PlatformDependencies, "ready" | "observed" | "browser" | "choose">): {
3
+ collectSources: ({ processedIds, maxPages, limit, }?: any) => Promise<any>;
4
+ readCreator: (args: import("./types.js").CreatorOptions) => Promise<any>;
5
+ readBookmarks: ({ folder, ...window }?: any) => Promise<any>;
6
+ searchSources: ({ query, limit, maxPages, processedIds, }?: any) => Promise<any>;
7
+ };
@@ -0,0 +1,223 @@
1
+ import { xURL, source } from "./platform-rules.js";
2
+ export function createPlatformCollections(dependencies) {
3
+ const { ready, observed, browser, choose } = dependencies;
4
+ async function collectSources({ processedIds = [], maxPages = 3, limit = 1, } = {}) {
5
+ if (!Array.isArray(processedIds) ||
6
+ !Number.isInteger(maxPages) ||
7
+ maxPages < 1 ||
8
+ maxPages > 10 ||
9
+ !Number.isInteger(limit) ||
10
+ limit < 1 ||
11
+ limit > 5)
12
+ throw new Error("来源窗口须为1–5,页面上限1–10及已处理ID数组");
13
+ const seen = new Set(processedIds), sources = [], missing = [];
14
+ let last, previousIds = "", offset = 0, firstDOMId;
15
+ for (let page = 0; page < maxPages && sources.length < limit; page++) {
16
+ last = await ready({ view: "posts", offset, limit: 5 });
17
+ if (page === 0)
18
+ firstDOMId = last.posts?.[0]?.id;
19
+ if (last.needsStateCheck || last.alerts?.length)
20
+ return {
21
+ ...last,
22
+ sources,
23
+ complete: false,
24
+ missing: ["source-page-unconfirmed"],
25
+ };
26
+ if (offset && !last.posts?.length) {
27
+ missing.push("source-window-not-yet-rendered");
28
+ break;
29
+ }
30
+ const ids = (last.posts ?? []).map((p) => p.id).join(",");
31
+ if (page && ids === previousIds) {
32
+ missing.push("pagination-did-not-advance");
33
+ break;
34
+ }
35
+ previousIds = ids;
36
+ for (const candidate of last.posts ?? []) {
37
+ if (!candidate.id || seen.has(candidate.id))
38
+ continue;
39
+ seen.add(candidate.id);
40
+ let post = candidate;
41
+ if (candidate.needsExpansion) {
42
+ const target = await observed({
43
+ view: "posts",
44
+ targetId: candidate.id,
45
+ limit: 1,
46
+ });
47
+ if (target.posts?.length !== 1) {
48
+ missing.push("expansion-target-unconfirmed");
49
+ continue;
50
+ }
51
+ await browser.act({ operation: "expandPost", postId: candidate.id });
52
+ const expanded = await ready({
53
+ view: "posts",
54
+ targetId: candidate.id,
55
+ limit: 1,
56
+ });
57
+ if (expanded.needsStateCheck || expanded.alerts?.length)
58
+ return {
59
+ ...expanded,
60
+ sources,
61
+ complete: false,
62
+ missing: ["expanded-source-unconfirmed"],
63
+ };
64
+ post = expanded.posts?.[0];
65
+ }
66
+ if (!post ||
67
+ post.needsExpansion ||
68
+ !post.body ||
69
+ !post.author ||
70
+ !Number.isFinite(Date.parse(post.datetime))) {
71
+ missing.push(`incomplete-source:${candidate.id}`);
72
+ continue;
73
+ }
74
+ sources.push(source(post, last.page));
75
+ if (sources.length >= limit)
76
+ break;
77
+ }
78
+ if (sources.length < limit && page + 1 < maxPages) {
79
+ // Expansion narrowed the semantic window; restore the list before scrolling.
80
+ last = await observed({ view: "posts", offset, limit: 5 });
81
+ if (last.moreInDOM)
82
+ offset += 5;
83
+ else {
84
+ await browser.act({ operation: "nextPosts" });
85
+ const advanced = await ready({ view: "posts", limit: 5 });
86
+ // Retained DOM prefixes include a partial final window too; skip
87
+ // exactly the consumed cards, while virtualized replacements restart.
88
+ offset =
89
+ firstDOMId && advanced.posts?.[0]?.id === firstDOMId
90
+ ? offset + (last.posts?.length ?? 0)
91
+ : 0;
92
+ firstDOMId = advanced.posts?.[0]?.id;
93
+ }
94
+ }
95
+ }
96
+ return {
97
+ page: last?.page,
98
+ accountHandle: last?.accountHandle,
99
+ observedAt: last?.observedAt,
100
+ fresh: true,
101
+ sources,
102
+ complete: sources.length >= limit,
103
+ pageComplete: false,
104
+ missing: sources.length >= limit
105
+ ? []
106
+ : [...new Set([...missing, "bounded-source-window-exhausted"])],
107
+ };
108
+ }
109
+ async function readCreator(args) {
110
+ if (typeof browser.collectCreator !== "function")
111
+ return {
112
+ status: "needsCapability",
113
+ complete: false,
114
+ missing: ["browser.collectCreator"],
115
+ };
116
+ const value = await browser.collectCreator(args);
117
+ const absent = [
118
+ ...new Set([
119
+ ...(value.missing ?? []),
120
+ ...(value.profile?.alerts ?? []),
121
+ ...(value.meta?.missing ?? []),
122
+ ]),
123
+ ];
124
+ return {
125
+ ...value,
126
+ sources: value.posts,
127
+ page: `https://x.com/${args.handle}`,
128
+ pageComplete: false,
129
+ complete: value.complete === true && !absent.length && !value.alerts?.length,
130
+ missing: absent,
131
+ };
132
+ }
133
+ async function readBookmarks({ folder, ...window } = {}) {
134
+ if (typeof folder !== "string" || !folder.trim())
135
+ throw new Error("需指定收藏文件夹;不以全部书签冒充命名文件夹");
136
+ let nav = await ready({ view: "navigation", targetLabel: folder });
137
+ const bookmark = (c) => /^(书签|收藏|Bookmarks)$/i.test(c.label);
138
+ if (!(await choose(nav, bookmark, { activateSelected: true }))) {
139
+ if (await choose(nav, (c) => /^(更多菜单项|More menu items)$/i.test(c.label))) {
140
+ nav = await ready({ view: "menu" });
141
+ if (!(await choose(nav, bookmark))) {
142
+ if (nav.items?.length)
143
+ await browser.act({ operation: "dismissMenu" });
144
+ nav = await ready({ view: "navigation", targetLabel: folder });
145
+ if (!(await choose(nav, (c) => /^(历史|History)$/i.test(c.label))))
146
+ return {
147
+ status: "needsCapability",
148
+ owner: "scripts/x-harness-browser.mjs",
149
+ missing: ["bookmarks-entry-not-observed"],
150
+ };
151
+ }
152
+ }
153
+ else if (!(await choose(nav, (c) => /^(历史|History)$/i.test(c.label))))
154
+ return {
155
+ status: "needsCapability",
156
+ owner: "scripts/x-harness-browser.mjs",
157
+ missing: ["bookmarks-entry-not-observed"],
158
+ };
159
+ }
160
+ nav = await ready({ view: "navigation", targetLabel: folder });
161
+ const tabs = (nav.controls ?? []).filter((c) => c.role === "tab" && bookmark(c));
162
+ if (tabs.length === 1 &&
163
+ (!tabs[0].selected ||
164
+ !(nav.controls ?? []).some((c) => c.label.split("\n")[0] === folder))) {
165
+ await choose(nav, (c) => c === tabs[0], {
166
+ activateSelected: true,
167
+ });
168
+ nav = await ready({ view: "navigation", targetLabel: folder });
169
+ }
170
+ if (!(nav.controls ?? []).some((c) => c.label.split("\n")[0] === folder)) {
171
+ try {
172
+ await browser.act({ operation: "waitForFolder", folder });
173
+ }
174
+ catch (error) {
175
+ if (!/timed out|timeout/i.test(error.message))
176
+ throw error;
177
+ }
178
+ nav = await ready({ view: "navigation", targetLabel: folder });
179
+ }
180
+ if (!(await choose(nav, (c) => c.label.split("\n")[0] === folder)))
181
+ return {
182
+ status: "needsCapability",
183
+ owner: "scripts/x-harness-browser.mjs",
184
+ page: nav.page,
185
+ missing: ["named-bookmark-folder-not-observed"],
186
+ folder,
187
+ controls: nav.controls,
188
+ };
189
+ const selected = await ready({ view: "navigation" });
190
+ if (selected.page === nav.page && !selected.headings?.includes(folder))
191
+ return {
192
+ status: "needsDecision",
193
+ complete: false,
194
+ missing: ["folder-selection-unconfirmed"],
195
+ folder,
196
+ page: selected.page,
197
+ };
198
+ const value = await collectSources(window);
199
+ return {
200
+ ...value,
201
+ folder,
202
+ folderEvidence: {
203
+ entryPage: nav.page,
204
+ selectedPage: selected.page,
205
+ name: folder,
206
+ },
207
+ };
208
+ }
209
+ async function searchSources({ query, limit = 3, maxPages = 1, processedIds = [], } = {}) {
210
+ await browser.act({ operation: "searchSources", query });
211
+ const value = await collectSources({ limit, maxPages, processedIds });
212
+ if (!value.page ||
213
+ xURL(value.page).pathname !== "/search" ||
214
+ xURL(value.page).searchParams.get("q") !== query)
215
+ return {
216
+ ...value,
217
+ complete: false,
218
+ missing: ["search-query-unconfirmed"],
219
+ };
220
+ return { ...value, query };
221
+ }
222
+ return { collectSources, readCreator, readBookmarks, searchSources };
223
+ }
@@ -0,0 +1,43 @@
1
+ import type { CreatorOptions } from "./types.js";
2
+ import type { BoundaryRecord } from "./boundary.js";
3
+ export interface PlatformDependencies {
4
+ browser: BoundaryRecord;
5
+ clock: () => number;
6
+ followStage: (action: any, stage: any, run: any) => Promise<any>;
7
+ missing: (operation: any) => any[];
8
+ observed: (query: any) => Promise<any>;
9
+ open: (target: any, query: any) => Promise<any>;
10
+ ready: (query: any) => Promise<any>;
11
+ choose: (value: any, pattern: any, { activateSelected }?: any) => Promise<any>;
12
+ source: (post: any, page: any) => {
13
+ id: any;
14
+ author: any;
15
+ datetime: any;
16
+ url: string | null;
17
+ body: any;
18
+ images: any;
19
+ media: any;
20
+ links: any;
21
+ needsExpansion: any;
22
+ missing: any[];
23
+ };
24
+ collectSources: ({ processedIds, maxPages, limit }?: any) => Promise<any>;
25
+ compose: (action: any) => Promise<any>;
26
+ preflight: ({ operation, input }: any) => any;
27
+ readCreator: (args: CreatorOptions) => Promise<any>;
28
+ readAccount: () => Promise<any>;
29
+ readBookmarks: ({ folder, ...window }?: any) => Promise<any>;
30
+ searchSources: ({ query, limit, maxPages, processedIds, }?: any) => Promise<any>;
31
+ readFeedback: ({ targets, replyLimit }?: any) => Promise<any>;
32
+ readPerson: (target: any) => Promise<any>;
33
+ readPost: (target: any) => Promise<any>;
34
+ findSubmission: (action: any) => Promise<any>;
35
+ submitFollow: (action: any) => Promise<any>;
36
+ submitPost: (action: any) => Promise<any>;
37
+ submitReply: (action: any) => Promise<any>;
38
+ captureProof: ({ observation }: any) => {
39
+ followDiagnostics?: any[] | undefined;
40
+ capturedAt: string;
41
+ observation: any;
42
+ };
43
+ }
@@ -0,0 +1,9 @@
1
+ import type { PlatformDependencies } from "./platform-dependencies.js";
2
+ export declare function createPlatformDiagnostics(dependencies: Pick<PlatformDependencies, "clock">): {
3
+ followStage: (action: any, stage: any, run: any) => Promise<any>;
4
+ captureProof: ({ observation }: any) => {
5
+ followDiagnostics?: any[] | undefined;
6
+ capturedAt: string;
7
+ observation: any;
8
+ };
9
+ };
@@ -0,0 +1,51 @@
1
+ export function createPlatformDiagnostics(dependencies) {
2
+ const { clock } = dependencies;
3
+ // Bounded semantic diagnostics travel with existing proof captures; no page dump or second state store.
4
+ const followDiagnostics = [];
5
+ async function followStage(action, stage, run) {
6
+ const started = clock();
7
+ const event = {
8
+ actionId: action.id ?? null,
9
+ handle: action.handle,
10
+ stage,
11
+ startedAt: new Date(started).toISOString(),
12
+ };
13
+ followDiagnostics.push(event);
14
+ if (followDiagnostics.length > 40)
15
+ followDiagnostics.shift();
16
+ try {
17
+ const value = await run();
18
+ Object.assign(event, {
19
+ status: "returned",
20
+ durationMs: clock() - started,
21
+ });
22
+ if (value && typeof value === "object")
23
+ Object.assign(event, {
24
+ page: value.page,
25
+ accountHandle: value.accountHandle,
26
+ relationship: value.relationship,
27
+ relationshipControls: value.relationshipControls,
28
+ needsStateCheck: value.needsStateCheck,
29
+ alerts: value.alerts,
30
+ fresh: value.fresh,
31
+ });
32
+ return value;
33
+ }
34
+ catch (error) {
35
+ Object.assign(event, {
36
+ status: "error",
37
+ durationMs: clock() - started,
38
+ error: String(error.message ?? error).slice(0, 500),
39
+ });
40
+ throw error;
41
+ }
42
+ }
43
+ const captureProof = ({ observation }) => ({
44
+ capturedAt: new Date(clock()).toISOString(),
45
+ observation,
46
+ ...(followDiagnostics.length
47
+ ? { followDiagnostics: structuredClone(followDiagnostics) }
48
+ : {}),
49
+ });
50
+ return { followStage, captureProof };
51
+ }
@@ -0,0 +1,8 @@
1
+ import type { PlatformDependencies } from "./platform-dependencies.js";
2
+ export declare function createPlatformObservation(dependencies: Pick<PlatformDependencies, "browser" | "clock">): {
3
+ missing: (operation: any) => any[];
4
+ observed: (query: any) => Promise<any>;
5
+ open: (target: any, query: any) => Promise<any>;
6
+ ready: (query: any) => Promise<any>;
7
+ choose: (value: any, pattern: any, { activateSelected }?: any) => Promise<any>;
8
+ };
@@ -0,0 +1,75 @@
1
+ import { xURL } from "./platform-rules.js";
2
+ export function createPlatformObservation(dependencies) {
3
+ const { browser, clock } = dependencies;
4
+ const methods = ["observe", "act"];
5
+ const missing = (operation) => [
6
+ ...methods.filter((name) => typeof browser?.[name] !== "function"),
7
+ ...(typeof browser?.capabilities === "function"
8
+ ? browser.capabilities(operation)
9
+ : ["capabilities"]),
10
+ ];
11
+ async function observed(query) {
12
+ const value = await browser.observe(query);
13
+ return {
14
+ ...value,
15
+ observedAt: new Date(clock()).toISOString(),
16
+ fresh: true,
17
+ };
18
+ }
19
+ async function open(target, query) {
20
+ const url = xURL(target.url);
21
+ if (!target.evidenceRef)
22
+ throw new Error("目标URL缺已观察证据");
23
+ let value = await observed(query);
24
+ let atTarget = false;
25
+ try {
26
+ atTarget = xURL(value.page).href === url.href;
27
+ }
28
+ catch {
29
+ /* Blank/non-X pages require observed navigation. */
30
+ }
31
+ if (!atTarget) {
32
+ await browser.act({
33
+ operation: "openObserved",
34
+ url: url.href,
35
+ evidenceRef: target.evidenceRef,
36
+ view: query.view,
37
+ handle: query.handle,
38
+ });
39
+ value = await ready(query);
40
+ }
41
+ if (!value.page || xURL(value.page).href !== url.href)
42
+ return {
43
+ ...value,
44
+ needsStateCheck: true,
45
+ missing: ["target-page-mismatch"],
46
+ };
47
+ return value;
48
+ }
49
+ async function ready(query) {
50
+ let value = await observed(query);
51
+ if (value.needsStateCheck &&
52
+ !value.alerts?.length &&
53
+ query.view === "posts") {
54
+ try {
55
+ await browser.act({ operation: "waitForPosts" });
56
+ }
57
+ catch (error) {
58
+ if (!/timed out|timeout/i.test(error.message))
59
+ throw error;
60
+ }
61
+ value = await observed(query);
62
+ }
63
+ return value;
64
+ }
65
+ async function choose(value, pattern, { activateSelected = false } = {}) {
66
+ const controls = (value.controls ?? value.items ?? []).filter((c) => !c.disabled && pattern(c));
67
+ if (controls.length !== 1)
68
+ return false;
69
+ if (controls[0].selected && !activateSelected)
70
+ return true;
71
+ await browser.act({ operation: "openControl", ...controls[0] });
72
+ return true;
73
+ }
74
+ return { missing, observed, open, ready, choose };
75
+ }
@@ -0,0 +1,17 @@
1
+ export declare const handlePattern: RegExp;
2
+ export declare function xURL(value: any): any;
3
+ export declare const sameHandle: (a: any, b: any) => boolean;
4
+ export declare const sameText: (a: any, b: any) => boolean;
5
+ export declare const samePublishedText: (a: any, b: any) => boolean;
6
+ export declare const source: (post: any, page: any) => {
7
+ id: any;
8
+ author: any;
9
+ datetime: any;
10
+ url: string | null;
11
+ body: any;
12
+ images: any;
13
+ media: any;
14
+ links: any;
15
+ needsExpansion: any;
16
+ missing: any[];
17
+ };
@@ -0,0 +1,41 @@
1
+ export 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
+ export const sameHandle = (a, b) => typeof a === "string" &&
13
+ typeof b === "string" &&
14
+ a.toLowerCase() === b.toLowerCase();
15
+ export const sameText = (a, b) => typeof a === "string" &&
16
+ typeof b === "string" &&
17
+ a.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n") ===
18
+ b.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n");
19
+ export const samePublishedText = (a, b) => {
20
+ const rendered = (value) => typeof value === "string"
21
+ ? value
22
+ .replace(/(https?:\/\/[^\s…]+)…(?=\s|$)/g, "$1")
23
+ .replace(/(https?:\/\/[A-Za-z0-9.-]+(?::\d+)?)\/(?=[\s`]|$)/g, "$1")
24
+ : value;
25
+ return sameText(rendered(a), rendered(b));
26
+ };
27
+ export const source = (post, page) => ({
28
+ id: post.id,
29
+ author: post.author,
30
+ datetime: post.datetime,
31
+ url: post.primary ? new URL(post.primary, page).href : null,
32
+ body: post.body,
33
+ images: post.images ?? [],
34
+ media: post.media ?? [],
35
+ links: post.links ?? [],
36
+ needsExpansion: post.needsExpansion,
37
+ missing: [
38
+ ...(post.missing ?? []),
39
+ ...(!post.body ? ["exact-body-unconfirmed"] : []),
40
+ ],
41
+ });
@@ -0,0 +1,7 @@
1
+ import type { PlatformDependencies } from "./platform-dependencies.js";
2
+ export declare function createPlatformTargets(dependencies: Pick<PlatformDependencies, "observed" | "open" | "browser" | "ready">): {
3
+ readAccount: () => Promise<any>;
4
+ readFeedback: ({ targets, replyLimit, }?: any) => Promise<any>;
5
+ readPerson: (target: any) => Promise<any>;
6
+ readPost: (target: any) => Promise<any>;
7
+ };
@@ -0,0 +1,129 @@
1
+ import { xURL, sameHandle, handlePattern, source } from "./platform-rules.js";
2
+ export function createPlatformTargets(dependencies) {
3
+ const { observed, open, browser, ready } = dependencies;
4
+ const readAccount = () => observed({ view: "account" });
5
+ async function readFeedback({ targets, replyLimit = 3, } = {}) {
6
+ if (!Array.isArray(targets) || targets.length < 1 || targets.length > 5)
7
+ throw new Error("反馈窗口须为1–5个已发布ID");
8
+ if (!Number.isInteger(replyLimit) || replyLimit < 0 || replyLimit > 3)
9
+ throw new Error("回复核验窗口须为0–3");
10
+ const feedback = [];
11
+ for (const target of targets) {
12
+ const value = await readPost(target), post = value.posts?.find((p) => p.id === target.postId);
13
+ if (value.needsStateCheck || value.alerts?.length || !post) {
14
+ feedback.push({
15
+ id: target.postId,
16
+ missing: ["published-post-unconfirmed"],
17
+ });
18
+ continue;
19
+ }
20
+ const thread = await observed({ view: "posts", limit: 5 });
21
+ const replies = [], unconfirmedReplyIds = [];
22
+ const targetIndex = (thread.posts ?? []).findIndex((p) => p.id === post.id);
23
+ const candidates = targetIndex < 0
24
+ ? []
25
+ : thread.posts.slice(targetIndex + 1, targetIndex + 1 + replyLimit);
26
+ for (const candidate of candidates) {
27
+ if (!candidate.primary || !candidate.id)
28
+ continue;
29
+ const url = new URL(candidate.primary, thread.page).href;
30
+ const detail = await open({
31
+ url,
32
+ evidenceRef: {
33
+ page: thread.page,
34
+ observedAt: thread.observedAt,
35
+ publicId: candidate.id,
36
+ primary: candidate.primary,
37
+ },
38
+ }, {
39
+ view: "posts",
40
+ targetId: candidate.id,
41
+ replyTargetId: target.postId,
42
+ limit: 1,
43
+ });
44
+ const reply = detail.posts?.find((p) => p.id === candidate.id);
45
+ if (!detail.needsStateCheck &&
46
+ !detail.alerts?.length &&
47
+ reply?.replyToId === target.postId &&
48
+ reply.body &&
49
+ !reply.needsExpansion)
50
+ replies.push(source(reply, detail.page));
51
+ else
52
+ unconfirmedReplyIds.push(candidate.id);
53
+ }
54
+ feedback.push({
55
+ id: post.id,
56
+ author: post.author,
57
+ metrics: [...(post.buttons ?? []), ...(post.metrics ?? [])],
58
+ replies,
59
+ unconfirmedReplyIds,
60
+ pageComplete: false,
61
+ observedAt: value.observedAt,
62
+ });
63
+ }
64
+ return {
65
+ feedback,
66
+ fresh: true,
67
+ complete: feedback.every((f) => !f.missing),
68
+ pageComplete: false,
69
+ };
70
+ }
71
+ async function readPerson(target) {
72
+ if (!handlePattern.test(target.handle ?? ""))
73
+ throw new Error("无效已观察handle");
74
+ if (xURL(target.url).pathname.toLowerCase() !==
75
+ `/${target.handle}`.toLowerCase())
76
+ throw new Error("人物URL与handle不一致");
77
+ const value = await open(target, {
78
+ view: "person",
79
+ handle: target.handle,
80
+ });
81
+ return sameHandle(value.handle, target.handle)
82
+ ? { ...value, url: target.url }
83
+ : {
84
+ ...value,
85
+ needsStateCheck: true,
86
+ missing: ["person-identity-mismatch"],
87
+ };
88
+ }
89
+ async function readPost(target) {
90
+ if (!/^\d+$/.test(target.postId ?? "") ||
91
+ xURL(target.url).pathname.match(/\/status\/(\d+)$/)?.[1] !== target.postId)
92
+ throw new Error("帖子URL与公开ID不一致");
93
+ let value = await open(target, {
94
+ view: "posts",
95
+ targetId: target.postId,
96
+ });
97
+ if (!target.expand)
98
+ return value;
99
+ let posts = (value.posts ?? []).filter((p) => p.id === target.postId);
100
+ if (!value.needsStateCheck &&
101
+ !value.alerts?.length &&
102
+ posts.length === 1 &&
103
+ posts[0].needsExpansion) {
104
+ await browser.act({ operation: "expandPost", postId: target.postId });
105
+ value = await ready({ view: "posts", targetId: target.postId });
106
+ posts = (value.posts ?? []).filter((p) => p.id === target.postId);
107
+ }
108
+ const missing = [...(value.missing ?? [])];
109
+ if (posts.length !== 1)
110
+ missing.push("unique-target-post-unconfirmed");
111
+ else {
112
+ if (!posts[0].body)
113
+ missing.push("exact-body-unconfirmed");
114
+ if (posts[0].needsExpansion)
115
+ missing.push("post-expansion-unconfirmed");
116
+ if (!posts[0].author)
117
+ missing.push("post-author-unconfirmed");
118
+ if (!Number.isFinite(Date.parse(posts[0].datetime)))
119
+ missing.push("absolute-time-unconfirmed");
120
+ }
121
+ return {
122
+ ...value,
123
+ posts,
124
+ complete: !missing.length && !value.needsStateCheck && !value.alerts?.length,
125
+ missing,
126
+ };
127
+ }
128
+ return { readAccount, readFeedback, readPerson, readPost };
129
+ }
@@ -0,0 +1,8 @@
1
+ import type { PlatformDependencies } from "./platform-dependencies.js";
2
+ export declare function createPlatformWriting(dependencies: Pick<PlatformDependencies, "observed" | "browser" | "followStage" | "readPerson" | "open" | "clock" | "readPost">): {
3
+ compose: (action: any) => Promise<any>;
4
+ findSubmission: (action: any) => Promise<any>;
5
+ submitFollow: (action: any) => Promise<any>;
6
+ submitPost: (action: any) => Promise<any>;
7
+ submitReply: (action: any) => Promise<any>;
8
+ };