Repository navigation
Expand file tree
/
Copy pathdelete-before-date.js
More file actions
370 lines (326 loc) · 10.8 KB
/
Copy pathdelete-before-date.js
File metadata and controls
370 lines (326 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
/**
* Delete YOUR tweets on X that were posted BEFORE a cutoff date.
*
* This uses your logged-in x.com session (same delete action as the website).
* It cannot delete anyone else's posts.
*
* HOW TO USE
* 1. Log in to x.com.
* 2. Open https://x.com/YOUR_USERNAME/with_replies
* 3. DevTools → Console (Cmd+Option+J on Mac, F12 elsewhere).
* Chrome may require you to type: allow pasting
* 4. Paste this whole file, press Enter.
* 5. Enter the cutoff date (YYYY-MM-DD). Tweets on or after that date are kept.
* 6. Choose Archive mode (complete) or Timeline mode (only what's on the page).
*
* STOP: in the console type STOP_DELETE = true
*
* ARCHIVE MODE (use this if the cutoff is more than a few thousand tweets back)
* X → Settings → Your account → Download an archive of your data.
* When it is ready, unzip it and pick data/tweets.js when asked.
*
* Timeline mode can only see tweets X still loads on the profile (recent ones).
* Older tweets will not appear there — use the archive.
*/
(async () => {
const DELETE_DELAY_MS = 500;
const SCROLL_DELAY_MS = 1500;
const MAX_IDLE_SCROLLS = 14;
window.STOP_DELETE = false;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const getCookie = (name) =>
document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))?.[1];
if (!/(^|\.)(x|twitter)\.com$/i.test(location.hostname)) {
alert("Open this script on x.com while logged into your account.");
return;
}
const csrf = getCookie("ct0");
if (!csrf) {
alert("No logged-in session found. Log in to x.com and try again.");
return;
}
function parseCutoffDate(raw) {
const match = String(raw ?? "")
.trim()
.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return date;
}
const cutoffInput = prompt(
"Delete YOUR tweets posted BEFORE this date.\nTweets on or after this date are kept.\n\nUse YYYY-MM-DD (local time, midnight):",
"2022-01-01",
);
const cutoff = parseCutoffDate(cutoffInput);
if (!cutoff) {
console.log("Cancelled or invalid date. Expected YYYY-MM-DD.");
return;
}
const cutoffLabel = cutoff.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
const confirmed = confirm(
`Delete YOUR posts from before ${cutoffLabel}?\n\n` +
`Kept: ${cutoffLabel} and later\n` +
"Deleted: anything older\n\n" +
"This only affects your own account. Keep this tab open while it runs.",
);
if (!confirmed) {
console.log("Cancelled.");
return;
}
const headers = {
authorization:
"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
"x-csrf-token": csrf,
"content-type": "application/json",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-active-user": "yes",
};
const stats = {
deleted: 0,
unretweeted: 0,
skippedNew: 0,
alreadyGone: 0,
failed: 0,
};
function tweetIdToDate(id) {
const timestamp = Number(BigInt(id) >> 22n) + 1288834974657;
return new Date(timestamp);
}
function isBeforeCutoff(date) {
return date.getTime() < cutoff.getTime();
}
async function gqlPost(queryId, opName, variables) {
let netErrors = 0;
while (true) {
if (window.STOP_DELETE) return { stopped: true };
let res;
try {
res = await fetch(`https://x.com/i/api/graphql/${queryId}/${opName}`, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify({ variables, queryId }),
});
} catch (error) {
netErrors += 1;
if (netErrors > 8) return { failed: true };
console.warn(`Network error ${netErrors}, retrying…`, error);
await sleep(8000 * netErrors);
continue;
}
if (res.status === 429) {
const reset = Number(res.headers.get("x-rate-limit-reset"));
let waitMs = reset ? reset * 1000 - Date.now() + 5000 : 15 * 60 * 1000;
waitMs = Math.min(Math.max(waitMs, 5000), 16 * 60 * 1000);
const until = new Date(Date.now() + waitMs).toLocaleTimeString();
console.log(
`Rate limited. Waiting ~${Math.ceil(waitMs / 60000)} min (until ${until})…`,
);
await sleep(waitMs);
continue;
}
const remaining = res.headers.get("x-rate-limit-remaining");
if (remaining !== null && Number(remaining) <= 5) {
console.log(`${remaining} deletions left in this window.`);
}
const body = await res.json().catch(() => ({}));
if (res.ok && !body.errors) return { ok: true };
const msg = JSON.stringify(body.errors ?? body);
if (/not found|no status found|already|deleted/i.test(msg)) {
return { gone: true };
}
if (res.status === 404 || res.status === 403) {
console.error(
`${opName} returned ${res.status}. X may have changed the delete endpoint. Details: ${msg}`,
);
return { fatal: true };
}
console.warn(`${opName} failed (HTTP ${res.status}): ${msg}`);
return { failed: true };
}
}
const deleteTweet = (id) =>
gqlPost("VaenaVgh5q5ih7kvyVjgtg", "DeleteTweet", {
tweet_id: id,
dark_request: false,
});
const deleteRetweet = (id) =>
gqlPost("iQtK4dl5hBmXewYZuEOKVw", "DeleteRetweet", {
source_tweet_id: id,
dark_request: false,
});
function logStats() {
console.log(
`Deleted ${stats.deleted} | Unretweeted ${stats.unretweeted} | ` +
`Kept (on/after cutoff) ${stats.skippedNew} | ` +
`Already gone ${stats.alreadyGone} | Failed ${stats.failed}`,
);
}
function handleResult(result, kind) {
if (result.ok) {
if (kind === "rt") stats.unretweeted += 1;
else stats.deleted += 1;
return true;
}
if (result.gone) {
stats.alreadyGone += 1;
return true;
}
if (result.failed) stats.failed += 1;
return !result.fatal && !result.stopped;
}
async function archiveMode() {
console.log("Select your archive file: data/tweets.js");
const file = await new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".js,.json,.txt";
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
if (!file) {
console.log("No file selected.");
return;
}
const text = await file.text();
const jsonStart = text.indexOf("[");
if (jsonStart === -1) {
alert("That file does not look like tweets.js from an X archive.");
return;
}
let entries;
try {
entries = JSON.parse(text.slice(jsonStart));
} catch (error) {
alert(`Could not parse the archive: ${error.message}`);
return;
}
const tweets = entries
.map((entry) => entry.tweet ?? entry)
.filter((tweet) => tweet.id_str)
.map((tweet) => ({
id: tweet.id_str,
date: tweet.created_at
? new Date(tweet.created_at)
: tweetIdToDate(tweet.id_str),
}))
.filter((tweet) => isBeforeCutoff(tweet.date));
console.log(
`Archive has ${entries.length} posts. ${tweets.length} are before ${cutoffLabel}.`,
);
if (tweets.length === 0) {
console.log("Nothing to delete for that cutoff.");
return;
}
for (let i = 0; i < tweets.length; i += 1) {
if (window.STOP_DELETE) {
console.log("Stopped.");
break;
}
const result = await deleteTweet(tweets[i].id);
if (!handleResult(result, "tweet")) break;
if ((i + 1) % 25 === 0) {
console.log(`… ${i + 1}/${tweets.length} processed`);
logStats();
}
await sleep(DELETE_DELAY_MS);
}
console.log("Archive pass finished.");
logStats();
}
async function timelineMode() {
const myHandle = location.pathname.split("/")[1]?.toLowerCase();
if (
!myHandle ||
["home", "search", "explore", "notifications", "i"].includes(myHandle)
) {
alert("Open your profile first: x.com/YOUR_USERNAME/with_replies");
return;
}
console.log(
`@${myHandle}: deleting visible posts older than ${cutoffLabel}. Newer posts are skipped.`,
);
console.log("Stop anytime with: STOP_DELETE = true");
const processed = new Set();
let idleScrolls = 0;
while (idleScrolls < MAX_IDLE_SCROLLS && !window.STOP_DELETE) {
const articles = [
...document.querySelectorAll('article[data-testid="tweet"]'),
];
let actedThisPass = false;
for (const article of articles) {
if (window.STOP_DELETE) break;
const link = article
.querySelector('a[href*="/status/"] time')
?.closest("a");
if (!link) continue;
const match = link
.getAttribute("href")
?.match(/^\/([^/]+)\/status\/(\d+)/);
if (!match) continue;
const [, author, id] = match;
if (processed.has(id)) continue;
processed.add(id);
const isMyRetweet = Boolean(
article.querySelector('[data-testid="unretweet"]'),
);
const isMyTweet = author.toLowerCase() === myHandle;
if (!isMyRetweet && !isMyTweet) continue;
const postedAt = tweetIdToDate(id);
if (!isBeforeCutoff(postedAt)) {
stats.skippedNew += 1;
continue;
}
const result = isMyRetweet
? await deleteRetweet(id)
: await deleteTweet(id);
actedThisPass = true;
if (
!handleResult(result, isMyRetweet ? "rt" : "tweet") &&
(result.fatal || result.stopped)
) {
logStats();
return;
}
article.closest('div[data-testid="cellInnerDiv"]')?.remove();
await sleep(DELETE_DELAY_MS);
}
if (actedThisPass) {
idleScrolls = 0;
logStats();
} else {
idleScrolls += 1;
}
window.scrollTo(0, document.body.scrollHeight);
await sleep(SCROLL_DELAY_MS);
}
console.log(
window.STOP_DELETE ? "Stopped." : "Finished this timeline pass.",
);
logStats();
console.log(
"If older posts never appeared, refresh and run again, or use Archive mode with tweets.js.",
);
}
const useArchive = confirm(
`Cutoff: ${cutoffLabel}\n\n` +
"OK = Archive mode (pick tweets.js — complete, including old posts)\n" +
"Cancel = Timeline mode (only posts currently loaded on this profile page)",
);
if (useArchive) await archiveMode();
else await timelineMode();
})();