@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.
- package/LICENSE +10 -0
- package/README.md +41 -2
- package/cli/main.js +219 -0
- package/dist/api.d.ts +10 -0
- package/dist/api.js +308 -0
- package/dist/boundary.d.ts +2 -0
- package/dist/browser-common.d.ts +4 -0
- package/dist/browser-common.js +29 -0
- package/dist/browser-dom-capabilities.d.ts +4 -0
- package/dist/browser-dom-capabilities.js +110 -0
- package/dist/browser-dom-dependencies.d.ts +16 -0
- package/dist/browser-dom-editing.d.ts +148 -0
- package/dist/browser-dom-editing.js +386 -0
- package/dist/browser-dom-navigation.d.ts +9 -0
- package/dist/browser-dom-navigation.js +165 -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 +2 -0
- package/dist/browser-dom-submission.js +82 -0
- package/dist/browser-dom.d.ts +2 -0
- package/dist/browser-dom.js +103 -0
- package/dist/browser-native.d.ts +1 -0
- package/dist/browser-native.js +215 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +147 -0
- package/dist/creator.d.ts +29 -0
- package/dist/creator.js +164 -0
- package/dist/growth-batch.d.ts +5 -0
- package/dist/growth-batch.js +168 -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-execution.d.ts +11 -0
- package/dist/growth-execution.js +315 -0
- package/dist/growth-parallel.d.ts +4 -0
- package/dist/growth-parallel.js +301 -0
- package/dist/growth-plan.d.ts +4 -0
- package/dist/growth-plan.js +207 -0
- package/dist/growth-policy.d.ts +9 -0
- package/dist/growth-policy.js +181 -0
- package/dist/growth-publishing.d.ts +5 -0
- package/dist/growth-publishing.js +240 -0
- package/dist/growth-reading.d.ts +4 -0
- package/dist/growth-reading.js +135 -0
- package/dist/growth-recovery.d.ts +6 -0
- package/dist/growth-recovery.js +158 -0
- package/dist/growth-state.d.ts +8 -0
- package/dist/growth-state.js +183 -0
- package/dist/growth-tasks.d.ts +17 -0
- package/dist/growth-tasks.js +101 -0
- package/dist/growth.d.ts +2 -0
- package/dist/growth.js +138 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +22 -0
- package/dist/observation.d.ts +1 -0
- package/dist/observation.js +2 -0
- package/dist/platform-capabilities.d.ts +4 -0
- package/dist/platform-capabilities.js +19 -0
- package/dist/platform-collections.d.ts +7 -0
- package/dist/platform-collections.js +223 -0
- package/dist/platform-dependencies.d.ts +43 -0
- package/dist/platform-diagnostics.d.ts +9 -0
- package/dist/platform-diagnostics.js +51 -0
- package/dist/platform-observation.d.ts +8 -0
- package/dist/platform-observation.js +75 -0
- package/dist/platform-rules.d.ts +17 -0
- package/dist/platform-rules.js +41 -0
- package/dist/platform-targets.d.ts +7 -0
- package/dist/platform-targets.js +129 -0
- package/dist/platform-writing.d.ts +8 -0
- package/dist/platform-writing.js +171 -0
- package/dist/platform.d.ts +2 -0
- package/dist/platform.js +60 -0
- package/dist/readers-actions.d.ts +2 -0
- package/dist/readers-actions.js +66 -0
- package/dist/readers-ax-host.d.ts +2 -0
- package/dist/readers-ax-host.js +125 -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-tree.d.ts +19 -0
- package/dist/readers-ax-tree.js +110 -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-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 -0
- package/dist/results.d.ts +6 -0
- package/dist/results.js +233 -0
- package/dist/round-state.d.ts +3 -0
- package/dist/round-state.js +155 -0
- package/dist/store.d.ts +30 -0
- package/dist/store.js +175 -0
- package/dist/types.d.ts +407 -0
- package/generated/observation.d.mts +1 -0
- package/generated/observation.mjs +515 -0
- package/package.json +29 -3
- package/skill/SKILL.md +33 -0
- package/skill/references/api.md +10 -0
- 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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { xURL } from "./platform-rules.js";
|
|
2
|
+
export function createDOMNavigation(dependencies) {
|
|
3
|
+
const { session, tab, observe } = dependencies;
|
|
4
|
+
async function searchSources(command) {
|
|
5
|
+
if (typeof command.query !== "string" ||
|
|
6
|
+
!command.query.trim() ||
|
|
7
|
+
command.query.length > 300)
|
|
8
|
+
throw new Error("需一条有效搜索词");
|
|
9
|
+
session.current = null;
|
|
10
|
+
const destination = `https://x.com/search?q=${encodeURIComponent(command.query)}&src=typed_query&f=live`;
|
|
11
|
+
if (typeof tab.url !== "function" || (await tab.url()) !== destination)
|
|
12
|
+
await tab.goto(destination);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
async function waitForFolder(command) {
|
|
16
|
+
await tab.playwright
|
|
17
|
+
.getByText(command.folder, { exact: true })
|
|
18
|
+
.waitFor({ state: "visible", timeoutMs: 2000 });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
async function waitForPosts(command) {
|
|
22
|
+
await tab.playwright
|
|
23
|
+
.getByRole("article")
|
|
24
|
+
.first()
|
|
25
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
async function openObserved(command) {
|
|
29
|
+
xURL(command.url);
|
|
30
|
+
if (!command.evidenceRef)
|
|
31
|
+
throw new Error("导航缺已观察证据");
|
|
32
|
+
// A direct adapter caller must also preserve a page already at the target.
|
|
33
|
+
// Explicit recovery uses forceNavigation; durable verification uses refreshPerson.
|
|
34
|
+
if (!command.forceNavigation &&
|
|
35
|
+
typeof tab.url === "function" &&
|
|
36
|
+
(await tab.url()) === xURL(command.url).href)
|
|
37
|
+
return;
|
|
38
|
+
const originalPage = session.current?.result?.page;
|
|
39
|
+
session.replyBinding = null;
|
|
40
|
+
session.current = null;
|
|
41
|
+
// Keep the loaded X client when the exact observed permalink is already visible.
|
|
42
|
+
// An ambiguous/missing link retains the existing navigation path, never an arbitrary click.
|
|
43
|
+
const path = xURL(command.url).pathname + xURL(command.url).search;
|
|
44
|
+
let link;
|
|
45
|
+
if (!command.forceNavigation &&
|
|
46
|
+
typeof tab.playwright?.locator === "function") {
|
|
47
|
+
link = tab.playwright
|
|
48
|
+
.locator(`a[href=${JSON.stringify(path)}]`)
|
|
49
|
+
.filter({ visible: true });
|
|
50
|
+
if (command.view === "person" &&
|
|
51
|
+
/^[A-Za-z0-9_]{1,15}$/.test(command.handle ?? "") &&
|
|
52
|
+
(await link.count()) > 1) {
|
|
53
|
+
// Avatar and name often share a profile href. Use the exact observed
|
|
54
|
+
// handle token to select the name link, never the arbitrary first match.
|
|
55
|
+
const named = link.filter({
|
|
56
|
+
hasText: new RegExp(`@${command.handle}(?![A-Za-z0-9_])`, "i"),
|
|
57
|
+
});
|
|
58
|
+
if ((await named.count()) === 1)
|
|
59
|
+
link = named;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const clickedLink = link && (await link.count()) === 1;
|
|
63
|
+
if (clickedLink)
|
|
64
|
+
await link.click();
|
|
65
|
+
else
|
|
66
|
+
await tab.goto(command.url);
|
|
67
|
+
try {
|
|
68
|
+
await tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// One inert client-link timeout may use the same observed URL directly.
|
|
72
|
+
// A changed route, warning, unknown UI or interrupted connection never falls back.
|
|
73
|
+
if (!clickedLink ||
|
|
74
|
+
!originalPage ||
|
|
75
|
+
!/Timed out waiting for URL/.test(error instanceof Error ? error.message : ""))
|
|
76
|
+
throw error;
|
|
77
|
+
const check = await observe({
|
|
78
|
+
view: command.view,
|
|
79
|
+
handle: command.handle,
|
|
80
|
+
limit: 1,
|
|
81
|
+
});
|
|
82
|
+
if (check.page !== originalPage ||
|
|
83
|
+
check.needsStateCheck ||
|
|
84
|
+
check.alerts?.length)
|
|
85
|
+
throw error;
|
|
86
|
+
await tab.goto(command.url);
|
|
87
|
+
await tab.playwright.waitForURL(command.url, { timeoutMs: 5000 });
|
|
88
|
+
}
|
|
89
|
+
if (command.view === "person") {
|
|
90
|
+
// Profile readiness belongs to profile content; self profiles have no Follow button.
|
|
91
|
+
await tab.playwright
|
|
92
|
+
.locator('[data-testid="UserName"]')
|
|
93
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
94
|
+
}
|
|
95
|
+
else if (command.view === "composer") {
|
|
96
|
+
await tab.playwright
|
|
97
|
+
.getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
|
|
98
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
99
|
+
}
|
|
100
|
+
else if (command.view === "posts") {
|
|
101
|
+
await tab.playwright
|
|
102
|
+
.getByRole("article")
|
|
103
|
+
.first()
|
|
104
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
async function refreshPerson(command) {
|
|
109
|
+
const at = xURL(await tab.url());
|
|
110
|
+
session.replyBinding = null;
|
|
111
|
+
if (at.href !== xURL(command.url).href ||
|
|
112
|
+
at.pathname.toLowerCase() !== `/${command.handle}`.toLowerCase())
|
|
113
|
+
throw new Error("持久核验页面不匹配");
|
|
114
|
+
session.current = null;
|
|
115
|
+
// Let the actual UI request settle before reloading; optimistic Following is not durable proof.
|
|
116
|
+
let idleTimedOut = false;
|
|
117
|
+
try {
|
|
118
|
+
await tab.playwright.waitForLoadState({
|
|
119
|
+
state: "networkidle",
|
|
120
|
+
timeoutMs: 8000,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!/Timed out waiting for networkidle/i.test(error.message ?? ""))
|
|
125
|
+
throw error;
|
|
126
|
+
// Long-lived X requests can prevent idle; the following reload still
|
|
127
|
+
// must expose the target relationship before any fresh observation.
|
|
128
|
+
idleTimedOut = true;
|
|
129
|
+
}
|
|
130
|
+
await tab.reload();
|
|
131
|
+
await tab.playwright
|
|
132
|
+
.getByRole("button", {
|
|
133
|
+
name: new RegExp(`^(?:正在关注|取消关注|关注|回关) @${command.handle}$`, "i"),
|
|
134
|
+
})
|
|
135
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
136
|
+
if (idleTimedOut)
|
|
137
|
+
session.refreshNotice =
|
|
138
|
+
"networkidle超时,已沿真实reload与目标关系控件恢复核验";
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
async function refreshPosts(command) {
|
|
142
|
+
session.replyBinding = null;
|
|
143
|
+
if (!command.evidenceRef ||
|
|
144
|
+
xURL(await tab.url()).href !== xURL(command.url).href)
|
|
145
|
+
throw new Error("公开核验页面不匹配");
|
|
146
|
+
session.current = null;
|
|
147
|
+
await tab.reload();
|
|
148
|
+
// Native observation settles the real loading transition when the tab exposes it.
|
|
149
|
+
if (typeof tab.getAXState === "function")
|
|
150
|
+
await tab.getAXState({ emit: false });
|
|
151
|
+
await tab.playwright
|
|
152
|
+
.getByRole("article")
|
|
153
|
+
.first()
|
|
154
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
searchSources,
|
|
159
|
+
waitForFolder,
|
|
160
|
+
waitForPosts,
|
|
161
|
+
openObserved,
|
|
162
|
+
refreshPerson,
|
|
163
|
+
refreshPosts,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function readXNetworkDiagnostics(tab: any, { afterSequence, startObserving }?: any): Promise<any>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Read-only transport diagnostics through the same real Chrome tab capability.
|
|
2
|
+
// Never return credential headers, cookies or bodies; only numeric rate metadata is allowed.
|
|
3
|
+
export async function readXNetworkDiagnostics(tab, { afterSequence = 0, startObserving = false } = {}) {
|
|
4
|
+
if (typeof tab?.capabilities?.get !== "function")
|
|
5
|
+
throw new Error("缺真实Chrome诊断能力");
|
|
6
|
+
const cdp = await tab.capabilities.get("cdp");
|
|
7
|
+
if (typeof cdp?.readEvents !== "function")
|
|
8
|
+
throw new Error("缺只读网络事件能力");
|
|
9
|
+
if (startObserving)
|
|
10
|
+
await cdp.send("Network.enable");
|
|
11
|
+
const result = await cdp.readEvents({
|
|
12
|
+
afterSequence,
|
|
13
|
+
limit: 1000,
|
|
14
|
+
methods: [
|
|
15
|
+
"Network.requestWillBeSent",
|
|
16
|
+
"Network.responseReceived",
|
|
17
|
+
"Network.loadingFailed",
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
const traffic = new Map();
|
|
21
|
+
const times = [];
|
|
22
|
+
for (const event of result.events) {
|
|
23
|
+
const p = event.params ?? {};
|
|
24
|
+
let u;
|
|
25
|
+
try {
|
|
26
|
+
u = new URL(p.response?.url ?? p.request?.url);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!((u.hostname === "x.com" && u.pathname.startsWith("/i/api/")) ||
|
|
32
|
+
u.hostname === "api.x.com"))
|
|
33
|
+
continue;
|
|
34
|
+
const location = u.origin + u.pathname;
|
|
35
|
+
const row = traffic.get(location) ?? {
|
|
36
|
+
location,
|
|
37
|
+
requests: 0,
|
|
38
|
+
responses: 0,
|
|
39
|
+
statuses: {},
|
|
40
|
+
};
|
|
41
|
+
if (event.method === "Network.requestWillBeSent")
|
|
42
|
+
row.requests++;
|
|
43
|
+
if (event.method === "Network.responseReceived") {
|
|
44
|
+
row.responses++;
|
|
45
|
+
const status = p.response?.status;
|
|
46
|
+
if (Number.isInteger(status))
|
|
47
|
+
row.statuses[status] = (row.statuses[status] ?? 0) + 1;
|
|
48
|
+
}
|
|
49
|
+
if (Number.isFinite(p.timestamp))
|
|
50
|
+
times.push(p.timestamp);
|
|
51
|
+
traffic.set(location, row);
|
|
52
|
+
}
|
|
53
|
+
const rates = (headers = {}) => {
|
|
54
|
+
const allowed = {};
|
|
55
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
56
|
+
const name = key.toLowerCase();
|
|
57
|
+
if (![
|
|
58
|
+
"x-rate-limit-limit",
|
|
59
|
+
"x-rate-limit-remaining",
|
|
60
|
+
"x-rate-limit-reset",
|
|
61
|
+
"retry-after",
|
|
62
|
+
].includes(name))
|
|
63
|
+
continue;
|
|
64
|
+
if (!/^\d{1,12}$/.test(String(value).trim()))
|
|
65
|
+
continue;
|
|
66
|
+
allowed[name] = Number(value);
|
|
67
|
+
}
|
|
68
|
+
const out = {};
|
|
69
|
+
if (allowed["x-rate-limit-limit"] !== undefined)
|
|
70
|
+
out.limit = allowed["x-rate-limit-limit"];
|
|
71
|
+
if (allowed["x-rate-limit-remaining"] !== undefined)
|
|
72
|
+
out.remaining = allowed["x-rate-limit-remaining"];
|
|
73
|
+
if (allowed["x-rate-limit-reset"] !== undefined) {
|
|
74
|
+
const date = new Date(allowed["x-rate-limit-reset"] * 1000);
|
|
75
|
+
if (Number.isFinite(date.getTime()))
|
|
76
|
+
out.resetAt = date.toISOString();
|
|
77
|
+
}
|
|
78
|
+
if (allowed["retry-after"] !== undefined)
|
|
79
|
+
out.retryAfterSeconds = allowed["retry-after"];
|
|
80
|
+
return Object.keys(out).length ? out : undefined;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
cursor: result.cursor,
|
|
84
|
+
hasMore: result.hasMore,
|
|
85
|
+
truncated: result.truncated,
|
|
86
|
+
traffic: {
|
|
87
|
+
complete: !result.truncated && !result.hasMore,
|
|
88
|
+
requests: [...traffic.values()].reduce((n, r) => n + r.requests, 0),
|
|
89
|
+
responses: [...traffic.values()].reduce((n, r) => n + r.responses, 0),
|
|
90
|
+
durationSeconds: times.length > 1 ? Math.max(...times) - Math.min(...times) : null,
|
|
91
|
+
endpointCount: traffic.size,
|
|
92
|
+
endpoints: [...traffic.values()]
|
|
93
|
+
.sort((a, b) => b.requests - a.requests)
|
|
94
|
+
.slice(0, 10),
|
|
95
|
+
},
|
|
96
|
+
events: result.events
|
|
97
|
+
.map((e) => {
|
|
98
|
+
const p = e.params ?? {};
|
|
99
|
+
const response = p.response ?? {};
|
|
100
|
+
let location;
|
|
101
|
+
try {
|
|
102
|
+
const u = new URL(response.url ?? p.request?.url);
|
|
103
|
+
location = ["http:", "https:"].includes(u.protocol)
|
|
104
|
+
? u.origin + u.pathname
|
|
105
|
+
: null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
location = null;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
method: e.method,
|
|
112
|
+
requestId: p.requestId,
|
|
113
|
+
location,
|
|
114
|
+
requestMethod: p.request?.method,
|
|
115
|
+
status: response.status,
|
|
116
|
+
errorText: p.errorText,
|
|
117
|
+
canceled: p.canceled,
|
|
118
|
+
timestamp: Number.isFinite(p.timestamp) ? p.timestamp : undefined,
|
|
119
|
+
rateLimit: rates(response.headers),
|
|
120
|
+
};
|
|
121
|
+
})
|
|
122
|
+
.filter((e) => e.method === "Network.loadingFailed" ||
|
|
123
|
+
(e.location &&
|
|
124
|
+
(e.status >= 400 ||
|
|
125
|
+
e.requestMethod === "POST" ||
|
|
126
|
+
/\/(EditTweet|CreateTweet)$/.test(e.location)))),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { DOMDependencies } from "./browser-dom-dependencies.js";
|
|
2
|
+
export declare function createDOMSubmission(dependencies: Pick<DOMDependencies, "submitAX" | "session" | "tab" | "observe" | "unique" | "locator">): (command: any, prepared: any, query: any, result: any) => Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { xURL } from "./platform-rules.js";
|
|
2
|
+
import { valueOf } from "./browser-common.js";
|
|
3
|
+
export function createDOMSubmission(dependencies) {
|
|
4
|
+
const { submitAX, session, tab, observe, unique, locator } = dependencies;
|
|
5
|
+
return async function finishDOMAction(command, prepared, query, result) {
|
|
6
|
+
const { loc, submittedEditor } = prepared;
|
|
7
|
+
const binding = command.operation === "openReply"
|
|
8
|
+
? {
|
|
9
|
+
page: result.page,
|
|
10
|
+
postId: command.postId,
|
|
11
|
+
body: result.posts[0].body,
|
|
12
|
+
author: result.posts[0].author,
|
|
13
|
+
}
|
|
14
|
+
: null;
|
|
15
|
+
let nativeSubmitIndex;
|
|
16
|
+
if (submitAX && ["submitPost", "submitReply"].includes(command.operation)) {
|
|
17
|
+
const native = await submitAX.observe({ mode: "composer" });
|
|
18
|
+
const editors = native.evidence?.filter((e) => /^(?:文本输入区(?:域)?|文本区域|文本栏) /.test(e.label)) ?? [];
|
|
19
|
+
const buttons = native.evidence?.filter((e) => new RegExp(command.operation === "submitReply"
|
|
20
|
+
? "^按钮 (回复|Reply)(?:,|$)"
|
|
21
|
+
: "^按钮 (发帖|发布|Post)(?:,|$)", "i").test(e.label)) ?? [];
|
|
22
|
+
if (native.needsStateCheck ||
|
|
23
|
+
native.alerts?.length ||
|
|
24
|
+
xURL(native.page).href !== xURL(result.page).href ||
|
|
25
|
+
editors.length !== 1 ||
|
|
26
|
+
valueOf(editors[0].label)?.trim() !== result.editorText.trim() ||
|
|
27
|
+
buttons.length !== 1)
|
|
28
|
+
throw new Error("原生提交缺同页、全文及唯一按钮证据");
|
|
29
|
+
nativeSubmitIndex = buttons[0].index;
|
|
30
|
+
}
|
|
31
|
+
if (["submitPost", "submitReply"].includes(command.operation))
|
|
32
|
+
await command.beforeSubmit?.();
|
|
33
|
+
session.current = null;
|
|
34
|
+
if (nativeSubmitIndex !== undefined)
|
|
35
|
+
await submitAX.act({
|
|
36
|
+
index: nativeSubmitIndex,
|
|
37
|
+
operation: "click",
|
|
38
|
+
read: { mode: "composer" },
|
|
39
|
+
});
|
|
40
|
+
else if (command.activation === "keyboard")
|
|
41
|
+
await loc.press("Enter");
|
|
42
|
+
else
|
|
43
|
+
await loc.click();
|
|
44
|
+
if (binding) {
|
|
45
|
+
session.replyBinding = binding;
|
|
46
|
+
await tab.playwright
|
|
47
|
+
.getByRole("textbox", { name: /^(帖子文本|Post text)$/i })
|
|
48
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
49
|
+
if (xURL(await tab.url()).pathname === "/compose/post")
|
|
50
|
+
await tab.playwright
|
|
51
|
+
.getByText(`回复 @${binding.author}`, { exact: true })
|
|
52
|
+
.waitFor({ state: "visible", timeoutMs: 5000 });
|
|
53
|
+
const composer = await observe({ view: "composer" });
|
|
54
|
+
const quote = composer.replyQuotes?.length === 1 ? composer.replyQuotes[0] : null;
|
|
55
|
+
if (!composer.replyToId &&
|
|
56
|
+
!composer.needsStateCheck &&
|
|
57
|
+
!composer.alerts?.length &&
|
|
58
|
+
composer.editorText?.trim() === "" &&
|
|
59
|
+
composer.replyContexts?.length === 1 &&
|
|
60
|
+
composer.replyContexts[0].includes(`回复 @${binding.author}`) &&
|
|
61
|
+
/显示更多|Show more/.test(composer.replyContexts[0]) &&
|
|
62
|
+
quote?.length >= 30 &&
|
|
63
|
+
binding.body?.startsWith(quote)) {
|
|
64
|
+
// Expand the observed quote; the unchanged full-context binding gate must still pass.
|
|
65
|
+
const dialog = await unique(locator("dialog").filter({ visible: true }));
|
|
66
|
+
const more = await unique(dialog.getByRole("button", { name: /^(显示更多|Show more)$/i }));
|
|
67
|
+
session.current = null;
|
|
68
|
+
await more.click();
|
|
69
|
+
await observe({ view: "composer" });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Inline replies keep the editor mounted. Wait for this submitted text to clear before
|
|
73
|
+
// receipt navigation/reload can interrupt the actual submission request.
|
|
74
|
+
if (submittedEditor)
|
|
75
|
+
await submittedEditor.waitFor({ state: "hidden", timeoutMs: 10000 });
|
|
76
|
+
if (["submitPost", "submitReply"].includes(command.operation))
|
|
77
|
+
await tab.playwright.waitForLoadState({
|
|
78
|
+
state: "networkidle",
|
|
79
|
+
timeoutMs: 8000,
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createAXHarness } from "./readers-ax-host.js";
|
|
2
|
+
import { readXObservation } from "./observation.js";
|
|
3
|
+
import { collectCreatorDOM } from "./creator.js";
|
|
4
|
+
import { serialized } from "./browser-common.js";
|
|
5
|
+
import { createDOMCapabilities } from "./browser-dom-capabilities.js";
|
|
6
|
+
import { createDOMNavigation } from "./browser-dom-navigation.js";
|
|
7
|
+
import { createDOMEditing } from "./browser-dom-editing.js";
|
|
8
|
+
import { createDOMSubmission } from "./browser-dom-submission.js";
|
|
9
|
+
export { readXNetworkDiagnostics } from "./browser-dom-network.js";
|
|
10
|
+
export function createDOMXBrowser(tab, { readDOM = readXObservation, nativeSubmit = false } = {}) {
|
|
11
|
+
const session = {};
|
|
12
|
+
const submitAX = nativeSubmit
|
|
13
|
+
? createAXHarness({
|
|
14
|
+
getAXState: (options) => tab.getAXState(options),
|
|
15
|
+
click: (index) => tab.click(index),
|
|
16
|
+
})
|
|
17
|
+
: null;
|
|
18
|
+
async function observe(query) {
|
|
19
|
+
session.current = null;
|
|
20
|
+
const evaluate = tab?.playwright?.evaluate?.bind(tab.playwright);
|
|
21
|
+
if (!evaluate)
|
|
22
|
+
throw new Error("缺playwright.evaluate能力");
|
|
23
|
+
const result = await evaluate(readDOM, {
|
|
24
|
+
...query,
|
|
25
|
+
limit: query.limit ?? 1,
|
|
26
|
+
});
|
|
27
|
+
const raw = result;
|
|
28
|
+
// This observed disclosure confirms AI provenance; preserve it without treating it as a rate/login failure.
|
|
29
|
+
result.notices = (result.alerts ?? []).filter((text) => text.trim() === "检测到 AI 生成内容");
|
|
30
|
+
if (session.refreshNotice) {
|
|
31
|
+
result.notices.push(session.refreshNotice);
|
|
32
|
+
session.refreshNotice = null;
|
|
33
|
+
}
|
|
34
|
+
result.alerts = (result.alerts ?? []).filter((text) => text.trim() !== "检测到 AI 生成内容");
|
|
35
|
+
if (query.view === "composer") {
|
|
36
|
+
result.editorText =
|
|
37
|
+
raw.editors?.length === 1 ? raw.editors[0].text : null;
|
|
38
|
+
if (result.inlineComposer &&
|
|
39
|
+
raw.editors?.length === 1 &&
|
|
40
|
+
session.replyBinding?.page === result.page &&
|
|
41
|
+
result.page.match(/\/status\/(\d+)$/)?.[1] ===
|
|
42
|
+
session.replyBinding.postId)
|
|
43
|
+
result.replyToId = session.replyBinding.postId;
|
|
44
|
+
if (!result.inlineComposer &&
|
|
45
|
+
raw.editors?.length === 1 &&
|
|
46
|
+
session.replyBinding?.body &&
|
|
47
|
+
session.replyBinding?.author &&
|
|
48
|
+
result.replyContexts?.length === 1 &&
|
|
49
|
+
(result.replyContexts[0].includes(session.replyBinding.body) ||
|
|
50
|
+
(result.replyQuotes?.length === 1 &&
|
|
51
|
+
result.replyQuotes[0].length >= 80 &&
|
|
52
|
+
session.replyBinding.body.startsWith(result.replyQuotes[0]))) &&
|
|
53
|
+
result.replyContexts[0].includes(`回复 @${session.replyBinding.author}`))
|
|
54
|
+
result.replyToId = session.replyBinding.postId;
|
|
55
|
+
}
|
|
56
|
+
session.current = { query, result };
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const locator = (role, label = undefined) => tab.playwright.getByRole(role, label ? { name: label, exact: true } : {});
|
|
60
|
+
const unique = async (loc) => {
|
|
61
|
+
if ((await loc.count()) !== 1)
|
|
62
|
+
throw new Error("DOM语义控件不唯一");
|
|
63
|
+
return loc;
|
|
64
|
+
};
|
|
65
|
+
const navigation = createDOMNavigation({ session, tab, observe });
|
|
66
|
+
const editing = createDOMEditing({ session, unique, locator, tab, observe });
|
|
67
|
+
const finishDOMAction = createDOMSubmission({
|
|
68
|
+
submitAX,
|
|
69
|
+
session,
|
|
70
|
+
tab,
|
|
71
|
+
observe,
|
|
72
|
+
unique,
|
|
73
|
+
locator,
|
|
74
|
+
});
|
|
75
|
+
return serialized({
|
|
76
|
+
...createDOMCapabilities({ tab, nativeSubmit }),
|
|
77
|
+
observe,
|
|
78
|
+
async collectCreator({ handle, ...options }) {
|
|
79
|
+
session.current = null;
|
|
80
|
+
return collectCreatorDOM(tab, handle, options);
|
|
81
|
+
},
|
|
82
|
+
screenshot: () => {
|
|
83
|
+
session.current = null;
|
|
84
|
+
return tab.screenshot();
|
|
85
|
+
},
|
|
86
|
+
async act(command) {
|
|
87
|
+
if (Object.hasOwn(navigation, command.operation))
|
|
88
|
+
return navigation[command.operation](command);
|
|
89
|
+
if (!session.current)
|
|
90
|
+
throw new Error("DOM语义操作必须先observe");
|
|
91
|
+
const { query, result } = session.current;
|
|
92
|
+
const handler = Object.hasOwn(editing, command.operation)
|
|
93
|
+
? editing[command.operation]
|
|
94
|
+
: undefined;
|
|
95
|
+
if (!handler)
|
|
96
|
+
throw new Error("未封装的DOM动作");
|
|
97
|
+
const prepared = await handler(command);
|
|
98
|
+
if (prepared.completed)
|
|
99
|
+
return prepared.value;
|
|
100
|
+
return finishDOMAction(command, prepared, query, result);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createNativeXBrowser(app: any): any;
|