@nextclaw/x-harness 0.2.66 → 0.2.68

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.66, commit 0b3b276a3f9390c3ca46514b8ae3845beadf1033.
3
+ Version 0.2.68, commit 9d121f1f8b24f7c1082d8f17a3f6ec473d45d534.
4
4
 
5
- Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/0b3b276a3f9390c3ca46514b8ae3845beadf1033
5
+ Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/9d121f1f8b24f7c1082d8f17a3f6ec473d45d534
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
@@ -22,7 +22,7 @@ export declare class BusinessHarness implements XGrowthHarness {
22
22
  results: never[];
23
23
  reason: string;
24
24
  } | {
25
- status: "needsDecision" | "verified";
25
+ status: "verified" | "needsDecision";
26
26
  results: ActionResult[];
27
27
  reason?: undefined;
28
28
  }>;
@@ -81,7 +81,9 @@ export declare class BusinessHarness implements XGrowthHarness {
81
81
  evidenceRef?: undefined;
82
82
  diagnostic?: undefined;
83
83
  }>;
84
+ discoverReplyCandidates(args: import("./types.js").ReplyDiscoveryOptions): Promise<import("./types.js").ReplyDiscoveryResult>;
84
85
  searchSources(args: import("./types.js").SearchOptions): Promise<ReadResult>;
86
+ readExploreRecommendations(args?: import("./types.js").ExploreRecommendationOptions): Promise<ReadResult>;
85
87
  readPost({ target, mode, includeContext, includeThread, resolveQuote, }: {
86
88
  includeContext?: boolean;
87
89
  includeThread?: boolean;
package/dist/api.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { ReplyDiscovery } from "./reply-discovery.js";
1
2
  import { sameHandle, samePublishedText } from "./platform-rules.js";
3
+ import { publicationTime } from "./publication-time.js";
2
4
  import { FollowbackAudit } from "./followback-audit.js";
3
5
  import { IncomingReactions } from "./incoming-reactions.js";
4
6
  import { createGrowthHarness } from "./growth.js";
@@ -32,7 +34,10 @@ export class BusinessHarness {
32
34
  this.readPost = this.readPost.bind(this);
33
35
  this.readIncomingConversations = this.readIncomingConversations.bind(this);
34
36
  this.readIncomingReplies = this.readIncomingReplies.bind(this);
37
+ this.discoverReplyCandidates = this.discoverReplyCandidates.bind(this);
35
38
  this.searchSources = this.searchSources.bind(this);
39
+ this.readExploreRecommendations =
40
+ this.readExploreRecommendations.bind(this);
36
41
  this.readBookmarks = this.readBookmarks.bind(this);
37
42
  this.readCreator = this.readCreator.bind(this);
38
43
  const { store, platform } = dependencies;
@@ -366,7 +371,7 @@ export class BusinessHarness {
366
371
  const phrases = pending
367
372
  .map((a) => '"' + a.text.slice(0, 80).replace(/["\\]/g, " ") + '"')
368
373
  .join(" OR ");
369
- const query = `from:${pending[0].accountHandle} (${phrases}) since:${new Date(Math.min(...pending.map((a) => Date.parse(a.intentAt)))).toISOString().slice(0, 10)}`;
374
+ const query = `from:${pending[0].accountHandle} (${phrases}) filter:replies`;
370
375
  let found = await this.searchSources({
371
376
  query,
372
377
  limit: 5,
@@ -391,8 +396,8 @@ export class BusinessHarness {
391
396
  p.url?.toLowerCase() ===
392
397
  `https://x.com/${action.accountHandle}/status/${p.id}`.toLowerCase() &&
393
398
  !(action.beforeIds ?? []).includes(p.id) &&
394
- Date.parse(p.datetime ?? "") >= Date.parse(action.intentAt) &&
395
- Date.parse(p.datetime ?? "") <= this.#clock() + 5000);
399
+ publicationTime(p) >= Date.parse(action.intentAt) &&
400
+ publicationTime(p) <= this.#clock() + 5000);
396
401
  if (matches.length !== 1)
397
402
  continue;
398
403
  let bound = false;
@@ -596,9 +601,15 @@ export class BusinessHarness {
596
601
  pageComplete: false,
597
602
  };
598
603
  }
604
+ discoverReplyCandidates(args) {
605
+ return new ReplyDiscovery((request) => this.searchSources(request), this.#store, this.#clock).run(args);
606
+ }
599
607
  searchSources(args) {
600
608
  return this.read("searchSources", args, args.mode);
601
609
  }
610
+ readExploreRecommendations(args = {}) {
611
+ return this.read("readExploreRecommendations", args, args.mode);
612
+ }
602
613
  readPost({ target, mode, includeContext, includeThread, resolveQuote, }) {
603
614
  return this.read("readPost", { ...target, includeContext, includeThread, resolveQuote }, mode);
604
615
  }
@@ -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")
@@ -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
@@ -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 = {
@@ -1,3 +1,4 @@
1
+ import { publicationTime } from "./publication-time.js";
1
2
  import { xURL, matchesReviewedAssets } from "./platform-rules.js";
2
3
  import { matchesRenderedLinks } from "./source-link-recovery.js";
3
4
  import { same } from "./growth-constants.js";
@@ -126,8 +127,8 @@ export class GrowthRecovery {
126
127
  matchesRenderedLinks(action.text, p, sourceLinks, this.#evidenceExists)) &&
127
128
  !p.needsExpansion &&
128
129
  !(action.beforeIds ?? []).includes(p.id) &&
129
- Date.parse(p.datetime) >= Date.parse(action.intentAt) &&
130
- Date.parse(p.datetime) <= this.#clock() + 5000 &&
130
+ publicationTime(p) >= Date.parse(action.intentAt) &&
131
+ publicationTime(p) <= this.#clock() + 5000 &&
131
132
  matchesReviewedAssets(p, action.assets));
132
133
  });
133
134
  Object.assign(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 { publicationTime } from "./publication-time.js";
1
2
  import { xURL, sameHandle, sameText, samePublishedText, matchesReviewedAssets, } from "./platform-rules.js";
2
3
  export class PlatformWriting {
3
4
  #observed;
@@ -204,9 +205,9 @@ export class PlatformWriting {
204
205
  /^\d+$/.test(post.id ?? "") &&
205
206
  matchesReviewedAssets(post, action.assets) &&
206
207
  !(action.beforeIds ?? []).includes(post.id) &&
207
- Number.isFinite(Date.parse(post.datetime)) &&
208
- Date.parse(post.datetime) >= Date.parse(action.intentAt) &&
209
- Date.parse(post.datetime) <= this.#clock() + 5000 &&
208
+ Number.isFinite(publicationTime(post)) &&
209
+ publicationTime(post) >= Date.parse(action.intentAt) &&
210
+ publicationTime(post) <= this.#clock() + 5000 &&
210
211
  !post.needsExpansion);
211
212
  }
212
213
  async readQuotedReceipt(value, action, candidate) {
@@ -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;
@@ -0,0 +1,5 @@
1
+ /** Refine an observed second-resolution timestamp only with its matching public X ID. */
2
+ export declare function publicationTime(post: {
3
+ id?: string | null;
4
+ datetime?: string | null;
5
+ }): number;
@@ -0,0 +1,18 @@
1
+ /** Refine an observed second-resolution timestamp only with its matching public X ID. */
2
+ export function publicationTime(post) {
3
+ const rendered = Date.parse(post.datetime ?? "");
4
+ if (!Number.isFinite(rendered))
5
+ return NaN;
6
+ if (!/^[1-9]\d{17,19}$/.test(post.id ?? ""))
7
+ return rendered;
8
+ const id = BigInt(post.id);
9
+ if (id > 9223372036854775807n)
10
+ return NaN;
11
+ // Twitter's published Snowflake layout: 41 timestamp bits, 22 lower bits.
12
+ const decoded = Number((id >> 22n) + 1288834974657n);
13
+ if (Math.floor(decoded / 1000) !== Math.floor(rendered / 1000))
14
+ return NaN;
15
+ if (rendered % 1000 !== 0 && rendered !== decoded)
16
+ return NaN;
17
+ return decoded;
18
+ }
@@ -0,0 +1,12 @@
1
+ import type { RoundStore } from "./store.js";
2
+ import type { ReadResult, ReplyDiscoveryOptions, ReplyDiscoveryResult, SearchOptions } from "./types.js";
3
+ /** Read-only discovery; shortlisted evidence still requires semantic review. */
4
+ export declare class ReplyDiscovery {
5
+ private search;
6
+ private store;
7
+ private clock;
8
+ constructor(search: (args: SearchOptions) => Promise<ReadResult>, store: RoundStore, clock: () => number);
9
+ private exclusion;
10
+ private rejection;
11
+ run(args: ReplyDiscoveryOptions): Promise<ReplyDiscoveryResult>;
12
+ }
@@ -0,0 +1,167 @@
1
+ /** Read-only discovery; shortlisted evidence still requires semantic review. */
2
+ export class ReplyDiscovery {
3
+ search;
4
+ store;
5
+ clock;
6
+ constructor(search, store, clock) {
7
+ this.search = search;
8
+ this.store = store;
9
+ this.clock = clock;
10
+ }
11
+ exclusion(state, handles) {
12
+ const authors = new Set(handles.map((h) => h.replace(/^@/, "").toLowerCase()));
13
+ const ids = new Set();
14
+ if (state.accountHandle)
15
+ authors.add(state.accountHandle.toLowerCase());
16
+ for (const a of [
17
+ ...(state.actions ?? []),
18
+ ...(state.replyAuthorHistory?.entries ?? []),
19
+ ]) {
20
+ if (a.kind !== "reply" || a.status === "skipped")
21
+ continue;
22
+ const author = a.author ?? a.conversation?.recipientHandle;
23
+ if (author)
24
+ authors.add(author.toLowerCase());
25
+ if (a.postId)
26
+ ids.add(a.postId);
27
+ }
28
+ return { authors, ids };
29
+ }
30
+ rejection(p, args, now) {
31
+ if (!p.id ||
32
+ !p.author ||
33
+ !p.url ||
34
+ !p.body?.trim() ||
35
+ p.needsExpansion ||
36
+ p.missing?.length)
37
+ return "incomplete-source";
38
+ const time = Date.parse(p.datetime ?? "");
39
+ if (!Number.isFinite(time) ||
40
+ time > now ||
41
+ now - time > (args.maxAgeHours ?? 12) * 3600000)
42
+ return "source-time";
43
+ if ((args.chineseOnly ?? true) && !/[\u3400-\u9fff]/u.test(p.body))
44
+ return "source-language";
45
+ const raw = p.metrics?.find((m) => /喜欢|点赞|likes?\b/i.test(m));
46
+ const match = raw?.match(/([\d,]+(?:\.\d+)?)\s*([万千KM])?/i);
47
+ const multiplier = {
48
+ 万: 10000,
49
+ 千: 1000,
50
+ K: 1000,
51
+ M: 1000000,
52
+ };
53
+ const likes = match
54
+ ? Number(match[1].replaceAll(",", "")) *
55
+ (multiplier[(match[2] ?? "").toUpperCase()] ?? 1)
56
+ : NaN;
57
+ if (!Number.isFinite(likes) || likes < (args.minLikes ?? 20))
58
+ return "source-likes";
59
+ }
60
+ async run(args) {
61
+ const target = args.target ?? 20, maxWindows = args.maxWindows ?? 12;
62
+ if (!args.queries?.length ||
63
+ args.queries.length > 10 ||
64
+ args.queries.some((q) => !q.trim()) ||
65
+ !Number.isInteger(target) ||
66
+ target < 1 ||
67
+ target > 50 ||
68
+ !Number.isInteger(maxWindows) ||
69
+ maxWindows < 1 ||
70
+ maxWindows > 30 ||
71
+ !Number.isFinite(args.minLikes ?? 20) ||
72
+ (args.minLikes ?? 20) < 0 ||
73
+ !Number.isFinite(args.maxAgeHours ?? 12) ||
74
+ (args.maxAgeHours ?? 12) <= 0) {
75
+ return {
76
+ status: "needsDecision",
77
+ reason: "discovery-options-invalid",
78
+ candidates: [],
79
+ examinedIds: [],
80
+ windows: [],
81
+ rejections: {},
82
+ targetReached: false,
83
+ pageComplete: false,
84
+ };
85
+ }
86
+ const examined = new Set(args.processedIds ?? []), authors = new Set();
87
+ const candidates = [], windows = [], rejections = {};
88
+ let reason;
89
+ for (let i = 0; i < maxWindows && candidates.length < target; i++) {
90
+ const now = this.clock(), hour = new Date(now + 8 * 3600000).getUTCHours();
91
+ if (hour < 7) {
92
+ reason = "night-platform-boundary";
93
+ break;
94
+ }
95
+ const excluded = this.exclusion((await this.store.load()).state, args.excludedHandles ?? []);
96
+ const query = args.queries[i % args.queries.length];
97
+ const request = {
98
+ query,
99
+ processedIds: [...examined, ...excluded.ids],
100
+ limit: 5,
101
+ maxPages: 3,
102
+ mode: "decision",
103
+ };
104
+ let read = await this.search(request);
105
+ if ((read.reason === "source-page-unconfirmed" ||
106
+ read.missing?.includes("source-page-unconfirmed")) &&
107
+ new Date(this.clock() + 8 * 3600000).getUTCHours() >= 7)
108
+ read = await this.search(request);
109
+ windows.push({
110
+ query,
111
+ evidenceRef: read.evidenceRef,
112
+ status: read.status,
113
+ missing: read.missing ?? [],
114
+ observed: read.sources?.length ?? 0,
115
+ });
116
+ if (read.status === "needsCapability" ||
117
+ read.missing?.includes("platform-alert") ||
118
+ read.reason === "night-platform-boundary") {
119
+ reason = read.reason ?? "platform-alert";
120
+ break;
121
+ }
122
+ for (const post of read.sources ?? []) {
123
+ if (post.id && examined.has(post.id))
124
+ continue;
125
+ if (post.id)
126
+ examined.add(post.id);
127
+ const author = post.author?.toLowerCase();
128
+ const rejection = author && (excluded.authors.has(author) || authors.has(author))
129
+ ? "author-history-or-duplicate"
130
+ : post.id && excluded.ids.has(post.id)
131
+ ? "target-history"
132
+ : this.rejection(post, args, this.clock());
133
+ if (rejection || !read.evidenceRef) {
134
+ const key = rejection ?? "source-proof-missing";
135
+ rejections[key] = (rejections[key] ?? 0) + 1;
136
+ continue;
137
+ }
138
+ candidates.push({
139
+ post,
140
+ evidenceRef: read.evidenceRef,
141
+ query,
142
+ requiresReview: true,
143
+ });
144
+ authors.add(author);
145
+ if (candidates.length === target)
146
+ break;
147
+ }
148
+ }
149
+ const result = {
150
+ status: candidates.length >= target ? "observed" : "needsDecision",
151
+ reason: reason ??
152
+ (candidates.length < target ? "bounded-candidate-shortage" : undefined),
153
+ candidates,
154
+ examinedIds: [...examined],
155
+ windows,
156
+ rejections,
157
+ targetReached: candidates.length >= target,
158
+ pageComplete: false,
159
+ };
160
+ result.evidenceRef = await this.store.recordProof({
161
+ operation: "discoverReplyCandidates",
162
+ capturedAt: new Date(this.clock()).toISOString(),
163
+ result,
164
+ });
165
+ return result;
166
+ }
167
+ }
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,40 @@ export interface BookmarkOptions extends WindowOptions {
253
259
  export interface SearchOptions extends WindowOptions {
254
260
  query: string;
255
261
  }
262
+ export interface ReplyDiscoveryOptions {
263
+ queries: string[];
264
+ target?: number;
265
+ maxWindows?: number;
266
+ processedIds?: string[];
267
+ excludedHandles?: string[];
268
+ minLikes?: number;
269
+ maxAgeHours?: number;
270
+ chineseOnly?: boolean;
271
+ }
272
+ export interface ReplyDiscoveryResult {
273
+ status: "observed" | "needsDecision";
274
+ reason?: string;
275
+ candidates: {
276
+ post: PostEvidence;
277
+ evidenceRef: EvidenceRef;
278
+ query: string;
279
+ requiresReview: true;
280
+ }[];
281
+ examinedIds: string[];
282
+ windows: {
283
+ query: string;
284
+ evidenceRef?: EvidenceRef;
285
+ status: ReadResult["status"];
286
+ missing: string[];
287
+ observed: number;
288
+ }[];
289
+ rejections: Record<string, number>;
290
+ targetReached: boolean;
291
+ pageComplete: false;
292
+ evidenceRef?: EvidenceRef;
293
+ }
294
+ export interface ExploreRecommendationOptions extends WindowOptions {
295
+ }
256
296
  export interface CreatorOptions {
257
297
  handle: string;
258
298
  target?: number;
@@ -562,7 +602,9 @@ export interface XGrowthHarness {
562
602
  readPeople(args: PeopleOptions): Promise<ReadResult>;
563
603
  readFollowbackAudit(args: FollowbackAuditOptions): Promise<FollowbackAuditResult>;
564
604
  readBookmarks(args: BookmarkOptions): Promise<ReadResult>;
605
+ discoverReplyCandidates(args: ReplyDiscoveryOptions): Promise<ReplyDiscoveryResult>;
565
606
  searchSources(args: SearchOptions): Promise<ReadResult>;
607
+ readExploreRecommendations(args?: ExploreRecommendationOptions): Promise<ReadResult>;
566
608
  readIncomingReplies(args?: IncomingReplyOptions): Promise<ReadResult>;
567
609
  readIncomingConversations(args?: IncomingConversationOptions): Promise<IncomingConversationResult>;
568
610
  readPost(args: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/x-harness",
3
- "version": "0.2.66",
3
+ "version": "0.2.68",
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": "0b3b276a3f9390c3ca46514b8ae3845beadf1033"
45
+ "gitHead": "9d121f1f8b24f7c1082d8f17a3f6ec473d45d534"
46
46
  }
@@ -0,0 +1,2 @@
1
+ # Batch discovery
2
+ For a batch of active replies, first use `x.discoverReplyCandidates({queries:["互关 min_faves:20", "串门 min_faves:20"],target:20,processedIds})`. One call owns serial paginated searches, original-ledger target/author exclusion, deduplication and deterministic age/likes/language/body gates. It performs no writes. Save/reuse `examinedIds` on the original actor; full candidate text, original per-window proofs and the combined proof stay available locally. `requiresReview:true` means relevance, human identity and suitable text still need review. Review the batch, then use existing `replyBatch`; do not rebuild per-person discovery or infer approval from metadata. Partial shortages include rejection totals and window warnings; `pageComplete:false` never claims all of X was read. Night boundary stops further reads. Missing metrics remain unknown. Repair a concrete missing capability at its original owner, validate it and resume the task; no implementation reading for normal calls.
@@ -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,22 @@
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
+ `pageComplete:false`: finite scope. Preserve full text; view media before describing.
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.
34
+
35
+ Batch replies: [automated discovery](batch-discovery.md).
@@ -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. |