@nextclaw/x-harness 0.2.43 → 0.2.44

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/SOURCE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Corresponding source
2
2
 
3
- Version 0.2.43, commit a1ddf2487e87818c3be2a23213700e9e5b2b4ce4.
3
+ Version 0.2.44, commit 5f83f4e0fa5f5d3e052b63498a4878f18e12e48b.
4
4
 
5
- Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/a1ddf2487e87818c3be2a23213700e9e5b2b4ce4
5
+ Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/5f83f4e0fa5f5d3e052b63498a4878f18e12e48b
6
6
 
7
7
  Clone this revision, run npm ci, npm run bootstrap and npm run release:build.
8
8
 
@@ -89,6 +89,19 @@ export class DOMCapabilities {
89
89
  "press",
90
90
  ]));
91
91
  }
92
+ if (operation === "openQuoteComposer") {
93
+ base.push(...missingMethods(this.#tab?.playwright, ["locator", "getByRole"]));
94
+ if (typeof this.#tab?.playwright?.getByRole === "function") {
95
+ base.push(...missingMethods(this.#tab.playwright.getByRole("article"), [
96
+ "filter",
97
+ "getByRole",
98
+ ]));
99
+ base.push(...missingMethods(this.#tab.playwright.getByRole("button"), [
100
+ "press",
101
+ "waitFor",
102
+ ]));
103
+ }
104
+ }
92
105
  if (operation === "attachAssets")
93
106
  base.push(...missingMethods(this.#tab?.playwright, [
94
107
  "waitForEvent",
@@ -1,6 +1,7 @@
1
1
  export interface DOMSession {
2
2
  current?: any;
3
3
  replyBinding?: any;
4
+ quoteBinding?: any;
4
5
  refreshNotice?: any;
5
6
  editBinding?: any;
6
7
  }
@@ -126,6 +126,7 @@ export declare class DOMEditing {
126
126
  submittedEditor: any;
127
127
  }>;
128
128
  likePost: (command: any) => Promise<any>;
129
+ openQuoteComposer: (command: any) => Promise<any>;
129
130
  openReply: (command: any) => Promise<{
130
131
  completed: boolean;
131
132
  loc: any;
@@ -165,6 +166,7 @@ export declare class DOMEditing {
165
166
  private handleAttachAssets;
166
167
  private handleFollow;
167
168
  private handleLikePost;
169
+ private handleOpenQuoteComposer;
168
170
  private handleOpenReply;
169
171
  private handleFillComposer;
170
172
  private handleSubmitPost;
@@ -19,6 +19,7 @@ export class DOMEditing {
19
19
  attachAssets;
20
20
  follow;
21
21
  likePost;
22
+ openQuoteComposer;
22
23
  openReply;
23
24
  fillComposer;
24
25
  submitPost;
@@ -44,6 +45,7 @@ export class DOMEditing {
44
45
  this.attachAssets = this.handleAttachAssets.bind(this);
45
46
  this.follow = this.handleFollow.bind(this);
46
47
  this.likePost = this.handleLikePost.bind(this);
48
+ this.openQuoteComposer = this.handleOpenQuoteComposer.bind(this);
47
49
  this.openReply = this.handleOpenReply.bind(this);
48
50
  this.fillComposer = this.handleFillComposer.bind(this);
49
51
  this.submitPost = this.handleSubmitPost.bind(this);
@@ -398,6 +400,40 @@ export class DOMEditing {
398
400
  },
399
401
  };
400
402
  }
403
+ async handleOpenQuoteComposer(command) {
404
+ const { query, result } = this.#session.current;
405
+ this.#session.quoteBinding = null;
406
+ if (query.targetId !== command.postId ||
407
+ result.needsStateCheck ||
408
+ result.alerts?.length ||
409
+ result.posts?.length !== 1 ||
410
+ result.posts[0].id !== command.postId)
411
+ throw Error("unique-quote-source-required");
412
+ const post = result.posts[0];
413
+ const buttons = post.buttons.filter((b) => /转帖|Repost|Retweet/i.test(b.label));
414
+ if (buttons.length !== 1 ||
415
+ !post.body?.trim() ||
416
+ !post.author ||
417
+ post.primary?.match(/\/status\/(\d+)$/)?.[1] !== command.postId)
418
+ throw Error("quote-source-controls-unconfirmed");
419
+ const article = await this.#unique(this.#locator("article").filter({
420
+ has: this.#tab.playwright.locator(`a[href=${JSON.stringify(post.primary)}]`),
421
+ visible: true,
422
+ }));
423
+ await (await this.#unique(article.getByRole("button", { name: buttons[0].label, exact: true }))).press("Enter");
424
+ const menu = this.#tab.playwright.getByRole("menuitem", {
425
+ name: /^(引用|Quote)$/,
426
+ });
427
+ await menu.waitFor({ state: "visible", timeoutMs: 2000 });
428
+ await (await this.#unique(menu)).press("Enter");
429
+ this.#session.quoteBinding = {
430
+ postId: post.id,
431
+ author: post.author,
432
+ body: post.body,
433
+ };
434
+ this.#session.current = null;
435
+ return { completed: true };
436
+ }
401
437
  async handleOpenReply(command) {
402
438
  const { query, result } = this.#session.current;
403
439
  let loc, submittedEditor;
@@ -89,6 +89,12 @@ export class DOMXBrowser {
89
89
  }
90
90
  result.alerts = (result.alerts ?? []).filter((text) => text.trim() !== "检测到 AI 生成内容");
91
91
  if (query.view === "composer") {
92
+ if (this.#session.quoteBinding && raw.quoteContexts?.length === 1) {
93
+ const q = raw.quoteContexts[0], b = this.#session.quoteBinding;
94
+ if (q.author?.toLowerCase() === b.author?.toLowerCase() &&
95
+ q.body?.startsWith(b.body))
96
+ result.quoteSourceId = b.postId;
97
+ }
92
98
  result.editorText =
93
99
  raw.editors?.length === 1 ? raw.editors[0].text : null;
94
100
  if (result.inlineComposer &&
@@ -173,6 +173,11 @@ export class GrowthPolicy {
173
173
  action.quality.draftFingerprint !==
174
174
  this.#store.fingerprint(action.draftPath)))
175
175
  return "draft-changed-since-quality-review";
176
+ if (action.quoteTarget &&
177
+ (!/^\d+$/.test(action.quoteTarget.postId ?? "") ||
178
+ !this.#evidenceExists(action.quoteTarget.evidenceRef) ||
179
+ xURL(action.quoteTarget.url).pathname.match(/\/status\/(\d+)$/)?.[1] !== action.quoteTarget.postId))
180
+ return "quote-target-proof-missing";
176
181
  const scheduleReason = new DraftSchedule().reason(action, this.#clock());
177
182
  if (scheduleReason)
178
183
  return scheduleReason;
@@ -49,7 +49,8 @@ export class GrowthRecovery {
49
49
  url.pathname.toLowerCase() !==
50
50
  `/${action.accountHandle}/status/${postId}`.toLowerCase())
51
51
  throw new Error("public-account-mismatch");
52
- const resolveQuote = /\nhttps:\/\/x\.com\/\w{1,15}\/status\/\d+$/.test(action.text);
52
+ const resolveQuote = Boolean(action.quoteTarget) ||
53
+ /\nhttps:\/\/x\.com\/\w{1,15}\/status\/\d+$/.test(action.text);
53
54
  const quoteCaps = this.#capability("read", { resolveQuote });
54
55
  if (!quoteCaps.covered)
55
56
  return { status: "needsCapability", missing: quoteCaps.missing };
@@ -57,7 +58,7 @@ export class GrowthRecovery {
57
58
  postId,
58
59
  url: publicURL,
59
60
  evidenceRef,
60
- resolveQuote: /\nhttps:\/\/x\.com\/\w{1,15}\/status\/\d+$/.test(action.text),
61
+ resolveQuote,
61
62
  });
62
63
  const trailing = action.text.match(/(?:\n+)?((?:https:\/\/|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\/)\S+)$/);
63
64
  let sourceURL = null;
@@ -114,6 +115,9 @@ export class GrowthRecovery {
114
115
  }
115
116
  }
116
117
  return (p.id === postId &&
118
+ (!action.quoteTarget ||
119
+ (p.quoteSource?.parentId === p.id &&
120
+ p.quoteSource?.id === action.quoteTarget.postId)) &&
117
121
  same(p.author, action.accountHandle) &&
118
122
  (exact ||
119
123
  quote ||
@@ -13,6 +13,10 @@ export class PlatformCapabilities {
13
13
  if (operation === "readCreator" &&
14
14
  typeof this.#browser.collectCreator !== "function")
15
15
  absent.push("browser.collectCreator");
16
+ if (input.quoteTarget)
17
+ absent.push(...(this.#browser.capabilities?.("openQuoteComposer") ?? [
18
+ "openQuoteComposer",
19
+ ]), ...(this.#browser.capabilities?.("openQuote") ?? ["openQuote"]));
16
20
  if (input.resolveQuote)
17
21
  absent.push(...(this.#browser.capabilities?.("openQuote") ?? ["openQuote"]));
18
22
  if (input.kind === "follow" || operation === "submitFollow")
@@ -9,6 +9,9 @@ export declare class PlatformWriting {
9
9
  constructor(dependencies: Pick<PlatformDependencies, "observed" | "browser" | "followStage" | "readPerson" | "open" | "clock" | "readPost">);
10
10
  private handleCompose;
11
11
  private handleFindSubmission;
12
+ private matchesQuote;
13
+ private matchesContent;
14
+ private readQuotedReceipt;
12
15
  private readLoadedReplyReceipt;
13
16
  private handleSubmitFollow;
14
17
  private handleSubmitPost;
@@ -33,7 +33,8 @@ export class PlatformWriting {
33
33
  value.alerts?.length ||
34
34
  typeof value.editorText !== "string" ||
35
35
  value.editorText.trim() !== "" ||
36
- (action.kind === "reply" && value.replyToId !== action.postId))
36
+ (action.kind === "reply" && value.replyToId !== action.postId) ||
37
+ (action.quoteTarget && value.quoteSourceId !== action.quoteTarget.postId))
37
38
  throw new Error("编辑器不为空或回复上下文未绑定");
38
39
  if (action.assets?.length)
39
40
  await this.#browser.act({
@@ -46,6 +47,8 @@ export class PlatformWriting {
46
47
  value.needsStateCheck ||
47
48
  value.alerts?.length ||
48
49
  (action.kind === "reply" && value.replyToId !== action.postId) ||
50
+ (action.quoteTarget &&
51
+ value.quoteSourceId !== action.quoteTarget.postId) ||
49
52
  (action.accountHandle &&
50
53
  !sameHandle(value.accountHandle, action.accountHandle)))
51
54
  throw new Error(`提交前账号/正文/目标未核实 ${JSON.stringify({ textMatches: sameText(value.editorText, action.text), needsStateCheck: value.needsStateCheck, alerts: value.alerts, accountHandle: value.accountHandle, replyToId: value.replyToId })}`);
@@ -124,15 +127,7 @@ export class PlatformWriting {
124
127
  });
125
128
  value = await this.#observed({ view: "posts", limit: 5 });
126
129
  }
127
- const matchesContent = (post) => sameHandle(post.author, action.accountHandle) &&
128
- samePublishedText(post.body, action.text) &&
129
- /^\d+$/.test(post.id ?? "") &&
130
- matchesReviewedAssets(post, action.assets) &&
131
- !(action.beforeIds ?? []).includes(post.id) &&
132
- Number.isFinite(Date.parse(post.datetime)) &&
133
- Date.parse(post.datetime) >= Date.parse(action.intentAt) &&
134
- Date.parse(post.datetime) <= this.#clock() + 5000 &&
135
- !post.needsExpansion;
130
+ const matchesContent = (post) => this.matchesContent(post, action);
136
131
  let matches = (value.posts ?? []).filter(matchesContent);
137
132
  const receiptCandidate = action.kind === "reply" &&
138
133
  matches.length === 1 &&
@@ -170,6 +165,12 @@ export class PlatformWriting {
170
165
  matches = (value.posts ?? []).filter(matchesContent);
171
166
  }
172
167
  }
168
+ if (action.quoteTarget && matches.length === 1) {
169
+ value = await this.readQuotedReceipt(value, action, matches[0]);
170
+ matches = (value.posts ?? [])
171
+ .filter(matchesContent)
172
+ .filter((p) => this.matchesQuote(p, action));
173
+ }
173
174
  if (action.kind === "reply")
174
175
  matches = matches.filter((post) => post.replyToId === action.postId);
175
176
  return {
@@ -180,6 +181,29 @@ export class PlatformWriting {
180
181
  missing: matches.length === 1 ? [] : ["unique-public-submission-unconfirmed"],
181
182
  };
182
183
  }
184
+ matchesQuote(post, action) {
185
+ return (post.quoteSource?.parentId === post.id &&
186
+ post.quoteSource?.id === action.quoteTarget.postId);
187
+ }
188
+ matchesContent(post, action) {
189
+ return (sameHandle(post.author, action.accountHandle) &&
190
+ samePublishedText(post.body, action.text) &&
191
+ /^\d+$/.test(post.id ?? "") &&
192
+ matchesReviewedAssets(post, action.assets) &&
193
+ !(action.beforeIds ?? []).includes(post.id) &&
194
+ Number.isFinite(Date.parse(post.datetime)) &&
195
+ Date.parse(post.datetime) >= Date.parse(action.intentAt) &&
196
+ Date.parse(post.datetime) <= this.#clock() + 5000 &&
197
+ !post.needsExpansion);
198
+ }
199
+ async readQuotedReceipt(value, action, candidate) {
200
+ return this.#readPost({
201
+ postId: candidate.id,
202
+ url: new URL(candidate.primary, value.page).href,
203
+ evidenceRef: { page: value.page, observedAt: value.observedAt },
204
+ resolveQuote: true,
205
+ });
206
+ }
183
207
  async readLoadedReplyReceipt(value, targetId, matchesContent) {
184
208
  const candidates = (value.posts ?? []).filter(matchesContent);
185
209
  if (value.needsStateCheck ||
@@ -222,7 +246,21 @@ export class PlatformWriting {
222
246
  return clicked;
223
247
  }
224
248
  async handleSubmitPost(action) {
225
- await this.#open({ url: action.composeURL, evidenceRef: action.composeEvidenceRef }, { view: "composer" });
249
+ if (action.quoteTarget) {
250
+ const source = await this.#readPost(action.quoteTarget);
251
+ if (source.needsStateCheck ||
252
+ source.alerts?.length ||
253
+ source.posts?.length !== 1 ||
254
+ source.posts[0].id !== action.quoteTarget.postId)
255
+ throw Error("quote-source-unconfirmed");
256
+ await this.#browser.act({
257
+ operation: "openQuoteComposer",
258
+ postId: action.quoteTarget.postId,
259
+ });
260
+ }
261
+ else {
262
+ await this.#open({ url: action.composeURL, evidenceRef: action.composeEvidenceRef }, { view: "composer" });
263
+ }
226
264
  return this.handleCompose(action);
227
265
  }
228
266
  async handleSubmitReply(action) {
@@ -101,6 +101,15 @@ export function readComposer() {
101
101
  kind: "video",
102
102
  label: el.getAttribute("aria-label"),
103
103
  })),
104
+ quoteContexts: Array.from(scope.querySelectorAll('[data-testid="attachments"]'))
105
+ .filter(visible)
106
+ .filter((el) => el.innerText.startsWith("引用") || el.innerText.startsWith("Quote"))
107
+ .map((el) => ({
108
+ author: el
109
+ .querySelector('[data-testid="User-Name"]')
110
+ ?.innerText.match(/@([A-Za-z0-9_]{1,15})/)?.[1],
111
+ body: el.querySelector('[data-testid="tweetText"]')?.innerText,
112
+ })),
104
113
  needsStateCheck: false,
105
114
  };
106
115
  }
package/dist/types.d.ts CHANGED
@@ -255,6 +255,7 @@ export type PreparedAction = PreparedActionBase & ({
255
255
  text: string;
256
256
  composeURL: string;
257
257
  composeEvidenceRef: EvidenceRef;
258
+ quoteTarget?: PostTarget;
258
259
  assets?: {
259
260
  path: string;
260
261
  alt: string;
@@ -383,6 +383,15 @@ export const readXObservation = (query) => {
383
383
  kind: "video",
384
384
  label: el.getAttribute("aria-label"),
385
385
  })),
386
+ quoteContexts: Array.from(scope.querySelectorAll('[data-testid="attachments"]'))
387
+ .filter(visible)
388
+ .filter((el) => el.innerText.startsWith("引用") || el.innerText.startsWith("Quote"))
389
+ .map((el) => ({
390
+ author: el
391
+ .querySelector('[data-testid="User-Name"]')
392
+ ?.innerText.match(/@([A-Za-z0-9_]{1,15})/)?.[1],
393
+ body: el.querySelector('[data-testid="tweetText"]')?.innerText,
394
+ })),
386
395
  needsStateCheck: false,
387
396
  };
388
397
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/x-harness",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
4
4
  "type": "module",
5
5
  "description": "Typed X task harness with compact business results and explicit diagnostics.",
6
6
  "main": "./dist/index.js",
@@ -42,5 +42,5 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "gitHead": "a1ddf2487e87818c3be2a23213700e9e5b2b4ce4"
45
+ "gitHead": "5f83f4e0fa5f5d3e052b63498a4878f18e12e48b"
46
46
  }
@@ -0,0 +1,5 @@
1
+ # Reviewed quote posts
2
+
3
+ Use publishPost with original preparedAction.quoteTarget:{postId,url,evidenceRef} from a complete observed source, and the reviewed commentary as text. The publisher opens the source’s observed repost→Quote editor. Empty composer, source preview, account and exact commentary are bound before submission. No plain-post fallback.
4
+
5
+ Public verification resolves the quote card and requires its exact source ID and parent binding. Wrong or absent card remains unknown. Same-ID recovery may run after notAfter; never resubmit unknown. Original draft, calendar deadline, quality fingerprint and shared intent guards remain.
@@ -16,15 +16,33 @@ Original action optional notBefore is an ISO timestamp with timezone. Explicit d
16
16
 
17
17
  Original actions may pass `notAfter` as the calendar window’s exclusive ISO end. It is rechecked at intent and immediately before activation; expiration submits nothing. Unknown verification remains allowed after expiry.
18
18
 
19
+ Real quotes: pass `quoteTarget:{postId,url,evidenceRef}`; composer and public card must bind that ID. See [quotes](quotes.md).
20
+
19
21
  Additional fields: kind:'original', draftPath (existing .post.md), text (complete approved text), composeURL, composeEvidenceRef; optional assets:[{path,alt}], activation:'click'|'keyboard'. Every media path must exist and be reviewed. The DOM uploader supports one absolute PNG/JPEG/WebP image with required ALT, or one absolute MP4 video. Video uploads wait for a visible preview and public settlement requires a video on the exact fresh post; image ALT controls are not applied to videos.
20
22
 
21
23
  ```js
22
- var result = await x.publishPost({ taskId, draftPath, preparedAction: {
23
- id: actionId, status: 'prepared', kind: 'original',
24
- accountHandle, accountURL, accountEvidenceRef,
25
- quality: { approved: true, approvedText: text, evidenceRef: reviewProof, draftFingerprint },
26
- draftPath, text, composeURL, composeEvidenceRef,
27
- } });
24
+ var result = await x.publishPost({
25
+ taskId,
26
+ draftPath,
27
+ preparedAction: {
28
+ id: actionId,
29
+ status: "prepared",
30
+ kind: "original",
31
+ accountHandle,
32
+ accountURL,
33
+ accountEvidenceRef,
34
+ quality: {
35
+ approved: true,
36
+ approvedText: text,
37
+ evidenceRef: reviewProof,
38
+ draftFingerprint,
39
+ },
40
+ draftPath,
41
+ text,
42
+ composeURL,
43
+ composeEvidenceRef,
44
+ },
45
+ });
28
46
  ```
29
47
 
30
48
  Optional independentExecution:{tabId,evidenceRef,scopeEvidenceRef} requires real independent task-tab/scope proof; binding executor scope is covered in setup only if needed. Existing original action can omit preparedAction; draftPath still required.
@@ -34,12 +52,24 @@ Optional independentExecution:{tabId,evidenceRef,scopeEvidenceRef} requires real
34
52
  Additional fields: kind:'reply', postId, url, evidenceRef (observed parent), text; optional assets/activation as above. Common account/quality fields still required. The exact parent is verified by the API. `replyBatch({taskId,actions})` accepts 1–5 reviewed replies, submits serially and recovers an observed receipt once without resending; waiting returns unfinished work. Incoming clicks have a ≥30s minimum gap, not a safety guarantee. Incoming conversations require reviewed conversation:{recipientHandle,rootId,round,automatedLoop?}; at most 3 own rounds per recipient/root, with intent/unknown counted. Mark observed automatic loops to stop earlier.
35
53
 
36
54
  ```js
37
- var result = await x.replyToPost({ taskId, postId, text, preparedAction: {
38
- id: actionId, status: 'prepared', kind: 'reply',
39
- accountHandle, accountURL, accountEvidenceRef,
40
- quality: { approved: true, approvedText: text, evidenceRef: reviewProof },
41
- postId, url: parentURL, evidenceRef: parentProof, text,
42
- } });
55
+ var result = await x.replyToPost({
56
+ taskId,
57
+ postId,
58
+ text,
59
+ preparedAction: {
60
+ id: actionId,
61
+ status: "prepared",
62
+ kind: "reply",
63
+ accountHandle,
64
+ accountURL,
65
+ accountEvidenceRef,
66
+ quality: { approved: true, approvedText: text, evidenceRef: reviewProof },
67
+ postId,
68
+ url: parentURL,
69
+ evidenceRef: parentProof,
70
+ text,
71
+ },
72
+ });
43
73
  ```
44
74
 
45
75
  ## Follow