@nextclaw/x-harness 0.2.63 → 0.2.64
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 +2 -0
- package/SOURCE.md +2 -2
- package/dist/api.d.ts +1 -0
- package/dist/api.js +7 -0
- package/dist/browser-dom-capabilities.js +5 -0
- package/dist/browser-dom-editing.js +36 -3
- package/dist/browser-dom-navigation.d.ts +2 -0
- package/dist/browser-dom-navigation.js +43 -8
- package/dist/executor-actionability.d.ts +2 -0
- package/dist/executor-actionability.js +17 -0
- package/dist/follow-batch-pacing.d.ts +11 -0
- package/dist/follow-batch-pacing.js +49 -0
- package/dist/followback-audit.d.ts +13 -0
- package/dist/followback-audit.js +281 -0
- package/dist/growth-batch.d.ts +1 -1
- package/dist/growth-batch.js +11 -1
- package/dist/growth-execution.d.ts +2 -1
- package/dist/growth-execution.js +8 -2
- package/dist/growth-state.js +3 -1
- package/dist/growth.d.ts +1 -1
- package/dist/growth.js +1 -0
- package/dist/platform-capabilities.js +2 -0
- package/dist/platform-observation.js +7 -1
- package/dist/platform-targets.js +23 -5
- package/dist/readers-profile.d.ts +1 -1
- package/dist/readers-profile.js +67 -4
- package/dist/types.d.ts +46 -0
- package/generated/observation.mjs +67 -4
- package/package.json +2 -2
- package/skill/SKILL.md +2 -1
- package/skill/references/followback-audit.md +17 -0
- package/skill/references/writes.md +4 -0
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.
|
|
3
|
+
Version 0.2.64, commit 5e504c1167735d4290c8ff4ac1103cf81f03ad09.
|
|
4
4
|
|
|
5
|
-
Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/
|
|
5
|
+
Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/5e504c1167735d4290c8ff4ac1103cf81f03ad09
|
|
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
|
|
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.
|
|
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.
|
|
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)
|
|
@@ -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
|
+
}
|
package/dist/growth-batch.d.ts
CHANGED
|
@@ -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;
|
package/dist/growth-batch.js
CHANGED
|
@@ -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
|
}
|
package/dist/growth-execution.js
CHANGED
|
@@ -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 (!
|
|
71
|
+
if (!this.actionIsActionable(state, action.kind))
|
|
66
72
|
return {
|
|
67
73
|
snapshot,
|
|
68
74
|
need: {
|
package/dist/growth-state.js
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
42
|
+
const incompletePerson = atTarget &&
|
|
43
|
+
query.view === "person" &&
|
|
44
|
+
!value.alerts?.length &&
|
|
45
|
+
(value.needsStateCheck ||
|
|
46
|
+
value.blue === null ||
|
|
47
|
+
value.relationship === "unknown");
|
|
48
|
+
if (!atTarget || incompletePerson) {
|
|
43
49
|
await this.#browser.act({
|
|
44
50
|
operation: "openObserved",
|
|
45
51
|
url: url.href,
|
package/dist/platform-targets.js
CHANGED
|
@@ -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, {
|
|
107
|
-
|
|
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;
|
package/dist/readers-profile.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.2.64",
|
|
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": "
|
|
45
|
+
"gitHead": "5e504c1167735d4290c8ff4ac1103cf81f03ad09"
|
|
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
|
|
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.
|