Delete X data again: posts, reposts, likes, bookmarks, and tombstones - #716
Merged
Merged
Conversation
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>
This was referenced Sep 15, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
DeleteTweetnxpZCY2K-I6QoFHAHeojFQ(rotated)https://x.com/<username>DeleteRetweetZyZigVsNiFO6v1dEks1eWg(new)https://x.com/<username>/repostsUnfavoriteTweetZYKSe-w7KEslx3JhSIk5LAhttps://x.com/i/history/likesDeleteBookmarkWlmlj2-xzyS1GN3a6cj-mQhttps://x.com/i/historyIt prefers an identifier observed in this session's own traffic over the seed. The store lives on the X controller, fed by the
webRequesthook 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
DeleteRetweetnaming 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-followonce 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 onMouseoverFailedwhen 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:data:URL to make aBlob, but X's CSP has nodata:inconnect-src, so the page refused it and reported only "Failed to fetch". It decodes the base64 itself now..checkedoff the first checkbox the moment the page loaded, before X had rendered any, so the script died onundefinedand Electron reported "Script failed to execute". It waits for the checkbox.sendInputEvent, a key at a time.keyDownandkeyUpmove focus and press Backspace — which is why the tabbing and deleting looked like they worked — but they insert no text without acharevent. 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.tsis 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:element.click(), its 250ms Apply-to-Save gap, and its tab-to-the-textarea all work too — the tab loop lands onTEXTAREA[name=description]after nine presses.That left the Electron webview as the only difference, and the missing
charevent as the cause.It also confirmed the file input that used to be a guess: the dialog carries three
fileInputelements and the banner is the first.Also
Spacing on the delete review page —
5 tweetsthat are older than 2 days— was Vue'scondensedeleting 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 nowizard.lockAccountLabelstring and rendered the key.Known limitations, accepted
retweetedTweetIDnull 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
fetchthrowing 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-sqlite3is 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