@nextclaw/x-harness 0.2.65 → 0.2.67

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.65, commit ece6b7ad7f4355d105ed6ec939bbcd8c136b4ce1.
3
+ Version 0.2.67, commit 03a90495bd2c781577399aca850742082abe3556.
4
4
 
5
- Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/ece6b7ad7f4355d105ed6ec939bbcd8c136b4ce1
5
+ Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/03a90495bd2c781577399aca850742082abe3556
6
6
 
7
7
  Clone this revision, run npm ci, npm run bootstrap and npm run release:build.
8
8
 
package/dist/api.d.ts CHANGED
@@ -82,6 +82,7 @@ export declare class BusinessHarness implements XGrowthHarness {
82
82
  diagnostic?: undefined;
83
83
  }>;
84
84
  searchSources(args: import("./types.js").SearchOptions): Promise<ReadResult>;
85
+ readExploreRecommendations(args?: import("./types.js").ExploreRecommendationOptions): Promise<ReadResult>;
85
86
  readPost({ target, mode, includeContext, includeThread, resolveQuote, }: {
86
87
  includeContext?: boolean;
87
88
  includeThread?: boolean;
package/dist/api.js CHANGED
@@ -33,6 +33,8 @@ export class BusinessHarness {
33
33
  this.readIncomingConversations = this.readIncomingConversations.bind(this);
34
34
  this.readIncomingReplies = this.readIncomingReplies.bind(this);
35
35
  this.searchSources = this.searchSources.bind(this);
36
+ this.readExploreRecommendations =
37
+ this.readExploreRecommendations.bind(this);
36
38
  this.readBookmarks = this.readBookmarks.bind(this);
37
39
  this.readCreator = this.readCreator.bind(this);
38
40
  const { store, platform } = dependencies;
@@ -599,6 +601,9 @@ export class BusinessHarness {
599
601
  searchSources(args) {
600
602
  return this.read("searchSources", args, args.mode);
601
603
  }
604
+ readExploreRecommendations(args = {}) {
605
+ return this.read("readExploreRecommendations", args, args.mode);
606
+ }
602
607
  readPost({ target, mode, includeContext, includeThread, resolveQuote, }) {
603
608
  return this.read("readPost", { ...target, includeContext, includeThread, resolveQuote }, mode);
604
609
  }
@@ -45,7 +45,12 @@ export class DOMCapabilities {
45
45
  if (operation === "readCreator") {
46
46
  return this.creatorCapabilities(base, operation);
47
47
  }
48
- if (["readBookmarks", "searchSources", "readFeedback"].includes(operation)) {
48
+ if ([
49
+ "readBookmarks",
50
+ "searchSources",
51
+ "readExploreRecommendations",
52
+ "readFeedback",
53
+ ].includes(operation)) {
49
54
  return this.collectionCapabilities(base, operation);
50
55
  }
51
56
  if (operation !== "read")
@@ -486,7 +486,8 @@ export class DOMEditing {
486
486
  if (names.length !== 1)
487
487
  throw new Error("回复按钮未唯一观察");
488
488
  const primary = result.posts[0].primary;
489
- if (!primary || primary.match(/\/status\/(\d+)$/)?.[1] !== command.postId)
489
+ if (!primary ||
490
+ primary.match(/\/status\/(\d+)(?:\/history)?$/)?.[1] !== command.postId)
490
491
  throw new Error("回复主帖永久链接未绑定");
491
492
  const article = this.#locator("article").filter({
492
493
  has: this.#tab.playwright.locator(`a[href=${JSON.stringify(primary)}]`),
@@ -3,6 +3,7 @@ export declare class DOMNavigation {
3
3
  #private;
4
4
  openQuote: (command: any) => Promise<any>;
5
5
  searchSources: (command: any) => Promise<void>;
6
+ openExploreRecommendations: (command: any) => Promise<void>;
6
7
  waitForFolder: (command: any) => Promise<void>;
7
8
  waitForPosts: (command: any) => Promise<void>;
8
9
  openObserved: (command: any) => Promise<void>;
@@ -11,6 +12,7 @@ export declare class DOMNavigation {
11
12
  constructor(dependencies: Pick<DOMDependencies, "session" | "tab" | "observe">);
12
13
  private handleOpenQuote;
13
14
  private handleSearchSources;
15
+ private handleOpenExploreRecommendations;
14
16
  private handleWaitForFolder;
15
17
  private handleWaitForPosts;
16
18
  private handleOpenObserved;
@@ -5,6 +5,7 @@ export class DOMNavigation {
5
5
  #observe;
6
6
  openQuote;
7
7
  searchSources;
8
+ openExploreRecommendations;
8
9
  waitForFolder;
9
10
  waitForPosts;
10
11
  openObserved;
@@ -17,6 +18,8 @@ export class DOMNavigation {
17
18
  this.#observe = observe;
18
19
  this.openQuote = this.handleOpenQuote.bind(this);
19
20
  this.searchSources = this.handleSearchSources.bind(this);
21
+ this.openExploreRecommendations =
22
+ this.handleOpenExploreRecommendations.bind(this);
20
23
  this.waitForFolder = this.handleWaitForFolder.bind(this);
21
24
  this.waitForPosts = this.handleWaitForPosts.bind(this);
22
25
  this.openObserved = this.handleOpenObserved.bind(this);
@@ -70,6 +73,10 @@ export class DOMNavigation {
70
73
  await this.#tab.goto(destination);
71
74
  return;
72
75
  }
76
+ async handleOpenExploreRecommendations() {
77
+ this.#session.current = null;
78
+ await this.#tab.goto("https://x.com/explore");
79
+ }
73
80
  async handleWaitForFolder(command) {
74
81
  if (command.selected) {
75
82
  await this.#tab.playwright
@@ -230,7 +237,7 @@ export class DOMNavigation {
230
237
  const articles = this.#tab.playwright.getByRole("article");
231
238
  const detail = /^\/\w{1,15}\/status\/\d+$/.test(path)
232
239
  ? articles.filter({
233
- has: this.#tab.playwright.locator(`a[href=${JSON.stringify(path)}]`),
240
+ has: this.#tab.playwright.locator(`a[href=${JSON.stringify(path)}],a[href=${JSON.stringify(path + "/history")}]`),
234
241
  })
235
242
  : articles;
236
243
  await this.handleWaitTargetContent(detail.first(), command.url);
@@ -170,7 +170,13 @@ export class NativeXBrowser {
170
170
  capabilities(operation) {
171
171
  if (operation === "readPeople")
172
172
  return ["native-people-blue-evidence"];
173
- if (["readBookmarks", "searchSources", "readFeedback", "likePost"].includes(operation))
173
+ if ([
174
+ "readBookmarks",
175
+ "searchSources",
176
+ "readExploreRecommendations",
177
+ "readFeedback",
178
+ "likePost",
179
+ ].includes(operation))
174
180
  return [`native.${operation}`];
175
181
  const names = operation === "read"
176
182
  ? ["getAXState"]
@@ -12,6 +12,7 @@ export declare class GrowthExecution {
12
12
  private checkExecutionIdentity;
13
13
  private actionIsActionable;
14
14
  private admitAction;
15
+ private readSubmissionWithURLContinuation;
15
16
  private handleExecute;
16
17
  }
17
18
  export declare function createGrowthExecution(...args: ConstructorParameters<typeof GrowthExecution>): GrowthExecution;
@@ -176,7 +176,7 @@ export class GrowthExecution {
176
176
  if (action.kind !== "follow") {
177
177
  let baseline;
178
178
  try {
179
- baseline = await this.#platform.findSubmission({ ...action, intentAt: this.#stamp() }, { phase: "baseline" });
179
+ baseline = await this.readSubmissionWithURLContinuation({ ...action, intentAt: this.#stamp() }, { phase: "baseline" });
180
180
  }
181
181
  catch (error) {
182
182
  return {
@@ -202,6 +202,23 @@ export class GrowthExecution {
202
202
  }
203
203
  return { beforeIds };
204
204
  }
205
+ async readSubmissionWithURLContinuation(action, context) {
206
+ try {
207
+ return await this.#platform.findSubmission(action, context);
208
+ }
209
+ catch (error) {
210
+ const preparedBaseline = context?.phase === "baseline";
211
+ const observedReceipt = !context &&
212
+ action.observedPublicURL &&
213
+ this.#evidenceExists(action.observedPublicEvidenceRef);
214
+ if (action.kind !== "reply" ||
215
+ !(preparedBaseline || observedReceipt) ||
216
+ !/Timed out waiting for URL/.test(String(error?.message ?? "")))
217
+ throw error;
218
+ // Continue only this read; the existing admission and receipt gates stay intact.
219
+ return this.#platform.findSubmission(action, context);
220
+ }
221
+ }
205
222
  async handleExecute(snapshot, action) {
206
223
  const executor = this.#executor;
207
224
  const recovering = ["intent", "unknown"].includes(action.status);
@@ -215,7 +232,7 @@ export class GrowthExecution {
215
232
  if (recovering) {
216
233
  let observation;
217
234
  try {
218
- observation = await this.#platform.findSubmission(action);
235
+ observation = await this.readSubmissionWithURLContinuation(action);
219
236
  }
220
237
  catch (error) {
221
238
  observation = {
@@ -10,6 +10,7 @@ export declare class GrowthTasks {
10
10
  readPerson(args: any): Promise<any>;
11
11
  readBookmarks(args: any): Promise<any>;
12
12
  searchSources(args: any): Promise<any>;
13
+ readExploreRecommendations(args?: any): Promise<any>;
13
14
  readFeedback(args: any): Promise<any>;
14
15
  runGrowthRound(args: any): Promise<any>;
15
16
  resumeGrowthRound(args: any): Promise<any>;
@@ -19,6 +19,8 @@ export class GrowthTasks {
19
19
  this.runGrowthRound = this.runGrowthRound.bind(this);
20
20
  this.readFeedback = this.readFeedback.bind(this);
21
21
  this.searchSources = this.searchSources.bind(this);
22
+ this.readExploreRecommendations =
23
+ this.readExploreRecommendations.bind(this);
22
24
  this.readBookmarks = this.readBookmarks.bind(this);
23
25
  this.readPerson = this.readPerson.bind(this);
24
26
  this.readPeople = this.readPeople.bind(this);
@@ -40,7 +42,13 @@ export class GrowthTasks {
40
42
  }
41
43
  preflight({ operation, input = {} }) {
42
44
  const executor = this.#executor;
43
- if (["readPeople", "readBookmarks", "searchSources", "readFeedback"].includes(operation))
45
+ if ([
46
+ "readPeople",
47
+ "readBookmarks",
48
+ "searchSources",
49
+ "readExploreRecommendations",
50
+ "readFeedback",
51
+ ].includes(operation))
44
52
  return this.#platform.preflight({ operation, input });
45
53
  if (["inspectRound", "verifySubmission"].includes(operation))
46
54
  return this.#capability("read", input);
@@ -110,6 +118,9 @@ export class GrowthTasks {
110
118
  searchSources(args) {
111
119
  return this.#readTask("searchSources", args);
112
120
  }
121
+ readExploreRecommendations(args = {}) {
122
+ return this.#readTask("readExploreRecommendations", args);
123
+ }
113
124
  readFeedback(args) {
114
125
  return this.#readTask("readFeedback", args);
115
126
  }
package/dist/growth.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare class GrowthHarness {
13
13
  readPerson: (args: any) => Promise<any>;
14
14
  readBookmarks: (args: any) => Promise<any>;
15
15
  searchSources: (args: any) => Promise<any>;
16
+ readExploreRecommendations: (args?: any) => Promise<any>;
16
17
  readFeedback: (args: any) => Promise<any>;
17
18
  runGrowthRound: (args: any) => Promise<any>;
18
19
  resumeGrowthRound: (args: any) => Promise<any>;
package/dist/growth.js CHANGED
@@ -51,6 +51,7 @@ export class GrowthHarness {
51
51
  readPerson;
52
52
  readBookmarks;
53
53
  searchSources;
54
+ readExploreRecommendations;
54
55
  readFeedback;
55
56
  runGrowthRound;
56
57
  resumeGrowthRound;
@@ -4,6 +4,7 @@ export declare class PlatformCollections {
4
4
  collectSources: ({ processedIds, maxPages, limit }?: any) => Promise<any>;
5
5
  readCreator: (args: import("./types.js").CreatorOptions) => Promise<any>;
6
6
  readBookmarks: ({ folder, ...window }?: any) => Promise<any>;
7
+ readExploreRecommendations: (args?: import("./types.js").ExploreRecommendationOptions) => Promise<any>;
7
8
  searchSources: ({ query, limit, maxPages, processedIds, }?: any) => Promise<any>;
8
9
  constructor(dependencies: Pick<PlatformDependencies, "ready" | "observed" | "browser" | "choose">);
9
10
  private handleCollectSources;
@@ -15,5 +16,6 @@ export declare class PlatformCollections {
15
16
  private bookmarkDiscovery;
16
17
  private waitForBookmarkLabel;
17
18
  private handleSearchSources;
19
+ private handleReadExploreRecommendations;
18
20
  }
19
21
  export declare function createPlatformCollections(...args: ConstructorParameters<typeof PlatformCollections>): PlatformCollections;
@@ -1,4 +1,15 @@
1
1
  import { xURL, source } from "./platform-rules.js";
2
+ const exploreNavigationEvidence = (nav) => ({
3
+ page: nav.page,
4
+ headings: (nav.headings ?? []).slice(0, 5),
5
+ tabs: (nav.controls ?? [])
6
+ .filter((control) => control.role === "tab")
7
+ .slice(0, 8)
8
+ .map((control) => ({
9
+ label: control.label,
10
+ selected: control.selected === true,
11
+ })),
12
+ });
2
13
  export class PlatformCollections {
3
14
  #ready;
4
15
  #observed;
@@ -7,6 +18,7 @@ export class PlatformCollections {
7
18
  collectSources;
8
19
  readCreator;
9
20
  readBookmarks;
21
+ readExploreRecommendations;
10
22
  searchSources;
11
23
  constructor(dependencies) {
12
24
  const { ready, observed, browser, choose } = dependencies;
@@ -18,6 +30,8 @@ export class PlatformCollections {
18
30
  this.readCreator = this.handleReadCreator.bind(this);
19
31
  this.readBookmarks = this.handleReadBookmarks.bind(this);
20
32
  this.searchSources = this.handleSearchSources.bind(this);
33
+ this.readExploreRecommendations =
34
+ this.handleReadExploreRecommendations.bind(this);
21
35
  }
22
36
  async handleCollectSources({ processedIds = [], maxPages = 3, limit = 1, } = {}) {
23
37
  if (!Array.isArray(processedIds) ||
@@ -439,6 +453,134 @@ export class PlatformCollections {
439
453
  };
440
454
  return { ...value, query };
441
455
  }
456
+ async handleReadExploreRecommendations({ limit = 3, maxPages = 1, processedIds = [], } = {}) {
457
+ await this.#browser.act({ operation: "openExploreRecommendations" });
458
+ let nav = await this.#ready({ view: "navigation" });
459
+ const page = (value) => {
460
+ try {
461
+ const url = xURL(value);
462
+ return url.pathname.replace(/\/+$/, "") === "/explore" ? url : null;
463
+ }
464
+ catch {
465
+ return null;
466
+ }
467
+ };
468
+ const entryPage = page(nav.page);
469
+ if (!entryPage)
470
+ return {
471
+ status: "needsDecision",
472
+ fresh: true,
473
+ complete: false,
474
+ page: nav.page,
475
+ missing: ["explore-page-unconfirmed"],
476
+ sources: [],
477
+ navigationEvidence: exploreNavigationEvidence(nav),
478
+ };
479
+ if (!nav.alerts?.length && nav.needsStateCheck) {
480
+ try {
481
+ await this.#browser.act({ operation: "waitForPosts" });
482
+ }
483
+ catch (error) {
484
+ if (!/timed out|timeout/i.test(error.message))
485
+ throw error;
486
+ }
487
+ nav = await this.#ready({ view: "navigation" });
488
+ }
489
+ if (nav.alerts?.length)
490
+ return {
491
+ status: "needsDecision",
492
+ fresh: true,
493
+ complete: false,
494
+ page: nav.page,
495
+ missing: ["platform-alert"],
496
+ sources: [],
497
+ navigationEvidence: exploreNavigationEvidence(nav),
498
+ };
499
+ if (nav.needsStateCheck) {
500
+ const diagnosticRead = await this.handleCollectSources({
501
+ limit,
502
+ maxPages,
503
+ processedIds,
504
+ });
505
+ const sameExplorePage = page(diagnosticRead.page);
506
+ return {
507
+ ...diagnosticRead,
508
+ status: "needsDecision",
509
+ complete: false,
510
+ missing: [
511
+ ...new Set([
512
+ "explore-navigation-unconfirmed",
513
+ ...(!sameExplorePage ? ["explore-page-changed-during-read"] : []),
514
+ ...(diagnosticRead.missing ?? []),
515
+ ]),
516
+ ],
517
+ sources: sameExplorePage ? (diagnosticRead.sources ?? []) : [],
518
+ navigationEvidence: exploreNavigationEvidence(nav),
519
+ };
520
+ }
521
+ const recommended = (nav.controls ?? []).filter((control) => control.role === "tab" &&
522
+ ["为你推荐", "For you"].includes(control.label.split("\n")[0]));
523
+ if (recommended.length !== 1)
524
+ return {
525
+ status: "needsDecision",
526
+ fresh: true,
527
+ complete: false,
528
+ page: entryPage.href,
529
+ missing: ["explore-recommended-tab-unconfirmed"],
530
+ sources: [],
531
+ navigationEvidence: exploreNavigationEvidence(nav),
532
+ };
533
+ const expectedLabel = recommended[0].label.split("\n")[0];
534
+ if (!recommended[0].selected &&
535
+ !(await this.#choose(nav, (control) => control.role === "tab" &&
536
+ control.label.split("\n")[0] === expectedLabel)))
537
+ return {
538
+ status: "needsDecision",
539
+ fresh: true,
540
+ complete: false,
541
+ page: entryPage.href,
542
+ missing: ["explore-recommended-tab-selection-unavailable"],
543
+ sources: [],
544
+ navigationEvidence: exploreNavigationEvidence(nav),
545
+ };
546
+ nav = await this.#ready({ view: "navigation" });
547
+ const selected = (nav.controls ?? []).filter((control) => control.role === "tab" &&
548
+ control.label.split("\n")[0] === expectedLabel &&
549
+ control.selected);
550
+ if (!page(nav.page) ||
551
+ nav.needsStateCheck ||
552
+ nav.alerts?.length ||
553
+ selected.length !== 1)
554
+ return {
555
+ status: "needsDecision",
556
+ fresh: true,
557
+ complete: false,
558
+ page: nav.page,
559
+ missing: ["explore-recommended-tab-selection-unconfirmed"],
560
+ sources: [],
561
+ navigationEvidence: exploreNavigationEvidence(nav),
562
+ };
563
+ const value = await this.handleCollectSources({
564
+ limit,
565
+ maxPages,
566
+ processedIds,
567
+ });
568
+ if (!page(value.page))
569
+ return {
570
+ ...value,
571
+ status: "needsDecision",
572
+ complete: false,
573
+ missing: ["explore-page-changed-during-read"],
574
+ };
575
+ return {
576
+ ...value,
577
+ sourceOrigin: {
578
+ kind: "explore-recommendations",
579
+ page: entryPage.href,
580
+ selectedTab: expectedLabel,
581
+ },
582
+ };
583
+ }
442
584
  }
443
585
  export function createPlatformCollections(...args) {
444
586
  return new PlatformCollections(...args);
@@ -19,6 +19,7 @@ export interface PlatformDependencies {
19
19
  media: any;
20
20
  links: any;
21
21
  needsExpansion: any;
22
+ metrics: string[];
22
23
  missing: any[];
23
24
  };
24
25
  collectSources: ({ processedIds, maxPages, limit }?: any) => Promise<any>;
@@ -28,6 +29,7 @@ export interface PlatformDependencies {
28
29
  readAccount: () => Promise<any>;
29
30
  readBookmarks: ({ folder, ...window }?: any) => Promise<any>;
30
31
  searchSources: ({ query, limit, maxPages, processedIds, }?: any) => Promise<any>;
32
+ readExploreRecommendations: (args?: import("./types.js").ExploreRecommendationOptions) => Promise<any>;
31
33
  readFeedback: ({ targets, replyLimit }?: any) => Promise<any>;
32
34
  readPerson: (target: any) => Promise<any>;
33
35
  readPost: (target: any) => Promise<any>;
@@ -34,7 +34,13 @@ export const source = (post, page) => ({
34
34
  images: post.images ?? [],
35
35
  media: post.media ?? [],
36
36
  links: post.links ?? [],
37
- metrics: [...(post.buttons ?? []), ...(post.metrics ?? [])],
37
+ metrics: [
38
+ ...new Set([...(post.buttons ?? []), ...(post.metrics ?? [])]
39
+ .map((metric) => typeof metric === "string" ? metric : metric?.label)
40
+ .filter((label) => typeof label === "string" &&
41
+ /(?:回复|转帖|喜欢|书签|观看|查看|引用|replies|reply|reposts?|retweets?|likes?|bookmarks?|views?|quotes?)/i.test(label) &&
42
+ /\d/.test(label))),
43
+ ],
38
44
  needsExpansion: post.needsExpansion,
39
45
  missing: [
40
46
  ...(post.missing ?? []),
@@ -1,3 +1,4 @@
1
+ import { createPlatformCollections } from "./platform-collections.js";
1
2
  export { xURL } from "./platform-rules.js";
2
3
  export declare class XPlatform {
3
4
  #private;
@@ -8,6 +9,7 @@ export declare class XPlatform {
8
9
  readAccount: () => Promise<any>;
9
10
  readBookmarks: ({ folder, ...window }?: any) => Promise<any>;
10
11
  searchSources: ({ query, limit, maxPages, processedIds, }?: any) => Promise<any>;
12
+ readExploreRecommendations: ReturnType<typeof createPlatformCollections>["readExploreRecommendations"];
11
13
  readFeedback: ({ targets, replyLimit }?: any) => Promise<any>;
12
14
  readPerson: (target: any) => Promise<any>;
13
15
  readPost: (target: any) => Promise<any>;
package/dist/platform.js CHANGED
@@ -23,6 +23,7 @@ export class XPlatform {
23
23
  #readCreator;
24
24
  #readBookmarks;
25
25
  #searchSources;
26
+ #readExploreRecommendations;
26
27
  #createPlatformDiagnosticsResult;
27
28
  #followStage;
28
29
  #captureProof;
@@ -41,6 +42,7 @@ export class XPlatform {
41
42
  readAccount;
42
43
  readBookmarks;
43
44
  searchSources;
45
+ readExploreRecommendations;
44
46
  readFeedback;
45
47
  readPerson;
46
48
  readPost;
@@ -79,11 +81,12 @@ export class XPlatform {
79
81
  browser: browser,
80
82
  choose: this.#choose,
81
83
  });
82
- const { collectSources, readCreator, readBookmarks, searchSources } = this.#createPlatformCollectionsResult;
84
+ const { collectSources, readCreator, readBookmarks, searchSources, readExploreRecommendations, } = this.#createPlatformCollectionsResult;
83
85
  this.#collectSources = collectSources;
84
86
  this.#readCreator = readCreator;
85
87
  this.#readBookmarks = readBookmarks;
86
88
  this.#searchSources = searchSources;
89
+ this.#readExploreRecommendations = readExploreRecommendations;
87
90
  this.#createPlatformDiagnosticsResult = createPlatformDiagnostics({
88
91
  clock: this.#clock,
89
92
  });
@@ -116,6 +119,7 @@ export class XPlatform {
116
119
  this.readAccount = this.#readAccount;
117
120
  this.readBookmarks = this.#readBookmarks;
118
121
  this.searchSources = this.#searchSources;
122
+ this.readExploreRecommendations = this.#readExploreRecommendations;
119
123
  this.readFeedback = this.#readFeedback;
120
124
  this.readPerson = this.#readPerson;
121
125
  this.readPeople = this.#createPlatformTargetsResult.readPeople;
package/dist/results.js CHANGED
@@ -90,6 +90,17 @@ export function source(value, page) {
90
90
  : []);
91
91
  if (out.links)
92
92
  out.links = out.links.filter((link) => isContentLink(link, out));
93
+ if (raw.metrics !== undefined) {
94
+ const metrics = list(raw.metrics).flatMap((value) => {
95
+ if (typeof value === "string")
96
+ return [value];
97
+ const label = object(value).label;
98
+ return typeof label === "string" ? [label] : [];
99
+ });
100
+ out.metrics = [
101
+ ...new Set(metrics.filter((label) => /(?:回复|转帖|喜欢|书签|观看|查看|引用|replies|reply|reposts?|retweets?|likes?|bookmarks?|views?|quotes?)/i.test(label) && /\d/.test(label))),
102
+ ];
103
+ }
93
104
  if (strings(raw.missing).length)
94
105
  out.missing = strings(raw.missing);
95
106
  return out;
@@ -200,6 +211,28 @@ export function readResult(operation, value, mode) {
200
211
  out.complete = false;
201
212
  if (raw.ids !== undefined)
202
213
  out.ids = strings(raw.ids);
214
+ if (operation === "readExploreRecommendations") {
215
+ const origin = object(raw.sourceOrigin);
216
+ try {
217
+ const page = new URL(String(origin.page));
218
+ if (origin.kind === "explore-recommendations" &&
219
+ page.protocol === "https:" &&
220
+ page.hostname === "x.com" &&
221
+ !page.username &&
222
+ !page.password &&
223
+ !page.port &&
224
+ page.pathname.replace(/\/+$/, "") === "/explore" &&
225
+ ["为你推荐", "For you"].includes(String(origin.selectedTab)))
226
+ out.sourceOrigin = {
227
+ kind: "explore-recommendations",
228
+ page: page.href,
229
+ selectedTab: String(origin.selectedTab),
230
+ };
231
+ }
232
+ catch {
233
+ /* Unverified provenance is omitted at the public business boundary. */
234
+ }
235
+ }
203
236
  const missing = strings(raw.missing);
204
237
  if (list(raw.alerts).length && !missing.includes("platform-alert"))
205
238
  missing.push("platform-alert");
package/dist/types.d.ts CHANGED
@@ -93,6 +93,11 @@ export interface Diagnostic {
93
93
  next?: "inspect-local-proof" | "restore-proof-store";
94
94
  evidenceRef?: EvidenceRef;
95
95
  }
96
+ export interface SourceOriginEvidence {
97
+ kind: "explore-recommendations";
98
+ page: string;
99
+ selectedTab: "为你推荐" | "For you";
100
+ }
96
101
  export interface ReadResult {
97
102
  status: "observed" | "needsDecision" | "needsCapability";
98
103
  fresh?: boolean;
@@ -124,6 +129,7 @@ export interface ReadResult {
124
129
  scopeComplete?: boolean;
125
130
  };
126
131
  sources?: PostEvidence[];
132
+ sourceOrigin?: SourceOriginEvidence;
127
133
  post?: PostEvidence;
128
134
  context?: PostEvidence[];
129
135
  threadPosts?: PostEvidence[];
@@ -253,6 +259,8 @@ export interface BookmarkOptions extends WindowOptions {
253
259
  export interface SearchOptions extends WindowOptions {
254
260
  query: string;
255
261
  }
262
+ export interface ExploreRecommendationOptions extends WindowOptions {
263
+ }
256
264
  export interface CreatorOptions {
257
265
  handle: string;
258
266
  target?: number;
@@ -563,6 +571,7 @@ export interface XGrowthHarness {
563
571
  readFollowbackAudit(args: FollowbackAuditOptions): Promise<FollowbackAuditResult>;
564
572
  readBookmarks(args: BookmarkOptions): Promise<ReadResult>;
565
573
  searchSources(args: SearchOptions): Promise<ReadResult>;
574
+ readExploreRecommendations(args?: ExploreRecommendationOptions): Promise<ReadResult>;
566
575
  readIncomingReplies(args?: IncomingReplyOptions): Promise<ReadResult>;
567
576
  readIncomingConversations(args?: IncomingConversationOptions): Promise<IncomingConversationResult>;
568
577
  readPost(args: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/x-harness",
3
- "version": "0.2.65",
3
+ "version": "0.2.67",
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": "ece6b7ad7f4355d105ed6ec939bbcd8c136b4ce1"
45
+ "gitHead": "03a90495bd2c781577399aca850742082abe3556"
46
46
  }
@@ -6,6 +6,7 @@
6
6
  | readCreator | handle | target (10;1–50), maxScrolls, navigate, expand, mode |
7
7
  | readBookmarks | folder (observed name), or discoverOnly:true | processedIds, limit (1–5), maxPages (1–10), resume, mode |
8
8
  | searchSources | query | processedIds, limit (1–5), maxPages (1–10), mode |
9
+ | readExploreRecommendations | none | processedIds, limit (1–5), maxPages (1–10), mode |
9
10
  | readPost | target: {postId,url,evidenceRef} | includeContext, resolveQuote, mode |
10
11
  | readPerson | target: {handle,url,evidenceRef} | mode |
11
12
  | readPeople | target: {url,evidenceRef} | offset, limit (1–5), advance, mode |
@@ -13,20 +14,20 @@
13
14
  | inspectAccount | none | mode |
14
15
  | inspectRound | taskId (original batch ID) | none |
15
16
 
16
- Targets require proof. Native creator: selected profile, navigate:false; relative time unknown; stop on page change.
17
+ Targets need proof. Native `readCreator` requires a selected profile (`navigate:false`); relative time is unknown. Stop on route changes.
17
18
 
18
- Bookmarks resume:true continues the bound folder; rendered tail ≠ full proof.
19
+ Bookmarks `resume:true` continues the bound folder; rendered tail ≠ full proof.
19
20
 
20
- observed ≠ field completeness; pageComplete:false = scope. View media before describing; preserve target text; keep other reads local.
21
+ Observed ≠ complete; `pageComplete:false` means finite scope. Preserve full text; view media before describing; keep raw reads local.
21
22
 
22
- `readIncomingReplies`: ledger account; skip settled/intent/unknown and processedIds across dates. readPost(includeContext:true) for ancestors; missing context blocks review. Dedup publicly.
23
+ Incoming replies: ledger only; carry processedIds; skip settled/intent/unknown. Read parents with `readPost(includeContext:true)`; deduplicate.
23
24
 
24
- Incoming: `readIncomingConversations({targets?,...window})` combines discovery/context (≤5); no preceding `readIncomingReplies`. Preflight/source failures remain visible. Returns ancestorPath, nodes, confirmed edges, unknown parents and attempts; rootCandidateId is only the earliest loaded ancestor. Missing context blocks review; never guess roots/edges or inbox clearance.
25
+ Incoming conversations: `readIncomingConversations({targets?,...window})` discovers/reads ≤5 chains in one call. Targets need proof; returns nodes, ancestors, confirmed edges, unknown parents and attempts. `rootCandidateId` may not be root. Missing context blocks review; don't infer edges or clearance.
25
26
 
26
27
  `resolveQuote:true` follows one observed card and returns `post.quoteSource` only after exact destination/body/author/time binding.
27
28
 
28
- `readPeople`: observed follower/following; offset reuses cells; advance scrolls a fresh unique handle. Full bio/blue/relationship; dedup; unknowns need profiles. No Native.
29
+ `readPeople`: selected followers/following; `offset` reuses cells, `advance` scrolls. Returns bio, blue, relationship; dedup. Unknowns need profiles; Native unsupported.
29
30
 
30
31
  Discovery: names only.
31
32
 
32
- Source decisions retain visible metric labels for batch screening; absent counts stay unknown.
33
+ Count `sourceOrigin` only with confirmed `/explore` “For you” tab; exclude trends/search. Preserve visible counts; absent stays unknown.
@@ -0,0 +1,7 @@
1
+ # Observed reply receipt recovery
2
+
3
+ If the root reports an original unknown but a fresh proof or public search exposes its actual own-account permalink, keep that original action. Never derive a receipt ID from a timestamp or guess URLs. Read the observed candidate with root readPost and its original evidenceRef; confirm the actual author, reviewed text, new ID and time at/after intent. A visible parent in context alone is not the final exact-parent verification.
4
+
5
+ The original ledger already supports observedPublicURL and observedPublicEvidenceRef. Use the existing diagnostics createFileRoundStore transaction to attach only these two fields to that same unknown/intent reply, preserving other actions, executors and counts. Both fields must come from actual observed proof, not a claimed success. Then call root verifySubmission({taskId,actionId}); its normal fresh author/text/time/assets/exact-parent gates settle the original action. A rejected/ambiguous hint remains unknown. Do not manually mark verified, create a replacement action, resend or bypass alerts.
6
+
7
+ Prepared reply baseline URL timeouts get one internal read-only continuation. A second timeout, unrelated failure, alerts or unconfirmed content stops before intent. Reusing this root behavior is preferable to model-level per-parent rewarming. This does not grant new platform access or override project budgets/windows.
@@ -4,7 +4,7 @@ Load on failure/unknown only. Keep operation/status/missing/code/proof/error; do
4
4
 
5
5
  | Signal | Next action and exit condition |
6
6
  | --- | --- |
7
- | unknown submission | `await x.verifySubmission({taskId,actionId})`, same IDs; no replacement action or new submission before resolution. |
7
+ | unknown submission | `await x.verifySubmission({taskId,actionId})`, same IDs; no resend. [Observed link](receipt-recovery.md). |
8
8
  | Existing public post needs recovery | `await x.verifyPublishedPost({taskId,draftPath,publicURL,evidenceRef})`; observed URL/proof. sourceLink/sourceLinks: [link proof](source-link-recovery.md). |
9
9
  | Follow needs independent recovery | `await x.verifyFollow({handle,url,evidenceRef,mode:'decision'})`; honor returned verified. Normal followBatch already verifies. |
10
10
  | needsDecision | Resolve returned missing review/proof in original action, resume same ID. Current AI performs internal review under project contract. |