-
-
Notifications
You must be signed in to change notification settings - Fork 107
Expand file tree
/
Copy pathsearchSweep.js
More file actions
1494 lines (1346 loc) · 70.2 KB
/
Copy pathsearchSweep.js
File metadata and controls
1494 lines (1346 loc) · 70.2 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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2024-2026 nich (@nichxbt). Licensed under the Apache License, Version 2.0.
// scripts/searchSweep.js
// Delete, like, repost, or reply to every post an X search returns, e.g. from:you @someone
// Paste in DevTools console on x.com/search?q=...
// by nichxbt
//
// The use case that built it: "I replied to @someone 400 times over two years and I want
// every one of those replies gone." X gives you no bulk tool for that, but its search does
// find them: from:you @someone. This sweeps that result set and applies the action you pick.
//
// https://x.com/search?q=from%3Ayou%20%40someone&src=typed_query&f=live
//
// Actions: delete (your own posts only), like, repost, reply. Delete is exclusive: a post
// you are about to remove is not one you also want to like.
//
// Two things about X search that this script works around, and you should know about:
// 1. Search returns a slice, not the whole set. One pass never finds everything.
// CONFIG.passes re-runs the query and sweeps again until a pass finds nothing new.
// 2. The search index lags deletions by minutes. A post you just deleted can still be
// listed. Every id is remembered, so a later pass skips it instead of erroring.
// Use &f=live (the Latest tab). Top ranking hides most of your older replies.
//
// AI replies from the console: x.com's Content-Security-Policy only lets the page reach a
// short list of hosts, and https://api.x.ai is on it. So provider 'xai' (Grok) works from
// the console with your xAI key. For OpenAI/Anthropic/OpenRouter/Ollama install the
// XActions browser extension (extension/) and set provider 'bridge'.
//
// A floating panel appears when you paste. Everything below can be changed there.
// Dry run is ON by default. Read one pass of the log, then turn it off.
//
// DELETION IS PERMANENT. Export your posts first with scripts/backupAccount.js.
/**
* ============================================================
* 🔍 Search Sweep
* ============================================================
*
* @name searchSweep.js
* @description Delete, like, repost, or reply to every post an X search returns. Built for "remove every reply I ever sent this account".
* @author nichxbt (https://x.com/nichxbt)
* @version 1.0.0
* @date 2026-08-27
* @repository https://github.com/nirholas/XActions
* ============================================================
*/
(() => {
'use strict';
if (document.getElementById('xsw-panel')) {
console.log('🔍 Search sweep already loaded. Use the panel, or window.XSearchSweep.');
return;
}
// ═══════════════════════════════════════════════════════════
// CONFIGURATION (the panel edits this live)
// ═══════════════════════════════════════════════════════════
const CONFIG = {
dryRun: true, // Log what would happen, touch nothing. SET FALSE TO RUN.
// The search. Leave `raw` empty and the pieces below are assembled into a query;
// fill `raw` and it is used verbatim, so any X search operator works.
query: {
from: 'me', // 'me' = the signed-in account. Or a handle. '' = anyone.
mentions: '', // Handle the post must mention, e.g. 'nichxbt'
to: '', // Handle the post must be a direct reply to
contains: '', // Extra words or operators, e.g. 'filter:replies -filter:links'
latest: true, // Latest tab (f=live). Top ranking hides old posts.
raw: '', // Full query, overrides everything above
},
actions: {
delete: true, // Your own posts only. Permanent. Exclusive with the rest.
like: false,
repost: false,
reply: false,
},
maxPosts: 0, // 0 = every result. Set e.g. 25 for a first live run.
passes: 6, // Re-run the query this many times. Search returns a slice per pass.
stopAfterEmptyPasses: 2, // Give up once this many passes in a row act on nothing.
filters: {
olderThanDays: null, // Only act on posts older than N days (null = any age)
beforeDate: null, // Only before this date: 'YYYY-MM-DD'
afterDate: null, // Only after this date: 'YYYY-MM-DD'
maxLikes: null, // Protect high performers: skip if likes > this
maxReposts: null, // Skip if reposts > this
excludeKeywords: [], // Never touch a post containing any of these
excludePinned: true, // Never touch your pinned post
includeReposts: false, // Also act on posts you reposted (delete undoes the repost)
},
// Pacing. 'safe' is what a fast human looks like. Faster presets get accounts limited.
speed: 'safe', // 'stealth' | 'safe' | 'moderate' | 'fast'
restEvery: 25, // After this many posts, take a longer break
restForSeconds: 120, // Length of that break
maxConsecutiveFailures: 4, // Back off after this many failures in a row
backoffSeconds: 300, // How long to back off (X soft-limits clear in 5-15 min)
reply: {
mode: 'templates', // 'templates' | 'ai'
templates: [
'Following up on this one, {name}. Did the thinking here hold up?',
'Still curious about this, {author}. What changed since?',
],
prompt: 'Reply as a thoughtful person continuing an existing conversation. Be specific to the post, add one idea or one honest question, keep it under two sentences, no hype words.',
persona: '',
provider: 'xai', // 'xai' (works from the console) | 'bridge' (XActions extension)
apiKey: '', // xAI key. Stored only if "remember" is ticked in the panel.
model: 'grok-3-mini',
bridgeProvider: 'openrouter',
bridgeBaseUrl: '',
temperature: 0.9,
allowHashtags: false,
allowEmoji: true,
fallbackToTemplates: true,
},
exportOnFinish: true, // Download a JSON record of everything acted on
};
// Deletes tolerate a tighter cadence than replies: one is a menu click, the other is a
// published post that X weighs much more heavily when it decides to limit an account.
const SPEEDS = {
stealth: { between: [12000, 24000], action: [2500, 5000] },
safe: { between: [4000, 9000], action: [1500, 3200] },
moderate: { between: [2500, 5000], action: [1100, 2400] },
fast: { between: [1300, 2600], action: [800, 1600] },
};
const REPLY_PACE_MULTIPLIER = 3;
const SEL = {
article: 'article[data-testid="tweet"]',
tweetText: '[data-testid="tweetText"]',
userName: '[data-testid="User-Name"]',
socialContext: '[data-testid="socialContext"]',
caret: '[data-testid="caret"]',
menu: '[role="menu"]',
menuItem: '[role="menuitem"]',
confirmSheet: '[data-testid="confirmationSheetConfirm"]',
like: '[data-testid="like"]',
unlike: '[data-testid="unlike"]',
retweet: '[data-testid="retweet"]',
unretweet: '[data-testid="unretweet"]',
retweetConfirm: '[data-testid="retweetConfirm"]',
unretweetConfirm: '[data-testid="unretweetConfirm"]',
reply: '[data-testid="reply"]',
tweetBox: '[data-testid="tweetTextarea_0"]',
tweetButton: '[data-testid="tweetButton"]',
closeModal: '[data-testid="app-bar-close"]',
toast: '[data-testid="toast"]',
searchBox: '[data-testid="SearchBox_Search_Input"]',
emptyState: '[data-testid="empty_state_header_text"]',
retryButton: '[data-testid="primaryColumn"] [role="button"]',
placement: '[data-testid="placementTracking"]',
profileLink: 'a[data-testid="AppTabBar_Profile_Link"]',
accountSwitcher: '[data-testid="SideNav_AccountSwitcher_Button"]',
};
const GENERIC_OPENERS = [
'great post', 'great point', 'great thread', 'great take', 'love this', 'this is so true',
'so true', 'well said', "couldn't agree more", 'could not agree more', 'thanks for sharing',
'thank you for sharing', 'interesting take', 'interesting perspective', 'as an ai', 'as a language model',
];
// ═══════════════════════════════════════════════════════════
// WHO AM I, AND AM I ON A SEARCH PAGE
// ═══════════════════════════════════════════════════════════
const ownHandle = (() => {
const href = document.querySelector(SEL.profileLink)?.getAttribute('href') || '';
const fromNav = href.match(/^\/([A-Za-z0-9_]{1,15})$/);
if (fromNav) return fromNav[1].toLowerCase();
const switcher = document.querySelector(SEL.accountSwitcher)?.textContent || '';
const fromSwitcher = switcher.match(/@([A-Za-z0-9_]{1,15})/);
return fromSwitcher ? fromSwitcher[1].toLowerCase() : null;
})();
const onSearchPage = () => /^\/search\/?$/.test(location.pathname);
if (!onSearchPage()) {
console.error(
'❌ Open an X search first, then paste this again. For example:\n' +
` https://x.com/search?q=${encodeURIComponent(`from:${ownHandle || 'yourhandle'} @someone`)}&src=typed_query&f=live`,
);
return;
}
const currentQuery = () => new URLSearchParams(location.search).get('q') || '';
const currentTab = () => new URLSearchParams(location.search).get('f') || 'top';
// Seed the panel from whatever the user already searched for, so pasting on a search
// they built by hand does not silently replace it with the defaults above.
(() => {
const q = currentQuery();
if (!q) return;
CONFIG.query.raw = q;
CONFIG.query.latest = currentTab() === 'live';
})();
const STORAGE_KEY = 'xactions_search_sweep';
const KEY_STORAGE = 'xactions_engage_ai_key'; // Shared with engageProfile on purpose
const persisted = (() => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') || {}; } catch { return {}; }
})();
const STATE = {
running: false,
paused: false,
stopRequested: false,
done: new Map(Object.entries(persisted.done || {})), // tweetId -> { deleted, liked, reposted, replied, at }
seenThisPass: new Set(),
pass: 0,
processed: 0,
deleted: 0,
liked: 0,
reposted: 0,
replied: 0,
skipped: 0,
failed: 0,
consecutiveFailures: 0,
startedAt: null,
results: [],
recentReplies: [],
lastTemplate: -1,
query: '',
};
const savedKey = (() => { try { return localStorage.getItem(KEY_STORAGE) || ''; } catch { return ''; } })();
if (savedKey && !CONFIG.reply.apiKey) CONFIG.reply.apiKey = savedKey;
const persist = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
updatedAt: Date.now(),
query: STATE.query,
done: Object.fromEntries(STATE.done),
}));
} catch (err) {
addLog(`Could not save progress: ${err.message}`, 'warn');
}
};
// ═══════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = ([min, max]) => Math.floor(min + ((Math.random() + Math.random()) / 2) * (max - min));
const speed = () => SPEEDS[CONFIG.speed] || SPEEDS.safe;
const betweenRange = () => {
const [lo, hi] = speed().between;
const scale = CONFIG.actions.reply ? REPLY_PACE_MULTIPLIER : 1;
return [lo * scale, hi * scale];
};
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const waitFor = async (sel, timeout = 6000, root = document) => {
const start = Date.now();
while (Date.now() - start < timeout) {
const el = root.querySelector(sel);
if (el) return el;
await sleep(120);
}
return null;
};
const waitForGone = async (sel, timeout = 6000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (!document.querySelector(sel)) return true;
await sleep(120);
}
return false;
};
const waitUntil = async (test, timeout = 6000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (test()) return true;
await sleep(150);
}
return false;
};
const pressEscape = () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }));
const close = $(SEL.closeModal);
if (close) close.click();
};
/** A toast that means X is pushing back, not just informing. */
const throttleToast = () => {
const toast = $(SEL.toast);
if (!toast) return null;
const text = toast.textContent || '';
return /rate limit|try again later|too many|temporarily|unable to|something went wrong|limit/i.test(text) ? text.trim() : null;
};
const afterClickCheck = async () => {
const toast = throttleToast();
if (toast) throw new Error(`X pushed back: "${toast}"`);
};
// ═══════════════════════════════════════════════════════════
// THE SEARCH ITSELF
// ═══════════════════════════════════════════════════════════
const buildQuery = () => {
const q = CONFIG.query;
if (q.raw && q.raw.trim()) return q.raw.trim();
const handle = (h) => String(h || '').trim().replace(/^@/, '');
const parts = [];
const from = handle(q.from) === 'me' ? ownHandle : handle(q.from);
if (from) parts.push(`from:${from}`);
if (handle(q.mentions)) parts.push(`@${handle(q.mentions)}`);
if (handle(q.to)) parts.push(`to:${handle(q.to)}`);
if (q.contains && q.contains.trim()) parts.push(q.contains.trim());
return parts.join(' ');
};
const searchUrl = (q) =>
`/search?q=${encodeURIComponent(q)}&src=typed_query${CONFIG.query.latest ? '&f=live' : ''}`;
/**
* Type a query into X's own search box and submit it. This keeps the SPA alive, which a
* location.href assignment would not: a page load throws away this script mid-run.
*/
const submitViaSearchBox = async (q) => {
const box = await waitFor(SEL.searchBox, 4000);
if (!box) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
box.focus();
setter.call(box, '');
box.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(150);
setter.call(box, q);
box.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(350);
for (const type of ['keydown', 'keypress', 'keyup']) {
box.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true }));
}
const landed = await waitUntil(() => onSearchPage() && currentQuery().trim() === q.trim(), 8000);
box.blur();
return landed;
};
/** Click the Latest / Top tab. They are X's own router links, so no page load. */
const clickTab = async (want) => {
const links = $$('[role="tablist"] a[href*="/search"], nav[role="navigation"] a[href*="/search"]');
const target = links.find((a) => {
const f = new URLSearchParams((a.getAttribute('href') || '').split('?')[1] || '').get('f') || 'top';
return f === want;
});
if (!target) return false;
if (target.getAttribute('aria-selected') === 'true' && currentTab() === want) return true;
target.click();
return waitUntil(() => currentTab() === want, 6000);
};
const goToQuery = async (q) => {
if (!q) throw new Error('Empty query. Set from/mentions in the panel, or paste a raw query.');
if (currentQuery().trim() !== q.trim()) {
const ok = await submitViaSearchBox(q);
if (!ok) {
throw new Error(`Could not drive X's search box. Open this URL yourself and paste the script again: https://x.com${searchUrl(q)}`);
}
await sleep(1200);
}
const wantTab = CONFIG.query.latest ? 'live' : 'top';
if (currentTab() !== wantTab) {
const switched = await clickTab(wantTab);
if (!switched && CONFIG.query.latest) {
addLog('Could not switch to the Latest tab. Top ranking hides older posts, so expect fewer results.', 'warn');
}
await sleep(1200);
}
return true;
};
/**
* Force X to re-run the current query. Deleting from a result list does not refresh it,
* and the index needs a fresh request to drop what is gone and surface the next slice.
* Toggling to the other tab and back is X's own router doing the refetch.
*/
const refreshResults = async () => {
const want = CONFIG.query.latest ? 'live' : 'top';
const other = want === 'live' ? 'top' : 'live';
window.scrollTo({ top: 0 });
await sleep(600);
const bounced = await clickTab(other);
if (!bounced) return false;
await sleep(1800);
const back = await clickTab(want);
await sleep(2200);
return back;
};
// ═══════════════════════════════════════════════════════════
// READING RESULTS
// ═══════════════════════════════════════════════════════════
const parseCount = (raw) => {
if (!raw) return 0;
const m = String(raw).replace(/,/g, '').match(/([\d.]+)\s*([KMBkmb])?/);
if (!m) return 0;
const n = parseFloat(m[1]);
const scale = m[2] ? { k: 1e3, m: 1e6, b: 1e9 }[m[2].toLowerCase()] : 1;
return Math.round(n * (scale || 1));
};
const readArticle = (article) => {
// The permalink is the anchor wrapping the timestamp. Taking the first /status/ link
// instead would pick up the quoted post on a quote tweet and act on the wrong author.
const statusLinks = $$('a[href*="/status/"]', article)
.filter((a) => /^\/[A-Za-z0-9_]+\/status\/\d+/.test(a.getAttribute('href') || ''));
const permalink = statusLinks.find((a) => a.querySelector('time')) || statusLinks[0];
const link = permalink?.getAttribute('href') || '';
if (!link) return null;
const id = (link.match(/\/status\/(\d+)/) || [])[1];
const author = ((link.match(/^\/([A-Za-z0-9_]+)\/status\//) || [])[1] || '').toLowerCase();
if (!id || !author) return null;
const nameEl = $(SEL.userName, article);
const nameSpans = nameEl ? $$('span', nameEl).map((s) => s.textContent.trim()).filter(Boolean) : [];
const authorName = nameSpans.find((t) => t && !t.startsWith('@')) || '';
const social = ($(SEL.socialContext, article)?.textContent || '').toLowerCase();
const isRepost = /reposted|retweeted/.test(social);
const isPinned = /pinned/.test(social);
const isReply = /replying to/i.test(article.textContent || '');
const isAd = !!$(SEL.placement, article) || $$('span', article).some((s) => s.textContent.trim() === 'Ad');
const time = $('time', article)?.getAttribute('datetime') || '';
const text = $(SEL.tweetText, article)?.textContent?.trim() || '';
const quotedText = $$(SEL.tweetText, article)[1]?.textContent?.trim() || '';
const hasMedia = !!$('[data-testid="tweetPhoto"], [data-testid="videoPlayer"], video', article);
return {
id,
url: `https://x.com${link.split('?')[0]}`,
author,
authorName,
text,
quotedText,
hasMedia,
timestamp: time,
likes: parseCount($(`${SEL.like}, ${SEL.unlike}`, article)?.getAttribute('aria-label')),
reposts: parseCount($(`${SEL.retweet}, ${SEL.unretweet}`, article)?.getAttribute('aria-label')),
isRepost,
isPinned,
isReply,
isAd,
isMine: !!ownHandle && author === ownHandle,
liked: !!$(SEL.unlike, article),
reposted: !!$(SEL.unretweet, article),
};
};
const eligible = (info) => {
if (!info) return { ok: false, why: 'unreadable' };
if (info.isAd) return { ok: false, why: 'ad' };
const f = CONFIG.filters;
if (f.excludePinned && info.isPinned) return { ok: false, why: 'pinned' };
if (info.isRepost && !f.includeReposts) return { ok: false, why: 'repost' };
if (CONFIG.actions.delete && !info.isMine) return { ok: false, why: 'not yours' };
if (!info.text && !info.hasMedia) return { ok: false, why: 'empty' };
if (info.timestamp) {
const posted = new Date(info.timestamp);
if (f.olderThanDays && posted > new Date(Date.now() - f.olderThanDays * 86400000)) {
return { ok: false, why: `newer than ${f.olderThanDays}d` };
}
if (f.beforeDate && posted >= new Date(f.beforeDate)) return { ok: false, why: `not before ${f.beforeDate}` };
if (f.afterDate && posted <= new Date(f.afterDate)) return { ok: false, why: `not after ${f.afterDate}` };
}
if (f.maxLikes !== null && f.maxLikes !== '' && info.likes > Number(f.maxLikes)) {
return { ok: false, why: `${info.likes} likes` };
}
if (f.maxReposts !== null && f.maxReposts !== '' && info.reposts > Number(f.maxReposts)) {
return { ok: false, why: `${info.reposts} reposts` };
}
const lower = `${info.text}\n${info.quotedText}`.toLowerCase();
const blocked = (f.excludeKeywords || []).filter(Boolean).find((k) => lower.includes(String(k).toLowerCase()));
if (blocked) return { ok: false, why: `excluded by "${blocked}"` };
return { ok: true };
};
// ═══════════════════════════════════════════════════════════
// REPLY TEXT
// ═══════════════════════════════════════════════════════════
const fillTemplate = (t, info) => t
.replace(/\{author\}/g, `@${info.author}`)
.replace(/\{name\}/g, info.authorName || `@${info.author}`);
const nextTemplate = (info) => {
const list = CONFIG.reply.templates.filter((t) => t && t.trim());
if (list.length === 0) return '';
let idx;
if (list.length === 1) idx = 0;
else { do { idx = Math.floor(Math.random() * list.length); } while (idx === STATE.lastTemplate); }
STATE.lastTemplate = idx;
return fillTemplate(list[idx], info);
};
const systemPrompt = () => {
const c = CONFIG.reply;
return [
c.persona ? c.persona.trim() : 'You are a real person replying to posts on X (Twitter).',
'',
'Your brief from the account owner, which you follow exactly:',
`"""${(c.prompt || 'Reply naturally and specifically to the post.').trim()}"""`,
'',
'Hard rules:',
'- Under 280 characters. One to two short sentences unless the brief asks for more.',
'- Respond to what THIS post actually says. Reference a concrete detail from it.',
'- Never open with "Great post", "Love this", "So true", "Thanks for sharing", or any generic praise.',
'- No hashtags' + (c.allowHashtags ? ' unless the brief asks for them.' : '.'),
c.allowEmoji ? '- Emoji only where a person would naturally use one. Never more than one.' : '- No emoji.',
'- No links, no mentions of being an AI, no disclaimers, no quotation marks around the reply.',
'- Do not repeat the post back. Add something: a reaction, a question, a related fact.',
'- Match the register of the post: serious gets thoughtful, funny gets witty, technical gets precise.',
'',
'Output ONLY the reply text. No preamble, no labels, no markdown.',
].join('\n');
};
const userPrompt = (info) => {
const parts = [`Post by @${info.author}${info.authorName ? ` (${info.authorName})` : ''}:`, `"""${info.text}"""`];
if (info.quotedText) parts.push('', 'It quotes this post:', `"""${info.quotedText}"""`);
if (info.hasMedia) parts.push('', '(The post has an image or video attached that you cannot see. Do not pretend to describe it.)');
if (STATE.recentReplies.length) {
parts.push('', 'Replies you already posted in this session. Do not reuse their openers or structure:');
for (const r of STATE.recentReplies.slice(-5)) parts.push(`- ${r}`);
}
parts.push('', 'Write the reply now.');
return parts.join('\n');
};
const sanitize = (raw) => {
if (!raw) return '';
let t = String(raw).trim();
t = t.replace(/^```[a-z]*\n?/i, '').replace(/\n?```$/, '').trim();
const unquote = (s) => { const m = s.match(/^["“'‘](.*)["”'’]$/s); return m ? m[1].trim() : s; };
t = unquote(t);
t = t.replace(/^(reply|comment|response|answer)\s*[:\-]\s*/i, '').trim();
t = unquote(t);
if (!CONFIG.reply.allowHashtags) t = t.replace(/(^|\s)#[\w]+/g, '$1');
t = t.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
if ([...t].length > 280) {
const clipped = [...t].slice(0, 280).join('');
const end = Math.max(clipped.lastIndexOf('. '), clipped.lastIndexOf('! '), clipped.lastIndexOf('? '));
t = (end > 140 ? clipped.slice(0, end + 1) : clipped.slice(0, clipped.lastIndexOf(' ') || 280)).trim();
}
return t;
};
const isGeneric = (t) => {
const lower = (t || '').toLowerCase().replace(/^[^a-z]+/, '');
return lower.length < 2 || GENERIC_OPENERS.some((o) => lower.startsWith(o));
};
/** Direct call to xAI. The only LLM host x.com's CSP lets the page reach. */
const completeViaXai = async (messages) => {
const c = CONFIG.reply;
if (!c.apiKey) throw new Error('No xAI API key. Paste one in the panel (console.x.ai) or switch provider to bridge.');
const res = await fetch('https://api.x.ai/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${c.apiKey}` },
body: JSON.stringify({ model: c.model || 'grok-3-mini', messages, temperature: c.temperature, max_tokens: 160 }),
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`xAI ${res.status}: ${detail.slice(0, 200)}`);
}
const data = await res.json();
return (data.choices?.[0]?.message?.content || '').trim();
};
/** Through the XActions extension, which can reach any provider. */
const completeViaBridge = (messages) => new Promise((resolve, reject) => {
if (!window.__xactions_bridge_loaded) {
reject(new Error('XActions extension not detected on this tab. Install extension/ and reload, or use provider xai.'));
return;
}
const c = CONFIG.reply;
const id = `xsw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const timer = setTimeout(() => { cleanup(); reject(new Error('Extension bridge timed out after 45s')); }, 45000);
const onMessage = (event) => {
if (event.source !== window || !event.data || event.data.source !== 'xactions-extension') return;
if (event.data.type !== 'LLM_RESPONSE' || event.data.id !== id) return;
cleanup();
if (event.data.error) reject(new Error(event.data.error));
else resolve(String(event.data.text || '').trim());
};
const cleanup = () => { clearTimeout(timer); window.removeEventListener('message', onMessage); };
window.addEventListener('message', onMessage);
window.postMessage({
source: 'xactions-page',
type: 'LLM_REQUEST',
id,
request: {
provider: c.bridgeProvider,
apiKey: c.apiKey,
baseUrl: c.bridgeBaseUrl,
model: c.model,
messages,
temperature: c.temperature,
maxTokens: 160,
},
}, '*');
});
const aiReply = async (info) => {
const complete = CONFIG.reply.provider === 'bridge' ? completeViaBridge : completeViaXai;
let last = '';
for (let attempt = 1; attempt <= 2; attempt++) {
const messages = [
{ role: 'system', content: systemPrompt() },
{ role: 'user', content: userPrompt(info) },
];
if (attempt === 2 && last) {
messages.push({ role: 'assistant', content: last });
messages.push({ role: 'user', content: 'That reads as generic. Rewrite it so it references a specific detail from the post and opens differently. Output only the reply.' });
}
let raw;
try {
raw = await complete(messages);
} catch (err) {
if (/Content Security Policy|Failed to fetch|NetworkError/i.test(err.message)) {
throw new Error(`Blocked by x.com's CSP: ${err.message}. Use provider 'xai' or the extension bridge.`);
}
throw err;
}
const text = sanitize(raw);
last = text;
if (text && !isGeneric(text) && !STATE.recentReplies.includes(text)) return text;
}
return last;
};
const buildReply = async (info) => {
if (CONFIG.reply.mode === 'ai') {
try {
const text = await aiReply(info);
if (text) return { text, source: 'ai' };
throw new Error('model returned nothing usable');
} catch (err) {
addLog(` 🤖 AI reply failed: ${err.message}`, 'warn');
if (!CONFIG.reply.fallbackToTemplates) return null;
}
}
const text = nextTemplate(info);
return text ? { text, source: 'template' } : null;
};
// ═══════════════════════════════════════════════════════════
// ACTIONS (each verifies the DOM changed, and watches for throttling)
// ═══════════════════════════════════════════════════════════
const openCaretMenu = async (article) => {
const caret = $(SEL.caret, article);
if (!caret) throw new Error('post menu button missing');
caret.click();
const menu = await waitFor(SEL.menu, 5000);
if (!menu) { pressEscape(); throw new Error('post menu did not open'); }
await sleep(jitter([250, 600]));
return menu;
};
const menuItemMatching = (pattern) =>
$$(SEL.menuItem).find((item) => pattern.test((item.textContent || '').trim()));
/**
* Delete one of your own posts. A repost is not deletable, it is undone, and X labels
* that menu entry differently, so both wordings are accepted.
*/
const doDelete = async (article, info) => {
if (!info.isMine) throw new Error(`@${info.author} is not you, refusing to delete`);
if (CONFIG.dryRun) return 'dry';
await openCaretMenu(article);
const item = menuItemMatching(/^\s*delete\b/i)
|| menuItemMatching(/\bdelete post\b/i)
|| (info.isRepost ? menuItemMatching(/undo repost|undo retweet/i) : null);
if (!item) {
pressEscape();
throw new Error('no Delete entry in the menu (not your post, or X changed the menu)');
}
const isUndoRepost = /undo repost|undo retweet/i.test(item.textContent || '');
item.click();
await sleep(jitter([400, 900]));
if (!isUndoRepost) {
const confirm = await waitFor(SEL.confirmSheet, 5000);
if (!confirm) { pressEscape(); throw new Error('delete confirmation never appeared'); }
confirm.click();
const closed = await waitForGone(SEL.confirmSheet, 8000);
if (!closed) { pressEscape(); throw new Error('confirmation stayed open, delete was probably rejected'); }
}
await sleep(jitter([600, 1200]));
await afterClickCheck();
// X usually pulls the row out of the timeline. It sometimes leaves it until the next
// fetch, so a still-connected article is not a failure, only worth noting.
const gone = await waitUntil(() => !article.isConnected, 3000);
return gone ? 'done' : 'done-stale';
};
const doLike = async (article, info) => {
if (info.liked) return 'already';
const btn = $(SEL.like, article);
if (!btn) throw new Error('like button missing');
if (CONFIG.dryRun) return 'dry';
btn.click();
await sleep(jitter(speed().action));
await afterClickCheck();
if (!$(SEL.unlike, article)) throw new Error('like did not register');
return 'done';
};
const doRepost = async (article, info) => {
if (info.reposted) return 'already';
const btn = $(SEL.retweet, article);
if (!btn) throw new Error('repost button missing');
if (CONFIG.dryRun) return 'dry';
btn.click();
const confirm = await waitFor(SEL.retweetConfirm, 4000);
if (!confirm) { pressEscape(); throw new Error('repost menu did not open'); }
await sleep(jitter([400, 900]));
confirm.click();
await sleep(jitter(speed().action));
await afterClickCheck();
if (!$(SEL.unretweet, article)) throw new Error('repost did not register');
return 'done';
};
const doReply = async (article, info) => {
const reply = await buildReply(info);
if (!reply) return { status: 'skipped', text: '' };
if (CONFIG.dryRun) return { status: 'dry', text: reply.text, source: reply.source };
const btn = $(SEL.reply, article);
if (!btn) throw new Error('reply button missing');
btn.click();
const box = await waitFor(SEL.tweetBox, 6000);
if (!box) { pressEscape(); throw new Error('reply composer did not open'); }
await sleep(jitter([500, 1000]));
box.focus();
document.execCommand('insertText', false, reply.text);
await sleep(jitter([600, 1200]));
if (!(box.textContent || '').trim()) { pressEscape(); throw new Error('could not type into the composer'); }
const send = await waitFor(SEL.tweetButton, 3000);
if (!send || send.getAttribute('aria-disabled') === 'true' || send.disabled) {
pressEscape();
throw new Error('post button not enabled');
}
send.click();
const closed = await waitForGone(SEL.tweetBox, 10000);
await afterClickCheck();
if (!closed) { pressEscape(); throw new Error('composer stayed open, reply probably rejected'); }
STATE.recentReplies.push(reply.text);
if (STATE.recentReplies.length > 30) STATE.recentReplies.shift();
return { status: 'done', text: reply.text, source: reply.source };
};
// ═══════════════════════════════════════════════════════════
// THE SWEEP
// ═══════════════════════════════════════════════════════════
const waitWhilePaused = async () => {
while (STATE.paused && !STATE.stopRequested) await sleep(300);
};
const countdown = async (seconds, label) => {
for (let s = seconds; s > 0 && !STATE.stopRequested; s--) {
setStatus(`${label} ${s}s`);
await sleep(1000);
await waitWhilePaused();
}
};
const wantedActions = () => {
if (CONFIG.actions.delete) return ['delete'];
return ['like', 'repost', 'reply'].filter((a) => CONFIG.actions[a]);
};
const alreadyDone = (info) => {
const record = STATE.done.get(info.id);
if (!record) return false;
return wantedActions().every((a) => {
if (a === 'delete') return record.deleted;
if (a === 'like') return record.liked || info.liked;
if (a === 'repost') return record.reposted || info.reposted;
return record.replied;
});
};
const processArticle = async (article, info) => {
const record = STATE.done.get(info.id) || { deleted: false, liked: false, reposted: false, replied: false, at: 0 };
const result = { id: info.id, url: info.url, author: info.author, at: info.timestamp, text: info.text.slice(0, 200), actions: [], errors: [] };
let failed = false;
highlight(article);
article.scrollIntoView({ behavior: 'smooth', block: 'center' });
await sleep(jitter([400, 900]));
addLog(`📝 @${info.author} ${info.timestamp ? info.timestamp.slice(0, 10) : ''}: "${info.text.slice(0, 70)}${info.text.length > 70 ? '…' : ''}"`);
const step = async (name, fn) => {
try {
const out = await fn();
const status = typeof out === 'string' ? out : out.status;
const label = status === 'dry' ? '[DRY] would ' : '';
if (status === 'done' || status === 'done-stale' || status === 'dry') {
result.actions.push(name);
if (name === 'delete') addLog(` 🗑️ ${label}delete${status === 'done-stale' ? ' (still listed, search index lags)' : ''}`);
if (name === 'like') addLog(` ❤️ ${label}like`);
if (name === 'repost') addLog(` 🔁 ${label}repost`);
if (name === 'reply') { result.reply = out.text; addLog(` 💬 ${label}reply (${out.source}): "${out.text}"`); }
if (status !== 'dry') {
if (name === 'delete') { STATE.deleted++; record.deleted = true; }
if (name === 'like') { STATE.liked++; record.liked = true; }
if (name === 'repost') { STATE.reposted++; record.reposted = true; }
if (name === 'reply') { STATE.replied++; record.replied = true; }
}
} else if (status === 'already') {
addLog(` ${name}: already done, skipping`, 'dim');
if (name === 'like') record.liked = true;
if (name === 'repost') record.reposted = true;
} else if (status === 'skipped') {
addLog(' reply: nothing to post (no template and no AI text)', 'warn');
}
await sleep(jitter(speed().action));
} catch (err) {
failed = true;
result.errors.push(`${name}: ${err.message}`);
addLog(` ⚠️ ${name} failed: ${err.message}`, 'warn');
if (/pushed back/i.test(err.message)) throw err;
}
};
for (const action of wantedActions()) {
if (STATE.stopRequested) break;
if (action === 'delete') await step('delete', () => doDelete(article, info));
if (action === 'like') await step('like', () => doLike(article, info));
if (action === 'repost') await step('repost', () => doRepost(article, info));
if (action === 'reply') await step('reply', () => doReply(article, info));
if (action === 'delete' && !article.isConnected) break;
}
record.at = Date.now();
if (!CONFIG.dryRun) { STATE.done.set(info.id, record); persist(); }
if (failed) { STATE.failed++; STATE.consecutiveFailures++; } else STATE.consecutiveFailures = 0;
unhighlight();
return result;
};
const clickRetryIfShown = () => {
const btn = $$(SEL.retryButton).find((b) => /retry|try again/i.test(b.textContent || ''));
if (btn) { btn.click(); return true; }
return false;
};
/** One top-to-bottom walk of the result list currently loaded. */
const sweepOnePass = async () => {
STATE.seenThisPass = new Set();
let acted = 0;
let idleRounds = 0;
let sinceRest = 0;
window.scrollTo({ top: 0 });
await sleep(1200);
while (!STATE.stopRequested) {
await waitWhilePaused();
if (STATE.stopRequested) break;
if (CONFIG.maxPosts && STATE.processed >= CONFIG.maxPosts) { addLog(`Reached maxPosts (${CONFIG.maxPosts}).`); break; }
if (STATE.consecutiveFailures >= CONFIG.maxConsecutiveFailures) {
addLog(`🛑 ${STATE.consecutiveFailures} failures in a row. X is probably throttling. Backing off ${CONFIG.backoffSeconds}s.`, 'warn');
await countdown(CONFIG.backoffSeconds, '⏳ Backing off');
STATE.consecutiveFailures = 0;
if (STATE.stopRequested) break;
}
const fresh = [];
for (const article of $$(SEL.article)) {
const info = readArticle(article);
if (!info || STATE.seenThisPass.has(info.id)) continue;
STATE.seenThisPass.add(info.id);
if (alreadyDone(info)) { STATE.skipped++; continue; }
const check = eligible(info);
if (!check.ok) { STATE.skipped++; addLog(` · skip ${info.id} (${check.why})`, 'dim'); continue; }
fresh.push({ article, info });
}
if (fresh.length === 0) {
if ($(SEL.emptyState)) { addLog('X reports no results for this query.'); break; }
idleRounds++;
if (clickRetryIfShown()) { addLog('Result list errored, clicked retry', 'warn'); await sleep(3000); }
if (idleRounds >= 6) { addLog('No new results after 6 scrolls. End of this pass.'); break; }
window.scrollBy({ top: Math.round(window.innerHeight * 0.9), behavior: 'smooth' });
await sleep(jitter([1500, 2600]));
continue;
}
idleRounds = 0;
for (const { article, info } of fresh) {
await waitWhilePaused();
if (STATE.stopRequested) break;
if (CONFIG.maxPosts && STATE.processed >= CONFIG.maxPosts) break;
if (!article.isConnected) { STATE.seenThisPass.delete(info.id); continue; }
let result;
try {
result = await processArticle(article, info);
} catch (err) {
STATE.failed++;
STATE.consecutiveFailures = CONFIG.maxConsecutiveFailures;
result = { id: info.id, url: info.url, errors: [err.message] };
addLog(` ${err.message}`, 'warn');
pressEscape();
}
STATE.results.push(result);
STATE.processed++;
if (result.actions && result.actions.length) acted++;
sinceRest++;
updateStats();
if (CONFIG.restEvery && sinceRest >= CONFIG.restEvery) {
sinceRest = 0;
addLog(`☕ Resting ${CONFIG.restForSeconds}s after ${CONFIG.restEvery} posts`);
await countdown(CONFIG.restForSeconds, '☕ Resting');
} else {
await countdown(Math.round(jitter(betweenRange()) / 1000), '⏱ Next post in');
}
if (STATE.consecutiveFailures >= CONFIG.maxConsecutiveFailures) break;
}
window.scrollBy({ top: Math.round(window.innerHeight * 0.8), behavior: 'smooth' });
await sleep(jitter([1200, 2200]));
}
return acted;
};
const sweep = async () => {
STATE.running = true;
STATE.stopRequested = false;
STATE.startedAt = Date.now();
STATE.results = [];
STATE.pass = 0;
STATE.processed = 0; STATE.deleted = 0; STATE.liked = 0; STATE.reposted = 0; STATE.replied = 0;
STATE.skipped = 0; STATE.failed = 0; STATE.consecutiveFailures = 0;
setButtons('running');
const actions = wantedActions();
if (actions.length === 0) {
addLog('Nothing to do: no action is ticked.', 'warn');
STATE.running = false; setButtons('idle'); return;
}
try {
STATE.query = buildQuery();
addLog(`🚀 Sweeping search: ${STATE.query}${CONFIG.dryRun ? ' (DRY RUN, nothing is touched)' : ''}`);
addLog(` actions=${actions.join('+')} tab=${CONFIG.query.latest ? 'Latest' : 'Top'} speed=${CONFIG.speed} passes=${CONFIG.passes} max=${CONFIG.maxPosts || '∞'}`);
if (STATE.done.size) addLog(` ${STATE.done.size} posts from earlier runs will be skipped`);
if (CONFIG.actions.delete && !CONFIG.dryRun) {
if (!ownHandle) {
addLog('Could not read the signed-in handle from the sidebar. Every delete would be refused, so stopping here.', 'warn');
return;
}
if (!/\bfrom:/i.test(STATE.query)) {
addLog(`This query has no from: operator. Only your own posts can be deleted, so everything else will be skipped. Consider from:${ownHandle}.`, 'warn');
}
addLog('⚠️ LIVE DELETE. This is permanent. Hit Stop in the next 5 seconds to cancel.', 'warn');
await countdown(5, '⚠️ Deleting in');
if (STATE.stopRequested) { addLog('Stopped before anything was deleted.'); return; }
}
await goToQuery(STATE.query);
let emptyPasses = 0;
for (let pass = 1; pass <= Math.max(1, CONFIG.passes); pass++) {
if (STATE.stopRequested) break;
if (CONFIG.maxPosts && STATE.processed >= CONFIG.maxPosts) break;
STATE.pass = pass;
setStatus(`Pass ${pass}/${CONFIG.passes}`);
addLog(`── Pass ${pass}/${CONFIG.passes} ──`, 'dim');
const acted = await sweepOnePass();