Skip to content
Merged
1 change: 0 additions & 1 deletion docs/x-capture/capture-walk-20260914.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ the first cursor.
- [ ] Open the permalink of the post with four images
- [ ] Open the permalink of the video post
- [ ] Open the permalink of the poll
- [ ] Open the permalink of the long-form post, if one was seeded
- [ ] Open the permalink of the link-card post
- [ ] Open the permalink of the retweet whose original was deleted — if it is
not on the timeline at all, that is the finding, and the decoded type
Expand Down
13 changes: 5 additions & 8 deletions scripts/x-capture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,13 @@ post. `--count N` overrides how many filler posts, likes, and bookmarks to
make; the default is 61 each, which clears three pages of twenty. The default
follow count is 21, enough for the following list to page once.

Two steps stop and ask you to do them in the open browser window, then
continue:
One step stops and asks you to do it in the open browser window, then
continues:

- **The long-form post** needs a premium account and X's own long-form
composer, which the ordinary composer will not stand in for.
- **The orphaned retweet** needs two accounts: retweet a post from the second
test account, then delete it from that account.

Both are recorded as manual steps rather than as selector failures, so they do
It is recorded as a manual step rather than as a selector failure, so it does
not pollute the selector evidence.

### When it stops
Expand Down Expand Up @@ -275,9 +273,8 @@ Two shapes were deliberately **not** promoted:
so there is no JSON body to promote. The status and the `x-rate-limit-*`
headers are what a test needs, and they are in
`GraphQLResponses.rateLimitError`.
- **The long-form post, and a rate limit disguised as a success.** Neither was
observed, so neither has a fixture. Inventing one would assert a shape this
capture cannot vouch for.
- **A rate limit disguised as a success.** Never observed, so it has no
fixture. Inventing one would assert a shape this capture cannot vouch for.

`src/renderer/src/view_models/XViewModel/test_fixtures.test.ts` asserts what
each fixture shows, so the findings do not drift away from the files.
2 changes: 0 additions & 2 deletions scripts/x-capture/lib/seed_plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ describe("kinds", () => {
});

test("names the kinds a person has to do", () => {
expect(isManualKind("longform")).toBe(true);
expect(isManualKind("retweet-to-orphan")).toBe(true);
expect(isManualKind("post")).toBe(false);
});
Expand All @@ -115,7 +114,6 @@ describe("buildSeedPlan", () => {
const counts = summarizePlan(buildSeedPlan(options()));
expect(counts.thread).toBe(1);
expect(counts.poll).toBe(1);
expect(counts.longform).toBe(1);
expect(counts.link).toBe(1);
expect(counts.quote).toBe(1);
expect(counts.retweet).toBe(1);
Expand Down
25 changes: 3 additions & 22 deletions scripts/x-capture/lib/seed_plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export type SeedActionKind =
| "thread"
| "poll"
| "media"
| "longform"
| "link"
| "quote"
| "retweet"
Expand All @@ -32,10 +31,10 @@ export const BULK_KINDS: SeedActionKind[] = [
];

/**
* Kinds no script can do on its own: the long-form composer needs premium, and
* an orphaned retweet needs a second account to delete the original from.
* Kinds no script can do on its own: an orphaned retweet needs a second
* account to delete the original from.
*/
export const MANUAL_KINDS: SeedActionKind[] = ["longform", "retweet-to-orphan"];
export const MANUAL_KINDS: SeedActionKind[] = ["retweet-to-orphan"];

export function isBulkKind(kind: SeedActionKind): boolean {
return BULK_KINDS.includes(kind);
Expand Down Expand Up @@ -167,23 +166,6 @@ function awkwardShapes(options: SeedPlanOptions): SeedAction[] {
note: "Post with a link card",
text: `Cyd seed link card ${dateStamp}: https://cyd.social/`,
},
{
id: "longform-001",
kind: "longform",
note: "Long-form post. Posted by hand: X's long-form composer needs premium, and the ordinary composer will not take it",
text: [
`Cyd seed long-form post ${dateStamp}.`,
"",
"This post exists to exercise the long-form note shape, where the body is",
"carried outside the ordinary post text field and the timeline entry is",
"truncated. It has to be long enough that X treats it as long-form rather",
"than an ordinary post, so it runs past the usual character limit and keeps",
"going for several paragraphs.",
"",
"Everything here is seeded test content on a test account and is safe to",
"delete at any point during the capture walk.",
].join("\n"),
},
];

targetUrls.slice(0, 2).forEach((targetUrl, index) => {
Expand Down Expand Up @@ -305,7 +287,6 @@ export const SHAPE_MARKERS: { label: string; pattern: RegExp }[] = [
{ label: "Poll", pattern: /Cyd seed poll/ },
{ label: "Link card", pattern: /Cyd seed link card/ },
{ label: "Quote post", pattern: /Cyd seed quote post/ },
{ label: "Long-form", pattern: /Cyd seed long-form/ },
];

/** The shape a timeline entry is, or null if it is not a seeded shape. */
Expand Down
2 changes: 1 addition & 1 deletion scripts/x-capture/lib/walk_plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("parsePermalinks", () => {
test("skips shapes that were never found", () => {
const markdown = [
"- [ ] Poll: https://x.com/someone/status/1",
"- [ ] Long-form: **not found on the timeline**",
"- [ ] Link card: **not found on the timeline**",
].join("\n");
expect(parsePermalinks(markdown)).toHaveLength(1);
});
Expand Down
46 changes: 35 additions & 11 deletions scripts/x-capture/lib/x_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import type { Page } from "playwright-core";
export const SELECTORS = {
// Composing
composerTextarea: '[data-testid="tweetTextarea_0"]',
// X opens /compose/post as a modal over the home timeline, whose own inline
// composer carries the same test identifier. Waiting on the unscoped
// selector re-resolves to that inline composer the moment the modal closes,
// so it never reports detached and every successful post looks like a
// failure. Waiting for the modal's own field is what says the post landed.
composerDialogTextarea: '[role="dialog"] [data-testid="tweetTextarea_0"]',
composerTextareaNth: (index: number) =>
`[data-testid="tweetTextarea_${index}"]`,
composerFileInput: 'input[data-testid="fileInput"]',
Expand Down Expand Up @@ -215,7 +221,7 @@ async function submitComposer(page: Page) {
}

const composerClosed = page
.locator(SELECTORS.composerTextarea)
.locator(SELECTORS.composerDialogTextarea)
.first()
.waitFor({ state: "detached", timeout: 20000 })
.then(() => "closed" as const)
Expand Down Expand Up @@ -348,31 +354,49 @@ async function clickNthAfterScrolling(
await assertNotBlocked(page);
const buttons = page.locator(selector);

for (let attempt = 0; attempt < 8; attempt++) {
// Every fifteen actions the seeder reloads the feed, which puts it back at
// the top — above everything it has already acted on. Finding the next post
// to act on then means scrolling past all of them, so how far this has to
// reach grows with the count. An image-heavy feed makes that worse: a single
// photo post runs to most of a screen, so forty already-liked posts can sit
// tens of thousands of pixels down. Reaching short of them looks exactly
// like a selector X has changed.
for (let attempt = 0; attempt < 40; attempt++) {
if ((await buttons.count()) > index) {
await buttons.nth(index).scrollIntoViewIfNeeded();
await buttons.nth(index).click();
return;
}
await page.mouse.wheel(0, 2000);
await page.mouse.wheel(0, 4000);
await page.waitForTimeout(1500);
}

throw new SelectorMissingError(selector);
}

export async function actOnNextPost(
page: Page,
action: "like" | "bookmark",
alreadyDone: number,
) {
/**
* Acts on the first post still offering the action, rather than counting how
* many have been done and reaching for that index.
*
* X virtualizes the timeline, dropping posts out of the DOM once they are
* scrolled well past, so the number rendered stays roughly flat however far
* down the feed you are. A liked post also leaves the `like` selector
* entirely, since its test identifier flips to `unlike`. Counting upwards
* against that meant the index climbed while the count did not, and past
* eight or so actions it could never be reached.
*
* The first match is always the next one to act on, for the same reason: what
* has been done is no longer in the set.
*/
export async function actOnNextPost(page: Page, action: "like" | "bookmark") {
await clickNthAfterScrolling(
page,
action === "like" ? SELECTORS.like : SELECTORS.bookmark,
alreadyDone,
0,
);
}

export async function followNextAccount(page: Page, alreadyDone: number) {
await clickNthAfterScrolling(page, SELECTORS.follow, alreadyDone);
/** The first account still offering Follow, for the reason above. */
export async function followNextAccount(page: Page) {
await clickNthAfterScrolling(page, SELECTORS.follow, 0);
}
23 changes: 4 additions & 19 deletions scripts/x-capture/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,6 @@ function requireTarget(action: SeedAction): string {
}

interface FeedState {
liked: number;
bookmarked: number;
followed: number;
actionsSinceReload: number;
}

Expand Down Expand Up @@ -247,35 +244,28 @@ async function runAction(
case "retweet":
await retweetPost(page, requireTarget(action));
break;
case "longform":
case "retweet-to-orphan":
// Handled above, before the switch.
break;
case "like":
if (feed.actionsSinceReload === 0) {
await page.goto(feedUrl, { waitUntil: "domcontentloaded" });
feed.liked = 0;
}
await actOnNextPost(page, "like", feed.liked);
feed.liked += 1;
await actOnNextPost(page, "like");
break;
case "bookmark":
if (feed.actionsSinceReload === 0) {
await page.goto(feedUrl, { waitUntil: "domcontentloaded" });
feed.bookmarked = 0;
}
await actOnNextPost(page, "bookmark", feed.bookmarked);
feed.bookmarked += 1;
await actOnNextPost(page, "bookmark");
break;
case "follow":
if (feed.actionsSinceReload === 0) {
await page.goto("https://x.com/i/connect_people", {
waitUntil: "domcontentloaded",
});
feed.followed = 0;
}
await followNextAccount(page, feed.followed);
feed.followed += 1;
await followNextAccount(page);
break;
}
}
Expand Down Expand Up @@ -350,12 +340,7 @@ async function main() {
return;
}

const feed: FeedState = {
liked: 0,
bookmarked: 0,
followed: 0,
actionsSinceReload: 0,
};
const feed: FeedState = { actionsSinceReload: 0 };

for (const action of plan) {
if (progress.isDone(action.id)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,9 +493,8 @@ test("indexParseTweets() reads the rate limit reset from the response headers",

test("indexParseTweets() saves the rest when one entry cannot be read", async () => {
// No fixture can show a retweet whose original was deleted — the entry is
// simply gone from the timeline (findings 5) — and a long-form post was
// never observed either. What both would cost, if X ever returns one Cyd
// cannot read, is the rest of the archive. They do not.
// simply gone from the timeline (findings 5). What that would cost, if X
// ever returns one Cyd cannot read, is the rest of the archive. It does not.
const body = JSON.parse(
fs.readFileSync(
path.join(
Expand Down
19 changes: 19 additions & 0 deletions src/account_x/x_account_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Agent } from "@atproto/api";
import { Record as BskyPostRecord } from "@atproto/api/dist/client/types/app/bsky/feed/post";

import { getAccountDataPath } from "../util";
import { RequestLog } from "../request_log";
import {
XAccount,
XJob,
Expand Down Expand Up @@ -82,14 +83,32 @@ export class XAccountController extends BaseAccountController<XProgress> {

private blueskyService: BlueskyService | null = null;

private requestLog: RequestLog;

constructor(accountID: number, mitmController: IMITMController) {
super(accountID, mitmController);
// Initialize progress with X-specific type
this.progress = emptyXProgress();

// Monitor web request metadata for X-specific functionality
const ses = session.fromPartition(`persist:account-${this.accountID}`);

// Off unless CYD_REQUEST_LOG says otherwise. See src/request_log.ts.
this.requestLog = new RequestLog(this.accountID);
if (this.requestLog.enabled) {
log.info(
`XAccountController: recording requests for account ${this.accountID} to ${process.env.CYD_REQUEST_LOG}`,
);
}

ses.webRequest.onCompleted((details) => {
this.requestLog.record({
method: details.method,
url: details.url,
statusCode: details.statusCode,
fromCache: details.fromCache,
});

// Learn the operation identifiers X is using right now
this.observeGraphqlOperation(details.url);

Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@
"youJustSaved": "You just saved:",
"youJustArchived": "You just archived:",
"youJustDeleted": "You just deleted:",
"youJustTombstoned": "You just tombstoned your account:",
"youJustMigrated": "You just migrated:"
},
"premium": {
Expand Down
Loading
Loading