• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thorstenalpers / CleanMyPosts / 15202427745

23 May 2025 04:37AM UTC coverage: 22.458% (+0.08%) from 22.379%
15202427745

push

github

thorstenalpers
Register ExceptionHandler only once in xaml

91 of 364 branches covered (25.0%)

Branch coverage included in aggregate %.

280 of 1288 relevant lines covered (21.74%)

0.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

15.19
/src/UI/Services/XScriptService.cs
1
using System.Net;
2
using Ardalis.GuardClauses;
3
using CleanMyPosts.UI.Contracts.Services;
4
using CleanMyPosts.UI.Helpers;
5
using Microsoft.Extensions.Logging;
6

7
namespace CleanMyPosts.UI.Services;
8

9
public class XScriptService(ILogger<XScriptService> logger, IWebViewHostService webViewHostService, IUserSettingsService userSettingsService) : IXScriptService
5✔
10
{
11
    private readonly ILogger<XScriptService> _logger = logger;
5✔
12
    private readonly IWebViewHostService _webViewHostService = webViewHostService;
5✔
13
    private readonly IUserSettingsService _userSettingsService = userSettingsService;
5✔
14
    private string _userName;
15

16
    public async Task ShowPostsAsync()
17
    {
18

19
        await EnsureUserNameAsync();
1✔
20
        Guard.Against.Null(_userName);
1✔
21
        var searchQuery = $"from:{_userName}";
1✔
22
        var encodedQuery = WebUtility.UrlEncode(searchQuery);
1✔
23
        var url = new Uri($"https://x.com/search?q={encodedQuery}&src=typed_query");
1✔
24

25
        if (_webViewHostService.Source == url)
1!
26
        {
27
            _webViewHostService.Reload();
×
28
        }
29
        else
30
        {
31
            _webViewHostService.Source = url;
1✔
32
        }
33

34
        if (!await WaitForDocumentReadyAsync())
1!
35
        {
36
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
37
        }
38

39
        _logger.LogInformation("Navigated to {Url}", url);
1✔
40
    }
1✔
41

42
    public async Task<int> DeletePostsAsync()
43
    {
44
        await EnsureUserNameAsync();
×
45
        Guard.Against.Null(_userName);
×
46

47
        var searchQuery = $"from:{_userName}";
×
48
        var encodedQuery = WebUtility.UrlEncode(searchQuery);
×
49
        var url = new Uri($"https://x.com/search?q={encodedQuery}&src=typed_query");
×
50

51
        if (_webViewHostService.Source == url)
×
52
        {
53
            _webViewHostService.Reload();
×
54
        }
55
        else
56
        {
57
            _webViewHostService.Source = url;
×
58
        }
59
        if (!await WaitForDocumentReadyAsync())
×
60
        {
61
            _logger.LogWarning("Navigation to search page failed.");
×
62
            return 0;
×
63
        }
64
        var postNumber = 1;
×
65
        var deletedItems = 0;
×
66
        while (await PostsExistAsync())
×
67
        {
68
            var countBefore = await GetPostsCountAsync();
×
69

70
            _logger.LogInformation("Found {Count} posts before deletion.", countBefore);
×
71

72
            try
73
            {
74
                _logger.LogInformation("Deleting post #{Number}...", postNumber);
×
75
                await DeleteSinglePostAsync();
×
76

77
                if (await WaitForPostDeletedAsync(countBefore))
×
78
                {
79
                    _logger.LogInformation("Post #{Number} deleted successfully.", postNumber);
×
80
                    deletedItems++;
×
81
                }
82
                else
83
                {
84
                    _logger.LogWarning("Post #{Number} was not deleted (DOM unchanged).", postNumber);
×
85
                    break;
×
86
                }
87
            }
×
88
            catch (Exception ex)
×
89
            {
90
                _logger.LogError(ex, "Error deleting post #{Number}.", postNumber);
×
91
                break;
×
92
            }
93
            postNumber++;
×
94
        }
95
        _webViewHostService.Reload();
×
96
        await WaitForDocumentReadyAsync();
×
97

98
        _logger.LogInformation("Deleted {TotalPosts} posts.", postNumber);
×
99
        return deletedItems;
×
100
    }
×
101

102
    public async Task ShowLikesAsync()
103
    {
104
        await EnsureUserNameAsync();
1✔
105
        Guard.Against.Null(_userName);
1✔
106

107
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/likes");
1✔
108

109
        if (_webViewHostService.Source == url)
1!
110
        {
111
            _webViewHostService.Reload();
×
112
        }
113
        else
114
        {
115
            _webViewHostService.Source = url;
1✔
116
        }
117

118
        if (!await WaitForDocumentReadyAsync())
1!
119
        {
120
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
121
        }
122

123
        _logger.LogInformation("Navigated to {Url}", url);
1✔
124
    }
1✔
125

126
    public async Task<int> DeleteLikesAsync()
127
    {
128
        await EnsureUserNameAsync();
×
129
        Guard.Against.Null(_userName);
×
130

131
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/likes");
×
132

133
        if (_webViewHostService.Source == url)
×
134
        {
135
            _webViewHostService.Reload();
×
136
        }
137
        else
138
        {
139
            _webViewHostService.Source = url;
×
140
        }
141
        if (!await WaitForDocumentReadyAsync())
×
142
        {
143
            _logger.LogWarning("Navigation to search page failed.");
×
144
            return 0;
×
145
        }
146

147
        var postNumber = 1;
×
148
        var deletedItems = 0;
×
149

150
        while (await LikesExistAsync())
×
151
        {
152
            var countBefore = await GetLikesCountAsync();
×
153

154
            _logger.LogInformation("Found {Count} likes before deletion.", countBefore);
×
155

156
            try
157
            {
158
                _logger.LogInformation("Deleting like #{Number}...", postNumber);
×
159
                await DeleteSingleLikeAsync();
×
160

161
                if (await WaitForLikeDeletedAsync(countBefore))
×
162
                {
163
                    _logger.LogInformation("Like #{Number} deleted successfully.", postNumber);
×
164
                    deletedItems++;
×
165
                }
166
                else
167
                {
168
                    _logger.LogWarning("Like #{Number} was not deleted (DOM unchanged).", postNumber);
×
169
                    break;
×
170
                }
171
            }
×
172
            catch (Exception ex)
×
173
            {
174
                _logger.LogError(ex, "Error deleting like #{Number}.", postNumber);
×
175
                break;
×
176
            }
177
            postNumber++;
×
178
        }
179

180
        _webViewHostService.Reload();
×
181
        await WaitForDocumentReadyAsync();
×
182

183
        _logger.LogInformation("Deleted {TotalLikes} Likes.", postNumber);
×
184
        return deletedItems;
×
185
    }
×
186

187
    public async Task ShowFollowingAsync()
188
    {
189
        await EnsureUserNameAsync();
1✔
190
        Guard.Against.Null(_userName);
1✔
191

192
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/following");
1✔
193

194
        if (_webViewHostService.Source == url)
1!
195
        {
196
            _webViewHostService.Reload();
×
197
        }
198
        else
199
        {
200
            _webViewHostService.Source = url;
1✔
201
        }
202

203
        if (!await WaitForDocumentReadyAsync())
1!
204
        {
205
            _logger.LogWarning("Navigation to {Url} failed.", url);
×
206
        }
207

208
        _logger.LogInformation("Navigated to {Url}", url);
1✔
209
    }
1✔
210

211
    public async Task<int> DeleteFollowingAsync()
212
    {
213
        await EnsureUserNameAsync();
×
214
        Guard.Against.Null(_userName);
×
215

216
        var url = new Uri($"https://x.com/{WebUtility.UrlEncode(_userName)}/following");
×
217

218
        if (_webViewHostService.Source == url)
×
219
        {
220
            _webViewHostService.Reload();
×
221
        }
222
        else
223
        {
224
            _webViewHostService.Source = url;
×
225
        }
226
        if (!await WaitForDocumentReadyAsync())
×
227
        {
228
            _logger.LogWarning("Navigation to search page failed.");
×
229
            return 0;
×
230
        }
231

232
        var postNumber = 1;
×
233
        var deletedItems = 0;
×
234
        while (await FollowingExistAsync())
×
235
        {
236
            var countBefore = await GetFollowingCountAsync();
×
237

238
            _logger.LogInformation("Found {Count} following before deletion.", countBefore);
×
239

240
            try
241
            {
242
                _logger.LogInformation("Deleting following #{Number}...", postNumber);
×
243
                await DeleteSingleFollowingAsync();
×
244

245
                if (await WaitForFollowingDeletedAsync(countBefore))
×
246
                {
247
                    _logger.LogInformation("Following #{Number} deleted successfully.", postNumber);
×
248
                    deletedItems++;
×
249
                }
250
                else
251
                {
252
                    _logger.LogWarning("Following #{Number} was not deleted (DOM unchanged).", postNumber);
×
253
                    break;
×
254
                }
255
            }
×
256
            catch (Exception ex)
×
257
            {
258
                _logger.LogError(ex, "Error deleting following #{Number}.", postNumber);
×
259
                break;
×
260
            }
261
            postNumber++;
×
262
        }
263

264
        _webViewHostService.Reload();
×
265
        await WaitForDocumentReadyAsync();
×
266

267
        _logger.LogInformation("Deleted {DeletedItems} Followings.", deletedItems);
×
268
        return deletedItems;
×
269
    }
×
270

271
    private async Task<bool> PostsExistAsync()
272
    {
273
        const string js = "document.querySelector('div[data-testid=\"primaryColumn\"] section button[data-testid=\"caret\"]') !== null";
274
        for (var i = 0; i < 5; i++)
14✔
275
        {
276
            if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
6✔
277
            {
278
                return true;
1✔
279
            }
280
            await Task.Delay(500);
5✔
281
        }
282
        return false;
1✔
283
    }
2✔
284

285
    private async Task<bool> FollowingExistAsync()
286
    {
287
        const string js = "document.querySelector('button[data-testid$=\"unfollow\"]') !== null";
288
        for (var i = 0; i < 5; i++)
×
289
        {
290
            if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
×
291
            {
292
                return true;
×
293
            }
294
            await Task.Delay(500);
×
295
        }
296
        return false;
×
297
    }
×
298

299
    private async Task<bool> LikesExistAsync()
300
    {
301
        const string js = "document.querySelector('button[data-testid=\"unlike\"]') !== null";
302
        for (var i = 0; i < 5; i++)
×
303
        {
304
            if (await _webViewHostService.ExecuteScriptAsync(js) == "true")
×
305
            {
306
                return true;
×
307
            }
308
            await Task.Delay(500);
×
309
        }
310
        return false;
×
311
    }
×
312

313
    private async Task DeleteSinglePostAsync()
314
    {
315
        var timeout = _userSettingsService.GetTimeoutSettings();
×
316
        var waitAfterDelete = timeout.WaitAfterDelete;
×
317
        var waitBetweenDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
×
318

319
        var js = $@"
×
320
        (() => {{
×
321
            const caret = document.querySelector(""div[data-testid='primaryColumn'] section button[data-testid='caret']"");
×
322
            if (!caret) return;
×
323

×
324
            caret.click();
×
325

×
326
            setTimeout(() => {{
×
327
                const delays = [ {waitBetweenDeleteAttempts}, 
×
328
                                {waitBetweenDeleteAttempts * 2}, 
×
329
                                {waitBetweenDeleteAttempts * 3}, 
×
330
                                {waitBetweenDeleteAttempts * 4}, 
×
331
                                {waitBetweenDeleteAttempts * 5} ];
×
332

×
333
                function tryClickDelete(attempt = 0) {{
×
334
                    if (attempt >= delays.length) return;
×
335
                    setTimeout(() => {{
×
336
                        const menu = document.querySelector(""[role='menu']"");
×
337
                        if (menu && menu.style.display !== ""none"") {{
×
338
                            const items = document.querySelectorAll(""[role='menuitem']"");
×
339
                            for (const item of items) {{
×
340
                                const span = item.querySelector(""span"");
×
341
                                if (!span) continue;
×
342

×
343
                                const color = getComputedStyle(span).color;
×
344
                                const rgb = color.match(/\d+/g).map(Number);
×
345
                                const [r, g, b] = rgb;
×
346
                                if (r > 180 && g < 100 && b < 100) {{
×
347
                                    span.click();
×
348
                                    confirmDelete();
×
349
                                    return;
×
350
                                }}
×
351
                            }}
×
352
                            tryClickDelete(attempt + 1);
×
353
                        }} else {{
×
354
                            tryClickDelete(attempt + 1);
×
355
                        }}
×
356
                    }}, delays[attempt]);
×
357
                }}
×
358

×
359
                function confirmDelete(attempt = 0) {{
×
360
                    if (attempt >= delays.length) return;
×
361
                    setTimeout(() => {{
×
362
                        const confirmBtn = document.querySelector(""button[data-testid='confirmationSheetConfirm']"");
×
363
                        if (confirmBtn && confirmBtn.offsetParent !== null) {{
×
364
                            confirmBtn.click();
×
365
                            window.scrollBy(0, 300);
×
366
                        }} else {{
×
367
                            confirmDelete(attempt + 1);
×
368
                        }}
×
369
                    }}, delays[attempt]);
×
370
                }}
×
371

×
372
                tryClickDelete();
×
373
            }}, {waitAfterDelete});
×
374
        }})();";
×
375

376
        await _webViewHostService.ExecuteScriptAsync(js);
×
377
    }
×
378
    private async Task DeleteSingleLikeAsync()
379
    {
380
        var waitBeforeTryClickDelete = _userSettingsService.GetTimeoutSettings().WaitAfterDelete;
×
381

382
        var js = $@"
×
383
        (() => {{
×
384
            const unlikeButton = document.querySelector('button[data-testid=""unlike""]');
×
385
            if (unlikeButton) {{
×
386
                unlikeButton.click();
×
387
            }}
×
388
            window.scrollBy(0, 300);
×
389
        }})();";
×
390
        await Task.Delay(waitBeforeTryClickDelete);
×
391
        await _webViewHostService.ExecuteScriptAsync(js);
×
392
    }
×
393

394
    private async Task DeleteSingleFollowingAsync()
395
    {
396
        var timeout = _userSettingsService.GetTimeoutSettings();
×
397
        var waitBeforeTryClickDelete = timeout.WaitAfterDelete;
×
398
        var waitBetweenTryClickDeleteAttempts = timeout.WaitBetweenRetryDeleteAttempts;
×
399

400
        var js = $@"
×
401
    (() => {{
×
402
        const unfollowingButton = document.querySelector('button[data-testid$=""-unfollow""]');
×
403
        if (!unfollowingButton) return;
×
404

×
405
        unfollowingButton.click();
×
406

×
407
        const delays = [
×
408
            {waitBetweenTryClickDeleteAttempts},
×
409
            {waitBetweenTryClickDeleteAttempts * 2},
×
410
            {waitBetweenTryClickDeleteAttempts * 3},
×
411
            {waitBetweenTryClickDeleteAttempts * 4},
×
412
            {waitBetweenTryClickDeleteAttempts * 5}
×
413
        ];
×
414

×
415
        function tryClickConfirm(attempt = 0) {{
×
416
            if (attempt >= delays.length) return;
×
417
            setTimeout(() => {{
×
418
                const confirmBtn = document.querySelector('button[data-testid=""confirmationSheetConfirm""]');
×
419
                if (confirmBtn && confirmBtn.offsetParent !== null) {{
×
420
                    confirmBtn.click();
×
421
                    window.scrollBy(0, 300);
×
422
                }} else {{
×
423
                    tryClickConfirm(attempt + 1);
×
424
                }}
×
425
            }}, delays[attempt]);
×
426
        }}
×
427

×
428
        setTimeout(() => {{
×
429
            tryClickConfirm();
×
430
        }}, {waitBeforeTryClickDelete});
×
431
    }})();";
×
432

433
        await _webViewHostService.ExecuteScriptAsync(js);
×
434
    }
×
435

436

437
    private async Task<int> GetPostsCountAsync()
438
    {
439
        const string js = """
440
        (() => document.querySelectorAll('div[data-testid="primaryColumn"] section button[data-testid="caret"]').length)()
441
        """;
442

443
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
444
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
445
    }
×
446

447
    private async Task<int> GetFollowingCountAsync()
448
    {
449
        const string js = """
450
        (() => document.querySelectorAll('button[data-testid$="-unfollow"]').length)()        
451
        """;
452

453
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
454
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
455
    }
×
456

457
    private async Task<int> GetLikesCountAsync()
458
    {
459
        const string js = """
460
        (() => document.querySelectorAll('button[data-testid="unlike"]').length)()
461
        """;
462

463
        var result = await _webViewHostService.ExecuteScriptAsync(js);
×
464
        return int.TryParse(result?.Trim('"'), out var count) ? count : 0;
×
465
    }
×
466

467
    private async Task<bool> WaitForPostDeletedAsync(int beforeCount)
468
    {
469
        int elapsed = 0, interval = 200, timeout = 5000;
×
470
        while (elapsed < timeout)
×
471
        {
472
            if (await GetPostsCountAsync() < beforeCount)
×
473
            {
474
                return true;
×
475
            }
476
            await Task.Delay(interval);
×
477
            elapsed += interval;
×
478
        }
479
        return false;
×
480
    }
×
481

482
    private async Task<bool> WaitForFollowingDeletedAsync(int beforeCount)
483
    {
484
        int elapsed = 0, interval = 200, timeout = 5000;
×
485
        while (elapsed < timeout)
×
486
        {
487
            if (await GetFollowingCountAsync() < beforeCount)
×
488
            {
489
                return true;
×
490
            }
491
            await Task.Delay(interval);
×
492
            elapsed += interval;
×
493
        }
494
        return false;
×
495
    }
×
496

497
    private async Task<bool> WaitForLikeDeletedAsync(int beforeCount)
498
    {
499
        int elapsed = 0, interval = 200, timeout = 5000;
×
500
        while (elapsed < timeout)
×
501
        {
502
            if (await GetLikesCountAsync() < beforeCount)
×
503
            {
504
                return true;
×
505
            }
506
            await Task.Delay(interval);
×
507
            elapsed += interval;
×
508
        }
509
        return false;
×
510
    }
×
511

512
    public async Task<string> GetUserNameAsync()
513
    {
514
        await WaitForDocumentReadyAsync();
×
515

516
        const string jsScript = @"
517
            (() => {
518
              const el = document.querySelector('a[data-testid=""AppTabBar_Profile_Link""]');
519
              const href = el?.getAttribute('href');
520
              return href?.split('/')[1] ?? '';
521
            })()";
522

523
        var userName = await _webViewHostService.ExecuteScriptAsync(jsScript);
×
524
        _userName = Helper.CleanJsonResult(userName);
×
525
        return _userName;
×
526
    }
×
527

528
    private async Task<bool> WaitForDocumentReadyAsync()
529
    {
530
        const int maxAttempts = 50;
531
        const int delayMs = 100;
532
        int waitAfterDocumentLoad = _userSettingsService.GetTimeoutSettings().WaitAfterDocumentLoad;
3✔
533

534
        for (var i = 0; i < maxAttempts; i++)
6!
535
        {
536
            try
537
            {
538
                var readyStateJson = await _webViewHostService.ExecuteScriptAsync("document.readyState");
3✔
539
                var readyState = readyStateJson?.Trim('"').ToLowerInvariant();
3!
540

541
                if (readyState == "complete")
3!
542
                {
543
                    await Task.Delay(waitAfterDocumentLoad);
3✔
544
                    return true;
3✔
545
                }
546
            }
×
547
            catch (Exception ex)
×
548
            {
549
                _logger.LogWarning(ex, "Error checking document.readyState.");
×
550
            }
×
551
            await Task.Delay(delayMs);
×
552
        }
553
        _logger.LogWarning("Timed out waiting for document.readyState = complete.");
×
554
        return false;
×
555
    }
3✔
556

557
    private async Task EnsureUserNameAsync()
558
    {
559
        if (string.IsNullOrEmpty(_userName))
3!
560
        {
561
            await GetUserNameAsync();
×
562
        }
563
    }
3✔
564
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc