@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,215 @@
1
+ import { createAXHarness } from "./readers-ax-host.js";
2
+ import { readAX } from "./readers-ax-parser.js";
3
+ import { xURL } from "./platform-rules.js";
4
+ import { serialized, missingMethods, plain, valueOf, } from "./browser-common.js";
5
+ export function createNativeXBrowser(app) {
6
+ async function navigateNativeTarget(command) {
7
+ xURL(command.url);
8
+ if (!command.evidenceRef)
9
+ throw new Error("导航URL缺原观察证据");
10
+ await low.observe({ mode: "navigation" }).then((raw) => {
11
+ current = { raw };
12
+ });
13
+ let index = one((e) => /^文本栏 .*地址和搜索栏/.test(e.label));
14
+ await low
15
+ .act({
16
+ index,
17
+ operation: "fill",
18
+ value: command.url,
19
+ read: { mode: "navigation" },
20
+ })
21
+ .then((raw) => {
22
+ current = { raw };
23
+ });
24
+ index = one((e) => /^文本栏 .*地址和搜索栏/.test(e.label));
25
+ await low.act({
26
+ index,
27
+ operation: "pressKey",
28
+ value: "Return",
29
+ read: { mode: "navigation" },
30
+ });
31
+ current = null;
32
+ return;
33
+ }
34
+ let latestAX;
35
+ const delegated = {};
36
+ for (const name of [
37
+ "getAXState",
38
+ "click",
39
+ "pressKey",
40
+ "paste",
41
+ "scroll",
42
+ "performSecondaryAction",
43
+ "getScreenshot",
44
+ ]) {
45
+ Object.defineProperty(delegated, name, {
46
+ get() {
47
+ if (typeof app?.[name] !== "function")
48
+ return undefined;
49
+ return name === "getAXState"
50
+ ? async (options) => {
51
+ latestAX = await app.getAXState(options);
52
+ return latestAX;
53
+ }
54
+ : (...args) => app[name](...args);
55
+ },
56
+ });
57
+ }
58
+ const low = createAXHarness(delegated);
59
+ let current;
60
+ const modes = {
61
+ account: "navigation",
62
+ person: "profile",
63
+ posts: "posts",
64
+ composer: "composer",
65
+ };
66
+ async function observe(query) {
67
+ current = null;
68
+ if (!modes[query.view])
69
+ throw new Error("未封装的业务视图");
70
+ const raw = await low.observe({
71
+ mode: modes[query.view],
72
+ ...(query.targetId ? { targetId: query.targetId } : {}),
73
+ limit: query.limit ?? 1,
74
+ });
75
+ current = { query, raw };
76
+ const entries = raw.evidence ?? [];
77
+ // Bind actor and target to the SAME suppressed observation; no additional tool round trip.
78
+ const ownLinks = readAX(latestAX, { mode: "navigation" }).evidence.filter((e) => /^link 个人资料, Value: x\.com\/\w+$/.test(e.label));
79
+ const base = {
80
+ page: raw.page,
81
+ alerts: raw.alerts,
82
+ needsStateCheck: raw.needsStateCheck,
83
+ missing: raw.missing ?? [],
84
+ accountHandle: ownLinks.length === 1
85
+ ? ownLinks[0].label.match(/x\.com\/(\w+)$/)[1]
86
+ : null,
87
+ };
88
+ if (query.view === "account") {
89
+ const own = entries.filter((e) => /^link 个人资料, Value: x\.com\/\w+$/.test(e.label));
90
+ return {
91
+ ...base,
92
+ accountHandle: own.length === 1 ? own[0].label.match(/x\.com\/(\w+)$/)[1] : null,
93
+ accountURL: own.length === 1 ? `https://${valueOf(own[0].label)}` : null,
94
+ };
95
+ }
96
+ if (query.view === "person") {
97
+ const labels = entries.map((e) => plain(e.label));
98
+ return {
99
+ ...base,
100
+ handle: query.handle,
101
+ relationship: labels.includes(`正在关注 @${query.handle}`) ||
102
+ labels.includes(`取消关注 @${query.handle}`)
103
+ ? "following"
104
+ : labels.includes(`回关 @${query.handle}`)
105
+ ? "follows-you"
106
+ : labels.includes(`关注 @${query.handle}`)
107
+ ? "not-following"
108
+ : "unknown",
109
+ blue: null,
110
+ };
111
+ }
112
+ if (query.view === "composer") {
113
+ const editors = entries.filter((e) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(e.label));
114
+ const ids = [
115
+ ...new Set(entries.flatMap((e) => e.label.match(/x\.com\/\w+\/status\/(\d+)(?:,|$)/)?.[1] ?? [])),
116
+ ];
117
+ return {
118
+ ...base,
119
+ editorText: editors.length === 1 ? valueOf(editors[0].label) : null,
120
+ replyToId: ids.length === 1 ? ids[0] : null,
121
+ };
122
+ }
123
+ return {
124
+ ...base,
125
+ posts: (raw.posts ?? []).map((p) => ({
126
+ ...p,
127
+ body: p.text,
128
+ replyToId: null,
129
+ })),
130
+ };
131
+ }
132
+ const one = (predicate) => {
133
+ const entries = (current?.raw.evidence ?? [])
134
+ .concat(current?.raw.navigation ?? [])
135
+ .filter(predicate);
136
+ if (entries.length !== 1)
137
+ throw new Error("语义控件未唯一观察:先补能力/证据");
138
+ return entries[0].index;
139
+ };
140
+ return serialized({
141
+ capabilities(operation) {
142
+ if (["readBookmarks", "searchSources", "readFeedback"].includes(operation))
143
+ return [`native.${operation}`];
144
+ const names = operation === "read"
145
+ ? ["getAXState"]
146
+ : ["getAXState", "click", "pressKey", "paste"];
147
+ return operation === "attachAssets"
148
+ ? ["native.attachAssets"]
149
+ : operation === "refreshPosts"
150
+ ? ["native.refreshPosts"]
151
+ : missingMethods(app, names);
152
+ },
153
+ observe,
154
+ async act(command) {
155
+ if (command.operation === "openObserved") {
156
+ return navigateNativeTarget(command);
157
+ }
158
+ if (command.operation === "refreshPerson") {
159
+ xURL(command.url);
160
+ await low.observe({ mode: "navigation" }).then((raw) => {
161
+ current = { raw };
162
+ });
163
+ const index = one((e) => /^按钮 (重新加载|刷新)(?:,|$)/.test(e.label));
164
+ current = null;
165
+ return low.act({
166
+ index,
167
+ operation: "click",
168
+ read: { mode: "profile" },
169
+ });
170
+ }
171
+ if (!current)
172
+ throw new Error("语义操作必须先observe");
173
+ let index, operation = "click", value;
174
+ if (command.operation === "follow")
175
+ index = one((e) => [
176
+ `按钮 关注 @${command.handle}`,
177
+ `按钮 回关 @${command.handle}`,
178
+ ].includes(e.label));
179
+ else if (command.operation === "openReply") {
180
+ if (current.query?.targetId !== command.postId)
181
+ throw new Error("回复目标不匹配");
182
+ const target = current.raw.posts?.filter((p) => p.id === command.postId);
183
+ if (target?.length !== 1)
184
+ throw new Error("回复主帖不唯一");
185
+ const buttons = target[0].evidence.filter((e) => /^按钮 .*回复/.test(e.label));
186
+ if (buttons.length !== 1)
187
+ throw new Error("回复按钮不唯一");
188
+ index = buttons[0].index;
189
+ }
190
+ else if (command.operation === "fillComposer") {
191
+ index = one((e) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(e.label));
192
+ operation = "fill";
193
+ value = command.text;
194
+ }
195
+ else if (["submitPost", "submitReply"].includes(command.operation)) {
196
+ index = one((e) => new RegExp(command.operation === "submitReply"
197
+ ? "^按钮 (回复|Reply)(?:,|$)"
198
+ : "^按钮 (发帖|发布|Post)(?:,|$)", "i").test(e.label));
199
+ }
200
+ else
201
+ throw new Error("未封装的原生动作");
202
+ const read = {
203
+ mode: command.operation === "follow" ? "profile" : "composer",
204
+ };
205
+ if (["submitPost", "submitReply"].includes(command.operation))
206
+ await command.beforeSubmit?.();
207
+ current = null;
208
+ return low.act({ index, operation, value, read });
209
+ },
210
+ screenshot: () => {
211
+ current = null;
212
+ return low.screenshot();
213
+ },
214
+ });
215
+ }
@@ -0,0 +1,4 @@
1
+ export { createDOMXBrowser, readXNetworkDiagnostics } from "./browser-dom.js";
2
+ export { createNativeXBrowser } from "./browser-native.js";
3
+ export declare function readXSurface({ view, posts, targetId, replyTargetId, }?: any): any;
4
+ export declare const atomicObservationCode: string;
@@ -0,0 +1,147 @@
1
+ import { readProfile } from "./readers-profile.js";
2
+ import { readPosts } from "./readers-posts.js";
3
+ import { readComposer, readMediaEditor } from "./readers-composer.js";
4
+ import { readNavigation, readMenu } from "./readers-navigation.js";
5
+ export { createDOMXBrowser, readXNetworkDiagnostics } from "./browser-dom.js";
6
+ export { createNativeXBrowser } from "./browser-native.js";
7
+ // Read-only supplement: account identity, composer context and exact tweet bodies.
8
+ // Existing readers still own article/profile/composer boundaries.
9
+ export function readXSurface({ view, posts = [], targetId, replyTargetId, } = {}) {
10
+ const visible = (el) => el.getClientRects().length > 0;
11
+ const profile = [...document.querySelectorAll("a[href]")].filter((el) => visible(el) &&
12
+ ["个人资料", "Profile"].includes(el.getAttribute("aria-label") || el.innerText.trim()) &&
13
+ /^\/\w{1,15}$/.test(el.getAttribute("href")));
14
+ const own = profile.length === 1 ? profile[0].getAttribute("href").slice(1) : null;
15
+ const base = {
16
+ page: location.href,
17
+ accountHandle: own,
18
+ accountURL: own ? `https://x.com/${own}` : null,
19
+ alerts: [...document.querySelectorAll('[role="alert"]')]
20
+ .filter(visible)
21
+ .map((el) => el.innerText),
22
+ };
23
+ if (view === "composer") {
24
+ const dialogs = [
25
+ ...document.querySelectorAll('[role="dialog"]'),
26
+ ].filter(visible);
27
+ const ids = dialogs.length === 1
28
+ ? [
29
+ ...new Set([...dialogs[0].querySelectorAll("a[href]")]
30
+ .filter(visible)
31
+ .flatMap((el) => el
32
+ .getAttribute("href")
33
+ .match(/^\/\w+\/status\/(\d+)$/)?.[1] ?? [])),
34
+ ]
35
+ : [];
36
+ const replyContexts = [...new Set(dialogs.map((el) => el.innerText))];
37
+ const replyQuotes = [
38
+ ...new Set(dialogs.flatMap((el) => [...el.querySelectorAll('[data-testid="tweetText"]')]
39
+ .filter(visible)
40
+ .map((text) => text.innerText))),
41
+ ];
42
+ return {
43
+ ...base,
44
+ replyToId: ids.length === 1 ? ids[0] : null,
45
+ inlineComposer: dialogs.length === 0,
46
+ replyContexts,
47
+ replyQuotes,
48
+ };
49
+ }
50
+ if (view === "posts") {
51
+ const all = [...document.querySelectorAll("article")].filter(visible);
52
+ return {
53
+ ...base,
54
+ bodies: posts.map((post) => {
55
+ const articles = all.filter((article) => [...article.querySelectorAll("time")]
56
+ .filter(visible)
57
+ .find((time) => time.closest("a")?.getAttribute("href") === post.primary)
58
+ ?.closest("a")
59
+ ?.getAttribute("href") === post.primary);
60
+ const texts = articles.length === 1
61
+ ? [
62
+ ...articles[0].querySelectorAll('[data-testid="tweetText"]'),
63
+ ].filter((el) => visible(el) && !el.closest?.('[role="link"][tabindex="0"]'))
64
+ : [];
65
+ let replyToId = null, parentEvidence = null;
66
+ const childIndex = all.indexOf(articles[0]);
67
+ if (replyTargetId &&
68
+ targetId === post.id &&
69
+ articles.length === 1 &&
70
+ childIndex > 0 &&
71
+ new URL(location.href).pathname.endsWith(`/status/${post.id}`)) {
72
+ // Nested conversations can include older ancestors before the direct parent.
73
+ const parent = [...all[childIndex - 1].querySelectorAll("time")]
74
+ .filter(visible)
75
+ .find((time) => /^\/\w+\/status\/\d+$/.test(time.closest("a")?.getAttribute("href") ?? ""))
76
+ ?.closest("a")
77
+ ?.getAttribute("href");
78
+ const match = parent?.match(/^\/(\w+)\/status\/(\d+)$/);
79
+ const labels = [...articles[0].querySelectorAll("div,span")]
80
+ .filter(visible)
81
+ .filter((el) => !el.closest('[data-testid="tweetText"]') &&
82
+ !el.querySelector('[data-testid="tweetText"]'))
83
+ .map((el) => el.innerText.trim().replace(/\s+/g, " "));
84
+ const directLabel = match &&
85
+ labels.some((text) => [`回复 @${match[1]}`, `Replying to @${match[1]}`].includes(text));
86
+ // X can omit the inline reply label on a permalink. Its title still prefixes the direct
87
+ // recipient before older ancestors; require that prefix and the adjacent parent together.
88
+ const titleRecipient = (document.title ?? "").match(/[“"]@([A-Za-z0-9_]{1,15})(?=\s)/)?.[1];
89
+ const titleMatchesParent = match && titleRecipient?.toLowerCase() === match[1].toLowerCase();
90
+ if (match?.[2] === replyTargetId &&
91
+ (directLabel || titleMatchesParent)) {
92
+ replyToId = match[2];
93
+ parentEvidence = {
94
+ parentPrimary: parent,
95
+ childPrimary: post.primary,
96
+ labels: labels.filter((text) => /^(回复|Replying to) @/.test(text)),
97
+ ...(titleMatchesParent ? { titleRecipient } : {}),
98
+ };
99
+ }
100
+ }
101
+ // Exclude observed nested quote-card text; any remaining ambiguity stays unknown.
102
+ return {
103
+ id: post.id,
104
+ body: texts.length === 1
105
+ ? (() => {
106
+ const root = texts[0];
107
+ // innerText adds layout breaks around inline mentions and URLs.
108
+ // Reconstruct only observed X permalinks; preserve all other text.
109
+ const read = (node) => {
110
+ if (node.nodeType === 3)
111
+ return node.textContent ?? "";
112
+ if (node.nodeType === 1 &&
113
+ node.tagName === "A") {
114
+ const href = node.getAttribute("href") ?? "";
115
+ if (/^\/\w{1,15}\/status\/\d+$/.test(href))
116
+ return `https://x.com${href}`;
117
+ }
118
+ return [...node.childNodes].map(read).join("");
119
+ };
120
+ return root.childNodes ? read(root) : root.innerText;
121
+ })()
122
+ : null,
123
+ replyToId,
124
+ parentEvidence,
125
+ };
126
+ }),
127
+ };
128
+ }
129
+ return base;
130
+ }
131
+ // CUA accepts a page-function string. Compose the existing read-only parsers,
132
+ // rather than running two browser requests or maintaining another tweet parser.
133
+ export const atomicObservationCode = `(query) => {
134
+ const readPosts = ${readPosts.toString()};
135
+ const readProfile = ${readProfile.toString()};
136
+ const readComposer = ${readComposer.toString()};
137
+ const readMediaEditor = ${readMediaEditor.toString()};
138
+ const readNavigation = ${readNavigation.toString()};
139
+ const readMenu = ${readMenu.toString()};
140
+ const readXSurface = ${readXSurface.toString()};
141
+ const readers = { posts:readPosts, person:readProfile, composer:readComposer, media:readMediaEditor, navigation:readNavigation, menu:readMenu, account:()=>({}) };
142
+ if (!readers[query.view]) throw new Error('未封装的DOM视图');
143
+ const raw = readers[query.view](query);
144
+ const surface = readXSurface({...query, posts:(raw.posts??[]).map(p=>({id:p.id,primary:p.primary}))});
145
+ return {...raw,...surface,needsStateCheck:Boolean(raw.needsStateCheck)||!surface.accountHandle,
146
+ ...(query.view==='posts'?{posts:raw.posts.map(p=>({...p,...surface.bodies.find(b=>b.id===p.id)}))}: {})};
147
+ }`;
@@ -0,0 +1,29 @@
1
+ import type { BoundaryRecord } from "./boundary.js";
2
+ import type { CreatorOptions } from "./types.js";
3
+ export declare function readCreatorMeta(): {
4
+ headerText: null;
5
+ totalPostsText: null;
6
+ missing: string[];
7
+ externalLinks?: undefined;
8
+ } | {
9
+ headerText: string;
10
+ totalPostsText: string | null;
11
+ externalLinks: (string | null)[];
12
+ missing: string[];
13
+ };
14
+ export declare function readCreatorPostExtras(): {
15
+ id: string | null;
16
+ metricDescriptions: (string | null)[];
17
+ analytics: {
18
+ label: string | null;
19
+ text: string;
20
+ }[];
21
+ images: {
22
+ alt: string | null;
23
+ src: string | null;
24
+ }[];
25
+ videoElements: number;
26
+ quoted: boolean;
27
+ pinned: boolean;
28
+ }[];
29
+ export declare function collectCreatorDOM(tab: BoundaryRecord, handle: string, { target, maxScrolls, navigate, expand, }?: Omit<CreatorOptions, "handle" | "mode">): Promise<BoundaryRecord>;
@@ -0,0 +1,164 @@
1
+ import { readProfile } from "./readers-profile.js";
2
+ import { readXObservation } from "./observation.js";
3
+ // Read-only DOM metadata. Counts retain the platform's displayed precision.
4
+ export function readCreatorMeta() {
5
+ const main = document.querySelector("main");
6
+ if (!main)
7
+ return { headerText: null, totalPostsText: null, missing: ["main"] };
8
+ const first = main.querySelector("article");
9
+ const body = main.innerText;
10
+ const boundary = first ? body.indexOf(first.innerText) : -1;
11
+ const headerText = boundary >= 0 ? body.slice(0, boundary) : body;
12
+ return {
13
+ headerText,
14
+ totalPostsText: headerText.match(/([\d,.]+\s*(?:万|亿|[KMB])?)\s*帖子/)?.[0] ?? null,
15
+ externalLinks: [
16
+ ...new Set(Array.from(main.querySelectorAll("a[href]"))
17
+ .filter((a) => !a.closest("article"))
18
+ .map((a) => a.getAttribute("href"))
19
+ .filter((h) => /^https?:/.test(h ?? ""))),
20
+ ],
21
+ missing: first ? [] : ["timeline-not-loaded"],
22
+ };
23
+ }
24
+ export function readCreatorPostExtras() {
25
+ return Array.from(document.querySelectorAll("article"))
26
+ .filter((a) => a.getClientRects().length)
27
+ .map((a) => {
28
+ const primary = Array.from(a.querySelectorAll("time"))
29
+ .find((t) => t.getClientRects().length > 0 &&
30
+ /\/status\/\d+(?:$|[/?#])/.test(t.closest("a")?.getAttribute("href") ?? ""))
31
+ ?.closest("a")
32
+ ?.getAttribute("href");
33
+ const id = primary?.match(/\/status\/(\d+)/)?.[1] ?? null;
34
+ return {
35
+ id,
36
+ metricDescriptions: Array.from(a.querySelectorAll("[aria-label]"))
37
+ .map((e) => e.getAttribute("aria-label"))
38
+ .filter((l) => /回复|喜欢|书签|次观看|次查看/.test(l ?? "")),
39
+ analytics: Array.from(a.querySelectorAll("a[href]"))
40
+ .filter((l) => l.getAttribute("href")?.includes(`/status/${id}/analytics`))
41
+ .map((l) => ({
42
+ label: l.getAttribute("aria-label"),
43
+ text: l.innerText,
44
+ })),
45
+ images: Array.from(a.querySelectorAll("img")).map((i) => ({
46
+ alt: i.getAttribute("alt"),
47
+ src: i.getAttribute("src"),
48
+ })),
49
+ videoElements: a.querySelectorAll("video").length,
50
+ quoted: /\n引用\n/.test(a.innerText),
51
+ pinned: a.innerText.startsWith("已置顶"),
52
+ };
53
+ });
54
+ }
55
+ export async function collectCreatorDOM(tab, handle, { target = 10, maxScrolls = 18, navigate = true, expand = true, } = {}) {
56
+ if (!/^[A-Za-z0-9_]{1,15}$/.test(handle) ||
57
+ !Number.isInteger(target) ||
58
+ target < 1 ||
59
+ target > 50 ||
60
+ !Number.isInteger(maxScrolls) ||
61
+ maxScrolls < 0 ||
62
+ maxScrolls > 50)
63
+ throw Error("Invalid creator collection input");
64
+ const url = `https://x.com/${handle}`;
65
+ if (navigate) {
66
+ if ((await tab.url()) === url)
67
+ await tab.reload();
68
+ else
69
+ await tab.goto(url);
70
+ }
71
+ const current = new URL(await tab.url());
72
+ if (current.origin !== "https://x.com" ||
73
+ current.pathname.toLowerCase() !== "/" + handle.toLowerCase())
74
+ throw Error("creator-url-mismatch");
75
+ await tab.getAXState({ emit: false });
76
+ await tab.playwright
77
+ .locator("article")
78
+ .first()
79
+ .waitFor({ state: "visible", timeoutMs: 15000 });
80
+ const profile = await tab.playwright.evaluate(readProfile, { handle });
81
+ const meta = await tab.playwright.evaluate(readCreatorMeta);
82
+ const posts = new Map();
83
+ let scrolls = 0;
84
+ let stagnant = 0;
85
+ for (;;) {
86
+ if (expand) {
87
+ const buttons = await tab.playwright
88
+ .locator("article")
89
+ .getByRole("button", { name: "显示更多", exact: true })
90
+ .all();
91
+ for (const button of buttons) {
92
+ try {
93
+ await button.click({ timeoutMs: 1000 });
94
+ }
95
+ catch (error) {
96
+ if (!(error instanceof Error) ||
97
+ !/timed out|timeout/i.test(error.message))
98
+ throw error;
99
+ /* Keep needsExpansion as evidence of incomplete text. */
100
+ }
101
+ }
102
+ if (buttons.length)
103
+ await tab.getAXState({ emit: false });
104
+ }
105
+ const extras = new Map((await tab.playwright.evaluate(readCreatorPostExtras)).map((p) => [p.id, p]));
106
+ const before = posts.size;
107
+ for (let offset = 0;; offset += 5) {
108
+ const read = await tab.playwright.evaluate(readXObservation, {
109
+ view: "posts",
110
+ offset,
111
+ limit: 5,
112
+ });
113
+ if (read.alerts?.length)
114
+ return {
115
+ profile,
116
+ meta,
117
+ posts: [...posts.values()],
118
+ complete: false,
119
+ alerts: read.alerts,
120
+ scrolls,
121
+ observedAt: new Date().toISOString(),
122
+ };
123
+ for (const post of read.posts) {
124
+ const extra = extras.get(post.id) ?? {};
125
+ if (post.author?.toLowerCase() === handle.toLowerCase() &&
126
+ !extra.pinned &&
127
+ !/^(.*转帖了|.*已转帖)/.test((post.text ?? post.body ?? "").split("\n")[0]))
128
+ posts.set(post.id, { ...post, ...extra, images: post.images });
129
+ }
130
+ if (!read.moreInDOM || posts.size >= target)
131
+ break;
132
+ if (offset >= 245)
133
+ break;
134
+ /* finite safety bound for unusual retained DOM */
135
+ }
136
+ if (posts.size >= target || scrolls >= maxScrolls || stagnant >= 6)
137
+ break;
138
+ stagnant = posts.size === before ? stagnant + 1 : 0;
139
+ await tab.scroll([780, 690], "down", 4);
140
+ await tab.getAXState({ emit: false });
141
+ scrolls++;
142
+ }
143
+ const selected = [...posts.values()]
144
+ .sort((a, b) => (b.datetime ?? "").localeCompare(a.datetime ?? ""))
145
+ .slice(0, target);
146
+ const missing = [
147
+ ...new Set(selected.flatMap((p) => [
148
+ !p.datetime ? "absolute-time-missing" : null,
149
+ p.needsExpansion ? "post-needs-expansion" : null,
150
+ !p.body ? "post-body-missing" : null,
151
+ ].filter(Boolean))),
152
+ ];
153
+ if (posts.size < target)
154
+ missing.push("creator-target-not-reached");
155
+ return {
156
+ profile,
157
+ meta,
158
+ posts: selected,
159
+ complete: missing.length === 0,
160
+ missing,
161
+ scrolls,
162
+ observedAt: new Date().toISOString(),
163
+ };
164
+ }
@@ -0,0 +1,5 @@
1
+ import type { BoundaryRecord } from "./boundary.js";
2
+ import type { GrowthDependencies } from "./growth-dependencies.js";
3
+ export declare function createFollowBatch(dependencies: Pick<GrowthDependencies, "prepareActionsForCall" | "store" | "scope" | "stamp" | "clock" | "evidenceExists" | "executor" | "parallelRound" | "round">): {
4
+ followBatch: ({ taskId, handles, preparedActions }?: any) => Promise<BoundaryRecord>;
5
+ };