Skip to content

Delete X data again: posts, reposts, likes, bookmarks, and tombstones - #716

Merged
micahflee merged 12 commits into
mainfrom
issue-711-delete-x-data
Sep 15, 2026
Merged

micahflee merged 12 commits into
mainfrom
issue-711-delete-x-data

Conversation

@micahflee

@micahflee micahflee commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Closes #711.

X rotates the identifiers its delete mutations are addressed by, and moved the timelines the mutations claim to come from. The four identifiers were hardcoded in four places, each written twice, and every referrer Cyd sent pointed at a route that now redirects.

What changed

One resolver, keyed by operation name, answers where a mutation goes, what identifier it carries, and what referrer it sends. Seeded from the #709 capture:

Operation Identifier Referrer
DeleteTweet nxpZCY2K-I6QoFHAHeojFQ (rotated) https://x.com/<username>
DeleteRetweet ZyZigVsNiFO6v1dEks1eWg (new) https://x.com/<username>/reposts
UnfavoriteTweet ZYKSe-w7KEslx3JhSIk5LA https://x.com/i/history/likes
DeleteBookmark Wlmlj2-xzyS1GN3a6cj-mQ https://x.com/i/history

It prefers an identifier observed in this session's own traffic over the seed. The store lives on the X controller, fed by the webRequest hook that already watches for rate limits: in memory for the session, no persistence, no invalidation. A cold-start run relies on the seeds.

Undoing a repost is its own operation. X sends DeleteRetweet naming the post that was reposted, not the repost, so reposts now carry that post's ID from the reposts timeline into the database (retweetedTweetID) and out again through the delete list.

Routes match referrers. Posts are deleted from the profile page and reposts from /reposts. Likes and bookmarks accept the redirect into X's /i/ namespace rather than dying on it — without that, neither job sent a single mutation.

Unfollowing now takes accounts in order. It reaches the Following button by [data-testid$="-unfollow"], but it walked an index across the matches — and X flips that attribute to -follow once an account is unfollowed while leaving the row in the list, so every unfollow shifted the remaining matches up by one. It unfollowed the first account, then the third, then the fifth, and died on MouseoverFailed when the index ran past what was left. It now always takes the first remaining match and re-counts.

The tombstone works, and is no longer behind x_tombstone. All three jobs did something wrong, and each was wrong in its own way:

  • The banner never reached the page. The script fetched the banner's data: URL to make a Blob, but X's CSP has no data: in connect-src, so the page refused it and reported only "Failed to fetch". It decodes the base64 itself now.
  • Locking the account threw. It read .checked off the first checkbox the moment the page loaded, before X had rendered any, so the script died on undefined and Electron reported "Script failed to execute". It waits for the checkbox.
  • The bio was typed in with sendInputEvent, a key at a time. keyDown and keyUp move focus and press Backspace — which is why the tabbing and deleting looked like they worked — but they insert no text without a char event. The textarea kept whatever X had put there and the save wrote it straight back. It goes through React's own value setter now, which took the tab loop, the per-character typing and the keycode table with it.

Both saves are checked. The banner and bio jobs clicked Save and called it done; a click on a button that does nothing still looks like a click. They read the profile back now — the banner URL either side of the save, the textarea after a reload — and a save that changed nothing is a reported failure rather than a finished job.

Finding that out

scripts/x-capture/probe-profile.ts is new. After two wrong guesses about why the profile dialog ignored Cyd, it drives a real Chromium through every plausible way of making the change and records the Save button state, the calls that carry the change, and whether the value survived a reload. Two rounds settled it:

  • Every banner delivery works, Cyd's own included, and real key events set the bio. So neither the file nor the typing was at fault.
  • Cyd's element.click(), its 250ms Apply-to-Save gap, and its tab-to-the-textarea all work too — the tab loop lands on TEXTAREA[name=description] after nine presses.
  • X's profile Save button is never disabled, which killed an assumption an earlier commit here had rested on.

That left the Electron webview as the only difference, and the missing char event as the cause.

It also confirmed the file input that used to be a guess: the dialog carries three fileInput elements and the banner is the first.

Also

Spacing on the delete review page — 5 tweetsthat are older than 2 days — was Vue's condense deleting a whitespace-only text node between two elements. The dashboard cards lost 72px of height so two rows clear the default 1000x900 window without a scrollbar, which matters more now that the tombstone card makes four. The lock-account checkbox had no wizard.lockAccountLabel string and rendered the key.

Known limitations, accepted

  • The learn-from-traffic preference never fires for these four operations. X's own client issues them only when a person clicks delete, which does not happen during automation. A rotation is still fixed by recapturing and reseeding off an error report. The read operations do get learned.
  • Existing databases take the fallback. Every repost already saved has retweetedTweetID null and is deleted the way Cyd has always deleted one, until a save run backfills it.

Testing

Resolver tested directly, both directions. Delete flows driven through the existing job seam with browser primitives stubbed, asserting the routes visited, the selectors awaited, and the mutation each job sends. The tombstone tests run the generated scripts against a stubbed page, with fetch throwing the way X's CSP makes it throw.

Renderer suite: 85 files, 1165 tests, no type errors. The DB-backed integration tests could not be run here — better-sqlite3 is currently built for Electron's ABI rather than Node's, from running the app — so CI is the check on those.

All three tombstone jobs were also run end to end against a real X account.

(This was written by an LLM.)

🤖 Generated with Claude Code

micahflee and others added 12 commits September 15, 2026 08:57
X rotates the identifiers its delete mutations are addressed by, and moved
the timelines the mutations claim to come from. The four identifiers were
hardcoded in four places, each written twice, and every referrer Cyd sent
pointed at a route that now redirects.

One resolver, keyed by operation name, now answers where a mutation goes,
what identifier it carries, and what referrer it sends. It is seeded from
the #709 capture and prefers an identifier observed in this session's own
traffic: X's client names the current one in every request it makes, so the
controller watches for them and a rotation stops being an outage. The store
is in memory for the session, with no persistence and no invalidation, so a
cold-start run still relies on the seeds. That is accepted.

Undoing a repost is its own operation. X sends DeleteRetweet naming the
post that was reposted, not the repost, so reposts now carry that post's ID
from the reposts timeline into the database and out again. A repost saved
before this still only knows its own ID and is deleted the way Cyd always
deleted one.

Posts are deleted from the profile page and reposts from /reposts, which is
where each now lives and which referrer each mutation has to match.

Unfollowing reaches the Following button by the identifier X gives it
rather than as the button inside the button inside the cell.

The banner tombstone was a stub that slept two seconds and reported
success. It now sets the banner on the profile form, confirms X's crop, and
saves — and reports a banner it could not set instead of claiming it
worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from review.

Deleting likes and bookmarks corrected the referrers but not the routes.
X redirects both into its /i/ namespace — which is where those referrers
come from — and an unexpected redirect raises URLChangedError before a
single mutation is sent. The save side already accepts them; now the delete
side does too.

Undoing a repost whose original Cyd never recorded calls the post deletion
rather than restating it, and the banner's file input is named for what it
matches rather than for the one of three matches that is the banner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vue's condense mode deletes a whitespace-only text node that sits
between two elements and contains a newline. The review page put the
count in a <b> and the qualifier in a <span> on the next line, so the
space between them was compiled away: "5 tweetsthat are older than 2
days", and likewise "these tweets.If you care,".

Build the qualifier clauses into a single string in script instead, and
interpolate it. Whitespace between an element and an interpolation
survives condense, so the space no longer depends on where the template
happens to wrap. Same for the not-archived warning, which also gets the
me-1 every other icon on the page already has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unfollowing walked an index across the buttons matching
[data-testid$="-unfollow"], but X swaps a button's data-testid to
"-follow" once the account is unfollowed and leaves the row in the list.
Every unfollow therefore shifted the remaining matches up by one, so
walking the index unfollowed the first account, then the third, then the
fifth. When the index ran past what was left, the mouseover found
nothing and the job died with x_runJob_unfollowEveryone_MouseoverFailed.

Always act on the first remaining match instead, and re-count after each
unfollow so the page reloads once it has nothing left rather than
trusting a count taken before any of this shifting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rows of cards came to about 678px against a scroll area of roughly
665px, so the dashboard opened with a sliver of scrollbar at the default
1000x900 window.

Take 72px back: the card icons drop from 96px to 72px with a slightly
tighter margin below them, which is 56px across two rows, and the
dashboard's vertical padding gives up another 8px. Horizontal padding is
untouched, so the cards keep their width.

Enough headroom that the fit does not depend on where the descriptions
happen to wrap, which moves with whatever font the platform resolves for
system-ui. The Facebook and Bluesky dashboards share these styles and
get the same treatment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tombstone page asked for wizard.lockAccountLabel, which no locale
had, so the checkbox rendered the key itself. Word it the way the review
page already words the same choice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things went wrong on the first real run of the tombstone jobs.

The banner never reached the page. The script fetched the banner's data:
URL to turn it into a Blob, but X's content security policy has no data:
in connect-src, so the page refused it and reported only "Failed to
fetch". Decode the base64 in the page instead, which asks nothing of the
network.

Locking the account threw. It read .checked off the first checkbox the
moment the page finished loading, before X had rendered any, so the
script died on undefined and Electron reported "Script failed to
execute". Wait for the checkbox, and let the scripts return false rather
than throw.

The banner and bio both reported success having saved nothing. Each
clicked the Save button blind and ignored the result, and a click on a
disabled button does nothing while still looking like it worked. Wait
for the button to stop being disabled, click it, and report a failure
either way.

The bio job also paused three times mid-run, waiting on the Resume
button. Those were for watching it work, and the job should run through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tombstone jobs put a banner on the profile dialog's file input and
type a bio into its textarea, then click Save. Both report success and
save nothing, which is the shape of a change X's own code never saw: the
DOM holds the new value, React's state does not, and Save stays inert.

Rather than guess which delivery X notices, probe-profile.ts tries each
one and records what X does. For the banner: what Cyd does today, the
same with an input event as well, and the browser's own file delivery.
For the bio: real key events, React's native value setter, and a plain
.value assignment as the control.

After each it records whether Save came out of its disabled state,
whether the crop step opened, which requests X made on save and what
they returned, and whether the value survived a reload. The reload is
the part that settles it, because an enabled button and a 200 prove
nothing if the profile comes back unchanged.

It logs every request the page makes to X, not the HAR tooling's subset:
a banner goes up through upload.twitter.com, which is not an X API host,
and that is the one call most worth seeing when a banner never arrives.

Unlike the walk, this writes to the account, so it says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first run answered the first question and ruled out what the
tombstone fix had assumed. Every way of delivering the banner worked,
Cyd's own decode included: all three reached update_profile_banner.json
and survived a reload. Real key events put the bio on the account.

It also found that the Save button is never disabled. It reads
disabled=false with no aria-disabled before any change is made, so
waiting for it to become enabled, which is what the jobs now do, can
never tell us anything.

That leaves how Cyd presses the buttons: through the element's own
click() rather than a real mouse press, and a quarter of a second after
Apply rather than a second and a half. Vary one at a time. For the bio,
report where Cyd's tab-towards-the-textarea actually lands, since it
never clicks the field it means to type into.

The report now separates the calls that carry the change from the
hundred X makes either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe ran Cyd's own sequence against X in a plain browser and every
part of it worked: its file delivery, its element.click() on Apply and
Save, the quarter second between them, and its tab-towards-the-textarea,
which lands on TEXTAREA[name=description] after nine presses. So what
fails is not the sequence. It is that Cyd runs it through an Electron
webview.

The bio was typed in with sendInputEvent, a key at a time. keyDown and
keyUp move focus and press Backspace, which is why the tabbing and
deleting looked like they worked, but they insert no text without a char
event. The textarea kept whatever X had put there and the save wrote it
straight back. Set the value through React's own setter instead, which
the probe confirmed survives a reload where a plain .value assignment
does not. That takes the tab loop, the per-character typing and the
keycode table with it, and means the bio text now needs quoting into the
script, which typing never had to care about.

The Save button gate goes too. The probe found the button is never
disabled: it reads disabled=false with no aria-disabled before any
change is made, so waiting for it to become enabled asked a question
with only one answer. Read the profile back instead. The banner job
compares the banner URL either side of the save, the bio job re-reads
the textarea, and a save that changed nothing is now a reported failure
rather than a finished job.

Why the banner does not save is still open. It now says so instead of
claiming success, and the error carries both URLs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three tombstone jobs now work against a real account, so the
x_tombstone flag has nothing left to protect. The Tombstone card shows
for any account that is not archive-only, on the same terms as the
Local Database and Delete cards beside it.

The flag mechanism stays: bluesky and facebook still use it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Retargeting the probe gave each bio case its own text, so it no longer
reads anything from the options. eslint runs across the repo, not just
the files a change touches, and it said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@micahflee
micahflee merged commit 6e1bfdc into main Sep 15, 2026
1 check passed
@micahflee
micahflee deleted the issue-711-delete-x-data branch September 15, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delete X data again: posts, retweets, likes, bookmarks, unfollowing, and tombstones

1 participant