@nextclaw/x-harness 0.2.63 → 0.2.65

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/README.md CHANGED
@@ -34,6 +34,8 @@ const result = await x.readCreator({
34
34
  ```
35
35
 
36
36
  Read APIs: `readCreator`, `readBookmarks`, `searchSources`, `readPost`, `readPerson`, `readFeedback`, `inspectAccount`, `inspectRound`.
37
+ Bulk list audit: `readFollowbackAudit` saves deduplicated relationship lists and resumable coverage evidence. Followback direction reads unfiltered followers; unmatched profile counts remain incomplete. See [the API reference](skill/references/followback-audit.md). `followBatch` optionally persists bounded random intent gaps, while retaining all original budget, refusal and unknown guards.
38
+
37
39
  Write APIs: `publishPost`, `replyToPost`, `followOne`, `followBatch`, `runGrowthRound`.
38
40
  Recovery: `verifySubmission`, `verifyPublishedPost`, `verifyFollow`.
39
41
 
package/SOURCE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Corresponding source
2
2
 
3
- Version 0.2.63, commit 6e70dc4b7351bc7ec59936ed517465133001099b.
3
+ Version 0.2.65, commit ece6b7ad7f4355d105ed6ec939bbcd8c136b4ce1.
4
4
 
5
- Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/6e70dc4b7351bc7ec59936ed517465133001099b
5
+ Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/ece6b7ad7f4355d105ed6ec939bbcd8c136b4ce1
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
@@ -89,6 +89,7 @@ export declare class BusinessHarness implements XGrowthHarness {
89
89
  target: import("./types.js").PostTarget;
90
90
  mode?: ReadMode;
91
91
  }): Promise<ReadResult>;
92
+ readFollowbackAudit(args: import("./types.js").FollowbackAuditOptions): Promise<import("./types.js").FollowbackAuditResult>;
92
93
  readPeople(args: import("./types.js").PeopleOptions): Promise<ReadResult>;
93
94
  readPerson({ target, mode, }: {
94
95
  target: import("./types.js").PersonTarget;
package/dist/api.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import { sameHandle, samePublishedText } from "./platform-rules.js";
2
+ import { FollowbackAudit } from "./followback-audit.js";
2
3
  import { IncomingReactions } from "./incoming-reactions.js";
3
4
  import { createGrowthHarness } from "./growth.js";
4
5
  import { readResult, decisions, diagnostic, stableReason } from "./results.js";
5
6
  export class BusinessHarness {
6
7
  #reactions;
8
+ #followbackAudit;
7
9
  #platform;
8
10
  #store;
9
11
  #core;
10
12
  #readCache;
11
13
  #clock;
12
14
  constructor(dependencies) {
15
+ this.readFollowbackAudit = this.readFollowbackAudit.bind(this);
13
16
  this.replyBatch = this.replyBatch.bind(this);
14
17
  this.likeIncomingBatch = this.likeIncomingBatch.bind(this);
15
18
  this.#reactions = new IncomingReactions(dependencies);
@@ -37,6 +40,7 @@ export class BusinessHarness {
37
40
  this.#platform = platform;
38
41
  this.#store = store;
39
42
  this.#core = createGrowthHarness(dependencies);
43
+ this.#followbackAudit = new FollowbackAudit((args) => this.#core.readPeople(args), (value) => store.recordProof(value), this.#clock, (ref) => store.evidenceExists(ref));
40
44
  this.#readCache = new Map();
41
45
  }
42
46
  async invoke(operation, args) {
@@ -598,6 +602,9 @@ export class BusinessHarness {
598
602
  readPost({ target, mode, includeContext, includeThread, resolveQuote, }) {
599
603
  return this.read("readPost", { ...target, includeContext, includeThread, resolveQuote }, mode);
600
604
  }
605
+ readFollowbackAudit(args) {
606
+ return this.#followbackAudit.run(args);
607
+ }
601
608
  readPeople(args) {
602
609
  return this.read("readPeople", args, args.mode);
603
610
  }
@@ -10,6 +10,11 @@ export class DOMCapabilities {
10
10
  }
11
11
  capabilities(operation) {
12
12
  const base = missingMethods(this.#tab?.playwright, ["evaluate"]);
13
+ if (operation === "resetPeople")
14
+ return [
15
+ ...base,
16
+ ...missingMethods(this.#tab, ["getAXState", "pressKey"]),
17
+ ];
13
18
  if (operation === "readPeople") {
14
19
  base.push(...missingMethods(this.#tab, ["goto", "url"]), ...missingMethods(this.#tab?.playwright, ["locator", "waitForURL"]));
15
20
  if (typeof this.#tab?.playwright?.locator === "function")
@@ -169,17 +169,50 @@ export class DOMEditing {
169
169
  }
170
170
  async handleNextPeople(_command) {
171
171
  const current = this.#session.current;
172
+ const reset = _command.reset === true;
172
173
  const handle = current?.result?.people?.at(-1)?.handle;
173
174
  if (current?.query?.view !== "people" ||
174
- current.result.needsStateCheck ||
175
+ (!reset && current.result.needsStateCheck) ||
175
176
  current.result.alerts?.length ||
176
- !/^[A-Za-z0-9_]{1,15}$/.test(handle ?? ""))
177
+ (!reset && !/^[A-Za-z0-9_]{1,15}$/.test(handle ?? "")))
177
178
  throw new Error("people-page-unconfirmed");
179
+ this.#session.current = null;
180
+ if (reset) {
181
+ await this.#tab.getAXState({ emit: false });
182
+ await this.#tab.pressKey(null, "Control+Home");
183
+ await this.#tab.getAXState({ emit: false });
184
+ return { completed: true };
185
+ }
186
+ if (typeof this.#tab.getAXState === "function" &&
187
+ typeof this.#tab.scroll === "function") {
188
+ const ax = await this.#tab.getAXState({
189
+ emit: false,
190
+ disableDiffing: true,
191
+ });
192
+ const anchorHandle = current.result.scrollAnchor?.handle;
193
+ const control = typeof ax === "string" &&
194
+ /^[A-Za-z0-9_]{1,15}$/.test(anchorHandle ?? "")
195
+ ? ax.match(new RegExp(`^.*?([0-9]+) button (?:正在关注|取消关注|回关|关注) @${anchorHandle}\\s*$`, "m"))
196
+ : null;
197
+ if (control) {
198
+ const pages = current.result.scrollAnchor?.pages;
199
+ await this.#tab.scroll(Number(control[1]), "down", Number.isInteger(pages) && pages >= 1 && pages <= 20 ? pages : 1);
200
+ await this.#tab.getAXState({ emit: false });
201
+ return { completed: true };
202
+ }
203
+ const timeline = typeof ax === "string"
204
+ ? ax.match(/^.*?([0-9]+) (?:container|scroll area) (?:时间线:|Timeline: ?)(?:正在关注|关注者|认证关注者|Following|Followers|Verified followers)\s*$/m)
205
+ : null;
206
+ if (timeline) {
207
+ await this.#tab.scroll(Number(timeline[1]), "down", 1);
208
+ await this.#tab.getAXState({ emit: false });
209
+ return { completed: true };
210
+ }
211
+ }
178
212
  const anchor = this.#tab.playwright
179
213
  .locator(`main [data-testid="primaryColumn"] a[href=${JSON.stringify("/" + handle)}]`)
180
214
  .filter({ visible: true, hasText: new RegExp(`^@${handle}$`, "i") });
181
215
  const unique = await this.#unique(anchor);
182
- this.#session.current = null;
183
216
  if (typeof this.#tab.pressKey === "function")
184
217
  await this.#tab.pressKey(null, "PageDown");
185
218
  else
@@ -14,6 +14,8 @@ export declare class DOMNavigation {
14
14
  private handleWaitForFolder;
15
15
  private handleWaitForPosts;
16
16
  private handleOpenObserved;
17
+ private waitFallbackRoute;
18
+ private handleWaitProfileContent;
17
19
  private handleWaitTargetContent;
18
20
  private handleRefreshPerson;
19
21
  private handleRefreshPosts;
@@ -145,8 +145,11 @@ export class DOMNavigation {
145
145
  // Explicit recovery uses forceNavigation; durable verification uses refreshPerson.
146
146
  if (!command.forceNavigation &&
147
147
  typeof this.#tab.url === "function" &&
148
- (await this.#tab.url()) === xURL(command.url).href)
148
+ (await this.#tab.url()) === xURL(command.url).href) {
149
+ if (command.view === "person")
150
+ await this.handleWaitProfileContent(this.#tab.playwright.locator('[data-testid="UserName"]'), command.url);
149
151
  return;
152
+ }
150
153
  const originalPage = typeof this.#tab.url === "function"
151
154
  ? await this.#tab.url()
152
155
  : this.#session.current?.result?.page;
@@ -203,15 +206,12 @@ export class DOMNavigation {
203
206
  check.alerts?.length)
204
207
  throw error;
205
208
  await this.#tab.goto(command.url);
206
- await this.#tab.playwright.waitForURL(command.url, {
207
- timeoutMs: command.view === "posts" ? 15000 : 5000,
208
- ...(command.view === "posts" ? { waitUntil: "commit" } : {}),
209
- });
209
+ await this.waitFallbackRoute(command);
210
210
  }
211
211
  }
212
212
  if (command.view === "person") {
213
213
  // Profile readiness belongs to profile content; self profiles have no Follow button.
214
- await this.handleWaitTargetContent(this.#tab.playwright.locator('[data-testid="UserName"]'), command.url);
214
+ await this.handleWaitProfileContent(this.#tab.playwright.locator('[data-testid="UserName"]'), command.url);
215
215
  }
216
216
  else if (command.view === "people") {
217
217
  await this.#tab.playwright
@@ -237,6 +237,41 @@ export class DOMNavigation {
237
237
  }
238
238
  return;
239
239
  }
240
+ async waitFallbackRoute(command) {
241
+ try {
242
+ await this.#tab.playwright.waitForURL(command.url, {
243
+ timeoutMs: command.view === "posts" ? 15000 : 5000,
244
+ ...(command.view === "posts" ? { waitUntil: "commit" } : {}),
245
+ });
246
+ }
247
+ catch (error) {
248
+ if (command.view !== "posts" ||
249
+ !/Timed out waiting for URL/.test(error.message))
250
+ throw error;
251
+ const check = await this.#observe({ view: "account", limit: 1 });
252
+ if (check.page !== command.url || check.alerts?.length)
253
+ throw error;
254
+ // Only a committed exact route is recovered; exact content readiness still follows.
255
+ }
256
+ }
257
+ async handleWaitProfileContent(target, url) {
258
+ try {
259
+ await this.handleWaitTargetContent(target, url);
260
+ return;
261
+ }
262
+ catch (error) {
263
+ if (!/timed out|timeout|deadline exceeded/i.test(error.message))
264
+ throw error;
265
+ const check = await this.#observe({ view: "account", limit: 1 });
266
+ if (check.page !== url || check.alerts?.length)
267
+ throw Error("target-readiness-route-or-alert", { cause: error });
268
+ // A loaded X error view does not become a profile by waiting longer.
269
+ // Reopen this exact observed route once; never repeat a Follow click.
270
+ this.#session.current = null;
271
+ await this.#tab.goto(url);
272
+ await this.handleWaitTargetContent(target, url);
273
+ }
274
+ }
240
275
  async handleWaitTargetContent(detail, url) {
241
276
  const deadline = Date.now() + 15000;
242
277
  for (let attempt = 0; attempt < 4; attempt++) {
@@ -257,7 +292,7 @@ export class DOMNavigation {
257
292
  throw error;
258
293
  const check = await this.#observe({ view: "account", limit: 1 });
259
294
  if (check.page !== url || check.alerts?.length)
260
- throw error;
295
+ throw Error("target-readiness-route-or-alert", { cause: error });
261
296
  }
262
297
  }
263
298
  }
@@ -284,7 +319,7 @@ export class DOMNavigation {
284
319
  idleTimedOut = true;
285
320
  }
286
321
  await this.#tab.reload();
287
- await this.handleWaitTargetContent(this.#tab.playwright.getByRole("button", {
322
+ await this.handleWaitProfileContent(this.#tab.playwright.getByRole("button", {
288
323
  name: new RegExp(`^(?:正在关注|取消关注|关注|回关) @${command.handle}$`, "i"),
289
324
  }), command.url);
290
325
  if (idleTimedOut)
@@ -133,6 +133,7 @@ export class NativeXBrowser {
133
133
  ? "not-following"
134
134
  : "unknown",
135
135
  blue: null,
136
+ blueEvidenceSupported: false,
136
137
  };
137
138
  }
138
139
  if (query.view === "composer") {
@@ -0,0 +1,2 @@
1
+ /** An explicit executor-local restriction never relaxes account-wide refusals. */
2
+ export declare function actionableForExecutor(state: any, kind: string, executor: any, evidenceExists: (path: any) => boolean): boolean;
@@ -0,0 +1,17 @@
1
+ /** An explicit executor-local restriction never relaxes account-wide refusals. */
2
+ export function actionableForExecutor(state, kind, executor, evidenceExists) {
3
+ const item = state.items.find((i) => i.kind === kind);
4
+ if (item?.actionable === true)
5
+ return true;
6
+ const restriction = item?.executionRestriction;
7
+ return Boolean(restriction?.scope === "executor" &&
8
+ restriction.executorId &&
9
+ restriction.reason === item.reason &&
10
+ item.blockEvidence?.includes(restriction.evidenceRef) &&
11
+ executor?.id &&
12
+ restriction.executorId !== executor.id &&
13
+ executor.scopeKinds?.includes(kind) &&
14
+ evidenceExists(executor.evidenceRef) &&
15
+ evidenceExists(restriction.evidenceRef) &&
16
+ state.executions?.[restriction.executorId]?.scopeKinds?.includes(kind));
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { RoundStore } from "./store.js";
2
+ import type { FollowPacing } from "./types.js";
3
+ /** Waits from the latest actual account attempt; chosen gaps survive batch resume. */
4
+ export declare class FollowBatchPacing {
5
+ private store;
6
+ private clock;
7
+ private wait;
8
+ constructor(store: RoundStore, clock: () => number, wait?: (ms: number) => Promise<void>);
9
+ static validate(pacing?: FollowPacing): void;
10
+ before(batchId: string, handle: string, pacing?: FollowPacing): Promise<void>;
11
+ }
@@ -0,0 +1,49 @@
1
+ import { randomInt } from "node:crypto";
2
+ /** Waits from the latest actual account attempt; chosen gaps survive batch resume. */
3
+ export class FollowBatchPacing {
4
+ store;
5
+ clock;
6
+ wait;
7
+ constructor(store, clock, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
8
+ this.store = store;
9
+ this.clock = clock;
10
+ this.wait = wait;
11
+ }
12
+ static validate(pacing) {
13
+ if (pacing &&
14
+ (!Number.isSafeInteger(pacing.minGapMs) ||
15
+ !Number.isSafeInteger(pacing.maxGapMs) ||
16
+ pacing.minGapMs < 5000 ||
17
+ pacing.maxGapMs > 30000 ||
18
+ pacing.maxGapMs < pacing.minGapMs))
19
+ throw new Error("follow-pacing-range-invalid");
20
+ }
21
+ async before(batchId, handle, pacing) {
22
+ if (!pacing)
23
+ return;
24
+ const snapshot = await this.store.transaction(({ state }) => {
25
+ const next = structuredClone(state), batch = next.followBatches.find((b) => b.id === batchId);
26
+ if (!batch)
27
+ throw new Error("follow-pacing-batch-missing");
28
+ if (batch.pacing &&
29
+ JSON.stringify(batch.pacing) !== JSON.stringify(pacing))
30
+ throw new Error("follow-pacing-resume-mismatch");
31
+ batch.pacing ??= pacing;
32
+ batch.gaps ??= {};
33
+ batch.gaps[handle.toLowerCase()] ??= {
34
+ gapMs: randomInt(pacing.minGapMs, pacing.maxGapMs + 1),
35
+ };
36
+ return next;
37
+ });
38
+ const attempts = (snapshot.state.attempts ?? []).filter((a) => a.kind === "follow" && Number.isFinite(Date.parse(a.at)));
39
+ const lastAt = Math.max(...attempts.map((a) => Date.parse(a.at)));
40
+ if (!Number.isFinite(lastAt))
41
+ return;
42
+ const batch = snapshot.state.followBatches.find((b) => b.id === batchId);
43
+ const notBefore = lastAt + batch.gaps[handle.toLowerCase()].gapMs;
44
+ const remaining = notBefore - this.clock();
45
+ if (remaining > 0)
46
+ await this.wait(remaining);
47
+ // The round owns fresh guards/claims/authorization after this wait, before intent.
48
+ }
49
+ }
@@ -0,0 +1,13 @@
1
+ import type { BoundaryRecord } from "./boundary.js";
2
+ import type { FollowbackAuditOptions, FollowbackAuditResult } from "./types.js";
3
+ /** Owns a read-only two-list observation, not an unfollow plan or account ledger. */
4
+ export declare class FollowbackAudit {
5
+ #private;
6
+ constructor(read: (args: BoundaryRecord) => Promise<BoundaryRecord>, proof: (value: unknown) => Promise<string>, clock: () => number, evidenceExists: (ref: string) => boolean);
7
+ private allowed;
8
+ private validate;
9
+ private job;
10
+ private window;
11
+ private result;
12
+ run(args: FollowbackAuditOptions): Promise<FollowbackAuditResult>;
13
+ }
@@ -0,0 +1,281 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ /** Owns a read-only two-list observation, not an unfollow plan or account ledger. */
4
+ export class FollowbackAudit {
5
+ #read;
6
+ #proof;
7
+ #clock;
8
+ #evidenceExists;
9
+ #jobs = new Map();
10
+ #running = false;
11
+ constructor(read, proof, clock, evidenceExists) {
12
+ this.#read = read;
13
+ this.#proof = proof;
14
+ this.#clock = clock;
15
+ this.#evidenceExists = evidenceExists;
16
+ }
17
+ allowed(args) {
18
+ const hour = new Date(this.#clock() + 8 * 3600000).getUTCHours();
19
+ return (hour >= 7 ||
20
+ (args.nightReadAuthorization?.userAuthorized === true &&
21
+ this.#evidenceExists(args.nightReadAuthorization.evidenceRef) &&
22
+ Number.isFinite(Date.parse(args.nightReadAuthorization.expiresAt)) &&
23
+ this.#clock() < Date.parse(args.nightReadAuthorization.expiresAt)));
24
+ }
25
+ validate(args) {
26
+ if (args.direction && !["both", "followback"].includes(args.direction))
27
+ throw new Error("followback-direction-invalid");
28
+ const a = new URL(args.following.url), b = new URL(args.followers.url);
29
+ const match = a.pathname.match(/^\/([A-Za-z0-9_]{1,15})\/following\/?$/);
30
+ if (!match ||
31
+ !["x.com", "www.x.com"].includes(a.hostname) ||
32
+ a.protocol !== "https:" ||
33
+ b.origin !== a.origin ||
34
+ b.pathname.toLowerCase().replace(/\/$/, "") !==
35
+ `/${match[1].toLowerCase()}/followers` ||
36
+ a.search ||
37
+ b.search ||
38
+ a.hash ||
39
+ b.hash ||
40
+ !args.following.evidenceRef ||
41
+ !args.followers.evidenceRef)
42
+ throw new Error("followback-own-unfiltered-list-targets-required");
43
+ for (const target of [args.following, args.followers])
44
+ if (target.expectedCount !== undefined &&
45
+ (!Number.isSafeInteger(target.expectedCount) ||
46
+ target.expectedCount < 0))
47
+ throw new Error("followback-exact-count-required");
48
+ if (!Number.isInteger(args.maxWindows ?? 100) ||
49
+ (args.maxWindows ?? 100) < 1 ||
50
+ (args.maxWindows ?? 100) > 1000)
51
+ throw new Error("followback-window-budget-invalid");
52
+ }
53
+ job(args) {
54
+ this.validate(args);
55
+ const targets = { following: args.following, followers: args.followers };
56
+ if (args.scanId) {
57
+ let prior = this.#jobs.get(args.scanId);
58
+ if (!prior &&
59
+ args.resumeEvidenceRef &&
60
+ this.#evidenceExists(args.resumeEvidenceRef)) {
61
+ const checkpoint = JSON.parse(readFileSync(args.resumeEvidenceRef, "utf8"));
62
+ if (checkpoint.schema !== "followback-audit/v1" ||
63
+ checkpoint.scanId !== args.scanId)
64
+ throw new Error("followback-checkpoint-invalid");
65
+ for (const kind of ["following", "followers"]) {
66
+ if (!checkpoint[kind]?.entries ||
67
+ Object.values(checkpoint[kind].entries).some((p) => !/^[A-Za-z0-9_]{1,15}$/.test(p.handle ?? "") ||
68
+ p.url?.toLowerCase() !==
69
+ `https://x.com/${p.handle.toLowerCase()}`))
70
+ throw new Error("followback-checkpoint-invalid");
71
+ }
72
+ prior = checkpoint;
73
+ }
74
+ if (!prior)
75
+ throw new Error("followback-resume-mismatch");
76
+ if (JSON.stringify(prior.targets) !== JSON.stringify(targets)) {
77
+ for (const kind of ["following", "followers"]) {
78
+ const old = prior.targets[kind], target = targets[kind];
79
+ if (old.url !== target.url ||
80
+ !this.#evidenceExists(target.evidenceRef))
81
+ throw new Error("followback-resume-mismatch");
82
+ if (old.expectedCount !== target.expectedCount &&
83
+ old.evidenceRef === target.evidenceRef)
84
+ throw new Error("followback-counter-revision-proof-required");
85
+ }
86
+ prior.counterRevisions ??= [];
87
+ prior.counterRevisions.push({
88
+ at: new Date(this.#clock()).toISOString(),
89
+ before: prior.targets,
90
+ after: targets,
91
+ });
92
+ prior.targets = targets;
93
+ for (const kind of ["following", "followers"])
94
+ prior[kind].complete =
95
+ targets[kind].expectedCount !== undefined &&
96
+ Object.keys(prior[kind].entries).length ===
97
+ targets[kind].expectedCount &&
98
+ !prior[kind].gaps.length;
99
+ }
100
+ this.#jobs.set(args.scanId, prior);
101
+ return prior;
102
+ }
103
+ const job = {
104
+ scanId: randomUUID(),
105
+ targets,
106
+ startedAt: new Date(this.#clock()).toISOString(),
107
+ following: {
108
+ entries: {},
109
+ complete: false,
110
+ advance: false,
111
+ stalled: 0,
112
+ gaps: [],
113
+ },
114
+ followers: {
115
+ entries: {},
116
+ complete: false,
117
+ advance: false,
118
+ stalled: 0,
119
+ gaps: [],
120
+ },
121
+ windows: [],
122
+ };
123
+ this.#jobs.set(job.scanId, job);
124
+ return job;
125
+ }
126
+ async window(job, kind) {
127
+ const state = job[kind], target = job.targets[kind];
128
+ const value = await this.#read({
129
+ target,
130
+ limit: 1000,
131
+ offset: state.started ? (state.offset ?? 0) : 0,
132
+ reset: !state.started,
133
+ bulk: true,
134
+ advance: state.started ? state.advance : false,
135
+ mode: "decision",
136
+ });
137
+ job.windows.push({
138
+ kind,
139
+ at: new Date(this.#clock()).toISOString(),
140
+ evidenceRef: value.evidenceRef,
141
+ status: value.status,
142
+ });
143
+ if (value.status !== "observed" || !value.complete || value.alerts?.length)
144
+ return value.reason ?? value.status;
145
+ const owner = new URL(target.url).pathname.split("/")[1];
146
+ if (value.accountHandle?.toLowerCase() !== owner.toLowerCase())
147
+ return "account-identity-unconfirmed";
148
+ state.started = true;
149
+ const before = Object.keys(state.entries).length;
150
+ for (const person of value.people ?? []) {
151
+ if (!person.handle ||
152
+ (person.relationship === "unknown" &&
153
+ (kind === "following" || !value.listBound))) {
154
+ state.gaps.push({
155
+ reason: "following-control-or-identity-unconfirmed",
156
+ evidenceRef: value.evidenceRef,
157
+ });
158
+ continue;
159
+ }
160
+ if (kind === "following" && person.relationship !== "following")
161
+ continue;
162
+ if (kind === "followers" &&
163
+ !value.listBound &&
164
+ person.relationship !== "follows-you" &&
165
+ !person.followsYou)
166
+ continue;
167
+ Object.defineProperty(state.entries, person.handle.toLowerCase(), {
168
+ enumerable: true,
169
+ configurable: true,
170
+ writable: true,
171
+ value: {
172
+ handle: person.handle,
173
+ url: person.url,
174
+ evidenceRef: value.evidenceRef,
175
+ relationship: person.relationship,
176
+ followsYou: person.followsYou,
177
+ listMembership: value.listBound ? kind : undefined,
178
+ },
179
+ });
180
+ }
181
+ const count = Object.keys(state.entries).length;
182
+ state.complete =
183
+ target.expectedCount !== undefined &&
184
+ count === target.expectedCount &&
185
+ !state.gaps.length;
186
+ state.stalled =
187
+ count > before ? 0 : value.moreInDOM ? state.stalled : state.stalled + 1;
188
+ state.offset = value.moreInDOM
189
+ ? (value.offset ?? state.offset ?? 0) + (value.people?.length ?? 0)
190
+ : 0;
191
+ state.advance = !value.moreInDOM;
192
+ if (state.complete)
193
+ return null;
194
+ if (target.expectedCount !== undefined && count > target.expectedCount)
195
+ return "observed-count-changed";
196
+ return state.stalled >= 2 && value.atDocumentEnd !== false
197
+ ? "list-progress-stalled"
198
+ : null;
199
+ }
200
+ async result(job, reason) {
201
+ const following = Object.values(job.following.entries);
202
+ const mutual = following.filter((p) => Object.hasOwn(job.followers.entries, p.handle.toLowerCase()));
203
+ const absent = following.filter((p) => !Object.hasOwn(job.followers.entries, p.handle.toLowerCase()));
204
+ const complete = job.direction === "followback"
205
+ ? job.followers.complete
206
+ : job.following.complete && job.followers.complete;
207
+ const nonFollowers = job.followers.complete ? absent : [];
208
+ const provisional = job.followers.complete ? [] : absent;
209
+ const notFollowedBack = Object.values(job.followers.entries).filter((p) => p.relationship === "follows-you" ||
210
+ (p.listMembership === "followers" &&
211
+ p.relationship === "not-following") ||
212
+ (job.following.complete &&
213
+ !Object.hasOwn(job.following.entries, p.handle.toLowerCase())));
214
+ const observedAt = job.windows.at(-1)?.at ?? job.startedAt;
215
+ const evidenceRef = await this.#proof({
216
+ schema: "followback-audit/v1",
217
+ ...job,
218
+ observedAt,
219
+ complete,
220
+ reason,
221
+ mutual,
222
+ nonFollowers,
223
+ provisional,
224
+ notFollowedBack,
225
+ });
226
+ return {
227
+ direction: job.direction,
228
+ status: reason === "night-window"
229
+ ? "waiting"
230
+ : complete
231
+ ? "observed"
232
+ : "needsDecision",
233
+ scanId: job.scanId,
234
+ complete,
235
+ followingComplete: job.following.complete,
236
+ followersComplete: job.followers.complete,
237
+ followingCount: following.length,
238
+ followersCount: Object.keys(job.followers.entries).length,
239
+ mutualCount: mutual.length,
240
+ notFollowedBackCount: notFollowedBack.length,
241
+ nonFollowerCount: nonFollowers.length,
242
+ provisionalCount: provisional.length,
243
+ evidenceRef,
244
+ startedAt: job.startedAt,
245
+ observedAt,
246
+ ...(reason ? { reason } : {}),
247
+ };
248
+ }
249
+ async run(args) {
250
+ if (this.#running)
251
+ throw new Error("followback-audit-running");
252
+ const job = this.job(args);
253
+ job.direction = args.direction ?? job.direction ?? "both";
254
+ this.#running = true;
255
+ let reason = null;
256
+ try {
257
+ for (const kind of job.direction === "followback"
258
+ ? ["followers"]
259
+ : ["following", "followers"]) {
260
+ job[kind].stalled = 0;
261
+ for (let n = 0; n < (args.maxWindows ?? 100) && !job[kind].complete; n++) {
262
+ if (!this.allowed(args)) {
263
+ reason = "night-window";
264
+ break;
265
+ }
266
+ reason = await this.window(job, kind);
267
+ if (reason)
268
+ break;
269
+ }
270
+ if (reason || !job[kind].complete) {
271
+ reason ??= "window-budget-exhausted";
272
+ break;
273
+ }
274
+ }
275
+ return await this.result(job, reason);
276
+ }
277
+ finally {
278
+ this.#running = false;
279
+ }
280
+ }
281
+ }
@@ -3,6 +3,6 @@ import type { GrowthDependencies } from "./growth-dependencies.js";
3
3
  export declare class FollowBatch {
4
4
  #private;
5
5
  constructor(dependencies: Pick<GrowthDependencies, "prepareActionsForCall" | "store" | "scope" | "stamp" | "clock" | "evidenceExists" | "executor" | "parallelRound" | "round">);
6
- followBatch({ taskId, handles, preparedActions }?: any): Promise<BoundaryRecord>;
6
+ followBatch({ taskId, handles, preparedActions, pacing, }?: any): Promise<BoundaryRecord>;
7
7
  }
8
8
  export declare function createFollowBatch(...args: ConstructorParameters<typeof FollowBatch>): FollowBatch;
@@ -1,6 +1,8 @@
1
+ import { FollowBatchPacing } from "./follow-batch-pacing.js";
1
2
  import { followWindows, same } from "./growth-constants.js";
2
3
  import { followObservationAuthorized } from "./follow-observation-policy.js";
3
4
  export class FollowBatch {
5
+ #pacing;
4
6
  #prepareActionsForCall;
5
7
  #store;
6
8
  #scope;
@@ -11,6 +13,7 @@ export class FollowBatch {
11
13
  #parallelRound;
12
14
  #round;
13
15
  constructor(dependencies) {
16
+ this.#pacing = new FollowBatchPacing(dependencies.store, dependencies.clock);
14
17
  this.followBatch = this.followBatch.bind(this);
15
18
  const { prepareActionsForCall, store, scope, stamp, clock, evidenceExists, executor, parallelRound, round, } = dependencies;
16
19
  this.#prepareActionsForCall = prepareActionsForCall;
@@ -23,8 +26,9 @@ export class FollowBatch {
23
26
  this.#parallelRound = parallelRound;
24
27
  this.#round = round;
25
28
  }
26
- async followBatch({ taskId, handles, preparedActions } = {}) {
29
+ async followBatch({ taskId, handles, preparedActions, pacing, } = {}) {
27
30
  const executor = this.#executor;
31
+ FollowBatchPacing.validate(pacing);
28
32
  if (!Array.isArray(handles) ||
29
33
  handles.length < 5 ||
30
34
  handles.length > 10 ||
@@ -47,6 +51,11 @@ export class FollowBatch {
47
51
  const previous = next.followBatches.findLast((b) => b.status !== "SETTLED" &&
48
52
  b.handles.length === handles.length &&
49
53
  b.handles.every((h, i) => same(h, handles[i])));
54
+ pacing ??=
55
+ previous?.pacing ??
56
+ next.executions?.[executor?.id]?.followCadence?.intentPacing ??
57
+ next.followCadence?.intentPacing;
58
+ FollowBatchPacing.validate(pacing);
50
59
  resumedBatch = Boolean(previous);
51
60
  batchId = previous?.id ?? `${taskId}-FOLLOW-${this.#stamp()}`;
52
61
  if (previous)
@@ -133,6 +142,7 @@ export class FollowBatch {
133
142
  same(a.handle, handle) &&
134
143
  a.status === "prepared"))
135
144
  continue;
145
+ await this.#pacing.before(batchId, handle, pacing);
136
146
  result = await (executor ? this.#parallelRound : this.#round)({
137
147
  taskId,
138
148
  requestedKind: "follow",
@@ -8,8 +8,9 @@ import type { GrowthDependencies } from "./growth-dependencies.js";
8
8
  export declare class GrowthExecution {
9
9
  #private;
10
10
  execute: (snapshot: any, action: any) => Promise<ExecutionStep>;
11
- constructor(dependencies: Pick<GrowthDependencies, "capability" | "platform" | "settleProof" | "clock" | "save" | "guardInput" | "waitBudget" | "authorizedWindow" | "stamp" | "store" | "executor" | "session">);
11
+ constructor(dependencies: Pick<GrowthDependencies, "capability" | "platform" | "settleProof" | "clock" | "save" | "guardInput" | "waitBudget" | "authorizedWindow" | "stamp" | "store" | "evidenceExists" | "executor" | "session">);
12
12
  private checkExecutionIdentity;
13
+ private actionIsActionable;
13
14
  private admitAction;
14
15
  private handleExecute;
15
16
  }
@@ -1,3 +1,4 @@
1
+ import { actionableForExecutor } from "./executor-actionability.js";
1
2
  import { operations, same } from "./growth-constants.js";
2
3
  export class GrowthExecution {
3
4
  #capability;
@@ -10,11 +11,12 @@ export class GrowthExecution {
10
11
  #authorizedWindow;
11
12
  #stamp;
12
13
  #store;
14
+ #evidenceExists;
13
15
  #executor;
14
16
  #session;
15
17
  execute;
16
18
  constructor(dependencies) {
17
- const { capability, platform, settleProof, clock, save, guardInput, waitBudget, authorizedWindow, stamp, store, executor, session, } = dependencies;
19
+ const { capability, platform, settleProof, clock, save, guardInput, waitBudget, authorizedWindow, stamp, store, executor, evidenceExists, session, } = dependencies;
18
20
  this.#capability = capability;
19
21
  this.#platform = platform;
20
22
  this.#settleProof = settleProof;
@@ -26,6 +28,7 @@ export class GrowthExecution {
26
28
  this.#stamp = stamp;
27
29
  this.#store = store;
28
30
  this.#executor = executor;
31
+ this.#evidenceExists = evidenceExists;
29
32
  this.#session = session;
30
33
  this.execute = this.handleExecute.bind(this);
31
34
  }
@@ -59,10 +62,13 @@ export class GrowthExecution {
59
62
  };
60
63
  return null;
61
64
  }
65
+ actionIsActionable(state, kind) {
66
+ return actionableForExecutor(state, kind, this.#executor, this.#evidenceExists);
67
+ }
62
68
  async admitAction(snapshot, action) {
63
69
  const state = snapshot.state;
64
70
  const item = state.items.find((i) => i.kind === action.kind);
65
- if (!item.actionable)
71
+ if (!this.actionIsActionable(state, action.kind))
66
72
  return {
67
73
  snapshot,
68
74
  need: {
@@ -1,3 +1,4 @@
1
+ import { actionableForExecutor } from "./executor-actionability.js";
1
2
  import { checkRound } from "./round-state.js";
2
3
  import { kinds } from "./growth-constants.js";
3
4
  export class GrowthState {
@@ -80,7 +81,8 @@ export class GrowthState {
80
81
  const item = next.items.find((i) => i.kind === entry.kind);
81
82
  const reservation = before.status === "prepared" && after.status === "intent";
82
83
  if (reservation) {
83
- if (!item.actionable || item.verified + item.unknown >= item.planned)
84
+ if (!actionableForExecutor(next, entry.kind, executor, this.#evidenceExists) ||
85
+ item.verified + item.unknown >= item.planned)
84
86
  throw new Error("shared-target-capacity-exhausted");
85
87
  if (entry.kind === "follow" && this.#waitBudget(next))
86
88
  throw new Error("shared-follow-budget-exhausted");
package/dist/growth.d.ts CHANGED
@@ -4,7 +4,7 @@ export declare class GrowthHarness {
4
4
  verifyPublishedPost: ({ taskId, draftPath, publicURL, evidenceRef, sourceLink, sourceLinks, }: any) => Promise<any>;
5
5
  verifySubmission: ({ taskId, actionId }: any) => Promise<any>;
6
6
  verifyFollow: (target: any) => Promise<any>;
7
- followBatch: ({ taskId, handles, preparedActions, }?: any) => Promise<import("./boundary.js").BoundaryRecord>;
7
+ followBatch: ({ taskId, handles, preparedActions, pacing, }?: any) => Promise<import("./boundary.js").BoundaryRecord>;
8
8
  preflight: ({ operation, input }: any) => any;
9
9
  readCreator: (args: any) => Promise<any>;
10
10
  readPeople: (args: any) => Promise<any>;
package/dist/growth.js CHANGED
@@ -108,6 +108,7 @@ export class GrowthHarness {
108
108
  this.#settleProof = settleProof;
109
109
  this.#releaseSession = releaseSession;
110
110
  const { execute } = createGrowthExecution({
111
+ evidenceExists: this.#evidenceExists,
111
112
  capability: this.#capability,
112
113
  platform: this.#platform,
113
114
  settleProof: this.#settleProof,
@@ -13,6 +13,8 @@ export class PlatformCapabilities {
13
13
  if (operation === "readCreator" &&
14
14
  typeof this.#browser.collectCreator !== "function")
15
15
  absent.push("browser.collectCreator");
16
+ if (operation === "readPeople" && input.reset)
17
+ absent.push(...(this.#browser.capabilities?.("resetPeople") ?? ["resetPeople"]));
16
18
  if (input.quoteTarget)
17
19
  absent.push(...(this.#browser.capabilities?.("openQuoteComposer") ?? [
18
20
  "openQuoteComposer",
@@ -39,7 +39,13 @@ export class PlatformObservation {
39
39
  catch {
40
40
  /* Blank/non-X pages require observed navigation. */
41
41
  }
42
- if (!atTarget) {
42
+ const incompletePerson = atTarget &&
43
+ query.view === "person" &&
44
+ !value.alerts?.length &&
45
+ (value.needsStateCheck ||
46
+ (value.blue === null && value.blueEvidenceSupported !== false) ||
47
+ value.relationship === "unknown");
48
+ if (!atTarget || incompletePerson) {
43
49
  await this.#browser.act({
44
50
  operation: "openObserved",
45
51
  url: url.href,
@@ -90,7 +90,7 @@ export class PlatformTargets {
90
90
  pageComplete: false,
91
91
  };
92
92
  }
93
- async handleReadPeople({ target, offset = 0, limit = 5, advance = false, }) {
93
+ async handleReadPeople({ target, offset = 0, limit = 5, advance = false, bulk = false, reset = false, }) {
94
94
  const url = xURL(target?.url);
95
95
  if (!/^\/[A-Za-z0-9_]{1,15}\/(?:following|followers|verified_followers)\/?$/.test(url.pathname) ||
96
96
  url.search ||
@@ -100,17 +100,35 @@ export class PlatformTargets {
100
100
  offset < 0 ||
101
101
  !Number.isInteger(limit) ||
102
102
  limit < 1 ||
103
- limit > 5 ||
103
+ limit > (bulk === true ? 1000 : 5) ||
104
104
  typeof advance !== "boolean")
105
105
  throw new Error("people-window-invalid");
106
- let value = await this.#open(target, { view: "people", offset, limit });
107
- if (advance) {
106
+ let value = await this.#open(target, {
107
+ view: "people",
108
+ offset,
109
+ limit,
110
+ bulk,
111
+ });
112
+ if (reset) {
113
+ if (value.page !== url.href || value.alerts?.length)
114
+ return { ...value, complete: false };
115
+ await this.#browser.act({ operation: "nextPeople", reset: true });
116
+ value = await this.#ready({ view: "people", offset: 0, limit, bulk });
117
+ if (value.atDocumentStart !== true)
118
+ return {
119
+ ...value,
120
+ complete: false,
121
+ needsStateCheck: true,
122
+ missing: [...(value.missing ?? []), "people-list-start-unconfirmed"],
123
+ };
124
+ }
125
+ if (advance && !reset) {
108
126
  if (value.needsStateCheck ||
109
127
  value.alerts?.length ||
110
128
  !value.people?.length)
111
129
  return { ...value, complete: false };
112
130
  await this.#browser.act({ operation: "nextPeople" });
113
- value = await this.#ready({ view: "people", offset: 0, limit });
131
+ value = await this.#ready({ view: "people", offset: 0, limit, bulk });
114
132
  }
115
133
  if (!value.page || xURL(value.page).href !== url.href)
116
134
  return {
@@ -1,3 +1,3 @@
1
1
  export declare function readProfile({ handle }?: any): any;
2
- export declare function readPeople({ offset, limit }?: any): any;
2
+ export declare function readPeople({ offset, limit, bulk, }?: any): any;
3
3
  export declare function screenCandidates(profiles: any, { minRatio }?: any): any;
@@ -78,15 +78,29 @@ export function readProfile({ handle } = {}) {
78
78
  };
79
79
  }
80
80
  // Bounded visible person cells for a currently loaded follower/following list.
81
- export function readPeople({ offset = 0, limit = 5 } = {}) {
81
+ export function readPeople({ offset = 0, limit = 5, bulk = false, } = {}) {
82
82
  if (!Number.isInteger(offset) ||
83
83
  offset < 0 ||
84
84
  !Number.isInteger(limit) ||
85
85
  limit < 1 ||
86
- limit > 5)
86
+ limit > (bulk === true ? 1000 : 5))
87
87
  throw new Error("人物窗口须为非负offset及1–5 limit");
88
88
  const visible = (el) => el.getClientRects().length > 0;
89
- const cells = Array.from(document.querySelectorAll('main [data-testid="primaryColumn"] [data-testid="UserCell"]')).filter(visible);
89
+ const suffix = new URL(location.href).pathname
90
+ .split("/")
91
+ .filter(Boolean)
92
+ .at(-1);
93
+ const labelsByList = {
94
+ following: ["时间线:正在关注", "Timeline: Following"],
95
+ followers: ["时间线:关注者", "Timeline: Followers"],
96
+ verified_followers: ["时间线:认证关注者", "Timeline: Verified followers"],
97
+ };
98
+ const scopes = Array.from(document.querySelectorAll('main [data-testid="primaryColumn"] [aria-label]')).filter((e) => visible(e) &&
99
+ (labelsByList[suffix ?? ""] ?? []).includes(e.getAttribute("aria-label")));
100
+ const scope = scopes.length === 1 ? scopes[0] : null;
101
+ const cells = Array.from(scope
102
+ ? scope.querySelectorAll('[data-testid="UserCell"]')
103
+ : document.querySelectorAll('main [data-testid="primaryColumn"] [data-testid="UserCell"]')).filter(visible);
90
104
  const people = cells.slice(offset, offset + limit).map((cell) => {
91
105
  const links = Array.from(cell.querySelectorAll("a[href]"))
92
106
  .filter(visible)
@@ -102,7 +116,17 @@ export function readPeople({ offset = 0, limit = 5 } = {}) {
102
116
  .map((a) => a.href?.match(/^\/([A-Za-z0-9_]{1,15})$/)?.[1])
103
117
  .filter(Boolean)),
104
118
  ];
105
- const handle = handles.length === 1 ? String(handles[0]) : null;
119
+ const controlHandles = [
120
+ ...new Set(buttons
121
+ .map((b) => b.label.match(/^(?:正在关注|取消关注|回关|关注) @([A-Za-z0-9_]{1,15})$/)?.[1])
122
+ .filter(Boolean)),
123
+ ];
124
+ const bound = controlHandles.length === 1 && handles.includes(controlHandles[0]);
125
+ const handle = bound
126
+ ? String(controlHandles[0])
127
+ : handles.length === 1 && !controlHandles.length
128
+ ? String(handles[0])
129
+ : null;
106
130
  const badges = Array.from(cell.querySelectorAll('svg[data-testid="icon-verified"]')).filter(visible);
107
131
  const blue = badges.length === 1
108
132
  ? [
@@ -147,14 +171,53 @@ export function readPeople({ offset = 0, limit = 5 } = {}) {
147
171
  url: handle ? "https://x.com/" + handle : null,
148
172
  blue,
149
173
  relationship,
174
+ followsYou: Array.from(cell.querySelectorAll('[data-testid="userFollowIndicator"]'))
175
+ .filter(visible)
176
+ .some((e) => /^(?:关注了你|Follows you)$/i.test(e.innerText?.trim() ?? e.textContent?.trim() ?? "")),
150
177
  biography,
151
178
  };
152
179
  });
180
+ const inViewport = cells.filter((cell) => {
181
+ if (typeof cell.getBoundingClientRect !== "function")
182
+ return false;
183
+ const rect = cell.getBoundingClientRect();
184
+ return rect.top > 100 && rect.bottom < window.innerHeight - 60;
185
+ });
186
+ const anchorCell = inViewport.at(-1);
187
+ const anchorLabels = anchorCell
188
+ ? Array.from(anchorCell.querySelectorAll("button"))
189
+ .filter(visible)
190
+ .map((b) => b.getAttribute("aria-label") || b.innerText)
191
+ : [];
192
+ const anchorButton = anchorCell
193
+ ? Array.from(anchorCell.querySelectorAll("button")).find((b) => /^(?:正在关注|取消关注|回关|关注) @/.test(b.getAttribute("aria-label") || b.innerText))
194
+ : null;
195
+ const anchorHeight = anchorButton?.getBoundingClientRect?.().height;
196
+ const anchorPages = anchorHeight > 0
197
+ ? Math.max(1, Math.min(20, Math.floor((window.innerHeight - 200) / anchorHeight)))
198
+ : 1;
199
+ const anchorHandles = [
200
+ ...new Set(anchorLabels
201
+ .map((label) => label.match(/^(?:正在关注|取消关注|回关|关注) @([A-Za-z0-9_]{1,15})$/)?.[1])
202
+ .filter(Boolean)),
203
+ ];
153
204
  return {
154
205
  page: location.href,
155
206
  people,
207
+ listBound: Boolean(scope),
208
+ scrollAnchor: anchorHandles.length === 1
209
+ ? { handle: anchorHandles[0], pages: anchorPages }
210
+ : null,
156
211
  offset,
157
212
  renderedPeople: cells.length,
213
+ atDocumentStart: typeof document.scrollingElement?.scrollTop === "number"
214
+ ? document.scrollingElement.scrollTop <= 3
215
+ : null,
216
+ atDocumentEnd: typeof document.scrollingElement?.scrollTop === "number"
217
+ ? document.scrollingElement.scrollTop +
218
+ document.scrollingElement.clientHeight >=
219
+ document.scrollingElement.scrollHeight - 3
220
+ : null,
158
221
  moreInDOM: offset + limit < cells.length,
159
222
  pageComplete: false,
160
223
  needsStateCheck: !people.length,
package/dist/types.d.ts CHANGED
@@ -132,6 +132,46 @@ export interface ReadResult {
132
132
  unchangedIds?: string[];
133
133
  creator?: CreatorEvidence;
134
134
  }
135
+ export interface FollowbackAuditOptions {
136
+ direction?: "both" | "followback";
137
+ /** Explicit user permission for this read task during 00–07; never authorizes writes. */
138
+ nightReadAuthorization?: {
139
+ userAuthorized: boolean;
140
+ evidenceRef: EvidenceRef;
141
+ expiresAt: string;
142
+ };
143
+ following: {
144
+ url: string;
145
+ evidenceRef: EvidenceRef;
146
+ expectedCount?: number;
147
+ };
148
+ followers: {
149
+ url: string;
150
+ evidenceRef: EvidenceRef;
151
+ expectedCount?: number;
152
+ };
153
+ maxWindows?: number;
154
+ scanId?: string;
155
+ resumeEvidenceRef?: EvidenceRef;
156
+ }
157
+ export interface FollowbackAuditResult {
158
+ direction: "both" | "followback";
159
+ status: "observed" | "needsDecision" | "waiting";
160
+ scanId: string;
161
+ complete: boolean;
162
+ followingComplete: boolean;
163
+ followersComplete: boolean;
164
+ followingCount: number;
165
+ followersCount: number;
166
+ mutualCount: number;
167
+ notFollowedBackCount: number;
168
+ nonFollowerCount: number;
169
+ provisionalCount: number;
170
+ evidenceRef: EvidenceRef;
171
+ startedAt: string;
172
+ observedAt: string;
173
+ reason?: string;
174
+ }
135
175
  export interface PeopleOptions {
136
176
  target: {
137
177
  url: string;
@@ -407,7 +447,12 @@ export interface ReplyOptions {
407
447
  kind: "reply";
408
448
  }>;
409
449
  }
450
+ export interface FollowPacing {
451
+ minGapMs: number;
452
+ maxGapMs: number;
453
+ }
410
454
  export interface FollowOptions {
455
+ pacing?: FollowPacing;
411
456
  taskId: string;
412
457
  handles: string[];
413
458
  preparedActions?: Extract<PreparedAction, {
@@ -515,6 +560,7 @@ export interface XGrowthHarness {
515
560
  likeIncomingBatch(args: IncomingLikeOptions): Promise<IncomingLikeResult>;
516
561
  readCreator(args: CreatorOptions): Promise<ReadResult>;
517
562
  readPeople(args: PeopleOptions): Promise<ReadResult>;
563
+ readFollowbackAudit(args: FollowbackAuditOptions): Promise<FollowbackAuditResult>;
518
564
  readBookmarks(args: BookmarkOptions): Promise<ReadResult>;
519
565
  searchSources(args: SearchOptions): Promise<ReadResult>;
520
566
  readIncomingReplies(args?: IncomingReplyOptions): Promise<ReadResult>;
@@ -205,15 +205,29 @@ export const readXObservation = (query) => {
205
205
  observedAt: new Date().toISOString(),
206
206
  };
207
207
  };
208
- const readPeople = function readPeople({ offset = 0, limit = 5 } = {}) {
208
+ const readPeople = function readPeople({ offset = 0, limit = 5, bulk = false, } = {}) {
209
209
  if (!Number.isInteger(offset) ||
210
210
  offset < 0 ||
211
211
  !Number.isInteger(limit) ||
212
212
  limit < 1 ||
213
- limit > 5)
213
+ limit > (bulk === true ? 1000 : 5))
214
214
  throw new Error("人物窗口须为非负offset及1–5 limit");
215
215
  const visible = (el) => el.getClientRects().length > 0;
216
- const cells = Array.from(document.querySelectorAll('main [data-testid="primaryColumn"] [data-testid="UserCell"]')).filter(visible);
216
+ const suffix = new URL(location.href).pathname
217
+ .split("/")
218
+ .filter(Boolean)
219
+ .at(-1);
220
+ const labelsByList = {
221
+ following: ["时间线:正在关注", "Timeline: Following"],
222
+ followers: ["时间线:关注者", "Timeline: Followers"],
223
+ verified_followers: ["时间线:认证关注者", "Timeline: Verified followers"],
224
+ };
225
+ const scopes = Array.from(document.querySelectorAll('main [data-testid="primaryColumn"] [aria-label]')).filter((e) => visible(e) &&
226
+ (labelsByList[suffix ?? ""] ?? []).includes(e.getAttribute("aria-label")));
227
+ const scope = scopes.length === 1 ? scopes[0] : null;
228
+ const cells = Array.from(scope
229
+ ? scope.querySelectorAll('[data-testid="UserCell"]')
230
+ : document.querySelectorAll('main [data-testid="primaryColumn"] [data-testid="UserCell"]')).filter(visible);
217
231
  const people = cells.slice(offset, offset + limit).map((cell) => {
218
232
  const links = Array.from(cell.querySelectorAll("a[href]"))
219
233
  .filter(visible)
@@ -229,7 +243,17 @@ export const readXObservation = (query) => {
229
243
  .map((a) => a.href?.match(/^\/([A-Za-z0-9_]{1,15})$/)?.[1])
230
244
  .filter(Boolean)),
231
245
  ];
232
- const handle = handles.length === 1 ? String(handles[0]) : null;
246
+ const controlHandles = [
247
+ ...new Set(buttons
248
+ .map((b) => b.label.match(/^(?:正在关注|取消关注|回关|关注) @([A-Za-z0-9_]{1,15})$/)?.[1])
249
+ .filter(Boolean)),
250
+ ];
251
+ const bound = controlHandles.length === 1 && handles.includes(controlHandles[0]);
252
+ const handle = bound
253
+ ? String(controlHandles[0])
254
+ : handles.length === 1 && !controlHandles.length
255
+ ? String(handles[0])
256
+ : null;
233
257
  const badges = Array.from(cell.querySelectorAll('svg[data-testid="icon-verified"]')).filter(visible);
234
258
  const blue = badges.length === 1
235
259
  ? [
@@ -274,14 +298,53 @@ export const readXObservation = (query) => {
274
298
  url: handle ? "https://x.com/" + handle : null,
275
299
  blue,
276
300
  relationship,
301
+ followsYou: Array.from(cell.querySelectorAll('[data-testid="userFollowIndicator"]'))
302
+ .filter(visible)
303
+ .some((e) => /^(?:关注了你|Follows you)$/i.test(e.innerText?.trim() ?? e.textContent?.trim() ?? "")),
277
304
  biography,
278
305
  };
279
306
  });
307
+ const inViewport = cells.filter((cell) => {
308
+ if (typeof cell.getBoundingClientRect !== "function")
309
+ return false;
310
+ const rect = cell.getBoundingClientRect();
311
+ return rect.top > 100 && rect.bottom < window.innerHeight - 60;
312
+ });
313
+ const anchorCell = inViewport.at(-1);
314
+ const anchorLabels = anchorCell
315
+ ? Array.from(anchorCell.querySelectorAll("button"))
316
+ .filter(visible)
317
+ .map((b) => b.getAttribute("aria-label") || b.innerText)
318
+ : [];
319
+ const anchorButton = anchorCell
320
+ ? Array.from(anchorCell.querySelectorAll("button")).find((b) => /^(?:正在关注|取消关注|回关|关注) @/.test(b.getAttribute("aria-label") || b.innerText))
321
+ : null;
322
+ const anchorHeight = anchorButton?.getBoundingClientRect?.().height;
323
+ const anchorPages = anchorHeight > 0
324
+ ? Math.max(1, Math.min(20, Math.floor((window.innerHeight - 200) / anchorHeight)))
325
+ : 1;
326
+ const anchorHandles = [
327
+ ...new Set(anchorLabels
328
+ .map((label) => label.match(/^(?:正在关注|取消关注|回关|关注) @([A-Za-z0-9_]{1,15})$/)?.[1])
329
+ .filter(Boolean)),
330
+ ];
280
331
  return {
281
332
  page: location.href,
282
333
  people,
334
+ listBound: Boolean(scope),
335
+ scrollAnchor: anchorHandles.length === 1
336
+ ? { handle: anchorHandles[0], pages: anchorPages }
337
+ : null,
283
338
  offset,
284
339
  renderedPeople: cells.length,
340
+ atDocumentStart: typeof document.scrollingElement?.scrollTop === "number"
341
+ ? document.scrollingElement.scrollTop <= 3
342
+ : null,
343
+ atDocumentEnd: typeof document.scrollingElement?.scrollTop === "number"
344
+ ? document.scrollingElement.scrollTop +
345
+ document.scrollingElement.clientHeight >=
346
+ document.scrollingElement.scrollHeight - 3
347
+ : null,
285
348
  moreInDOM: offset + limit < cells.length,
286
349
  pageComplete: false,
287
350
  needsStateCheck: !people.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/x-harness",
3
- "version": "0.2.63",
3
+ "version": "0.2.65",
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": "6e70dc4b7351bc7ec59936ed517465133001099b"
45
+ "gitHead": "ece6b7ad7f4355d105ed6ec939bbcd8c136b4ce1"
46
46
  }
package/skill/SKILL.md CHANGED
@@ -6,7 +6,7 @@ description: Use X through task APIs with compact receipts, complete target evid
6
6
 
7
7
  # X Harness
8
8
 
9
- Use the root business API. Load only the current task reference; reuse unchanged instructions and a valid binding. Normal use needs no implementation source or type-file reading.
9
+ Use root API/current task reference. Normal use needs no implementation source or type-file reading.
10
10
 
11
11
  ## Bind once
12
12
 
@@ -25,6 +25,7 @@ var x = createXGrowthHarness({ tab, ledgerPath, proofDirectory });
25
25
  | Current need | Read only |
26
26
  | --- | --- |
27
27
  | Creator/bookmarks/search/post/person/feedback/account | [reads](references/reads.md) |
28
+ | Followback list audit | [audit](references/followback-audit.md) |
28
29
  | Publish/reply/follow or a round | [writes](references/writes.md), preamble + common review + matching operation heading |
29
30
  | Unknown submission, missing capability or connection failure | [recovery](references/recovery.md) |
30
31
 
@@ -0,0 +1,17 @@
1
+ # Followback list audit
2
+
3
+ `x.readFollowbackAudit({following,followers,maxWindows?,scanId?,nightReadAuthorization?})` reads lists only. `direction:"followback"` scans followers alone and uses exact follows-you controls; complete then means the observed follower count matched, not both lists. Default "both" retains two-list coverage. Each target is `{url,evidenceRef,expectedCount?}` from the same observed account profile: following and **unfiltered** followers URLs, exact counters only. `verified_followers` is a subset and is rejected. A missing/rounded counter cannot establish full coverage. No profile-per-person opening, follow or unfollow is performed.
4
+
5
+ One call scans bulk visible rows (up to 1,000 per observation; remaining DOM offsets are consumed before scrolling) and serially advances the original list; no model-driven five-person loop. maxWindows defaults to 100 per list, range 1–1000. Resume via scanId with identical targets; a new instance also requires resumeEvidenceRef pointing to the original immutable checkpoint. Keep the list tab at its original position; a changed/new tab must restart conservatively. Full immutable observations and checkpoint go to the existing proof directory; the receipt gives counts, completeness and evidenceRef. Read that local proof for lists or export it without dumping all rows into model context.
6
+
7
+ Proof arrays: `mutual`, `nonFollowers` (we follow, they do not), `notFollowedBack` (they follow, we do not), `provisional` (following entries absent from incomplete followers). `notFollowedBack` uses exact follows-you controls, or count-matched following coverage; nonFollowers requires count-matched unfiltered followers. Never treat provisional as confirmed negative. Default direction requires both count-matched lists; followback direction requires the unfiltered follower count to match. Scoped follower-list membership with an exact not-following control also establishes a positive candidate. Counts can change while scanning; the result is an observation interval, not an atomic or permanent relationship guarantee. Check exact relationship again before any write.
8
+
9
+ Alerts, wrong routes, unreadable rows, stall and window exhaustion retain incomplete proof; resume without assuming a virtualized tail means all. Missing own following controls remain a coverage gap. Shanghai 00–07 stops each new window. Only explicit human permission for this task/time can set `nightReadAuthorization:{userAuthorized:true,evidenceRef,expiresAt}` with an existing local user-authorization proof; this permits this audit's reads only, never writes or other tasks. It is not an SDK-wide technical lock.
10
+
11
+ To actually follow back, load the matching writes reference, review candidates under the original project authorization, register original stable IDs, and use followBatch. This audit grants no relationship-write permission or quota exemption.
12
+
13
+ Optional followBatch `pacing:{minGapMs,maxGapMs}` uses bounded 5–30 second ranges. Gaps are sampled once per original batch handle, persisted and reused on resume; it waits from the latest actual follow attempt. The normal round guards recheck after the wait. Pacing does not waive refusal/backoff/budgets or guarantee platform acceptance.
14
+
15
+ Each newly started list resets through native Chrome Control+Home and verifies the DOM start before collecting rows. A saved checkpoint resumes the original position. Counter changes require a different existing reviewed evidenceRef for the same URLs; counter revisions are retained in the proof. A stall with an unmatched counter remains incomplete, even at the visible tail.
16
+
17
+ On followBatch resume, persisted batch pacing wins; otherwise an omitted pacing option can use the original executor/account followCadence.intentPacing. Existing refusal, unknown and rolling-budget guards still apply.
@@ -93,3 +93,7 @@ Review an existing action with decision:{actionId,approved,evidenceRef,approvedT
93
93
  `likeIncomingBatch({batchId,targets:[{postId,url,evidenceRef,author,incomingEvidenceRef}],sampleRate:0.3})` samples once per stable batch (1–5 observed received comments), retains selection in the original ledger, serially likes and verifies exact public state. Existing likes never toggle. Each fresh click has a 30-second minimum gap; waiting returns resumeAt rather than polling. Unknown intent freezes that object. No reply-goal credit or guarantee against platform restrictions; alerts stop the batch.
94
94
 
95
95
  Optional executor.replyEmojiPolicy={sampleRate,maxInRecent} gates new reply drafts: stable original-ID sampling, no consecutive emoji, at most maxInRecent in the latest ten intents/unknown/verified replies. Plain text remains allowed; existing unknown recovery is unchanged.
96
+
97
+ ## Executor-local read restrictions
98
+
99
+ A non-actionable item remains account-wide unless its original ledger explicitly records `executionRestriction:{scope:'executor',executorId,reason,evidenceRef}`. The exact current reason and evidence in `blockEvidence` must match, and the restricting executor must exist with the same kind. Another authorized executor can proceed through the root API with its own identity/source proof; the affected executor stays blocked. Missing/stale scope proof, shared capacity, alerts, windows, pacing, quality and unknown guards still reject. Do not infer locality from a message or silently clear another executor's marker.